@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,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import { PLATFORM_LABEL, resolveCodexBin, spawnEnv } from "../src/platform.mjs";
|
|
8
|
+
|
|
9
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
const codexBin = resolveCodexBin(process.env.CODEX_EXE);
|
|
11
|
+
const serverName = process.env.CLAUDE_BRIDGE_NAME ?? "claude-bridge";
|
|
12
|
+
const entry = path.join(root, "src", "claude-bridge.mjs");
|
|
13
|
+
const remove = process.argv.includes("--remove");
|
|
14
|
+
|
|
15
|
+
if (!fs.existsSync(entry)) throw new Error(`bridge entry point missing: ${entry}`);
|
|
16
|
+
if (!path.isAbsolute(codexBin) || !fs.existsSync(codexBin)) {
|
|
17
|
+
throw new Error(`codex binary not found (resolved to "${codexBin}"). Set CODEX_EXE to its absolute path.`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const run = (args) => execFileSync(codexBin, args, { env: spawnEnv(), stdio: "pipe" }).toString().trim();
|
|
21
|
+
|
|
22
|
+
if (remove) {
|
|
23
|
+
console.log(run(["mcp", "remove", serverName]) || `removed ${serverName}`);
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
run(["mcp", "remove", serverName]);
|
|
29
|
+
} catch {
|
|
30
|
+
// not registered yet
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const args = ["mcp", "add", serverName];
|
|
34
|
+
const peerName = process.env.CLAUDE_BRIDGE_PEER_NAME;
|
|
35
|
+
if (peerName) args.push("--env", `CLAUDE_BRIDGE_PEER_NAME=${peerName}`);
|
|
36
|
+
args.push("--", process.execPath, entry);
|
|
37
|
+
|
|
38
|
+
run(args);
|
|
39
|
+
|
|
40
|
+
console.log(`platform: ${PLATFORM_LABEL}`);
|
|
41
|
+
console.log(`registered MCP server "${serverName}" with Codex:`);
|
|
42
|
+
console.log(run(["mcp", "get", serverName]));
|
|
43
|
+
console.log("\nRestart the Codex app (or start a new Codex session) to load the bridge.");
|
|
44
|
+
console.log(`remove: node scripts/install-codex-mcp.mjs --remove`);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { IS_MACOS, LAUNCH_AGENT_LABEL, launchAgentPath, resolveCodexBin, spawnEnv } from "../src/platform.mjs";
|
|
7
|
+
|
|
8
|
+
if (!IS_MACOS) {
|
|
9
|
+
throw new Error("install-launch-agent.mjs is macOS-only. On Windows use a Startup shortcut or a scheduled task.");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const plistPath = launchAgentPath();
|
|
13
|
+
const uninstall = process.argv.includes("--uninstall");
|
|
14
|
+
const domain = `gui/${process.getuid()}`;
|
|
15
|
+
|
|
16
|
+
function bootout() {
|
|
17
|
+
try {
|
|
18
|
+
execFileSync("launchctl", ["bootout", `${domain}/${LAUNCH_AGENT_LABEL}`], { stdio: "pipe" });
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (uninstall) {
|
|
26
|
+
const wasLoaded = bootout();
|
|
27
|
+
if (fs.existsSync(plistPath)) fs.rmSync(plistPath);
|
|
28
|
+
console.log(`removed ${plistPath}${wasLoaded ? " (agent unloaded)" : ""}`);
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const codexBin = resolveCodexBin(process.env.CODEX_EXE);
|
|
33
|
+
if (!path.isAbsolute(codexBin) || !fs.existsSync(codexBin)) {
|
|
34
|
+
throw new Error(`codex binary not found (resolved to "${codexBin}"). Set CODEX_EXE to its absolute path.`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const url = process.env.CODEX_APP_SERVER_URL ?? "ws://127.0.0.1:8791";
|
|
38
|
+
const logDir = path.join(os.homedir(), "Library", "Logs", "codex-mcp-bridge");
|
|
39
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
40
|
+
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
41
|
+
|
|
42
|
+
const escapeXml = (value) =>
|
|
43
|
+
value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
44
|
+
|
|
45
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
46
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
47
|
+
<plist version="1.0">
|
|
48
|
+
<dict>
|
|
49
|
+
<key>Label</key>
|
|
50
|
+
<string>${LAUNCH_AGENT_LABEL}</string>
|
|
51
|
+
<key>ProgramArguments</key>
|
|
52
|
+
<array>
|
|
53
|
+
<string>${escapeXml(codexBin)}</string>
|
|
54
|
+
<string>app-server</string>
|
|
55
|
+
<string>--listen</string>
|
|
56
|
+
<string>${escapeXml(url)}</string>
|
|
57
|
+
</array>
|
|
58
|
+
<key>EnvironmentVariables</key>
|
|
59
|
+
<dict>
|
|
60
|
+
<key>PATH</key>
|
|
61
|
+
<string>${escapeXml(spawnEnv().PATH)}</string>
|
|
62
|
+
<key>HOME</key>
|
|
63
|
+
<string>${escapeXml(os.homedir())}</string>
|
|
64
|
+
</dict>
|
|
65
|
+
<key>RunAtLoad</key>
|
|
66
|
+
<true/>
|
|
67
|
+
<key>KeepAlive</key>
|
|
68
|
+
<dict>
|
|
69
|
+
<key>SuccessfulExit</key>
|
|
70
|
+
<false/>
|
|
71
|
+
</dict>
|
|
72
|
+
<key>ThrottleInterval</key>
|
|
73
|
+
<integer>10</integer>
|
|
74
|
+
<key>ProcessType</key>
|
|
75
|
+
<string>Background</string>
|
|
76
|
+
<key>StandardOutPath</key>
|
|
77
|
+
<string>${escapeXml(path.join(logDir, "app-server.out.log"))}</string>
|
|
78
|
+
<key>StandardErrorPath</key>
|
|
79
|
+
<string>${escapeXml(path.join(logDir, "app-server.err.log"))}</string>
|
|
80
|
+
</dict>
|
|
81
|
+
</plist>
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
fs.writeFileSync(plistPath, plist, "utf8");
|
|
85
|
+
bootout();
|
|
86
|
+
execFileSync("launchctl", ["bootstrap", domain, plistPath], { stdio: "inherit" });
|
|
87
|
+
execFileSync("launchctl", ["enable", `${domain}/${LAUNCH_AGENT_LABEL}`], { stdio: "pipe" });
|
|
88
|
+
|
|
89
|
+
console.log(`installed ${plistPath}`);
|
|
90
|
+
console.log(`endpoint: ${url}`);
|
|
91
|
+
console.log(`logs: ${logDir}`);
|
|
92
|
+
console.log(`status: launchctl print ${domain}/${LAUNCH_AGENT_LABEL} | head -20`);
|
|
93
|
+
console.log(`remove: node scripts/install-launch-agent.mjs --uninstall`);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
7
|
+
const cwdForTest = process.env.SMOKE_CWD ?? root;
|
|
8
|
+
|
|
9
|
+
const transport = new StdioClientTransport({
|
|
10
|
+
command: process.execPath,
|
|
11
|
+
args: [path.join(root, "src", "index.mjs")],
|
|
12
|
+
env: { ...process.env, CODEX_BRIDGE_ALLOWED_ROOTS: process.env.CODEX_BRIDGE_ALLOWED_ROOTS ?? cwdForTest },
|
|
13
|
+
stderr: "inherit",
|
|
14
|
+
});
|
|
15
|
+
const client = new Client({ name: "smoke", version: "1.0.0" });
|
|
16
|
+
await client.connect(transport);
|
|
17
|
+
|
|
18
|
+
const tools = await client.listTools();
|
|
19
|
+
console.log("TOOLS:", tools.tools.map((t) => t.name).join(", "));
|
|
20
|
+
|
|
21
|
+
const listed = await client.callTool({ name: "list_codex_threads", arguments: { limit: 3 } });
|
|
22
|
+
console.log("\n--- list_codex_threads ---\n" + listed.content[0].text.slice(0, 900));
|
|
23
|
+
|
|
24
|
+
const created = await client.callTool({
|
|
25
|
+
name: "start_codex_thread",
|
|
26
|
+
arguments: { cwd: cwdForTest },
|
|
27
|
+
});
|
|
28
|
+
console.log("\n--- start_codex_thread ---\n" + created.content[0].text);
|
|
29
|
+
const threadId = created.content[0].text.match(/threadId: ([0-9a-f-]+)/)?.[1];
|
|
30
|
+
if (!threadId) throw new Error("could not parse threadId");
|
|
31
|
+
|
|
32
|
+
const first = await client.callTool({
|
|
33
|
+
name: "send_to_codex_thread",
|
|
34
|
+
arguments: { threadId, prompt: "Remember the codeword PAPAYA. Reply with only: SAVED", timeoutSec: 180 },
|
|
35
|
+
});
|
|
36
|
+
console.log("\n--- send #1 ---\n" + first.content[0].text);
|
|
37
|
+
|
|
38
|
+
const second = await client.callTool({
|
|
39
|
+
name: "send_to_codex_thread",
|
|
40
|
+
arguments: { threadId, prompt: "What codeword did I give you? Reply with only that word.", timeoutSec: 180 },
|
|
41
|
+
});
|
|
42
|
+
console.log("\n--- send #2 (same thread, must recall) ---\n" + second.content[0].text);
|
|
43
|
+
|
|
44
|
+
const read = await client.callTool({ name: "read_codex_thread", arguments: { threadId, limit: 6 } });
|
|
45
|
+
console.log("\n--- read_codex_thread ---\n" + read.content[0].text.slice(0, 900));
|
|
46
|
+
|
|
47
|
+
const ok = /PAPAYA/i.test(second.content[0].text);
|
|
48
|
+
console.log("\nRESULT:", ok ? "PASS - thread continuity works" : "FAIL - Codex did not recall the codeword");
|
|
49
|
+
await client.close();
|
|
50
|
+
process.exit(ok ? 0 : 1);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `codex_bridge_status` reports a version constant that lives in the source,
|
|
8
|
+
* and `npm version` only ever rewrites package.json. Left to a human the two
|
|
9
|
+
* drift, and a drifting version turns every bug report into a guess about
|
|
10
|
+
* which build is actually running - so this runs from the `version` lifecycle
|
|
11
|
+
* script, between the bump and the commit npm makes for it.
|
|
12
|
+
*/
|
|
13
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
14
|
+
const entry = path.join(root, "src", "index.mjs");
|
|
15
|
+
const { version } = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
16
|
+
|
|
17
|
+
const before = fs.readFileSync(entry, "utf8");
|
|
18
|
+
const after = before.replace(/^const VERSION = "[^"]+";$/m, `const VERSION = "${version}";`);
|
|
19
|
+
|
|
20
|
+
if (!after.includes(`const VERSION = "${version}";`)) {
|
|
21
|
+
throw new Error(`could not find the VERSION constant in ${entry}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (before !== after) fs.writeFileSync(entry, after);
|
|
25
|
+
console.log(`src/index.mjs VERSION -> ${version}`);
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
|
|
4
|
+
import { PLATFORM_LABEL, resolveCodexBin, spawnEnv } from "./platform.mjs";
|
|
5
|
+
import { assertAllowedAppServerUrl } from "./security-policy.mjs";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_URL = "ws://127.0.0.1:8791";
|
|
8
|
+
const CONNECT_ATTEMPTS = 2;
|
|
9
|
+
const CONNECT_RETRY_DELAY_MS = 750;
|
|
10
|
+
|
|
11
|
+
function httpBase(wsUrl) {
|
|
12
|
+
return wsUrl.replace(/^ws:/, "http:").replace(/^wss:/, "https:").replace(/\/+$/, "");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Opening a thread in the desktop app while this bridge still holds it produces
|
|
17
|
+
* a message with no cause attached to it - the app just says the thread is open
|
|
18
|
+
* somewhere else. Saying so at the moment of opening turns that into something
|
|
19
|
+
* the reader can act on.
|
|
20
|
+
*/
|
|
21
|
+
export function writerLockWarning(threadId) {
|
|
22
|
+
return [
|
|
23
|
+
"",
|
|
24
|
+
`NOTE: this bridge still holds the writer lock on thread ${threadId}, because its app-server has the`,
|
|
25
|
+
"thread loaded. Until that app-server stops, the Codex app will refuse to write to it and show",
|
|
26
|
+
'"open in another application". Release it with stop_codex_app_server when the hand-off is done;',
|
|
27
|
+
"the bridge starts a new app-server the next time it needs one.",
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class AppServerError extends Error {
|
|
32
|
+
constructor(message, code) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "AppServerError";
|
|
35
|
+
this.code = code;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class CodexAppServerClient {
|
|
40
|
+
constructor(options = {}) {
|
|
41
|
+
this.url = options.url ?? process.env.CODEX_APP_SERVER_URL ?? DEFAULT_URL;
|
|
42
|
+
assertAllowedAppServerUrl(this.url);
|
|
43
|
+
this.codexBin = resolveCodexBin(options.codexBin);
|
|
44
|
+
this.autoStart = options.autoStart ?? process.env.CODEX_BRIDGE_AUTOSTART !== "0";
|
|
45
|
+
this.log = options.log ?? (() => {});
|
|
46
|
+
const requestedApproval = options.approval ?? process.env.CODEX_BRIDGE_APPROVAL ?? "deny";
|
|
47
|
+
const autoApproveAcknowledged = options.allowAutoApprove ?? process.env.CODEX_BRIDGE_AUTO_APPROVE_ACK === "1";
|
|
48
|
+
this.approval = requestedApproval === "approve" && !autoApproveAcknowledged ? "deny" : requestedApproval;
|
|
49
|
+
this.clientInfo = options.clientInfo ?? { name: "codex-mcp-bridge", version: "1.0.0" };
|
|
50
|
+
|
|
51
|
+
if (requestedApproval === "approve" && !autoApproveAcknowledged) {
|
|
52
|
+
this.log("CODEX_BRIDGE_APPROVAL=approve ignored without CODEX_BRIDGE_AUTO_APPROVE_ACK=1");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.ws = null;
|
|
56
|
+
this.connecting = null;
|
|
57
|
+
this.nextId = 0;
|
|
58
|
+
this.pending = new Map();
|
|
59
|
+
this.threadListeners = new Map();
|
|
60
|
+
this.disconnectListeners = new Set();
|
|
61
|
+
this.attachedThreads = new Set();
|
|
62
|
+
this.threadCwds = new Map();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async isServerUp() {
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetch(`${httpBase(this.url)}/readyz`, {
|
|
68
|
+
signal: AbortSignal.timeout(2000),
|
|
69
|
+
});
|
|
70
|
+
return res.ok;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async startServer() {
|
|
77
|
+
this.log(`starting shared app-server on ${PLATFORM_LABEL}: ${this.codexBin} app-server --listen ${this.url}`);
|
|
78
|
+
const needsShell = /\.(cmd|bat)$/i.test(this.codexBin);
|
|
79
|
+
const child = spawn(this.codexBin, ["app-server", "--listen", this.url], {
|
|
80
|
+
detached: true,
|
|
81
|
+
stdio: "ignore",
|
|
82
|
+
windowsHide: true,
|
|
83
|
+
shell: needsShell,
|
|
84
|
+
env: spawnEnv(),
|
|
85
|
+
});
|
|
86
|
+
child.on("error", (err) => this.log(`spawn error: ${err.message}`));
|
|
87
|
+
child.unref();
|
|
88
|
+
|
|
89
|
+
for (let i = 0; i < 40; i += 1) {
|
|
90
|
+
await delay(500);
|
|
91
|
+
if (await this.isServerUp()) return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The shared app-server keeps running after the bridge stops using it, and
|
|
98
|
+
* alongside the desktop app it competes for the same ~/.codex sqlite state.
|
|
99
|
+
* Stopping it on demand is what keeps the Codex app responsive between
|
|
100
|
+
* delegations.
|
|
101
|
+
*/
|
|
102
|
+
async stopServer() {
|
|
103
|
+
if (!(await this.isServerUp())) return { stopped: false, reason: "no app-server was listening" };
|
|
104
|
+
const port = this.url.replace(/^wss?:\/\//, "").split("/")[0].split(":").pop();
|
|
105
|
+
const { execFileSync } = await import("node:child_process");
|
|
106
|
+
const pids = execFileSync("/usr/sbin/lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
|
|
107
|
+
env: spawnEnv(),
|
|
108
|
+
})
|
|
109
|
+
.toString()
|
|
110
|
+
.split("\n")
|
|
111
|
+
.map((line) => Number(line.trim()))
|
|
112
|
+
.filter(Boolean);
|
|
113
|
+
if (!pids.length) return { stopped: false, reason: `nothing is listening on port ${port}` };
|
|
114
|
+
this.ws?.close();
|
|
115
|
+
for (const pid of pids) {
|
|
116
|
+
try {
|
|
117
|
+
process.kill(pid, "SIGTERM");
|
|
118
|
+
} catch (err) {
|
|
119
|
+
this.log(`could not stop pid ${pid}: ${err.message}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { stopped: true, pids };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A freshly booted machine hands out transient failures: the app-server is
|
|
127
|
+
* still opening its sqlite state under ~/.codex, or an old one is shutting
|
|
128
|
+
* down yet still answering /readyz. Retrying once turns those into a slower
|
|
129
|
+
* first call instead of a failed tool call the user has to repeat by hand.
|
|
130
|
+
*/
|
|
131
|
+
async connect() {
|
|
132
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
|
133
|
+
if (this.connecting) return this.connecting;
|
|
134
|
+
|
|
135
|
+
this.connecting = (async () => {
|
|
136
|
+
let lastError = null;
|
|
137
|
+
for (let attempt = 1; attempt <= CONNECT_ATTEMPTS; attempt += 1) {
|
|
138
|
+
try {
|
|
139
|
+
await this.#openConnection();
|
|
140
|
+
return;
|
|
141
|
+
} catch (err) {
|
|
142
|
+
lastError = err;
|
|
143
|
+
this.log(`connect attempt ${attempt}/${CONNECT_ATTEMPTS} failed: ${err.message}`);
|
|
144
|
+
if (attempt < CONNECT_ATTEMPTS) await delay(CONNECT_RETRY_DELAY_MS);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
throw lastError;
|
|
148
|
+
})();
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
await this.connecting;
|
|
152
|
+
} finally {
|
|
153
|
+
this.connecting = null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async #openConnection() {
|
|
158
|
+
if (!(await this.isServerUp())) {
|
|
159
|
+
if (!this.autoStart) {
|
|
160
|
+
throw new AppServerError(
|
|
161
|
+
`No Codex app-server reachable at ${this.url}. Start one with: codex app-server --listen ${this.url}`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const ok = await this.startServer();
|
|
165
|
+
if (!ok) {
|
|
166
|
+
throw new AppServerError(`Failed to start a Codex app-server at ${this.url} within 20s.`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const ws = new WebSocket(this.url);
|
|
171
|
+
await new Promise((resolve, reject) => {
|
|
172
|
+
const timer = globalThis.setTimeout(
|
|
173
|
+
() => reject(new AppServerError(`Timed out connecting to ${this.url}`)),
|
|
174
|
+
15000,
|
|
175
|
+
);
|
|
176
|
+
ws.onopen = () => {
|
|
177
|
+
globalThis.clearTimeout(timer);
|
|
178
|
+
resolve();
|
|
179
|
+
};
|
|
180
|
+
ws.onerror = (event) => {
|
|
181
|
+
globalThis.clearTimeout(timer);
|
|
182
|
+
reject(new AppServerError(`WebSocket error against ${this.url}: ${event?.message ?? "unknown"}`));
|
|
183
|
+
};
|
|
184
|
+
ws.onclose = () => {
|
|
185
|
+
globalThis.clearTimeout(timer);
|
|
186
|
+
reject(new AppServerError(`Connection to ${this.url} closed during the handshake`));
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
ws.onmessage = (event) => this.#handleMessage(event.data);
|
|
191
|
+
ws.onclose = () => {
|
|
192
|
+
if (this.ws !== ws) return;
|
|
193
|
+
this.log("app-server connection closed");
|
|
194
|
+
this.ws = null;
|
|
195
|
+
this.attachedThreads.clear();
|
|
196
|
+
this.threadCwds.clear();
|
|
197
|
+
for (const [, entry] of this.pending) {
|
|
198
|
+
entry.reject(new AppServerError("Connection to Codex app-server closed"));
|
|
199
|
+
}
|
|
200
|
+
this.pending.clear();
|
|
201
|
+
this.#notifyDisconnect();
|
|
202
|
+
};
|
|
203
|
+
ws.onerror = (event) => this.log(`websocket error: ${event?.message ?? "unknown"}`);
|
|
204
|
+
|
|
205
|
+
this.ws = ws;
|
|
206
|
+
const init = await this.request("initialize", {
|
|
207
|
+
clientInfo: this.clientInfo,
|
|
208
|
+
capabilities: { experimentalApi: true },
|
|
209
|
+
});
|
|
210
|
+
this.#send({ jsonrpc: "2.0", method: "initialized", params: {} });
|
|
211
|
+
this.log(`connected to app-server (codexHome=${init?.codexHome ?? "?"})`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#send(payload) {
|
|
215
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
216
|
+
throw new AppServerError("Not connected to Codex app-server");
|
|
217
|
+
}
|
|
218
|
+
this.ws.send(JSON.stringify(payload));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#handleMessage(raw) {
|
|
222
|
+
let msg;
|
|
223
|
+
try {
|
|
224
|
+
msg = JSON.parse(raw);
|
|
225
|
+
} catch {
|
|
226
|
+
this.log(`ignored non-JSON frame (${String(raw).slice(0, 80)})`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (msg.id !== undefined && msg.method === undefined) {
|
|
231
|
+
const entry = this.pending.get(msg.id);
|
|
232
|
+
if (!entry) return;
|
|
233
|
+
this.pending.delete(msg.id);
|
|
234
|
+
if (msg.error) {
|
|
235
|
+
entry.reject(new AppServerError(msg.error.message ?? JSON.stringify(msg.error), msg.error.code));
|
|
236
|
+
} else {
|
|
237
|
+
entry.resolve(msg.result);
|
|
238
|
+
}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (msg.id !== undefined && msg.method) {
|
|
243
|
+
this.#handleServerRequest(msg);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (msg.method) this.#dispatchNotification(msg);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#dispatchNotification(msg) {
|
|
251
|
+
const threadId = msg.params?.threadId;
|
|
252
|
+
if (!threadId) return;
|
|
253
|
+
const listeners = this.threadListeners.get(threadId);
|
|
254
|
+
if (!listeners) return;
|
|
255
|
+
for (const listener of [...listeners]) {
|
|
256
|
+
try {
|
|
257
|
+
listener(msg);
|
|
258
|
+
} catch (err) {
|
|
259
|
+
this.log(`listener error: ${err.message}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Every server->client request must get a reply. The app-server blocks the
|
|
266
|
+
* turn until it hears back, so an unanswered method does not surface as an
|
|
267
|
+
* error - the turn simply stops mid-run and looks like Codex paused itself.
|
|
268
|
+
* The ten methods below are the full ServerRequest set of the app-server
|
|
269
|
+
* protocol (identical in codex-cli 0.147 and 0.148), each answered with the
|
|
270
|
+
* response shape its own schema declares - they are not interchangeable.
|
|
271
|
+
*/
|
|
272
|
+
#handleServerRequest(msg) {
|
|
273
|
+
const approve = this.approval === "approve";
|
|
274
|
+
const respond = (result) => {
|
|
275
|
+
try {
|
|
276
|
+
this.#send({ jsonrpc: "2.0", id: msg.id, result });
|
|
277
|
+
} catch (err) {
|
|
278
|
+
this.log(`failed to answer ${msg.method}: ${err.message}`);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
const refuse = (message) => {
|
|
282
|
+
try {
|
|
283
|
+
this.#send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message } });
|
|
284
|
+
} catch (err) {
|
|
285
|
+
this.log(`failed to refuse ${msg.method}: ${err.message}`);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
this.log(`server request ${msg.method} -> ${approve ? "approve" : "deny"}`);
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
switch (msg.method) {
|
|
293
|
+
case "item/commandExecution/requestApproval":
|
|
294
|
+
case "item/fileChange/requestApproval":
|
|
295
|
+
respond({ decision: approve ? "accept" : "decline" });
|
|
296
|
+
return;
|
|
297
|
+
case "item/permissions/requestApproval":
|
|
298
|
+
respond({
|
|
299
|
+
permissions: approve ? (msg.params?.permissions ?? {}) : {},
|
|
300
|
+
scope: "turn",
|
|
301
|
+
});
|
|
302
|
+
return;
|
|
303
|
+
case "execCommandApproval":
|
|
304
|
+
case "applyPatchApproval":
|
|
305
|
+
respond({ decision: approve ? "approved" : "denied" });
|
|
306
|
+
return;
|
|
307
|
+
case "item/tool/requestUserInput":
|
|
308
|
+
respond({ answers: {} });
|
|
309
|
+
return;
|
|
310
|
+
case "mcpServer/elicitation/request":
|
|
311
|
+
respond({ action: "decline", content: null });
|
|
312
|
+
return;
|
|
313
|
+
case "item/tool/call":
|
|
314
|
+
respond({
|
|
315
|
+
success: false,
|
|
316
|
+
contentItems: [
|
|
317
|
+
{ type: "text", text: "codex-mcp-bridge registers no dynamic tools." },
|
|
318
|
+
],
|
|
319
|
+
});
|
|
320
|
+
return;
|
|
321
|
+
case "attestation/generate":
|
|
322
|
+
case "account/chatgptAuthTokens/refresh":
|
|
323
|
+
refuse(`codex-mcp-bridge cannot serve ${msg.method}; run this thread from the Codex app instead.`);
|
|
324
|
+
return;
|
|
325
|
+
default:
|
|
326
|
+
refuse(`codex-mcp-bridge does not handle ${msg.method}`);
|
|
327
|
+
}
|
|
328
|
+
} catch (err) {
|
|
329
|
+
refuse(`codex-mcp-bridge failed handling ${msg.method}: ${err.message}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async request(method, params, { timeoutMs = 60000 } = {}) {
|
|
334
|
+
const id = ++this.nextId;
|
|
335
|
+
const promise = new Promise((resolve, reject) => {
|
|
336
|
+
const timer = globalThis.setTimeout(() => {
|
|
337
|
+
this.pending.delete(id);
|
|
338
|
+
reject(new AppServerError(`Request ${method} timed out after ${timeoutMs}ms`));
|
|
339
|
+
}, timeoutMs);
|
|
340
|
+
this.pending.set(id, {
|
|
341
|
+
resolve: (value) => {
|
|
342
|
+
globalThis.clearTimeout(timer);
|
|
343
|
+
resolve(value);
|
|
344
|
+
},
|
|
345
|
+
reject: (err) => {
|
|
346
|
+
globalThis.clearTimeout(timer);
|
|
347
|
+
reject(err);
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
this.#send({ jsonrpc: "2.0", id, method, params: params ?? {} });
|
|
352
|
+
return promise;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async call(method, params, opts) {
|
|
356
|
+
await this.connect();
|
|
357
|
+
return this.request(method, params, opts);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* A turn waits on `turn/completed`, which can only arrive over a live socket.
|
|
362
|
+
* Without an explicit disconnect signal a dropped app-server - the machine
|
|
363
|
+
* sleeping, a reboot, the desktop app reclaiming the state - leaves the
|
|
364
|
+
* caller blocked until its own timeout expires, four minutes by default.
|
|
365
|
+
*/
|
|
366
|
+
subscribeDisconnect(listener) {
|
|
367
|
+
this.disconnectListeners.add(listener);
|
|
368
|
+
return () => this.disconnectListeners.delete(listener);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
#notifyDisconnect() {
|
|
372
|
+
for (const listener of [...this.disconnectListeners]) {
|
|
373
|
+
try {
|
|
374
|
+
listener();
|
|
375
|
+
} catch (err) {
|
|
376
|
+
this.log(`disconnect listener error: ${err.message}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
subscribe(threadId, listener) {
|
|
382
|
+
if (!this.threadListeners.has(threadId)) this.threadListeners.set(threadId, new Set());
|
|
383
|
+
this.threadListeners.get(threadId).add(listener);
|
|
384
|
+
return () => {
|
|
385
|
+
const set = this.threadListeners.get(threadId);
|
|
386
|
+
if (!set) return;
|
|
387
|
+
set.delete(listener);
|
|
388
|
+
if (set.size === 0) this.threadListeners.delete(threadId);
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async ensureThreadAttached(threadId, resumeParams = {}) {
|
|
393
|
+
await this.connect();
|
|
394
|
+
if (this.attachedThreads.has(threadId)) return { resumed: false, thread: this.threadCwds.get(threadId) };
|
|
395
|
+
const result = await this.request("thread/resume", { threadId, ...resumeParams });
|
|
396
|
+
this.attachedThreads.add(threadId);
|
|
397
|
+
if (result?.thread?.cwd) this.threadCwds.set(threadId, result.thread);
|
|
398
|
+
return { resumed: true, thread: result?.thread };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The app-server takes the per-thread writer lock when it loads a thread and
|
|
403
|
+
* keeps it until it exits, so a thread this bridge has attached cannot be
|
|
404
|
+
* written to from anywhere else - the desktop app included.
|
|
405
|
+
*/
|
|
406
|
+
holdsThread(threadId) {
|
|
407
|
+
return this.attachedThreads.has(threadId);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
markAttached(threadId, thread = null) {
|
|
411
|
+
this.attachedThreads.add(threadId);
|
|
412
|
+
if (thread?.cwd) this.threadCwds.set(threadId, thread);
|
|
413
|
+
}
|
|
414
|
+
}
|