@minhspark/codex-mcp-bridge 1.22.5 → 1.22.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-mcp-bridge",
3
- "version": "1.22.5",
3
+ "version": "1.22.7",
4
4
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
5
5
  "author": {
6
6
  "name": "Bui Dang Minh"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,52 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.22.7] - 2026-10-20
8
+
9
+ ### Added
10
+
11
+ - Install with Homebrew: `brew install buidangminh23/tap/codex-mcp-bridge`. The formula in [buidangminh23/homebrew-tap](https://github.com/buidangminh23/homebrew-tap) follows each npm release.
12
+
13
+ ### Fixed
14
+
15
+ - Record Homebrew's stable `opt` paths instead of a versioned Cellar keg when the Claude Desktop, Codex and native relay installers and `bridge-projects register-manager` write a client configuration, for both the bridge and Homebrew's Node.js. A keg path broke every registration as soon as `brew upgrade` removed the old keg. Paths outside a Homebrew keg are written exactly as before.
16
+ - Start the supervisor when its path reaches the installation through a symbolic link, such as Homebrew's `opt` alias. The direct-execution check compared the unresolved command-line path with the resolved module path, so the process exited at once without serving MCP.
17
+ - Follow a Homebrew upgrade in a running supervisor: it watches the `opt` alias, reloads the new keg like any other installed update, and forks workers with the stable Node.js path, instead of reporting "Installation is not ready" after the old keg is removed.
18
+
19
+ ### Verification
20
+
21
+ - 9 new regression tests: Cellar to `opt` mapping, a custom `HOMEBREW_PREFIX`, the main-module check through a symlink and a respelled path, a running supervisor following a bridge keg switch, and one following a Node.js keg replacement. The supervisor tests fail on v1.22.6 and pass here.
22
+ - End to end on macOS with a local tap: two packed versions installed with brew, the three installers run against an isolated home, and every recorded path was `opt/codex-mcp-bridge` and `opt/node` (the relay keeps Codex Desktop's Node). A bridge launched from the recorded Claude Desktop entry kept its supervisor through `brew upgrade` and `brew cleanup` and reloaded the new keg; fresh launches from the recorded paths answered `initialize`.
23
+ - An independent adversarial review confirmed two defects in the first draft, a main-module regression for respelled paths and a worker Node path resolved too late, and both were fixed before merge.
24
+
25
+ ### Limitations
26
+
27
+ - Registrations written from a Homebrew keg by an earlier version still name that keg; run the installers once after upgrading. `codex-native-relay-install` still refuses to rewrite an entry that has `env_vars`, so edit that entry's path by hand.
28
+
29
+ ## [1.22.6] - 2026-10-20
30
+
31
+ ### Fixed
32
+
33
+ - Keep a confirmed Desktop creation identifiable when post-creation verification fails. `delegate_to_codex` and `start_codex_thread` now return the task's threadId, its deliveryId and "Task was accepted; do not resend" instead of a bare allowed-roots error that read as if nothing had been created. The confirmed threadId is kept even when a later account, receipt or scope check fails after the bridge waited for the task's workspace.
34
+ - Treat a filesystem root that Codex Desktop briefly reports as a newly created task's workspace, observed as `/` right after creation following an account switch, as "workspace not yet assigned" when it is not the expected project. Creation keeps observing until its deadline and then returns a pending receipt that keeps the accepted threadId; reply observation keeps polling. The tolerance lasts at most 120 seconds from creation, or from the first such report during reply observation, and the bridge acts on nothing at that path and grants no task access from it during that time.
35
+ - Report a pending creation whose task Desktop already confirmed as `deliveryStatus: accepted` instead of `unconfirmed`.
36
+
37
+ ### Security
38
+
39
+ - A root report that outlasts the grace period, any other workspace outside the allowed roots, a different project, an account change or a sender mismatch still fails closed. Sends and plain thread inspection keep the normal allowed-roots refusal for a root workspace; nothing is sent.
40
+
41
+ ### Verification
42
+
43
+ - 10 regression tests reproduce the defect on v1.22.5 and pass with the fix: creation confirmation with a brief `/` report, a pending result at the deadline, grace expiry, an outside-root workspace, a later account change, reply observation in both the wait and snapshot paths, and two MCP-level `delegate_to_codex` tests through the relay companion.
44
+ - The full CI matrix passed on Windows, macOS and Linux with Node 22 and 24. A local run outside the sandbox passed 1406 tests with 0 failures.
45
+ - An independent adversarial review found no security regression; its four confirmed correctness findings were fixed before merge.
46
+
47
+ ### Upgrade notes
48
+
49
+ - Reconnect the MCP server after updating and confirm the loaded version from the actual runtime before relying on the fix.
50
+ - A creation that is pending because Desktop has not reported its workspace continues with `wait_codex_reply` and the returned deliveryId. Do not resend the prompt or create the task again.
51
+ - Not yet exercised against a live Desktop account switch. A placeholder workspace other than a filesystem root still fails closed as before.
52
+
7
53
  ## [1.22.5] - 2026-10-19
8
54
 
9
55
  ### Fixed
package/README.md CHANGED
@@ -27,6 +27,14 @@ npm install -g @minhspark/codex-mcp-bridge@latest
27
27
 
28
28
  Requires **Node.js 22+**. In Windows PowerShell, use `npm.cmd` instead of `npm`. Run the same command to update; reconnect MCP servers if their settings or installation paths changed.
29
29
 
30
+ On macOS or Linux you can install with [Homebrew](https://github.com/buidangminh23/homebrew-tap) instead, which brings its own Node.js:
31
+
32
+ ```bash
33
+ brew install buidangminh23/tap/codex-mcp-bridge
34
+ ```
35
+
36
+ Update with `brew upgrade codex-mcp-bridge`. The installers record Homebrew's stable `opt` paths, so registrations keep working after an upgrade and running bridges reload the new version. Keep one install channel: whichever of npm or Homebrew comes first on `PATH` provides the commands.
37
+
30
38
  [Windows](#windows-powershell) · [macOS](#macos-terminal) · [Linux / WSL](#linux--wsl-bash) · [ChatGPT plugin](#chatgpt-plugin)
31
39
 
32
40
  ### Windows (PowerShell)
@@ -93,6 +101,15 @@ claude mcp add --scope user codex-bridge \
93
101
  -- "$(command -v node)" "$bridge_root/src/mcp-supervisor.mjs" index.mjs
94
102
  ```
95
103
 
104
+ For a Homebrew install, use the stable paths instead of `npm root -g` and `command -v node`:
105
+
106
+ ```bash
107
+ bridge_root="$(brew --prefix)/opt/codex-mcp-bridge/libexec/lib/node_modules/@minhspark/codex-mcp-bridge"
108
+ node_bin="$(brew --prefix)/opt/node/bin/node"
109
+ ```
110
+
111
+ and end the command with `-- "$node_bin" "$bridge_root/src/mcp-supervisor.mjs" index.mjs`.
112
+
96
113
  </details>
97
114
 
98
115
  Separate multiple allowed directories with `;` on Windows or `:` on macOS/Linux.
@@ -178,7 +195,7 @@ Dispatch expires after two minutes; operation history remains stored. Disconnect
178
195
 
179
196
  ## Troubleshooting
180
197
 
181
- - **Command missing:** open a new terminal and check Node.js 22+ and the global npm directory on PATH.
198
+ - **Command missing:** open a new terminal and check Node.js 22+ and the global npm directory (or Homebrew's `bin`) on PATH.
182
199
  - **PowerShell blocks scripts:** use `npm.cmd`, installer `.cmd` launchers, and `codex.exe`.
183
200
  - **No sessions or wrong project:** open the intended Claude **Code** session and use the exact project directory saved in Codex Desktop.
184
201
  - **Relay unavailable:** check [pipe forwarding](#native-relay-pipe-forwarding), reconnect, and run `native_relay_status` in the task.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.22.5",
3
+ "version": "1.22.7",
4
4
  "description": "Two-way MCP bridge between Claude and Codex: prompts into a live Codex thread, messages into a running Claude Code session.",
5
5
  "keywords": [
6
6
  "mcp",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "codex-mcp-bridge",
4
- "version": "1.22.5",
4
+ "version": "1.22.7",
5
5
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
6
6
  "author": {
7
7
  "name": "Bui Dang Minh",
@@ -9,10 +9,11 @@ import { desktopTasksConfigured } from "../src/native-relay.mjs";
9
9
  import { exitForVersionRequest } from "../src/cli-version.mjs";
10
10
  import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs";
11
11
  import { enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-access.mjs";
12
+ import { stableInstallPath, stableNodeBin } from "../src/install-paths.mjs";
12
13
 
13
14
  exitForVersionRequest(import.meta.url);
14
15
 
15
- const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
16
+ const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
16
17
  const cfgPath = process.env.CLAUDE_DESKTOP_CONFIG ?? claudeDesktopConfigPath();
17
18
  const reset = process.argv.includes("--reset");
18
19
  const forceDesktopTasks = process.argv.includes("--desktop-tasks");
@@ -29,7 +30,7 @@ if (fullAccess) enableCodexFullAccess();
29
30
  */
30
31
  const defaultRoots = "*";
31
32
 
32
- const nodeBin = process.env.NODE_EXE ?? process.execPath;
33
+ const nodeBin = process.env.NODE_EXE ?? stableNodeBin();
33
34
  const codexBin = resolveCodexBin(process.env.CODEX_EXE);
34
35
 
35
36
  if (!fs.existsSync(nodeBin)) throw new Error(`node executable not found: ${nodeBin}`);
@@ -10,10 +10,12 @@ import { desktopTasksConfigured } from "../src/native-relay.mjs";
10
10
  import { codexMcpRegistration } from "../src/codex-mcp-registration.mjs";
11
11
  import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs";
12
12
  import { enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-access.mjs";
13
+ import { stableInstallPath, stableNodeBin } from "../src/install-paths.mjs";
13
14
 
14
15
  exitForVersionRequest(import.meta.url);
15
16
 
16
- const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
17
+ const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
18
+ const nodeBin = process.env.NODE_EXE ?? stableNodeBin();
17
19
  const codexBin = resolveCodexBin(process.env.CODEX_EXE);
18
20
  const serverName = process.env.CLAUDE_BRIDGE_NAME ?? "claude-bridge";
19
21
  const entry = path.join(root, "src", "mcp-supervisor.mjs");
@@ -39,13 +41,13 @@ const servers = JSON.parse(run(["mcp", "list", "--json"]));
39
41
  if (!Array.isArray(servers)) throw new Error("Codex returned an invalid MCP inventory; existing configuration was not changed");
40
42
  const existing = servers.some((server) => server.name === serverName)
41
43
  ? JSON.parse(run(["mcp", "get", serverName, "--json"])) : null;
42
- const registration = codexMcpRegistration({ name: serverName, existing, node: process.execPath, entry, entryArgs, env: process.env, desktopOnly: desktopTasksConfigured() });
44
+ const registration = codexMcpRegistration({ name: serverName, existing, node: nodeBin, entry, entryArgs, env: process.env, desktopOnly: desktopTasksConfigured() });
43
45
  createReleaseSnapshot(root, { cache: snapshotRoot({ ...process.env, ...registration.environment }) });
44
46
  run(registration.args);
45
47
 
46
48
  console.log(`platform: ${PLATFORM_LABEL}`);
47
49
  console.log(`registered MCP server "${serverName}" with Codex:`);
48
- console.log(`command: ${process.execPath} ${entry} ${entryArgs.join(" ")}`);
50
+ console.log(`command: ${nodeBin} ${entry} ${entryArgs.join(" ")}`);
49
51
  console.log(`environment keys: ${registration.environmentKeys.join(", ")}`);
50
52
  console.log("\nReconnect claude-bridge once in the existing Codex Desktop task to load the supervisor. Subsequent compatible installed-source updates reload automatically when the worker is safely idle. Do not create a replacement task.");
51
53
  console.log("Verify claude_bridge_status from that task: autoReload must be enabled, runtime state must be current, and the session policy must match this installation. A separate diagnostic process does not verify the app's loaded MCP process.");
@@ -20,11 +20,12 @@ import { exitForVersionRequest } from "../src/cli-version.mjs";
20
20
  import { stdioMcpRegistration } from "../src/codex-mcp-registration.mjs";
21
21
  import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs";
22
22
  import { enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-access.mjs";
23
+ import { stableInstallPath } from "../src/install-paths.mjs";
23
24
 
24
25
  exitForVersionRequest(import.meta.url);
25
26
 
26
- const VERSION = "1.22.5";
27
- const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
27
+ const VERSION = "1.22.7";
28
+ const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
28
29
  const entry = path.join(root, "src", "mcp-supervisor.mjs");
29
30
  const entryArgs = ["native-relay-companion.mjs"];
30
31
  const serverName = process.env.CODEX_NATIVE_RELAY_NAME ?? "codex-native-relay";
@@ -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.5";
33
+ const VERSION = "1.22.7";
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;
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.5";
43
+ const VERSION = "1.22.7";
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
 
@@ -211,6 +211,25 @@ function threadNameFor({ cwd, prompt, name }) {
211
211
  );
212
212
  }
213
213
 
214
+ /**
215
+ * Once a Desktop creation was dispatched, a later verification failure must
216
+ * never read like a refusal: the caller keeps the task and receipt identity
217
+ * and is told not to resend. Verification still fails closed; only the report
218
+ * changes.
219
+ */
220
+ function creationVerificationFailure({ created, deliveryId, requestId, err }) {
221
+ const threadId = created.threadId ?? err?.threadId ?? null;
222
+ const lines = threadId
223
+ ? ["Codex Desktop accepted the task, but post-creation verification failed.", `threadId: ${threadId}`]
224
+ : ["Codex Desktop creation was dispatched, but its outcome is not yet confirmed."];
225
+ lines.push(...(requestId ? [`requestId: ${requestId}`] : []), `cwd: ${created.cwd}`, ...(deliveryId ? [`deliveryId: ${deliveryId}`] : []), `error: ${err?.message ?? String(err)}`,
226
+ threadId ? "Task was accepted; do not resend. Resolve the verification error, then continue observing with wait_codex_reply and this deliveryId." : "Do not resend or create again. Continue observing with wait_codex_reply and this deliveryId.");
227
+ return {
228
+ ...textResult(lines.join("\n"), true),
229
+ structuredContent: { ...failure(err).structuredContent, threadId, deliveryStatus: threadId ? "accepted" : "unconfirmed", ...(deliveryId ? { deliveryId } : {}), nextAction: deliveryId ? "wait_codex_reply" : "none" },
230
+ };
231
+ }
232
+
214
233
  async function delegateDesktopTask({ cwd, prompt, name, requestId, model, effort, timeoutSec, openInApp, waitForReply = true }) {
215
234
  const deadline = desktopOperation.getStore()?.deadline ?? Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS);
216
235
  const workspace = resolveWorkspacePath(cwd);
@@ -222,8 +241,17 @@ async function delegateDesktopTask({ cwd, prompt, name, requestId, model, effort
222
241
  const deliveryId = created.deliveryId;
223
242
  let delivered = deliveryId ? replyReceipts.read(deliveryId, desktopOperation.getStore()) : null;
224
243
  if (delivered?.creationObservation) {
225
- const resolved = await desktopTasks.resolveCreation(delivered, { deadline });
226
- if (!resolved) return replyResult(delivered, deliveryId, { threadId: null, status: "timeout", responseStatus: "unavailable", text: "" }, ["Creation acknowledgement pending; do not create again."]);
244
+ let resolved;
245
+ try {
246
+ resolved = await desktopTasks.resolveCreation(delivered, { deadline });
247
+ } catch (err) {
248
+ return creationVerificationFailure({ created, deliveryId, requestId, err });
249
+ }
250
+ if (!resolved) {
251
+ const threadId = created.threadId ?? null;
252
+ return replyResult({ ...delivered, threadId, ...(threadId ? { deliveryStatus: "accepted" } : {}) }, deliveryId, { threadId, status: "timeout", responseStatus: "unavailable", text: "" },
253
+ [...(threadId ? [`threadId: ${threadId}`, "Task was accepted; Codex Desktop has not yet confirmed its workspace or creation receipt."] : []), "Creation acknowledgement pending; do not create again."]);
254
+ }
227
255
  delivered = resolved;
228
256
  created.threadId = resolved.threadId;
229
257
  }
@@ -0,0 +1,54 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const KEG_PATH = /^(\/.*?)\/Cellar\/([^/]+)\/[^/]+(\/.*)?$/;
6
+
7
+ function resolved(target) {
8
+ try { return fs.realpathSync.native(target); } catch { return null; }
9
+ }
10
+
11
+ /**
12
+ * Homebrew installs every version of a formula into its own Cellar keg and
13
+ * deletes the old keg on upgrade, while <prefix>/opt/<formula> is retargeted to
14
+ * the current one. A path that outlives this process - written into a client
15
+ * configuration, or watched for an upgrade - therefore has to name the opt
16
+ * alias. A keg path is rewritten only when the alias resolves to the same file
17
+ * right now; every other path is returned unchanged.
18
+ */
19
+ export function stableInstallPath(target, env = process.env) {
20
+ if (process.platform === "win32" || typeof target !== "string") return target;
21
+ const match = KEG_PATH.exec(target);
22
+ if (!match) return target;
23
+ const real = resolved(target);
24
+ if (!real) return target;
25
+ const [, base, formula, rest = ""] = match;
26
+ const prefixes = [...new Set([env.HOMEBREW_PREFIX, base].filter((prefix) => prefix && path.isAbsolute(prefix)))];
27
+ for (const prefix of prefixes) {
28
+ const alias = path.join(prefix, "opt", formula) + rest;
29
+ if (resolved(alias) === real) return alias;
30
+ }
31
+ return target;
32
+ }
33
+
34
+ /** The node binary to record for later launches. */
35
+ export function stableNodeBin(env = process.env) {
36
+ return stableInstallPath(process.execPath, env);
37
+ }
38
+
39
+ /**
40
+ * True when `entry` names the running main module, however it was reached.
41
+ * Node resolves the main module with the JavaScript realpath, which follows
42
+ * symlinks but keeps the spelling of the path as typed (case, 8.3 names,
43
+ * substituted drives), so that is the form compared first.
44
+ */
45
+ export function isMainModule(entry, moduleUrl) {
46
+ if (!entry) return false;
47
+ const file = path.resolve(entry);
48
+ const module = fileURLToPath(moduleUrl);
49
+ let typed;
50
+ try { typed = fs.realpathSync(file); } catch { typed = file; }
51
+ if (typed === module) return true;
52
+ const canonical = resolved(file);
53
+ return canonical !== null && canonical === resolved(module);
54
+ }
@@ -5,7 +5,9 @@ import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { createReleaseSnapshot, sourceRevision } from "./release-snapshot.mjs";
7
7
  import { desktopTasksConfigured } from "./native-relay.mjs";
8
+ import { isMainModule, stableInstallPath } from "./install-paths.mjs";
8
9
 
10
+ const WORKER_NODE = stableInstallPath(process.execPath);
9
11
  const ENTRIES = new Set(["index.mjs", "claude-bridge.mjs", "native-relay-companion.mjs"]);
10
12
  const MAX_FRAME_BYTES = 16 * 1024 * 1024;
11
13
  const MAX_QUEUE = 256;
@@ -40,7 +42,7 @@ class Worker {
40
42
  this.retiring = false;
41
43
  this.closed = false;
42
44
  this.process = fork(path.join(release.directory, "src", entry), [], {
43
- execPath: process.execPath, execArgv: [], cwd: process.cwd(), windowsHide: true,
45
+ execPath: WORKER_NODE, execArgv: [], cwd: process.cwd(), windowsHide: true,
44
46
  env: { ...env, CODEX_BRIDGE_WORKER: "1", CODEX_BRIDGE_STAGED: "1" },
45
47
  stdio: ["pipe", "pipe", "pipe", "ipc"], serialization: "json",
46
48
  });
@@ -133,14 +135,16 @@ class Worker {
133
135
 
134
136
  export async function runSupervisor(entry, options = {}) {
135
137
  if (!ENTRIES.has(entry)) throw new Error("Expected index.mjs, claude-bridge.mjs, or native-relay-companion.mjs");
136
- const root = fs.realpathSync.native(options.root ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
138
+ const installRoot = stableInstallPath(fs.realpathSync.native(options.root ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")));
139
+ const currentRoot = () => fs.realpathSync.native(installRoot);
140
+ const root = currentRoot();
137
141
  if (process.argv.includes("--version") || process.argv.includes("-v")) {
138
142
  process.stdout.write(`${JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version}\n`);
139
143
  return;
140
144
  }
141
145
  const log = (message) => process.stderr.write(`[bridge-supervisor] ${message}\n`);
142
146
  const output = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
143
- const env = { ...process.env, CODEX_BRIDGE_SOURCE_ROOT: root };
147
+ const env = { ...process.env, CODEX_BRIDGE_SOURCE_ROOT: installRoot };
144
148
  const routingConfiguration = () => entry === "native-relay-companion.mjs" ? null : desktopTasksConfigured(env);
145
149
  if (entry === "native-relay-companion.mjs" && !env.CODEX_APP_TOOLS_PIPE_PATH) {
146
150
  const { resolveNativeToolsPipePath } = await import("./native-relay.mjs");
@@ -226,9 +230,10 @@ export async function runSupervisor(entry, options = {}) {
226
230
  };
227
231
  const check = async () => {
228
232
  if (switching || !ready || stopping || failure) return;
233
+ let source;
229
234
  let revision;
230
235
  let routing;
231
- try { revision = sourceRevision(root); routing = routingConfiguration(); }
236
+ try { source = currentRoot(); revision = sourceRevision(source); routing = routingConfiguration(); }
232
237
  catch (error) { reason = `Installation is not ready: ${error.message}`; return; }
233
238
  if (observed !== revision || observedRouting !== routing) { observed = revision; observedRouting = routing; observedAt = Date.now(); retryAt = 0; }
234
239
  if (revision === active.release.revision && routing === active.routingConfiguration) { reason = null; return; }
@@ -241,7 +246,7 @@ export async function runSupervisor(entry, options = {}) {
241
246
  try {
242
247
  const state = await previous.control("inspect");
243
248
  if (!state.reloadable) { reason = state.reason ?? "Pending deliveries prevent a safe reload"; return; }
244
- const release = createReleaseSnapshot(root, { expectedRevision: revision });
249
+ const release = createReleaseSnapshot(source, { expectedRevision: revision });
245
250
  candidate = spawn(release, routing);
246
251
  const initialized = await candidate.rpc("initialize", initializeParams);
247
252
  if (initialized.protocolVersion !== initializeResult.protocolVersion || JSON.stringify(initialized.capabilities) !== JSON.stringify(initializeResult.capabilities)) {
@@ -251,7 +256,7 @@ export async function runSupervisor(entry, options = {}) {
251
256
  const catalog = await candidate.rpc("tools/list", {});
252
257
  if (!Array.isArray(catalog.tools) || !catalog.tools.length || catalog.nextCursor) throw new Error("The candidate tool catalog is incomplete");
253
258
  const oldCatalog = await previous.rpc("tools/list", {});
254
- if (sourceRevision(root) !== revision) throw new Error("The installation changed before activation");
259
+ if (sourceRevision(currentRoot()) !== revision) throw new Error("The installation changed before activation");
255
260
  if (routingConfiguration() !== routing) throw new Error("The routing configuration changed before activation");
256
261
  quiesced = true;
257
262
  const exported = await previous.control("quiesce");
@@ -339,6 +344,6 @@ export async function runSupervisor(entry, options = {}) {
339
344
  log(`supervising ${entry}; installed updates activate after pending work completes`);
340
345
  }
341
346
 
342
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
347
+ if (isMainModule(process.argv[1], import.meta.url)) {
343
348
  await runSupervisor(process.argv[2]);
344
349
  }
@@ -38,7 +38,7 @@ import { NativeCreationReceipts, creationIdValid } from "./native-creation-recei
38
38
 
39
39
  exitForVersionRequest(import.meta.url);
40
40
 
41
- const VERSION = "1.22.5";
41
+ const VERSION = "1.22.7";
42
42
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
43
43
 
44
44
  function errorResponse(code, message, sent, details) {
package/src/platform.mjs CHANGED
@@ -5,6 +5,8 @@ import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { promisify } from "node:util";
7
7
 
8
+ import { stableNodeBin } from "./install-paths.mjs";
9
+
8
10
  const execFileAsync = promisify(execFile);
9
11
 
10
12
  export const IS_MACOS = process.platform === "darwin";
@@ -230,7 +232,7 @@ export function resolveCodexDesktopNodeBin(
230
232
  ]) {
231
233
  if (candidate && isRunnable(candidate)) return { path: candidate, source };
232
234
  }
233
- return { path: process.execPath, source: "process.execPath" };
235
+ return { path: stableNodeBin(env), source: "process.execPath" };
234
236
  }
235
237
 
236
238
  /**
@@ -393,7 +395,7 @@ export function spawnEnv(extra = {}) {
393
395
  const systemDirs = IS_WINDOWS
394
396
  ? []
395
397
  : ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"];
396
- const merged = [path.dirname(process.execPath), ...systemDirs, ...(process.env.PATH ?? "").split(separator)]
398
+ const merged = [path.dirname(stableNodeBin()), ...systemDirs, ...(process.env.PATH ?? "").split(separator)]
397
399
  .filter(Boolean)
398
400
  .filter((dir, index, all) => all.indexOf(dir) === index)
399
401
  .join(separator);
@@ -4,15 +4,16 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { readRegularConfigFile, backupRegularConfigFile } from "./project-policy.mjs";
7
+ import { stableInstallPath, stableNodeBin } from "./install-paths.mjs";
7
8
 
8
9
  // Optional UI adapter discovery. Neither bridge startup nor policy evaluation reads
9
10
  // this file. Removing the card extension or this descriptor cannot stop messaging.
10
- export function registerProjectManager({ policyFile, env = process.env, executable = process.execPath } = {}) {
11
+ export function registerProjectManager({ policyFile, env = process.env, executable = stableNodeBin(env) } = {}) {
11
12
  if (!path.isAbsolute(policyFile ?? "")) throw new Error("Manager policy path must be absolute");
12
13
  const home = env.HOME ?? env.USERPROFILE ?? os.homedir();
13
14
  const file = path.join(home, ".config", "GptClaudeBridge", "management.json");
14
15
  const value = { version: 1, node: executable,
15
- script: fileURLToPath(new URL("../scripts/bridge-projects.mjs", import.meta.url)),
16
+ script: stableInstallPath(fileURLToPath(new URL("../scripts/bridge-projects.mjs", import.meta.url)), env),
16
17
  policyFile, configFile: env.CLAUDE_CONFIG_DIR ? path.join(env.CLAUDE_CONFIG_DIR, ".claude.json") : path.join(home, ".claude.json") };
17
18
  if (!Object.values(value).filter(v => typeof v === "string").every(v => path.isAbsolute(v))) throw new Error("Manager paths must be absolute");
18
19
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -26,6 +26,8 @@ import { requireClaudeSenderContext } from "./claude-sender-context.mjs";
26
26
  export const NATIVE_BACKEND = "codex-desktop-native";
27
27
  export const APP_SERVER_BACKEND = "app-server";
28
28
  export const DESKTOP_TOOL_BUDGET_MS = 40000;
29
+ export const WORKSPACE_UNASSIGNED = "DESKTOP_WORKSPACE_UNASSIGNED";
30
+ export const UNASSIGNED_WORKSPACE_GRACE_MS = 120000;
29
31
  const RELEASE_STATUSES = new Set(["completed", "interrupted", "failed"]);
30
32
  const METADATA_READ_OPTIONS = { includeOutputs: false, maxOutputCharsPerItem: 1 };
31
33
 
@@ -35,6 +37,17 @@ function unavailableWaitRenderer(error) {
35
37
  /^(Codex app tool request failed\.?|The app tool call ended before producing a result\.?)$/.test(cause.message);
36
38
  }
37
39
 
40
+ function isFilesystemRoot(value) {
41
+ if (typeof value !== "string" || !path.isAbsolute(value)) return false;
42
+ const resolved = path.resolve(value);
43
+ return path.parse(resolved).root === resolved;
44
+ }
45
+
46
+ function acceptedCreationError(threadId, error) {
47
+ if (error.acceptedCreation) return error;
48
+ return Object.assign(new Error(`Codex Desktop created task ${threadId}, but its post-creation verification failed: ${error.message}. threadId: ${threadId}. Task was accepted; do not resend. Its reply was withheld until the verification error is resolved.`, { cause: error }), { code: "DESKTOP_CREATION_VERIFICATION_FAILED", threadId, acceptedCreation: true });
49
+ }
50
+
38
51
  function sameAccountContext(expected, current) {
39
52
  return Boolean(expected && current && ["claude", "codex"].every((provider) =>
40
53
  typeof expected[provider] === "string" && expected[provider] === current[provider]));
@@ -75,6 +88,8 @@ export class DesktopTaskDelivery {
75
88
  this.threadOperations = new Map();
76
89
  }
77
90
 
91
+ #unassignedSince = new Map();
92
+
78
93
  senderProjectScope(cwd) {
79
94
  if (!this.senderContext) return null;
80
95
  const sender = requireClaudeSenderContext(this.senderContext());
@@ -329,6 +344,15 @@ export class DesktopTaskDelivery {
329
344
  async resolveCreation(delivered, { deadline = this.now() + DESKTOP_TOOL_BUDGET_MS } = {}) {
330
345
  const binding = delivered.creationObservation;
331
346
  if (!binding) return delivered;
347
+ let confirmedThreadId = null;
348
+ try {
349
+ return await this.#resolveCreationLoop(delivered, binding, deadline, (threadId) => { confirmedThreadId = threadId; });
350
+ } catch (error) {
351
+ throw confirmedThreadId ? acceptedCreationError(confirmedThreadId, error) : error;
352
+ }
353
+ }
354
+
355
+ async #resolveCreationLoop(delivered, binding, deadline, onConfirmed) {
332
356
  for (;;) {
333
357
  if (this.now() >= deadline) return null;
334
358
  this.security.assertCwd(delivered.cwd);
@@ -345,17 +369,29 @@ export class DesktopTaskDelivery {
345
369
  const response = row.result, threadId = response?.threadId;
346
370
  if (typeof threadId !== "string" || !threadId.trim() || response.hostId !== "local" || response.status === "outcome-unknown" ||
347
371
  response.firstTurn && response.firstTurn.status !== "accepted" || receipt.threadId && receipt.threadId !== threadId) throw new Error("Native creation is not confirmed; do not resend");
372
+ onConfirmed(threadId);
348
373
  let inspected;
349
- try { inspected = await this.#inspectMetadata(threadId, delivered.cwd, { deadline }); }
350
- catch (error) { if (this.now() >= deadline && /deadline|within \d+ms/.test(error.message)) return null; throw error; }
351
- if (inspected.executorThreadId !== envelope.executorThreadId) throw new Error("Native creation executor changed");
352
- if (!sameAccountContext(binding.accountContext, this.accountContext?.())) throw new Error("Creation receipt account changed");
353
- this.security.assertCwd(delivered.cwd);
354
- this.senderProjectScope(delivered.cwd);
355
- if (this.security.hardenedRoots?.mode === "project-policy") this.security.hardenedRoots.recheck(inspected.scopeBinding);
356
- if (inspected.projectScope) recheckProjectScope(inspected.projectScope);
357
- this.security.registerThread(threadId);
358
- this.security.assertThread(threadId, inspected.thread.cwd);
374
+ try { inspected = await this.#inspectMetadata(threadId, delivered.cwd, { deadline, tolerance: { since: receipt.startedAt } }); }
375
+ catch (error) {
376
+ if (error.code === WORKSPACE_UNASSIGNED) {
377
+ await this.sleep(Math.min(1000, Math.max(0, deadline - this.now())));
378
+ continue;
379
+ }
380
+ if (this.now() >= deadline && /deadline|within \d+ms/.test(error.message)) return null;
381
+ throw acceptedCreationError(threadId, error);
382
+ }
383
+ try {
384
+ if (inspected.executorThreadId !== envelope.executorThreadId) throw new Error("Native creation executor changed");
385
+ if (!sameAccountContext(binding.accountContext, this.accountContext?.())) throw new Error("Creation receipt account changed");
386
+ this.security.assertCwd(delivered.cwd);
387
+ this.senderProjectScope(delivered.cwd);
388
+ if (this.security.hardenedRoots?.mode === "project-policy") this.security.hardenedRoots.recheck(inspected.scopeBinding);
389
+ if (inspected.projectScope) recheckProjectScope(inspected.projectScope);
390
+ this.security.registerThread(threadId);
391
+ this.security.assertThread(threadId, inspected.thread.cwd);
392
+ } catch (error) {
393
+ throw acceptedCreationError(threadId, error);
394
+ }
359
395
  return { threadId, cwd: delivered.cwd, previousTurnId: null, deliveryStatus: "accepted",
360
396
  responseObservation: { operation: "create_thread", threadId, previousTurnId: null, expectedCwd: delivered.cwd,
361
397
  executorThreadId: envelope.executorThreadId, prompt: binding.args.prompt, accountContext: binding.accountContext } };
@@ -366,18 +402,18 @@ export class DesktopTaskDelivery {
366
402
  }
367
403
  }
368
404
 
369
- async inspect(threadId, cwd, { deadline } = {}) {
370
- const inspected = await this.#inspectMetadata(threadId, cwd, { deadline });
405
+ async inspect(threadId, cwd, { deadline, tolerateUnassigned = false } = {}) {
406
+ const inspected = await this.#inspectMetadata(threadId, cwd, { deadline, ...(tolerateUnassigned ? { tolerance: {} } : {}) });
371
407
  this.security.assertThread(threadId, inspected.thread.cwd);
372
408
  return inspected;
373
409
  }
374
410
 
375
- async #inspectMetadata(threadId, cwd, { deadline } = {}) {
411
+ async #inspectMetadata(threadId, cwd, { deadline, tolerance } = {}) {
376
412
  const envelope = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1, ...METADATA_READ_OPTIONS }, { deadline, includeRelayContext: true });
377
413
  const response = envelope.result;
378
414
  const thread = response?.thread;
379
415
  if (thread?.id !== threadId || thread.hostId !== "local" || !thread.cwd) throw new Error("Desktop did not confirm the task's local workspace.");
380
- this.security.assertCwd(thread.cwd);
416
+ this.#assertReportedWorkspace(threadId, thread.cwd, cwd, tolerance);
381
417
  const projectScope = this.senderProjectScope(thread.cwd);
382
418
  if (cwd && path.relative(realpathSync.native(cwd), realpathSync.native(thread.cwd))) {
383
419
  throw new Error("Native Desktop delivery cannot change an existing task's workspace; create a new task at the requested cwd.");
@@ -386,6 +422,39 @@ export class DesktopTaskDelivery {
386
422
  return { thread, latestTurnId: response.turns?.[0]?.id ?? null, projectScope, scopeBinding, executorThreadId: envelope.executorThreadId ?? null };
387
423
  }
388
424
 
425
+ /**
426
+ * Codex Desktop can briefly report a filesystem root as a task's workspace
427
+ * before it finishes assigning the task's saved project (observed as "/"
428
+ * right after creation, following an account switch). Only observers that
429
+ * pass a tolerance - creation confirmation and reply observation, both of
430
+ * which already know the expected workspace - treat a root that differs from
431
+ * that workspace as "not yet assigned", and only for a bounded grace period
432
+ * measured from creation or from the first such report. During it the bridge
433
+ * acts on nothing at that path, grants no access and keeps observing. After
434
+ * it, and for every other caller or path, the normal fail-closed
435
+ * allowed-roots check applies.
436
+ */
437
+ #assertReportedWorkspace(threadId, reported, expected, tolerance) {
438
+ if (!isFilesystemRoot(reported)) this.#unassignedSince.delete(threadId);
439
+ else if (tolerance && expected && !this.#isExpectedWorkspace(reported, expected)) {
440
+ const now = this.now();
441
+ if (tolerance.since === undefined && !this.#unassignedSince.has(threadId)) this.#unassignedSince.set(threadId, now);
442
+ const since = tolerance.since ?? this.#unassignedSince.get(threadId);
443
+ if (now - since < UNASSIGNED_WORKSPACE_GRACE_MS) {
444
+ throw Object.assign(new Error(`Codex Desktop has not yet reported task ${threadId}'s project workspace (it currently reports ${reported}); the bridge did not act on that path.`), { code: WORKSPACE_UNASSIGNED, threadId });
445
+ }
446
+ }
447
+ this.security.assertCwd(reported);
448
+ }
449
+
450
+ #isExpectedWorkspace(reported, expected) {
451
+ try {
452
+ return path.relative(realpathSync.native(expected), realpathSync.native(reported)) === "";
453
+ } catch {
454
+ return false;
455
+ }
456
+ }
457
+
389
458
  async send({ threadId, prompt, cwd, model, effort, name, deadline, onPrepared }) {
390
459
  const inspected = await this.inspect(threadId, cwd, { deadline });
391
460
  const scopeBinding = this.security.hardenedRoots?.mode === "project-policy" ? this.security.hardenedRoots.capture(inspected.thread.cwd) : undefined;
@@ -445,12 +514,12 @@ export class DesktopTaskDelivery {
445
514
  if (realpathSync.native(expectedCwd) !== expectedCwd) throw new Error("The selected native task workspace changed while its response was being observed");
446
515
  };
447
516
  await recheck();
448
- const initial = await this.inspect(threadId, expectedCwd, { deadline });
517
+ const initial = await this.inspect(threadId, expectedCwd, { deadline, tolerateUnassigned: true });
449
518
  if (!resolveDispatch && initial.latestTurnId !== turnId) throw new Error("The selected native task changed before its response could be read; reply content was withheld");
450
519
  const read = resolveDispatch ? this.resolveResponse : responseObservation.operation === "create_thread" ? readCodexNativeCreationResponse : this.readResponse;
451
520
  const observed = read({ ...responseObservation, threadId, turnId, desktopEvidence: initial });
452
521
  await recheck();
453
- const inspected = await this.inspect(threadId, expectedCwd, { deadline });
522
+ const inspected = await this.inspect(threadId, expectedCwd, { deadline, tolerateUnassigned: true });
454
523
  if (inspected.latestTurnId !== (resolveDispatch ? initial.latestTurnId : turnId)) throw new Error("The selected native task changed before its response could be confirmed; reply content was withheld");
455
524
  await recheck();
456
525
  return observed;
@@ -485,7 +554,7 @@ export class DesktopTaskDelivery {
485
554
  const result = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1, ...METADATA_READ_OPTIONS }, { deadline: startedAt + timeoutMs });
