@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 +32 -0
- package/README.md +11 -1
- package/package.json +1 -1
- package/src/claude-bridge.mjs +1 -1
- package/src/index.mjs +44 -15
- package/src/security-policy.mjs +65 -8
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
|
|
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
package/src/claude-bridge.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { PLATFORM_LABEL } from "./platform.mjs";
|
|
|
8
8
|
import { PeerEndpoint, findClaudeSession, listClaudeSessions, readTranscript } from "./peer-protocol.mjs";
|
|
9
9
|
import { runTurn } from "./turn.mjs";
|
|
10
10
|
|
|
11
|
-
const VERSION = "1.
|
|
11
|
+
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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},
|
|
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
|
];
|
package/src/security-policy.mjs
CHANGED
|
@@ -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
|
-
|
|
100
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
};
|