@minhspark/codex-mcp-bridge 1.15.2 → 1.16.1
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 +20 -0
- package/README.md +212 -565
- package/package.json +3 -1
- package/scripts/collect-repo-analytics.mjs +163 -0
- package/scripts/install-native-relay.mjs +1 -1
- package/scripts/publish-repo-analytics.mjs +208 -0
- package/scripts/refresh-hosted-analytics.mjs +33 -0
- package/scripts/render-repo-analytics.mjs +50 -0
- package/scripts/usage-summary.sql +19 -0
- package/src/claude-bridge-launcher.mjs +2 -1
- package/src/claude-bridge.mjs +194 -19
- package/src/claude-desktop-context.mjs +19 -13
- package/src/claude-session-creation.mjs +216 -0
- package/src/claude-session-router.mjs +16 -0
- package/src/codex-bridge-launcher.mjs +2 -1
- package/src/codex-native-response.mjs +333 -0
- package/src/hardened-root-policy.mjs +95 -0
- package/src/index.mjs +17 -5
- package/src/native-relay-companion.mjs +268 -18
- package/src/native-relay.mjs +3 -0
- package/src/peer-protocol.mjs +111 -11
- package/src/platform.mjs +18 -0
- package/src/reply-forwarder.mjs +10 -3
- package/src/security-policy.mjs +4 -0
- package/src/telemetry-config.mjs +2 -0
- package/src/telemetry.mjs +149 -0
- package/src/thread-delivery.mjs +89 -11
- package/src/turn.mjs +35 -3
- package/src/windows-pipe-acl.mjs +27 -0
- package/src/windows-pipe-acl.ps1 +85 -0
package/src/peer-protocol.mjs
CHANGED
|
@@ -7,6 +7,8 @@ import path from "node:path";
|
|
|
7
7
|
|
|
8
8
|
import { homeDir, IS_WINDOWS } from "./platform.mjs";
|
|
9
9
|
import { cloneReloadState } from "./reload-control.mjs";
|
|
10
|
+
import { hardenedBridgeEnabled } from "./hardened-root-policy.mjs";
|
|
11
|
+
import { protectCurrentUserPipe } from "./windows-pipe-acl.mjs";
|
|
10
12
|
|
|
11
13
|
const SOCKET_DIR = "/tmp/cc-socks";
|
|
12
14
|
|
|
@@ -100,6 +102,8 @@ function readPeerToken(socket) {
|
|
|
100
102
|
const key = JSON.parse(fs.readFileSync(peerKeyPath(candidates[0].pid, socket), "utf8"));
|
|
101
103
|
if (typeof key.peerToken !== "string" || !/^[0-9a-f]{32}$/i.test(key.peerToken)) throw new Error("Invalid peer key");
|
|
102
104
|
const identity = readPeerProcessIdentity(key);
|
|
105
|
+
const hasIdentity = Object.hasOwn(key, "procStart") || (IS_WINDOWS && Object.hasOwn(key, "procStartFt"));
|
|
106
|
+
if (!identity && (hasIdentity || (IS_WINDOWS && hardenedBridgeEnabled()))) throw new Error("Peer process identity missing or invalid");
|
|
103
107
|
if (identity && identity !== readProcessStart(candidates[0].pid)) throw new Error("Peer process identity changed");
|
|
104
108
|
return key.peerToken;
|
|
105
109
|
} catch {
|
|
@@ -247,7 +251,7 @@ export function assertClaudeSessionCwd(session, expectedCwd) {
|
|
|
247
251
|
}
|
|
248
252
|
|
|
249
253
|
export function assertClaudeSessionProcess(session) {
|
|
250
|
-
if (!session
|
|
254
|
+
if (!session?.alive || typeof session.processStart !== "string" || !session.processStart ||
|
|
251
255
|
readProcessStart(session.pid) !== session.processStart) {
|
|
252
256
|
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.");
|
|
253
257
|
}
|
|
@@ -299,6 +303,29 @@ export function readTranscript(sessionId, cwd, limit = 10) {
|
|
|
299
303
|
return { file, messages: messages.slice(-limit) };
|
|
300
304
|
}
|
|
301
305
|
|
|
306
|
+
export function readInitialTranscriptMessage(sessionId, cwd) {
|
|
307
|
+
const file = findTranscriptFile(sessionId, cwd);
|
|
308
|
+
if (!fs.existsSync(file)) return { file, messages: [] };
|
|
309
|
+
const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
310
|
+
try {
|
|
311
|
+
const stat = fs.fstatSync(descriptor);
|
|
312
|
+
if (!stat.isFile()) return { file, messages: [] };
|
|
313
|
+
const buffer = Buffer.alloc(Math.min(stat.size, 1024 * 1024));
|
|
314
|
+
const bytes = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
315
|
+
const text = buffer.subarray(0, bytes).toString("utf8");
|
|
316
|
+
for (const line of text.slice(0, text.lastIndexOf("\n") + 1).split("\n")) {
|
|
317
|
+
if (!line.trim()) continue;
|
|
318
|
+
let entry;
|
|
319
|
+
try { entry = JSON.parse(line); } catch { return { file, messages: [] }; }
|
|
320
|
+
if (entry.isMeta || entry.isSidechain || entry.message?.role !== "user") continue;
|
|
321
|
+
const content = entry.message.content;
|
|
322
|
+
const initial = (Array.isArray(content) ? content.filter((part) => part?.type === "text").map((part) => part.text ?? "").join("\n") : String(content ?? "")).trim();
|
|
323
|
+
return { file, messages: initial ? [{ role: "user", text: initial }] : [] };
|
|
324
|
+
}
|
|
325
|
+
return { file, messages: [] };
|
|
326
|
+
} finally { fs.closeSync(descriptor); }
|
|
327
|
+
}
|
|
328
|
+
|
|
302
329
|
/**
|
|
303
330
|
* Claude Code records an injected peer message in one of two shapes. An idle
|
|
304
331
|
* recipient starts a new turn with a user entry whose uuid is the message id
|
|
@@ -360,7 +387,7 @@ export function readTranscriptReply(sessionId, cwd, msgId) {
|
|
|
360
387
|
* answer on and the conversation stays one-way.
|
|
361
388
|
*/
|
|
362
389
|
export class PeerEndpoint {
|
|
363
|
-
constructor({ name = `codex-${process.pid}`, cwd = process.cwd(), log = () => {} } = {}) {
|
|
390
|
+
constructor({ name = `codex-${process.pid}`, cwd = process.cwd(), log = () => {}, env = process.env, protectSocket } = {}) {
|
|
364
391
|
this.name = name;
|
|
365
392
|
this.cwd = cwd;
|
|
366
393
|
this.log = log;
|
|
@@ -385,6 +412,13 @@ export class PeerEndpoint {
|
|
|
385
412
|
this.activityRevision = 0;
|
|
386
413
|
this.reloadPaused = false;
|
|
387
414
|
this.processHandlersRegistered = false;
|
|
415
|
+
this.strict = hardenedBridgeEnabled(env);
|
|
416
|
+
this.protectSocket = protectSocket ?? (hardenedBridgeEnabled(env) ? protectCurrentUserPipe : null);
|
|
417
|
+
this.aclReady = !IS_WINDOWS || !this.protectSocket;
|
|
418
|
+
this.provisionalSockets = new Set();
|
|
419
|
+
this.startGeneration = 0;
|
|
420
|
+
this.startPromise = null;
|
|
421
|
+
this.closed = Promise.resolve();
|
|
388
422
|
}
|
|
389
423
|
|
|
390
424
|
/**
|
|
@@ -414,9 +448,19 @@ export class PeerEndpoint {
|
|
|
414
448
|
}
|
|
415
449
|
}
|
|
416
450
|
|
|
417
|
-
|
|
451
|
+
start() {
|
|
418
452
|
if (this.reloadPaused) throw new Error("The peer endpoint is quiesced for reload");
|
|
419
453
|
if (this.started) return;
|
|
454
|
+
if (this.startPromise) return this.startPromise;
|
|
455
|
+
const starting = this.#startOnce();
|
|
456
|
+
const shared = starting.finally(() => {
|
|
457
|
+
if (this.startPromise === shared) this.startPromise = null;
|
|
458
|
+
});
|
|
459
|
+
this.startPromise = shared;
|
|
460
|
+
return shared;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async #startOnce() {
|
|
420
464
|
this.#sweepDeadBridges();
|
|
421
465
|
const procStart = readProcessStart(this.pid);
|
|
422
466
|
const peerToken = crypto.randomBytes(16).toString("hex");
|
|
@@ -427,12 +471,34 @@ export class PeerEndpoint {
|
|
|
427
471
|
fs.mkdirSync(sessionsDir(), { recursive: true });
|
|
428
472
|
if (!IS_WINDOWS && fs.existsSync(this.socketPath)) fs.rmSync(this.socketPath, { force: true });
|
|
429
473
|
|
|
474
|
+
const generation = ++this.startGeneration;
|
|
475
|
+
this.aclReady = !IS_WINDOWS || !this.protectSocket;
|
|
430
476
|
await new Promise((resolve, reject) => {
|
|
431
477
|
this.server = net.createServer((socket) => this.#handleConnection(socket));
|
|
432
478
|
this.server.on("error", reject);
|
|
433
479
|
this.server.listen(this.socketPath, resolve);
|
|
434
480
|
});
|
|
435
481
|
if (!IS_WINDOWS) fs.chmodSync(this.socketPath, 0o600);
|
|
482
|
+
if (IS_WINDOWS && this.protectSocket) {
|
|
483
|
+
try {
|
|
484
|
+
await this.protectSocket(this.socketPath);
|
|
485
|
+
if (generation !== this.startGeneration) throw new Error("Peer pipe ACL startup was cancelled");
|
|
486
|
+
this.aclReady = true;
|
|
487
|
+
for (const socket of this.provisionalSockets) socket.destroy();
|
|
488
|
+
this.provisionalSockets.clear();
|
|
489
|
+
} catch (error) {
|
|
490
|
+
for (const socket of this.provisionalSockets) socket.destroy();
|
|
491
|
+
this.provisionalSockets.clear();
|
|
492
|
+
await this.#closeServer();
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (generation !== this.startGeneration) {
|
|
497
|
+
for (const socket of this.provisionalSockets) socket.destroy();
|
|
498
|
+
this.provisionalSockets.clear();
|
|
499
|
+
await this.#closeServer();
|
|
500
|
+
throw new Error("Peer endpoint startup was cancelled");
|
|
501
|
+
}
|
|
436
502
|
|
|
437
503
|
const processIdentity = procStart ? (IS_WINDOWS ? { procStart, procStartFt: procStart } : { procStart }) : {};
|
|
438
504
|
this.registry = {
|
|
@@ -467,7 +533,26 @@ export class PeerEndpoint {
|
|
|
467
533
|
this.log(`peer "${this.name}" listening on ${this.socketPath}`);
|
|
468
534
|
}
|
|
469
535
|
|
|
536
|
+
#closeServer() {
|
|
537
|
+
const server = this.server;
|
|
538
|
+
return new Promise((resolve) => {
|
|
539
|
+
if (!server) return resolve();
|
|
540
|
+
try {
|
|
541
|
+
if (!server.listening) return resolve();
|
|
542
|
+
server.close(() => resolve());
|
|
543
|
+
} catch {
|
|
544
|
+
resolve();
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
470
549
|
#handleConnection(socket) {
|
|
550
|
+
if (IS_WINDOWS && !this.aclReady) {
|
|
551
|
+
this.provisionalSockets.add(socket);
|
|
552
|
+
socket.once("close", () => this.provisionalSockets.delete(socket));
|
|
553
|
+
socket.once("error", () => {});
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
471
556
|
this.activityRevision += 1;
|
|
472
557
|
this.connections.add(socket);
|
|
473
558
|
socket.once("close", () => this.connections.delete(socket));
|
|
@@ -531,18 +616,25 @@ export class PeerEndpoint {
|
|
|
531
616
|
}
|
|
532
617
|
|
|
533
618
|
#receiveMessage(message) {
|
|
619
|
+
const key = message.inReplyTo ?? [...this.pendingMessages].find(([, entry]) => entry.targetSocket === message.fromSocket)?.[0];
|
|
620
|
+
const pending = this.pendingMessages.get(key);
|
|
621
|
+
const correlated = pending && (!pending.transcriptSession || (message.source === "transcript" && message.inReplyTo === key));
|
|
622
|
+
if (this.strict && !(key && correlated && pending.targetSocket === message.fromSocket)) {
|
|
623
|
+
this.log(`ignored uncorrelated inbound peer message from ${message.fromSocket ?? "?"} in hardened mode`);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
534
626
|
this.activityRevision += 1;
|
|
535
627
|
const record = { ...message, receivedAt: Date.now(), sequence: ++this.messageSequence };
|
|
536
628
|
this.inbox.push(record);
|
|
537
|
-
const key = record.inReplyTo ?? [...this.pendingMessages].find(([, entry]) => entry.targetSocket === record.fromSocket)?.[0];
|
|
538
|
-
const pending = this.pendingMessages.get(key);
|
|
539
|
-
const correlated = pending && (!pending.transcriptSession || (record.source === "transcript" && record.inReplyTo === key));
|
|
540
629
|
if (key && correlated && pending.targetSocket === record.fromSocket) {
|
|
541
630
|
const sent = this.sentMessages.get(key);
|
|
542
631
|
if (sent) {
|
|
543
632
|
record.inReplyTo = key;
|
|
544
633
|
record.replyThreadId = sent.replyThreadId ?? null;
|
|
545
634
|
record.accountContext = sent.accountContext ?? null;
|
|
635
|
+
record.cwd = sent.transcriptSession?.cwd ?? null;
|
|
636
|
+
record.senderCwd = sent.scopeBindings?.sender?.path ?? null;
|
|
637
|
+
record.scopeBindings = sent.scopeBindings ?? null;
|
|
546
638
|
sent.reply = record;
|
|
547
639
|
}
|
|
548
640
|
this.#removePendingReply(record.fromSocket, key);
|
|
@@ -660,7 +752,7 @@ export class PeerEndpoint {
|
|
|
660
752
|
return frame.msg_id;
|
|
661
753
|
}
|
|
662
754
|
|
|
663
|
-
async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next", transcriptSession, beforeSend, permissionMode = this.permissionMode, replyThreadId, senderReview, senderApprovalPolicy, recipient, accountContext } = {}) {
|
|
755
|
+
async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next", transcriptSession, beforeSend, permissionMode = this.permissionMode, replyThreadId, senderReview, senderApprovalPolicy, recipient, accountContext, scopeBindings } = {}) {
|
|
664
756
|
const previous = this.requestQueues.get(targetSocket) ?? Promise.resolve();
|
|
665
757
|
const pending = previous.catch(() => {}).then(async () => {
|
|
666
758
|
await beforeSend?.();
|
|
@@ -678,7 +770,7 @@ export class PeerEndpoint {
|
|
|
678
770
|
this.unconfirmedReplies.set(targetSocket, unconfirmed + 1);
|
|
679
771
|
let msgId = crypto.randomUUID();
|
|
680
772
|
this.pendingMessages.set(msgId, { targetSocket, transcriptSession });
|
|
681
|
-
this.sentMessages.set(msgId, { targetSocket, transcriptSession, sentAt: since, replyThreadId, permissionMode, senderApprovalPolicy, ...(senderReview ? { senderReview: { ...senderReview } } : {}), ...(recipient ? { recipient: { ...recipient } } : {}), ...(accountContext ? { accountContext: Object.freeze({ ...accountContext }) } : {}) });
|
|
773
|
+
this.sentMessages.set(msgId, { targetSocket, transcriptSession, sentAt: since, replyThreadId, permissionMode, senderApprovalPolicy, ...(senderReview ? { senderReview: { ...senderReview } } : {}), ...(recipient ? { recipient: { ...recipient } } : {}), ...(accountContext ? { accountContext: Object.freeze({ ...accountContext }) } : {}), ...(scopeBindings ? { scopeBindings: Object.freeze(structuredClone(scopeBindings)) } : {}) });
|
|
682
774
|
if (transcriptSession && !this.responsePoll) {
|
|
683
775
|
this.responsePoll = globalThis.setInterval(() => this.#refreshTranscriptReplies(), 250);
|
|
684
776
|
this.responsePoll.unref();
|
|
@@ -792,6 +884,7 @@ export class PeerEndpoint {
|
|
|
792
884
|
...(sent.recipient ? { recipientPermissionMode: sent.recipient.permissionMode ?? null, recipientPermissionClass: sent.recipient.permissionClass ?? null, recipientInboundPolicy: sent.recipient.inboundPolicy ?? null } : {}),
|
|
793
885
|
replyThreadId: sent.replyThreadId ?? null,
|
|
794
886
|
...(sent.accountContext ? { accountContext: { ...sent.accountContext } } : {}),
|
|
887
|
+
...(sent.scopeBindings ? { scopeBindings: structuredClone(sent.scopeBindings), senderCwd: sent.scopeBindings.sender?.path ?? null } : {}),
|
|
795
888
|
pending: this.pendingMessages.has(msgId),
|
|
796
889
|
...(sent.reply ? { reply: sent.reply.text, source: sent.reply.source ?? "peer", ...(sent.reply.absorbed ? { replyAbsorbed: true } : {}), ...(sent.reply.forwardingError ? { forwardingError: { ...sent.reply.forwardingError } } : {}) } : {}),
|
|
797
890
|
};
|
|
@@ -877,12 +970,19 @@ export class PeerEndpoint {
|
|
|
877
970
|
}
|
|
878
971
|
|
|
879
972
|
stop() {
|
|
973
|
+
this.startGeneration += 1;
|
|
974
|
+
this.aclReady = !IS_WINDOWS || !this.protectSocket;
|
|
975
|
+
for (const socket of this.provisionalSockets) socket.destroy();
|
|
976
|
+
this.provisionalSockets.clear();
|
|
880
977
|
globalThis.clearInterval(this.responsePoll);
|
|
881
978
|
this.responsePoll = null;
|
|
882
979
|
this.pendingMessages.clear();
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
980
|
+
const server = this.server;
|
|
981
|
+
this.closed = new Promise((resolve) => {
|
|
982
|
+
if (!server) return resolve();
|
|
983
|
+
try { server.close(() => resolve()); }
|
|
984
|
+
catch { resolve(); }
|
|
985
|
+
});
|
|
886
986
|
for (const socket of this.connections) socket.destroy();
|
|
887
987
|
this.connections.clear();
|
|
888
988
|
this.#removeRegistration();
|
package/src/platform.mjs
CHANGED
|
@@ -468,3 +468,21 @@ export async function openThreadInCodexApp(threadId, { activate = true } = {}) {
|
|
|
468
468
|
await execFileAsync("open", args, { timeout: 10000 });
|
|
469
469
|
return url;
|
|
470
470
|
}
|
|
471
|
+
|
|
472
|
+
export async function openClaudeCodeComposer(url, { platform = process.platform, env = process.env, run = execFileAsync } = {}) {
|
|
473
|
+
const parsed = new URL(url);
|
|
474
|
+
if (parsed.protocol !== "claude:" || parsed.hostname !== "code" || parsed.pathname !== "/new" || parsed.username || parsed.password || parsed.port || parsed.hash
|
|
475
|
+
|| [...parsed.searchParams.keys()].some((key) => !["q", "folder"].includes(key))) {
|
|
476
|
+
throw new Error("Invalid Claude Desktop Code creation link");
|
|
477
|
+
}
|
|
478
|
+
if (platform === "win32") {
|
|
479
|
+
const shell = path.win32.join(env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
480
|
+
const encodedUrl = Buffer.from(url, "utf8").toString("base64");
|
|
481
|
+
const script = `$ErrorActionPreference='Stop'; $creationUrl=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedUrl}')); Start-Process -FilePath $creationUrl -ErrorAction Stop`;
|
|
482
|
+
await run(shell, ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { windowsHide: true, timeout: 10000 });
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const command = platform === "darwin" ? "/usr/bin/open" : platform === "linux" ? "/usr/bin/xdg-open" : null;
|
|
486
|
+
if (!command) throw new Error("Claude Desktop Code links are unsupported on this platform");
|
|
487
|
+
await run(command, [url], { windowsHide: true, timeout: 10000 });
|
|
488
|
+
}
|
package/src/reply-forwarder.mjs
CHANGED
|
@@ -93,10 +93,17 @@ export class ReplyForwarder {
|
|
|
93
93
|
return entry ? { ...entry.receipt } : null;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
status() {
|
|
96
|
+
status(accepts = () => true) {
|
|
97
97
|
const counts = { total: this.records.size, queued: 0, sending: 0, forwarded: 0, failed: 0, unknown: 0, blocked: 0 };
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
counts.total = 0;
|
|
99
|
+
let attempts = 0;
|
|
100
|
+
for (const { record, receipt } of this.records.values()) {
|
|
101
|
+
if (!accepts(record, receipt)) continue;
|
|
102
|
+
counts.total += 1;
|
|
103
|
+
counts[receipt.status] += 1;
|
|
104
|
+
if (Number.isFinite(receipt.attemptedAt)) attempts += 1;
|
|
105
|
+
}
|
|
106
|
+
return { ...counts, attempts, maxPerSession: this.maxPerSession, closed: this.closed };
|
|
100
107
|
}
|
|
101
108
|
|
|
102
109
|
reloadReason() {
|
package/src/security-policy.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
|
|
3
4
|
|
|
4
5
|
const APPROVAL_POLICIES = new Set(["untrusted", "on-failure", "on-request", "never"]);
|
|
5
6
|
const SANDBOXES = new Set(["read-only", "workspace-write"]);
|
|
@@ -84,6 +85,7 @@ export function assertAllowedAppServerUrl(value) {
|
|
|
84
85
|
|
|
85
86
|
export class BridgeSecurityPolicy {
|
|
86
87
|
constructor(env = process.env) {
|
|
88
|
+
this.hardenedRoots = createHardenedRootPolicy(env);
|
|
87
89
|
const configuredThreads = parseList(env.CODEX_BRIDGE_ALLOWED_THREADS);
|
|
88
90
|
this.allowAllThreads = configuredThreads.has(ALLOW_ALL_THREADS);
|
|
89
91
|
this.allowedThreadIds = new Set([...configuredThreads].filter((threadId) => threadId !== ALLOW_ALL_THREADS));
|
|
@@ -189,6 +191,7 @@ export class BridgeSecurityPolicy {
|
|
|
189
191
|
}
|
|
190
192
|
|
|
191
193
|
isCwdAuthorized(cwd) {
|
|
194
|
+
if (this.hardenedRoots.enabled) return this.hardenedRoots.allows(cwd);
|
|
192
195
|
if (!cwd) return false;
|
|
193
196
|
if (this.allowAllRoots) return true;
|
|
194
197
|
if (!this.allowedRoots.length) return false;
|
|
@@ -197,6 +200,7 @@ export class BridgeSecurityPolicy {
|
|
|
197
200
|
}
|
|
198
201
|
|
|
199
202
|
assertCwd(cwd) {
|
|
203
|
+
if (this.hardenedRoots.enabled) return this.hardenedRoots.assert(cwd);
|
|
200
204
|
if (!this.allowAllRoots && !this.allowedRoots.length) {
|
|
201
205
|
throw new Error(
|
|
202
206
|
"No authorized workspace roots are configured. Set CODEX_BRIDGE_ALLOWED_ROOTS to one or more project directories.",
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { mkdir, readFile, open, rename, unlink, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
7
|
+
const versionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
8
|
+
|
|
9
|
+
export function telemetryDirectory(platform = process.platform, env = process.env, home = homedir()) {
|
|
10
|
+
const base = platform === 'win32' ? env.LOCALAPPDATA || path.join(home, 'AppData', 'Local') : platform === 'darwin' ? path.join(home, 'Library', 'Application Support') : env.XDG_DATA_HOME || path.join(home, '.local', 'share');
|
|
11
|
+
return path.join(base, 'codex-mcp-bridge');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function resolveOptions(options = {}) {
|
|
15
|
+
return { directory: telemetryDirectory(), env: process.env, platform: process.platform, now: new Date(), ...options };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function readSettings(directory) {
|
|
19
|
+
try {
|
|
20
|
+
const settings = JSON.parse(await readFile(path.join(directory, 'telemetry.json'), 'utf8'));
|
|
21
|
+
if (settings.enabled !== true || !uuidPattern.test(settings.installId)) return { enabled: false };
|
|
22
|
+
return settings;
|
|
23
|
+
} catch { return { enabled: false }; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function writeSettings(directory, settings) {
|
|
27
|
+
const filename = path.join(directory, 'telemetry.json');
|
|
28
|
+
const temporary = `${filename}.${randomUUID()}.tmp`;
|
|
29
|
+
let file;
|
|
30
|
+
try {
|
|
31
|
+
file = await open(temporary, 'wx', 0o600);
|
|
32
|
+
await file.writeFile(`${JSON.stringify(settings, null, 2)}\n`);
|
|
33
|
+
await file.sync();
|
|
34
|
+
await file.close();
|
|
35
|
+
file = null;
|
|
36
|
+
await rename(temporary, filename);
|
|
37
|
+
} finally {
|
|
38
|
+
await file?.close();
|
|
39
|
+
await unlink(temporary).catch(() => {});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function locked(directory, action) {
|
|
44
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
45
|
+
const filename = path.join(directory, '.telemetry.lock');
|
|
46
|
+
let lock;
|
|
47
|
+
try {
|
|
48
|
+
lock = await open(filename, 'wx', 0o600);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error.code !== 'EEXIST') throw error;
|
|
51
|
+
const info = await stat(filename).catch(() => null);
|
|
52
|
+
if (info && Date.now() - info.mtimeMs > 60000) {
|
|
53
|
+
await unlink(filename).catch(() => {});
|
|
54
|
+
lock = await open(filename, 'wx', 0o600);
|
|
55
|
+
} else throw new Error('Telemetry settings are busy. Retry in a few seconds.');
|
|
56
|
+
}
|
|
57
|
+
try { return await action(); }
|
|
58
|
+
finally { await lock.close(); await unlink(filename).catch(() => {}); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function suppressed(env) {
|
|
62
|
+
return env.DO_NOT_TRACK === '1' || env.CODEX_BRIDGE_TELEMETRY === '0';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function getTelemetryStatus(options = {}) {
|
|
66
|
+
const opts = resolveOptions(options);
|
|
67
|
+
const settings = await readSettings(opts.directory);
|
|
68
|
+
return { enabled: settings.enabled, effective: settings.enabled && !suppressed(opts.env), suppressed: suppressed(opts.env), lastReportDay: settings.lastReportDay || null, settingsPath: path.join(opts.directory, 'telemetry.json') };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function enableTelemetry(options = {}) {
|
|
72
|
+
const opts = resolveOptions(options);
|
|
73
|
+
await locked(opts.directory, async () => {
|
|
74
|
+
const previous = await readSettings(opts.directory);
|
|
75
|
+
await writeSettings(opts.directory, previous.enabled ? previous : { enabled: true, installId: randomUUID(), consentedAt: opts.now.toISOString() });
|
|
76
|
+
});
|
|
77
|
+
return getTelemetryStatus(opts);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function disableTelemetry(options = {}) {
|
|
81
|
+
const opts = resolveOptions(options);
|
|
82
|
+
await locked(opts.directory, () => writeSettings(opts.directory, { enabled: false }));
|
|
83
|
+
return getTelemetryStatus(opts);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function reportUsage(options = {}) {
|
|
87
|
+
try {
|
|
88
|
+
const opts = resolveOptions(options);
|
|
89
|
+
if (suppressed(opts.env) || !versionPattern.test(opts.version || '')) return false;
|
|
90
|
+
if (!(await readSettings(opts.directory)).enabled) return false;
|
|
91
|
+
const platform = { win32: 'windows', darwin: 'macos', linux: 'linux' }[opts.platform];
|
|
92
|
+
if (!platform) return false;
|
|
93
|
+
return await locked(opts.directory, async () => {
|
|
94
|
+
const settings = await readSettings(opts.directory);
|
|
95
|
+
const day = opts.now.toISOString().slice(0, 10);
|
|
96
|
+
if (!settings.enabled || settings.lastReportDay === day) return false;
|
|
97
|
+
const config = opts.config || await import('./telemetry-config.mjs');
|
|
98
|
+
const endpoint = config.TELEMETRY_ENDPOINT;
|
|
99
|
+
const key = config.TELEMETRY_PUBLIC_KEY;
|
|
100
|
+
if (!endpoint || !key || new URL(endpoint).protocol !== 'https:') return false;
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
const timeout = Math.max(1, Math.min(opts.timeoutMs || 2000, 2000));
|
|
103
|
+
let timer;
|
|
104
|
+
try {
|
|
105
|
+
const response = await Promise.race([
|
|
106
|
+
(opts.fetcher || fetch)(endpoint, {
|
|
107
|
+
method: 'POST', redirect: 'error', signal: controller.signal,
|
|
108
|
+
headers: { 'Content-Type': 'application/json', apikey: key },
|
|
109
|
+
body: JSON.stringify({ install_id: settings.installId, version: opts.version, platform, day }),
|
|
110
|
+
}),
|
|
111
|
+
new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new Error('timeout')); }, timeout); }),
|
|
112
|
+
]);
|
|
113
|
+
if (!response.ok) return false;
|
|
114
|
+
await writeSettings(opts.directory, { ...settings, lastReportDay: day });
|
|
115
|
+
return true;
|
|
116
|
+
} finally { clearTimeout(timer); controller.abort(); }
|
|
117
|
+
});
|
|
118
|
+
} catch { return false; }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function startUsageReporting(options = {}) {
|
|
122
|
+
const initialReport = reportUsage(options);
|
|
123
|
+
const schedule = options.schedule || setInterval;
|
|
124
|
+
const cancel = options.cancel || clearInterval;
|
|
125
|
+
const timer = schedule(() => { void reportUsage(options); }, 60 * 60 * 1000);
|
|
126
|
+
timer.unref?.();
|
|
127
|
+
return { initialReport, stop: () => cancel(timer) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function handleTelemetryCommand(args = process.argv.slice(2), options = {}) {
|
|
131
|
+
if (args[0] !== 'telemetry') return false;
|
|
132
|
+
const output = options.output || (message => process.stdout.write(`${message}\n`));
|
|
133
|
+
if (args.length !== 2 || !['enable', 'disable', 'status'].includes(args[1])) {
|
|
134
|
+
output('Usage: codex-mcp-bridge telemetry <enable|disable|status>');
|
|
135
|
+
process.exitCode = 1;
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const status = await ({ enable: enableTelemetry, disable: disableTelemetry, status: getTelemetryStatus }[args[1]])(options);
|
|
140
|
+
output(`Telemetry: ${status.effective ? 'enabled' : 'disabled'}${status.suppressed ? ' (environment override)' : ''}.`);
|
|
141
|
+
output('When enabled, sends a random installation ID, package version, OS family, and UTC day; at most once per day. No prompts, paths, account IDs, or message contents.');
|
|
142
|
+
if (args[1] === 'disable') output('Local installation ID removed. Previously submitted records remain until retention cleanup.');
|
|
143
|
+
output(`Settings: ${status.settingsPath}`);
|
|
144
|
+
} catch {
|
|
145
|
+
output('Could not update telemetry settings. Check directory permissions and retry when no telemetry request is running.');
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
}
|
|
148
|
+
return true;
|
|
149
|
+
}
|
package/src/thread-delivery.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { runTurn } from "./turn.mjs";
|
|
|
3
3
|
import { realpathSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { DesktopTaskReceipts } from "./desktop-task-receipts.mjs";
|
|
6
|
+
import { captureCodexRolloutWatermark, inspectCodexNativeTurn, readCodexNativeTurnResponse } from "./codex-native-response.mjs";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Which backend puts a message into a Codex thread.
|
|
@@ -23,6 +24,11 @@ export const APP_SERVER_BACKEND = "app-server";
|
|
|
23
24
|
export const DESKTOP_TOOL_BUDGET_MS = 40000;
|
|
24
25
|
const RELEASE_STATUSES = new Set(["completed", "interrupted", "failed"]);
|
|
25
26
|
|
|
27
|
+
function sameAccountContext(expected, current) {
|
|
28
|
+
return Boolean(expected && current && ["claude", "codex"].every((provider) =>
|
|
29
|
+
typeof expected[provider] === "string" && expected[provider] === current[provider]));
|
|
30
|
+
}
|
|
31
|
+
|
|
26
32
|
export function matchDesktopProject(projects, cwd, { canonicalize = realpathSync.native, paths = path } = {}) {
|
|
27
33
|
const requested = canonicalize(cwd);
|
|
28
34
|
const matches = projects.filter((project) => {
|
|
@@ -42,7 +48,7 @@ export function matchDesktopProject(projects, cwd, { canonicalize = realpathSync
|
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
export class DesktopTaskDelivery {
|
|
45
|
-
constructor({ relay = new NativeDesktopRelay({ socketPath: desktopTaskSocketPath(), accountSocketPath: accountRelaySocketPath() }), security, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now, receipts = new DesktopTaskReceipts(), beforeRequest, accountContext } = {}) {
|
|
51
|
+
constructor({ relay = new NativeDesktopRelay({ socketPath: desktopTaskSocketPath(), accountSocketPath: accountRelaySocketPath() }), security, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now, receipts = new DesktopTaskReceipts(), beforeRequest, accountContext, captureResponse = captureCodexRolloutWatermark, readResponse = readCodexNativeTurnResponse, inspectResponse = inspectCodexNativeTurn } = {}) {
|
|
46
52
|
this.relay = relay;
|
|
47
53
|
this.security = security;
|
|
48
54
|
this.sleep = sleep;
|
|
@@ -50,10 +56,13 @@ export class DesktopTaskDelivery {
|
|
|
50
56
|
this.receipts = receipts;
|
|
51
57
|
this.beforeRequest = beforeRequest;
|
|
52
58
|
this.accountContext = accountContext;
|
|
59
|
+
this.captureResponse = captureResponse;
|
|
60
|
+
this.readResponse = readResponse;
|
|
61
|
+
this.inspectResponse = inspectResponse;
|
|
53
62
|
this.threadOperations = new Map();
|
|
54
63
|
}
|
|
55
64
|
|
|
56
|
-
async request(operation, args, { deadline } = {}) {
|
|
65
|
+
async request(operation, args, { deadline, includeRelayContext = false } = {}) {
|
|
57
66
|
try {
|
|
58
67
|
await this.beforeRequest?.({ operation, args });
|
|
59
68
|
const remaining = deadline === undefined ? undefined : deadline - this.now();
|
|
@@ -64,7 +73,7 @@ export class DesktopTaskDelivery {
|
|
|
64
73
|
...(this.beforeRequest ? { beforeSend: () => this.beforeRequest({ operation, args, phase: "write" }) } : {}),
|
|
65
74
|
...(accountContext ? { accountContext } : {}),
|
|
66
75
|
});
|
|
67
|
-
return response.result;
|
|
76
|
+
return includeRelayContext ? { result: response.result, executorThreadId: response.executorThreadId } : response.result;
|
|
68
77
|
} catch (err) {
|
|
69
78
|
throw new Error(`Codex Desktop operation ${operation} failed: ${err.message}. Desktop-only mode will not start or use an external app-server. Open Codex Desktop and reconnect its native relay, then inspect the existing task before retrying a send.`, { cause: err });
|
|
70
79
|
}
|
|
@@ -247,17 +256,32 @@ export class DesktopTaskDelivery {
|
|
|
247
256
|
async send({ threadId, prompt, cwd, model, effort, name, deadline }) {
|
|
248
257
|
const inspected = await this.inspect(threadId, cwd, { deadline });
|
|
249
258
|
if (name) await this.request("set_thread_title", { threadId, title: name.trim().slice(0, 200) }, { deadline });
|
|
250
|
-
const
|
|
259
|
+
const expectedCwd = realpathSync.native(inspected.thread.cwd);
|
|
260
|
+
const accountContext = this.accountContext?.();
|
|
261
|
+
const watermark = this.captureResponse({ threadId, expectedCwd });
|
|
262
|
+
const envelope = await this.request("send_message_to_thread", {
|
|
251
263
|
threadId, prompt,
|
|
252
264
|
...(model ? { model } : {}),
|
|
253
265
|
...(effort ? { thinking: effort } : {}),
|
|
254
|
-
}, { deadline });
|
|
266
|
+
}, { deadline, includeRelayContext: true });
|
|
267
|
+
const response = envelope.result;
|
|
255
268
|
if (response?.threadId !== threadId || response?.success === false || response?.isError === true ||
|
|
256
269
|
(response?.status !== undefined && !["accepted", "sent"].includes(response.status)) ||
|
|
257
270
|
(response?.firstTurn && response.firstTurn.status !== "accepted")) {
|
|
258
271
|
throw new Error(`Desktop send is not confirmed for ${threadId}. Do not resend: ${JSON.stringify(response)}`);
|
|
259
272
|
}
|
|
260
|
-
return {
|
|
273
|
+
return {
|
|
274
|
+
threadId, cwd: expectedCwd, name: inspected.thread.title, previousTurnId: inspected.latestTurnId, backend: NATIVE_BACKEND,
|
|
275
|
+
responseObservation: {
|
|
276
|
+
threadId,
|
|
277
|
+
previousTurnId: inspected.latestTurnId,
|
|
278
|
+
expectedCwd,
|
|
279
|
+
executorThreadId: envelope.executorThreadId ?? null,
|
|
280
|
+
prompt,
|
|
281
|
+
accountContext: accountContext ? { ...accountContext } : null,
|
|
282
|
+
watermark,
|
|
283
|
+
},
|
|
284
|
+
};
|
|
261
285
|
}
|
|
262
286
|
|
|
263
287
|
async open(threadId, { deadline } = {}) {
|
|
@@ -265,12 +289,51 @@ export class DesktopTaskDelivery {
|
|
|
265
289
|
if (response?.navigated !== true) throw new Error(`Desktop did not confirm opening task ${threadId}`);
|
|
266
290
|
}
|
|
267
291
|
|
|
268
|
-
async
|
|
292
|
+
async observeNativeResponse(threadId, turnId, responseObservation, { deadline } = {}) {
|
|
293
|
+
if (!responseObservation) return unavailableObservation("Codex Desktop completed the turn without exposing assistant response text or a pre-send observation binding.");
|
|
294
|
+
const { expectedCwd } = responseObservation;
|
|
295
|
+
const args = { threadId, hostId: "local", turnLimit: 1 };
|
|
296
|
+
const recheck = async () => {
|
|
297
|
+
await this.beforeRequest?.({ operation: "read_thread", args, phase: "observe" });
|
|
298
|
+
if (this.accountContext && !sameAccountContext(responseObservation.accountContext, this.accountContext())) throw new Error("The original account changed while the native response was being observed; reply content was withheld");
|
|
299
|
+
this.security.assertCwd(expectedCwd);
|
|
300
|
+
this.security.assertThread(threadId, expectedCwd);
|
|
301
|
+
if (realpathSync.native(expectedCwd) !== expectedCwd) throw new Error("The selected native task workspace changed while its response was being observed");
|
|
302
|
+
};
|
|
303
|
+
await recheck();
|
|
304
|
+
const observed = this.readResponse({ ...responseObservation, threadId, turnId });
|
|
305
|
+
await recheck();
|
|
306
|
+
const inspected = await this.inspect(threadId, expectedCwd, { deadline });
|
|
307
|
+
if (inspected.latestTurnId !== turnId) throw new Error("The selected native task changed before its response could be confirmed; reply content was withheld");
|
|
308
|
+
await recheck();
|
|
309
|
+
return observed;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async inspectNativeTurn(threadId, turnId, cwd, { deadline } = {}) {
|
|
313
|
+
const initial = await this.inspect(threadId, cwd, { deadline });
|
|
314
|
+
const expectedCwd = realpathSync.native(initial.thread.cwd);
|
|
315
|
+
const accountContext = this.accountContext?.() ?? null;
|
|
316
|
+
const args = { threadId, hostId: "local", turnLimit: 1 };
|
|
317
|
+
const recheck = async () => {
|
|
318
|
+
await this.beforeRequest?.({ operation: "read_thread", args, phase: "inspect-turn" });
|
|
319
|
+
if (this.accountContext && !sameAccountContext(accountContext, this.accountContext())) throw new Error("The account changed while the native turn was being inspected; response content was withheld");
|
|
320
|
+
this.security.assertCwd(expectedCwd);
|
|
321
|
+
this.security.assertThread(threadId, expectedCwd);
|
|
322
|
+
if (realpathSync.native(expectedCwd) !== expectedCwd) throw new Error("The selected native task workspace changed while its turn was being inspected");
|
|
323
|
+
};
|
|
324
|
+
await recheck();
|
|
325
|
+
const result = this.inspectResponse({ threadId, turnId, expectedCwd });
|
|
326
|
+
await recheck();
|
|
327
|
+
await this.inspect(threadId, expectedCwd, { deadline });
|
|
328
|
+
await recheck();
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async wait(threadId, { timeoutMs = 240000, previousTurnId = null, responseObservation = null } = {}) {
|
|
269
333
|
const startedAt = this.now();
|
|
270
334
|
let cursor;
|
|
271
335
|
let turnId = null;
|
|
272
|
-
|
|
273
|
-
const expired = () => ({ threadId, turnId, status: "timeout", text, activity: [], errors: [], durationMs: this.now() - startedAt });
|
|
336
|
+
const expired = () => ({ threadId, turnId, status: "timeout", text: "", responseStatus: "unavailable", assistantItems: [], replySha256: null, activity: [], errors: [], durationMs: this.now() - startedAt });
|
|
274
337
|
for (;;) {
|
|
275
338
|
if (this.now() - startedAt >= timeoutMs) return expired();
|
|
276
339
|
let response;
|
|
@@ -292,10 +355,21 @@ export class DesktopTaskDelivery {
|
|
|
292
355
|
}
|
|
293
356
|
if (turn?.id && turn.id !== previousTurnId) {
|
|
294
357
|
turnId = turn.id;
|
|
295
|
-
if (poll.latestAssistantMessage?.turnId === turnId && poll.latestAssistantMessage?.phase === "final_answer") text = poll.latestAssistantMessage.text ?? text;
|
|
296
358
|
const status = turn.status;
|
|
297
359
|
if (RELEASE_STATUSES.has(status)) {
|
|
298
|
-
|
|
360
|
+
const observed = await this.observeNativeResponse(threadId, turnId, responseObservation, { deadline: startedAt + timeoutMs });
|
|
361
|
+
const responseStatus = observed.status;
|
|
362
|
+
return {
|
|
363
|
+
threadId, turnId, status,
|
|
364
|
+
text: responseStatus === "completed" ? observed.text : "",
|
|
365
|
+
responseStatus,
|
|
366
|
+
observationStatus: responseStatus,
|
|
367
|
+
...(observed.reason ? { observationReason: observed.reason } : {}),
|
|
368
|
+
assistantItems: observed.assistantItems ?? [],
|
|
369
|
+
replySha256: observed.replySha256 ?? null,
|
|
370
|
+
responseSource: observed.source ?? null,
|
|
371
|
+
activity: [], errors: turn.error ? [turn.error] : [], durationMs: turn.durationMs ?? this.now() - startedAt,
|
|
372
|
+
};
|
|
299
373
|
}
|
|
300
374
|
}
|
|
301
375
|
if (this.now() - startedAt >= timeoutMs) return expired();
|
|
@@ -304,6 +378,10 @@ export class DesktopTaskDelivery {
|
|
|
304
378
|
}
|
|
305
379
|
}
|
|
306
380
|
|
|
381
|
+
function unavailableObservation(reason) {
|
|
382
|
+
return { status: "unavailable", reason };
|
|
383
|
+
}
|
|
384
|
+
|
|
307
385
|
export function createThreadDelivery({
|
|
308
386
|
codex,
|
|
309
387
|
relay = new NativeDesktopRelay(),
|