@minhspark/codex-mcp-bridge 1.22.10 → 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.10",
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,45 @@ 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
+
29
+ ## [1.22.11] - 2026-10-20
30
+
31
+ ### Fixed
32
+
33
+ - Stop `claude-bridge` and native relay companion workers from outliving their MCP client. The supervisor ends a worker's stdin and IPC channel and waits before killing it, but MCP clients such as the SDK stdio transport kill the supervisor about 4 seconds after closing it. `index.mjs` already exited on stdin end; the other two workers did not, so they kept running with parent PID 1 and kept their relay and peer sockets. Each `npm test` run left two such processes, and the same could happen in Codex Desktop or Claude Desktop whenever an MCP client closed.
34
+ - Both workers now exit on their own when stdin ends or the supervisor channel disconnects. They first stop taking new work and wait up to 35 seconds for work already accepted: the companion refuses new relay connections with `RELAY_STOPPING` (`sent: false`) and finishes accepted connections, active requests and pending Desktop dispatches; `claude-bridge` unregisters its peer endpoint and finishes open peer connections, in-flight sends and queued reply forwards. A late activation from the supervisor cannot reopen the relay during this drain, and losing the supervisor's output pipes no longer crashes the worker mid-drain. The supervisor waits for that drain before forcing a stop when its client closes; retiring a quiesced worker after a reload keeps its 6-second limit.
35
+
36
+ ### Verification
37
+
38
+ - Every MCP supervisor test now records the supervisor and worker PIDs it sees and fails if any outlives its client, killing leftovers so a failure cannot accumulate processes. On v1.22.10, 4 tests fail: the real `claude-bridge` and companion upgrade tests leave their workers behind, and a new test that kills the supervisor finds both workers still running.
39
+ - New tests cover the drain: an accepted relay delivery finishes before exit, a late connection is refused with `RELAY_STOPPING`, a late activation is rejected, a worker whose parent is killed during a drain still exits 0 after its work finishes (it crashes on v1.22.10's output handling), and the peer endpoint stops accepting while keeping open connections.
40
+ - Two full `npm test` runs on macOS left no process from a `mcp-supervisor-test-` directory; the run on v1.22.10 left two.
41
+
42
+ ### Limitations
43
+
44
+ - On Windows the supervisor's workers belong to its job object and end with it, so they never outlived their client there, and a drain cannot continue after the client kills the supervisor. The supervisor-kill test passes on Windows for that reason rather than through the drain.
45
+
7
46
  ## [1.22.10] - 2026-10-20
8
47
 
9
48
  ### 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.10",
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.10",
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.10";
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");
@@ -18,7 +18,7 @@ import { readClaudeInboundPolicy } from "./claude-inbound-policy.mjs";
18
18
  import { assertRecipientClass, preflightFailure } from "./recipient-preflight.mjs";
19
19
  import { resolveCodexSenderContext } from "./codex-sender-context.mjs";
20
20
  import { ReplyForwarder } from "./reply-forwarder.mjs";
21
- import { assertRoutingReload, clientReloadReason, createReloadControl } from "./reload-control.mjs";
21
+ import { assertRoutingReload, clientReloadReason, createReloadControl, exitWhenClientCloses } from "./reload-control.mjs";
22
22
  import { readClaudeAccountContext } from "./desktop-account-context.mjs";
23
23
  import { assertAccountIdentity, bindUnsolicitedClaudeMessageAccount, publicAccountState, readBridgeAccounts, requireBridgeAccounts, sameAccountIdentity } from "./bridge-account-context.mjs";
24
24
  import { recoverClaudeDesktopSession, resolveClaudeDesktopSession, sameClaudeDesktopRecipient } from "./claude-session-router.mjs";
@@ -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.10";
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;
@@ -811,5 +811,13 @@ try {
811
811
 
812
812
  const transport = new StdioServerTransport();
813
813
  await server.connect(transport);
814
+ exitWhenClientCloses({
815
+ prepare: () => peer.closeIntake(),
816
+ busy: () => peer.connections.size ? "Peer socket connections are still open"
817
+ : peer.requestQueues.size ? "Claude messages are still being sent"
818
+ : replyForwarder.forwardingReason(),
819
+ stop: async () => { replyForwarder.close(); peer.stop(); codex.close(); await peer.closed; },
820
+ log,
821
+ });
814
822
  reload.listen();
815
823
  log(`ready on ${PLATFORM_LABEL} as peer "${peer.name}" (${peer.started ? peer.socketPath : "peer endpoint down"})`);
@@ -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.10";
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
 
@@ -6,11 +6,14 @@ import { fileURLToPath } from "node:url";
6
6
  import { createReleaseSnapshot, sourceRevision } from "./release-snapshot.mjs";
7
7
  import { desktopTasksConfigured } from "./native-relay.mjs";
8
8
  import { isMainModule, stableInstallPath } from "./install-paths.mjs";
9
+ import { CLIENT_CLOSE_DRAIN_MS, CLIENT_CLOSE_STOP_MS } from "./reload-control.mjs";
9
10
 
10
11
  const WORKER_NODE = stableInstallPath(process.execPath);
11
12
  const ENTRIES = new Set(["index.mjs", "claude-bridge.mjs", "native-relay-companion.mjs"]);
12
13
  const MAX_FRAME_BYTES = 16 * 1024 * 1024;
13
14
  const MAX_QUEUE = 256;
15
+ const RETIRE_GRACE_MS = 6000;
16
+ const CLIENT_CLOSE_GRACE_MS = CLIENT_CLOSE_DRAIN_MS + CLIENT_CLOSE_STOP_MS + 1000;
14
17
  const hasId = (message) => Object.hasOwn(message, "id");
15
18
  const idKey = (id) => JSON.stringify(id);
16
19
  const errorMessage = (id, message) => ({ jsonrpc: "2.0", id, error: { code: -32603, message } });
@@ -111,7 +114,7 @@ class Worker {
111
114
  this.handlers.exit(this, error);
112
115
  }
113
116
 
114
- async stop() {
117
+ async stop(graceMs = RETIRE_GRACE_MS) {
115
118
  this.retiring = true;
116
119
  if (this.stopping) return this.stopping;
117
120
  this.stopping = (async () => {
@@ -123,7 +126,7 @@ class Worker {
123
126
  };
124
127
  this.process.stdin.end();
125
128
  if (this.process.connected) this.process.disconnect();
126
- if (await wait(6000)) return;
129
+ if (await wait(graceMs)) return;
127
130
  if (this.process.exitCode === null && this.process.signalCode === null) {
128
131
  this.process.kill("SIGKILL");
129
132
  }
@@ -324,7 +327,7 @@ export async function runSupervisor(entry, options = {}) {
324
327
  if (stopping) return;
325
328
  stopping = true;
326
329
  clearInterval(timer);
327
- await Promise.allSettled([...workers].map((worker) => worker.stop()));
330
+ await Promise.allSettled([...workers].map((worker) => worker.stop(CLIENT_CLOSE_GRACE_MS)));
328
331
  process.stdin.pause();
329
332
  };
330
333
  const preparationStarted = performance.now();
@@ -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 } 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.10";
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) {
@@ -46,6 +47,9 @@ function errorResponse(code, message, sent, details) {
46
47
  return { ok: false, v: RELAY_PROTOCOL_VERSION, error: { code, message, ...size, ...(sent === false ? { sent: false } : {}) } };
47
48
  }
48
49
 
50
+ const RELAY_RELOADING_REFUSAL = Object.freeze({ code: "RELAY_RELOADING", message: "The native relay is reloading; no message was sent" });
51
+ const RELAY_STOPPING_REFUSAL = Object.freeze({ code: "RELAY_STOPPING", message: "The native relay is shutting down because its MCP client closed; no message was sent" });
52
+
49
53
  function boundRequest(payload) {
50
54
  return payload?.v === ACCOUNT_RELAY_PROTOCOL_VERSION;
51
55
  }
@@ -411,12 +415,21 @@ export class RelaySocketServer {
411
415
  this.connections = new Set();
412
416
  this.handlers = new Set();
413
417
  this.accepting = true;
418
+ this.refusal = RELAY_RELOADING_REFUSAL;
414
419
  this.closed = Promise.resolve();
415
420
  this.processHandlers = new Map();
416
421
  this.aclReady = !IS_WINDOWS || !strict;
417
422
  this.provisionalSockets = new Set();
418
423
  this.startGeneration = 0;
419
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 {}
420
433
  }
421
434
 
422
435
  start() {
@@ -434,6 +447,8 @@ export class RelaySocketServer {
434
447
  if (!IS_WINDOWS) fs.mkdirSync(path.dirname(this.socketPath), { recursive: true });
435
448
 
436
449
  const generation = ++this.startGeneration;
450
+ this.withdrawn = false;
451
+ this.boundIdentity = null;
437
452
  this.aclReady = !IS_WINDOWS || !this.strict;
438
453
  this.server = net.createServer((socket) => this.#handleConnection(socket));
439
454
  await this.#listen({ replaceStale: true });
@@ -500,7 +515,54 @@ export class RelaySocketServer {
500
515
  * swept, an accepted one means a live companion already has the socket and
501
516
  * this process leaves it alone.
502
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
+
503
564
  async #listen({ replaceStale }) {
565
+ if (!IS_WINDOWS && await this.#listenLinked(replaceStale)) return;
504
566
  try {
505
567
  await new Promise((resolve, reject) => {
506
568
  this.server.once("error", reject);
@@ -558,7 +620,9 @@ export class RelaySocketServer {
558
620
  }
559
621
  if (!this.accepting) {
560
622
  socket.on("error", () => {});
561
- socket.end(`${JSON.stringify(errorResponse("RELAY_RELOADING", "The native relay is reloading; no message was sent", false))}\n`);
623
+ socket.setTimeout(5000, () => socket.destroy());
624
+ socket.resume();
625
+ socket.end(`${JSON.stringify(errorResponse(this.refusal.code, this.refusal.message, false))}\n`);
562
626
  return;
563
627
  }
564
628
  let buffer = Buffer.alloc(0);
@@ -641,7 +705,7 @@ export class RelaySocketServer {
641
705
  catch { resolve(); }
642
706
  });
643
707
  try {
644
- 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 });
645
709
  } catch {}
646
710
  this.started = false;
647
711
  }
@@ -712,10 +776,16 @@ export function createNativeRelayLifecycle({ nativeTools, relays, log: logFn = (
712
776
  const servers = [...new Set(relays)];
713
777
  let startups = [];
714
778
  let ownedSockets = [];
715
- const inspect = () => {
779
+ let stopping = false;
780
+ const deliveryReason = () => {
716
781
  if (servers.some((relay) => relay.handlers.size)) return "Native relay requests are still active";
717
782
  if (servers.some((relay) => relay.connections.size)) return "Native relay clients are still connected";
718
783
  if (nativeTools.pending.size) return "Native Desktop dispatches are still pending";
784
+ return null;
785
+ };
786
+ const inspect = () => {
787
+ const reason = deliveryReason();
788
+ if (reason) return reason;
719
789
  if (nativeTools.connecting || startups.some((startup) => startup.busy)) return "Native relay startup is still pending";
720
790
  return null;
721
791
  };
@@ -729,6 +799,8 @@ export function createNativeRelayLifecycle({ nativeTools, relays, log: logFn = (
729
799
  nativeTools.close();
730
800
  };
731
801
  const activate = async () => {
802
+ if (stopping) throw new Error("The native relay is shutting down because its MCP client closed");
803
+ for (const relay of servers) relay.refusal = RELAY_RELOADING_REFUSAL;
732
804
  if (startups.length) {
733
805
  for (const relay of servers) relay.accepting = true;
734
806
  return;
@@ -745,14 +817,26 @@ export function createNativeRelayLifecycle({ nativeTools, relays, log: logFn = (
745
817
  };
746
818
  return {
747
819
  inspect,
820
+ deliveryReason,
748
821
  activate,
749
822
  stop,
823
+ refuseNewDeliveries() {
824
+ stopping = true;
825
+ for (const relay of servers) {
826
+ relay.refusal = RELAY_STOPPING_REFUSAL;
827
+ relay.accepting = false;
828
+ }
829
+ },
750
830
  async quiesce() {
751
831
  for (const relay of servers) relay.accepting = false;
752
832
  const reason = inspect();
753
833
  if (reason) throw new Error(reason);
754
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();
755
838
  await stop();
839
+ if (late) throw new Error(late);
756
840
  },
757
841
  exportState: () => ({ ownedSockets }),
758
842
  restore(payload) {
@@ -861,6 +945,7 @@ if (invokedDirectly) {
861
945
  if (!reload.staged) void lifecycle.activate().catch((error) => log(`native relay startup failed: ${error.message}`));
862
946
  mcp.server.onclose = () => { void lifecycle.stop(); };
863
947
  await mcp.connect(new StdioServerTransport());
948
+ exitWhenClientCloses({ prepare: () => lifecycle.refuseNewDeliveries(), busy: lifecycle.deliveryReason, stop: lifecycle.stop, log });
864
949
  reload.listen();
865
950
  log(`ready on ${PLATFORM_LABEL} (${strictDesktop ? accountRelay.socketPath : relay.started ? relay.socketPath : "socket down"})`);
866
951
  }
@@ -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
@@ -616,6 +618,7 @@ export class PeerEndpoint {
616
618
  this.connections = new Set();
617
619
  this.activityRevision = 0;
618
620
  this.reloadPaused = false;
621
+ this.intakeClosed = false;
619
622
  this.processHandlersRegistered = false;
620
623
  this.strict = hardenedBridgeEnabled(env);
621
624
  this.protectSocket = protectSocket ?? (hardenedBridgeEnabled(env) ? protectCurrentUserPipe : null);
@@ -655,6 +658,7 @@ export class PeerEndpoint {
655
658
 
656
659
  start() {
657
660
  if (this.reloadPaused) throw new Error("The peer endpoint is quiesced for reload");
661
+ if (this.intakeClosed) throw new Error("The peer endpoint is shutting down because its MCP client closed");
658
662
  if (this.started) return;
659
663
  if (this.startPromise) return this.startPromise;
660
664
  const starting = this.#startOnce();
@@ -1138,6 +1142,19 @@ export class PeerEndpoint {
1138
1142
  async quiesce() {
1139
1143
  const reason = this.reloadReason();
1140
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
+ }
1141
1158
  const revision = this.activityRevision;
1142
1159
  this.reloadPaused = true;
1143
1160
  globalThis.clearInterval(this.responsePoll);
@@ -1150,6 +1167,12 @@ export class PeerEndpoint {
1150
1167
  if (revision !== this.activityRevision || this.reloadReason()) throw new Error("Peer activity raced with reload; the endpoint remains quiesced");
1151
1168
  }
1152
1169
 
1170
+ closeIntake() {
1171
+ this.intakeClosed = true;
1172
+ this.#removeRegistration();
1173
+ if (this.server?.listening) this.server.close();
1174
+ }
1175
+
1153
1176
  async resume() {
1154
1177
  this.reloadPaused = false;
1155
1178
  await this.start();
@@ -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";
@@ -100,3 +104,38 @@ export function createReloadControl({ entry, inspect = () => null, quiesce = ()
100
104
  },
101
105
  };
102
106
  }
107
+
108
+ export const CLIENT_CLOSE_DRAIN_MS = 35000;
109
+ export const CLIENT_CLOSE_STOP_MS = 5000;
110
+
111
+ export function exitWhenClientCloses({ prepare = () => {}, busy = () => null, stop = () => {}, log = () => {}, drainMs = CLIENT_CLOSE_DRAIN_MS, stopMs = CLIENT_CLOSE_STOP_MS, pollMs = 50, stdin = process.stdin, channel = process, outputs = [process.stdout, process.stderr], exit = (code) => process.exit(code) } = {}) {
112
+ let closing = null;
113
+ const bounded = (promise, ms) => {
114
+ let timer;
115
+ return Promise.race([Promise.resolve(promise).then(() => true), new Promise((resolve) => { timer = setTimeout(() => resolve(false), ms); })])
116
+ .finally(() => clearTimeout(timer));
117
+ };
118
+ const close = (why) => closing ??= (async () => {
119
+ log(`MCP client closed (${why}); draining before exit`);
120
+ let code = 0;
121
+ try {
122
+ await prepare();
123
+ const deadline = Date.now() + drainMs;
124
+ let reason = busy();
125
+ while (reason && Date.now() < deadline) {
126
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
127
+ reason = busy();
128
+ }
129
+ if (reason) log(`exiting after ${drainMs} ms with work still open: ${reason}`);
130
+ if (!await bounded(stop(), stopMs)) log(`listener shutdown exceeded ${stopMs} ms`);
131
+ } catch (error) {
132
+ code = 1;
133
+ log(`shutdown failed: ${error?.message ?? String(error)}`);
134
+ }
135
+ exit(code);
136
+ })();
137
+ for (const output of outputs) output?.on("error", (error) => { void close(`output failed: ${error?.code ?? error?.message}`); });
138
+ stdin?.once("end", () => { void close("stdin ended"); });
139
+ channel?.once("disconnect", () => { void close("supervisor channel disconnected"); });
140
+ return { close, get closing() { return closing !== null; } };
141
+ }
@@ -106,8 +106,13 @@ export class ReplyForwarder {
106
106
  return { ...counts, attempts, maxPerSession: this.maxPerSession, closed: this.closed };
107
107
  }
108
108
 
109
+ forwardingReason() {
110
+ return this.active || this.queue.length || this.timer !== null ? "Reply forwarding is still active or queued" : null;
111
+ }
112
+
109
113
  reloadReason() {
110
- if (this.active || this.queue.length || this.timer !== null) return "Reply forwarding is still active or queued";
114
+ const forwarding = this.forwardingReason();
115
+ if (forwarding) return forwarding;
111
116
  if ([...this.records.values()].some(({ receipt }) => ["queued", "sending", "unknown"].includes(receipt.status))) {
112
117
  return "A reply forwarding outcome remains unconfirmed";
113
118
  }