@minhspark/codex-mcp-bridge 1.13.9 → 1.15.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.
@@ -0,0 +1,95 @@
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
+ function inventory(root, prefix = "") {
13
+ const files = [];
14
+ for (const entry of fs.readdirSync(path.join(root, prefix), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name, "en"))) {
15
+ if (entry.name === ".bin") continue;
16
+ const relative = path.join(prefix, entry.name);
17
+ const stat = entry.isSymbolicLink() ? fs.statSync(path.join(root, relative)) : entry;
18
+ if (stat.isDirectory()) files.push(...inventory(root, relative));
19
+ else if (stat.isFile()) files.push(relative);
20
+ else throw new Error(`Unsupported release file: ${relative}`);
21
+ }
22
+ return files;
23
+ }
24
+
25
+ export function sourceRevision(root) {
26
+ const files = ["package.json", ...inventory(root, "src")];
27
+ for (const file of ["package-lock.json", "node_modules/.package-lock.json"]) {
28
+ if (fs.existsSync(path.join(root, file))) files.push(file);
29
+ }
30
+ return digestFiles(root, files);
31
+ }
32
+
33
+ export function snapshotRoot(env = process.env) {
34
+ return path.resolve(env.CODEX_BRIDGE_RUNTIME_CACHE ?? path.join(env.CODEX_HOME ?? path.join(os.homedir(), ".codex"), "bridge-runtimes"));
35
+ }
36
+
37
+ function removeOwned(directory, cache) {
38
+ const relative = path.relative(cache, directory);
39
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("Refusing to remove an unowned runtime directory");
40
+ if (fs.existsSync(directory) && fs.lstatSync(directory).isSymbolicLink()) throw new Error("Refusing to remove a linked runtime directory");
41
+ fs.rmSync(directory, { recursive: true, force: true });
42
+ }
43
+
44
+ export function createReleaseSnapshot(root, { cache = snapshotRoot(), expectedRevision = sourceRevision(root) } = {}) {
45
+ root = fs.realpathSync.native(root);
46
+ if (fs.existsSync(cache) && fs.lstatSync(cache).isSymbolicLink()) throw new Error("The runtime cache cannot be a symbolic link");
47
+ const missing = [];
48
+ let ancestor = path.resolve(cache);
49
+ while (!fs.existsSync(ancestor)) {
50
+ missing.unshift(path.basename(ancestor));
51
+ const parent = path.dirname(ancestor);
52
+ if (parent === ancestor) throw new Error("The runtime cache has no accessible filesystem root");
53
+ ancestor = parent;
54
+ }
55
+ cache = path.join(fs.realpathSync.native(ancestor), ...missing);
56
+ fs.mkdirSync(cache, { recursive: true, mode: 0o700 });
57
+ if (fs.lstatSync(cache).isSymbolicLink()) throw new Error("The runtime cache cannot be a symbolic link");
58
+ cache = fs.realpathSync.native(cache);
59
+ const dependencies = inventory(root, "node_modules");
60
+ const dependencyRevision = digestFiles(root, dependencies);
61
+ const key = createHash("sha256").update(expectedRevision).update(dependencyRevision).digest("hex");
62
+ const target = path.join(cache, key);
63
+ const validate = (directory) => {
64
+ if (fs.lstatSync(directory).isSymbolicLink()) throw new Error("The release directory cannot be a symbolic link");
65
+ return sourceRevision(directory) === expectedRevision && digestFiles(directory, inventory(directory, "node_modules")) === dependencyRevision;
66
+ };
67
+ if (fs.existsSync(target)) {
68
+ if (!validate(target)) throw new Error("An existing immutable runtime failed integrity verification");
69
+ if (sourceRevision(root) !== expectedRevision) throw new Error("The installation changed during snapshot verification");
70
+ return { directory: target, revision: expectedRevision, key };
71
+ }
72
+ const temporary = path.join(cache, `.preparing-${process.pid}-${randomUUID()}`);
73
+ try {
74
+ fs.mkdirSync(temporary, { mode: 0o700 });
75
+ const files = ["package.json", ...inventory(root, "src"), ...dependencies];
76
+ if (fs.existsSync(path.join(root, "package-lock.json"))) files.push("package-lock.json");
77
+ const directories = new Set(["src", "node_modules", ...files.map((file) => path.dirname(file))]);
78
+ for (const directory of directories) fs.mkdirSync(path.join(temporary, directory), { recursive: true, mode: 0o700 });
79
+ for (const file of files) {
80
+ fs.copyFileSync(path.join(root, file), path.join(temporary, file), fs.constants.COPYFILE_FICLONE);
81
+ }
82
+ if (sourceRevision(root) !== expectedRevision || digestFiles(root, inventory(root, "node_modules")) !== dependencyRevision || !validate(temporary)) {
83
+ throw new Error("The installation changed while its immutable runtime was being prepared");
84
+ }
85
+ try { fs.renameSync(temporary, target); }
86
+ catch (error) {
87
+ if (!fs.existsSync(target) || !validate(target)) throw error;
88
+ removeOwned(temporary, cache);
89
+ }
90
+ return { directory: target, revision: expectedRevision, key };
91
+ } catch (error) {
92
+ removeOwned(temporary, cache);
93
+ throw error;
94
+ }
95
+ }
@@ -0,0 +1,96 @@
1
+ export function cloneReloadState(value) {
2
+ const encoded = JSON.stringify(value);
3
+ if (typeof encoded !== "string" || Buffer.byteLength(encoded) > 16 * 1024 * 1024) {
4
+ throw new Error("Reload state is missing or exceeds its transfer limit");
5
+ }
6
+ return JSON.parse(encoded);
7
+ }
8
+
9
+ export function clientReloadReason(client) {
10
+ if (!client) return null;
11
+ for (const field of ["pending", "threadOperations", "attachingThreads", "activeTurns", "threadListeners"]) {
12
+ if (client[field]?.size) return `The app-server still has ${field}`;
13
+ }
14
+ if (client.connecting) return "The app-server connection is still being established";
15
+ return null;
16
+ }
17
+
18
+ export function createReloadControl({ entry, inspect = () => null, quiesce = () => {}, exportState = () => ({}), restore = () => {}, activate = () => {}, resume = activate, env = process.env, channel = process } = {}) {
19
+ if (!["index.mjs", "claude-bridge.mjs", "native-relay-companion.mjs"].includes(entry)) throw new Error("Invalid reload entry point");
20
+ const enabled = env.CODEX_BRIDGE_WORKER === "1" && typeof channel.send === "function";
21
+ let phase = enabled && env.CODEX_BRIDGE_STAGED === "1" ? "staged" : "active";
22
+ let active = 0;
23
+ let deferred = null;
24
+ let listening = false;
25
+ let commands = Promise.resolve();
26
+
27
+ const status = () => {
28
+ const reason = active ? "MCP tool calls are still active" : deferred ?? inspect();
29
+ return { reloadable: phase === "active" && !reason, reason: reason ?? (phase === "active" ? null : `The worker is ${phase}`), schema: 1, phase };
30
+ };
31
+
32
+ const control = async (action, data) => {
33
+ if (action === "inspect") return status();
34
+ if (action === "quiesce") {
35
+ const current = status();
36
+ if (!current.reloadable) throw new Error(current.reason);
37
+ phase = "quiescing";
38
+ try {
39
+ await quiesce();
40
+ const reason = deferred ?? inspect();
41
+ if (active || reason) throw new Error(reason ?? "A tool call raced with quiescence");
42
+ const state = cloneReloadState({ schema: 1, entry, payload: exportState() });
43
+ phase = "quiesced";
44
+ return { state };
45
+ } catch (error) {
46
+ phase = "quiesced";
47
+ throw error;
48
+ }
49
+ }
50
+ if (action === "restore") {
51
+ if (phase !== "staged") throw new Error("Reload state can only be restored into a staged worker");
52
+ const state = cloneReloadState(data);
53
+ if (!state || state.schema !== 1 || state.entry !== entry || !state.payload || typeof state.payload !== "object" || Array.isArray(state.payload)) {
54
+ throw new Error("Reload state schema or entry point does not match this worker");
55
+ }
56
+ await restore(state.payload);
57
+ return { restored: true, schema: 1 };
58
+ }
59
+ if (action === "activate" || action === "resume") {
60
+ if (phase === "active") return { active: true, schema: 1 };
61
+ if (action === "activate" && phase !== "staged" || action === "resume" && phase !== "quiesced") {
62
+ throw new Error(`Cannot ${action} a ${phase} worker`);
63
+ }
64
+ await (action === "activate" ? activate() : resume());
65
+ phase = "active";
66
+ return { active: true, schema: 1 };
67
+ }
68
+ throw new Error("Unknown bridge control action");
69
+ };
70
+
71
+ return {
72
+ get staged() { return phase === "staged"; },
73
+ inspect: status,
74
+ defer(reason) { deferred ??= String(reason); },
75
+ async run(handler) {
76
+ if (phase !== "active") throw new Error(`Bridge worker is ${phase}; no tool operation was started`);
77
+ active += 1;
78
+ try { return await handler(); }
79
+ finally { active -= 1; }
80
+ },
81
+ control,
82
+ listen() {
83
+ if (!enabled || listening) return;
84
+ listening = true;
85
+ channel.on("message", (message) => {
86
+ if (message?.type !== "bridge:control" || !["string", "number"].includes(typeof message.id)) return;
87
+ commands = commands.catch(() => {}).then(async () => {
88
+ let response;
89
+ try { response = { result: await control(message.action, message.data) }; }
90
+ catch (error) { response = { error: error?.message ?? String(error) }; }
91
+ if (channel.connected !== false) channel.send({ type: "bridge:control-result", id: message.id, ...response });
92
+ });
93
+ });
94
+ },
95
+ };
96
+ }
@@ -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);
@@ -1,4 +1,4 @@
1
- import { NativeDesktopRelay, desktopTaskSocketPath, desktopTasksConfigured } from "./native-relay.mjs";
1
+ import { NativeDesktopRelay, accountRelaySocketPath, desktopTaskSocketPath, desktopTasksConfigured } from "./native-relay.mjs";
2
2
  import { runTurn } from "./turn.mjs";
