From 8b6bf385601dd878e81ca11d47076aa93e7cb806 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:07:51 +1100 Subject: [PATCH] Make native fixtures deterministic and expose verifier failures safely --- docs/CURRENT_STATUS.md | 25 ++++++++++-- docs/NEXUS_CARD_RULES.md | 6 +-- tests/fixture-native-skills-stronghold.ts | 14 ++++--- tools/verify-container.mjs | 48 +++++++++++++++++++++-- 4 files changed, 79 insertions(+), 14 deletions(-) diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index eefc7695..f0f7b9e3 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -1,6 +1,6 @@ # Current development status -Updated 4 October 2026. **The full twelve-faction goal is unfinished. Basic +Updated 5 October 2026. **The full twelve-faction goal is unfinished. Basic is playable and the six classic factions have a user-authorized Advanced preview. Expansion starts and full rules/publication acceptance remain gated.** @@ -16,6 +16,25 @@ open mode gates. ## Current checkpoint and work +**Paired checkpoint publication blocked:** code +**2d24f4372196515b4b4b07555e5af518a209e18b** is pushed. +[CI37208188809](https://github.com/Quintine/Dune/actions/runs/37208188809) / +container111453717886 failed isolated storage/HTTP step7; publication step9 +was correctly skipped. Public job/annotations show only exit1; detailed logs +require unavailable authenticated access. Investigation uses the original +proxy-free `deploy/serve.mjs` with Node22 and a new private QA store, never an +existing game database or unsafe POST retry. Original owner/operator login, +foreign-origin rejection,25 administrator HTTP checks and the exact55/55 HTTP +integration cases pass there. Original room/seat/admin witnesses survive +process replacement; HTTPS external-origin login/Secure cookies, creation, +join/discussion and four foreign-mutation rejections also pass. This is +proxy-free Node22 evidence, not a passing Docker/CI claim. The failed job's +specific boundary is still unknown. `tools/verify-container.mjs` now reports +redacted failing stages and up to three failing integration names as public +CI annotations while retaining nonzero failure and the publication gate. +Local fail-closed diagnostic smoke emits the missing-Docker stage and exit1. +No successful publication or deployed claim is made for this checkpoint. + **Paired E1/E2 Nexus / Skills / Tech / Strongholds — bounded Development Verified:** both Ixians+Tleilaxu OR CHOAM+Richese plus classics, one original family47/ 35+cache deck/all14/all12, selected unused Tech3..6 Basic/Advanced and/or @@ -27,8 +46,8 @@ rejects and four legal policies pass. Thirty-four genuine2..6 games complete 16,094 accepted actions/no rejection,420 JSON continuations and22 actual closing choices on unchanged tree `472b5705ca5376fb95f8e6ff6f40192d9f1745d7d08c41efdef2b3e53783139a`. -An older native marker fixture's unseeded scalar actions are now controlled; -all nine native cases and the frozen175-case union pass afterward. +The older native fixture now controls the original initializer remainder and +later scalar actions; final59 module/native cases, types/lint/build pass. Original backed-up CLI **PPL5CNEB / TZRU5GYP v10/setup** retains actual offers/hands/IDs after a scoped original14 shuffle. Human390px **PPL5CNEB v28/Collection** copies held Arrakeen into HMS, uses full Suboids and trained diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index 75f53df8..ba873ba0 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -53,9 +53,9 @@ games complete16,094 accepted actions/no rejection,420 JSON continuations and 22 actual closing choices. Natural Cunning combinations are not claimed. Reports under `/tmp/dune-paired-nexus-skills-{tech,stronghold,both}-games-20261005` capture unchanged tree `472b5705ca5376fb95f8e6ff6f40192d9f1745d7d08c41efdef2b3e53783139a`. -An older native marker fixture's scalar action entropy is controlled after an -intermittent missing-marker assertion; its nine cases and final175 union pass. -No production marker behavior or unresolved ruling is changed for that fixture. +An older native fixture's initializer remainder and scalar actions are now +controlled after intermittent marker/held-owner assertions; final59 module/ +native cases, types/lint/build pass. No production behavior or ruling changes. Original backed-up CLI **PPL5CNEB / TZRU5GYP v10/setup** preserves first offers/hands/IDs after only the original14 pre-deal shuffle. Controlled390px diff --git a/tests/fixture-native-skills-stronghold.ts b/tests/fixture-native-skills-stronghold.ts index 621c84b5..5a05d75d 100644 --- a/tests/fixture-native-skills-stronghold.ts +++ b/tests/fixture-native-skills-stronghold.ts @@ -100,8 +100,8 @@ export function nextNativeSkillsStrongholdStep(game: Game): StrongholdFactionsNa return game.players.some(p => p.faction === 'richese') ? nextRicheseStrongholdsNativeStep(game) : nextStrongholdFactionsNativeStep(game); } -function act(game: Game, next: StrongholdFactionsNativeStep, actions?: StrongholdFactionsNativeStep[]): Game { - actions?.push(structuredClone(next)); +/** Scope the original initializer remainder and later native shuffles alike. */ +function nativeScalarEntropy(run: () => T): T { const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); const native = crypto.getRandomValues.bind(crypto); crypto.getRandomValues = (array: V): V => { @@ -109,12 +109,16 @@ function act(game: Game, next: StrongholdFactionsNativeStep, actions?: Stronghol else Reflect.apply(native, crypto, [array]); return array; }; - try { return applyAction(game, next.actor, next.action); } + try { return run(); } finally { if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); else Reflect.deleteProperty(crypto, 'getRandomValues'); } } +function act(game: Game, next: StrongholdFactionsNativeStep, actions?: StrongholdFactionsNativeStep[]): Game { + actions?.push(structuredClone(next)); + return nativeScalarEntropy(() => applyAction(game, next.actor, next.action)); +} export function advanceNativeSkillsStronghold(state: Game, until: (g: Game) => boolean, actions?: StrongholdFactionsNativeStep[], firstDial = 0): Game { let game = state; @@ -209,9 +213,9 @@ export function createNativeSkillsStrongholdFixture(options: NativeSkillsStrongh if (options.optionalTech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true }); if (game.techTokens) assert.ok(game.players.length >= 3); const initial = structuredClone(game), actions: StrongholdFactionsNativeStep[] = [], - staging: string[] = ['Scoped original scalar action entropy keeps native source programs reproducible; UUID byte arrays remain native.']; + staging: string[] = ['Scoped original initializer and scalar action entropy keeps native source programs reproducible; UUID byte arrays remain native.']; const native: AdvancedNativeSkillsOptions = { family, skillOwner: ownerFaction, requestedSkill: 'suk-graduate', initial: game }; - game = initializeAdvancedNativeSkillsSetup(native); + game = nativeScalarEntropy(() => initializeAdvancedNativeSkillsSetup(native)); const offered = structuredClone(game); for (let i = 0; game.status === 'setup' && i < 200; i++) { if (game.setupStage === 'leaderSkills') { diff --git a/tools/verify-container.mjs b/tools/verify-container.mjs index ebc55d5d..68dc44c2 100644 --- a/tools/verify-container.mjs +++ b/tools/verify-container.mjs @@ -1,6 +1,6 @@ // Runs only against a disposable container and volume created by this command. import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { setTimeout as delay } from 'node:timers/promises'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; @@ -15,6 +15,13 @@ const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8' }).t let base; const adminFiles = mkdtempSync(joinPath(tmpdir(), 'dune-admin-container-')); let adminCookie; +let stage = 'isolated volume and cold start'; +function annotateFailure(message) { + if (process.env.GITHUB_ACTIONS !== 'true') return; + const safe = String(message).replaceAll(/dune-admin\.[0-9a-f-]{36}\.[0-9a-f]{64}/g, '[REDACTED ADMIN KEY]') + .replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A'); + console.error(`::error title=Container verification - ${stage}::${safe}`); +} async function ready() { for (let attempt = 0; attempt < 90; attempt++) { try { @@ -42,12 +49,14 @@ try { docker('volume', 'create', volume); start(); await ready(); + stage = 'first owner bootstrap and login'; const bootstrapKey = checkBootstrap(1); const ownerLogin = await fetch(`${base}/api/admin/session`, { method: 'POST', headers: { 'content-type': 'application/json', origin: base }, body: JSON.stringify({ action: 'login', key: bootstrapKey }), }); assert.equal(ownerLogin.status, 200, 'Bootstrapped owner must be able to sign in'); + stage = 'operator provisioning and login'; execFileSync('node', ['tools/admin-access.mjs', '--name', 'Container QA', '--role', 'operator', '--out', joinPath(adminFiles, 'key')]); execFileSync('docker', ['exec', '-i', name, 'node', '-e', "require('node:fs').writeFileSync('/tmp/dune-admin-provision.sql', require('node:fs').readFileSync(0), {mode:0o600})"], { input: readFileSync(joinPath(adminFiles, 'key/provision.sql')), // Container USER owns this private file. @@ -61,6 +70,7 @@ try { assert.equal(adminLogin.status, 200); adminCookie = adminLogin.headers.get('set-cookie')?.split(';')[0]; assert.ok(adminCookie); + stage = 'mapped origin and room creation'; const headers = { 'content-type': 'application/json', origin: base }; const body = JSON.stringify({ name: 'Container verification', faction: 'atreides', advanced: false, expansions: [] }); const create = await fetch(`${base}/api/rooms`, { method: 'POST', headers, body }); @@ -81,23 +91,44 @@ try { assert.equal(admin.status, 200, 'Administrator session must survive replacement'); } } + stage = 'container restart and saved witnesses'; docker('restart', '--time', '60', name); // Docker may assign a different ephemeral host port after a restart. base = `http://${docker('port', name, '3000/tcp')}`; await ready(); assert.equal(checkBootstrap(1), bootstrapKey, 'Restart must not replace the owner key'); await restored(); + stage = 'container replacement and bootstrap custody'; docker('stop', '--time', '60', name); docker('rm', name); start(); await ready(); await restored(); checkBootstrap(0); + stage = 'administrator HTTP acceptance'; execFileSync('node', ['tools/verify-admin.mjs', '--url', base, '--key-file', joinPath(adminFiles, 'key/access-key.txt'), '--qa-account', adminKey.split('.')[1], '--out', joinPath(adminFiles, 'http')], { stdio: 'inherit' }); adminCookie = undefined; // The acceptance test intentionally signs out all sessions. - execFileSync('npm', ['run', 'test:integration'], { - stdio: 'inherit', env: { ...process.env, DUNE_TEST_URL: base }, + stage = 'HTTP integration'; + const integration = spawnSync('npm', ['run', 'test:integration'], { + encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, + env: { ...process.env, DUNE_TEST_URL: base }, }); + if (integration.stdout) process.stdout.write(integration.stdout); + if (integration.stderr) process.stderr.write(integration.stderr); + if (integration.error) throw integration.error; + if (integration.status !== 0) { + const details = `${integration.stdout ?? ''}\n${integration.stderr ?? ''}`; + const failures = details.match(/^\s*\u2716 [^\r\n]+/gm) ?? []; + const reported = new Set(); + for (const failure of failures) { + const name = failure.trim(); + if (name.includes('failing tests:') || reported.has(name)) continue; + reported.add(name); annotateFailure(name); + if (reported.size === 3) break; + } + throw new Error(`HTTP integration exited with ${integration.signal ?? integration.status}`); + } + stage = 'HTTPS proxy replacement and original witnesses'; docker('stop', '--time', '60', name); docker('rm', name); const publicOrigin = 'https://dune.example.test'; @@ -105,12 +136,14 @@ try { await ready(); await restored(); checkBootstrap(0); + stage = 'HTTPS external-origin login and cookies'; const proxyAdmin = await fetch(`${base}/api/admin/session`, { method: 'POST', headers: { 'content-type': 'application/json', origin: publicOrigin }, body: JSON.stringify({ action: 'login', key: adminKey }), }); assert.equal(proxyAdmin.status, 200); assert.ok(proxyAdmin.headers.get('set-cookie')?.includes('; Secure')); + stage = 'HTTPS external-origin room, join and discussion'; const proxyCreate = await fetch(`${base}/api/rooms`, { method: 'POST', headers: { ...headers, origin: publicOrigin }, body, }); @@ -129,6 +162,7 @@ try { body: JSON.stringify({ id: randomUUID(), recipientId: null, text: 'Proxy verification' }), }); assert.equal(message.status, 200, 'Table discussion must work through HTTPS proxy'); + stage = 'foreign-origin mutation rejection'; for (const path of ['/api/rooms', `/api/rooms/${proxyRoom.code}`, `/api/rooms/${proxyRoom.code}/control`, `/api/rooms/${proxyRoom.code}/messages`]) { const rejected = await fetch(base + path, { method: 'POST', headers: { ...headers, origin: 'https://foreign.invalid' }, body, @@ -137,6 +171,14 @@ try { } console.log('Container origin, restart, replacement, saved seat and HTTP integration checks passed.'); } catch (error) { + annotateFailure(String(error?.message ?? error).split('\n')[0]); + if (stage === 'administrator HTTP acceptance') { + try { + const report = JSON.parse(readFileSync(joinPath(adminFiles, 'http/report.json'), 'utf8')); + const request = report.failureRequest ?? report.lastRequest; + if (request) annotateFailure(`${request.path} returned ${request.status}; ${report.failureLocation ?? 'see the failed acceptance check'}`); + } catch { /* Early failures may not create the private report; the original error remains fatal. */ } + } try { console.error(docker('logs', name).replaceAll(/dune-admin\.[0-9a-f-]{36}\.[0-9a-f]{64}/g, '[REDACTED ADMIN KEY]')); } catch { /* May not exist yet. */ } throw error; } finally {