@lyupro/codex-bridge 0.6.9 → 0.6.11
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/CHANGELOG.md +132 -0
- package/README.md +15 -4
- package/cli/dispatcher-model-check.mjs +59 -0
- package/cli/doctor-host-side.mjs +2 -1
- package/cli/doctor-installation.mjs +111 -111
- package/cli/doctor.mjs +13 -5
- package/cli/handback-witness-check.mjs +24 -18
- package/cli/hosts.mjs +2 -10
- package/cli/install-owners.mjs +1 -4
- package/cli/install-record.mjs +2 -1
- package/cli/inventory-transition.mjs +1 -1
- package/cli/lifecycle-lock-inspect.mjs +3 -2
- package/cli/lifecycle-lock.mjs +19 -132
- package/cli/manifest.mjs +6 -6
- package/cli/read.mjs +20 -0
- package/cli/settings-merge.mjs +3 -29
- package/package.json +2 -2
- package/scripts/home-write-inventory.mjs +12 -2
- package/src/agents/codex-advisor.md +37 -121
- package/src/agents/codex-build.md +27 -135
- package/src/agents/codex-review.md +19 -111
- package/src/agents/codex-scout.md +21 -130
- package/src/home/hooks/dispatcher-gate.mjs +14 -9
- package/src/home/hooks/live-runs.mjs +0 -41
- package/src/home/hooks/order-gate.mjs +80 -47
- package/src/home/hooks/reply-guard.mjs +52 -65
- package/src/home/hooks/reply-identity.mjs +64 -0
- package/src/home/hooks/reply-parser.mjs +1 -1
- package/src/home/hooks/reply-verdicts.mjs +31 -27
- package/src/home/hooks/reply-witness.mjs +48 -0
- package/src/home/lib/claude-layout.mjs +14 -0
- package/src/home/lib/command-path.mjs +32 -0
- package/src/home/lib/dispatcher-call.mjs +171 -0
- package/src/home/lib/dispatcher-command.mjs +18 -54
- package/src/home/lib/dispatcher-gate.mjs +4 -0
- package/src/home/lib/dispatcher-model.mjs +112 -0
- package/src/home/lib/dispatcher-order.mjs +21 -22
- package/src/home/lib/dispatcher-pin.mjs +60 -0
- package/src/home/lib/dispatcher-protocol.mjs +91 -0
- package/src/home/lib/dispatcher-stop.mjs +11 -2
- package/src/home/lib/handback-witness.mjs +137 -48
- package/src/home/lib/home-registry.mjs +7 -0
- package/src/home/lib/install-owner-roots.mjs +53 -0
- package/src/home/lib/kernel-lock.mjs +171 -0
- package/src/home/lib/meta/chain.mjs +19 -8
- package/src/home/lib/meta/context-only.mjs +43 -0
- package/src/home/lib/meta/events.mjs +14 -2
- package/src/home/lib/meta/launch-rows.mjs +23 -0
- package/src/home/lib/meta/paths.mjs +1 -1
- package/src/home/lib/meta/reply.mjs +39 -4
- package/src/home/lib/meta/run-state.mjs +3 -0
- package/src/home/lib/meta/verdict.mjs +16 -8
- package/src/home/lib/model-family.mjs +38 -0
- package/src/home/lib/observation-ledger.mjs +145 -0
- package/src/home/lib/order-schema.mjs +204 -0
- package/src/home/lib/order-values.mjs +46 -0
- package/src/home/lib/run-codex.mjs +6 -3
- package/src/home/lib/runner/advise-carry.mjs +2 -1
- package/src/home/lib/runner/args.mjs +140 -220
- package/src/home/lib/runner/attach.mjs +24 -17
- package/src/home/lib/runner/build-evidence.mjs +29 -0
- package/src/home/lib/runner/codex-availability.mjs +50 -0
- package/src/home/lib/runner/codex-cmd.mjs +2 -19
- package/src/home/lib/runner/continuation.mjs +174 -140
- package/src/home/lib/runner/exit-codes.mjs +13 -0
- package/src/home/lib/runner/flag-baseline.mjs +105 -0
- package/src/home/lib/runner/flag-scan.mjs +165 -0
- package/src/home/lib/runner/git-state.mjs +0 -33
- package/src/home/lib/runner/launcher.mjs +237 -277
- package/src/home/lib/runner/order-claim.mjs +84 -0
- package/src/home/lib/runner/order-options.mjs +30 -0
- package/src/home/lib/runner/order-owner.mjs +19 -0
- package/src/home/lib/runner/pass-gate.mjs +111 -0
- package/src/home/lib/runner/preflight.mjs +26 -28
- package/src/home/lib/runner/prompts.mjs +7 -2
- package/src/home/lib/runner/question-kind.mjs +41 -0
- package/src/home/lib/runner/run-env.mjs +2 -1
- package/src/home/lib/runner/sandbox-probe.mjs +1 -1
- package/src/home/lib/runner/scope-check.mjs +31 -12
- package/src/home/lib/runner/task-input.mjs +34 -12
- package/src/home/lib/runner/worker-admission.mjs +43 -0
- package/src/home/lib/runner/worker.mjs +19 -18
- package/src/home/lib/subagent-model-env.mjs +44 -0
- package/src/home/lib/task-header.mjs +158 -0
- package/src/home/lib/untyped-stop.mjs +30 -0
- package/src/home/lib/write-meta.mjs +17 -5
- package/src/home/lib/required-inputs.mjs +0 -261
- /package/{cli → src/home/lib}/frontmatter.mjs +0 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns only the dispatcher protocol text shared by every dispatcher prompt.
|
|
3
|
+
* Plan_63 D11: four hand-written copies drifted; build even requested a continuation
|
|
4
|
+
* flag absent from the canonical command. Render the command from its own assembler.
|
|
5
|
+
*/
|
|
6
|
+
import { renderRunCommandTemplate } from './dispatcher-command.mjs';
|
|
7
|
+
import { TASK_FILE_INPUT } from './dispatcher-call.mjs';
|
|
8
|
+
|
|
9
|
+
export function renderDispatcherProtocol(agentType) {
|
|
10
|
+
const command = renderRunCommandTemplate(agentType);
|
|
11
|
+
return `## Required dispatcher input
|
|
12
|
+
|
|
13
|
+
The call is exactly one line: \`${TASK_FILE_INPUT.label}: <task-file path from the orchestrator>\`.
|
|
14
|
+
The path must be absolute and name a file the orchestrator wrote with its file tool.
|
|
15
|
+
The dispatcher never creates, reads or rewrites it: writing it from the shell brings back
|
|
16
|
+
the permission prompt this file channel exists to remove.
|
|
17
|
+
The whole order — order id, grants and every other value — lives in that file's header.
|
|
18
|
+
The runner checks the header, never the dispatcher. The dispatcher never adds, removes or
|
|
19
|
+
reorders anything on the command line because of what the call or the file says.
|
|
20
|
+
No path in the call means run nothing and return
|
|
21
|
+
\`FAIL — could not start the Codex run: no task file in the call\`, the form the no-self-execution rule gives.
|
|
22
|
+
|
|
23
|
+
## When the host refuses the command
|
|
24
|
+
|
|
25
|
+
If the host refuses to run \`codex-bridge run\` — a permission prompt, a classifier denial, anything
|
|
26
|
+
that stops the command — that refusal is your final answer. Report \`FAIL\`, name the task file from the call,
|
|
27
|
+
and state the one correction: the operator runs \`codex-bridge install\`, which grants the permission
|
|
28
|
+
rule this package needs.
|
|
29
|
+
|
|
30
|
+
You are forbidden to look for a way around it. Specifically, and without exception:
|
|
31
|
+
|
|
32
|
+
- never call \`run-codex.mjs\`, or any file inside the installed package, by path;
|
|
33
|
+
- never start the runner through \`node\`, \`npx\`, \`sh\`, \`bash\` or any other interpreter;
|
|
34
|
+
- never retry the same call in PowerShell because Bash refused it, or the reverse;
|
|
35
|
+
- never split the call over more than one line, and never add a pipe, a semicolon or a redirect;
|
|
36
|
+
- never advise the operator to grant a permission rule on an internal file — a rule on anything but
|
|
37
|
+
the package command undoes the very design that makes this call permission-stable.
|
|
38
|
+
|
|
39
|
+
Every one of those forms was removed on purpose: a host matches a permission rule against the
|
|
40
|
+
beginning of the final command line, so an interpreter, a path or a continuation makes the call
|
|
41
|
+
unmatchable by construction. Reaching for one does not rescue the run; it guarantees the refusal
|
|
42
|
+
and asks the operator to make it permanent. On 2026-08-15 an order in another repository did all
|
|
43
|
+
three in sequence and ended by telling its operator to grant a rule on \`run-codex.mjs\`.
|
|
44
|
+
|
|
45
|
+
## The only thing you do
|
|
46
|
+
|
|
47
|
+
\`\`\`bash
|
|
48
|
+
${command}
|
|
49
|
+
\`\`\`
|
|
50
|
+
|
|
51
|
+
Replace the placeholder with the path from the call, nothing else.
|
|
52
|
+
The first call starts the run and returns at once with \`RUN=<path> order-id=<id>\` and a
|
|
53
|
+
\`STARTED\` line. To get the verdict, run the identical command again. That call does not start
|
|
54
|
+
a second run or cost quota: it attaches to the run already in flight, prints
|
|
55
|
+
\`ATTACH=<path> order-id=<id> started=<time>\`, blocks until the verdict exists and prints it.
|
|
56
|
+
Give the attaching call \`timeout: 1800000\` (30 minutes). A real run takes 20-25 minutes,
|
|
57
|
+
which is normal, not a hang. If the ceiling kills it, run the identical command again:
|
|
58
|
+
it attaches to the same run and keeps waiting.
|
|
59
|
+
|
|
60
|
+
Background execution (\`run_in_background\`, \`&\`, \`nohup\`) is prohibited, and so is inventing
|
|
61
|
+
a report from memory. Never add a flag: the host allows exactly the one canonical command
|
|
62
|
+
and refuses every other.
|
|
63
|
+
|
|
64
|
+
A runner refusal — an order id collision, a header problem or a grant problem — is the whole
|
|
65
|
+
answer: return it as \`FAIL\` with the runner's text. Never retry with a changed command or another
|
|
66
|
+
task file. The next pass is assigned only by the orchestrator, never chosen by you.
|
|
67
|
+
The order id in the task-file header is issued by the orchestrator and is what makes a repeat
|
|
68
|
+
harmless; a changed task file or command turns a repeat into a second paid run.
|
|
69
|
+
On 2026-08-03 one order became six runs exactly this way, and four of them were never accounted for
|
|
70
|
+
at all. A second writing run in one worktree is refused anyway — the tree is shared and has no
|
|
71
|
+
isolation.
|
|
72
|
+
|
|
73
|
+
**Your response = the exact stdout of the attaching call**: the \`ATTACH=<path>\` line and the status
|
|
74
|
+
block below it. Do not add or remove anything: no preamble, explanations, or retelling of the diff.
|
|
75
|
+
The report is in \`report.md\`; the orchestrator will read it. Do not make commits: that is the
|
|
76
|
+
operator's decision. The \`STARTED\` output of the first call is not a result and is never the response.
|
|
77
|
+
When the first call prints no \`RUN=\` line — a runner refusal or an \`UNAVAILABLE\` block, with no run
|
|
78
|
+
folder created — there is nothing to attach to: that output, verbatim, is the whole response, and
|
|
79
|
+
the identical command is not run again.
|
|
80
|
+
|
|
81
|
+
The only allowed final response is this exact stdout. Wording such as "the run has started,
|
|
82
|
+
waiting for completion," "I will wait for a notification," or "Monitor started in the background" is
|
|
83
|
+
prohibited in any form: the subagent ceases to exist immediately after responding, nobody can wait,
|
|
84
|
+
and the orchestrator gets a promise instead of a result. A real case: the dispatcher gave exactly
|
|
85
|
+
such a response during a live run — the worktree remained busy, and this could only be discovered
|
|
86
|
+
by checking \`status.json\` manually.
|
|
87
|
+
Inventing any outcome the runner did not print is equally prohibited. On 2026-08-13 a dispatcher said
|
|
88
|
+
\`FAIL — could not get the Codex run result because of an architectural environment limitation\` while
|
|
89
|
+
that run's \`status.json\` already said \`state=finished\`, \`status=OK\`.
|
|
90
|
+
`;
|
|
91
|
+
}
|
|
@@ -12,21 +12,30 @@ export function transcriptToolUses(transcriptPath) {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
const toolUses = [];
|
|
15
|
+
let complete = true;
|
|
15
16
|
for (const line of source.split(/\r?\n/)) {
|
|
17
|
+
if (!line.trim()) continue;
|
|
16
18
|
let record;
|
|
17
19
|
try {
|
|
18
20
|
record = parseJsonText(transcriptPath, line);
|
|
19
21
|
} catch {
|
|
22
|
+
complete = false;
|
|
20
23
|
continue;
|
|
21
24
|
}
|
|
22
25
|
if (record?.type !== 'assistant' && record?.message?.role !== 'assistant') continue;
|
|
23
26
|
const content = record.message?.content;
|
|
24
27
|
if (!Array.isArray(content)) continue;
|
|
25
28
|
for (const block of content) {
|
|
26
|
-
if (block?.type === 'tool_use')
|
|
29
|
+
if (block?.type === 'tool_use') {
|
|
30
|
+
const toolUse = { id: block.id, name: block.name };
|
|
31
|
+
if (block.name === 'Bash' && typeof block.input?.command === 'string') {
|
|
32
|
+
toolUse.command = block.input.command;
|
|
33
|
+
}
|
|
34
|
+
toolUses.push(toolUse);
|
|
35
|
+
}
|
|
27
36
|
}
|
|
28
37
|
}
|
|
29
|
-
return toolUses;
|
|
38
|
+
return { toolUses, complete };
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
export function decideDispatcherStop({ state, toolUses }) {
|
|
@@ -1,73 +1,162 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/** records whether hosts and dispatchers keep the handback contract, per cause, over the observation ledger
|
|
2
|
+
* Plan_67 D8 cures the eternal-warning defect with cause-specific evidence; D9 keeps old untyped
|
|
3
|
+
* alarms without dispatcher evidence in legacy history, never as confirmed violations.
|
|
4
|
+
*/
|
|
3
5
|
import path from 'node:path';
|
|
4
|
-
import { writeHomeJsonAtomic } from './atomic-json.mjs';
|
|
5
|
-
import { parseJsonText } from './json-file.mjs';
|
|
6
|
-
import { withHomeFileLock } from './file-lock.mjs';
|
|
7
|
-
import { stateDirWriter } from './home-write.mjs';
|
|
8
6
|
import { transcriptHostVersion } from './host-version.mjs';
|
|
7
|
+
import {
|
|
8
|
+
emptyLedger, normalizeLedger, readLedgerFile, reduceObservation, updateLedgerFile,
|
|
9
|
+
} from './observation-ledger.mjs';
|
|
9
10
|
|
|
10
11
|
export const WITNESS_FILE = 'handback-witness.json';
|
|
12
|
+
export const WITNESS_CAUSES = Object.freeze(['missing-agent-type', 'missing-ids', 'tools-outside-gate']);
|
|
11
13
|
|
|
12
14
|
export async function witnessHostVersion(payload) {
|
|
13
15
|
return transcriptHostVersion(payload?.transcript_path);
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
18
|
+
const object = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
19
|
+
const hostMap = (value) => object(value) && Object.values(value).every((at) => typeof at === 'string');
|
|
20
|
+
const alarmEntry = (entry) => object(entry)
|
|
21
|
+
&& (entry.hostVersion === null || typeof entry.hostVersion === 'string')
|
|
22
|
+
&& typeof entry.at === 'string' && typeof entry.detail === 'string';
|
|
23
|
+
|
|
24
|
+
function emptyWitness() {
|
|
25
|
+
return { version: 2, ledger: emptyLedger(), intercepted: {}, legacy: [] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function witnessKey({ cause, hostVersion, agentType }) {
|
|
29
|
+
if (!WITNESS_CAUSES.includes(cause)) throw new TypeError('cause must be a witness cause');
|
|
30
|
+
if (hostVersion != null && typeof hostVersion !== 'string') {
|
|
31
|
+
throw new TypeError('hostVersion must be a string or null');
|
|
32
|
+
}
|
|
33
|
+
const host = hostVersion || 'unknown';
|
|
34
|
+
if (cause === 'missing-agent-type') return `${host}|${cause}`;
|
|
35
|
+
if (typeof agentType !== 'string' || agentType.length === 0) {
|
|
36
|
+
throw new TypeError('agentType is required for this witness cause');
|
|
37
|
+
}
|
|
38
|
+
return `${host}|${agentType}|${cause}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Preserve the previous normalizer's accepted host-map and SDK shapes. SDK versions are not hosts.
|
|
42
|
+
function normalizeOldWitness(record) {
|
|
43
|
+
if (!object(record) || !Array.isArray(record.alarms)) return null;
|
|
44
|
+
if (record.alarms.every(alarmEntry) && hostMap(record.lastSeen)
|
|
45
|
+
&& !Object.hasOwn(record.lastSeen, 'sdkVersion') && !Object.hasOwn(record.lastSeen, 'at')) return record;
|
|
46
|
+
const legacy = record.lastSeen === null || (object(record.lastSeen)
|
|
47
|
+
&& typeof record.lastSeen.sdkVersion === 'string' && typeof record.lastSeen.at === 'string');
|
|
48
|
+
const legacyAlarm = (entry) => object(entry)
|
|
27
49
|
&& (entry.sdkVersion === null || typeof entry.sdkVersion === 'string') && typeof entry.at === 'string'
|
|
28
50
|
&& typeof entry.detail === 'string';
|
|
29
|
-
if (legacy && record.alarms.every(legacyAlarm))
|
|
51
|
+
if (legacy && record.alarms.every(legacyAlarm)) {
|
|
52
|
+
return { lastSeen: {}, alarms: record.alarms.map(({ at, detail }) => ({ hostVersion: null, at, detail })) };
|
|
53
|
+
}
|
|
30
54
|
return null;
|
|
31
55
|
}
|
|
32
56
|
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
57
|
+
function normalizeWitnessLedger(parsed) {
|
|
58
|
+
const ledger = normalizeLedger(parsed);
|
|
59
|
+
if (!ledger) return null;
|
|
60
|
+
for (const entry of Object.values(ledger.entries)) {
|
|
61
|
+
for (const observation of [entry.lastObservation, entry.lastViolation, entry.lastMatch, ...entry.history]) {
|
|
62
|
+
if (observation === null) continue;
|
|
63
|
+
const data = observation.data;
|
|
64
|
+
if (!data || Object.getPrototypeOf(data) !== Object.prototype || !WITNESS_CAUSES.includes(data.cause)
|
|
65
|
+
|| !(data.hostVersion === null || typeof data.hostVersion === 'string')
|
|
66
|
+
|| !(data.agentType === null || typeof data.agentType === 'string')) return null;
|
|
67
|
+
}
|
|
41
68
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
69
|
+
return ledger;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function migrateWitness(parsed) {
|
|
73
|
+
if (object(parsed) && Object.hasOwn(parsed, 'version')) {
|
|
74
|
+
return parsed.version === 2 && normalizeWitnessLedger(parsed.ledger) && hostMap(parsed.intercepted)
|
|
75
|
+
&& Array.isArray(parsed.legacy) && parsed.legacy.every((entry) => alarmEntry(entry)
|
|
76
|
+
&& ['legacy-untyped-unverified', 'unclassified'].includes(entry.disposition)) ? parsed : null;
|
|
77
|
+
}
|
|
78
|
+
const old = normalizeOldWitness(parsed);
|
|
79
|
+
if (!old) return null;
|
|
80
|
+
const record = { ...emptyWitness(), intercepted: { ...old.lastSeen } };
|
|
81
|
+
const alarms = [...old.alarms].sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
|
|
82
|
+
for (const { at, hostVersion, detail } of alarms) {
|
|
83
|
+
if (/^host omitted agent_type for agent \S+$/.test(detail)) {
|
|
84
|
+
// D9: old stops lack dispatcher evidence; reclassification must not manufacture a violation.
|
|
85
|
+
record.legacy.push({ at, hostVersion, detail, disposition: 'legacy-untyped-unverified' });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const ids = /^host omitted session_id or agent_id for (\S+)$/.exec(detail);
|
|
89
|
+
const tools = /^(\S+) (\S+): (.+) outside the dispatcher gate$/.exec(detail);
|
|
90
|
+
if (!ids && !tools) {
|
|
91
|
+
record.legacy.push({ at, hostVersion, detail, disposition: 'unclassified' });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const cause = ids ? 'missing-ids' : 'tools-outside-gate';
|
|
95
|
+
const agentType = (ids || tools)[1];
|
|
96
|
+
try {
|
|
97
|
+
record.ledger = reduceObservation(record.ledger, {
|
|
98
|
+
key: witnessKey({ cause, hostVersion, agentType }), verdict: 'violation', at, detail,
|
|
99
|
+
data: { cause, hostVersion, agentType },
|
|
100
|
+
});
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
47
104
|
}
|
|
105
|
+
return record;
|
|
48
106
|
}
|
|
49
107
|
|
|
50
|
-
|
|
51
|
-
|
|
108
|
+
// readLedgerFile returns an empty ledger for ENOENT, even when its normalizer is an adapter.
|
|
109
|
+
function witnessRecord(record) {
|
|
110
|
+
return record.version !== 2 && record.format === 1 ? emptyWitness() : record;
|
|
52
111
|
}
|
|
53
112
|
|
|
113
|
+
export function readHandbackWitness({ stateDir }) {
|
|
114
|
+
return witnessRecord(readLedgerFile(path.join(stateDir, WITNESS_FILE), { normalize: migrateWitness }));
|
|
115
|
+
}
|
|
54
116
|
|
|
55
|
-
|
|
56
|
-
if (kind !== 'seen' && kind !== 'alarm') throw new TypeError("kind must be 'seen' or 'alarm'");
|
|
117
|
+
function validateHost(hostVersion) {
|
|
57
118
|
if (hostVersion !== null && typeof hostVersion !== 'string') {
|
|
58
119
|
throw new TypeError('hostVersion must be a string or null');
|
|
59
120
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function recordWitnessObservation({
|
|
124
|
+
stateDir, cause, hostVersion, agentType = null, verdict, detail = '', now = new Date(),
|
|
125
|
+
}) {
|
|
126
|
+
return recordWitnessObservations({
|
|
127
|
+
stateDir, observations: [{ cause, hostVersion, agentType, verdict, detail }], now,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function recordWitnessObservations({ stateDir, observations, now = new Date() }) {
|
|
132
|
+
const prepared = observations.map(({ cause, hostVersion, agentType = null, verdict, detail = '' }) => {
|
|
133
|
+
validateHost(hostVersion);
|
|
134
|
+
const key = witnessKey({ cause, hostVersion, agentType });
|
|
135
|
+
if (agentType !== null && (typeof agentType !== 'string' || agentType.length === 0)) {
|
|
136
|
+
throw new TypeError('agentType must be a non-empty string or null');
|
|
137
|
+
}
|
|
138
|
+
return { key, verdict, detail, data: { cause, hostVersion, agentType } };
|
|
139
|
+
});
|
|
140
|
+
const at = new Date(now).toISOString();
|
|
141
|
+
return updateLedgerFile({
|
|
142
|
+
stateDir, id: 'handback-witness', file: path.join(stateDir, WITNESS_FILE), normalize: migrateWitness,
|
|
143
|
+
update: (current) => {
|
|
144
|
+
const record = witnessRecord(current);
|
|
145
|
+
return { ...record, ledger: prepared.reduce((ledger, observation) =>
|
|
146
|
+
reduceObservation(ledger, { ...observation, at }), record.ledger) };
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function recordInterceptedAttempt({ stateDir, hostVersion, now = new Date() }) {
|
|
152
|
+
validateHost(hostVersion);
|
|
153
|
+
const at = new Date(now).toISOString();
|
|
154
|
+
return updateLedgerFile({
|
|
155
|
+
stateDir, id: 'handback-witness', file: path.join(stateDir, WITNESS_FILE), normalize: migrateWitness,
|
|
156
|
+
update: (current) => {
|
|
157
|
+
const record = witnessRecord(current);
|
|
158
|
+
// D8: even denied handbacks are intercepted, so this timestamp is never match evidence.
|
|
159
|
+
return { ...record, intercepted: { ...record.intercepted, [hostVersion || 'unknown']: at } };
|
|
160
|
+
},
|
|
161
|
+
});
|
|
73
162
|
}
|
|
@@ -83,6 +83,13 @@ const entries = [
|
|
|
83
83
|
primary: ['state/handback-witness.json'],
|
|
84
84
|
sides: ['lock', 'atomic-temporary'],
|
|
85
85
|
},
|
|
86
|
+
{
|
|
87
|
+
id: 'dispatcher-model',
|
|
88
|
+
removal: 'purge-only',
|
|
89
|
+
consequence: 'Doctor forgets which model each dispatcher type was last observed on and every model violation it recorded.',
|
|
90
|
+
primary: ['state/dispatcher-model.json'],
|
|
91
|
+
sides: ['lock', 'atomic-temporary'],
|
|
92
|
+
},
|
|
86
93
|
{
|
|
87
94
|
id: 'host-observations',
|
|
88
95
|
removal: 'purge-only',
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads which host roots own this home from the installation record, read-only, for hooks.
|
|
3
|
+
* Plan_67 D10 needs every recorded owner; the home image has no cli/ modules.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { homeArtifact } from './home-registry.mjs';
|
|
8
|
+
import { parseJsonText } from './json-file.mjs';
|
|
9
|
+
|
|
10
|
+
const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
11
|
+
|
|
12
|
+
export function isFormat2(parsed) {
|
|
13
|
+
return isObject(parsed) && parsed.format === 2;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readOwnerRoots({ brandRoot }) {
|
|
17
|
+
const file = path.join(brandRoot, homeArtifact('install-record').primary[0]);
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return error.code === 'ENOENT'
|
|
23
|
+
? { problem: 'missing', detail: `Installation record at ${file} is missing.` }
|
|
24
|
+
: { problem: 'unreadable', detail: `Installation record at ${file} cannot be read.` };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = parseJsonText(file, raw);
|
|
30
|
+
} catch {
|
|
31
|
+
return { problem: 'unreadable', detail: `Installation record at ${file} contains invalid JSON.` };
|
|
32
|
+
}
|
|
33
|
+
if (!isFormat2(parsed)) {
|
|
34
|
+
return { problem: 'not-format-2', detail: `Installation record at ${file} is not format 2.` };
|
|
35
|
+
}
|
|
36
|
+
if (parsed.inventory !== 'complete') {
|
|
37
|
+
return { problem: 'inventory-incomplete', detail: `Installation record at ${file} has an incomplete inventory.` };
|
|
38
|
+
}
|
|
39
|
+
if (Object.hasOwn(parsed, 'legacy')) {
|
|
40
|
+
return { problem: 'legacy-partition', detail: `Installation record at ${file} contains a legacy partition.` };
|
|
41
|
+
}
|
|
42
|
+
if (!isObject(parsed.owners) || Object.keys(parsed.owners).length === 0) {
|
|
43
|
+
return { problem: 'no-owners', detail: `Installation record at ${file} has no owners.` };
|
|
44
|
+
}
|
|
45
|
+
const roots = [];
|
|
46
|
+
for (const owner of Object.values(parsed.owners)) {
|
|
47
|
+
if (typeof owner?.root !== 'string' || owner.root.trim().length === 0) {
|
|
48
|
+
return { problem: 'malformed-owner', detail: `Installation record at ${file} has an owner without a non-empty root.` };
|
|
49
|
+
}
|
|
50
|
+
roots.push(owner.root);
|
|
51
|
+
}
|
|
52
|
+
return { roots: roots.sort() };
|
|
53
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locks the kernel releases on its holder's death, shared by the lifecycle lock and the order claim.
|
|
3
|
+
* Plan_60 D4: on 2026-09-24 two launchers with one order id started 2.8 seconds apart and both ran
|
|
4
|
+
* and billed; the runner needs the lifecycle lock's primitive and may not import `cli/`.
|
|
5
|
+
* Where the kernel can release a lock on the holder's death, the lock is a kernel object: a named
|
|
6
|
+
* pipe on Windows, an abstract socket on Linux — both reproduced on real machines (hard kill, name
|
|
7
|
+
* free at once, nothing on disk), which matters on a machine that lost power six times in two weeks.
|
|
8
|
+
* The name comes from the directory's dev+ino, not its path: aliases must reach the same claim.
|
|
9
|
+
* A second listen inside the holder must fail, or the platform did not give exclusivity and the
|
|
10
|
+
* call refuses rather than switch protocols (Plan_65 D5/D8). What the holder answers a waiter is
|
|
11
|
+
* diagnosis only — it never grants anything.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import net from 'node:net';
|
|
16
|
+
|
|
17
|
+
const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
18
|
+
|
|
19
|
+
export function kernelLockStrategy(platform = process.platform) {
|
|
20
|
+
if (platform === 'win32') return 'named-pipe';
|
|
21
|
+
return platform === 'linux' ? 'abstract-socket' : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function kernelLockAddress(purpose, digest, platform = process.platform) {
|
|
25
|
+
if (typeof purpose !== 'string' || !/^[a-z][a-z-]*$/.test(purpose)) {
|
|
26
|
+
throw new TypeError('purpose must match ^[a-z][a-z-]*$');
|
|
27
|
+
}
|
|
28
|
+
if (platform === 'win32') return `\\\\.\\pipe\\codex-bridge-${purpose}-${digest}`;
|
|
29
|
+
return platform === 'linux' ? `\0codex-bridge-${purpose}-${digest}` : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function directoryDigest(directory, { suffix } = {}) {
|
|
33
|
+
try {
|
|
34
|
+
const stat = fs.statSync(directory, { bigint: true });
|
|
35
|
+
if (!stat.isDirectory()) throw new Error(`${directory} is not a directory`);
|
|
36
|
+
const identity = `${stat.dev}:${stat.ino}`;
|
|
37
|
+
const extra = typeof suffix === 'string' && suffix.length > 0 ? `:${suffix}` : '';
|
|
38
|
+
return createHash('sha256').update(identity + extra).digest('hex');
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error?.code === 'ENOENT') return null;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function listen(server, address) {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const listening = () => { server.removeListener('error', failed); resolve(); };
|
|
48
|
+
const failed = (error) => { server.removeListener('listening', listening); reject(error); };
|
|
49
|
+
server.once('listening', listening);
|
|
50
|
+
server.once('error', failed);
|
|
51
|
+
try {
|
|
52
|
+
server.listen(address);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
server.removeListener('listening', listening);
|
|
55
|
+
server.removeListener('error', failed);
|
|
56
|
+
reject(error);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function closeServer(server) {
|
|
62
|
+
if (!server) return Promise.resolve();
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
try {
|
|
65
|
+
server.close((error) => {
|
|
66
|
+
if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') reject(error);
|
|
67
|
+
else resolve();
|
|
68
|
+
});
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error.code === 'ERR_SERVER_NOT_RUNNING') resolve();
|
|
71
|
+
else reject(error);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function closeQuietly(server) {
|
|
77
|
+
try { await closeServer(server); } catch { /* Preserve the original failure. */ }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function selfCheck(server, address, createServer, label) {
|
|
81
|
+
let probe;
|
|
82
|
+
let detail;
|
|
83
|
+
try {
|
|
84
|
+
probe = createServer();
|
|
85
|
+
await listen(probe, address);
|
|
86
|
+
detail = 'a second listener succeeded';
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (!error || error.code !== 'EADDRINUSE') detail = (error && (error.code || error.message)) || String(error);
|
|
89
|
+
}
|
|
90
|
+
if (detail === undefined) {
|
|
91
|
+
await closeQuietly(probe);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
await Promise.all([closeQuietly(probe), closeQuietly(server)]);
|
|
95
|
+
throw new Error(`${label} self-check failed: ${detail}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function tryHoldSocket(address, createServer = net.createServer, label = 'kernel lock') {
|
|
99
|
+
const server = createServer((socket) => socket.destroy());
|
|
100
|
+
try {
|
|
101
|
+
await listen(server, address);
|
|
102
|
+
await selfCheck(server, address, createServer, label);
|
|
103
|
+
let releasePromise;
|
|
104
|
+
return () => (releasePromise ??= closeServer(server));
|
|
105
|
+
} catch (error) {
|
|
106
|
+
await closeQuietly(server);
|
|
107
|
+
if (error?.code === 'EADDRINUSE') return 'in-use';
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readSocketHolder(address, timeoutMs, parseAnswer) {
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
let data = '';
|
|
115
|
+
let settled = false;
|
|
116
|
+
const socket = net.createConnection(address);
|
|
117
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
118
|
+
const finish = (holder) => {
|
|
119
|
+
if (settled) return;
|
|
120
|
+
settled = true;
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
socket.destroy();
|
|
123
|
+
resolve(holder);
|
|
124
|
+
};
|
|
125
|
+
socket.on('data', (chunk) => {
|
|
126
|
+
data += chunk.toString('utf8');
|
|
127
|
+
const newline = data.indexOf('\n');
|
|
128
|
+
if (newline !== -1) finish(parseAnswer(data.slice(0, newline)));
|
|
129
|
+
});
|
|
130
|
+
socket.once('end', () => finish(parseAnswer(data)));
|
|
131
|
+
socket.once('error', () => finish(null));
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function acquireKernelLock({
|
|
136
|
+
address, holder, waitMs, retryMs, answerTimeoutMs, parseAnswer, label,
|
|
137
|
+
createServer = net.createServer,
|
|
138
|
+
}) {
|
|
139
|
+
const accepted = new Set();
|
|
140
|
+
const onConnection = (socket) => {
|
|
141
|
+
accepted.add(socket);
|
|
142
|
+
socket.once('close', () => accepted.delete(socket));
|
|
143
|
+
socket.end(`${JSON.stringify(holder)}\n`);
|
|
144
|
+
};
|
|
145
|
+
const deadline = Date.now() + waitMs;
|
|
146
|
+
|
|
147
|
+
for (;;) {
|
|
148
|
+
const server = createServer(onConnection);
|
|
149
|
+
try {
|
|
150
|
+
await listen(server, address);
|
|
151
|
+
holder.acquiredAt = new Date().toISOString();
|
|
152
|
+
await selfCheck(server, address, createServer, label);
|
|
153
|
+
server.unref();
|
|
154
|
+
let releasePromise;
|
|
155
|
+
const release = async () => {
|
|
156
|
+
const closing = closeServer(server);
|
|
157
|
+
for (const socket of accepted) socket.destroy();
|
|
158
|
+
await closing;
|
|
159
|
+
};
|
|
160
|
+
return { held: true, release: () => (releasePromise ??= release()) };
|
|
161
|
+
} catch (error) {
|
|
162
|
+
await closeQuietly(server);
|
|
163
|
+
if (!error || error.code !== 'EADDRINUSE') throw error;
|
|
164
|
+
const remaining = deadline - Date.now();
|
|
165
|
+
if (remaining <= 0) break;
|
|
166
|
+
await wait(Math.min(retryMs, remaining));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { held: false, holder: await readSocketHolder(address, answerTimeoutMs, parseAnswer) };
|
|
171
|
+
}
|
|
@@ -12,6 +12,10 @@ import { createHash } from 'node:crypto';
|
|
|
12
12
|
import { startedRuns } from './pre-start.mjs';
|
|
13
13
|
import { normalizePath, readJson } from './paths.mjs';
|
|
14
14
|
|
|
15
|
+
// Plan_75 D5: 2 hashes the header-free body. Runs without this field used earlier schemes:
|
|
16
|
+
// the whole task text, then the task without grant lines (Plan_75 P1).
|
|
17
|
+
export const TASK_HASH_SCHEME = 2;
|
|
18
|
+
|
|
15
19
|
/**
|
|
16
20
|
* What makes two runs the same task when their names disagree.
|
|
17
21
|
*
|
|
@@ -21,6 +25,8 @@ import { normalizePath, readJson } from './paths.mjs';
|
|
|
21
25
|
* built to refuse. Whitespace and case are normalized away because the same order re-sent
|
|
22
26
|
* through a shell is rewrapped, not rewritten; anything more forgiving would tie together
|
|
23
27
|
* tasks that merely look alike.
|
|
28
|
+
* Plan_75 D5: the runner supplies the header-free body; every body line counts, including
|
|
29
|
+
* prose beginning with a grant label. Stripping it again would silently change identity.
|
|
24
30
|
*/
|
|
25
31
|
export const taskFingerprint = (taskText) => {
|
|
26
32
|
const normalized = String(taskText ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
@@ -35,14 +41,19 @@ export const taskFingerprint = (taskText) => {
|
|
|
35
41
|
* honestly, changed nothing and was told it had done no work. Folder names rather than
|
|
36
42
|
* full paths; the current run joins the list as soon as its status.json exists.
|
|
37
43
|
*/
|
|
38
|
-
export function chainRuns(runsRoot, repo, slug, taskHash = '', orderId = '') {
|
|
44
|
+
export function chainRuns(runsRoot, repo, slug, taskHash = '', orderId = '', grantRun = '') {
|
|
39
45
|
const wantedRepo = normalizePath(repo);
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
46
|
+
if (!wantedRepo) return [];
|
|
47
|
+
const namedStatus = typeof grantRun === 'string' && grantRun &&
|
|
48
|
+
!/[\\/]/.test(grantRun) && grantRun !== '.' && grantRun !== '..'
|
|
49
|
+
? readJson(path.join(runsRoot, grantRun, 'status.json')) : null;
|
|
50
|
+
const namedRun = namedStatus && normalizePath(namedStatus.repo) === wantedRepo ? namedStatus : null;
|
|
51
|
+
const wantedSlugs = new Set([slug, namedRun?.slug].map((value) => String(value ?? '').trim().toLowerCase()).filter(Boolean));
|
|
52
|
+
const wantedHashes = new Set([taskHash, namedRun?.task_hash].map((value) => String(value ?? '').trim().toLowerCase()).filter(Boolean));
|
|
53
|
+
const wantedOrderIds = new Set([orderId, namedRun?.order_id].map((value) => String(value ?? '').trim()).filter(Boolean));
|
|
43
54
|
// No handle on the task, or no repo, means nothing to chain — an empty context must find
|
|
44
55
|
// nothing rather than fall back to "every run in this folder".
|
|
45
|
-
if (!
|
|
56
|
+
if (!wantedSlugs.size && !wantedHashes.size && !wantedOrderIds.size) return [];
|
|
46
57
|
let entries;
|
|
47
58
|
try {
|
|
48
59
|
entries = fs.readdirSync(runsRoot, { withFileTypes: true });
|
|
@@ -56,9 +67,9 @@ export function chainRuns(runsRoot, repo, slug, taskHash = '', orderId = '') {
|
|
|
56
67
|
if (!status) continue;
|
|
57
68
|
// Either handle identifies the task. The hash catches a repeat that renamed itself; the
|
|
58
69
|
// slug still catches the follow-up whose wording the orchestrator edited on purpose.
|
|
59
|
-
const sameSlug =
|
|
60
|
-
const sameTask =
|
|
61
|
-
const sameOrder =
|
|
70
|
+
const sameSlug = wantedSlugs.has(String(status.slug ?? '').trim().toLowerCase());
|
|
71
|
+
const sameTask = wantedHashes.has(String(status.task_hash ?? '').trim().toLowerCase());
|
|
72
|
+
const sameOrder = wantedOrderIds.has(String(status.order_id ?? '').trim());
|
|
62
73
|
if (!sameSlug && !sameTask && !sameOrder) continue;
|
|
63
74
|
if (normalizePath(status.repo) !== wantedRepo) continue;
|
|
64
75
|
runs.push({ name: entry.name, at: String(status.started_at || '') });
|