@minhspark/codex-mcp-bridge 1.22.11 → 1.22.12

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-mcp-bridge",
3
- "version": "1.22.11",
3
+ "version": "1.22.12",
4
4
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
5
5
  "author": {
6
6
  "name": "Bui Dang Minh"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.22.12] - 2026-10-20
8
+
9
+ ### Fixed
10
+
11
+ - Move directly registered Codex entries to the supervisor. `claude-mcp-bridge-install` and `codex-native-relay-install` used to refuse an existing `codex-native-relay` entry that had `env_vars`, the very setting the README told users to add, and `codex mcp add` drops `env_vars` and every comment in `config.toml`. When the environment does not change, the installers now edit only `command` and `args` of the existing table in place, read the entry back through `codex mcp get --json`, and restore the previous file if anything differs. Seen on 2026-10-20: both Codex-side entries still pointed at `src/claude-bridge.mjs` and `src/native-relay-companion.mjs`, so `claude-bridge` stayed `1.22.8 STALE` after v1.22.10 until Codex Desktop restarted.
12
+ - On Windows and macOS, `codex-native-relay-install` adds `CODEX_APP_TOOLS_PIPE_PATH` to the relay's `env_vars` itself, by name only, instead of asking for a hand edit.
13
+ - An entry written as an inline table or with dotted keys, which the in-place edit does not handle, still goes through `codex mcp add` when that loses nothing; if the relay's `env_vars` cannot then be added, the installer says so instead of failing. Datetimes written with a space instead of `T` no longer stop the scan.
14
+ - Keep relay requests and peer messages that reach the listener while a reload quiesces. A client whose connection was still in the kernel's accept queue when the listener closed was reset: the relay client reported `RELAY_DELIVERY_UNCONFIRMED` for a request the companion never read, and a Claude peer message was lost after a successful write. Quiesce now removes the socket path first, waits a few event-loop turns for queued connections, answers relay clients with `RELAY_RELOADING` (no message sent), and reads peer messages into the inbox that the next worker restores. A relay client answered with `RELAY_RELOADING` during reload also no longer keeps the old listener from closing. The relay now binds a private name and hard-links it to the public path, because closing a Unix socket server unlinks the path it was bound to; a companion that binds the public path while a reload drains keeps its socket.
15
+
16
+ ### Verification
17
+
18
+ - 18 new tests. The installer tests run both installers against a fake `codex` and a live-shaped `config.toml`; 7 tests fail on v1.22.10. The race tests block the event loop while a client connects and writes, then quiesce; the relay and peer cases fail on v1.22.10 with `write EPIPE` and an empty inbox. A successor companion's socket survives the old listener closing; that test fails when the relay binds the public path directly.
19
+ - Both installers were also run against the real Codex CLI 0.163.0 with a temporary `CODEX_HOME`, covering a CRLF file, a multi-line `args` array with comments, a rerun and a fresh install. Only `command`, `args` and the added `env_vars` changed.
20
+ - An independent review of reload safety covered the relay, the peer endpoint and the supervisor. Two of five claims survived two refutation attempts each; both are fixed above. A second review of this change found three more defects that survived refutation: inline-table entries refused, space-separated datetimes refused, and the close-time unlink of a successor's socket. All three are fixed, each with a test.
21
+
22
+ - CodeQL reported a file-system race in the first version of the config write: it checked the file, then wrote it by path. The installer now reads `config.toml` through one file descriptor, keeps its device, inode, mode and contents, and checks all four again through a new descriptor just before the atomic rename. The remaining `js/path-injection` reports on the config and relay socket paths come from operator configuration (`CODEX_HOME`, `CODEX_NATIVE_RELAY_SOCKET` or the home directory) and were dismissed as false positives, as in earlier releases.
23
+
24
+ ### Limitations
25
+
26
+ - Windows named pipes cannot be removed from the namespace before the listener closes, so a client that connects in the last moment before close can still be reset there. It is reported as unconfirmed, never as delivered.
27
+ - Moving an entry to the supervisor needs one Codex Desktop restart. The installer does not touch an entry whose environment must also change while it has custom `env_vars`, `cwd`, tool filters or timeouts; it prints the exact `command` and `args` to set by hand.
28
+
7
29
  ## [1.22.11] - 2026-10-20
8
30
 
9
31
  ### Fixed
package/README.md CHANGED
@@ -116,14 +116,14 @@ Separate multiple allowed directories with `;` on Windows or `:` on macOS/Linux.
116
116
 
117
117
  ### Native relay pipe forwarding
118
118
 
119
- Add this to the relay's **existing** entry in `~/.codex/config.toml` (or `$CODEX_HOME/config.toml`), keeping its `command`, `args`, and other settings:
119
+ On Windows and macOS, `codex-native-relay-install` adds this to the relay's entry in `~/.codex/config.toml` (or `$CODEX_HOME/config.toml`) and keeps its other settings:
120
120
 
121
121
  ```toml
122
122
  [mcp_servers.codex-native-relay]
123
123
  env_vars = ["CODEX_APP_TOOLS_PIPE_PATH"]
124
124
  ```
125
125
 
126
- Append the variable name if `env_vars` already exists. Reconnect the relay in Codex Desktop after editing.
126
+ If you register the relay by hand, add the same line and append the variable name if `env_vars` already exists. Only the name is saved, never the temporary pipe path. Restart Codex Desktop after active work finishes so it relaunches the relay.
127
127
 
128
128
  ### Linux / WSL (Bash)
129
129
 
@@ -22,10 +22,11 @@ connector. For a local Desktop installation, use the following path instead:
22
22
  `CODEX_BRIDGE_ALLOWED_ROOTS`, rather than the example's entire home directory;
23
23
  macOS separates multiple roots with `:`. Preserve an existing entry's
24
24
  custom settings.
