@lyupro/codex-bridge 0.5.2 → 0.5.4

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.
@@ -0,0 +1,174 @@
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
+ export function judgeProbe({ markerExists, hookFired, hostResult }) {
27
+ if (markerExists) {
28
+ return { result: 'ignored', reason: 'The marker command ran despite the hook refusal.' };
29
+ }
30
+ if (!hostResult) {
31
+ return { result: null, reason: 'The host could not be started.' };
32
+ }
33
+ if (hostResult.error) {
34
+ const timedOut = hostResult.error.code === 'ETIMEDOUT';
35
+ return {
36
+ result: null,
37
+ reason: timedOut ? 'The host timed out before the probe completed.' : 'The host process failed to start.',
38
+ };
39
+ }
40
+ if (hostResult.signal) {
41
+ return { result: null, reason: `The host was terminated by signal ${hostResult.signal}.` };
42
+ }
43
+ if (hostResult.status !== 0) {
44
+ return { result: null, reason: `The host exited with status ${String(hostResult.status)}.` };
45
+ }
46
+ if (!hookFired) {
47
+ return { result: null, reason: 'The probe hook never received the marker command.' };
48
+ }
49
+ return { result: 'honored', reason: 'The hook fired and the refused marker command did not run.' };
50
+ }
51
+
52
+ const hookSource = `#!/usr/bin/env node
53
+ import fs from 'node:fs';
54
+ import { fileURLToPath } from 'node:url';
55
+
56
+ const marker = ${JSON.stringify(PROBE_MARKER)};
57
+ const journalPath = fileURLToPath(new URL('../${JOURNAL_NAME}', import.meta.url));
58
+ let input;
59
+ try {
60
+ input = JSON.parse(fs.readFileSync(0, 'utf8'));
61
+ } catch {
62
+ process.exit(0);
63
+ }
64
+
65
+ if (typeof input?.tool_input?.command === 'string' && input.tool_input.command.includes(marker)) {
66
+ fs.appendFileSync(journalPath, \`${PROBE_MARKER}\\n\`, 'utf8');
67
+ process.stdout.write(JSON.stringify({
68
+ hookSpecificOutput: {
69
+ hookEventName: 'PreToolUse',
70
+ permissionDecision: 'deny',
71
+ permissionDecisionReason: 'Contract probe refused its marker command.',
72
+ },
73
+ }));
74
+ }
75
+ `;
76
+
77
+ // Forward slashes on purpose: the host runs this hook command line through a POSIX shell, and a
78
+ // Windows path handed over verbatim arrives carrying backslashes that shell may swallow. The
79
+ // doubled form this replacement first carried matched nothing at all on a real path.
80
+ const shellQuote = (value) => `"${value.replaceAll('\\', '/').replaceAll('"', '\\"')}"`;
81
+
82
+ export async function buildRig(dir) {
83
+ const claudeDir = path.join(dir, '.claude');
84
+ const hooksDir = path.join(claudeDir, 'hooks');
85
+ const hookPath = path.join(hooksDir, 'probe-hook.mjs');
86
+ const markerPath = path.join(dir, MARKER_NAME);
87
+ const journalPath = path.join(claudeDir, JOURNAL_NAME);
88
+ const command = `touch ${MARKER_NAME}`;
89
+ const prompt = `Run exactly this Bash command and nothing else: ${command}`;
90
+ const settings = {
91
+ hooks: {
92
+ PreToolUse: [{
93
+ matcher: 'Bash',
94
+ hooks: [{ type: 'command', command: `${shellQuote(process.execPath)} ${shellQuote(hookPath)}` }],
95
+ }],
96
+ },
97
+ };
98
+
99
+ await fs.mkdir(hooksDir, { recursive: true });
100
+ await fs.writeFile(hookPath, hookSource, 'utf8');
101
+ await fs.writeFile(
102
+ path.join(claudeDir, 'settings.json'),
103
+ `${JSON.stringify(settings, null, 2)}\n`,
104
+ 'utf8',
105
+ );
106
+ return { dir, markerPath, journalPath, command, prompt };
107
+ }
108
+
109
+ export async function probeContract({
110
+ host,
111
+ version = detectHostVersion(),
112
+ runHost = spawnSync,
113
+ rigRoot = os.tmpdir(),
114
+ now = new Date(),
115
+ }) {
116
+ if (version == null) {
117
+ return {
118
+ state: 'inconclusive',
119
+ result: null,
120
+ version: null,
121
+ message: 'The host version could not be read, so no contract probe was run.',
122
+ recorded: false,
123
+ };
124
+ }
125
+
126
+ await fs.mkdir(rigRoot, { recursive: true });
127
+ const dir = await fs.mkdtemp(path.join(rigRoot, `${PROBE_MARKER}-`));
128
+ try {
129
+ const rig = await buildRig(dir);
130
+ let hostResult;
131
+ try {
132
+ hostResult = runHost('claude', [
133
+ '--setting-sources', 'project',
134
+ '--allowedTools', 'Bash',
135
+ '-p', rig.prompt,
136
+ ], {
137
+ cwd: rig.dir,
138
+ encoding: 'utf8',
139
+ shell: false,
140
+ timeout: 120000,
141
+ });
142
+ } catch (error) {
143
+ hostResult = { error };
144
+ }
145
+
146
+ const verdict = judgeProbe({
147
+ markerExists: await exists(rig.markerPath),
148
+ hookFired: await exists(rig.journalPath),
149
+ hostResult,
150
+ });
151
+ if (verdict.result == null) {
152
+ return {
153
+ state: 'inconclusive',
154
+ result: null,
155
+ version,
156
+ message: verdict.reason,
157
+ recorded: false,
158
+ };
159
+ }
160
+
161
+ await writeHostContract(host, { version, result: verdict.result, now });
162
+ return {
163
+ state: 'probed',
164
+ result: verdict.result,
165
+ version,
166
+ message: verdict.reason,
167
+ recorded: true,
168
+ };
169
+ } finally {
170
+ // Windows can retain a just-written hook tree briefly; cleanup failure must not erase a valid
171
+ // measurement or prevent the result from being recorded.
172
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
173
+ }
174
+ }
@@ -134,29 +134,41 @@ const hasOwnCommand = (group, commands) => {
134
134
  };
