@minhspark/codex-mcp-bridge 1.22.20 → 1.23.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-mcp-bridge",
3
- "version": "1.22.20",
3
+ "version": "1.23.0",
4
4
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
5
5
  "author": {
6
6
  "name": "Bui Dang Minh"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.23.0] - 2026-10-26
8
+
9
+ ### Added
10
+
11
+ - `remote_codex` lists, reads and messages existing Codex Desktop tasks on an explicitly configured SSH host. Calling-turn verification, exact project and repository checks, current account binding and signed durable receipts prevent wrong-project delivery and duplicate sends after uncertain transport outcomes. The two computers may use different Codex accounts; signing in again is discovered on the next call.
12
+
13
+ ### Tests
14
+
15
+ - Remote handoff regressions cover different accounts, mid-request identity changes, target and repository mismatches, missing sender metadata, SSH argument isolation, size and deadline limits, concurrent retries and receipt recovery after restart. Task membership is verified through native listings because native task reads can omit the project ID.
16
+
17
+ ### Upgrade
18
+
19
+ - Update both computers with `npm install -g @minhspark/codex-mcp-bridge@latest`. If an existing Codex task keeps its old tool catalog, reconnect its MCP server or start a fresh task to load `remote_codex`.
20
+
21
+ ## [1.22.21] - 2026-10-26
22
+
23
+ ### Fixed
24
+
25
+ - Recognize native Claude background-task completion notifications stored as inline attachments. A completed command could otherwise remain outstanding, hiding the final reply and blocking later messages with `PEER_REPLY_PENDING`. Both notification formats retain native-origin, exact task/tool-call identity and transcript ancestry checks; observing the original reply never resends its action.
26
+ - Resolve Claude reply ancestry when a final response is persisted before its parent attachment. Process unambiguous records with ancestors first, reject cycles, and preserve input boundaries and pending ownership until the required parent arrives.
27
+
28
+ ### Verification
29
+
30
+ - Three focused regressions fail against 1.22.20 and pass with this fix: inline completion, child-before-parent ancestry, and pending ownership recovery without repeating the original action.
31
+ - Independent parser checks cover 1,320 record-order and invalid-notification combinations. Endpoint tests verify that missing parents keep ownership pending and that a distinct follow-up is allowed only after the original reply resolves.
32
+
33
+ ### Limitations
34
+
35
+ - Missing or ambiguous transcript ancestry still cannot establish a reply. Inspect the existing task instead of resending its original action. An already blocked older runtime may need a client restart after upgrading to load this fix.
36
+
7
37
  ## [1.22.20] - 2026-10-24
8
38
 
9
39
  ### Fixed
package/README.md CHANGED
@@ -166,6 +166,7 @@ Send a short message and confirm its reply **in both directions**. Registration
166
166
 
167
167
  - **Claude → Codex:** “Send this review to my Codex task in this project and wait for its reply.”
168
168
  - **Codex → Claude:** “Send this result to my Claude Desktop Code session in this project.”
169
+ - **Codex → Codex on another computer:** configure an SSH project mapping, then use `remote_codex` to list, read or message an existing Desktop task. Different Codex accounts on the two computers are supported. See [remote Codex setup](REFERENCE.md#codex-to-codex-over-ssh).
169
170
  - **New work:** “Create a new Codex conversation for this feature and send the brief there.”
170
171
 
171
172
  Continue unfinished work with its original `threadId`. For an authorized new Desktop task, use `delegate_to_codex` or `start_codex_thread` with a fresh UUID `requestId`; retain that ID on retries. If a send times out, use `wait_codex_reply` with the original `deliveryId` before retrying the message.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.22.20",
3
+ "version": "1.23.0",
4
4
  "description": "Two-way MCP bridge between Claude and Codex: prompts into a live Codex thread, messages into a running Claude Code session.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -83,6 +83,6 @@
83
83
  },
84
84
  "dependencies": {
85
85
  "@modelcontextprotocol/sdk": "^1.32.1",
86
- "zod": "^3.25.76"
86
+ "zod": "^4.6.7"
87
87
  }
88
88
  }
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "codex-mcp-bridge",
4
- "version": "1.22.20",
4
+ "version": "1.23.0",
5
5
  "description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
