@minhspark/codex-mcp-bridge 1.13.8 → 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,92 @@
1
+ import { assertClaudeSessionCwd } from "./peer-protocol.mjs";
2
+ import { preflightFailure } from "./recipient-preflight.mjs";
3
+
4
+ function assertCwd(session, expectedCwd) {
5
+ try {
6
+ assertClaudeSessionCwd(session, expectedCwd);
7
+ } catch (error) {
8
+ throw preflightFailure("CLAUDE_SESSION_CWD_MISMATCH", error.message.replace(/ No message was sent\.$/, ""));
9
+ }
10
+ }
11
+
12
+ function taskMatches(session, expectedTaskId) {
13
+ return expectedTaskId === undefined || session.desktop.taskId === expectedTaskId;
14
+ }
15
+
16
+ function uniqueLiveSessions(sessions) {
17
+ const unique = new Map();
18
+ for (const session of sessions) {
19
+ if (!session?.alive) continue;
20
+ const identity = JSON.stringify([session.pid, session.sessionId, session.socket, session.processStart, session.cwd, session.entrypoint, session.name, session.bridgeSessionId]);
21
+ if (!unique.has(identity)) unique.set(identity, session);
22
+ }
23
+ return [...unique.values()];
24
+ }
25
+
26
+ export function resolveClaudeDesktopSession({ target, expectedCwd, expectedTaskId, sessions, account, readContext }) {
27
+ if (account?.status !== "verified") {
28
+ throw preflightFailure("CLAUDE_ACCOUNT_UNVERIFIED", account?.reason ?? "The current Claude Desktop account is not confirmed.");
29
+ }
30
+ assertCwd({ cwd: expectedCwd }, expectedCwd);
31
+ const needle = typeof target === "string" || typeof target === "number" ? String(target).trim() : "";
32
+ const automatic = needle === "auto";
33
+ if (!needle) throw preflightFailure("CLAUDE_SESSION_NOT_FOUND", "Provide an exact Claude Desktop session identity or target auto.");
34
+ const assertExpectedTaskId = () => {
35
+ if ((!automatic && (typeof expectedTaskId !== "string" || !expectedTaskId.trim())) ||
36
+ (expectedTaskId !== undefined && (typeof expectedTaskId !== "string" || !expectedTaskId.trim()))) {
37
+ throw preflightFailure("CLAUDE_DESKTOP_TASK_MISMATCH", "Provide expectedTaskId from the intended native Desktop task after checking its title and cwd.");
38
+ }
39
+ };
40
+ const live = uniqueLiveSessions(sessions ?? []);
41
+ const contextualize = (session) => {
42
+ let desktop;
43
+ try {
44
+ desktop = readContext(session, account);
45
+ } catch {
46
+ throw preflightFailure("CLAUDE_DESKTOP_TASK_UNVERIFIED", "The live session's current-account Desktop task could not be inspected consistently.");
47
+ }
48
+ return { ...session, desktop };
49
+ };
50
+ if (!automatic) {
51
+ const byId = live.filter((session) => String(session.pid) === needle || session.sessionId === needle);
52
+ const matches = byId.length ? byId : live.filter((session) => session.name === needle);
53
+ if (matches.length > 1) {
54
+ throw preflightFailure("CLAUDE_SESSION_AMBIGUOUS", "The exact target identifies multiple live sessions; use an unambiguous sessionId or pid.");
55
+ }
56
+ if (matches.length === 1) {
57
+ const found = matches[0];
58
+ if (found.entrypoint !== "claude-desktop") {
59
+ throw preflightFailure("CLAUDE_DESKTOP_TASK_UNVERIFIED", "The exact target is not an existing Claude Desktop Code session.");
60
+ }
61
+ assertCwd(found, expectedCwd);
62
+ assertExpectedTaskId();
63
+ const session = contextualize(found);
64
+ if (session.desktop?.status !== "matched") {
65
+ throw preflightFailure("CLAUDE_DESKTOP_TASK_UNVERIFIED", session.desktop?.reason ?? "The exact live target does not belong to a verified task in the current Claude Desktop account.");
66
+ }
67
+ if (!taskMatches(session, expectedTaskId)) {
68
+ throw preflightFailure("CLAUDE_DESKTOP_TASK_MISMATCH", "The exact live target belongs to a different native Claude Desktop task than expectedTaskId.");
69
+ }
70
+ return session;
71
+ }
72
+ }
73
+ assertExpectedTaskId();
74
+ const candidates = [];
75
+ for (const candidate of live) {
76
+ if (candidate.entrypoint !== "claude-desktop") continue;
77
+ try {
78
+ assertClaudeSessionCwd(candidate, expectedCwd);
79
+ } catch {
80
+ continue;
81
+ }
82
+ const session = contextualize(candidate);
83
+ if (session.desktop?.status === "matched" && taskMatches(session, expectedTaskId)) candidates.push(session);
84
+ }
85
+ if (candidates.length > 1) {
86
+ throw preflightFailure("CLAUDE_SESSION_AMBIGUOUS", "Multiple live Claude Desktop sessions match the current account and intended project/task; inspect the intended task and provide its exact identity.");
87
+ }
88
+ if (!candidates.length) {
89
+ throw preflightFailure("CLAUDE_SESSION_NOT_FOUND", "No live Claude Desktop session matches the current account and intended project/task. Open or reconnect the existing task in Claude Desktop.");
90
+ }
91
+ return candidates[0];
92
+ }
@@ -0,0 +1,116 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { createHash } from "node:crypto";
5
+
6
+ const MAX_AUTH_BYTES = 1024 * 1024;
7
+ const MAX_CONFIG_BYTES = 2 * 1024 * 1024;
8
+ const MAX_TOKEN_BYTES = 128 * 1024;
9
+
10
+ function object(value) {
11
+ return value !== null && typeof value === "object" && !Array.isArray(value);
12
+ }
13
+
14
+ function identity(value) {
15
+ return typeof value === "string" && value.length > 0 && value.length <= 512 && value.trim() === value && !/[\u0000-\u001f\u007f]/.test(value) ? value : null;
16
+ }
17
+
18
+ function readStable(file, limit) {
19
+ const entry = fs.lstatSync(file);
20
+ if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("Codex account evidence must be a regular file");
21
+ const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
22
+ try {
23
+ const before = fs.fstatSync(descriptor);
24
+ if (!before.isFile() || before.size > limit || entry.ino !== before.ino || entry.dev !== before.dev) throw new Error("Codex account evidence is invalid or exceeds its read limit");
25
+ const data = Buffer.alloc(before.size);
26
+ let offset = 0;
27
+ while (offset < data.length) {
28
+ const count = fs.readSync(descriptor, data, offset, data.length - offset, offset);
29
+ if (!count) throw new Error("Codex account evidence changed while reading; retry on the next call");
30
+ offset += count;
31
+ }
32
+ const after = fs.fstatSync(descriptor);
33
+ const current = fs.lstatSync(file);
34
+ if (!current.isFile() || current.isSymbolicLink() || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || before.ino !== current.ino || before.dev !== current.dev || after.size !== current.size || after.mtimeMs !== current.mtimeMs || after.ctimeMs !== current.ctimeMs) throw new Error("Codex account evidence changed while reading; retry on the next call");
35
+ return data.toString("utf8");
36
+ } finally {
37
+ fs.closeSync(descriptor);
38
+ }
39
+ }
40
+
41
+ function configAllowsFile(file) {
42
+ let text;
43
+ try {
44
+ text = readStable(file, MAX_CONFIG_BYTES);
45
+ } catch (error) {
46
+ if (error.code === "ENOENT") return;
47
+ throw new Error("Codex credential storage configuration could not be verified");
48
+ }
49
+ for (const line of text.split(/\r?\n/)) {
50
+ let quote = null;
51
+ let end = line.length;
52
+ for (let index = 0; index < line.length; index++) {
53
+ if (quote) {
54
+ if (quote === '"' && line[index] === "\\") index++;
55
+ else if (line[index] === quote) quote = null;
56
+ } else if (line[index] === '"' || line[index] === "'") quote = line[index];
57
+ else if (line[index] === "#") { end = index; break; }
58
+ }
59
+ const content = line.slice(0, end);
60
+ if (!/\bcli_auth_credentials_store\b/.test(content)) continue;
61
+ const assignments = [...content.matchAll(/(?:^|[\s{,])(?:cli_auth_credentials_store|"cli_auth_credentials_store"|'cli_auth_credentials_store')\s*=\s*(?:"([^"\\]*)"|'([^']*)')(?=\s*(?:[,}]|$))/g)];
62
+ if (!assignments.length || assignments.some((match) => (match[1] ?? match[2]) !== "file")) throw new Error("Codex account detection requires verified file credential storage; keyring, auto, ephemeral, or ambiguous storage is unavailable");
63
+ }
64
+ }
65
+
66
+ function tokenIdentity(token) {
67
+ if (typeof token !== "string" || token.length > MAX_TOKEN_BYTES) throw new Error("Codex account evidence has no usable user identity token");
68
+ const parts = token.split(".");
69
+ if (parts.length !== 3 || parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))) throw new Error("Codex account user identity is malformed; retry on the next call");
70
+ let claims;
71
+ try {
72
+ claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
73
+ } catch {
74
+ throw new Error("Codex account user identity is malformed; retry on the next call");
75
+ }
76
+ if (!object(claims)) throw new Error("Codex account user identity is unavailable");
77
+ const auth = object(claims["https://api.openai.com/auth"]) ? claims["https://api.openai.com/auth"] : {};
78
+ return { userId: identity(auth.chatgpt_user_id) ?? identity(claims.sub), accountId: identity(auth.chatgpt_account_id) };
79
+ }
80
+
81
+ export function readCodexAccountContext({ env = process.env, root } = {}) {
82
+ let source = null;
83
+ const result = (status, reason, values = {}) => ({ status, provider: "codex", accountId: null, userId: null, fingerprint: null, source, reason, ...values });
84
+ try {
85
+ const home = root ?? env.CODEX_HOME ?? path.join(env.HOME || env.USERPROFILE || os.homedir(), ".codex");
86
+ if (typeof home !== "string" || !path.isAbsolute(home)) return result("unavailable", "The configured Codex home must be absolute");
87
+ source = path.join(home, "auth.json");
88
+ configAllowsFile(path.join(home, "config.toml"));
89
+ let text;
90
+ try {
91
+ text = readStable(source, MAX_AUTH_BYTES);
92
+ } catch (error) {
93
+ if (error.code === "ENOENT") return result("signed_out", "Codex has no stored login identity; retry after sign-in completes");
94
+ throw error;
95
+ }
96
+ let auth;
97
+ try {
98
+ auth = JSON.parse(text);
99
+ } catch {
100
+ return result("unavailable", "Codex account evidence is incomplete or malformed; retry on the next call");
101
+ }
102
+ if (!object(auth)) return result("unavailable", "Codex account evidence is not an object");
103
+ if (auth.auth_mode !== "chatgpt") return result("unavailable", "Codex account detection requires a stored ChatGPT login; external tokens and other authentication modes are unavailable");
104
+ const accountId = identity(auth.tokens?.account_id);
105
+ if (!accountId) return result("unavailable", "Codex account evidence has no stable account ID; retry after sign-in completes");
106
+ const decoded = tokenIdentity(auth.tokens?.id_token);
107
+ if (!decoded.userId) return result("unavailable", "Codex account evidence has no stable user ID; no identity was inferred");
108
+ if (decoded.accountId && decoded.accountId !== accountId) return result("unavailable", "Codex account and user identity evidence disagree; retry on the next call");
109
+ configAllowsFile(path.join(home, "config.toml"));
110
+ if (readStable(source, MAX_AUTH_BYTES) !== text) return result("unavailable", "Codex account evidence changed during identity verification; retry on the next call");
111
+ const fingerprint = createHash("sha256").update(JSON.stringify(["codex", accountId, decoded.userId])).digest("hex");
112
+ return result("verified", "Stored Codex ChatGPT account and user identity were read consistently", { accountId, userId: decoded.userId, fingerprint });
113
+ } catch (error) {
114
+ return result("unavailable", error.code ? "Codex account evidence could not be read; retry on the next call" : error.message);
115
+ }
116
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { runSupervisor } from "./mcp-supervisor.mjs";
3
+ await runSupervisor("index.mjs");
@@ -1,13 +1,23 @@
1
- export function codexMcpRegistration({ name, existing, node, entry, env, desktopOnly }) {
1
+ export function stdioMcpRegistration({ name, existing, node, entry, entryArgs = [], envOverrides = {} }) {
2
2
  if (existing) {
3
3
  const customized = ["enabled_tools", "disabled_tools", "startup_timeout_sec", "tool_timeout_sec", "disabled_reason"]
4
4
  .some((key) => existing[key] != null && (!Array.isArray(existing[key]) || existing[key].length > 0));
5
5
  if (existing.enabled === false || customized || existing.transport?.type !== "stdio" || existing.transport?.cwd || existing.transport?.env_vars?.length) {
6
- throw new Error("The existing MCP entry has custom access, timeout, or transport settings. Keep that entry and update only its command/args using Codex settings; the installer will not remove or reset it.");
6
+ throw new Error(`The existing MCP entry has custom access, timeout, or transport settings. The installer will not remove or reset it. Keep those settings and update only command=${JSON.stringify(node)} and args=${JSON.stringify([entry, ...entryArgs])} in the existing entry, then reconnect that MCP server once.`);
7
7
  }
8
8
  }
9
+ const values = { ...existing?.transport?.env, ...envOverrides };
10
+ const args = ["mcp", "add", name];
11
+ for (const [key, value] of Object.entries(values).sort(([a], [b]) => a.localeCompare(b))) {
12
+ if (typeof value !== "string") throw new Error(`MCP environment value ${key} must be a string`);
13
+ args.push("--env", `${key}=${value}`);
14
+ }
15
+ return { args: [...args, "--", node, entry, ...entryArgs], environment: values, environmentKeys: Object.keys(values).sort() };
16
+ }
17
+
18
+ export function codexMcpRegistration({ name, existing, node, entry, entryArgs = [], env, desktopOnly }) {
9
19
  const values = { ...existing?.transport?.env };
10
- for (const key of ["CLAUDE_BRIDGE_PEER_NAME", "CLAUDE_BRIDGE_PERMISSION_MODE", "CODEX_BRIDGE_DESKTOP_TASKS"]) {
20
+ for (const key of ["CLAUDE_BRIDGE_PEER_NAME", "CLAUDE_BRIDGE_PERMISSION_MODE", "CODEX_BRIDGE_DESKTOP_TASKS", "CLAUDE_DESKTOP_USER_DATA"]) {
11
21
  if (env[key] !== undefined) values[key] = env[key];
12
22
  }
13
23
  values.CODEX_BRIDGE_DESKTOP_TASKS ??= desktopOnly ? "1" : "0";
@@ -16,10 +26,5 @@ export function codexMcpRegistration({ name, existing, node, entry, env, desktop
16
26
  throw new Error("CLAUDE_BRIDGE_PERMISSION_MODE must be bypass or prompting; never infer it from the recipient");
17
27
  }
18
28
  if (values.CODEX_BRIDGE_DESKTOP_TASKS === "1") values.CODEX_BRIDGE_AUTOSTART = "0";
19
- const args = ["mcp", "add", name];
20
- for (const [key, value] of Object.entries(values).sort(([a], [b]) => a.localeCompare(b))) {
21
- if (typeof value !== "string") throw new Error(`MCP environment value ${key} must be a string`);
22
- args.push("--env", `${key}=${value}`);
23
- }
24
- return { args: [...args, "--", node, entry], environmentKeys: Object.keys(values).sort() };
29
+ return stdioMcpRegistration({ name, existing, node, entry, entryArgs, envOverrides: values });
25
30
  }
@@ -0,0 +1,140 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ const ACCOUNT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
+ const PACKAGE_NAME = /^Claude_[a-z0-9._-]+$/i;
8
+ const MAX_CONFIG_BYTES = 1024 * 1024;
9
+ const MAX_PACKAGES = 512;
10
+ const MAX_ROOTS = 16;
11
+ const VERSION_FIELDS = ["size", "mtimeMs", "ctimeMs", "ino", "dev"];
12
+ const sameVersion = (left, right) => VERSION_FIELDS.every((field) => left[field] === right[field]);
13
+ const isMissing = (error) => error.code === "ENOENT";
14
+
15
+ function result(status, reason, root = null, accountId = null) {
16
+ return {
17
+ status,
18
+ accountId,
19
+ fingerprint: accountId ? createHash("sha256").update(`claude-desktop\0${accountId}`).digest("hex") : null,
20
+ root,
21
+ reason,
22
+ };
23
+ }
24
+
25
+ function absoluteDirectory(value) {
26
+ if (typeof value !== "string" || !value || !path.isAbsolute(value)) throw new Error("invalid root");
27
+ return path.resolve(value);
28
+ }
29
+
30
+ function directoryStat(directory) {
31
+ const stat = fs.lstatSync(directory);
32
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("invalid directory");
33
+ return stat;
34
+ }
35
+
36
+ function candidateRoots({ platform, env, root }) {
37
+ const selected = root !== undefined ? root : env.CLAUDE_DESKTOP_USER_DATA;
38
+ if (selected !== undefined) return [absoluteDirectory(selected)];
39
+ const home = env.HOME ?? env.USERPROFILE ?? os.homedir();
40
+ const config = platform === "darwin" ? path.join(home, "Library", "Application Support")
41
+ : platform === "win32" ? env.APPDATA || path.join(home, "AppData", "Roaming")
42
+ : env.XDG_CONFIG_HOME || path.join(home, ".config");
43
+ const roots = [absoluteDirectory(path.join(config, "Claude"))];
44
+ if (platform === "win32") {
45
+ const packages = absoluteDirectory(path.join(env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Packages"));
46
+ let entries;
47
+ try {
48
+ directoryStat(packages);
49
+ entries = fs.readdirSync(packages, { withFileTypes: true });
50
+ } catch (error) {
51
+ if (!isMissing(error)) throw error;
52
+ entries = [];
53
+ }
54
+ if (entries.length > MAX_PACKAGES) throw new Error("package scan limit");
55
+ for (const entry of entries) {
56
+ if (!PACKAGE_NAME.test(entry.name)) continue;
57
+ const packageRoot = path.join(packages, entry.name);
58
+ directoryStat(packageRoot);
59
+ roots.push(path.join(packageRoot, "LocalCache", "Roaming", "Claude"));
60
+ }
61
+ }
62
+ const unique = [...new Map(roots.map((value) => [platform === "win32" ? value.toLowerCase() : value, value])).values()].sort();
63
+ if (unique.length > MAX_ROOTS) throw new Error("root scan limit");
64
+ return unique;
65
+ }
66
+
67
+ function readConfig(root) {
68
+ let rootStat;
69
+ try { rootStat = directoryStat(root); }
70
+ catch (error) {
71
+ if (isMissing(error)) return { kind: "missing-root" };
72
+ throw error;
73
+ }
74
+ const file = path.join(root, "config.json");
75
+ let initial;
76
+ try { initial = fs.lstatSync(file); }
77
+ catch (error) {
78
+ if (isMissing(error)) return { kind: "missing-config", rootStat };
79
+ throw error;
80
+ }
81
+ if (!initial.isFile() || initial.isSymbolicLink() || initial.size > MAX_CONFIG_BYTES) throw new Error("invalid config");
82
+ const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
83
+ let bytes;
84
+ let current;
85
+ try {
86
+ const before = fs.fstatSync(descriptor);
87
+ if (!before.isFile() || !sameVersion(initial, before)) throw new Error("config changed");
88
+ bytes = Buffer.alloc(before.size);
89
+ if (fs.readSync(descriptor, bytes, 0, bytes.length, 0) !== bytes.length) throw new Error("short read");
90
+ const after = fs.fstatSync(descriptor);
91
+ current = fs.lstatSync(file);
92
+ if (!current.isFile() || current.isSymbolicLink() || !sameVersion(before, after) || !sameVersion(after, current)) throw new Error("config changed");
93
+ } finally {
94
+ fs.closeSync(descriptor);
95
+ }
96
+ if (!sameVersion(rootStat, directoryStat(root))) throw new Error("root changed");
97
+ const config = JSON.parse(bytes.toString("utf8"));
98
+ if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("invalid config");
99
+ const hasAccount = Object.hasOwn(config, "lastKnownAccountUuid");
100
+ const hasSignedIn = Object.hasOwn(config, "windowSizeWasSignedIn");
101
+ if (!hasAccount && !hasSignedIn) return { kind: "unrelated", rootStat, fileStat: current, bytes };
102
+ if (!hasAccount || !hasSignedIn || typeof config.windowSizeWasSignedIn !== "boolean" ||
103
+ typeof config.lastKnownAccountUuid !== "string" || !ACCOUNT_ID.test(config.lastKnownAccountUuid)) throw new Error("invalid account state");
104
+ return {
105
+ kind: config.windowSizeWasSignedIn ? "verified" : "signed_out",
106
+ accountId: config.lastKnownAccountUuid.toLowerCase(),
107
+ rootStat,
108
+ fileStat: current,
109
+ bytes,
110
+ };
111
+ }
112
+
113
+ function sameSnapshot(left, right) {
114
+ return left.kind === right.kind &&
115
+ (!left.rootStat || right.rootStat && sameVersion(left.rootStat, right.rootStat)) &&
116
+ (!left.fileStat || right.fileStat && sameVersion(left.fileStat, right.fileStat)) &&
117
+ (!left.bytes || right.bytes && left.bytes.equals(right.bytes));
118
+ }
119
+
120
+ export function readClaudeAccountContext({ platform = process.platform, env = process.env, root } = {}) {
121
+ let roots;
122
+ try {
123
+ roots = candidateRoots({ platform, env, root });
124
+ const snapshots = roots.map((candidate) => readConfig(candidate));
125
+ const currentRoots = candidateRoots({ platform, env, root });
126
+ if (roots.length !== currentRoots.length || roots.some((candidate, index) => candidate !== currentRoots[index]) ||
127
+ roots.some((candidate, index) => !sameSnapshot(snapshots[index], readConfig(candidate)))) {
128
+ return result("unavailable", "Claude Desktop account state changed during inspection; inspect the existing session again.", roots.length === 1 ? roots[0] : null);
129
+ }
130
+ const credible = snapshots.map((snapshot, index) => ({ ...snapshot, root: roots[index] }))
131
+ .filter((snapshot) => snapshot.kind === "verified" || snapshot.kind === "signed_out");
132
+ if (credible.length > 1) return result("ambiguous", "Multiple Claude Desktop data roots contain account state; no current account or metadata root is selected.");
133
+ if (!credible.length) return result("unavailable", "Claude Desktop account state is unavailable; no current account is confirmed.", roots.length === 1 ? roots[0] : null);
134
+ const selected = credible[0];
135
+ if (selected.kind === "signed_out") return result("signed_out", "Claude Desktop's persisted account state reports signed out; no destination is confirmed.", selected.root);
136
+ return result("verified", "Claude Desktop's persisted signed-in account state identifies this account; destination session identity still requires verification.", selected.root, selected.accountId);
137
+ } catch {
138
+ return result("unavailable", "Claude Desktop account state could not be read completely and consistently; no current account is confirmed.", roots?.length === 1 ? roots[0] : null);
139
+ }
140
+ }
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { homeDir } from "./platform.mjs";
6
6
 
7
7
  const HASH = /^[a-f0-9]{64}$/;
8
- const FIELDS = new Set(["version", "key", "cwd", "promptHash", "state", "startedAt", "threadId", "projectId", "projectName", "name"]);
8
+ const FIELDS = new Set(["version", "key", "cwd", "promptHash", "state", "startedAt", "threadId", "projectId", "projectName", "name", "accountContext"]);
9
9
  const digest = (value) => createHash("sha256").update(value).digest("hex");
10
10
  const normalizedName = (name) => name?.normalize("NFC").trim().replace(/\s+/g, " ");
11
11
  const canonicalCwd = (cwd) => process.platform === "win32" ? path.normalize(cwd).toLowerCase() : path.normalize(cwd);
@@ -26,6 +26,8 @@ function validateReceipt(key, receipt) {
26
26
  && nonemptyString(receipt.cwd) && path.isAbsolute(receipt.cwd)
27
27
  && ["pending", "unknown", "known"].includes(receipt.state)
28
28
  && Number.isSafeInteger(receipt.startedAt) && receipt.startedAt >= 0
29
+ && (receipt.accountContext === undefined || receipt.accountContext && typeof receipt.accountContext === "object" && !Array.isArray(receipt.accountContext)
30
+ && Object.keys(receipt.accountContext).length === 2 && ["claude", "codex"].every((provider) => typeof receipt.accountContext[provider] === "string" && HASH.test(receipt.accountContext[provider])))
29
31
  && ["threadId", "projectId", "projectName", "name"].every((field) => receipt[field] === undefined || nonemptyString(receipt[field]))
30
32
  && (receipt.state !== "known" || nonemptyString(receipt.threadId));
31
33
  if (!valid) throw unsafeReceipt(key);
package/src/index.mjs CHANGED
@@ -4,6 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { z } from "zod";
5
5
  import path from "node:path";
6
6
  import { realpathSync } from "node:fs";
7
+ import { AsyncLocalStorage } from "node:async_hooks";
7
8
 
8
9
  import { CodexAppServerClient, writerLockWarning } from "./app-server-client.mjs";
9
10
  import {
@@ -26,10 +27,13 @@ import { DesktopTaskDelivery, DESKTOP_TOOL_BUDGET_MS } from "./thread-delivery.m
26
27
  import { desktopTasksConfigured } from "./native-relay.mjs";
27
28
  import { exitForVersionRequest } from "./cli-version.mjs";
28
29
  import { createRuntimeState } from "./runtime-state.mjs";
30
+ import { clientReloadReason, createReloadControl } from "./reload-control.mjs";
31
+ import { accountIdentity, assertAccountIdentity, publicAccountState, readBridgeAccounts, requireBridgeAccounts } from "./bridge-account-context.mjs";
32
+ import { assertClaudeSenderContext, readClaudeSenderContext, requireClaudeSenderContext } from "./claude-sender-context.mjs";
29
33
 
30
34
  exitForVersionRequest(import.meta.url);
31
35
 
32
- const VERSION = "1.13.8";
36
+ const VERSION = "1.15.0";
33
37
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
34
38
 
35
39
  /**
@@ -50,13 +54,60 @@ const RELEASE_TURN_STATUSES = TERMINAL_TURN_STATUSES;
50
54
  const security = new BridgeSecurityPolicy();
51
55
  const desktopTasksEnabled = desktopTasksConfigured();
52
56
  const runtime = createRuntimeState({ configuration: desktopTasksConfigured });
53
- const desktopTasks = new DesktopTaskDelivery({ security });
57
+ const desktopOperation = new AsyncLocalStorage();
58
+ const desktopTasks = new DesktopTaskDelivery({ security, beforeRequest: beforeDesktopRequest, accountContext: () => desktopOperation.getStore()?.accounts });
59
+
60
+ async function assertDesktopOperation(context, { verifyProcess = false } = {}) {
61
+ if (!context || context.diagnostic) return;
62
+ runtime.assertCurrent();
63
+ const accounts = readBridgeAccounts();
64
+ assertAccountIdentity(context.accounts, accounts);
65
+ await assertClaudeSenderContext(context.caller, {
66
+ account: accounts.claude,
67
+ ...(!verifyProcess ? { readAncestry: async () => context.caller.lineage } : {}),
68
+ });
69
+ assertAccountIdentity(context.accounts);
70
+ }
71
+
72
+ async function beforeDesktopRequest({ operation, args, phase }) {
73
+ const context = desktopOperation.getStore();
74
+ if (context?.diagnostic) return;
75
+ if (!context) throw new Error("Desktop operations require a verified calling Code session.");
76
+ const mutating = ["create_thread", "send_message_to_thread", "set_thread_title", "navigate_to_codex_page"].includes(operation);
77
+ await assertDesktopOperation(context, { verifyProcess: mutating && phase === "write" });
78
+ if (mutating) {
79
+ context.dispatched = true;
80
+ if (args.threadId) context.threadId = args.threadId;
81
+ }
82
+ }
83
+
84
+ function withheldDesktopResult(error, context) {
85
+ if (context.dispatched) reload.defer("A Desktop operation was dispatched without a fully verified outcome");
86
+ const reason = (error?.message ?? "The calling session or accounts could not be reverified.").replace(/\s*No message was sent\./g, "");
87
+ return {
88
+ ...textResult(`${reason} ${context.dispatched ? "A Desktop operation may already have been dispatched. Its original destination and any creation receipt were preserved." : "No Desktop mutation was dispatched."} Reply content was withheld. Inspect the original task and do not resend automatically.`, true),
89
+ structuredContent: { accountContext: context.accounts, operation: { state: context.dispatched ? "dispatched_outcome_unverified" : "blocked", threadId: context.threadId ?? null } },
90
+ };
91
+ }
54
92
 
55
93
  const client = desktopTasksEnabled ? null : new CodexAppServerClient({
56
94
  clientInfo: { name: "codex-mcp-bridge", title: "Codex MCP Bridge", version: VERSION },
57
95
  log,
58
96
  });
59
97
 
98
+ const reload = createReloadControl({
99
+ entry: "index.mjs",
100
+ inspect: () => desktopTasks.threadOperations.size ? "Desktop task operations are still active" : clientReloadReason(client),
101
+ quiesce: () => { client?.close(); },
102
+ exportState: () => ({ desktopTasksEnabled, ownedThreadIds: [...security.ownedThreadIds] }),
103
+ restore: (state) => {
104
+ if (state.desktopTasksEnabled !== desktopTasksEnabled || !Array.isArray(state.ownedThreadIds)
105
+ || state.ownedThreadIds.some((id) => typeof id !== "string" || !id.trim())
106
+ || new Set(state.ownedThreadIds).size !== state.ownedThreadIds.length) throw new Error("Invalid or incompatible Codex worker reload state");
107
+ security.ownedThreadIds = new Set(state.ownedThreadIds);
108
+ },
109
+ });
110
+
60
111
  const textResult = (text, isError = false) => ({
61
112
  content: [{ type: "text", text }],
62
113
  ...(isError ? { isError: true } : {}),
@@ -116,7 +167,7 @@ function threadNameFor({ cwd, prompt, name }) {
116
167
  }
117
168
 
118
169
  async function delegateDesktopTask({ cwd, prompt, name, model, effort, timeoutSec, openInApp, waitForReply = true }) {
119
- const deadline = Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS);
170
+ const deadline = desktopOperation.getStore()?.deadline ?? Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS);
120
171
  const workspace = resolveWorkspacePath(cwd);
121
172
  const created = await desktopTasks.create({
122
173
  cwd: workspace.path, prompt, name: threadNameFor({ cwd: workspace.path, prompt, name }),
@@ -293,16 +344,39 @@ const server = new McpServer(
293
344
  function registerTool(name, definition, handler) {
294
345
  server.registerTool(name, definition, async (...args) => {
295
346
  try {
347
+ return await reload.run(async () => {
296
348
  if (!definition.annotations?.readOnlyHint) runtime.assertCurrent();
297
349
  if (name === "codex_bridge_status") {
298
350
  const state = runtime.status();
299
351
  if (!state.current) return { ...failure(new Error(`${state.reason}; reconnect this MCP server in the existing task.`)), structuredContent: { runtime: state } };
300
- const result = await handler(...args);
352
+ const accounts = desktopTasksEnabled ? readBridgeAccounts() : null;
353
+ const result = await desktopOperation.run({ diagnostic: true, accounts: accountIdentity(accounts) }, () => handler(...args));
301
354
  result.content.push({ type: "text", text: `runtime pid: ${state.pid}\nloaded source: ${state.revision}\nruntime state: current` });
302
355
  result.structuredContent = { ...result.structuredContent, runtime: state };
356
+ if (desktopTasksEnabled) {
357
+ result.structuredContent.accounts = { claude: publicAccountState(accounts.claude), codex: publicAccountState(accounts.codex) };
358
+ }
303
359
  return result;
304
360
  }
361
+ if (desktopTasksEnabled) {
362
+ const deadline = Date.now() + Math.min((args[0]?.timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS);
363
+ const accounts = readBridgeAccounts();
364
+ const identity = requireBridgeAccounts(accounts);
365
+ const caller = requireClaudeSenderContext(await readClaudeSenderContext({ account: accounts.claude }));
366
+ const context = { accounts: identity, caller, dispatched: false, deadline };
367
+ return await desktopOperation.run(context, async () => {
368
+ try {
369
+ await assertDesktopOperation(context);
370
+ const result = await handler(...args);
371
+ await assertDesktopOperation(context);
372
+ return result;
373
+ } catch (error) {
374
+ return withheldDesktopResult(error, context);
375
+ }
376
+ });
377
+ }
305
378
  return await handler(...args);
379
+ });
306
380
  } catch (err) {
307
381
  return failure(err);
308
382
  }
@@ -441,7 +515,7 @@ registerTool(
441
515
  },
442
516
  },
443
517
  async ({ threadId, prompt, timeoutSec, cwd, model, effort, name, openInApp, releaseAfterTurn }) => {
444
- const deadline = desktopTasksEnabled ? Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS) : undefined;
518
+ const deadline = desktopTasksEnabled ? desktopOperation.getStore()?.deadline ?? Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS) : undefined;
445
519
  return (desktopTasksEnabled ? desktopTasks : client).withThread(threadId, async () => {
446
520
  const notes = [];
447
521
  const shouldOpen = openInApp ?? DEFAULT_OPEN_IN_APP;
@@ -670,7 +744,7 @@ registerTool(
670
744
  async ({ threadId, limit }) => {
671
745
  try {
672
746
  if (desktopTasksEnabled) {
673
- const deadline = Date.now() + DESKTOP_TOOL_BUDGET_MS;
747
+ const deadline = desktopOperation.getStore()?.deadline ?? Date.now() + DESKTOP_TOOL_BUDGET_MS;
674
748
  await desktopTasks.inspect(threadId, undefined, { deadline });
675
749
  const response = await desktopTasks.request("read_thread", { threadId, hostId: "local", turnLimit: Math.min(limit ?? 10, 10) }, { deadline });
676
750
  return textResult(JSON.stringify(response, null, 2));
@@ -758,9 +832,12 @@ registerTool(
758
832
  async ({ threadId, background }) => {
759
833
  try {
760
834
  if (desktopTasksEnabled) {
761
- const deadline = Date.now() + DESKTOP_TOOL_BUDGET_MS;
835
+ const deadline = desktopOperation.getStore()?.deadline ?? Date.now() + DESKTOP_TOOL_BUDGET_MS;
762
836
  await desktopTasks.inspect(threadId, undefined, { deadline });
763
- if (background) await openThreadInCodexApp(threadId, { activate: false });
837
+ if (background) {
838
+ await beforeDesktopRequest({ operation: "navigate_to_codex_page", args: { threadId }, phase: "write" });
839
+ await openThreadInCodexApp(threadId, { activate: false });
840
+ }
764
841
  else await desktopTasks.open(threadId, { deadline });
765
842
  return textResult(`Opened ${codexThreadUrl(threadId)} in Codex Desktop.`);
766
843
  }
@@ -894,4 +971,5 @@ registerTool(
894
971
 
895
972
  const transport = new StdioServerTransport();
896
973
  await server.connect(transport);
974
+ reload.listen();
897
975
  log(desktopTasksEnabled ? `ready on ${PLATFORM_LABEL} (Codex Desktop only; external app-server disabled)` : `ready on ${PLATFORM_LABEL} (app-server endpoint: ${client.url}, codex: ${client.codexBin})`);