3
3
  import { realpathSync } from "node:fs";
4
4
  import path from "node:path";
@@ -42,20 +42,28 @@ export function matchDesktopProject(projects, cwd, { canonicalize = realpathSync
42
42
  }
43
43
 
44
44
  export class DesktopTaskDelivery {
45
- constructor({ relay = new NativeDesktopRelay({ socketPath: desktopTaskSocketPath() }), security, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now, receipts = new DesktopTaskReceipts() } = {}) {
45
+ constructor({ relay = new NativeDesktopRelay({ socketPath: desktopTaskSocketPath(), accountSocketPath: accountRelaySocketPath() }), security, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now, receipts = new DesktopTaskReceipts(), beforeRequest, accountContext } = {}) {
46
46
  this.relay = relay;
47
47
  this.security = security;
48
48
  this.sleep = sleep;
49
49
  this.now = now;
50
50
  this.receipts = receipts;
51
+ this.beforeRequest = beforeRequest;
52
+ this.accountContext = accountContext;
51
53
  this.threadOperations = new Map();
52
54
  }
53
55
 
54
56
  async request(operation, args, { deadline } = {}) {
55
57
  try {
58
+ await this.beforeRequest?.({ operation, args });
56
59
  const remaining = deadline === undefined ? undefined : deadline - this.now();
57
60
  if (remaining !== undefined && remaining <= 0) throw new Error("The bridge's response deadline has elapsed; this operation was not sent");
58
- const response = await this.relay.requestDesktop(operation, args, remaining === undefined ? undefined : { timeoutMs: remaining });
61
+ const accountContext = this.accountContext?.();
62
+ const response = await this.relay.requestDesktop(operation, args, {
63
+ ...(remaining === undefined ? {} : { timeoutMs: remaining }),
64
+ ...(this.beforeRequest ? { beforeSend: () => this.beforeRequest({ operation, args, phase: "write" }) } : {}),
65
+ ...(accountContext ? { accountContext } : {}),
66
+ });
59
67
  return response.result;
60
68
  } catch (err) {
61
69
  throw new Error(`Codex Desktop operation ${operation} failed: ${err.message}. Desktop-only mode will not start or use an external app-server. Open Codex Desktop and reconnect its native relay, then inspect the existing task before retrying a send.`, { cause: err });
@@ -87,12 +95,15 @@ export class DesktopTaskDelivery {
87
95
  }
88
96
 
89
97
  async status() {
98
+ const accountContext = this.accountContext?.();
99
+ const socketPath = this.relay.status?.({ accountContext })?.socketPath ?? this.relay.socketPath;
100
+ if (this.accountContext && !accountContext) return { available: false, socketPath, reason: "The current Claude and Codex accounts could not both be verified. Finish signing in and inspect the account diagnostics before sending work." };
90
101
  try {
91
102
  const response = await this.request("list_projects", {});
92
103
  if (!Array.isArray(response?.projects)) throw new Error("Desktop returned no project list");
93
- return { available: true, socketPath: this.relay.socketPath, localProjects: response.projects.filter((project) => project.projectKind === "local" && project.hostId === "local").length };
104
+ return { available: true, socketPath, localProjects: response.projects.filter((project) => project.projectKind === "local" && project.hostId === "local").length };
94
105
  } catch (err) {
95
- return { available: false, socketPath: this.relay.socketPath, reason: err.message };
106
+ return { available: false, socketPath, reason: err.message };
96
107
  }
97
108
  }
98
109
 
@@ -118,8 +129,11 @@ export class DesktopTaskDelivery {
118
129
  return { rows, coverage: "Codex Desktop's recent/pinned snapshot; local Codex workspaces only. Agent-created tasks visible in the sidebar can be omitted; read a known task ID directly before assuming creation failed." };
119
130
  }
120
131
 
121
- async reuseReceipt(receipt, { cwd, promptHash, deadline }) {
132
+ async reuseReceipt(receipt, { cwd, promptHash, deadline, accountContext }) {
122
133
  if (!receipt) return null;
134
+ if (accountContext && (!receipt.accountContext || ["claude", "codex"].some((provider) => receipt.accountContext[provider] !== accountContext[provider]))) {
135
+ throw new Error(`The existing Desktop creation receipt ${receipt.threadId ? `for task ${receipt.threadId} ` : ""}${receipt.accountContext ? "belongs to different accounts" : "has no verified original account binding"}. Its receipt and task ID were retained. No creation or prompt resend was attempted; inspect the explicit existing task before continuing.`);
136
+ }
123
137
  if (path.relative(cwd, realpathSync.native(receipt.cwd))) throw new Error("The stored Desktop creation receipt belongs to another workspace. No prompt was sent.");
124
138
  if (receipt.state !== "known") throw new Error(`An earlier Desktop creation is ${receipt.state} and may already have started. ${receipt.threadId ? `threadId: ${receipt.threadId}. ` : ""}Do not resend or create another task; inspect the existing Desktop task and resolve its creation receipt first.`);
125
139
  let response;
@@ -180,7 +194,9 @@ export class DesktopTaskDelivery {
180
194
  this.security.assertCwd(cwd);
181
195
  dedupeName = dedupeName?.normalize("NFC").trim().replace(/\s+/g, " ") || undefined;
182
196
  const identity = this.receipts.key({ cwd, prompt, name: dedupeName });
183
- const options = { cwd, promptHash: identity.promptHash, deadline };
197
+ const accountContext = this.accountContext?.();
198
+ if (this.accountContext && !accountContext) throw new Error("Desktop creation requires the original verified account context.");
199
+ const options = { cwd, promptHash: identity.promptHash, deadline, accountContext };
184
200
  const reused = await this.reuseReceipt(await this.receipts.read(identity.key), options);
185
201
  if (reused) return reused;
186
202
  const listed = await this.request("list_projects", {}, { deadline });
@@ -190,7 +206,7 @@ export class DesktopTaskDelivery {
190
206
  const existing = await this.reuseReceipt(await this.receipts.read(identity.key), options);
191
207
  if (existing) return existing;
192
208
  if (this.now() >= deadline) throw new Error("The response deadline elapsed before Desktop creation. No task was created.");
193
- const receipt = { version: 1, ...identity, cwd, state: "pending", startedAt: this.now(), ...(dedupeName ? { name: dedupeName } : {}), projectId: project.projectId, ...(project.label ? { projectName: project.label } : {}) };
209
+ const receipt = { version: 1, ...identity, cwd, state: "pending", startedAt: this.now(), ...(accountContext ? { accountContext: { ...accountContext } } : {}), ...(dedupeName ? { name: dedupeName } : {}), projectId: project.projectId, ...(project.label ? { projectName: project.label } : {}) };
194
210
  await this.receipts.write(identity.key, receipt);
195
211
  let threadId;
196
212
  let creationConfirmed = false;
@@ -309,11 +325,11 @@ export function createThreadDelivery({
309
325
  * app-server only spawns a process that contends for the ~/.codex state and
310
326
  * then fails on the writer lock the native path exists to avoid.
311
327
  */
312
- async function deliver(threadId, text) {
313
- const status = relay.status();
328
+ async function deliver(threadId, text, options = {}) {
329
+ const status = relay.status(options);
314
330
  if (status.enabled) {
315
331
  try {
316
- const ack = await relay.sendMessage(threadId, text);
332
+ const ack = await relay.sendMessage(threadId, text, options);
317
333
  reportedUnavailable = null;
318
334
  return { backend: NATIVE_BACKEND, threadId, ack };
319
335
  } catch (err) {