@minhspark/codex-mcp-bridge 1.12.1 → 1.12.2

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 CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](https://semver.org/).
4
4
 
5
+ ## [1.12.2] - 2026-09-05
6
+
7
+ ### Fixed
8
+
9
+ - **Use the native protocol measured by Seb in [#23](https://github.com/buidangminh23/codex-mcp-bridge/issues/23#issuecomment-5547163688).** The companion uses the native pipe inherited from Codex Desktop, separately from MCP stdio. Dispatch is `tools/call` with distinct executor and destination thread IDs, fresh call/turn IDs, and a UInt32LE length prefix. A response must explicitly report `success: true`. The companion recovers when a configured native pipe appears after startup.
10
+ - **Do not kill other delegations when one finishes.** Automatic handoff unsubscribes only the completed thread. It confirms unload before opening Desktop, reports pending idle unload honestly, and fails safely on servers without `thread/unsubscribe`. Explicit shared-server shutdown remains a separate destructive tool.
11
+ - **Keep RPC and turn state consistent across failures.** Failed sends settle their pending promise, concurrent calls wait for initialization, and disconnects clean up listeners and requests. Same-thread transactions are serialized while other threads remain independent; timed-out or disconnected turns require state reconciliation before another turn starts.
12
+ - **Prevent duplicate or misattributed messages.** A native request with an unconfirmed outcome is never retried through another backend, and invalid messages cannot bypass validation through fallback. Claude requests are queued per destination; unresolved earlier replies block new waited sends instead of assigning a late reply to the wrong request. Fire-and-forget and late responses remain accessible through the inbox.
13
+ - **Preserve Unicode across fragmented pipe traffic.** Both relay protocols and Claude peer messages retain complete UTF-8 characters when stream chunks split a character.
14
+ - **Validate the workspace actually used.** Relative input becomes absolute, regular files are refused as directories, and a created thread must report the requested workspace within allowed roots. A path map cannot disguise an unexpected server-created cwd.
15
+ - **List loaded threads correctly.** The bridge reads the IDs returned by `thread/loaded/list`, applies workspace/title filters, and follows pagination to find matching threads.
16
+ - **Keep diagnostics and installer settings truthful.** The Desktop installer honors both enabling and revoking `CODEX_BRIDGE_AUTO_APPROVE_ACK`. Check commands fail on MCP tool errors, and the live continuity smoke test requires two completed turns to preserve a unique codeword.
17
+
18
+ ### Tests
19
+
20
+ - Added regressions for connection and turn lifecycle, authorization, concurrent handoffs, late replies, fragmented/malformed native frames, missing/late native pipes, fallback boundaries, configuration preservation, and diagnostic exit status. Native wire tests run a real MCP child against isolated mock pipes without sending messages to user sessions.
21
+
5
22
  ## [1.12.1] - 2026-09-05
6
23
 
7
24
  ### Fixed
package/README.md CHANGED
@@ -33,7 +33,7 @@ claude-bridge ───────┤ named pipe / unix socket
33
33
  - The app-server is a **singleton per port**. The bridge probes `http://127.0.0.1:8791/readyz`; if nothing answers it spawns a detached `codex app-server --listen ws://127.0.0.1:8791`, which keeps running after the bridge exits.
34
34
  - Every client pointed at the same URL shares **one app-server**, so `thread/resume` with a `threadId` rejoins the running thread instead of opening a new session.
35
35
  - The bridge keeps exactly one WebSocket, calls `initialize` once, and routes notifications by `threadId`, so parallel threads never bleed into each other.
36
- - `delegate_to_codex` is the one-call Claude → Codex hand-off: it starts the thread at the supplied `cwd`, names it, sends the prompt, stops the bridge app-server after a terminal turn, and opens `codex://threads/<id>` in Codex Desktop when enabled.
36
+ - `delegate_to_codex` starts the thread at the supplied `cwd`, names it, sends the prompt, and unsubscribes that thread after a terminal turn. It opens `codex://threads/<id>` only after unload is confirmed; other threads on the shared app-server keep running.
37
37
  - The **native relay** (Windows/macOS, optional) is the third line: a thread the human is watching in Codex Desktop belongs to the app, and a second app-server cannot write to it. Instead of taking the thread away, `claude-bridge` hands the message to a companion the app itself launched, and the app delivers it. See [Codex Desktop native relay](#codex-desktop-native-relay).
38
38
 
39
39
  ## Requirements
@@ -235,7 +235,7 @@ Optional, and only worth installing if you keep the bound thread **open in Codex
235
235
  node scripts/install-native-relay.mjs
236
236
  ```
237
237
 
238
- This registers the companion with Codex (`codex mcp add codex-native-relay -- <node> src/native-relay-companion.mjs`) and bootstraps the executor thread the native dispatch needs, writing its id to `~/.codex/native-relay.json`. The bootstrap starts a throwaway app-server, creates one thread, and stops the app-server again so nothing is left holding a lock.
238
+ This registers the companion with Codex (`codex mcp add codex-native-relay -- <node> src/native-relay-companion.mjs`) and bootstraps the executor thread the native dispatch needs, writing its id to `~/.codex/native-relay.json`. The bootstrap creates one thread, unsubscribes it, and closes its connection. It leaves other clients and threads running; an idle server may retain the executor until its configured unload delay expires.
239
239
 
240
240
  ```bash
241
241
  codex mcp get codex-native-relay
@@ -308,6 +308,10 @@ On macOS and Linux the `codex` launcher is a Node script with a `#!/usr/bin/env
308
308
 
309
309
  `delegate_to_codex` accepts `cwd`, `prompt`, an optional `name`, `timeoutSec` (default 240), `model`, `effort`, `openInApp`, and `releaseAfterTurn`. `send_to_codex_thread` accepts the same hand-off controls plus an existing `threadId`. On Windows, the installer defaults `openInApp` and `releaseAfterTurn` to `1`; explicit tool arguments override them. A timeout does **not** cancel the turn: the bridge returns what it collected plus the `turnId`; keep reading with `read_codex_thread` or stop it with `interrupt_codex_turn`.
310
310
 
311
+ `releaseAfterTurn` uses `thread/unsubscribe`, never an automatic process stop. The server may keep a thread loaded during its idle grace period or while another client subscribes. The bridge reports that state and defers Desktop opening rather than claiming the writer lock is released. Older servers without `thread/unsubscribe` return an explicit release failure. `stop_codex_app_server` remains a separate, destructive operation that affects every client on that endpoint.
312
+
313
+ Concurrent sends to the same Codex thread are serialized; different threads can run in parallel. After a timeout or disconnect, the bridge checks unresolved turn state before allowing another turn. `loadedOnly` resolves the server's thread IDs and searches subsequent pages before applying workspace and title filters.
314
+
311
315
  For the normal Claude → Codex workflow, Claude should call `delegate_to_codex` with the exact project directory in `cwd`. The response always includes the Codex `threadId`, visible session `name`, exact `cwd`, rollout path, and the desktop deep link or the reason it could not be opened. The bridge sets the protocol-supported `thread/name/set` before the first turn, so the session is not an unnamed entry in Recents.
312
316
 
313
317
  Thread operations are checked before the bridge attaches, and `CODEX_BRIDGE_THREAD_POLICY` decides what counts as permission:
@@ -334,6 +338,8 @@ Under `owned`, a thread a human opened is **unreachable rather than merely restr
334
338
 
335
339
  Every tool declares MCP annotation hints (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`), because a client decides whether a call needs a human in the loop from those hints and a missing one reads as "unknown". Two are worth naming: `read_claude_inbox` empties the inbox as it reads it, so it is **not** read-only despite the name, and `claude_bridge_status` registers the peer endpoint on first call, so it writes too.
336
340
 
341
+ Waited sends to the same Claude session run in order. If an earlier send timed out or used `waitSec: 0` and its reply has not arrived, a new waited send returns `PEER_REPLY_PENDING` before delivering another message. Wait for the earlier reply and inspect `read_claude_inbox`, or use `waitSec: 0` if another asynchronous message is intended. Different destination sessions remain independent.
342
+
337
343
  ### How each side sees the other
338
344
 
339
345
  - **Claude sees Codex:** `claude-bridge` registers itself as a *peer session* under `~/.claude/sessions/`. Claude lists it with `ListAgents` and messages it with `SendMessage` — not hidden, not an invisible background process. The default name is `codex-<pid>`; after `bind_codex_thread` it renames itself to `codex-<first 8 chars of threadId>`, which is what makes several bridges distinguishable (Codex starts **one bridge per session**, so a few peers usually advertise at once).
@@ -416,15 +422,21 @@ Resolution order is `CODEX_RELAY_ID` → that file → an error naming both. Nev
416
422
  | `CODEX_BRIDGE_NATIVE_RELAY` is not `0` | switched off by hand |
417
423
  | the Windows named pipe or macOS unix socket exists | the companion is not installed, or Codex Desktop is not running |
418
424
 
419
- A companion that cannot be reached falls back to the app-server path, because an absent relay says nothing about the target thread. A companion that **answered with a refusal** does not: Codex has already been asked, and a second app-server would only contend for the `~/.codex` state and then fail on the very writer lock this backend exists to avoid.
425
+ A companion that cannot be reached before sending falls back to the app-server path. 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.
426
+
427
+ Codex Desktop supplies `CODEX_APP_TOOLS_PIPE_PATH` to its companion. The companion keeps that native connection separate from MCP stdio. Without the inherited native pipe it does not advertise a working relay endpoint; if a configured pipe is late during startup, it retries in the background.
420
428
 
421
429
  > ⚠️ `codex_app.send_message_to_thread` and the native tools pipe are **Codex Desktop internals with no public documentation**, on the same footing as the Claude peer protocol above. That is why the relay is Windows/macOS, feature-detected, optional and fallback-safe. If Codex changes it, the two places to fix are `NATIVE_DISPATCH_METHOD` and `nativeDispatchParams()` in `src/native-relay.mjs`; `CODEX_NATIVE_RELAY_METHOD` overrides the method name without a release. The request the companion sends is:
422
430
  >
423
431
  > ```json
424
- > {"jsonrpc":"2.0","id":1,"method":"codex_app.send_message_to_thread",
425
- > "params":{"executorThreadId":"<relay thread>","threadId":"<destination>","message":"..."}}
432
+ > {"jsonrpc":"2.0","id":1,"method":"tools/call",
433
+ > "params":{"arguments":{"threadId":"<destination>","prompt":"..."},
434
+ > "callId":"<unique call id>","namespace":"codex_app","threadId":"<relay thread>",
435
+ > "tool":"send_message_to_thread","turnId":"<unique turn id>"}}
426
436
  > ```
427
437
 
438
+ The native pipe uses a 4-byte UInt32LE payload length followed by UTF-8 JSON-RPC, and delivery requires `response.result.success === true`. `callId` and `turnId` are fresh for each dispatch. The local bridge-to-companion protocol remains NDJSON. Both transports preserve Unicode across arbitrary stream chunk boundaries. This wire format follows [Seb's measured prototype in issue #23](https://github.com/buidangminh23/codex-mcp-bridge/issues/23#issuecomment-5547163688).
439
+
428
440
  **Security.** On macOS/Linux the relay socket is mode `0600` inside `~/.codex`; Windows uses the Claude-compatible local named-pipe namespace. Anything able to open the relay can put text into a Codex thread, so the endpoint is feature-detected and the companion accepts exactly one shape, `{ targetThreadId, message }`, caps a frame at 128 KiB, and refuses a destination that is its own executor thread — otherwise a mistaken bind would deliver into the invisible relay thread and report success.
429
441
 
430
442
  ### Caveats
@@ -437,6 +449,8 @@ A companion that cannot be reached falls back to the app-server path, because an
437
449
 
438
450
  ## Troubleshooting
439
451
 
452
+ **A turn fails with "model requires a newer version of Codex" after upgrading Desktop.** An app-server already listening at `CODEX_APP_SERVER_URL` keeps running its original executable. Resolving a newer `CODEX_EXE` does not replace that process. Check the listening process and its executable version, then restart that server with the current Codex binary when its active work has finished. An isolated server on another local port can verify the upgrade without interrupting shared work.
453
+
440
454
  **`readyz` never returns 200 after restarting the app-server.** The log says `failed to initialize sqlite state runtime under ~/.codex`. Cause: an older app-server is still alive and holding the sqlite state of `~/.codex` — only **one** process may hold it. Hard kills (`pkill -9`) or repeated `launchctl kickstart -k` leave zombies that `pkill -f "app-server --listen ws://…"` misses, because the process name is the vendored binary path.
441
455
 
442
456
  ```bash
@@ -454,7 +468,7 @@ python3 -c "import json;[print(v['properties']['method'].get('const') or v['prop
454
468
 
455
469
  **The bridge disappears from Claude after sending into a busy thread.** Fixed in 1.6.0. A rejected `turn/start` — which is exactly what a thread locked by the desktop app produces — also rejected an internal promise nothing was awaiting. Node treats that as an unhandled rejection and, by default, exits the process, so the MCP server died while the tool handler was still formatting a tidy error message for a client that no longer had a server. Pinned by a test that runs the failure in a real child process and asserts it exits 0.
456
470
 
457
- **The Codex app says a thread is "open in another application".** That is the per-thread writer lock, and the other application is usually this bridge: the shared app-server takes the lock when it loads a thread and keeps it until it exits, so the desktop app cannot write to the same thread. `delegate_to_codex` releases the bridge server before opening the final desktop link when `releaseAfterTurn` is enabled. For an existing thread, pass `releaseAfterTurn: true` or call `stop_codex_app_server` once the hand-off is done the bridge starts a new app-server the next time it needs one. A thread held by a *different* Codex window is the app's own lock; close it there. If what you want is for Codex Desktop to **keep** the thread while Claude messages into it, that is what the [native relay](#codex-desktop-native-relay) is for it never asks for the lock.
471
+ **The Codex app says a thread is "open in another application".** The app-server holds a writer lock while the thread is loaded. With `releaseAfterTurn: true`, the bridge unsubscribes after completion; unload can still wait for the server's idle delay or another subscriber. Open the thread after it unloads. Use `stop_codex_app_server` only when all work on that shared server may be stopped. A thread held by a different Codex window must be released there. To keep a thread open in Codex Desktop while Claude messages into it, use the [native relay](#codex-desktop-native-relay), which never takes a second writer lock.
458
472
 
459
473
  **`claude_bridge_status` says the delivery backend is `app-server` on Windows or macOS with the relay installed.** The `delivery:` line carries the reason: *"no companion socket at …"* means Codex Desktop has not launched the companion, so restart the app after `install-native-relay.mjs` and check `codex mcp get codex-native-relay`; *"disabled by CODEX_BRIDGE_NATIVE_RELAY=0"* means it was switched off in the MCP server's `env`; *"unavailable on Linux"* means the native relay is not supported there. A relay that is reachable but has no executor thread fails at send time instead, with `RELAY_THREAD_UNCONFIGURED` naming both `CODEX_RELAY_ID` and the file to bootstrap.
460
474
 
@@ -492,12 +506,12 @@ The bridge reads these from the environment its MCP client hands it — there is
492
506
  | `CODEX_BRIDGE_MODEL` | from `~/.codex/config.toml` | Default model for threads and turns the bridge creates, e.g. `gpt-5.6-luna`. |
493
507
  | `CODEX_BRIDGE_EFFORT` | from `~/.codex/config.toml` | Default reasoning effort: `minimal` · `low` · `medium` · `high` · `xhigh` · `ultra`. |
494
508
  | `CODEX_BRIDGE_OPEN_IN_APP` | `1` on Windows, `0` elsewhere | Open delegated or sent threads through the `codex://threads/<id>` desktop link. |
495
- | `CODEX_BRIDGE_RELEASE_AFTER_TURN` | `1` on Windows, `0` elsewhere | Stop the shared bridge app-server after a terminal turn so Codex Desktop can write the handed-off thread. |
509
+ | `CODEX_BRIDGE_RELEASE_AFTER_TURN` | `1` on Windows, `0` elsewhere | Unsubscribe the completed thread without stopping other work; defer Desktop opening until unload is confirmed. |
496
510
  | `CODEX_BRIDGE_NATIVE_RELAY` | `auto` | Delivery backend for relayed Claude messages. `auto` uses the Codex Desktop native relay on Windows/macOS when its companion endpoint exists; `0` never does; `1` attempts it on any platform. |
497
511
  | `CODEX_RELAY_ID` | from `~/.codex/native-relay.json` | Executor thread for `codex_app.send_message_to_thread`. Not the destination — see [Codex Desktop native relay](#codex-desktop-native-relay). |
498
512
  | `CODEX_HOME` | `~/.codex` | Where `native-relay.json` lives; POSIX relay sockets also live here, while Windows uses a named pipe. |
499
513
  | `CODEX_NATIVE_RELAY_SOCKET` | Windows named pipe or `$CODEX_HOME/native-relay.sock` on macOS | Override the companion endpoint on both halves of the relay. |
500
- | `CODEX_NATIVE_RELAY_METHOD` | `codex_app.send_message_to_thread` | The undocumented Codex Desktop method the companion dispatches through; override it if Codex renames it. |
514
+ | `CODEX_NATIVE_RELAY_METHOD` | `tools/call` | The undocumented Codex Desktop JSON-RPC method the companion dispatches through; override only for a verified protocol change. |
501
515
  | `CODEX_NATIVE_RELAY_NAME` | `codex-native-relay` | The MCP server name `scripts/install-native-relay.mjs` registers with Codex. |
502
516
  | `CLAUDE_BRIDGE_PEER_NAME` | `codex-<pid>` | The name Claude shows for this bridge in its agent list. |
503
517
  | `CLAUDE_BRIDGE_CWD` | the process cwd | The working directory the peer advertises. |
@@ -539,6 +553,8 @@ Runs the whole suite with `node --test`. It needs no Codex install, no login and
539
553
  | `test/tool-contract.test.mjs` | all three servers boot over stdio and every tool declares a title, a description, per-parameter descriptions and complete annotation hints |
540
554
  | `test/server-requests.test.mjs` | all 10 app-server requests get a reply in the shape their schema declares — the regression test for "the turn pauses itself" |
541
555
  | `test/reconnect.test.mjs` | reconnect after a dropped socket, no leaked pending requests or listeners, an interrupted turn ending promptly, a refused first handshake being retried |
556
+ | `test/app-server-lifecycle.test.mjs` | concurrent initialization, failed sends, per-thread serialization and unsubscribe, and reconciliation of turns after reconnect |
557
+ | `test/bridge-integration.test.mjs` | real MCP children against isolated app-servers: workspace authorization, loaded-thread pagination, diagnostic exit status, and concurrent handoffs |
542
558
  | `test/turn.test.mjs` | the turn state machine: buffered notifications, terminal statuses, timeout, disconnect, retryable vs fatal errors, and that a failed `turn/start` cannot kill the process |
543
559
  | `test/peer-protocol.test.mjs` | frame round-trips, the session registry, transcript scanning, and a live peer endpoint over a real unix socket |
544
560
  | `test/platform.test.mjs` | binary resolution, the PATH handed to child processes, per-OS config paths and cwd remapping |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.12.1",
3
+ "version": "1.12.2",
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",
@@ -54,7 +54,7 @@
54
54
  "uninstall:relay": "node scripts/install-native-relay.mjs --remove",
55
55
  "install:agent": "node scripts/install-launch-agent.mjs",
56
56
  "uninstall:agent": "node scripts/install-launch-agent.mjs --uninstall",
57
- "version": "node scripts/sync-version.mjs && git add src/index.mjs src/claude-bridge.mjs src/native-relay-companion.mjs"
57
+ "version": "node scripts/sync-version.mjs && git add src/index.mjs src/claude-bridge.mjs src/native-relay-companion.mjs scripts/install-native-relay.mjs"
58
58
  },
59
59
  "engines": {
60
60
  "node": ">=22"
@@ -10,28 +10,32 @@ const transport = new StdioClientTransport({
10
10
  stderr: "inherit",
11
11
  });
12
12
  const client = new Client({ name: "claude-bridge-check", version: "1.0.0" });
13
- await client.connect(transport);
13
+ try {
14
+ await client.connect(transport);
14
15
 
15
- const tools = await client.listTools();
16
- console.log("tools:", tools.tools.map((t) => t.name).join(", "));
16
+ const tools = await client.listTools();
17
+ console.log("tools:", tools.tools.map((t) => t.name).join(", "));
17
18
 
18
- const status = await client.callTool({ name: "claude_bridge_status", arguments: {} });
19
- console.log("\n--- claude_bridge_status ---\n" + status.content[0].text);
19
+ const status = await client.callTool({ name: "claude_bridge_status", arguments: {} });
20
+ console.log("\n--- claude_bridge_status ---\n" + status.content[0].text);
21
+ if (status.isError) throw new Error("Claude bridge status failed");
20
22
 
21
- const listed = await client.callTool({ name: "list_claude_sessions", arguments: {} });
22
- console.log("\n--- list_claude_sessions ---\n" + listed.content[0].text);
23
+ const listed = await client.callTool({ name: "list_claude_sessions", arguments: {} });
24
+ console.log("\n--- list_claude_sessions ---\n" + listed.content[0].text);
25
+ if (listed.isError) throw new Error("Claude session listing failed");
23
26
 
24
- const target = process.env.CLAUDE_TARGET;
25
- if (target) {
26
- const message = process.env.CLAUDE_MESSAGE ?? "Ping from the Codex side of the bridge.";
27
- const waitSec = Number(process.env.CLAUDE_WAIT ?? 180);
28
- console.log(`\nsending to "${target}" (waiting ${waitSec}s)...`);
29
- const sent = await client.callTool({
30
- name: "send_to_claude_session",
31
- arguments: { target, message, waitSec },
32
- });
33
- console.log("\n--- send_to_claude_session ---\n" + sent.content[0].text);
27
+ const target = process.env.CLAUDE_TARGET;
28
+ if (target) {
29
+ const message = process.env.CLAUDE_MESSAGE ?? "Ping from the Codex side of the bridge.";
30
+ const waitSec = Number(process.env.CLAUDE_WAIT ?? 180);
31
+ console.log(`\nsending to "${target}" (waiting ${waitSec}s)...`);
32
+ const sent = await client.callTool({
33
+ name: "send_to_claude_session",
34
+ arguments: { target, message, waitSec },
35
+ });
36
+ console.log("\n--- send_to_claude_session ---\n" + sent.content[0].text);
37
+ if (sent.isError) throw new Error("Claude message delivery failed");
38
+ }
39
+ } finally {
40
+ await client.close();
34
41
  }
35
-
36
- await client.close();
37
- process.exit(0);
package/scripts/check.mjs CHANGED
@@ -34,10 +34,16 @@ const transport = new StdioClientTransport({
34
34
  });
35
35
  const client = new Client({ name: "bridge-check", version: "1.0.0" });
36
36
 
37
- await client.connect(transport);
38
- const tools = await client.listTools();
39
- console.log("tools:", tools.tools.map((t) => t.name).join(", "));
40
- const listed = await client.callTool({ name: "list_codex_threads", arguments: { limit: 3 } });
41
- console.log(listed.content[0].text.slice(0, 600));
42
- await client.close();
43
- process.exit(0);
37
+ try {
38
+ await client.connect(transport);
39
+ const tools = await client.listTools();
40
+ console.log("tools:", tools.tools.map((t) => t.name).join(", "));
41
+ const listed = await client.callTool({ name: "list_codex_threads", arguments: { limit: 3 } });
42
+ console.log(listed.content[0].text.slice(0, 600));
43
+ if (listed.isError) process.exitCode = 1;
44
+ } catch (err) {
45
+ console.error(err.message);
46
+ process.exitCode = 1;
47
+ } finally {
48
+ await client.close();
49
+ }
@@ -72,6 +72,7 @@ cfg.mcpServers["codex-bridge"] = {
72
72
  CODEX_APP_SERVER_URL: settled("CODEX_APP_SERVER_URL", "ws://127.0.0.1:8791"),
73
73
  CODEX_BRIDGE_ALLOWED_ROOTS: settled("CODEX_BRIDGE_ALLOWED_ROOTS", defaultRoots),
74
74
  CODEX_BRIDGE_APPROVAL: settled("CODEX_BRIDGE_APPROVAL", "deny"),
75
+ CODEX_BRIDGE_AUTO_APPROVE_ACK: settled("CODEX_BRIDGE_AUTO_APPROVE_ACK", "0"),
75
76
  CODEX_BRIDGE_APPROVAL_POLICY: settled("CODEX_BRIDGE_APPROVAL_POLICY", "on-request"),
76
77
  /**
77
78
  * Written out even at its default so it is visible in the file. Left
@@ -8,17 +8,7 @@ import { CodexAppServerClient } from "../src/app-server-client.mjs";
8
8
  import { bootstrapRelayThread, readRelayConfig, relayConfigPath, relaySocketPath } from "../src/native-relay.mjs";
9
9
  import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir, resolveCodexBin, spawnEnv } from "../src/platform.mjs";
10
10
 
11
- /**
12
- * Installs the Codex Desktop native relay: registers the companion as an MCP
13
- * server so Codex Desktop launches it, and bootstraps the executor thread the
14
- * native dispatch needs.
15
- *
16
- * The bootstrap is the one step that has to take a writer lock, and it takes it
17
- * on a thread that belongs to nobody: a dedicated relay thread, created through
18
- * an ordinary app-server which is then stopped so the lock is released. After
19
- * this runs, no part of the relay ever attaches a thread again.
20
- */
21
-
11
+ const VERSION = "1.12.2";
22
12
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
23
13
  const entry = path.join(root, "src", "native-relay-companion.mjs");
24
14
  const serverName = process.env.CODEX_NATIVE_RELAY_NAME ?? "codex-native-relay";
@@ -76,22 +66,17 @@ if (existing) {
76
66
  console.log(`\nskipped the relay thread bootstrap; set CODEX_RELAY_ID or rerun without --no-bootstrap.`);
77
67
  } else {
78
68
  const client = new CodexAppServerClient({
79
- clientInfo: { name: "native-relay-install", title: "Native Relay Install", version: "1.12.1" },
69
+ clientInfo: { name: "native-relay-install", title: "Native Relay Install", version: VERSION },
80
70
  log: (msg) => console.log(` ${msg}`),
81
71
  });
82
72
  console.log("\nbootstrapping the relay executor thread...");
83
73
  try {
84
- const { threadId, configPath } = await bootstrapRelayThread(client, { cwd: homeDir() });
74
+ const { threadId, configPath, release } = await bootstrapRelayThread(client, { cwd: homeDir() });
85
75
  console.log(`relay thread: ${threadId}`);
86
76
  console.log(`written to: ${configPath}`);
77
+ console.log(release.released ? "released the bootstrap thread" : `bootstrap thread release pending: ${release.reason ?? release.status}`);
87
78
  } finally {
88
- /**
89
- * The bootstrap thread must not stay locked by this app-server: leaving it
90
- * held would reintroduce, for the relay's own thread, exactly the writer
91
- * conflict the relay exists to remove.
92
- */
93
- const stopped = await client.stopServer();
94
- console.log(stopped.stopped ? "released the bootstrap app-server" : `app-server not stopped: ${stopped.reason}`);
79
+ await client.close();
95
80
  }
96
81
  }
97
82
 
package/scripts/smoke.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import { randomUUID } from "node:crypto";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import path from "node:path";
5
6
 
6
7
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7
8
  const cwdForTest = process.env.SMOKE_CWD ?? root;
8
-
9
+ const codeword = "BRIDGE-" + randomUUID();
9
10
  const transport = new StdioClientTransport({
10
11
  command: process.execPath,
11
12
  args: [path.join(root, "src", "index.mjs")],
@@ -13,38 +14,40 @@ const transport = new StdioClientTransport({
13
14
  stderr: "inherit",
14
15
  });
15
16
  const client = new Client({ name: "smoke", version: "1.0.0" });
16
- await client.connect(transport);
17
-
18
- const tools = await client.listTools();
19
- console.log("TOOLS:", tools.tools.map((t) => t.name).join(", "));
20
-
21
- const listed = await client.callTool({ name: "list_codex_threads", arguments: { limit: 3 } });
22
- console.log("\n--- list_codex_threads ---\n" + listed.content[0].text.slice(0, 900));
23
-
24
- const created = await client.callTool({
25
- name: "start_codex_thread",
26
- arguments: { cwd: cwdForTest },
27
- });
28
- console.log("\n--- start_codex_thread ---\n" + created.content[0].text);
29
- const threadId = created.content[0].text.match(/threadId: ([0-9a-f-]+)/)?.[1];
30
- if (!threadId) throw new Error("could not parse threadId");
31
-
32
- const first = await client.callTool({
33
- name: "send_to_codex_thread",
34
- arguments: { threadId, prompt: "Remember the codeword PAPAYA. Reply with only: SAVED", timeoutSec: 180 },
35
- });
36
- console.log("\n--- send #1 ---\n" + first.content[0].text);
37
-
38
- const second = await client.callTool({
39
- name: "send_to_codex_thread",
40
- arguments: { threadId, prompt: "What codeword did I give you? Reply with only that word.", timeoutSec: 180 },
41
- });
42
- console.log("\n--- send #2 (same thread, must recall) ---\n" + second.content[0].text);
43
-
44
- const read = await client.callTool({ name: "read_codex_thread", arguments: { threadId, limit: 6 } });
45
- console.log("\n--- read_codex_thread ---\n" + read.content[0].text.slice(0, 900));
46
17
 
47
- const ok = /PAPAYA/i.test(second.content[0].text);
48
- console.log("\nRESULT:", ok ? "PASS - thread continuity works" : "FAIL - Codex did not recall the codeword");
49
- await client.close();
50
- process.exit(ok ? 0 : 1);
18
+ async function call(name, args) {
19
+ const result = await client.callTool({ name, arguments: args }, undefined, { timeout: 240000 });
20
+ const text = result.content.map(item => item.text ?? "").join("\n");
21
+ if (result.isError) throw new Error(text);
22
+ return text;
23
+ }
24
+
25
+ try {
26
+ await client.connect(transport);
27
+ const tools = await client.listTools();
28
+ console.log("TOOLS:", tools.tools.map(tool => tool.name).join(", "));
29
+ const created = await call("start_codex_thread", { cwd: cwdForTest, name: "Bridge continuity smoke test" });
30
+ console.log(created);
31
+ const threadId = created.match(/threadId: ([0-9a-f-]+)/)?.[1];
32
+ if (!threadId) throw new Error("could not parse threadId");
33
+ const options = { threadId, timeoutSec: 180, openInApp: false, releaseAfterTurn: false };
34
+ const first = await call("send_to_codex_thread", {
35
+ ...options,
36
+ prompt: "This is a transport smoke test. Do not run tools, commands, or change files. Remember this exact codeword: " + codeword + ". Reply with only: SAVED",
37
+ });
38
+ if (!/status: completed/.test(first) || first.split("--- Codex reply ---").at(-1).trim() !== "SAVED") {
39
+ throw new Error("The first smoke turn did not complete with SAVED: " + first);
40
+ }
41
+ const second = await call("send_to_codex_thread", {
42
+ ...options,
43
+ prompt: "What exact codeword did I give you? Do not use tools. Reply with only that codeword.",
44
+ });
45
+ if (!/status: completed/.test(second) || second.split("--- Codex reply ---").at(-1).trim() !== codeword) {
46
+ throw new Error("Codex did not recall the per-run codeword: " + second);
47
+ }
48
+ const read = await call("read_codex_thread", { threadId, limit: 6 });
49
+ if (!read.includes(codeword)) throw new Error("The saved thread did not contain the smoke reply");
50
+ console.log("RESULT: PASS - two completed turns preserved a unique codeword and its transcript");
51
+ } finally {
52
+ await client.close();
53
+ }
@@ -19,6 +19,7 @@ const entries = [
19
19
  path.join("src", "index.mjs"),
20
20
  path.join("src", "claude-bridge.mjs"),
21
21
  path.join("src", "native-relay-companion.mjs"),
22
+ path.join("scripts", "install-native-relay.mjs"),
22
23
  ];
23
24
  const { version } = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
24
25