@minhspark/codex-mcp-bridge 1.11.2 → 1.12.0
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 +72 -1
- package/README.md +98 -9
- package/package.json +7 -3
- package/scripts/install-native-relay.mjs +100 -0
- package/scripts/sync-version.mjs +5 -1
- package/src/claude-bridge.mjs +19 -16
- package/src/index.mjs +1 -1
- package/src/native-relay-companion.mjs +337 -0
- package/src/native-relay.mjs +319 -0
- package/src/peer-protocol.mjs +20 -5
- package/src/thread-delivery.mjs +71 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,10 +2,81 @@
|
|
|
2
2
|
|
|
3
3
|
Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](https://semver.org/).
|
|
4
4
|
|
|
5
|
-
## [
|
|
5
|
+
## [1.12.0] - 2026-09-04
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **A thread open in Codex Desktop can now receive Claude's messages without being taken away from the app.**
|
|
10
|
+
Binding a thread with `bind_codex_thread` and then watching it in Codex Desktop was the workflow the relay
|
|
11
|
+
was built for, and it was the one case that could not work: Codex takes a per-thread writer lock when the
|
|
12
|
+
app loads a thread and holds it while the thread is open, so the relay's `thread/resume` was refused with
|
|
13
|
+
`thread <id> already has an active writer`. The only way through was to close the thread before every
|
|
14
|
+
message - which gives up the reason the thread was bound in the first place.
|
|
15
|
+
|
|
16
|
+
The fix is to stop bringing a second writer. `codex-native-relay` is a companion MCP process that Codex
|
|
17
|
+
Desktop launches itself, so it already sits inside the app's context and can ask the app's own app-server
|
|
18
|
+
to deliver the message. Nothing attaches, nothing resumes, no second app-server starts, and the desktop app
|
|
19
|
+
stays the single writer of the thread throughout. `claude-bridge` reaches it over a private mode-`0600`
|
|
20
|
+
unix socket at `~/.codex/native-relay.sock` and sends exactly `{ targetThreadId, message }`.
|
|
21
|
+
|
|
22
|
+
`codex_app.send_message_to_thread` runs against an *executor* thread, distinct from the destination and
|
|
23
|
+
validated by Codex - a synthetic UUID is rejected. A dedicated relay thread carries that role so the watched
|
|
24
|
+
thread never has to: `scripts/install-native-relay.mjs` creates it once, records it in
|
|
25
|
+
`~/.codex/native-relay.json`, and stops the app-server it borrowed so no lock is left held. Resolution is
|
|
26
|
+
`CODEX_RELAY_ID`, then that file, then an explicit error naming both - never an invented id, which Codex
|
|
27
|
+
would reject with a message that says nothing about the missing configuration behind it.
|
|
28
|
+
|
|
29
|
+
- Delivery is now a **backend choice** rather than a call, in `src/thread-delivery.mjs`. Nothing else moves:
|
|
30
|
+
the Claude peer protocol, `list_claude_sessions`, `send_to_claude_session`, `bind_codex_thread`, the routing,
|
|
31
|
+
the ping-pong limits, `codex-mcp-bridge`, `CodexAppServerClient` and the thread authorization policies are
|
|
32
|
+
unchanged, and the app-server path stays the default for every thread Codex Desktop does not own. The native
|
|
33
|
+
path is macOS-only, feature-detected on the companion socket, and switched off entirely with
|
|
34
|
+
`CODEX_BRIDGE_NATIVE_RELAY=0`.
|
|
35
|
+
|
|
36
|
+
An unreachable companion falls back to the app-server path, because an absent relay says nothing about the
|
|
37
|
+
target thread. A companion that answered with a *refusal* does not: Codex has already been asked, and a
|
|
38
|
+
second app-server would only contend for the `~/.codex` state before failing on the very writer lock the
|
|
39
|
+
native path exists to avoid.
|
|
40
|
+
|
|
41
|
+
- `claude_bridge_status` and `bind_codex_thread` report the backend in force, and carry the reason when it is
|
|
42
|
+
not the native one - a missing companion socket, an explicit `0`, and an unsupported platform are three
|
|
43
|
+
different problems that otherwise look identical from the outside.
|
|
44
|
+
|
|
45
|
+
- `native_relay_status` on the companion reports its socket, its executor thread and the dispatch method.
|
|
46
|
+
`npm run install:relay` / `npm run uninstall:relay` register and remove it.
|
|
47
|
+
|
|
48
|
+
### Security
|
|
49
|
+
|
|
50
|
+
- The relay socket is created mode `0600` inside the Codex home and swept on exit; that file mode is the whole
|
|
51
|
+
boundary, exactly as it already is for the Claude peer protocol. The companion accepts one payload shape,
|
|
52
|
+
caps a frame at 128 KiB on both halves, and refuses a destination that is its own executor thread - otherwise
|
|
53
|
+
a mistaken bind would deliver into the invisible relay thread and report success. A socket already held by a
|
|
54
|
+
live companion is never stolen: an in-use path is probed before any leftover from a killed process is swept.
|
|
55
|
+
|
|
56
|
+
### Notes
|
|
57
|
+
|
|
58
|
+
- `codex_app.send_message_to_thread` and the Codex Desktop native tools pipe are **internals with no public
|
|
59
|
+
documentation**, on the same footing as the Claude peer protocol in `src/peer-protocol.mjs`. That is why this
|
|
60
|
+
path is optional, feature-detected and fallback-safe rather than the default. If Codex changes it, the two
|
|
61
|
+
places to fix are `NATIVE_DISPATCH_METHOD` and `nativeDispatchParams()` in `src/native-relay.mjs`, and
|
|
62
|
+
`CODEX_NATIVE_RELAY_METHOD` overrides the method name without a release.
|
|
63
|
+
|
|
64
|
+
## [1.11.3] - 2026-09-03
|
|
6
65
|
|
|
7
66
|
### Fixed
|
|
8
67
|
|
|
68
|
+
- **The peer endpoint never started on Windows, so the bridge was one-way there.** `/tmp/cc-socks` has no
|
|
69
|
+
Windows equivalent, and `path.join` rewrote it to `\tmp\cc-socks` on the system drive, where `listen()`
|
|
70
|
+
fails with `EACCES`. Codex could push a message into a live Claude session, but Claude had no address to
|
|
71
|
+
answer on - and the only sign was one line on stderr saying replies could not be received, which nothing
|
|
72
|
+
surfaces once the server is running under an MCP client. The peer now listens on a named pipe on Windows,
|
|
73
|
+
the same transport Claude Code itself advertises in `~/.claude/sessions/<pid>.json`, and skips the
|
|
74
|
+
directory, mode and unlink steps that a pipe does not have.
|
|
75
|
+
|
|
76
|
+
- `fast-uri` is bumped to 3.1.7 and `qs` to 6.16.0. `fast-uri` 3.0.0-3.1.5 is vulnerable to host confusion
|
|
77
|
+
and server-side request forgery through repeated hostname percent-decoding (CVE-2026-75899, high); it
|
|
78
|
+
reaches the tree transitively through `@modelcontextprotocol/sdk` -> `ajv`.
|
|
79
|
+
|
|
9
80
|
- **A tag is not a release, and nothing was creating the release.** Pushing `v1.10.1`, `v1.11.0` and
|
|
10
81
|
`v1.11.1` published all three to npm, while the Releases page still showed `v1.10.0` as *Latest* -
|
|
11
82
|
the one release that had been created by hand. Anyone reading the repository saw a project that had
|
package/README.md
CHANGED
|
@@ -24,12 +24,17 @@ Codex ───────────────┤ stdio
|
|
|
24
24
|
└────────────────────────────────────────────────────────────────────┘
|
|
25
25
|
|
|
26
26
|
Codex TUI ──codex --remote ws://127.0.0.1:8791──> same app-server, same live thread
|
|
27
|
+
|
|
28
|
+
┌──── codex-native-relay (launched by Codex Desktop, macOS) ──────────┐
|
|
29
|
+
claude-bridge ───────┤ unix socket ~/.codex/native-relay.sock native tools ├──> the thread already open in Codex Desktop
|
|
30
|
+
└────────────────────────────────────────────────────────────────────┘
|
|
27
31
|
```
|
|
28
32
|
|
|
29
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.
|
|
30
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.
|
|
31
35
|
- The bridge keeps exactly one WebSocket, calls `initialize` once, and routes notifications by `threadId`, so parallel threads never bleed into each other.
|
|
32
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.
|
|
37
|
+
- The **native relay** (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-macos).
|
|
33
38
|
|
|
34
39
|
## Requirements
|
|
35
40
|
|
|
@@ -222,7 +227,25 @@ launchctl kickstart -k gui/$UID/com.codex-mcp-bridge.app-server
|
|
|
222
227
|
|
|
223
228
|
`CLAUDE_BRIDGE_PEER_NAME` sets the name Claude shows for this bridge in its agent list.
|
|
224
229
|
|
|
225
|
-
### 5. macOS only:
|
|
230
|
+
### 5. macOS only: the Codex Desktop native relay
|
|
231
|
+
|
|
232
|
+
Optional, and only worth installing if you keep the bound thread **open in Codex Desktop** while Claude messages back. See [Codex Desktop native relay](#codex-desktop-native-relay-macos) for what it does and why.
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
node scripts/install-native-relay.mjs
|
|
236
|
+
```
|
|
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.
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
codex mcp get codex-native-relay
|
|
242
|
+
node scripts/install-native-relay.mjs --no-bootstrap # register only; supply CODEX_RELAY_ID yourself
|
|
243
|
+
node scripts/install-native-relay.mjs --remove # unregister, and delete the socket and the thread id
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Restart Codex Desktop so it launches the companion, then call its `native_relay_status` tool — or `claude_bridge_status`, whose `delivery:` line names the backend in force. Until both the companion and the executor thread are in place, `claude-bridge` keeps using the app-server path exactly as before.
|
|
247
|
+
|
|
248
|
+
### 6. macOS only: keep the app-server alive with launchd
|
|
226
249
|
|
|
227
250
|
> ⚠️ **Do not enable the LaunchAgent while using the Codex desktop app.** The app runs its **own** stdio app-server against the **same** `~/.codex` sqlite state. Two app-servers contend even while idle — measured here: the launchd one burned ~11% CPU doing nothing and **the Codex app UI stuttered**. Keep exactly one alive; `codex_bridge_status` detects and warns about this.
|
|
228
251
|
>
|
|
@@ -240,20 +263,21 @@ tail -f ~/Library/Logs/codex-mcp-bridge/app-server.err.log # logs
|
|
|
240
263
|
node scripts/install-launch-agent.mjs --uninstall # remove
|
|
241
264
|
```
|
|
242
265
|
|
|
243
|
-
###
|
|
266
|
+
### 7. Verify the install
|
|
244
267
|
|
|
245
268
|
```bash
|
|
246
269
|
npm run check # boots the bridge, autostarts an app-server, lists threads
|
|
247
270
|
npm run check:claude # lists the live Claude Code sessions Codex can reach
|
|
248
271
|
```
|
|
249
272
|
|
|
250
|
-
From inside Claude, call the `codex_bridge_status` tool; from inside Codex, call `claude_bridge_status`. Both print the resolved binary, the endpoint and whether anything is listening.
|
|
273
|
+
From inside Claude, call the `codex_bridge_status` tool; from inside Codex, call `claude_bridge_status`. Both print the resolved binary, the endpoint and whether anything is listening. `claude_bridge_status` also prints a `delivery:` line naming the backend that would carry a relayed message, and the reason when it is not the native one.
|
|
251
274
|
|
|
252
|
-
###
|
|
275
|
+
### 8. Uninstall everything
|
|
253
276
|
|
|
254
277
|
```bash
|
|
255
278
|
claude mcp remove codex-bridge --scope user
|
|
256
279
|
node scripts/install-codex-mcp.mjs --remove
|
|
280
|
+
node scripts/install-native-relay.mjs --remove
|
|
257
281
|
node scripts/install-launch-agent.mjs --uninstall
|
|
258
282
|
```
|
|
259
283
|
|
|
@@ -334,6 +358,14 @@ There is no token in the frame — **the socket is mode `0600`, so owning the us
|
|
|
334
358
|
|
|
335
359
|
The relay has two hard limits in `src/claude-bridge.mjs`: at most **one message every 5s** and **50 per bridge run**. Two agents left talking to each other unattended still come to a stop.
|
|
336
360
|
|
|
361
|
+
## Tools — `codex-native-relay` (launched by Codex Desktop, macOS)
|
|
362
|
+
|
|
363
|
+
| Tool | What it does | Hints |
|
|
364
|
+
|---|---|---|
|
|
365
|
+
| `native_relay_status` | Reports the local socket the companion listens on, the executor thread it dispatches through, and the dispatch method in force. | read-only |
|
|
366
|
+
|
|
367
|
+
The companion carries no work of its own. Its job is the socket and the dispatch; everything a human asks for still goes through the two bridges above.
|
|
368
|
+
|
|
337
369
|
## macOS notes
|
|
338
370
|
|
|
339
371
|
### Watch a thread in the Codex desktop app
|
|
@@ -347,10 +379,58 @@ send_to_codex_thread { threadId: "01a0…", prompt: "…", openInApp: true }
|
|
|
347
379
|
|
|
348
380
|
This is how a human watches Codex work in real time instead of reading the rollout at `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` after the fact.
|
|
349
381
|
|
|
382
|
+
### Codex Desktop native relay (macOS)
|
|
383
|
+
|
|
384
|
+
`bind_codex_thread` relays every message Claude sends into a Codex thread. That works — until the thread is one **you are watching in Codex Desktop**, which is the case it was built for. Codex takes a per-thread writer lock when the app loads a thread and holds it for as long as the thread is open, so the app-server path, which has to `thread/resume` before it can send, is refused:
|
|
385
|
+
|
|
386
|
+
```
|
|
387
|
+
thread <id> already has an active writer
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Closing the thread first is not a fix. It is the opposite of the point: Codex Desktop is meant to stay the permanent owner of the thread and the surface the human is looking at.
|
|
391
|
+
|
|
392
|
+
The native relay removes the second writer rather than fighting it. A companion MCP process, **launched by Codex Desktop's own app-server**, already sits inside the app's context, so it can ask that app-server to deliver the message. Nothing attaches, nothing resumes, no second app-server starts, and the lock never changes hands:
|
|
393
|
+
|
|
394
|
+
```
|
|
395
|
+
Claude
|
|
396
|
+
→ claude-bridge
|
|
397
|
+
→ ~/.codex/native-relay.sock (unix socket, mode 0600)
|
|
398
|
+
→ codex-native-relay (launched by Codex Desktop's app-server)
|
|
399
|
+
→ codex_app.send_message_to_thread (over that same connection)
|
|
400
|
+
→ the thread already open in Codex Desktop
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
**The executor thread.** `codex_app.send_message_to_thread` runs against an executor thread, and that thread is not the destination — it is validated, so a synthetic UUID is rejected with `NATIVE_DISPATCH_FAILED`. A dedicated relay thread keeps that requirement away from the thread you are watching. It is created once by `scripts/install-native-relay.mjs` and recorded in `~/.codex/native-relay.json`:
|
|
404
|
+
|
|
405
|
+
```json
|
|
406
|
+
{ "relayThreadId": "<uuid>" }
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
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.
|
|
410
|
+
|
|
411
|
+
**It is a backend, not a replacement.** `claude-bridge` picks between two delivery backends and reports which one it used; the tools, the peer protocol, the routing, the rate limits and `CodexAppServerClient` are untouched. The native path is used only when all of these hold — otherwise the app-server path runs exactly as it did before:
|
|
412
|
+
|
|
413
|
+
| Condition | Otherwise |
|
|
414
|
+
|---|---|
|
|
415
|
+
| macOS | the app-server path (`CODEX_BRIDGE_NATIVE_RELAY=1` forces the attempt anyway) |
|
|
416
|
+
| `CODEX_BRIDGE_NATIVE_RELAY` is not `0` | switched off by hand |
|
|
417
|
+
| `~/.codex/native-relay.sock` exists and is a socket | the companion is not installed, or Codex Desktop is not running |
|
|
418
|
+
|
|
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.
|
|
420
|
+
|
|
421
|
+
> ⚠️ `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 macOS-only, 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
|
+
>
|
|
423
|
+
> ```json
|
|
424
|
+
> {"jsonrpc":"2.0","id":1,"method":"codex_app.send_message_to_thread",
|
|
425
|
+
> "params":{"executorThreadId":"<relay thread>","threadId":"<destination>","message":"..."}}
|
|
426
|
+
> ```
|
|
427
|
+
|
|
428
|
+
**Security.** The socket is mode `0600` inside `~/.codex`, and that file mode is the entire boundary — the same one the Claude peer protocol relies on. Anything able to open it can put text into a Codex thread, so it is created private and swept on exit. 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
|
+
|
|
350
430
|
### Caveats
|
|
351
431
|
|
|
352
|
-
- The Codex desktop app runs its own app-server over stdio (`ChatGPT.app/Contents/Resources/codex … app-server`, **no** `--listen`), so nothing external can attach to it. `~/.codex/ipc/ipc.sock` is the Electron app's internal IPC, not an app-server. Threads opened there can still be driven through the bridge, but by resuming from the rollout `.jsonl` rather than attaching live.
|
|
353
|
-
- **A thread currently open in the desktop app cannot be written to** — Codex holds a per-thread writer lock (`~/.codex/thread-writer-locks/`) and returns `thread <id> already has an active writer`. That error is the guard working, not data loss. Check `status` with `list_codex_threads` first and only send when it is `idle` or `notLoaded` and not open in the app.
|
|
432
|
+
- The Codex desktop app runs its own app-server over stdio (`ChatGPT.app/Contents/Resources/codex … app-server`, **no** `--listen`), so nothing external can attach to it. `~/.codex/ipc/ipc.sock` is the Electron app's internal IPC, not an app-server. Threads opened there can still be driven through the bridge, but by resuming from the rollout `.jsonl` rather than attaching live. The [native relay](#codex-desktop-native-relay-macos) is not an exception to this: the companion never attaches to that app-server, it is *launched by* it as one of the app's own MCP servers.
|
|
433
|
+
- **A thread currently open in the desktop app cannot be written to** through a second app-server — Codex holds a per-thread writer lock (`~/.codex/thread-writer-locks/`) and returns `thread <id> already has an active writer`. That error is the guard working, not data loss. Check `status` with `list_codex_threads` first and only send when it is `idle` or `notLoaded` and not open in the app. For the Claude → Codex relay specifically, the [native relay](#codex-desktop-native-relay-macos) removes the second writer instead of waiting for the lock.
|
|
354
434
|
- **Bridge-created threads are named before they are opened.** The bridge calls the app-server's `thread/name/set` with the requested title, or derives `[project] first line of prompt`, then opens the exact `codex://threads/<id>` link. This gives Codex Desktop a visible session title and preserves the precise `cwd` in the thread metadata.
|
|
355
435
|
- 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.
|
|
356
436
|
- `codex app-server daemon start` uses the `unix://` transport with a control socket at `~/.codex/app-server-control/app-server-control.sock`. The bridge does **not** use that path (different framing, no public API) — it always talks over `ws://`.
|
|
@@ -374,7 +454,9 @@ python3 -c "import json;[print(v['properties']['method'].get('const') or v['prop
|
|
|
374
454
|
|
|
375
455
|
**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.
|
|
376
456
|
|
|
377
|
-
**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.
|
|
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-macos) is for — it never asks for the lock.
|
|
458
|
+
|
|
459
|
+
**`claude_bridge_status` says the delivery backend is `app-server` on a Mac with the relay installed.** The `delivery:` line carries the reason, and there are only three. *"no companion socket at …"* — Codex Desktop has not launched the companion: restart the app after `install-native-relay.mjs`, and check `codex mcp get codex-native-relay`. *"disabled by CODEX_BRIDGE_NATIVE_RELAY=0"* — it was switched off in the MCP server's `env`. *"macOS-only"* — the bridge is not running where the Codex Desktop app is; the app-server path is the correct answer 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.
|
|
378
460
|
|
|
379
461
|
**A thread opens against the wrong directory.** The same project sits at a different absolute path on each machine: on the shared drive's letter under Windows, under its mount point when that drive is visible from macOS (**read-only** there), and in a native checkout otherwise. Since 1.4.0 the bridge picks the candidate that both **exists and is writable** on the current machine and prints a `note: cwd remapped …` line whenever it rewrites one. If nothing usable exists it fails immediately instead of opening a thread somewhere wrong. Handing Codex a read-only cwd is a reliable way to hit the freeze above: it runs a few reads, then asks for write permission and stalls.
|
|
380
462
|
|
|
@@ -411,6 +493,12 @@ The bridge reads these from the environment its MCP client hands it — there is
|
|
|
411
493
|
| `CODEX_BRIDGE_EFFORT` | from `~/.codex/config.toml` | Default reasoning effort: `minimal` · `low` · `medium` · `high` · `xhigh` · `ultra`. |
|
|
412
494
|
| `CODEX_BRIDGE_OPEN_IN_APP` | `1` on Windows, `0` elsewhere | Open delegated or sent threads through the `codex://threads/<id>` desktop link. |
|
|
413
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. |
|
|
496
|
+
| `CODEX_BRIDGE_NATIVE_RELAY` | `auto` | Delivery backend for relayed Claude messages. `auto` uses the Codex Desktop native relay on macOS when the companion socket exists; `0` never does; `1` attempts it on any platform. |
|
|
497
|
+
| `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-macos). |
|
|
498
|
+
| `CODEX_HOME` | `~/.codex` | Where the relay socket and `native-relay.json` live. |
|
|
499
|
+
| `CODEX_NATIVE_RELAY_SOCKET` | `$CODEX_HOME/native-relay.sock` | Override the companion's socket path 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. |
|
|
501
|
+
| `CODEX_NATIVE_RELAY_NAME` | `codex-native-relay` | The MCP server name `scripts/install-native-relay.mjs` registers with Codex. |
|
|
414
502
|
| `CLAUDE_BRIDGE_PEER_NAME` | `codex-<pid>` | The name Claude shows for this bridge in its agent list. |
|
|
415
503
|
| `CLAUDE_BRIDGE_CWD` | the process cwd | The working directory the peer advertises. |
|
|
416
504
|
| `CLAUDE_DESKTOP_CONFIG` | auto-detected | Override the config path used by `install-claude-desktop.mjs`. |
|
|
@@ -448,15 +536,16 @@ Runs the whole suite with `node --test`. It needs no Codex install, no login and
|
|
|
448
536
|
|
|
449
537
|
| File | Covers |
|
|
450
538
|
|---|---|
|
|
451
|
-
| `test/tool-contract.test.mjs` |
|
|
539
|
+
| `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 |
|
|
452
540
|
| `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" |
|
|
453
541
|
| `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 |
|
|
454
542
|
| `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 |
|
|
455
543
|
| `test/peer-protocol.test.mjs` | frame round-trips, the session registry, transcript scanning, and a live peer endpoint over a real unix socket |
|
|
456
544
|
| `test/platform.test.mjs` | binary resolution, the PATH handed to child processes, per-OS config paths and cwd remapping |
|
|
545
|
+
| `test/native-relay.test.mjs` | the Codex Desktop relay: executor thread resolution, feature detection, socket round trips over a real unix socket, reclaiming a socket a killed companion left behind, backend selection and when it may fall back, and the companion answering a real MCP client that plays Codex Desktop |
|
|
457
546
|
| `test/repo-hygiene.test.mjs` | no environment file or build output is ever tracked, versions do not drift, documentation stays in English |
|
|
458
547
|
|
|
459
|
-
GitHub Actions runs the same command on every push and pull request, across Node 22 and 24 on Linux, macOS and Windows (`.github/workflows/ci.yml`). The Codex → Claude direction
|
|
548
|
+
GitHub Actions runs the same command on every push and pull request, across Node 22 and 24 on Linux, macOS and Windows (`.github/workflows/ci.yml`). The Codex → Claude direction and the native relay both need unix sockets, so those tests skip on Windows; the rest of the suite runs there like anywhere else.
|
|
460
549
|
|
|
461
550
|
Two checks need a real Codex and are not part of `npm test`:
|
|
462
551
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@minhspark/codex-mcp-bridge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
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",
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
"codex-mcp-bridge": "src/index.mjs",
|
|
26
26
|
"claude-mcp-bridge": "src/claude-bridge.mjs",
|
|
27
27
|
"codex-mcp-bridge-install": "scripts/install-claude-desktop.mjs",
|
|
28
|
-
"claude-mcp-bridge-install": "scripts/install-codex-mcp.mjs"
|
|
28
|
+
"claude-mcp-bridge-install": "scripts/install-codex-mcp.mjs",
|
|
29
|
+
"codex-native-relay": "src/native-relay-companion.mjs",
|
|
30
|
+
"codex-native-relay-install": "scripts/install-native-relay.mjs"
|
|
29
31
|
},
|
|
30
32
|
"files": [
|
|
31
33
|
"src",
|
|
@@ -48,9 +50,11 @@
|
|
|
48
50
|
"install:desktop": "node scripts/install-claude-desktop.mjs",
|
|
49
51
|
"install:codex": "node scripts/install-codex-mcp.mjs",
|
|
50
52
|
"uninstall:codex": "node scripts/install-codex-mcp.mjs --remove",
|
|
53
|
+
"install:relay": "node scripts/install-native-relay.mjs",
|
|
54
|
+
"uninstall:relay": "node scripts/install-native-relay.mjs --remove",
|
|
51
55
|
"install:agent": "node scripts/install-launch-agent.mjs",
|
|
52
56
|
"uninstall:agent": "node scripts/install-launch-agent.mjs --uninstall",
|
|
53
|
-
"version": "node scripts/sync-version.mjs && git add src/index.mjs src/claude-bridge.mjs"
|
|
57
|
+
"version": "node scripts/sync-version.mjs && git add src/index.mjs src/claude-bridge.mjs src/native-relay-companion.mjs"
|
|
54
58
|
},
|
|
55
59
|
"engines": {
|
|
56
60
|
"node": ">=22"
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import { CodexAppServerClient } from "../src/app-server-client.mjs";
|
|
8
|
+
import { bootstrapRelayThread, readRelayConfig, relayConfigPath, relaySocketPath } from "../src/native-relay.mjs";
|
|
9
|
+
import { IS_MACOS, PLATFORM_LABEL, homeDir, resolveCodexBin, spawnEnv } from "../src/platform.mjs";
|
|
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
|
+
|
|
22
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
|
+
const entry = path.join(root, "src", "native-relay-companion.mjs");
|
|
24
|
+
const serverName = process.env.CODEX_NATIVE_RELAY_NAME ?? "codex-native-relay";
|
|
25
|
+
const remove = process.argv.includes("--remove");
|
|
26
|
+
const skipBootstrap = process.argv.includes("--no-bootstrap");
|
|
27
|
+
|
|
28
|
+
const codexBin = resolveCodexBin(process.env.CODEX_EXE);
|
|
29
|
+
const run = (args) => execFileSync(codexBin, args, { env: spawnEnv(), stdio: "pipe" }).toString().trim();
|
|
30
|
+
|
|
31
|
+
if (!fs.existsSync(entry)) throw new Error(`companion entry point missing: ${entry}`);
|
|
32
|
+
if (!path.isAbsolute(codexBin) || !fs.existsSync(codexBin)) {
|
|
33
|
+
throw new Error(`codex binary not found (resolved to "${codexBin}"). Set CODEX_EXE to its absolute path.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (remove) {
|
|
37
|
+
try {
|
|
38
|
+
console.log(run(["mcp", "remove", serverName]) || `removed ${serverName}`);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.log(`${serverName} was not registered (${err.message.trim().split("\n").at(-1)})`);
|
|
41
|
+
}
|
|
42
|
+
for (const leftover of [relaySocketPath(), relayConfigPath()]) {
|
|
43
|
+
if (fs.existsSync(leftover)) {
|
|
44
|
+
fs.rmSync(leftover, { force: true });
|
|
45
|
+
console.log(`removed ${leftover}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Not a hard failure: the companion is harmless on any platform - it simply
|
|
53
|
+
* never gets a native tools connection to dispatch through - and refusing to
|
|
54
|
+
* register it would make the install order depend on which machine runs it.
|
|
55
|
+
*/
|
|
56
|
+
if (!IS_MACOS) {
|
|
57
|
+
console.log(`note: the native relay only delivers on macOS; this is ${PLATFORM_LABEL}.`);
|
|
58
|
+
console.log("claude-bridge will keep using the app-server path here.");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
run(["mcp", "remove", serverName]);
|
|
63
|
+
} catch {
|
|
64
|
+
// not registered yet
|
|
65
|
+
}
|
|
66
|
+
run(["mcp", "add", serverName, "--", process.execPath, entry]);
|
|
67
|
+
|
|
68
|
+
console.log(`platform: ${PLATFORM_LABEL}`);
|
|
69
|
+
console.log(`registered MCP server "${serverName}" with Codex:`);
|
|
70
|
+
console.log(run(["mcp", "get", serverName]));
|
|
71
|
+
|
|
72
|
+
const existing = readRelayConfig()?.relayThreadId;
|
|
73
|
+
if (existing) {
|
|
74
|
+
console.log(`\nrelay thread already bootstrapped: ${existing} (${relayConfigPath()})`);
|
|
75
|
+
} else if (skipBootstrap) {
|
|
76
|
+
console.log(`\nskipped the relay thread bootstrap; set CODEX_RELAY_ID or rerun without --no-bootstrap.`);
|
|
77
|
+
} else {
|
|
78
|
+
const client = new CodexAppServerClient({
|
|
79
|
+
clientInfo: { name: "native-relay-install", title: "Native Relay Install", version: "1.11.3" },
|
|
80
|
+
log: (msg) => console.log(` ${msg}`),
|
|
81
|
+
});
|
|
82
|
+
console.log("\nbootstrapping the relay executor thread...");
|
|
83
|
+
try {
|
|
84
|
+
const { threadId, configPath } = await bootstrapRelayThread(client, { cwd: homeDir() });
|
|
85
|
+
console.log(`relay thread: ${threadId}`);
|
|
86
|
+
console.log(`written to: ${configPath}`);
|
|
87
|
+
} 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}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log(`\nrelay socket: ${relaySocketPath()}`);
|
|
99
|
+
console.log("Restart Codex Desktop so it launches the companion, then check with native_relay_status.");
|
|
100
|
+
console.log("remove: node scripts/install-native-relay.mjs --remove");
|
package/scripts/sync-version.mjs
CHANGED
|
@@ -15,7 +15,11 @@ import { fileURLToPath } from "node:url";
|
|
|
15
15
|
* claude-bridge sat at 1.3.0 while the package shipped 1.10.0.
|
|
16
16
|
*/
|
|
17
17
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
18
|
-
const entries = [
|
|
18
|
+
const entries = [
|
|
19
|
+
path.join("src", "index.mjs"),
|
|
20
|
+
path.join("src", "claude-bridge.mjs"),
|
|
21
|
+
path.join("src", "native-relay-companion.mjs"),
|
|
22
|
+
];
|
|
19
23
|
const { version } = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
20
24
|
|
|
21
25
|
for (const entry of entries) {
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -6,9 +6,9 @@ import { z } from "zod";
|
|
|
6
6
|
import { CodexAppServerClient } from "./app-server-client.mjs";
|
|
7
7
|
import { PLATFORM_LABEL } from "./platform.mjs";
|
|
8
8
|
import { PeerEndpoint, findClaudeSession, listClaudeSessions, readTranscript } from "./peer-protocol.mjs";
|
|
9
|
-
import {
|
|
9
|
+
import { createThreadDelivery } from "./thread-delivery.mjs";
|
|
10
10
|
|
|
11
|
-
const VERSION = "1.
|
|
11
|
+
const VERSION = "1.12.0";
|
|
12
12
|
const FORWARD_MIN_INTERVAL_MS = 5000;
|
|
13
13
|
const FORWARD_MAX_PER_SESSION = 50;
|
|
14
14
|
|
|
@@ -27,6 +27,14 @@ const codex = new CodexAppServerClient({
|
|
|
27
27
|
log,
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Delivery is a backend choice, not a call: a thread the human is watching in
|
|
32
|
+
* Codex Desktop is written through the desktop's own app-server, and every
|
|
33
|
+
* other thread through the shared one. `claude-bridge` never picks between
|
|
34
|
+
* them - see `thread-delivery.mjs`.
|
|
35
|
+
*/
|
|
36
|
+
const delivery = createThreadDelivery({ codex, log });
|
|
37
|
+
|
|
30
38
|
const forwarding = {
|
|
31
39
|
threadId: process.env.CODEX_THREAD_ID ?? null,
|
|
32
40
|
lastAt: 0,
|
|
@@ -64,18 +72,11 @@ async function forwardToCodexThread(record) {
|
|
|
64
72
|
forwarding.lastAt = now;
|
|
65
73
|
forwarding.count += 1;
|
|
66
74
|
try {
|
|
67
|
-
await
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
type: "text",
|
|
73
|
-
text: `[message from Claude session ${record.fromSocket ?? "?"}]\n\n${record.text}`,
|
|
74
|
-
},
|
|
75
|
-
],
|
|
76
|
-
timeoutMs: 240000,
|
|
77
|
-
});
|
|
78
|
-
log(`forwarded a Claude message into thread ${forwarding.threadId}`);
|
|
75
|
+
const { backend } = await delivery.deliver(
|
|
76
|
+
forwarding.threadId,
|
|
77
|
+
`[message from Claude session ${record.fromSocket ?? "?"}]\n\n${record.text}`,
|
|
78
|
+
);
|
|
79
|
+
log(`forwarded a Claude message into thread ${forwarding.threadId} via ${backend}`);
|
|
79
80
|
} catch (err) {
|
|
80
81
|
log(`forward failed: ${err.message}`);
|
|
81
82
|
}
|
|
@@ -245,7 +246,8 @@ server.registerTool(
|
|
|
245
246
|
title: "Relay Claude messages into a Codex thread",
|
|
246
247
|
description:
|
|
247
248
|
"Bind a Codex thread so every message Claude pushes to this bridge is relayed into that thread, where it " +
|
|
248
|
-
"shows up in the Codex desktop app.
|
|
249
|
+
"shows up in the Codex desktop app. On macOS a thread already open in Codex Desktop is written through " +
|
|
250
|
+
"the desktop's own app-server, so it keeps its writer lock and stays open. Pass an empty threadId to stop.",
|
|
249
251
|
inputSchema: {
|
|
250
252
|
threadId: z.string().describe("Codex thread id, or an empty string to unbind"),
|
|
251
253
|
},
|
|
@@ -264,7 +266,7 @@ server.registerTool(
|
|
|
264
266
|
peer.rename(name);
|
|
265
267
|
return textResult(
|
|
266
268
|
trimmed
|
|
267
|
-
? `Relaying Claude messages into Codex thread ${trimmed} (max ${FORWARD_MAX_PER_SESSION} per bridge run, at most one every ${FORWARD_MIN_INTERVAL_MS / 1000}s).\nClaude now sees this bridge as "${name}".`
|
|
269
|
+
? `Relaying Claude messages into Codex thread ${trimmed} (max ${FORWARD_MAX_PER_SESSION} per bridge run, at most one every ${FORWARD_MIN_INTERVAL_MS / 1000}s).\ndelivery: ${delivery.describe()}\nClaude now sees this bridge as "${name}".`
|
|
268
270
|
: `Relay disabled. Messages stay in the inbox. Claude sees this bridge as "${name}".`,
|
|
269
271
|
);
|
|
270
272
|
},
|
|
@@ -296,6 +298,7 @@ server.registerTool(
|
|
|
296
298
|
`peer socket: ${peer.socketPath}`,
|
|
297
299
|
`live sessions: ${sessions.length}`,
|
|
298
300
|
`relay thread: ${forwarding.threadId ?? "(none - use bind_codex_thread)"}`,
|
|
301
|
+
`delivery: ${delivery.describe()}`,
|
|
299
302
|
`inbox: ${peer.inbox.length} pending message(s)`,
|
|
300
303
|
];
|
|
301
304
|
return textResult(lines.join("\n"));
|
package/src/index.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
import { runTurn } from "./turn.mjs";
|
|
22
22
|
import { BridgeSecurityPolicy } from "./security-policy.mjs";
|
|
23
23
|
|
|
24
|
-
const VERSION = "1.
|
|
24
|
+
const VERSION = "1.12.0";
|
|
25
25
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
26
26
|
|
|
27
27
|
/**
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
MAX_FRAME_BYTES,
|
|
13
|
+
NATIVE_DISPATCH_METHOD,
|
|
14
|
+
RELAY_PROTOCOL_VERSION,
|
|
15
|
+
nativeDispatchParams,
|
|
16
|
+
relaySocketPath,
|
|
17
|
+
resolveRelayThreadId,
|
|
18
|
+
} from "./native-relay.mjs";
|
|
19
|
+
import { PLATFORM_LABEL } from "./platform.mjs";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The companion half of the Codex Desktop native relay.
|
|
23
|
+
*
|
|
24
|
+
* Codex Desktop launches this as one of its own MCP servers, so the connection
|
|
25
|
+
* it answers on belongs to the app's real app-server - the one already holding
|
|
26
|
+
* the writer lock of every thread the human has open. Asking that app-server to
|
|
27
|
+
* deliver a message is therefore not a second writer, and the thread stays open
|
|
28
|
+
* and owned by Codex Desktop throughout.
|
|
29
|
+
*
|
|
30
|
+
* Everything else is deliberately small: a private socket, one accepted shape
|
|
31
|
+
* (`{ targetThreadId, message }`), one dispatch, one acknowledgement.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const VERSION = "1.12.0";
|
|
35
|
+
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
36
|
+
|
|
37
|
+
function errorResponse(code, message) {
|
|
38
|
+
return { ok: false, v: RELAY_PROTOCOL_VERSION, error: { code, message } };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A JSON-RPC error arrives with a numeric code, and passing that straight back
|
|
43
|
+
* would put `-32601` in a field whose other values read `RELAY_TIMEOUT`. Only a
|
|
44
|
+
* string code from this project's own errors is carried through.
|
|
45
|
+
*/
|
|
46
|
+
function errorCode(err) {
|
|
47
|
+
return typeof err?.code === "string" ? err.code : "NATIVE_DISPATCH_FAILED";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The whole request handler, kept free of sockets and of the MCP connection so
|
|
52
|
+
* the rules it enforces can be tested against a stub dispatcher rather than
|
|
53
|
+
* against a running Codex Desktop.
|
|
54
|
+
*/
|
|
55
|
+
export async function handleRelayRequest(
|
|
56
|
+
payload,
|
|
57
|
+
{ dispatch, resolveExecutor = resolveRelayThreadId, env = process.env } = {},
|
|
58
|
+
) {
|
|
59
|
+
const targetThreadId = typeof payload?.targetThreadId === "string" ? payload.targetThreadId.trim() : "";
|
|
60
|
+
const message = typeof payload?.message === "string" ? payload.message : "";
|
|
61
|
+
|
|
62
|
+
if (!targetThreadId) return errorResponse("RELAY_BAD_REQUEST", "targetThreadId must be a non-empty string");
|
|
63
|
+
if (!message.trim()) return errorResponse("RELAY_BAD_REQUEST", "message must be a non-empty string");
|
|
64
|
+
|
|
65
|
+
let executorThreadId;
|
|
66
|
+
try {
|
|
67
|
+
executorThreadId = resolveExecutor(env).threadId;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return errorResponse(errorCode(err), err.message);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Codex validates the executor thread, so a destination that is also the
|
|
74
|
+
* executor would be dispatched rather than refused - and the message would
|
|
75
|
+
* land in the relay thread instead of the thread the human is watching.
|
|
76
|
+
* Nothing downstream can tell those two apart afterwards.
|
|
77
|
+
*/
|
|
78
|
+
if (executorThreadId === targetThreadId) {
|
|
79
|
+
return errorResponse(
|
|
80
|
+
"RELAY_BAD_REQUEST",
|
|
81
|
+
`${targetThreadId} is the relay's own executor thread, not a destination. Bind the thread you are watching in Codex Desktop.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const result = await dispatch({ executorThreadId, targetThreadId, message });
|
|
87
|
+
return { ok: true, v: RELAY_PROTOCOL_VERSION, targetThreadId, executorThreadId, result: result ?? null };
|
|
88
|
+
} catch (err) {
|
|
89
|
+
return errorResponse(errorCode(err), err?.message ?? String(err));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Listens on a private unix socket and answers one NDJSON line per request.
|
|
95
|
+
*
|
|
96
|
+
* The socket is mode 0600 inside the Codex home directory, which is the same
|
|
97
|
+
* boundary the Claude peer protocol already relies on: owning the user account
|
|
98
|
+
* is what grants access, and nothing weaker does. Named separately here because
|
|
99
|
+
* this socket can put text into a Codex thread, so the file mode is the
|
|
100
|
+
* security control rather than a detail of the transport.
|
|
101
|
+
*/
|
|
102
|
+
export class RelaySocketServer {
|
|
103
|
+
constructor({
|
|
104
|
+
socketPath,
|
|
105
|
+
dispatch,
|
|
106
|
+
resolveExecutor = resolveRelayThreadId,
|
|
107
|
+
restrictSocket = (target) => fs.chmodSync(target, 0o600),
|
|
108
|
+
log: logFn = () => {},
|
|
109
|
+
} = {}) {
|
|
110
|
+
this.socketPath = socketPath;
|
|
111
|
+
this.dispatch = dispatch;
|
|
112
|
+
this.resolveExecutor = resolveExecutor;
|
|
113
|
+
this.restrictSocket = restrictSocket;
|
|
114
|
+
this.log = logFn;
|
|
115
|
+
this.server = null;
|
|
116
|
+
this.started = false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async start() {
|
|
120
|
+
if (this.started) return this.socketPath;
|
|
121
|
+
fs.mkdirSync(path.dirname(this.socketPath), { recursive: true });
|
|
122
|
+
|
|
123
|
+
this.server = net.createServer((socket) => this.#handleConnection(socket));
|
|
124
|
+
await this.#listen({ replaceStale: true });
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The file mode is the whole security boundary, so a socket whose mode
|
|
128
|
+
* could not be set is not a degraded relay - it is an open one. Refuse it
|
|
129
|
+
* and let the caller fall back to the app-server path, rather than serving
|
|
130
|
+
* thread writes on an address anyone can open.
|
|
131
|
+
*/
|
|
132
|
+
try {
|
|
133
|
+
this.restrictSocket(this.socketPath);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
try {
|
|
136
|
+
this.server.close();
|
|
137
|
+
} catch {}
|
|
138
|
+
throw new Error(`refusing to serve on ${this.socketPath}: its mode could not be restricted (${err.message})`);
|
|
139
|
+
}
|
|
140
|
+
this.started = true;
|
|
141
|
+
|
|
142
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
143
|
+
process.on(signal, () => {
|
|
144
|
+
this.stop();
|
|
145
|
+
process.exit(0);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
process.on("exit", () => this.stop());
|
|
149
|
+
|
|
150
|
+
this.log(`relay socket listening on ${this.socketPath}`);
|
|
151
|
+
return this.socketPath;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A companion killed with SIGKILL leaves its socket file behind, and the next
|
|
156
|
+
* one then fails to bind a path nothing is listening on. Removing it blindly
|
|
157
|
+
* would be worse: Codex Desktop can launch more than one companion, and the
|
|
158
|
+
* second would silently steal the address from the first. So an in-use path
|
|
159
|
+
* is probed - a refused connection means the owner is gone and the file is
|
|
160
|
+
* swept, an accepted one means a live companion already has the socket and
|
|
161
|
+
* this process leaves it alone.
|
|
162
|
+
*/
|
|
163
|
+
async #listen({ replaceStale }) {
|
|
164
|
+
try {
|
|
165
|
+
await new Promise((resolve, reject) => {
|
|
166
|
+
this.server.once("error", reject);
|
|
167
|
+
this.server.listen(this.socketPath, () => {
|
|
168
|
+
this.server.off("error", reject);
|
|
169
|
+
resolve();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if (err.code !== "EADDRINUSE" || !replaceStale) throw err;
|
|
174
|
+
if (await this.#socketIsLive()) {
|
|
175
|
+
throw new Error(`another native relay companion already owns ${this.socketPath}`);
|
|
176
|
+
}
|
|
177
|
+
this.log(`removing the stale relay socket left at ${this.socketPath}`);
|
|
178
|
+
fs.rmSync(this.socketPath, { force: true });
|
|
179
|
+
await this.#listen({ replaceStale: false });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#socketIsLive() {
|
|
184
|
+
return new Promise((resolve) => {
|
|
185
|
+
const probe = net.connect({ path: this.socketPath });
|
|
186
|
+
const timer = globalThis.setTimeout(() => done(false), 1000);
|
|
187
|
+
const done = (answer) => {
|
|
188
|
+
globalThis.clearTimeout(timer);
|
|
189
|
+
probe.destroy();
|
|
190
|
+
resolve(answer);
|
|
191
|
+
};
|
|
192
|
+
probe.on("connect", () => done(true));
|
|
193
|
+
probe.on("error", () => done(false));
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#handleConnection(socket) {
|
|
198
|
+
let buffer = "";
|
|
199
|
+
socket.on("error", (err) => this.log(`relay socket error: ${err.message}`));
|
|
200
|
+
socket.on("data", (chunk) => {
|
|
201
|
+
buffer += chunk.toString("utf8");
|
|
202
|
+
if (Buffer.byteLength(buffer, "utf8") > MAX_FRAME_BYTES) {
|
|
203
|
+
this.#reply(socket, errorResponse("RELAY_MESSAGE_TOO_LARGE", `a relay frame may not exceed ${MAX_FRAME_BYTES} bytes`));
|
|
204
|
+
socket.destroy();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
let index;
|
|
208
|
+
while ((index = buffer.indexOf("\n")) >= 0) {
|
|
209
|
+
const line = buffer.slice(0, index).trim();
|
|
210
|
+
buffer = buffer.slice(index + 1);
|
|
211
|
+
if (!line) continue;
|
|
212
|
+
void this.#handleLine(socket, line);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async #handleLine(socket, line) {
|
|
218
|
+
let payload;
|
|
219
|
+
try {
|
|
220
|
+
payload = JSON.parse(line);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
this.#reply(socket, errorResponse("RELAY_BAD_REQUEST", `malformed JSON: ${err.message}`));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const response = await handleRelayRequest(payload, {
|
|
226
|
+
dispatch: this.dispatch,
|
|
227
|
+
resolveExecutor: this.resolveExecutor,
|
|
228
|
+
});
|
|
229
|
+
if (!response.ok) this.log(`relay refused ${payload?.targetThreadId ?? "?"}: ${response.error.message}`);
|
|
230
|
+
else this.log(`relayed a message into thread ${response.targetThreadId}`);
|
|
231
|
+
this.#reply(socket, response);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#reply(socket, response) {
|
|
235
|
+
if (socket.destroyed) return;
|
|
236
|
+
socket.write(`${JSON.stringify(response)}\n`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
stop() {
|
|
240
|
+
try {
|
|
241
|
+
this.server?.close();
|
|
242
|
+
} catch {}
|
|
243
|
+
try {
|
|
244
|
+
if (this.started) fs.rmSync(this.socketPath, { force: true });
|
|
245
|
+
} catch {}
|
|
246
|
+
this.started = false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* `import.meta.main` is Node 24 and up, and this project supports Node 22, so
|
|
252
|
+
* the entry point is detected by comparing the resolved argv path instead.
|
|
253
|
+
*/
|
|
254
|
+
const invokedDirectly =
|
|
255
|
+
Boolean(process.argv[1]) && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
256
|
+
|
|
257
|
+
if (invokedDirectly) {
|
|
258
|
+
const mcp = new McpServer(
|
|
259
|
+
{ name: "codex-native-relay", version: VERSION },
|
|
260
|
+
{
|
|
261
|
+
instructions:
|
|
262
|
+
"Companion process for the Codex Desktop native relay. It carries no work of its own: it accepts " +
|
|
263
|
+
"messages from claude-bridge on a private local socket and asks the Codex Desktop app-server that " +
|
|
264
|
+
"launched it to deliver them into an already-open thread, so that thread keeps its writer lock.",
|
|
265
|
+
},
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The dispatch goes back over the very connection Codex Desktop opened to
|
|
270
|
+
* launch this process, which is what keeps the app the single writer. Sent as
|
|
271
|
+
* a plain JSON-RPC request rather than through a typed helper because the
|
|
272
|
+
* method is an internal of the app, not part of the MCP specification.
|
|
273
|
+
*/
|
|
274
|
+
const dispatch = ({ executorThreadId, targetThreadId, message }) =>
|
|
275
|
+
mcp.server.request(
|
|
276
|
+
{
|
|
277
|
+
method: process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD,
|
|
278
|
+
params: nativeDispatchParams({ executorThreadId, targetThreadId, message }),
|
|
279
|
+
},
|
|
280
|
+
z.any(),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const relay = new RelaySocketServer({ socketPath: relaySocketPath(), dispatch, log });
|
|
284
|
+
|
|
285
|
+
mcp.registerTool(
|
|
286
|
+
"native_relay_status",
|
|
287
|
+
{
|
|
288
|
+
title: "Check the Codex Desktop native relay",
|
|
289
|
+
description:
|
|
290
|
+
"Report the local socket this companion listens on, the executor thread it dispatches through, and " +
|
|
291
|
+
"whether the relay is ready to deliver messages into threads Codex Desktop has open.",
|
|
292
|
+
inputSchema: {},
|
|
293
|
+
annotations: {
|
|
294
|
+
readOnlyHint: true,
|
|
295
|
+
openWorldHint: false,
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
async () => {
|
|
299
|
+
let executor = "(unconfigured)";
|
|
300
|
+
try {
|
|
301
|
+
const resolved = resolveRelayThreadId();
|
|
302
|
+
executor = `${resolved.threadId} (from ${resolved.source})`;
|
|
303
|
+
} catch (err) {
|
|
304
|
+
executor = err.message;
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
content: [
|
|
308
|
+
{
|
|
309
|
+
type: "text",
|
|
310
|
+
text: [
|
|
311
|
+
`platform: ${PLATFORM_LABEL} (${process.platform}/${process.arch})`,
|
|
312
|
+
`companion: codex-native-relay ${VERSION}`,
|
|
313
|
+
`relay socket: ${relay.started ? relay.socketPath : `${relay.socketPath} (not listening)`}`,
|
|
314
|
+
`executor: ${executor}`,
|
|
315
|
+
`dispatch: ${process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD}`,
|
|
316
|
+
].join("\n"),
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
};
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Never let the socket take the MCP server down. Codex Desktop waits on the
|
|
325
|
+
* `initialize` handshake, so a process that dies before answering reads as a
|
|
326
|
+
* hang rather than an error - the same failure mode `claude-bridge` already
|
|
327
|
+
* guards its peer endpoint against.
|
|
328
|
+
*/
|
|
329
|
+
try {
|
|
330
|
+
await relay.start();
|
|
331
|
+
} catch (err) {
|
|
332
|
+
log(`relay socket unavailable (${err.message}) - claude-bridge will fall back to the app-server path`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
await mcp.connect(new StdioServerTransport());
|
|
336
|
+
log(`ready on ${PLATFORM_LABEL} (${relay.started ? relay.socketPath : "socket down"})`);
|
|
337
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { IS_MACOS, PLATFORM_LABEL, homeDir } from "./platform.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The Codex Desktop app owns the per-thread writer lock of every thread it has
|
|
9
|
+
* open, and it keeps it for as long as the thread is open. Anything that wants
|
|
10
|
+
* to write into such a thread by attaching a second app-server loses: the
|
|
11
|
+
* app-server answers `thread <id> already has an active writer`. Closing the
|
|
12
|
+
* thread first is not an answer either, because the whole point of binding a
|
|
13
|
+
* thread is that the human keeps watching it in Codex Desktop.
|
|
14
|
+
*
|
|
15
|
+
* The way through is to stop bringing a second writer. A companion MCP process
|
|
16
|
+
* launched by Codex Desktop's own app-server already sits inside the app's
|
|
17
|
+
* context, so it can ask that app-server to deliver the message on the app's
|
|
18
|
+
* behalf. No resume, no attach, no second app-server, no lock to fight over.
|
|
19
|
+
*
|
|
20
|
+
* This module is the client half - the part `claude-bridge` talks to. The
|
|
21
|
+
* companion half lives in `native-relay-companion.mjs`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Measured, not documented. `codex_app.send_message_to_thread` is an internal
|
|
26
|
+
* of the Codex Desktop native tools pipe, on the same footing as the Claude
|
|
27
|
+
* peer protocol in `peer-protocol.mjs`: it works today and carries no public
|
|
28
|
+
* contract. When Codex changes it, this constant and `nativeDispatchParams`
|
|
29
|
+
* below are the two places to fix, and `CODEX_NATIVE_RELAY_METHOD` overrides
|
|
30
|
+
* the name without a release.
|
|
31
|
+
*/
|
|
32
|
+
export const NATIVE_DISPATCH_METHOD = "codex_app.send_message_to_thread";
|
|
33
|
+
|
|
34
|
+
export const RELAY_PROTOCOL_VERSION = 1;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A relay frame carries one chat message, so a megabyte-scale line is either a
|
|
38
|
+
* bug or something trying to make the companion buffer without limit. The cap
|
|
39
|
+
* is applied on both halves: the client refuses to send an oversized message,
|
|
40
|
+
* and the companion refuses to accumulate one.
|
|
41
|
+
*/
|
|
42
|
+
export const MAX_FRAME_BYTES = 128 * 1024;
|
|
43
|
+
|
|
44
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
45
|
+
const RELAY_SOCKET_NAME = "native-relay.sock";
|
|
46
|
+
const RELAY_CONFIG_NAME = "native-relay.json";
|
|
47
|
+
|
|
48
|
+
export class NativeRelayError extends Error {
|
|
49
|
+
/**
|
|
50
|
+
* `reachedCompanion` is what decides whether falling back to the app-server
|
|
51
|
+
* path is worth doing. A companion that never answered says nothing about
|
|
52
|
+
* the target thread, so the older path deserves its turn; a companion that
|
|
53
|
+
* answered with a refusal has already asked Codex, and asking again through
|
|
54
|
+
* a second app-server only adds a writer-lock failure on top.
|
|
55
|
+
*/
|
|
56
|
+
constructor(message, code, { reachedCompanion = false } = {}) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = "NativeRelayError";
|
|
59
|
+
this.code = code;
|
|
60
|
+
this.reachedCompanion = reachedCompanion;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function codexHome(env = process.env) {
|
|
65
|
+
return env.CODEX_HOME ?? path.join(homeDir(), ".codex");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function relaySocketPath(env = process.env) {
|
|
69
|
+
return env.CODEX_NATIVE_RELAY_SOCKET ?? path.join(codexHome(env), RELAY_SOCKET_NAME);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function relayConfigPath(env = process.env) {
|
|
73
|
+
return path.join(codexHome(env), RELAY_CONFIG_NAME);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isSocketFile(target) {
|
|
77
|
+
try {
|
|
78
|
+
return fs.statSync(target).isSocket();
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function readRelayConfig(env = process.env) {
|
|
85
|
+
const file = relayConfigPath(env);
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
88
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `codex_app.send_message_to_thread` runs against an executor thread, which has
|
|
96
|
+
* to be a real Codex thread and is not the destination: a synthetic UUID is
|
|
97
|
+
* rejected with `NATIVE_DISPATCH_FAILED`, so the id cannot be invented at call
|
|
98
|
+
* time. A thread dedicated to the relay keeps that requirement off the
|
|
99
|
+
* destination thread, which stays open in Codex Desktop and untouched.
|
|
100
|
+
*
|
|
101
|
+
* The order is deliberate: an explicit `CODEX_RELAY_ID` wins so a single run
|
|
102
|
+
* can be pointed elsewhere without editing state, then the id bootstrapped
|
|
103
|
+
* once into `~/.codex/native-relay.json`, and then an error. Never a guess -
|
|
104
|
+
* an invented executor fails inside Codex with a message that says nothing
|
|
105
|
+
* about the missing configuration that actually caused it.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveRelayThreadId(env = process.env) {
|
|
108
|
+
const explicit = env.CODEX_RELAY_ID?.trim();
|
|
109
|
+
if (explicit) return { threadId: explicit, source: "CODEX_RELAY_ID" };
|
|
110
|
+
|
|
111
|
+
const persisted = readRelayConfig(env)?.relayThreadId;
|
|
112
|
+
if (typeof persisted === "string" && persisted.trim()) {
|
|
113
|
+
return { threadId: persisted.trim(), source: relayConfigPath(env) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
throw new NativeRelayError(
|
|
117
|
+
`No Codex relay thread is configured. Set CODEX_RELAY_ID, or bootstrap one into ${relayConfigPath(env)} ` +
|
|
118
|
+
"with: node scripts/install-native-relay.mjs",
|
|
119
|
+
"RELAY_THREAD_UNCONFIGURED",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Builds the parameters of the native dispatch. Kept apart from the transport
|
|
125
|
+
* so the one shape this project cannot verify against a published schema sits
|
|
126
|
+
* in a single named function with a single test, rather than inline in the
|
|
127
|
+
* middle of a request.
|
|
128
|
+
*/
|
|
129
|
+
export function nativeDispatchParams({ executorThreadId, targetThreadId, message }) {
|
|
130
|
+
return { executorThreadId, threadId: targetThreadId, message };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Whether the native path is usable right now, and when it is not, why. The
|
|
135
|
+
* reason is carried rather than dropped because "the relay did nothing" is the
|
|
136
|
+
* one answer nobody can act on: a missing companion socket, an unsupported
|
|
137
|
+
* platform and an operator switching the backend off all look identical from
|
|
138
|
+
* the outside, and they need three different responses.
|
|
139
|
+
*/
|
|
140
|
+
export function nativeRelayStatus(env = process.env) {
|
|
141
|
+
const mode = (env.CODEX_BRIDGE_NATIVE_RELAY ?? "auto").toLowerCase();
|
|
142
|
+
const socketPath = relaySocketPath(env);
|
|
143
|
+
|
|
144
|
+
if (mode === "0" || mode === "off") {
|
|
145
|
+
return { enabled: false, mode, socketPath, reason: "disabled by CODEX_BRIDGE_NATIVE_RELAY=0" };
|
|
146
|
+
}
|
|
147
|
+
const forced = mode === "1" || mode === "on";
|
|
148
|
+
if (!IS_MACOS && !forced) {
|
|
149
|
+
return {
|
|
150
|
+
enabled: false,
|
|
151
|
+
mode,
|
|
152
|
+
socketPath,
|
|
153
|
+
reason: `the Codex Desktop native relay is macOS-only (this is ${PLATFORM_LABEL})`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (!isSocketFile(socketPath)) {
|
|
157
|
+
return {
|
|
158
|
+
enabled: false,
|
|
159
|
+
mode,
|
|
160
|
+
socketPath,
|
|
161
|
+
reason: `no companion socket at ${socketPath} - is the native relay installed and Codex Desktop running?`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return { enabled: true, mode, socketPath, reason: null };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Speaks one request per connection to the companion: a single NDJSON line out,
|
|
169
|
+
* a single NDJSON line back. Connections are not pooled - a relayed message is
|
|
170
|
+
* a rare event bounded by the bridge's own ping-pong guard, and a short-lived
|
|
171
|
+
* socket cannot go stale while Codex Desktop restarts underneath it.
|
|
172
|
+
*/
|
|
173
|
+
export class NativeDesktopRelay {
|
|
174
|
+
constructor({ env = process.env, socketPath = null, timeoutMs = DEFAULT_TIMEOUT_MS, log = () => {} } = {}) {
|
|
175
|
+
this.env = env;
|
|
176
|
+
this.explicitSocketPath = socketPath;
|
|
177
|
+
this.timeoutMs = timeoutMs;
|
|
178
|
+
this.log = log;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
get socketPath() {
|
|
182
|
+
return this.explicitSocketPath ?? relaySocketPath(this.env);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
status() {
|
|
186
|
+
if (!this.explicitSocketPath) return nativeRelayStatus(this.env);
|
|
187
|
+
const status = nativeRelayStatus({ ...this.env, CODEX_NATIVE_RELAY_SOCKET: this.explicitSocketPath });
|
|
188
|
+
return { ...status, socketPath: this.explicitSocketPath };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
get available() {
|
|
192
|
+
return this.status().enabled;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async sendMessage(targetThreadId, message, { timeoutMs = this.timeoutMs } = {}) {
|
|
196
|
+
const request = { v: RELAY_PROTOCOL_VERSION, targetThreadId, message };
|
|
197
|
+
const line = `${JSON.stringify(request)}\n`;
|
|
198
|
+
if (Buffer.byteLength(line, "utf8") > MAX_FRAME_BYTES) {
|
|
199
|
+
throw new NativeRelayError(
|
|
200
|
+
`Relay message is larger than ${MAX_FRAME_BYTES} bytes; shorten it before relaying.`,
|
|
201
|
+
"RELAY_MESSAGE_TOO_LARGE",
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const response = await this.#roundTrip(line, timeoutMs);
|
|
206
|
+
if (response?.ok) return response;
|
|
207
|
+
throw new NativeRelayError(
|
|
208
|
+
response?.error?.message ?? "the Codex Desktop relay refused the message",
|
|
209
|
+
response?.error?.code ?? "NATIVE_DISPATCH_FAILED",
|
|
210
|
+
{ reachedCompanion: true },
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#roundTrip(line, timeoutMs) {
|
|
215
|
+
const socketPath = this.socketPath;
|
|
216
|
+
return new Promise((resolve, reject) => {
|
|
217
|
+
let buffer = "";
|
|
218
|
+
let settled = false;
|
|
219
|
+
const socket = net.connect({ path: socketPath });
|
|
220
|
+
|
|
221
|
+
const finish = (fn, value) => {
|
|
222
|
+
if (settled) return;
|
|
223
|
+
settled = true;
|
|
224
|
+
globalThis.clearTimeout(timer);
|
|
225
|
+
socket.destroy();
|
|
226
|
+
fn(value);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const timer = globalThis.setTimeout(
|
|
230
|
+
() =>
|
|
231
|
+
finish(
|
|
232
|
+
reject,
|
|
233
|
+
new NativeRelayError(
|
|
234
|
+
`The Codex Desktop relay did not answer within ${timeoutMs}ms`,
|
|
235
|
+
"RELAY_TIMEOUT",
|
|
236
|
+
{ reachedCompanion: true },
|
|
237
|
+
),
|
|
238
|
+
),
|
|
239
|
+
timeoutMs,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
socket.on("connect", () => socket.write(line));
|
|
243
|
+
socket.on("data", (chunk) => {
|
|
244
|
+
buffer += chunk.toString("utf8");
|
|
245
|
+
if (Buffer.byteLength(buffer, "utf8") > MAX_FRAME_BYTES) {
|
|
246
|
+
finish(
|
|
247
|
+
reject,
|
|
248
|
+
new NativeRelayError("The Codex Desktop relay answered with an oversized frame", "RELAY_BAD_RESPONSE", {
|
|
249
|
+
reachedCompanion: true,
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const index = buffer.indexOf("\n");
|
|
255
|
+
if (index < 0) return;
|
|
256
|
+
try {
|
|
257
|
+
finish(resolve, JSON.parse(buffer.slice(0, index)));
|
|
258
|
+
} catch (err) {
|
|
259
|
+
finish(
|
|
260
|
+
reject,
|
|
261
|
+
new NativeRelayError(
|
|
262
|
+
`The Codex Desktop relay answered with malformed JSON: ${err.message}`,
|
|
263
|
+
"RELAY_BAD_RESPONSE",
|
|
264
|
+
{ reachedCompanion: true },
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
socket.on("error", (err) =>
|
|
270
|
+
finish(
|
|
271
|
+
reject,
|
|
272
|
+
new NativeRelayError(`Cannot reach the Codex Desktop relay at ${socketPath}: ${err.message}`, "RELAY_UNREACHABLE"),
|
|
273
|
+
),
|
|
274
|
+
);
|
|
275
|
+
socket.on("close", () =>
|
|
276
|
+
finish(
|
|
277
|
+
reject,
|
|
278
|
+
new NativeRelayError(
|
|
279
|
+
`The Codex Desktop relay at ${socketPath} closed before answering`,
|
|
280
|
+
"RELAY_UNREACHABLE",
|
|
281
|
+
),
|
|
282
|
+
),
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Creates the dedicated executor thread once and remembers it, using the
|
|
290
|
+
* ordinary app-server path - which is allowed to take a writer lock here
|
|
291
|
+
* precisely because this thread belongs to nobody else. The caller stops the
|
|
292
|
+
* app-server afterwards, so the lock is released and Codex Desktop is left
|
|
293
|
+
* with the state to itself.
|
|
294
|
+
*/
|
|
295
|
+
export async function bootstrapRelayThread(client, { cwd = homeDir(), env = process.env, name = "Native Relay" } = {}) {
|
|
296
|
+
const res = await client.call("thread/start", {
|
|
297
|
+
cwd,
|
|
298
|
+
approvalPolicy: "never",
|
|
299
|
+
sandbox: "read-only",
|
|
300
|
+
});
|
|
301
|
+
const threadId = res?.thread?.id;
|
|
302
|
+
if (!threadId) throw new NativeRelayError("Codex app-server created no relay thread id", "RELAY_BOOTSTRAP_FAILED");
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
await client.call("thread/name/set", { threadId, name });
|
|
306
|
+
} catch {
|
|
307
|
+
// A thread without a title still works as an executor context.
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
writeRelayConfig({ relayThreadId: threadId, createdAt: new Date().toISOString() }, env);
|
|
311
|
+
return { threadId, configPath: relayConfigPath(env) };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function writeRelayConfig(config, env = process.env) {
|
|
315
|
+
const file = relayConfigPath(env);
|
|
316
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
317
|
+
fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
318
|
+
return file;
|
|
319
|
+
}
|
package/src/peer-protocol.mjs
CHANGED
|
@@ -5,9 +5,24 @@ import net from "node:net";
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
|
|
8
|
-
import { homeDir } from "./platform.mjs";
|
|
8
|
+
import { homeDir, IS_WINDOWS } from "./platform.mjs";
|
|
9
9
|
|
|
10
10
|
const SOCKET_DIR = "/tmp/cc-socks";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Windows has no /tmp, and path.join rewrites the unix default to "\tmp\cc-socks"
|
|
14
|
+
* on the system drive, where listen() fails with EACCES. The endpoint then never
|
|
15
|
+
* comes up, so Claude has no address to answer on and the bridge is silently
|
|
16
|
+
* one-way: messages reach Claude, replies never come back.
|
|
17
|
+
*
|
|
18
|
+
* Claude Code advertises a named pipe on Windows for exactly this reason, so the
|
|
19
|
+
* peer uses the same transport there. net.connect({ path }) and server.listen()
|
|
20
|
+
* accept a pipe name unchanged, so only creation differs - a pipe has no
|
|
21
|
+
* directory to create, no mode to chmod and no file to unlink beforehand.
|
|
22
|
+
*/
|
|
23
|
+
function peerSocketPath(pid) {
|
|
24
|
+
return IS_WINDOWS ? `\\\\.\\pipe\\LOCAL\\cc-peer-${pid}` : path.join(SOCKET_DIR, `${pid}.sock`);
|
|
25
|
+
}
|
|
11
26
|
const PEER_PROTOCOL_VERSION = 1;
|
|
12
27
|
const CLAUDE_VERSION_HINT = "2.1.229";
|
|
13
28
|
const PS_BIN = "/bin/ps";
|
|
@@ -171,7 +186,7 @@ export class PeerEndpoint {
|
|
|
171
186
|
this.cwd = cwd;
|
|
172
187
|
this.log = log;
|
|
173
188
|
this.pid = process.pid;
|
|
174
|
-
this.socketPath =
|
|
189
|
+
this.socketPath = peerSocketPath(this.pid);
|
|
175
190
|
this.registryPath = path.join(sessionsDir(), `${this.pid}.json`);
|
|
176
191
|
this.keyPath = null;
|
|
177
192
|
this.server = null;
|
|
@@ -219,16 +234,16 @@ export class PeerEndpoint {
|
|
|
219
234
|
const keyHash = crypto.createHash("sha256").update(`${peerToken}${procStart}`).digest("hex");
|
|
220
235
|
this.keyPath = path.join(sessionsDir(), `${this.pid}.${keyHash}.key`);
|
|
221
236
|
|
|
222
|
-
fs.mkdirSync(SOCKET_DIR, { recursive: true });
|
|
237
|
+
if (!IS_WINDOWS) fs.mkdirSync(SOCKET_DIR, { recursive: true });
|
|
223
238
|
fs.mkdirSync(sessionsDir(), { recursive: true });
|
|
224
|
-
if (fs.existsSync(this.socketPath)) fs.rmSync(this.socketPath, { force: true });
|
|
239
|
+
if (!IS_WINDOWS && fs.existsSync(this.socketPath)) fs.rmSync(this.socketPath, { force: true });
|
|
225
240
|
|
|
226
241
|
await new Promise((resolve, reject) => {
|
|
227
242
|
this.server = net.createServer((socket) => this.#handleConnection(socket));
|
|
228
243
|
this.server.on("error", reject);
|
|
229
244
|
this.server.listen(this.socketPath, resolve);
|
|
230
245
|
});
|
|
231
|
-
fs.chmodSync(this.socketPath, 0o600);
|
|
246
|
+
if (!IS_WINDOWS) fs.chmodSync(this.socketPath, 0o600);
|
|
232
247
|
|
|
233
248
|
this.registry = {
|
|
234
249
|
pid: this.pid,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { NativeDesktopRelay } from "./native-relay.mjs";
|
|
2
|
+
import { runTurn } from "./turn.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Which backend puts a message into a Codex thread.
|
|
6
|
+
*
|
|
7
|
+
* There are two, and they are not interchangeable. The app-server path resumes
|
|
8
|
+
* the thread through a second app-server, which takes the per-thread writer
|
|
9
|
+
* lock - correct for a thread nobody else has open, and guaranteed to fail with
|
|
10
|
+
* `thread <id> already has an active writer` for a thread Codex Desktop is
|
|
11
|
+
* showing. The native path asks Codex Desktop's own app-server to deliver the
|
|
12
|
+
* message, so the app stays the single writer and the thread stays open.
|
|
13
|
+
*
|
|
14
|
+
* Naming the choice here rather than branching inside the relay keeps
|
|
15
|
+
* `claude-bridge` unaware of either mechanism: it asks for delivery and is told
|
|
16
|
+
* which backend did it.
|
|
17
|
+
*/
|
|
18
|
+
export const NATIVE_BACKEND = "codex-desktop-native";
|
|
19
|
+
export const APP_SERVER_BACKEND = "app-server";
|
|
20
|
+
|
|
21
|
+
export function createThreadDelivery({
|
|
22
|
+
codex,
|
|
23
|
+
relay = new NativeDesktopRelay(),
|
|
24
|
+
log = () => {},
|
|
25
|
+
timeoutMs = 240000,
|
|
26
|
+
} = {}) {
|
|
27
|
+
let reportedUnavailable = null;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Falling back is right when the companion never answered - an absent relay
|
|
31
|
+
* says nothing about the target thread, and the older path is exactly as good
|
|
32
|
+
* as it was before this backend existed. It is wrong once the companion has
|
|
33
|
+
* answered: Codex has already refused, and retrying through a second
|
|
34
|
+
* app-server only spawns a process that contends for the ~/.codex state and
|
|
35
|
+
* then fails on the writer lock the native path exists to avoid.
|
|
36
|
+
*/
|
|
37
|
+
async function deliver(threadId, text) {
|
|
38
|
+
const status = relay.status();
|
|
39
|
+
if (status.enabled) {
|
|
40
|
+
try {
|
|
41
|
+
const ack = await relay.sendMessage(threadId, text);
|
|
42
|
+
reportedUnavailable = null;
|
|
43
|
+
return { backend: NATIVE_BACKEND, threadId, ack };
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err.reachedCompanion) throw err;
|
|
46
|
+
log(`native relay unreachable (${err.message}); falling back to the app-server path`);
|
|
47
|
+
}
|
|
48
|
+
} else if (status.reason !== reportedUnavailable) {
|
|
49
|
+
reportedUnavailable = status.reason;
|
|
50
|
+
log(`native relay not in use: ${status.reason}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!codex) throw new Error("No Codex app-server client is configured to deliver this message");
|
|
54
|
+
await codex.ensureThreadAttached(threadId);
|
|
55
|
+
const turn = await runTurn(codex, {
|
|
56
|
+
threadId,
|
|
57
|
+
input: [{ type: "text", text }],
|
|
58
|
+
timeoutMs,
|
|
59
|
+
});
|
|
60
|
+
return { backend: APP_SERVER_BACKEND, threadId, turn };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function describe() {
|
|
64
|
+
const status = relay.status();
|
|
65
|
+
return status.enabled
|
|
66
|
+
? `${NATIVE_BACKEND} via ${status.socketPath}`
|
|
67
|
+
: `${APP_SERVER_BACKEND} (${status.reason})`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { deliver, describe };
|
|
71
|
+
}
|