@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.
- package/CHANGELOG.md +79 -0
- package/README.md +7 -5
- package/bin/codex-bridge.mjs +186 -178
- package/cli/dispatcher-contract-record.mjs +51 -0
- package/cli/dispatcher-contract.mjs +90 -0
- package/cli/doctor-hooks.mjs +104 -114
- package/cli/doctor-installation.mjs +111 -111
- package/cli/doctor-runs.mjs +55 -55
- package/cli/doctor.mjs +53 -5
- package/cli/handback-witness-check.mjs +31 -0
- package/cli/hook-targets.mjs +14 -6
- package/cli/hook.mjs +32 -23
- package/cli/host-contract.mjs +145 -135
- package/cli/install.mjs +23 -17
- package/cli/launcher-probe.mjs +38 -0
- package/cli/probe-contract.mjs +55 -70
- package/cli/probe-rig.mjs +53 -0
- package/cli/probe-target.mjs +33 -0
- package/cli/run-launcher.mjs +17 -0
- package/cli/session-hosts.mjs +51 -0
- package/cli/settings-merge.mjs +42 -35
- package/cli/uninstall.mjs +16 -3
- package/cli/update.mjs +45 -16
- package/package.json +4 -4
- package/scripts/run-tests.mjs +7 -0
- package/src/agents/codex-advisor.md +3 -4
- package/src/agents/codex-build.md +5 -5
- package/src/agents/codex-review.md +3 -3
- package/src/agents/codex-scout.md +3 -4
- package/src/home/hooks/dispatcher-gate.mjs +132 -0
- package/src/home/hooks/guard-tries.mjs +5 -6
- package/src/home/hooks/live-runs.mjs +1 -0
- package/src/home/hooks/order-gate.mjs +20 -5
- package/src/home/hooks/reply-guard.mjs +95 -10
- package/src/home/hooks/reply-verdicts.mjs +5 -0
- package/src/home/hooks/worktree-lock.mjs +108 -107
- package/src/home/lib/atomic-json.mjs +21 -0
- package/src/home/lib/brand-home.mjs +6 -0
- package/src/home/lib/dispatcher-command.mjs +79 -0
- package/src/home/lib/dispatcher-gate.mjs +95 -0
- package/src/home/lib/dispatcher-order.mjs +30 -8
- package/src/home/lib/dispatcher-state.mjs +111 -0
- package/src/home/lib/dispatcher-stop.mjs +52 -0
- package/src/home/lib/handback-witness.mjs +70 -0
- package/src/home/lib/hook-definitions.mjs +10 -0
- package/src/home/lib/hook-entry.mjs +20 -0
- package/src/home/lib/host-observations.mjs +57 -0
- package/src/home/lib/host-version.mjs +27 -0
- package/src/home/lib/meta/advice-verdict.mjs +37 -21
- package/src/home/lib/required-inputs.mjs +1 -1
- package/src/home/lib/runner/codex-cmd.mjs +3 -2
- package/src/home/lib/runner/git-state.mjs +1 -1
- package/src/home/lib/runner/preflight.mjs +6 -3
- package/src/home/lib/runner/prompts.mjs +8 -7
- package/src/home/lib/runner/schemas.mjs +33 -7
package/cli/doctor-runs.mjs
CHANGED
|
@@ -1,55 +1,55 @@
|
|
|
1
|
-
/** Reports the state of run storage on this machine: location, live count, retention. */
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { readRunConfig, retentionNotice } from '../src/home/lib/run-config.mjs';
|
|
5
|
-
import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
|
|
6
|
-
import { resolveProjectRunsDir } from '../src/home/lib/runner/project-dir.mjs';
|
|
7
|
-
import { allLiveRuns } from '../src/home/hooks/live-runs.mjs';
|
|
8
|
-
import { STOP_COMMAND_TEMPLATE } from '../src/home/lib/stop-contract.mjs';
|
|
9
|
-
import { check } from './doctor-format.mjs';
|
|
10
|
-
|
|
11
|
-
export function retentionCheck(host) {
|
|
12
|
-
try {
|
|
13
|
-
const notice = retentionNotice(readRunConfig(host.brandConfigPath));
|
|
14
|
-
return check('retention', notice.enabled ? 'warn' : 'ok', notice.text);
|
|
15
|
-
} catch (err) {
|
|
16
|
-
return check('retention', 'fail', `invalid configuration: ${err.message}`);
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* The runner asks git for the repository root before it picks a runs folder, so doctor has to
|
|
22
|
-
* ask the same question: run from `src/home/lib/runner`, a plain cwd would name the folder `runner` and
|
|
23
|
-
* report a location no run will ever use.
|
|
24
|
-
*/
|
|
25
|
-
function repoRoot(cwd) {
|
|
26
|
-
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
|
|
27
|
-
return top.status === 0 && top.stdout.trim() ? top.stdout.trim() : cwd;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* A marker that cannot be read is exactly what doctor exists to report, so it is caught here.
|
|
32
|
-
* Left to propagate it would kill the whole diagnosis and hide the seven checks around it.
|
|
33
|
-
*/
|
|
34
|
-
export function projectRunsCheck() {
|
|
35
|
-
let resolved;
|
|
36
|
-
try {
|
|
37
|
-
resolved = resolveProjectRunsDir(runsRoot(), repoRoot(process.cwd()), { create: false });
|
|
38
|
-
} catch (err) {
|
|
39
|
-
return check('projectRuns', 'fail', err.message);
|
|
40
|
-
}
|
|
41
|
-
const note = resolved.reason === 'created' ? 'not created yet' : resolved.reason;
|
|
42
|
-
return check('projectRuns', 'ok', `${path.resolve(resolved.dir)} (${note})`);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function liveRunsCheck() {
|
|
46
|
-
let count;
|
|
47
|
-
try {
|
|
48
|
-
count = allLiveRuns(runsRoot(), { requireConfirmedIdentity: true }).length;
|
|
49
|
-
} catch (err) {
|
|
50
|
-
return check('liveRuns', 'warn', `working-run count unavailable: ${err.message}`);
|
|
51
|
-
}
|
|
52
|
-
if (!count) return check('liveRuns', 'ok', '0 runs working right now');
|
|
53
|
-
const noun = count === 1 ? 'run' : 'runs';
|
|
54
|
-
return check('liveRuns', 'warn', `${count} ${noun} working right now; stop with ${STOP_COMMAND_TEMPLATE}`);
|
|
55
|
-
}
|
|
1
|
+
/** Reports the state of run storage on this machine: location, live count, retention. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { readRunConfig, retentionNotice } from '../src/home/lib/run-config.mjs';
|
|
5
|
+
import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
|
|
6
|
+
import { resolveProjectRunsDir } from '../src/home/lib/runner/project-dir.mjs';
|
|
7
|
+
import { allLiveRuns } from '../src/home/hooks/live-runs.mjs';
|
|
8
|
+
import { STOP_COMMAND_TEMPLATE } from '../src/home/lib/stop-contract.mjs';
|
|
9
|
+
import { check } from './doctor-format.mjs';
|
|
10
|
+
|
|
11
|
+
export function retentionCheck(host) {
|
|
12
|
+
try {
|
|
13
|
+
const notice = retentionNotice(readRunConfig(host.brandConfigPath));
|
|
14
|
+
return check('retention', notice.enabled ? 'warn' : 'ok', notice.text);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return check('retention', 'fail', `invalid configuration: ${err.message}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The runner asks git for the repository root before it picks a runs folder, so doctor has to
|
|
22
|
+
* ask the same question: run from `src/home/lib/runner`, a plain cwd would name the folder `runner` and
|
|
23
|
+
* report a location no run will ever use.
|
|
24
|
+
*/
|
|
25
|
+
function repoRoot(cwd) {
|
|
26
|
+
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', windowsHide: true });
|
|
27
|
+
return top.status === 0 && top.stdout.trim() ? top.stdout.trim() : cwd;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A marker that cannot be read is exactly what doctor exists to report, so it is caught here.
|
|
32
|
+
* Left to propagate it would kill the whole diagnosis and hide the seven checks around it.
|
|
33
|
+
*/
|
|
34
|
+
export function projectRunsCheck() {
|
|
35
|
+
let resolved;
|
|
36
|
+
try {
|
|
37
|
+
resolved = resolveProjectRunsDir(runsRoot(), repoRoot(process.cwd()), { create: false });
|
|
38
|
+
} catch (err) {
|
|
39
|
+
return check('projectRuns', 'fail', err.message);
|
|
40
|
+
}
|
|
41
|
+
const note = resolved.reason === 'created' ? 'not created yet' : resolved.reason;
|
|
42
|
+
return check('projectRuns', 'ok', `${path.resolve(resolved.dir)} (${note})`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function liveRunsCheck() {
|
|
46
|
+
let count;
|
|
47
|
+
try {
|
|
48
|
+
count = allLiveRuns(runsRoot(), { requireConfirmedIdentity: true }).length;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return check('liveRuns', 'warn', `working-run count unavailable: ${err.message}`);
|
|
51
|
+
}
|
|
52
|
+
if (!count) return check('liveRuns', 'ok', '0 runs working right now');
|
|
53
|
+
const noun = count === 1 ? 'run' : 'runs';
|
|
54
|
+
return check('liveRuns', 'warn', `${count} ${noun} working right now; stop with ${STOP_COMMAND_TEMPLATE}`);
|
|
55
|
+
}
|
package/cli/doctor.mjs
CHANGED
|
@@ -16,7 +16,15 @@ import {
|
|
|
16
16
|
rulesCheck,
|
|
17
17
|
} from './doctor-installation.mjs';
|
|
18
18
|
import { hookChecks } from './doctor-hooks.mjs';
|
|
19
|
-
import { contractStatus,
|
|
19
|
+
import { contractStatus, readHostContract } from './host-contract.mjs';
|
|
20
|
+
import { handbackWitnessStatus } from './handback-witness-check.mjs';
|
|
21
|
+
import { dispatcherContractStatus } from './dispatcher-contract.mjs';
|
|
22
|
+
import { DISPATCHER_CONTRACT_FILE, readDispatcherContract } from './dispatcher-contract-record.mjs';
|
|
23
|
+
import { PROBE_COMMAND } from './host-contract.mjs';
|
|
24
|
+
import { readHandbackWitness } from '../src/home/lib/handback-witness.mjs';
|
|
25
|
+
import { brandStateDir } from '../src/home/lib/brand-home.mjs';
|
|
26
|
+
import { readHostObservations } from '../src/home/lib/host-observations.mjs';
|
|
27
|
+
import { otherHostCheck, sessionHostCheck, sessionHostVersions } from './session-hosts.mjs';
|
|
20
28
|
import { liveRunsCheck, projectRunsCheck, retentionCheck } from './doctor-runs.mjs';
|
|
21
29
|
|
|
22
30
|
export { renderDoctor };
|
|
@@ -52,13 +60,30 @@ export function probeCodexBridge() {
|
|
|
52
60
|
return { available: true, value: (result.stdout || result.stderr).trim() };
|
|
53
61
|
}
|
|
54
62
|
|
|
63
|
+
// Plan_62 D19: `changed` fails because the dispatcher gate rests on that contract; everything short of a
|
|
64
|
+
// measured break only warns. A corrupt record gets one line: the probe refuses to overwrite it.
|
|
65
|
+
const DISPATCHER_CONTRACT_STATUS = { verified: 'ok', changed: 'fail' };
|
|
66
|
+
|
|
67
|
+
function dispatcherContractChecks({ record, version, stateDir }) {
|
|
68
|
+
if (record?.corrupt) {
|
|
69
|
+
return [check('dispatcherContract', 'warn',
|
|
70
|
+
`The dispatcher contract record is unreadable; delete ${path.join(stateDir, DISPATCHER_CONTRACT_FILE)}, then run ${PROBE_COMMAND}.`)];
|
|
71
|
+
}
|
|
72
|
+
return dispatcherContractStatus({ record, version }).map(({ contract, state, message }) =>
|
|
73
|
+
check(`dispatcherContract:${contract}`, DISPATCHER_CONTRACT_STATUS[state] ?? 'warn', message));
|
|
74
|
+
}
|
|
75
|
+
|
|
55
76
|
export async function diagnose({
|
|
56
77
|
host,
|
|
57
78
|
codexProbe = probeCodex,
|
|
58
79
|
bridgeProbe = probeCodexBridge,
|
|
80
|
+
launcherProbe,
|
|
59
81
|
currentPackage,
|
|
60
82
|
contractRecord,
|
|
83
|
+
handbackWitnessRecord,
|
|
84
|
+
dispatcherContractRecord,
|
|
61
85
|
hostVersion,
|
|
86
|
+
observations,
|
|
62
87
|
} = {}) {
|
|
63
88
|
const checks = [sourceCheck()];
|
|
64
89
|
const hostExists = await exists(host.root);
|
|
@@ -101,15 +126,36 @@ export async function diagnose({
|
|
|
101
126
|
checks.push(await permissionsCheck(host));
|
|
102
127
|
const bridge = bridgeProbe();
|
|
103
128
|
checks.push(bridgeCommandCheck(bridge));
|
|
104
|
-
checks.push(...await hookChecks(host, record,
|
|
129
|
+
checks.push(...await hookChecks(host, record, launcherProbe));
|
|
130
|
+
const stateDir = brandStateDir(host.brandRoot);
|
|
131
|
+
const hostObservations = observations === undefined ? readHostObservations({ stateDir }) : observations;
|
|
132
|
+
const observedVersions = sessionHostVersions(hostObservations);
|
|
133
|
+
const detectedHostVersion = hostVersion === undefined ? observedVersions[0] ?? null : hostVersion;
|
|
134
|
+
checks.push(sessionHostCheck(observedVersions, hostObservations));
|
|
135
|
+
const refusalRecord = contractRecord === undefined ? await readHostContract(host) : contractRecord;
|
|
105
136
|
const hostContract = contractStatus({
|
|
106
|
-
record:
|
|
107
|
-
version:
|
|
137
|
+
record: refusalRecord,
|
|
138
|
+
version: detectedHostVersion,
|
|
108
139
|
});
|
|
109
140
|
const hostContractStatus = hostContract.state === 'verified'
|
|
110
141
|
? 'ok'
|
|
111
142
|
: hostContract.state === 'ignored' ? 'fail' : 'warn';
|
|
112
143
|
checks.push(check('hostContract', hostContractStatus, hostContract.message));
|
|
144
|
+
const witness = handbackWitnessStatus({
|
|
145
|
+
record: handbackWitnessRecord === undefined ? readHandbackWitness({ stateDir }) : handbackWitnessRecord,
|
|
146
|
+
hostVersion: detectedHostVersion,
|
|
147
|
+
stateDir,
|
|
148
|
+
});
|
|
149
|
+
checks.push(check('handbackWitness', ['seen', 'unobserved'].includes(witness.state) ? 'ok' : 'warn', witness.message));
|
|
150
|
+
const dispatcherRecord = dispatcherContractRecord === undefined ? readDispatcherContract({ stateDir }) : dispatcherContractRecord;
|
|
151
|
+
checks.push(...dispatcherContractChecks({
|
|
152
|
+
record: dispatcherRecord,
|
|
153
|
+
version: detectedHostVersion,
|
|
154
|
+
stateDir,
|
|
155
|
+
}));
|
|
156
|
+
for (const version of observedVersions.filter((item) => item !== detectedHostVersion)) {
|
|
157
|
+
checks.push(otherHostCheck({ version, contractRecord: refusalRecord, dispatcherRecord }));
|
|
158
|
+
}
|
|
113
159
|
const retention = retentionCheck(host);
|
|
114
160
|
checks.push(retention);
|
|
115
161
|
const conventions = await conventionsCheck(host);
|
|
@@ -127,7 +173,9 @@ export async function diagnose({
|
|
|
127
173
|
return {
|
|
128
174
|
exitCode: !record || recordBroken || missingFiles.length || agents.status === 'fail' || rules.status === 'fail'
|
|
129
175
|
|| hostContractStatus === 'fail' || retention.status === 'fail' || conventions.status === 'fail'
|
|
130
|
-
|| projectRuns.status === 'fail'
|
|
176
|
+
|| projectRuns.status === 'fail'
|
|
177
|
+
|| checks.some((item) => /^(hook|dispatcherContract):/.test(item.key) && item.status === 'fail')
|
|
178
|
+
|| checks.some((item) => item.key.startsWith('otherHost:') && item.status === 'fail') ? 1 : 0,
|
|
131
179
|
checks,
|
|
132
180
|
record,
|
|
133
181
|
missingFiles,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Surfaces handback drift; 2026-09-25 established exact host transcript versions as identity. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { WITNESS_FILE } from '../src/home/lib/handback-witness.mjs';
|
|
4
|
+
|
|
5
|
+
export function handbackWitnessStatus({ record, hostVersion, stateDir }) {
|
|
6
|
+
if (record?.corrupt) {
|
|
7
|
+
return { state: 'unreadable', message: `The handback witness is unreadable; delete ${path.join(stateDir, WITNESS_FILE)} to reset it.` };
|
|
8
|
+
}
|
|
9
|
+
if (record?.alarms?.length > 0) {
|
|
10
|
+
const alarm = record.alarms.at(-1);
|
|
11
|
+
return {
|
|
12
|
+
state: 'alarm',
|
|
13
|
+
message: `${record.alarms.length} dispatcher alarm(s); newest on ${alarm.hostVersion ?? 'unknown host'}: ${alarm.detail} at ${alarm.at}; do not trust a dispatcher answer from that time; see Plan_62 D15.`,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
if (!record?.lastSeen || Object.keys(record.lastSeen).length === 0) {
|
|
17
|
+
return { state: 'unobserved', message: 'Not observed yet — a handback is seen only when a dispatcher runs in an interactive session.' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (record.lastSeen[hostVersion]) {
|
|
21
|
+
return { state: 'seen', message: `Handback contract last seen on host ${hostVersion} at ${record.lastSeen[hostVersion]}.` };
|
|
22
|
+
}
|
|
23
|
+
const [newest, iso] = Object.entries(record.lastSeen).sort((left, right) => right[1].localeCompare(left[1]))[0];
|
|
24
|
+
if (newest) {
|
|
25
|
+
return {
|
|
26
|
+
state: 'stale',
|
|
27
|
+
message: `Handback contract last seen on host ${newest} at ${iso}, not yet on host ${hostVersion}; it is recorded the next time a dispatcher runs in an interactive session on that host.`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return { state: 'unobserved', message: 'Not observed yet — a handback is seen only when a dispatcher runs in an interactive session.' };
|
|
31
|
+
}
|
package/cli/hook-targets.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Describes where each guard is installed and how its registration will be spelled. */
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { HOOK_DEFINITIONS, installedHookPath } from './manifest.mjs';
|
|
4
|
+
import { probeHookLauncher } from './launcher-probe.mjs';
|
|
4
5
|
import { commandFor, hookRegistration } from './settings-merge.mjs';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -9,15 +10,15 @@ import { commandFor, hookRegistration } from './settings-merge.mjs';
|
|
|
9
10
|
* list that decides what goes into the operator's settings.json is the same shape of defect
|
|
10
11
|
* Plan_19 had to reconcile between the installer and the hooks.
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* and took every guard on the machine down with it.
|
|
13
|
+
* The short command form requires proof that the PATH launcher dispatches from this home. On
|
|
14
|
+
* 2026-08-11, checking only that a command existed registered hooks against a release without the
|
|
15
|
+
* hook subcommand; on 2026-09-24, matching versions also failed for unreleased clone code.
|
|
16
16
|
*/
|
|
17
|
-
export function hookTargets(host, env = process.env
|
|
17
|
+
export function hookTargets(host, env = process.env) {
|
|
18
|
+
const probe = probeHookLauncher({ env, brandRoot: host.brandRoot });
|
|
18
19
|
return HOOK_DEFINITIONS.map((definition) => {
|
|
19
20
|
const target = installedHookPath(host, definition);
|
|
20
|
-
const registration = hookRegistration(definition.name, target,
|
|
21
|
+
const registration = hookRegistration(definition.name, target, probe);
|
|
21
22
|
const fallback = commandFor(target);
|
|
22
23
|
const alternate = registration.command === fallback
|
|
23
24
|
? `codex-bridge hook ${definition.name}`
|
|
@@ -37,3 +38,10 @@ export function hookTargets(host, env = process.env, packageVersion = null) {
|
|
|
37
38
|
};
|
|
38
39
|
});
|
|
39
40
|
}
|
|
41
|
+
|
|
42
|
+
export function recordHasHooks(record, targets) {
|
|
43
|
+
return Boolean(record)
|
|
44
|
+
&& targets.every(({ definition, relative, spec }) => record.hooks?.some((hook) =>
|
|
45
|
+
hook.event === definition.event && hook.root === 'brand' && hook.path === relative
|
|
46
|
+
&& hook.command === spec.command));
|
|
47
|
+
}
|
package/cli/hook.mjs
CHANGED
|
@@ -1,34 +1,43 @@
|
|
|
1
|
-
/** Dispatches a named host hook
|
|
1
|
+
/** Dispatches a named host hook from the installed brand home. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
2
4
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { resolveBrandHome } from '../src/home/lib/brand-home.mjs';
|
|
5
7
|
|
|
6
|
-
const
|
|
7
|
-
const VALID_NAMES = HOOK_DEFINITIONS.map(({ name }) => name);
|
|
8
|
+
export const HOOK_LAUNCHER_PROTOCOL = 1;
|
|
8
9
|
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
function usageError(io, message) {
|
|
14
|
-
io.error(`codex-bridge hook ${message} Valid hook names: ${validNames()}.`);
|
|
15
|
-
return 2;
|
|
10
|
+
function reportFailure(io, name, reason) {
|
|
11
|
+
io.error(`codex-bridge hook: guard "${name}" did not run: ${reason}. Run codex-bridge doctor.`);
|
|
12
|
+
return 1;
|
|
16
13
|
}
|
|
17
14
|
|
|
18
15
|
export async function hook(args = [], io = console) {
|
|
19
16
|
if (!Array.isArray(args) || args.length !== 1) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
: 'requires exactly one hook name.';
|
|
23
|
-
return usageError(io, detail);
|
|
17
|
+
io.error('Usage: codex-bridge hook <name> | codex-bridge hook --home');
|
|
18
|
+
return 1;
|
|
24
19
|
}
|
|
25
20
|
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
21
|
+
const home = resolveBrandHome({ homedir: os.homedir(), env: process.env });
|
|
22
|
+
if (args[0] === '--home') {
|
|
23
|
+
io.log(JSON.stringify({ protocol: HOOK_LAUNCHER_PROTOCOL, dispatch: 'home', homeRoot: home.root }));
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
if (!args[0] || args[0].startsWith('-')) {
|
|
27
|
+
io.error('Usage: codex-bridge hook <name> | codex-bridge hook --home');
|
|
28
|
+
return 1;
|
|
29
|
+
}
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
const name = args[0];
|
|
32
|
+
const entry = path.join(home.root, 'lib', 'hook-entry.mjs');
|
|
33
|
+
if (!fs.existsSync(entry)) {
|
|
34
|
+
io.error(`codex-bridge hook: guard "${name}" did not run: codex-bridge is not installed in ${home.root}. Run codex-bridge install.`);
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const { runHook } = await import(pathToFileURL(entry).href);
|
|
39
|
+
return await runHook(name, io);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return reportFailure(io, name, error?.message ?? String(error));
|
|
42
|
+
}
|
|
34
43
|
}
|
package/cli/host-contract.mjs
CHANGED
|
@@ -1,135 +1,145 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Records whether the installed Claude Code host still honours hook refusals.
|
|
3
|
-
*
|
|
4
|
-
* Every guard here refuses work with `permissionDecision: "deny"`, and the sibling key of that same
|
|
5
|
-
* object died quietly: a neighbouring package established that `"allow"` stopped being honoured
|
|
6
|
-
* somewhere between host 2.1.119 and 2.1.231, with no error printed on the way. Nothing in this
|
|
7
|
-
* package would notice `deny` going the same way — the suite checks that a hook RETURNED a refusal,
|
|
8
|
-
* never that the host APPLIED it. The record is bound to a host version because that is what
|
|
9
|
-
* changes underneath an installation: the host updates itself, the package does not.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import
|
|
14
|
-
import
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
export
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
result: record.result,
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
await
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Records whether the installed Claude Code host still honours hook refusals.
|
|
3
|
+
*
|
|
4
|
+
* Every guard here refuses work with `permissionDecision: "deny"`, and the sibling key of that same
|
|
5
|
+
* object died quietly: a neighbouring package established that `"allow"` stopped being honoured
|
|
6
|
+
* somewhere between host 2.1.119 and 2.1.231, with no error printed on the way. Nothing in this
|
|
7
|
+
* package would notice `deny` going the same way — the suite checks that a hook RETURNED a refusal,
|
|
8
|
+
* never that the host APPLIED it. The record is bound to a host version because that is what
|
|
9
|
+
* changes underneath an installation: the host updates itself, the package does not. The
|
|
10
|
+
* 2026-09-25 incident ran VS Code host 2.1.282 beside CLI host 2.1.281, so each version keeps
|
|
11
|
+
* its own verdict and probing either must preserve the other.
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import fs from 'node:fs/promises';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { CLI_NAMES } from '../src/home/lib/cli-names.mjs';
|
|
18
|
+
import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
|
|
19
|
+
import { readJsonFile } from '../src/home/lib/json-file.mjs';
|
|
20
|
+
|
|
21
|
+
export const HOST_CONTRACT_RECORD_NAME = '.host-contract.json';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The command that runs the probe, kept here so every message gains it in one edit.
|
|
25
|
+
*
|
|
26
|
+
* It stayed unnamed in the messages until the command existed: a package that sends an operator to
|
|
27
|
+
* a command answering `unknown doctor option` has spent its credibility on the first line they
|
|
28
|
+
* read. The advice arrived in the same change as the command, and only in the states a probe can
|
|
29
|
+
* actually change — a message that advises a run which would tell you nothing new is noise.
|
|
30
|
+
*
|
|
31
|
+
* Derived rather than spelled out: Plan_17 §5 found installer and hook lists drifting apart once
|
|
32
|
+
* the same name lived in two places. `CLI_NAMES[0]` is the canonical spelling.
|
|
33
|
+
*/
|
|
34
|
+
export const PROBE_COMMAND = `${CLI_NAMES[0]} doctor --probe-contract`;
|
|
35
|
+
|
|
36
|
+
export function hostContractPath(host) {
|
|
37
|
+
if (!host?.brandRoot) throw new Error('host has no brand installation root');
|
|
38
|
+
return path.join(host.brandRoot, HOST_CONTRACT_RECORD_NAME);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function readHostContract(host) {
|
|
42
|
+
const target = hostContractPath(host);
|
|
43
|
+
try {
|
|
44
|
+
const record = await readJsonFile(target);
|
|
45
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) return null;
|
|
46
|
+
if (record.hosts && typeof record.hosts === 'object' && !Array.isArray(record.hosts)) return record;
|
|
47
|
+
if (typeof record.version === 'string' && record.version) {
|
|
48
|
+
return { hosts: { [record.version]: { result: record.result, checkedAt: record.checkedAt } } };
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function writeHostContract(host, { version, result, now = new Date() }) {
|
|
57
|
+
if (typeof version !== 'string' || !version) {
|
|
58
|
+
throw new Error('host contract version must be a non-empty string');
|
|
59
|
+
}
|
|
60
|
+
if (result !== 'honored' && result !== 'ignored') {
|
|
61
|
+
throw new Error('host contract result must be honored or ignored');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const target = hostContractPath(host);
|
|
65
|
+
const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
66
|
+
const previous = await readHostContract(host);
|
|
67
|
+
const cutoff = now.getTime() - 180 * 24 * 60 * 60 * 1000;
|
|
68
|
+
const hosts = Object.fromEntries(Object.entries(previous?.hosts ?? {}).filter(([, entry]) =>
|
|
69
|
+
Number.isFinite(Date.parse(entry?.checkedAt)) && Date.parse(entry.checkedAt) >= cutoff));
|
|
70
|
+
hosts[version] = { result, checkedAt: now.toISOString() };
|
|
71
|
+
const record = { hosts };
|
|
72
|
+
await fs.mkdir(host.brandRoot, { recursive: true });
|
|
73
|
+
try {
|
|
74
|
+
await fs.writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
|
75
|
+
await fs.rename(temporary, target);
|
|
76
|
+
} finally {
|
|
77
|
+
await fs.rm(temporary, { force: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function parseHostVersion(text) {
|
|
82
|
+
if (typeof text !== 'string') return null;
|
|
83
|
+
return text.trim().match(/^(\d+\.\d+\.\d+)(?:\s|$)/)?.[1] ?? null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function detectHostVersion({ run = spawnSync, executable = 'claude' } = {}) {
|
|
87
|
+
try {
|
|
88
|
+
const result = run(executable, ['--version'], {
|
|
89
|
+
encoding: 'utf8',
|
|
90
|
+
shell: false,
|
|
91
|
+
timeout: 5000,
|
|
92
|
+
windowsHide: true,
|
|
93
|
+
});
|
|
94
|
+
if (!result || result.error || result.status !== 0) return null;
|
|
95
|
+
return parseHostVersion(result.stdout);
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function contractStatus({ record, version }) {
|
|
102
|
+
// Loose equality on purpose: a caller that forgot the field hands over `undefined`, and the
|
|
103
|
+
// stale branch below would then report "the current host is undefined" as if it had measured it.
|
|
104
|
+
if (version == null) {
|
|
105
|
+
return {
|
|
106
|
+
state: 'unknown-host',
|
|
107
|
+
message: 'The host version could not be read, so the refusal contract cannot be judged.',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (!record) {
|
|
111
|
+
return {
|
|
112
|
+
state: 'unverified',
|
|
113
|
+
message: `The refusal contract has never been probed on this machine (host ${version}); run ${PROBE_COMMAND}.`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const entry = record.hosts?.[version];
|
|
117
|
+
if (!entry && Object.keys(record.hosts ?? {}).length) {
|
|
118
|
+
const [otherVersion] = Object.entries(record.hosts).sort((a, b) => Date.parse(b[1]?.checkedAt) - Date.parse(a[1]?.checkedAt))[0];
|
|
119
|
+
return {
|
|
120
|
+
state: 'stale',
|
|
121
|
+
message: `The refusal contract was probed on host ${otherVersion}, but the current host is ${version}; run ${PROBE_COMMAND}.`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (!entry) return { state: 'unverified', message: `The refusal contract has never been probed on this machine (host ${version}); run ${PROBE_COMMAND}.` };
|
|
125
|
+
if (entry.result === 'ignored') {
|
|
126
|
+
const guards = HOOK_DEFINITIONS
|
|
127
|
+
.filter((definition) => definition.event === 'PreToolUse')
|
|
128
|
+
.map((definition) => definition.name)
|
|
129
|
+
.join(', ');
|
|
130
|
+
return {
|
|
131
|
+
state: 'ignored',
|
|
132
|
+
message: `DANGER: Host ${version} does not honour hook refusals, so every guard is inert: ${guards}. Re-run ${PROBE_COMMAND} once the host updates.`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (entry.result === 'honored') {
|
|
136
|
+
return {
|
|
137
|
+
state: 'verified',
|
|
138
|
+
message: `Host ${version} honours hook refusals (verified ${entry.checkedAt}).`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
state: 'unverified',
|
|
143
|
+
message: `The refusal contract record is invalid (host ${version}); run ${PROBE_COMMAND}.`,
|
|
144
|
+
};
|
|
145
|
+
}
|