@lyupro/codex-bridge 0.6.6 → 0.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +5 -5
- package/bin/codex-bridge.mjs +181 -178
- package/cli/dispatcher-contract-record.mjs +42 -0
- package/cli/dispatcher-contract.mjs +89 -0
- package/cli/doctor-hooks.mjs +104 -114
- package/cli/doctor-runs.mjs +55 -55
- package/cli/doctor.mjs +39 -3
- package/cli/handback-witness-check.mjs +39 -0
- package/cli/hook-targets.mjs +14 -6
- package/cli/hook.mjs +32 -23
- package/cli/host-contract.mjs +136 -135
- package/cli/install.mjs +9 -14
- package/cli/launcher-probe.mjs +38 -0
- package/cli/probe-contract.mjs +31 -62
- package/cli/probe-rig.mjs +53 -0
- package/cli/run-launcher.mjs +17 -0
- package/cli/settings-merge.mjs +42 -35
- package/cli/uninstall.mjs +15 -3
- package/cli/update.mjs +45 -16
- package/package.json +4 -4
- 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 +119 -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 +75 -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 +65 -0
- package/src/home/lib/hook-definitions.mjs +10 -0
- package/src/home/lib/hook-entry.mjs +20 -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/host-contract.mjs
CHANGED
|
@@ -1,135 +1,136 @@
|
|
|
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
|
-
import { spawnSync } from 'node:child_process';
|
|
12
|
-
import { randomUUID } from 'node:crypto';
|
|
13
|
-
import fs from 'node:fs/promises';
|
|
14
|
-
import path from 'node:path';
|
|
15
|
-
import { CLI_NAMES } from '../src/home/lib/cli-names.mjs';
|
|
16
|
-
import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
|
|
17
|
-
import { readJsonFile } from '../src/home/lib/json-file.mjs';
|
|
18
|
-
|
|
19
|
-
export const HOST_CONTRACT_RECORD_NAME = '.host-contract.json';
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* The command that runs the probe, kept here so every message gains it in one edit.
|
|
23
|
-
*
|
|
24
|
-
* It stayed unnamed in the messages until the command existed: a package that sends an operator to
|
|
25
|
-
* a command answering `unknown doctor option` has spent its credibility on the first line they
|
|
26
|
-
* read. The advice arrived in the same change as the command, and only in the states a probe can
|
|
27
|
-
* actually change — a message that advises a run which would tell you nothing new is noise.
|
|
28
|
-
*
|
|
29
|
-
* Derived rather than spelled out: Plan_17 §5 found installer and hook lists drifting apart once
|
|
30
|
-
* the same name lived in two places. `CLI_NAMES[0]` is the canonical spelling.
|
|
31
|
-
*/
|
|
32
|
-
export const PROBE_COMMAND = `${CLI_NAMES[0]} doctor --probe-contract`;
|
|
33
|
-
|
|
34
|
-
export function hostContractPath(host) {
|
|
35
|
-
if (!host?.brandRoot) throw new Error('host has no brand installation root');
|
|
36
|
-
return path.join(host.brandRoot, HOST_CONTRACT_RECORD_NAME);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export async function readHostContract(host) {
|
|
40
|
-
const target = hostContractPath(host);
|
|
41
|
-
try {
|
|
42
|
-
const record = await readJsonFile(target);
|
|
43
|
-
if (!record || typeof record !== 'object' || Array.isArray(record)
|
|
44
|
-
|| typeof record.version !== 'string' || !record.version) return null;
|
|
45
|
-
return {
|
|
46
|
-
version: record.version,
|
|
47
|
-
checkedAt: record.checkedAt,
|
|
48
|
-
result: record.result,
|
|
49
|
-
};
|
|
50
|
-
} catch {
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export async function writeHostContract(host, { version, result, now = new Date() }) {
|
|
56
|
-
if (typeof version !== 'string' || !version) {
|
|
57
|
-
throw new Error('host contract version must be a non-empty string');
|
|
58
|
-
}
|
|
59
|
-
if (result !== 'honored' && result !== 'ignored') {
|
|
60
|
-
throw new Error('host contract result must be honored or ignored');
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const target = hostContractPath(host);
|
|
64
|
-
const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
65
|
-
const record = { version, result, checkedAt: now.toISOString() };
|
|
66
|
-
await fs.mkdir(host.brandRoot, { recursive: true });
|
|
67
|
-
try {
|
|
68
|
-
await fs.writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
|
69
|
-
await fs.rename(temporary, target);
|
|
70
|
-
} finally {
|
|
71
|
-
await fs.rm(temporary, { force: true });
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function parseHostVersion(text) {
|
|
76
|
-
if (typeof text !== 'string') return null;
|
|
77
|
-
return text.trim().match(/^(\d+\.\d+\.\d+)(?:\s|$)/)?.[1] ?? null;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function detectHostVersion({ run = spawnSync } = {}) {
|
|
81
|
-
try {
|
|
82
|
-
const result = run('claude', ['--version'], {
|
|
83
|
-
encoding: 'utf8',
|
|
84
|
-
shell: false,
|
|
85
|
-
timeout: 5000,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
if
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
.
|
|
119
|
-
.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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.
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { randomUUID } from 'node:crypto';
|
|
13
|
+
import fs from 'node:fs/promises';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { CLI_NAMES } from '../src/home/lib/cli-names.mjs';
|
|
16
|
+
import { HOOK_DEFINITIONS } from '../src/home/lib/hook-definitions.mjs';
|
|
17
|
+
import { readJsonFile } from '../src/home/lib/json-file.mjs';
|
|
18
|
+
|
|
19
|
+
export const HOST_CONTRACT_RECORD_NAME = '.host-contract.json';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The command that runs the probe, kept here so every message gains it in one edit.
|
|
23
|
+
*
|
|
24
|
+
* It stayed unnamed in the messages until the command existed: a package that sends an operator to
|
|
25
|
+
* a command answering `unknown doctor option` has spent its credibility on the first line they
|
|
26
|
+
* read. The advice arrived in the same change as the command, and only in the states a probe can
|
|
27
|
+
* actually change — a message that advises a run which would tell you nothing new is noise.
|
|
28
|
+
*
|
|
29
|
+
* Derived rather than spelled out: Plan_17 §5 found installer and hook lists drifting apart once
|
|
30
|
+
* the same name lived in two places. `CLI_NAMES[0]` is the canonical spelling.
|
|
31
|
+
*/
|
|
32
|
+
export const PROBE_COMMAND = `${CLI_NAMES[0]} doctor --probe-contract`;
|
|
33
|
+
|
|
34
|
+
export function hostContractPath(host) {
|
|
35
|
+
if (!host?.brandRoot) throw new Error('host has no brand installation root');
|
|
36
|
+
return path.join(host.brandRoot, HOST_CONTRACT_RECORD_NAME);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function readHostContract(host) {
|
|
40
|
+
const target = hostContractPath(host);
|
|
41
|
+
try {
|
|
42
|
+
const record = await readJsonFile(target);
|
|
43
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)
|
|
44
|
+
|| typeof record.version !== 'string' || !record.version) return null;
|
|
45
|
+
return {
|
|
46
|
+
version: record.version,
|
|
47
|
+
checkedAt: record.checkedAt,
|
|
48
|
+
result: record.result,
|
|
49
|
+
};
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function writeHostContract(host, { version, result, now = new Date() }) {
|
|
56
|
+
if (typeof version !== 'string' || !version) {
|
|
57
|
+
throw new Error('host contract version must be a non-empty string');
|
|
58
|
+
}
|
|
59
|
+
if (result !== 'honored' && result !== 'ignored') {
|
|
60
|
+
throw new Error('host contract result must be honored or ignored');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const target = hostContractPath(host);
|
|
64
|
+
const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
65
|
+
const record = { version, result, checkedAt: now.toISOString() };
|
|
66
|
+
await fs.mkdir(host.brandRoot, { recursive: true });
|
|
67
|
+
try {
|
|
68
|
+
await fs.writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
|
69
|
+
await fs.rename(temporary, target);
|
|
70
|
+
} finally {
|
|
71
|
+
await fs.rm(temporary, { force: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function parseHostVersion(text) {
|
|
76
|
+
if (typeof text !== 'string') return null;
|
|
77
|
+
return text.trim().match(/^(\d+\.\d+\.\d+)(?:\s|$)/)?.[1] ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function detectHostVersion({ run = spawnSync } = {}) {
|
|
81
|
+
try {
|
|
82
|
+
const result = run('claude', ['--version'], {
|
|
83
|
+
encoding: 'utf8',
|
|
84
|
+
shell: false,
|
|
85
|
+
timeout: 5000,
|
|
86
|
+
windowsHide: true,
|
|
87
|
+
});
|
|
88
|
+
if (!result || result.error || result.status !== 0) return null;
|
|
89
|
+
return parseHostVersion(result.stdout);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function contractStatus({ record, version }) {
|
|
96
|
+
// Loose equality on purpose: a caller that forgot the field hands over `undefined`, and the
|
|
97
|
+
// stale branch below would then report "the current host is undefined" as if it had measured it.
|
|
98
|
+
if (version == null) {
|
|
99
|
+
return {
|
|
100
|
+
state: 'unknown-host',
|
|
101
|
+
message: 'The host version could not be read, so the refusal contract cannot be judged.',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (!record) {
|
|
105
|
+
return {
|
|
106
|
+
state: 'unverified',
|
|
107
|
+
message: `The refusal contract has never been probed on this machine (host ${version}); run ${PROBE_COMMAND}.`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (record.version !== version) {
|
|
111
|
+
return {
|
|
112
|
+
state: 'stale',
|
|
113
|
+
message: `The refusal contract was probed on host ${record.version}, but the current host is ${version}; run ${PROBE_COMMAND}.`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (record.result === 'ignored') {
|
|
117
|
+
const guards = HOOK_DEFINITIONS
|
|
118
|
+
.filter((definition) => definition.event === 'PreToolUse')
|
|
119
|
+
.map((definition) => definition.name)
|
|
120
|
+
.join(', ');
|
|
121
|
+
return {
|
|
122
|
+
state: 'ignored',
|
|
123
|
+
message: `DANGER: Host ${version} does not honour hook refusals, so every guard is inert: ${guards}. Re-run ${PROBE_COMMAND} once the host updates.`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (record.result === 'honored') {
|
|
127
|
+
return {
|
|
128
|
+
state: 'verified',
|
|
129
|
+
message: `Host ${version} honours hook refusals (verified ${record.checkedAt}).`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
state: 'unverified',
|
|
134
|
+
message: `The refusal contract record is invalid (host ${version}); run ${PROBE_COMMAND}.`,
|
|
135
|
+
};
|
|
136
|
+
}
|
package/cli/install.mjs
CHANGED
|
@@ -15,11 +15,10 @@ 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,
|
|
@@ -42,12 +41,6 @@ async function targetExists(target) {
|
|
|
42
41
|
}
|
|
43
42
|
}
|
|
44
43
|
|
|
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
44
|
function retentionLine(host) {
|
|
52
45
|
const notice = retentionNotice(readRunConfig(host.brandConfigPath));
|
|
53
46
|
return notice.enabled ? `${WARNING}${notice.text}${RESET}` : notice.text;
|
|
@@ -128,7 +121,7 @@ async function installInRun({
|
|
|
128
121
|
const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
|
|
129
122
|
const currentPackage = await packageInfo(packageRoot);
|
|
130
123
|
const record = await readInstallRecord(host);
|
|
131
|
-
const targets = hookTargets(host, env
|
|
124
|
+
const targets = hookTargets(host, env);
|
|
132
125
|
for (const target of targets) {
|
|
133
126
|
if (!plan.some((item) => item.root === 'brand' && item.relativeToRoot === target.relative)) {
|
|
134
127
|
throw new Error(`install plan does not contain hooks/${target.definition.file}`);
|
|
@@ -168,7 +161,7 @@ async function installInRun({
|
|
|
168
161
|
const sameRecord = recordMatchesPackage(record, plan, currentPackage,
|
|
169
162
|
new Map(states.map((state) => [recordFileKey(state.item), state.fingerprint])),
|
|
170
163
|
{ path: rule.target, fingerprint: ruleState.fingerprint });
|
|
171
|
-
if (!changedFiles.length && !changedRule && inspectedHooks.every((state) => state.
|
|
164
|
+
if (!changedFiles.length && !changedRule && inspectedHooks.every((state) => state.current)
|
|
172
165
|
&& recordHasHooks(record, targets) && sameRecord) {
|
|
173
166
|
if (!dryRun) {
|
|
174
167
|
await addRulesOwner(host);
|
|
@@ -200,9 +193,11 @@ async function installInRun({
|
|
|
200
193
|
}
|
|
201
194
|
inspectedHooks.forEach((state, index) => {
|
|
202
195
|
const { definition, registration } = targets[index];
|
|
203
|
-
lines.push(state.present
|
|
204
|
-
?
|
|
205
|
-
:
|
|
196
|
+
lines.push(state.present && !state.current
|
|
197
|
+
? `Would rewrite ${definition.event} hook ${definition.name} for matcher ${definition.matcher} to ${registration.form} command: ${registration.reason}.`
|
|
198
|
+
: state.present
|
|
199
|
+
? `${definition.event} hook is already registered (${registration.form} command).`
|
|
200
|
+
: `Would register ${definition.event} hook ${definition.name} for matcher ${definition.matcher} with ${registration.form} command.`);
|
|
206
201
|
});
|
|
207
202
|
lines.push('Would write installation record in the brand root.');
|
|
208
203
|
return { exitCode: 0, output: contractOutput(hostContract, retentionOutput(configuredRetentionLine, lines.join('\n'))) };
|
|
@@ -242,7 +237,7 @@ async function installInRun({
|
|
|
242
237
|
const prior = record?.hooks?.find((hook) => hook.event === definition.event
|
|
243
238
|
&& hook.root === 'brand' && hook.path === relative);
|
|
244
239
|
const createdGroup = hookResults[index].createdGroup || prior?.createdGroup === true;
|
|
245
|
-
const command =
|
|
240
|
+
const command = registration.command;
|
|
246
241
|
return {
|
|
247
242
|
event: definition.event,
|
|
248
243
|
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
|
@@ -8,12 +8,15 @@ import fs from 'node:fs/promises';
|
|
|
8
8
|
import os from 'node:os';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { detectHostVersion, 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';
|
|
11
17
|
|
|
12
18
|
export const PROBE_MARKER = 'codex-bridge-contract-probe';
|
|
13
19
|
|
|
14
|
-
const MARKER_NAME = `${PROBE_MARKER}.marker`;
|
|
15
|
-
const JOURNAL_NAME = 'probe-journal.log';
|
|
16
|
-
|
|
17
20
|
const exists = async (target) => {
|
|
18
21
|
try {
|
|
19
22
|
await fs.access(target);
|
|
@@ -55,63 +58,6 @@ export function judgeProbe({ markerExists, hookFired, hostResult }) {
|
|
|
55
58
|
return { result: 'honored', reason: 'The hook fired and the refused marker command did not run.' };
|
|
56
59
|
}
|
|
57
60
|
|
|
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
61
|
export async function probeContract({
|
|
116
62
|
host,
|
|
117
63
|
version = detectHostVersion(),
|
|
@@ -126,34 +72,55 @@ export async function probeContract({
|
|
|
126
72
|
version: null,
|
|
127
73
|
message: 'The host version could not be read, so no contract probe was run.',
|
|
128
74
|
recorded: false,
|
|
75
|
+
dispatcher: Object.fromEntries(DISPATCHER_CONTRACTS.map((name) => [name, { result: 'inconclusive', detail: 'Host version is unavailable.' }])),
|
|
129
76
|
};
|
|
130
77
|
}
|
|
131
78
|
|
|
132
79
|
await fs.mkdir(rigRoot, { recursive: true });
|
|
133
80
|
const dir = await fs.mkdtemp(path.join(rigRoot, `${PROBE_MARKER}-`));
|
|
134
81
|
try {
|
|
135
|
-
const
|
|
82
|
+
const token = randomUUID().slice(0, 8);
|
|
83
|
+
const rig = await buildRig(dir, token);
|
|
136
84
|
let hostResult;
|
|
137
85
|
try {
|
|
138
86
|
hostResult = runHost('claude', [
|
|
139
87
|
'--setting-sources', 'project',
|
|
140
|
-
'--allowedTools', 'Bash',
|
|
88
|
+
'--allowedTools', 'Bash,Agent',
|
|
141
89
|
'-p', rig.prompt,
|
|
142
90
|
], {
|
|
143
91
|
cwd: rig.dir,
|
|
144
92
|
encoding: 'utf8',
|
|
145
93
|
shell: false,
|
|
146
94
|
timeout: 120000,
|
|
95
|
+
windowsHide: true,
|
|
147
96
|
});
|
|
148
97
|
} catch (error) {
|
|
149
98
|
hostResult = { error };
|
|
150
99
|
}
|
|
151
100
|
|
|
101
|
+
let entries = [];
|
|
102
|
+
let malformed = false;
|
|
103
|
+
try {
|
|
104
|
+
const source = await fs.readFile(rig.dispatcherJournalPath, 'utf8');
|
|
105
|
+
entries = source.split(/\r?\n/).filter(Boolean).map((line) => parseJsonText(rig.dispatcherJournalPath, line));
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error.code !== 'ENOENT') malformed = true;
|
|
108
|
+
}
|
|
152
109
|
const verdict = judgeProbe({
|
|
153
110
|
markerExists: await exists(rig.markerPath),
|
|
154
111
|
hookFired: await exists(rig.journalPath),
|
|
155
112
|
hostResult,
|
|
156
113
|
});
|
|
114
|
+
let dispatcher = judgeDispatcherContracts({
|
|
115
|
+
entries: malformed ? null : entries,
|
|
116
|
+
hostHealthy: !malformed && !hostResult?.error && !hostResult?.signal && hostResult?.status === 0,
|
|
117
|
+
okCommand: rig.okCommand, failCommand: rig.failCommand, okOutput: rig.okOutput,
|
|
118
|
+
failOutput: rig.failOutput, agentType: 'probe-agent', promptToken: token,
|
|
119
|
+
});
|
|
120
|
+
if (verdict.result == null) dispatcher = Object.fromEntries(Object.keys(dispatcher).map((name) => [name, { result: 'inconclusive', detail: 'Refusal probe did not complete.' }]));
|
|
121
|
+
if (verdict.result != null && version != null && !malformed && !hostResult?.error && !hostResult?.signal && hostResult?.status === 0) {
|
|
122
|
+
await writeDispatcherContract({ stateDir: brandStateDir(host.brandRoot), version, verdicts: dispatcher, now });
|
|
123
|
+
}
|
|
157
124
|
if (verdict.result == null) {
|
|
158
125
|
return {
|
|
159
126
|
state: 'inconclusive',
|
|
@@ -161,6 +128,7 @@ export async function probeContract({
|
|
|
161
128
|
version,
|
|
162
129
|
message: verdict.reason,
|
|
163
130
|
recorded: false,
|
|
131
|
+
dispatcher: Object.fromEntries(Object.entries(dispatcher).map(([name, item]) => [name, { result: 'inconclusive', detail: 'Host did not complete the probe.' }])),
|
|
164
132
|
};
|
|
165
133
|
}
|
|
166
134
|
|
|
@@ -171,6 +139,7 @@ export async function probeContract({
|
|
|
171
139
|
version,
|
|
172
140
|
message: verdict.reason,
|
|
173
141
|
recorded: true,
|
|
142
|
+
dispatcher,
|
|
174
143
|
};
|
|
175
144
|
} finally {
|
|
176
145
|
// 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,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
|
+
}
|