@lyupro/codex-bridge 0.6.5 → 0.6.7
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 +60 -0
- package/README.md +5 -5
- package/bin/codex-bridge.mjs +181 -178
- package/cli/dispatcher-contract-record.mjs +42 -0
- package/cli/dispatcher-contract.mjs +89 -0
- package/cli/doctor-hooks.mjs +104 -114
- package/cli/doctor-runs.mjs +55 -55
- package/cli/doctor.mjs +39 -3
- package/cli/handback-witness-check.mjs +39 -0
- package/cli/hook-targets.mjs +14 -6
- package/cli/hook.mjs +32 -23
- package/cli/host-contract.mjs +136 -135
- package/cli/install.mjs +9 -14
- package/cli/launcher-probe.mjs +38 -0
- package/cli/probe-contract.mjs +31 -62
- package/cli/probe-rig.mjs +53 -0
- package/cli/run-launcher.mjs +17 -0
- package/cli/settings-merge.mjs +42 -35
- package/cli/uninstall.mjs +15 -3
- package/cli/update.mjs +45 -16
- package/package.json +4 -4
- package/src/agents/codex-advisor.md +3 -4
- package/src/agents/codex-build.md +5 -5
- package/src/agents/codex-review.md +3 -3
- package/src/agents/codex-scout.md +3 -4
- package/src/home/hooks/dispatcher-gate.mjs +119 -0
- package/src/home/hooks/guard-tries.mjs +5 -6
- package/src/home/hooks/live-runs.mjs +1 -0
- package/src/home/hooks/order-gate.mjs +20 -5
- package/src/home/hooks/reply-guard.mjs +75 -10
- package/src/home/hooks/reply-verdicts.mjs +5 -0
- package/src/home/hooks/worktree-lock.mjs +108 -107
- package/src/home/lib/atomic-json.mjs +21 -0
- package/src/home/lib/brand-home.mjs +6 -0
- package/src/home/lib/dispatcher-command.mjs +79 -0
- package/src/home/lib/dispatcher-gate.mjs +95 -0
- package/src/home/lib/dispatcher-order.mjs +30 -8
- package/src/home/lib/dispatcher-state.mjs +111 -0
- package/src/home/lib/dispatcher-stop.mjs +52 -0
- package/src/home/lib/handback-witness.mjs +65 -0
- package/src/home/lib/hook-definitions.mjs +10 -0
- package/src/home/lib/hook-entry.mjs +20 -0
- package/src/home/lib/meta/advice-status.mjs +3 -7
- package/src/home/lib/meta/advice-verdict.mjs +37 -21
- package/src/home/lib/required-inputs.mjs +1 -1
- package/src/home/lib/runner/advise-carry.mjs +50 -0
- package/src/home/lib/runner/attach.mjs +11 -7
- package/src/home/lib/runner/codex-cmd.mjs +3 -2
- package/src/home/lib/runner/continuation.mjs +3 -1
- package/src/home/lib/runner/git-state.mjs +1 -1
- package/src/home/lib/runner/launcher.mjs +14 -13
- package/src/home/lib/runner/preflight.mjs +6 -3
- package/src/home/lib/runner/prompts.mjs +9 -8
- package/src/home/lib/runner/schemas.mjs +33 -7
|
@@ -21,18 +21,23 @@
|
|
|
21
21
|
* argument about the SHAPE of the reply can loop forever and eventually steps aside, an
|
|
22
22
|
* argument about EXTERNAL run state cannot be argued away by the model and ends the turn
|
|
23
23
|
* with `continue: false` instead of a silent pass. See MAX_FORM_BLOCKS / MAX_STATE_BLOCKS.
|
|
24
|
+
* In the handback era, SubagentHandback delivers the caller-visible answer before SubagentStop.
|
|
25
|
+
* Plan_62 makes this hook yield after delivery and audit every transcript tool call against gate receipts.
|
|
24
26
|
*
|
|
25
27
|
* Input is JSON on stdin. The fields used here (`agent_type`, `last_assistant_message`)
|
|
26
|
-
* come from Claude Code; the last payload is kept in
|
|
27
|
-
* the contract stays inspectable if it ever changes shape.
|
|
28
|
+
* come from Claude Code; the last payload is kept in state/diagnostics/reply-guard.last.json
|
|
29
|
+
* so the contract stays inspectable if it ever changes shape.
|
|
28
30
|
*/
|
|
29
31
|
import fs from 'node:fs';
|
|
30
|
-
import os from 'node:os';
|
|
31
32
|
import path from 'node:path';
|
|
32
33
|
import { AGENTS } from '../lib/agents.mjs';
|
|
34
|
+
import { BRAND_STATE_DIR } from '../lib/brand-home.mjs';
|
|
33
35
|
import { runOrderMismatch, transcriptOrderId } from '../lib/dispatcher-order.mjs';
|
|
36
|
+
import { decideDispatcherStop, transcriptToolUses } from '../lib/dispatcher-stop.mjs';
|
|
34
37
|
import { recognizeHostRefusal } from '../lib/host-refusal.mjs';
|
|
35
38
|
import { readJsonFileSync } from '../lib/json-file.mjs';
|
|
39
|
+
import { readDispatcherState, updateDispatcherState } from '../lib/dispatcher-state.mjs';
|
|
40
|
+
import { hostSdkVersion, recordHandbackWitness } from '../lib/handback-witness.mjs';
|
|
36
41
|
import { runLiveness } from '../lib/meta/run-liveness.mjs';
|
|
37
42
|
import { resolveProjectRunsDir } from '../lib/runner/project-dir.mjs';
|
|
38
43
|
import { runsRoot } from '../lib/runner/runs-root.mjs';
|
|
@@ -44,6 +49,7 @@ import {
|
|
|
44
49
|
abandonedRunStop,
|
|
45
50
|
deadRunReason,
|
|
46
51
|
deadRunStop,
|
|
52
|
+
handbackDemandReason,
|
|
47
53
|
liveRunReason,
|
|
48
54
|
liveRunStop,
|
|
49
55
|
missingDiscoveredMetaStop,
|
|
@@ -65,8 +71,6 @@ import {
|
|
|
65
71
|
// Plan_46's first invariant recognizes only direct expressions such as
|
|
66
72
|
// blockForm('Contract violated: run codex-bridge run and return its stdout verbatim.');
|
|
67
73
|
// The broader hook-directory scan protects the extracted verdict text from the same incident.
|
|
68
|
-
const HOME = os.homedir();
|
|
69
|
-
const LOG_DIR = path.join(HOME, '.claude', 'logs');
|
|
70
74
|
const GUARDED = new Set(Object.keys(AGENTS));
|
|
71
75
|
|
|
72
76
|
/**
|
|
@@ -91,7 +95,13 @@ const GUARDED = new Set(Object.keys(AGENTS));
|
|
|
91
95
|
* worktree was busy.
|
|
92
96
|
*/
|
|
93
97
|
/** Never let a guard failure break real work: on any doubt, stay silent. */
|
|
94
|
-
|
|
98
|
+
let dispatcherSystemMessage = null;
|
|
99
|
+
const pass = () => {
|
|
100
|
+
if (dispatcherSystemMessage) {
|
|
101
|
+
process.stdout.write(JSON.stringify({ systemMessage: dispatcherSystemMessage }));
|
|
102
|
+
}
|
|
103
|
+
process.exit(0);
|
|
104
|
+
};
|
|
95
105
|
|
|
96
106
|
let input;
|
|
97
107
|
try {
|
|
@@ -101,8 +111,9 @@ try {
|
|
|
101
111
|
}
|
|
102
112
|
|
|
103
113
|
try {
|
|
104
|
-
|
|
105
|
-
fs.
|
|
114
|
+
const diagnosticsDir = path.join(BRAND_STATE_DIR, 'diagnostics');
|
|
115
|
+
fs.mkdirSync(diagnosticsDir, { recursive: true });
|
|
116
|
+
fs.writeFileSync(path.join(diagnosticsDir, 'reply-guard.last.json'), `${JSON.stringify(input, null, 2)}\n`);
|
|
106
117
|
} catch {
|
|
107
118
|
// Diagnostics are a convenience, never a reason to fail the turn.
|
|
108
119
|
}
|
|
@@ -112,10 +123,59 @@ try {
|
|
|
112
123
|
// unrelated one, which cost a re-answer for nothing.
|
|
113
124
|
if (!GUARDED.has(input.agent_type)) pass();
|
|
114
125
|
|
|
126
|
+
const emitStop = (payload) => {
|
|
127
|
+
process.stdout.write(JSON.stringify(payload));
|
|
128
|
+
process.exit(0);
|
|
129
|
+
};
|
|
130
|
+
try {
|
|
131
|
+
if (typeof input.session_id === 'string' && input.session_id.length > 0
|
|
132
|
+
&& typeof input.agent_id === 'string' && input.agent_id.length > 0) {
|
|
133
|
+
const ids = {
|
|
134
|
+
stateDir: BRAND_STATE_DIR,
|
|
135
|
+
sessionId: input.session_id,
|
|
136
|
+
agentId: input.agent_id,
|
|
137
|
+
};
|
|
138
|
+
const state = readDispatcherState(ids);
|
|
139
|
+
const toolUses = transcriptToolUses(input.agent_transcript_path);
|
|
140
|
+
const stop = decideDispatcherStop({ state, toolUses });
|
|
141
|
+
if (stop.unseen.length) {
|
|
142
|
+
const names = stop.unseen.map((toolUse) => toolUse.name).join(', ');
|
|
143
|
+
dispatcherSystemMessage = `codex-bridge: dispatcher ${input.agent_type} used ${names} outside the dispatcher gate — do not trust its answer; run codex-bridge doctor.`;
|
|
144
|
+
await recordHandbackWitness({
|
|
145
|
+
stateDir: BRAND_STATE_DIR,
|
|
146
|
+
kind: 'alarm',
|
|
147
|
+
sdkVersion: hostSdkVersion(),
|
|
148
|
+
detail: `${input.agent_type} ${input.agent_id}: ${names} outside the dispatcher gate`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (stop.stateUpdate) {
|
|
152
|
+
await updateDispatcherState(ids, (current) => ({
|
|
153
|
+
...(current.corrupt ? {} : current),
|
|
154
|
+
...stop.stateUpdate,
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
if (stop.route === 'yield') {
|
|
158
|
+
if (dispatcherSystemMessage) emitStop({ systemMessage: dispatcherSystemMessage });
|
|
159
|
+
pass();
|
|
160
|
+
}
|
|
161
|
+
if (stop.route === 'demand') {
|
|
162
|
+
emitStop({
|
|
163
|
+
decision: 'block',
|
|
164
|
+
reason: handbackDemandReason,
|
|
165
|
+
...(dispatcherSystemMessage ? { systemMessage: dispatcherSystemMessage } : {}),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// The handback path is fail-open; the established reply checks remain the fallback.
|
|
171
|
+
}
|
|
172
|
+
|
|
115
173
|
const reply = String(input.last_assistant_message || '').trim();
|
|
116
174
|
if (!reply) pass();
|
|
117
175
|
const emit = (payload) => {
|
|
118
|
-
process.stdout.write(JSON.stringify(
|
|
176
|
+
process.stdout.write(JSON.stringify(
|
|
177
|
+
dispatcherSystemMessage ? { ...payload, systemMessage: dispatcherSystemMessage } : payload,
|
|
178
|
+
));
|
|
119
179
|
process.exit(0);
|
|
120
180
|
};
|
|
121
181
|
/** Wrong shape of reply: three tries, then the reply goes through as it always has. */
|
|
@@ -166,7 +226,12 @@ if (!runDir) {
|
|
|
166
226
|
let candidates;
|
|
167
227
|
try {
|
|
168
228
|
searchedRunsDir = resolveProjectRunsDir(runsRoot(), input.cwd, { create: false }).dir;
|
|
169
|
-
|
|
229
|
+
// Plan_62 r2: on 2026-09-24 the newest run of this agent type belonged to an earlier order, and the
|
|
230
|
+
// guard judged the reply against that stranger's folder. With the order id known, only its runs count.
|
|
231
|
+
candidates = recentRuns(searchedRunsDir, {
|
|
232
|
+
agent: input.agent_type,
|
|
233
|
+
...(orderedOrderId ? { orderId: orderedOrderId } : {}),
|
|
234
|
+
});
|
|
170
235
|
} catch {
|
|
171
236
|
pass();
|
|
172
237
|
}
|
|
@@ -57,6 +57,11 @@ export const missingRunReason =
|
|
|
57
57
|
'confirmed. Run codex-bridge run and return its stdout verbatim. If the runner refused before ' +
|
|
58
58
|
'creating a folder, return that refusal exactly as printed.';
|
|
59
59
|
|
|
60
|
+
export const handbackDemandReason =
|
|
61
|
+
'Hand your answer back now through SubagentHandback. If the dispatcher gate refuses the handback, run ' +
|
|
62
|
+
'exactly the command its refusal quotes and hand back again; the gate replaces the message with the ' +
|
|
63
|
+
"runner's output.";
|
|
64
|
+
|
|
60
65
|
export const noRecentRunReason =
|
|
61
66
|
'Contract violated: the response has no RUN= or ATTACH= line, and no recent run for this ' +
|
|
62
67
|
'dispatcher was found on disk. Run codex-bridge run and return its stdout verbatim; your own ' +
|
|
@@ -1,44 +1,44 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* PreToolUse lock for file tools and plainly recognisable shell writes in the Claude Code host.
|
|
4
|
-
*
|
|
5
|
-
* A live codex-build runner snapshots its repository before and after editing. Letting the
|
|
6
|
-
* orchestrator write during that window folds unrelated work into the runner's result, exactly
|
|
7
|
-
* as the 2026-08-05 incident did. This hook denies only a recognised file path inside a live
|
|
8
|
-
* build run's repository; every malformed or unreadable input passes so diagnostics never break
|
|
9
|
-
* unrelated host work.
|
|
10
|
-
*/
|
|
11
|
-
import fs from 'node:fs';
|
|
12
|
-
import path from 'node:path';
|
|
13
|
-
import { spawnSync } from 'node:child_process';
|
|
14
|
-
import { formatSilence, heartbeatAge } from '../lib/heartbeat.mjs';
|
|
15
|
-
import { SHELL_TOOLS, WRITE_TOOLS } from '../lib/hook-definitions.mjs';
|
|
16
|
-
import { runsRoot } from '../lib/runner/runs-root.mjs';
|
|
17
|
-
import { allLiveRuns, normalizePath } from './live-runs.mjs';
|
|
18
|
-
import { shellWriteIntent } from './shell-write-intent.mjs';
|
|
19
|
-
|
|
20
|
-
const WRITE_TOOL_NAMES = new Set(WRITE_TOOLS);
|
|
21
|
-
const SHELL_TOOL_NAMES = new Set(SHELL_TOOLS);
|
|
22
|
-
const pass = () => process.exit(0);
|
|
23
|
-
|
|
24
|
-
let input;
|
|
25
|
-
try {
|
|
26
|
-
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
27
|
-
} catch {
|
|
28
|
-
pass();
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
|
|
32
|
-
if (!WRITE_TOOL_NAMES.has(input.tool_name) && !SHELL_TOOL_NAMES.has(input.tool_name)) pass();
|
|
33
|
-
|
|
34
|
-
const toolInput = input.tool_input;
|
|
35
|
-
if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
|
|
36
|
-
let rawPaths;
|
|
37
|
-
if (WRITE_TOOL_NAMES.has(input.tool_name)) {
|
|
38
|
-
const pathField = input.tool_name === 'NotebookEdit' ? 'notebook_path' : 'file_path';
|
|
39
|
-
const rawPath = toolInput[pathField];
|
|
40
|
-
if (typeof rawPath !== 'string' || !rawPath.trim()) pass();
|
|
41
|
-
rawPaths = [rawPath];
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse lock for file tools and plainly recognisable shell writes in the Claude Code host.
|
|
4
|
+
*
|
|
5
|
+
* A live codex-build runner snapshots its repository before and after editing. Letting the
|
|
6
|
+
* orchestrator write during that window folds unrelated work into the runner's result, exactly
|
|
7
|
+
* as the 2026-08-05 incident did. This hook denies only a recognised file path inside a live
|
|
8
|
+
* build run's repository; every malformed or unreadable input passes so diagnostics never break
|
|
9
|
+
* unrelated host work.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { formatSilence, heartbeatAge } from '../lib/heartbeat.mjs';
|
|
15
|
+
import { SHELL_TOOLS, WRITE_TOOLS } from '../lib/hook-definitions.mjs';
|
|
16
|
+
import { runsRoot } from '../lib/runner/runs-root.mjs';
|
|
17
|
+
import { allLiveRuns, normalizePath } from './live-runs.mjs';
|
|
18
|
+
import { shellWriteIntent } from './shell-write-intent.mjs';
|
|
19
|
+
|
|
20
|
+
const WRITE_TOOL_NAMES = new Set(WRITE_TOOLS);
|
|
21
|
+
const SHELL_TOOL_NAMES = new Set(SHELL_TOOLS);
|
|
22
|
+
const pass = () => process.exit(0);
|
|
23
|
+
|
|
24
|
+
let input;
|
|
25
|
+
try {
|
|
26
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
27
|
+
} catch {
|
|
28
|
+
pass();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
|
|
32
|
+
if (!WRITE_TOOL_NAMES.has(input.tool_name) && !SHELL_TOOL_NAMES.has(input.tool_name)) pass();
|
|
33
|
+
|
|
34
|
+
const toolInput = input.tool_input;
|
|
35
|
+
if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
|
|
36
|
+
let rawPaths;
|
|
37
|
+
if (WRITE_TOOL_NAMES.has(input.tool_name)) {
|
|
38
|
+
const pathField = input.tool_name === 'NotebookEdit' ? 'notebook_path' : 'file_path';
|
|
39
|
+
const rawPath = toolInput[pathField];
|
|
40
|
+
if (typeof rawPath !== 'string' || !rawPath.trim()) pass();
|
|
41
|
+
rawPaths = [rawPath];
|
|
42
42
|
} else {
|
|
43
43
|
let intent;
|
|
44
44
|
try {
|
|
@@ -48,69 +48,70 @@ if (WRITE_TOOL_NAMES.has(input.tool_name)) {
|
|
|
48
48
|
// the scanner. Unrecognised input must fail open; the witness checks writes afterwards.
|
|
49
49
|
pass();
|
|
50
50
|
}
|
|
51
|
-
if (!intent.writes || !intent.paths.length) pass();
|
|
52
|
-
rawPaths = intent.paths;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
let liveRuns;
|
|
56
|
-
try {
|
|
57
|
-
liveRuns = allLiveRuns(runsRoot());
|
|
58
|
-
} catch {
|
|
59
|
-
pass();
|
|
60
|
-
}
|
|
61
|
-
let match;
|
|
62
|
-
for (const rawPath of rawPaths) {
|
|
63
|
-
let targetPath;
|
|
64
|
-
try {
|
|
65
|
-
if (path.isAbsolute(rawPath)) targetPath = rawPath;
|
|
66
|
-
else if (typeof input.cwd === 'string' && input.cwd.trim()) targetPath = path.resolve(input.cwd, rawPath);
|
|
67
|
-
else continue;
|
|
68
|
-
} catch {
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
const target = normalizePath(targetPath);
|
|
72
|
-
if (!target) continue;
|
|
73
|
-
const owner = liveRuns.find(({ status }) => {
|
|
74
|
-
if (status.agent !== 'codex-build') return false;
|
|
75
|
-
const repository = normalizePath(status.repo);
|
|
76
|
-
return Boolean(repository) && (target === repository || target.startsWith(`${repository}/`));
|
|
77
|
-
});
|
|
78
|
-
if (owner) {
|
|
79
|
-
match = { owner, targetPath };
|
|
80
|
-
break;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
if (!match) pass();
|
|
84
|
-
|
|
85
|
-
const { status, dir } = match.owner;
|
|
86
|
-
// A run is only ever failed for what `git status --porcelain` reports, and an ignored path never
|
|
87
|
-
// appears there — the witness that grades a run does not see it either. Meanwhile the working
|
|
88
|
-
// order for a live run is to touch exactly those files: plans and checklists live in ignored
|
|
89
|
-
// folders. On 2026-08-23 this guard refused four such edits in five minutes, which is worse than
|
|
90
|
-
// a miss: a miss has the witness behind it, a false refusal has nothing and reads as a broken
|
|
91
|
-
// tool. Only a proven `ignored` passes — anything else (not a repository, git absent, any other
|
|
92
|
-
// error) keeps the refusal, because "no proof it is ignored" is not "proof it is harmless".
|
|
93
|
-
const repository = normalizePath(status.repo);
|
|
94
|
-
const target = normalizePath(match.targetPath);
|
|
95
|
-
const gitMetadata = target === `${repository}/.git` || target.startsWith(`${repository}/.git/`);
|
|
96
|
-
if (!gitMetadata) {
|
|
97
|
-
const ignored = spawnSync('git', ['-C', status.repo, 'check-ignore', '--quiet', '--', match.targetPath], {
|
|
98
|
-
stdio: 'ignore',
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
+ `
|
|
115
|
-
|
|
116
|
-
}
|
|
51
|
+
if (!intent.writes || !intent.paths.length) pass();
|
|
52
|
+
rawPaths = intent.paths;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let liveRuns;
|
|
56
|
+
try {
|
|
57
|
+
liveRuns = allLiveRuns(runsRoot());
|
|
58
|
+
} catch {
|
|
59
|
+
pass();
|
|
60
|
+
}
|
|
61
|
+
let match;
|
|
62
|
+
for (const rawPath of rawPaths) {
|
|
63
|
+
let targetPath;
|
|
64
|
+
try {
|
|
65
|
+
if (path.isAbsolute(rawPath)) targetPath = rawPath;
|
|
66
|
+
else if (typeof input.cwd === 'string' && input.cwd.trim()) targetPath = path.resolve(input.cwd, rawPath);
|
|
67
|
+
else continue;
|
|
68
|
+
} catch {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const target = normalizePath(targetPath);
|
|
72
|
+
if (!target) continue;
|
|
73
|
+
const owner = liveRuns.find(({ status }) => {
|
|
74
|
+
if (status.agent !== 'codex-build') return false;
|
|
75
|
+
const repository = normalizePath(status.repo);
|
|
76
|
+
return Boolean(repository) && (target === repository || target.startsWith(`${repository}/`));
|
|
77
|
+
});
|
|
78
|
+
if (owner) {
|
|
79
|
+
match = { owner, targetPath };
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!match) pass();
|
|
84
|
+
|
|
85
|
+
const { status, dir } = match.owner;
|
|
86
|
+
// A run is only ever failed for what `git status --porcelain` reports, and an ignored path never
|
|
87
|
+
// appears there — the witness that grades a run does not see it either. Meanwhile the working
|
|
88
|
+
// order for a live run is to touch exactly those files: plans and checklists live in ignored
|
|
89
|
+
// folders. On 2026-08-23 this guard refused four such edits in five minutes, which is worse than
|
|
90
|
+
// a miss: a miss has the witness behind it, a false refusal has nothing and reads as a broken
|
|
91
|
+
// tool. Only a proven `ignored` passes — anything else (not a repository, git absent, any other
|
|
92
|
+
// error) keeps the refusal, because "no proof it is ignored" is not "proof it is harmless".
|
|
93
|
+
const repository = normalizePath(status.repo);
|
|
94
|
+
const target = normalizePath(match.targetPath);
|
|
95
|
+
const gitMetadata = target === `${repository}/.git` || target.startsWith(`${repository}/.git/`);
|
|
96
|
+
if (!gitMetadata) {
|
|
97
|
+
const ignored = spawnSync('git', ['-C', status.repo, 'check-ignore', '--quiet', '--', match.targetPath], {
|
|
98
|
+
stdio: 'ignore',
|
|
99
|
+
windowsHide: true,
|
|
100
|
+
});
|
|
101
|
+
if (ignored.status === 0) pass();
|
|
102
|
+
}
|
|
103
|
+
process.stdout.write(JSON.stringify({
|
|
104
|
+
hookSpecificOutput: {
|
|
105
|
+
hookEventName: 'PreToolUse',
|
|
106
|
+
permissionDecision: 'deny',
|
|
107
|
+
permissionDecisionReason: `Worktree lock denied ${input.tool_name}: ${match.targetPath} is inside repository `
|
|
108
|
+
+ `${status.repo}, held by live run folder ${dir} (agent ${status.agent}, slug ${status.slug}). `
|
|
109
|
+
// "Working, last progress X ago" rather than "silent for X": this hook only ever denies a
|
|
110
|
+
// run whose heartbeat is still fresh — a stale one is not live here and the edit passes. The
|
|
111
|
+
// first live probe printed "silent for 10 seconds" next to a stop command on a perfectly
|
|
112
|
+
// healthy run, which reads as "it hung, kill it" and would cost the operator 20 minutes of
|
|
113
|
+
// work they meant to keep.
|
|
114
|
+
+ `It is working; last progress ${formatSilence(heartbeatAge(dir))} ago. `
|
|
115
|
+
+ `To take the repository back before it finishes: codex-bridge stop ${path.basename(dir)}.`,
|
|
116
|
+
},
|
|
117
|
+
}));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publishes a JSON record whole or not at all: a temporary file in the target's directory renamed over it.
|
|
3
|
+
*
|
|
4
|
+
* Plan_62 B2 wrote this twice, once per state module; two copies of one write discipline drift apart the
|
|
5
|
+
* first time either is fixed. Callers hold their own lock — this does not serialize concurrent writers
|
|
6
|
+
* (`config-edit.mjs` keeps its own compare-and-swap publish for that reason).
|
|
7
|
+
*/
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
export function writeJsonAtomic(file, record) {
|
|
13
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
14
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
15
|
+
try {
|
|
16
|
+
fs.writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { flag: 'wx' });
|
|
17
|
+
fs.renameSync(temporary, file);
|
|
18
|
+
} finally {
|
|
19
|
+
fs.rmSync(temporary, { force: true });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
import os from 'node:os';
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
|
|
18
|
+
export function brandStateDir(root) {
|
|
19
|
+
return path.join(root, 'state');
|
|
20
|
+
}
|
|
21
|
+
|
|
18
22
|
export function resolveBrandHome({ homedir = os.homedir(), env = process.env } = {}) {
|
|
19
23
|
const overridden = Boolean(env.CODEX_BRIDGE_HOME);
|
|
20
24
|
const root = path.resolve(
|
|
@@ -23,6 +27,7 @@ export function resolveBrandHome({ homedir = os.homedir(), env = process.env } =
|
|
|
23
27
|
return {
|
|
24
28
|
root,
|
|
25
29
|
source: overridden ? 'CODEX_BRIDGE_HOME' : 'default',
|
|
30
|
+
stateDir: brandStateDir(root),
|
|
26
31
|
configPath: path.join(root, 'config.json'),
|
|
27
32
|
conventionsPath: path.join(root, 'conventions.md'),
|
|
28
33
|
};
|
|
@@ -35,5 +40,6 @@ export function resolveBrandHome({ homedir = os.homedir(), env = process.env } =
|
|
|
35
40
|
* environment after import, which would not be seen here.
|
|
36
41
|
*/
|
|
37
42
|
export const BRAND_HOME = resolveBrandHome();
|
|
43
|
+
export const BRAND_STATE_DIR = BRAND_HOME.stateDir;
|
|
38
44
|
export const BRAND_CONFIG_PATH = BRAND_HOME.configPath;
|
|
39
45
|
export const BRAND_CONVENTIONS_PATH = BRAND_HOME.conventionsPath;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Owns the canonical dispatcher command because the 2026-09-23 TradeForge and 2026-09-24 local incidents let haiku ignore its prompt and register two paid runs for one order id. */
|
|
2
|
+
import { CLI_NAMES } from './cli-names.mjs';
|
|
3
|
+
import {
|
|
4
|
+
extractValue,
|
|
5
|
+
isAbsoluteTaskFilePath,
|
|
6
|
+
isInputPlaceholder,
|
|
7
|
+
parseContinuationGrant,
|
|
8
|
+
REQUIRED_INPUTS,
|
|
9
|
+
} from './required-inputs.mjs';
|
|
10
|
+
import { firstShellUnsafeSequence } from './shell-unsafe.mjs';
|
|
11
|
+
|
|
12
|
+
function refusal(reason) {
|
|
13
|
+
return { refusal: `Refused: ${reason}.` };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function unsafeValueReason(value, label) {
|
|
17
|
+
const sequence = firstShellUnsafeSequence(value);
|
|
18
|
+
if (sequence !== null) return `input "${label}" contains unsafe shell sequence ${JSON.stringify(sequence)}`;
|
|
19
|
+
if (value.includes('"') || value.includes('\r') || value.includes('\n')) {
|
|
20
|
+
return `input "${label}" contains a double quote or line break`;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function quote(value) {
|
|
26
|
+
return `"${value}"`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Builds the only permitted run command directly from the immutable order transcript. */
|
|
30
|
+
export function canonicalRunCommand(agentType, promptText) {
|
|
31
|
+
if (typeof agentType !== 'string' || !Object.prototype.hasOwnProperty.call(REQUIRED_INPUTS, agentType)) {
|
|
32
|
+
return refusal(`unknown dispatcher agent ${JSON.stringify(agentType)}`);
|
|
33
|
+
}
|
|
34
|
+
if (typeof promptText !== 'string') return refusal('prompt text is missing or is not text');
|
|
35
|
+
|
|
36
|
+
const inputs = new Map();
|
|
37
|
+
for (const entry of REQUIRED_INPUTS[agentType]) {
|
|
38
|
+
if (entry.conditional) continue;
|
|
39
|
+
const value = extractValue(promptText, entry.label);
|
|
40
|
+
if (isInputPlaceholder(value, entry.label)) {
|
|
41
|
+
return refusal(`required input "${entry.label}" is missing or is a placeholder`);
|
|
42
|
+
}
|
|
43
|
+
const unsafe = unsafeValueReason(value, entry.label);
|
|
44
|
+
if (unsafe) return refusal(unsafe);
|
|
45
|
+
if (entry.label === 'task file' && !isAbsoluteTaskFilePath(value)) {
|
|
46
|
+
return refusal('input "task file" must be an absolute path');
|
|
47
|
+
}
|
|
48
|
+
inputs.set(entry.label, value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The continuation grant never reaches the command line (`--continue` is bare), so its free-text
|
|
52
|
+
// reason is not screened: the live grant "…five risks; advise settles them" would have been refused.
|
|
53
|
+
const optionalLabels = ['repository', 'scope new', 'slug', 'effort'];
|
|
54
|
+
for (const label of optionalLabels) {
|
|
55
|
+
const value = extractValue(promptText, label);
|
|
56
|
+
if (value === null) continue;
|
|
57
|
+
const unsafe = unsafeValueReason(value, label);
|
|
58
|
+
if (unsafe) return refusal(unsafe);
|
|
59
|
+
inputs.set(label, value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const tokens = [CLI_NAMES[0], 'run', '--agent', agentType, '--repo', quote(inputs.get('repository') || '.')];
|
|
63
|
+
if (agentType === 'codex-advisor') tokens.push('--phase', quote(inputs.get('phase')));
|
|
64
|
+
if (agentType === 'codex-build') {
|
|
65
|
+
tokens.push('--scope', quote(inputs.get('scope')));
|
|
66
|
+
if (inputs.get('scope new')) tokens.push('--scope-new', quote(inputs.get('scope new')));
|
|
67
|
+
}
|
|
68
|
+
if (inputs.get('slug')) tokens.push('--slug', quote(inputs.get('slug')));
|
|
69
|
+
tokens.push('--order-id', quote(inputs.get('order id')));
|
|
70
|
+
tokens.push('--task-file', quote(inputs.get('task file')));
|
|
71
|
+
if (inputs.get('effort')) tokens.push('--effort', quote(inputs.get('effort')));
|
|
72
|
+
if (parseContinuationGrant(promptText)) tokens.push('--continue');
|
|
73
|
+
return { command: tokens.join(' ') };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Compares the proposed command as text, allowing whitespace only around its boundary. */
|
|
77
|
+
export function sameCommand(candidate, canonical) {
|
|
78
|
+
return typeof candidate === 'string' && typeof canonical === 'string' && candidate.trim() === canonical;
|
|
79
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** Holds dispatchers to the ordered runner because the 2026-09-23 TradeForge incident let one write and hand back without running it. */
|
|
2
|
+
import { canonicalRunCommand, sameCommand } from './dispatcher-command.mjs';
|
|
3
|
+
import { ownTranscriptPath, transcriptPrompt } from './dispatcher-order.mjs';
|
|
4
|
+
import { HANDBACK_TOOL } from './hook-definitions.mjs';
|
|
5
|
+
|
|
6
|
+
export { HANDBACK_TOOL };
|
|
7
|
+
|
|
8
|
+
export function gateOrder(payload) {
|
|
9
|
+
const transcriptPath = ownTranscriptPath(payload);
|
|
10
|
+
const prompt = transcriptPath ? transcriptPrompt(transcriptPath) : null;
|
|
11
|
+
if (!transcriptPath || !prompt) {
|
|
12
|
+
return {
|
|
13
|
+
refusal: `the order could not be read from the agent transcript ${transcriptPath || '(none)'}`,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
return canonicalRunCommand(payload.agent_type, prompt);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function runnerOutput(payload) {
|
|
20
|
+
if (payload?.hook_event_name === 'PostToolUse') {
|
|
21
|
+
const output = payload.tool_response?.stdout;
|
|
22
|
+
return typeof output === 'string' && output.length > 0 ? { output, exitCode: 0 } : null;
|
|
23
|
+
}
|
|
24
|
+
if (payload?.hook_event_name !== 'PostToolUseFailure' || typeof payload.error !== 'string'
|
|
25
|
+
|| payload.error.length === 0) return null;
|
|
26
|
+
|
|
27
|
+
const newline = payload.error.indexOf('\n');
|
|
28
|
+
const firstLine = (newline < 0 ? payload.error : payload.error.slice(0, newline)).replace(/\r$/, '');
|
|
29
|
+
const match = /^Exit code (\d+)$/.exec(firstLine);
|
|
30
|
+
const output = match ? (newline < 0 ? '' : payload.error.slice(newline + 1)) : payload.error;
|
|
31
|
+
return output.length > 0 ? { output, exitCode: match ? Number(match[1]) : null } : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isFinalOutput(output) {
|
|
35
|
+
return !output.split(/\r?\n/).some((line) => line.startsWith('STARTED '));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function denial(message, order) {
|
|
39
|
+
const prefix = `codex-bridge dispatcher gate: ${message}`;
|
|
40
|
+
if (typeof order?.command !== 'string' || order.command.length === 0) return prefix;
|
|
41
|
+
return `${prefix}\n${order.command}\nRun it exactly, in the foreground; repeating it attaches to the same run.`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function deny(message, order, stateUpdate) {
|
|
45
|
+
const decision = { kind: 'deny', reason: denial(message, order) };
|
|
46
|
+
if (stateUpdate) decision.stateUpdate = stateUpdate;
|
|
47
|
+
return decision;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function allow(message) {
|
|
51
|
+
return { kind: 'allow', message, stateUpdate: { handback: 'delivered' } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function decidePreToolUse({ payload, order, state }) {
|
|
55
|
+
const toolName = payload?.tool_name;
|
|
56
|
+
// Once the caller holds the answer, the dispatcher is finished. On 2026-09-23 the real run was started
|
|
57
|
+
// after the handback and wrote into a tree the caller believed settled; a substituted FAIL followed by a
|
|
58
|
+
// late run would repeat exactly that, so nothing — not even the canonical command — runs past delivery.
|
|
59
|
+
if (state?.handback === 'delivered') {
|
|
60
|
+
return deny('the handback was already delivered; this dispatcher is finished and may not act further.');
|
|
61
|
+
}
|
|
62
|
+
if (toolName === 'Bash') {
|
|
63
|
+
if (order?.refusal) {
|
|
64
|
+
return deny('the order could not be read; nothing may run, so hand back now.', order);
|
|
65
|
+
}
|
|
66
|
+
if (order?.command && sameCommand(payload.tool_input?.command, order.command)
|
|
67
|
+
&& payload.tool_input?.run_in_background !== true) return { kind: 'pass' };
|
|
68
|
+
return deny('only the canonical runner command may run in the foreground.', order);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (toolName === HANDBACK_TOOL) {
|
|
72
|
+
if (order?.refusal) return allow(`FAIL — dispatcher gate: ${order.refusal}`);
|
|
73
|
+
if (typeof state?.runnerOutput === 'string' && state.runnerOutput.length > 0) {
|
|
74
|
+
if (state.runnerFinal) return allow(state.runnerOutput);
|
|
75
|
+
return deny('the run has started without a verdict; repeat the exact command to attach and wait.', order);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const attempts = Number.isInteger(state?.handbackAttempts) ? state.handbackAttempts : 0;
|
|
79
|
+
const handbackAttempts = attempts + 1;
|
|
80
|
+
if (handbackAttempts === 1) {
|
|
81
|
+
return deny('the dispatcher handed back without a runner verdict; run the command first.', order,
|
|
82
|
+
{ handbackAttempts });
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
kind: 'allow',
|
|
86
|
+
message: 'FAIL — dispatcher did not delegate: it handed back without running `' + order.command + '`',
|
|
87
|
+
stateUpdate: { handbackAttempts, handback: 'delivered' },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (order?.refusal) {
|
|
92
|
+
return deny('the order could not be read; nothing may run, so hand back now.', order);
|
|
93
|
+
}
|
|
94
|
+
return deny('only the canonical runner command may run before handback.', order);
|
|
95
|
+
}
|