6
6
  "author": {
7
7
  "name": "Bui Dang Minh",
@@ -25,7 +25,7 @@ import { removeRelayFile } from "../src/native-relay-files.mjs";
25
25
 
26
26
  exitForVersionRequest(import.meta.url);
27
27
 
28
- const VERSION = "1.22.20";
28
+ const VERSION = "1.23.0";
29
29
  const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
30
30
  const entry = path.join(root, "src", "mcp-supervisor.mjs");
31
31
  const entryArgs = ["native-relay-companion.mjs"];
@@ -30,7 +30,7 @@ import { discoverClaudeProject } from "./claude-project-discovery.mjs";
30
30
 
31
31
  exitForVersionRequest(import.meta.url);
32
32
 
33
- const VERSION = "1.22.20";
33
+ const VERSION = "1.23.0";
34
34
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
35
35
  const FORWARD_MIN_INTERVAL_MS = 5000;
36
36
  const FORWARD_MAX_PER_SESSION = 50;
package/src/index.mjs CHANGED
@@ -41,7 +41,7 @@ import { createRelayWake, relayKeeperEnabled, relayWakeStateFile, startRelayKeep
41
41
 
42
42
  exitForVersionRequest(import.meta.url);
43
43
 
44
- const VERSION = "1.22.20";
44
+ const VERSION = "1.23.0";
45
45
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
46
46
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
47
47
 
@@ -21,7 +21,9 @@ export class NativeCreationReceipts {
21
21
  #keyBoundary;
22
22
  #keyDirectoryBoundary;
23
23
  #keyDirectory;
24
- constructor({ directory = path.join(process.env.CODEX_HOME ?? path.join(homeDir(), ".codex"), "bridge-native-creations") } = {}) {
24
+ constructor({ directory = path.join(process.env.CODEX_HOME ?? path.join(homeDir(), ".codex"), "bridge-native-creations"), accountProviders = ["claude", "codex"] } = {}) {
25
+ if (!["claude,codex", "codex"].includes(accountProviders?.join())) throw Error("Invalid receipt account providers");
26
+ this.accountProviders = Object.freeze([...accountProviders]);
25
27
  this.directory = path.resolve(directory);
26
28
  this.#keyDirectory = path.join(path.dirname(this.directory), "bridge-native-creation-authority");
27
29
  }
@@ -137,7 +139,7 @@ export class NativeCreationReceipts {
137
139
  if (current.isSymbolicLink() || !sameFile(opened, after) || !sameFile(after, current) || BigInt(count) !== opened.size) throw Error("Native creation receipt changed");
138
140
  const row = JSON.parse(bytes.subarray(0, count).toString());
139
141
  if (row.version !== 1 || row.id !== id || row.hash !== hash || creationRequestHash(row.args) !== hash ||
140
- !accounts || ["claude", "codex"].some(k => !accounts[k] || row.accounts?.[k] !== accounts[k]) ||
142
+ !accounts || Object.keys(row.accounts ?? {}).sort().join() !== this.accountProviders.join() || this.accountProviders.some(k => !accounts[k] || row.accounts?.[k] !== accounts[k]) ||
141
143
  typeof row.executorThreadId !== "string" || !["pending", "completed", "failed"].includes(row.state)) throw Error("Native creation receipt binding mismatch");
142
144
  if (typeof row.signature !== "string" || !/^[a-f0-9]{64}$/.test(row.signature) ||
143
145
  !timingSafeEqual(Buffer.from(row.signature, "hex"), Buffer.from(this.#signature(row), "hex"))) throw Error("Native creation receipt signature is invalid; ownership recovery is blocked");
@@ -37,10 +37,12 @@ import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
37
37
  import { removeRelayFile, relayFileIdentity, verifyRelaySocketMode } from "./native-relay-files.mjs";
38
38
 
39
39
  import { NativeCreationReceipts, creationIdValid } from "./native-creation-receipts.mjs";
40
+ import { registerRemoteCodexTools } from "./remote-codex-tools.mjs";
41
+ import { remoteCodexSocketPath, remoteNativeAdapter, remoteRequestResponse } from "./remote-codex.mjs";
40
42
 
41
43
  exitForVersionRequest(import.meta.url);
42
44
 
43
- const VERSION = "1.22.20";
45
+ const VERSION = "1.23.0";
44
46
  const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
45
47
 
46
48
  function errorResponse(code, message, sent, details) {
@@ -392,6 +394,7 @@ export class RelaySocketServer {
392
394
  authorize,
393
395
  creationReceipts = new NativeCreationReceipts(),
394
396
  requireAccountContext = false,
397
+ requestHandler,
395
398
  strict = hardenedBridgeEnabled(),
396
399
  protectSocket = protectCurrentUserPipe,
397
400
  restrictSocket = (target, expected) => {
@@ -408,6 +411,7 @@ export class RelaySocketServer {
408
411
  this.authorize = authorize;
409
412
  this.creationReceipts = creationReceipts;
410
413
  this.requireAccountContext = requireAccountContext;
414
+ this.requestHandler = requestHandler;
411
415
  this.strict = strict;
412
416
  this.protectSocket = protectSocket;
413
417
  this.restrictSocket = restrictSocket;
@@ -667,6 +671,10 @@ export class RelaySocketServer {
667
671
  this.#reply(socket, errorResponse("RELAY_BAD_REQUEST", `malformed JSON: ${err.message}`));
668
672
  return;
669
673
  }
674
+ if (this.requestHandler) {
675
+ this.#reply(socket, await this.requestHandler(payload));
676
+ return;
677
+ }
670
678
  if ((this.requireAccountContext || this.strict) && !boundRequest(payload)) {
671
679
  this.#reply(socket, errorResponse("RELAY_BAD_REQUEST", "The account relay requires protocol 2 and the original account context", false));
672
680
  return;
@@ -875,7 +883,12 @@ export function createRelayServers({ nativeTools, env = process.env, signalExit
875
883
  ...shared,
876
884
  });
877
885
  const strictDesktop = hardened && desktopTasksConfigured(env);
878
- return { relay, desktopRelay, accountRelay, strictDesktop, relays: strictDesktop ? [accountRelay] : [relay, desktopRelay, accountRelay] };
886
+ const remoteRelay = new RelaySocketServer({
887
+ socketPath: remoteCodexSocketPath(env), strict: true,
888
+ requestHandler: (request) => remoteRequestResponse(request, { env, relay: remoteNativeAdapter(nativeTools, env) }),
889
+ ...shared,
890
+ });
891
+ return { relay, desktopRelay, accountRelay, remoteRelay, strictDesktop, relays: strictDesktop ? [accountRelay, remoteRelay] : [relay, desktopRelay, accountRelay, remoteRelay] };
879
892
  }
880
893
 
881
894
  /**
@@ -891,16 +904,17 @@ if (invokedDirectly) {
891
904
  {
892
905
  instructions:
893
906
  "Companion process for the Codex Desktop native relay. It carries no work of its own: it accepts " +
894
- "messages from claude-bridge on a private local socket and asks the Codex Desktop app-server that " +
907
+ "authorized Codex handoffs over SSH and messages from claude-bridge on private local sockets, and asks the Codex Desktop app-server that " +
895
908
  "launched it to deliver them into an already-open thread, so that thread keeps its writer lock.",
896
909
  },
897
910
  );
898
911
 
899
912
  const nativeTools = new NativeToolsClient();
900
- const { relay, desktopRelay, accountRelay, strictDesktop, relays } = createRelayServers({ nativeTools, signalExit: false, log });
913
+ const { relay, desktopRelay, accountRelay, remoteRelay, strictDesktop, relays } = createRelayServers({ nativeTools, signalExit: false, log });
901
914
  const runtime = createRuntimeState();
902
915
  const lifecycle = createNativeRelayLifecycle({ nativeTools, relays, log });
903
916
  const reload = createReloadControl({ entry: "native-relay-companion.mjs", ...lifecycle });
917
+ registerRemoteCodexTools(mcp, { nativeTools, runtime, reload });
904
918
 
905
919
  mcp.registerTool(
906
920
  "native_relay_status",
@@ -936,6 +950,7 @@ if (invokedDirectly) {
936
950
  `relay socket: ${strictDesktop ? "disabled by hardened Desktop profile" : relay.started ? relay.socketPath : `${relay.socketPath} (${listening ? "shared companion listening" : "not listening"})`}`,
937
951
  `desktop tasks: ${strictDesktop ? "disabled by hardened Desktop profile" : `${desktopRelay.socketPath} (${await desktopRelay.isListening() ? "listening" : "not listening"})`}`,
938
952
  `account relay: ${accountRelay.socketPath} (protocol ${ACCOUNT_RELAY_PROTOCOL_VERSION}, ${await accountRelay.isListening() ? "listening" : "not listening"})`,
953
+ `remote Codex: ${remoteRelay.socketPath} (${await remoteRelay.isListening() ? "listening" : "not listening"})`,
939
954
  `executor: ${executor}`,
940
955
  `dispatch: ${process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD}`,
941
956
  `native pipe: ${nativeTools.socketPath ?? "unavailable (requires Codex Desktop)"}`,
@@ -486,6 +486,28 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
486
486
  if (roots.length !== 1) return null;
487
487
  const { entry: root, absorbed } = roots[0];
488
488
  const entriesById = new Map(entries.map((entry) => [entry.uuid, entry]));
489
+ const orderedEntries = ambiguous ? entries : [];
490
+ if (!ambiguous) {
491
+ // Claude can persist a reply before its parent attachment; visit late
492
+ // ancestors before their children instead of assuming append order.
493
+ const visited = new Set();
494
+ for (const entry of entries) {
495
+ const ancestors = [];
496
+ const visiting = new Set();
497
+ let ancestor = entry;
498
+ while (ancestor && !visited.has(ancestor)) {
499
+ if (visiting.has(ancestor)) return null;
500
+ visiting.add(ancestor);
501
+ ancestors.push(ancestor);
502
+ ancestor = typeof ancestor.parentUuid === "string" ? entriesById.get(ancestor.parentUuid) : null;
503
+ }
504
+ while (ancestors.length) {
505
+ const ancestor = ancestors.pop();
506
+ visited.add(ancestor);
507
+ orderedEntries.push(ancestor);
508
+ }
509
+ }
510
+ }
489
511
  const descendants = new Map();
490
512
  const backgroundCalls = new Map();
491
513
  const openLeaves = new Set();
@@ -497,7 +519,7 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
497
519
  openLeaves.delete(entry.parentUuid);
498
520
  interruptions.push({ entryId: entry.uuid, backgroundTaskIds: [...outstanding.keys()] });
499
521
  };
500
- for (const entry of entries) {
522
+ for (const entry of orderedEntries) {
501
523
  if (entry === root) { descendants.set(entry.uuid, new Map()); openLeaves.add(entry.uuid); continue; }
502
524
  let parentUuid = entry.parentUuid;
503
525
  if (!ambiguous && !interruptions.length && descendants.has(entry.logicalParentUuid)
@@ -509,6 +531,7 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
509
531
  }
510
532
  }
511
533
  if (!descendants.has(parentUuid)) continue;
534
+ let taskNotification = null;
512
535
  if (entry.type === "attachment" && entry.attachment?.type === "queued_command") {
513
536
  // Only a native command record is a boundary. Its text is never used as
514
537
  // proof of authorization, cancellation, or successful task completion.
@@ -516,12 +539,12 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
516
539
  interrupt(entry, descendants.get(entry.parentUuid));
517
540
  continue;
518
541
  }
519
- // An inline native task notification is not a new prompt. Preserve the
520
- // active reply chain even when the notification belongs to another task;
521
- // its text alone never completes any outstanding background command.
542
+ // Inline native notifications preserve the chain; only matching task and
543
+ // tool-call identities below can complete an outstanding command.
522
544
  if (entry.attachment.commandMode !== "task-notification"
523
545
  || entry.attachment.origin?.kind !== "task-notification"
524
546
  || entry.attachment.origin?.producer !== "session-task") continue;
547
+ taskNotification = entry.attachment.prompt;
525
548
  }
526
549
  const role = entry.message?.role;
527
550
  const content = entry.message?.content;
@@ -548,14 +571,18 @@ export function readTranscriptOutcome(sessionId, cwd, msgId) {
548
571
  if (entry.origin?.kind !== "task-notification" || entry.origin.producer !== "session-task"
549
572
  || entry.promptSource !== "system" || entry.turnOrigin !== "task_notification"
550
573
  || typeof content !== "string" || !/^<task-notification>\s[\s\S]*<\/task-notification>$/.test(content.trim())) continue;
574
+ taskNotification = content;
575
+ }
576
+ if (typeof taskNotification === "string" && /^<task-notification>\s[\s\S]*<\/task-notification>$/.test(taskNotification.trim())) {
551
577
  const field = (name) => {
552
- const matches = [...content.matchAll(new RegExp(`<${name}>([^<>]+)</${name}>`, "g"))];
578
+ const matches = [...taskNotification.matchAll(new RegExp(`<${name}>([^<>]+)</${name}>`, "g"))];
553
579
  return matches.length === 1 ? matches[0][1] : null;
554
580
  };
555
581
  const taskId = field("task-id");
556
582
  if (!outstanding.has(taskId) || outstanding.get(taskId) !== field("tool-use-id")
557
- || !["completed", "failed", "killed"].includes(field("status"))) continue;
558
- outstanding.delete(taskId);
583
+ || !["completed", "failed", "killed"].includes(field("status"))) {
584
+ if (role === "user") continue;
585
+ } else outstanding.delete(taskId);
559
586
  }
560
587
  if (typeof entry.uuid !== "string" || !entry.uuid.trim()) continue;
561
588
  if (role === "assistant" && Array.isArray(content)) {
@@ -0,0 +1,17 @@
1
+ import { forwardRemoteRequest } from "./remote-codex.mjs";
2
+
3
+ const chunks = [];
4
+ let bytes = 0;
5
+ for await (const chunk of process.stdin) {
6
+ bytes += chunk.length;
7
+ if (bytes > 128 * 1024) throw new Error("Remote request exceeds its limit");
8
+ chunks.push(chunk);
9
+ }
10
+ let request;
11
+ try {
12
+ request = JSON.parse(Buffer.concat(chunks).toString("utf8"));
13
+ const result = await forwardRemoteRequest(request);
14
+ process.stdout.write(JSON.stringify({ version: 1, ok: true, action: request.action, requestId: request.requestId ?? null, result }) + "\n");
15
+ } catch (error) {
16
+ process.stdout.write(JSON.stringify({ version: 1, ok: false, action: request?.action, requestId: request?.requestId ?? null, error: { code: error.code ?? "REMOTE_FAILED", message: error.message } }) + "\n");
17
+ }
@@ -0,0 +1,62 @@
1
+ import os from "node:os";
2
+ import { z } from "zod";
3
+ import { resolveCodexSenderContext } from "./codex-sender-context.mjs";
4
+ import { readCodexAccountContext } from "./codex-account-context.mjs";
5
+ import { captureProjectScope, recheckProjectScope } from "./project-scope.mjs";
6
+ import { decodeNativeToolResult } from "./native-relay.mjs";
7
+ import { exchangeRemoteRequest, readRemoteProfile, remoteFailure, repositoryIdentity, sameRemoteProfile, validateRemoteRequest } from "./remote-codex.mjs";
8
+
9
+ export function registerRemoteCodexTools(mcp, { nativeTools, runtime, reload,
10
+ resolveSender = resolveCodexSenderContext, readAccount = readCodexAccountContext,
11
+ readProfile = readRemoteProfile, captureScope = captureProjectScope, recheckScope = recheckProjectScope,
12
+ repositoryIdentityOf = repositoryIdentity, exchange = exchangeRemoteRequest, hostname = os.hostname,
13
+ }) {
14
+ mcp.registerTool("remote_codex", {
15
+ title: "Send to Codex Desktop on another computer",
16
+ description: "List, read or message existing Codex Desktop tasks through an explicitly configured SSH host. Requires bridge-remote-hosts.json and live host-supplied sender metadata. Each computer may use a different Codex account; the current accounts are rediscovered per call and cannot change during delivery. Select the exact saved project and task from list before send. Send requires Full access and a fresh UUID requestId; retain that ID after uncertainty, because retries only recover the durable receipt and never dispatch twice. Submitted means accepted, not completed; use read to verify the reply. Only send work explicitly authorized by the user. This does not delegate to Claude or run an external app-server.",
17
+ inputSchema: {
18
+ host: z.string().min(1).max(64).describe("Exact host name from bridge-remote-hosts.json"),
19
+ action: z.enum(["list", "read", "send"]).describe("List matching tasks, read a selected task, or send an authorized message"),
20
+ threadId: z.string().uuid().optional().describe("Exact remote task id from list; required for read and send"),
21
+ message: z.string().min(1).max(50000).optional().describe("Authorized prompt for send; retain exactly on receipt recovery"),
22
+ requestId: z.string().uuid().optional().describe("Fresh v4 UUID for a new send; preserve on retries"),
23
+ },
24
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
25
+ }, async (args, extra) => reload.run(async () => {
26
+ try {
27
+ runtime.assertCurrent();
28
+ const readSender = () => resolveSender(extra?._meta, { inspectDesktopTask: async (threadId) => {
29
+ const result = decodeNativeToolResult(await nativeTools.dispatchDesktop({ executorThreadId: threadId, operation: "read_thread", arguments: { threadId, hostId: "local", turnLimit: 1, includeOutputs: false, maxOutputCharsPerItem: 1 } }));
30
+ return { thread: result.thread, latestTurnId: result.turns?.[0]?.id ?? null };
31
+ } });
32
+ const sender = await readSender();
33
+ if (sender.status !== "verified") throw remoteFailure("CODEX_SENDER_CONTEXT_UNVERIFIED", sender.reason);
34
+ if (args.action === "send" && sender.mode !== "bypass") throw remoteFailure("REMOTE_SENDER_PERMISSION", "Remote sending requires this Codex turn to use Full access; no permissions were changed");
35
+ const account = readAccount();
36
+ if (account.status !== "verified") throw remoteFailure("REMOTE_ACCOUNT_UNVERIFIED", account.reason);
37
+ const profile = readProfile(args.host);
38
+ const scope = captureScope(sender.cwd, profile.localCwd);
39
+ const repository = repositoryIdentityOf(profile.localCwd);
40
+ const check = async () => {
41
+ runtime.assertCurrent();
42
+ const current = await readSender();
43
+ if (current.status !== "verified" || ["threadId", "turnId", "cwd", "mode"].some((key) => current[key] !== sender[key])) throw remoteFailure("CODEX_SENDER_CHANGED", "The sending Codex turn changed; inspect the destination before retrying");
44
+ const currentAccount = readAccount();
45
+ if (currentAccount.status !== "verified" || currentAccount.fingerprint !== account.fingerprint) throw remoteFailure("REMOTE_ACCOUNT_CHANGED", "The sending Codex account changed");
46
+ sameRemoteProfile(profile, readProfile(args.host));
47
+ recheckScope(scope);
48
+ if (repositoryIdentityOf(profile.localCwd) !== repository) throw remoteFailure("REMOTE_PROJECT_CHANGED", "The sending repository changed");
49
+ };
50
+ await check();
51
+ const request = { version: 1, action: args.action, cwd: profile.remoteCwd, repository, account: account.fingerprint,
52
+ sender: { threadId: sender.threadId, mode: sender.mode, host: hostname() },
53
+ ...(args.threadId ? { threadId: args.threadId } : {}), ...(args.message ? { message: args.message } : {}), ...(args.requestId ? { requestId: args.requestId } : {}) };
54
+ validateRemoteRequest(request);
55
+ const result = await exchange(profile, request);
56
+ await check();
57
+ return { content: [{ type: "text", text: JSON.stringify(result) }], structuredContent: result };
58
+ } catch (error) {
59
+ return { isError: true, content: [{ type: "text", text: `${error.code ?? "REMOTE_FAILED"}: ${error.message}` }] };
60
+ }
61
+ }));
62
+ }
@@ -0,0 +1,281 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import net from "node:net";
5
+ import { execFileSync, spawn } from "node:child_process";
6
+ import { isDeepStrictEqual } from "node:util";
7
+ import { relaySocketPath, resolveRelayThreadId, validateDesktopOperation, decodeNativeToolResult } from "./native-relay.mjs";
8
+ import { readCodexAccountContext } from "./codex-account-context.mjs";
9
+ import { NativeCreationReceipts, creationIdValid, creationRequestHash } from "./native-creation-receipts.mjs";
10
+ import { captureProjectScope, recheckProjectScope } from "./project-scope.mjs";
11
+ import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
12
+
13
+ const LIMIT = 128 * 1024;
14
+ const ID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
15
+ const HASH = /^[a-f0-9]{64}$/;
16
+ const ownObject = (value) => value && typeof value === "object" && !Array.isArray(value);
17
+ const cleanPath = (value) => typeof value === "string" && value.length <= 4096 && !/[\0\r\n]/.test(value);
18
+ const home = (env) => env.CODEX_HOME ?? path.join(env.HOME || env.USERPROFILE || os.homedir(), ".codex");
19
+ export const remoteCodexSocketPath = (env = process.env) => `${relaySocketPath(env)}-codex-remote-v1`;
20
+
21
+ export function currentRemoteAccount() {
22
+ const account = readCodexAccountContext();
23
+ if (account.status !== "verified") throw remoteFailure("REMOTE_ACCOUNT_UNVERIFIED", account.reason);
24
+ return { codex: account.fingerprint };
25
+ }
26
+
27
+ export function assertRemoteAccount(expected) {
28
+ if (currentRemoteAccount().codex !== expected.codex) throw remoteFailure("REMOTE_ACCOUNT_CHANGED", "The receiving Codex account changed during this operation");
29
+ }
30
+
31
+ export function remoteNativeAdapter(nativeTools, env = process.env) {
32
+ return { async requestDesktop(operation, args, { beforeSend }) {
33
+ validateDesktopOperation(operation, args);
34
+ const result = await nativeTools.dispatchDesktop({ executorThreadId: resolveRelayThreadId(env).threadId, operation, arguments: args }, { beforeSend });
35
+ return { result: decodeNativeToolResult(result) };
36
+ } };
37
+ }
38
+
39
+ export function forwardRemoteRequest(request, { env = process.env, timeoutMs = 30000, socketPath = remoteCodexSocketPath(env) } = {}) {
40
+ validateRemoteRequest(request);
41
+ return new Promise((resolve, reject) => {
42
+ const socket = net.connect({ path: socketPath });
43
+ let output = Buffer.alloc(0), settled = false;
44
+ const finish = (error, result) => {
45
+ if (settled) return;
46
+ settled = true;
47
+ clearTimeout(timer); socket.destroy();
48
+ if (error) reject(error); else resolve(result);
49
+ };
50
+ const uncertain = () => remoteFailure("REMOTE_DELIVERY_UNCONFIRMED", "The Codex remote companion did not confirm delivery. Keep the requestId and inspect the destination; no fallback or resend was attempted");
51
+ const timer = setTimeout(() => finish(uncertain()), timeoutMs);
52
+ socket.on("error", () => finish(uncertain()));
53
+ socket.on("close", () => { if (!settled) finish(uncertain()); });
54
+ socket.on("connect", () => socket.write(JSON.stringify(request) + "\n"));
55
+ socket.on("data", (chunk) => {
56
+ output = Buffer.concat([output, chunk]);
57
+ if (output.length > 4 * 1024 * 1024) return finish(uncertain());
58
+ const end = output.indexOf(10);
59
+ if (end < 0) return;
60
+ try {
61
+ const response = JSON.parse(output.subarray(0, end).toString("utf8"));
62
+ if (response.version !== 1 || response.action !== request.action || response.requestId !== (request.requestId ?? null)) throw uncertain();
63
+ if (!response.ok) throw remoteFailure(response.error?.code ?? "REMOTE_FAILED", response.error?.message ?? "Remote Codex refused the request");
64
+ finish(null, response.result);
65
+ } catch (error) { finish(error); }
66
+ });
67
+ });
68
+ }
69
+
70
+ export async function remoteRequestResponse(request, options) {
71
+ try {
72
+ const result = await handleRemoteRequest(request, options);
73
+ return { version: 1, ok: true, action: request.action, requestId: request.requestId ?? null, result };
74
+ } catch (error) {
75
+ return { version: 1, ok: false, action: request?.action, requestId: request?.requestId ?? null, error: { code: error.code ?? "REMOTE_FAILED", message: error.message } };
76
+ }
77
+ }
78
+
79
+ export function remoteFailure(code, message) {
80
+ return Object.assign(new Error(message), { code });
81
+ }
82
+
83
+ export function repositoryIdentity(cwd) {
84
+ const value = execFileSync("git", ["-C", cwd, "config", "--get", "remote.origin.url"], { encoding: "utf8", timeout: 5000, windowsHide: true }).trim();
85
+ const scp = value.includes("://") ? null : value.match(/^(?:[^@/:]+@)?([^/:]+):([^/].*)$/);
86
+ let url;
87
+ try { url = new URL(scp ? `ssh://${scp[1]}/${scp[2]}` : value); }
88
+ catch { throw remoteFailure("REMOTE_PROJECT_UNVERIFIED", "The project needs a canonical network origin URL"); }
89
+ if (!["https:", "ssh:"].includes(url.protocol) || url.password || url.protocol === "https:" && url.username || url.search || url.hash) throw remoteFailure("REMOTE_PROJECT_UNVERIFIED", "The project origin is unsupported");
90
+ const name = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
91
+ if (!name || name.split("/").some((part) => !part || part === "." || part === "..")) throw remoteFailure("REMOTE_PROJECT_UNVERIFIED", "The project origin path is invalid");
92
+ return `${url.hostname.toLowerCase()}${url.port ? `:${url.port}` : ""}/${name}`;
93
+ }
94
+
95
+ export function readRemoteProfile(name, { env = process.env } = {}) {
96
+ if (typeof name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name)) throw remoteFailure("REMOTE_HOST_INVALID", "Choose an exact configured remote host name");
97
+ const root = home(env);
98
+ if (!cleanPath(root) || !path.isAbsolute(root)) throw remoteFailure("REMOTE_CONFIG_UNSAFE", "Codex configuration directory must be absolute");
99
+ const directory = fs.realpathSync.native(root);
100
+ const file = path.resolve(directory, "bridge-remote-hosts.json");
101
+ if (!file.startsWith(directory + path.sep)) throw remoteFailure("REMOTE_CONFIG_UNSAFE", "Remote host configuration is outside Codex home");
102
+ const safe = (entry) => entry.isFile() && !entry.isSymbolicLink() && entry.nlink === 1n && entry.size <= BigInt(LIMIT)
103
+ && (process.platform === "win32" || entry.uid === BigInt(process.getuid()) && !(entry.mode & 0o077n));
104
+ const same = (left, right) => ["dev", "ino", "size", "mode", "uid", "nlink", "mtimeNs", "ctimeNs"].every((key) => left[key] === right[key]);
105
+ const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
106
+ let config;
107
+ try {
108
+ const opened = fs.fstatSync(descriptor, { bigint: true });
109
+ const stat = fs.lstatSync(file, { bigint: true });
110
+ if (!safe(opened) || !safe(stat)) throw remoteFailure("REMOTE_CONFIG_UNSAFE", "Remote host configuration must be a private regular file");
111
+ if (!same(stat, opened)) throw remoteFailure("REMOTE_CONFIG_CHANGED", "Remote host configuration changed after opening");
112
+ const data = Buffer.alloc(Number(opened.size));
113
+ let offset = 0;
114
+ while (offset < data.length) {
115
+ const count = fs.readSync(descriptor, data, offset, data.length - offset, offset);
116
+ if (!count) throw remoteFailure("REMOTE_CONFIG_CHANGED", "Remote host configuration changed during reading");
117
+ offset += count;
118
+ }
119
+ const current = fs.lstatSync(file, { bigint: true });
120
+ if (!safe(current) || !same(opened, fs.fstatSync(descriptor, { bigint: true })) || !same(opened, current)
121
+ || fs.realpathSync.native(root) !== directory) throw remoteFailure("REMOTE_CONFIG_CHANGED", "Remote host configuration changed during reading");
122
+ config = JSON.parse(data.toString("utf8"));
123
+ } finally { fs.closeSync(descriptor); }
124
+ const profile = config.version === 1 && Object.hasOwn(config.hosts ?? {}, name) ? config.hosts[name] : null;
125
+ if (!ownObject(profile) || Object.keys(profile).sort().join() !== ["sshHost", "node", "receiver", "localCwd", "remoteCwd"].sort().join()
126
+ || typeof profile.sshHost !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(profile.sshHost)
127
+ || ![profile.node, profile.receiver, profile.remoteCwd].every((value) => cleanPath(value) && path.posix.isAbsolute(value))
128
+ || !cleanPath(profile.localCwd) || !path.isAbsolute(profile.localCwd)) throw remoteFailure("REMOTE_CONFIG_INVALID", "Remote host needs an SSH alias, absolute POSIX runtime/receiver/project paths and an absolute local project path");
129
+ return Object.freeze({ ...profile });
130
+ }
131
+
132
+ export function validateRemoteRequest(request) {
133
+ if (!ownObject(request) || Object.keys(request).some((key) => !["version", "action", "cwd", "repository", "account", "sender", "threadId", "message", "requestId"].includes(key))
134
+ || request.version !== 1 || !["list", "read", "send"].includes(request.action)
135
+ || !cleanPath(request.cwd) || !path.isAbsolute(request.cwd)
136
+ || typeof request.repository !== "string" || request.repository.length > 4096 || !HASH.test(request.account)
137
+ || !ownObject(request.sender) || Object.keys(request.sender).sort().join() !== ["host", "threadId", "mode"].sort().join()
138
+ || !ID.test(request.sender.threadId) || !["bypass", "prompting"].includes(request.sender.mode)
139
+ || typeof request.sender.host !== "string" || !/^[A-Za-z0-9_.-]{1,255}$/.test(request.sender.host)
140
+ || request.action !== "list" && !ID.test(request.threadId)
141
+ || request.action === "send" && (!creationIdValid(request.requestId) || typeof request.message !== "string" || !request.message.trim() || request.message.length > 50000)
142
+ || request.action !== "send" && (request.message !== undefined || request.requestId !== undefined)
143
+ || request.action === "list" && request.threadId !== undefined
144
+ || Buffer.byteLength(JSON.stringify(request)) > LIMIT) throw remoteFailure("REMOTE_REQUEST_INVALID", "Invalid remote Codex request");
145
+ if (request.action === "send" && request.sender.mode !== "bypass") throw remoteFailure("REMOTE_SENDER_PERMISSION", "Remote sending requires a verified Full access Codex turn; no permission setting was changed");
146
+ return request;
147
+ }
148
+
149
+ export function remoteSshArguments(profile) {
150
+ const quote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
151
+ const command = `${quote(profile.node)} ${quote(profile.receiver)}`;
152
+ return ["-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=10", "-o", "ClearAllForwardings=yes", "-o", "PermitLocalCommand=no", "--", profile.sshHost, `zsh -lc ${quote(command)}`];
153
+ }
154
+
155
+ export function exchangeRemoteRequest(profile, request, { spawnProcess = spawn, timeoutMs = 35000 } = {}) {
156
+ const input = JSON.stringify(request);
157
+ if (Buffer.byteLength(input) > LIMIT) throw remoteFailure("REMOTE_REQUEST_INVALID", "Remote request exceeds its limit");
158
+ return new Promise((resolve, reject) => {
159
+ const child = spawnProcess("ssh", remoteSshArguments(profile), { shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
160
+ let output = "", size = 0, settled = false;
161
+ const finish = (error, value) => {
162
+ if (settled) return;
163
+ settled = true;
164
+ clearTimeout(timer);
165
+ if (error) { child.kill(); reject(error); } else resolve(value);
166
+ };
167
+ const uncertain = () => remoteFailure("REMOTE_DELIVERY_UNCONFIRMED", "SSH did not confirm the result. Keep the same requestId and inspect the existing destination; do not send a new request");
168
+ const timer = setTimeout(() => finish(uncertain()), timeoutMs);
169
+ child.on("error", () => finish(uncertain()));
170
+ child.stdin.on("error", () => finish(uncertain()));
171
+ child.stdout.setEncoding("utf8");
172
+ child.stdout.on("data", (chunk) => {
173
+ size += Buffer.byteLength(chunk);
174
+ if (size > 4 * 1024 * 1024) return finish(uncertain());
175
+ output += chunk;
176
+ });
177
+ child.stderr.resume();
178
+ child.on("close", (code) => {
179
+ if (code !== 0) return finish(uncertain());
180
+ try {
181
+ const response = JSON.parse(output);
182
+ if (response.version !== 1 || response.action !== request.action || response.requestId !== (request.requestId ?? null)) throw uncertain();
183
+ if (!response.ok) return finish(remoteFailure(response.error?.code ?? "REMOTE_FAILED", response.error?.message ?? "Remote Codex refused the request"));
184
+ finish(null, response.result);
185
+ } catch (error) { finish(error); }
186
+ });
187
+ child.stdin.end(input + "\n");
188
+ });
189
+ }
190
+
191
+ export async function handleRemoteRequest(request, {
192
+ env = process.env,
193
+ readAccounts = currentRemoteAccount,
194
+ assertAccounts = assertRemoteAccount,
195
+ repository = repositoryIdentity,
196
+ canonical = (value) => fs.realpathSync.native(value),
197
+ captureScope = captureProjectScope,
198
+ recheckScope = recheckProjectScope,
199
+ relay,
200
+ rootPolicy = createHardenedRootPolicy(env),
201
+ receipts = new NativeCreationReceipts({ directory: path.join(home(env), "bridge-remote-deliveries"), accountProviders: ["codex"] }),
202
+ executor = () => resolveRelayThreadId(env).threadId,
203
+ } = {}) {
204
+ validateRemoteRequest(request);
205
+ const accounts = readAccounts();
206
+ if (!accounts || Object.keys(accounts).join() !== "codex" || !HASH.test(accounts.codex)) throw remoteFailure("REMOTE_ACCOUNT_UNVERIFIED", "No current receiving Codex account was verified");
207
+ const cwd = canonical(request.cwd);
208
+ const scope = captureScope(cwd, cwd);
209
+ const check = () => {
210
+ assertAccounts(accounts);
211
+ rootPolicy.assert(cwd, "Remote Codex project");
212
+ recheckScope(scope);
213
+ if (canonical(request.cwd) !== cwd || repository(cwd) !== request.repository) throw remoteFailure("REMOTE_PROJECT_MISMATCH", "The remote project differs from the verified sender repository");
214
+ };
215
+ check();
216
+ const call = async (operation, args, guard = check) => {
217
+ check();
218
+ const { result } = await relay.requestDesktop(operation, args, { beforeSend: guard });
219
+ check();
220
+ return result;
221
+ };
222
+ const project = async () => {
223
+ const result = await call("list_projects", {});
224
+ const matches = (result?.projects ?? []).filter((item) => item.projectKind === "local" && item.hostId === "local" && exactCwd(item.path));
225
+ if (matches.length !== 1 || !matches[0].projectId) throw remoteFailure("REMOTE_PROJECT_UNVERIFIED", "Select exactly one existing saved Codex project on the remote computer");
226
+ return matches[0].projectId;
227
+ };
228
+ const exactCwd = (value) => {
229
+ try { return typeof value === "string" && canonical(value) === cwd; } catch { return false; }
230
+ };
231
+ const projectId = await project();
232
+ const matchesIdentity = (thread) => thread?.kind === "codex" && thread.hostId === "local" && exactCwd(thread.cwd);
233
+ const matches = (thread) => matchesIdentity(thread) && thread.projectId === projectId;
234
+ const listedThreads = async () => {
235
+ const result = await call("list_threads", { limit: 50 });
236
+ return [...(result?.pinnedThreads ?? []), ...(result?.threads ?? [])];
237
+ };
238
+ if (request.action === "list") {
239
+ const threads = (await listedThreads()).filter(matches)
240
+ .map(({ id, title, status }) => ({ id, title, status, cwd, projectId }));
241
+ return { threads, coverage: "Most recent 50 unpinned tasks plus pinned tasks in the exact saved project" };
242
+ }
243
+ const inspect = async (includeOutputs = false) => {
244
+ if (await project() !== projectId) throw remoteFailure("REMOTE_PROJECT_MISMATCH", "The destination saved project changed");
245
+ const result = await call("read_thread", { threadId: request.threadId, hostId: "local", turnLimit: 2, includeOutputs, maxOutputCharsPerItem: includeOutputs ? 6000 : 1 });
246
+ const thread = result?.thread;
247
+ if (!matchesIdentity(thread) || thread.id !== request.threadId || (thread.projectId !== undefined && thread.projectId !== projectId)) throw remoteFailure("REMOTE_THREAD_MISMATCH", "The target task does not belong to the exact saved project");
248
+ const listed = (await listedThreads()).filter((item) => item.id === request.threadId);
249
+ if (listed.length !== 1 || !matches(listed[0])) throw remoteFailure("REMOTE_THREAD_MISMATCH", "The target task is no longer listed in the exact saved project");
250
+ return result;
251
+ };
252
+ if (request.action === "read") return inspect(true);
253
+ await inspect();
254
+ const executorId = executor();
255
+ if (request.threadId === executorId) throw remoteFailure("REMOTE_THREAD_MISMATCH", "The relay executor cannot be a handoff destination");
256
+ const hash = creationRequestHash(request);
257
+ const existing = receipts.read(request.requestId, accounts, hash);
258
+ if (existing) return { requestId: request.requestId, threadId: request.threadId, status: existing.state === "completed" ? "submitted" : "unconfirmed", result: existing.result ?? null, duplicate: true };
259
+ let row;
260
+ try { row = receipts.reserve(request.requestId, request, accounts, executorId); }
261
+ catch (error) {
262
+ if (error.code !== "EEXIST") throw error;
263
+ receipts.read(request.requestId, accounts, hash);
264
+ return { requestId: request.requestId, threadId: request.threadId, status: "unconfirmed", duplicate: true };
265
+ }
266
+ try {
267
+ await inspect();
268
+ const prompt = `[From Codex on ${request.sender.host} · task ${request.sender.threadId}]\n\n${request.message}`;
269
+ const result = await call("send_message_to_thread", { threadId: request.threadId, prompt }, async () => { await inspect(); check(); });
270
+ await inspect();
271
+ receipts.write({ ...row, state: "completed", result });
272
+ return { requestId: request.requestId, threadId: request.threadId, status: "submitted", result, duplicate: false };
273
+ } catch (error) {
274
+ receipts.write({ ...row, state: "failed", error: { code: error.code ?? "REMOTE_FAILED", message: error.message } });
275
+ throw remoteFailure("REMOTE_DELIVERY_UNCONFIRMED", "The send was reserved but its outcome was not confirmed. Inspect this task with read; reuse the original requestId only to recover its receipt");
276
+ }
277
+ }
278
+
279
+ export function sameRemoteProfile(left, right) {
280
+ if (!isDeepStrictEqual(left, right)) throw remoteFailure("REMOTE_CONFIG_CHANGED", "The remote host profile changed during the operation");
281
+ }
@@ -16,7 +16,9 @@ export async function protectCurrentUserPipe(socketPath, { pid = process.pid, ru
16
16
  if (!IS_WINDOWS) return { skipped: true };
17
17
  const name = bridgePipeName(socketPath);
18
18
  const script = fileURLToPath(new URL("./windows-pipe-acl.ps1", import.meta.url));
19
- const { stdout } = await run(powershell, ["-NoProfile", "-NonInteractive", "-File", script, "-PipeName", name, "-ServerPid", String(pid)], { windowsHide: true, timeout: 15000 });
19
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.toLowerCase() !== "psmodulepath"));
20
+ env.PSModulePath = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\Modules";
21
+ const { stdout } = await run(powershell, ["-NoProfile", "-NonInteractive", "-File", script, "-PipeName", name, "-ServerPid", String(pid)], { env, windowsHide: true, timeout: 15000 });
20
22
  let result;
21
23
  try { result = JSON.parse(stdout.trim()); } catch { throw new Error("Windows pipe ACL helper returned invalid readback"); }
22
24
  if (result?.serverMatches !== true || result?.serverUserMatches !== true || result?.ownerMatches !== true || result?.protected !== true || result?.aceCount !== 1