25
- 4. Add [native relay pipe forwarding](../README.md#native-relay-pipe-forwarding)
26
- to the existing relay entry. Forward `CODEX_APP_TOOLS_PIPE_PATH` by name,
27
- not its temporary value. The relay needs the trusted app-bundled Node runtime
28
- selected by the installer; do not substitute an arbitrary Homebrew/nvm Node.
25
+ 4. Check that the relay entry has
26
+ [native relay pipe forwarding](../README.md#native-relay-pipe-forwarding).
27
+ The installer adds `CODEX_APP_TOOLS_PIPE_PATH` by name, never its temporary
28
+ value. The relay needs the trusted app-bundled Node runtime selected by the
29
+ installer; do not substitute an arbitrary Homebrew/nvm Node.
29
30
  5. Reconnect the affected MCP servers in the actual Desktop Code conversations,
30
31
  or restart the affected app after active work finishes. Complete
31
32
  [installation verification](../README.md#verify-the-installation), including
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.22.11",
3
+ "version": "1.22.12",
4
4
  "description": "Two-way MCP bridge between Claude and Codex: prompts into a live Codex thread, messages into a running Claude Code session.",
5
5
  "keywords": [
6
6
  "mcp",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "codex-mcp-bridge",
4
- "version": "1.22.11",
4
+ "version": "1.22.12",
5
5
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
6
6
  "author": {
7
7
  "name": "Bui Dang Minh",
@@ -7,9 +7,9 @@ import { fileURLToPath } from "node:url";
7
7
  import { PLATFORM_LABEL, resolveCodexBin, spawnEnv } from "../src/platform.mjs";
8
8
  import { exitForVersionRequest } from "../src/cli-version.mjs";
9
9
  import { desktopTasksConfigured } from "../src/native-relay.mjs";
10
- import { codexMcpRegistration } from "../src/codex-mcp-registration.mjs";
10
+ import { applyStdioMcpRegistration, codexMcpRegistration } from "../src/codex-mcp-registration.mjs";
11
11
  import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs";
12
- import { enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-access.mjs";
12
+ import { codexAccessPaths, enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-access.mjs";
13
13
  import { stableInstallPath, stableNodeBin } from "../src/install-paths.mjs";
14
14
 
15
15
  exitForVersionRequest(import.meta.url);
@@ -43,12 +43,12 @@ const existing = servers.some((server) => server.name === serverName)
43
43
  ? JSON.parse(run(["mcp", "get", serverName, "--json"])) : null;
44
44
  const registration = codexMcpRegistration({ name: serverName, existing, node: nodeBin, entry, entryArgs, env: process.env, desktopOnly: desktopTasksConfigured() });
45
45
  createReleaseSnapshot(root, { cache: snapshotRoot({ ...process.env, ...registration.environment }) });
46
- run(registration.args);
46
+ const applied = applyStdioMcpRegistration({ name: serverName, registration, existing, run, configPath: codexAccessPaths().config });
47
47
 
48
48
  console.log(`platform: ${PLATFORM_LABEL}`);
49
- console.log(`registered MCP server "${serverName}" with Codex:`);
49
+ console.log(`registered MCP server "${serverName}" with Codex (${applied.mode === "retarget" ? "existing entry retargeted in place; its other settings were kept" : "codex mcp add"}):`);
50
50
  console.log(`command: ${nodeBin} ${entry} ${entryArgs.join(" ")}`);
51
51
  console.log(`environment keys: ${registration.environmentKeys.join(", ")}`);
52
- console.log("\nReconnect claude-bridge once in the existing Codex Desktop task to load the supervisor. Subsequent compatible installed-source updates reload automatically when the worker is safely idle. Do not create a replacement task.");
53
- console.log("Verify claude_bridge_status from that task: autoReload must be enabled, runtime state must be current, and the session policy must match this installation. A separate diagnostic process does not verify the app's loaded MCP process.");
52
+ console.log("\nOne-time step: after active Codex work finishes, quit and reopen Codex Desktop once so it starts the supervisor. Later compatible installed updates reload automatically when the worker is idle. Do not create a replacement task.");
53
+ console.log("Verify claude_bridge_status from an existing task: it must report \"automatic reload: current\" with a supervisor pid, runtime state current, and the session policy of this installation. A separate diagnostic process does not verify the app's loaded MCP process.");
54
54
  console.log(`remove: node scripts/install-codex-mcp.mjs --remove`);
@@ -17,14 +17,14 @@ import {
17
17
  spawnEnv,
18
18
  } from "../src/platform.mjs";
19
19
  import { exitForVersionRequest } from "../src/cli-version.mjs";
20
- import { stdioMcpRegistration } from "../src/codex-mcp-registration.mjs";
20
+ import { applyStdioMcpRegistration, stdioMcpRegistration } from "../src/codex-mcp-registration.mjs";
21
21
  import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs";
22
- import { enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-access.mjs";
22
+ import { codexAccessPaths, enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-access.mjs";
23
23
  import { stableInstallPath } from "../src/install-paths.mjs";
24
24
 
25
25
  exitForVersionRequest(import.meta.url);
26
26
 
27
- const VERSION = "1.22.11";
27
+ const VERSION = "1.22.12";
28
28
  const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
29
29
  const entry = path.join(root, "src", "mcp-supervisor.mjs");
30
30
  const entryArgs = ["native-relay-companion.mjs"];
@@ -80,16 +80,12 @@ const existingServer = servers.some((server) => server.name === serverName)
80
80
  */
81
81
  const runtime = resolveCodexDesktopNodeBin();
82
82
 
83
- if (IS_MACOS || IS_WINDOWS) {
84
- console.log(`Native Desktop setup: in ${path.join(homeDir(), ".codex", "config.toml")} (or CODEX_HOME/config.toml), add env_vars = ["CODEX_APP_TOOLS_PIPE_PATH"] to the existing [mcp_servers.${serverName}] entry after registration.`);
85
- console.log("Preserve existing settings and append the name to any existing env_vars list; do not save the temporary pipe path. Reconnect the relay or restart Codex Desktop after active work finishes, then check native_relay_status in the existing task.");
86
- }
87
-
88
83
  const registration = stdioMcpRegistration({ name: serverName, existing: existingServer, node: runtime.path, entry, entryArgs, envOverrides: {
89
84
  ...(process.env.CLAUDE_DESKTOP_USER_DATA !== undefined ? { CLAUDE_DESKTOP_USER_DATA: process.env.CLAUDE_DESKTOP_USER_DATA } : {}),
90
85
  } });
91
86
  createReleaseSnapshot(root, { cache: snapshotRoot({ ...process.env, ...registration.environment }) });
92
- run(registration.args);
87
+ const ensureEnvVars = IS_MACOS || IS_WINDOWS ? ["CODEX_APP_TOOLS_PIPE_PATH"] : [];
88
+ const applied = applyStdioMcpRegistration({ name: serverName, registration, existing: existingServer, run, configPath: codexAccessPaths().config, ensureEnvVars });
93
89
 
94
90
  console.log(`platform: ${PLATFORM_LABEL}`);
95
91
  console.log(`relay runtime: ${runtime.path} (${runtime.source})`);
@@ -105,8 +101,10 @@ if (runtime.source === "process.execPath" && hasCodexDesktopApp()) {
105
101
  );
106
102
  console.log("runtime shipped inside Codex Desktop if that happens.");
107
103
  }
108
- console.log(`registered MCP server "${serverName}" with Codex:`);
104
+ console.log(`registered MCP server "${serverName}" with Codex (${applied.mode === "retarget" ? "existing entry retargeted in place; its other settings were kept" : "codex mcp add"}):`);
109
105
  console.log(run(["mcp", "get", serverName]));
106
+ if (applied.notice) console.log(`NOTE: ${applied.notice}`);
107
+ else if (ensureEnvVars.length) console.log(`env_vars includes ${ensureEnvVars.join(", ")}; the temporary pipe path itself is never saved.`);
110
108
 
111
109
  const existing = readRelayConfig()?.relayThreadId;
112
110
  if (existing) {
@@ -134,5 +132,5 @@ if (process.argv.includes("--desktop-tasks")) {
134
132
  writeRelayConfig({ ...readRelayConfig(), desktopTasks: true });
135
133
  console.log("Desktop task creation enabled: exact saved local projects, immediate visibility, Codex Desktop permissions.");
136
134
  }
137
- console.log("Reconnect codex-native-relay once in the existing Codex Desktop task to load the supervisor, then verify native_relay_status reports autoReload enabled. Subsequent compatible installed-source updates reload automatically when safely idle.");
135
+ console.log("One-time step: after active Codex work finishes, quit and reopen Codex Desktop once so it starts the supervisor. Then call native_relay_status in an existing task: it must report \"automatic reload: current\" with a supervisor pid. Later compatible installed updates reload automatically when the relay is idle.");
138
136
  console.log("remove: node scripts/install-native-relay.mjs --remove");
@@ -30,7 +30,7 @@ import { discoverClaudeProject } from "./claude-project-discovery.mjs";
30
30
 
31
31
  exitForVersionRequest(import.meta.url);
32
32
 
33
- const VERSION = "1.22.11";
33
+ const VERSION = "1.22.12";
34
34
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
35
35
  const FORWARD_MIN_INTERVAL_MS = 5000;
36
36
  const FORWARD_MAX_PER_SESSION = 50;
@@ -1,18 +1,35 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { retargetMcpServerToml } from "./codex-mcp-toml.mjs";
4
+
5
+ const PRESERVED_FIELDS = ["enabled", "disabled_reason", "enabled_tools", "disabled_tools", "startup_timeout_sec", "tool_timeout_sec"];
6
+
7
+ function sameEnvironment(left = {}, right = {}) {
8
+ const keys = (value) => Object.keys(value ?? {}).sort();
9
+ return JSON.stringify(keys(left)) === JSON.stringify(keys(right)) && keys(left).every((key) => left[key] === right[key]);
10
+ }
11
+
12
+ function addLosesSettings(existing) {
13
+ const customized = ["enabled_tools", "disabled_tools", "startup_timeout_sec", "tool_timeout_sec", "disabled_reason"]
14
+ .some((key) => existing[key] != null && (!Array.isArray(existing[key]) || existing[key].length > 0));
15
+ return existing.enabled === false || customized || Boolean(existing.transport?.cwd) || Boolean(existing.transport?.env_vars?.length);
16
+ }
17
+
1
18
  export function stdioMcpRegistration({ name, existing, node, entry, entryArgs = [], envOverrides = {} }) {
2
- if (existing) {
3
- const customized = ["enabled_tools", "disabled_tools", "startup_timeout_sec", "tool_timeout_sec", "disabled_reason"]
4
- .some((key) => existing[key] != null && (!Array.isArray(existing[key]) || existing[key].length > 0));
5
- if (existing.enabled === false || customized || existing.transport?.type !== "stdio" || existing.transport?.cwd || existing.transport?.env_vars?.length) {
6
- throw new Error(`The existing MCP entry has custom access, timeout, or transport settings. The installer will not remove or reset it. Keep those settings and update only command=${JSON.stringify(node)} and args=${JSON.stringify([entry, ...entryArgs])} in the existing entry, then reconnect that MCP server once.`);
7
- }
8
- }
9
19
  const values = { ...existing?.transport?.env, ...envOverrides };
10
- const args = ["mcp", "add", name];
11
- for (const [key, value] of Object.entries(values).sort(([a], [b]) => a.localeCompare(b))) {
20
+ for (const [key, value] of Object.entries(values)) {
12
21
  if (typeof value !== "string") throw new Error(`MCP environment value ${key} must be a string`);
13
- args.push("--env", `${key}=${value}`);
14
22
  }
15
- return { args: [...args, "--", node, entry, ...entryArgs], environment: values, environmentKeys: Object.keys(values).sort() };
23
+ const commandArgs = [entry, ...entryArgs];
24
+ const manual = `Keep those settings and update only command=${JSON.stringify(node)} and args=${JSON.stringify(commandArgs)} in the existing entry, then restart Codex Desktop once.`;
25
+ const plan = { command: node, commandArgs, environment: values, environmentKeys: Object.keys(values).sort(), manual };
26
+ const addIsSafe = !existing || existing.transport?.type === "stdio" && !addLosesSettings(existing);
27
+ const addArgs = ["mcp", "add", name];
28
+ for (const [key, value] of Object.entries(values).sort(([a], [b]) => a.localeCompare(b))) addArgs.push("--env", `${key}=${value}`);
29
+ const add = addIsSafe ? { args: [...addArgs, "--", node, ...commandArgs] } : {};
30
+ if (existing?.transport?.type === "stdio" && sameEnvironment(existing.transport.env, values)) return { ...plan, ...add, mode: "retarget" };
31
+ if (!addIsSafe) throw new Error(`The existing MCP entry has custom access, timeout, or transport settings. The installer will not remove or reset it. ${manual}`);
32
+ return { ...plan, ...add, mode: "add" };
16
33
  }
17
34
 
18
35
  export function codexMcpRegistration({ name, existing, node, entry, entryArgs = [], env, desktopOnly }) {
@@ -28,3 +45,105 @@ export function codexMcpRegistration({ name, existing, node, entry, entryArgs =
28
45
  if (values.CODEX_BRIDGE_DESKTOP_TASKS === "1") values.CODEX_BRIDGE_AUTOSTART = "0";
29
46
  return stdioMcpRegistration({ name, existing, node, entry, entryArgs, envOverrides: values });
30
47
  }
48
+
49
+ function readConfigSnapshot(configPath) {
50
+ let descriptor;
51
+ try { descriptor = fs.openSync(configPath, "r"); }
52
+ catch (error) {
53
+ if (error.code === "ENOENT") return null;
54
+ throw error;
55
+ }
56
+ try {
57
+ const stats = fs.fstatSync(descriptor, { bigint: true });
58
+ return { path: fs.realpathSync.native(configPath), contents: fs.readFileSync(descriptor, "utf8"), dev: stats.dev, ino: stats.ino, mode: Number(stats.mode & 0o777n) };
59
+ } finally {
60
+ fs.closeSync(descriptor);
61
+ }
62
+ }
63
+
64
+ function writeConfigAtomically(snapshot, next) {
65
+ const temporary = path.join(path.dirname(snapshot.path), `.${path.basename(snapshot.path)}.bridge-${process.pid}.tmp`);
66
+ fs.writeFileSync(temporary, next, { encoding: "utf8", mode: snapshot.mode, flag: "wx" });
67
+ try {
68
+ const current = readConfigSnapshot(snapshot.path);
69
+ if (!current || current.dev !== snapshot.dev || current.ino !== snapshot.ino || current.contents !== snapshot.contents) {
70
+ throw new Error("The Codex configuration changed while the installer was updating it; nothing was changed. Run the installer again.");
71
+ }
72
+ fs.renameSync(temporary, snapshot.path);
73
+ } catch (error) {
74
+ fs.rmSync(temporary, { force: true });
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ function verificationErrors(before, after, registration, ensureEnvVars) {
80
+ const errors = [];
81
+ const transport = after?.transport ?? {};
82
+ if (transport.type !== "stdio") errors.push("transport is no longer stdio");
83
+ if (transport.command !== registration.command) errors.push(`command is ${JSON.stringify(transport.command)}`);
84
+ if (JSON.stringify(transport.args ?? []) !== JSON.stringify(registration.commandArgs)) errors.push(`args are ${JSON.stringify(transport.args)}`);
85
+ if (!sameEnvironment(transport.env, registration.environment)) errors.push("the environment block changed");
86
+ const envVars = transport.env_vars ?? [];
87
+ for (const variable of [...(before?.transport?.env_vars ?? []), ...ensureEnvVars]) {
88
+ if (!envVars.includes(variable)) errors.push(`env_vars lost ${variable}`);
89
+ }
90
+ if (before) {
91
+ if ((transport.cwd ?? null) !== (before.transport?.cwd ?? null)) errors.push("cwd changed");
92
+ for (const field of PRESERVED_FIELDS) {
93
+ if (JSON.stringify(after[field] ?? null) !== JSON.stringify(before[field] ?? null)) errors.push(`${field} changed`);
94
+ }
95
+ }
96
+ return errors;
97
+ }
98
+
99
+ export function applyStdioMcpRegistration({ name, registration, existing, run, configPath, ensureEnvVars = [] }) {
100
+ const original = readConfigSnapshot(configPath);
101
+ const target = { command: registration.command, args: registration.commandArgs, ensureEnvVars };
102
+ const editInPlace = () => {
103
+ const current = readConfigSnapshot(configPath);
104
+ if (!current) throw new Error("The Codex configuration file does not exist");
105
+ const next = retargetMcpServerToml(current.contents, name, target);
106
+ if (next === current.contents) return false;
107
+ writeConfigAtomically(current, next);
108
+ return true;
109
+ };
110
+ let mode = registration.mode;
111
+ let mutated = false;
112
+ let edited = false;
113
+ let envVarsAdded = ensureEnvVars.length === 0;
114
+ let notice = null;
115
+ try {
116
+ if (mode === "retarget") {
117
+ try {
118
+ edited = editInPlace();
119
+ mutated = edited;
120
+ envVarsAdded = true;
121
+ } catch (error) {
122
+ if (!registration.args) throw new Error(`${error.message}. ${registration.manual}`);
123
+ mode = "add";
124
+ }
125
+ }
126
+ if (mode === "add") {
127
+ run(registration.args);
128
+ mutated = true;
129
+ if (!envVarsAdded) {
130
+ try {
131
+ edited = editInPlace() || edited;
132
+ envVarsAdded = true;
133
+ } catch (error) {
134
+ notice = `Add env_vars = ${JSON.stringify(ensureEnvVars)} to the ${name} entry by hand (${error.message}).`;
135
+ }
136
+ }
137
+ }
138
+ const errors = verificationErrors(existing, JSON.parse(run(["mcp", "get", name, "--json"])), registration, envVarsAdded ? ensureEnvVars : []);
139
+ if (errors.length) throw new Error(`Codex did not read back the expected ${name} entry (${errors.join("; ")})`);
140
+ } catch (error) {
141
+ if (!mutated) throw error;
142
+ const current = readConfigSnapshot(configPath);
143
+ if (original === null) fs.rmSync(configPath, { force: true });
144
+ else if (current) writeConfigAtomically(current, original.contents);
145
+ else fs.writeFileSync(original.path, original.contents, { encoding: "utf8", mode: original.mode, flag: "wx" });
146
+ throw new Error(`${error.message}; the previous Codex configuration was restored`);
147
+ }
148
+ return { mode, edited, envVarsAdded, notice };
149
+ }
@@ -0,0 +1,239 @@
1
+ const BARE_KEY = /[A-Za-z0-9_-]/;
2
+ const WHITESPACE = /[ \t]/;
3
+
4
+ class TomlScanError extends Error {}
5
+
6
+ function fail(message) {
7
+ throw new TomlScanError(message);
8
+ }
9
+
10
+ function skipWhitespace(text, index) {
11
+ while (index < text.length && WHITESPACE.test(text[index])) index++;
12
+ return index;
13
+ }
14
+
15
+ function lineEnd(text, index) {
16
+ const newline = text.indexOf("\n", index);
17
+ return newline === -1 ? text.length : newline + 1;
18
+ }
19
+
20
+ function readString(text, index) {
21
+ const quote = text[index];
22
+ const multiline = text.startsWith(quote.repeat(3), index);
23
+ const delimiter = multiline ? quote.repeat(3) : quote;
24
+ let cursor = index + delimiter.length;
25
+ let value = "";
26
+ while (cursor < text.length) {
27
+ if (text.startsWith(delimiter, cursor)) {
28
+ let end = cursor + delimiter.length;
29
+ if (multiline) while (text[end] === quote && end - cursor < 5) end++;
30
+ if (multiline) value += quote.repeat(end - cursor - 3);
31
+ return { end, value };
32
+ }
33
+ const character = text[cursor];
34
+ if (!multiline && (character === "\n" || character === "\r")) fail("Unterminated single-line string");
35
+ if (quote === '"' && character === "\\") {
36
+ const escape = text[cursor + 1];
37
+ const simple = { b: "\b", t: "\t", n: "\n", f: "\f", r: "\r", e: "\u001b", '"': '"', "\\": "\\" };
38
+ if (escape in simple) { value += simple[escape]; cursor += 2; continue; }
39
+ const width = escape === "u" ? 4 : escape === "U" ? 8 : escape === "x" ? 2 : 0;
40
+ if (width) {
41
+ const hex = text.slice(cursor + 2, cursor + 2 + width);
42
+ if (!new RegExp(`^[0-9A-Fa-f]{${width}}$`).test(hex)) fail("Invalid string escape");
43
+ value += String.fromCodePoint(Number.parseInt(hex, 16));
44
+ cursor += 2 + width;
45
+ continue;
46
+ }
47
+ if (multiline && /[ \t\r\n]/.test(escape)) {
48
+ let next = cursor + 1;
49
+ while (next < text.length && /[ \t]/.test(text[next])) next++;
50
+ if (text[next] === "\r") next++;
51
+ if (text[next] !== "\n") fail("Invalid line-ending backslash");
52
+ while (next < text.length && /[ \t\r\n]/.test(text[next])) next++;
53
+ cursor = next;
54
+ continue;
55
+ }
56
+ fail("Invalid string escape");
57
+ }
58
+ value += character;
59
+ cursor++;
60
+ }
61
+ return fail("Unterminated string");
62
+ }
63
+
64
+ function readKey(text, index, terminator) {
65
+ const parts = [];
66
+ let cursor = skipWhitespace(text, index);
67
+ for (;;) {
68
+ if (text[cursor] === '"' || text[cursor] === "'") {
69
+ if (text.startsWith(text[cursor].repeat(3), cursor)) fail("Multi-line strings are not valid keys");
70
+ const string = readString(text, cursor);
71
+ parts.push(string.value);
72
+ cursor = string.end;
73
+ } else {
74
+ const start = cursor;
75
+ while (cursor < text.length && BARE_KEY.test(text[cursor])) cursor++;
76
+ if (cursor === start) fail("Invalid key");
77
+ parts.push(text.slice(start, cursor));
78
+ }
79
+ cursor = skipWhitespace(text, cursor);
80
+ if (text[cursor] === ".") { cursor = skipWhitespace(text, cursor + 1); continue; }
81
+ if (!text.startsWith(terminator, cursor)) fail(`Expected ${terminator} after key`);
82
+ return { parts, end: cursor + terminator.length };
83
+ }
84
+ }
85
+
86
+ function skipTrivia(text, index) {
87
+ let cursor = index;
88
+ for (;;) {
89
+ cursor = skipWhitespace(text, cursor);
90
+ if (text[cursor] === "#") cursor = lineEnd(text, cursor);
91
+ else if (text[cursor] === "\r" || text[cursor] === "\n") cursor++;
92
+ else return cursor;
93
+ }
94
+ }
95
+
96
+ function readValue(text, index) {
97
+ const character = text[index];
98
+ if (character === '"' || character === "'") return { end: readString(text, index).end };
99
+ if (character === "[" || character === "{") {
100
+ const close = character === "[" ? "]" : "}";
101
+ let cursor = skipTrivia(text, index + 1);
102
+ while (text[cursor] !== close) {
103
+ if (cursor >= text.length) fail("Unterminated array or inline table");
104
+ if (close === "}") {
105
+ const key = readKey(text, cursor, "=");
106
+ cursor = skipWhitespace(text, key.end);
107
+ }
108
+ cursor = skipTrivia(text, readValue(text, cursor).end);
109
+ if (text[cursor] === ",") cursor = skipTrivia(text, cursor + 1);
110
+ else if (text[cursor] !== close) fail("Expected a separator in an array or inline table");
111
+ }
112
+ return { end: cursor + 1 };
113
+ }
114
+ let cursor = index;
115
+ const scalar = () => { while (cursor < text.length && !/[ \t\r\n#,\]}]/.test(text[cursor])) cursor++; };
116
+ scalar();
117
+ if (cursor === index) fail("Missing value");
118
+ if (/^\d{4}-\d{2}-\d{2}$/.test(text.slice(index, cursor)) && /^ \d{2}:\d{2}/.test(text.slice(cursor, cursor + 6))) {
119
+ cursor++;
120
+ scalar();
121
+ }
122
+ return { end: cursor };
123
+ }
124
+
125
+ function scan(text) {
126
+ const statements = [];
127
+ let cursor = text.startsWith("\uFEFF") ? 1 : 0;
128
+ while (cursor < text.length) {
129
+ const start = cursor;
130
+ const content = skipWhitespace(text, cursor);
131
+ const character = text[content];
132
+ if (content >= text.length || character === "\n" || character === "\r" || character === "#") {
133
+ cursor = lineEnd(text, content);
134
+ continue;
135
+ }
136
+ if (character === "[") {
137
+ const array = text[content + 1] === "[";
138
+ const key = readKey(text, content + (array ? 2 : 1), array ? "]]" : "]");
139
+ statements.push({ kind: array ? "array-table" : "table", path: key.parts, start });
140
+ cursor = lineEnd(text, key.end);
141
+ continue;
142
+ }
143
+ const key = readKey(text, content, "=");
144
+ const valueStart = skipWhitespace(text, key.end);
145
+ const { end: valueEnd } = readValue(text, valueStart);
146
+ const tail = skipWhitespace(text, valueEnd);
147
+ if (tail < text.length && !["\r", "\n", "#"].includes(text[tail])) fail("Unexpected text after a value");
148
+ cursor = lineEnd(text, valueEnd);
149
+ statements.push({ kind: "value", path: key.parts, start, valueStart, valueEnd, end: cursor });
150
+ }
151
+ return statements;
152
+ }
153
+
154
+ export function scanTomlStatements(text) {
155
+ try { return scan(text); }
156
+ catch (error) {
157
+ if (error instanceof TomlScanError) throw new Error(`The Codex configuration could not be scanned safely (${error.message}); it was not changed`);
158
+ throw error;
159
+ }
160
+ }
161
+
162
+ function samePath(left, right) {
163
+ return left.length === right.length && left.every((part, index) => part === right[index]);
164
+ }
165
+
166
+ export function tomlString(value) {
167
+ return JSON.stringify(String(value)).replaceAll("\u007f", "\\u007F");
168
+ }
169
+
170
+ function tomlArray(values) {
171
+ return `[${values.map(tomlString).join(", ")}]`;
172
+ }
173
+
174
+ function parseStringArray(text, statement) {
175
+ const raw = text.slice(statement.valueStart, statement.valueEnd);
176
+ if (!raw.startsWith("[")) fail("env_vars is not an array");
177
+ const values = [];
178
+ let cursor = skipTrivia(raw, 1);
179
+ while (raw[cursor] !== "]") {
180
+ if (raw[cursor] !== '"' && raw[cursor] !== "'") fail("env_vars must contain only strings");
181
+ const string = readString(raw, cursor);
182
+ values.push(string.value);
183
+ cursor = skipTrivia(raw, string.end);
184
+ if (raw[cursor] === ",") cursor = skipTrivia(raw, cursor + 1);
185
+ }
186
+ return values;
187
+ }
188
+
189
+ export function retargetMcpServerToml(contents, name, { command, args, ensureEnvVars = [] }) {
190
+ const statements = scanTomlStatements(contents);
191
+ const serverPath = ["mcp_servers", name];
192
+ const headers = statements.filter((statement) => statement.kind !== "value");
193
+ const owners = headers.filter((statement) => statement.kind === "table" && samePath(statement.path, serverPath));
194
+ if (owners.length !== 1) throw new Error(`Expected exactly one [mcp_servers.${name}] table in the Codex configuration; it was not changed`);
195
+ const header = owners[0];
196
+ const sectionEnd = headers.find((statement) => statement.start > header.start)?.start ?? contents.length;
197
+ const section = statements.filter((statement) => statement.kind === "value" && statement.start > header.start && statement.start < sectionEnd);
198
+ for (const statement of statements) {
199
+ if (statement.kind !== "value" || section.includes(statement)) continue;
200
+ const owner = headers.filter((candidate) => candidate.start < statement.start).at(-1);
201
+ const fullPath = [...(owner?.kind === "table" ? owner.path : []), ...statement.path];
202
+ if (samePath(fullPath, serverPath) || ["command", "args", "env_vars"].some((key) => samePath(fullPath, [...serverPath, key]))) {
203
+ throw new Error(`The ${name} MCP entry is also defined outside its table; it was not changed`);
204
+ }
205
+ }
206
+ const find = (key) => {
207
+ const matches = section.filter((statement) => samePath(statement.path, [key]));
208
+ if (matches.length > 1) throw new Error(`The ${name} MCP entry defines ${key} more than once; it was not changed`);
209
+ return matches[0];
210
+ };
211
+ const commandStatement = find("command");
212
+ const argsStatement = find("args");
213
+ if (!commandStatement) throw new Error(`The ${name} MCP entry has no command; it was not changed`);
214
+ const newline = contents.includes("\r\n") ? "\r\n" : "\n";
215
+ const edits = [{ start: commandStatement.valueStart, end: commandStatement.valueEnd, text: tomlString(command) }];
216
+ if (argsStatement) edits.push({ start: argsStatement.valueStart, end: argsStatement.valueEnd, text: tomlArray(args) });
217
+ else edits.push({ start: commandStatement.end, end: commandStatement.end, text: `${contents.slice(commandStatement.end - 1, commandStatement.end) === "\n" ? "" : newline}args = ${tomlArray(args)}${newline}` });
218
+ if (ensureEnvVars.length) {
219
+ const envVarsStatement = find("env_vars");
220
+ if (envVarsStatement) {
221
+ let existing;
222
+ try { existing = parseStringArray(contents, envVarsStatement); }
223
+ catch (error) {
224
+ if (error instanceof TomlScanError) throw new Error(`The ${name} MCP entry has env_vars that are not a string list; it was not changed`);
225
+ throw error;
226
+ }
227
+ const missing = ensureEnvVars.filter((variable) => !existing.includes(variable));
228
+ if (missing.length) edits.push({ start: envVarsStatement.valueStart, end: envVarsStatement.valueEnd, text: tomlArray([...existing, ...missing]) });
229
+ } else {
230
+ const anchor = argsStatement ?? commandStatement;
231
+ const lineBreak = contents.slice(anchor.end - 1, anchor.end) === "\n" ? "" : newline;
232
+ edits.push({ start: anchor.end, end: anchor.end, text: `${lineBreak}env_vars = ${tomlArray(ensureEnvVars)}${newline}`, after: true });
233
+ }
234
+ }
235
+ edits.sort((left, right) => right.start - left.start || (left.after ? 0 : 1) - (right.after ? 0 : 1));
236
+ let updated = contents;
237
+ for (const edit of edits) updated = `${updated.slice(0, edit.start)}${edit.text}${updated.slice(edit.end)}`;
238
+ return updated;
239
+ }
package/src/index.mjs CHANGED
@@ -40,7 +40,7 @@ import { PROJECT_SETUP_TOOLS, registerProjectOnboardingTools } from "./project-o
40
40
 
41
41
  exitForVersionRequest(import.meta.url);
42
42
 
43
- const VERSION = "1.22.11";
43
+ const VERSION = "1.22.12";
44
44
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
45
45
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
46
46
 
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { randomBytes } from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import net from "node:net";
4
5
  import path from "node:path";
@@ -28,7 +29,7 @@ import {
28
29
  import { IS_WINDOWS, PLATFORM_LABEL } from "./platform.mjs";
29
30
  import { exitForVersionRequest } from "./cli-version.mjs";
30
31
  import { assertAccountIdentity } from "./bridge-account-context.mjs";
31
- import { createReloadControl, exitWhenClientCloses } from "./reload-control.mjs";
32
+ import { createReloadControl, drainPendingAccepts, exitWhenClientCloses } from "./reload-control.mjs";
32
33
  import { createRuntimeState } from "./runtime-state.mjs";
33
34
  import { hardenedBridgeEnabled } from "./hardened-root-policy.mjs";
34
35
  import { protectCurrentUserPipe } from "./windows-pipe-acl.mjs";
@@ -38,7 +39,7 @@ import { NativeCreationReceipts, creationIdValid } from "./native-creation-recei
38
39
 
39
40
  exitForVersionRequest(import.meta.url);
40
41
 
41
- const VERSION = "1.22.11";
42
+ const VERSION = "1.22.12";
42
43
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
43
44
 
44
45
  function errorResponse(code, message, sent, details) {
@@ -421,6 +422,14 @@ export class RelaySocketServer {
421
422
  this.provisionalSockets = new Set();
422
423
  this.startGeneration = 0;
423
424
  this.startPromise = null;
425
+ this.withdrawn = false;
426
+ this.boundIdentity = null;
427
+ }
428
+
429
+ withdraw() {
430
+ if (!this.started || IS_WINDOWS || this.withdrawn || !this.#ownsPublicPath()) return;
431
+ this.withdrawn = true;
432
+ try { fs.rmSync(this.socketPath, { force: true }); } catch {}
424
433
  }
425
434
 
426
435
  start() {
@@ -438,6 +447,8 @@ export class RelaySocketServer {
438
447
  if (!IS_WINDOWS) fs.mkdirSync(path.dirname(this.socketPath), { recursive: true });
439
448
 
440
449
  const generation = ++this.startGeneration;
450
+ this.withdrawn = false;
451
+ this.boundIdentity = null;
441
452
  this.aclReady = !IS_WINDOWS || !this.strict;
442
453
  this.server = net.createServer((socket) => this.#handleConnection(socket));
443
454
  await this.#listen({ replaceStale: true });
@@ -504,7 +515,54 @@ export class RelaySocketServer {
504
515
  * swept, an accepted one means a live companion already has the socket and
505
516
  * this process leaves it alone.
506
517
  */
518
+ #bind(target) {
519
+ return new Promise((resolve, reject) => {
520
+ this.server.once("error", reject);
521
+ this.server.listen(target, () => {
522
+ this.server.off("error", reject);
523
+ resolve();
524
+ });
525
+ });
526
+ }
527
+
528
+ #ownsPublicPath() {
529
+ if (!this.boundIdentity) return false;
530
+ try {
531
+ const current = fs.lstatSync(this.socketPath, { bigint: true });
532
+ return current.dev === this.boundIdentity.dev && current.ino === this.boundIdentity.ino;
533
+ } catch { return false; }
534
+ }
535
+
536
+ async #listenLinked(replaceStale) {
537
+ const privatePath = path.join(path.dirname(this.socketPath), `.${randomBytes(6).toString("hex")}.sock`);
538
+ try { await this.#bind(privatePath); }
539
+ catch (error) {
540
+ if (["ENAMETOOLONG", "EINVAL"].includes(error.code)) return false;
541
+ throw error;
542
+ }
543
+ try {
544
+ for (let attempt = 0; ; attempt++) {
545
+ try { fs.linkSync(privatePath, this.socketPath); break; }
546
+ catch (error) {
547
+ if (error.code !== "EEXIST") throw error;
548
+ if (!replaceStale || attempt || await this.#socketIsLive()) throw new Error(`another native relay companion already owns ${this.socketPath}`);
549
+ this.log(`removing the stale relay socket left at ${this.socketPath}`);
550
+ fs.rmSync(this.socketPath, { force: true });
551
+ }
552
+ }
553
+ const identity = fs.lstatSync(this.socketPath, { bigint: true });
554
+ this.boundIdentity = { dev: identity.dev, ino: identity.ino };
555
+ } catch (error) {
556
+ await this.#closeServer();
557
+ throw error;
558
+ } finally {
559
+ fs.rmSync(privatePath, { force: true });
560
+ }
561
+ return true;
562
+ }
563
+
507
564
  async #listen({ replaceStale }) {
565
+ if (!IS_WINDOWS && await this.#listenLinked(replaceStale)) return;
508
566
  try {
509
567
  await new Promise((resolve, reject) => {
510
568
  this.server.once("error", reject);
@@ -562,6 +620,8 @@ export class RelaySocketServer {
562
620
  }
563
621
  if (!this.accepting) {
564
622
  socket.on("error", () => {});
623
+ socket.setTimeout(5000, () => socket.destroy());
624
+ socket.resume();
565
625
  socket.end(`${JSON.stringify(errorResponse(this.refusal.code, this.refusal.message, false))}\n`);
566
626
  return;
567
627
  }
@@ -645,7 +705,7 @@ export class RelaySocketServer {
645
705
  catch { resolve(); }
646
706
  });
647
707
  try {
648
- if (this.started && !IS_WINDOWS) fs.rmSync(this.socketPath, { force: true });
708
+ if (this.started && !IS_WINDOWS && !this.withdrawn && (!this.boundIdentity || this.#ownsPublicPath())) fs.rmSync(this.socketPath, { force: true });
649
709
  } catch {}
650
710
  this.started = false;
651
711
  }
@@ -772,7 +832,11 @@ export function createNativeRelayLifecycle({ nativeTools, relays, log: logFn = (
772
832
  const reason = inspect();
773
833
  if (reason) throw new Error(reason);
774
834
  ownedSockets = servers.filter((relay) => relay.started).map((relay) => relay.socketPath);
835
+ for (const relay of servers) relay.withdraw();
836
+ await drainPendingAccepts();
837
+ const late = inspect();
775
838
  await stop();
839
+ if (late) throw new Error(late);
776
840
  },
777
841
  exportState: () => ({ ownedSockets }),
778
842
  restore(payload) {
@@ -6,7 +6,7 @@ import os from "node:os";
6
6
  import path from "node:path";
7
7
 
8
8
  import { homeDir, IS_WINDOWS } from "./platform.mjs";
9
- import { cloneReloadState } from "./reload-control.mjs";
9
+ import { cloneReloadState, drainPendingAccepts } from "./reload-control.mjs";
10
10
  import { hardenedBridgeEnabled } from "./hardened-root-policy.mjs";
11
11
  import { protectCurrentUserPipe } from "./windows-pipe-acl.mjs";
12
12
 
@@ -586,6 +586,8 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
586
586
  return null;
587
587
  }
588
588
 
589
+ const RELOAD_DRAIN_MS = 1000;
590
+
589
591
  /**
590
592
  * Registers this process as a peer that Claude sessions can see in their agent
591
593
  * list and reply to. Without a registered socket, Claude has no address to
@@ -1140,6 +1142,19 @@ export class PeerEndpoint {
1140
1142
  async quiesce() {
1141
1143
  const reason = this.reloadReason();
1142
1144
  if (reason) throw new Error(reason);
1145
+ const withdrawn = this.started && !IS_WINDOWS;
1146
+ if (withdrawn) { try { fs.rmSync(this.socketPath, { force: true }); } catch {} }
1147
+ await drainPendingAccepts();
1148
+ const deadline = Date.now() + RELOAD_DRAIN_MS;
1149
+ while (this.connections.size && Date.now() < deadline) await new Promise((resolve) => globalThis.setTimeout(resolve, 10));
1150
+ const late = this.reloadReason();
1151
+ if (late) {
1152
+ if (withdrawn) {
1153
+ try { this.server?.close(); } catch {}
1154
+ this.started = false;
1155
+ }
1156
+ throw new Error(late);
1157
+ }
1143
1158
  const revision = this.activityRevision;
1144
1159
  this.reloadPaused = true;
1145
1160
  globalThis.clearInterval(this.responsePoll);
@@ -21,6 +21,10 @@ export function clientReloadReason(client) {
21
21
  return null;
22
22
  }
23
23
 
24
+ export async function drainPendingAccepts(turns = 3) {
25
+ for (let turn = 0; turn < turns; turn++) await new Promise((resolve) => setImmediate(resolve));
26
+ }
27
+
24
28
  export function createReloadControl({ entry, inspect = () => null, quiesce = () => {}, exportState = () => ({}), restore = () => {}, activate = () => {}, resume = activate, env = process.env, channel = process } = {}) {
25
29
  if (!["index.mjs", "claude-bridge.mjs", "native-relay-companion.mjs"].includes(entry)) throw new Error("Invalid reload entry point");
26
30
  const enabled = env.CODEX_BRIDGE_WORKER === "1" && typeof channel.send === "function";