@minhspark/codex-mcp-bridge 1.10.0 → 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/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.0";
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
  ];
@@ -1,149 +1,206 @@
1
- import { realpathSync } from "node:fs";
2
- import path from "node:path";
3
-
4
- const APPROVAL_POLICIES = new Set(["untrusted", "on-failure", "on-request", "never"]);
5
- const SANDBOXES = new Set(["read-only", "workspace-write"]);
6
-
7
- /**
8
- * Resolves the deepest ancestor that exists and re-appends the rest, rather
9
- * than giving up on the whole path when the leaf is missing. Falling back to
10
- * the unresolved path made containment depend on the platform: macOS puts
11
- * temporary and home directories behind symlinks (/var -> /private/var), so an
12
- * allowed root canonicalised while a missing candidate did not, and the two
13
- * stopped sharing a prefix; on Linux, with no symlink in the way, the same
14
- * pair matched. Same policy, opposite answer, decided by a detail of the disk.
15
- *
16
- * Resolving the existing prefix keeps the protection that matters: a symlink
17
- * pointing out of an allowed root resolves to where it really goes, so it is
18
- * still recognised as outside.
19
- */
20
- function canonicalPath(input) {
21
- const resolved = path.resolve(input);
22
- let head = resolved;
23
- const missing = [];
24
- for (;;) {
25
- try {
26
- return path.join(realpathSync.native(head), ...missing);
27
- } catch {
28
- const parent = path.dirname(head);
29
- if (parent === head) return resolved;
30
- missing.unshift(path.basename(head));
31
- head = parent;
32
- }
33
- }
34
- }
35
-
36
- function isWithin(root, candidate) {
37
- const relative = path.relative(root, candidate);
38
- return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
39
- }
40
-
41
- function parseList(value) {
42
- return new Set(
43
- String(value ?? "")
44
- .split(",")
45
- .map((entry) => entry.trim())
46
- .filter(Boolean),
47
- );
48
- }
49
-
50
- function parseRoots(value) {
51
- return String(value ?? "")
52
- .split(path.delimiter)
53
- .map((entry) => entry.trim())
54
- .filter(Boolean)
55
- .map(canonicalPath);
56
- }
57
-
58
- export function assertAllowedAppServerUrl(value) {
59
- let parsed;
60
- try {
61
- parsed = new URL(value);
62
- } catch {
63
- throw new Error(`Invalid CODEX_APP_SERVER_URL: ${value}`);
64
- }
65
- if (!new Set(["ws:", "wss:"]).has(parsed.protocol)) {
66
- throw new Error(`CODEX_APP_SERVER_URL must use ws:// or wss://: ${value}`);
67
- }
68
- const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
69
- const loopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
70
- if (!loopback) {
71
- throw new Error(
72
- `Refusing non-loopback app-server endpoint ${value}; the bridge only supports authenticated local app-servers`,
73
- );
74
- }
75
- return parsed;
76
- }
77
-
78
- export class BridgeSecurityPolicy {
79
- constructor(env = process.env) {
80
- this.allowedThreadIds = parseList(env.CODEX_BRIDGE_ALLOWED_THREADS);
81
- this.allowedRoots = parseRoots(env.CODEX_BRIDGE_ALLOWED_ROOTS);
82
- this.ownedThreadIds = new Set();
83
-
84
- this.approvalPolicy = env.CODEX_BRIDGE_APPROVAL_POLICY ?? "on-request";
85
- if (!APPROVAL_POLICIES.has(this.approvalPolicy)) {
86
- throw new Error(`Invalid CODEX_BRIDGE_APPROVAL_POLICY: ${this.approvalPolicy}`);
87
- }
88
-
89
- this.sandbox = env.CODEX_BRIDGE_SANDBOX ?? "workspace-write";
90
- if (!SANDBOXES.has(this.sandbox)) {
91
- throw new Error(`CODEX_BRIDGE_SANDBOX must be read-only or workspace-write: ${this.sandbox}`);
92
- }
93
- }
94
-
95
- registerThread(threadId) {
96
- if (threadId) this.ownedThreadIds.add(threadId);
97
- }
98
-
99
- isThreadAuthorized(threadId) {
100
- return this.ownedThreadIds.has(threadId) || this.allowedThreadIds.has(threadId);
101
- }
102
-
103
- assertThread(threadId) {
104
- if (this.isThreadAuthorized(threadId)) return;
105
- if (!this.allowedThreadIds.size && !this.ownedThreadIds.size) {
106
- throw new Error(
107
- "No authorized Codex threads are configured. Set CODEX_BRIDGE_ALLOWED_THREADS or create a thread with start_codex_thread.",
108
- );
109
- }
110
- throw new Error(`Codex thread ${threadId} is not authorized for this bridge`);
111
- }
112
-
113
- /**
114
- * Listing is gated on the workspace root, not on the send allowlist. Gating
115
- * both ways left no path to a thread id at all: you cannot allowlist a
116
- * thread whose id you have no way to learn, so the only usable thread was
117
- * 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.
120
- */
121
- filterThreads(threads) {
122
- return threads.filter((thread) => this.isCwdAuthorized(thread?.cwd));
123
- }
124
-
125
- isCwdAuthorized(cwd) {
126
- if (!this.allowedRoots.length || !cwd) return false;
127
- const candidate = canonicalPath(cwd);
128
- return this.allowedRoots.some((root) => isWithin(root, candidate));
129
- }
130
-
131
- assertCwd(cwd) {
132
- if (!this.allowedRoots.length) {
133
- throw new Error(
134
- "No authorized workspace roots are configured. Set CODEX_BRIDGE_ALLOWED_ROOTS to one or more project directories.",
135
- );
136
- }
137
- if (this.isCwdAuthorized(cwd)) return;
138
- throw new Error(`Working directory is outside CODEX_BRIDGE_ALLOWED_ROOTS: ${cwd}`);
139
- }
140
-
141
- summary() {
142
- return {
143
- authorizedThreads: this.allowedThreadIds.size + this.ownedThreadIds.size,
144
- allowedRoots: this.allowedRoots,
145
- approvalPolicy: this.approvalPolicy,
146
- sandbox: this.sandbox,
147
- };
148
- }
149
- }
1
+ import { realpathSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const APPROVAL_POLICIES = new Set(["untrusted", "on-failure", "on-request", "never"]);
5
+ const SANDBOXES = new Set(["read-only", "workspace-write"]);
6
+ const THREAD_POLICIES = new Set(["owned", "roots"]);
7
+
8
+ /**
9
+ * Resolves the deepest ancestor that exists and re-appends the rest, rather
10
+ * than giving up on the whole path when the leaf is missing. Falling back to
11
+ * the unresolved path made containment depend on the platform: macOS puts
12
+ * temporary and home directories behind symlinks (/var -> /private/var), so an
13
+ * allowed root canonicalised while a missing candidate did not, and the two
14
+ * stopped sharing a prefix; on Linux, with no symlink in the way, the same
15
+ * pair matched. Same policy, opposite answer, decided by a detail of the disk.
16
+ *
17
+ * Resolving the existing prefix keeps the protection that matters: a symlink
18
+ * pointing out of an allowed root resolves to where it really goes, so it is
19
+ * still recognised as outside.
20
+ */
21
+ function canonicalPath(input) {
22
+ const resolved = path.resolve(input);
23
+ let head = resolved;
24
+ const missing = [];
25
+ for (;;) {
26
+ try {
27
+ return path.join(realpathSync.native(head), ...missing);
28
+ } catch {
29
+ const parent = path.dirname(head);
30
+ if (parent === head) return resolved;
31
+ missing.unshift(path.basename(head));
32
+ head = parent;
33
+ }
34
+ }
35
+ }
36
+
37
+ function isWithin(root, candidate) {
38
+ const relative = path.relative(root, candidate);
39
+ return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
40
+ }
41
+
42
+ function parseList(value) {
43
+ return new Set(
44
+ String(value ?? "")
45
+ .split(",")
46
+ .map((entry) => entry.trim())
47
+ .filter(Boolean),
48
+ );
49
+ }
50
+
51
+ function parseRoots(value) {
52
+ return String(value ?? "")
53
+ .split(path.delimiter)
54
+ .map((entry) => entry.trim())
55
+ .filter(Boolean)
56
+ .map(canonicalPath);
57
+ }
58
+
59
+ export function assertAllowedAppServerUrl(value) {
60
+ let parsed;
61
+ try {
62
+ parsed = new URL(value);
63
+ } catch {
64
+ throw new Error(`Invalid CODEX_APP_SERVER_URL: ${value}`);
65
+ }
66
+ if (!new Set(["ws:", "wss:"]).has(parsed.protocol)) {
67
+ throw new Error(`CODEX_APP_SERVER_URL must use ws:// or wss://: ${value}`);
68
+ }
69
+ const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
70
+ const loopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
71
+ if (!loopback) {
72
+ throw new Error(
73
+ `Refusing non-loopback app-server endpoint ${value}; the bridge only supports authenticated local app-servers`,
74
+ );
75
+ }
76
+ return parsed;
77
+ }
78
+
79
+ export class BridgeSecurityPolicy {
80
+ constructor(env = process.env) {
81
+ this.allowedThreadIds = parseList(env.CODEX_BRIDGE_ALLOWED_THREADS);
82
+ this.allowedRoots = parseRoots(env.CODEX_BRIDGE_ALLOWED_ROOTS);
83
+ this.ownedThreadIds = new Set();
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
+
109
+ this.approvalPolicy = env.CODEX_BRIDGE_APPROVAL_POLICY ?? "on-request";
110
+ if (!APPROVAL_POLICIES.has(this.approvalPolicy)) {
111
+ throw new Error(`Invalid CODEX_BRIDGE_APPROVAL_POLICY: ${this.approvalPolicy}`);
112
+ }
113
+
114
+ this.sandbox = env.CODEX_BRIDGE_SANDBOX ?? "workspace-write";
115
+ if (!SANDBOXES.has(this.sandbox)) {
116
+ throw new Error(`CODEX_BRIDGE_SANDBOX must be read-only or workspace-write: ${this.sandbox}`);
117
+ }
118
+ }
119
+
120
+ registerThread(threadId) {
121
+ if (threadId) this.ownedThreadIds.add(threadId);
122
+ }
123
+
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);
136
+ }
137
+
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
+
156
+ if (!this.allowedThreadIds.size && !this.ownedThreadIds.size) {
157
+ throw new Error(
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.",
161
+ );
162
+ }
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
+ );
167
+ }
168
+
169
+ /**
170
+ * Listing is gated on the workspace root, not on the send allowlist. Gating
171
+ * both ways left no path to a thread id at all: you cannot allowlist a
172
+ * thread whose id you have no way to learn, so the only usable thread was
173
+ * one the bridge had created itself. An operator who names a root has
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.
176
+ */
177
+ filterThreads(threads) {
178
+ return threads.filter((thread) => this.isCwdAuthorized(thread?.cwd));
179
+ }
180
+
181
+ isCwdAuthorized(cwd) {
182
+ if (!this.allowedRoots.length || !cwd) return false;
183
+ const candidate = canonicalPath(cwd);
184
+ return this.allowedRoots.some((root) => isWithin(root, candidate));
185
+ }
186
+
187
+ assertCwd(cwd) {
188
+ if (!this.allowedRoots.length) {
189
+ throw new Error(
190
+ "No authorized workspace roots are configured. Set CODEX_BRIDGE_ALLOWED_ROOTS to one or more project directories.",
191
+ );
192
+ }
193
+ if (this.isCwdAuthorized(cwd)) return;
194
+ throw new Error(`Working directory is outside CODEX_BRIDGE_ALLOWED_ROOTS: ${cwd}`);
195
+ }
196
+
197
+ summary() {
198
+ return {
199
+ authorizedThreads: this.allowedThreadIds.size + this.ownedThreadIds.size,
200
+ allowedRoots: this.allowedRoots,
201
+ threadPolicy: this.threadPolicy,
202
+ approvalPolicy: this.approvalPolicy,
203
+ sandbox: this.sandbox,
204
+ };
205
+ }
206
+ }