@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.
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +22 -0
- package/README.md +2 -2
- package/docs/new-macos-setup.md +5 -4
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/install-codex-mcp.mjs +6 -6
- package/scripts/install-native-relay.mjs +9 -11
- package/src/claude-bridge.mjs +1 -1
- package/src/codex-mcp-registration.mjs +130 -11
- package/src/codex-mcp-toml.mjs +239 -0
- package/src/index.mjs +1 -1
- package/src/native-relay-companion.mjs +67 -3
- package/src/peer-protocol.mjs +16 -1
- package/src/reload-control.mjs +4 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
package/docs/new-macos-setup.md
CHANGED
|
@@ -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.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
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.
|
|
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(
|
|
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("\
|
|
53
|
-
console.log("Verify claude_bridge_status from
|
|
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.
|
|
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
|
-
|
|
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("
|
|
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");
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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) {
|
package/src/peer-protocol.mjs
CHANGED
|
@@ -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);
|
package/src/reload-control.mjs
CHANGED
|
@@ -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";
|