@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.
- package/CHANGELOG.md +223 -0
- package/LICENSE +21 -0
- package/README.md +444 -0
- package/package.json +62 -0
- package/scripts/check-claude-bridge.mjs +37 -0
- package/scripts/check.mjs +20 -0
- package/scripts/install-claude-desktop.mjs +67 -0
- package/scripts/install-codex-mcp.mjs +44 -0
- package/scripts/install-launch-agent.mjs +93 -0
- package/scripts/smoke.mjs +50 -0
- package/scripts/sync-version.mjs +25 -0
- package/src/app-server-client.mjs +414 -0
- package/src/claude-bridge.mjs +322 -0
- package/src/index.mjs +477 -0
- package/src/peer-protocol.mjs +367 -0
- package/src/platform.mjs +320 -0
- package/src/security-policy.mjs +149 -0
- package/src/turn.mjs +140 -0
|
@@ -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
|
+
}
|