486
555
  const thread = result?.thread;
487
556
  if (thread?.id !== threadId || thread.hostId !== "local" || !thread.cwd) throw new Error("Desktop did not confirm the observed task's local workspace.");
488
- this.security.assertCwd(thread.cwd);
557
+ this.#assertReportedWorkspace(threadId, thread.cwd, responseObservation?.expectedCwd, {});
489
558
  this.security.assertThread(threadId, thread.cwd);
490
559
  this.senderProjectScope(thread.cwd);
491
560
  if (responseObservation?.expectedCwd && realpathSync.native(thread.cwd) !== responseObservation.expectedCwd) throw new Error("The selected native task workspace changed during observation");
@@ -497,14 +566,17 @@ export class DesktopTaskDelivery {
497
566
  return this.now() - startedAt >= timeoutMs && (["DESKTOP_RESPONSE_DEADLINE", "RELAY_TIMEOUT"].includes(cause.code) ||
498
567
  cause.code === "RELAY_UNREACHABLE" && cause.message === "The Codex Desktop relay send deadline elapsed before dispatch");
499
568
  };
569
+ const unassigned = Symbol("workspace-unassigned");
500
570
  const observe = async (selectedTurnId, resolveDispatch = false) => {
501
571
  try {
502
572
  return await this.observeNativeResponse(threadId, selectedTurnId, responseObservation, { deadline: startedAt + timeoutMs, resolveDispatch });
503
573
  } catch (error) {
504
574
  if (deadlineError(error)) return null;
575
+ if (error.code === WORKSPACE_UNASSIGNED) return unassigned;
505
576
  throw error;
506
577
  }
507
578
  };
