@minhspark/codex-mcp-bridge 1.11.0 → 1.11.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 +66 -0
- package/README.md +44 -17
- package/package.json +1 -1
- package/scripts/check.mjs +23 -0
- package/scripts/install-claude-desktop.mjs +79 -12
- package/scripts/release-notes.mjs +38 -0
- package/src/app-server-client.mjs +58 -12
- package/src/claude-bridge.mjs +1 -1
- package/src/index.mjs +266 -46
- package/src/platform.mjs +76 -9
- package/src/security-policy.mjs +23 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,72 @@
|
|
|
2
2
|
|
|
3
3
|
Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **A tag is not a release, and nothing was creating the release.** Pushing `v1.10.1`, `v1.11.0` and
|
|
10
|
+
`v1.11.1` published all three to npm, while the Releases page still showed `v1.10.0` as *Latest* -
|
|
11
|
+
the one release that had been created by hand. Anyone reading the repository saw a project that had
|
|
12
|
+
not shipped in days, and the three versions people were actually installing had no notes anywhere
|
|
13
|
+
except this file.
|
|
14
|
+
|
|
15
|
+
The publish workflow now creates the release from the changelog entry, in a separate job that is the
|
|
16
|
+
only one granted `contents: write` - the job that talks to npm keeps the read-only default. Generated
|
|
17
|
+
commit lists say what changed; the entry says why it mattered, so the entry is what ships. Re-running
|
|
18
|
+
a tag is safe: an existing release is left alone.
|
|
19
|
+
|
|
20
|
+
- `scripts/release-notes.mjs` prints one version's changelog section, and a test asserts the version in
|
|
21
|
+
`package.json` has an entry substantial enough to be a release note - so forgetting to write it fails
|
|
22
|
+
before the tag is pushed rather than at the end of a release.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `delegate_to_codex` now gives Claude a single contract for handing work to Codex: it creates the thread
|
|
27
|
+
at the requested project directory, names it through `thread/name/set`, returns the exact `threadId` and
|
|
28
|
+
`cwd`, releases the bridge writer lock after a terminal turn, and opens the Windows or macOS `codex://`
|
|
29
|
+
deep link when configured. Windows app-server shutdown uses `netstat` and `taskkill` instead of the Unix-only
|
|
30
|
+
`lsof` path, which removes the lock that previously left Codex Desktop showing "open in another app".
|
|
31
|
+
|
|
32
|
+
## [1.11.2] - 2026-08-28
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- Thread workspaces are normalized through the cross-machine path resolver before authorization, attaching,
|
|
37
|
+
reading, interrupting, or opening. A stale drive letter, mount point, or UNC path can therefore be mapped to
|
|
38
|
+
the writable checkout on the current machine instead of sending Codex into the wrong directory.
|
|
39
|
+
- `CODEX_BRIDGE_ALLOWED_ROOTS=*` now explicitly means every usable workspace, and the installer uses that
|
|
40
|
+
cross-machine default together with `CODEX_BRIDGE_THREAD_POLICY=roots`, so a thread opened by a human is not
|
|
41
|
+
rejected because its id or the install directory belongs to another machine. A workspace still has to resolve
|
|
42
|
+
to an existing writable directory before a turn acts on it.
|
|
43
|
+
- `scripts/check.mjs` now forwards `CODEX_BRIDGE_THREAD_POLICY`, so validation checks the same authorization mode
|
|
44
|
+
as the MCP server.
|
|
45
|
+
|
|
46
|
+
## [1.11.1] - 2026-08-23
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- **Re-running `codex-mcp-bridge-install` deleted settings it does not write.** It assigned a whole new
|
|
51
|
+
entry over the old one, so `CODEX_BRIDGE_ALLOWED_THREADS`, a hand-added `CODEX_BRIDGE_THREAD_POLICY`
|
|
52
|
+
and any key from a later version were dropped, and `CODEX_BRIDGE_ALLOWED_ROOTS` was reset to the
|
|
53
|
+
install directory. Upgrading is exactly when people re-run it, so the command you reach for to keep
|
|
54
|
+
the bridge current was the command that silently broke it. Measured on a config carrying four custom
|
|
55
|
+
values: all four gone, roots narrowed from two projects to the install directory, other MCP servers
|
|
56
|
+
untouched.
|
|
57
|
+
|
|
58
|
+
Existing values are now the fallback rather than the casualty. Precedence, highest first: a variable
|
|
59
|
+
passed to this run, what the config already says, then the default. `command`, `args` and `CODEX_BIN`
|
|
60
|
+
are still resolved fresh — pointing the entry at the code installed now is the reason to re-run at
|
|
61
|
+
all. `--reset` discards inherited values for the rare case of wanting the defaults back.
|
|
62
|
+
|
|
63
|
+
### Added
|
|
64
|
+
|
|
65
|
+
- The installer now writes `CODEX_BRIDGE_THREAD_POLICY` explicitly, and when it is left at `owned` it
|
|
66
|
+
says what that means: a thread opened in the Codex app or the VS Code extension will answer
|
|
67
|
+
`NOT AUTHORIZED`, because its id is assigned as it opens and cannot be allowlisted in advance. Shipping
|
|
68
|
+
1.11.0 without this left the fix reachable only by reading the environment table — and the symptom
|
|
69
|
+
points nowhere near the setting responsible.
|
|
70
|
+
|
|
5
71
|
## [1.11.0] - 2026-08-23
|
|
6
72
|
|
|
7
73
|
### Added
|
package/README.md
CHANGED
|
@@ -29,6 +29,7 @@ Codex TUI ──codex --remote ws://127.0.0.1:8791──> same app-server, same
|
|
|
29
29
|
- 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
30
|
- 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
31
|
- The bridge keeps exactly one WebSocket, calls `initialize` once, and routes notifications by `threadId`, so parallel threads never bleed into each other.
|
|
32
|
+
- `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.
|
|
32
33
|
|
|
33
34
|
## Requirements
|
|
34
35
|
|
|
@@ -80,6 +81,12 @@ Two ways in. Pick by what you intend to do with it.
|
|
|
80
81
|
npm install -g @minhspark/codex-mcp-bridge
|
|
81
82
|
```
|
|
82
83
|
|
|
84
|
+
Already installed? The same command with `@latest` upgrades it in place — then restart the client. Details in [Upgrading an install you already have](#upgrading-an-install-you-already-have):
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npm install -g @minhspark/codex-mcp-bridge@latest
|
|
88
|
+
```
|
|
89
|
+
|
|
83
90
|
Installing straight from the repository works the same way and needs no registry account:
|
|
84
91
|
|
|
85
92
|
```bash
|
|
@@ -88,6 +95,12 @@ npm install -g git+https://github.com/buidangminh23/codex-mcp-bridge.git
|
|
|
88
95
|
|
|
89
96
|
Either route puts four commands on your PATH — `codex-mcp-bridge` and `claude-mcp-bridge` are the two servers, `codex-mcp-bridge-install` and `claude-mcp-bridge-install` do the wiring in the steps below. Wherever this README runs `node scripts/install-claude-desktop.mjs`, an installed copy runs `codex-mcp-bridge-install` instead.
|
|
90
97
|
|
|
98
|
+
#### Upgrading an install you already have
|
|
99
|
+
|
|
100
|
+
`npm install -g @minhspark/codex-mcp-bridge@latest`, then restart Claude Desktop or Claude Code — an MCP server only loads its code when the client spawns it. Check `codex_bridge_status` reports the version you expect. The global install path carries no version number, so the entry keeps pointing at the right file. If the existing entry still has `CODEX_BRIDGE_THREAD_POLICY=owned`, pass `CODEX_BRIDGE_THREAD_POLICY=roots` once when re-running the installer to enable human-opened threads.
|
|
101
|
+
|
|
102
|
+
Re-running `codex-mcp-bridge-install` is **not** required to upgrade, and before 1.11.1 it actively hurt: it replaced the whole entry, discarding `CODEX_BRIDGE_ALLOWED_THREADS`, any hand-added `CODEX_BRIDGE_THREAD_POLICY`, and resetting `CODEX_BRIDGE_ALLOWED_ROOTS` to the install directory. From 1.11.1 it keeps what is already there — an environment variable you pass wins, the existing value is the fallback, and `--reset` gives you the defaults back.
|
|
103
|
+
|
|
91
104
|
**Clone it** — right if you intend to read, test or change the code:
|
|
92
105
|
|
|
93
106
|
```bash
|
|
@@ -96,6 +109,8 @@ cd codex-mcp-bridge
|
|
|
96
109
|
npm install
|
|
97
110
|
```
|
|
98
111
|
|
|
112
|
+
A clone upgrades with `git pull && npm ci`, then the same restart.
|
|
113
|
+
|
|
99
114
|
Confirm the tree is healthy before wiring it into anything:
|
|
100
115
|
|
|
101
116
|
```bash
|
|
@@ -116,14 +131,20 @@ The script detects the platform, creates the config file if it does not exist, b
|
|
|
116
131
|
| Windows | `%APPDATA%\Claude\claude_desktop_config.json` |
|
|
117
132
|
| Linux | `${XDG_CONFIG_HOME:-~/.config}/Claude/claude_desktop_config.json` |
|
|
118
133
|
|
|
119
|
-
|
|
134
|
+
The v1.11.2 installer accepts threads from every usable workspace by default, so it does not bake one machine's clone path into the config. Narrow the scope to named projects when you want that:
|
|
120
135
|
|
|
121
136
|
```bash
|
|
122
137
|
# macOS and Linux separate entries with ":", Windows with ";"
|
|
123
138
|
CODEX_BRIDGE_ALLOWED_ROOTS="/path/to/project-a:/path/to/project-b" codex-mcp-bridge-install
|
|
124
139
|
```
|
|
125
140
|
|
|
126
|
-
|
|
141
|
+
On an older config, existing values are preserved. To migrate that entry to the cross-machine behaviour explicitly, run:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
CODEX_BRIDGE_ALLOWED_ROOTS="*" CODEX_BRIDGE_THREAD_POLICY=roots codex-mcp-bridge-install
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The wildcard means every usable workspace; the bridge still resolves the thread's reported `cwd` and refuses a path that does not exist or cannot be written.
|
|
127
148
|
|
|
128
149
|
Pass defaults through the environment if you want them written into the entry:
|
|
129
150
|
|
|
@@ -251,27 +272,30 @@ On macOS and Linux the `codex` launcher is a Node script with a `#!/usr/bin/env
|
|
|
251
272
|
|
|
252
273
|
| Tool | What it does | Hints |
|
|
253
274
|
|---|---|---|
|
|
275
|
+
| `delegate_to_codex` | Creates a named Codex thread at the requested project `cwd`, sends Claude's prompt, returns the reply, releases the bridge writer lock, and opens the exact session in Codex Desktop when enabled. | destructive |
|
|
254
276
|
| `send_to_codex_thread` | Sends a prompt as a user turn into `threadId`, waits for `turn/completed`, returns Codex's reply plus an activity trail (commands run, files changed). | destructive |
|
|
255
|
-
| `list_codex_threads` | Lists threads (id, title, cwd, last update, status) so you can pick the **exact** `threadId`. `loadedOnly: true` shows only threads live inside the app-server.
|
|
256
|
-
| `start_codex_thread` | Opens a new Codex thread at a permitted `cwd
|
|
277
|
+
| `list_codex_threads` | Lists threads (id, title, cwd, last update, status) so you can pick the **exact** `threadId`. `loadedOnly: true` shows only threads live inside the app-server. Windows and macOS rows carry a `codex://threads/<id>` deep link. | read-only |
|
|
278
|
+
| `start_codex_thread` | Opens a new Codex thread at a permitted `cwd`, optionally names it, and returns its `threadId`; the bridge applies its configured safe sandbox and approval policy. | writes |
|
|
257
279
|
| `read_codex_thread` | Reads the recent conversation without sending anything. | read-only |
|
|
258
280
|
| `interrupt_codex_turn` | Stops a turn that is still running. | destructive |
|
|
259
|
-
| `open_codex_thread` | **macOS**: brings a thread to the front in the Codex desktop app via `codex://threads/<id>` so a human can watch it work. Pass `background: true` to open without stealing focus. | writes |
|
|
281
|
+
| `open_codex_thread` | **Windows or macOS**: brings a thread to the front in the Codex desktop app via `codex://threads/<id>` so a human can watch it work. Pass `background: true` to open without stealing focus. | writes |
|
|
260
282
|
| `stop_codex_app_server` | Stops the shared app-server once a hand-off is done, so it stops competing with Codex Desktop for the `~/.codex` state. The bridge starts a new one when it next needs it. | destructive |
|
|
261
283
|
| `codex_bridge_status` | Reports the environment: platform, resolved `codex` binary, whether the app-server endpoint is live, plus the macOS integrations (LaunchAgent, desktop app), and **warns when two app-servers are running**. Start here when something misbehaves. | read-only |
|
|
262
284
|
|
|
263
|
-
`
|
|
285
|
+
`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`.
|
|
286
|
+
|
|
287
|
+
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.
|
|
264
288
|
|
|
265
|
-
Thread operations are
|
|
289
|
+
Thread operations are checked before the bridge attaches, and `CODEX_BRIDGE_THREAD_POLICY` decides what counts as permission:
|
|
266
290
|
|
|
267
291
|
| Policy | A thread is reachable when | Use it when |
|
|
268
292
|
|---|---|---|
|
|
269
|
-
| `owned` *(default)* | the bridge created it with `start_codex_thread`, or its exact ID is listed in `CODEX_BRIDGE_ALLOWED_THREADS` | the bridge drives threads it opens itself |
|
|
270
|
-
| `roots` | it is working inside a directory
|
|
293
|
+
| `owned` *(runtime default)* | the bridge created it with `start_codex_thread`, or its exact ID is listed in `CODEX_BRIDGE_ALLOWED_THREADS` | the bridge drives only threads it opens itself |
|
|
294
|
+
| `roots` *(v1.11.2 installer default)* | it is working inside a directory allowed by `CODEX_BRIDGE_ALLOWED_ROOTS` | you open threads in the Codex app or VS Code and want Claude to talk to them |
|
|
271
295
|
|
|
272
296
|
Under `owned`, a thread a human opened is **unreachable rather than merely restricted**: Codex assigns its ID at the moment it opens, so the ID cannot have been allowlisted beforehand, and the bridge-owned set lives in memory and empties whenever the MCP server restarts. If every live thread answers `NOT AUTHORIZED`, that is the cause — switch to `roots`.
|
|
273
297
|
|
|
274
|
-
`roots` does not remove a gate; it moves it from the ID to the workspace, which is the containment every tool already applies to the `cwd` it is handed. The bridge resolves a thread's workspace with a read **before** attaching, so a thread outside every root is refused without ever taking its writer lock.
|
|
298
|
+
`roots` does not remove a gate; it moves it from the ID to the workspace, which is the containment every tool already applies to the `cwd` it is handed. The bridge resolves a thread's workspace with a read **before** attaching, so a thread outside every root is refused without ever taking its writer lock. `CODEX_BRIDGE_ALLOWED_ROOTS=*` means every usable workspace. Otherwise set it to absolute project directories, separated by `:` (`;` on Windows). A root as broad as `/` or `C:\` has the same all-directories meaning, but `*` is portable across operating systems and machines.
|
|
275
299
|
|
|
276
300
|
## Tools — `claude-bridge` (runs inside Codex)
|
|
277
301
|
|
|
@@ -327,7 +351,7 @@ This is how a human watches Codex work in real time instead of reading the rollo
|
|
|
327
351
|
|
|
328
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.
|
|
329
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.
|
|
330
|
-
- **
|
|
354
|
+
- **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.
|
|
331
355
|
- 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.
|
|
332
356
|
- `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://`.
|
|
333
357
|
|
|
@@ -350,13 +374,13 @@ python3 -c "import json;[print(v['properties']['method'].get('const') or v['prop
|
|
|
350
374
|
|
|
351
375
|
**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.
|
|
352
376
|
|
|
353
|
-
**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. `
|
|
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.
|
|
354
378
|
|
|
355
379
|
**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.
|
|
356
380
|
|
|
357
|
-
**
|
|
381
|
+
**A path from another machine is remapped before it can cause a wrong checkout.** An explicit `CODEX_BRIDGE_PATH_MAP` is checked first. Otherwise the bridge keeps a path that already exists and is writable, and only then tries portable candidates for foreign drive letters, mount points, or UNC shares. If nothing usable exists it fails with the paths it tried instead of opening Codex in a guessed directory.
|
|
358
382
|
|
|
359
|
-
A path counts as coming from elsewhere when it names a drive letter (`D:\project`) or an attached volume (`/Volumes/<label>/project`, `/mnt/<label>/project`, `/media/<user>/<label>/project`). No particular letter or label is blessed, so any dual-boot or external-disk layout works without configuration. The bridge then looks for that project
|
|
383
|
+
A path counts as coming from elsewhere when it names a drive letter (`D:\project`), a UNC share (`\\server\share\project`), or an attached volume (`/Volumes/<label>/project`, `/mnt/<label>/project`, `/media/<user>/<label>/project`). No particular letter, share, or label is blessed, so any dual-boot or external-disk layout works without configuration. The bridge then looks for that project under `$HOME`, then under **its own parent directory** — a bridge checked out at `~/code/codex-mcp-bridge` makes `~/code` the obvious place to find a sibling project. Override the list with `CODEX_BRIDGE_WORKSPACE_ROOTS`, provide deterministic source/target pairs with `CODEX_BRIDGE_PATH_MAP='{"L:\\project":"C:\\project"}'`, or set `CODEX_BRIDGE_REMAP=0` to switch heuristic rewriting off entirely.
|
|
360
384
|
|
|
361
385
|
Note: Codex Desktop does **not** group threads by directory — the sidebar has `Pinned` and everything else, and the protocol exposes no API to file a thread under a section (`thread/start` takes no `sectionId`; `thread/metadata/update` only patches gitInfo). What ties a thread to a project is its `cwd`.
|
|
362
386
|
|
|
@@ -373,17 +397,20 @@ The bridge reads these from the environment its MCP client hands it — there is
|
|
|
373
397
|
| `CODEX_APP_SERVER_URL` | `ws://127.0.0.1:8791` | Shared **loopback-only** app-server endpoint. Non-loopback endpoints are rejected because this bridge does not implement remote WebSocket authentication. |
|
|
374
398
|
| `CODEX_BIN` | auto-detected | Path to `codex` used for autostart. |
|
|
375
399
|
| `CODEX_BRIDGE_AUTOSTART` | `1` | `0` = never spawn an app-server; one must already be running. |
|
|
376
|
-
| `CODEX_BRIDGE_THREAD_POLICY` | `owned` | What authorizes a thread: `owned` (created by this bridge, or listed in `CODEX_BRIDGE_ALLOWED_THREADS`) or `roots` (working inside `CODEX_BRIDGE_ALLOWED_ROOTS`).
|
|
377
|
-
| `CODEX_BRIDGE_ALLOWED_THREADS` | empty | Exact comma-separated thread IDs permitted for read/send/interrupt/open/list
|
|
378
|
-
| `CODEX_BRIDGE_ALLOWED_ROOTS` |
|
|
400
|
+
| `CODEX_BRIDGE_THREAD_POLICY` | `owned` in the direct server; installer writes `roots` for new v1.11.2 entries | What authorizes a thread: `owned` (created by this bridge, or listed in `CODEX_BRIDGE_ALLOWED_THREADS`) or `roots` (working inside `CODEX_BRIDGE_ALLOWED_ROOTS`). Existing config values are preserved on upgrade. |
|
|
401
|
+
| `CODEX_BRIDGE_ALLOWED_THREADS` | empty | Exact comma-separated thread IDs permitted for read/send/interrupt/open/list; `*` explicitly permits every thread ID. Under `roots`, the workspace check still runs. |
|
|
402
|
+
| `CODEX_BRIDGE_ALLOWED_ROOTS` | `*` in the v1.11.2 installer | Absolute project directories permitted for `cwd`, separated by `:` (`;` on Windows); `*` means every usable workspace. |
|
|
379
403
|
| `CODEX_BRIDGE_APPROVAL` | `deny` | How to answer approval requests from Codex. `approve` is ignored unless `CODEX_BRIDGE_AUTO_APPROVE_ACK=1` is also set. |
|
|
380
404
|
| `CODEX_BRIDGE_AUTO_APPROVE_ACK` | empty | Explicit acknowledgement required to enable automatic command/file approval; set to `1` only after reviewing the risk. |
|
|
381
405
|
| `CODEX_BRIDGE_APPROVAL_POLICY` | `on-request` | Policy applied to threads created by the bridge. It is no longer caller-controlled. |
|
|
382
406
|
| `CODEX_BRIDGE_SANDBOX` | `workspace-write` | Sandbox applied to threads created by the bridge: `read-only` or `workspace-write`; unrestricted `danger-full-access` is rejected. |
|
|
383
407
|
| `CODEX_BRIDGE_REMAP` | `1` | `0` disables cwd remapping between a shared drive and a local checkout. |
|
|
408
|
+
| `CODEX_BRIDGE_PATH_MAP` | empty | Optional JSON object mapping absolute source paths to absolute target paths; use it when the same project has a known different path on another machine. |
|
|
384
409
|
| `CODEX_BRIDGE_WORKSPACE_ROOTS` | `$HOME` and the bridge's parent directory | Where to look for a project by name, most preferred first, separated by `:` (`;` on Windows). Setting it replaces the derived roots rather than adding to them. |
|
|
385
410
|
| `CODEX_BRIDGE_MODEL` | from `~/.codex/config.toml` | Default model for threads and turns the bridge creates, e.g. `gpt-5.6-luna`. |
|
|
386
411
|
| `CODEX_BRIDGE_EFFORT` | from `~/.codex/config.toml` | Default reasoning effort: `minimal` · `low` · `medium` · `high` · `xhigh` · `ultra`. |
|
|
412
|
+
| `CODEX_BRIDGE_OPEN_IN_APP` | `1` on Windows, `0` elsewhere | Open delegated or sent threads through the `codex://threads/<id>` desktop link. |
|
|
413
|
+
| `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. |
|
|
387
414
|
| `CLAUDE_BRIDGE_PEER_NAME` | `codex-<pid>` | The name Claude shows for this bridge in its agent list. |
|
|
388
415
|
| `CLAUDE_BRIDGE_CWD` | the process cwd | The working directory the peer advertises. |
|
|
389
416
|
| `CLAUDE_DESKTOP_CONFIG` | auto-detected | Override the config path used by `install-claude-desktop.mjs`. |
|
package/package.json
CHANGED
package/scripts/check.mjs
CHANGED
|
@@ -4,9 +4,32 @@ import path from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
6
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
7
|
+
const bridgeEnvNames = [
|
|
8
|
+
"CODEX_APP_SERVER_URL",
|
|
9
|
+
"CODEX_BIN",
|
|
10
|
+
"CODEX_BRIDGE_ALLOWED_ROOTS",
|
|
11
|
+
"CODEX_BRIDGE_ALLOWED_THREADS",
|
|
12
|
+
"CODEX_BRIDGE_APPROVAL",
|
|
13
|
+
"CODEX_BRIDGE_AUTO_APPROVE_ACK",
|
|
14
|
+
"CODEX_BRIDGE_APPROVAL_POLICY",
|
|
15
|
+
"CODEX_BRIDGE_AUTOSTART",
|
|
16
|
+
"CODEX_BRIDGE_EFFORT",
|
|
17
|
+
"CODEX_BRIDGE_MODEL",
|
|
18
|
+
"CODEX_BRIDGE_PATH_MAP",
|
|
19
|
+
"CODEX_BRIDGE_REMAP",
|
|
20
|
+
"CODEX_BRIDGE_SANDBOX",
|
|
21
|
+
"CODEX_BRIDGE_THREAD_POLICY",
|
|
22
|
+
"CODEX_BRIDGE_WORKSPACE_ROOTS",
|
|
23
|
+
];
|
|
24
|
+
const bridgeEnv = Object.fromEntries(
|
|
25
|
+
bridgeEnvNames
|
|
26
|
+
.filter((name) => process.env[name] !== undefined)
|
|
27
|
+
.map((name) => [name, process.env[name]]),
|
|
28
|
+
);
|
|
7
29
|
const transport = new StdioClientTransport({
|
|
8
30
|
command: process.execPath,
|
|
9
31
|
args: [path.join(root, "src", "index.mjs")],
|
|
32
|
+
env: bridgeEnv,
|
|
10
33
|
stderr: "inherit",
|
|
11
34
|
});
|
|
12
35
|
const client = new Client({ name: "bridge-check", version: "1.0.0" });
|
|
@@ -3,19 +3,21 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
|
-
import { PLATFORM_LABEL, claudeDesktopConfigPath, resolveCodexBin } from "../src/platform.mjs";
|
|
6
|
+
import { IS_WINDOWS, PLATFORM_LABEL, claudeDesktopConfigPath, resolveCodexBin } from "../src/platform.mjs";
|
|
7
7
|
|
|
8
8
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
9
|
const cfgPath = process.env.CLAUDE_DESKTOP_CONFIG ?? claudeDesktopConfigPath();
|
|
10
|
+
const reset = process.argv.includes("--reset");
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* This bridge is intentionally the local hand-off point between Claude and
|
|
14
|
+
* Codex. A thread can have been opened from any checkout on this machine (or
|
|
15
|
+
* report a checkout from another machine), so a machine-specific install path
|
|
16
|
+
* is the wrong default: it starts successfully and then refuses the useful
|
|
17
|
+
* thread. The bridge still resolves/remaps the reported cwd and requires a
|
|
18
|
+
* usable directory before it acts.
|
|
16
19
|
*/
|
|
17
|
-
const
|
|
18
|
-
const defaultRoots = installedAsDependency ? process.cwd() : root;
|
|
20
|
+
const defaultRoots = "*";
|
|
19
21
|
|
|
20
22
|
const nodeBin = process.env.NODE_EXE ?? process.execPath;
|
|
21
23
|
const codexBin = resolveCodexBin(process.env.CODEX_EXE);
|
|
@@ -31,16 +33,63 @@ fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
|
|
|
31
33
|
|
|
32
34
|
const cfg = fs.existsSync(cfgPath) ? JSON.parse(fs.readFileSync(cfgPath, "utf8")) : {};
|
|
33
35
|
cfg.mcpServers = cfg.mcpServers ?? {};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Re-running the installer used to assign a whole new entry over the old one,
|
|
39
|
+
* which silently deleted every setting the installer does not itself write -
|
|
40
|
+
* CODEX_BRIDGE_ALLOWED_THREADS and a hand-added CODEX_BRIDGE_THREAD_POLICY
|
|
41
|
+
* among them - and reset CODEX_BRIDGE_ALLOWED_ROOTS back to the install
|
|
42
|
+
* directory. Upgrading is the moment people re-run this, so the command they
|
|
43
|
+
* reach for to keep the bridge current was the command that broke it.
|
|
44
|
+
*
|
|
45
|
+
* Existing values are now the fallback rather than the casualty. Precedence,
|
|
46
|
+
* highest first: an environment variable passed to this run, what the config
|
|
47
|
+
* already says, then the default. `--reset` drops the middle one for the rare
|
|
48
|
+
* case of wanting the defaults back.
|
|
49
|
+
*/
|
|
50
|
+
const previousEnv = (reset ? {} : cfg.mcpServers["codex-bridge"]?.env) ?? {};
|
|
51
|
+
const settled = (name, fallback) => process.env[name] ?? previousEnv[name] ?? fallback;
|
|
52
|
+
|
|
53
|
+
const kept = Object.keys(previousEnv).filter(
|
|
54
|
+
(name) => process.env[name] === undefined && name !== "CODEX_BIN",
|
|
55
|
+
);
|
|
56
|
+
|
|
34
57
|
cfg.mcpServers["codex-bridge"] = {
|
|
35
58
|
command: nodeBin,
|
|
36
59
|
args: [path.join(root, "src", "index.mjs")],
|
|
37
60
|
env: {
|
|
61
|
+
/**
|
|
62
|
+
* Keys this installer knows nothing about survive too - a setting added by
|
|
63
|
+
* a later version, or by hand, is not this script's to throw away.
|
|
64
|
+
*/
|
|
65
|
+
...previousEnv,
|
|
66
|
+
/**
|
|
67
|
+
* These three are the point of re-running: they name where the code and
|
|
68
|
+
* the codex binary actually live now, so they are resolved fresh rather
|
|
69
|
+
* than inherited.
|
|
70
|
+
*/
|
|
38
71
|
CODEX_BIN: codexBin,
|
|
39
|
-
CODEX_APP_SERVER_URL:
|
|
40
|
-
CODEX_BRIDGE_ALLOWED_ROOTS:
|
|
41
|
-
CODEX_BRIDGE_APPROVAL:
|
|
42
|
-
CODEX_BRIDGE_APPROVAL_POLICY:
|
|
43
|
-
|
|
72
|
+
CODEX_APP_SERVER_URL: settled("CODEX_APP_SERVER_URL", "ws://127.0.0.1:8791"),
|
|
73
|
+
CODEX_BRIDGE_ALLOWED_ROOTS: settled("CODEX_BRIDGE_ALLOWED_ROOTS", defaultRoots),
|
|
74
|
+
CODEX_BRIDGE_APPROVAL: settled("CODEX_BRIDGE_APPROVAL", "deny"),
|
|
75
|
+
CODEX_BRIDGE_APPROVAL_POLICY: settled("CODEX_BRIDGE_APPROVAL_POLICY", "on-request"),
|
|
76
|
+
/**
|
|
77
|
+
* Written out even at its default so it is visible in the file. Left
|
|
78
|
+
* implicit, the one setting that decides whether the bridge can reach a
|
|
79
|
+
* thread a human opened is a variable you have to already know exists -
|
|
80
|
+
* and the symptom when you do not, every thread answering NOT AUTHORIZED,
|
|
81
|
+
* points nowhere near it.
|
|
82
|
+
*/
|
|
83
|
+
CODEX_BRIDGE_THREAD_POLICY: settled("CODEX_BRIDGE_THREAD_POLICY", "roots"),
|
|
84
|
+
CODEX_BRIDGE_SANDBOX: settled("CODEX_BRIDGE_SANDBOX", "workspace-write"),
|
|
85
|
+
CODEX_BRIDGE_OPEN_IN_APP: settled("CODEX_BRIDGE_OPEN_IN_APP", IS_WINDOWS ? "1" : "0"),
|
|
86
|
+
CODEX_BRIDGE_RELEASE_AFTER_TURN: settled("CODEX_BRIDGE_RELEASE_AFTER_TURN", IS_WINDOWS ? "1" : "0"),
|
|
87
|
+
...(process.env.CODEX_BRIDGE_ALLOWED_THREADS !== undefined
|
|
88
|
+
? { CODEX_BRIDGE_ALLOWED_THREADS: process.env.CODEX_BRIDGE_ALLOWED_THREADS }
|
|
89
|
+
: {}),
|
|
90
|
+
...(process.env.CODEX_BRIDGE_PATH_MAP !== undefined
|
|
91
|
+
? { CODEX_BRIDGE_PATH_MAP: process.env.CODEX_BRIDGE_PATH_MAP }
|
|
92
|
+
: {}),
|
|
44
93
|
...(process.env.CODEX_BRIDGE_MODEL ? { CODEX_BRIDGE_MODEL: process.env.CODEX_BRIDGE_MODEL } : {}),
|
|
45
94
|
...(process.env.CODEX_BRIDGE_EFFORT ? { CODEX_BRIDGE_EFFORT: process.env.CODEX_BRIDGE_EFFORT } : {}),
|
|
46
95
|
},
|
|
@@ -55,6 +104,14 @@ fs.writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8");
|
|
|
55
104
|
console.log(`platform: ${PLATFORM_LABEL}`);
|
|
56
105
|
console.log(`updated ${cfgPath}`);
|
|
57
106
|
console.log(JSON.stringify(cfg.mcpServers["codex-bridge"], null, 2));
|
|
107
|
+
|
|
108
|
+
if (reset) {
|
|
109
|
+
console.log("\n--reset: existing values were discarded in favour of the defaults.");
|
|
110
|
+
} else if (kept.length) {
|
|
111
|
+
console.log(`\nkept from the existing entry: ${kept.join(", ")}`);
|
|
112
|
+
console.log("Pass the variable to override one, or --reset to drop them all.");
|
|
113
|
+
}
|
|
114
|
+
|
|
58
115
|
const writtenRoots = cfg.mcpServers["codex-bridge"].env.CODEX_BRIDGE_ALLOWED_ROOTS;
|
|
59
116
|
if (writtenRoots.split(path.delimiter).some((entry) => entry.split(path.sep).includes("node_modules"))) {
|
|
60
117
|
console.log(
|
|
@@ -64,4 +121,14 @@ if (writtenRoots.split(path.delimiter).some((entry) => entry.split(path.sep).inc
|
|
|
64
121
|
);
|
|
65
122
|
}
|
|
66
123
|
|
|
124
|
+
if (cfg.mcpServers["codex-bridge"].env.CODEX_BRIDGE_THREAD_POLICY === "owned") {
|
|
125
|
+
console.log(
|
|
126
|
+
"\nNOTE: thread policy is `owned` - this bridge may only act on threads it created itself.\n" +
|
|
127
|
+
"A thread you open in the Codex app or the VS Code extension will answer NOT AUTHORIZED,\n" +
|
|
128
|
+
"because its id is assigned as it opens and cannot be allowlisted in advance. To let the\n" +
|
|
129
|
+
"bridge reach any thread working inside CODEX_BRIDGE_ALLOWED_ROOTS, re-run with:\n" +
|
|
130
|
+
" CODEX_BRIDGE_THREAD_POLICY=roots codex-mcp-bridge-install",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
67
134
|
console.log("\nRestart Claude Desktop to load the bridge.");
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Prints one version's section of the changelog, so a GitHub release carries
|
|
8
|
+
* the notes that were already written by hand rather than a generated list of
|
|
9
|
+
* commit subjects. Used by the publish workflow and runnable on its own when a
|
|
10
|
+
* release has to be created after the fact.
|
|
11
|
+
*/
|
|
12
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
|
+
|
|
14
|
+
export function releaseNotes(changelog, version) {
|
|
15
|
+
const lines = changelog.split("\n");
|
|
16
|
+
const heading = lines.findIndex((line) => line.startsWith(`## [${version}]`));
|
|
17
|
+
if (heading === -1) return null;
|
|
18
|
+
|
|
19
|
+
const rest = lines.slice(heading + 1);
|
|
20
|
+
const next = rest.findIndex((line) => line.startsWith("## ["));
|
|
21
|
+
const body = (next === -1 ? rest : rest.slice(0, next)).join("\n").trim();
|
|
22
|
+
return body === "" ? null : body;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Only runs the CLI when invoked directly - importing this from a test must
|
|
27
|
+
* not read argv or exit the process.
|
|
28
|
+
*/
|
|
29
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
30
|
+
const version = process.argv[2] ?? JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version;
|
|
31
|
+
const notes = releaseNotes(fs.readFileSync(path.join(root, "CHANGELOG.md"), "utf8"), version);
|
|
32
|
+
|
|
33
|
+
if (!notes) {
|
|
34
|
+
process.stderr.write(`CHANGELOG.md has no entry for ${version}\n`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
process.stdout.write(`${notes}\n`);
|
|
38
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
2
|
import { setTimeout as delay } from "node:timers/promises";
|
|
3
3
|
|
|
4
4
|
import { PLATFORM_LABEL, resolveCodexBin, spawnEnv } from "./platform.mjs";
|
|
@@ -8,6 +8,45 @@ const DEFAULT_URL = "ws://127.0.0.1:8791";
|
|
|
8
8
|
const CONNECT_ATTEMPTS = 2;
|
|
9
9
|
const CONNECT_RETRY_DELAY_MS = 750;
|
|
10
10
|
|
|
11
|
+
export function parseListeningPids(output, port) {
|
|
12
|
+
const suffix = `:${port}`;
|
|
13
|
+
return [
|
|
14
|
+
...new Set(
|
|
15
|
+
String(output)
|
|
16
|
+
.split(/\r?\n/)
|
|
17
|
+
.map((line) => line.trim().split(/\s+/))
|
|
18
|
+
.filter((fields) => fields[0]?.toUpperCase() === "TCP")
|
|
19
|
+
.filter((fields) => fields.some((field) => field.toUpperCase() === "LISTENING"))
|
|
20
|
+
.filter((fields) => fields[1]?.endsWith(suffix))
|
|
21
|
+
.map((fields) => Number(fields.at(-1)))
|
|
22
|
+
.filter((pid) => Number.isInteger(pid) && pid > 0),
|
|
23
|
+
),
|
|
24
|
+
];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function appServerPort(url) {
|
|
28
|
+
return url.replace(/^wss?:\/\//, "").split("/")[0].split(":").pop();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function listeningPids(port) {
|
|
32
|
+
if (process.platform === "win32") {
|
|
33
|
+
return parseListeningPids(
|
|
34
|
+
execFileSync("netstat.exe", ["-ano", "-p", "tcp"], {
|
|
35
|
+
env: spawnEnv(),
|
|
36
|
+
windowsHide: true,
|
|
37
|
+
}),
|
|
38
|
+
port,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return execFileSync("/usr/sbin/lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
|
|
42
|
+
env: spawnEnv(),
|
|
43
|
+
})
|
|
44
|
+
.toString()
|
|
45
|
+
.split("\n")
|
|
46
|
+
.map((line) => Number(line.trim()))
|
|
47
|
+
.filter(Boolean);
|
|
48
|
+
}
|
|
49
|
+
|
|
11
50
|
function httpBase(wsUrl) {
|
|
12
51
|
return wsUrl.replace(/^ws:/, "http:").replace(/^wss:/, "https:").replace(/\/+$/, "");
|
|
13
52
|
}
|
|
@@ -101,25 +140,32 @@ export class CodexAppServerClient {
|
|
|
101
140
|
*/
|
|
102
141
|
async stopServer() {
|
|
103
142
|
if (!(await this.isServerUp())) return { stopped: false, reason: "no app-server was listening" };
|
|
104
|
-
const port = this.url
|
|
105
|
-
const
|
|
106
|
-
const pids = execFileSync("/usr/sbin/lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
|
|
107
|
-
env: spawnEnv(),
|
|
108
|
-
})
|
|
109
|
-
.toString()
|
|
110
|
-
.split("\n")
|
|
111
|
-
.map((line) => Number(line.trim()))
|
|
112
|
-
.filter(Boolean);
|
|
143
|
+
const port = appServerPort(this.url);
|
|
144
|
+
const pids = listeningPids(port);
|
|
113
145
|
if (!pids.length) return { stopped: false, reason: `nothing is listening on port ${port}` };
|
|
114
146
|
this.ws?.close();
|
|
115
147
|
for (const pid of pids) {
|
|
116
148
|
try {
|
|
117
|
-
process.
|
|
149
|
+
if (process.platform === "win32") {
|
|
150
|
+
execFileSync("taskkill.exe", ["/PID", String(pid), "/T", "/F"], {
|
|
151
|
+
stdio: "ignore",
|
|
152
|
+
windowsHide: true,
|
|
153
|
+
});
|
|
154
|
+
} else {
|
|
155
|
+
process.kill(pid, "SIGTERM");
|
|
156
|
+
}
|
|
118
157
|
} catch (err) {
|
|
119
158
|
this.log(`could not stop pid ${pid}: ${err.message}`);
|
|
120
159
|
}
|
|
121
160
|
}
|
|
122
|
-
|
|
161
|
+
this.ws = null;
|
|
162
|
+
this.attachedThreads.clear();
|
|
163
|
+
this.threadCwds.clear();
|
|
164
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
165
|
+
if (!(await this.isServerUp())) return { stopped: true, pids };
|
|
166
|
+
await delay(100);
|
|
167
|
+
}
|
|
168
|
+
return { stopped: true, pids, stillListening: true };
|
|
123
169
|
}
|
|
124
170
|
|
|
125
171
|
/**
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { PLATFORM_LABEL } from "./platform.mjs";
|
|
|
8
8
|
import { PeerEndpoint, findClaudeSession, listClaudeSessions, readTranscript } from "./peer-protocol.mjs";
|
|
9
9
|
import { runTurn } from "./turn.mjs";
|
|
10
10
|
|
|
11
|
-
const VERSION = "1.11.
|
|
11
|
+
const VERSION = "1.11.2";
|
|
12
12
|
const FORWARD_MIN_INTERVAL_MS = 5000;
|
|
13
13
|
const FORWARD_MAX_PER_SESSION = 50;
|
|
14
14
|
|
package/src/index.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { z } from "zod";
|
|
|
6
6
|
import { CodexAppServerClient, writerLockWarning } from "./app-server-client.mjs";
|
|
7
7
|
import {
|
|
8
8
|
IS_MACOS,
|
|
9
|
+
IS_WINDOWS,
|
|
9
10
|
PLATFORM_LABEL,
|
|
10
11
|
claudeDesktopConfigPath,
|
|
11
12
|
codexThreadUrl,
|
|
@@ -15,11 +16,12 @@ import {
|
|
|
15
16
|
launchAgentPath,
|
|
16
17
|
openThreadInCodexApp,
|
|
17
18
|
resolveWorkspacePath,
|
|
19
|
+
supportsCodexThreadLinks,
|
|
18
20
|
} from "./platform.mjs";
|
|
19
21
|
import { runTurn } from "./turn.mjs";
|
|
20
22
|
import { BridgeSecurityPolicy } from "./security-policy.mjs";
|
|
21
23
|
|
|
22
|
-
const VERSION = "1.11.
|
|
24
|
+
const VERSION = "1.11.2";
|
|
23
25
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
24
26
|
|
|
25
27
|
/**
|
|
@@ -29,6 +31,13 @@ const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
|
29
31
|
*/
|
|
30
32
|
const DEFAULT_MODEL = process.env.CODEX_BRIDGE_MODEL || null;
|
|
31
33
|
const DEFAULT_EFFORT = process.env.CODEX_BRIDGE_EFFORT || null;
|
|
34
|
+
const DEFAULT_OPEN_IN_APP = process.env.CODEX_BRIDGE_OPEN_IN_APP
|
|
35
|
+
? process.env.CODEX_BRIDGE_OPEN_IN_APP === "1"
|
|
36
|
+
: IS_WINDOWS;
|
|
37
|
+
const DEFAULT_RELEASE_AFTER_TURN = process.env.CODEX_BRIDGE_RELEASE_AFTER_TURN
|
|
38
|
+
? process.env.CODEX_BRIDGE_RELEASE_AFTER_TURN === "1"
|
|
39
|
+
: IS_WINDOWS;
|
|
40
|
+
const TERMINAL_TURN_STATUSES = new Set(["completed", "interrupted", "failed"]);
|
|
32
41
|
const security = new BridgeSecurityPolicy();
|
|
33
42
|
|
|
34
43
|
const client = new CodexAppServerClient({
|
|
@@ -54,23 +63,120 @@ const failure = (err) => textResult(`Codex bridge error: ${err?.message ?? Strin
|
|
|
54
63
|
* allowlisted skips the round-trip entirely: its answer cannot change.
|
|
55
64
|
*/
|
|
56
65
|
async function assertThreadAccess(threadId) {
|
|
57
|
-
if (security.isThreadAuthorized(threadId)) return null;
|
|
66
|
+
if (security.threadPolicy !== "roots" && security.isThreadAuthorized(threadId)) return null;
|
|
58
67
|
if (security.threadPolicy !== "roots") {
|
|
59
68
|
security.assertThread(threadId);
|
|
60
69
|
return null;
|
|
61
70
|
}
|
|
62
71
|
const res = await client.call("thread/read", { threadId });
|
|
63
|
-
const thread = res?.thread ?? res ?? {};
|
|
72
|
+
const thread = normalizeThreadCwd(res?.thread ?? res ?? {}, { strict: true });
|
|
64
73
|
security.assertThread(threadId, thread.cwd);
|
|
65
74
|
security.assertCwd(thread.cwd);
|
|
66
75
|
return thread;
|
|
67
76
|
}
|
|
68
77
|
|
|
78
|
+
function normalizeThreadCwd(thread, { strict = false } = {}) {
|
|
79
|
+
if (!thread?.cwd) return thread;
|
|
80
|
+
try {
|
|
81
|
+
const workspace = resolveWorkspacePath(thread.cwd);
|
|
82
|
+
return workspace.path === thread.cwd ? thread : { ...thread, cwd: workspace.path };
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (strict) throw err;
|
|
85
|
+
return thread;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function projectLabel(cwd) {
|
|
90
|
+
return cwd.replace(/[\\/]+$/, "").split(/[\\/]/).filter(Boolean).at(-1) || cwd;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function threadNameFor({ cwd, prompt, name }) {
|
|
94
|
+
const explicit = name?.trim();
|
|
95
|
+
if (explicit) return explicit.slice(0, 200);
|
|
96
|
+
const summary = String(prompt ?? "")
|
|
97
|
+
.split(/\r?\n/)
|
|
98
|
+
.map((line) => line.trim())
|
|
99
|
+
.find(Boolean);
|
|
100
|
+
return `[${projectLabel(cwd)}] ${(summary || "Claude delegation").replace(/\s+/g, " ").slice(0, 160)}`.slice(
|
|
101
|
+
0,
|
|
102
|
+
200,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function createCodexThread({ cwd, model, name, prompt }) {
|
|
107
|
+
const workspace = resolveWorkspacePath(cwd);
|
|
108
|
+
security.assertCwd(workspace.path);
|
|
109
|
+
const res = await client.call("thread/start", {
|
|
110
|
+
cwd: workspace.path,
|
|
111
|
+
...(model ?? DEFAULT_MODEL ? { model: model ?? DEFAULT_MODEL } : {}),
|
|
112
|
+
approvalPolicy: security.approvalPolicy,
|
|
113
|
+
sandbox: security.sandbox,
|
|
114
|
+
});
|
|
115
|
+
const thread = res?.thread ?? {};
|
|
116
|
+
if (!thread.id) throw new Error("Codex app-server created no thread id");
|
|
117
|
+
const threadName = name || prompt ? threadNameFor({ cwd: thread.cwd ?? workspace.path, prompt, name }) : null;
|
|
118
|
+
if (threadName) {
|
|
119
|
+
await client.call("thread/name/set", { threadId: thread.id, name: threadName });
|
|
120
|
+
}
|
|
121
|
+
client.markAttached(thread.id, thread);
|
|
122
|
+
security.registerThread(thread.id);
|
|
123
|
+
return {
|
|
124
|
+
threadId: thread.id,
|
|
125
|
+
name: threadName ?? thread.name ?? "(unnamed)",
|
|
126
|
+
cwd: thread.cwd ?? workspace.path,
|
|
127
|
+
rollout: thread.path ?? "(not written yet)",
|
|
128
|
+
workspace,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function finishDesktopHandoff({ threadId, result, openInApp, releaseAfterTurn }) {
|
|
133
|
+
const notes = [];
|
|
134
|
+
let canOpenAfterRelease = true;
|
|
135
|
+
const terminal = TERMINAL_TURN_STATUSES.has(result.status);
|
|
136
|
+
|
|
137
|
+
if (releaseAfterTurn && terminal) {
|
|
138
|
+
try {
|
|
139
|
+
const released = await client.stopServer();
|
|
140
|
+
if (released.stopped) {
|
|
141
|
+
if (released.stillListening) {
|
|
142
|
+
canOpenAfterRelease = false;
|
|
143
|
+
notes.push(
|
|
144
|
+
`stop requested for app-server${released.pids?.length ? ` (pid ${released.pids.join(", ")})` : ""}, but it is still listening`,
|
|
145
|
+
);
|
|
146
|
+
notes.push("WARNING: the app-server is still listening, so the desktop thread was not opened to avoid another lock");
|
|
147
|
+
} else {
|
|
148
|
+
notes.push(
|
|
149
|
+
`released app-server${released.pids?.length ? ` (pid ${released.pids.join(", ")})` : ""}; Codex Desktop can write this thread`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
canOpenAfterRelease = false;
|
|
154
|
+
notes.push(`could not release app-server: ${released.reason}`);
|
|
155
|
+
}
|
|
156
|
+
} catch (err) {
|
|
157
|
+
canOpenAfterRelease = false;
|
|
158
|
+
notes.push(`could not release app-server: ${err.message}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (openInApp && (terminal ? canOpenAfterRelease : !releaseAfterTurn)) {
|
|
163
|
+
try {
|
|
164
|
+
notes.push(`opened in Codex app: ${await openThreadInCodexApp(threadId)}`);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
notes.push(`could not open the thread in the Codex app: ${err.message}`);
|
|
167
|
+
}
|
|
168
|
+
} else if (openInApp && releaseAfterTurn && !terminal) {
|
|
169
|
+
notes.push(`desktop open deferred because the turn status is ${result.status}; release it after the turn finishes`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return notes;
|
|
173
|
+
}
|
|
174
|
+
|
|
69
175
|
function formatThreadRow(t) {
|
|
70
176
|
const title = t.name || (t.preview ?? "").replace(/\s+/g, " ").slice(0, 70) || "(no title)";
|
|
71
177
|
const updated = t.updatedAt ? new Date(t.updatedAt * 1000).toISOString().replace("T", " ").slice(0, 16) : "?";
|
|
72
178
|
const status = t.status?.type ?? "?";
|
|
73
|
-
const deepLink =
|
|
179
|
+
const deepLink = supportsCodexThreadLinks() ? `\n open: ${codexThreadUrl(t.id)}` : "";
|
|
74
180
|
const authorized = security.isThreadAuthorized(t.id, t.cwd)
|
|
75
181
|
? ""
|
|
76
182
|
: "\n NOT AUTHORIZED: add this id to CODEX_BRIDGE_ALLOWED_THREADS, or set " +
|
|
@@ -122,10 +228,100 @@ const server = new McpServer(
|
|
|
122
228
|
{ name: "codex-bridge", version: VERSION },
|
|
123
229
|
{
|
|
124
230
|
instructions:
|
|
125
|
-
"Bridge
|
|
126
|
-
"
|
|
127
|
-
"
|
|
128
|
-
"
|
|
231
|
+
"Bridge Claude work into Codex. Prefer delegate_to_codex: it creates a named Codex thread at the " +
|
|
232
|
+
"requested cwd, sends the prompt, releases the bridge writer lock, and opens the exact thread in " +
|
|
233
|
+
"Codex Desktop. Use send_to_codex_thread only when an existing threadId is intentional; use " +
|
|
234
|
+
"list_codex_threads or read_codex_thread to inspect sessions and codex_bridge_status to inspect wiring.",
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
server.registerTool(
|
|
239
|
+
"delegate_to_codex",
|
|
240
|
+
{
|
|
241
|
+
title: "Delegate work to a new Codex session",
|
|
242
|
+
description:
|
|
243
|
+
"Create a named Codex session at the requested project directory, send Claude's prompt into it, " +
|
|
244
|
+
"return Codex's reply, and hand the session to Codex Desktop without leaving the bridge writer lock behind.",
|
|
245
|
+
inputSchema: {
|
|
246
|
+
cwd: z.string().describe("Absolute project directory where Codex must work"),
|
|
247
|
+
prompt: z.string().describe("The complete task Claude is delegating to Codex"),
|
|
248
|
+
name: z.string().min(1).max(200).optional().describe("Optional Codex session title; otherwise one is derived from the prompt"),
|
|
249
|
+
timeoutSec: z
|
|
250
|
+
.number()
|
|
251
|
+
.int()
|
|
252
|
+
.min(10)
|
|
253
|
+
.max(3600)
|
|
254
|
+
.optional()
|
|
255
|
+
.describe("How long to wait for the turn to finish (default 240s)"),
|
|
256
|
+
model: z.string().optional().describe("Model override, e.g. gpt-5.6-luna"),
|
|
257
|
+
effort: z
|
|
258
|
+
.enum(["minimal", "low", "medium", "high", "xhigh", "ultra"])
|
|
259
|
+
.optional()
|
|
260
|
+
.describe(`Override reasoning effort (default ${DEFAULT_EFFORT ?? "whatever ~/.codex/config.toml says"})`),
|
|
261
|
+
openInApp: z
|
|
262
|
+
.boolean()
|
|
263
|
+
.optional()
|
|
264
|
+
.describe("Open the finished session in Codex Desktop on Windows or macOS"),
|
|
265
|
+
releaseAfterTurn: z
|
|
266
|
+
.boolean()
|
|
267
|
+
.optional()
|
|
268
|
+
.describe("Stop the bridge app-server after a terminal turn so Codex Desktop owns the writer lock"),
|
|
269
|
+
},
|
|
270
|
+
annotations: {
|
|
271
|
+
readOnlyHint: false,
|
|
272
|
+
destructiveHint: true,
|
|
273
|
+
idempotentHint: false,
|
|
274
|
+
openWorldHint: true,
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
async ({ cwd, prompt, name, timeoutSec, model, effort, openInApp, releaseAfterTurn }) => {
|
|
278
|
+
const shouldOpen = openInApp ?? DEFAULT_OPEN_IN_APP;
|
|
279
|
+
const shouldRelease = releaseAfterTurn ?? DEFAULT_RELEASE_AFTER_TURN;
|
|
280
|
+
const notes = [];
|
|
281
|
+
try {
|
|
282
|
+
const created = await createCodexThread({ cwd, prompt, name, model });
|
|
283
|
+
if (created.workspace.note) notes.push(created.workspace.note);
|
|
284
|
+
if (shouldOpen && !shouldRelease) {
|
|
285
|
+
try {
|
|
286
|
+
notes.push(`opened in Codex app: ${await openThreadInCodexApp(created.threadId)}`);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
notes.push(`could not open the thread in the Codex app: ${err.message}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const result = await runTurn(client, {
|
|
292
|
+
threadId: created.threadId,
|
|
293
|
+
input: [{ type: "text", text: prompt }],
|
|
294
|
+
timeoutMs: (timeoutSec ?? 240) * 1000,
|
|
295
|
+
turnOverrides: {
|
|
296
|
+
...(model ?? DEFAULT_MODEL ? { model: model ?? DEFAULT_MODEL } : {}),
|
|
297
|
+
...(effort ?? DEFAULT_EFFORT ? { effort: effort ?? DEFAULT_EFFORT } : {}),
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
notes.push(
|
|
301
|
+
...(await finishDesktopHandoff({
|
|
302
|
+
threadId: created.threadId,
|
|
303
|
+
result,
|
|
304
|
+
openInApp: shouldOpen,
|
|
305
|
+
releaseAfterTurn: shouldRelease,
|
|
306
|
+
})),
|
|
307
|
+
);
|
|
308
|
+
const failed = result.status === "failed" || result.status === "disconnected";
|
|
309
|
+
return textResult(
|
|
310
|
+
[
|
|
311
|
+
"Delegated to Codex",
|
|
312
|
+
`threadId: ${created.threadId}`,
|
|
313
|
+
`name: ${created.name}`,
|
|
314
|
+
`cwd: ${created.cwd}`,
|
|
315
|
+
`rollout: ${created.rollout}`,
|
|
316
|
+
...notes,
|
|
317
|
+
"",
|
|
318
|
+
formatTurn(result),
|
|
319
|
+
].join("\n"),
|
|
320
|
+
failed,
|
|
321
|
+
);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
return failure(err);
|
|
324
|
+
}
|
|
129
325
|
},
|
|
130
326
|
);
|
|
131
327
|
|
|
@@ -152,10 +348,15 @@ server.registerTool(
|
|
|
152
348
|
.enum(["minimal", "low", "medium", "high", "xhigh", "ultra"])
|
|
153
349
|
.optional()
|
|
154
350
|
.describe(`Override reasoning effort (default ${DEFAULT_EFFORT ?? "whatever ~/.codex/config.toml says"})`),
|
|
351
|
+
name: z.string().min(1).max(200).optional().describe("Optional title to show for this Codex session"),
|
|
155
352
|
openInApp: z
|
|
156
353
|
.boolean()
|
|
157
354
|
.optional()
|
|
158
|
-
.describe("
|
|
355
|
+
.describe("Open the thread in Codex Desktop on Windows or macOS so a human can watch it live"),
|
|
356
|
+
releaseAfterTurn: z
|
|
357
|
+
.boolean()
|
|
358
|
+
.optional()
|
|
359
|
+
.describe("Stop the bridge app-server after a terminal turn so Codex Desktop owns the writer lock"),
|
|
159
360
|
},
|
|
160
361
|
annotations: {
|
|
161
362
|
readOnlyHint: false,
|
|
@@ -164,29 +365,40 @@ server.registerTool(
|
|
|
164
365
|
openWorldHint: true,
|
|
165
366
|
},
|
|
166
367
|
},
|
|
167
|
-
async ({ threadId, prompt, timeoutSec, cwd, model, effort, openInApp }) => {
|
|
168
|
-
|
|
368
|
+
async ({ threadId, prompt, timeoutSec, cwd, model, effort, name, openInApp, releaseAfterTurn }) => {
|
|
369
|
+
const notes = [];
|
|
370
|
+
const shouldOpen = openInApp ?? DEFAULT_OPEN_IN_APP;
|
|
371
|
+
const shouldRelease = releaseAfterTurn ?? DEFAULT_RELEASE_AFTER_TURN;
|
|
169
372
|
try {
|
|
170
|
-
await assertThreadAccess(threadId);
|
|
373
|
+
const authorizedThread = await assertThreadAccess(threadId);
|
|
171
374
|
let resolvedCwd = null;
|
|
172
375
|
if (cwd) {
|
|
173
376
|
const workspace = resolveWorkspacePath(cwd);
|
|
174
377
|
security.assertCwd(workspace.path);
|
|
175
378
|
resolvedCwd = workspace.path;
|
|
176
|
-
if (workspace.note)
|
|
379
|
+
if (workspace.note) notes.push(workspace.note);
|
|
380
|
+
} else if (authorizedThread?.cwd) {
|
|
381
|
+
const workspace = resolveWorkspacePath(authorizedThread.cwd);
|
|
382
|
+
resolvedCwd = workspace.path;
|
|
383
|
+
if (workspace.note) notes.push(workspace.note);
|
|
177
384
|
}
|
|
178
385
|
const attached = await client.ensureThreadAttached(threadId, resolvedCwd ? { cwd: resolvedCwd } : {});
|
|
179
|
-
|
|
386
|
+
const attachedThread = normalizeThreadCwd(attached.thread ?? authorizedThread, { strict: true });
|
|
387
|
+
security.assertCwd(attachedThread?.cwd);
|
|
388
|
+
if (name) {
|
|
389
|
+
await client.call("thread/name/set", { threadId, name: name.trim().slice(0, 200) });
|
|
390
|
+
notes.push(`session name: ${name.trim().slice(0, 200)}`);
|
|
391
|
+
}
|
|
180
392
|
/**
|
|
181
393
|
* Opening the thread in the app comes after both gates. It ran first
|
|
182
394
|
* once, which meant a thread this bridge was about to refuse still got
|
|
183
395
|
* raised on screen - a refusal that leaked which threads exist.
|
|
184
396
|
*/
|
|
185
|
-
if (
|
|
397
|
+
if (shouldOpen && !shouldRelease) {
|
|
186
398
|
try {
|
|
187
|
-
|
|
399
|
+
notes.push(`opened in Codex app: ${await openThreadInCodexApp(threadId)}`);
|
|
188
400
|
} catch (err) {
|
|
189
|
-
|
|
401
|
+
notes.push(`could not open the thread in the Codex app: ${err.message}`);
|
|
190
402
|
}
|
|
191
403
|
}
|
|
192
404
|
const result = await runTurn(client, {
|
|
@@ -201,8 +413,14 @@ server.registerTool(
|
|
|
201
413
|
});
|
|
202
414
|
const body = formatTurn(result);
|
|
203
415
|
const failed = result.status === "failed" || result.status === "disconnected";
|
|
204
|
-
|
|
205
|
-
|
|
416
|
+
notes.push(...(await finishDesktopHandoff({
|
|
417
|
+
threadId,
|
|
418
|
+
result,
|
|
419
|
+
openInApp: shouldOpen,
|
|
420
|
+
releaseAfterTurn: shouldRelease,
|
|
421
|
+
})));
|
|
422
|
+
const held = shouldOpen && !shouldRelease && client.holdsThread(threadId) ? writerLockWarning(threadId) : "";
|
|
423
|
+
return textResult(`${notes.length ? `${notes.join("\n")}\n` : ""}${body}${held}`, failed);
|
|
206
424
|
} catch (err) {
|
|
207
425
|
return failure(err);
|
|
208
426
|
}
|
|
@@ -240,7 +458,15 @@ server.registerTool(
|
|
|
240
458
|
if (searchTerm) params.searchTerm = searchTerm;
|
|
241
459
|
const method = loadedOnly ? "thread/loaded/list" : "thread/list";
|
|
242
460
|
const res = await client.call(method, loadedOnly ? { limit: limit ?? 15 } : params);
|
|
243
|
-
const rows = security.filterThreads(
|
|
461
|
+
const rows = security.filterThreads(
|
|
462
|
+
(res?.data ?? res?.threads ?? []).flatMap((thread) => {
|
|
463
|
+
try {
|
|
464
|
+
return [normalizeThreadCwd(thread, { strict: true })];
|
|
465
|
+
} catch {
|
|
466
|
+
return [];
|
|
467
|
+
}
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
244
470
|
if (!rows.length) {
|
|
245
471
|
return textResult(
|
|
246
472
|
security.summary().allowedRoots.length
|
|
@@ -266,6 +492,7 @@ server.registerTool(
|
|
|
266
492
|
inputSchema: {
|
|
267
493
|
cwd: z.string().describe("Absolute working directory for the new Codex session"),
|
|
268
494
|
model: z.string().optional().describe("Model override, e.g. gpt-5.6-luna"),
|
|
495
|
+
name: z.string().min(1).max(200).optional().describe("Optional title to show for the new Codex session"),
|
|
269
496
|
},
|
|
270
497
|
annotations: {
|
|
271
498
|
readOnlyHint: false,
|
|
@@ -274,28 +501,17 @@ server.registerTool(
|
|
|
274
501
|
openWorldHint: true,
|
|
275
502
|
},
|
|
276
503
|
},
|
|
277
|
-
async ({ cwd, model }) => {
|
|
504
|
+
async ({ cwd, model, name }) => {
|
|
278
505
|
try {
|
|
279
|
-
const
|
|
280
|
-
security.assertCwd(workspace.path);
|
|
281
|
-
const res = await client.call("thread/start", {
|
|
282
|
-
cwd: workspace.path,
|
|
283
|
-
...(model ?? DEFAULT_MODEL ? { model: model ?? DEFAULT_MODEL } : {}),
|
|
284
|
-
approvalPolicy: security.approvalPolicy,
|
|
285
|
-
sandbox: security.sandbox,
|
|
286
|
-
});
|
|
287
|
-
const thread = res?.thread ?? {};
|
|
288
|
-
if (thread.id) {
|
|
289
|
-
client.markAttached(thread.id, thread);
|
|
290
|
-
security.registerThread(thread.id);
|
|
291
|
-
}
|
|
506
|
+
const created = await createCodexThread({ cwd, model, name });
|
|
292
507
|
return textResult(
|
|
293
508
|
[
|
|
294
509
|
"Created Codex thread",
|
|
295
|
-
` threadId: ${
|
|
296
|
-
`
|
|
297
|
-
`
|
|
298
|
-
|
|
510
|
+
` threadId: ${created.threadId}`,
|
|
511
|
+
` name: ${created.name}`,
|
|
512
|
+
` cwd: ${created.cwd}`,
|
|
513
|
+
` rollout: ${created.rollout}`,
|
|
514
|
+
...(created.workspace.note ? [` note: ${created.workspace.note}`] : []),
|
|
299
515
|
].join("\n"),
|
|
300
516
|
);
|
|
301
517
|
} catch (err) {
|
|
@@ -322,7 +538,7 @@ server.registerTool(
|
|
|
322
538
|
try {
|
|
323
539
|
await assertThreadAccess(threadId);
|
|
324
540
|
const res = await client.call("thread/read", { threadId, includeTurns: true });
|
|
325
|
-
const thread = res?.thread ?? res ?? {};
|
|
541
|
+
const thread = normalizeThreadCwd(res?.thread ?? res ?? {}, { strict: true });
|
|
326
542
|
security.assertCwd(thread.cwd);
|
|
327
543
|
const items = (thread.turns ?? []).flatMap((t) => t.items ?? []);
|
|
328
544
|
const msgs = items
|
|
@@ -364,8 +580,9 @@ server.registerTool(
|
|
|
364
580
|
async ({ threadId, turnId }) => {
|
|
365
581
|
try {
|
|
366
582
|
await assertThreadAccess(threadId);
|
|
367
|
-
const
|
|
368
|
-
|
|
583
|
+
const res = await client.call("thread/read", { threadId });
|
|
584
|
+
const thread = normalizeThreadCwd(res?.thread ?? res ?? {}, { strict: true });
|
|
585
|
+
security.assertCwd(thread.cwd);
|
|
369
586
|
await client.call("turn/interrupt", { threadId, turnId });
|
|
370
587
|
return textResult(`Interrupted turn ${turnId} in thread ${threadId}.`);
|
|
371
588
|
} catch (err) {
|
|
@@ -379,7 +596,7 @@ server.registerTool(
|
|
|
379
596
|
{
|
|
380
597
|
title: "Open a Codex thread in the desktop app",
|
|
381
598
|
description:
|
|
382
|
-
"
|
|
599
|
+
"Bring a Codex thread to the front on Windows or macOS using (codex://threads/<id>) " +
|
|
383
600
|
"so a human can watch the work live instead of reading the transcript afterwards.",
|
|
384
601
|
inputSchema: {
|
|
385
602
|
threadId: z.string().describe("Codex thread id"),
|
|
@@ -398,8 +615,9 @@ server.registerTool(
|
|
|
398
615
|
async ({ threadId, background }) => {
|
|
399
616
|
try {
|
|
400
617
|
await assertThreadAccess(threadId);
|
|
401
|
-
const
|
|
402
|
-
|
|
618
|
+
const res = await client.call("thread/read", { threadId });
|
|
619
|
+
const thread = normalizeThreadCwd(res?.thread ?? res ?? {}, { strict: true });
|
|
620
|
+
security.assertCwd(thread.cwd);
|
|
403
621
|
const url = await openThreadInCodexApp(threadId, { activate: !background });
|
|
404
622
|
const held = client.holdsThread(threadId) ? writerLockWarning(threadId) : "";
|
|
405
623
|
return textResult(`Opened ${url} in the Codex desktop app.${held}`);
|
|
@@ -445,7 +663,7 @@ server.registerTool(
|
|
|
445
663
|
title: "Check the Codex bridge environment",
|
|
446
664
|
description:
|
|
447
665
|
"Report how this bridge is wired on the current machine: platform, resolved codex binary, " +
|
|
448
|
-
"app-server endpoint and whether it is live, plus
|
|
666
|
+
"app-server endpoint and whether it is live, plus desktop deep-link support and macOS integrations.",
|
|
449
667
|
inputSchema: {},
|
|
450
668
|
annotations: {
|
|
451
669
|
readOnlyHint: true,
|
|
@@ -453,6 +671,7 @@ server.registerTool(
|
|
|
453
671
|
},
|
|
454
672
|
},
|
|
455
673
|
async () => {
|
|
674
|
+
const summary = security.summary();
|
|
456
675
|
const up = await client.isServerUp();
|
|
457
676
|
let liveThreads = null;
|
|
458
677
|
if (up) {
|
|
@@ -471,7 +690,8 @@ server.registerTool(
|
|
|
471
690
|
`defaults: model ${DEFAULT_MODEL ?? "(from ~/.codex/config.toml)"}, effort ${DEFAULT_EFFORT ?? "(from ~/.codex/config.toml)"}`,
|
|
472
691
|
`app-server: ${client.url} - ${up ? "live" : "not reachable"}`,
|
|
473
692
|
`autostart: ${client.autoStart ? "on" : "off"} approvals: ${client.approval}`,
|
|
474
|
-
`
|
|
693
|
+
`desktop links: ${supportsCodexThreadLinks() ? "codex:// available" : "not available on this platform"}`,
|
|
694
|
+
`security: thread policy ${security.threadPolicy} (${summary.allowAllThreads ? "all threads" : `${summary.authorizedThreads} pre-authorized thread(s)`}), ${summary.allowAllRoots ? "all directories" : `${summary.allowedRoots.length} allowed root(s)`}, sandbox ${security.sandbox}, approvals ${security.approvalPolicy}`,
|
|
475
695
|
`live threads: ${liveThreads ?? "(unknown)"}`,
|
|
476
696
|
`claude desktop config: ${claudeDesktopConfigPath()}`,
|
|
477
697
|
];
|
package/src/platform.mjs
CHANGED
|
@@ -46,6 +46,7 @@ export const homeDir = () => process.env.HOME ?? process.env.USERPROFILE ?? os.h
|
|
|
46
46
|
*/
|
|
47
47
|
const FOREIGN_ROOT_PATTERNS = [
|
|
48
48
|
/^[A-Za-z]:\/(.+)$/,
|
|
49
|
+
/^\/\/[^/]+\/[^/]+\/(.+)$/,
|
|
49
50
|
/^\/Volumes\/[^/]+\/(.+)$/,
|
|
50
51
|
/^\/mnt\/[^/]+\/(.+)$/,
|
|
51
52
|
/^\/media\/[^/]+\/[^/]+\/(.+)$/,
|
|
@@ -179,6 +180,55 @@ function normalizeSeparators(input) {
|
|
|
179
180
|
return input.replace(/\\/g, "/").replace(/\/+$/, "") || input;
|
|
180
181
|
}
|
|
181
182
|
|
|
183
|
+
function isNetworkPath(input) {
|
|
184
|
+
return normalizeSeparators(input).startsWith("//");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isSameOrWithin(root, candidate) {
|
|
188
|
+
const relative = path.relative(root, candidate);
|
|
189
|
+
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Absolute paths in a Claude session can refer to a stale mount or checkout
|
|
194
|
+
* that happens to remain as an empty directory on this machine. An explicit
|
|
195
|
+
* JSON map is the deterministic escape hatch: it is checked before the input
|
|
196
|
+
* path, while the normal workspace-root policy still validates the result.
|
|
197
|
+
*
|
|
198
|
+
* Example:
|
|
199
|
+
* {"L:\\codex-mcp-bridge":"C:\\codex-mcp-bridge"}
|
|
200
|
+
*/
|
|
201
|
+
function configuredPathMaps() {
|
|
202
|
+
const raw = process.env.CODEX_BRIDGE_PATH_MAP;
|
|
203
|
+
if (!raw) return [];
|
|
204
|
+
|
|
205
|
+
let parsed;
|
|
206
|
+
try {
|
|
207
|
+
parsed = JSON.parse(raw);
|
|
208
|
+
} catch {
|
|
209
|
+
throw new Error("Invalid CODEX_BRIDGE_PATH_MAP: expected a JSON object of absolute source paths to targets");
|
|
210
|
+
}
|
|
211
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
212
|
+
throw new Error("Invalid CODEX_BRIDGE_PATH_MAP: expected a JSON object of absolute source paths to targets");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return Object.entries(parsed)
|
|
216
|
+
.map(([from, to]) => {
|
|
217
|
+
if (typeof from !== "string" || typeof to !== "string" || !path.isAbsolute(from) || !path.isAbsolute(to)) {
|
|
218
|
+
throw new Error("Invalid CODEX_BRIDGE_PATH_MAP: every source and target must be an absolute path");
|
|
219
|
+
}
|
|
220
|
+
return { from: path.resolve(from), to: path.resolve(to) };
|
|
221
|
+
})
|
|
222
|
+
.sort((a, b) => b.from.length - a.from.length);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function pathMapCandidates(input) {
|
|
226
|
+
const candidate = path.resolve(input);
|
|
227
|
+
return configuredPathMaps()
|
|
228
|
+
.filter(({ from }) => isSameOrWithin(from, candidate))
|
|
229
|
+
.map(({ from, to }) => path.join(to, path.relative(from, candidate)));
|
|
230
|
+
}
|
|
231
|
+
|
|
182
232
|
function foreignRootRelative(input) {
|
|
183
233
|
const normalized = normalizeSeparators(input);
|
|
184
234
|
for (const pattern of FOREIGN_ROOT_PATTERNS) {
|
|
@@ -203,16 +253,22 @@ function remapCandidates(input) {
|
|
|
203
253
|
* looking at, or one the agent cannot write to - which then stalls the turn on
|
|
204
254
|
* a permission request instead of failing outright.
|
|
205
255
|
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
256
|
+
* An explicit map takes precedence for a stale alias. Otherwise the path as
|
|
257
|
+
* given goes first: if this machine can already write there, no rewriting is
|
|
258
|
+
* warranted and guessing would be the bug. Rewriting only happens for a path
|
|
259
|
+
* this machine cannot use, which is exactly the case it was written for - a
|
|
260
|
+
* foreign drive, mount, or UNC share can be visible while the original
|
|
261
|
+
* checkout is unavailable locally. UNC shares are tried after local remap
|
|
262
|
+
* candidates so a disconnected network location cannot stall the bridge.
|
|
211
263
|
*/
|
|
212
264
|
export function resolveWorkspacePath(input) {
|
|
213
265
|
if (!input) return { path: input, remapped: false, writable: false, note: null };
|
|
214
266
|
const original = input;
|
|
215
|
-
const
|
|
267
|
+
const mapped = pathMapCandidates(input);
|
|
268
|
+
const remapped = remapEnabled() ? remapCandidates(input) : [];
|
|
269
|
+
const candidates = isNetworkPath(input)
|
|
270
|
+
? [...mapped, ...remapped, input]
|
|
271
|
+
: [...mapped, input, ...remapped];
|
|
216
272
|
const ordered = candidates.filter((candidate, index) => candidates.indexOf(candidate) === index);
|
|
217
273
|
|
|
218
274
|
const writable = ordered.find((candidate) => existsSync(candidate) && isWritableDir(candidate));
|
|
@@ -223,7 +279,9 @@ export function resolveWorkspacePath(input) {
|
|
|
223
279
|
writable: true,
|
|
224
280
|
note:
|
|
225
281
|
writable !== original
|
|
226
|
-
?
|
|
282
|
+
? mapped.includes(writable)
|
|
283
|
+
? `cwd mapped for ${PLATFORM_LABEL} via CODEX_BRIDGE_PATH_MAP: ${original} -> ${writable}`
|
|
284
|
+
: `cwd remapped for ${PLATFORM_LABEL}: ${original} -> ${writable}`
|
|
227
285
|
: null,
|
|
228
286
|
};
|
|
229
287
|
}
|
|
@@ -282,6 +340,10 @@ export function hasCodexDesktopApp() {
|
|
|
282
340
|
return IS_MACOS && existsSync(CODEX_DESKTOP_APP_MACOS);
|
|
283
341
|
}
|
|
284
342
|
|
|
343
|
+
export function supportsCodexThreadLinks() {
|
|
344
|
+
return IS_WINDOWS || hasCodexDesktopApp();
|
|
345
|
+
}
|
|
346
|
+
|
|
285
347
|
export function isLaunchAgentInstalled() {
|
|
286
348
|
return IS_MACOS && existsSync(launchAgentPath());
|
|
287
349
|
}
|
|
@@ -304,12 +366,17 @@ export function isDesktopAppServerRunning() {
|
|
|
304
366
|
/**
|
|
305
367
|
* Bring a thread to the foreground in the Codex desktop app so a human can
|
|
306
368
|
* watch the turn run instead of only reading the transcript afterwards.
|
|
307
|
-
*
|
|
369
|
+
* Windows registers the `codex://` scheme through the packaged Codex app, and
|
|
370
|
+
* macOS registers it through /Applications/ChatGPT.app.
|
|
308
371
|
*/
|
|
309
372
|
export async function openThreadInCodexApp(threadId, { activate = true } = {}) {
|
|
310
373
|
const url = codexThreadUrl(threadId);
|
|
374
|
+
if (IS_WINDOWS) {
|
|
375
|
+
await execFileAsync("explorer.exe", [url], { windowsHide: true, timeout: 10000 });
|
|
376
|
+
return url;
|
|
377
|
+
}
|
|
311
378
|
if (!IS_MACOS) {
|
|
312
|
-
throw new Error(`Opening a Codex thread in the desktop app is macOS
|
|
379
|
+
throw new Error(`Opening a Codex thread in the desktop app is supported on Windows and macOS. Open ${url} manually.`);
|
|
313
380
|
}
|
|
314
381
|
if (!hasCodexDesktopApp()) {
|
|
315
382
|
throw new Error(`Codex desktop app not found at ${CODEX_DESKTOP_APP_MACOS}. Install it to use ${url}.`);
|
package/src/security-policy.mjs
CHANGED
|
@@ -4,6 +4,8 @@ import path from "node:path";
|
|
|
4
4
|
const APPROVAL_POLICIES = new Set(["untrusted", "on-failure", "on-request", "never"]);
|
|
5
5
|
const SANDBOXES = new Set(["read-only", "workspace-write"]);
|
|
6
6
|
const THREAD_POLICIES = new Set(["owned", "roots"]);
|
|
7
|
+
const ALLOW_ALL_ROOTS = "*";
|
|
8
|
+
const ALLOW_ALL_THREADS = "*";
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Resolves the deepest ancestor that exists and re-appends the rest, rather
|
|
@@ -49,11 +51,15 @@ function parseList(value) {
|
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
function parseRoots(value) {
|
|
52
|
-
|
|
54
|
+
const entries = String(value ?? "")
|
|
53
55
|
.split(path.delimiter)
|
|
54
56
|
.map((entry) => entry.trim())
|
|
55
|
-
.filter(Boolean)
|
|
56
|
-
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
allowAll: entries.includes(ALLOW_ALL_ROOTS),
|
|
61
|
+
roots: entries.filter((entry) => entry !== ALLOW_ALL_ROOTS).map(canonicalPath),
|
|
62
|
+
};
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
export function assertAllowedAppServerUrl(value) {
|
|
@@ -78,8 +84,12 @@ export function assertAllowedAppServerUrl(value) {
|
|
|
78
84
|
|
|
79
85
|
export class BridgeSecurityPolicy {
|
|
80
86
|
constructor(env = process.env) {
|
|
81
|
-
|
|
82
|
-
this.
|
|
87
|
+
const configuredThreads = parseList(env.CODEX_BRIDGE_ALLOWED_THREADS);
|
|
88
|
+
this.allowAllThreads = configuredThreads.has(ALLOW_ALL_THREADS);
|
|
89
|
+
this.allowedThreadIds = new Set([...configuredThreads].filter((threadId) => threadId !== ALLOW_ALL_THREADS));
|
|
90
|
+
const roots = parseRoots(env.CODEX_BRIDGE_ALLOWED_ROOTS ?? ALLOW_ALL_ROOTS);
|
|
91
|
+
this.allowAllRoots = roots.allowAll;
|
|
92
|
+
this.allowedRoots = roots.roots;
|
|
83
93
|
this.ownedThreadIds = new Set();
|
|
84
94
|
|
|
85
95
|
/**
|
|
@@ -130,7 +140,7 @@ export class BridgeSecurityPolicy {
|
|
|
130
140
|
* inside a root is refused exactly like one placed outside it.
|
|
131
141
|
*/
|
|
132
142
|
isThreadAuthorized(threadId, cwd) {
|
|
133
|
-
if (this.ownedThreadIds.has(threadId) || this.allowedThreadIds.has(threadId)) return true;
|
|
143
|
+
if (this.allowAllThreads || this.ownedThreadIds.has(threadId) || this.allowedThreadIds.has(threadId)) return true;
|
|
134
144
|
if (this.threadPolicy !== "roots") return false;
|
|
135
145
|
return cwd == null ? false : this.isCwdAuthorized(cwd);
|
|
136
146
|
}
|
|
@@ -179,13 +189,15 @@ export class BridgeSecurityPolicy {
|
|
|
179
189
|
}
|
|
180
190
|
|
|
181
191
|
isCwdAuthorized(cwd) {
|
|
182
|
-
if (!
|
|
192
|
+
if (!cwd) return false;
|
|
193
|
+
if (this.allowAllRoots) return true;
|
|
194
|
+
if (!this.allowedRoots.length) return false;
|
|
183
195
|
const candidate = canonicalPath(cwd);
|
|
184
196
|
return this.allowedRoots.some((root) => isWithin(root, candidate));
|
|
185
197
|
}
|
|
186
198
|
|
|
187
199
|
assertCwd(cwd) {
|
|
188
|
-
if (!this.allowedRoots.length) {
|
|
200
|
+
if (!this.allowAllRoots && !this.allowedRoots.length) {
|
|
189
201
|
throw new Error(
|
|
190
202
|
"No authorized workspace roots are configured. Set CODEX_BRIDGE_ALLOWED_ROOTS to one or more project directories.",
|
|
191
203
|
);
|
|
@@ -197,7 +209,9 @@ export class BridgeSecurityPolicy {
|
|
|
197
209
|
summary() {
|
|
198
210
|
return {
|
|
199
211
|
authorizedThreads: this.allowedThreadIds.size + this.ownedThreadIds.size,
|
|
200
|
-
|
|
212
|
+
allowAllThreads: this.allowAllThreads,
|
|
213
|
+
allowedRoots: this.allowAllRoots ? [ALLOW_ALL_ROOTS] : this.allowedRoots,
|
|
214
|
+
allowAllRoots: this.allowAllRoots,
|
|
201
215
|
threadPolicy: this.threadPolicy,
|
|
202
216
|
approvalPolicy: this.approvalPolicy,
|
|
203
217
|
sandbox: this.sandbox,
|