@minhspark/codex-mcp-bridge 1.10.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.
@@ -0,0 +1,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
+
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
+ }
package/src/turn.mjs ADDED
@@ -0,0 +1,140 @@
1
+ const TERMINAL_STATUSES = new Set(["completed", "interrupted", "failed"]);
2
+
3
+ function summarizeItem(item) {
4
+ switch (item?.type) {
5
+ case "agentMessage":
6
+ return { kind: "agentMessage", text: item.text ?? "" };
7
+ case "commandExecution":
8
+ return {
9
+ kind: "command",
10
+ command: item.command ?? item.parsedCmd ?? null,
11
+ exitCode: item.exitCode ?? null,
12
+ status: item.status ?? null,
13
+ };
14
+ case "fileChange":
15
+ return {
16
+ kind: "fileChange",
17
+ files: (item.changes ?? []).map((c) => c.path ?? c.file ?? null).filter(Boolean),
18
+ status: item.status ?? null,
19
+ };
20
+ case "mcpToolCall":
21
+ return { kind: "mcpToolCall", server: item.server ?? null, tool: item.tool ?? null };
22
+ case "webSearch":
23
+ return { kind: "webSearch", query: item.query ?? null };
24
+ default:
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Send one user turn into an existing Codex thread and wait for it to finish.
31
+ * Returns the agent text plus a compact activity trail.
32
+ */
33
+ export async function runTurn(client, { threadId, input, timeoutMs = 240000, turnOverrides = {} }) {
34
+ const messages = [];
35
+ const activity = [];
36
+ const errors = [];
37
+ const buffered = [];
38
+ let turnId = null;
39
+ let settled = false;
40
+ let resolveDone;
41
+
42
+ /**
43
+ * Only ever resolved, never rejected. `done` has exactly one consumer, and it
44
+ * sits after `turn/start` has already returned - so rejecting it from the
45
+ * catch below would reject a promise nobody is awaiting, which Node turns
46
+ * into an unhandledRejection and, by default, into process exit. A failing
47
+ * `turn/start` (a thread locked by the desktop app is the everyday case) used
48
+ * to take the whole MCP server down that way, while the tool handler was
49
+ * still busy formatting a tidy error message for a client that no longer had
50
+ * a server to talk to.
51
+ */
52
+ const done = new Promise((resolve) => {
53
+ resolveDone = resolve;
54
+ });
55
+
56
+ const process = (msg) => {
57
+ const params = msg.params ?? {};
58
+ if (turnId && params.turnId && params.turnId !== turnId) return;
59
+
60
+ switch (msg.method) {
61
+ case "item/completed": {
62
+ const summary = summarizeItem(params.item);
63
+ if (!summary) return;
64
+ if (summary.kind === "agentMessage") {
65
+ if (summary.text.trim()) messages.push(summary.text);
66
+ } else {
67
+ activity.push(summary);
68
+ }
69
+ return;
70
+ }
71
+ case "error": {
72
+ errors.push(params.error ?? { message: "unknown error" });
73
+ if (!params.willRetry && !settled) {
74
+ settled = true;
75
+ resolveDone({ status: "failed", error: params.error ?? null });
76
+ }
77
+ return;
78
+ }
79
+ case "turn/completed": {
80
+ const turn = params.turn ?? {};
81
+ if (turnId && turn.id && turn.id !== turnId) return;
82
+ if (!TERMINAL_STATUSES.has(turn.status)) return;
83
+ if (settled) return;
84
+ settled = true;
85
+ resolveDone({ status: turn.status, error: turn.error ?? null, durationMs: turn.durationMs ?? null });
86
+ return;
87
+ }
88
+ default:
89
+ }
90
+ };
91
+
92
+ const unsubscribe = client.subscribe(threadId, (msg) => {
93
+ if (!turnId) {
94
+ buffered.push(msg);
95
+ return;
96
+ }
97
+ process(msg);
98
+ });
99
+
100
+ const timer = globalThis.setTimeout(() => {
101
+ if (settled) return;
102
+ settled = true;
103
+ resolveDone({ status: "timeout", error: null });
104
+ }, timeoutMs);
105
+
106
+ const unsubscribeDisconnect = client.subscribeDisconnect(() => {
107
+ if (settled) return;
108
+ settled = true;
109
+ resolveDone({
110
+ status: "disconnected",
111
+ error: { message: "the app-server connection dropped while the turn was running" },
112
+ });
113
+ });
114
+
115
+ try {
116
+ const started = await client.request(
117
+ "turn/start",
118
+ { threadId, input, ...turnOverrides },
119
+ { timeoutMs: Math.min(timeoutMs, 60000) },
120
+ );
121
+ turnId = started?.turn?.id ?? null;
122
+ for (const msg of buffered.splice(0)) process(msg);
123
+
124
+ const outcome = await done;
125
+ return {
126
+ threadId,
127
+ turnId,
128
+ status: outcome.status,
129
+ error: outcome.error,
130
+ durationMs: outcome.durationMs ?? null,
131
+ text: messages.join("\n\n").trim(),
132
+ activity,
133
+ errors,
134
+ };
135
+ } finally {
136
+ globalThis.clearTimeout(timer);
137
+ unsubscribe();
138
+ unsubscribeDisconnect();
139
+ }
140
+ }