@lyupro/codex-bridge 0.6.6 → 0.6.8

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 (55) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +7 -5
  3. package/bin/codex-bridge.mjs +186 -178
  4. package/cli/dispatcher-contract-record.mjs +51 -0
  5. package/cli/dispatcher-contract.mjs +90 -0
  6. package/cli/doctor-hooks.mjs +104 -114
  7. package/cli/doctor-installation.mjs +111 -111
  8. package/cli/doctor-runs.mjs +55 -55
  9. package/cli/doctor.mjs +53 -5
  10. package/cli/handback-witness-check.mjs +31 -0
  11. package/cli/hook-targets.mjs +14 -6
  12. package/cli/hook.mjs +32 -23
  13. package/cli/host-contract.mjs +145 -135
  14. package/cli/install.mjs +23 -17
  15. package/cli/launcher-probe.mjs +38 -0
  16. package/cli/probe-contract.mjs +55 -70
  17. package/cli/probe-rig.mjs +53 -0
  18. package/cli/probe-target.mjs +33 -0
  19. package/cli/run-launcher.mjs +17 -0
  20. package/cli/session-hosts.mjs +51 -0
  21. package/cli/settings-merge.mjs +42 -35
  22. package/cli/uninstall.mjs +16 -3
  23. package/cli/update.mjs +45 -16
  24. package/package.json +4 -4
  25. package/scripts/run-tests.mjs +7 -0
  26. package/src/agents/codex-advisor.md +3 -4
  27. package/src/agents/codex-build.md +5 -5
  28. package/src/agents/codex-review.md +3 -3
  29. package/src/agents/codex-scout.md +3 -4
  30. package/src/home/hooks/dispatcher-gate.mjs +132 -0
  31. package/src/home/hooks/guard-tries.mjs +5 -6
  32. package/src/home/hooks/live-runs.mjs +1 -0
  33. package/src/home/hooks/order-gate.mjs +20 -5
  34. package/src/home/hooks/reply-guard.mjs +95 -10
  35. package/src/home/hooks/reply-verdicts.mjs +5 -0
  36. package/src/home/hooks/worktree-lock.mjs +108 -107
  37. package/src/home/lib/atomic-json.mjs +21 -0
  38. package/src/home/lib/brand-home.mjs +6 -0
  39. package/src/home/lib/dispatcher-command.mjs +79 -0
  40. package/src/home/lib/dispatcher-gate.mjs +95 -0
  41. package/src/home/lib/dispatcher-order.mjs +30 -8
  42. package/src/home/lib/dispatcher-state.mjs +111 -0
  43. package/src/home/lib/dispatcher-stop.mjs +52 -0
  44. package/src/home/lib/handback-witness.mjs +70 -0
  45. package/src/home/lib/hook-definitions.mjs +10 -0
  46. package/src/home/lib/hook-entry.mjs +20 -0
  47. package/src/home/lib/host-observations.mjs +57 -0
  48. package/src/home/lib/host-version.mjs +27 -0
  49. package/src/home/lib/meta/advice-verdict.mjs +37 -21
  50. package/src/home/lib/required-inputs.mjs +1 -1
  51. package/src/home/lib/runner/codex-cmd.mjs +3 -2
  52. package/src/home/lib/runner/git-state.mjs +1 -1
  53. package/src/home/lib/runner/preflight.mjs +6 -3
  54. package/src/home/lib/runner/prompts.mjs +8 -7
  55. package/src/home/lib/runner/schemas.mjs +33 -7
package/cli/install.mjs CHANGED
@@ -15,18 +15,20 @@ import {
15
15
  writeInstallRecord,
16
16
  } from './manifest.mjs';
17
17
  import { copyPlannedFile, targetMatches } from './copy.mjs';
18
- import { hookTargets } from './hook-targets.mjs';
18
+ import { hookTargets, recordHasHooks } from './hook-targets.mjs';
19
19
  import { fingerprintFor, INSTALL_METHOD_COPY, INSTALL_METHOD_KEY } from './install-record.mjs';
20
20
  import {
21
21
  commandFor,
22
- hookRegistration,
23
22
  inspectHook,
24
23
  mergeHook,
25
24
  withSettingsRun,
26
25
  } from './settings-merge.mjs';
27
26
  import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
28
27
  import { addPermissionRules, inspectPermissions } from './permissions.mjs';
29
- import { contractStatus, detectHostVersion, readHostContract } from './host-contract.mjs';
28
+ import { contractStatus, readHostContract } from './host-contract.mjs';
29
+ import { readHostObservations } from '../src/home/lib/host-observations.mjs';
30
+ import { brandStateDir } from '../src/home/lib/brand-home.mjs';
31
+ import { sessionHostVersions } from './session-hosts.mjs';
30
32
  import { readRunConfig, retentionNotice } from '../src/home/lib/run-config.mjs';
31
33
 
32
34
  const WARNING = '\u001b[33m';
@@ -42,12 +44,6 @@ async function targetExists(target) {
42
44
  }
43
45
  }
44
46
 
