@minhspark/codex-mcp-bridge 1.16.0 → 1.17.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.
@@ -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,7 +102,13 @@ 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);
103
- if (identity && identity !== readProcessStart(candidates[0].pid)) throw new Error("Peer process identity changed");
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");
107
+ if (identity) {
108
+ const current = readProcessStart(candidates[0].pid);
109
+ if (current === null) throw new Error("The live peer process identity could not be read; message was not sent. That failure is not itself evidence the process changed.");
110
+ if (identity !== current) throw new Error("Peer process identity changed");
111
+ }
104
112
  return key.peerToken;
105
113
  } catch {
106
114
  if (IS_WINDOWS) throw new Error("The destination inbox authentication key is missing or invalid; message was not sent");
@@ -119,21 +127,31 @@ export function readPeerProcessIdentity(entry, platform = process.platform) {
119
127
  return values[0];
120
128
  }
121
129
 
130
+ const PROCESS_START_TIMEOUT_MS = 30000;
131
+
122
132
  /**
123
133
  * MCP servers are spawned with an empty PATH, so `ps` must be addressed by its
124
134
  * absolute path - a bare lookup throws ENOENT and takes the whole server down
125
- * before it can answer the client's initialize call. The timestamp only guards
126
- * against pid reuse, so an empty value is an acceptable fallback.
135
+ * before it can answer the client's initialize call.
136
+ *
137
+ * A failed read is reported as null rather than as an empty string, because
138
+ * the callers compare this against a recorded identity and an empty string
139
+ * silently compares as "changed". A cold PowerShell on a contended Windows
140
+ * runner has been measured in this repo between 5s and 15s, so the previous
141
+ * 3s ceiling turned ordinary slowness into a report that the peer process had
142
+ * been swapped - the alarming reading of the two, and the one that fails a
143
+ * send. The budget now matches what the suites already give their own
144
+ * identity reads.
127
145
  */
128
146
  function readProcessStart(pid) {
129
147
  try {
130
148
  if (IS_WINDOWS) {
131
149
  const shell = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
132
- 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();
150
+ return execFileSync(shell, ["-NoProfile", "-NonInteractive", "-Command", `[System.Diagnostics.Process]::GetProcessById(${Number(pid)}).StartTime.ToUniversalTime().ToFileTimeUtc().ToString()`], { timeout: PROCESS_START_TIMEOUT_MS, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }).toString().trim();
133
151
  }
134
- return execFileSync(PS_BIN, ["-o", "lstart=", "-p", String(pid)], { env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, timeout: 3000 }).toString().trim();
152
+ return execFileSync(PS_BIN, ["-o", "lstart=", "-p", String(pid)], { env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, timeout: PROCESS_START_TIMEOUT_MS }).toString().trim();
135
153
  } catch {
136
- return "";
154
+ return null;
137
155
  }
138
156
  }
139
157
 
@@ -247,10 +265,13 @@ export function assertClaudeSessionCwd(session, expectedCwd) {
247
265
  }
248
266
 
249
267
  export function assertClaudeSessionProcess(session) {
250
- if (!session.alive || typeof session.processStart !== "string" || !session.processStart ||
251
- readProcessStart(session.pid) !== session.processStart) {
252
- 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.");
268
+ const changed = "The live Claude process identity is missing or changed. No message was sent; reopen the existing Desktop task and inspect its session again.";
269
+ if (!session?.alive || typeof session.processStart !== "string" || !session.processStart) throw new Error(changed);
270
+ const current = readProcessStart(session.pid);
271
+ if (current === null) {
272
+ throw new Error("The live Claude process identity could not be read, so this send was refused rather than sent unverified. That failure is not itself evidence the session changed; inspect the existing Desktop task before retrying.");
253
273
  }
274
+ if (current !== session.processStart) throw new Error(changed);
254
275
  }
255
276
 
256
277
  /**
@@ -383,7 +404,7 @@ export function readTranscriptReply(sessionId, cwd, msgId) {
383
404
  * answer on and the conversation stays one-way.
384
405
  */
385
406
  export class PeerEndpoint {
386
- constructor({ name = `codex-${process.pid}`, cwd = process.cwd(), log = () => {} } = {}) {
407
+ constructor({ name = `codex-${process.pid}`, cwd = process.cwd(), log = () => {}, env = process.env, protectSocket } = {}) {
387
408
  this.name = name;
388
409
  this.cwd = cwd;
389
410
  this.log = log;
@@ -408,6 +429,13 @@ export class PeerEndpoint {
408
429
  this.activityRevision = 0;
409
430
  this.reloadPaused = false;
410
431
  this.processHandlersRegistered = false;
432
+ this.strict = hardenedBridgeEnabled(env);
433
+ this.protectSocket = protectSocket ?? (hardenedBridgeEnabled(env) ? protectCurrentUserPipe : null);
434
+ this.aclReady = !IS_WINDOWS || !this.protectSocket;
435
+ this.provisionalSockets = new Set();
436
+ this.startGeneration = 0;
437
+ this.startPromise = null;
438
+ this.closed = Promise.resolve();
411
439
  }
412
440
 
413
441
  /**
@@ -437,11 +465,21 @@ export class PeerEndpoint {
437
465
  }
438
466
  }
439
467
 
440
- async start() {
468
+ start() {
441
469
  if (this.reloadPaused) throw new Error("The peer endpoint is quiesced for reload");
442
470
  if (this.started) return;
471
+ if (this.startPromise) return this.startPromise;
472
+ const starting = this.#startOnce();
473
+ const shared = starting.finally(() => {
474
+ if (this.startPromise === shared) this.startPromise = null;
475
+ });
476
+ this.startPromise = shared;
477
+ return shared;
478
+ }
479
+
480
+ async #startOnce() {
443
481
  this.#sweepDeadBridges();
444
- const procStart = readProcessStart(this.pid);
482
+ const procStart = readProcessStart(this.pid) ?? "";
445
483
  const peerToken = crypto.randomBytes(16).toString("hex");
446
484
  this.peerToken = peerToken;
447
485
  this.keyPath = peerKeyPath(this.pid, this.socketPath);
@@ -450,12 +488,34 @@ export class PeerEndpoint {
450
488
  fs.mkdirSync(sessionsDir(), { recursive: true });
451
489
  if (!IS_WINDOWS && fs.existsSync(this.socketPath)) fs.rmSync(this.socketPath, { force: true });
452
490
 
491
+ const generation = ++this.startGeneration;
492
+ this.aclReady = !IS_WINDOWS || !this.protectSocket;
453
493
  await new Promise((resolve, reject) => {
454
494
  this.server = net.createServer((socket) => this.#handleConnection(socket));
455
495
  this.server.on("error", reject);
456
496
  this.server.listen(this.socketPath, resolve);
457
497
  });
458
498
  if (!IS_WINDOWS) fs.chmodSync(this.socketPath, 0o600);
499
+ if (IS_WINDOWS && this.protectSocket) {
500
+ try {
501
+ await this.protectSocket(this.socketPath);
502
+ if (generation !== this.startGeneration) throw new Error("Peer pipe ACL startup was cancelled");
503
+ this.aclReady = true;
504
+ for (const socket of this.provisionalSockets) socket.destroy();
505
+ this.provisionalSockets.clear();
506
+ } catch (error) {
507
+ for (const socket of this.provisionalSockets) socket.destroy();
508
+ this.provisionalSockets.clear();
509
+ await this.#closeServer();
510
+ throw error;
511
+ }
512
+ }
513
+ if (generation !== this.startGeneration) {
514
+ for (const socket of this.provisionalSockets) socket.destroy();
515
+ this.provisionalSockets.clear();
516
+ await this.#closeServer();
517
+ throw new Error("Peer endpoint startup was cancelled");
518
+ }
459
519
 
460
520
  const processIdentity = procStart ? (IS_WINDOWS ? { procStart, procStartFt: procStart } : { procStart }) : {};
461
521
  this.registry = {
@@ -490,7 +550,26 @@ export class PeerEndpoint {
490
550
  this.log(`peer "${this.name}" listening on ${this.socketPath}`);
491
551
  }
492
552
 
553
+ #closeServer() {
554
+ const server = this.server;
555
+ return new Promise((resolve) => {
556
+ if (!server) return resolve();
557
+ try {
558
+ if (!server.listening) return resolve();
559
+ server.close(() => resolve());
560
+ } catch {
561
+ resolve();
562
+ }
563
+ });
564
+ }
565
+
493
566
  #handleConnection(socket) {
567
+ if (IS_WINDOWS && !this.aclReady) {
568
+ this.provisionalSockets.add(socket);
569
+ socket.once("close", () => this.provisionalSockets.delete(socket));
570
+ socket.once("error", () => {});
571
+ return;
572
+ }
494
573
  this.activityRevision += 1;
495
574
  this.connections.add(socket);
496
575
  socket.once("close", () => this.connections.delete(socket));
@@ -554,18 +633,25 @@ export class PeerEndpoint {
554
633
  }
555
634
 
556
635
  #receiveMessage(message) {
636
+ const key = message.inReplyTo ?? [...this.pendingMessages].find(([, entry]) => entry.targetSocket === message.fromSocket)?.[0];
637
+ const pending = this.pendingMessages.get(key);
638
+ const correlated = pending && (!pending.transcriptSession || (message.source === "transcript" && message.inReplyTo === key));
639
+ if (this.strict && !(key && correlated && pending.targetSocket === message.fromSocket)) {
640
+ this.log(`ignored uncorrelated inbound peer message from ${message.fromSocket ?? "?"} in hardened mode`);
641
+ return;
642
+ }
557
643
  this.activityRevision += 1;
558
644
  const record = { ...message, receivedAt: Date.now(), sequence: ++this.messageSequence };
559
645
  this.inbox.push(record);
560
- const key = record.inReplyTo ?? [...this.pendingMessages].find(([, entry]) => entry.targetSocket === record.fromSocket)?.[0];
561
- const pending = this.pendingMessages.get(key);
562
- const correlated = pending && (!pending.transcriptSession || (record.source === "transcript" && record.inReplyTo === key));
563
646
  if (key && correlated && pending.targetSocket === record.fromSocket) {
564
647
  const sent = this.sentMessages.get(key);
565
648
  if (sent) {
566
649
  record.inReplyTo = key;
567
650
  record.replyThreadId = sent.replyThreadId ?? null;
568
651
  record.accountContext = sent.accountContext ?? null;
652
+ record.cwd = sent.transcriptSession?.cwd ?? null;
653
+ record.senderCwd = sent.scopeBindings?.sender?.path ?? null;
654
+ record.scopeBindings = sent.scopeBindings ?? null;
569
655
  sent.reply = record;
570
656
  }
571
657
  this.#removePendingReply(record.fromSocket, key);
@@ -683,7 +769,7 @@ export class PeerEndpoint {
683
769
  return frame.msg_id;
684
770
  }
685
771
 
686
- async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next", transcriptSession, beforeSend, permissionMode = this.permissionMode, replyThreadId, senderReview, senderApprovalPolicy, recipient, accountContext } = {}) {
772
+ async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next", transcriptSession, beforeSend, permissionMode = this.permissionMode, replyThreadId, senderReview, senderApprovalPolicy, recipient, accountContext, scopeBindings } = {}) {
687
773
  const previous = this.requestQueues.get(targetSocket) ?? Promise.resolve();
688
774
  const pending = previous.catch(() => {}).then(async () => {
689
775
  await beforeSend?.();
@@ -701,7 +787,7 @@ export class PeerEndpoint {
701
787
  this.unconfirmedReplies.set(targetSocket, unconfirmed + 1);
702
788
  let msgId = crypto.randomUUID();
703
789
  this.pendingMessages.set(msgId, { targetSocket, transcriptSession });
704
- this.sentMessages.set(msgId, { targetSocket, transcriptSession, sentAt: since, replyThreadId, permissionMode, senderApprovalPolicy, ...(senderReview ? { senderReview: { ...senderReview } } : {}), ...(recipient ? { recipient: { ...recipient } } : {}), ...(accountContext ? { accountContext: Object.freeze({ ...accountContext }) } : {}) });
790
+ 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)) } : {}) });
705
791
  if (transcriptSession && !this.responsePoll) {
706
792
  this.responsePoll = globalThis.setInterval(() => this.#refreshTranscriptReplies(), 250);
707
793
  this.responsePoll.unref();
@@ -815,6 +901,7 @@ export class PeerEndpoint {
815
901
  ...(sent.recipient ? { recipientPermissionMode: sent.recipient.permissionMode ?? null, recipientPermissionClass: sent.recipient.permissionClass ?? null, recipientInboundPolicy: sent.recipient.inboundPolicy ?? null } : {}),
816
902
  replyThreadId: sent.replyThreadId ?? null,
817
903
  ...(sent.accountContext ? { accountContext: { ...sent.accountContext } } : {}),
904
+ ...(sent.scopeBindings ? { scopeBindings: structuredClone(sent.scopeBindings), senderCwd: sent.scopeBindings.sender?.path ?? null } : {}),
818
905
  pending: this.pendingMessages.has(msgId),
819
906
  ...(sent.reply ? { reply: sent.reply.text, source: sent.reply.source ?? "peer", ...(sent.reply.absorbed ? { replyAbsorbed: true } : {}), ...(sent.reply.forwardingError ? { forwardingError: { ...sent.reply.forwardingError } } : {}) } : {}),
820
907
  };
@@ -900,12 +987,19 @@ export class PeerEndpoint {
900
987
  }
901
988
 
902
989
  stop() {
990
+ this.startGeneration += 1;
991
+ this.aclReady = !IS_WINDOWS || !this.protectSocket;
992
+ for (const socket of this.provisionalSockets) socket.destroy();
993
+ this.provisionalSockets.clear();
903
994
  globalThis.clearInterval(this.responsePoll);
904
995
  this.responsePoll = null;
905
996
  this.pendingMessages.clear();
906
- try {
907
- this.server?.close();
908
- } catch {}
997
+ const server = this.server;
998
+ this.closed = new Promise((resolve) => {
999
+ if (!server) return resolve();
1000
+ try { server.close(() => resolve()); }
1001
+ catch { resolve(); }
1002
+ });
909
1003
  for (const socket of this.connections) socket.destroy();
910
1004
  this.connections.clear();
911
1005
  this.#removeRegistration();
@@ -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
- for (const { receipt } of this.records.values()) counts[receipt.status] += 1;
99
- return { ...counts, attempts: this.attempts, maxPerSession: this.maxPerSession, closed: this.closed };
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() {
@@ -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,2 @@
1
+ export const TELEMETRY_ENDPOINT = "https://vdbwkowdggtcihixowxi.supabase.co/functions/v1/bridge-usage";
2
+ export const TELEMETRY_PUBLIC_KEY = "sb_publishable_2GIhGKL82wM8mN44L-Gzxw_Zgd-Vioh";
@@ -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
+ }
@@ -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 response = await this.request("send_message_to_thread", {
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 { threadId, cwd: inspected.thread.cwd, name: inspected.thread.title, previousTurnId: inspected.latestTurnId, backend: NATIVE_BACKEND };
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 wait(threadId, { timeoutMs = 240000, previousTurnId = null } = {}) {
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
- let text = "";
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
- return { threadId, turnId, status, text, activity: [], errors: turn.error ? [turn.error] : [], durationMs: turn.durationMs ?? this.now() - startedAt };
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(),