579
+ const pause = () => this.sleep(Math.min(1500, timeoutMs - (this.now() - startedAt)));
508
580
  for (;;) {
509
581
  if (this.now() - startedAt >= timeoutMs) return expired();
510
582
  let response;
@@ -514,13 +586,19 @@ export class DesktopTaskDelivery {
514
586
  }, { deadline: startedAt + timeoutMs });
515
587
  } catch (err) {
516
588
  if (deadlineError(err)) return expired();
589
+ if (err.code === WORKSPACE_UNASSIGNED) {
590
+ await pause();
591
+ continue;
592
+ }
517
593
  if (readSnapshots || !unavailableWaitRenderer(err)) throw err;
518
594
  readSnapshots = true;
519
595
  try {
520
596
  response = await readSnapshot();
521
597
  } catch (readError) {
522
598
  if (deadlineError(readError)) return expired();
523
- throw readError;
599
+ if (readError.code !== WORKSPACE_UNASSIGNED) throw readError;
600
+ await pause();
601
+ continue;
524
602
  }
525
603
  }
526
604
  const poll = response?.polls?.find((item) => item.thread?.id === threadId && item.thread?.hostId === "local");
@@ -533,6 +611,10 @@ export class DesktopTaskDelivery {
533
611
  if (this.resolveResponse && responseObservation?.watermark?.status === "available" && responseObservation.operation !== "create_thread") {
534
612
  const observed = await observe(null, true);
535
613
  if (!observed) return expired();
614
+ if (observed === unassigned) {
615
+ await pause();
616
+ continue;
617
+ }
536
618
  turnId = observed.turnId ?? null;
537
619
  if (observed.status !== "pending") {
538
620
  return {
@@ -559,6 +641,10 @@ export class DesktopTaskDelivery {
559
641
  if (RELEASE_STATUSES.has(status)) {
560
642
  const observed = await observe(turn.id);
561
643
  if (!observed) return expired();
644
+ if (observed === unassigned) {
645
+ await pause();
646
+ continue;
647
+ }
562
648
  const responseStatus = observed.status;
563
649
  if (existingTurn && !observed.dispatchObserved && !["completed", "completed_no_reply"].includes(responseStatus)) {
564
650
  turnId = null;