@lyupro/codex-bridge 0.6.10 → 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 +81 -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/manifest.mjs +6 -6
- package/package.json +2 -2
- package/src/agents/codex-advisor.md +25 -119
- package/src/agents/codex-build.md +15 -133
- package/src/agents/codex-review.md +10 -109
- package/src/agents/codex-scout.md +11 -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 +59 -40
- package/src/home/hooks/reply-guard.mjs +52 -65
- package/src/home/hooks/reply-identity.mjs +64 -0
- 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/dispatcher-call.mjs +171 -0
- package/src/home/lib/dispatcher-command.mjs +18 -56
- 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/meta/paths.mjs +1 -1
- package/src/home/lib/meta/reply.mjs +2 -1
- 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/runner/args.mjs +140 -231
- package/src/home/lib/runner/attach.mjs +3 -3
- package/src/home/lib/runner/continuation.mjs +174 -192
- package/src/home/lib/runner/launcher.mjs +9 -9
- package/src/home/lib/runner/order-options.mjs +30 -0
- package/src/home/lib/runner/order-owner.mjs +2 -1
- package/src/home/lib/runner/pass-gate.mjs +11 -9
- package/src/home/lib/runner/preflight.mjs +4 -3
- package/src/home/lib/runner/scope-check.mjs +11 -10
- package/src/home/lib/runner/task-input.mjs +23 -10
- package/src/home/lib/subagent-model-env.mjs +44 -0
- package/src/home/lib/task-header.mjs +158 -127
- package/src/home/lib/untyped-stop.mjs +30 -0
- package/src/home/lib/required-inputs.mjs +0 -301
- /package/{cli → src/home/lib}/frontmatter.mjs +0 -0
|
@@ -15,136 +15,17 @@ ChatGPT subscription. All heavy work (reading files, reasoning, generating the r
|
|
|
15
15
|
on the Codex side. Every extra line you send to chat costs Claude tokens. Therefore: do not read
|
|
16
16
|
files, do not run grep, do not retell the report, and do not reason about the task.
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
## What you receive as input
|
|
23
|
-
|
|
24
|
-
- The task statement (what to find out / what to diagnose / what to review).
|
|
25
|
-
- The path to a task file containing that statement verbatim. The orchestrator supplies this path;
|
|
26
|
-
pass it as `--task-file` and never create, read or rewrite it. Writing that file yourself
|
|
27
|
-
from the shell — `cat > … << EOF` or any equivalent — puts back the permission prompt the
|
|
28
|
-
flag exists to remove. Given no path, start the runner without the flag and return its refusal.
|
|
29
|
-
- The path to the repository. If none is given, work in the current working directory.
|
|
30
|
-
- Scope patterns are globs relative to the repository root. A pattern that matches nothing there is
|
|
31
|
-
refused before the run starts.
|
|
32
|
-
- Optional: `effort: <none|low|medium|high|xhigh|max>` — Codex reasoning depth.
|
|
33
|
-
- Optional: `slug: <short-name>` for the run folder; by default, the slug is taken from the order
|
|
34
|
-
id.
|
|
35
|
-
- Every input listed under **Required dispatcher inputs** above, passed on exactly as given:
|
|
36
|
-
`order id` as `--order-id`. Never invent a value, never edit one, never reuse an order id from
|
|
37
|
-
another order — the runner chains runs by that label, and a made-up label is how a repeat run
|
|
38
|
-
hides. If the orchestrator did not give a required input, do not guess — start the runner without
|
|
39
|
-
its flag and return the runner's refusal verbatim.
|
|
40
|
-
- The sub-questions are NOT yours to pass. They live in the task file the orchestrator wrote, under
|
|
41
|
-
a `Questions` heading, one Markdown list item each. A question may carry the orderer's
|
|
42
|
-
`[context-only]` marker; the dispatcher never adds, removes or repairs it. You never read that
|
|
43
|
-
file, never edit it and never put a question on the command line: a question is free prose, and
|
|
44
|
-
free prose in an argument
|
|
45
|
-
makes the host stop applying the operator's permission rule, which is how a delegation dies on a
|
|
46
|
-
refusal. If the file holds no question, start the runner anyway and return its refusal verbatim.
|
|
47
|
-
- `continue` — when the orchestrator's call carries a line beginning with `continue:` or `retry:`,
|
|
48
|
-
pass the bare `--continue` flag even when its run name or reason looks malformed. A `retry:`
|
|
49
|
-
grant lets the orchestrator repeat a failed pass (FAIL, LIMIT, UNAVAILABLE) of the same order
|
|
50
|
-
once; it is not the next pass. Do not inspect, repair, or swallow this grant; pass it through
|
|
51
|
-
and let the runner issue the refusal. A continuation is
|
|
52
|
-
assigned by the orchestrator, never chosen by you. After the verdict, return the exact attaching
|
|
53
|
-
output and stop; do not issue or invent another continuation. If no such grant line is present,
|
|
54
|
-
the flag must not be present.
|
|
55
|
-
|
|
56
|
-
## When the host refuses the command
|
|
57
|
-
|
|
58
|
-
If the host refuses to run `codex-bridge run` — a permission prompt, a classifier denial, anything
|
|
59
|
-
that stops the command — that refusal is your final answer. Report `FAIL`, name your own order id,
|
|
60
|
-
and state the one correction: the operator runs `codex-bridge install`, which grants the permission
|
|
61
|
-
rule this package needs.
|
|
62
|
-
|
|
63
|
-
You are forbidden to look for a way around it. Specifically, and without exception:
|
|
64
|
-
|
|
65
|
-
- never call `run-codex.mjs`, or any file inside the installed package, by path;
|
|
66
|
-
- never start the runner through `node`, `npx`, `sh`, `bash` or any other interpreter;
|
|
67
|
-
- never retry the same call in PowerShell because Bash refused it, or the reverse;
|
|
68
|
-
- never split the call over more than one line, and never add a pipe, a semicolon or a redirect;
|
|
69
|
-
- never advise the operator to grant a permission rule on an internal file — a rule on anything but
|
|
70
|
-
the package command undoes the very design that makes this call permission-stable.
|
|
71
|
-
|
|
72
|
-
Every one of those forms was removed on purpose: a host matches a permission rule against the
|
|
73
|
-
beginning of the final command line, so an interpreter, a path or a continuation makes the call
|
|
74
|
-
unmatchable by construction. Reaching for one does not rescue the run; it guarantees the refusal
|
|
75
|
-
and asks the operator to make it permanent. On 2026-08-15 an order in another repository did all
|
|
76
|
-
three in sequence and ended by telling its operator to grant a rule on `run-codex.mjs`.
|
|
77
|
-
|
|
78
|
-
## The only thing you do
|
|
79
|
-
|
|
80
|
-
```bash
|
|
81
|
-
codex-bridge run --agent codex-scout --repo "<repository-path or .>" --slug "<slug>" --order-id "<order id from the orchestrator>" --task-file "<task-file path from the orchestrator>"
|
|
82
|
-
```
|
|
18
|
+
{{CODEX_DISPATCHER_PROTOCOL}}
|
|
19
|
+
|
|
20
|
+
## Role notes
|
|
83
21
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
If the orchestrator's call carries a line beginning with `continue:` or `retry:`, add the bare
|
|
93
|
-
`--continue` flag even when its run name or reason looks malformed. Do not inspect, repair, or
|
|
94
|
-
swallow the line;
|
|
95
|
-
the runner parses it and issues the refusal. If no such grant line is present, the flag must not be
|
|
96
|
-
present in the command.
|
|
97
|
-
No value
|
|
98
|
-
is passed to it: the runner accepts only `1/true/yes/0/false/no`, while a placeholder string left in
|
|
99
|
-
the
|
|
100
|
-
command from this template is an argument error, and the run will not start. If it was not given,
|
|
101
|
-
the flag
|
|
102
|
-
must not be present in the command at all.
|
|
103
|
-
|
|
104
|
-
The call does not wait. It starts the run and returns at once with `RUN=<path> order-id=<id>` and a
|
|
105
|
-
`STARTED` line. To get the verdict, run the **identical command a second time** — same `--order-id`,
|
|
106
|
-
same `--slug`, same flags. That second call does not start a second run and costs no quota: it
|
|
107
|
-
attaches to the run already in flight, prints `ATTACH=<path> order-id=<id> started=<time>`, blocks
|
|
108
|
-
until the verdict exists and prints it.
|
|
109
|
-
|
|
110
|
-
If the runner refuses with an order id collision — the id already belongs to a run whose task
|
|
111
|
-
differs — that refusal is the whole answer: return `FAIL` with the runner's text, which names the
|
|
112
|
-
remedy. Never retry under a different id of your own choosing; the order id is the orchestrator's.
|
|
113
|
-
|
|
114
|
-
Background execution (`run_in_background`, `&`, `nohup`) is prohibited, and so is inventing a report
|
|
115
|
-
from memory. If the attaching call is killed by a time ceiling, run the identical command again: it attaches to the same
|
|
116
|
-
run and keeps waiting. Never add a flag — the host allows exactly the one command your
|
|
117
|
-
order yields and refuses every other. A real run takes 20-25 minutes, which is normal, not a hang. Give the ordinary
|
|
118
|
-
attaching call `timeout: 1800000` (30 minutes).
|
|
119
|
-
|
|
120
|
-
**Never change `--order-id` or `--slug` to get a fresh run.** The order id is issued by the
|
|
121
|
-
orchestrator and is what makes a repeat harmless; changing it turns a repeat into a second paid run.
|
|
122
|
-
On 2026-08-03 one order became six runs exactly this way, and four of them were never accounted for
|
|
123
|
-
at all.
|
|
124
|
-
|
|
125
|
-
The runner does the rest: creates the run folder, `task.md` (the verbatim task + instructions for
|
|
126
|
-
Codex),
|
|
127
|
-
the response schema, the synchronous Codex run in a read-only sandbox, `meta.json`, artifact status,
|
|
128
|
-
and ready-made response lines.
|
|
129
|
-
|
|
130
|
-
**Your response = the exact stdout of the attaching call**: the `ATTACH=<path>` line and the status
|
|
131
|
-
block below it.
|
|
132
|
-
Do not add or remove anything: no preamble, explanations, apologies, or retelling of
|
|
133
|
-
findings. The report is in the file; the orchestrator will read it if needed.
|
|
134
|
-
The `STARTED` output of the first call is not a result and is never the response on its own.
|
|
135
|
-
|
|
136
|
-
The only allowed final response is this exact stdout. Wording such as "the run has started,
|
|
137
|
-
waiting for completion," "I will wait for a notification," or "Monitor started in the background" is
|
|
138
|
-
prohibited in any
|
|
139
|
-
form: the subagent ceases to exist immediately after responding, nobody can wait, and the
|
|
140
|
-
orchestrator gets
|
|
141
|
-
a promise instead of a result. This is the same failure as in codex-build: the dispatcher gives
|
|
142
|
-
such a response while a run is live, it is left without an owner, and this can only be discovered by
|
|
143
|
-
checking
|
|
144
|
-
`status.json` manually.
|
|
145
|
-
Inventing any outcome the runner did not print is equally prohibited. On 2026-08-13 a dispatcher said
|
|
146
|
-
`FAIL — could not get the Codex run result because of an architectural environment limitation` while
|
|
147
|
-
that run's `status.json` already said `state=finished`, `status=OK`.
|
|
22
|
+
The sub-questions are NOT yours to pass. They live in the task file the orchestrator wrote, under
|
|
23
|
+
a `## Questions` heading, one Markdown list item each. A question may carry the orderer's
|
|
24
|
+
`[context-only]` marker; the dispatcher never adds, removes or repairs it. You never read that
|
|
25
|
+
file, never edit it and never put a question on the command line: a question is free prose, and
|
|
26
|
+
free prose in an argument
|
|
27
|
+
makes the host stop applying the operator's permission rule, which is how a delegation dies on a
|
|
28
|
+
refusal. If the file holds no question, start the runner anyway and return its refusal verbatim.
|
|
148
29
|
|
|
149
30
|
## What you return
|
|
150
31
|
|
|
@@ -198,7 +79,7 @@ The run folder contains `status.json` (`running` / `finished` / `failed` / `aban
|
|
|
198
79
|
runner pid.
|
|
199
80
|
An abandoned run is not a reason to start over yourself: the orchestrator decides whether to repeat
|
|
200
81
|
it, and
|
|
201
|
-
without the
|
|
82
|
+
without the orchestrator's `continue:` or `retry:` grant in the task-file header, the runner will reject that repeat run itself. Only a run still
|
|
202
83
|
alive is attached to; a run that already has a verdict sends your repeat into that same refusal,
|
|
203
84
|
which is the runner telling you the answer is on disk already.
|
|
204
85
|
|
|
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
|
|
4
4
|
import { AGENTS } from '../lib/agents.mjs';
|
|
5
5
|
import { BRAND_STATE_DIR } from '../lib/brand-home.mjs';
|
|
6
6
|
import { sameCommand } from '../lib/dispatcher-command.mjs';
|
|
7
|
+
import { bindReceipt, runnerReceipt } from '../lib/dispatcher-order.mjs';
|
|
7
8
|
import { parseJsonText } from '../lib/json-file.mjs';
|
|
8
9
|
import {
|
|
9
10
|
decidePreToolUse,
|
|
@@ -17,7 +18,7 @@ import {
|
|
|
17
18
|
readDispatcherState,
|
|
18
19
|
updateDispatcherState,
|
|
19
20
|
} from '../lib/dispatcher-state.mjs';
|
|
20
|
-
import {
|
|
21
|
+
import { recordInterceptedAttempt, witnessHostVersion } from '../lib/handback-witness.mjs';
|
|
21
22
|
import { observeSessionHost } from '../lib/host-observations.mjs';
|
|
22
23
|
|
|
23
24
|
function emit(decision, toolInput) {
|
|
@@ -81,6 +82,7 @@ async function main() {
|
|
|
81
82
|
const next = {
|
|
82
83
|
...(current.corrupt ? {} : current),
|
|
83
84
|
...(decision.stateUpdate || {}),
|
|
85
|
+
agentType: payload.agent_type,
|
|
84
86
|
};
|
|
85
87
|
if (toolUseId) {
|
|
86
88
|
const seenToolUseIds = Array.isArray(next.seenToolUseIds) ? next.seenToolUseIds : [];
|
|
@@ -97,9 +99,8 @@ async function main() {
|
|
|
97
99
|
|
|
98
100
|
if (payload.tool_name === HANDBACK_TOOL) {
|
|
99
101
|
try {
|
|
100
|
-
await
|
|
102
|
+
await recordInterceptedAttempt({
|
|
101
103
|
stateDir: BRAND_STATE_DIR,
|
|
102
|
-
kind: 'seen',
|
|
103
104
|
hostVersion: await witnessHostVersion(payload),
|
|
104
105
|
});
|
|
105
106
|
} catch {}
|
|
@@ -119,12 +120,16 @@ async function main() {
|
|
|
119
120
|
if (!order.command || !sameCommand(payload.tool_input?.command, order.command)) return;
|
|
120
121
|
const result = runnerOutput(payload);
|
|
121
122
|
if (!result) return;
|
|
122
|
-
await updateDispatcherState(ids, (current) =>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
123
|
+
await updateDispatcherState(ids, (current) => {
|
|
124
|
+
const base = current.corrupt ? {} : current;
|
|
125
|
+
return {
|
|
126
|
+
...base,
|
|
127
|
+
...bindReceipt(base, runnerReceipt(result.output)),
|
|
128
|
+
runnerOutput: result.output,
|
|
129
|
+
runnerFinal: isFinalOutput(result.output),
|
|
130
|
+
runnerExitCode: result.exitCode,
|
|
131
|
+
};
|
|
132
|
+
});
|
|
128
133
|
} catch {}
|
|
129
134
|
}
|
|
130
135
|
}
|
|
@@ -18,8 +18,6 @@ import { readJsonFileSync } from '../lib/json-file.mjs';
|
|
|
18
18
|
import { runLiveness } from '../lib/meta/run-liveness.mjs';
|
|
19
19
|
import { IDENTITY_ALIVE } from '../lib/process-identity.mjs';
|
|
20
20
|
|
|
21
|
-
export const RECENT_RUN_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
|
|
22
|
-
|
|
23
21
|
/**
|
|
24
22
|
* Match the package's repository comparison without resolving symlinks: Windows realpath adds
|
|
25
23
|
* `\\?\\` and UNC spellings, turning equal paths into unequal ones. A null result is uncertainty,
|
|
@@ -98,45 +96,6 @@ export function liveRuns(runsDir, options = {}) {
|
|
|
98
96
|
return result;
|
|
99
97
|
}
|
|
100
98
|
|
|
101
|
-
/**
|
|
102
|
-
* Find runs recent enough to explain the current dispatcher reply, including completed runs.
|
|
103
|
-
* The 24-hour window covers long and overnight dispatches without letting old project history
|
|
104
|
-
* explain a new reply. This closes the August 13 incident where a fabricated FAIL hid a
|
|
105
|
-
* finished/OK run simply by omitting its folder. Null means the disk could not be trusted.
|
|
106
|
-
*/
|
|
107
|
-
export function recentRuns(runsDir, options = {}) {
|
|
108
|
-
if (typeof runsDir !== 'string' || !runsDir.trim()) return null;
|
|
109
|
-
const now = options.now ?? Date.now();
|
|
110
|
-
const maxAgeMs = options.maxAgeMs ?? RECENT_RUN_MAX_AGE_MS;
|
|
111
|
-
let entries;
|
|
112
|
-
try {
|
|
113
|
-
entries = fs.readdirSync(runsDir, { withFileTypes: true });
|
|
114
|
-
} catch (err) {
|
|
115
|
-
return err.code === 'ENOENT' ? [] : null;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const result = [];
|
|
119
|
-
for (const entry of entries) {
|
|
120
|
-
if (!entry.isDirectory()) continue;
|
|
121
|
-
const dir = path.join(runsDir, entry.name);
|
|
122
|
-
const statusPath = path.join(dir, 'status.json');
|
|
123
|
-
if (!fs.existsSync(statusPath)) continue;
|
|
124
|
-
let status;
|
|
125
|
-
try {
|
|
126
|
-
status = readJsonFileSync(statusPath);
|
|
127
|
-
} catch {
|
|
128
|
-
return null;
|
|
129
|
-
}
|
|
130
|
-
if (!status || typeof status !== 'object' || Array.isArray(status)) return null;
|
|
131
|
-
if (options.agent && status.agent !== options.agent) continue;
|
|
132
|
-
if (options.orderId && String(status.order_id ?? '').trim() !== options.orderId) continue;
|
|
133
|
-
const timestamp = Date.parse(status.finished_at || status.started_at || '');
|
|
134
|
-
if (!Number.isFinite(timestamp) || timestamp > now || now - timestamp > maxAgeMs) continue;
|
|
135
|
-
result.push({ dir, status, timestamp });
|
|
136
|
-
}
|
|
137
|
-
return result.sort((a, b) => b.timestamp - a.timestamp);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
99
|
/** Scan every project under the configured runs root for worktree ownership. */
|
|
141
100
|
export function allLiveRuns(runsRoot, options = {}) {
|
|
142
101
|
if (typeof runsRoot !== 'string' || !runsRoot.trim()) return [];
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* PreToolUse gate for the Codex dispatchers (codex-scout / codex-build / codex-review).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Plan_63 D9: the call carries only the task file; order id and grant come from its header.
|
|
6
|
+
* OW-054: validate the header and order before a grant can skip the order-owner diagnostic,
|
|
7
|
+
* so a `continue: none` line cannot disarm the check. The producer can still correct the order
|
|
8
|
+
* before the runner creates a run folder or spends quota.
|
|
9
9
|
*
|
|
10
10
|
* Input is Claude Code's hook JSON on stdin. The last payload is retained for diagnostics so a
|
|
11
11
|
* future host schema change can be inspected instead of guessed at. Any uncertain shape passes
|
|
@@ -14,15 +14,14 @@
|
|
|
14
14
|
import fs from 'node:fs';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { AGENTS } from '../lib/agents.mjs';
|
|
17
|
+
import { BRAND_HOME, BRAND_STATE_DIR } from '../lib/brand-home.mjs';
|
|
18
|
+
import { modelLatch, readDispatcherModel } from '../lib/dispatcher-model.mjs';
|
|
19
|
+
import { readDispatcherPin } from '../lib/dispatcher-pin.mjs';
|
|
17
20
|
import { recordHookDiagnostic } from '../lib/hook-diagnostics.mjs';
|
|
21
|
+
import { transcriptHostVersion } from '../lib/host-version.mjs';
|
|
18
22
|
import { readJsonFileSync } from '../lib/json-file.mjs';
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
extractValue,
|
|
22
|
-
missingInputs,
|
|
23
|
-
parseGrant,
|
|
24
|
-
shellUnsafeInputs,
|
|
25
|
-
} from '../lib/required-inputs.mjs';
|
|
23
|
+
import { parseDispatcherCall, renderCallRefusal } from '../lib/dispatcher-call.mjs';
|
|
24
|
+
import { orderFromHeader, renderOrderProblems } from '../lib/order-schema.mjs';
|
|
26
25
|
import { SUBAGENT_TOOLS } from '../lib/hook-definitions.mjs';
|
|
27
26
|
import { taskFingerprint } from '../lib/meta/chain.mjs';
|
|
28
27
|
import { conflictingOrderOwner, orderOwnerConflictText } from '../lib/runner/order-owner.mjs';
|
|
@@ -30,6 +29,7 @@ import { resolveProjectRunsDir } from '../lib/runner/project-dir.mjs';
|
|
|
30
29
|
import { runsRoot } from '../lib/runner/runs-root.mjs';
|
|
31
30
|
import { parseTaskDocument } from '../lib/runner/task-file.mjs';
|
|
32
31
|
import { parseTaskHeader, taskHeaderRefusal } from '../lib/task-header.mjs';
|
|
32
|
+
import { envModelProblem } from '../lib/subagent-model-env.mjs';
|
|
33
33
|
|
|
34
34
|
const GUARDED = new Set(Object.keys(AGENTS));
|
|
35
35
|
/**
|
|
@@ -39,9 +39,14 @@ const GUARDED = new Set(Object.keys(AGENTS));
|
|
|
39
39
|
*/
|
|
40
40
|
const SUBAGENT_TOOL_NAMES = new Set(SUBAGENT_TOOLS);
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
let systemMessage;
|
|
43
|
+
const pass = () => {
|
|
44
|
+
if (systemMessage) process.stdout.write(JSON.stringify({ systemMessage }));
|
|
45
|
+
process.exit(0);
|
|
46
|
+
};
|
|
43
47
|
const deny = (reason) => {
|
|
44
48
|
process.stdout.write(JSON.stringify({
|
|
49
|
+
...(systemMessage ? { systemMessage } : {}),
|
|
45
50
|
hookSpecificOutput: {
|
|
46
51
|
hookEventName: 'PreToolUse',
|
|
47
52
|
permissionDecision: 'deny',
|
|
@@ -65,7 +70,37 @@ if (!SUBAGENT_TOOL_NAMES.has(input.tool_name)) pass();
|
|
|
65
70
|
|
|
66
71
|
const toolInput = input.tool_input;
|
|
67
72
|
if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
|
|
68
|
-
if (!GUARDED.has(toolInput.subagent_type)
|
|
73
|
+
if (!GUARDED.has(toolInput.subagent_type)) pass();
|
|
74
|
+
|
|
75
|
+
// Plan_67 D6: one installed model contract; refuse even matching aliases before prompt exits.
|
|
76
|
+
const agentType = toolInput.subagent_type;
|
|
77
|
+
if (Object.hasOwn(toolInput, 'model')) {
|
|
78
|
+
deny(
|
|
79
|
+
"Order gate denied the Agent call because it passes `model`: a dispatcher's model is set in one place, "
|
|
80
|
+
+ `its installed agent file. Call ${agentType} without model. `
|
|
81
|
+
+ 'The run folder was not created; quota was not spent.',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const hostVersion = await transcriptHostVersion(input.transcript_path).catch(() => null);
|
|
86
|
+
const pin = readDispatcherPin({ brandRoot: BRAND_HOME.root, agentType });
|
|
87
|
+
const problem = envModelProblem({ env: process.env, hostVersion, pinFamily: pin.family });
|
|
88
|
+
if (problem) deny(problem);
|
|
89
|
+
} catch {
|
|
90
|
+
// D6 (b): uncertain host/env evidence is diagnostic only; the stop observer still checks it.
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const latch = modelLatch({ ledger: readDispatcherModel({ stateDir: BRAND_STATE_DIR }), agentType });
|
|
94
|
+
if (latch.active) {
|
|
95
|
+
const { data, at } = latch.violation;
|
|
96
|
+
systemMessage = `codex-bridge: the last ${agentType} ran on ${data.parsed.join(', ')} `
|
|
97
|
+
+ `while the installed contract pins ${data.pinFamily} (${data.hostVersion ?? 'unknown'}, ${at}); `
|
|
98
|
+
+ 'the cause is outside this call — another hook or the host; run codex-bridge doctor.';
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// D4 (a): a corrupt ledger must not turn a diagnostic warning into a refusal.
|
|
102
|
+
}
|
|
103
|
+
if (typeof toolInput.prompt !== 'string') pass();
|
|
69
104
|
|
|
70
105
|
/**
|
|
71
106
|
* Plan_62 D7: with agent teams enabled, a registered dispatcher launched with `name` or `team_name`
|
|
@@ -83,28 +118,9 @@ if (teammateField) {
|
|
|
83
118
|
);
|
|
84
119
|
}
|
|
85
120
|
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
if (
|
|
89
|
-
const reason = missing.length
|
|
90
|
-
? [
|
|
91
|
-
'Order gate denied the Agent call because required dispatcher input(s) are missing or still placeholders.',
|
|
92
|
-
'Write each value in tool_input.prompt using `label: value` before launching the dispatcher:',
|
|
93
|
-
...missing.flatMap((entry) => {
|
|
94
|
-
const lines = [`- ${entry.label}: ${entry.explanation} Example: \`${entry.example}\`.`];
|
|
95
|
-
const diagnosis = diagnoseInput(toolInput.prompt, entry.label);
|
|
96
|
-
if (diagnosis) lines.push(` found \`${diagnosis.line}\`, ${diagnosis.reason}`);
|
|
97
|
-
return lines;
|
|
98
|
-
}),
|
|
99
|
-
].join('\n')
|
|
100
|
-
: [
|
|
101
|
-
'Order gate denied the Agent call because labelled dispatcher input(s) contain forbidden shell sequences.',
|
|
102
|
-
'Correct each field before launching the dispatcher:',
|
|
103
|
-
...unsafe.map(({ entry, sequence }) =>
|
|
104
|
-
`- ${entry.label}: found ${JSON.stringify(sequence)}; put free text in the task file and pass only a short command-line value.`),
|
|
105
|
-
].join('\n');
|
|
106
|
-
deny(reason);
|
|
107
|
-
}
|
|
121
|
+
// Plan_76 D1: exact registry labels prevent the scope-new and repository-prose incident of 2026-10-04.
|
|
122
|
+
const call = parseDispatcherCall(toolInput.subagent_type, toolInput.prompt);
|
|
123
|
+
if (call.problems.length) deny(renderCallRefusal(toolInput.subagent_type, call.problems));
|
|
108
124
|
|
|
109
125
|
const readStatus = (file) => {
|
|
110
126
|
try {
|
|
@@ -114,11 +130,7 @@ const readStatus = (file) => {
|
|
|
114
130
|
}
|
|
115
131
|
};
|
|
116
132
|
|
|
117
|
-
const
|
|
118
|
-
const taskFile = extractValue(toolInput.prompt, 'task file');
|
|
119
|
-
// Plan_75 D1, TradeForge capacity incident: retries keep their order; the runner owns conflicting-grant refusal.
|
|
120
|
-
const grant = parseGrant(toolInput.prompt);
|
|
121
|
-
if (!orderId || !taskFile || grant?.kind || grant?.error) pass();
|
|
133
|
+
const taskFile = call.inputs.get('task file');
|
|
122
134
|
|
|
123
135
|
let rawTask;
|
|
124
136
|
try {
|
|
@@ -130,6 +142,13 @@ try {
|
|
|
130
142
|
const parsed = parseTaskHeader(rawTask);
|
|
131
143
|
const headerRefusal = taskHeaderRefusal(parsed);
|
|
132
144
|
if (headerRefusal) deny(headerRefusal);
|
|
145
|
+
const { order, problems } = orderFromHeader(toolInput.subagent_type, parsed);
|
|
146
|
+
if (problems.length) {
|
|
147
|
+
deny(`${renderOrderProblems(toolInput.subagent_type, problems)}\nThe run folder was not created; quota was not spent.`);
|
|
148
|
+
}
|
|
149
|
+
const orderId = order.get('order id');
|
|
150
|
+
// Plan_63 D9 / OW-054: only a validated header grant reaches the runner's grant rules.
|
|
151
|
+
if (parsed.grant) pass();
|
|
133
152
|
let taskHash;
|
|
134
153
|
try {
|
|
135
154
|
taskHash = taskFingerprint(parseTaskDocument(parsed.body).task);
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* In the handback era, SubagentHandback delivers the caller-visible answer before SubagentStop.
|
|
25
25
|
* Alarms name the host by its transcript version: on 2026-09-25 the SDK variable proved inherited.
|
|
26
26
|
* Plan_62 makes this hook yield after delivery and audit every transcript tool call against gate receipts.
|
|
27
|
+
* Plan_63 D10 identifies this run only by the host-keyed gate's immutable runner receipt.
|
|
27
28
|
*
|
|
28
29
|
* Input is JSON on stdin. The fields used here (`agent_type`, `last_assistant_message`)
|
|
29
30
|
* come from Claude Code; the last payload is kept in state/diagnostics/reply-guard.last.json
|
|
@@ -32,20 +33,21 @@
|
|
|
32
33
|
import fs from 'node:fs';
|
|
33
34
|
import path from 'node:path';
|
|
34
35
|
import { AGENTS } from '../lib/agents.mjs';
|
|
35
|
-
import { BRAND_STATE_DIR } from '../lib/brand-home.mjs';
|
|
36
|
+
import { BRAND_HOME, BRAND_STATE_DIR } from '../lib/brand-home.mjs';
|
|
36
37
|
import { recordHookDiagnostic } from '../lib/hook-diagnostics.mjs';
|
|
37
|
-
import { runOrderMismatch, transcriptOrderId } from '../lib/dispatcher-order.mjs';
|
|
38
38
|
import { decideDispatcherStop, transcriptToolUses } from '../lib/dispatcher-stop.mjs';
|
|
39
|
+
import { observeDispatcherModelStop } from '../lib/dispatcher-model.mjs';
|
|
39
40
|
import { recognizeHostRefusal } from '../lib/host-refusal.mjs';
|
|
40
41
|
import { readJsonFileSync } from '../lib/json-file.mjs';
|
|
41
42
|
import { readDispatcherState, updateDispatcherState } from '../lib/dispatcher-state.mjs';
|
|
42
|
-
import {
|
|
43
|
+
import { witnessHostVersion } from '../lib/handback-witness.mjs';
|
|
44
|
+
import { untypedStopEvidence } from '../lib/untyped-stop.mjs';
|
|
43
45
|
import { runLiveness } from '../lib/meta/run-liveness.mjs';
|
|
44
|
-
import {
|
|
45
|
-
import { runsRoot } from '../lib/runner/runs-root.mjs';
|
|
46
|
-
import { liveRuns, normalizePath, recentRuns } from './live-runs.mjs';
|
|
46
|
+
import { liveRuns, normalizePath } from './live-runs.mjs';
|
|
47
47
|
import { FORM, MAX_STATE_BLOCKS, STATE, takeTry } from './guard-tries.mjs';
|
|
48
48
|
import { parseReply } from './reply-parser.mjs';
|
|
49
|
+
import { recordStopWitness, typedStopObservations, untypedStopObservation } from './reply-witness.mjs';
|
|
50
|
+
import { decideReplyIdentity, missingIdsAlarm, readReceiptEvidence } from './reply-identity.mjs';
|
|
49
51
|
import {
|
|
50
52
|
abandonedRunReason,
|
|
51
53
|
abandonedRunStop,
|
|
@@ -58,14 +60,11 @@ import {
|
|
|
58
60
|
missingHostOrderIdReason,
|
|
59
61
|
missingMetaReason,
|
|
60
62
|
missingRunReason,
|
|
61
|
-
noRecentRunReason,
|
|
62
|
-
noRecentRunStop,
|
|
63
63
|
nonexistentRunReason,
|
|
64
64
|
omittedDiscoveredRunReason,
|
|
65
65
|
omittedDiscoveredRunStop,
|
|
66
66
|
omittedSiblingReason,
|
|
67
67
|
omittedSiblingStop,
|
|
68
|
-
orderMismatchStop,
|
|
69
68
|
statusMismatchReason,
|
|
70
69
|
statusMismatchStop,
|
|
71
70
|
} from './reply-verdicts.mjs';
|
|
@@ -114,20 +113,24 @@ try {
|
|
|
114
113
|
|
|
115
114
|
recordHookDiagnostic('reply-guard', input);
|
|
116
115
|
|
|
117
|
-
//
|
|
118
|
-
// audit without a word (Plan_66 D2 (c)); say so instead of passing silently.
|
|
116
|
+
// Plan_67 D9: only evidence bound to this stop can identify an untyped dispatcher.
|
|
119
117
|
if (typeof input.agent_id === 'string' && input.agent_id.length > 0
|
|
120
118
|
&& !(typeof input.agent_type === 'string' && input.agent_type.length > 0)) {
|
|
119
|
+
let evidence;
|
|
121
120
|
try {
|
|
122
|
-
|
|
123
|
-
stateDir: BRAND_STATE_DIR,
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
detail: `host omitted agent_type for agent ${input.agent_id}`,
|
|
127
|
-
});
|
|
121
|
+
const state = typeof input.session_id === 'string' && input.session_id.length > 0
|
|
122
|
+
? readDispatcherState({ stateDir: BRAND_STATE_DIR, sessionId: input.session_id, agentId: input.agent_id }) : null;
|
|
123
|
+
const transcript = transcriptToolUses(input.agent_transcript_path);
|
|
124
|
+
evidence = untypedStopEvidence({ state, toolUses: transcript?.toolUses ?? null });
|
|
128
125
|
} catch {
|
|
129
|
-
//
|
|
126
|
+
// An unreadable state is no evidence either way; a guard failure must never break the stop.
|
|
127
|
+
evidence = { dispatcher: false, reason: 'state-unreadable' };
|
|
130
128
|
}
|
|
129
|
+
const { observation, alarm } = untypedStopObservation({
|
|
130
|
+
evidence, hostVersion: await witnessHostVersion(input), agentId: input.agent_id,
|
|
131
|
+
});
|
|
132
|
+
await recordStopWitness({ stateDir: BRAND_STATE_DIR, observations: [observation] });
|
|
133
|
+
if (!alarm) pass();
|
|
131
134
|
process.stdout.write(JSON.stringify({
|
|
132
135
|
systemMessage: 'codex-bridge: the host did not report agent_type for a subagent — the dispatcher gate cannot recognise dispatchers, so their answers are unchecked; run codex-bridge doctor.',
|
|
133
136
|
}));
|
|
@@ -137,32 +140,40 @@ if (typeof input.agent_id === 'string' && input.agent_id.length > 0
|
|
|
137
140
|
// business — an earlier version inferred the agent from transcript text and blocked an
|
|
138
141
|
// unrelated one, which cost a re-answer for nothing.
|
|
139
142
|
if (!GUARDED.has(input.agent_type)) pass();
|
|
143
|
+
try {
|
|
144
|
+
dispatcherSystemMessage = await observeDispatcherModelStop({
|
|
145
|
+
brandRoot: BRAND_HOME.root, stateDir: BRAND_STATE_DIR, input, hostVersion: await witnessHostVersion(input),
|
|
146
|
+
});
|
|
147
|
+
} catch { /* Plan_67 D4: model diagnostics must never block a dispatcher stop. */ }
|
|
140
148
|
|
|
141
149
|
const emitStop = (payload) => {
|
|
142
150
|
process.stdout.write(JSON.stringify(payload));
|
|
143
151
|
process.exit(0);
|
|
144
152
|
};
|
|
153
|
+
const hasIds = typeof input.session_id === 'string' && input.session_id.length > 0
|
|
154
|
+
&& typeof input.agent_id === 'string' && input.agent_id.length > 0;
|
|
155
|
+
let receipt = null;
|
|
156
|
+
let receiptConflict = null;
|
|
145
157
|
try {
|
|
146
|
-
if (
|
|
147
|
-
&& typeof input.agent_id === 'string' && input.agent_id.length > 0) {
|
|
158
|
+
if (hasIds) {
|
|
148
159
|
const ids = {
|
|
149
160
|
stateDir: BRAND_STATE_DIR,
|
|
150
161
|
sessionId: input.session_id,
|
|
151
162
|
agentId: input.agent_id,
|
|
152
163
|
};
|
|
153
164
|
const state = readDispatcherState(ids);
|
|
154
|
-
|
|
165
|
+
({ receipt, receiptConflict } = readReceiptEvidence(state));
|
|
166
|
+
const transcript = transcriptToolUses(input.agent_transcript_path);
|
|
167
|
+
const toolUses = transcript?.toolUses ?? null;
|
|
155
168
|
const stop = decideDispatcherStop({ state, toolUses });
|
|
156
169
|
if (stop.unseen.length) {
|
|
157
170
|
const names = stop.unseen.map((toolUse) => toolUse.name).join(', ');
|
|
158
|
-
dispatcherSystemMessage = `codex-bridge: dispatcher ${input.agent_type} used ${names} outside the dispatcher gate — do not trust its answer; run codex-bridge doctor
|
|
159
|
-
await recordHandbackWitness({
|
|
160
|
-
stateDir: BRAND_STATE_DIR,
|
|
161
|
-
kind: 'alarm',
|
|
162
|
-
hostVersion: await witnessHostVersion(input),
|
|
163
|
-
detail: `${input.agent_type} ${input.agent_id}: ${names} outside the dispatcher gate`,
|
|
164
|
-
});
|
|
171
|
+
dispatcherSystemMessage = [dispatcherSystemMessage, `codex-bridge: dispatcher ${input.agent_type} used ${names} outside the dispatcher gate — do not trust its answer; run codex-bridge doctor.`].filter(Boolean).join(' ');
|
|
165
172
|
}
|
|
173
|
+
await recordStopWitness({ stateDir: BRAND_STATE_DIR, observations: typedStopObservations({
|
|
174
|
+
agentType: input.agent_type, agentId: input.agent_id, hostVersion: await witnessHostVersion(input),
|
|
175
|
+
hasIds, state, toolUses, unseen: stop.unseen, transcriptComplete: transcript?.complete === true,
|
|
176
|
+
}) });
|
|
166
177
|
if (stop.stateUpdate) {
|
|
167
178
|
await updateDispatcherState(ids, (current) => ({
|
|
168
179
|
...(current.corrupt ? {} : current),
|
|
@@ -185,6 +196,14 @@ try {
|
|
|
185
196
|
// The handback path is fail-open; the established reply checks remain the fallback.
|
|
186
197
|
}
|
|
187
198
|
|
|
199
|
+
if (!hasIds) {
|
|
200
|
+
await recordStopWitness({ stateDir: BRAND_STATE_DIR, observations: typedStopObservations({
|
|
201
|
+
agentType: input.agent_type, hostVersion: await witnessHostVersion(input),
|
|
202
|
+
hasIds, state: null, toolUses: null, unseen: [], transcriptComplete: false,
|
|
203
|
+
}) });
|
|
204
|
+
dispatcherSystemMessage ||= missingIdsAlarm(input.agent_type);
|
|
205
|
+
}
|
|
206
|
+
|
|
188
207
|
const reply = String(input.last_assistant_message || '').trim();
|
|
189
208
|
if (!reply) pass();
|
|
190
209
|
const emit = (payload) => {
|
|
@@ -220,48 +239,22 @@ const blockState = (reason, stopReason) => {
|
|
|
220
239
|
|
|
221
240
|
const { runDirs, claimed } = parseReply(reply);
|
|
222
241
|
let runDir = runDirs[0] || null;
|
|
223
|
-
let discoveredRun = false;
|
|
224
|
-
let discoveredStatus = null;
|
|
225
|
-
let searchedRunsDir = null;
|
|
226
242
|
|
|
227
243
|
if (runDir && !fs.existsSync(runDir)) {
|
|
228
244
|
blockForm(nonexistentRunReason);
|
|
229
245
|
}
|
|
230
246
|
|
|
231
|
-
const orderedOrderId = transcriptOrderId(input.agent_transcript_path);
|
|
232
|
-
|
|
233
247
|
if (!runDir && !claimed) {
|
|
234
248
|
// A reply that pronounces nothing cannot contradict the disk. It stays on the old, softer path:
|
|
235
249
|
// three tries about the SHAPE of the answer and then through, which is what a quoted refusal
|
|
236
|
-
// needs. Only a reply that hands down a verdict earns the
|
|
250
|
+
// needs. Only a reply that hands down a verdict earns the receipt checks below.
|
|
237
251
|
blockForm(missingRunReason);
|
|
238
252
|
}
|
|
239
253
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
// Plan_62 r2: on 2026-09-24 the newest run of this agent type belonged to an earlier order, and the
|
|
245
|
-
// guard judged the reply against that stranger's folder. With the order id known, only its runs count.
|
|
246
|
-
candidates = recentRuns(searchedRunsDir, {
|
|
247
|
-
agent: input.agent_type,
|
|
248
|
-
...(orderedOrderId ? { orderId: orderedOrderId } : {}),
|
|
249
|
-
});
|
|
250
|
-
} catch {
|
|
251
|
-
pass();
|
|
252
|
-
}
|
|
253
|
-
if (candidates === null) pass();
|
|
254
|
-
|
|
255
|
-
if (!candidates.length) {
|
|
256
|
-
blockState(
|
|
257
|
-
noRecentRunReason,
|
|
258
|
-
noRecentRunStop(MAX_STATE_BLOCKS, input.agent_type, searchedRunsDir),
|
|
259
|
-
);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
({ dir: runDir, status: discoveredStatus } = candidates[0]);
|
|
263
|
-
discoveredRun = true;
|
|
264
|
-
}
|
|
254
|
+
const identity = decideReplyIdentity({ runDir, hasIds, receipt, receiptConflict, agentType: input.agent_type });
|
|
255
|
+
if (identity.block) blockState(identity.block.reason, identity.block.stop);
|
|
256
|
+
const { discoveredRun, discoveredStatus } = identity;
|
|
257
|
+
runDir = identity.runDir;
|
|
265
258
|
|
|
266
259
|
/**
|
|
267
260
|
* The dispatcher must name every WRITING run the project has live, not just the one it chose to
|
|
@@ -307,12 +300,6 @@ if (fs.existsSync(statusPath)) {
|
|
|
307
300
|
} catch {
|
|
308
301
|
runStatus = null;
|
|
309
302
|
}
|
|
310
|
-
const mismatch = runOrderMismatch(orderedOrderId, runStatus, runDir);
|
|
311
|
-
|
|
312
|
-
if (mismatch) {
|
|
313
|
-
blockState(mismatch.reason, orderMismatchStop(MAX_STATE_BLOCKS, mismatch, runStatus, runDir));
|
|
314
|
-
}
|
|
315
|
-
|
|
316
303
|
if (runStatus?.state === 'running') {
|
|
317
304
|
// Plan_57 D5/D27: a bare pid mistook a reused foreign process for this run's worker.
|
|
318
305
|
// The shared judge uses run identity and keeps unverified processes live (fail open).
|