@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.
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +39 -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 +10 -2
- package/src/codex-mcp-registration.mjs +130 -11
- package/src/codex-mcp-toml.mjs +239 -0
- package/src/index.mjs +1 -1
- package/src/mcp-supervisor.mjs +6 -3
- package/src/native-relay-companion.mjs +90 -5
- package/src/peer-protocol.mjs +24 -1
- package/src/reload-control.mjs +39 -0
- package/src/reply-forwarder.mjs +6 -1
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
|
-
|
|
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
|
@@ -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.
|
|
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
|
|
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
|
|
package/src/mcp-supervisor.mjs
CHANGED
|
@@ -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(
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
}
|
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
|
|
@@ -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();
|
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";
|
|
@@ -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
|
+
}
|
package/src/reply-forwarder.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|