@lyupro/codex-bridge 0.5.3 → 0.5.5
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 +98 -0
- package/README.md +152 -152
- package/bin/codex-bridge.mjs +188 -174
- package/cli/doctor-format.mjs +23 -0
- package/cli/doctor-hooks.mjs +114 -0
- package/cli/doctor-installation.mjs +111 -0
- package/cli/doctor-runs.mjs +55 -0
- package/cli/doctor.mjs +150 -399
- package/cli/host-contract.mjs +135 -0
- package/cli/hosts.mjs +4 -3
- package/cli/install.mjs +81 -11
- package/cli/permissions.mjs +7 -1
- package/cli/probe-contract.mjs +180 -0
- package/package.json +49 -49
- package/scripts/run-tests.mjs +4 -1
- package/src/agents/codex-build.md +265 -265
- package/src/agents/codex-review.md +219 -219
- package/src/agents/codex-scout.md +232 -232
- package/src/home/hooks/shell-write-intent.mjs +44 -15
- package/src/home/lib/brand-home.mjs +39 -0
- package/src/home/lib/meta/reply.mjs +27 -0
- package/src/home/lib/run-config.mjs +18 -6
- package/src/home/lib/runner/codex-args.mjs +154 -0
- package/src/home/lib/runner/codex-cmd.mjs +268 -398
- package/src/home/lib/runner/launcher.mjs +18 -30
- package/src/home/lib/runner/worker-order.mjs +56 -0
- package/src/home/lib/write-meta.mjs +11 -3
|
@@ -0,0 +1,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
|
+
});
|
|
87
|
+
if (!result || result.error || result.status !== 0) return null;
|
|
88
|
+
return parseHostVersion(result.stdout);
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function contractStatus({ record, version }) {
|
|
95
|
+
// Loose equality on purpose: a caller that forgot the field hands over `undefined`, and the
|
|
96
|
+
// stale branch below would then report "the current host is undefined" as if it had measured it.
|
|
97
|
+
if (version == null) {
|
|
98
|
+
return {
|
|
99
|
+
state: 'unknown-host',
|
|
100
|
+
message: 'The host version could not be read, so the refusal contract cannot be judged.',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (!record) {
|
|
104
|
+
return {
|
|
105
|
+
state: 'unverified',
|
|
106
|
+
message: `The refusal contract has never been probed on this machine (host ${version}); run ${PROBE_COMMAND}.`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (record.version !== version) {
|
|
110
|
+
return {
|
|
111
|
+
state: 'stale',
|
|
112
|
+
message: `The refusal contract was probed on host ${record.version}, but the current host is ${version}; run ${PROBE_COMMAND}.`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (record.result === 'ignored') {
|
|
116
|
+
const guards = HOOK_DEFINITIONS
|
|
117
|
+
.filter((definition) => definition.event === 'PreToolUse')
|
|
118
|
+
.map((definition) => definition.name)
|
|
119
|
+
.join(', ');
|
|
120
|
+
return {
|
|
121
|
+
state: 'ignored',
|
|
122
|
+
message: `DANGER: Host ${version} does not honour hook refusals, so every guard is inert: ${guards}. Re-run ${PROBE_COMMAND} once the host updates.`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (record.result === 'honored') {
|
|
126
|
+
return {
|
|
127
|
+
state: 'verified',
|
|
128
|
+
message: `Host ${version} honours hook refusals (verified ${record.checkedAt}).`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
state: 'unverified',
|
|
133
|
+
message: `The refusal contract record is invalid (host ${version}); run ${PROBE_COMMAND}.`,
|
|
134
|
+
};
|
|
135
|
+
}
|
package/cli/hosts.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { resolveBrandHome } from '../src/home/lib/brand-home.mjs';
|
|
5
6
|
|
|
6
7
|
function repositoryRoot(start) {
|
|
7
8
|
let current = path.resolve(start);
|
|
@@ -35,9 +36,9 @@ export function resolveHost({
|
|
|
35
36
|
// Plan_25 moves package-owned runtime files out of foreign Claude settings, whose absolute
|
|
36
37
|
// hook paths caused the package-file-layout incident. Keep the brand root overrideable so tests
|
|
37
38
|
// and later installation steps never need to know the operator's home directory.
|
|
38
|
-
const resolvedBrandRoot =
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
const resolvedBrandRoot = brandRoot
|
|
40
|
+
? path.resolve(brandRoot)
|
|
41
|
+
: resolveBrandHome({ homedir, env: process.env }).root;
|
|
41
42
|
const brandPaths = {
|
|
42
43
|
brandRoot: resolvedBrandRoot,
|
|
43
44
|
brandHooksDir: path.join(resolvedBrandRoot, 'hooks'),
|
package/cli/install.mjs
CHANGED
|
@@ -25,7 +25,8 @@ import {
|
|
|
25
25
|
withSettingsRun,
|
|
26
26
|
} from './settings-merge.mjs';
|
|
27
27
|
import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
|
|
28
|
-
import { addPermissionRules } from './permissions.mjs';
|
|
28
|
+
import { addPermissionRules, inspectPermissions } from './permissions.mjs';
|
|
29
|
+
import { contractStatus, detectHostVersion, readHostContract } from './host-contract.mjs';
|
|
29
30
|
import { readRunConfig, retentionNotice } from '../src/home/lib/run-config.mjs';
|
|
30
31
|
|
|
31
32
|
const WARNING = '\u001b[33m';
|
|
@@ -56,6 +57,40 @@ function retentionOutput(line, output) {
|
|
|
56
57
|
return `${output}\n${line}`;
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
function contractOutput(status, output) {
|
|
61
|
+
return status.state === 'verified' ? output : `${output}\n${status.message}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function naturalList(items) {
|
|
65
|
+
if (items.length < 2) return items.join('');
|
|
66
|
+
return items.length === 2
|
|
67
|
+
? items.join(' and ')
|
|
68
|
+
: `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hookSummary(targets) {
|
|
72
|
+
const counts = new Map();
|
|
73
|
+
for (const { definition } of targets) {
|
|
74
|
+
counts.set(definition.event, (counts.get(definition.event) || 0) + 1);
|
|
75
|
+
}
|
|
76
|
+
const events = [...counts].map(([event, count]) => `${count} ${event}`);
|
|
77
|
+
return `${targets.length} hooks: ${naturalList(events)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function permissionOutput(result, settingsPath) {
|
|
81
|
+
if (result.dryRun) return `Would evaluate permission rules in ${settingsPath}; settings unchanged.`;
|
|
82
|
+
if (result.added > 0) {
|
|
83
|
+
return `Granted ${result.added} of ${result.total} permission rule(s) in ${settingsPath} so the host runs the package command without asking.`;
|
|
84
|
+
}
|
|
85
|
+
// Zero granted is the good outcome only when the whole set is there. Saying "all are in place"
|
|
86
|
+
// off the count of what was added would state the opposite of the truth on a host someone had
|
|
87
|
+
// edited by hand, and permission windows are exactly what the operator would then not understand.
|
|
88
|
+
if (result.present < result.total) {
|
|
89
|
+
return `${result.present} of ${result.total} permission rules are in place in ${settingsPath}; run codex-bridge install --force to restore the rest.`;
|
|
90
|
+
}
|
|
91
|
+
return `All ${result.total} permission rules are already in place in ${settingsPath}, so the host runs the package command without asking.`;
|
|
92
|
+
}
|
|
93
|
+
|
|
59
94
|
async function migrateLegacySeed(host, seed) {
|
|
60
95
|
if (await targetExists(seed.target)) return;
|
|
61
96
|
const legacyName = path.basename(seed.target) === 'config.json' ? 'run-config.json' : path.basename(seed.target);
|
|
@@ -73,10 +108,22 @@ async function migrateLegacySeed(host, seed) {
|
|
|
73
108
|
await fs.rm(legacy, { force: true });
|
|
74
109
|
}
|
|
75
110
|
|
|
76
|
-
async function installInRun({
|
|
111
|
+
async function installInRun({
|
|
112
|
+
host,
|
|
113
|
+
dryRun = false,
|
|
114
|
+
force = false,
|
|
115
|
+
packageRoot,
|
|
116
|
+
env = process.env,
|
|
117
|
+
contractRecord,
|
|
118
|
+
hostVersion,
|
|
119
|
+
} = {}) {
|
|
77
120
|
// Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
|
|
78
121
|
await readRulesRegistry(host);
|
|
79
122
|
const configuredRetentionLine = retentionLine(host);
|
|
123
|
+
const hostContract = contractStatus({
|
|
124
|
+
record: contractRecord === undefined ? await readHostContract(host) : contractRecord,
|
|
125
|
+
version: hostVersion === undefined ? detectHostVersion() : hostVersion,
|
|
126
|
+
});
|
|
80
127
|
const plan = await buildInstallPlan(host, packageRoot);
|
|
81
128
|
const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
|
|
82
129
|
const currentPackage = await packageInfo(packageRoot);
|
|
@@ -112,7 +159,7 @@ async function installInRun({ host, dryRun = false, force = false, packageRoot,
|
|
|
112
159
|
const files = conflicts.map(({ item }) => ` ${item.relativeToRoot || item.name}`).join('\n');
|
|
113
160
|
return {
|
|
114
161
|
exitCode: 1,
|
|
115
|
-
output: retentionOutput(configuredRetentionLine, `Conflicting files:\n${files}\nRun install again with --force to overwrite them.`),
|
|
162
|
+
output: contractOutput(hostContract, retentionOutput(configuredRetentionLine, `Conflicting files:\n${files}\nRun install again with --force to overwrite them.`)),
|
|
116
163
|
};
|
|
117
164
|
}
|
|
118
165
|
|
|
@@ -123,8 +170,26 @@ async function installInRun({ host, dryRun = false, force = false, packageRoot,
|
|
|
123
170
|
{ path: rule.target, fingerprint: ruleState.fingerprint });
|
|
124
171
|
if (!changedFiles.length && !changedRule && inspectedHooks.every((state) => state.present)
|
|
125
172
|
&& recordHasHooks(record, targets) && sameRecord) {
|
|
126
|
-
if (!dryRun)
|
|
127
|
-
|
|
173
|
+
if (!dryRun) {
|
|
174
|
+
await addRulesOwner(host);
|
|
175
|
+
// Read, never write: this branch's whole claim is that there is nothing to do, and a
|
|
176
|
+
// sentence about permission rules must not be paid for by quietly restoring one.
|
|
177
|
+
const permissionResult = await inspectPermissions(host.settingsPath);
|
|
178
|
+
return {
|
|
179
|
+
exitCode: 0,
|
|
180
|
+
output: contractOutput(
|
|
181
|
+
hostContract,
|
|
182
|
+
retentionOutput(
|
|
183
|
+
configuredRetentionLine,
|
|
184
|
+
`codex-bridge is already installed; nothing to do.\n${permissionOutput(permissionResult, host.settingsPath)}`,
|
|
185
|
+
),
|
|
186
|
+
),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
exitCode: 0,
|
|
191
|
+
output: contractOutput(hostContract, retentionOutput(configuredRetentionLine, 'codex-bridge is already installed; nothing to do.')),
|
|
192
|
+
};
|
|
128
193
|
}
|
|
129
194
|
|
|
130
195
|
if (dryRun) {
|
|
@@ -140,7 +205,7 @@ async function installInRun({ host, dryRun = false, force = false, packageRoot,
|
|
|
140
205
|
: `Would register ${definition.event} hook for matcher ${definition.matcher} with ${registration.form} command.`);
|
|
141
206
|
});
|
|
142
207
|
lines.push('Would write installation record in the brand root.');
|
|
143
|
-
return { exitCode: 0, output: retentionOutput(configuredRetentionLine, lines.join('\n')) };
|
|
208
|
+
return { exitCode: 0, output: contractOutput(hostContract, retentionOutput(configuredRetentionLine, lines.join('\n'))) };
|
|
144
209
|
}
|
|
145
210
|
|
|
146
211
|
// Claim ownership of the shared rules file before writing anything. Claiming it last meant a
|
|
@@ -165,7 +230,9 @@ async function installInRun({ host, dryRun = false, force = false, packageRoot,
|
|
|
165
230
|
// it goes looking for a way around, down to the absolute path to run-codex.mjs that Plan_41
|
|
166
231
|
// removed. The rule follows the scope of the install, because host.settingsPath already is the
|
|
167
232
|
// scope — a --scope project install must not reach into the operator's global config.
|
|
168
|
-
const permissionResult = dryRun
|
|
233
|
+
const permissionResult = dryRun
|
|
234
|
+
? { dryRun: true, added: 0, present: 0, total: 0 }
|
|
235
|
+
: await addPermissionRules(host.settingsPath);
|
|
169
236
|
const fingerprints = {};
|
|
170
237
|
for (const item of plan) {
|
|
171
238
|
fingerprints[item.root] ??= {};
|
|
@@ -196,10 +263,13 @@ async function installInRun({ host, dryRun = false, force = false, packageRoot,
|
|
|
196
263
|
});
|
|
197
264
|
return {
|
|
198
265
|
exitCode: 0,
|
|
199
|
-
output:
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
266
|
+
output: contractOutput(
|
|
267
|
+
hostContract,
|
|
268
|
+
retentionOutput(
|
|
269
|
+
configuredRetentionLine,
|
|
270
|
+
`Installed ${plan.length} files and the Codex rules file, and registered ${hookSummary(targets)}.`
|
|
271
|
+
+ `\n${permissionOutput(permissionResult, host.settingsPath)}`,
|
|
272
|
+
),
|
|
203
273
|
),
|
|
204
274
|
};
|
|
205
275
|
}
|
package/cli/permissions.mjs
CHANGED
|
@@ -108,7 +108,13 @@ export async function addPermissionRules(settingsPath) {
|
|
|
108
108
|
added += 1;
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
|
-
|
|
111
|
+
const status = permissionStatus(settings);
|
|
112
|
+
return {
|
|
113
|
+
changed: added > 0,
|
|
114
|
+
added,
|
|
115
|
+
present: status.present,
|
|
116
|
+
total: status.total,
|
|
117
|
+
};
|
|
112
118
|
});
|
|
113
119
|
}
|
|
114
120
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measures whether the installed host applies a PreToolUse refusal, not merely whether the hook
|
|
3
|
+
* returns one. The sibling `allow` decision silently stopped working between hosts 2.1.119 and
|
|
4
|
+
* 2.1.231, so a missing marker is trusted only when the hook fired and the host completed cleanly.
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { detectHostVersion, writeHostContract } from './host-contract.mjs';
|
|
11
|
+
|
|
12
|
+
export const PROBE_MARKER = 'codex-bridge-contract-probe';
|
|
13
|
+
|
|
14
|
+
const MARKER_NAME = `${PROBE_MARKER}.marker`;
|
|
15
|
+
const JOURNAL_NAME = 'probe-journal.log';
|
|
16
|
+
|
|
17
|
+
const exists = async (target) => {
|
|
18
|
+
try {
|
|
19
|
+
await fs.access(target);
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function lastNonEmptyLine(output) {
|
|
27
|
+
return String(output ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean).at(-1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function judgeProbe({ markerExists, hookFired, hostResult }) {
|
|
31
|
+
if (markerExists) {
|
|
32
|
+
return { result: 'ignored', reason: 'The marker command ran despite the hook refusal.' };
|
|
33
|
+
}
|
|
34
|
+
if (!hostResult) {
|
|
35
|
+
return { result: null, reason: 'The host could not be started.' };
|
|
36
|
+
}
|
|
37
|
+
if (hostResult.error) {
|
|
38
|
+
const timedOut = hostResult.error.code === 'ETIMEDOUT';
|
|
39
|
+
return {
|
|
40
|
+
result: null,
|
|
41
|
+
reason: timedOut ? 'The host timed out before the probe completed.' : 'The host process failed to start.',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (hostResult.signal) {
|
|
45
|
+
return { result: null, reason: `The host was terminated by signal ${hostResult.signal}.` };
|
|
46
|
+
}
|
|
47
|
+
if (hostResult.status !== 0) {
|
|
48
|
+
const output = lastNonEmptyLine(hostResult.stderr) || lastNonEmptyLine(hostResult.stdout);
|
|
49
|
+
const detail = output ? `: ${output.slice(0, 200)}` : '';
|
|
50
|
+
return { result: null, reason: `The host exited with status ${String(hostResult.status)}${detail}.` };
|
|
51
|
+
}
|
|
52
|
+
if (!hookFired) {
|
|
53
|
+
return { result: null, reason: 'The probe hook never received the marker command.' };
|
|
54
|
+
}
|
|
55
|
+
return { result: 'honored', reason: 'The hook fired and the refused marker command did not run.' };
|
|
56
|
+
}
|
|
57
|
+
|
|
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
|
+
export async function probeContract({
|
|
116
|
+
host,
|
|
117
|
+
version = detectHostVersion(),
|
|
118
|
+
runHost = spawnSync,
|
|
119
|
+
rigRoot = os.tmpdir(),
|
|
120
|
+
now = new Date(),
|
|
121
|
+
}) {
|
|
122
|
+
if (version == null) {
|
|
123
|
+
return {
|
|
124
|
+
state: 'inconclusive',
|
|
125
|
+
result: null,
|
|
126
|
+
version: null,
|
|
127
|
+
message: 'The host version could not be read, so no contract probe was run.',
|
|
128
|
+
recorded: false,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await fs.mkdir(rigRoot, { recursive: true });
|
|
133
|
+
const dir = await fs.mkdtemp(path.join(rigRoot, `${PROBE_MARKER}-`));
|
|
134
|
+
try {
|
|
135
|
+
const rig = await buildRig(dir);
|
|
136
|
+
let hostResult;
|
|
137
|
+
try {
|
|
138
|
+
hostResult = runHost('claude', [
|
|
139
|
+
'--setting-sources', 'project',
|
|
140
|
+
'--allowedTools', 'Bash',
|
|
141
|
+
'-p', rig.prompt,
|
|
142
|
+
], {
|
|
143
|
+
cwd: rig.dir,
|
|
144
|
+
encoding: 'utf8',
|
|
145
|
+
shell: false,
|
|
146
|
+
timeout: 120000,
|
|
147
|
+
});
|
|
148
|
+
} catch (error) {
|
|
149
|
+
hostResult = { error };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const verdict = judgeProbe({
|
|
153
|
+
markerExists: await exists(rig.markerPath),
|
|
154
|
+
hookFired: await exists(rig.journalPath),
|
|
155
|
+
hostResult,
|
|
156
|
+
});
|
|
157
|
+
if (verdict.result == null) {
|
|
158
|
+
return {
|
|
159
|
+
state: 'inconclusive',
|
|
160
|
+
result: null,
|
|
161
|
+
version,
|
|
162
|
+
message: verdict.reason,
|
|
163
|
+
recorded: false,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await writeHostContract(host, { version, result: verdict.result, now });
|
|
168
|
+
return {
|
|
169
|
+
state: 'probed',
|
|
170
|
+
result: verdict.result,
|
|
171
|
+
version,
|
|
172
|
+
message: verdict.reason,
|
|
173
|
+
recorded: true,
|
|
174
|
+
};
|
|
175
|
+
} finally {
|
|
176
|
+
// Windows can retain a just-written hook tree briefly; cleanup failure must not erase a valid
|
|
177
|
+
// measurement or prevent the result from being recorded.
|
|
178
|
+
await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
179
|
+
}
|
|
180
|
+
}
|
package/package.json
CHANGED
|
@@ -1,49 +1,49 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@lyupro/codex-bridge",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "Claude Code-to-Codex delegation with 6 guards,
|
|
5
|
-
"keywords": [
|
|
6
|
-
"claude-code",
|
|
7
|
-
"codex",
|
|
8
|
-
"codex-cli",
|
|
9
|
-
"subagent",
|
|
10
|
-
"ai-agents",
|
|
11
|
-
"orchestration",
|
|
12
|
-
"hooks"
|
|
13
|
-
],
|
|
14
|
-
"license": "MIT",
|
|
15
|
-
"author": "Lyu Pro <lyupro.dev@gmail.com> (https://lyupro.com)",
|
|
16
|
-
"repository": {
|
|
17
|
-
"type": "git",
|
|
18
|
-
"url": "git+https://github.com/lyupro/codex-bridge.git"
|
|
19
|
-
},
|
|
20
|
-
"bugs": {
|
|
21
|
-
"url": "https://github.com/lyupro/codex-bridge/issues"
|
|
22
|
-
},
|
|
23
|
-
"homepage": "https://github.com/lyupro/codex-bridge#readme",
|
|
24
|
-
"type": "module",
|
|
25
|
-
"bin": {
|
|
26
|
-
"codex-bridge": "./bin/codex-bridge.mjs",
|
|
27
|
-
"codexb": "./bin/codex-bridge.mjs"
|
|
28
|
-
},
|
|
29
|
-
"engines": {
|
|
30
|
-
"node": ">=24.0.0"
|
|
31
|
-
},
|
|
32
|
-
"files": [
|
|
33
|
-
"bin",
|
|
34
|
-
"cli",
|
|
35
|
-
"src",
|
|
36
|
-
"scripts",
|
|
37
|
-
"README.md",
|
|
38
|
-
"LICENSE",
|
|
39
|
-
"CHANGELOG.md"
|
|
40
|
-
],
|
|
41
|
-
"scripts": {
|
|
42
|
-
"dev:install": "node bin/codex-bridge.mjs update --force",
|
|
43
|
-
"test": "node scripts/run-tests.mjs",
|
|
44
|
-
"check:size": "node scripts/check-file-size.mjs"
|
|
45
|
-
},
|
|
46
|
-
"publishConfig": {
|
|
47
|
-
"access": "public"
|
|
48
|
-
}
|
|
49
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@lyupro/codex-bridge",
|
|
3
|
+
"version": "0.5.5",
|
|
4
|
+
"description": "Claude Code-to-Codex delegation with 6 guards, 817 tests, zero runtime dependencies, and zero build steps.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"codex",
|
|
8
|
+
"codex-cli",
|
|
9
|
+
"subagent",
|
|
10
|
+
"ai-agents",
|
|
11
|
+
"orchestration",
|
|
12
|
+
"hooks"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "Lyu Pro <lyupro.dev@gmail.com> (https://lyupro.com)",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/lyupro/codex-bridge.git"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/lyupro/codex-bridge/issues"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/lyupro/codex-bridge#readme",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"bin": {
|
|
26
|
+
"codex-bridge": "./bin/codex-bridge.mjs",
|
|
27
|
+
"codexb": "./bin/codex-bridge.mjs"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=24.0.0"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"bin",
|
|
34
|
+
"cli",
|
|
35
|
+
"src",
|
|
36
|
+
"scripts",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"CHANGELOG.md"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"dev:install": "node bin/codex-bridge.mjs update --force",
|
|
43
|
+
"test": "node scripts/run-tests.mjs",
|
|
44
|
+
"check:size": "node scripts/check-file-size.mjs"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -25,6 +25,8 @@ const created = ISOLATED_ROOTS.map((name) => [
|
|
|
25
25
|
name,
|
|
26
26
|
fs.mkdtempSync(path.join(os.tmpdir(), `codex-bridge-test-${name.toLowerCase()}-`)),
|
|
27
27
|
]);
|
|
28
|
+
// Fixtures created temporary trees no one removed, so keep them under one disposable root.
|
|
29
|
+
const testTmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-bridge-test-tmp-'));
|
|
28
30
|
const pattern = process.argv[2] || 'tests/**/*.test.mjs';
|
|
29
31
|
// Only a whole-suite run can judge the README's number; a single file legitimately reports two.
|
|
30
32
|
const wholeSuite = !process.argv[2];
|
|
@@ -43,7 +45,7 @@ const reporters = tapFile
|
|
|
43
45
|
try {
|
|
44
46
|
const result = spawnSync(process.execPath, ['--test', ...reporters, pattern], {
|
|
45
47
|
stdio: 'inherit',
|
|
46
|
-
env: { ...process.env, ...Object.fromEntries(created) },
|
|
48
|
+
env: { ...process.env, ...Object.fromEntries(created), CODEX_BRIDGE_TEST_TMP: testTmpRoot },
|
|
47
49
|
});
|
|
48
50
|
process.exitCode = result.status ?? 1;
|
|
49
51
|
if (tapFile && process.exitCode === 0) {
|
|
@@ -61,5 +63,6 @@ try {
|
|
|
61
63
|
}
|
|
62
64
|
} finally {
|
|
63
65
|
for (const [, directory] of created) fs.rmSync(directory, { recursive: true, force: true });
|
|
66
|
+
fs.rmSync(testTmpRoot, { recursive: true, force: true });
|
|
64
67
|
if (tapFile) fs.rmSync(path.dirname(tapFile), { recursive: true, force: true });
|
|
65
68
|
}
|