@minhspark/codex-mcp-bridge 1.13.3 → 1.13.5
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 +29 -0
- package/README.md +34 -16
- package/package.json +1 -1
- package/scripts/check-claude-bridge.mjs +2 -1
- package/scripts/install-codex-mcp.mjs +12 -15
- package/scripts/install-native-relay.mjs +35 -3
- package/src/claude-bridge.mjs +167 -57
- package/src/claude-desktop-context.mjs +160 -0
- package/src/codex-mcp-registration.mjs +25 -0
- package/src/codex-sender-context.mjs +134 -0
- package/src/index.mjs +33 -11
- package/src/native-relay-companion.mjs +1 -1
- package/src/peer-protocol.mjs +126 -28
- package/src/platform.mjs +55 -1
- package/src/reply-forwarder.mjs +172 -0
- package/src/runtime-state.mjs +34 -0
- package/src/thread-delivery.mjs +8 -4
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const METADATA_KEY = "x-codex-turn-metadata";
|
|
6
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
7
|
+
const LIFECYCLE = new Set(["task_started", "task_complete", "task_completed", "turn_started", "turn_complete", "turn_completed", "turn_aborted", "task_aborted"]);
|
|
8
|
+
const STARTED = new Set(["task_started", "turn_started"]);
|
|
9
|
+
const MAX_ROLLOUT_BYTES = 64 * 1024 * 1024;
|
|
10
|
+
const MAX_ENTRIES = 100000;
|
|
11
|
+
const MAX_DIRECTORIES = 4096;
|
|
12
|
+
|
|
13
|
+
function object(value) {
|
|
14
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function exactObject(value, keys) {
|
|
18
|
+
return object(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function unavailable(reason, identity = {}) {
|
|
22
|
+
return { status: "unavailable", threadId: null, turnId: null, mode: null, cwd: null, source: null, ...identity, reason };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function reviewFlag(metadata, field) {
|
|
26
|
+
if (!Object.hasOwn(metadata, field) || metadata[field] === undefined) return "missing";
|
|
27
|
+
if (metadata[field] === true) return "enabled";
|
|
28
|
+
if (metadata[field] === false) return "disabled";
|
|
29
|
+
return "invalid";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function findRollout(sessions, threadId) {
|
|
33
|
+
if (!fs.lstatSync(sessions).isDirectory()) throw new Error("The Codex sessions path is not a regular directory");
|
|
34
|
+
const queue = [{ directory: sessions, depth: 0 }];
|
|
35
|
+
const matches = [];
|
|
36
|
+
let entries = 0;
|
|
37
|
+
let directories = 0;
|
|
38
|
+
while (queue.length) {
|
|
39
|
+
const { directory, depth } = queue.pop();
|
|
40
|
+
if (++directories > MAX_DIRECTORIES) throw new Error("The bounded Codex sessions scan exceeded its directory limit");
|
|
41
|
+
const children = fs.readdirSync(directory, { withFileTypes: true });
|
|
42
|
+
entries += children.length;
|
|
43
|
+
if (entries > MAX_ENTRIES) throw new Error("The bounded Codex sessions scan exceeded its entry limit");
|
|
44
|
+
for (const child of children) {
|
|
45
|
+
const candidate = path.join(directory, child.name);
|
|
46
|
+
if (depth < 3 && (depth === 0 ? /^\d{4}$/ : /^\d{2}$/).test(child.name)) {
|
|
47
|
+
if (child.isSymbolicLink()) throw new Error("The Codex sessions scan encountered a linked date directory");
|
|
48
|
+
if (child.isDirectory()) queue.push({ directory: candidate, depth: depth + 1 });
|
|
49
|
+
}
|
|
50
|
+
if (depth === 3 && child.name.startsWith("rollout-") && child.name.endsWith(`-${threadId}.jsonl`)) {
|
|
51
|
+
if (!child.isFile() || child.isSymbolicLink()) throw new Error("The sender rollout is not a regular file");
|
|
52
|
+
matches.push(candidate);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (matches.length !== 1) throw new Error(matches.length ? "Multiple rollouts match the calling Codex task" : "No rollout matches the calling Codex task");
|
|
57
|
+
return matches[0];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readState(file, maxBytes) {
|
|
61
|
+
const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
62
|
+
try {
|
|
63
|
+
const before = fs.fstatSync(descriptor);
|
|
64
|
+
if (!before.isFile() || before.size === 0 || before.size > maxBytes) throw new Error("The sender rollout is empty or exceeds the bounded read limit");
|
|
65
|
+
const data = Buffer.alloc(before.size);
|
|
66
|
+
let offset = 0;
|
|
67
|
+
while (offset < data.length) {
|
|
68
|
+
const count = fs.readSync(descriptor, data, offset, data.length - offset, offset);
|
|
69
|
+
if (!count) throw new Error("The sender rollout changed while reading");
|
|
70
|
+
offset += count;
|
|
71
|
+
}
|
|
72
|
+
const after = fs.fstatSync(descriptor);
|
|
73
|
+
const current = fs.lstatSync(file);
|
|
74
|
+
if (!current.isFile() || current.isSymbolicLink() || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ino !== current.ino || before.dev !== current.dev) throw new Error("The sender rollout changed while reading");
|
|
75
|
+
const text = data.toString("utf8");
|
|
76
|
+
if (!text.endsWith("\n")) throw new Error("The sender rollout has an incomplete final record");
|
|
77
|
+
let session;
|
|
78
|
+
let context;
|
|
79
|
+
let lifecycle;
|
|
80
|
+
for (const line of text.split("\n")) {
|
|
81
|
+
if (!line) continue;
|
|
82
|
+
const record = JSON.parse(line);
|
|
83
|
+
if (!object(record) || !object(record.payload)) throw new Error("The sender rollout contains an invalid record");
|
|
84
|
+
if (record.type === "session_meta") {
|
|
85
|
+
if (session) throw new Error("The sender rollout repeats its session identity");
|
|
86
|
+
session = record.payload;
|
|
87
|
+
} else if (record.type === "turn_context") {
|
|
88
|
+
context = record.payload;
|
|
89
|
+
} else if (record.type === "event_msg" && LIFECYCLE.has(record.payload.type)) {
|
|
90
|
+
lifecycle = record.payload;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { session, context, lifecycle };
|
|
94
|
+
} finally {
|
|
95
|
+
fs.closeSync(descriptor);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function permissionClass(context, metadata) {
|
|
100
|
+
for (const field of ["auto_review_enabled", "node_repl_auto_review_required"]) {
|
|
101
|
+
if (!Object.hasOwn(metadata, field) || typeof metadata[field] !== "boolean") throw new Error(`The caller's ${field} review evidence is ${reviewFlag(metadata, field)}; the host must supply an explicit boolean`);
|
|
102
|
+
}
|
|
103
|
+
if (context.approvals_reviewer !== "user") throw new Error("The caller's effective approval reviewer is unverified");
|
|
104
|
+
if (!exactObject(context.permission_profile, ["type"]) || context.permission_profile.type !== "disabled") throw new Error("The caller's permission profile is restricted or unsupported; no permission class was inferred");
|
|
105
|
+
if (!exactObject(context.sandbox_policy, ["type"]) || context.sandbox_policy.type !== "danger-full-access") throw new Error("The caller's effective sandbox policy does not match its disabled permission profile");
|
|
106
|
+
if (!["never", "on-request", "on-failure", "untrusted"].includes(context.approval_policy)) throw new Error("The caller's effective approval policy is unsupported");
|
|
107
|
+
return context.approval_policy === "never" && !metadata.auto_review_enabled && !metadata.node_repl_auto_review_required ? "bypass" : "prompting";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function readCodexSenderContext(meta, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
|
|
111
|
+
const metadata = object(meta) ? meta[METADATA_KEY] : undefined;
|
|
112
|
+
if (!object(metadata) || typeof metadata.thread_id !== "string" || typeof metadata.turn_id !== "string" || !UUID.test(metadata.thread_id) || !UUID.test(metadata.turn_id)) return unavailable("This MCP call has no valid host-supplied Codex task and turn identity");
|
|
113
|
+
const identity = { threadId: metadata.thread_id, turnId: metadata.turn_id,
|
|
114
|
+
review: { autoReview: reviewFlag(metadata, "auto_review_enabled"), nodeReplReview: reviewFlag(metadata, "node_repl_auto_review_required") } };
|
|
115
|
+
try {
|
|
116
|
+
if (!Number.isSafeInteger(maxRolloutBytes) || maxRolloutBytes < 1) throw new Error("The sender rollout read limit is invalid");
|
|
117
|
+
if (metadata.thread_source !== "user") throw new Error("This MCP call is not from a user-owned Codex task");
|
|
118
|
+
const configuredHome = env.CODEX_HOME || path.join(env.HOME || env.USERPROFILE || os.homedir(), ".codex");
|
|
119
|
+
if (!path.isAbsolute(configuredHome)) throw new Error("The configured Codex home must be absolute");
|
|
120
|
+
const sessions = path.join(configuredHome, "sessions");
|
|
121
|
+
const file = findRollout(sessions, identity.threadId);
|
|
122
|
+
const state = readState(file, Math.min(MAX_ROLLOUT_BYTES, maxRolloutBytes));
|
|
123
|
+
const { session, context, lifecycle } = state;
|
|
124
|
+
if (session?.id !== identity.threadId || session.originator !== "Codex Desktop" || session.source !== "vscode") throw new Error("The caller rollout does not confirm a root Codex Desktop task");
|
|
125
|
+
if (context?.turn_id !== identity.turnId || lifecycle?.turn_id !== identity.turnId || !STARTED.has(lifecycle?.type)) throw new Error("The calling turn is no longer the latest active Codex turn");
|
|
126
|
+
if (typeof context.cwd !== "string" || !path.isAbsolute(context.cwd) || typeof session.cwd !== "string" || !path.isAbsolute(session.cwd)) throw new Error("The caller's workspace is missing or invalid");
|
|
127
|
+
const cwd = fs.realpathSync.native(context.cwd);
|
|
128
|
+
if (!fs.statSync(cwd).isDirectory() || path.relative(fs.realpathSync.native(session.cwd), cwd)) throw new Error("The caller's workspace changed from its Desktop session identity");
|
|
129
|
+
const mode = permissionClass(context, metadata);
|
|
130
|
+
return { status: "verified", ...identity, mode, cwd, source: file, approvalPolicy: context.approval_policy, reason: "Host-supplied calling task and active turn match the Desktop rollout's effective permission settings" };
|
|
131
|
+
} catch (error) {
|
|
132
|
+
return unavailable(error?.code ? `Caller evidence could not be read (${error.code}); no sender permission class was inferred` : error.message, identity);
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -25,10 +25,11 @@ import { BridgeSecurityPolicy } from "./security-policy.mjs";
|
|
|
25
25
|
import { DesktopTaskDelivery, DESKTOP_TOOL_BUDGET_MS } from "./thread-delivery.mjs";
|
|
26
26
|
import { desktopTasksConfigured } from "./native-relay.mjs";
|
|
27
27
|
import { exitForVersionRequest } from "./cli-version.mjs";
|
|
28
|
+
import { createRuntimeState } from "./runtime-state.mjs";
|
|
28
29
|
|
|
29
30
|
exitForVersionRequest(import.meta.url);
|
|
30
31
|
|
|
31
|
-
const VERSION = "1.13.
|
|
32
|
+
const VERSION = "1.13.5";
|
|
32
33
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -48,6 +49,7 @@ const TERMINAL_TURN_STATUSES = new Set(["completed", "interrupted", "failed"]);
|
|
|
48
49
|
const RELEASE_TURN_STATUSES = TERMINAL_TURN_STATUSES;
|
|
49
50
|
const security = new BridgeSecurityPolicy();
|
|
50
51
|
const desktopTasksEnabled = desktopTasksConfigured();
|
|
52
|
+
const runtime = createRuntimeState({ configuration: desktopTasksConfigured });
|
|
51
53
|
const desktopTasks = new DesktopTaskDelivery({ security });
|
|
52
54
|
|
|
53
55
|
const client = desktopTasksEnabled ? null : new CodexAppServerClient({
|
|
@@ -279,7 +281,8 @@ const server = new McpServer(
|
|
|
279
281
|
{ name: "codex-bridge", version: VERSION },
|
|
280
282
|
{
|
|
281
283
|
instructions:
|
|
282
|
-
"Bridge Claude work into Codex. Prefer
|
|
284
|
+
"Bridge Claude work into Codex. Prefer an existing task: inspect its exact threadId and project, then use send_to_codex_thread. " +
|
|
285
|
+
"Only use delegate_to_codex or start_codex_thread when the user explicitly requests a new task at the " +
|
|
283
286
|
"requested cwd. With Desktop tasks enabled it assigns the exact saved project and starts visibly in " +
|
|
284
287
|
"Codex Desktop using Desktop permissions. Otherwise it releases the bridge writer lock and opens the exact thread in " +
|
|
285
288
|
"Codex Desktop. Use send_to_codex_thread only when an existing threadId is intentional; use " +
|
|
@@ -287,7 +290,26 @@ const server = new McpServer(
|
|
|
287
290
|
},
|
|
288
291
|
);
|
|
289
292
|
|
|
290
|
-
|
|
293
|
+
function registerTool(name, definition, handler) {
|
|
294
|
+
server.registerTool(name, definition, async (...args) => {
|
|
295
|
+
try {
|
|
296
|
+
if (!definition.annotations?.readOnlyHint) runtime.assertCurrent();
|
|
297
|
+
if (name === "codex_bridge_status") {
|
|
298
|
+
const state = runtime.status();
|
|
299
|
+
if (!state.current) return { ...failure(new Error(`${state.reason}; reconnect this MCP server in the existing task.`)), structuredContent: { runtime: state } };
|
|
300
|
+
const result = await handler(...args);
|
|
301
|
+
result.content.push({ type: "text", text: `runtime pid: ${state.pid}\nloaded source: ${state.revision}\nruntime state: current` });
|
|
302
|
+
result.structuredContent = { ...result.structuredContent, runtime: state };
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
return await handler(...args);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return failure(err);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
registerTool(
|
|
291
313
|
"delegate_to_codex",
|
|
292
314
|
{
|
|
293
315
|
title: "Delegate work to a new Codex session",
|
|
@@ -378,7 +400,7 @@ server.registerTool(
|
|
|
378
400
|
},
|
|
379
401
|
);
|
|
380
402
|
|
|
381
|
-
|
|
403
|
+
registerTool(
|
|
382
404
|
"send_to_codex_thread",
|
|
383
405
|
{
|
|
384
406
|
title: "Send a prompt to a Codex thread",
|
|
@@ -503,7 +525,7 @@ server.registerTool(
|
|
|
503
525
|
},
|
|
504
526
|
);
|
|
505
527
|
|
|
506
|
-
|
|
528
|
+
registerTool(
|
|
507
529
|
"list_codex_threads",
|
|
508
530
|
{
|
|
509
531
|
title: "List Codex threads",
|
|
@@ -589,7 +611,7 @@ server.registerTool(
|
|
|
589
611
|
},
|
|
590
612
|
);
|
|
591
613
|
|
|
592
|
-
|
|
614
|
+
registerTool(
|
|
593
615
|
"start_codex_thread",
|
|
594
616
|
{
|
|
595
617
|
title: "Start a new Codex thread",
|
|
@@ -631,7 +653,7 @@ server.registerTool(
|
|
|
631
653
|
},
|
|
632
654
|
);
|
|
633
655
|
|
|
634
|
-
|
|
656
|
+
registerTool(
|
|
635
657
|
"read_codex_thread",
|
|
636
658
|
{
|
|
637
659
|
title: "Read a Codex thread",
|
|
@@ -678,7 +700,7 @@ server.registerTool(
|
|
|
678
700
|
},
|
|
679
701
|
);
|
|
680
702
|
|
|
681
|
-
|
|
703
|
+
registerTool(
|
|
682
704
|
"interrupt_codex_turn",
|
|
683
705
|
{
|
|
684
706
|
title: "Interrupt a Codex turn",
|
|
@@ -712,7 +734,7 @@ server.registerTool(
|
|
|
712
734
|
},
|
|
713
735
|
);
|
|
714
736
|
|
|
715
|
-
|
|
737
|
+
registerTool(
|
|
716
738
|
"open_codex_thread",
|
|
717
739
|
{
|
|
718
740
|
title: "Open a Codex thread in the desktop app",
|
|
@@ -755,7 +777,7 @@ server.registerTool(
|
|
|
755
777
|
},
|
|
756
778
|
);
|
|
757
779
|
|
|
758
|
-
|
|
780
|
+
registerTool(
|
|
759
781
|
"stop_codex_app_server",
|
|
760
782
|
{
|
|
761
783
|
title: "Stop the shared Codex app-server",
|
|
@@ -789,7 +811,7 @@ server.registerTool(
|
|
|
789
811
|
},
|
|
790
812
|
);
|
|
791
813
|
|
|
792
|
-
|
|
814
|
+
registerTool(
|
|
793
815
|
"codex_bridge_status",
|
|
794
816
|
{
|
|
795
817
|
title: "Check the Codex bridge environment",
|
|
@@ -23,7 +23,7 @@ import { exitForVersionRequest } from "./cli-version.mjs";
|
|
|
23
23
|
|
|
24
24
|
exitForVersionRequest(import.meta.url);
|
|
25
25
|
|
|
26
|
-
const VERSION = "1.13.
|
|
26
|
+
const VERSION = "1.13.5";
|
|
27
27
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
28
28
|
|
|
29
29
|
function errorResponse(code, message) {
|
package/src/peer-protocol.mjs
CHANGED
|
@@ -117,7 +117,7 @@ function readProcessStart(pid) {
|
|
|
117
117
|
try {
|
|
118
118
|
if (IS_WINDOWS) {
|
|
119
119
|
const shell = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
120
|
-
return execFileSync(shell, ["-NoProfile", "-Command", `(
|
|
120
|
+
return execFileSync(shell, ["-NoProfile", "-NonInteractive", "-Command", `[System.Diagnostics.Process]::GetProcessById(${Number(pid)}).StartTime.ToUniversalTime().ToFileTimeUtc().ToString()`], { timeout: 3000, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }).toString().trim();
|
|
121
121
|
}
|
|
122
122
|
return execFileSync(PS_BIN, ["-o", "lstart=", "-p", String(pid)], { env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, timeout: 3000 }).toString().trim();
|
|
123
123
|
} catch {
|
|
@@ -176,10 +176,12 @@ export function listClaudeSessions({ includeDead = false, includeBridges = false
|
|
|
176
176
|
pid: entry.pid,
|
|
177
177
|
name: entry.name ?? null,
|
|
178
178
|
sessionId: entry.sessionId ?? null,
|
|
179
|
+
bridgeSessionId: entry.bridgeSessionId ?? null,
|
|
179
180
|
cwd: entry.cwd ?? null,
|
|
180
181
|
kind: entry.kind ?? null,
|
|
181
182
|
entrypoint: entry.entrypoint ?? null,
|
|
182
183
|
startedAt: entry.startedAt ?? null,
|
|
184
|
+
processStart: (IS_WINDOWS ? entry.procStartFt : entry.procStart) ?? null,
|
|
183
185
|
socket: entry.messagingSocketPath,
|
|
184
186
|
alive,
|
|
185
187
|
});
|
|
@@ -187,9 +189,21 @@ export function listClaudeSessions({ includeDead = false, includeBridges = false
|
|
|
187
189
|
return rows.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0));
|
|
188
190
|
}
|
|
189
191
|
|
|
190
|
-
export function findClaudeSession(target) {
|
|
192
|
+
export function findClaudeSession(target, { desktopOnly = false, expectedCwd } = {}) {
|
|
191
193
|
const sessions = listClaudeSessions();
|
|
192
194
|
const needle = String(target).trim();
|
|
195
|
+
if (desktopOnly) {
|
|
196
|
+
const byId = sessions.filter((session) => String(session.pid) === needle || session.sessionId === needle);
|
|
197
|
+
const matches = byId.length ? byId : sessions.filter((session) => session.name === needle);
|
|
198
|
+
if (!needle || matches.length === 0) return null;
|
|
199
|
+
if (matches.length !== 1) throw new Error(`Desktop-only mode requires an unambiguous sessionId or pid; "${needle}" matches multiple sessions. No message was sent.`);
|
|
200
|
+
const session = matches[0];
|
|
201
|
+
if (session.entrypoint !== "claude-desktop") {
|
|
202
|
+
throw new Error(`Desktop-only mode refuses Claude session ${session.sessionId ?? session.pid} with entrypoint ${session.entrypoint ?? "unknown"}. Open or reconnect an existing Code session in Claude Desktop for the intended project. Do not launch a replacement CLI session. No message was sent.`);
|
|
203
|
+
}
|
|
204
|
+
if (expectedCwd !== undefined) assertClaudeSessionCwd(session, expectedCwd);
|
|
205
|
+
return session;
|
|
206
|
+
}
|
|
193
207
|
return (
|
|
194
208
|
sessions.find((s) => String(s.pid) === needle) ??
|
|
195
209
|
sessions.find((s) => s.sessionId === needle) ??
|
|
@@ -199,6 +213,31 @@ export function findClaudeSession(target) {
|
|
|
199
213
|
);
|
|
200
214
|
}
|
|
201
215
|
|
|
216
|
+
export function assertClaudeSessionCwd(session, expectedCwd) {
|
|
217
|
+
if (typeof expectedCwd !== "string" || !path.isAbsolute(expectedCwd) || !session.cwd || !path.isAbsolute(session.cwd)) {
|
|
218
|
+
throw new Error("Desktop delivery requires an explicit absolute expectedCwd and an absolute session cwd. No message was sent.");
|
|
219
|
+
}
|
|
220
|
+
let expected;
|
|
221
|
+
let actual;
|
|
222
|
+
try {
|
|
223
|
+
expected = fs.realpathSync.native(expectedCwd);
|
|
224
|
+
actual = fs.realpathSync.native(session.cwd);
|
|
225
|
+
} catch {
|
|
226
|
+
throw new Error("The intended project directory or the Claude session cwd no longer exists. No message was sent.");
|
|
227
|
+
}
|
|
228
|
+
const normalize = (value) => IS_WINDOWS ? value.toLowerCase() : value;
|
|
229
|
+
if (normalize(expected) !== normalize(actual)) {
|
|
230
|
+
throw new Error(`Claude session cwd ${actual} does not match expectedCwd ${expected}. No message was sent.`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function assertClaudeSessionProcess(session) {
|
|
235
|
+
if (!session.alive || typeof session.processStart !== "string" || !session.processStart ||
|
|
236
|
+
readProcessStart(session.pid) !== session.processStart) {
|
|
237
|
+
throw new Error("The live Claude process identity is missing or changed. No message was sent; reopen the existing Desktop task and inspect its session again.");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
202
241
|
/**
|
|
203
242
|
* Claude Code stores a transcript at ~/.claude/projects/<slug>/<sessionId>.jsonl
|
|
204
243
|
* where the slug rewrites more than just path separators (/mnt/dev_disk ->
|
|
@@ -292,6 +331,7 @@ export class PeerEndpoint {
|
|
|
292
331
|
this.peerToken = null;
|
|
293
332
|
this.permissionMode = process.env.CLAUDE_BRIDGE_PERMISSION_MODE;
|
|
294
333
|
this.deliveryReceipts = new Map();
|
|
334
|
+
this.sentMessages = new Map();
|
|
295
335
|
this.pendingMessages = new Map();
|
|
296
336
|
this.responsePoll = null;
|
|
297
337
|
}
|
|
@@ -432,7 +472,18 @@ export class PeerEndpoint {
|
|
|
432
472
|
#receiveMessage(message) {
|
|
433
473
|
const record = { ...message, receivedAt: Date.now(), sequence: ++this.messageSequence };
|
|
434
474
|
this.inbox.push(record);
|
|
435
|
-
this
|
|
475
|
+
const key = record.inReplyTo ?? [...this.pendingMessages].find(([, entry]) => entry.targetSocket === record.fromSocket)?.[0];
|
|
476
|
+
const pending = this.pendingMessages.get(key);
|
|
477
|
+
const correlated = pending && (!pending.transcriptSession || (record.source === "transcript" && record.inReplyTo === key));
|
|
478
|
+
if (key && correlated && pending.targetSocket === record.fromSocket) {
|
|
479
|
+
const sent = this.sentMessages.get(key);
|
|
480
|
+
if (sent) {
|
|
481
|
+
record.inReplyTo = key;
|
|
482
|
+
record.replyThreadId = sent.replyThreadId ?? null;
|
|
483
|
+
sent.reply = record;
|
|
484
|
+
}
|
|
485
|
+
this.#removePendingReply(record.fromSocket, key);
|
|
486
|
+
}
|
|
436
487
|
this.log(`inbox <- ${record.fromSocket ?? "?"}: ${record.text.slice(0, 120)}`);
|
|
437
488
|
for (const listener of [...this.listeners]) {
|
|
438
489
|
try { listener(record); }
|
|
@@ -480,12 +531,13 @@ export class PeerEndpoint {
|
|
|
480
531
|
return () => this.listeners.delete(listener);
|
|
481
532
|
}
|
|
482
533
|
|
|
483
|
-
async send(targetSocket, text, { priority = "next", msgId } = {}) {
|
|
484
|
-
const frame = buildFrame({ text, fromSocket: this.socketPath, priority, permissionMode
|
|
534
|
+
async send(targetSocket, text, { priority = "next", msgId, permissionMode = this.permissionMode, beforeSend } = {}) {
|
|
535
|
+
const frame = buildFrame({ text, fromSocket: this.socketPath, priority, permissionMode });
|
|
485
536
|
if (msgId) frame.uuid = frame.msg_id = msgId;
|
|
486
537
|
const token = readPeerToken(targetSocket);
|
|
487
538
|
const line = (token ? JSON.stringify({ type: "auth", token }) + "\n" : "") + JSON.stringify(frame) + "\n";
|
|
488
539
|
for (let attempt = 1; attempt <= PEER_SEND_ATTEMPTS; attempt += 1) {
|
|
540
|
+
await beforeSend?.();
|
|
489
541
|
try {
|
|
490
542
|
await new Promise((resolve, reject) => {
|
|
491
543
|
const client = net.connect({ path: targetSocket });
|
|
@@ -503,22 +555,30 @@ export class PeerEndpoint {
|
|
|
503
555
|
settled = true;
|
|
504
556
|
globalThis.clearTimeout(timer);
|
|
505
557
|
if (error) {
|
|
558
|
+
if (writeStarted) error.deliveryUncertain = true;
|
|
506
559
|
reject({ error, retryable: !connected && !writeStarted });
|
|
507
560
|
} else {
|
|
508
561
|
resolve();
|
|
509
562
|
}
|
|
510
563
|
};
|
|
511
|
-
client.once("connect", () => {
|
|
564
|
+
client.once("connect", async () => {
|
|
512
565
|
connected = true;
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
if (
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
566
|
+
try {
|
|
567
|
+
await beforeSend?.();
|
|
568
|
+
if (settled) return;
|
|
569
|
+
writeStarted = true;
|
|
570
|
+
client.write(line, (error) => {
|
|
571
|
+
if (error) {
|
|
572
|
+
finish(error);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
client.end();
|
|
576
|
+
finish();
|
|
577
|
+
});
|
|
578
|
+
} catch (error) {
|
|
579
|
+
finish(error);
|
|
580
|
+
client.destroy();
|
|
581
|
+
}
|
|
522
582
|
});
|
|
523
583
|
client.once("error", finish);
|
|
524
584
|
});
|
|
@@ -534,14 +594,15 @@ export class PeerEndpoint {
|
|
|
534
594
|
return frame.msg_id;
|
|
535
595
|
}
|
|
536
596
|
|
|
537
|
-
async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next", transcriptSession } = {}) {
|
|
597
|
+
async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next", transcriptSession, beforeSend, permissionMode = this.permissionMode, replyThreadId, senderReview } = {}) {
|
|
538
598
|
const previous = this.requestQueues.get(targetSocket) ?? Promise.resolve();
|
|
539
599
|
const pending = previous.catch(() => {}).then(async () => {
|
|
600
|
+
await beforeSend?.();
|
|
540
601
|
const unconfirmed = this.unconfirmedReplies.get(targetSocket) ?? 0;
|
|
541
|
-
if (
|
|
602
|
+
if (unconfirmed > 0) {
|
|
542
603
|
const error = new Error(
|
|
543
604
|
`${unconfirmed} earlier message(s) to ${targetSocket} still await a reply; this message was not sent. `
|
|
544
|
-
+ "
|
|
605
|
+
+ "Inspect read_claude_delivery and wait for Claude's outstanding replies. Changing waitSec or starting another bridge must not be used to bypass a pending message.",
|
|
545
606
|
);
|
|
546
607
|
error.code = "PEER_REPLY_PENDING";
|
|
547
608
|
throw error;
|
|
@@ -551,22 +612,33 @@ export class PeerEndpoint {
|
|
|
551
612
|
this.unconfirmedReplies.set(targetSocket, unconfirmed + 1);
|
|
552
613
|
let msgId = crypto.randomUUID();
|
|
553
614
|
this.pendingMessages.set(msgId, { targetSocket, transcriptSession });
|
|
615
|
+
this.sentMessages.set(msgId, { targetSocket, transcriptSession, sentAt: since, replyThreadId, permissionMode, ...(senderReview ? { senderReview: { ...senderReview } } : {}) });
|
|
616
|
+
if (transcriptSession && !this.responsePoll) {
|
|
617
|
+
this.responsePoll = globalThis.setInterval(() => this.#refreshTranscriptReplies(), 250);
|
|
618
|
+
this.responsePoll.unref();
|
|
619
|
+
}
|
|
554
620
|
try {
|
|
555
|
-
const sentId = await this.send(targetSocket, text, { priority, msgId });
|
|
621
|
+
const sentId = await this.send(targetSocket, text, { priority, msgId, permissionMode, beforeSend });
|
|
556
622
|
if (sentId !== msgId) {
|
|
557
623
|
const pendingMessage = this.pendingMessages.get(msgId);
|
|
624
|
+
const earlyReply = this.sentMessages.get(msgId)?.reply;
|
|
625
|
+
if (earlyReply?.inReplyTo === msgId) earlyReply.inReplyTo = sentId;
|
|
558
626
|
this.pendingMessages.delete(msgId);
|
|
627
|
+
this.sentMessages.set(sentId, this.sentMessages.get(msgId));
|
|
628
|
+
this.sentMessages.delete(msgId);
|
|
559
629
|
msgId = sentId;
|
|
560
630
|
if (pendingMessage) this.pendingMessages.set(msgId, pendingMessage);
|
|
561
631
|
}
|
|
562
632
|
} catch (err) {
|
|
563
|
-
this
|
|
633
|
+
const sent = this.sentMessages.get(msgId);
|
|
634
|
+
if (sent) sent.error = err.message;
|
|
635
|
+
if (!err.deliveryUncertain) {
|
|
636
|
+
this.#removePendingReply(targetSocket, msgId);
|
|
637
|
+
if (sent) sent.failed = true;
|
|
638
|
+
}
|
|
639
|
+
err.msgId = msgId;
|
|
564
640
|
throw err;
|
|
565
641
|
}
|
|
566
|
-
if (transcriptSession && !this.responsePoll && this.pendingMessages.has(msgId)) {
|
|
567
|
-
this.responsePoll = globalThis.setInterval(() => this.#refreshTranscriptReplies(), 250);
|
|
568
|
-
this.responsePoll.unref();
|
|
569
|
-
}
|
|
570
642
|
const reply = timeoutMs > 0
|
|
571
643
|
? await this.waitForReply(targetSocket, { timeoutMs, since, afterSequence, msgId })
|
|
572
644
|
: null;
|
|
@@ -584,7 +656,8 @@ export class PeerEndpoint {
|
|
|
584
656
|
|
|
585
657
|
#removePendingReply(fromSocket, msgId) {
|
|
586
658
|
const key = msgId ?? [...this.pendingMessages].find(([, entry]) => entry.targetSocket === fromSocket)?.[0];
|
|
587
|
-
if (key
|
|
659
|
+
if (!key || this.pendingMessages.get(key)?.targetSocket !== fromSocket) return;
|
|
660
|
+
this.pendingMessages.delete(key);
|
|
588
661
|
const pending = this.unconfirmedReplies.get(fromSocket) ?? 0;
|
|
589
662
|
if (pending > 1) this.unconfirmedReplies.set(fromSocket, pending - 1);
|
|
590
663
|
else this.unconfirmedReplies.delete(fromSocket);
|
|
@@ -595,7 +668,10 @@ export class PeerEndpoint {
|
|
|
595
668
|
* reply is matched by origin socket and arrival time.
|
|
596
669
|
*/
|
|
597
670
|
waitForReply(fromSocket, { timeoutMs = 120000, since = Date.now(), afterSequence = null, msgId } = {}) {
|
|
671
|
+
const expectsTranscript = Boolean(msgId && this.sentMessages.get(msgId)?.transcriptSession);
|
|
598
672
|
const matches = (record) => record.fromSocket === fromSocket
|
|
673
|
+
&& (!expectsTranscript || (record.source === "transcript" && record.inReplyTo === msgId))
|
|
674
|
+
&& (!record.inReplyTo || !msgId || record.inReplyTo === msgId)
|
|
599
675
|
&& (afterSequence === null ? record.receivedAt >= since : record.sequence > afterSequence);
|
|
600
676
|
const existing = this.inbox.find(matches);
|
|
601
677
|
if (existing) return Promise.resolve(existing);
|
|
@@ -619,9 +695,31 @@ export class PeerEndpoint {
|
|
|
619
695
|
}
|
|
620
696
|
|
|
621
697
|
drainInbox(limit = 20) {
|
|
622
|
-
|
|
623
|
-
this.inbox
|
|
624
|
-
|
|
698
|
+
if (!Number.isSafeInteger(limit) || limit < 1) throw new Error("Inbox limit must be a positive integer");
|
|
699
|
+
return this.inbox.splice(0, limit);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
readDelivery(msgId) {
|
|
703
|
+
const sent = this.sentMessages.get(msgId);
|
|
704
|
+
if (!sent) return null;
|
|
705
|
+
const delivery = this.deliveryReceipts.get(msgId);
|
|
706
|
+
const session = sent.transcriptSession;
|
|
707
|
+
return {
|
|
708
|
+
msgId,
|
|
709
|
+
status: sent.reply ? "reply_received" : sent.failed ? "send_failed" : delivery?.status ?? "sent_unconfirmed",
|
|
710
|
+
reason: delivery?.reason ?? sent.error ?? null,
|
|
711
|
+
sentAt: sent.sentAt,
|
|
712
|
+
sessionId: session?.sessionId ?? null,
|
|
713
|
+
cwd: session?.cwd ?? null,
|
|
714
|
+
entrypoint: session?.entrypoint ?? null,
|
|
715
|
+
taskId: session?.desktop?.taskId ?? null,
|
|
716
|
+
title: session?.desktop?.title ?? null,
|
|
717
|
+
senderMode: sent.permissionMode ?? null,
|
|
718
|
+
...(sent.senderReview ? { senderReview: { ...sent.senderReview } } : {}),
|
|
719
|
+
replyThreadId: sent.replyThreadId ?? null,
|
|
720
|
+
pending: this.pendingMessages.has(msgId),
|
|
721
|
+
...(sent.reply ? { reply: sent.reply.text, source: sent.reply.source ?? "peer", ...(sent.reply.forwardingError ? { forwardingError: { ...sent.reply.forwardingError } } : {}) } : {}),
|
|
722
|
+
};
|
|
625
723
|
}
|
|
626
724
|
|
|
627
725
|
stop() {
|
package/src/platform.mjs
CHANGED
|
@@ -20,7 +20,8 @@ export const PLATFORM_LABEL = IS_MACOS
|
|
|
20
20
|
: process.platform;
|
|
21
21
|
|
|
22
22
|
const CODEX_DESKTOP_APP_MACOS = "/Applications/ChatGPT.app";
|
|
23
|
-
const
|
|
23
|
+
const CODEX_DESKTOP_RESOURCES_MACOS = `${CODEX_DESKTOP_APP_MACOS}/Contents/Resources`;
|
|
24
|
+
const CODEX_DESKTOP_BIN_MACOS = `${CODEX_DESKTOP_RESOURCES_MACOS}/codex`;
|
|
24
25
|
const CODEX_THREAD_URL_PREFIX = "codex://threads/";
|
|
25
26
|
|
|
26
27
|
/**
|
|
@@ -176,6 +177,59 @@ export function resolveCodexBin(explicit) {
|
|
|
176
177
|
return "codex";
|
|
177
178
|
}
|
|
178
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Codex Desktop authenticates the code-signing identity of whatever process
|
|
182
|
+
* connects to its native tools pipe, and closes the connection before reading
|
|
183
|
+
* a single byte when that identity is not the vendor's - the app records
|
|
184
|
+
* `dynamic_app_tools_peer_rejected`. A companion launched by the user's own
|
|
185
|
+
* Node build carries the Node.js Foundation signature rather than OpenAI's,
|
|
186
|
+
* so the relay still reports itself installed and still creates its socket
|
|
187
|
+
* while every delivery fails; the symptom surfaces nowhere near the cause.
|
|
188
|
+
* The runtime therefore has to be the one the app ships, which is also the
|
|
189
|
+
* one the app hands its own bundled plugin through CODEX_MCP_NODE_PATH.
|
|
190
|
+
*
|
|
191
|
+
* The vendor's launcher additionally falls back to a cached runtime under
|
|
192
|
+
* ~/.cache/codex-runtimes and to a bare PATH lookup. Both are deliberately
|
|
193
|
+
* absent here: the cached binary measured on a real install is the same
|
|
194
|
+
* version and within 1.3 KB of the bundled one, yet carries the Node.js
|
|
195
|
+
* Foundation signature, so copying that list wholesale would reproduce the
|
|
196
|
+
* rejection this resolves under a different file name. For the same reason
|
|
197
|
+
* the runtime is never taken from PATH or from a version match.
|
|
198
|
+
*
|
|
199
|
+
* Only macOS ships this bundle and only macOS was measured to enforce the
|
|
200
|
+
* check, so the vendor-owned rungs are gated by platform. Every other
|
|
201
|
+
* platform keeps the runtime it has always used, and the last rung is an
|
|
202
|
+
* unconditional real path rather than a bare command name because the caller
|
|
203
|
+
* writes the result straight into client configuration.
|
|
204
|
+
*/
|
|
205
|
+
export function resolveCodexDesktopNodeBin(
|
|
206
|
+
explicit,
|
|
207
|
+
{ env = process.env, platform = process.platform, resourcesDir = CODEX_DESKTOP_RESOURCES_MACOS } = {},
|
|
208
|
+
) {
|
|
209
|
+
const bundledNode = (dir) => path.join(dir, "cua_node", "bin", platform === "win32" ? "node.exe" : "node");
|
|
210
|
+
const vendorRungs =
|
|
211
|
+
platform === "darwin"
|
|
212
|
+
? [
|
|
213
|
+
[env.CODEX_MCP_NODE_PATH, "CODEX_MCP_NODE_PATH"],
|
|
214
|
+
[env.CODEX_BROWSER_USE_NODE_PATH, "CODEX_BROWSER_USE_NODE_PATH"],
|
|
215
|
+
[
|
|
216
|
+
env.CODEX_ELECTRON_RESOURCES_PATH && bundledNode(env.CODEX_ELECTRON_RESOURCES_PATH),
|
|
217
|
+
"CODEX_ELECTRON_RESOURCES_PATH",
|
|
218
|
+
],
|
|
219
|
+
[bundledNode(resourcesDir), "Codex Desktop bundle"],
|
|
220
|
+
]
|
|
221
|
+
: [];
|
|
222
|
+
|
|
223
|
+
for (const [candidate, source] of [
|
|
224
|
+
[explicit, "explicit"],
|
|
225
|
+
[env.CODEX_NATIVE_RELAY_NODE, "CODEX_NATIVE_RELAY_NODE"],
|
|
226
|
+
...vendorRungs,
|
|
227
|
+
]) {
|
|
228
|
+
if (candidate && isRunnable(candidate)) return { path: candidate, source };
|
|
229
|
+
}
|
|
230
|
+
return { path: process.execPath, source: "process.execPath" };
|
|
231
|
+
}
|
|
232
|
+
|
|
179
233
|
/**
|
|
180
234
|
* The macOS and Linux `codex` launcher is a Node script with a
|
|
181
235
|
* `#!/usr/bin/env node` shebang, so the spawned child needs a PATH that
|