@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.
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +30 -0
- package/README.md +1 -0
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/scripts/install-native-relay.mjs +1 -1
- package/src/claude-bridge.mjs +1 -1
- package/src/index.mjs +1 -1
- package/src/native-creation-receipts.mjs +4 -2
- package/src/native-relay-companion.mjs +19 -4
- package/src/peer-protocol.mjs +34 -7
- package/src/remote-codex-receiver.mjs +17 -0
- package/src/remote-codex-tools.mjs +62 -0
- package/src/remote-codex.mjs +281 -0
- package/src/windows-pipe-acl.mjs +3 -1
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.
|
|
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": "^
|
|
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.
|
|
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.
|
|
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"];
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
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 ||
|
|
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.
|
|
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
|
-
|
|
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
|
|
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)"}`,
|
package/src/peer-protocol.mjs
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
520
|
-
//
|
|
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 = [...
|
|
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")))
|
|
558
|
-
|
|
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
|
+
}
|
package/src/windows-pipe-acl.mjs
CHANGED
|
@@ -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
|
|
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
|