@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/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,
|
|
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
|
|
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:
|
|
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
|
|
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.
|
|
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
|
-
?
|
|
205
|
-
:
|
|
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 =
|
|
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
|
+
}
|
package/cli/probe-contract.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
64
|
+
target,
|
|
118
65
|
runHost = spawnSync,
|
|
119
66
|
rigRoot = os.tmpdir(),
|
|
120
67
|
now = new Date(),
|
|
121
68
|
}) {
|
|
122
|
-
if (version
|
|
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
|
|
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
|
|
84
|
+
const token = randomUUID().slice(0, 8);
|
|
85
|
+
const rig = await buildRig(dir, token);
|
|
136
86
|
let hostResult;
|
|
137
87
|
try {
|
|
138
|
-
hostResult = runHost(
|
|
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
|
+
}
|
package/cli/settings-merge.mjs
CHANGED
|
@@ -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
|
|
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,
|
|
70
|
-
//
|
|
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}"
|
|
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,
|
|
77
|
-
|
|
78
|
-
|
|
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,
|
|
82
|
-
|
|
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
|
|
95
|
+
reason: `codex-bridge on PATH launches guards from this home (${probe.homeRoot})`,
|
|
92
96
|
};
|
|
93
97
|
}
|
|
94
|
-
|
|
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.
|
|
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
|
|
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
|
|
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 (!
|
|
288
|
-
|
|
289
|
-
|
|
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;
|