@minhspark/codex-mcp-bridge 1.15.0 → 1.15.1
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/CHANGELOG.md +14 -0
- package/README.md +16 -6
- package/package.json +1 -1
- package/scripts/check.mjs +6 -1
- package/scripts/install-claude-desktop.mjs +10 -4
- package/scripts/install-native-relay.mjs +1 -1
- package/src/claude-bridge.mjs +4 -3
- package/src/index.mjs +8 -4
- package/src/mcp-supervisor.mjs +19 -7
- package/src/native-relay-companion.mjs +7 -3
- package/src/native-relay.mjs +115 -3
- package/src/release-snapshot.mjs +107 -4
- package/src/reload-control.mjs +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [1.15.1] - 2026-09-14
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Allow bounded cold startup time for the Windows snapshot identity test on slower CI hosts while preserving the production five-second inspection deadline and the real MCP caller check.
|
|
12
|
+
- Discover the native tools pipe on current Windows Codex Desktop builds that load app tools through a plugin instead of an inline app-server override. Validate the Desktop process ancestry and pipe owner before connecting, so the relay can start without a pinned pipe address.
|
|
13
|
+
- Keep native relay reloads usable when another companion takes over a shared listener during the handoff, while still rejecting a missing endpoint.
|
|
14
|
+
- Migrate legacy Desktop installations explicitly with `install-claude-desktop.mjs --desktop-tasks`, disable external app-server autostart, and leave automatic routing unpinned so a later relay installation can take effect.
|
|
15
|
+
- Reload effective shared routing changes on the existing MCP connection when safely idle. Allow legacy-to-Desktop upgrades while preserving confirmed receipts without replay; reject automatic downgrades to the external app-server. Clarify that open Desktop tasks are valid native destinations.
|
|
16
|
+
- Reuse validated dependency hashes for unchanged files when preparing cached runtimes, avoiding repeated content reads that can delay MCP initialization during Windows startup. Changed source dependencies and modified cached copies still require verification; missing or invalid hash metadata falls back to reading file contents.
|
|
17
|
+
- Log runtime preparation start and elapsed time before the MCP worker launches, so startup delays can be distinguished from worker initialization failures.
|
|
18
|
+
|
|
5
19
|
## [1.15.0] - 2026-09-07
|
|
6
20
|
|
|
7
21
|
### Added
|
package/README.md
CHANGED
|
@@ -32,6 +32,8 @@ Reusing a receipt also verifies that the saved project still has the same identi
|
|
|
32
32
|
|
|
33
33
|
The updated companion exposes a separate `-desktop-tasks` endpoint so a previous companion holding the legacy reply socket need not be killed during an upgrade. `codex_bridge_status` verifies the native connection through Desktop's local project list; `native_relay_status` reports both endpoints. Reconnect the companion after upgrading so it accepts the current native operations.
|
|
34
34
|
|
|
35
|
+
On Windows, the companion supports both legacy inline app-server configuration and current Codex Desktop plugin startup. When no native pipe is inherited, discovery checks the companion's Desktop process ancestry and the named pipe's server process, then uses a read-only project-list probe to distinguish app tools from browser-only pipes. A running companion alone does not establish readiness: verify that `native_relay_status` reports its account relay listening and that `codex_bridge_status` can read Desktop projects.
|
|
36
|
+
|
|
35
37
|
Desktop mode never contacts, starts, or falls back to an external app-server, including during status checks and thread discovery. The Claude Desktop installer records `CODEX_BRIDGE_AUTOSTART=0` in this mode. If Desktop or its companion is unavailable, the bridge reports the failure and leaves existing tasks in Desktop. `stop_codex_app_server` is a no-op in Desktop mode; stop an obsolete external service through its own launcher after confirming it has no active work.
|
|
36
38
|
|
|
37
39
|
`list_codex_threads` filters Desktop's recent/pinned task snapshot by authorization, workspace/title, and result limit. The snapshot can omit agent-created tasks visible in the sidebar and does not cover the complete archive. Absence from this list does not mean creation failed: read the returned task ID directly. Desktop does not expose the external server's `loadedOnly` state; requesting it returns an explanation without contacting that server.
|
|
@@ -142,12 +144,18 @@ Install the new files with `npm install -g @minhspark/codex-mcp-bridge@latest`.
|
|
|
142
144
|
|
|
143
145
|
After this one-time migration, the stable supervisor keeps the client's MCP stdio connection open while it starts each worker from an immutable source snapshot. The installers prepare this snapshot before changing the registration, avoiding a slow first copy during MCP initialization. Compatible installed-source changes become candidates after the tree stabilizes. A candidate must initialize successfully before it can replace an idle worker. Active calls, pending deliveries, late replies, and relay work defer the switch; an update never silently resends a prompt. A failed candidate leaves the existing worker and its delivery state available.
|
|
144
146
|
|
|
147
|
+
Snapshot verification reuses previously calculated dependency hashes when file identity, size, modification time, and change time still match. Changed files are read and hashed again; directory inventories still detect additions and deletions. Missing or invalid hash metadata falls back to reading file contents. This reduces repeated disk reads during simultaneous MCP startup, especially just after Windows boots. Supervisor stderr logs show when runtime preparation starts and how long it takes.
|
|
148
|
+
|
|
145
149
|
Call `codex_bridge_status`, `claude_bridge_status`, or `native_relay_status` from the actual app task to inspect `autoReload`, the loaded version, and any pending update or blocker. Reading an old message receipt still refers to the worker that accepted that message. Pending or uncertain messages can keep an update deferred until they are resolved; a new process or an unknown message ID is never evidence that a send failed. If the supervisor protocol itself changes incompatibly, reconnect that MCP server once again.
|
|
146
150
|
|
|
147
151
|
Auto-reload watches the **installed source at the configured path**. It does not poll npm, download a release, run `git pull`, or upgrade Claude/Codex themselves. Keep the normal package update mechanism to install new versions. Changes to environment variables or access settings supplied by the MCP client require a client MCP reload to pass the new configuration to the supervisor. Subsequent account switches remain detectable per call without this configuration reload.
|
|
148
152
|
|
|
149
153
|
The installers preserve existing environment and access settings; explicit supported environment variables override their stored values. Claude's `--reset` explicitly returns to defaults. Codex entries with custom access, timeout, or transport settings are left intact with the exact command and arguments to update in place. `CLAUDE_DESKTOP_USER_DATA` pins only the data directory, never an account identity. Re-running an installer is unnecessary for later compatible source updates. The global install path remains stable across package versions.
|
|
150
154
|
|
|
155
|
+
To migrate a legacy Claude registration to native Desktop task delivery, run `codex-mcp-bridge-install --desktop-tasks` (from a checkout: `node scripts/install-claude-desktop.mjs --desktop-tasks`). This explicit flag overrides a stored or inherited `CODEX_BRIDGE_DESKTOP_TASKS=0` and disables external app-server autostart while preserving access settings. Without the flag or an explicit routing setting, the installer leaves routing automatic so a later native relay installation can enable Desktop tasks. Existing explicit `0` settings remain unchanged until the flag or an environment override is supplied.
|
|
156
|
+
|
|
157
|
+
Reconnect `codex-bridge` in the existing Claude task after changing its registration; source auto-reload cannot replace environment variables inherited by a running MCP process. If the task has a separate Claude Code MCP registration, update that registration too. Verify `codex_bridge_status` from the actual sending task reports Desktop tasks enabled, the native relay available, and a current runtime. Keep the destination open in Codex Desktop. An earlier unconfirmed send must be inspected before sending again.
|
|
158
|
+
|
|
151
159
|
#### Show the final npm installation result
|
|
152
160
|
|
|
153
161
|
Enable the optional shell integration once after installing the package:
|
|
@@ -510,7 +518,9 @@ Claude
|
|
|
510
518
|
|
|
511
519
|
Resolution order is `CODEX_RELAY_ID` → that file → an error naming both. Never a guess: an invented executor fails inside Codex with a message that says nothing about the configuration that actually caused it. The recorded thread works as an executor even if it has never been opened in the app.
|
|
512
520
|
|
|
513
|
-
**
|
|
521
|
+
**Desktop-only delivery.** With Desktop tasks enabled, both `send_to_codex_thread` and correlated Claude replies use the native relay. Desktop keeps its writer lock throughout delivery. If the relay is unavailable, the bridge reports that failure and does not start or use an external app-server.
|
|
522
|
+
|
|
523
|
+
**Legacy reply delivery.** When Desktop tasks are explicitly disabled, `claude-bridge` can choose between native and external app-server delivery. The native path requires:
|
|
514
524
|
|
|
515
525
|
| Condition | Otherwise |
|
|
516
526
|
|---|---|
|
|
@@ -518,7 +528,7 @@ Resolution order is `CODEX_RELAY_ID` → that file → an error naming both. Nev
|
|
|
518
528
|
| `CODEX_BRIDGE_NATIVE_RELAY` is not `0` | switched off by hand |
|
|
519
529
|
| the Windows named pipe or macOS unix socket exists | the companion is not installed, or Codex Desktop is not running |
|
|
520
530
|
|
|
521
|
-
|
|
531
|
+
Only legacy reply delivery falls back to the app-server path when a companion cannot be reached before sending. Once a request has been written, a refusal, timeout, or lost acknowledgement does not trigger another delivery: the first attempt may already have succeeded. Invalid or oversized messages are also refused rather than passed to another backend.
|
|
522
532
|
|
|
523
533
|
The companion uses `CODEX_APP_TOOLS_PIPE_PATH` when inherited. Desktop builds that supply it only to their bundled `codex_app` MCP are also supported: the companion reads the exact `mcp_servers.codex_app.env` override from its launching app-server. It never picks a pipe from another session or saves a restart-specific address. The native connection remains separate from MCP stdio. If neither source provides a valid pipe, the relay stays unavailable; if a configured pipe is late during startup, it retries in the background. When several MCP instances start, status identifies a reachable shared companion instead of reporting that the relay is down.
|
|
524
534
|
|
|
@@ -537,11 +547,11 @@ The native pipe uses a 4-byte UInt32LE payload length followed by UTF-8 JSON-RPC
|
|
|
537
547
|
|
|
538
548
|
### Caveats
|
|
539
549
|
|
|
540
|
-
-
|
|
541
|
-
- **
|
|
542
|
-
- **
|
|
550
|
+
- Codex Desktop runs its own app-server. Its internal IPC endpoint is not an external app-server connection. The [native relay](#codex-desktop-native-relay), launched in Desktop's context, invokes Desktop's own tools to deliver into an existing task without resuming it through another server.
|
|
551
|
+
- **The writer-lock limitation applies to the legacy external app-server path.** A second writer receives `thread <id> already has an active writer`; an `idle` task can still be owned by Desktop. Native Desktop delivery supports tasks open in the app. Check `codex_bridge_status` in the actual sending task and migrate a legacy registration with `--desktop-tasks`; do not delete lock files, stop Desktop, or ask for manual copying merely because Desktop owns the task.
|
|
552
|
+
- **New Desktop tasks receive their saved project and title through Desktop's native creation tool.** Legacy bridge-created threads instead use the app-server's `thread/name/set` and an exact `codex://threads/<id>` link. Both paths preserve the requested workspace.
|
|
543
553
|
- A repo living on the NTFS partition of a dual-boot machine (`/Volumes/<label>/...`) is **read-only** under macOS. Keep a separate checkout on an APFS volume to run and edit it.
|
|
544
|
-
- `codex app-server daemon start` uses the `unix://` transport with a control socket at `~/.codex/app-server-control/app-server-control.sock`. The
|
|
554
|
+
- `codex app-server daemon start` uses the `unix://` transport with a control socket at `~/.codex/app-server-control/app-server-control.sock`. The legacy external bridge uses `ws://` instead; Desktop-only delivery uses the native relay.
|
|
545
555
|
|
|
546
556
|
## Troubleshooting
|
|
547
557
|
|
package/package.json
CHANGED
package/scripts/check.mjs
CHANGED
|
@@ -2,9 +2,12 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
2
2
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { desktopTasksConfigured } from "../src/native-relay.mjs";
|
|
5
6
|
|
|
6
7
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
7
8
|
const bridgeEnvNames = [
|
|
9
|
+
"CODEX_HOME",
|
|
10
|
+
"CLAUDE_DESKTOP_USER_DATA",
|
|
8
11
|
"CODEX_APP_SERVER_URL",
|
|
9
12
|
"CODEX_BIN",
|
|
10
13
|
"CODEX_BRIDGE_ALLOWED_ROOTS",
|
|
@@ -40,7 +43,9 @@ try {
|
|
|
40
43
|
await client.connect(transport);
|
|
41
44
|
const tools = await client.listTools();
|
|
42
45
|
console.log("tools:", tools.tools.map((t) => t.name).join(", "));
|
|
43
|
-
const listed = await client.callTool(
|
|
46
|
+
const listed = await client.callTool(desktopTasksConfigured()
|
|
47
|
+
? { name: "codex_bridge_status", arguments: {} }
|
|
48
|
+
: { name: "list_codex_threads", arguments: { limit: 3 } });
|
|
44
49
|
console.log(listed.content[0].text.slice(0, 600));
|
|
45
50
|
if (listed.isError) process.exitCode = 1;
|
|
46
51
|
} catch (err) {
|
|
@@ -13,6 +13,7 @@ exitForVersionRequest(import.meta.url);
|
|
|
13
13
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
14
14
|
const cfgPath = process.env.CLAUDE_DESKTOP_CONFIG ?? claudeDesktopConfigPath();
|
|
15
15
|
const reset = process.argv.includes("--reset");
|
|
16
|
+
const forceDesktopTasks = process.argv.includes("--desktop-tasks");
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* This bridge is intentionally the local hand-off point between Claude and
|
|
@@ -55,10 +56,13 @@ cfg.mcpServers = cfg.mcpServers ?? {};
|
|
|
55
56
|
const previousEntry = (reset ? {} : cfg.mcpServers["codex-bridge"]) ?? {};
|
|
56
57
|
const previousEnv = previousEntry.env ?? {};
|
|
57
58
|
const settled = (name, fallback) => process.env[name] ?? previousEnv[name] ?? fallback;
|
|
58
|
-
const desktopMode =
|
|
59
|
+
const desktopMode = forceDesktopTasks ? "1" : settled("CODEX_BRIDGE_DESKTOP_TASKS");
|
|
60
|
+
const desktopEnabled = desktopMode === undefined ? desktopTasksConfigured() : desktopMode === "1";
|
|
59
61
|
|
|
60
62
|
const kept = Object.keys(previousEnv).filter(
|
|
61
|
-
(name) => process.env[name] === undefined && name !== "CODEX_BIN"
|
|
63
|
+
(name) => process.env[name] === undefined && name !== "CODEX_BIN" &&
|
|
64
|
+
!(forceDesktopTasks && name === "CODEX_BRIDGE_DESKTOP_TASKS") &&
|
|
65
|
+
!(desktopEnabled && name === "CODEX_BRIDGE_AUTOSTART"),
|
|
62
66
|
);
|
|
63
67
|
|
|
64
68
|
cfg.mcpServers["codex-bridge"] = {
|
|
@@ -93,8 +97,8 @@ cfg.mcpServers["codex-bridge"] = {
|
|
|
93
97
|
CODEX_BRIDGE_SANDBOX: settled("CODEX_BRIDGE_SANDBOX", "workspace-write"),
|
|
94
98
|
CODEX_BRIDGE_OPEN_IN_APP: settled("CODEX_BRIDGE_OPEN_IN_APP", IS_WINDOWS ? "1" : "0"),
|
|
95
99
|
CODEX_BRIDGE_RELEASE_AFTER_TURN: settled("CODEX_BRIDGE_RELEASE_AFTER_TURN", IS_WINDOWS ? "1" : "0"),
|
|
96
|
-
CODEX_BRIDGE_DESKTOP_TASKS: desktopMode,
|
|
97
|
-
CODEX_BRIDGE_AUTOSTART:
|
|
100
|
+
...(desktopMode !== undefined ? { CODEX_BRIDGE_DESKTOP_TASKS: desktopMode } : {}),
|
|
101
|
+
CODEX_BRIDGE_AUTOSTART: desktopEnabled ? "0" : settled("CODEX_BRIDGE_AUTOSTART", "1"),
|
|
98
102
|
...(process.env.CODEX_BRIDGE_ALLOWED_THREADS !== undefined
|
|
99
103
|
? { CODEX_BRIDGE_ALLOWED_THREADS: process.env.CODEX_BRIDGE_ALLOWED_THREADS }
|
|
100
104
|
: {}),
|
|
@@ -120,6 +124,7 @@ fs.writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8");
|
|
|
120
124
|
console.log(`platform: ${PLATFORM_LABEL}`);
|
|
121
125
|
console.log(`updated ${cfgPath}`);
|
|
122
126
|
console.log(JSON.stringify(cfg.mcpServers["codex-bridge"], null, 2));
|
|
127
|
+
console.log(`Desktop tasks: ${desktopEnabled ? "enabled" : "disabled"} (${desktopMode === undefined ? "automatic relay configuration" : forceDesktopTasks ? "--desktop-tasks" : "explicit environment setting"}).`);
|
|
123
128
|
|
|
124
129
|
if (reset) {
|
|
125
130
|
console.log("\n--reset: existing values were discarded in favour of the defaults.");
|
|
@@ -149,3 +154,4 @@ if (cfg.mcpServers["codex-bridge"].env.CODEX_BRIDGE_THREAD_POLICY === "owned") {
|
|
|
149
154
|
|
|
150
155
|
console.log("\nReconnect codex-bridge once in the existing Claude task to load the supervisor. Subsequent compatible installed-source updates reload automatically when the worker is safely idle.");
|
|
151
156
|
console.log("Verify codex_bridge_status from that task: autoReload must be enabled and runtime state must be current. A separate diagnostic process does not verify the app's loaded MCP process.");
|
|
157
|
+
if (desktopEnabled) console.log("Verify Desktop tasks are enabled and the native relay is available. Configuration changes require MCP reconnect even when source auto-reload is enabled; keep the destination task open in Codex Desktop.");
|
|
@@ -22,7 +22,7 @@ import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs
|
|
|
22
22
|
|
|
23
23
|
exitForVersionRequest(import.meta.url);
|
|
24
24
|
|
|
25
|
-
const VERSION = "1.15.
|
|
25
|
+
const VERSION = "1.15.1";
|
|
26
26
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
27
|
const entry = path.join(root, "src", "mcp-supervisor.mjs");
|
|
28
28
|
const entryArgs = ["native-relay-companion.mjs"];
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -15,14 +15,14 @@ import { readClaudeInboundPolicy } from "./claude-inbound-policy.mjs";
|
|
|
15
15
|
import { assertRecipientClass, preflightFailure } from "./recipient-preflight.mjs";
|
|
16
16
|
import { readCodexSenderContext } from "./codex-sender-context.mjs";
|
|
17
17
|
import { ReplyForwarder } from "./reply-forwarder.mjs";
|
|
18
|
-
import { clientReloadReason, createReloadControl } from "./reload-control.mjs";
|
|
18
|
+
import { assertRoutingReload, clientReloadReason, createReloadControl } from "./reload-control.mjs";
|
|
19
19
|
import { readClaudeAccountContext } from "./desktop-account-context.mjs";
|
|
20
20
|
import { assertAccountIdentity, bindUnsolicitedClaudeMessageAccount, publicAccountState, readBridgeAccounts, requireBridgeAccounts, sameAccountIdentity } from "./bridge-account-context.mjs";
|
|
21
21
|
import { resolveClaudeDesktopSession } from "./claude-session-router.mjs";
|
|
22
22
|
|
|
23
23
|
exitForVersionRequest(import.meta.url);
|
|
24
24
|
|
|
25
|
-
const VERSION = "1.15.
|
|
25
|
+
const VERSION = "1.15.1";
|
|
26
26
|
const FORWARD_MIN_INTERVAL_MS = 5000;
|
|
27
27
|
const FORWARD_MAX_PER_SESSION = 50;
|
|
28
28
|
|
|
@@ -74,7 +74,8 @@ const reload = createReloadControl({
|
|
|
74
74
|
quiesce: async () => { await peer.quiesce(); codex.close(); },
|
|
75
75
|
exportState: () => ({ desktopOnly, peer: peer.exportReloadState(), forwarding: replyForwarder.exportReloadState(), threadId: forwarding.threadId }),
|
|
76
76
|
restore: (state) => {
|
|
77
|
-
|
|
77
|
+
assertRoutingReload(state.desktopOnly, desktopOnly);
|
|
78
|
+
if (state.threadId !== null && (typeof state.threadId !== "string" || !state.threadId.trim())) {
|
|
78
79
|
throw new Error("Invalid or incompatible Claude worker reload state");
|
|
79
80
|
}
|
|
80
81
|
peer.restoreReloadState(state.peer);
|
package/src/index.mjs
CHANGED
|
@@ -27,13 +27,13 @@ import { DesktopTaskDelivery, DESKTOP_TOOL_BUDGET_MS } from "./thread-delivery.m
|
|
|
27
27
|
import { desktopTasksConfigured } from "./native-relay.mjs";
|
|
28
28
|
import { exitForVersionRequest } from "./cli-version.mjs";
|
|
29
29
|
import { createRuntimeState } from "./runtime-state.mjs";
|
|
30
|
-
import { clientReloadReason, createReloadControl } from "./reload-control.mjs";
|
|
30
|
+
import { assertRoutingReload, clientReloadReason, createReloadControl } from "./reload-control.mjs";
|
|
31
31
|
import { accountIdentity, assertAccountIdentity, publicAccountState, readBridgeAccounts, requireBridgeAccounts } from "./bridge-account-context.mjs";
|
|
32
32
|
import { assertClaudeSenderContext, readClaudeSenderContext, requireClaudeSenderContext } from "./claude-sender-context.mjs";
|
|
33
33
|
|
|
34
34
|
exitForVersionRequest(import.meta.url);
|
|
35
35
|
|
|
36
|
-
const VERSION = "1.15.
|
|
36
|
+
const VERSION = "1.15.1";
|
|
37
37
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
38
38
|
|
|
39
39
|
/**
|
|
@@ -101,7 +101,8 @@ const reload = createReloadControl({
|
|
|
101
101
|
quiesce: () => { client?.close(); },
|
|
102
102
|
exportState: () => ({ desktopTasksEnabled, ownedThreadIds: [...security.ownedThreadIds] }),
|
|
103
103
|
restore: (state) => {
|
|
104
|
-
|
|
104
|
+
assertRoutingReload(state.desktopTasksEnabled, desktopTasksEnabled);
|
|
105
|
+
if (!Array.isArray(state.ownedThreadIds)
|
|
105
106
|
|| state.ownedThreadIds.some((id) => typeof id !== "string" || !id.trim())
|
|
106
107
|
|| new Set(state.ownedThreadIds).size !== state.ownedThreadIds.length) throw new Error("Invalid or incompatible Codex worker reload state");
|
|
107
108
|
security.ownedThreadIds = new Set(state.ownedThreadIds);
|
|
@@ -480,7 +481,10 @@ registerTool(
|
|
|
480
481
|
title: "Send a prompt to a Codex thread",
|
|
481
482
|
description:
|
|
482
483
|
"Send a prompt as a new user turn inside an existing Codex thread and wait for Codex to answer. " +
|
|
483
|
-
"The thread keeps its full history, cwd and model. Use list_codex_threads first if you do not know the threadId."
|
|
484
|
+
"The thread keeps its full history, cwd and model. Use list_codex_threads first if you do not know the threadId. " +
|
|
485
|
+
"Desktop-owned tasks must use Desktop native delivery; an open task is a valid destination. " +
|
|
486
|
+
"If legacy delivery reports an active writer, inspect codex_bridge_status and repair the native relay/configuration. " +
|
|
487
|
+
"Do not close the task, create a replacement, or ask the user to copy the message manually.",
|
|
484
488
|
inputSchema: {
|
|
485
489
|
threadId: z.string().describe("Codex thread id (UUID) - get it from list_codex_threads"),
|
|
486
490
|
prompt: z.string().describe("The message to send to Codex, exactly as a user would type it"),
|
package/src/mcp-supervisor.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import fs from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { createReleaseSnapshot, sourceRevision } from "./release-snapshot.mjs";
|
|
7
|
+
import { desktopTasksConfigured } from "./native-relay.mjs";
|
|
7
8
|
|
|
8
9
|
const ENTRIES = new Set(["index.mjs", "claude-bridge.mjs", "native-relay-companion.mjs"]);
|
|
9
10
|
const MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
|
@@ -129,6 +130,7 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
129
130
|
const log = (message) => process.stderr.write(`[bridge-supervisor] ${message}\n`);
|
|
130
131
|
const output = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
131
132
|
const env = { ...process.env, CODEX_BRIDGE_SOURCE_ROOT: root };
|
|
133
|
+
const routingConfiguration = () => entry === "native-relay-companion.mjs" ? null : desktopTasksConfigured(env);
|
|
132
134
|
if (entry === "native-relay-companion.mjs" && !env.CODEX_APP_TOOLS_PIPE_PATH) {
|
|
133
135
|
const { resolveNativeToolsPipePath } = await import("./native-relay.mjs");
|
|
134
136
|
const pipe = await resolveNativeToolsPipePath();
|
|
@@ -145,6 +147,7 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
145
147
|
let failure = null;
|
|
146
148
|
let sequence = 0;
|
|
147
149
|
let observed = null;
|
|
150
|
+
let observedRouting = null;
|
|
148
151
|
let observedAt = 0;
|
|
149
152
|
let retryAt = 0;
|
|
150
153
|
let reason = null;
|
|
@@ -154,7 +157,9 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
154
157
|
const reverse = new Map();
|
|
155
158
|
const workers = new Set();
|
|
156
159
|
const diagnostics = () => ({ enabled: true, supervisorPid: process.pid, workerPid: active?.process.pid ?? null,
|
|
157
|
-
revision: active?.release.revision ?? null, availableRevision: observed,
|
|
160
|
+
revision: active?.release.revision ?? null, availableRevision: observed,
|
|
161
|
+
routingConfiguration: active?.routingConfiguration ?? null, availableRoutingConfiguration: observedRouting,
|
|
162
|
+
pending: Boolean(observed && (observed !== active?.release.revision || observedRouting !== active?.routingConfiguration)),
|
|
158
163
|
state: failure ? "failed" : switching ? "reloading" : reason ? "deferred" : "current", reason: failure ?? reason, reloads });
|
|
159
164
|
|
|
160
165
|
const handlers = {
|
|
@@ -197,19 +202,21 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
197
202
|
if (worker === active && !worker.retiring && !stopping) { failure = `${error.message}; automatic replay is disabled because delivery state may be unknown`; log(failure); }
|
|
198
203
|
},
|
|
199
204
|
};
|
|
200
|
-
const spawn = (release) => {
|
|
205
|
+
const spawn = (release, routing) => {
|
|
201
206
|
if (stopping) throw new Error("The MCP client disconnected before activation");
|
|
202
207
|
const worker = new Worker(release, entry, env, handlers);
|
|
208
|
+
worker.routingConfiguration = routing;
|
|
203
209
|
workers.add(worker);
|
|
204
210
|
return worker;
|
|
205
211
|
};
|
|
206
212
|
const check = async () => {
|
|
207
213
|
if (switching || !ready || stopping || failure) return;
|
|
208
214
|
let revision;
|
|
209
|
-
|
|
215
|
+
let routing;
|
|
216
|
+
try { revision = sourceRevision(root); routing = routingConfiguration(); }
|
|
210
217
|
catch (error) { reason = `Installation is not ready: ${error.message}`; return; }
|
|
211
|
-
if (observed !== revision) { observed = revision; observedAt = Date.now(); retryAt = 0; }
|
|
212
|
-
if (revision === active.release.revision) { reason = null; return; }
|
|
218
|
+
if (observed !== revision || observedRouting !== routing) { observed = revision; observedRouting = routing; observedAt = Date.now(); retryAt = 0; }
|
|
219
|
+
if (revision === active.release.revision && routing === active.routingConfiguration) { reason = null; return; }
|
|
213
220
|
if (Date.now() - observedAt < settleMs || Date.now() < retryAt) { reason ??= "Waiting for a complete, stable installation"; return; }
|
|
214
221
|
if (active.clientPending.size || reverse.size) { reason = "A request is still running; its worker is retained"; return; }
|
|
215
222
|
switching = true;
|
|
@@ -220,7 +227,7 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
220
227
|
const state = await previous.control("inspect");
|
|
221
228
|
if (!state.reloadable) { reason = state.reason ?? "Pending deliveries prevent a safe reload"; return; }
|
|
222
229
|
const release = createReleaseSnapshot(root, { expectedRevision: revision });
|
|
223
|
-
candidate = spawn(release);
|
|
230
|
+
candidate = spawn(release, routing);
|
|
224
231
|
const initialized = await candidate.rpc("initialize", initializeParams);
|
|
225
232
|
if (initialized.protocolVersion !== initializeResult.protocolVersion || JSON.stringify(initialized.capabilities) !== JSON.stringify(initializeResult.capabilities)) {
|
|
226
233
|
throw new Error("The new MCP protocol or capabilities require an explicit client reconnect");
|
|
@@ -230,6 +237,7 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
230
237
|
if (!Array.isArray(catalog.tools) || !catalog.tools.length || catalog.nextCursor) throw new Error("The candidate tool catalog is incomplete");
|
|
231
238
|
const oldCatalog = await previous.rpc("tools/list", {});
|
|
232
239
|
if (sourceRevision(root) !== revision) throw new Error("The installation changed before activation");
|
|
240
|
+
if (routingConfiguration() !== routing) throw new Error("The routing configuration changed before activation");
|
|
233
241
|
quiesced = true;
|
|
234
242
|
const exported = await previous.control("quiesce");
|
|
235
243
|
await candidate.control("restore", exported.state);
|
|
@@ -296,9 +304,13 @@ export async function runSupervisor(entry, options = {}) {
|
|
|
296
304
|
await Promise.allSettled([...workers].map((worker) => worker.stop()));
|
|
297
305
|
process.stdin.pause();
|
|
298
306
|
};
|
|
307
|
+
const preparationStarted = performance.now();
|
|
308
|
+
log(`preparing ${entry} runtime`);
|
|
299
309
|
const initial = createReleaseSnapshot(root);
|
|
310
|
+
log(`prepared ${entry} runtime in ${Math.round(performance.now() - preparationStarted)} ms`);
|
|
300
311
|
observed = initial.revision;
|
|
301
|
-
|
|
312
|
+
observedRouting = routingConfiguration();
|
|
313
|
+
active = spawn(initial, observedRouting);
|
|
302
314
|
const timer = setInterval(() => { void check(); }, pollMs);
|
|
303
315
|
lines(process.stdin, (message) => {
|
|
304
316
|
if (!message || message.jsonrpc !== "2.0" || (!message.method && !hasId(message))) throw new Error("Invalid client JSON-RPC message");
|
|
@@ -30,7 +30,7 @@ import { createRuntimeState } from "./runtime-state.mjs";
|
|
|
30
30
|
|
|
31
31
|
exitForVersionRequest(import.meta.url);
|
|
32
32
|
|
|
33
|
-
const VERSION = "1.15.
|
|
33
|
+
const VERSION = "1.15.1";
|
|
34
34
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
35
35
|
|
|
36
36
|
function errorResponse(code, message, sent) {
|
|
@@ -369,9 +369,10 @@ export function startRelayWhenAvailable({ nativeTools, relay, log: logFn = () =>
|
|
|
369
369
|
let resolveReady;
|
|
370
370
|
const ready = new Promise((resolve) => { resolveReady = resolve; });
|
|
371
371
|
let pendingAttempt = null;
|
|
372
|
+
let nativeConnected = false;
|
|
372
373
|
const attempt = async () => {
|
|
373
374
|
if (stopped) return;
|
|
374
|
-
|
|
375
|
+
nativeConnected = false;
|
|
375
376
|
try {
|
|
376
377
|
await nativeTools.connect();
|
|
377
378
|
nativeConnected = true;
|
|
@@ -413,6 +414,7 @@ export function startRelayWhenAvailable({ nativeTools, relay, log: logFn = () =>
|
|
|
413
414
|
return {
|
|
414
415
|
ready,
|
|
415
416
|
firstAttempt,
|
|
417
|
+
get connected() { return nativeConnected; },
|
|
416
418
|
get busy() { return pendingAttempt !== null; },
|
|
417
419
|
stop() {
|
|
418
420
|
if (stopped) return pendingAttempt ?? Promise.resolve();
|
|
@@ -454,7 +456,9 @@ export function createNativeRelayLifecycle({ nativeTools, relays, log: logFn = (
|
|
|
454
456
|
for (const relay of servers) relay.accepting = true;
|
|
455
457
|
startups = servers.map((relay) => startRelayWhenAvailable({ nativeTools, relay, log: logFn }));
|
|
456
458
|
await Promise.all(startups.map((startup) => startup.firstAttempt));
|
|
457
|
-
|
|
459
|
+
const restoredListeners = await Promise.all(servers.map(async (relay, index) =>
|
|
460
|
+
!ownedSockets.includes(relay.socketPath) || startups[index].connected && await relay.isListening()));
|
|
461
|
+
if (restoredListeners.some((available) => !available)) {
|
|
458
462
|
await stop();
|
|
459
463
|
throw new Error("The replacement native relay could not reclaim its listening sockets");
|
|
460
464
|
}
|
package/src/native-relay.mjs
CHANGED
|
@@ -418,18 +418,130 @@ async function readParentCommandLine(parentPid, platform) {
|
|
|
418
418
|
return stdout.trim();
|
|
419
419
|
}
|
|
420
420
|
|
|
421
|
+
export function nativeToolsPipeCandidatesFromWindowsSnapshot(snapshot, { parentPid, localAppData = process.env.LOCALAPPDATA } = {}) {
|
|
422
|
+
if (!Number.isSafeInteger(parentPid) || parentPid <= 0 || !Array.isArray(snapshot?.ancestors)
|
|
423
|
+
|| snapshot.ancestors.length < 2 || snapshot.ancestors.length > 4 || !Array.isArray(snapshot.pipes)) return null;
|
|
424
|
+
const ancestors = snapshot.ancestors;
|
|
425
|
+
let expectedPid = parentPid;
|
|
426
|
+
const visited = new Set();
|
|
427
|
+
for (const ancestor of ancestors) {
|
|
428
|
+
if (!Number.isSafeInteger(ancestor?.pid) || ancestor.pid !== expectedPid || visited.has(ancestor.pid)
|
|
429
|
+
|| typeof ancestor.executablePath !== "string" || /[\r\n\0]/.test(ancestor.executablePath)) return null;
|
|
430
|
+
visited.add(ancestor.pid);
|
|
431
|
+
expectedPid = ancestor.parentPid;
|
|
432
|
+
}
|
|
433
|
+
const desktop = ancestors.at(-1);
|
|
434
|
+
const appServer = ancestors.at(-2);
|
|
435
|
+
const desktopPath = path.win32.normalize(desktop.executablePath);
|
|
436
|
+
if (!/^[a-z]:\\Program Files\\WindowsApps\\OpenAI\.Codex_[0-9.]+_(?:x64|arm64)__2p2nqsd0c76g0\\app\\ChatGPT\.exe$/i.test(desktopPath)) return null;
|
|
437
|
+
if (ancestors.slice(0, -2).some((ancestor) => path.win32.basename(ancestor.executablePath).toLowerCase() !== "node.exe")) return null;
|
|
438
|
+
const serverPath = path.win32.normalize(appServer.executablePath);
|
|
439
|
+
const bundledServer = path.win32.join(path.win32.dirname(desktopPath), "resources", "codex.exe");
|
|
440
|
+
const downloadedRoot = localAppData ? path.win32.join(localAppData, "OpenAI", "Codex", "bin") : null;
|
|
441
|
+
const downloadedRelative = downloadedRoot ? path.win32.relative(downloadedRoot, serverPath) : "";
|
|
442
|
+
if (serverPath.toLowerCase() !== bundledServer.toLowerCase() && !/^[a-f0-9]{8,64}\\codex\.exe$/i.test(downloadedRelative)) return null;
|
|
443
|
+
if (typeof appServer.commandLine !== "string" || /[\r\n\0]/.test(appServer.commandLine)) return null;
|
|
444
|
+
const args = splitDesktopCommandLine(appServer.commandLine, "win32");
|
|
445
|
+
if (!args.length || path.win32.normalize(args[0]).toLowerCase() !== serverPath.toLowerCase()) return null;
|
|
446
|
+
let command = null;
|
|
447
|
+
for (let index = 1; index < args.length; index++) {
|
|
448
|
+
if (args[index] === "-c" || args[index] === "--config") index++;
|
|
449
|
+
else if (!args[index].startsWith("-")) { command = args[index]; break; }
|
|
450
|
+
}
|
|
451
|
+
if (command !== "app-server") return null;
|
|
452
|
+
const candidates = snapshot.pipes.filter((pipe) => pipe?.serverPid === desktop.pid
|
|
453
|
+
&& typeof pipe.path === "string" && /^\\\\\.\\pipe\\codex-browser-use-[a-z0-9-]+$/i.test(pipe.path));
|
|
454
|
+
const paths = [...new Set(candidates.map((pipe) => pipe.path))].sort();
|
|
455
|
+
return paths;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function readWindowsNativePipeSnapshot(parentPid) {
|
|
459
|
+
if (!Number.isSafeInteger(parentPid) || parentPid <= 0) return null;
|
|
460
|
+
const powershell = path.win32.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
461
|
+
const script = String.raw`
|
|
462
|
+
$ErrorActionPreference = 'Stop'
|
|
463
|
+
$ancestors = @()
|
|
464
|
+
$nextProcessId = ${parentPid}
|
|
465
|
+
for ($depth = 0; $depth -lt 4; $depth++) {
|
|
466
|
+
$ancestor = Get-CimInstance Win32_Process -Filter "ProcessId=$nextProcessId"
|
|
467
|
+
if (!$ancestor) { break }
|
|
468
|
+
$ancestors += @{ pid = [int]$ancestor.ProcessId; parentPid = [int]$ancestor.ParentProcessId; executablePath = $ancestor.ExecutablePath; commandLine = $ancestor.CommandLine }
|
|
469
|
+
if ($ancestor.Name -ieq 'ChatGPT.exe') { break }
|
|
470
|
+
if ($ancestor.Name -ine 'node.exe' -and $ancestor.Name -ine 'codex.exe') { break }
|
|
471
|
+
$nextProcessId = [int]$ancestor.ParentProcessId
|
|
472
|
+
}
|
|
473
|
+
$pipes = @()
|
|
474
|
+
if ($ancestors.Count -ge 2 -and $ancestor.Name -ieq 'ChatGPT.exe') {
|
|
475
|
+
Add-Type -TypeDefinition @'
|
|
476
|
+
using System;
|
|
477
|
+
using System.Runtime.InteropServices;
|
|
478
|
+
using Microsoft.Win32.SafeHandles;
|
|
479
|
+
public static class CodexNativePipeOwner {
|
|
480
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
481
|
+
public static extern bool GetNamedPipeServerProcessId(SafePipeHandle pipe, out uint serverProcessId);
|
|
482
|
+
}
|
|
483
|
+
'@
|
|
484
|
+
$names = @([System.IO.Directory]::GetFiles('\\.\pipe\') | Where-Object { $_ -match '^\\\\\.\\pipe\\codex-browser-use-[a-zA-Z0-9-]+$' } | Sort-Object | Select-Object -First 128)
|
|
485
|
+
foreach ($pipePath in $names) {
|
|
486
|
+
$client = $null
|
|
487
|
+
try {
|
|
488
|
+
$client = [System.IO.Pipes.NamedPipeClientStream]::new('.', $pipePath.Substring(9), [System.IO.Pipes.PipeDirection]::InOut, [System.IO.Pipes.PipeOptions]::Asynchronous)
|
|
489
|
+
$client.Connect(30)
|
|
490
|
+
$serverProcessId = [uint32]0
|
|
491
|
+
if ([CodexNativePipeOwner]::GetNamedPipeServerProcessId($client.SafePipeHandle, [ref]$serverProcessId) -and $serverProcessId -eq $ancestor.ProcessId) {
|
|
492
|
+
$pipes += @{ path = $pipePath; serverPid = [int]$serverProcessId }
|
|
493
|
+
}
|
|
494
|
+
} catch {} finally { if ($client) { $client.Dispose() } }
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
@{ ancestors = @($ancestors); pipes = @($pipes) } | ConvertTo-Json -Depth 4 -Compress
|
|
498
|
+
`;
|
|
499
|
+
const { stdout } = await execFileAsync(powershell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], {
|
|
500
|
+
timeout: 5000, maxBuffer: 128 * 1024, windowsHide: true,
|
|
501
|
+
});
|
|
502
|
+
return stdout.trim() ? JSON.parse(stdout.trim()) : null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function probeWindowsNativeToolsPipe(socketPath, { env, timeoutMs }) {
|
|
506
|
+
const executorThreadId = resolveRelayThreadId(env).threadId;
|
|
507
|
+
const client = new NativeToolsClient({ env, socketPath, timeoutMs });
|
|
508
|
+
const timer = globalThis.setTimeout(() => client.close(), timeoutMs);
|
|
509
|
+
try {
|
|
510
|
+
const nativeResult = await client.dispatchDesktop({ executorThreadId, operation: "list_projects", arguments: {} });
|
|
511
|
+
return Array.isArray(decodeNativeToolResult(nativeResult)?.projects);
|
|
512
|
+
} finally {
|
|
513
|
+
globalThis.clearTimeout(timer);
|
|
514
|
+
client.close();
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
421
518
|
export async function resolveNativeToolsPipePath({
|
|
422
519
|
env = process.env,
|
|
423
520
|
parentPid = process.ppid,
|
|
424
521
|
platform = process.platform,
|
|
425
522
|
readParentCommandLine: readParent = readParentCommandLine,
|
|
523
|
+
readWindowsSnapshot = readWindowsNativePipeSnapshot,
|
|
524
|
+
probeWindowsPipe = probeWindowsNativeToolsPipe,
|
|
525
|
+
windowsDiscoveryTimeoutMs = 7000,
|
|
426
526
|
} = {}) {
|
|
427
527
|
if (env.CODEX_APP_TOOLS_PIPE_PATH) return env.CODEX_APP_TOOLS_PIPE_PATH;
|
|
428
528
|
try {
|
|
429
|
-
|
|
430
|
-
|
|
529
|
+
const inherited = nativeToolsPipeFromCommandLine(await readParent(parentPid, platform), { platform });
|
|
530
|
+
if (inherited) return inherited;
|
|
531
|
+
} catch {}
|
|
532
|
+
if (platform !== "win32") return null;
|
|
533
|
+
const deadline = Date.now() + Math.max(1, Math.min(7000, Number(windowsDiscoveryTimeoutMs) || 7000));
|
|
534
|
+
try {
|
|
535
|
+
const candidates = nativeToolsPipeCandidatesFromWindowsSnapshot(await readWindowsSnapshot(parentPid), { parentPid, localAppData: env.LOCALAPPDATA }) ?? [];
|
|
536
|
+
for (const candidate of candidates) {
|
|
537
|
+
const remaining = deadline - Date.now();
|
|
538
|
+
if (remaining <= 0) break;
|
|
539
|
+
try {
|
|
540
|
+
if (await probeWindowsPipe(candidate, { env, timeoutMs: Math.min(750, remaining) })) return candidate;
|
|
541
|
+
} catch {}
|
|
542
|
+
}
|
|
431
543
|
return null;
|
|
432
|
-
}
|
|
544
|
+
} catch { return null; }
|
|
433
545
|
}
|
|
434
546
|
|
|
435
547
|
export class NativeToolsClient {
|
package/src/release-snapshot.mjs
CHANGED
|
@@ -9,6 +9,102 @@ function digestFiles(root, files) {
|
|
|
9
9
|
return hash.digest("hex");
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
const DIGEST_FIELDS = ["dev", "ino", "size", "mtimeNs", "ctimeNs"];
|
|
13
|
+
const DIGEST_PATTERN = /^[a-f0-9]{64}$/;
|
|
14
|
+
|
|
15
|
+
function safeRelativePath(file) {
|
|
16
|
+
return typeof file === "string" && file.length > 0 && !file.includes("\0")
|
|
17
|
+
&& !path.posix.isAbsolute(file) && !path.win32.parse(file).root
|
|
18
|
+
&& path.normalize(file) === file && !file.split(/[\\/]/).some((part) => !part || part === "." || part === "..");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function digestMetadata(cache) {
|
|
22
|
+
const directories = new Map();
|
|
23
|
+
const dirty = new Set();
|
|
24
|
+
const metadataPath = (directory) => path.join(cache, `.digests-${createHash("sha256").update(directory).digest("hex")}.json`);
|
|
25
|
+
const signature = (stat) => Object.fromEntries(DIGEST_FIELDS.map((field) => [field, String(stat[field])]));
|
|
26
|
+
const matches = (left, right) => left && DIGEST_FIELDS.every((field) => left[field] === right[field]);
|
|
27
|
+
const load = (directory) => {
|
|
28
|
+
dirty.add(directory);
|
|
29
|
+
let descriptor;
|
|
30
|
+
try {
|
|
31
|
+
const file = metadataPath(directory);
|
|
32
|
+
const info = fs.lstatSync(file, { bigint: true });
|
|
33
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1n || info.size > 32n * 1024n * 1024n) return new Map();
|
|
34
|
+
descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
35
|
+
const opened = fs.fstatSync(descriptor, { bigint: true });
|
|
36
|
+
if (!opened.isFile() || opened.nlink !== 1n || !matches(signature(info), signature(opened))) return new Map();
|
|
37
|
+
const metadata = JSON.parse(fs.readFileSync(descriptor, "utf8"));
|
|
38
|
+
if (metadata?.schema !== 1 || metadata.directory !== directory || !metadata.files || typeof metadata.files !== "object" || Array.isArray(metadata.files)) return new Map();
|
|
39
|
+
const entries = Object.entries(metadata.files);
|
|
40
|
+
if (entries.some(([file, record]) => !safeRelativePath(file) || !record || typeof record !== "object" || Array.isArray(record)
|
|
41
|
+
|| typeof record.hash !== "string" || !DIGEST_PATTERN.test(record.hash)
|
|
42
|
+
|| DIGEST_FIELDS.some((field) => typeof record[field] !== "string" || !/^-?\d+$/.test(record[field])))) return new Map();
|
|
43
|
+
dirty.delete(directory);
|
|
44
|
+
return new Map(entries);
|
|
45
|
+
} catch {
|
|
46
|
+
return new Map();
|
|
47
|
+
} finally {
|
|
48
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
digest(directory, files) {
|
|
53
|
+
const previous = directories.get(directory) ?? load(directory);
|
|
54
|
+
const records = new Map();
|
|
55
|
+
const hash = createHash("sha256");
|
|
56
|
+
if (previous.size !== files.length) dirty.add(directory);
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
if (!safeRelativePath(file)) throw new Error(`Unsafe release file: ${file}`);
|
|
59
|
+
const fullPath = path.join(directory, file);
|
|
60
|
+
const before = fs.statSync(fullPath, { bigint: true });
|
|
61
|
+
if (!before.isFile()) throw new Error(`Unsupported release file: ${file}`);
|
|
62
|
+
const current = signature(before);
|
|
63
|
+
let record = previous.get(file);
|
|
64
|
+
if (!matches(record, current)) {
|
|
65
|
+
dirty.add(directory);
|
|
66
|
+
const contentHash = createHash("sha256").update(fs.readFileSync(fullPath)).digest("hex");
|
|
67
|
+
const after = fs.statSync(fullPath, { bigint: true });
|
|
68
|
+
if (!after.isFile() || !matches(current, signature(after))) throw new Error("A release file changed during integrity verification");
|
|
69
|
+
record = { ...current, hash: contentHash };
|
|
70
|
+
}
|
|
71
|
+
records.set(file, record);
|
|
72
|
+
hash.update(file).update("\0").update(record.hash).update("\0");
|
|
73
|
+
}
|
|
74
|
+
directories.set(directory, records);
|
|
75
|
+
return hash.digest("hex");
|
|
76
|
+
},
|
|
77
|
+
relocate(from, to) {
|
|
78
|
+
if (directories.has(from)) {
|
|
79
|
+
directories.set(to, directories.get(from));
|
|
80
|
+
dirty.add(to);
|
|
81
|
+
}
|
|
82
|
+
directories.delete(from);
|
|
83
|
+
dirty.delete(from);
|
|
84
|
+
},
|
|
85
|
+
forget(directory) { directories.delete(directory); dirty.delete(directory); },
|
|
86
|
+
flush() {
|
|
87
|
+
for (const [directory, records] of directories) {
|
|
88
|
+
if (!dirty.has(directory)) continue;
|
|
89
|
+
const file = metadataPath(directory);
|
|
90
|
+
const temporary = path.join(cache, `.digests-${process.pid}-${randomUUID()}.tmp`);
|
|
91
|
+
try {
|
|
92
|
+
try {
|
|
93
|
+
const info = fs.lstatSync(file);
|
|
94
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1) continue;
|
|
95
|
+
} catch (error) { if (error.code !== "ENOENT") continue; }
|
|
96
|
+
fs.writeFileSync(temporary, JSON.stringify({ schema: 1, directory, files: Object.fromEntries(records) }), { flag: "wx", mode: 0o600 });
|
|
97
|
+
fs.renameSync(temporary, file);
|
|
98
|
+
dirty.delete(directory);
|
|
99
|
+
} catch {
|
|
100
|
+
} finally {
|
|
101
|
+
try { fs.rmSync(temporary, { force: true }); } catch {}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
12
108
|
function inventory(root, prefix = "") {
|
|
13
109
|
const files = [];
|
|
14
110
|
for (const entry of fs.readdirSync(path.join(root, prefix), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name, "en"))) {
|
|
@@ -56,17 +152,19 @@ export function createReleaseSnapshot(root, { cache = snapshotRoot(), expectedRe
|
|
|
56
152
|
fs.mkdirSync(cache, { recursive: true, mode: 0o700 });
|
|
57
153
|
if (fs.lstatSync(cache).isSymbolicLink()) throw new Error("The runtime cache cannot be a symbolic link");
|
|
58
154
|
cache = fs.realpathSync.native(cache);
|
|
155
|
+
const metadata = digestMetadata(cache);
|
|
59
156
|
const dependencies = inventory(root, "node_modules");
|
|
60
|
-
const dependencyRevision =
|
|
157
|
+
const dependencyRevision = metadata.digest(root, dependencies);
|
|
61
158
|
const key = createHash("sha256").update(expectedRevision).update(dependencyRevision).digest("hex");
|
|
62
159
|
const target = path.join(cache, key);
|
|
63
160
|
const validate = (directory) => {
|
|
64
161
|
if (fs.lstatSync(directory).isSymbolicLink()) throw new Error("The release directory cannot be a symbolic link");
|
|
65
|
-
return sourceRevision(directory) === expectedRevision &&
|
|
162
|
+
return sourceRevision(directory) === expectedRevision && metadata.digest(directory, inventory(directory, "node_modules")) === dependencyRevision;
|
|
66
163
|
};
|
|
67
164
|
if (fs.existsSync(target)) {
|
|
68
165
|
if (!validate(target)) throw new Error("An existing immutable runtime failed integrity verification");
|
|
69
166
|
if (sourceRevision(root) !== expectedRevision) throw new Error("The installation changed during snapshot verification");
|
|
167
|
+
metadata.flush();
|
|
70
168
|
return { directory: target, revision: expectedRevision, key };
|
|
71
169
|
}
|
|
72
170
|
const temporary = path.join(cache, `.preparing-${process.pid}-${randomUUID()}`);
|
|
@@ -79,14 +177,19 @@ export function createReleaseSnapshot(root, { cache = snapshotRoot(), expectedRe
|
|
|
79
177
|
for (const file of files) {
|
|
80
178
|
fs.copyFileSync(path.join(root, file), path.join(temporary, file), fs.constants.COPYFILE_FICLONE);
|
|
81
179
|
}
|
|
82
|
-
if (sourceRevision(root) !== expectedRevision ||
|
|
180
|
+
if (sourceRevision(root) !== expectedRevision || metadata.digest(root, inventory(root, "node_modules")) !== dependencyRevision || !validate(temporary)) {
|
|
83
181
|
throw new Error("The installation changed while its immutable runtime was being prepared");
|
|
84
182
|
}
|
|
85
|
-
try {
|
|
183
|
+
try {
|
|
184
|
+
fs.renameSync(temporary, target);
|
|
185
|
+
metadata.relocate(temporary, target);
|
|
186
|
+
}
|
|
86
187
|
catch (error) {
|
|
87
188
|
if (!fs.existsSync(target) || !validate(target)) throw error;
|
|
88
189
|
removeOwned(temporary, cache);
|
|
190
|
+
metadata.forget(temporary);
|
|
89
191
|
}
|
|
192
|
+
metadata.flush();
|
|
90
193
|
return { directory: target, revision: expectedRevision, key };
|
|
91
194
|
} catch (error) {
|
|
92
195
|
removeOwned(temporary, cache);
|
package/src/reload-control.mjs
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
export function assertRoutingReload(previous, next) {
|
|
2
|
+
if (typeof previous !== "boolean" || typeof next !== "boolean" || previous && !next) {
|
|
3
|
+
throw new Error("Invalid routing reload or Desktop-to-app-server downgrade; reconnect explicitly to change to legacy mode");
|
|
4
|
+
}
|
|
5
|
+
}
|
|
6
|
+
|
|
1
7
|
export function cloneReloadState(value) {
|
|
2
8
|
const encoded = JSON.stringify(value);
|
|
3
9
|
if (typeof encoded !== "string" || Buffer.byteLength(encoded) > 16 * 1024 * 1024) {
|