@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.
@@ -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 = path.resolve(
39
- brandRoot || process.env.CODEX_BRIDGE_HOME || path.join(homedir, '.lyupro', '.codex-bridge'),
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({ host, dryRun = false, force = false, packageRoot, env = process.env } = {}) {
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) await addRulesOwner(host);
127
- return { exitCode: 0, output: retentionOutput(configuredRetentionLine, 'codex-bridge is already installed; nothing to do.') };
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 ? { added: 0 } : await addPermissionRules(host.settingsPath);
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: retentionOutput(
200
- configuredRetentionLine,
201
- `Installed ${plan.length + 1} files and registered the ${targets.map(({ definition }) => definition.event).join(' and ')} hooks.`
202
- + `\nGranted ${permissionResult.added} permission rule(s) in ${host.settingsPath} so the host runs the package command without asking.`,
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
  }
@@ -108,7 +108,13 @@ export async function addPermissionRules(settingsPath) {
108
108
  added += 1;
109
109
  }
110
110
  }
111
- return { changed: added > 0, added };
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.3",
4
- "description": "Claude Code-to-Codex delegation with 6 guards, 757 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.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
+ }
@@ -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
  }