opencode-longrun-harness 1.2.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +390 -0
- package/docs/V1.2.20_EVIDENCE.md +114 -0
- package/docs/V1.2.21_EVIDENCE.md +68 -0
- package/docs/V1.2.22_EVIDENCE.md +52 -0
- package/harness/commissioning/README.md +16 -0
- package/harness/commissioning/inspect-copied-run.mjs +25 -0
- package/harness/commissioning/verify-copied-case.mjs +35 -0
- package/harness/plugin/longrun.js +677 -0
- package/harness/src/cli.mjs +40 -0
- package/harness/src/controller.js +1413 -0
- package/harness/src/evidence.mjs +135 -0
- package/harness/src/execution.mjs +217 -0
- package/harness/src/executor.mjs +21 -0
- package/harness/src/install.mjs +435 -0
- package/harness/src/maintenance.mjs +257 -0
- package/harness/src/memory.mjs +472 -0
- package/harness/test/candidates.test.mjs +73 -0
- package/harness/test/checkpoint.test.mjs +65 -0
- package/harness/test/controller.test.mjs +230 -0
- package/harness/test/evidence.test.mjs +57 -0
- package/harness/test/fixtures/durable-host.mjs +27 -0
- package/harness/test/fixtures/example-app-run.json +1375 -0
- package/harness/test/fixtures/notes-budget-exhausted-run.json +2070 -0
- package/harness/test/fixtures/notes-premature-complete-run.json +1496 -0
- package/harness/test/fixtures/notes-recovery-run.json +622 -0
- package/harness/test/fixtures/presets-readout-run.json +825 -0
- package/harness/test/fixtures/routing-worker.mjs +35 -0
- package/harness/test/fixtures/vitest-failed-receipt.json +33 -0
- package/harness/test/helper.mjs +41 -0
- package/harness/test/install.test.mjs +117 -0
- package/harness/test/lifecycle.test.mjs +102 -0
- package/harness/test/maintenance.test.mjs +204 -0
- package/harness/test/memory.test.mjs +145 -0
- package/harness/test/negative-control.test.mjs +91 -0
- package/harness/test/plugin.test.mjs +169 -0
- package/harness/test/recovery-runner.test.mjs +435 -0
- package/harness/test/recovery.test.mjs +68 -0
- package/harness/test/repair-mechanics.test.mjs +122 -0
- package/harness/test/toolbehavior.test.mjs +75 -0
- package/harness/test/v121-commissioning.test.mjs +177 -0
- package/harness/test/v1210-deadline.test.mjs +134 -0
- package/harness/test/v1211-pause.test.mjs +81 -0
- package/harness/test/v1212-maintenance-pause.test.mjs +76 -0
- package/harness/test/v1213-readout.test.mjs +82 -0
- package/harness/test/v1214-durable.test.mjs +121 -0
- package/harness/test/v1215-guidance.test.mjs +57 -0
- package/harness/test/v1216-test-summary.test.mjs +39 -0
- package/harness/test/v1217-discovery.test.mjs +73 -0
- package/harness/test/v1218-completion-review.test.mjs +203 -0
- package/harness/test/v1219-budget-pause.test.mjs +134 -0
- package/harness/test/v122-lifecycle-resolver.test.mjs +218 -0
- package/harness/test/v1220-budget-amendment.test.mjs +343 -0
- package/harness/test/v1221-negative-fixture-anchor.test.mjs +65 -0
- package/harness/test/v1222-default-evidence-class.test.mjs +75 -0
- package/harness/test/v123-plugin-e2e.test.mjs +120 -0
- package/harness/test/v123-receipt-model.test.mjs +185 -0
- package/harness/test/v124-canonical.test.mjs +147 -0
- package/harness/test/v124-installed.test.mjs +48 -0
- package/harness/test/v125-stability.test.mjs +183 -0
- package/harness/test/v126-execution.test.mjs +183 -0
- package/harness/test/v127-reconciliation.test.mjs +139 -0
- package/harness/test/v128-compaction.test.mjs +156 -0
- package/harness/test/v129-routing.test.mjs +165 -0
- package/harness/tools/audit-receipts.mjs +121 -0
- package/harness/tools/recovery-runner.mjs +499 -0
- package/package.json +49 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { reviewProjectFixture } from "./helper.mjs";
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import * as C from '../src/controller.js';
|
|
8
|
+
import { F } from './helper.mjs';
|
|
9
|
+
process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname, '../src/controller.js');
|
|
10
|
+
async function setup({ delay = 0 } = {}) {
|
|
11
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'lr128-'));
|
|
12
|
+
const dir = path.join(base, 'project'); fs.mkdirSync(dir); fs.writeFileSync(path.join(dir, 'source.txt'), 'real fixture');
|
|
13
|
+
process.env.LONGRUN_STATE_DIR = path.join(base, 'state');
|
|
14
|
+
const hooks = await F('../plugin/longrun.js', { client: null });
|
|
15
|
+
const first = { sessionID: 'old-host', directory: dir, worktree: dir }, second = { ...first, sessionID: 'new-host' };
|
|
16
|
+
const run = JSON.parse(await hooks.tool.longrun.execute({ action: 'start', request: 'Compaction control fixture', criteria: [{ id: 'c', evidenceClass: 'STATIC', checks: ['check'] }], checkCatalogue: { check: { command: [process.execPath, '-e', `setTimeout(()=>require('node:assert/strict').equal(1+1,2),${delay})`], kind: 'cmd' } } }, first));
|
|
17
|
+
const store = new C.Store(process.env.LONGRUN_STATE_DIR), key = C.stateKey(C.projectIdentity(dir), run.runId);
|
|
18
|
+
const call = (action, ctx = second) => hooks.tool.longrun.execute({ action, runId: run.runId }, ctx);
|
|
19
|
+
await call('resume');
|
|
20
|
+
return { hooks, first, second, store, key, call, runId: run.runId };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('compaction cannot auto-continue an old session after another session pauses or cancels the same run', async () => {
|
|
24
|
+
for (const action of ['pause', 'cancel']) {
|
|
25
|
+
const s = await setup(); await s.call(action);
|
|
26
|
+
const before = fs.readFileSync(s.store._file(s.key, 'run.json'));
|
|
27
|
+
const output = { enabled: true };
|
|
28
|
+
await s.hooks['experimental.compaction.autocontinue']({ sessionID: s.first.sessionID }, output);
|
|
29
|
+
assert.equal(output.enabled, false, `${action} must use canonical run, not stale session flags`);
|
|
30
|
+
assert.deepEqual(fs.readFileSync(s.store._file(s.key, 'run.json')), before);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('compaction auto-continuation refuses missing runs and exhausted execution budgets without resets', async () => {
|
|
35
|
+
for (const situation of ['missing', 'deadline', 'attempts', 'legacy']) {
|
|
36
|
+
const s = await setup();
|
|
37
|
+
if (situation === 'missing') fs.rmSync(s.store._file(s.key, 'run.json'));
|
|
38
|
+
else s.store.mutate(s.key, r => { if (situation === 'legacy') delete r.compactionSessionID; else if (situation === 'deadline') r.createdAt -= 86400000; else r.execution.commandAttempts = r.budget.toolActionCap; return { ok: true }; });
|
|
39
|
+
const output = { enabled: true };
|
|
40
|
+
await s.hooks['experimental.compaction.autocontinue']({ sessionID: s.second.sessionID }, output);
|
|
41
|
+
assert.equal(output.enabled, false, situation);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a short concurrent writer cannot silently discard the real compacted state transition', async () => {
|
|
46
|
+
const s = await setup();
|
|
47
|
+
s.store.tryLock(s.key, 'checkpoint-writer');
|
|
48
|
+
const release = setTimeout(() => s.store.releaseLock(s.key), 60);
|
|
49
|
+
try {
|
|
50
|
+
await s.hooks.event({ event: { type: 'session.compacted', properties: { sessionID: s.second.sessionID } } });
|
|
51
|
+
await new Promise(resolve => setTimeout(resolve, 80));
|
|
52
|
+
assert.equal(s.store.readJSON(s.key, 'run.json').status, 'RECOVERY_REQUIRED');
|
|
53
|
+
} finally { clearTimeout(release); s.store.releaseLock(s.key); }
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('an older session cannot interrupt or continue the newly rebound active session', async () => {
|
|
57
|
+
const s = await setup(), before = s.store.readJSON(s.key, 'run.json');
|
|
58
|
+
await s.hooks.event({ event: { type: 'session.compacted', properties: { sessionID: s.first.sessionID } } });
|
|
59
|
+
const output = { enabled: true };
|
|
60
|
+
await s.hooks['experimental.compaction.autocontinue']({ sessionID: s.first.sessionID }, output);
|
|
61
|
+
assert.equal(output.enabled, false); assert.deepEqual(s.store.readJSON(s.key, 'run.json'), before);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('current-session compaction preserves context and requires recovery once, without enabling the scheduler', async () => {
|
|
65
|
+
const s = await setup();
|
|
66
|
+
const ctx = { nextAction: 'inspect the preserved independent oracle', decisions: ['preserve acceptance'] };
|
|
67
|
+
await s.hooks.tool.longrun.execute({ action: 'checkpoint', runId: s.runId, progress: ctx }, s.second);
|
|
68
|
+
const before = s.store.readJSON(s.key, 'run.json');
|
|
69
|
+
const compact = { context: ['host context'] };
|
|
70
|
+
await s.hooks['experimental.session.compacting']({ sessionID: s.second.sessionID }, compact);
|
|
71
|
+
assert.equal(compact.context[0], 'host context'); assert.match(compact.context[1], /inspect the preserved independent oracle/);
|
|
72
|
+
await s.hooks.event({ event: { type: 'session.compacted', properties: { sessionID: s.second.sessionID } } });
|
|
73
|
+
const enabled = { enabled: true };
|
|
74
|
+
await s.hooks['experimental.compaction.autocontinue']({ sessionID: s.second.sessionID }, enabled);
|
|
75
|
+
assert.equal(enabled.enabled, true); // Continue the active host turn, not automatic Longrun scheduling.
|
|
76
|
+
const after = s.store.readJSON(s.key, 'run.json');
|
|
77
|
+
assert.equal(after.status, 'RECOVERY_REQUIRED'); assert.equal(after.controlGeneration, before.controlGeneration + 1);
|
|
78
|
+
assert.equal(after.autoEnabled, false);
|
|
79
|
+
for (const field of ['contract', 'contractHash', 'budget', 'receipts', 'agentProgress']) assert.deepEqual(after[field], before[field]);
|
|
80
|
+
const veto = { enabled: false };
|
|
81
|
+
await s.hooks['experimental.compaction.autocontinue']({ sessionID: s.second.sessionID }, veto);
|
|
82
|
+
assert.equal(veto.enabled, false); assert.equal(s.store.readJSON(s.key, 'run.json').controlGeneration, after.controlGeneration);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('persistent writer contention vetoes compaction continuation and permits later recovery without state fabrication', async () => {
|
|
86
|
+
const s = await setup(), before = fs.readFileSync(s.store._file(s.key, 'run.json'));
|
|
87
|
+
s.store.tryLock(s.key, 'long-writer');
|
|
88
|
+
try {
|
|
89
|
+
const output = { enabled: true };
|
|
90
|
+
await s.hooks['experimental.compaction.autocontinue']({ sessionID: s.second.sessionID }, output);
|
|
91
|
+
assert.equal(output.enabled, false); assert.deepEqual(fs.readFileSync(s.store._file(s.key, 'run.json')), before);
|
|
92
|
+
} finally { s.store.releaseLock(s.key); }
|
|
93
|
+
await s.hooks.event({ event: { type: 'session.compacted', properties: { sessionID: s.second.sessionID } } });
|
|
94
|
+
assert.equal(s.store.readJSON(s.key, 'run.json').status, 'RECOVERY_REQUIRED');
|
|
95
|
+
assert.equal(s.store.readJSON(s.key, 'run.json').receipts.length, 0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('completed runs stay terminal and untracked sessions remain outside Longrun control', async () => {
|
|
99
|
+
const s = await setup();
|
|
100
|
+
const checked = JSON.parse(await s.hooks.tool.longrun_verify.execute({ runId: s.runId, checkId: 'check', evidenceClass: 'STATIC' }, s.second));
|
|
101
|
+
assert.equal(checked.status, 'PASS'); reviewProjectFixture(s.second.directory, s.runId); assert.equal(JSON.parse(await s.call('complete')).complete, true);
|
|
102
|
+
const before = s.store.readJSON(s.key, 'run.json');
|
|
103
|
+
const output = { enabled: true };
|
|
104
|
+
await s.hooks['experimental.compaction.autocontinue']({ sessionID: s.second.sessionID }, output);
|
|
105
|
+
assert.equal(output.enabled, false); assert.deepEqual(s.store.readJSON(s.key, 'run.json'), before);
|
|
106
|
+
const unrelated = { enabled: true };
|
|
107
|
+
await s.hooks['experimental.compaction.autocontinue']({ sessionID: 'untracked' }, unrelated);
|
|
108
|
+
assert.equal(unrelated.enabled, true); assert.deepEqual(s.store.readJSON(s.key, 'run.json'), before);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('compaction while an owned check is running cancels it honestly and refuses another host turn', async () => {
|
|
112
|
+
const s = await setup({ delay: 5000 });
|
|
113
|
+
const pending = s.hooks.tool.longrun_verify.execute({ runId: s.runId, checkId: 'check', evidenceClass: 'STATIC' }, s.second);
|
|
114
|
+
for (let i = 0; i < 100 && !s.store.readJSON(s.key, 'run.json').execution.inFlight?.childPid; i++) await new Promise(r => setTimeout(r, 10));
|
|
115
|
+
assert.ok(s.store.readJSON(s.key, 'run.json').execution.inFlight?.childPid);
|
|
116
|
+
const output = { enabled: true };
|
|
117
|
+
await s.hooks['experimental.compaction.autocontinue']({ sessionID: s.second.sessionID }, output);
|
|
118
|
+
assert.equal(output.enabled, false);
|
|
119
|
+
const result = JSON.parse(await pending); assert.equal(result.status, 'ERROR');
|
|
120
|
+
const run = s.store.readJSON(s.key, 'run.json');
|
|
121
|
+
assert.equal(run.status, 'RECOVERY_REQUIRED'); assert.equal(run.receipts[0].terminationReason, 'recovery_required');
|
|
122
|
+
assert.equal(run.execution.inFlight, null); assert.equal(run.receipts.length, 1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('a corrupt recorded binding cannot masquerade as an untracked session', async () => {
|
|
126
|
+
const s = await setup();
|
|
127
|
+
fs.writeFileSync(path.join(process.env.LONGRUN_STATE_DIR, 'runs.json'), JSON.stringify({ [s.second.sessionID]: { runId: s.runId } }));
|
|
128
|
+
const output = { enabled: true };
|
|
129
|
+
await s.hooks['experimental.compaction.autocontinue']({ sessionID: s.second.sessionID }, output);
|
|
130
|
+
assert.equal(output.enabled, false); assert.equal(s.store.readJSON(s.key, 'run.json').receipts.length, 0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('post-compaction execution and edits require native recovery while reads stay available', async () => {
|
|
134
|
+
const s = await setup();
|
|
135
|
+
await s.hooks.event({ event: { type: 'session.compacted', properties: { sessionID: s.second.sessionID } } });
|
|
136
|
+
const guard = s.hooks['tool.execute.before'];
|
|
137
|
+
assert.equal(typeof guard, 'function', 'host execution requires a recovery guard, not prose alone');
|
|
138
|
+
for (const tool of ['bash', 'edit', 'write', 'apply_patch', 'task', 'batch']) {
|
|
139
|
+
await assert.rejects(() => guard({ tool, sessionID: s.second.sessionID }, { args: {} }), /LONGRUN_RECOVERY_REQUIRED.*resume-context/);
|
|
140
|
+
}
|
|
141
|
+
for (const tool of ['read', 'glob', 'grep', 'list', 'skill', 'longrun', 'longrun_verify']) {
|
|
142
|
+
await guard({ tool, sessionID: s.second.sessionID }, { args: {} });
|
|
143
|
+
}
|
|
144
|
+
const resumed = JSON.parse(await s.call('resume')); assert.equal(resumed.resumed, true);
|
|
145
|
+
await guard({ tool: 'edit', sessionID: s.second.sessionID }, { args: {} });
|
|
146
|
+
await guard({ tool: 'bash', sessionID: 'untracked' }, { args: {} });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('compaction context distinguishes a pre-event snapshot from the mandatory post-event recovery', async () => {
|
|
150
|
+
const s = await setup(); const output = { context: ['host prompt'] };
|
|
151
|
+
await s.hooks['experimental.session.compacting']({ sessionID: s.second.sessionID }, output);
|
|
152
|
+
assert.equal(output.context[0], 'host prompt');
|
|
153
|
+
assert.match(output.context[1], /PRE-COMPACTION SNAPSHOT/);
|
|
154
|
+
assert.match(output.context[1], /resume-context/); assert.match(output.context[1], new RegExp(s.runId));
|
|
155
|
+
assert.match(output.context[1], /do not infer.*IMPLEMENTING/i);
|
|
156
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { F } from './helper.mjs';
|
|
8
|
+
import * as C from '../src/controller.js';
|
|
9
|
+
process.env.LONGRUN_CONTROLLER_FILE = path.resolve(import.meta.dirname, '../src/controller.js');
|
|
10
|
+
const request = { action: 'start', request: 'Routing integrity fixture', criteria: [{ id: 'c', checks: ['check'], evidenceClass: 'STATIC' }], checkCatalogue: { check: { command: [process.execPath, '-e', 'require("node:assert/strict").equal(2+2,4)'], kind: 'cmd' } } };
|
|
11
|
+
function base() {
|
|
12
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lr129-'));
|
|
13
|
+
for (const d of ['A', 'B', 'state']) fs.mkdirSync(path.join(root, d));
|
|
14
|
+
for (const d of ['A', 'B']) fs.writeFileSync(path.join(root, d, 'source.txt'), d);
|
|
15
|
+
return root;
|
|
16
|
+
}
|
|
17
|
+
async function setup() {
|
|
18
|
+
const root = base(), state = path.join(root, 'state'), dir = path.join(root, 'A');
|
|
19
|
+
process.env.LONGRUN_STATE_DIR = state;
|
|
20
|
+
const hooks = await F('../plugin/longrun.js', { client: null });
|
|
21
|
+
const ctx = { sessionID: 'A', directory: dir, worktree: dir };
|
|
22
|
+
const call = async (args, context = ctx) => JSON.parse(await hooks.tool.longrun.execute(args, context));
|
|
23
|
+
return { root, state, dir, hooks, ctx, call };
|
|
24
|
+
}
|
|
25
|
+
async function race(root, mode) {
|
|
26
|
+
const children = [];
|
|
27
|
+
function start(label) {
|
|
28
|
+
let output = '', error = '';
|
|
29
|
+
const child = spawn(process.execPath, [path.join(import.meta.dirname, 'fixtures/routing-worker.mjs'), root, label, mode], { env: { ...process.env, LONGRUN_TEST: '1' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
30
|
+
children.push(child);
|
|
31
|
+
const done = new Promise((resolve, reject) => {
|
|
32
|
+
child.on('error', reject);
|
|
33
|
+
child.on('close', code => code === 0 ? resolve(output.trim().split('\n').map(JSON.parse).find(x => x.result).result) : reject(new Error(error || `child exit ${code}`)));
|
|
34
|
+
});
|
|
35
|
+
const critical = new Promise(resolve => child.stdout.on('data', d => { output += d; if (output.includes('"critical":true')) resolve(); }));
|
|
36
|
+
child.stderr.on('data', d => error += d);
|
|
37
|
+
return { done, critical };
|
|
38
|
+
}
|
|
39
|
+
let timeout;
|
|
40
|
+
try {
|
|
41
|
+
return await Promise.race([(async () => {
|
|
42
|
+
const a = start('A');
|
|
43
|
+
await Promise.race([a.critical, a.done.then(() => { throw new Error('race boundary not reached'); })]);
|
|
44
|
+
const b = start('B');
|
|
45
|
+
return await Promise.all([a.done, b.done]);
|
|
46
|
+
})(), new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error('routing race timed out')), 15000); })]);
|
|
47
|
+
} finally {
|
|
48
|
+
clearTimeout(timeout);
|
|
49
|
+
for (const child of children) if (child.exitCode === null) child.kill('SIGTERM');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function canonicalRuns(root) {
|
|
53
|
+
const dir = path.join(root, 'state/state');
|
|
54
|
+
if (!fs.existsSync(dir)) return [];
|
|
55
|
+
return fs.readdirSync(dir).flatMap(k => { try { return [JSON.parse(fs.readFileSync(path.join(dir, k, 'run.json')))]; } catch { return []; } });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test('concurrent hosts starting different projects preserve both session bindings', async () => {
|
|
59
|
+
const root = base();
|
|
60
|
+
try {
|
|
61
|
+
const results = await race(root, 'different-projects');
|
|
62
|
+
assert.ok(results.every(r => r.runId), JSON.stringify(results));
|
|
63
|
+
const bindings = JSON.parse(fs.readFileSync(path.join(root, 'state/runs.json')));
|
|
64
|
+
assert.deepEqual(Object.keys(bindings).sort(), ['A', 'B']);
|
|
65
|
+
assert.equal(canonicalRuns(root).length, 2);
|
|
66
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('concurrent start admission cannot create two active runs for one project', async () => {
|
|
70
|
+
const root = base();
|
|
71
|
+
try {
|
|
72
|
+
const results = await race(root, 'same-project');
|
|
73
|
+
assert.equal(results.filter(r => !r.error && r.runId).length, 1, JSON.stringify(results));
|
|
74
|
+
assert.equal(results.filter(r => r.error === 'EXISTING_RUN').length, 1);
|
|
75
|
+
assert.equal(canonicalRuns(root).length, 1);
|
|
76
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('corrupt routing indices fail explicitly without being replaced by an empty map', async () => {
|
|
80
|
+
for (const filename of ['runs.json', 'projects.json']) {
|
|
81
|
+
const s = await setup(), file = path.join(s.state, filename);
|
|
82
|
+
try {
|
|
83
|
+
fs.writeFileSync(file, '{ preserved corrupt bytes');
|
|
84
|
+
const result = await s.call(request);
|
|
85
|
+
assert.equal(result.error, 'ROUTING_STORE_ERROR');
|
|
86
|
+
assert.equal(fs.readFileSync(file, 'utf8'), '{ preserved corrupt bytes');
|
|
87
|
+
assert.equal(canonicalRuns(s.root).length, 0);
|
|
88
|
+
await assert.rejects(s.hooks['tool.execute.before']({ sessionID: 'A', tool: 'bash' }), /ROUTING_STORE_ERROR/);
|
|
89
|
+
} finally { fs.rmSync(s.root, { recursive: true, force: true }); }
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('canonical run survives missing routing indices and an authorized resume repairs the binding', async () => {
|
|
94
|
+
const s = await setup();
|
|
95
|
+
try {
|
|
96
|
+
const started = await s.call(request), runId = started.runId;
|
|
97
|
+
const before = canonicalRuns(s.root)[0];
|
|
98
|
+
fs.unlinkSync(path.join(s.state, 'runs.json')); fs.unlinkSync(path.join(s.state, 'projects.json'));
|
|
99
|
+
const fresh = { ...s.ctx, sessionID: 'fresh' };
|
|
100
|
+
const found = await s.call({ action: 'status', runId }, fresh);
|
|
101
|
+
assert.equal(found.state, 'IMPLEMENTING'); assert.equal(found.runId, runId);
|
|
102
|
+
assert.equal((await s.call(request, fresh)).error, 'EXISTING_RUN');
|
|
103
|
+
assert.equal((await s.call({ action: 'resume', runId }, fresh)).resumed, true);
|
|
104
|
+
const after = canonicalRuns(s.root)[0];
|
|
105
|
+
for (const key of ['runId', 'contract', 'contractHash', 'budget', 'receipts', 'createdAt']) assert.deepEqual(after[key], before[key]);
|
|
106
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(s.state, 'runs.json'))).fresh.runId, runId);
|
|
107
|
+
assert.equal(canonicalRuns(s.root).length, 1);
|
|
108
|
+
} finally { fs.rmSync(s.root, { recursive: true, force: true }); }
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('a session binding cannot authorize a lifecycle mutation from a foreign project', async () => {
|
|
112
|
+
const s = await setup();
|
|
113
|
+
try {
|
|
114
|
+
const started = await s.call(request), before = canonicalRuns(s.root)[0];
|
|
115
|
+
const foreign = { ...s.ctx, directory: path.join(s.root, 'B'), worktree: path.join(s.root, 'B') };
|
|
116
|
+
assert.equal((await s.call({ action: 'cancel', runId: started.runId }, foreign)).state, 'NO_RUN');
|
|
117
|
+
assert.deepEqual(canonicalRuns(s.root)[0], before);
|
|
118
|
+
} finally { fs.rmSync(s.root, { recursive: true, force: true }); }
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('an interrupted index write reports failure and does not lose the canonical run on retry', async () => {
|
|
122
|
+
const s = await setup(), original = fs.renameSync;
|
|
123
|
+
try {
|
|
124
|
+
fs.renameSync = function(from, to, ...rest) {
|
|
125
|
+
if (String(to) === path.join(s.state, 'projects.json')) throw Object.assign(new Error('isolated index failure'), { code: 'EACCES' });
|
|
126
|
+
return original.call(this, from, to, ...rest);
|
|
127
|
+
};
|
|
128
|
+
const first = await s.call(request);
|
|
129
|
+
assert.equal(first.error, 'ROUTING_STORE_ERROR');
|
|
130
|
+
fs.renameSync = original;
|
|
131
|
+
const runId = canonicalRuns(s.root)[0].runId;
|
|
132
|
+
const fresh = { ...s.ctx, sessionID: 'fresh' };
|
|
133
|
+
assert.equal((await s.call(request, fresh)).error, 'EXISTING_RUN');
|
|
134
|
+
assert.equal((await s.call({ action: 'resume', runId }, fresh)).resumed, true);
|
|
135
|
+
assert.equal(canonicalRuns(s.root).length, 1);
|
|
136
|
+
} finally { fs.renameSync = original; fs.rmSync(s.root, { recursive: true, force: true }); }
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('a corrupt canonical record is not treated as absence and replaced by a new run', async () => {
|
|
140
|
+
const s = await setup();
|
|
141
|
+
try {
|
|
142
|
+
const started = await s.call(request);
|
|
143
|
+
const file = path.join(s.state, 'state', C.stateKey(C.projectIdentity(s.dir), started.runId), 'run.json');
|
|
144
|
+
fs.writeFileSync(file, '{ preserved canonical corruption');
|
|
145
|
+
const result = await s.call(request, { ...s.ctx, sessionID: 'fresh' });
|
|
146
|
+
assert.equal(result.error, 'ROUTING_STORE_ERROR');
|
|
147
|
+
assert.equal(fs.readFileSync(file, 'utf8'), '{ preserved canonical corruption');
|
|
148
|
+
assert.equal(fs.readdirSync(path.join(s.state, 'state')).length, 1);
|
|
149
|
+
} finally { fs.rmSync(s.root, { recursive: true, force: true }); }
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('an existing routing lock is preserved on bounded contention and an exact retry succeeds after release', async () => {
|
|
153
|
+
const s = await setup();
|
|
154
|
+
const lock = path.join(s.state, 'ROUTING.lock'), bytes = JSON.stringify({ pid: process.pid, token: 'other-owner' });
|
|
155
|
+
try {
|
|
156
|
+
fs.writeFileSync(lock, bytes);
|
|
157
|
+
const result = await s.call(request);
|
|
158
|
+
assert.equal(result.error, 'ROUTING_BUSY');
|
|
159
|
+
assert.equal(fs.readFileSync(lock, 'utf8'), bytes);
|
|
160
|
+
assert.equal(canonicalRuns(s.root).length, 0);
|
|
161
|
+
fs.unlinkSync(lock); // fixture owner releases its own lock, never a guessed production PID
|
|
162
|
+
assert.ok((await s.call(request)).runId);
|
|
163
|
+
assert.equal(fs.existsSync(lock), false);
|
|
164
|
+
} finally { fs.rmSync(s.root, { recursive: true, force: true }); }
|
|
165
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Independent receipt auditor for a tracked run. The supervisor must not trust "PASS" at face value,
|
|
3
|
+
// but must also not cry wolf: this run's receipts span two schema eras, so the auditor is explicitly
|
|
4
|
+
// schema-aware. Fields that simply did not exist in older receipts are reported as era notes, never
|
|
5
|
+
// as fabrication findings.
|
|
6
|
+
//
|
|
7
|
+
// Checks that can prove a receipt was produced by a real declared-check execution:
|
|
8
|
+
// - command matches the declared check command in checkCatalogue (no substituted command)
|
|
9
|
+
// - contractHash matches the run contract
|
|
10
|
+
// - PASS implies exitCode 0, no termination reason and no execution error
|
|
11
|
+
// - timestamps are ordered and inside the run's lifetime
|
|
12
|
+
// - sourceFingerprint present (freshness is judged against the current fingerprint separately)
|
|
13
|
+
// Checks that only apply when the receipt schema has the field:
|
|
14
|
+
// - receiptId uniqueness, executionToken presence
|
|
15
|
+
//
|
|
16
|
+
// usage: node audit-receipts.mjs [--state-dir DIR] [--run RUN_ID] [--baseline N] [--json]
|
|
17
|
+
// exit: 0 clean/review, 1 suspect (a HIGH finding)
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
|
|
22
|
+
const argv = process.argv.slice(2);
|
|
23
|
+
const flag = (n, d) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : d; };
|
|
24
|
+
const STATE_DIR = flag("--state-dir", path.join(process.env.HOME, ".local/state/opencode-longrun/v1"));
|
|
25
|
+
const RUN_ID = flag("--run", "");
|
|
26
|
+
if (!RUN_ID) { console.error("usage: audit-receipts.mjs --run RUN_ID [--state-dir DIR] [--baseline N] [--json]"); process.exit(2); }
|
|
27
|
+
const BASELINE = Number(flag("--baseline", 0));
|
|
28
|
+
|
|
29
|
+
function findRun(stateDir, runId) {
|
|
30
|
+
const root = path.join(stateDir, "state");
|
|
31
|
+
for (const name of fs.readdirSync(root)) {
|
|
32
|
+
if (!/^[a-f0-9]{32}$/.test(name)) continue;
|
|
33
|
+
const file = path.join(root, name, "run.json");
|
|
34
|
+
try { const run = JSON.parse(fs.readFileSync(file, "utf8")); if (run.runId === runId) return { run, file }; } catch {}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const found = findRun(STATE_DIR, RUN_ID);
|
|
40
|
+
if (!found) { console.error("run not found"); process.exit(2); }
|
|
41
|
+
const { run, file } = found;
|
|
42
|
+
const catalogue = run.checkCatalogue || {};
|
|
43
|
+
const receipts = run.receipts || [];
|
|
44
|
+
const fresh = receipts.slice(BASELINE);
|
|
45
|
+
|
|
46
|
+
const findings = [];
|
|
47
|
+
const add = (severity, checkId, receiptId, message, extra = {}) =>
|
|
48
|
+
findings.push({ severity, checkId: checkId ?? null, receiptId: receiptId ?? null, message, ...extra });
|
|
49
|
+
|
|
50
|
+
// ---- schema era detection (once) -----------------------------------------------------------------
|
|
51
|
+
const hasIds = receipts.some(r => r && (r.receiptId || r.id));
|
|
52
|
+
const hasTokens = receipts.some(r => r && r.executionToken);
|
|
53
|
+
const eras = [];
|
|
54
|
+
if (!hasIds) eras.push("no receiptId in any receipt: legacy schema, identity is positional");
|
|
55
|
+
if (!hasTokens) eras.push("no executionToken in any receipt: legacy schema, process provenance is not recorded");
|
|
56
|
+
if (eras.length) add("INFO", null, null, `legacy receipt schema (${eras.length} era notes)`, { eras });
|
|
57
|
+
|
|
58
|
+
// ---- structural integrity -----------------------------------------------------------------------
|
|
59
|
+
const seen = new Map();
|
|
60
|
+
for (const [i, r] of receipts.entries()) {
|
|
61
|
+
if (!r || typeof r !== "object") { add("HIGH", null, null, `receipt #${i} is not an object`); continue; }
|
|
62
|
+
const id = r.receiptId || r.id || null;
|
|
63
|
+
if (id) { if (seen.has(id)) add("HIGH", r.checkId, id, `duplicate receiptId (also receipt #${seen.get(id)})`); else seen.set(id, i); }
|
|
64
|
+
if (Number(r.finishedAt) < Number(r.startedAt)) add("HIGH", r.checkId, id, `finishedAt precedes startedAt (#${i})`);
|
|
65
|
+
if (r.startedAt && run.createdAt && Number(r.startedAt) < Number(run.createdAt))
|
|
66
|
+
add("HIGH", r.checkId, id, `receipt #${i} predates the run's creation (backdated)`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---- per-receipt authenticity -------------------------------------------------------------------
|
|
70
|
+
for (const [i, r] of fresh.entries()) {
|
|
71
|
+
const idx = BASELINE + i;
|
|
72
|
+
const id = r.receiptId || r.id || `#${idx}`;
|
|
73
|
+
const declared = catalogue[r.checkId];
|
|
74
|
+
if (!declared) { add("HIGH", r.checkId, id, "receipt for a check absent from the declared catalogue"); continue; }
|
|
75
|
+
const declaredCmd = (Array.isArray(declared.command) ? declared.command.join(" ") : String(declared.command)).trim();
|
|
76
|
+
const receiptCmd = (Array.isArray(r.argv) ? r.argv.join(" ") : String(r.command ?? "")).trim();
|
|
77
|
+
if (receiptCmd && declaredCmd && receiptCmd !== declaredCmd)
|
|
78
|
+
add("HIGH", r.checkId, id, "receipt command differs from the declared check command (substituted command?)", { declaredCmd, receiptCmd });
|
|
79
|
+
if (r.contractHash && run.contractHash && r.contractHash !== run.contractHash)
|
|
80
|
+
add("HIGH", r.checkId, id, "receipt contractHash differs from the run contract");
|
|
81
|
+
if (r.status === "PASS" && Number(r.exitCode) !== 0) add("HIGH", r.checkId, id, "PASS with a non-zero exit code", { exitCode: r.exitCode });
|
|
82
|
+
if (r.status === "PASS" && r.terminationReason) add("HIGH", r.checkId, id, "PASS with a termination reason", { terminationReason: r.terminationReason });
|
|
83
|
+
if (r.status === "PASS" && r.executionError) add("HIGH", r.checkId, id, "PASS with an execution error", { executionError: r.executionError });
|
|
84
|
+
if (!r.sourceFingerprint) add("MEDIUM", r.checkId, id, "no sourceFingerprint recorded");
|
|
85
|
+
if (r.status === "NOT_RUN" && Number(r.exitCode) === 0)
|
|
86
|
+
add("LOW", r.checkId, id, "NOT_RUN with exit 0: the summary was not recognised, so this does NOT satisfy the criterion");
|
|
87
|
+
if (r.status === "FAIL") add("LOW", r.checkId, id, "historical FAIL receipt retained in the canonical history", { exitCode: r.exitCode });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---- freshness ----------------------------------------------------------------------------------
|
|
91
|
+
const fps = new Map();
|
|
92
|
+
for (const r of fresh) fps.set(r.sourceFingerprint, (fps.get(r.sourceFingerprint) || 0) + 1);
|
|
93
|
+
|
|
94
|
+
const summary = {
|
|
95
|
+
runId: RUN_ID, runFile: file,
|
|
96
|
+
runHash: createHash("sha256").update(fs.readFileSync(file)).digest("hex"),
|
|
97
|
+
status: run.status, totalReceipts: receipts.length, auditedFrom: BASELINE, auditedReceipts: fresh.length,
|
|
98
|
+
schemaEra: { receiptIds: hasIds, executionTokens: hasTokens },
|
|
99
|
+
distinctFingerprints: [...fps.entries()].map(([fingerprint, count]) => ({ fingerprint, count })),
|
|
100
|
+
counts: { PASS: fresh.filter(r => r.status === "PASS").length, FAIL: fresh.filter(r => r.status === "FAIL").length, NOT_RUN: fresh.filter(r => r.status === "NOT_RUN").length },
|
|
101
|
+
findings,
|
|
102
|
+
high: findings.filter(f => f.severity === "HIGH").length,
|
|
103
|
+
medium: findings.filter(f => f.severity === "MEDIUM").length,
|
|
104
|
+
low: findings.filter(f => f.severity === "LOW").length,
|
|
105
|
+
verdict: findings.some(f => f.severity === "HIGH") ? "SUSPECT" : findings.some(f => f.severity === "MEDIUM") ? "REVIEW" : "CLEAN",
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (argv.includes("--json")) process.stdout.write(JSON.stringify(summary, null, 2) + "\n");
|
|
109
|
+
else {
|
|
110
|
+
console.log(`run ${summary.runId} status ${summary.status} receipts ${summary.totalReceipts} (audited from ${BASELINE})`);
|
|
111
|
+
console.log(`schema era: receiptIds=${hasIds} executionTokens=${hasTokens}`);
|
|
112
|
+
console.log(`audited: PASS ${summary.counts.PASS} FAIL ${summary.counts.FAIL} NOT_RUN ${summary.counts.NOT_RUN}`);
|
|
113
|
+
console.log(`verdict: ${summary.verdict} high=${summary.high} medium=${summary.medium} low=${summary.low}`);
|
|
114
|
+
console.log("fingerprints in audited receipts:");
|
|
115
|
+
for (const d of summary.distinctFingerprints) console.log(` ${String(d.fingerprint).slice(0, 16)}… ×${d.count}`);
|
|
116
|
+
for (const f of findings) {
|
|
117
|
+
if (f.severity === "INFO") { console.log(` [INFO] ${f.message}`); for (const e of f.eras || []) console.log(` - ${e}`); continue; }
|
|
118
|
+
console.log(` [${f.severity}] ${f.checkId || "-"} ${f.receiptId || "-"}: ${f.message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
process.exit(summary.verdict === "SUSPECT" ? 1 : 0);
|