@minhspark/codex-mcp-bridge 1.13.9 → 1.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.md +53 -24
- package/package.json +6 -6
- package/scripts/check.mjs +6 -1
- package/scripts/install-claude-desktop.mjs +22 -7
- package/scripts/install-codex-mcp.mjs +8 -5
- package/scripts/install-native-relay.mjs +15 -9
- package/src/bridge-account-context.mjs +77 -0
- package/src/claude-bridge-launcher.mjs +3 -0
- package/src/claude-bridge.mjs +91 -28
- package/src/claude-desktop-context.mjs +11 -6
- package/src/claude-sender-context.mjs +206 -0
- package/src/claude-session-router.mjs +92 -0
- package/src/codex-account-context.mjs +116 -0
- package/src/codex-bridge-launcher.mjs +3 -0
- package/src/codex-mcp-registration.mjs +14 -9
- package/src/desktop-account-context.mjs +140 -0
- package/src/desktop-task-receipts.mjs +3 -1
- package/src/index.mjs +91 -9
- package/src/mcp-supervisor.mjs +326 -0
- package/src/native-relay-companion.mjs +173 -34
- package/src/native-relay-launcher.mjs +3 -0
- package/src/native-relay.mjs +234 -50
- package/src/peer-protocol.mjs +120 -17
- package/src/platform.mjs +4 -1
- package/src/release-snapshot.mjs +198 -0
- package/src/reload-control.mjs +102 -0
- package/src/reply-forwarder.mjs +43 -0
- package/src/thread-delivery.mjs +27 -11
package/src/peer-protocol.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import os from "node:os";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
|
|
8
8
|
import { homeDir, IS_WINDOWS } from "./platform.mjs";
|
|
9
|
+
import { cloneReloadState } from "./reload-control.mjs";
|
|
9
10
|
|
|
10
11
|
const SOCKET_DIR = "/tmp/cc-socks";
|
|
11
12
|
|
|
@@ -366,6 +367,10 @@ export class PeerEndpoint {
|
|
|
366
367
|
this.sentMessages = new Map();
|
|
367
368
|
this.pendingMessages = new Map();
|
|
368
369
|
this.responsePoll = null;
|
|
370
|
+
this.connections = new Set();
|
|
371
|
+
this.activityRevision = 0;
|
|
372
|
+
this.reloadPaused = false;
|
|
373
|
+
this.processHandlersRegistered = false;
|
|
369
374
|
}
|
|
370
375
|
|
|
371
376
|
/**
|
|
@@ -396,6 +401,7 @@ export class PeerEndpoint {
|
|
|
396
401
|
}
|
|
397
402
|
|
|
398
403
|
async start() {
|
|
404
|
+
if (this.reloadPaused) throw new Error("The peer endpoint is quiesced for reload");
|
|
399
405
|
if (this.started) return;
|
|
400
406
|
this.#sweepDeadBridges();
|
|
401
407
|
const procStart = readProcessStart(this.pid);
|
|
@@ -432,24 +438,33 @@ export class PeerEndpoint {
|
|
|
432
438
|
fs.writeFileSync(this.registryPath, JSON.stringify(this.registry), { mode: 0o600 });
|
|
433
439
|
fs.writeFileSync(this.keyPath, JSON.stringify({ peerToken, ...processIdentity }), { mode: 0o600 });
|
|
434
440
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
441
|
+
if (!this.processHandlersRegistered) {
|
|
442
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
443
|
+
process.on(signal, () => {
|
|
444
|
+
this.stop();
|
|
445
|
+
process.exit(0);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
process.on("exit", () => this.stop());
|
|
449
|
+
this.processHandlersRegistered = true;
|
|
440
450
|
}
|
|
441
|
-
process.on("exit", () => this.stop());
|
|
442
451
|
|
|
443
452
|
this.started = true;
|
|
444
453
|
this.log(`peer "${this.name}" listening on ${this.socketPath}`);
|
|
445
454
|
}
|
|
446
455
|
|
|
447
456
|
#handleConnection(socket) {
|
|
457
|
+
this.activityRevision += 1;
|
|
458
|
+
this.connections.add(socket);
|
|
459
|
+
socket.once("close", () => this.connections.delete(socket));
|
|
460
|
+
if (this.reloadPaused) { socket.destroy(); return; }
|
|
448
461
|
let buffer = "";
|
|
449
462
|
let authenticated = false;
|
|
450
463
|
let firstLine = true;
|
|
451
464
|
socket.setEncoding("utf8");
|
|
452
465
|
socket.on("data", (chunk) => {
|
|
466
|
+
this.activityRevision += 1;
|
|
467
|
+
if (this.reloadPaused) { socket.destroy(); return; }
|
|
453
468
|
buffer += chunk;
|
|
454
469
|
if (buffer.length > 1048576) { socket.destroy(); return; }
|
|
455
470
|
let index;
|
|
@@ -502,6 +517,7 @@ export class PeerEndpoint {
|
|
|
502
517
|
}
|
|
503
518
|
|
|
504
519
|
#receiveMessage(message) {
|
|
520
|
+
this.activityRevision += 1;
|
|
505
521
|
const record = { ...message, receivedAt: Date.now(), sequence: ++this.messageSequence };
|
|
506
522
|
this.inbox.push(record);
|
|
507
523
|
const key = record.inReplyTo ?? [...this.pendingMessages].find(([, entry]) => entry.targetSocket === record.fromSocket)?.[0];
|
|
@@ -512,11 +528,12 @@ export class PeerEndpoint {
|
|
|
512
528
|
if (sent) {
|
|
513
529
|
record.inReplyTo = key;
|
|
514
530
|
record.replyThreadId = sent.replyThreadId ?? null;
|
|
531
|
+
record.accountContext = sent.accountContext ?? null;
|
|
515
532
|
sent.reply = record;
|
|
516
533
|
}
|
|
517
534
|
this.#removePendingReply(record.fromSocket, key);
|
|
518
535
|
}
|
|
519
|
-
this.log(`inbox <- ${record.fromSocket ?? "?"}: ${record.text.
|
|
536
|
+
this.log(`inbox <- ${record.fromSocket ?? "?"}: ${record.text.length} characters`);
|
|
520
537
|
for (const listener of [...this.listeners]) {
|
|
521
538
|
try { listener(record); }
|
|
522
539
|
catch (err) { this.log(`peer listener error: ${err.message}`); }
|
|
@@ -564,6 +581,7 @@ export class PeerEndpoint {
|
|
|
564
581
|
}
|
|
565
582
|
|
|
566
583
|
async send(targetSocket, text, { priority = "next", msgId, permissionMode = this.permissionMode, beforeSend } = {}) {
|
|
584
|
+
if (this.reloadPaused) throw new Error("The peer endpoint is quiesced; no message was sent");
|
|
567
585
|
const frame = buildFrame({ text, fromSocket: this.socketPath, priority, permissionMode });
|
|
568
586
|
if (msgId) frame.uuid = frame.msg_id = msgId;
|
|
569
587
|
const token = readPeerToken(targetSocket);
|
|
@@ -573,6 +591,8 @@ export class PeerEndpoint {
|
|
|
573
591
|
try {
|
|
574
592
|
await new Promise((resolve, reject) => {
|
|
575
593
|
const client = net.connect({ path: targetSocket });
|
|
594
|
+
this.connections.add(client);
|
|
595
|
+
client.once("close", () => this.connections.delete(client));
|
|
576
596
|
let connected = false;
|
|
577
597
|
let writeStarted = false;
|
|
578
598
|
let settled = false;
|
|
@@ -626,7 +646,7 @@ export class PeerEndpoint {
|
|
|
626
646
|
return frame.msg_id;
|
|
627
647
|
}
|
|
628
648
|
|
|
629
|
-
async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next", transcriptSession, beforeSend, permissionMode = this.permissionMode, replyThreadId, senderReview, senderApprovalPolicy, recipient } = {}) {
|
|
649
|
+
async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next", transcriptSession, beforeSend, permissionMode = this.permissionMode, replyThreadId, senderReview, senderApprovalPolicy, recipient, accountContext } = {}) {
|
|
630
650
|
const previous = this.requestQueues.get(targetSocket) ?? Promise.resolve();
|
|
631
651
|
const pending = previous.catch(() => {}).then(async () => {
|
|
632
652
|
await beforeSend?.();
|
|
@@ -644,7 +664,7 @@ export class PeerEndpoint {
|
|
|
644
664
|
this.unconfirmedReplies.set(targetSocket, unconfirmed + 1);
|
|
645
665
|
let msgId = crypto.randomUUID();
|
|
646
666
|
this.pendingMessages.set(msgId, { targetSocket, transcriptSession });
|
|
647
|
-
this.sentMessages.set(msgId, { targetSocket, transcriptSession, sentAt: since, replyThreadId, permissionMode, senderApprovalPolicy, ...(senderReview ? { senderReview: { ...senderReview } } : {}), ...(recipient ? { recipient: { ...recipient } } : {}) });
|
|
667
|
+
this.sentMessages.set(msgId, { targetSocket, transcriptSession, sentAt: since, replyThreadId, permissionMode, senderApprovalPolicy, ...(senderReview ? { senderReview: { ...senderReview } } : {}), ...(recipient ? { recipient: { ...recipient } } : {}), ...(accountContext ? { accountContext: Object.freeze({ ...accountContext }) } : {}) });
|
|
648
668
|
if (transcriptSession && !this.responsePoll) {
|
|
649
669
|
this.responsePoll = globalThis.setInterval(() => this.#refreshTranscriptReplies(), 250);
|
|
650
670
|
this.responsePoll.unref();
|
|
@@ -726,9 +746,15 @@ export class PeerEndpoint {
|
|
|
726
746
|
});
|
|
727
747
|
}
|
|
728
748
|
|
|
729
|
-
drainInbox(limit = 20) {
|
|
749
|
+
drainInbox(limit = 20, accepts = () => true) {
|
|
730
750
|
if (!Number.isSafeInteger(limit) || limit < 1) throw new Error("Inbox limit must be a positive integer");
|
|
731
|
-
|
|
751
|
+
const selected = [];
|
|
752
|
+
this.inbox = this.inbox.filter((record) => {
|
|
753
|
+
if (selected.length >= limit || !accepts(record)) return true;
|
|
754
|
+
selected.push(record);
|
|
755
|
+
return false;
|
|
756
|
+
});
|
|
757
|
+
return selected;
|
|
732
758
|
}
|
|
733
759
|
|
|
734
760
|
readDelivery(msgId) {
|
|
@@ -751,11 +777,91 @@ export class PeerEndpoint {
|
|
|
751
777
|
...(sent.senderReview ? { senderReview: { ...sent.senderReview } } : {}),
|
|
752
778
|
...(sent.recipient ? { recipientPermissionMode: sent.recipient.permissionMode ?? null, recipientPermissionClass: sent.recipient.permissionClass ?? null, recipientInboundPolicy: sent.recipient.inboundPolicy ?? null } : {}),
|
|
753
779
|
replyThreadId: sent.replyThreadId ?? null,
|
|
780
|
+
...(sent.accountContext ? { accountContext: { ...sent.accountContext } } : {}),
|
|
754
781
|
pending: this.pendingMessages.has(msgId),
|
|
755
782
|
...(sent.reply ? { reply: sent.reply.text, source: sent.reply.source ?? "peer", ...(sent.reply.absorbed ? { replyAbsorbed: true } : {}), ...(sent.reply.forwardingError ? { forwardingError: { ...sent.reply.forwardingError } } : {}) } : {}),
|
|
756
783
|
};
|
|
757
784
|
}
|
|
758
785
|
|
|
786
|
+
reloadReason() {
|
|
787
|
+
if (this.connections.size) return "Peer socket connections are still open";
|
|
788
|
+
if (this.requestQueues.size || this.pendingMessages.size || this.unconfirmedReplies.size) return "Claude messages still have pending or unconfirmed delivery";
|
|
789
|
+
for (const [id, sent] of this.sentMessages) {
|
|
790
|
+
if (!sent.reply && !sent.failed && !["refused", "denied", "expired", "dropped"].includes(this.deliveryReceipts.get(id)?.status)) {
|
|
791
|
+
return "A Claude delivery outcome remains unconfirmed";
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async quiesce() {
|
|
798
|
+
const reason = this.reloadReason();
|
|
799
|
+
if (reason) throw new Error(reason);
|
|
800
|
+
const revision = this.activityRevision;
|
|
801
|
+
this.reloadPaused = true;
|
|
802
|
+
globalThis.clearInterval(this.responsePoll);
|
|
803
|
+
this.responsePoll = null;
|
|
804
|
+
if (this.server?.listening) {
|
|
805
|
+
await new Promise((resolve, reject) => this.server.close((error) => error ? reject(error) : resolve()));
|
|
806
|
+
}
|
|
807
|
+
this.started = false;
|
|
808
|
+
this.#removeRegistration();
|
|
809
|
+
if (revision !== this.activityRevision || this.reloadReason()) throw new Error("Peer activity raced with reload; the endpoint remains quiesced");
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async resume() {
|
|
813
|
+
this.reloadPaused = false;
|
|
814
|
+
await this.start();
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
exportReloadState() {
|
|
818
|
+
const reason = this.reloadReason();
|
|
819
|
+
if (reason || this.started && !this.reloadPaused) throw new Error(reason ?? "Quiesce the peer endpoint before exporting state");
|
|
820
|
+
return cloneReloadState({ name: this.name, messageSequence: this.messageSequence, inbox: this.inbox,
|
|
821
|
+
sentMessages: [...this.sentMessages], deliveryReceipts: [...this.deliveryReceipts] });
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
restoreReloadState(input) {
|
|
825
|
+
if (this.started || this.connections.size || this.sentMessages.size || this.inbox.length) throw new Error("Peer reload state requires an empty dormant endpoint");
|
|
826
|
+
const state = cloneReloadState(input);
|
|
827
|
+
if (!state || typeof state.name !== "string" || !state.name || !Number.isSafeInteger(state.messageSequence) || state.messageSequence < 0
|
|
828
|
+
|| !Array.isArray(state.inbox) || !Array.isArray(state.sentMessages) || !Array.isArray(state.deliveryReceipts)) throw new Error("Invalid peer reload state");
|
|
829
|
+
const restoreMap = (entries) => {
|
|
830
|
+
const restored = new Map();
|
|
831
|
+
for (const pair of entries) {
|
|
832
|
+
if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== "string" || !pair[0] || restored.has(pair[0])
|
|
833
|
+
|| !pair[1] || typeof pair[1] !== "object" || Array.isArray(pair[1])) throw new Error("Invalid peer reload record");
|
|
834
|
+
restored.set(pair[0], pair[1]);
|
|
835
|
+
}
|
|
836
|
+
return restored;
|
|
837
|
+
};
|
|
838
|
+
const sentMessages = restoreMap(state.sentMessages);
|
|
839
|
+
const deliveryReceipts = restoreMap(state.deliveryReceipts);
|
|
840
|
+
for (const record of state.inbox) {
|
|
841
|
+
if (!record || typeof record.text !== "string" || !Number.isSafeInteger(record.sequence) || record.sequence < 1
|
|
842
|
+
|| record.sequence > state.messageSequence || !Number.isFinite(record.receivedAt)) throw new Error("Invalid inbox reload record");
|
|
843
|
+
}
|
|
844
|
+
for (const [id, sent] of sentMessages) {
|
|
845
|
+
if (typeof sent.targetSocket !== "string" || !sent.targetSocket || !Number.isFinite(sent.sentAt)
|
|
846
|
+
|| sent.reply && (typeof sent.reply.text !== "string" || sent.reply.inReplyTo !== id)
|
|
847
|
+
|| !sent.reply && !sent.failed && !["refused", "denied", "expired", "dropped"].includes(deliveryReceipts.get(id)?.status)) {
|
|
848
|
+
throw new Error("Invalid or unconfirmed Claude delivery reload record");
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
this.name = state.name;
|
|
852
|
+
this.messageSequence = state.messageSequence;
|
|
853
|
+
this.inbox = state.inbox;
|
|
854
|
+
this.sentMessages = sentMessages;
|
|
855
|
+
this.deliveryReceipts = deliveryReceipts;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
#removeRegistration() {
|
|
859
|
+
for (const file of [this.socketPath, this.registryPath, this.keyPath]) {
|
|
860
|
+
if (!file) continue;
|
|
861
|
+
try { fs.rmSync(file, { force: true }); } catch {}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
759
865
|
stop() {
|
|
760
866
|
globalThis.clearInterval(this.responsePoll);
|
|
761
867
|
this.responsePoll = null;
|
|
@@ -763,12 +869,9 @@ export class PeerEndpoint {
|
|
|
763
869
|
try {
|
|
764
870
|
this.server?.close();
|
|
765
871
|
} catch {}
|
|
766
|
-
for (const
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
fs.rmSync(file, { force: true });
|
|
770
|
-
} catch {}
|
|
771
|
-
}
|
|
872
|
+
for (const socket of this.connections) socket.destroy();
|
|
873
|
+
this.connections.clear();
|
|
874
|
+
this.#removeRegistration();
|
|
772
875
|
this.started = false;
|
|
773
876
|
}
|
|
774
877
|
}
|
package/src/platform.mjs
CHANGED
|
@@ -61,7 +61,10 @@ const FOREIGN_ROOT_PATTERNS = [
|
|
|
61
61
|
* directory layout out of the source.
|
|
62
62
|
*/
|
|
63
63
|
function bridgeParentDir() {
|
|
64
|
-
const
|
|
64
|
+
const sourceRoot = process.env.CODEX_BRIDGE_WORKER === "1" && process.env.CODEX_BRIDGE_SOURCE_ROOT;
|
|
65
|
+
const repoRoot = sourceRoot && path.isAbsolute(sourceRoot)
|
|
66
|
+
? sourceRoot
|
|
67
|
+
: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
65
68
|
return path.dirname(repoRoot);
|
|
66
69
|
}
|
|
67
70
|
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
function digestFiles(root, files) {
|
|
7
|
+
const hash = createHash("sha256");
|
|
8
|
+
for (const file of files) hash.update(file).update("\0").update(fs.readFileSync(path.join(root, file))).update("\0");
|
|
9
|
+
return hash.digest("hex");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DIGEST_FIELDS = ["dev", "ino", "size", "mtimeNs", "ctimeNs"];
|
|
13
|
+
const DIGEST_PATTERN = /^[a-f0-9]{64}$/;
|
|
14
|
+
|
|
15
|
+
function safeRelativePath(file) {
|
|
16
|
+
return typeof file === "string" && file.length > 0 && !file.includes("\0")
|
|
17
|
+
&& !path.posix.isAbsolute(file) && !path.win32.parse(file).root
|
|
18
|
+
&& path.normalize(file) === file && !file.split(/[\\/]/).some((part) => !part || part === "." || part === "..");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function digestMetadata(cache) {
|
|
22
|
+
const directories = new Map();
|
|
23
|
+
const dirty = new Set();
|
|
24
|
+
const metadataPath = (directory) => path.join(cache, `.digests-${createHash("sha256").update(directory).digest("hex")}.json`);
|
|
25
|
+
const signature = (stat) => Object.fromEntries(DIGEST_FIELDS.map((field) => [field, String(stat[field])]));
|
|
26
|
+
const matches = (left, right) => left && DIGEST_FIELDS.every((field) => left[field] === right[field]);
|
|
27
|
+
const load = (directory) => {
|
|
28
|
+
dirty.add(directory);
|
|
29
|
+
let descriptor;
|
|
30
|
+
try {
|
|
31
|
+
const file = metadataPath(directory);
|
|
32
|
+
const info = fs.lstatSync(file, { bigint: true });
|
|
33
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1n || info.size > 32n * 1024n * 1024n) return new Map();
|
|
34
|
+
descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
35
|
+
const opened = fs.fstatSync(descriptor, { bigint: true });
|
|
36
|
+
if (!opened.isFile() || opened.nlink !== 1n || !matches(signature(info), signature(opened))) return new Map();
|
|
37
|
+
const metadata = JSON.parse(fs.readFileSync(descriptor, "utf8"));
|
|
38
|
+
if (metadata?.schema !== 1 || metadata.directory !== directory || !metadata.files || typeof metadata.files !== "object" || Array.isArray(metadata.files)) return new Map();
|
|
39
|
+
const entries = Object.entries(metadata.files);
|
|
40
|
+
if (entries.some(([file, record]) => !safeRelativePath(file) || !record || typeof record !== "object" || Array.isArray(record)
|
|
41
|
+
|| typeof record.hash !== "string" || !DIGEST_PATTERN.test(record.hash)
|
|
42
|
+
|| DIGEST_FIELDS.some((field) => typeof record[field] !== "string" || !/^-?\d+$/.test(record[field])))) return new Map();
|
|
43
|
+
dirty.delete(directory);
|
|
44
|
+
return new Map(entries);
|
|
45
|
+
} catch {
|
|
46
|
+
return new Map();
|
|
47
|
+
} finally {
|
|
48
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
digest(directory, files) {
|
|
53
|
+
const previous = directories.get(directory) ?? load(directory);
|
|
54
|
+
const records = new Map();
|
|
55
|
+
const hash = createHash("sha256");
|
|
56
|
+
if (previous.size !== files.length) dirty.add(directory);
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
if (!safeRelativePath(file)) throw new Error(`Unsafe release file: ${file}`);
|
|
59
|
+
const fullPath = path.join(directory, file);
|
|
60
|
+
const before = fs.statSync(fullPath, { bigint: true });
|
|
61
|
+
if (!before.isFile()) throw new Error(`Unsupported release file: ${file}`);
|
|
62
|
+
const current = signature(before);
|
|
63
|
+
let record = previous.get(file);
|
|
64
|
+
if (!matches(record, current)) {
|
|
65
|
+
dirty.add(directory);
|
|
66
|
+
const contentHash = createHash("sha256").update(fs.readFileSync(fullPath)).digest("hex");
|
|
67
|
+
const after = fs.statSync(fullPath, { bigint: true });
|
|
68
|
+
if (!after.isFile() || !matches(current, signature(after))) throw new Error("A release file changed during integrity verification");
|
|
69
|
+
record = { ...current, hash: contentHash };
|
|
70
|
+
}
|
|
71
|
+
records.set(file, record);
|
|
72
|
+
hash.update(file).update("\0").update(record.hash).update("\0");
|
|
73
|
+
}
|
|
74
|
+
directories.set(directory, records);
|
|
75
|
+
return hash.digest("hex");
|
|
76
|
+
},
|
|
77
|
+
relocate(from, to) {
|
|
78
|
+
if (directories.has(from)) {
|
|
79
|
+
directories.set(to, directories.get(from));
|
|
80
|
+
dirty.add(to);
|
|
81
|
+
}
|
|
82
|
+
directories.delete(from);
|
|
83
|
+
dirty.delete(from);
|
|
84
|
+
},
|
|
85
|
+
forget(directory) { directories.delete(directory); dirty.delete(directory); },
|
|
86
|
+
flush() {
|
|
87
|
+
for (const [directory, records] of directories) {
|
|
88
|
+
if (!dirty.has(directory)) continue;
|
|
89
|
+
const file = metadataPath(directory);
|
|
90
|
+
const temporary = path.join(cache, `.digests-${process.pid}-${randomUUID()}.tmp`);
|
|
91
|
+
try {
|
|
92
|
+
try {
|
|
93
|
+
const info = fs.lstatSync(file);
|
|
94
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1) continue;
|
|
95
|
+
} catch (error) { if (error.code !== "ENOENT") continue; }
|
|
96
|
+
fs.writeFileSync(temporary, JSON.stringify({ schema: 1, directory, files: Object.fromEntries(records) }), { flag: "wx", mode: 0o600 });
|
|
97
|
+
fs.renameSync(temporary, file);
|
|
98
|
+
dirty.delete(directory);
|
|
99
|
+
} catch {
|
|
100
|
+
} finally {
|
|
101
|
+
try { fs.rmSync(temporary, { force: true }); } catch {}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function inventory(root, prefix = "") {
|
|
109
|
+
const files = [];
|
|
110
|
+
for (const entry of fs.readdirSync(path.join(root, prefix), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name, "en"))) {
|
|
111
|
+
if (entry.name === ".bin") continue;
|
|
112
|
+
const relative = path.join(prefix, entry.name);
|
|
113
|
+
const stat = entry.isSymbolicLink() ? fs.statSync(path.join(root, relative)) : entry;
|
|
114
|
+
if (stat.isDirectory()) files.push(...inventory(root, relative));
|
|
115
|
+
else if (stat.isFile()) files.push(relative);
|
|
116
|
+
else throw new Error(`Unsupported release file: ${relative}`);
|
|
117
|
+
}
|
|
118
|
+
return files;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function sourceRevision(root) {
|
|
122
|
+
const files = ["package.json", ...inventory(root, "src")];
|
|
123
|
+
for (const file of ["package-lock.json", "node_modules/.package-lock.json"]) {
|
|
124
|
+
if (fs.existsSync(path.join(root, file))) files.push(file);
|
|
125
|
+
}
|
|
126
|
+
return digestFiles(root, files);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function snapshotRoot(env = process.env) {
|
|
130
|
+
return path.resolve(env.CODEX_BRIDGE_RUNTIME_CACHE ?? path.join(env.CODEX_HOME ?? path.join(os.homedir(), ".codex"), "bridge-runtimes"));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function removeOwned(directory, cache) {
|
|
134
|
+
const relative = path.relative(cache, directory);
|
|
135
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("Refusing to remove an unowned runtime directory");
|
|
136
|
+
if (fs.existsSync(directory) && fs.lstatSync(directory).isSymbolicLink()) throw new Error("Refusing to remove a linked runtime directory");
|
|
137
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createReleaseSnapshot(root, { cache = snapshotRoot(), expectedRevision = sourceRevision(root) } = {}) {
|
|
141
|
+
root = fs.realpathSync.native(root);
|
|
142
|
+
if (fs.existsSync(cache) && fs.lstatSync(cache).isSymbolicLink()) throw new Error("The runtime cache cannot be a symbolic link");
|
|
143
|
+
const missing = [];
|
|
144
|
+
let ancestor = path.resolve(cache);
|
|
145
|
+
while (!fs.existsSync(ancestor)) {
|
|
146
|
+
missing.unshift(path.basename(ancestor));
|
|
147
|
+
const parent = path.dirname(ancestor);
|
|
148
|
+
if (parent === ancestor) throw new Error("The runtime cache has no accessible filesystem root");
|
|
149
|
+
ancestor = parent;
|
|
150
|
+
}
|
|
151
|
+
cache = path.join(fs.realpathSync.native(ancestor), ...missing);
|
|
152
|
+
fs.mkdirSync(cache, { recursive: true, mode: 0o700 });
|
|
153
|
+
if (fs.lstatSync(cache).isSymbolicLink()) throw new Error("The runtime cache cannot be a symbolic link");
|
|
154
|
+
cache = fs.realpathSync.native(cache);
|
|
155
|
+
const metadata = digestMetadata(cache);
|
|
156
|
+
const dependencies = inventory(root, "node_modules");
|
|
157
|
+
const dependencyRevision = metadata.digest(root, dependencies);
|
|
158
|
+
const key = createHash("sha256").update(expectedRevision).update(dependencyRevision).digest("hex");
|
|
159
|
+
const target = path.join(cache, key);
|
|
160
|
+
const validate = (directory) => {
|
|
161
|
+
if (fs.lstatSync(directory).isSymbolicLink()) throw new Error("The release directory cannot be a symbolic link");
|
|
162
|
+
return sourceRevision(directory) === expectedRevision && metadata.digest(directory, inventory(directory, "node_modules")) === dependencyRevision;
|
|
163
|
+
};
|
|
164
|
+
if (fs.existsSync(target)) {
|
|
165
|
+
if (!validate(target)) throw new Error("An existing immutable runtime failed integrity verification");
|
|
166
|
+
if (sourceRevision(root) !== expectedRevision) throw new Error("The installation changed during snapshot verification");
|
|
167
|
+
metadata.flush();
|
|
168
|
+
return { directory: target, revision: expectedRevision, key };
|
|
169
|
+
}
|
|
170
|
+
const temporary = path.join(cache, `.preparing-${process.pid}-${randomUUID()}`);
|
|
171
|
+
try {
|
|
172
|
+
fs.mkdirSync(temporary, { mode: 0o700 });
|
|
173
|
+
const files = ["package.json", ...inventory(root, "src"), ...dependencies];
|
|
174
|
+
if (fs.existsSync(path.join(root, "package-lock.json"))) files.push("package-lock.json");
|
|
175
|
+
const directories = new Set(["src", "node_modules", ...files.map((file) => path.dirname(file))]);
|
|
176
|
+
for (const directory of directories) fs.mkdirSync(path.join(temporary, directory), { recursive: true, mode: 0o700 });
|
|
177
|
+
for (const file of files) {
|
|
178
|
+
fs.copyFileSync(path.join(root, file), path.join(temporary, file), fs.constants.COPYFILE_FICLONE);
|
|
179
|
+
}
|
|
180
|
+
if (sourceRevision(root) !== expectedRevision || metadata.digest(root, inventory(root, "node_modules")) !== dependencyRevision || !validate(temporary)) {
|
|
181
|
+
throw new Error("The installation changed while its immutable runtime was being prepared");
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
fs.renameSync(temporary, target);
|
|
185
|
+
metadata.relocate(temporary, target);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (!fs.existsSync(target) || !validate(target)) throw error;
|
|
189
|
+
removeOwned(temporary, cache);
|
|
190
|
+
metadata.forget(temporary);
|
|
191
|
+
}
|
|
192
|
+
metadata.flush();
|
|
193
|
+
return { directory: target, revision: expectedRevision, key };
|
|
194
|
+
} catch (error) {
|
|
195
|
+
removeOwned(temporary, cache);
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export function assertRoutingReload(previous, next) {
|
|
2
|
+
if (typeof previous !== "boolean" || typeof next !== "boolean" || previous && !next) {
|
|
3
|
+
throw new Error("Invalid routing reload or Desktop-to-app-server downgrade; reconnect explicitly to change to legacy mode");
|
|
4
|
+
}
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function cloneReloadState(value) {
|
|
8
|
+
const encoded = JSON.stringify(value);
|
|
9
|
+
if (typeof encoded !== "string" || Buffer.byteLength(encoded) > 16 * 1024 * 1024) {
|
|
10
|
+
throw new Error("Reload state is missing or exceeds its transfer limit");
|
|
11
|
+
}
|
|
12
|
+
return JSON.parse(encoded);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function clientReloadReason(client) {
|
|
16
|
+
if (!client) return null;
|
|
17
|
+
for (const field of ["pending", "threadOperations", "attachingThreads", "activeTurns", "threadListeners"]) {
|
|
18
|
+
if (client[field]?.size) return `The app-server still has ${field}`;
|
|
19
|
+
}
|
|
20
|
+
if (client.connecting) return "The app-server connection is still being established";
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createReloadControl({ entry, inspect = () => null, quiesce = () => {}, exportState = () => ({}), restore = () => {}, activate = () => {}, resume = activate, env = process.env, channel = process } = {}) {
|
|
25
|
+
if (!["index.mjs", "claude-bridge.mjs", "native-relay-companion.mjs"].includes(entry)) throw new Error("Invalid reload entry point");
|
|
26
|
+
const enabled = env.CODEX_BRIDGE_WORKER === "1" && typeof channel.send === "function";
|
|
27
|
+
let phase = enabled && env.CODEX_BRIDGE_STAGED === "1" ? "staged" : "active";
|
|
28
|
+
let active = 0;
|
|
29
|
+
let deferred = null;
|
|
30
|
+
let listening = false;
|
|
31
|
+
let commands = Promise.resolve();
|
|
32
|
+
|
|
33
|
+
const status = () => {
|
|
34
|
+
const reason = active ? "MCP tool calls are still active" : deferred ?? inspect();
|
|
35
|
+
return { reloadable: phase === "active" && !reason, reason: reason ?? (phase === "active" ? null : `The worker is ${phase}`), schema: 1, phase };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const control = async (action, data) => {
|
|
39
|
+
if (action === "inspect") return status();
|
|
40
|
+
if (action === "quiesce") {
|
|
41
|
+
const current = status();
|
|
42
|
+
if (!current.reloadable) throw new Error(current.reason);
|
|
43
|
+
phase = "quiescing";
|
|
44
|
+
try {
|
|
45
|
+
await quiesce();
|
|
46
|
+
const reason = deferred ?? inspect();
|
|
47
|
+
if (active || reason) throw new Error(reason ?? "A tool call raced with quiescence");
|
|
48
|
+
const state = cloneReloadState({ schema: 1, entry, payload: exportState() });
|
|
49
|
+
phase = "quiesced";
|
|
50
|
+
return { state };
|
|
51
|
+
} catch (error) {
|
|
52
|
+
phase = "quiesced";
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (action === "restore") {
|
|
57
|
+
if (phase !== "staged") throw new Error("Reload state can only be restored into a staged worker");
|
|
58
|
+
const state = cloneReloadState(data);
|
|
59
|
+
if (!state || state.schema !== 1 || state.entry !== entry || !state.payload || typeof state.payload !== "object" || Array.isArray(state.payload)) {
|
|
60
|
+
throw new Error("Reload state schema or entry point does not match this worker");
|
|
61
|
+
}
|
|
62
|
+
await restore(state.payload);
|
|
63
|
+
return { restored: true, schema: 1 };
|
|
64
|
+
}
|
|
65
|
+
if (action === "activate" || action === "resume") {
|
|
66
|
+
if (phase === "active") return { active: true, schema: 1 };
|
|
67
|
+
if (action === "activate" && phase !== "staged" || action === "resume" && phase !== "quiesced") {
|
|
68
|
+
throw new Error(`Cannot ${action} a ${phase} worker`);
|
|
69
|
+
}
|
|
70
|
+
await (action === "activate" ? activate() : resume());
|
|
71
|
+
phase = "active";
|
|
72
|
+
return { active: true, schema: 1 };
|
|
73
|
+
}
|
|
74
|
+
throw new Error("Unknown bridge control action");
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
get staged() { return phase === "staged"; },
|
|
79
|
+
inspect: status,
|
|
80
|
+
defer(reason) { deferred ??= String(reason); },
|
|
81
|
+
async run(handler) {
|
|
82
|
+
if (phase !== "active") throw new Error(`Bridge worker is ${phase}; no tool operation was started`);
|
|
83
|
+
active += 1;
|
|
84
|
+
try { return await handler(); }
|
|
85
|
+
finally { active -= 1; }
|
|
86
|
+
},
|
|
87
|
+
control,
|
|
88
|
+
listen() {
|
|
89
|
+
if (!enabled || listening) return;
|
|
90
|
+
listening = true;
|
|
91
|
+
channel.on("message", (message) => {
|
|
92
|
+
if (message?.type !== "bridge:control" || !["string", "number"].includes(typeof message.id)) return;
|
|
93
|
+
commands = commands.catch(() => {}).then(async () => {
|
|
94
|
+
let response;
|
|
95
|
+
try { response = { result: await control(message.action, message.data) }; }
|
|
96
|
+
catch (error) { response = { error: error?.message ?? String(error) }; }
|
|
97
|
+
if (channel.connected !== false) channel.send({ type: "bridge:control-result", id: message.id, ...response });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
package/src/reply-forwarder.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { cloneReloadState } from "./reload-control.mjs";
|
|
2
|
+
|
|
1
3
|
const KNOWN_UNSENT_CODES = new Set([
|
|
2
4
|
"RELAY_UNREACHABLE",
|
|
3
5
|
"RELAY_MESSAGE_TOO_LARGE",
|
|
@@ -97,6 +99,47 @@ export class ReplyForwarder {
|
|
|
97
99
|
return { ...counts, attempts: this.attempts, maxPerSession: this.maxPerSession, closed: this.closed };
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
reloadReason() {
|
|
103
|
+
if (this.active || this.queue.length || this.timer !== null) return "Reply forwarding is still active or queued";
|
|
104
|
+
if ([...this.records.values()].some(({ receipt }) => ["queued", "sending", "unknown"].includes(receipt.status))) {
|
|
105
|
+
return "A reply forwarding outcome remains unconfirmed";
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
exportReloadState() {
|
|
111
|
+
const reason = this.reloadReason();
|
|
112
|
+
if (reason) throw new Error(reason);
|
|
113
|
+
return cloneReloadState({ records: [...this.records], attempts: this.attempts, lastAt: this.lastAt, closed: this.closed,
|
|
114
|
+
maxPerSession: this.maxPerSession, minIntervalMs: this.minIntervalMs });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
restoreReloadState(input) {
|
|
118
|
+
if (this.records.size || this.active || this.queue.length || this.timer !== null) throw new Error("Reply forwarding state is not empty");
|
|
119
|
+
const state = cloneReloadState(input);
|
|
120
|
+
if (!state || !Array.isArray(state.records) || !Number.isSafeInteger(state.attempts) || state.attempts < 0
|
|
121
|
+
|| !Number.isSafeInteger(state.maxPerSession) || state.maxPerSession < 1 || state.attempts > state.maxPerSession
|
|
122
|
+
|| !Number.isFinite(state.minIntervalMs) || state.minIntervalMs < 0 || typeof state.closed !== "boolean"
|
|
123
|
+
|| state.lastAt !== null && (!Number.isFinite(state.lastAt) || state.lastAt < 0)) throw new Error("Invalid reply forwarding reload state");
|
|
124
|
+
const records = new Map();
|
|
125
|
+
for (const pair of state.records) {
|
|
126
|
+
if (!Array.isArray(pair) || pair.length !== 2) throw new Error("Invalid reply forwarding record");
|
|
127
|
+
const [id, entry] = pair;
|
|
128
|
+
if (!validIdentity(id) || records.has(id) || !entry?.record || !entry.receipt
|
|
129
|
+
|| entry.receipt.msgId !== id || !validIdentity(entry.receipt.threadId)
|
|
130
|
+
|| entry.record.replyThreadId !== entry.receipt.threadId || (entry.record.inReplyTo ?? entry.record.msgId) !== id
|
|
131
|
+
|| typeof entry.record.text !== "string" || !entry.record.text.trim()
|
|
132
|
+
|| !["forwarded", "failed", "blocked"].includes(entry.receipt.status)) throw new Error("Invalid or unconfirmed reply forwarding record");
|
|
133
|
+
records.set(id, { record: Object.freeze(entry.record), receipt: entry.receipt });
|
|
134
|
+
}
|
|
135
|
+
this.records = records;
|
|
136
|
+
this.attempts = state.attempts;
|
|
137
|
+
this.lastAt = state.lastAt;
|
|
138
|
+
this.closed = state.closed;
|
|
139
|
+
this.maxPerSession = Math.min(this.maxPerSession, state.maxPerSession);
|
|
140
|
+
this.minIntervalMs = Math.max(this.minIntervalMs, state.minIntervalMs);
|
|
141
|
+
}
|
|
142
|
+
|
|
100
143
|
close() {
|
|
101
144
|
this.closed = true;
|
|
102
145
|
if (this.timer !== null) this.cancel(this.timer);
|