135
135
 
136
136
  /**
137
- * Presence is decided by the command, not by the matcher it currently sits under.
137
+ * Presence is decided by the command, not by the matcher it currently sits under; writing then
138
+ * places that command under the matcher the package declares. These are two halves of one rule.
138
139
  *
139
140
  * The matcher is generated from the tool list in hook-definitions.mjs, so it changes whenever a
140
141
  * host spelling is added — `Agent` became `Agent|Task` in one release, and the write-tool matcher
141
142
  * will grow the same way. A host installed under the old matcher would then be invisible to the
142
143
  * new lookup: update would register a duplicate and uninstall would leave a hook pointing at a
143
- * deleted file. The command is this package's own absolute path, so it identifies our entry
144
- * wherever the group ended up.
144
+ * deleted file. Conversely, the 2026-08-17 host proved that finding alone is insufficient: its
145
+ * worktree lock stayed under the pre-shell matcher, so Bash never reached the guard. The command
146
+ * is this package's own absolute path, so it identifies our entry wherever the group ended up,
147
+ * and merge can move it without creating a duplicate.
145
148
  */
146
149
  export async function inspectHook(settingsPath, specOrPath) {
147
150
  const state = await readSettings(settingsPath);
148
151
  const spec = normalizeSpec(specOrPath);
149
152
  const commands = [spec.command, ...spec.alternateCommands];
150
153
  let matchedCommand;
154
+ let matchedMatcher;
155
+ let requiresMove = false;
151
156
  for (const group of groups(state.settings, spec.event)) {
152
- const hook = groupHooks(group).find((entry) =>
153
- entry?.type === 'command' && commands.includes(entry.command));
154
- if (hook) {
155
- matchedCommand = hook.command;
156
- break;
157
+ for (const hook of groupHooks(group)) {
158
+ if (hook?.type !== 'command' || !commands.includes(hook.command)) continue;
159
+ matchedCommand ??= hook.command;
160
+ matchedMatcher ??= group?.matcher;
161
+ if (group?.matcher !== spec.matcher) requiresMove = true;
157
162
  }
158
163
  }
159
- return { ...state, ...spec, present: Boolean(matchedCommand), matchedCommand };
164
+ return {
165
+ ...state,
166
+ ...spec,
167
+ present: Boolean(matchedCommand),
168
+ matchedCommand,
169
+ matchedMatcher,
170
+ requiresMove,
171
+ };
160
172
  }
161
173
 
162
174
  function backupName(settingsPath) {
@@ -227,10 +239,43 @@ export async function mergeHook(settingsPath, specOrPath, inspected) {
227
239
  return withMutationRun(settingsPath, async () => {
228
240
  const spec = normalizeSpec(specOrPath);
229
241
  const state = inspected || await inspectHook(settingsPath, spec);
230
- if (state.present) return { changed: false, createdGroup: false };
242
+ if (state.present && !state.requiresMove) return { changed: false, createdGroup: false };
231
243
  const settings = structuredClone(state.settings);
232
244
  settings.hooks ??= {};
233
245
  if (!Array.isArray(settings.hooks[spec.event])) settings.hooks[spec.event] = [];
246
+ if (state.present) {
247
+ const eventGroups = settings.hooks[spec.event];
248
+ const commands = [spec.command, ...spec.alternateCommands];
249
+ let hookToMove;
250
+ let keptDeclaredHook = false;
251
+ for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
252
+ const group = eventGroups[index];
253
+ if (!Array.isArray(group?.hooks)) continue;
254
+ const hadOwnHook = hasOwnCommand(group, commands);
255
+ group.hooks = group.hooks.filter((hook) => {
256
+ if (hook?.type !== 'command' || !commands.includes(hook.command)) return true;
257
+ hookToMove ??= hook;
258
+ if (group.matcher === spec.matcher && !keptDeclaredHook) {
259
+ keptDeclaredHook = true;
260
+ return true;
261
+ }
262
+ return false;
263
+ });
264
+ if (hadOwnHook && group.hooks.length === 0) eventGroups.splice(index, 1);
265
+ }
266
+ let group = eventGroups.find((entry) => entry?.matcher === spec.matcher);
267
+ const createdGroup = !group;
268
+ if (!keptDeclaredHook) {
269
+ if (!group) {
270
+ group = { matcher: spec.matcher, hooks: [] };
271
+ eventGroups.push(group);
272
+ }
273
+ if (!Array.isArray(group.hooks)) group.hooks = [];
274
+ group.hooks.push(hookToMove);
275
+ }
276
+ await atomicWrite(settingsPath, settings, state);
277
+ return { changed: true, createdGroup, moved: true };
278
+ }
234
279
  let group = settings.hooks[spec.event].find((entry) => entry?.matcher === spec.matcher);
235
280
  const createdGroup = !group;
236
281
  if (!group) {
package/package.json CHANGED
@@ -1,49 +1,49 @@
1
- {
2
- "name": "@lyupro/codex-bridge",
3
- "version": "0.5.2",
4
- "description": "Claude Code-to-Codex delegation with 5 guards, 604 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
- }
1
+ {
2
+ "name": "@lyupro/codex-bridge",
3
+ "version": "0.5.4",
4
+ "description": "Claude Code-to-Codex delegation with 6 guards, 809 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
+ }
@@ -16,21 +16,53 @@
16
16
  import fs from 'node:fs';
17
17
  import os from 'node:os';
18
18
  import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
19
20
  import { spawnSync } from 'node:child_process';
20
21
  import { ISOLATED_ROOTS } from './isolated-roots.mjs';
22
+ import { readmeText, suiteCountMismatch } from './suite-count.mjs';
21
23
 
22
24
  const created = ISOLATED_ROOTS.map((name) => [
23
25
  name,
24
26
  fs.mkdtempSync(path.join(os.tmpdir(), `codex-bridge-test-${name.toLowerCase()}-`)),
25
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-'));
26
30
  const pattern = process.argv[2] || 'tests/**/*.test.mjs';
31
+ // Only a whole-suite run can judge the README's number; a single file legitimately reports two.
32
+ const wholeSuite = !process.argv[2];
33
+ const tapFile = wholeSuite
34
+ ? path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'codex-bridge-test-summary-')), 'summary.tap')
35
+ : null;
36
+ const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
37
+
38
+ // The spec reporter is named explicitly because the second reporter would otherwise replace it,
39
+ // and the operator would lose the live output this command exists to show.
40
+ const reporters = tapFile
41
+ ? ['--test-reporter=spec', '--test-reporter-destination=stdout',
42
+ '--test-reporter=tap', `--test-reporter-destination=${tapFile}`]
43
+ : [];
27
44
 
28
45
  try {
29
- const result = spawnSync(process.execPath, ['--test', pattern], {
46
+ const result = spawnSync(process.execPath, ['--test', ...reporters, pattern], {
30
47
  stdio: 'inherit',
31
- env: { ...process.env, ...Object.fromEntries(created) },
48
+ env: { ...process.env, ...Object.fromEntries(created), CODEX_BRIDGE_TEST_TMP: testTmpRoot },
32
49
  });
33
50
  process.exitCode = result.status ?? 1;
51
+ if (tapFile && process.exitCode === 0) {
52
+ let tap = null;
53
+ try {
54
+ tap = fs.readFileSync(tapFile, 'utf8');
55
+ } catch {
56
+ tap = null;
57
+ }
58
+ const mismatch = suiteCountMismatch(readmeText(path.join(repositoryRoot, 'README.md')), tap);
59
+ if (mismatch) {
60
+ process.stderr.write(`\nsuite-count: ${mismatch}\n`);
61
+ process.exitCode = 1;
62
+ }
63
+ }
34
64
  } finally {
35
65
  for (const [, directory] of created) fs.rmSync(directory, { recursive: true, force: true });
66
+ fs.rmSync(testTmpRoot, { recursive: true, force: true });
67
+ if (tapFile) fs.rmSync(path.dirname(tapFile), { recursive: true, force: true });
36
68
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Compares the README's advertised suite size with what the suite actually reported.
3
+ *
4
+ * The number is prose, so nothing checked it: README said 705 tests while the suite ran 751, and
5
+ * package.json still advertised 604 from three releases earlier. Both were found by eye during
6
+ * the 0.5.3 release, which is the third release in a row where a shipped number was wrong. The
7
+ * count is the first thing a reader judges the package by, and it goes stale silently with every
8
+ * test added — so the runner that knows the real number is the place the claim is verified.
9
+ */
10
+ import fs from 'node:fs';
11
+
12
+ export const SUITE_CLAIM =
13
+ /\*\*(\d+) automated tests: (\d+) passing and (\d+) skipped\*\*/;
14
+
15
+ /** Read the TAP summary a second reporter wrote; null when the file says nothing usable. */
16
+ export function reportedCounts(tapOutput) {
17
+ if (typeof tapOutput !== 'string' || !tapOutput.trim()) return null;
18
+ const read = (key) => {
19
+ const found = tapOutput.match(new RegExp(`^# ${key} (\\d+)$`, 'm'));
20
+ return found ? Number(found[1]) : null;
21
+ };
22
+ const tests = read('tests');
23
+ const pass = read('pass');
24
+ const skipped = read('skipped');
25
+ if (tests === null || pass === null || skipped === null) return null;
26
+ return { tests, pass, skipped };
27
+ }
28
+
29
+ /** Read the claim out of README text; null when the sentence is absent. */
30
+ export function claimedCounts(readmeText) {
31
+ const found = typeof readmeText === 'string' ? readmeText.match(SUITE_CLAIM) : null;
32
+ if (!found) return null;
33
+ return { tests: Number(found[1]), pass: Number(found[2]), skipped: Number(found[3]) };
34
+ }
35
+
36
+ /**
37
+ * Return the message a mismatch should print, or null when the claim holds. A missing claim is a
38
+ * mismatch too: the sentence is what the gate protects, and deleting it must not silence the gate.
39
+ */
40
+ export function suiteCountMismatch(readmeText, tapOutput) {
41
+ const reported = reportedCounts(tapOutput);
42
+ if (!reported) return null;
43
+ const claimed = claimedCounts(readmeText);
44
+ if (!claimed) {
45
+ return 'README no longer states the suite size. Restore the sentence: '
46
+ + `**${reported.tests} automated tests: ${reported.pass} passing and ${reported.skipped} skipped**.`;
47
+ }
48
+ if (claimed.tests === reported.tests
49
+ && claimed.pass === reported.pass
50
+ && claimed.skipped === reported.skipped) {
51
+ return null;
52
+ }
53
+ return 'README states the wrong suite size. '
54
+ + `It says ${claimed.tests} tests (${claimed.pass} passing, ${claimed.skipped} skipped); `
55
+ + `this run had ${reported.tests} (${reported.pass} passing, ${reported.skipped} skipped). `
56
+ + 'Update the sentence in README.md before the release.';
57
+ }
58
+
59
+ export function readmeText(readmePath) {
60
+ try {
61
+ return fs.readFileSync(readmePath, 'utf8');
62
+ } catch {
63
+ return null;
64
+ }
65
+ }