@minhspark/codex-mcp-bridge 1.13.9 → 1.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ import { execFile } from "node:child_process";
2
+ import path from "node:path";
3
+ import { promisify } from "node:util";
4
+ import { listClaudeSessions } from "./peer-protocol.mjs";
5
+ import { readClaudeDesktopContext } from "./claude-desktop-context.mjs";
6
+
7
+ const execute = promisify(execFile);
8
+ const MAX_ANCESTORS = 8;
9
+ const WINDOWS_ANCESTRY_SOURCE = `
10
+ using System;
11
+ using System.Collections.Generic;
12
+ using System.ComponentModel;
13
+ using System.Globalization;
14
+ using System.Runtime.InteropServices;
15
+
16
+ public static class ClaudeProcessAncestry {
17
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
18
+ private struct ProcessEntry {
19
+ public uint dwSize, cntUsage, th32ProcessID;
20
+ public UIntPtr th32DefaultHeapID;
21
+ public uint th32ModuleID, cntThreads, th32ParentProcessID;
22
+ public int pcPriClassBase;
23
+ public uint dwFlags;
24
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szExeFile;
25
+ }
26
+ public sealed class Row {
27
+ public uint pid;
28
+ public uint parentPid;
29
+ public string processStart;
30
+ }
31
+ [DllImport("kernel32.dll", SetLastError = true)]
32
+ private static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint processId);
33
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
34
+ [return: MarshalAs(UnmanagedType.Bool)]
35
+ private static extern bool Process32FirstW(IntPtr snapshot, ref ProcessEntry entry);
36
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
37
+ [return: MarshalAs(UnmanagedType.Bool)]
38
+ private static extern bool Process32NextW(IntPtr snapshot, ref ProcessEntry entry);
39
+ [DllImport("kernel32.dll", SetLastError = true)]
40
+ private static extern IntPtr OpenProcess(uint access, [MarshalAs(UnmanagedType.Bool)] bool inherit, uint processId);
41
+ [DllImport("kernel32.dll", SetLastError = true)]
42
+ [return: MarshalAs(UnmanagedType.Bool)]
43
+ private static extern bool GetProcessTimes(IntPtr process, out long creation, out long exit, out long kernel, out long user);
44
+ [DllImport("kernel32.dll")]
45
+ [return: MarshalAs(UnmanagedType.Bool)]
46
+ private static extern bool CloseHandle(IntPtr handle);
47
+
48
+ public static Row[] Read(uint firstPid, int limit) {
49
+ var capturedAt = DateTime.UtcNow.ToFileTimeUtc();
50
+ var snapshot = CreateToolhelp32Snapshot(2, 0);
51
+ if (snapshot == new IntPtr(-1)) throw new Win32Exception(Marshal.GetLastWin32Error());
52
+ var parents = new Dictionary<uint, uint>();
53
+ try {
54
+ var entry = new ProcessEntry { dwSize = (uint)Marshal.SizeOf(typeof(ProcessEntry)) };
55
+ if (!Process32FirstW(snapshot, ref entry)) throw new Win32Exception(Marshal.GetLastWin32Error());
56
+ do {
57
+ if (parents.Count >= 65536) throw new InvalidOperationException("Process snapshot exceeds its read limit.");
58
+ parents.Add(entry.th32ProcessID, entry.th32ParentProcessID);
59
+ } while (Process32NextW(snapshot, ref entry));
60
+ if (Marshal.GetLastWin32Error() != 18) throw new Win32Exception(Marshal.GetLastWin32Error());
61
+ } finally {
62
+ CloseHandle(snapshot);
63
+ }
64
+ var rows = new List<Row>();
65
+ var seen = new HashSet<uint>();
66
+ var next = firstPid;
67
+ var childStart = capturedAt;
68
+ while (next > 0 && rows.Count < limit && seen.Add(next)) {
69
+ uint parent;
70
+ if (!parents.TryGetValue(next, out parent)) break;
71
+ var process = OpenProcess(0x1000, false, next);
72
+ if (process == IntPtr.Zero) break;
73
+ try {
74
+ long creation, exit, kernel, user;
75
+ if (!GetProcessTimes(process, out creation, out exit, out kernel, out user)
76
+ || creation <= 0 || creation > childStart || exit != 0) break;
77
+ rows.Add(new Row { pid = next, parentPid = parent, processStart = creation.ToString(CultureInfo.InvariantCulture) });
78
+ childStart = creation;
79
+ next = parent;
80
+ } finally {
81
+ CloseHandle(process);
82
+ }
83
+ }
84
+ return rows.ToArray();
85
+ }
86
+ }`;
87
+
88
+ function parseWindowsAncestry(stdout, parentPid, limit) {
89
+ const rows = JSON.parse(stdout.trim());
90
+ if (!Array.isArray(rows) || rows.length > limit) throw new Error("Invalid process ancestry response");
91
+ const seen = new Set();
92
+ let next = parentPid;
93
+ let childStart;
94
+ for (const row of rows) {
95
+ if (!row || typeof row !== "object" || Object.keys(row).some((key) => !["pid", "parentPid", "processStart"].includes(key))
96
+ || !Number.isSafeInteger(row.pid) || row.pid !== next || row.pid <= 0 || row.pid > 0xffffffff || seen.has(row.pid)
97
+ || !Number.isSafeInteger(row.parentPid) || row.parentPid < 0 || row.parentPid > 0xffffffff
98
+ || typeof row.processStart !== "string" || !/^[1-9]\d{0,18}$/.test(row.processStart)) throw new Error("Invalid process ancestry response");
99
+ const start = BigInt(row.processStart);
100
+ if (childStart !== undefined && start > childStart) throw new Error("Process ancestry contains a replaced parent");
101
+ childStart = start;
102
+ seen.add(row.pid);
103
+ next = row.parentPid;
104
+ }
105
+ return rows;
106
+ }
107
+
108
+ export async function readProcessAncestry({ parentPid = process.ppid, platform = process.platform, maxDepth = MAX_ANCESTORS, run = execute } = {}) {
109
+ if (!Number.isSafeInteger(parentPid) || parentPid <= 0 || parentPid > 0xffffffff) return [];
110
+ const limit = Number.isInteger(maxDepth) && maxDepth > 0 ? Math.min(maxDepth, MAX_ANCESTORS) : MAX_ANCESTORS;
111
+ const options = { timeout: 5000, maxBuffer: 16384, windowsHide: true };
112
+ if (platform === "win32") {
113
+ const shell = path.win32.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
114
+ const script = `$ErrorActionPreference='Stop'; $PSModuleAutoLoadingPreference='None'; Import-Module -Name ([IO.Path]::Combine($PSHOME,'Modules','Microsoft.PowerShell.Utility','Microsoft.PowerShell.Utility.psd1')) -ErrorAction Stop; Add-Type -TypeDefinition @'\n${WINDOWS_ANCESTRY_SOURCE}\n'@\nConvertTo-Json -InputObject @([ClaudeProcessAncestry]::Read(${parentPid},${limit})) -Compress`;
115
+ const { stdout } = await run(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], options);
116
+ return parseWindowsAncestry(stdout, parentPid, limit);
117
+ }
118
+ const rows = [];
119
+ const seen = new Set();
120
+ let next = parentPid;
121
+ const deadline = Date.now() + 5000;
122
+ while (next > 0 && !seen.has(next) && rows.length < limit) {
123
+ seen.add(next);
124
+ const remaining = deadline - Date.now();
125
+ if (remaining <= 0) throw new Error("process inspection deadline elapsed");
126
+ const { stdout } = await run("/bin/ps", ["-p", String(next), "-o", "pid=,ppid=,lstart="], {
127
+ ...options, timeout: remaining, env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
128
+ });
129
+ const match = stdout.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
130
+ if (!match) break;
131
+ const row = { pid: Number(match[1]), parentPid: Number(match[2]), processStart: match[3].trim() };
132
+ rows.push(row);
133
+ next = row.parentPid;
134
+ }
135
+ return rows;
136
+ }
137
+
138
+ function senderResult(status, reason, values = {}) {
139
+ return { status, pid: null, sessionId: null, taskId: null, cwd: null, processStart: null, accountFingerprint: null, reason, ...values };
140
+ }
141
+
142
+ function senderFailureCode(error) {
143
+ if (error?.code === "ETIMEDOUT" || (error?.killed && ["SIGTERM", "SIGKILL"].includes(error.signal))) return "INSPECTION_TIMEOUT";
144
+ if (error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return "OUTPUT_LIMIT";
145
+ if (["ENOENT", "EACCES", "EPERM"].includes(error?.code)) return error.code;
146
+ if (typeof error?.code === "number") return "NATIVE_EXIT";
147
+ if (error instanceof SyntaxError) return "INVALID_JSON";
148
+ return "UNAVAILABLE";
149
+ }
150
+
151
+ export async function readClaudeSenderContext({ account, parentPid = process.ppid, readAncestry = readProcessAncestry, listSessions = listClaudeSessions, readContext = readClaudeDesktopContext } = {}) {
152
+ if (account?.status !== "verified" || !account.fingerprint) return senderResult("unavailable", "The calling Claude account is not verified.");
153
+ let stage = "session_registry";
154
+ try {
155
+ const registered = listSessions();
156
+ const direct = registered.some((session) => session.entrypoint === "claude-desktop" && session.alive && session.pid === parentPid);
157
+ stage = "process_inspection";
158
+ const ancestry = await readAncestry({ parentPid, maxDepth: direct ? 1 : MAX_ANCESTORS });
159
+ stage = "ancestry_validation";
160
+ if (!Array.isArray(ancestry) || !ancestry.length || ancestry.length > MAX_ANCESTORS) throw new Error("invalid ancestry");
161
+ const seen = new Set();
162
+ let expected = parentPid;
163
+ for (const entry of ancestry) {
164
+ if (!Number.isSafeInteger(entry.pid) || entry.pid !== expected || seen.has(entry.pid) ||
165
+ !Number.isSafeInteger(entry.parentPid) || entry.parentPid < 0 || typeof entry.processStart !== "string" || !entry.processStart.trim()) throw new Error("invalid ancestry");
166
+ seen.add(entry.pid);
167
+ expected = entry.parentPid;
168
+ }
169
+ const sessions = registered.filter((session) => session.entrypoint === "claude-desktop" && session.alive && seen.has(session.pid));
170
+ if (sessions.length > 1) return senderResult("ambiguous", "Multiple registered Claude Desktop sessions appear in this MCP process's ancestry.");
171
+ if (!sessions.length) return senderResult("unavailable", "This MCP process has no registered Claude Desktop Code session in its parent ancestry. Reconnect the bridge inside the intended existing Code task.");
172
+ const session = sessions[0];
173
+ const process = ancestry.find((entry) => entry.pid === session.pid);
174
+ if (session.processStart && session.processStart !== process.processStart) return senderResult("unavailable", "The calling Claude session's registered process identity changed.");
175
+ stage = "task_metadata";
176
+ const context = readContext(session, { account });
177
+ if (context.status !== "matched") return senderResult("unavailable", "The calling Claude Code session is not confirmed in the currently signed-in account.");
178
+ return senderResult("verified", "The calling MCP process belongs to a live Claude Desktop Code session in the selected account.", {
179
+ pid: session.pid, sessionId: session.sessionId, taskId: context.taskId, cwd: context.cwd,
180
+ processStart: process.processStart, accountFingerprint: account.fingerprint,
181
+ lineage: ancestry.map((entry) => ({ ...entry })),
182
+ });
183
+ } catch (error) {
184
+ const code = senderFailureCode(error);
185
+ return senderResult("unavailable", `The calling Claude process ancestry or task identity could not be verified (${stage}:${code}).`, { diagnostic: { stage, code } });
186
+ }
187
+ }
188
+
189
+ export function requireClaudeSenderContext(sender) {
190
+ if (sender?.status !== "verified") {
191
+ const error = new Error(sender?.reason ?? "The calling Claude Desktop Code session is unverified.");
192
+ error.code = "CLAUDE_SENDER_CONTEXT_UNVERIFIED";
193
+ throw error;
194
+ }
195
+ return sender;
196
+ }
197
+
198
+ export async function assertClaudeSenderContext(expected, options) {
199
+ const current = requireClaudeSenderContext(await readClaudeSenderContext(options));
200
+ if (["pid", "sessionId", "taskId", "cwd", "processStart", "accountFingerprint"].some((field) => current[field] !== expected?.[field])) {
201
+ const error = new Error("The calling Claude Desktop session or account changed while this operation was pending.");
202
+ error.code = "CLAUDE_SENDER_CONTEXT_CHANGED";
203
+ throw error;
204
+ }
205
+ return current;
206
+ }
@@ -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);