@lyupro/codex-bridge 0.6.6 → 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +5 -5
  3. package/bin/codex-bridge.mjs +181 -178
  4. package/cli/dispatcher-contract-record.mjs +42 -0
  5. package/cli/dispatcher-contract.mjs +89 -0
  6. package/cli/doctor-hooks.mjs +104 -114
  7. package/cli/doctor-runs.mjs +55 -55
  8. package/cli/doctor.mjs +39 -3
  9. package/cli/handback-witness-check.mjs +39 -0
  10. package/cli/hook-targets.mjs +14 -6
  11. package/cli/hook.mjs +32 -23
  12. package/cli/host-contract.mjs +136 -135
  13. package/cli/install.mjs +9 -14
  14. package/cli/launcher-probe.mjs +38 -0
  15. package/cli/probe-contract.mjs +31 -62
  16. package/cli/probe-rig.mjs +53 -0
  17. package/cli/run-launcher.mjs +17 -0
  18. package/cli/settings-merge.mjs +42 -35
  19. package/cli/uninstall.mjs +15 -3
  20. package/cli/update.mjs +45 -16
  21. package/package.json +4 -4
  22. package/src/agents/codex-advisor.md +3 -4
  23. package/src/agents/codex-build.md +5 -5
  24. package/src/agents/codex-review.md +3 -3
  25. package/src/agents/codex-scout.md +3 -4
  26. package/src/home/hooks/dispatcher-gate.mjs +119 -0
  27. package/src/home/hooks/guard-tries.mjs +5 -6
  28. package/src/home/hooks/live-runs.mjs +1 -0
  29. package/src/home/hooks/order-gate.mjs +20 -5
  30. package/src/home/hooks/reply-guard.mjs +75 -10
  31. package/src/home/hooks/reply-verdicts.mjs +5 -0
  32. package/src/home/hooks/worktree-lock.mjs +108 -107
  33. package/src/home/lib/atomic-json.mjs +21 -0
  34. package/src/home/lib/brand-home.mjs +6 -0
  35. package/src/home/lib/dispatcher-command.mjs +79 -0
  36. package/src/home/lib/dispatcher-gate.mjs +95 -0
  37. package/src/home/lib/dispatcher-order.mjs +30 -8
  38. package/src/home/lib/dispatcher-state.mjs +111 -0
  39. package/src/home/lib/dispatcher-stop.mjs +52 -0
  40. package/src/home/lib/handback-witness.mjs +65 -0
  41. package/src/home/lib/hook-definitions.mjs +10 -0
  42. package/src/home/lib/hook-entry.mjs +20 -0
  43. package/src/home/lib/meta/advice-verdict.mjs +37 -21
  44. package/src/home/lib/required-inputs.mjs +1 -1
  45. package/src/home/lib/runner/codex-cmd.mjs +3 -2
  46. package/src/home/lib/runner/git-state.mjs +1 -1
  47. package/src/home/lib/runner/preflight.mjs +6 -3
  48. package/src/home/lib/runner/prompts.mjs +8 -7
  49. package/src/home/lib/runner/schemas.mjs +33 -7
package/CHANGELOG.md CHANGED
@@ -4,6 +4,46 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.6.7] - 2026-09-25
8
+
9
+ ### Added
10
+
11
+ - The dispatcher gate allows only the single `codex-bridge run …` command derived from its order, and
12
+ hands back the runner's own words. After a handback is delivered, the dispatcher cannot keep
13
+ working; its stop is audited for calls the gate missed, teammate launches are refused, and the
14
+ reply guard looks up runs for that order. A dispatcher had written code itself and returned its
15
+ own `OK`, while another writer started in the same tree.
16
+ - `doctor --probe-contract` measures the four host behaviours the dispatcher gate relies on in one
17
+ host run. `doctor` prints four `dispatcherContract:` lines and a handback witness line, so a
18
+ changed or missing host contract is visible instead of silently weakening the gate.
19
+
20
+ ### Changed
21
+
22
+ - Package state and hook diagnostics now live under `~/.lyupro/.codex-bridge/state/` instead of
23
+ `~/.claude/logs/`; `update` removes the three legacy files so one package no longer splits its
24
+ state across another product's logs directory.
25
+
26
+ ### Fixed
27
+
28
+ - `codex-bridge hook` and `codex-bridge run` now launch the installed home copy. The installer uses
29
+ the short hook command only when PATH proves it serves that home, `update` rewrites registrations
30
+ in an unsafe form, and `doctor` fails them. Launcher failures exit 1, not 2: a global older
31
+ command once rejected newly registered guard names and refused every shell call on the machine.
32
+ - Windows child processes no longer open console windows during delegated runs; visible windows had
33
+ flashed for each process used to snapshot or inspect the repository and host.
34
+ - Advisor evidence now names locations as a list, and only an `OK` judged `advise` pass authorizes
35
+ a build. Real citations had failed because multiple addresses were folded into one string, while
36
+ a scope pass or an advice the judge had failed still counted as the second opinion a build needs.
37
+ - The published `bin` entries use the spelling npm publishes. Publishing 0.6.6 logged both CLI entries
38
+ as invalid and removed; the registry kept them, but a release log saying so had to be checked by
39
+ hand every time.
40
+ - `uninstall` now names every kind of file it leaves in the package home, including runtime state,
41
+ host refusal measurements and conventions; complete removal still requires deleting those files
42
+ by hand.
43
+
44
+ After installing 0.6.7, run `codex-bridge update` once to rewrite hook registrations to the safe form
45
+ and register the dispatcher gate; releases up to 0.6.6 cannot launch the new guard names.
46
+
7
47
  ## [0.6.6] - 2026-09-23
