@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 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
- Tell it which projects the bridge may drive. The default is the checkout when you cloned, and the directory you ran the installer from when you installed the package neither is a sensible guess for more than one project, so name them:
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
- Skip this and the bridge still starts cleanly, then answers every `list_codex_threads` with `No workspace roots are configured`. It is the most common way an otherwise correct install refuses to do anything, so the installer now prints a warning when the roots it wrote point inside an install directory.
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. On macOS each row carries a `codex://threads/<id>` deep link. | read-only |
256
- | `start_codex_thread` | Opens a new Codex thread at a permitted `cwd` and returns its `threadId`; the bridge applies its configured safe sandbox and approval policy. | writes |
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
- `send_to_codex_thread` also accepts `timeoutSec` (default 240), `cwd`, `model`, `effort`, and `openInApp` (macOS surface the thread in the desktop app before sending). 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`.
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 deny-by-default, and `CODEX_BRIDGE_THREAD_POLICY` decides what counts as permission:
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 named in `CODEX_BRIDGE_ALLOWED_ROOTS` | you open threads in the Codex app or VS Code and want Claude to talk to them |
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. Set `CODEX_BRIDGE_ALLOWED_ROOTS` to the absolute project directories the bridge may use the installer defaults that root to this repository, so change it when delegating into another project. A root as broad as `/` or `C:\` makes `roots` mean *every thread on this machine*.
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
- - **Threads created by the bridge do not show a title in the app.** The app lists from `~/.codex/session_index.jsonl`, and entries land there only once a thread has been named naming is done by the app, not the app-server. The thread still exists in `~/.codex/state_5.sqlite` and opens via the `codex://threads/<id>` deep link.
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. `open_codex_thread` and `openInApp` now say so at the moment they open a thread the bridge is holding, instead of leaving the app's message to be decoded. Release it with `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.
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
- **The path as given always goes first.** If this machine can already write there, nothing is rewritten guessing over an explicit instruction would be the bug. Rewriting only happens for a path this machine cannot use, which is exactly the case it exists for: macOS mounts NTFS read-only, so the drive a Windows brief quotes is visible and useless at the same time.
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 name 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, which is measured rather than configured. Override the list with `CODEX_BRIDGE_WORKSPACE_ROOTS`, or set `CODEX_BRIDGE_REMAP=0` to switch the rewriting off entirely.
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`). Default is unchanged on upgrade, so an existing install never widens by itself. |
377
- | `CODEX_BRIDGE_ALLOWED_THREADS` | empty | Exact comma-separated thread IDs permitted for read/send/interrupt/open/list. Empty means no pre-existing thread access. |
378
- | `CODEX_BRIDGE_ALLOWED_ROOTS` | empty (installer sets its repo root) | Absolute project directories permitted for `cwd`, separated by `:` (`;` on Windows). |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.11.0",
3
+ "version": "1.11.2",
4
4
  "description": "Two-way MCP bridge between Claude and Codex: prompts into a live Codex thread, messages into a running Claude Code session.",
5
5
  "keywords": [
6
6
  "mcp",
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
- * When the package is installed as a dependency its own directory is never a
13
- * workspace anybody wants to drive Codex in - defaulting to it produces an
14
- * entry that starts fine and then refuses every thread. Fall back to the
15
- * directory the operator ran the installer from instead, and say so out loud.
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 installedAsDependency = root.split(path.sep).includes("node_modules");
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: process.env.CODEX_APP_SERVER_URL ?? "ws://127.0.0.1:8791",
40
- CODEX_BRIDGE_ALLOWED_ROOTS: process.env.CODEX_BRIDGE_ALLOWED_ROOTS ?? defaultRoots,
41
- CODEX_BRIDGE_APPROVAL: process.env.CODEX_BRIDGE_APPROVAL ?? "deny",
42
- CODEX_BRIDGE_APPROVAL_POLICY: process.env.CODEX_BRIDGE_APPROVAL_POLICY ?? "on-request",
43
- CODEX_BRIDGE_SANDBOX: process.env.CODEX_BRIDGE_SANDBOX ?? "workspace-write",
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.replace(/^wss?:\/\//, "").split("/")[0].split(":").pop();
105
- const { execFileSync } = await import("node:child_process");
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.kill(pid, "SIGTERM");
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
- return { stopped: true, pids };
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
  /**
@@ -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.0";
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.0";
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 = IS_MACOS && hasCodexDesktopApp() ? `\n open: ${codexThreadUrl(t.id)}` : "";
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 into a live Codex session. Use list_codex_threads to find the right threadId, " +
126
- "then send_to_codex_thread to push a prompt into that exact thread and read Codex's reply. " +
127
- "On macOS, open_codex_thread (or openInApp) surfaces the thread in the Codex desktop app so a " +
128
- "human can watch it run, and codex_bridge_status reports how the bridge is wired on this machine.",
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("macOS only: open the thread in the Codex desktop app before sending so a human can watch it live"),
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
- let openNote = null;
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) openNote = openNote ? `${openNote}\n${workspace.note}` : 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
- security.assertCwd(attached.thread?.cwd);
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 (openInApp) {
397
+ if (shouldOpen && !shouldRelease) {
186
398
  try {
187
- openNote = `opened in Codex app: ${await openThreadInCodexApp(threadId)}`;
399
+ notes.push(`opened in Codex app: ${await openThreadInCodexApp(threadId)}`);
188
400
  } catch (err) {
189
- openNote = `could not open the thread in the Codex app: ${err.message}`;
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
- const held = openInApp && client.holdsThread(threadId) ? writerLockWarning(threadId) : "";
205
- return textResult(`${openNote ? `${openNote}\n${body}` : body}${held}`, failed);
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(res?.data ?? res?.threads ?? []);
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 workspace = resolveWorkspacePath(cwd);
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: ${thread.id}`,
296
- ` cwd: ${thread.cwd}`,
297
- ` rollout: ${thread.path ?? "(not written yet)"}`,
298
- ...(workspace.note ? [` note: ${workspace.note}`] : []),
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 thread = await client.call("thread/read", { threadId });
368
- security.assertCwd((thread?.thread ?? thread)?.cwd);
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
- "macOS only: bring a Codex thread to the front in the Codex desktop app (codex://threads/<id>) " +
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 thread = await client.call("thread/read", { threadId });
402
- security.assertCwd((thread?.thread ?? thread)?.cwd);
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 the macOS integrations (LaunchAgent, desktop app).",
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
- `security: thread policy ${security.threadPolicy} (${security.summary().authorizedThreads} pre-authorized thread(s)), ${security.summary().allowedRoots.length} allowed root(s), sandbox ${security.sandbox}, approvals ${security.approvalPolicy}`,
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
- * The path as given always goes first: if this machine can already write
207
- * there, no rewriting is warranted and guessing would be the bug. Rewriting
208
- * only happens for a path this machine cannot use, which is exactly the case
209
- * it was written for - macOS mounts NTFS read-only, so the drive a Windows
210
- * brief quotes is visible and useless at the same time.
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 candidates = [input, ...(remapEnabled() ? remapCandidates(input) : [])];
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
- ? `cwd remapped for ${PLATFORM_LABEL}: ${original} -> ${writable}`
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
- * macOS registers the `codex://` scheme through /Applications/ChatGPT.app.
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-only. Open ${url} manually.`);
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}.`);
@@ -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
- return String(value ?? "")
54
+ const entries = String(value ?? "")
53
55
  .split(path.delimiter)
54
56
  .map((entry) => entry.trim())
55
- .filter(Boolean)
56
- .map(canonicalPath);
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
- this.allowedThreadIds = parseList(env.CODEX_BRIDGE_ALLOWED_THREADS);
82
- this.allowedRoots = parseRoots(env.CODEX_BRIDGE_ALLOWED_ROOTS);
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 (!this.allowedRoots.length || !cwd) return false;
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
- allowedRoots: this.allowedRoots,
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,