@minhspark/codex-mcp-bridge 1.10.1 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](https://semver.org/).
4
4
 
5
+ ## [1.11.0] - 2026-08-23
6
+
7
+ ### Added
8
+
9
+ - `CODEX_BRIDGE_THREAD_POLICY` selects what authorizes a thread: `owned` (unchanged default) or `roots`.
10
+
11
+ Under the only behaviour that existed before, a thread opened in the Codex app or the VS Code extension
12
+ was not merely restricted - it was unreachable. Codex assigns the thread ID at the moment it opens, so
13
+ the ID cannot have been listed in `CODEX_BRIDGE_ALLOWED_THREADS` beforehand, and the bridge-owned set
14
+ lives in memory and empties on every MCP server restart. The listing showed those threads, because
15
+ listing is gated on the workspace, and then every send into one answered `not authorized`. On a machine
16
+ with ten live threads, nine of them were unreachable and the tenth only because the bridge had just
17
+ created it.
18
+
19
+ `roots` grants on the workspace instead of the ID: a thread already working inside a directory named in
20
+ `CODEX_BRIDGE_ALLOWED_ROOTS` is reachable. That is the same containment every tool already enforces on
21
+ the `cwd` it is handed, applied to the `cwd` the thread reports. It is opt-in so an existing install
22
+ cannot widen silently on upgrade, and an unknown workspace fails closed exactly like one outside the
23
+ roots.
24
+
25
+ ### Fixed
26
+
27
+ - Authorization now happens **before** `thread/resume`. Attaching takes the per-thread writer lock away
28
+ from whoever else has the thread open, so deciding afterwards would have locked a thread on its way to
29
+ being refused.
30
+ - `openInApp` no longer raises a thread on screen before that thread is known to be in scope - a refusal
31
+ that leaked which threads exist.
32
+ - `codex_bridge_status` printed the approval policy under a `thread policy` label. Two different settings:
33
+ one was misreported, the other invisible. It now names both.
34
+ - The `NOT AUTHORIZED` line in `list_codex_threads` pointed only at `CODEX_BRIDGE_ALLOWED_THREADS`, which
35
+ is the option that cannot work for a thread a human just opened. It now names the policy as well.
36
+
5
37
  ## [1.10.1] - 2026-08-20
6
38
 
7
39
  ### Fixed
package/README.md CHANGED
@@ -262,7 +262,16 @@ On macOS and Linux the `codex` launcher is a Node script with a `#!/usr/bin/env
262
262
 
263
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`.
264
264
 
265
- Thread operations are deny-by-default. Set `CODEX_BRIDGE_ALLOWED_THREADS` to a comma-separated list of exact thread IDs you want this MCP server to access; threads created by `start_codex_thread` are authorized for the lifetime of this bridge process. 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.
265
+ Thread operations are deny-by-default, and `CODEX_BRIDGE_THREAD_POLICY` decides what counts as permission:
266
+
267
+ | Policy | A thread is reachable when | Use it when |
268
+ |---|---|---|
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 |
271
+
272
+ 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
+
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*.
266
275
 
267
276
  ## Tools — `claude-bridge` (runs inside Codex)
268
277
 
@@ -364,6 +373,7 @@ The bridge reads these from the environment its MCP client hands it — there is
364
373
  | `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. |
365
374
  | `CODEX_BIN` | auto-detected | Path to `codex` used for autostart. |
366
375
  | `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. |
367
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. |
368
378
  | `CODEX_BRIDGE_ALLOWED_ROOTS` | empty (installer sets its repo root) | Absolute project directories permitted for `cwd`, separated by `:` (`;` on Windows). |
369
379
  | `CODEX_BRIDGE_APPROVAL` | `deny` | How to answer approval requests from Codex. `approve` is ignored unless `CODEX_BRIDGE_AUTO_APPROVE_ACK=1` is also set. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.10.1",
3
+ "version": "1.11.0",
4
4
  "description": "Two-way MCP bridge between Claude and Codex: prompts into a live Codex thread, messages into a running Claude Code session.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -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.10.1";
11
+ const VERSION = "1.11.0";
12
12
  const FORWARD_MIN_INTERVAL_MS = 5000;
13
13
  const FORWARD_MAX_PER_SESSION = 50;
14
14
 
package/src/index.mjs CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  import { runTurn } from "./turn.mjs";
20
20
  import { BridgeSecurityPolicy } from "./security-policy.mjs";
21
21
 
22
- const VERSION = "1.10.1";
22
+ const VERSION = "1.11.0";
23
23
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
24
24
 
25
25
  /**
@@ -43,14 +43,38 @@ const textResult = (text, isError = false) => ({
43
43
 
44
44
  const failure = (err) => textResult(`Codex bridge error: ${err?.message ?? String(err)}`, true);
45
45
 
46
+ /**
47
+ * Decides whether this bridge may act on a thread, before anything acts on it.
48
+ *
49
+ * Under `roots` the answer depends on where the thread works, which only
50
+ * `thread/read` reports - and it must be asked before `thread/resume`, because
51
+ * resuming takes the per-thread writer lock away from whoever else has the
52
+ * thread open. Reading first means a thread outside every root is refused
53
+ * without ever being locked. A thread the bridge already owns or the operator
54
+ * allowlisted skips the round-trip entirely: its answer cannot change.
55
+ */
56
+ async function assertThreadAccess(threadId) {
57
+ if (security.isThreadAuthorized(threadId)) return null;
58
+ if (security.threadPolicy !== "roots") {
59
+ security.assertThread(threadId);
60
+ return null;
61
+ }
62
+ const res = await client.call("thread/read", { threadId });
63
+ const thread = res?.thread ?? res ?? {};
64
+ security.assertThread(threadId, thread.cwd);
65
+ security.assertCwd(thread.cwd);
66
+ return thread;
67
+ }
68
+
46
69
  function formatThreadRow(t) {
47
70
  const title = t.name || (t.preview ?? "").replace(/\s+/g, " ").slice(0, 70) || "(no title)";
48
71
  const updated = t.updatedAt ? new Date(t.updatedAt * 1000).toISOString().replace("T", " ").slice(0, 16) : "?";
49
72
  const status = t.status?.type ?? "?";
50
73
  const deepLink = IS_MACOS && hasCodexDesktopApp() ? `\n open: ${codexThreadUrl(t.id)}` : "";
51
- const authorized = security.isThreadAuthorized(t.id)
74
+ const authorized = security.isThreadAuthorized(t.id, t.cwd)
52
75
  ? ""
53
- : "\n NOT AUTHORIZED: add this id to CODEX_BRIDGE_ALLOWED_THREADS to send into it";
76
+ : "\n NOT AUTHORIZED: add this id to CODEX_BRIDGE_ALLOWED_THREADS, or set " +
77
+ "CODEX_BRIDGE_THREAD_POLICY=roots to reach every thread inside an allowed root";
54
78
  return `- ${t.id}\n title: ${title}\n cwd: ${t.cwd ?? "?"}\n updated: ${updated} status: ${status} source: ${t.source ?? "?"}${deepLink}${authorized}`;
55
79
  }
56
80
 
@@ -143,14 +167,7 @@ server.registerTool(
143
167
  async ({ threadId, prompt, timeoutSec, cwd, model, effort, openInApp }) => {
144
168
  let openNote = null;
145
169
  try {
146
- security.assertThread(threadId);
147
- if (openInApp) {
148
- try {
149
- openNote = `opened in Codex app: ${await openThreadInCodexApp(threadId)}`;
150
- } catch (err) {
151
- openNote = `could not open the thread in the Codex app: ${err.message}`;
152
- }
153
- }
170
+ await assertThreadAccess(threadId);
154
171
  let resolvedCwd = null;
155
172
  if (cwd) {
156
173
  const workspace = resolveWorkspacePath(cwd);
@@ -160,6 +177,18 @@ server.registerTool(
160
177
  }
161
178
  const attached = await client.ensureThreadAttached(threadId, resolvedCwd ? { cwd: resolvedCwd } : {});
162
179
  security.assertCwd(attached.thread?.cwd);
180
+ /**
181
+ * Opening the thread in the app comes after both gates. It ran first
182
+ * once, which meant a thread this bridge was about to refuse still got
183
+ * raised on screen - a refusal that leaked which threads exist.
184
+ */
185
+ if (openInApp) {
186
+ try {
187
+ openNote = `opened in Codex app: ${await openThreadInCodexApp(threadId)}`;
188
+ } catch (err) {
189
+ openNote = `could not open the thread in the Codex app: ${err.message}`;
190
+ }
191
+ }
163
192
  const result = await runTurn(client, {
164
193
  threadId,
165
194
  input: [{ type: "text", text: prompt }],
@@ -291,7 +320,7 @@ server.registerTool(
291
320
  },
292
321
  async ({ threadId, limit }) => {
293
322
  try {
294
- security.assertThread(threadId);
323
+ await assertThreadAccess(threadId);
295
324
  const res = await client.call("thread/read", { threadId, includeTurns: true });
296
325
  const thread = res?.thread ?? res ?? {};
297
326
  security.assertCwd(thread.cwd);
@@ -334,7 +363,7 @@ server.registerTool(
334
363
  },
335
364
  async ({ threadId, turnId }) => {
336
365
  try {
337
- security.assertThread(threadId);
366
+ await assertThreadAccess(threadId);
338
367
  const thread = await client.call("thread/read", { threadId });
339
368
  security.assertCwd((thread?.thread ?? thread)?.cwd);
340
369
  await client.call("turn/interrupt", { threadId, turnId });
@@ -368,7 +397,7 @@ server.registerTool(
368
397
  },
369
398
  async ({ threadId, background }) => {
370
399
  try {
371
- security.assertThread(threadId);
400
+ await assertThreadAccess(threadId);
372
401
  const thread = await client.call("thread/read", { threadId });
373
402
  security.assertCwd((thread?.thread ?? thread)?.cwd);
374
403
  const url = await openThreadInCodexApp(threadId, { activate: !background });
@@ -442,7 +471,7 @@ server.registerTool(
442
471
  `defaults: model ${DEFAULT_MODEL ?? "(from ~/.codex/config.toml)"}, effort ${DEFAULT_EFFORT ?? "(from ~/.codex/config.toml)"}`,
443
472
  `app-server: ${client.url} - ${up ? "live" : "not reachable"}`,
444
473
  `autostart: ${client.autoStart ? "on" : "off"} approvals: ${client.approval}`,
445
- `security: ${security.summary().authorizedThreads} authorized thread(s), ${security.summary().allowedRoots.length} allowed root(s), sandbox ${security.sandbox}, thread policy ${security.approvalPolicy}`,
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}`,
446
475
  `live threads: ${liveThreads ?? "(unknown)"}`,
447
476
  `claude desktop config: ${claudeDesktopConfigPath()}`,
448
477
  ];
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
 
4
4
  const APPROVAL_POLICIES = new Set(["untrusted", "on-failure", "on-request", "never"]);
5
5
  const SANDBOXES = new Set(["read-only", "workspace-write"]);
6
+ const THREAD_POLICIES = new Set(["owned", "roots"]);
6
7
 
7
8
  /**
8
9
  * Resolves the deepest ancestor that exists and re-appends the rest, rather
@@ -81,6 +82,30 @@ export class BridgeSecurityPolicy {
81
82
  this.allowedRoots = parseRoots(env.CODEX_BRIDGE_ALLOWED_ROOTS);
82
83
  this.ownedThreadIds = new Set();
83
84
 
85
+ /**
86
+ * Which threads this bridge may act on, beyond the ones it created itself.
87
+ *
88
+ * `owned` was the only behaviour until now, and it does not merely
89
+ * restrict the everyday workflow - it makes it impossible. A thread opened
90
+ * in the Codex app or the VS Code extension is given its id at that
91
+ * moment, so it can never have appeared in CODEX_BRIDGE_ALLOWED_THREADS
92
+ * beforehand; and the bridge-owned set lives in memory, so it empties
93
+ * every time the MCP server restarts. That left the operator allowlisting
94
+ * an id that is already stale by the next turn, and every thread a human
95
+ * actually opened answered "not authorized".
96
+ *
97
+ * `roots` grants on the workspace instead of the id: a thread already
98
+ * working inside a directory the operator declared in scope is reachable.
99
+ * This is not a weaker gate bolted on - it is the same containment every
100
+ * acting tool already enforces on the cwd it is handed, applied to the cwd
101
+ * the thread itself reports. It stays opt-in so an existing install cannot
102
+ * widen silently on upgrade.
103
+ */
104
+ this.threadPolicy = env.CODEX_BRIDGE_THREAD_POLICY ?? "owned";
105
+ if (!THREAD_POLICIES.has(this.threadPolicy)) {
106
+ throw new Error(`CODEX_BRIDGE_THREAD_POLICY must be owned or roots: ${this.threadPolicy}`);
107
+ }
108
+
84
109
  this.approvalPolicy = env.CODEX_BRIDGE_APPROVAL_POLICY ?? "on-request";
85
110
  if (!APPROVAL_POLICIES.has(this.approvalPolicy)) {
86
111
  throw new Error(`Invalid CODEX_BRIDGE_APPROVAL_POLICY: ${this.approvalPolicy}`);
@@ -96,18 +121,49 @@ export class BridgeSecurityPolicy {
96
121
  if (threadId) this.ownedThreadIds.add(threadId);
97
122
  }
98
123
 
99
- isThreadAuthorized(threadId) {
100
- return this.ownedThreadIds.has(threadId) || this.allowedThreadIds.has(threadId);
124
+ /**
125
+ * `cwd` is optional because the id almost always arrives before the
126
+ * workspace does: a caller holds an id from a listing, and the cwd is only
127
+ * known once the thread has been read. Under `owned` the answer never
128
+ * depended on the workspace, so omitting it changes nothing. Under `roots`
129
+ * an unknown workspace is never a grant - a thread that cannot be placed
130
+ * inside a root is refused exactly like one placed outside it.
131
+ */
132
+ isThreadAuthorized(threadId, cwd) {
133
+ if (this.ownedThreadIds.has(threadId) || this.allowedThreadIds.has(threadId)) return true;
134
+ if (this.threadPolicy !== "roots") return false;
135
+ return cwd == null ? false : this.isCwdAuthorized(cwd);
101
136
  }
102
137
 
103
- assertThread(threadId) {
104
- if (this.isThreadAuthorized(threadId)) return;
138
+ assertThread(threadId, cwd) {
139
+ if (this.isThreadAuthorized(threadId, cwd)) return;
140
+
141
+ if (this.threadPolicy === "roots") {
142
+ /**
143
+ * Refused here rather than waved through to a later cwd check, because
144
+ * the caller has to attach to a thread before it can act on it, and
145
+ * attaching takes the per-thread writer lock away from whoever else has
146
+ * the thread open. Deciding afterwards would mean a thread outside every
147
+ * root still got locked on the way to being rejected.
148
+ */
149
+ throw new Error(
150
+ cwd == null
151
+ ? `Codex thread ${threadId} reports no workspace, so it cannot be matched against CODEX_BRIDGE_ALLOWED_ROOTS`
152
+ : `Codex thread ${threadId} works in ${cwd}, which is outside CODEX_BRIDGE_ALLOWED_ROOTS`,
153
+ );
154
+ }
155
+
105
156
  if (!this.allowedThreadIds.size && !this.ownedThreadIds.size) {
106
157
  throw new Error(
107
- "No authorized Codex threads are configured. Set CODEX_BRIDGE_ALLOWED_THREADS or create a thread with start_codex_thread.",
158
+ "No authorized Codex threads are configured. Set CODEX_BRIDGE_ALLOWED_THREADS, create one with " +
159
+ "start_codex_thread, or set CODEX_BRIDGE_THREAD_POLICY=roots to reach any thread already working " +
160
+ "inside CODEX_BRIDGE_ALLOWED_ROOTS.",
108
161
  );
109
162
  }
110
- throw new Error(`Codex thread ${threadId} is not authorized for this bridge`);
163
+ throw new Error(
164
+ `Codex thread ${threadId} is not authorized for this bridge. Add it to CODEX_BRIDGE_ALLOWED_THREADS, ` +
165
+ "or set CODEX_BRIDGE_THREAD_POLICY=roots to reach any thread inside CODEX_BRIDGE_ALLOWED_ROOTS.",
166
+ );
111
167
  }
112
168
 
113
169
  /**
@@ -115,8 +171,8 @@ export class BridgeSecurityPolicy {
115
171
  * both ways left no path to a thread id at all: you cannot allowlist a
116
172
  * thread whose id you have no way to learn, so the only usable thread was
117
173
  * one the bridge had created itself. An operator who names a root has
118
- * declared that project in scope, and the id is still useless without being
119
- * allowlisted for the calls that act.
174
+ * declared that project in scope, and under the default `owned` policy the
175
+ * id is still useless without being allowlisted for the calls that act.
120
176
  */
121
177
  filterThreads(threads) {
122
178
  return threads.filter((thread) => this.isCwdAuthorized(thread?.cwd));
@@ -142,6 +198,7 @@ export class BridgeSecurityPolicy {
142
198
  return {
143
199
  authorizedThreads: this.allowedThreadIds.size + this.ownedThreadIds.size,
144
200
  allowedRoots: this.allowedRoots,
201
+ threadPolicy: this.threadPolicy,
145
202
  approvalPolicy: this.approvalPolicy,
146
203
  sandbox: this.sandbox,
147
204
  };