8
48
 
9
49
  ### Fixed
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # codex-bridge
2
2
 
3
- **Delegate implementation, reconnaissance, review and design advice from Claude Code to Codex — six host guards, zero runtime dependencies, zero build steps.**
3
+ **Delegate implementation, reconnaissance, review and design advice from Claude Code to Codex — seven host guards, zero runtime dependencies, zero build steps.**
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@lyupro/codex-bridge)](https://www.npmjs.com/package/@lyupro/codex-bridge)
6
6
  [![Node.js](https://img.shields.io/node/v/@lyupro/codex-bridge)](https://www.npmjs.com/package/@lyupro/codex-bridge)
@@ -57,12 +57,12 @@ If a global install and a clone coexist, you have two package copies. `codex-bri
57
57
  - **Invalid work is rejected before quota is spent.** Scope, order, retry, branch, and continuation checks run before a run folder is created.
58
58
  - **Verdicts come from artifacts.** CLI events, git state, and the declared report decide the result, not model claims about itself.
59
59
  - **Identical retries attach safely.** Repeating the same command joins the active run instead of starting another one.
60
- - **6 host guards enforce the boundary.** `reply-guard`, `order-gate`, `worktree-lock`, `prune-guard`, and `stop-guard` cover replies, orders, edits, deletion, and stopping; `worktree-witness` stands behind the lock and names a write that reached the tree anyway.
60
+ - **7 host guards enforce the boundary.** `reply-guard`, `order-gate`, `worktree-lock`, `prune-guard`, and `stop-guard` cover replies, orders, edits, deletion, and stopping; `worktree-witness` stands behind the lock and names a write that reached the tree anyway; `dispatcher-gate` lets a dispatcher run only the one runner command of its order and hands its caller the runner's own words.
61
61
  - **Installation is identifiable and reversible.** Writes to `settings.json` are merged and backed up; uninstall removes recorded package files only.
62
62
  - **Every run leaves an audit folder.** The verbatim task, scope, before/after git state, events, report, verdict, and reason remain together.
63
63
  - **Zero runtime dependencies and zero build steps.** The package is plain `.mjs` on the Node.js standard library.
64
64
 
65
- The current suite contains **1501 automated tests: 1500 passing and 1 skipped**.
65
+ The current suite contains **1636 automated tests: 1635 passing and 1 skipped**.
66
66
 
67
67
  ## Verify
68
68
 
@@ -91,7 +91,7 @@ Output on a healthy host (trimmed):
91
91
  [ok] liveRuns: 0 runs working right now
92
92
  ```
93
93
 
94
- `doctor` reports the selected host, package source, installed files and guards, Codex availability, retention policy, permissions, live runs, and the current repository's run folder. The `agents` line is a read, not a file count: each installed definition is parsed and its `name` checked against the dispatcher it claims to be, because a definition the host cannot read is a dispatcher that does not exist while every other line still says `[ok]`. Content that drifts from the packaged definition is a failure, not a warning: `doctor` exits 1 and names `codex-bridge update --force`, because a host running someone else's agent files may be calling the runner in a form no permission rule matches.
94
+ `doctor` reports the selected host, package source, installed files and guards, Codex availability, retention policy, permissions, live runs, and the current repository's run folder. It also reports `handbackWitness` and the four dispatcher contract lines (`dispatcherContract:agentIdentity`, `dispatcherContract:shellStdout`, `dispatcherContract:shellFailure`, and `dispatcherContract:agentTranscript`): verified contracts are `ok`, unknown, unprobed, or stale contracts are `warn`, and a measured change is `fail` with exit 1. The `agents` line is a read, not a file count: each installed definition is parsed and its `name` checked against the dispatcher it claims to be, because a definition the host cannot read is a dispatcher that does not exist while every other line still says `[ok]`. Content that drifts from the packaged definition is a failure, not a warning: `doctor` exits 1 and names `codex-bridge update --force`, because a host running someone else's agent files may be calling the runner in a form no permission rule matches.
95
95
 
96
96
  If a session answers `Agent type 'codex-build' not found`, a run can still be started without the dispatcher — the agent is a wrapper that types one command:
97
97
 
@@ -128,7 +128,7 @@ Transport files from runs at least **30 days** old are pruned automatically when
128
128
  | `update [--scope user\|project] [--host <path>] [--dry-run] [--force]` | Refresh a recorded installation. |
129
129
  | `permissions [add\|remove] [--scope user\|project] [--host <path>]` | Inspect or manage optional shell rules. |
130
130
  | `uninstall [--scope user\|project] [--host <path>] [--dry-run]` | Remove recorded package files while preserving run artifacts. |
131
- | `doctor [--scope user\|project] [--host <path>] [--probe-contract]` | Diagnose the selected host and Codex connection. `--probe-contract` measures on a live host whether it still honours a hook refusal, and records the answer. |
131
+ | `doctor [--scope user\|project] [--host <path>] [--probe-contract]` | Diagnose the selected host and Codex connection, including the handback witness and four dispatcher contract lines. `--probe-contract` measures refusal and dispatcher contracts in one live host run and records them when the host completes it. |
132
132
  | `run <runner options> --task-file <abs path>` | Start or attach to a delegated run through the permission-stable package command. The task file carries the statement, the scout questions and the verification command; its path must be absolute. Piping the statement on stdin is the alternative channel, and the two cannot be combined. |
133
133
  | `model [list\|set\|unset\|speed]` | Show the model, effort, pinned speed tier and provenance of each role, with the config path; `model list` prints the live catalogue from Codex, hidden models included and marked; `model set <role> <model> [effort]` checks the pair and any retained speed tier against that model's catalogue entry, and `model unset <role>` removes the profile. `model speed <role> <tier>` previews a live accelerated tier and its quoted cost description; repeat with trailing `confirm` to pin it. `model speed <role> unset` removes only speed, without confirmation. The profile is machine-wide, shared by every project on this machine. |
134
134
  | `projects [<name>] [--json]` | List projects or runs in the run store. |
@@ -1,138 +1,141 @@
1
- #!/usr/bin/env node
2
- /** Dispatches codex-bridge CLI arguments to focused command modules. */
3
- import { diagnose, renderDoctor } from '../cli/doctor.mjs';
4
- import { probeContract } from '../cli/probe-contract.mjs';
5
- import { isInvokedDirectly } from '../cli/invoked-directly.mjs';
6
- import { hook } from '../cli/hook.mjs';
7
- import { resolveHost } from '../cli/hosts.mjs';
1
+ #!/usr/bin/env node
2
+ /** Dispatches codex-bridge CLI arguments to focused command modules. */
3
+ import { diagnose, renderDoctor } from '../cli/doctor.mjs';
4
+ import { probeContract } from '../cli/probe-contract.mjs';
5
+ import { isInvokedDirectly } from '../cli/invoked-directly.mjs';
6
+ import { hook } from '../cli/hook.mjs';
7
+ import { resolveHost } from '../cli/hosts.mjs';
8
8
  import { install } from '../cli/install.mjs';
9
9
  import { model } from '../cli/model.mjs';
10
10
  import { projects } from '../cli/projects.mjs';
11
- import { read } from '../cli/read.mjs';
12
- import { runCodexCommand } from '../src/home/lib/run-codex.mjs';
13
- import { packageInfo } from '../cli/manifest.mjs';
14
- import { permissions } from '../cli/permissions.mjs';
15
- import { prune } from '../cli/prune.mjs';
16
- import { stop } from '../cli/stop.mjs';
17
- import { unlock } from '../cli/unlock.mjs';
18
- import { uninstall } from '../cli/uninstall.mjs';
19
- import { update } from '../cli/update.mjs';
20
-
21
- export const HELP = `codex-bridge — Claude Code dispatchers for Codex
22
-
23
- Usage:
24
- codex-bridge install [--scope user|project] [--host <path>] [--dry-run] [--force]
25
- codex-bridge update [--scope user|project] [--host <path>] [--dry-run] [--force]
26
- codex-bridge permissions [add|remove] [--scope user|project] [--host <path>]
27
- codex-bridge uninstall [--scope user|project] [--host <path>] [--dry-run]
28
- codex-bridge doctor [--scope user|project] [--host <path>] [--probe-contract]
11
+ import { read } from '../cli/read.mjs';
12
+ import { runCodex } from '../cli/run-launcher.mjs';
13
+ import { packageInfo } from '../cli/manifest.mjs';
14
+ import { permissions } from '../cli/permissions.mjs';
15
+ import { prune } from '../cli/prune.mjs';
16
+ import { stop } from '../cli/stop.mjs';
17
+ import { unlock } from '../cli/unlock.mjs';
18
+ import { uninstall } from '../cli/uninstall.mjs';
19
+ import { update } from '../cli/update.mjs';
20
+
21
+ export const HELP = `codex-bridge — Claude Code dispatchers for Codex
22
+
23
+ Usage:
24
+ codex-bridge install [--scope user|project] [--host <path>] [--dry-run] [--force]
25
+ codex-bridge update [--scope user|project] [--host <path>] [--dry-run] [--force]
26
+ codex-bridge permissions [add|remove] [--scope user|project] [--host <path>]
27
+ codex-bridge uninstall [--scope user|project] [--host <path>] [--dry-run]
28
+ codex-bridge doctor [--scope user|project] [--host <path>] [--probe-contract]
29
29
  codex-bridge run <runner options> --task-file <path>
30
30
  codex-bridge model [list]
31
31
  codex-bridge projects [<name>] [--json]
32
- codex-bridge prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]
33
- codex-bridge prune --all-projects [--older-than <age>] [-f] [--json]
34
- codex-bridge unlock [<project>|--all]
35
- codex-bridge read <run>
36
- codex-bridge stop <run>
37
- codex-bridge hook <name>
38
- codexb <same command forms as codex-bridge>
39
- codex-bridge --help
40
- codex-bridge --version
41
-
42
- Commands:
43
- install Install codex-bridge into the selected Claude Code host
44
- update Update a recorded codex-bridge installation
45
- permissions Show, add, or remove optional shell permission rules
46
- uninstall Remove installed files while preserving run artifacts
47
- doctor Diagnose the selected Claude Code host (--probe-contract measures on a live host)
32
+ codex-bridge prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]
33
+ codex-bridge prune --all-projects [--older-than <age>] [-f] [--json]
34
+ codex-bridge unlock [<project>|--all]
35
+ codex-bridge read <run>
36
+ codex-bridge stop <run>
37
+ codex-bridge hook <name>
38
+ codexb <same command forms as codex-bridge>
39
+ codex-bridge --help
40
+ codex-bridge --version
41
+
42
+ Commands:
43
+ install Install codex-bridge into the selected Claude Code host
44
+ update Update a recorded codex-bridge installation
45
+ permissions Show, add, or remove optional shell permission rules
46
+ uninstall Remove installed files while preserving run artifacts
47
+ doctor Diagnose the selected Claude Code host (--probe-contract measures on a live host)
48
48
  run Start or attach to a delegated Codex run
49
49
  model Show machine-wide model profiles or list the live catalogue
50
50
  projects List projects or runs from the run store
51
- prune Remove archived transport, or purge selected run folders
52
- unlock Close running records whose runner is gone
53
- read Render a run's structured event stream
54
- stop Stop a running Codex run and record FAIL
55
-
56
- Hook dispatch:
57
- codex-bridge hook <name> Dispatch a registered guard with stdin unchanged`;
58
-
59
- const HOOK_COMMAND = 'hook';
60
-
61
- export function commandOptions(command, argv) {
62
- const options = {};
63
- const booleanFlags = command === 'install' || command === 'update' ? new Set(['--dry-run', '--force'])
64
- : command === 'uninstall' ? new Set(['--dry-run'])
65
- : command === 'doctor' ? new Set(['--probe-contract']) : new Set();
66
- const flagNames = new Map([
67
- ['--dry-run', 'dryRun'],
68
- ['--force', 'force'],
69
- ['--probe-contract', 'probeContract'],
70
- ]);
71
- for (let index = 0; index < argv.length; index += 1) {
72
- const arg = argv[index];
73
- if (booleanFlags.has(arg)) {
74
- options[flagNames.get(arg)] = true;
75
- continue;
76
- }
77
- if (arg !== '--scope' && arg !== '--host') throw new Error(`unknown ${command} option "${arg}"`);
78
- const value = argv[index + 1];
79
- if (!value || value.startsWith('-')) throw new Error(`${arg} requires a value`);
80
- options[arg === '--scope' ? 'scope' : 'host'] = value;
81
- index += 1;
82
- }
83
- return options;
84
- }
85
-
86
- export async function main(argv, io = console) {
87
- const [command, ...rest] = argv;
88
- if (!command || command === '--help' || command === '-h') {
89
- io.log(HELP);
90
- return 0;
91
- }
92
- if (command === '--version' || command === '-v') {
93
- io.log((await packageInfo()).version);
94
- return 0;
95
- }
96
- if (command === 'run') return runCodexCommand(rest);
97
- if (command === 'doctor') {
98
- const options = commandOptions(command, rest);
99
- const host = resolveHost(options);
100
- // The probe runs BEFORE the diagnosis so the `hostContract` line below carries the verdict just
101
- // measured. Printing the diagnosis first and the measurement after would answer one question
102
- // twice in one output, with the older answer on top (Plan_52 D25).
103
- let probe = null;
104
- if (options.probeContract) {
105
- probe = await probeContract({ host });
106
- io.log(`probe: ${probe.message}`);
107
- }
108
- const result = await diagnose({ host });
109
- io.log(renderDoctor(result));
110
- // An inconclusive probe wrote nothing and measured nothing; exiting 0 would let a failed
111
- // measurement pass silently in a script (Plan_52 D26).
112
- return probe && probe.state === 'inconclusive' ? 2 : result.exitCode;
113
- }
114
- if (command === 'stop') {
115
- if (rest.length !== 1) {
116
- io.error('codex-bridge stop requires exactly one run folder (full path or bare name).');
117
- return 2;
118
- }
119
- const result = await stop({ run: rest[0] });
120
- io.log(result.output);
121
- return result.exitCode;
122
- }
123
- if (command === HOOK_COMMAND) return hook(rest, io);
124
- if (command === 'read') {
125
- if (rest.length !== 1) {
126
- io.error('codex-bridge read requires exactly one run folder (full path or bare name).');
127
- return 2;
128
- }
129
- const result = read({ run: rest[0] });
130
- io.log(result.output);
131
- return result.exitCode;
132
- }
51
+ prune Remove archived transport, or purge selected run folders
52
+ unlock Close running records whose runner is gone
53
+ read Render a run's structured event stream
54
+ stop Stop a running Codex run and record FAIL
55
+
56
+ Hook dispatch:
57
+ codex-bridge hook <name> Dispatch a registered guard with stdin unchanged`;
58
+
59
+ const HOOK_COMMAND = 'hook';
60
+
61
+ export function commandOptions(command, argv) {
62
+ const options = {};
63
+ const booleanFlags = command === 'install' || command === 'update' ? new Set(['--dry-run', '--force'])
64
+ : command === 'uninstall' ? new Set(['--dry-run'])
65
+ : command === 'doctor' ? new Set(['--probe-contract']) : new Set();
66
+ const flagNames = new Map([
67
+ ['--dry-run', 'dryRun'],
68
+ ['--force', 'force'],
69
+ ['--probe-contract', 'probeContract'],
70
+ ]);
71
+ for (let index = 0; index < argv.length; index += 1) {
72
+ const arg = argv[index];
73
+ if (booleanFlags.has(arg)) {
74
+ options[flagNames.get(arg)] = true;
75
+ continue;
76
+ }
77
+ if (arg !== '--scope' && arg !== '--host') throw new Error(`unknown ${command} option "${arg}"`);
78
+ const value = argv[index + 1];
79
+ if (!value || value.startsWith('-')) throw new Error(`${arg} requires a value`);
80
+ options[arg === '--scope' ? 'scope' : 'host'] = value;
81
+ index += 1;
82
+ }
83
+ return options;
84
+ }
85
+
86
+ export async function main(argv, io = console) {
87
+ const [command, ...rest] = argv;
88
+ if (!command || command === '--help' || command === '-h') {
89
+ io.log(HELP);
90
+ return 0;
91
+ }
92
+ if (command === '--version' || command === '-v') {
93
+ io.log((await packageInfo()).version);
94
+ return 0;
95
+ }
96
+ if (command === 'run') return runCodex(rest);
97
+ if (command === 'doctor') {
98
+ const options = commandOptions(command, rest);
99
+ const host = resolveHost(options);
100
+ // The probe runs BEFORE the diagnosis so the `hostContract` line below carries the verdict just
101
+ // measured. Printing the diagnosis first and the measurement after would answer one question
102
+ // twice in one output, with the older answer on top (Plan_52 D25).
103
+ let probe = null;
104
+ if (options.probeContract) {
105
+ probe = await probeContract({ host });
106
+ io.log(`probe: ${probe.message}`);
107
+ for (const [name, verdict] of Object.entries(probe.dispatcher ?? {})) {
108
+ io.log(`probe: ${name} ${verdict.result} — ${verdict.detail}`);
109
+ }
110
+ }
111
+ const result = await diagnose({ host });
112
+ io.log(renderDoctor(result));
113
+ // An inconclusive probe wrote nothing and measured nothing; exiting 0 would let a failed
114
+ // measurement pass silently in a script (Plan_52 D26).
115
+ return probe && probe.state === 'inconclusive' ? 2 : result.exitCode;
116
+ }
117
+ if (command === 'stop') {
118
+ if (rest.length !== 1) {
119
+ io.error('codex-bridge stop requires exactly one run folder (full path or bare name).');
120
+ return 2;
121
+ }
122
+ const result = await stop({ run: rest[0] });
123
+ io.log(result.output);
124
+ return result.exitCode;
125
+ }
126
+ if (command === HOOK_COMMAND) return hook(rest, io);
127
+ if (command === 'read') {
128
+ if (rest.length !== 1) {
129
+ io.error('codex-bridge read requires exactly one run folder (full path or bare name).');
130
+ return 2;
131
+ }
132
+ const result = read({ run: rest[0] });
133
+ io.log(result.output);
134
+ return result.exitCode;
135
+ }
133
136
  if (command === 'projects') {
134
- const result = projects(rest);
135
- io.log(result.output);
137
+ const result = projects(rest);
138
+ io.log(result.output);
136
139
  return result.exitCode;
137
140
  }
138
141
  if (command === 'model') {
@@ -141,56 +144,56 @@ export async function main(argv, io = console) {
141
144
  return result.exitCode;
142
145
  }
143
146
  if (command === 'prune') {
144
- const result = await prune(rest);
145
- io.log(result.output);
146
- return result.exitCode;
147
- }
148
- if (command === 'sweep') {
149
- io.error('codex-bridge sweep was renamed to codex-bridge unlock; use the new command.');
150
- return 2;
151
- }
152
- if (command === 'unlock') {
153
- const result = unlock(rest);
154
- io.log(result.output);
155
- return result.exitCode;
156
- }
157
- if (command === 'permissions') {
158
- let action;
159
- let optionArgs = rest;
160
- if (rest[0] && !rest[0].startsWith('-')) {
161
- action = rest[0];
162
- optionArgs = rest.slice(1);
163
- }
164
- if (action && !['add', 'remove'].includes(action)) {
165
- throw new Error(`unknown permissions action "${action}"`);
166
- }
167
- const options = commandOptions(command, optionArgs);
168
- const host = resolveHost(options);
169
- const result = await permissions({ host, action });
170
- io.log(result.output);
171
- return result.exitCode;
172
- }
173
- if (command === 'install' || command === 'update' || command === 'uninstall') {
174
- const options = commandOptions(command, rest);
175
- const host = resolveHost(options);
176
- const handlers = {
177
- install: () => install({ host, dryRun: options.dryRun, force: options.force }),
178
- update: () => update({ host, dryRun: options.dryRun, force: options.force }),
179
- uninstall: () => uninstall({ host, dryRun: options.dryRun }),
180
- };
181
- const result = await handlers[command]();
182
- io.log(result.output);
183
- return result.exitCode;
184
- }
185
- io.error(`codex-bridge: unknown command "${command}"\nRun codex-bridge --help for usage.`);
186
- return 2;
187
- }
188
-
189
- if (isInvokedDirectly(process.argv[1], import.meta.url)) {
190
- main(process.argv.slice(2))
191
- .then((exitCode) => { process.exitCode = exitCode; })
192
- .catch((err) => {
193
- console.error(`codex-bridge: ${err.message}`);
194
- process.exitCode = 2;
195
- });
196
- }
147
+ const result = await prune(rest);
148
+ io.log(result.output);
149
+ return result.exitCode;
150
+ }
151
+ if (command === 'sweep') {
152
+ io.error('codex-bridge sweep was renamed to codex-bridge unlock; use the new command.');
153
+ return 2;
154
+ }
155
+ if (command === 'unlock') {
156
+ const result = unlock(rest);
157
+ io.log(result.output);
158
+ return result.exitCode;
159
+ }
160
+ if (command === 'permissions') {
161
+ let action;
162
+ let optionArgs = rest;
163
+ if (rest[0] && !rest[0].startsWith('-')) {
164
+ action = rest[0];
165
+ optionArgs = rest.slice(1);
166
+ }
167
+ if (action && !['add', 'remove'].includes(action)) {
168
+ throw new Error(`unknown permissions action "${action}"`);
169
+ }
170
+ const options = commandOptions(command, optionArgs);
171
+ const host = resolveHost(options);
172
+ const result = await permissions({ host, action });
173
+ io.log(result.output);
174
+ return result.exitCode;
175
+ }
176
+ if (command === 'install' || command === 'update' || command === 'uninstall') {
177
+ const options = commandOptions(command, rest);
178
+ const host = resolveHost(options);
179
+ const handlers = {
180
+ install: () => install({ host, dryRun: options.dryRun, force: options.force }),
181
+ update: () => update({ host, dryRun: options.dryRun, force: options.force }),
182
+ uninstall: () => uninstall({ host, dryRun: options.dryRun }),
183
+ };
184
+ const result = await handlers[command]();
185
+ io.log(result.output);
186
+ return result.exitCode;
187
+ }
188
+ io.error(`codex-bridge: unknown command "${command}"\nRun codex-bridge --help for usage.`);
189
+ return 2;
190
+ }
191
+
192
+ if (isInvokedDirectly(process.argv[1], import.meta.url)) {
193
+ main(process.argv.slice(2))
194
+ .then((exitCode) => { process.exitCode = exitCode; })
195
+ .catch((err) => {
196
+ console.error(`codex-bridge: ${err.message}`);
197
+ process.exitCode = 2;
198
+ });
199
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Keeps the latest conclusive verdict per dispatcher host contract, each bound to the host version it was
3
+ * measured on. Its own file rather than a field of .host-contract.json (Plan_62 D14, D19): the refusal
4
+ * record is replaced whole by its writer, and an inconclusive probe must never erase an earlier verdict.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { writeJsonAtomic } from '../src/home/lib/atomic-json.mjs';
9
+ import { parseJsonText } from '../src/home/lib/json-file.mjs';
10
+ import { DISPATCHER_CONTRACTS } from './dispatcher-contract.mjs';
11
+
12
+ export const DISPATCHER_CONTRACT_FILE = 'dispatcher-contract.json';
13
+
14
+ export function readDispatcherContract({ stateDir }) {
15
+ const file = path.join(stateDir, DISPATCHER_CONTRACT_FILE);
16
+ let source;
17
+ try { source = fs.readFileSync(file, 'utf8'); } catch (error) {
18
+ return error.code === 'ENOENT' ? null : { corrupt: true };
19
+ }
20
+ try {
21
+ const record = parseJsonText(file, source);
22
+ if (!record || typeof record !== 'object' || Array.isArray(record)
23
+ || !record.contracts || typeof record.contracts !== 'object' || Array.isArray(record.contracts)) return { corrupt: true };
24
+ return record;
25
+ } catch { return { corrupt: true }; }
26
+ }
27
+
28
+ export function writeDispatcherContract({ stateDir, version, verdicts, now = new Date() }) {
29
+ const file = path.join(stateDir, DISPATCHER_CONTRACT_FILE);
30
+ const previous = readDispatcherContract({ stateDir });
31
+ if (previous?.corrupt) throw new Error(`Cannot update corrupt dispatcher contract: ${file}`);
32
+ const contracts = { ...(previous?.contracts ?? {}) };
33
+ for (const name of DISPATCHER_CONTRACTS) {
34
+ const result = verdicts?.[name]?.result;
35
+ if (result === 'honored' || result === 'changed') {
36
+ contracts[name] = { result, version, checkedAt: new Date(now).toISOString(), detail: verdicts[name].detail };
37
+ }
38
+ }
39
+ const record = { contracts };
40
+ writeJsonAtomic(file, record);
41
+ return record;
42
+ }
@@ -0,0 +1,89 @@
1
+ /** Judges dispatcher host behaviour so a silent regression cannot leave the Plan_62 D19 gate trusted. */
2
+ import { parseJsonText } from '../src/home/lib/json-file.mjs';
3
+ import { PROBE_COMMAND } from './host-contract.mjs';
4
+
5
+ export const DISPATCHER_CONTRACTS = ['agentIdentity', 'shellStdout', 'shellFailure', 'agentTranscript'];
6
+
7
+ function inputOf(entry) {
8
+ const input = entry?.payload?.tool_input;
9
+ if (input && typeof input === 'object') return input;
10
+ if (typeof input === 'string') {
11
+ try { return parseJsonText('<hook tool_input>', input); } catch { return {}; }
12
+ }
13
+ return {};
14
+ }
15
+
16
+ function matching(entries, event, command) {
17
+ return entries.filter((entry) => entry?.event === event && inputOf(entry).command === command);
18
+ }
19
+
20
+ function verdict(result, detail) { return { result, detail }; }
21
+
22
+ export function judgeDispatcherContracts({ entries, hostHealthy, okCommand, failCommand, okOutput, failOutput, agentType, promptToken }) {
23
+ const inconclusive = () => verdict('inconclusive', 'Host did not complete the probe.');
24
+ if (hostHealthy === false) return Object.fromEntries(DISPATCHER_CONTRACTS.map((name) => [name, inconclusive()]));
25
+ const list = Array.isArray(entries) ? entries : [];
26
+ const okPre = matching(list, 'PreToolUse', okCommand);
27
+ const okPost = matching(list, 'PostToolUse', okCommand);
28
+ const failPre = matching(list, 'PreToolUse', failCommand);
29
+ const failPost = matching(list, 'PostToolUse', failCommand);
30
+ const failures = matching(list, 'PostToolUseFailure', failCommand);
31
+ let transcriptVerdict;
32
+
33
+ const agentPre = list.filter((entry) => entry?.event === 'PreToolUse' && entry.payload?.agent_id);
34
+ if (!agentPre.length) transcriptVerdict = verdict('inconclusive', 'No agent_id was observed.');
35
+ else {
36
+ const first = agentPre[0];
37
+ if (first.transcript?.error) transcriptVerdict = verdict('changed', `First transcript read failed: ${first.transcript.error}`);
38
+ else {
39
+ let parsed;
40
+ try { parsed = parseJsonText(first.transcript?.path ?? '<transcript>', first.transcript?.firstLine ?? ''); } catch {}
41
+ const content = parsed?.message?.content;
42
+ const text = typeof content === 'string' ? content : Array.isArray(content)
43
+ ? content.filter((part) => part?.type === 'text').map((part) => part.text).join('\n') : '';
44
+ transcriptVerdict = parsed?.type === 'user' && text.includes(promptToken)
45
+ ? verdict('honored', 'First agent transcript line contains the caller prompt.')
46
+ : verdict('changed', 'First agent transcript line is not the caller user prompt.');
47
+ }
48
+ }
49
+
50
+ return {
51
+ agentIdentity: !okPre.length ? verdict('inconclusive', 'No delegated successful-command call was observed.')
52
+ : okPre.some((entry) => entry.payload.agent_type === agentType && typeof entry.payload.agent_id === 'string' && entry.payload.agent_id.trim())
53
+ ? verdict('honored', 'Agent type and non-empty agent_id were present.')
54
+ : verdict('changed', 'Successful-command PreToolUse lacked the expected agent_type or non-empty agent_id.'),
55
+ shellStdout: !okPre.length ? verdict('inconclusive', 'No delegated successful-command call was observed.')
56
+ : okPost.some((entry) => typeof entry.payload?.tool_response?.stdout === 'string' && entry.payload.tool_response.stdout.includes(okOutput))
57
+ ? verdict('honored', 'PostToolUse stdout contained the probe output.')
58
+ : verdict('changed', 'PostToolUse stdout did not contain the probe output.'),
59
+ shellFailure: !failPre.length ? verdict('inconclusive', 'No delegated failing-command call was observed.')
60
+ : !failPost.length && failures.some((entry) => typeof entry.payload?.error === 'string'
61
+ && entry.payload.error.startsWith('Exit code 2') && entry.payload.error.includes(failOutput)
62
+ && entry.payload.agent_type === agentType)
63
+ ? verdict('honored', 'Failure event carried the expected agent type and exit details without PostToolUse.')
64
+ : verdict('changed', 'Expected failure event contract was not observed.'),
65
+ agentTranscript: transcriptVerdict,
66
+ };
67
+ }
68
+
69
+ export function dispatcherContractStatus({ record, version }) {
70
+ return DISPATCHER_CONTRACTS.map((contract) => {
71
+ const item = record?.contracts?.[contract];
72
+ let state;
73
+ let message;
74
+ if (version == null) {
75
+ state = 'unknown-host'; message = 'Host version is unknown; dispatcher contracts cannot be judged.';
76
+ } else if (!item) {
77
+ state = 'unverified'; message = `Dispatcher contract ${contract} has never been probed on host ${version}; run ${PROBE_COMMAND}.`;
78
+ } else if (item.version !== version) {
79
+ state = 'stale'; message = `Dispatcher contract ${contract} was recorded for host ${item.version}; run ${PROBE_COMMAND}.`;
80
+ } else if (item.result === 'honored') {
81
+ state = 'verified'; message = `Dispatcher contract ${contract} is verified on host ${version}.`;
82
+ } else if (item.result === 'changed') {
83
+ state = 'changed'; message = `Dispatcher gate cannot be trusted on host ${version}: ${contract} changed; see Plan_62 D19.`;
84
+ } else {
85
+ state = 'unverified'; message = `Dispatcher contract ${contract} has no valid verdict; run ${PROBE_COMMAND}.`;
86
+ }
87
+ return { contract, state, message };
88
+ });
89
+ }