@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,367 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
import { homeDir } from "./platform.mjs";
|
|
9
|
+
|
|
10
|
+
const SOCKET_DIR = "/tmp/cc-socks";
|
|
11
|
+
const PEER_PROTOCOL_VERSION = 1;
|
|
12
|
+
const CLAUDE_VERSION_HINT = "2.1.229";
|
|
13
|
+
const PS_BIN = "/bin/ps";
|
|
14
|
+
|
|
15
|
+
const sessionsDir = () => path.join(homeDir(), ".claude", "sessions");
|
|
16
|
+
const projectsDir = () => path.join(homeDir(), ".claude", "projects");
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A Claude Code session advertises itself in ~/.claude/sessions/<pid>.json and
|
|
20
|
+
* listens for peer messages on a unix socket. Messages are newline-delimited
|
|
21
|
+
* JSON; the wrapper element is what Claude renders in its chat surface.
|
|
22
|
+
*/
|
|
23
|
+
export function buildFrame({ text, fromSocket, priority = "next" }) {
|
|
24
|
+
return {
|
|
25
|
+
msgV: PEER_PROTOCOL_VERSION,
|
|
26
|
+
msg_id: crypto.randomUUID(),
|
|
27
|
+
type: "user",
|
|
28
|
+
message: {
|
|
29
|
+
role: "user",
|
|
30
|
+
content: `<cross-session-message from="uds:${fromSocket}" from-mode="bypass">\n${text}\n</cross-session-message>`,
|
|
31
|
+
},
|
|
32
|
+
priority,
|
|
33
|
+
from: `uds:${fromSocket}`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseFrame(line) {
|
|
38
|
+
const frame = JSON.parse(line);
|
|
39
|
+
const raw = frame?.message?.content ?? "";
|
|
40
|
+
const inner = raw.match(/<cross-session-message[^>]*>\n?([\s\S]*?)\n?<\/cross-session-message>/);
|
|
41
|
+
return {
|
|
42
|
+
msgId: frame.msg_id ?? null,
|
|
43
|
+
from: frame.from ?? null,
|
|
44
|
+
fromSocket: (frame.from ?? "").replace(/^uds:/, "") || null,
|
|
45
|
+
text: (inner ? inner[1] : raw).trim(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* MCP servers are spawned with an empty PATH, so `ps` must be addressed by its
|
|
51
|
+
* absolute path - a bare lookup throws ENOENT and takes the whole server down
|
|
52
|
+
* before it can answer the client's initialize call. The timestamp only guards
|
|
53
|
+
* against pid reuse, so an empty value is an acceptable fallback.
|
|
54
|
+
*/
|
|
55
|
+
function readProcessStart(pid) {
|
|
56
|
+
try {
|
|
57
|
+
return execFileSync(PS_BIN, ["-o", "lstart=", "-p", String(pid)]).toString().trim();
|
|
58
|
+
} catch {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isProcessAlive(pid) {
|
|
64
|
+
try {
|
|
65
|
+
process.kill(pid, 0);
|
|
66
|
+
return true;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
return err.code === "EPERM";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const BRIDGE_ENTRYPOINT = "codex-bridge";
|
|
73
|
+
|
|
74
|
+
export function listClaudeSessions({ includeDead = false, includeBridges = false } = {}) {
|
|
75
|
+
const dir = sessionsDir();
|
|
76
|
+
if (!fs.existsSync(dir)) return [];
|
|
77
|
+
const rows = [];
|
|
78
|
+
for (const file of fs.readdirSync(dir)) {
|
|
79
|
+
if (!file.endsWith(".json")) continue;
|
|
80
|
+
let entry;
|
|
81
|
+
try {
|
|
82
|
+
entry = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
|
|
83
|
+
} catch {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!entry?.pid || !entry?.messagingSocketPath) continue;
|
|
87
|
+
if (entry.entrypoint === BRIDGE_ENTRYPOINT && !includeBridges) continue;
|
|
88
|
+
const alive = isProcessAlive(entry.pid) && fs.existsSync(entry.messagingSocketPath);
|
|
89
|
+
if (!alive && !includeDead) continue;
|
|
90
|
+
rows.push({
|
|
91
|
+
pid: entry.pid,
|
|
92
|
+
name: entry.name ?? null,
|
|
93
|
+
sessionId: entry.sessionId ?? null,
|
|
94
|
+
cwd: entry.cwd ?? null,
|
|
95
|
+
kind: entry.kind ?? null,
|
|
96
|
+
entrypoint: entry.entrypoint ?? null,
|
|
97
|
+
startedAt: entry.startedAt ?? null,
|
|
98
|
+
socket: entry.messagingSocketPath,
|
|
99
|
+
alive,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return rows.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function findClaudeSession(target) {
|
|
106
|
+
const sessions = listClaudeSessions();
|
|
107
|
+
const needle = String(target).trim();
|
|
108
|
+
return (
|
|
109
|
+
sessions.find((s) => String(s.pid) === needle) ??
|
|
110
|
+
sessions.find((s) => s.sessionId === needle) ??
|
|
111
|
+
sessions.find((s) => s.name === needle) ??
|
|
112
|
+
sessions.find((s) => (s.name ?? "").toLowerCase().includes(needle.toLowerCase())) ??
|
|
113
|
+
null
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Claude Code stores a transcript at ~/.claude/projects/<slug>/<sessionId>.jsonl
|
|
119
|
+
* where the slug rewrites more than just path separators (/mnt/dev_disk ->
|
|
120
|
+
* -mnt-dev-disk, losing the underscore), so the file is located by scanning
|
|
121
|
+
* rather than by reconstructing the slug.
|
|
122
|
+
*/
|
|
123
|
+
function findTranscriptFile(sessionId, cwd) {
|
|
124
|
+
const dir = projectsDir();
|
|
125
|
+
const guess = path.join(dir, String(cwd ?? "").replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`);
|
|
126
|
+
if (fs.existsSync(guess)) return guess;
|
|
127
|
+
if (!fs.existsSync(dir)) return guess;
|
|
128
|
+
for (const project of fs.readdirSync(dir)) {
|
|
129
|
+
const candidate = path.join(dir, project, `${sessionId}.jsonl`);
|
|
130
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
131
|
+
}
|
|
132
|
+
return guess;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function readTranscript(sessionId, cwd, limit = 10) {
|
|
136
|
+
const file = findTranscriptFile(sessionId, cwd);
|
|
137
|
+
if (!fs.existsSync(file)) return { file, messages: [] };
|
|
138
|
+
const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
139
|
+
const messages = [];
|
|
140
|
+
for (const line of lines) {
|
|
141
|
+
let entry;
|
|
142
|
+
try {
|
|
143
|
+
entry = JSON.parse(line);
|
|
144
|
+
} catch {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const role = entry?.message?.role;
|
|
148
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
149
|
+
if (entry.isMeta || entry.isSidechain) continue;
|
|
150
|
+
const content = entry?.message?.content;
|
|
151
|
+
const text = Array.isArray(content)
|
|
152
|
+
? content
|
|
153
|
+
.filter((c) => c?.type === "text")
|
|
154
|
+
.map((c) => c.text ?? "")
|
|
155
|
+
.join("\n")
|
|
156
|
+
: String(content ?? "");
|
|
157
|
+
if (!text.trim()) continue;
|
|
158
|
+
messages.push({ role, text: text.trim() });
|
|
159
|
+
}
|
|
160
|
+
return { file, messages: messages.slice(-limit) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Registers this process as a peer that Claude sessions can see in their agent
|
|
165
|
+
* list and reply to. Without a registered socket, Claude has no address to
|
|
166
|
+
* answer on and the conversation stays one-way.
|
|
167
|
+
*/
|
|
168
|
+
export class PeerEndpoint {
|
|
169
|
+
constructor({ name = `codex-${process.pid}`, cwd = process.cwd(), log = () => {} } = {}) {
|
|
170
|
+
this.name = name;
|
|
171
|
+
this.cwd = cwd;
|
|
172
|
+
this.log = log;
|
|
173
|
+
this.pid = process.pid;
|
|
174
|
+
this.socketPath = path.join(SOCKET_DIR, `${this.pid}.sock`);
|
|
175
|
+
this.registryPath = path.join(sessionsDir(), `${this.pid}.json`);
|
|
176
|
+
this.keyPath = null;
|
|
177
|
+
this.server = null;
|
|
178
|
+
this.inbox = [];
|
|
179
|
+
this.listeners = new Set();
|
|
180
|
+
this.started = false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A bridge killed with SIGKILL leaves its registry entry and socket behind,
|
|
185
|
+
* and Claude then lists a crowd of dead peers with the same name. Sweep the
|
|
186
|
+
* leftovers of previous bridge runs before advertising this one.
|
|
187
|
+
*/
|
|
188
|
+
#sweepDeadBridges() {
|
|
189
|
+
const dir = sessionsDir();
|
|
190
|
+
if (!fs.existsSync(dir)) return;
|
|
191
|
+
for (const file of fs.readdirSync(dir)) {
|
|
192
|
+
if (!file.endsWith(".json")) continue;
|
|
193
|
+
const registry = path.join(dir, file);
|
|
194
|
+
let entry;
|
|
195
|
+
try {
|
|
196
|
+
entry = JSON.parse(fs.readFileSync(registry, "utf8"));
|
|
197
|
+
} catch {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (entry?.entrypoint !== BRIDGE_ENTRYPOINT) continue;
|
|
201
|
+
if (!entry.pid || entry.pid === this.pid || isProcessAlive(entry.pid)) continue;
|
|
202
|
+
for (const stale of [registry, entry.messagingSocketPath, ...fs.readdirSync(dir)
|
|
203
|
+
.filter((f) => f.startsWith(`${entry.pid}.`) && f.endsWith(".key"))
|
|
204
|
+
.map((f) => path.join(dir, f))]) {
|
|
205
|
+
if (!stale) continue;
|
|
206
|
+
try {
|
|
207
|
+
fs.rmSync(stale, { force: true });
|
|
208
|
+
} catch {}
|
|
209
|
+
}
|
|
210
|
+
this.log(`swept dead bridge peer pid ${entry.pid}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async start() {
|
|
215
|
+
if (this.started) return;
|
|
216
|
+
this.#sweepDeadBridges();
|
|
217
|
+
const procStart = readProcessStart(this.pid);
|
|
218
|
+
const peerToken = crypto.randomBytes(16).toString("hex");
|
|
219
|
+
const keyHash = crypto.createHash("sha256").update(`${peerToken}${procStart}`).digest("hex");
|
|
220
|
+
this.keyPath = path.join(sessionsDir(), `${this.pid}.${keyHash}.key`);
|
|
221
|
+
|
|
222
|
+
fs.mkdirSync(SOCKET_DIR, { recursive: true });
|
|
223
|
+
fs.mkdirSync(sessionsDir(), { recursive: true });
|
|
224
|
+
if (fs.existsSync(this.socketPath)) fs.rmSync(this.socketPath, { force: true });
|
|
225
|
+
|
|
226
|
+
await new Promise((resolve, reject) => {
|
|
227
|
+
this.server = net.createServer((socket) => this.#handleConnection(socket));
|
|
228
|
+
this.server.on("error", reject);
|
|
229
|
+
this.server.listen(this.socketPath, resolve);
|
|
230
|
+
});
|
|
231
|
+
fs.chmodSync(this.socketPath, 0o600);
|
|
232
|
+
|
|
233
|
+
this.registry = {
|
|
234
|
+
pid: this.pid,
|
|
235
|
+
sessionId: crypto.randomUUID(),
|
|
236
|
+
cwd: this.cwd,
|
|
237
|
+
startedAt: Date.now(),
|
|
238
|
+
procStart,
|
|
239
|
+
version: CLAUDE_VERSION_HINT,
|
|
240
|
+
peerProtocol: PEER_PROTOCOL_VERSION,
|
|
241
|
+
kind: "interactive",
|
|
242
|
+
entrypoint: BRIDGE_ENTRYPOINT,
|
|
243
|
+
messagingSocketPath: this.socketPath,
|
|
244
|
+
name: this.name,
|
|
245
|
+
nameSource: "derived",
|
|
246
|
+
};
|
|
247
|
+
fs.writeFileSync(this.registryPath, JSON.stringify(this.registry), { mode: 0o600 });
|
|
248
|
+
fs.writeFileSync(this.keyPath, JSON.stringify({ peerToken, procStart }), { mode: 0o600 });
|
|
249
|
+
|
|
250
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
251
|
+
process.on(signal, () => {
|
|
252
|
+
this.stop();
|
|
253
|
+
process.exit(0);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
process.on("exit", () => this.stop());
|
|
257
|
+
|
|
258
|
+
this.started = true;
|
|
259
|
+
this.log(`peer "${this.name}" listening on ${this.socketPath}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#handleConnection(socket) {
|
|
263
|
+
let buffer = "";
|
|
264
|
+
socket.on("data", (chunk) => {
|
|
265
|
+
buffer += chunk.toString("utf8");
|
|
266
|
+
let index;
|
|
267
|
+
while ((index = buffer.indexOf("\n")) >= 0) {
|
|
268
|
+
const line = buffer.slice(0, index).trim();
|
|
269
|
+
buffer = buffer.slice(index + 1);
|
|
270
|
+
if (!line) continue;
|
|
271
|
+
let message;
|
|
272
|
+
try {
|
|
273
|
+
message = parseFrame(line);
|
|
274
|
+
} catch {
|
|
275
|
+
this.log(`ignored malformed peer frame (${line.slice(0, 80)})`);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const record = { ...message, receivedAt: Date.now() };
|
|
279
|
+
this.inbox.push(record);
|
|
280
|
+
this.log(`inbox <- ${record.fromSocket ?? "?"}: ${record.text.slice(0, 120)}`);
|
|
281
|
+
for (const listener of [...this.listeners]) {
|
|
282
|
+
try {
|
|
283
|
+
listener(record);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
this.log(`peer listener error: ${err.message}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
socket.on("error", (err) => this.log(`peer socket error: ${err.message}`));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Codex starts one bridge per session, so several peers advertise at once.
|
|
295
|
+
* Renaming to the bound thread is what lets a human tell them apart in
|
|
296
|
+
* Claude's agent list.
|
|
297
|
+
*/
|
|
298
|
+
rename(name) {
|
|
299
|
+
if (!name || name === this.name) return this.name;
|
|
300
|
+
this.name = name;
|
|
301
|
+
if (this.started && this.registry) {
|
|
302
|
+
this.registry.name = name;
|
|
303
|
+
fs.writeFileSync(this.registryPath, JSON.stringify(this.registry), { mode: 0o600 });
|
|
304
|
+
this.log(`peer renamed to "${name}"`);
|
|
305
|
+
}
|
|
306
|
+
return this.name;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
onMessage(listener) {
|
|
310
|
+
this.listeners.add(listener);
|
|
311
|
+
return () => this.listeners.delete(listener);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async send(targetSocket, text, { priority = "next" } = {}) {
|
|
315
|
+
const frame = buildFrame({ text, fromSocket: this.socketPath, priority });
|
|
316
|
+
await new Promise((resolve, reject) => {
|
|
317
|
+
const client = net.connect({ path: targetSocket }, () => {
|
|
318
|
+
client.write(`${JSON.stringify(frame)}\n`, () => {
|
|
319
|
+
client.end();
|
|
320
|
+
resolve();
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
client.on("error", reject);
|
|
324
|
+
});
|
|
325
|
+
return frame.msg_id;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Claude answers with a fresh msg_id rather than an in-reply-to field, so a
|
|
330
|
+
* reply is matched by origin socket and arrival time.
|
|
331
|
+
*/
|
|
332
|
+
waitForReply(fromSocket, { timeoutMs = 120000, since = Date.now() } = {}) {
|
|
333
|
+
const existing = this.inbox.find((m) => m.fromSocket === fromSocket && m.receivedAt >= since);
|
|
334
|
+
if (existing) return Promise.resolve(existing);
|
|
335
|
+
return new Promise((resolve) => {
|
|
336
|
+
const timer = globalThis.setTimeout(() => {
|
|
337
|
+
unsubscribe();
|
|
338
|
+
resolve(null);
|
|
339
|
+
}, timeoutMs);
|
|
340
|
+
const unsubscribe = this.onMessage((record) => {
|
|
341
|
+
if (record.fromSocket !== fromSocket) return;
|
|
342
|
+
globalThis.clearTimeout(timer);
|
|
343
|
+
unsubscribe();
|
|
344
|
+
resolve(record);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
drainInbox(limit = 20) {
|
|
350
|
+
const messages = this.inbox.slice(-limit);
|
|
351
|
+
this.inbox = [];
|
|
352
|
+
return messages;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
stop() {
|
|
356
|
+
try {
|
|
357
|
+
this.server?.close();
|
|
358
|
+
} catch {}
|
|
359
|
+
for (const file of [this.socketPath, this.registryPath, this.keyPath]) {
|
|
360
|
+
if (!file) continue;
|
|
361
|
+
try {
|
|
362
|
+
fs.rmSync(file, { force: true });
|
|
363
|
+
} catch {}
|
|
364
|
+
}
|
|
365
|
+
this.started = false;
|
|
366
|
+
}
|
|
367
|
+
}
|
package/src/platform.mjs
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import { accessSync, constants, existsSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
export const IS_MACOS = process.platform === "darwin";
|
|
11
|
+
export const IS_WINDOWS = process.platform === "win32";
|
|
12
|
+
export const IS_LINUX = process.platform === "linux";
|
|
13
|
+
|
|
14
|
+
export const PLATFORM_LABEL = IS_MACOS
|
|
15
|
+
? "macOS"
|
|
16
|
+
: IS_WINDOWS
|
|
17
|
+
? "Windows"
|
|
18
|
+
: IS_LINUX
|
|
19
|
+
? "Linux"
|
|
20
|
+
: process.platform;
|
|
21
|
+
|
|
22
|
+
const CODEX_DESKTOP_APP_MACOS = "/Applications/ChatGPT.app";
|
|
23
|
+
const CODEX_DESKTOP_BIN_MACOS = `${CODEX_DESKTOP_APP_MACOS}/Contents/Resources/codex`;
|
|
24
|
+
const CODEX_THREAD_URL_PREFIX = "codex://threads/";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read per call rather than at import, so a client that changes the
|
|
28
|
+
* environment does not have to restart the bridge to be believed.
|
|
29
|
+
*/
|
|
30
|
+
const remapEnabled = () => process.env.CODEX_BRIDGE_REMAP !== "0";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `os.homedir()` reads HOME on macOS and Linux but USERPROFILE on Windows, so
|
|
34
|
+
* a process that sets only HOME is obeyed on two platforms and ignored on the
|
|
35
|
+
* third. Every module resolves the home directory through here so all three
|
|
36
|
+
* behave the same.
|
|
37
|
+
*/
|
|
38
|
+
export const homeDir = () => process.env.HOME ?? process.env.USERPROFILE ?? os.homedir();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A path that names a drive letter or an attached volume was almost certainly
|
|
42
|
+
* written on the other machine - that is the whole signal, and it needs no
|
|
43
|
+
* configuration to read. Naming one particular drive here would have been a
|
|
44
|
+
* fact about one machine, and the generic form covers every dual-boot and
|
|
45
|
+
* external-disk layout instead of a single blessed one.
|
|
46
|
+
*/
|
|
47
|
+
const FOREIGN_ROOT_PATTERNS = [
|
|
48
|
+
/^[A-Za-z]:\/(.+)$/,
|
|
49
|
+
/^\/Volumes\/[^/]+\/(.+)$/,
|
|
50
|
+
/^\/mnt\/[^/]+\/(.+)$/,
|
|
51
|
+
/^\/media\/[^/]+\/[^/]+\/(.+)$/,
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Where the bridge itself is checked out answers the question "where does this
|
|
56
|
+
* user keep projects?" without anyone having to say so: a bridge living in
|
|
57
|
+
* ~/code/codex-mcp-bridge makes ~/code the obvious place to look for a sibling
|
|
58
|
+
* project. That is a measurement, not a guess, and it keeps one developer's
|
|
59
|
+
* directory layout out of the source.
|
|
60
|
+
*/
|
|
61
|
+
function bridgeParentDir() {
|
|
62
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
63
|
+
return path.dirname(repoRoot);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Set CODEX_BRIDGE_WORKSPACE_ROOTS to take over the search entirely - it
|
|
68
|
+
* replaces the derived roots rather than adding to them, so the order is
|
|
69
|
+
* exactly what was written.
|
|
70
|
+
*/
|
|
71
|
+
function workspaceRoots() {
|
|
72
|
+
const configured = process.env.CODEX_BRIDGE_WORKSPACE_ROOTS;
|
|
73
|
+
if (configured) {
|
|
74
|
+
return configured
|
|
75
|
+
.split(path.delimiter)
|
|
76
|
+
.map((entry) => entry.trim())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
}
|
|
79
|
+
return [homeDir(), bridgeParentDir()];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isRunnable(candidate) {
|
|
83
|
+
if (!candidate || !existsSync(candidate)) return false;
|
|
84
|
+
if (IS_WINDOWS) return true;
|
|
85
|
+
try {
|
|
86
|
+
accessSync(candidate, constants.X_OK);
|
|
87
|
+
return true;
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function windowsCodexCandidates() {
|
|
94
|
+
const roaming = process.env.APPDATA;
|
|
95
|
+
const local = process.env.LOCALAPPDATA;
|
|
96
|
+
return [
|
|
97
|
+
local && path.join(local, "Programs", "OpenAI", "Codex", "bin", "codex.exe"),
|
|
98
|
+
roaming && path.join(roaming, "npm", "codex.cmd"),
|
|
99
|
+
process.env.ProgramFiles && path.join(process.env.ProgramFiles, "nodejs", "codex.cmd"),
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function unixCodexCandidates() {
|
|
104
|
+
const home = homeDir();
|
|
105
|
+
return [
|
|
106
|
+
path.join(home, ".local", "bin", "codex"),
|
|
107
|
+
path.join(home, ".npm-global", "bin", "codex"),
|
|
108
|
+
"/opt/homebrew/bin/codex",
|
|
109
|
+
"/usr/local/bin/codex",
|
|
110
|
+
path.join(home, ".volta", "bin", "codex"),
|
|
111
|
+
path.join(home, ".bun", "bin", "codex"),
|
|
112
|
+
path.join(home, ".cargo", "bin", "codex"),
|
|
113
|
+
path.join(home, ".codex", "packages", "standalone", "current", "codex"),
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The desktop app ships its own codex build and holds ~/.codex/state_*.sqlite
|
|
119
|
+
* open while it runs. Spawning the app-server from a different CLI build makes
|
|
120
|
+
* two versions write that same state, so prefer the app's binary whenever the
|
|
121
|
+
* app is installed and fall back to the standalone CLI installs otherwise.
|
|
122
|
+
*/
|
|
123
|
+
function macosCodexCandidates() {
|
|
124
|
+
return [
|
|
125
|
+
hasCodexDesktopApp() ? CODEX_DESKTOP_BIN_MACOS : null,
|
|
126
|
+
...unixCodexCandidates(),
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function pathCandidates() {
|
|
131
|
+
const separator = IS_WINDOWS ? ";" : ":";
|
|
132
|
+
const names = IS_WINDOWS ? ["codex.exe", "codex.cmd"] : ["codex"];
|
|
133
|
+
return (process.env.PATH ?? "")
|
|
134
|
+
.split(separator)
|
|
135
|
+
.filter(Boolean)
|
|
136
|
+
.flatMap((dir) => names.map((name) => path.join(dir, name)));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Claude Desktop and launchd start child processes with a trimmed PATH, so a
|
|
141
|
+
* bare `codex` lookup fails on macOS where the CLI usually lives under
|
|
142
|
+
* ~/.local/bin, ~/.npm-global/bin or Homebrew. Probe every known install
|
|
143
|
+
* location for the running platform before giving up on PATH resolution.
|
|
144
|
+
*/
|
|
145
|
+
export function resolveCodexBin(explicit) {
|
|
146
|
+
const candidates = [
|
|
147
|
+
explicit,
|
|
148
|
+
process.env.CODEX_BIN,
|
|
149
|
+
...(IS_WINDOWS ? windowsCodexCandidates() : IS_MACOS ? macosCodexCandidates() : unixCodexCandidates()),
|
|
150
|
+
...pathCandidates(),
|
|
151
|
+
].filter(Boolean);
|
|
152
|
+
|
|
153
|
+
for (const candidate of candidates) {
|
|
154
|
+
if (isRunnable(candidate)) return candidate;
|
|
155
|
+
}
|
|
156
|
+
return "codex";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The macOS and Linux `codex` launcher is a Node script with a
|
|
161
|
+
* `#!/usr/bin/env node` shebang, so the spawned child needs a PATH that
|
|
162
|
+
* actually contains node - the trimmed PATH handed to MCP servers does not.
|
|
163
|
+
*/
|
|
164
|
+
/**
|
|
165
|
+
* A path is only usable as a Codex cwd when the agent can also write to it.
|
|
166
|
+
* The NTFS mounts this project is shared through are read-only on macOS, so an
|
|
167
|
+
* existence check alone would still hand Codex a directory it cannot edit.
|
|
168
|
+
*/
|
|
169
|
+
export function isWritableDir(target) {
|
|
170
|
+
try {
|
|
171
|
+
accessSync(target, constants.W_OK);
|
|
172
|
+
return true;
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function normalizeSeparators(input) {
|
|
179
|
+
return input.replace(/\\/g, "/").replace(/\/+$/, "") || input;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function foreignRootRelative(input) {
|
|
183
|
+
const normalized = normalizeSeparators(input);
|
|
184
|
+
for (const pattern of FOREIGN_ROOT_PATTERNS) {
|
|
185
|
+
const match = normalized.match(pattern);
|
|
186
|
+
if (match) return match[1];
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function remapCandidates(input) {
|
|
192
|
+
const relative = foreignRootRelative(input);
|
|
193
|
+
if (!relative) return [];
|
|
194
|
+
const segments = relative.split("/").filter(Boolean);
|
|
195
|
+
return workspaceRoots().map((root) => path.join(root, ...segments));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The same project lives at a different absolute path on each machine: on the
|
|
200
|
+
* shared drive's own letter under Windows, under its mount point when that
|
|
201
|
+
* drive is visible from macOS, and in a native checkout otherwise. Handing
|
|
202
|
+
* Codex the wrong one starts the thread against a directory the user is not
|
|
203
|
+
* looking at, or one the agent cannot write to - which then stalls the turn on
|
|
204
|
+
* a permission request instead of failing outright.
|
|
205
|
+
*
|
|
206
|
+
* The path as given always goes first: if this machine can already write
|
|
207
|
+
* there, no rewriting is warranted and guessing would be the bug. Rewriting
|
|
208
|
+
* only happens for a path this machine cannot use, which is exactly the case
|
|
209
|
+
* it was written for - macOS mounts NTFS read-only, so the drive a Windows
|
|
210
|
+
* brief quotes is visible and useless at the same time.
|
|
211
|
+
*/
|
|
212
|
+
export function resolveWorkspacePath(input) {
|
|
213
|
+
if (!input) return { path: input, remapped: false, writable: false, note: null };
|
|
214
|
+
const original = input;
|
|
215
|
+
const candidates = [input, ...(remapEnabled() ? remapCandidates(input) : [])];
|
|
216
|
+
const ordered = candidates.filter((candidate, index) => candidates.indexOf(candidate) === index);
|
|
217
|
+
|
|
218
|
+
const writable = ordered.find((candidate) => existsSync(candidate) && isWritableDir(candidate));
|
|
219
|
+
if (writable) {
|
|
220
|
+
return {
|
|
221
|
+
path: writable,
|
|
222
|
+
remapped: writable !== original,
|
|
223
|
+
writable: true,
|
|
224
|
+
note:
|
|
225
|
+
writable !== original
|
|
226
|
+
? `cwd remapped for ${PLATFORM_LABEL}: ${original} -> ${writable}`
|
|
227
|
+
: null,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const existing = ordered.find((candidate) => existsSync(candidate));
|
|
232
|
+
if (existing) {
|
|
233
|
+
return {
|
|
234
|
+
path: existing,
|
|
235
|
+
remapped: existing !== original,
|
|
236
|
+
writable: false,
|
|
237
|
+
note: `cwd ${existing} exists but is not writable on ${PLATFORM_LABEL}; Codex will fail on any file edit.`,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
throw new Error(
|
|
242
|
+
`No usable working directory for "${original}" on ${PLATFORM_LABEL}. Tried: ${ordered.join(", ")}.`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function spawnEnv(extra = {}) {
|
|
247
|
+
const separator = IS_WINDOWS ? ";" : ":";
|
|
248
|
+
const systemDirs = IS_WINDOWS
|
|
249
|
+
? []
|
|
250
|
+
: ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"];
|
|
251
|
+
const merged = [path.dirname(process.execPath), ...systemDirs, ...(process.env.PATH ?? "").split(separator)]
|
|
252
|
+
.filter(Boolean)
|
|
253
|
+
.filter((dir, index, all) => all.indexOf(dir) === index)
|
|
254
|
+
.join(separator);
|
|
255
|
+
return { ...process.env, PATH: merged, ...extra };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function claudeDesktopConfigPath() {
|
|
259
|
+
const home = homeDir();
|
|
260
|
+
if (IS_MACOS) {
|
|
261
|
+
return path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
262
|
+
}
|
|
263
|
+
if (IS_WINDOWS) {
|
|
264
|
+
const roaming = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
|
|
265
|
+
return path.join(roaming, "Claude", "claude_desktop_config.json");
|
|
266
|
+
}
|
|
267
|
+
const configHome = process.env.XDG_CONFIG_HOME ?? path.join(home, ".config");
|
|
268
|
+
return path.join(configHome, "Claude", "claude_desktop_config.json");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function launchAgentPath() {
|
|
272
|
+
return path.join(homeDir(), "Library", "LaunchAgents", "com.codex-mcp-bridge.app-server.plist");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export const LAUNCH_AGENT_LABEL = "com.codex-mcp-bridge.app-server";
|
|
276
|
+
|
|
277
|
+
export function codexThreadUrl(threadId) {
|
|
278
|
+
return `${CODEX_THREAD_URL_PREFIX}${threadId}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function hasCodexDesktopApp() {
|
|
282
|
+
return IS_MACOS && existsSync(CODEX_DESKTOP_APP_MACOS);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function isLaunchAgentInstalled() {
|
|
286
|
+
return IS_MACOS && existsSync(launchAgentPath());
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The desktop app runs its own stdio app-server against the same ~/.codex
|
|
291
|
+
* sqlite state. A second long-lived app-server contends for that state and the
|
|
292
|
+
* app's UI stutters, so the two should not both sit idle in the background.
|
|
293
|
+
*/
|
|
294
|
+
export function isDesktopAppServerRunning() {
|
|
295
|
+
if (!IS_MACOS) return false;
|
|
296
|
+
try {
|
|
297
|
+
const out = execFileSync("/bin/ps", ["-Ao", "command="], { maxBuffer: 4 * 1024 * 1024 }).toString();
|
|
298
|
+
return out.split("\n").some((line) => line.includes("ChatGPT.app") && line.includes("app-server"));
|
|
299
|
+
} catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Bring a thread to the foreground in the Codex desktop app so a human can
|
|
306
|
+
* watch the turn run instead of only reading the transcript afterwards.
|
|
307
|
+
* macOS registers the `codex://` scheme through /Applications/ChatGPT.app.
|
|
308
|
+
*/
|
|
309
|
+
export async function openThreadInCodexApp(threadId, { activate = true } = {}) {
|
|
310
|
+
const url = codexThreadUrl(threadId);
|
|
311
|
+
if (!IS_MACOS) {
|
|
312
|
+
throw new Error(`Opening a Codex thread in the desktop app is macOS-only. Open ${url} manually.`);
|
|
313
|
+
}
|
|
314
|
+
if (!hasCodexDesktopApp()) {
|
|
315
|
+
throw new Error(`Codex desktop app not found at ${CODEX_DESKTOP_APP_MACOS}. Install it to use ${url}.`);
|
|
316
|
+
}
|
|
317
|
+
const args = activate ? [url] : ["-g", url];
|
|
318
|
+
await execFileAsync("open", args, { timeout: 10000 });
|
|
319
|
+
return url;
|
|
320
|
+
}
|