45
- function recordHasHooks(record, targets) {
46
- return Boolean(record)
47
- && targets.every(({ definition, relative }) => record.hooks?.some((hook) =>
48
- hook.event === definition.event && hook.root === 'brand' && hook.path === relative));
49
- }
50
-
51
47
  function retentionLine(host) {
52
48
  const notice = retentionNotice(readRunConfig(host.brandConfigPath));
53
49
  return notice.enabled ? `${WARNING}${notice.text}${RESET}` : notice.text;
@@ -116,19 +112,27 @@ async function installInRun({
116
112
  env = process.env,
117
113
  contractRecord,
118
114
  hostVersion,
115
+ observations,
119
116
  } = {}) {
120
117
  // Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
121
118
  await readRulesRegistry(host);
122
119
  const configuredRetentionLine = retentionLine(host);
123
- const hostContract = contractStatus({
120
+ const hostObservations = observations === undefined
121
+ ? readHostObservations({ stateDir: brandStateDir(host.brandRoot) }) : observations;
122
+ const observedVersion = sessionHostVersions(hostObservations)[0] ?? null;
123
+ const currentHostVersion = hostVersion === undefined ? observedVersion : hostVersion;
124
+ let hostContract = contractStatus({
124
125
  record: contractRecord === undefined ? await readHostContract(host) : contractRecord,
125
- version: hostVersion === undefined ? detectHostVersion() : hostVersion,
126
+ version: currentHostVersion,
126
127
  });
128
+ const noObservedHostMessage = currentHostVersion === null
129
+ ? 'Host contracts are judged once a session has run a shell command; then run codex-bridge doctor.' : null;
130
+ if (noObservedHostMessage) hostContract = { ...hostContract, message: noObservedHostMessage };
127
131
  const plan = await buildInstallPlan(host, packageRoot);
128
132
  const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
129
133
  const currentPackage = await packageInfo(packageRoot);
130
134
  const record = await readInstallRecord(host);
131
- const targets = hookTargets(host, env, currentPackage.version);
135
+ const targets = hookTargets(host, env);
132
136
  for (const target of targets) {
133
137
  if (!plan.some((item) => item.root === 'brand' && item.relativeToRoot === target.relative)) {
134
138
  throw new Error(`install plan does not contain hooks/${target.definition.file}`);
@@ -168,7 +172,7 @@ async function installInRun({
168
172
  const sameRecord = recordMatchesPackage(record, plan, currentPackage,
169
173
  new Map(states.map((state) => [recordFileKey(state.item), state.fingerprint])),
170
174
  { path: rule.target, fingerprint: ruleState.fingerprint });
171
- if (!changedFiles.length && !changedRule && inspectedHooks.every((state) => state.present)
175
+ if (!changedFiles.length && !changedRule && inspectedHooks.every((state) => state.current)
172
176
  && recordHasHooks(record, targets) && sameRecord) {
173
177
  if (!dryRun) {
174
178
  await addRulesOwner(host);
@@ -200,9 +204,11 @@ async function installInRun({
200
204
  }
201
205
  inspectedHooks.forEach((state, index) => {
202
206
  const { definition, registration } = targets[index];
203
- lines.push(state.present
204
- ? `${definition.event} hook is already registered (${registration.form} command).`
205
- : `Would register ${definition.event} hook for matcher ${definition.matcher} with ${registration.form} command.`);
207
+ lines.push(state.present && !state.current
208
+ ? `Would rewrite ${definition.event} hook ${definition.name} for matcher ${definition.matcher} to ${registration.form} command: ${registration.reason}.`
209
+ : state.present
210
+ ? `${definition.event} hook is already registered (${registration.form} command).`
211
+ : `Would register ${definition.event} hook ${definition.name} for matcher ${definition.matcher} with ${registration.form} command.`);
206
212
  });
207
213
  lines.push('Would write installation record in the brand root.');
208
214
  return { exitCode: 0, output: contractOutput(hostContract, retentionOutput(configuredRetentionLine, lines.join('\n'))) };
@@ -242,7 +248,7 @@ async function installInRun({
242
248
  const prior = record?.hooks?.find((hook) => hook.event === definition.event
243
249
  && hook.root === 'brand' && hook.path === relative);
244
250
  const createdGroup = hookResults[index].createdGroup || prior?.createdGroup === true;
245
- const command = inspectedHooks[index].matchedCommand || registration.command;
251
+ const command = registration.command;
246
252
  return {
247
253
  event: definition.event,
248
254
  root: 'brand',
@@ -0,0 +1,38 @@
1
+ /** Proves the PATH launcher dispatches guards from the home being installed. */
2
+ import path from 'node:path';
3
+ import { HOOK_LAUNCHER_PROTOCOL } from './hook.mjs';
4
+ import { runReachableCommand } from './settings-merge.mjs';
5
+ import { parseJsonText } from '../src/home/lib/json-file.mjs';
6
+
7
+ function normalizeRoot(root) {
8
+ let normalized = root.replaceAll('\\', '/').replace(/\/+$/, '');
9
+ if (process.platform === 'win32') normalized = normalized.toLowerCase();
10
+ return normalized;
11
+ }
12
+
13
+ export function probeHookLauncher({ env, brandRoot }) {
14
+ const result = runReachableCommand('codex-bridge', ['hook', '--home'], env);
15
+ if (!result) return { ok: false, reason: 'codex-bridge is not on PATH' };
16
+ if (result.status !== 0) {
17
+ return { ok: false, reason: 'codex-bridge on PATH is not a launcher (hook --home failed)' };
18
+ }
19
+ let answer;
20
+ try {
21
+ answer = parseJsonText('codex-bridge hook --home', result.stdout);
22
+ } catch {
23
+ return { ok: false, reason: 'malformed launcher answer' };
24
+ }
25
+ if (!answer || typeof answer !== 'object' || Array.isArray(answer)) {
26
+ return { ok: false, reason: 'malformed launcher answer' };
27
+ }
28
+ if (answer.protocol !== HOOK_LAUNCHER_PROTOCOL) {
29
+ return { ok: false, reason: `unsupported launcher protocol ${String(answer.protocol)}` };
30
+ }
31
+ if (answer.dispatch !== 'home' || typeof answer.homeRoot !== 'string' || !answer.homeRoot.trim()) {
32
+ return { ok: false, reason: 'malformed launcher answer' };
33
+ }
34
+ if (normalizeRoot(answer.homeRoot) !== normalizeRoot(path.resolve(brandRoot))) {
35
+ return { ok: false, reason: `launcher runs from ${answer.homeRoot}, not ${brandRoot}` };
36
+ }
37
+ return { ok: true, commandPath: result.path, homeRoot: answer.homeRoot };
38
+ }
@@ -7,13 +7,17 @@ import { spawnSync } from 'node:child_process';
7
7
  import fs from 'node:fs/promises';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
- import { detectHostVersion, writeHostContract } from './host-contract.mjs';
10
+ import { writeHostContract } from './host-contract.mjs';
11
+ import { buildRig } from './probe-rig.mjs';
12
+ import { DISPATCHER_CONTRACTS, judgeDispatcherContracts } from './dispatcher-contract.mjs';
13
+ import { writeDispatcherContract } from './dispatcher-contract-record.mjs';
14
+ import { brandStateDir } from '../src/home/lib/brand-home.mjs';
15
+ import { parseJsonText } from '../src/home/lib/json-file.mjs';
16
+ import { randomUUID } from 'node:crypto';
17
+ import { transcriptHostVersion } from '../src/home/lib/host-version.mjs';
11
18
 
12
19
  export const PROBE_MARKER = 'codex-bridge-contract-probe';
13
20
 
14
- const MARKER_NAME = `${PROBE_MARKER}.marker`;
15
- const JOURNAL_NAME = 'probe-journal.log';
16
-
17
21
  const exists = async (target) => {
18
22
  try {
19
23
  await fs.access(target);
@@ -55,122 +59,103 @@ export function judgeProbe({ markerExists, hookFired, hostResult }) {
55
59
  return { result: 'honored', reason: 'The hook fired and the refused marker command did not run.' };
56
60
  }
57
61
 
58
- const hookSource = `#!/usr/bin/env node
59
- import fs from 'node:fs';
60
- import { fileURLToPath } from 'node:url';
61
-
62
- const marker = ${JSON.stringify(PROBE_MARKER)};
63
- const journalPath = fileURLToPath(new URL('../${JOURNAL_NAME}', import.meta.url));
64
- let input;
65
- try {
66
- input = JSON.parse(fs.readFileSync(0, 'utf8'));
67
- } catch {
68
- process.exit(0);
69
- }
70
-
71
- if (typeof input?.tool_input?.command === 'string' && input.tool_input.command.includes(marker)) {
72
- fs.appendFileSync(journalPath, \`${PROBE_MARKER}\\n\`, 'utf8');
73
- process.stdout.write(JSON.stringify({
74
- hookSpecificOutput: {
75
- hookEventName: 'PreToolUse',
76
- permissionDecision: 'deny',
77
- permissionDecisionReason: 'Contract probe refused its marker command.',
78
- },
79
- }));
80
- }
81
- `;
82
-
83
- // Forward slashes on purpose: the host runs this hook command line through a POSIX shell, and a
84
- // Windows path handed over verbatim arrives carrying backslashes that shell may swallow. The
85
- // doubled form this replacement first carried matched nothing at all on a real path.
86
- const shellQuote = (value) => `"${value.replaceAll('\\', '/').replaceAll('"', '\\"')}"`;
87
-
88
- export async function buildRig(dir) {
89
- const claudeDir = path.join(dir, '.claude');
90
- const hooksDir = path.join(claudeDir, 'hooks');
91
- const hookPath = path.join(hooksDir, 'probe-hook.mjs');
92
- const markerPath = path.join(dir, MARKER_NAME);
93
- const journalPath = path.join(claudeDir, JOURNAL_NAME);
94
- const command = `touch ${MARKER_NAME}`;
95
- const prompt = `Run exactly this Bash command and nothing else: ${command}`;
96
- const settings = {
97
- hooks: {
98
- PreToolUse: [{
99
- matcher: 'Bash',
100
- hooks: [{ type: 'command', command: `${shellQuote(process.execPath)} ${shellQuote(hookPath)}` }],
101
- }],
102
- },
103
- };
104
-
105
- await fs.mkdir(hooksDir, { recursive: true });
106
- await fs.writeFile(hookPath, hookSource, 'utf8');
107
- await fs.writeFile(
108
- path.join(claudeDir, 'settings.json'),
109
- `${JSON.stringify(settings, null, 2)}\n`,
110
- 'utf8',
111
- );
112
- return { dir, markerPath, journalPath, command, prompt };
113
- }
114
-
115
62
  export async function probeContract({
116
63
  host,
117
- version = detectHostVersion(),
64
+ target,
118
65
  runHost = spawnSync,
119
66
  rigRoot = os.tmpdir(),
120
67
  now = new Date(),
121
68
  }) {
122
- if (version == null) {
69
+ if (target?.error || !target?.version || !target?.executable) {
70
+ const message = target?.error ?? 'The probe target is unavailable.';
123
71
  return {
124
72
  state: 'inconclusive',
125
73
  result: null,
126
74
  version: null,
127
- message: 'The host version could not be read, so no contract probe was run.',
75
+ message,
128
76
  recorded: false,
77
+ dispatcher: Object.fromEntries(DISPATCHER_CONTRACTS.map((name) => [name, { result: 'inconclusive', detail: 'Host version is unavailable.' }])),
129
78
  };
130
79
  }
131
80
 
132
81
  await fs.mkdir(rigRoot, { recursive: true });
133
82
  const dir = await fs.mkdtemp(path.join(rigRoot, `${PROBE_MARKER}-`));
134
83
  try {
135
- const rig = await buildRig(dir);
84
+ const token = randomUUID().slice(0, 8);
85
+ const rig = await buildRig(dir, token);
136
86
  let hostResult;
137
87
  try {
138
- hostResult = runHost('claude', [
88
+ hostResult = runHost(target.executable, [
139
89
  '--setting-sources', 'project',
140
- '--allowedTools', 'Bash',
90
+ '--allowedTools', 'Bash,Agent',
141
91
  '-p', rig.prompt,
142
92
  ], {
143
93
  cwd: rig.dir,
144
94
  encoding: 'utf8',
145
95
  shell: false,
146
96
  timeout: 120000,
97
+ windowsHide: true,
147
98
  });
148
99
  } catch (error) {
149
100
  hostResult = { error };
150
101
  }
151
102
 
103
+ let entries = [];
104
+ let malformed = false;
105
+ try {
106
+ const source = await fs.readFile(rig.dispatcherJournalPath, 'utf8');
107
+ entries = source.split(/\r?\n/).filter(Boolean).map((line) => parseJsonText(rig.dispatcherJournalPath, line));
108
+ } catch (error) {
109
+ if (error.code !== 'ENOENT') malformed = true;
110
+ }
111
+ // Only a host that completed the run can be asked who it was; a spawn failure or timeout keeps its own
112
+ // reason below instead of reading as "ran on host unknown".
113
+ const hostCompleted = !malformed && !hostResult?.error && !hostResult?.signal && hostResult?.status === 0;
114
+ let seen = null;
115
+ const transcriptPath = entries.find((entry) => typeof entry?.payload?.transcript_path === 'string')?.payload.transcript_path;
116
+ if (transcriptPath) seen = await transcriptHostVersion(transcriptPath);
117
+ if (hostCompleted && seen !== target.version) {
118
+ return {
119
+ state: 'inconclusive', result: null, version: target.version,
120
+ message: `The probe ran on host ${seen ?? 'unknown'}, not on ${target.version}; nothing was recorded.`,
121
+ recorded: false,
122
+ dispatcher: Object.fromEntries(DISPATCHER_CONTRACTS.map((name) => [name, { result: 'inconclusive', detail: 'Probe host version did not match target.' }])),
123
+ };
124
+ }
152
125
  const verdict = judgeProbe({
153
126
  markerExists: await exists(rig.markerPath),
154
127
  hookFired: await exists(rig.journalPath),
155
128
  hostResult,
156
129
  });
130
+ let dispatcher = judgeDispatcherContracts({
131
+ entries: malformed ? null : entries,
132
+ hostHealthy: !malformed && !hostResult?.error && !hostResult?.signal && hostResult?.status === 0,
133
+ okCommand: rig.okCommand, failCommand: rig.failCommand, okOutput: rig.okOutput,
134
+ failOutput: rig.failOutput, agentType: 'probe-agent', promptToken: token,
135
+ });
136
+ if (verdict.result == null) dispatcher = Object.fromEntries(Object.keys(dispatcher).map((name) => [name, { result: 'inconclusive', detail: 'Refusal probe did not complete.' }]));
137
+ if (verdict.result != null && !malformed && !hostResult?.error && !hostResult?.signal && hostResult?.status === 0) {
138
+ await writeDispatcherContract({ stateDir: brandStateDir(host.brandRoot), version: target.version, verdicts: dispatcher, now });
139
+ }
157
140
  if (verdict.result == null) {
158
141
  return {
159
142
  state: 'inconclusive',
160
143
  result: null,
161
- version,
144
+ version: target.version,
162
145
  message: verdict.reason,
163
146
  recorded: false,
147
+ dispatcher: Object.fromEntries(Object.entries(dispatcher).map(([name, item]) => [name, { result: 'inconclusive', detail: 'Host did not complete the probe.' }])),
164
148
  };
165
149
  }
166
150
 
167
- await writeHostContract(host, { version, result: verdict.result, now });
151
+ await writeHostContract(host, { version: target.version, result: verdict.result, now });
168
152
  return {
169
153
  state: 'probed',
170
154
  result: verdict.result,
171
- version,
155
+ version: target.version,
172
156
  message: verdict.reason,
173
157
  recorded: true,
158
+ dispatcher,
174
159
  };
175
160
  } finally {
176
161
  // Windows can retain a just-written hook tree briefly; cleanup failure must not erase a valid
@@ -0,0 +1,53 @@
1
+ /** Builds one host run that measures refusal and dispatcher contracts together to avoid charging for separate probes. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { PROBE_MARKER } from './probe-contract.mjs';
6
+
7
+ const JOURNAL_NAME = 'probe-journal.log';
8
+ const shellQuote = (value) => `"${value.replaceAll('\\', '/').replaceAll('"', '\\"')}"`;
9
+
10
+ const hookSource = (refusalCommand) => `#!/usr/bin/env node
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ const base = path.dirname(fileURLToPath(import.meta.url));
15
+ let payload;
16
+ try { payload = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { payload = {}; }
17
+ const event = process.argv[2];
18
+ const entry = { event, payload };
19
+ if (event === 'PreToolUse' && payload?.agent_id && payload?.transcript_path && payload?.session_id) {
20
+ const transcriptPath = path.join(path.dirname(payload.transcript_path), payload.session_id, 'subagents', 'agent-' + payload.agent_id + '.jsonl');
21
+ try { entry.transcript = { path: transcriptPath, firstLine: fs.readFileSync(transcriptPath, 'utf8').split(/\\r?\\n/, 1)[0] }; }
22
+ catch (error) { entry.transcript = { path: transcriptPath, error: error.code || error.message }; }
23
+ }
24
+ try { fs.appendFileSync(path.join(base, '..', 'dispatcher-journal.jsonl'), JSON.stringify(entry) + '\\n', 'utf8'); } catch {}
25
+ const refusal = ${JSON.stringify(PROBE_MARKER)};
26
+ if (event === 'PreToolUse' && payload?.tool_input?.command === ${JSON.stringify(refusalCommand)}) {
27
+ try { fs.appendFileSync(path.join(base, '..', '${JOURNAL_NAME}'), refusal + '\\n', 'utf8'); } catch {}
28
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'Contract probe refused its marker command.' } }));
29
+ }
30
+ `;
31
+
32
+ export async function buildRig(dir, token) {
33
+ const claudeDir = path.join(dir, '.claude');
34
+ const hooksDir = path.join(claudeDir, 'hooks');
35
+ const hookPath = path.join(hooksDir, 'probe-hook.mjs');
36
+ const markerPath = path.join(dir, `${PROBE_MARKER}-${token}.marker`);
37
+ const journalPath = path.join(claudeDir, JOURNAL_NAME);
38
+ const dispatcherJournalPath = path.join(claudeDir, 'dispatcher-journal.jsonl');
39
+ const okCommand = `node -e "console.log('cb-probe-ok-${token}')"`;
40
+ const failCommand = `node -e "console.log('cb-probe-fail-${token}'); console.error('cb-probe-err-${token}'); process.exit(2)"`;
41
+ const refusalCommand = `touch ${PROBE_MARKER}-${token}.marker`;
42
+ const agentPrompt = `probe ${token}: run your two commands`;
43
+ const prompt = `Use the Agent tool with subagent_type "probe-agent" in the foreground and pass it exactly: ${agentPrompt}. Wait for its answer, then run exactly this Bash command and nothing else: ${refusalCommand}`;
44
+ const hook = (event) => ({ matcher: 'Bash', hooks: [{ type: 'command', command: `${shellQuote(process.execPath)} ${shellQuote(hookPath)} ${event}` }] });
45
+ const settings = { hooks: { PreToolUse: [hook('PreToolUse')], PostToolUse: [hook('PostToolUse')], PostToolUseFailure: [hook('PostToolUseFailure')] } };
46
+ const agent = `---\nname: probe-agent\ndescription: Run the dispatcher contract commands.\ntools: Bash\n---\nRun exactly two Bash commands, each as its own call, in the foreground, never retried: first ${okCommand}, then ${failCommand}. Reply with the literal outputs, nothing else.\n`;
47
+ await fs.mkdir(hooksDir, { recursive: true });
48
+ await fs.mkdir(path.join(claudeDir, 'agents'), { recursive: true });
49
+ await fs.writeFile(hookPath, hookSource(refusalCommand), 'utf8');
50
+ await fs.writeFile(path.join(claudeDir, 'agents', 'probe-agent.md'), agent, 'utf8');
51
+ await fs.writeFile(path.join(claudeDir, 'settings.json'), `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
52
+ return { dir, markerPath, journalPath, dispatcherJournalPath, prompt, token, okCommand, failCommand, refusalCommand, okOutput: `cb-probe-ok-${token}`, failOutput: `cb-probe-fail-${token}` };
53
+ }
@@ -0,0 +1,33 @@
1
+ /** Resolves the session host after the 2026-09-25 PATH host identity incident. */
2
+ import { spawnSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import { detectHostVersion } from './host-contract.mjs';
5
+ import { readHostObservations } from '../src/home/lib/host-observations.mjs';
6
+
7
+ export function resolveProbeTarget({ stateDir, executable, env = process.env, run = spawnSync, exists = fs.existsSync }) {
8
+ if (executable) {
9
+ const version = detectHostVersion({ run, executable });
10
+ return version === null
11
+ ? { error: `Could not read the version of ${executable}.` }
12
+ : { executable, version, source: 'flag' };
13
+ }
14
+
15
+ const observations = readHostObservations({ stateDir });
16
+ const newest = Object.entries(observations.hosts)
17
+ .filter(([, entry]) => Number.isFinite(Date.parse(entry?.lastSeen)))
18
+ .sort((a, b) => Date.parse(b[1].lastSeen) - Date.parse(a[1].lastSeen))[0];
19
+ if (!newest) {
20
+ return { error: 'No Claude Code session has been observed by this installation yet; run any shell command in a session first, or pass --probe-executable <path>.' };
21
+ }
22
+ const [version] = newest;
23
+ const candidates = [];
24
+ if (env.CLAUDE_CODE_EXECPATH && exists(env.CLAUDE_CODE_EXECPATH)) candidates.push(env.CLAUDE_CODE_EXECPATH);
25
+ candidates.push('claude');
26
+ const checked = [];
27
+ for (const candidate of candidates) {
28
+ const candidateVersion = detectHostVersion({ run, executable: candidate });
29
+ checked.push(`${candidate} (${candidateVersion ?? 'unknown'})`);
30
+ if (candidateVersion === version) return { executable: candidate, version, source: 'observed' };
31
+ }
32
+ return { error: `No executable of the observed host ${version} was found (checked: ${checked.join(', ')}); pass --probe-executable <path>.` };
33
+ }
@@ -0,0 +1,17 @@
1
+ /** Loads the installed runner because Plan_62 D17 makes the brand home the runtime source of truth. */
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { resolveBrandHome } from '../src/home/lib/brand-home.mjs';
7
+
8
+ export async function runCodex(args = [], io = console) {
9
+ const home = resolveBrandHome({ homedir: os.homedir(), env: process.env });
10
+ const entry = path.join(home.root, 'lib', 'run-codex.mjs');
11
+ if (!fs.existsSync(entry)) {
12
+ io.error(`codex-bridge run: codex-bridge is not installed in ${home.root}. Run codex-bridge install.`);
13
+ return 1;
14
+ }
15
+ const { runCodexCommand } = await import(pathToFileURL(entry).href);
16
+ return runCodexCommand(args);
17
+ }
@@ -0,0 +1,51 @@
1
+ /** Judges every observed session host after the 2026-09-25 PATH host identity incident. */
2
+ import { contractStatus, PROBE_COMMAND } from './host-contract.mjs';
3
+ import { DISPATCHER_CONTRACTS, dispatcherContractStatus } from './dispatcher-contract.mjs';
4
+
5
+ export function sessionHostVersions(observations) {
6
+ return Object.entries(observations?.hosts ?? {})
7
+ .map(([version, entry]) => ({ version, timestamp: Date.parse(entry?.lastSeen) }))
8
+ .filter(({ timestamp }) => Number.isFinite(timestamp))
9
+ .sort((a, b) => b.timestamp - a.timestamp)
10
+ .map(({ version }) => version);
11
+ }
12
+
13
+ export function sessionHostCheck(versions, observations) {
14
+ if (!versions.length) {
15
+ return {
16
+ key: 'sessionHost',
17
+ status: 'warn',
18
+ value: 'No Claude Code session has been observed by this installation yet; host contracts are judged once a session runs a shell command.',
19
+ };
20
+ }
21
+ const newest = versions[0];
22
+ const lastSeen = new Date(observations.hosts[newest].lastSeen).toISOString();
23
+ const others = versions.slice(1);
24
+ return {
25
+ key: 'sessionHost',
26
+ status: 'ok',
27
+ value: `Sessions run on host ${newest} (last seen ${lastSeen})${others.length ? `; also seen: ${others.join(', ')}` : ''}`,
28
+ };
29
+ }
30
+
31
+ export function otherHostCheck({ version, contractRecord, dispatcherRecord }) {
32
+ const refusal = contractStatus({ record: contractRecord, version });
33
+ const dispatchers = dispatcherContractStatus({ record: dispatcherRecord, version });
34
+ const key = `otherHost:${version}`;
35
+ if (refusal.state === 'ignored' || dispatchers.some(({ state }) => state === 'changed')) {
36
+ const changed = dispatchers.filter(({ state }) => state === 'changed').map(({ contract }) => contract);
37
+ return { key, status: 'fail', value: [refusal.state === 'ignored' ? 'refusal contract ignored' : '', ...changed].filter(Boolean).join('; ') };
38
+ }
39
+ if (refusal.state === 'verified' && dispatchers.every(({ state }) => state === 'verified')) {
40
+ return { key, status: 'ok', value: `Host ${version} refusal and dispatcher contracts are verified.` };
41
+ }
42
+ const missing = [];
43
+ if (refusal.state !== 'verified') missing.push('refusal');
44
+ missing.push(...dispatchers.filter(({ state }) => state !== 'verified').map(({ contract }) => contract));
45
+ const probe = `${PROBE_COMMAND} --probe-executable <path to a ${version} executable>`;
46
+ return {
47
+ key,
48
+ status: 'warn',
49
+ value: `Host ${version} contracts not yet measured: ${missing.join(', ')}; run ${probe}.`,
50
+ };
51
+ }
@@ -57,44 +57,45 @@ export function commandReachable(name = 'codex-bridge', env = process.env) {
57
57
  * could decide anything, and the host refused Bash, PowerShell, file edits and agent launches.
58
58
  */
59
59
  export function reachableCommandVersion(name = 'codex-bridge', env = process.env) {
60
+ const result = runReachableCommand(name, ['--version'], env);
61
+ if (!result || result.status !== 0) return null;
62
+ return String(result.stdout || '').trim().split(/\r?\n/).pop()?.trim() || null;
63
+ }
64
+
65
+ export function runReachableCommand(name, args, env = process.env) {
60
66
  const shimPath = resolveCommand(name, env);
61
67
  if (!shimPath) return null;
62
68
  // Node 24 warned with DEP0190 during update (2026-09-07): invoke cmd explicitly for npm shims.
63
69
  // Live probes covered plain, bin with space, Program Files (x86), weird & name, and caret^dir.
64
- // /d /s /c with path and --version as separate arguments only passed the plain directory:
70
+ // /d /s /c with path and fixed arguments as separate arguments only passed the plain directory:
65
71
  // Node adds quotes, then cmd's /s strips the first and last quote of the whole command line.
66
72
  // /d /c without /s failed on & and ^. This single, double-wrapped verbatim command passed all
67
73
  // five. Keep native path separators: forward-slash Windows paths do not work through cmd.
68
74
  // The interpreter is a property of the machine, not of the PATH handed in: resolving cmd.exe
69
- // through the caller's PATH would return null on any env whose PATH omits System32, and a null
70
- // version is exactly the silent failure this function exists to prevent.
75
+ // through the caller's PATH would return null on any env whose PATH omits System32, hiding the
76
+ // exact command outcome this probe exists to report.
71
77
  const shell = envValue(env, 'comspec') || process.env.ComSpec || 'cmd.exe';
78
+ const fixedArgs = args.map((argument) => ` ${argument}`).join('');
72
79
  const result = process.platform === 'win32'
73
- ? spawnSync(shell, ['/d', '/s', '/c', `""${shimPath}" --version"`], {
74
- encoding: 'utf8', env, shell: false, windowsVerbatimArguments: true,
80
+ ? spawnSync(shell, ['/d', '/s', '/c', `""${shimPath}"${fixedArgs}"`], {
81
+ encoding: 'utf8', env, shell: false, windowsVerbatimArguments: true, windowsHide: true,
82
+ timeout: 10000, maxBuffer: 64 * 1024,
75
83
  })
76
- : spawnSync(shimPath, ['--version'], { encoding: 'utf8', env, shell: false });
77
- if (result.error || result.status !== 0) return null;
78
- return String(result.stdout || '').trim().split(/\r?\n/).pop()?.trim() || null;
84
+ : spawnSync(shimPath, args, {
85
+ encoding: 'utf8', env, shell: false, windowsHide: true, timeout: 10000, maxBuffer: 64 * 1024,
86
+ });
87
+ return { path: shimPath, status: result.status, stdout: String(result.stdout || '') };
79
88
  }
80
89
 
81
- export function hookRegistration(name, target, env = process.env, packageVersion = null) {
82
- // Requiring the versions to match is stricter than asking whether `hook` exists, deliberately:
83
- // a command of another version would run code this install has never seen, which is the hazard
84
- // README describes when a global install and a clone coexist. Anything short of a match falls
85
- // back to the copy this install placed itself.
86
- const reachable = reachableCommandVersion('codex-bridge', env);
87
- if (reachable && packageVersion && reachable === packageVersion) {
90
+ export function hookRegistration(name, target, probe) {
91
+ if (probe.ok) {
88
92
  return {
89
93
  command: shortCommandFor(name),
90
94
  form: 'short',
91
- reason: `codex-bridge ${reachable} on PATH matches this package; the global command will execute`,
95
+ reason: `codex-bridge on PATH launches guards from this home (${probe.homeRoot})`,
92
96
  };
93
97
  }
94
- const reason = reachable
95
- ? `codex-bridge on PATH reports ${reachable}, not ${packageVersion}; the installed copy will execute`
96
- : 'codex-bridge is not reachable from PATH; the installed copy will execute';
97
- return { command: commandFor(target), form: 'path', reason };
98
+ return { command: commandFor(target), form: 'path', reason: `${probe.reason}; the installed copy will execute` };
98
99
  }
99
100
 
100
101
  export function commandForm(command) {
@@ -163,7 +164,9 @@ const hasOwnCommand = (group, commands) => {
163
164
  * deleted file. Conversely, the 2026-08-17 host proved that finding alone is insufficient: its
164
165
  * worktree lock stayed under the pre-shell matcher, so Bash never reached the guard. The command
165
166
  * is this package's own absolute path, so it identifies our entry wherever the group ended up,
166
- * and merge can move it without creating a duplicate.
167
+ * and merge can move it without creating a duplicate. Alternate commands identify our entry, but
168
+ * do not prove it is in the launcher form selected for this operation. Update must rewrite stale
169
+ * forms, or unsafe registrations already in settings.json would survive every future update.
167
170
  */
168
171
  export async function inspectHook(settingsPath, specOrPath) {
169
172
  const state = await readSettings(settingsPath);
@@ -172,11 +175,15 @@ export async function inspectHook(settingsPath, specOrPath) {
172
175
  let matchedCommand;
173
176
  let matchedMatcher;
174
177
  let requiresMove = false;
178
+ let requiresRewrite = false;
179
+ let duplicates = 0;
175
180
  for (const group of groups(state.settings, spec.event)) {
176
181
  for (const hook of groupHooks(group)) {
177
182
  if (hook?.type !== 'command' || !commands.includes(hook.command)) continue;
178
183
  matchedCommand ??= hook.command;
179
184
  matchedMatcher ??= group?.matcher;
185
+ duplicates += 1;
186
+ if (hook.command !== spec.command) requiresRewrite = true;
180
187
  if (group?.matcher !== spec.matcher) requiresMove = true;
181
188
  }
182
189
  }
@@ -187,6 +194,9 @@ export async function inspectHook(settingsPath, specOrPath) {
187
194
  matchedCommand,
188
195
  matchedMatcher,
189
196
  requiresMove,
197
+ requiresRewrite,
198
+ duplicates: duplicates > 1,
199
+ current: Boolean(matchedCommand) && !requiresMove && !requiresRewrite && duplicates === 1,
190
200
  };
191
201
  }
192
202
 
@@ -258,15 +268,17 @@ export async function mergeHook(settingsPath, specOrPath, inspected) {
258
268
  return withMutationRun(settingsPath, async () => {
259
269
  const spec = normalizeSpec(specOrPath);
260
270
  const state = inspected || await inspectHook(settingsPath, spec);
261
- if (state.present && !state.requiresMove) return { changed: false, createdGroup: false };
271
+ if (state.current) return { changed: false, createdGroup: false };
262
272
  const settings = structuredClone(state.settings);
263
273
  settings.hooks ??= {};
264
274
  if (!Array.isArray(settings.hooks[spec.event])) settings.hooks[spec.event] = [];
265
275
  if (state.present) {
266
276
  const eventGroups = settings.hooks[spec.event];
267
277
  const commands = [spec.command, ...spec.alternateCommands];
278
+ // The entry in the declared group wins, so a field the operator added there (a timeout)
279
+ // survives the rewrite; groups are walked from the end, which would otherwise pick a stale copy.
268
280
  let hookToMove;
269
- let keptDeclaredHook = false;
281
+ let declaredHook;
270
282
  for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
271
283
  const group = eventGroups[index];
272
284
  if (!Array.isArray(group?.hooks)) continue;
@@ -274,26 +286,21 @@ export async function mergeHook(settingsPath, specOrPath, inspected) {
274
286
  group.hooks = group.hooks.filter((hook) => {
275
287
  if (hook?.type !== 'command' || !commands.includes(hook.command)) return true;
276
288
  hookToMove ??= hook;
277
- if (group.matcher === spec.matcher && !keptDeclaredHook) {
278
- keptDeclaredHook = true;
279
- return true;
280
- }
289
+ if (group.matcher === spec.matcher) declaredHook ??= hook;
281
290
  return false;
282
291
  });
283
292
  if (hadOwnHook && group.hooks.length === 0) eventGroups.splice(index, 1);
284
293
  }
285
294
  let group = eventGroups.find((entry) => entry?.matcher === spec.matcher);
286
295
  const createdGroup = !group;
287
- if (!keptDeclaredHook) {
288
- if (!group) {
289
- group = { matcher: spec.matcher, hooks: [] };
290
- eventGroups.push(group);
291
- }
292
- if (!Array.isArray(group.hooks)) group.hooks = [];
293
- group.hooks.push(hookToMove);
296
+ if (!group) {
297
+ group = { matcher: spec.matcher, hooks: [] };
298
+ eventGroups.push(group);
294
299
  }
300
+ if (!Array.isArray(group.hooks)) group.hooks = [];
301
+ group.hooks.push({ ...(declaredHook || hookToMove), command: spec.command });
295
302
  await atomicWrite(settingsPath, settings, state);
296
- return { changed: true, createdGroup, moved: true };
303
+ return { changed: true, createdGroup, ...(state.requiresMove ? { moved: true } : {}), ...(state.requiresRewrite ? { rewritten: true } : {}) };
297
304
  }
298
305
  let group = settings.hooks[spec.event].find((entry) => entry?.matcher === spec.matcher);
299
306
  const createdGroup = !group;