Make native fixtures deterministic and expose verifier failures safely
This commit is contained in:
1 parent
2d24f43721
commit
8b6bf38560
4 files changed
+79
-14
No files matched your search
+22
-3
@@ -1,6 +1,6 @@
|
|||||||
# Current development status
|
# 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.
|
is playable and the six classic factions have a user-authorized Advanced preview.
|
||||||
Expansion starts and full rules/publication acceptance remain gated.**
|
Expansion starts and full rules/publication acceptance remain gated.**
|
||||||
|
|
||||||
@@ -16,6 +16,25 @@ open mode gates.
|
|||||||
|
|
||||||
## Current checkpoint and work
|
## 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:**
|
**Paired E1/E2 Nexus / Skills / Tech / Strongholds — bounded Development Verified:**
|
||||||
both Ixians+Tleilaxu OR CHOAM+Richese plus classics, one original family47/
|
both Ixians+Tleilaxu OR CHOAM+Richese plus classics, one original family47/
|
||||||
35+cache deck/all14/all12, selected unused Tech3..6 Basic/Advanced and/or
|
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
|
16,094 accepted actions/no rejection,420 JSON continuations and22 actual
|
||||||
closing choices on unchanged tree
|
closing choices on unchanged tree
|
||||||
`472b5705ca5376fb95f8e6ff6f40192d9f1745d7d08c41efdef2b3e53783139a`.
|
`472b5705ca5376fb95f8e6ff6f40192d9f1745d7d08c41efdef2b3e53783139a`.
|
||||||
An older native marker fixture's unseeded scalar actions are now controlled;
|
The older native fixture now controls the original initializer remainder and
|
||||||
all nine native cases and the frozen175-case union pass afterward.
|
later scalar actions; final59 module/native cases, types/lint/build pass.
|
||||||
Original backed-up CLI **PPL5CNEB / TZRU5GYP v10/setup** retains actual
|
Original backed-up CLI **PPL5CNEB / TZRU5GYP v10/setup** retains actual
|
||||||
offers/hands/IDs after a scoped original14 shuffle. Human390px **PPL5CNEB
|
offers/hands/IDs after a scoped original14 shuffle. Human390px **PPL5CNEB
|
||||||
v28/Collection** copies held Arrakeen into HMS, uses full Suboids and trained
|
v28/Collection** copies held Arrakeen into HMS, uses full Suboids and trained
|
||||||
|
|||||||
@@ -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.
|
22 actual closing choices. Natural Cunning combinations are not claimed.
|
||||||
Reports under `/tmp/dune-paired-nexus-skills-{tech,stronghold,both}-games-20261005`
|
Reports under `/tmp/dune-paired-nexus-skills-{tech,stronghold,both}-games-20261005`
|
||||||
capture unchanged tree `472b5705ca5376fb95f8e6ff6f40192d9f1745d7d08c41efdef2b3e53783139a`.
|
capture unchanged tree `472b5705ca5376fb95f8e6ff6f40192d9f1745d7d08c41efdef2b3e53783139a`.
|
||||||
An older native marker fixture's scalar action entropy is controlled after an
|
An older native fixture's initializer remainder and scalar actions are now
|
||||||
intermittent missing-marker assertion; its nine cases and final175 union pass.
|
controlled after intermittent marker/held-owner assertions; final59 module/
|
||||||
No production marker behavior or unresolved ruling is changed for that fixture.
|
native cases, types/lint/build pass. No production behavior or ruling changes.
|
||||||
|
|
||||||
Original backed-up CLI **PPL5CNEB / TZRU5GYP v10/setup** preserves first
|
Original backed-up CLI **PPL5CNEB / TZRU5GYP v10/setup** preserves first
|
||||||
offers/hands/IDs after only the original14 pre-deal shuffle. Controlled390px
|
offers/hands/IDs after only the original14 pre-deal shuffle. Controlled390px
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ export function nextNativeSkillsStrongholdStep(game: Game): StrongholdFactionsNa
|
|||||||
return game.players.some(p => p.faction === 'richese')
|
return game.players.some(p => p.faction === 'richese')
|
||||||
? nextRicheseStrongholdsNativeStep(game) : nextStrongholdFactionsNativeStep(game);
|
? nextRicheseStrongholdsNativeStep(game) : nextStrongholdFactionsNativeStep(game);
|
||||||
}
|
}
|
||||||
function act(game: Game, next: StrongholdFactionsNativeStep, actions?: StrongholdFactionsNativeStep[]): Game {
|
/** Scope the original initializer remainder and later native shuffles alike. */
|
||||||
actions?.push(structuredClone(next));
|
function nativeScalarEntropy<T>(run: () => T): T {
|
||||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||||
const native = crypto.getRandomValues.bind(crypto);
|
const native = crypto.getRandomValues.bind(crypto);
|
||||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||||
@@ -109,12 +109,16 @@ function act(game: Game, next: StrongholdFactionsNativeStep, actions?: Stronghol
|
|||||||
else Reflect.apply(native, crypto, [array]);
|
else Reflect.apply(native, crypto, [array]);
|
||||||
return array;
|
return array;
|
||||||
};
|
};
|
||||||
try { return applyAction(game, next.actor, next.action); }
|
try { return run(); }
|
||||||
finally {
|
finally {
|
||||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
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,
|
export function advanceNativeSkillsStronghold(state: Game, until: (g: Game) => boolean,
|
||||||
actions?: StrongholdFactionsNativeStep[], firstDial = 0): Game {
|
actions?: StrongholdFactionsNativeStep[], firstDial = 0): Game {
|
||||||
let game = state;
|
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 (options.optionalTech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
|
||||||
if (game.techTokens) assert.ok(game.players.length >= 3);
|
if (game.techTokens) assert.ok(game.players.length >= 3);
|
||||||
const initial = structuredClone(game), actions: StrongholdFactionsNativeStep[] = [],
|
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 };
|
const native: AdvancedNativeSkillsOptions = { family, skillOwner: ownerFaction, requestedSkill: 'suk-graduate', initial: game };
|
||||||
game = initializeAdvancedNativeSkillsSetup(native);
|
game = nativeScalarEntropy(() => initializeAdvancedNativeSkillsSetup(native));
|
||||||
const offered = structuredClone(game);
|
const offered = structuredClone(game);
|
||||||
for (let i = 0; game.status === 'setup' && i < 200; i++) {
|
for (let i = 0; game.status === 'setup' && i < 200; i++) {
|
||||||
if (game.setupStage === 'leaderSkills') {
|
if (game.setupStage === 'leaderSkills') {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Runs only against a disposable container and volume created by this command.
|
// Runs only against a disposable container and volume created by this command.
|
||||||
import assert from 'node:assert/strict';
|
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 { randomUUID } from 'node:crypto';
|
||||||
import { setTimeout as delay } from 'node:timers/promises';
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||||
@@ -15,6 +15,13 @@ const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8' }).t
|
|||||||
let base;
|
let base;
|
||||||
const adminFiles = mkdtempSync(joinPath(tmpdir(), 'dune-admin-container-'));
|
const adminFiles = mkdtempSync(joinPath(tmpdir(), 'dune-admin-container-'));
|
||||||
let adminCookie;
|
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() {
|
async function ready() {
|
||||||
for (let attempt = 0; attempt < 90; attempt++) {
|
for (let attempt = 0; attempt < 90; attempt++) {
|
||||||
try {
|
try {
|
||||||
@@ -42,12 +49,14 @@ try {
|
|||||||
docker('volume', 'create', volume);
|
docker('volume', 'create', volume);
|
||||||
start();
|
start();
|
||||||
await ready();
|
await ready();
|
||||||
|
stage = 'first owner bootstrap and login';
|
||||||
const bootstrapKey = checkBootstrap(1);
|
const bootstrapKey = checkBootstrap(1);
|
||||||
const ownerLogin = await fetch(`${base}/api/admin/session`, {
|
const ownerLogin = await fetch(`${base}/api/admin/session`, {
|
||||||
method: 'POST', headers: { 'content-type': 'application/json', origin: base },
|
method: 'POST', headers: { 'content-type': 'application/json', origin: base },
|
||||||
body: JSON.stringify({ action: 'login', key: bootstrapKey }),
|
body: JSON.stringify({ action: 'login', key: bootstrapKey }),
|
||||||
});
|
});
|
||||||
assert.equal(ownerLogin.status, 200, 'Bootstrapped owner must be able to sign in');
|
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('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})"], {
|
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.
|
input: readFileSync(joinPath(adminFiles, 'key/provision.sql')), // Container USER owns this private file.
|
||||||
@@ -61,6 +70,7 @@ try {
|
|||||||
assert.equal(adminLogin.status, 200);
|
assert.equal(adminLogin.status, 200);
|
||||||
adminCookie = adminLogin.headers.get('set-cookie')?.split(';')[0];
|
adminCookie = adminLogin.headers.get('set-cookie')?.split(';')[0];
|
||||||
assert.ok(adminCookie);
|
assert.ok(adminCookie);
|
||||||
|
stage = 'mapped origin and room creation';
|
||||||
const headers = { 'content-type': 'application/json', origin: base };
|
const headers = { 'content-type': 'application/json', origin: base };
|
||||||
const body = JSON.stringify({ name: 'Container verification', faction: 'atreides', advanced: false, expansions: [] });
|
const body = JSON.stringify({ name: 'Container verification', faction: 'atreides', advanced: false, expansions: [] });
|
||||||
const create = await fetch(`${base}/api/rooms`, { method: 'POST', headers, body });
|
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');
|
assert.equal(admin.status, 200, 'Administrator session must survive replacement');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
stage = 'container restart and saved witnesses';
|
||||||
docker('restart', '--time', '60', name);
|
docker('restart', '--time', '60', name);
|
||||||
// Docker may assign a different ephemeral host port after a restart.
|
// Docker may assign a different ephemeral host port after a restart.
|
||||||
base = `http://${docker('port', name, '3000/tcp')}`;
|
base = `http://${docker('port', name, '3000/tcp')}`;
|
||||||
await ready();
|
await ready();
|
||||||
assert.equal(checkBootstrap(1), bootstrapKey, 'Restart must not replace the owner key');
|
assert.equal(checkBootstrap(1), bootstrapKey, 'Restart must not replace the owner key');
|
||||||
await restored();
|
await restored();
|
||||||
|
stage = 'container replacement and bootstrap custody';
|
||||||
docker('stop', '--time', '60', name);
|
docker('stop', '--time', '60', name);
|
||||||
docker('rm', name);
|
docker('rm', name);
|
||||||
start();
|
start();
|
||||||
await ready();
|
await ready();
|
||||||
await restored();
|
await restored();
|
||||||
checkBootstrap(0);
|
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' });
|
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.
|
adminCookie = undefined; // The acceptance test intentionally signs out all sessions.
|
||||||
execFileSync('npm', ['run', 'test:integration'], {
|
stage = 'HTTP integration';
|
||||||
stdio: 'inherit', env: { ...process.env, DUNE_TEST_URL: base },
|
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('stop', '--time', '60', name);
|
||||||
docker('rm', name);
|
docker('rm', name);
|
||||||
const publicOrigin = 'https://dune.example.test';
|
const publicOrigin = 'https://dune.example.test';
|
||||||
@@ -105,12 +136,14 @@ try {
|
|||||||
await ready();
|
await ready();
|
||||||
await restored();
|
await restored();
|
||||||
checkBootstrap(0);
|
checkBootstrap(0);
|
||||||
|
stage = 'HTTPS external-origin login and cookies';
|
||||||
const proxyAdmin = await fetch(`${base}/api/admin/session`, {
|
const proxyAdmin = await fetch(`${base}/api/admin/session`, {
|
||||||
method: 'POST', headers: { 'content-type': 'application/json', origin: publicOrigin },
|
method: 'POST', headers: { 'content-type': 'application/json', origin: publicOrigin },
|
||||||
body: JSON.stringify({ action: 'login', key: adminKey }),
|
body: JSON.stringify({ action: 'login', key: adminKey }),
|
||||||
});
|
});
|
||||||
assert.equal(proxyAdmin.status, 200);
|
assert.equal(proxyAdmin.status, 200);
|
||||||
assert.ok(proxyAdmin.headers.get('set-cookie')?.includes('; Secure'));
|
assert.ok(proxyAdmin.headers.get('set-cookie')?.includes('; Secure'));
|
||||||
|
stage = 'HTTPS external-origin room, join and discussion';
|
||||||
const proxyCreate = await fetch(`${base}/api/rooms`, {
|
const proxyCreate = await fetch(`${base}/api/rooms`, {
|
||||||
method: 'POST', headers: { ...headers, origin: publicOrigin }, body,
|
method: 'POST', headers: { ...headers, origin: publicOrigin }, body,
|
||||||
});
|
});
|
||||||
@@ -129,6 +162,7 @@ try {
|
|||||||
body: JSON.stringify({ id: randomUUID(), recipientId: null, text: 'Proxy verification' }),
|
body: JSON.stringify({ id: randomUUID(), recipientId: null, text: 'Proxy verification' }),
|
||||||
});
|
});
|
||||||
assert.equal(message.status, 200, 'Table discussion must work through HTTPS proxy');
|
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`]) {
|
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, {
|
const rejected = await fetch(base + path, {
|
||||||
method: 'POST', headers: { ...headers, origin: 'https://foreign.invalid' }, body,
|
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.');
|
console.log('Container origin, restart, replacement, saved seat and HTTP integration checks passed.');
|
||||||
} catch (error) {
|
} 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. */ }
|
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;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in new issue
Block a user