@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.
- package/CHANGELOG.md +34 -1
- package/README.md +37 -18
- package/package.json +6 -6
- package/scripts/install-claude-desktop.mjs +12 -3
- 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 +90 -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 +86 -8
- package/src/mcp-supervisor.mjs +314 -0
- package/src/native-relay-companion.mjs +169 -34
- package/src/native-relay-launcher.mjs +3 -0
- package/src/native-relay.mjs +119 -47
- package/src/peer-protocol.mjs +120 -17
- package/src/platform.mjs +4 -1
- package/src/release-snapshot.mjs +95 -0
- package/src/reload-control.mjs +96 -0
- package/src/reply-forwarder.mjs +43 -0
- package/src/thread-delivery.mjs +27 -11
|
@@ -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
|
+
}
|
|
@@ -1,13 +1,23 @@
|
|
|
1
|
-
export function
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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)
|
|
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})`);
|