From 8e9aeb7da3a75e7f3dc942c450e1cab76faaf0d9 Mon Sep 17 00:00:00 2001 From: Daniel Oon <47585778+EauDoon@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:57:31 +0800 Subject: [PATCH] fix: reject unreadable case bundles without replacing evidence --- CHANGELOG.md | 3 ++ bin/aas.mjs | 79 ++++++++++++++++++++++++++----------------- test/stack.test.mjs | 82 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 132 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b841e66..5278103 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed +- Case persistence now checks the exact formatted UTF-8 replay budget before writing files. Oversized combined evidence is rejected without publishing an unreadable case or replacing the previous latest pointer. + ## [0.3.0] - 2026-10-09 The first tagged release since 0.2.0. `package.json` carried 0.2.1 to 0.2.5 diff --git a/bin/aas.mjs b/bin/aas.mjs index ac87aaa..1240206 100755 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -1531,39 +1531,53 @@ export function persistRunBundle({ const runsDir = join(outputRoot, "runs"); const finalDir = join(runsDir, runId); const temporaryDir = join(runsDir, `.${runId}.${process.pid}.${randomUUID()}.tmp`); + // Assemble and size the exact documents before publishing any case files. + // Individually bounded child outputs can exceed the portable case budget + // when combined or pretty-printed. Such a case cannot be reopened by any + // reader, so it must not replace latest.json or appear as a successful save. + const serializedStages = {}; + const stageValues = {}; + const stageManifest = {}; + for (const stage of STAGE_NAMES) { + const current = stages[stage] ?? { status: "skipped", reason: "not_reached" }; + const artifact = stageArtifact(stage, current.raw); + stageManifest[stage] = { + status: current.status, + reason: current.reason ?? null, + code: current.code ?? null, + stderr: current.stderr ? clipChildStderr(current.stderr) : null, + artifact: artifact?.path ?? null, + }; + if (artifact) { + stageValues[stage] = current.raw; + serializedStages[artifact.path] = `${JSON.stringify(current.raw, null, 2)}\n`; + } + } + const manifest = { + schema_version: "agent-action-stack.run/v1", + run_id: runId, + created_at: now, + exit_code: exitCode, + // Additive within run/v1: readers treat a missing field as null. + stack_version: orchestratorVersion, + component_provenance: componentProvenance, + stages: stageManifest, + report: "report.json", + }; + const serializedManifest = `${JSON.stringify(manifest, null, 2)}\n`; + const serializedReport = `${JSON.stringify(report, null, 2)}\n`; + const serializedBundle = `${JSON.stringify({ manifest, report, stages: stageValues }, null, 2)}\n`; + if ([serializedManifest, serializedReport, serializedBundle, ...Object.values(serializedStages)] + .some((text) => Buffer.byteLength(text, "utf8") > CHILD_JSON_LIMIT)) { + throw new Error("Run bundle exceeds the replay byte limit; no case was saved."); + } mkdirSync(temporaryDir, { recursive: true }); try { - const stageManifest = {}; - for (const stage of STAGE_NAMES) { - const current = stages[stage] ?? { status: "skipped", reason: "not_reached" }; - const artifact = stageArtifact(stage, current.raw); - stageManifest[stage] = { - status: current.status, - reason: current.reason ?? null, - code: current.code ?? null, - stderr: current.stderr ? clipChildStderr(current.stderr) : null, - artifact: artifact?.path ?? null, - }; - if (artifact) { - writeAtomicFile( - join(temporaryDir, artifact.path), - `${JSON.stringify(current.raw, null, 2)}\n`, - ); - } + for (const [path, text] of Object.entries(serializedStages)) { + writeAtomicFile(join(temporaryDir, path), text); } - const manifest = { - schema_version: "agent-action-stack.run/v1", - run_id: runId, - created_at: now, - exit_code: exitCode, - // Additive within run/v1: readers treat a missing field as null. - stack_version: orchestratorVersion, - component_provenance: componentProvenance, - stages: stageManifest, - report: "report.json", - }; - writeAtomicFile(join(temporaryDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); - writeAtomicFile(join(temporaryDir, "report.json"), `${JSON.stringify(report, null, 2)}\n`); + writeAtomicFile(join(temporaryDir, "manifest.json"), serializedManifest); + writeAtomicFile(join(temporaryDir, "report.json"), serializedReport); if (existsSync(finalDir)) throw new Error(`Run bundle already exists: ${runId}`); renameSync(temporaryDir, finalDir); const pointer = { @@ -1696,7 +1710,10 @@ export async function runDemo(args = [], options = {}) { prove: stageRecord("skipped", { reason: "not_reached" }), }, }; - const finalize = () => { + // Return this promise without awaiting it inside the stage handlers below. + // Persistence failures must escape, not be rewritten as component failures + // that discard raw evidence and publish a different, smaller error case. + const finalize = async () => { report.stages = Object.fromEntries(STAGE_NAMES.map((stage) => { const { raw: _raw, ...visible } = report.stages[stage] ?? stages[stage]; return [stage, { ...visible, status: stages[stage].status }]; diff --git a/test/stack.test.mjs b/test/stack.test.mjs index 40517ef..99f91b9 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -2120,7 +2120,9 @@ test('case exports reject mismatched identities and oversized persisted reports' test('export byte budget includes the actual formatted download representation',async()=>{ const outputRoot=tempRoot(); - await runDemo([],{paths:{outputRoot},runId:'formatted-budget',componentResolver:()=>[],runDecideFn:async()=>({ok:false,raw:{passed:false,rows:Array(30000).fill({x:0})},status:0})}); + const result = await runDemo([],{paths:{outputRoot},runId:'formatted-budget',componentResolver:()=>[],runDecideFn:async()=>({ok:false,raw:{passed:false},status:0})}); + // Preserve the reader's guard against externally modified historical cases. + writeFileSync(join(result.bundleDir, 'stages/decide.json'), JSON.stringify({passed:false,rows:Array(30000).fill({x:0})})); assert.throws(()=>exportRunBundle('formatted-budget',{outputRoot}),/byte limit/); }); @@ -2733,3 +2735,81 @@ test("npm test reports every declared test", () => { assert.ok(counts.length > 0, `no test count in the runner summary:\n${result.stdout.slice(-800)}`); assert.ok(reported >= declared, `runner reported ${reported} results but ${declared} tests are declared`); }); + + +test('case persistence rejects a combined oversized case before replacing latest', () => { + const outputRoot = tempRoot(); + const save = (runId, stages) => persistRunBundle({ + outputRoot, runId, report: {run_id: runId, stages: {}}, stages, + componentProvenance: [], exitCode: 0, + }); + save('previous-readable-case', {}); + const latest = readFileSync(join(outputRoot, 'latest.json'), 'utf8'); + assert.throws(() => save('oversized-combination', { + decide: {status: 'passed', raw: {pad: 'x'.repeat(600000)}}, + act: {status: 'passed', raw: {pad: 'y'.repeat(600000)}}, + }), /replay byte limit; no case was saved/); + assert.equal(readFileSync(join(outputRoot, 'latest.json'), 'utf8'), latest); + assert.deepEqual(readdirSync(join(outputRoot, 'runs')), ['previous-readable-case']); + assert.equal(exportRunBundle('previous-readable-case', {outputRoot}).manifest.run_id, 'previous-readable-case'); +}); + +test('case persistence accounts for pretty printing and UTF-8 bytes', () => { + for (const raw of [{rows: Array(30000).fill({x: 0})}, {pad: '界'.repeat(360000)}]) { + const outputRoot = tempRoot(); + assert.throws(() => persistRunBundle({ + outputRoot, runId: 'oversized-representation', report: {run_id: 'oversized-representation'}, + stages: {decide: {status: 'passed', raw}}, componentProvenance: [], exitCode: 0, + }), /replay byte limit; no case was saved/); + assert.equal(existsSync(join(outputRoot, 'latest.json')), false); + assert.equal(existsSync(join(outputRoot, 'runs')), false); + } +}); + +test('successfully persisted cases round-trip at the exact portable byte limit', () => { + const outputRoot = tempRoot(); + const runId = 'exact-portable-limit'; + const parameters = { + outputRoot, runId, report: {run_id: runId}, stages: {decide: {status: 'passed', raw: {pad: ''}}}, + componentProvenance: [], exitCode: 0, now: '2026-10-15T00:00:00.000Z', + }; + const probeRoot = tempRoot(); + persistRunBundle({...parameters, outputRoot: probeRoot}); + const empty = exportRunBundle(runId, {outputRoot: probeRoot}); + const size = Buffer.byteLength(JSON.stringify(empty, null, 2) + '\n'); + parameters.stages.decide.raw.pad = 'x'.repeat(CHILD_JSON_LIMIT - size); + persistRunBundle(parameters); + const result = exportRunBundle(runId, {outputRoot}); + assert.equal(Buffer.byteLength(JSON.stringify(result, null, 2) + '\n'), CHILD_JSON_LIMIT); + assert.equal(result.stages.decide.pad, parameters.stages.decide.raw.pad); + assert.throws(() => persistRunBundle({...parameters, outputRoot: tempRoot(), + stages: {decide: {status: 'passed', raw: {pad: parameters.stages.decide.raw.pad + 'x'}}}, + }), /replay byte limit/); +}); + + +test("runDemo preserves the previous case when finalization fails in any stage", async () => { + const large = { rows: Array.from({ length: 30_000 }, () => ({ x: 0 })) }; + const scenarios = [ + { name: "policy-denied", decide: { ok: false, raw: { passed: false, ...large } } }, + { name: "act-failed", act: { ok: false, raw: { ...large } } }, + { name: "settled", act: { ok: true, raw: { outcome: "settled", ...large } } }, + { name: "prove-passed", prove: { ok: true, raw: { ...large } } }, + { name: "prove-failed", prove: { ok: false, raw: { ...large } } }, + ]; + for (const scenario of scenarios) { + const outputRoot = tempRoot(); + await runDemo([], stubOptions(outputRoot, { runId: "previous" })); + const previous = readFileSync(join(outputRoot, "latest.json"), "utf8"); + await assert.rejects(runDemo([], { + paths: { outputRoot }, + runId: scenario.name, + componentResolver: () => [], + runDecideFn: async () => scenario.decide ?? { ok: true, raw: { passed: true } }, + runActFn: async () => scenario.act ?? { ok: true, raw: { outcome: "disputed" } }, + runProveFn: async () => scenario.prove, + }), /replay byte limit; no case was saved/, scenario.name); + assert.equal(readFileSync(join(outputRoot, "latest.json"), "utf8"), previous, scenario.name); + assert.deepEqual(readdirSync(join(outputRoot, "runs")), ["previous"], scenario.name); + } +});