@minhspark/codex-mcp-bridge 1.22.21 → 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.21",
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,20 @@ 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
+
7
21
  ## [1.22.21] - 2026-10-26
8
22
 
9
23
  ### 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.21",
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",
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.21",
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.21";
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.21";
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.21";
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.21";
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)"}`,
@@ -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