@minhspark/codex-mcp-bridge 1.20.1 → 1.21.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.
Files changed (53) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +26 -0
  3. package/README.md +57 -15
  4. package/assets/README.md +30 -0
  5. package/assets/agent-pets.svg +1 -0
  6. package/docs/daily-handoffs.md +106 -0
  7. package/docs/desktop-handoff-design.md +180 -0
  8. package/docs/new-machine-setup.md +112 -0
  9. package/docs/project-onboarding.md +122 -0
  10. package/package.json +8 -2
  11. package/plugin.json +1 -1
  12. package/scripts/bridge-projects.mjs +44 -0
  13. package/scripts/check.mjs +2 -1
  14. package/scripts/collect-repo-analytics.mjs +5 -2
  15. package/scripts/configure-message-automation.mjs +18 -0
  16. package/scripts/install-claude-code.mjs +64 -0
  17. package/scripts/install-claude-desktop.mjs +48 -5
  18. package/scripts/install-native-relay.mjs +6 -1
  19. package/scripts/install-npm-footer.mjs +13 -1
  20. package/scripts/publish-repo-analytics.mjs +24 -1
  21. package/src/app-server-client.mjs +5 -2
  22. package/src/bridge-readiness.mjs +23 -0
  23. package/src/card-settings-sync.mjs +125 -0
  24. package/src/claude-bridge.mjs +54 -20
  25. package/src/claude-code-registration.mjs +54 -0
  26. package/src/claude-sender-context.mjs +1 -1
  27. package/src/claude-session-creation.mjs +10 -2
  28. package/src/codex-mcp-registration.mjs +1 -1
  29. package/src/codex-native-response.mjs +139 -107
  30. package/src/codex-sender-context.mjs +62 -61
  31. package/src/desktop-reply-receipts.mjs +87 -0
  32. package/src/desktop-task-receipts.mjs +30 -7
  33. package/src/hardened-root-policy.mjs +5 -0
  34. package/src/index.mjs +97 -19
  35. package/src/mcp-supervisor.mjs +6 -2
  36. package/src/message-automation.mjs +89 -0
  37. package/src/native-creation-receipts.mjs +155 -0
  38. package/src/native-relay-companion.mjs +37 -6
  39. package/src/native-relay.mjs +12 -3
  40. package/src/peer-protocol.mjs +192 -18
  41. package/src/project-manager-registration.mjs +34 -0
  42. package/src/project-onboarding-tools.mjs +30 -0
  43. package/src/project-onboarding.mjs +178 -0
  44. package/src/project-policy.mjs +221 -0
  45. package/src/project-scope.mjs +33 -5
  46. package/src/prompt-guidance.mjs +2 -0
  47. package/src/release-snapshot.mjs +30 -5
  48. package/src/rollout-reader.mjs +58 -0
  49. package/src/rollout-segments.mjs +65 -0
  50. package/src/security-policy.mjs +4 -2
  51. package/src/sites-connector.mjs +17 -2
  52. package/src/telemetry.mjs +10 -8
  53. package/src/thread-delivery.mjs +134 -35
@@ -2,6 +2,7 @@
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { randomUUID } from "node:crypto";
5
6
 
6
7
  import { IS_WINDOWS, PLATFORM_LABEL, claudeDesktopConfigPath, resolveCodexBin } from "../src/platform.mjs";
7
8
  import { desktopTasksConfigured } from "../src/native-relay.mjs";
@@ -40,7 +41,37 @@ if (!path.isAbsolute(codexBin) || !fs.existsSync(codexBin)) {
40
41
 
41
42
  fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
42
43
 
43
- const cfg = fs.existsSync(cfgPath) ? JSON.parse(fs.readFileSync(cfgPath, "utf8")) : {};
44
+ function readConfig() {
45
+ let descriptor;
46
+ try {
47
+ const before = fs.lstatSync(cfgPath);
48
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > 4 * 1024 * 1024) throw new Error("Unsafe Desktop configuration file");
49
+ descriptor = fs.openSync(cfgPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
50
+ const opened = fs.fstatSync(descriptor);
51
+ const fields = ["dev", "ino", "size", "mtimeMs", "ctimeMs"];
52
+ const matches = (left, right) => fields.every(field => left[field] === right[field]);
53
+ if (!opened.isFile() || opened.nlink !== 1 || !matches(before, opened)) throw new Error("Desktop configuration changed during access");
54
+ const data = Buffer.alloc(opened.size);
55
+ let offset = 0;
56
+ while (offset < data.length) {
57
+ const count = fs.readSync(descriptor, data, offset, data.length - offset, offset);
58
+ if (!count) throw new Error("Desktop configuration changed during access");
59
+ offset += count;
60
+ }
61
+ const after = fs.fstatSync(descriptor);
62
+ const current = fs.lstatSync(cfgPath);
63
+ if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || !matches(opened, after) || !matches(after, current)) throw new Error("Desktop configuration changed during access");
64
+ return { data, version: fields.map(field => current[field]) };
65
+ } catch (error) {
66
+ if (error.code === "ENOENT" && descriptor === undefined) return null;
67
+ throw error;
68
+ } finally {
69
+ if (descriptor !== undefined) fs.closeSync(descriptor);
70
+ }
71
+ }
72
+
73
+ const original = readConfig();
74
+ const cfg = original ? JSON.parse(original.data.toString("utf8")) : {};
44
75
  cfg.mcpServers = cfg.mcpServers ?? {};
45
76
 
46
77
  /**
@@ -119,11 +150,23 @@ cfg.mcpServers["codex-bridge"] = {
119
150
 
120
151
  createReleaseSnapshot(root, { cache: snapshotRoot({ ...process.env, ...cfg.mcpServers["codex-bridge"].env }) });
121
152
 
122
- if (fs.existsSync(cfgPath)) {
123
- const backup = `${cfgPath}.bak-${new Date().toISOString().slice(0, 10)}-codexbridge`;
124
- if (!fs.existsSync(backup)) fs.copyFileSync(cfgPath, backup);
153
+ const backup = `${cfgPath}.bak-${new Date().toISOString().slice(0, 10)}-codexbridge`;
154
+ if (original) {
155
+ try { fs.writeFileSync(backup, original.data, { flag: "wx", mode: 0o600 }); }
156
+ catch (error) { if (error.code !== "EEXIST") throw error; }
157
+ }
158
+ const temporary = `${cfgPath}.${randomUUID()}.tmp`;
159
+ try {
160
+ fs.writeFileSync(temporary, `${JSON.stringify(cfg, null, 2)}\n`, { flag: "wx", mode: 0o600 });
161
+ const current = readConfig();
162
+ if (original === null ? current !== null : current === null || !original.data.equals(current.data) || original.version.some((value, index) => value !== current.version[index])) {
163
+ throw new Error("Desktop configuration changed during installation; retry after the other writer finishes");
164
+ }
165
+ fs.renameSync(temporary, cfgPath);
166
+ } finally {
167
+ try { fs.unlinkSync(temporary); }
168
+ catch (error) { if (error.code !== "ENOENT") throw error; }
125
169
  }
126
- fs.writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8");
127
170
 
128
171
  console.log(`platform: ${PLATFORM_LABEL}`);
129
172
  console.log(`updated ${cfgPath}`);
@@ -23,7 +23,7 @@ import { enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-acce
23
23
 
24
24
  exitForVersionRequest(import.meta.url);
25
25
 
26
- const VERSION = "1.20.1";
26
+ const VERSION = "1.21.0";
27
27
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
28
28
  const entry = path.join(root, "src", "mcp-supervisor.mjs");
29
29
  const entryArgs = ["native-relay-companion.mjs"];
@@ -79,6 +79,11 @@ const existingServer = servers.some((server) => server.name === serverName)
79
79
  */
80
80
  const runtime = resolveCodexDesktopNodeBin();
81
81
 
82
+ if (IS_MACOS || IS_WINDOWS) {
83
+ console.log(`Native Desktop setup: in ${path.join(homeDir(), ".codex", "config.toml")} (or CODEX_HOME/config.toml), add env_vars = ["CODEX_APP_TOOLS_PIPE_PATH"] to the existing [mcp_servers.${serverName}] entry after registration.`);
84
+ console.log("Preserve existing settings and append the name to any existing env_vars list; do not save the temporary pipe path. Reconnect the relay or restart Codex Desktop after active work finishes, then check native_relay_status in the existing task.");
85
+ }
86
+
82
87
  const registration = stdioMcpRegistration({ name: serverName, existing: existingServer, node: runtime.path, entry, entryArgs, envOverrides: {
83
88
  ...(process.env.CLAUDE_DESKTOP_USER_DATA !== undefined ? { CLAUDE_DESKTOP_USER_DATA: process.env.CLAUDE_DESKTOP_USER_DATA } : {}),
84
89
  } });
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { execFileSync } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { randomUUID } from "node:crypto";
7
8
  import { exitForVersionRequest } from "../src/cli-version.mjs";
8
9
 
9
10
  exitForVersionRequest(import.meta.url);
@@ -70,7 +71,18 @@ if (!remove) {
70
71
  fs.mkdirSync(footerDirectory, { recursive: true, mode: 0o700 });
71
72
  if (fs.existsSync(footerPath) && (!fs.lstatSync(footerPath).isFile() || fs.lstatSync(footerPath).isSymbolicLink())) throw new Error(`Unsafe footer destination: ${footerPath}`);
72
73
  if (!fs.existsSync(footerPath) || fs.readFileSync(footerPath, "utf8") !== source) {
73
- fs.writeFileSync(footerPath, source, { mode: 0o600 });
74
+ const temporary = `${footerPath}.${randomUUID()}.tmp`;
75
+ let created = false;
76
+ try {
77
+ fs.writeFileSync(temporary, source, { mode: 0o600, flag: "wx" });
78
+ created = true;
79
+ fs.renameSync(temporary, footerPath);
80
+ } finally {
81
+ if (created) {
82
+ try { fs.unlinkSync(temporary); }
83
+ catch (error) { if (error.code !== "ENOENT") throw error; }
84
+ }
85
+ }
74
86
  }
75
87
  }
76
88
  for (const { file, before, after } of edits) {
@@ -105,6 +105,29 @@ export function renderPublicDashboard(data) {
105
105
  return `<svg xmlns="http://www.w3.org/2000/svg" width="820" height="${y + 35}" viewBox="0 0 820 ${y + 35}" role="img" aria-label="Repository aggregate analytics"><rect width="100%" height="100%" fill="#0d1626"/><g font-family="Arial, sans-serif">${body.join('')}</g></svg>\n`;
106
106
  }
107
107
 
108
+ export function renderGithubViewsBadge(data, { now = Date.now() } = {}) {
109
+ const snapshot = [...data.snapshots].sort((a, b) => String(b.collectedAt).localeCompare(String(a.collectedAt))).find(row => row.views);
110
+ const views = snapshot?.views;
111
+ const time = snapshot?.sourceCollectedAt?.views;
112
+ const start = views?.views?.[0]?.timestamp?.slice(0, 10);
113
+ const end = views?.views?.at(-1)?.timestamp?.slice(0, 10);
114
+ const validWindow = date(start) && date(end);
115
+ const days = validWindow ? (Date.parse(end) - Date.parse(start)) / 86400000 + 1 : null;
116
+ const label = `GitHub views / ${days > 0 ? `${days}d` : 'window'}`;
117
+ const value = views?.count;
118
+ const undated = !date(time);
119
+ const stale = !undated && now - Date.parse(time) > staleAfter;
120
+ const age = value == null ? '' : undated ? ' · undated' : stale ? ' · stale' : '';
121
+ const message = format(value) + age;
122
+ const color = value == null ? '#555' : stale || undated ? '#92400e' : '#0369a1';
123
+ const detail = `${validWindow ? `${start} to ${end} UTC` : 'Source window unavailable'}. Source: ${displayTime(time)}. Archived badge; freshness is relative to generation time.`;
124
+ const labelWidth = Math.ceil([...label].length * 7.2 + 16);
125
+ const messageWidth = Math.ceil([...message].length * 7.2 + 16);
126
+ const width = labelWidth + messageWidth;
127
+ const accessible = `${label}: ${message}`;
128
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="24" viewBox="0 0 ${width} 24" role="img" aria-label="${escapeXml(accessible)}"><title>${escapeXml(`${accessible}. ${detail}`)}</title><rect width="${width}" height="24" rx="4" fill="#555"/><path d="M${labelWidth} 0H${width - 4}Q${width} 0 ${width} 4V20Q${width} 24 ${width - 4} 24H${labelWidth}Z" fill="${color}"/><g fill="#fff" font-family="DejaVu Sans Mono,monospace" font-size="12" text-anchor="middle"><text x="${labelWidth / 2}" y="16">${escapeXml(label)}</text><text x="${labelWidth + messageWidth / 2}" y="16">${escapeXml(message)}</text></g></svg>\n`;
129
+ }
130
+
108
131
  export function renderLiveDashboard(data) {
109
132
  const latest = source => [...data.snapshots].sort((a, b) => String(b.collectedAt).localeCompare(String(a.collectedAt))).find(row => row[source]);
110
133
  const npm = latest('npm');
@@ -195,7 +218,7 @@ export function publicFiles(history, usage) {
195
218
  const table = ['| Date (UTC) | Views | Unique visitors | Clones | Unique cloners | npm downloads | Active installations |', '|---|---:|---:|---:|---:|---:|---:|', ...days.map(day => `| ${day} | ${[data.daily.views[day]?.count, data.daily.views[day]?.uniques, data.daily.clones[day]?.count, data.daily.clones[day]?.uniques, data.daily.npm[day]?.downloads, usageDays[day]].map(format).join(' | ')} |`)].join('\n');
196
219
  const readme = `# Public repository analytics\n\n![Repository dashboard](dashboard.svg)\n\n[Aggregate JSON history](data.json) · [Repository](https://github.com/${data.repo})\n\nAll dates and source windows use UTC. Source timestamps on the dashboard show when each metric was last available. Downloads and clones include automation and do not measure people. Unique visitors/cloners apply only to the source window; never sum daily unique counts across dates. Active installations cover consenting installations only, with distinct counts for each whole period. Operating system and version breakdowns cover 30 days; an installation may appear in multiple version groups after upgrading.\n\nThis branch contains only aggregate statistics. No installation identifiers, prompts, account details, credentials, private error messages, or local paths are published. Missing metrics mean unavailable, not zero.\n\n## Daily history\n\nEvery archived source day is listed below, newest first. Active installations count consenting installations that reported that UTC day.\n\n${table}\n`;
197
220
  const pagesUrl = `https://${data.repo.split('/')[0]}.github.io/${data.repo.split('/')[1]}/`;
198
- return { 'dashboard.svg': renderPublicDashboard(data), 'README.md': readme.replace('![Repository dashboard]', `[Live dashboard · refreshes every 30 seconds](${pagesUrl})\n\n![Repository dashboard]`), 'data.json': `${JSON.stringify(data, null, 2)}\n`, 'index.html': renderLiveDashboard(data) };
221
+ return { 'stats-github-views.svg': renderGithubViewsBadge(data), 'dashboard.svg': renderPublicDashboard(data), 'README.md': readme.replace('![Repository dashboard]', `[Live dashboard · refreshes every 30 seconds](${pagesUrl})\n\n![Repository dashboard]`), 'data.json': `${JSON.stringify(data, null, 2)}\n`, 'index.html': renderLiveDashboard(data) };
199
222
  }
200
223
 
201
224
  export function githubEnvironment(token, env = process.env) {
@@ -69,7 +69,9 @@ function listeningPids(port) {
69
69
  }
70
70
 
71
71
  function httpBase(wsUrl) {
72
- return wsUrl.replace(/^ws:/, "http:").replace(/^wss:/, "https:").replace(/\/+$/, "");
72
+ const url = assertAllowedAppServerUrl(wsUrl);
73
+ url.protocol = url.protocol === "ws:" ? "http:" : "https:";
74
+ return url.href.replace(/\/+$/, "");
73
75
  }
74
76
 
75
77
  /**
@@ -133,6 +135,7 @@ export class CodexAppServerClient {
133
135
  try {
134
136
  const res = await fetch(`${httpBase(this.url)}/readyz`, {
135
137
  signal: AbortSignal.timeout(2000),
138
+ redirect: "error",
136
139
  });
137
140
  return res.ok;
138
141
  } catch {
@@ -283,7 +286,7 @@ export class CodexAppServerClient {
283
286
  }
284
287
 
285
288
  this.#assertConnectionEpoch(epoch);
286
- const ws = new WebSocket(this.url);
289
+ const ws = new WebSocket(assertAllowedAppServerUrl(this.url).href);
287
290
  this.ws = ws;
288
291
  try {
289
292
  await new Promise((resolve, reject) => {
@@ -0,0 +1,23 @@
1
+ export function bridgeReadiness({ sender, scope, targetCwd, nativeAvailable = true, verifyTargetProject }) {
2
+ const issues = [];
3
+ if (sender?.status !== "verified") issues.push({ code: "SENDER_UNVERIFIED", detail: sender?.reason ?? "No current Code session identity", action: "Load the user-level Claude Code bridge entry in this existing task; a shared Desktop entry cannot identify the caller. Run the Code registration check, then reload the affected client." });
4
+ const projectPolicy = scope?.status?.(sender?.status === "verified" ? sender.cwd : undefined) ?? null;
5
+ if (projectPolicy?.error) issues.push({ code: "PROJECT_SCOPE_INVALID", detail: projectPolicy.error, action: "Check the shared project policy. Authorize the intended project once; never change the sending task to work around a refusal." });
6
+ let target = null;
7
+ if (targetCwd) {
8
+ try {
9
+ const binding = scope.capture?.(targetCwd);
10
+ target = { ...(binding ?? {}), cwd: binding?.path ?? scope.assert(targetCwd), authorized: true };
11
+ }
12
+ catch (error) { target = { cwd: targetCwd, authorized: false }; issues.push({ code: "TARGET_NOT_AUTHORIZED", detail: error.message, action: "Authorize this project or its parent directory once in the shared project policy." }); }
13
+ }
14
+ if (target?.authorized && sender?.status === "verified" && verifyTargetProject) {
15
+ try { verifyTargetProject(sender.cwd, target.cwd); target.sameProject = true; }
16
+ catch (error) {
17
+ target.sameProject = false;
18
+ issues.push({ code: error.code ?? "PROJECT_SCOPE_UNVERIFIED", detail: error.message, action: "Select a destination in the verified sender's project. A project grant does not authorize cross-project delivery." });
19
+ }
20
+ }
21
+ if (!nativeAvailable) issues.push({ code: "RELAY_UNAVAILABLE", action: "Open Codex Desktop and check native_relay_status in its existing task." });
22
+ return { ready: issues.length === 0, readyScope: "transport-identity-and-project", hostToolPermission: "evaluated-by-client-at-call-time", sender, projectPolicy, target, issues };
23
+ }
@@ -0,0 +1,125 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { projectRevocationEntry, scopeEntry, updateProjectPolicy } from './project-policy.mjs';
5
+
6
+ const empty = () => ({ projects: [], parents: [], excluded: [] });
7
+ const key = p => process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p);
8
+ const same = (a, b) => key(a) === key(b);
9
+ const includes = (list, p) => list.some(v => same(v, p));
10
+ function canonicalSettingsPath(value) {
11
+ // Policy entries use real directory paths. Native pages can use /var aliases
12
+ // or Windows short names; compare them in the same namespace. Keep missing
13
+ // paths removable by resolving their nearest existing ancestor, not dropping
14
+ // the record (in particular a revoked or deleted project).
15
+ let current = path.resolve(value);
16
+ const suffix = [];
17
+ for (;;) {
18
+ try { return path.join(fs.realpathSync.native(current), ...suffix); }
19
+ catch (error) {
20
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
21
+ const parent = path.dirname(current);
22
+ if (parent === current) return path.resolve(value);
23
+ suffix.unshift(path.basename(current));
24
+ current = parent;
25
+ }
26
+ }
27
+ }
28
+ function normalize(page) {
29
+ const output = empty();
30
+ for (const field of Object.keys(output)) {
31
+ if (!Array.isArray(page?.[field]) || page[field].length > 512) throw new Error('Invalid card settings list: ' + field);
32
+ for (const item of page[field]) {
33
+ if (typeof item !== 'string' || item.length > 32768) throw new Error('Invalid card settings path');
34
+ const value = item.trim();
35
+ if (!value) continue;
36
+ if (!path.isAbsolute(value)) throw new Error('Card settings paths must be absolute');
37
+ const canonical = canonicalSettingsPath(value);
38
+ if (!includes(output[field], canonical)) output[field].push(canonical);
39
+ }
40
+ output[field].sort((a, b) => key(a).localeCompare(key(b)));
41
+ }
42
+ return output;
43
+ }
44
+ const equal = (a, b) => Object.keys(empty()).every(k => a[k].length === b[k].length && a[k].every(p => includes(b[k], p)));
45
+ const pageHash = page => createHash('sha256').update(JSON.stringify(Object.fromEntries(Object.entries(page).map(([k, v]) => [k, v.map(key).sort()])))).digest('hex');
46
+ const fingerprint = policy => createHash('sha256').update(JSON.stringify({ grants: policy.grants, denies: policy.denies })).digest('hex');
47
+ const projection = policy => normalize({ projects: policy.grants.filter(g => g.kind === 'project').map(g => g.path),
48
+ parents: policy.grants.filter(g => g.kind === 'parent').map(g => g.path), excluded: policy.denies.map(g => g.path) });
49
+ const sameEntry = (a, b) => same(a.path, b.path) || (a.repository && b.repository && same(a.repository.path, b.repository.path) && a.repository.identity === b.repository.identity);
50
+
51
+ // This optional UI adapter is not read by messaging permission checks. Keep the
52
+ // acknowledgement in the same atomic file as grants so a crash cannot replay an
53
+ // old settings save as a fresh permission change. No card grants or trust flags
54
+ // are copied into the messaging policy.
55
+ export function syncCardSettings(policyFile, input, options = {}) {
56
+ const page = normalize(input);
57
+ let result;
58
+ updateProjectPolicy(policyFile, policy => {
59
+ const saved = policy.uiAdapters?.localCardDesktop;
60
+ if (saved && saved.version !== 1) throw new Error('Unsupported card settings acknowledgement');
61
+ const previous = saved ? normalize(saved.page) : empty();
62
+ const current = fingerprint(policy);
63
+ const priorRenders = saved?.renderTargets ?? [];
64
+ if (!Array.isArray(priorRenders) || priorRenders.length > 16 || priorRenders.some(v => !/^[a-f0-9]{64}$/.test(v))) throw new Error('Invalid settings render acknowledgement');
65
+ const rendered = saved && (equal(page, normalize(saved.target)) || priorRenders.includes(pageHash(page)));
66
+ const changed = !equal(page, previous);
67
+ // Reinstalling the extension resets its page to blank while this acknowledgement
68
+ // survives, so a blank page cannot be told apart from "remove everything". Treat
69
+ // it like a first page: import the current lists and revoke nothing. A last
70
+ // project can still be revoked explicitly through the exclusion list.
71
+ const blank = Object.values(page).every(list => list.length === 0);
72
+ const reset = saved && changed && blank && !rendered && !options.acknowledgeRender;
73
+ const conflict = saved && changed && !rendered && !reset && (saved.renderOverflow || current !== saved.authorizationFingerprint);
74
+ const warnings = [];
75
+ if (conflict) warnings.push('通信授权刚被其他入口修改,本次页面更改未应用;先同步最新页面,再修改。');
76
+ if (reset) warnings.push('通信页面为空,未撤销任何授权,已按现有授权重新填充。确需撤销,请把项目移入通信禁止列表后保存。');
77
+ if (changed && !rendered && !conflict && !reset && !options.acknowledgeRender) {
78
+ // Removing a displayed exclusion is an explicit UI action. A new grant
79
+ // never implicitly removes an exclusion that the page has not shown.
80
+ const removedExclusions = previous.excluded.filter(p => !includes(page.excluded, p));
81
+ policy.denies = policy.denies.filter(d => !includes(removedExclusions, d.path));
82
+ for (const [field, kind] of [['projects', 'project'], ['parents', 'parent']]) {
83
+ const removed = previous[field].filter(p => !includes(page[field], p));
84
+ const oldGrants = policy.grants.filter(g => g.kind === kind && includes(removed, g.path));
85
+ policy.grants = policy.grants.filter(g => !(g.kind === kind && includes(removed, g.path)));
86
+ // Removing one project revokes it even under a granted parent. Removing
87
+ // a parent only removes that broad grant; exact project grants survive.
88
+ if (kind === 'project') for (const grant of oldGrants) {
89
+ if (!policy.denies.some(d => sameEntry(d, grant))) policy.denies.push(projectRevocationEntry(grant, options));
90
+ }
91
+ for (const p of page[field].filter(p => !includes(previous[field], p))) {
92
+ if (policy.grants.some(g => g.kind === kind && same(g.path, p))) continue;
93
+ const entry = scopeEntry(p, kind, options);
94
+ if (policy.denies.some(d => sameEntry(d, entry))) {
95
+ warnings.push(`项目仍被禁止,未恢复授权:${p}。请先从通信禁止列表移除,再添加授权。`);
96
+ continue;
97
+ }
98
+ policy.grants = policy.grants.filter(g => !(g.kind === kind && sameEntry(g, entry)));
99
+ policy.grants.push(entry);
100
+ }
101
+ }
102
+ for (const p of page.excluded.filter(p => !includes(previous.excluded, p))) {
103
+ if (policy.denies.some(d => same(d.path, p))) continue;
104
+ const old = policy.grants.find(g => same(g.path, p));
105
+ const entry = projectRevocationEntry(old ?? scopeEntry(p, 'project', options), options);
106
+ policy.grants = policy.grants.filter(g => !sameEntry(g, entry));
107
+ policy.denies.push(entry);
108
+ }
109
+ }
110
+ const target = projection(policy);
111
+ // A detached page helper can finish rendering an older policy after a newer
112
+ // revocation. Remember issued snapshots: that save is display acknowledgement,
113
+ // never a user's request to restore the old grant.
114
+ const aligned = equal(page, target);
115
+ const acknowledged = aligned && (options.acknowledgeRender || (changed && rendered));
116
+ const renderTargets = acknowledged ? [] : aligned ? [...priorRenders] : [...new Set([...priorRenders, pageHash(target)])];
117
+ policy.uiAdapters ??= {};
118
+ policy.uiAdapters.localCardDesktop = { version: 1, page, target, authorizationFingerprint: fingerprint(policy),
119
+ renderTargets: renderTargets.slice(-16), renderOverflow: !acknowledged && (saved?.renderOverflow === true || renderTargets.length > 16) };
120
+ result = { status: conflict ? 'conflict_preserved' : reset ? 'blank_page_imported' : 'synchronized', target,
121
+ needsPageUpdate: !equal(page, target), warnings, authorizationChanged: current !== fingerprint(policy),
122
+ detail: 'Only messaging lists were reconciled. Card grants and workspace trust are unchanged.' };
123
+ });
124
+ return result;
125
+ }
@@ -10,13 +10,13 @@ import { PLATFORM_LABEL, openClaudeCodeComposer } from "./platform.mjs";
10
10
  import { PeerEndpoint, assertClaudeSessionCwd, assertClaudeSessionProcess, findClaudeSession, listClaudeSessions, readTranscript, readInitialTranscriptMessage } from "./peer-protocol.mjs";
11
11
  import { createThreadDelivery } from "./thread-delivery.mjs";
12
12
  import { exitForVersionRequest } from "./cli-version.mjs";
13
- import { desktopTasksConfigured } from "./native-relay.mjs";
13
+ import { desktopTasksConfigured, NativeDesktopRelay, desktopTaskSocketPath, accountRelaySocketPath } from "./native-relay.mjs";
14
14
  import { createRuntimeState } from "./runtime-state.mjs";
15
15
  import { readClaudeDesktopContext, readClaudeDesktopTasks } from "./claude-desktop-context.mjs";
16
16
  import { ClaudeSessionCreation } from "./claude-session-creation.mjs";
17
17
  import { readClaudeInboundPolicy } from "./claude-inbound-policy.mjs";
18
18
  import { assertRecipientClass, preflightFailure } from "./recipient-preflight.mjs";
19
- import { readCodexSenderContext } from "./codex-sender-context.mjs";
19
+ import { resolveCodexSenderContext } from "./codex-sender-context.mjs";
20
20
  import { ReplyForwarder } from "./reply-forwarder.mjs";
21
21
  import { assertRoutingReload, clientReloadReason, createReloadControl } from "./reload-control.mjs";
22
22
  import { readClaudeAccountContext } from "./desktop-account-context.mjs";
@@ -25,10 +25,11 @@ import { recoverClaudeDesktopSession, resolveClaudeDesktopSession, sameClaudeDes
25
25
  import { submitClaudeCodeComposer, trustClaudeCodeComposer } from "./claude-composer-submit.mjs";
26
26
  import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
27
27
  import { AGENT_PROMPT_GUIDANCE, PROMPT_FIELD_HINT } from "./prompt-guidance.mjs";
28
+ import { registerProjectOnboardingTools } from "./project-onboarding-tools.mjs";
28
29
 
29
30
  exitForVersionRequest(import.meta.url);
30
31
 
31
- const VERSION = "1.20.1";
32
+ const VERSION = "1.21.0";
32
33
  void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
33
34
  const FORWARD_MIN_INTERVAL_MS = 5000;
34
35
  const FORWARD_MAX_PER_SESSION = 50;
@@ -216,8 +217,20 @@ function assertDesktopTask(session, expectedTaskId) {
216
217
  }
217
218
  }
218
219
 
219
- function assertSender(meta) {
220
- const sender = readCodexSenderContext(meta);
220
+ const senderRelay = new NativeDesktopRelay({ socketPath: desktopTaskSocketPath(), accountSocketPath: accountRelaySocketPath() });
221
+ async function readSender(meta) {
222
+ return resolveCodexSenderContext(meta, { inspectDesktopTask: async (threadId) => {
223
+ const accounts = requireBridgeAccounts(readBridgeAccounts());
224
+ const { result } = await senderRelay.requestDesktop("read_thread", {
225
+ threadId, hostId: "local", turnLimit: 1, includeOutputs: false, maxOutputCharsPerItem: 1,
226
+ }, { accountContext: accounts, beforeSend: () => assertAccountIdentity(accounts) });
227
+ assertAccountIdentity(accounts);
228
+ return { thread: result?.thread, latestTurnId: result?.turns?.[0]?.id ?? null };
229
+ } });
230
+ }
231
+
232
+ async function assertSender(meta) {
233
+ const sender = await readSender(meta);
221
234
  if (sender.status !== "verified") {
222
235
  throw preflightFailure("CODEX_SENDER_CONTEXT_UNVERIFIED", `${sender.reason} Check claude_bridge_status in the calling Codex Desktop task. Manual relay binding and a global permission override do not establish the sender's permissions.`);
223
236
  }
@@ -233,7 +246,7 @@ function assertCreationRoots(requestId, sender) {
233
246
  async function startClaudeCreation({ requestId, cwd, prompt, autoSubmit = process.platform === "win32", trustProject = process.env.CLAUDE_BRIDGE_AUTO_TRUST_PROJECTS === "1" }, meta) {
234
247
  runtime.assertCurrent();
235
248
  if (!desktopOnly) throw new Error("New Claude sessions require Desktop-only mode");
236
- const sender = assertSender(meta);
249
+ const sender = await assertSender(meta);
237
250
  const accounts = readBridgeAccounts();
238
251
  const selectedAccounts = requireBridgeAccounts(accounts);
239
252
  if (rootPolicy.enabled && creations.requests.has(requestId)) assertCreationRoots(requestId, sender);
@@ -242,13 +255,17 @@ async function startClaudeCreation({ requestId, cwd, prompt, autoSubmit = proces
242
255
  recipient: rootPolicy.capture(cwd, "Claude creation working directory"),
243
256
  } : null;
244
257
  const receipt = await creations.start({ requestId, cwd, prompt, autoSubmit, trustProject, account: accounts.claude, senderThreadId: sender.threadId,
245
- beforeOpen: () => {
258
+ beforeOpen: async () => {
246
259
  runtime.assertCurrent();
247
260
  assertAccountIdentity(selectedAccounts);
248
261
  recheckScopeBindings(scopeBindings);
249
262
  if (rootPolicy.enabled && !rootPolicy.same(cwd, scopeBindings.recipient.path)) throw new Error("The Claude creation working directory changed before opening Desktop");
250
- const current = assertSender(meta);
263
+ const current = await assertSender(meta);
251
264
  if (JSON.stringify(current) !== JSON.stringify(sender)) throw new Error("The calling Codex turn changed before opening Claude Desktop");
265
+ runtime.assertCurrent();
266
+ assertAccountIdentity(selectedAccounts);
267
+ recheckScopeBindings(scopeBindings);
268
+ if (rootPolicy.enabled && !rootPolicy.same(cwd, scopeBindings.recipient.path)) throw new Error("The Claude creation working directory changed before opening Desktop");
252
269
  },
253
270
  });
254
271
  if (rootPolicy.enabled) creationRootBindings.set(requestId, scopeBindings);
@@ -256,7 +273,7 @@ async function startClaudeCreation({ requestId, cwd, prompt, autoSubmit = proces
256
273
  }
257
274
 
258
275
  async function inspectClaudeCreation(requestId, meta) {
259
- const sender = assertSender(meta);
276
+ const sender = await assertSender(meta);
260
277
  assertCreationRoots(requestId, sender);
261
278
  const accounts = readBridgeAccounts();
262
279
  requireBridgeAccounts(accounts);
@@ -314,6 +331,13 @@ function registerTool(name, definition, handler) {
314
331
  });
315
332
  }
316
333
 
334
+ registerProjectOnboardingTools(registerTool, { beforePrepare: async (extra) => {
335
+ runtime.assertCurrent();
336
+ if (!desktopOnly) throw new Error("Project onboarding requires a verified Desktop caller");
337
+ await assertSender(extra?._meta);
338
+ requireBridgeAccounts(readBridgeAccounts());
339
+ } });
340
+
317
341
  registerTool(
318
342
  "start_claude_session",
319
343
  {
@@ -351,7 +375,7 @@ registerTool(
351
375
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
352
376
  },
353
377
  async ({ requestId }, extra) => {
354
- const sender = assertSender(extra?._meta);
378
+ const sender = await assertSender(extra?._meta);
355
379
  assertCreationRoots(requestId, sender);
356
380
  const accounts = readBridgeAccounts();
357
381
  requireBridgeAccounts(accounts);
@@ -443,7 +467,7 @@ registerTool(
443
467
  runtime.assertCurrent();
444
468
  if (target === "new") {
445
469
  if (expectedTaskId !== undefined) throw new Error("A new Claude conversation cannot target an existing task ID");
446
- const sender = assertSender(extra?._meta);
470
+ const sender = await assertSender(extra?._meta);
447
471
  const creationId = requestId ?? createHash("sha256").update(JSON.stringify([sender.threadId, expectedCwd, message])).digest("hex");
448
472
  return await startClaudeCreation({ requestId: creationId, cwd: expectedCwd, prompt: message, autoSubmit, trustProject }, extra?._meta);
449
473
  }
@@ -458,8 +482,8 @@ registerTool(
458
482
  listTasks: () => readClaudeDesktopTasks({ account: accounts.claude }),
459
483
  listSessions: () => listClaudeSessions().filter((entry) => entry.pid !== process.pid),
460
484
  open: (taskId) => openClaudeCodeComposer(`claude://code/continue?session=${encodeURIComponent(taskId)}`),
461
- beforeOpen: () => {
462
- const currentSender = assertSender(extra?._meta);
485
+ beforeOpen: async () => {
486
+ const currentSender = await assertSender(extra?._meta);
463
487
  if (!recoverySender) {
464
488
  recoverySender = currentSender;
465
489
  recoveryBindings = rootPolicy.enabled ? { sender: rootPolicy.capture(recoverySender.cwd, "Codex sender working directory"), recipient: rootPolicy.capture(expectedCwd, "Claude recipient working directory") } : null;
@@ -481,7 +505,7 @@ registerTool(
481
505
  assertDesktopTask(session, selectedTaskId);
482
506
  assertClaudeSessionProcess(session);
483
507
  }
484
- const sender = desktopOnly ? assertSender(extra?._meta) : null;
508
+ const sender = desktopOnly ? await assertSender(extra?._meta) : null;
485
509
  const scopeBindings = rootPolicy.enabled ? {
486
510
  sender: rootPolicy.capture(sender?.cwd, "Codex sender working directory"),
487
511
  recipient: rootPolicy.capture(session.cwd, "Claude recipient working directory"),
@@ -491,14 +515,14 @@ registerTool(
491
515
 
492
516
  const wait = waitSec ?? 180;
493
517
  const desktop = session.entrypoint === "claude-desktop";
494
- const text = desktop ? `${message}\n\n[Bridge response routing: reply with ordinary text in this conversation. The bridge reads the response associated with this message from the local transcript; no cross-session reply tool is needed.]` : message;
518
+ const text = desktop ? `${message}\n\n[Bridge response routing: reply with ordinary text in this conversation. The bridge reads the response associated with this message from the local transcript; no cross-session reply tool is needed. For native child tasks, check completion with native session/event tools. Do not infer completion by regex-matching JSON field order or leave an unbounded custom watcher running.]` : message;
495
519
  const { msgId, reply, delivery } = await peer.sendAndWait(session.socket, text, {
496
520
  timeoutMs: wait * 1000,
497
521
  ...(selectedAccounts ? { accountContext: selectedAccounts } : {}),
498
522
  ...(sender ? { permissionMode: sender.mode, replyThreadId: sender.threadId, senderReview: sender.review, senderApprovalPolicy: sender.approvalPolicy } : {}),
499
523
  ...(desktop ? { recipient: { permissionMode: session.desktop.permissionMode ?? null, permissionClass: session.desktop.permissionClass ?? null, inboundPolicy: session.inbound?.value ?? null } } : {}),
500
524
  ...(scopeBindings ? { scopeBindings } : {}),
501
- beforeSend: () => {
525
+ beforeSend: async () => {
502
526
  runtime.assertCurrent();
503
527
  if (desktopOnly) assertAccountIdentity(selectedAccounts);
504
528
  const current = findClaudeSession(session.sessionId ?? String(session.pid), { desktopOnly });
@@ -514,7 +538,7 @@ registerTool(
514
538
  assertClaudeSessionProcess(current);
515
539
  const refreshed = withDesktopContext(current, readClaudeAccountContext());
516
540
  assertDesktopTask(refreshed, selectedTaskId);
517
- const active = assertSender(extra?._meta);
541
+ const active = await assertSender(extra?._meta);
518
542
  if (rootPolicy.enabled) {
519
543
  rootPolicy.recheck(scopeBindings.sender, "Codex sender working directory");
520
544
  rootPolicy.recheck(scopeBindings.recipient, "Claude recipient working directory");
@@ -531,6 +555,10 @@ registerTool(
531
555
  });
532
556
  const status = reply ? "reply_received" : delivery?.status ?? (wait === 0 ? "sent_unconfirmed" : "reply_timeout");
533
557
  const receipt = { status, msgId, target: session.name ?? String(session.pid), sessionId: session.sessionId, cwd: session.cwd, entrypoint: session.entrypoint, waitSec: wait,
558
+ ...(delivery?.status === "interrupted" ? { pending: false, outcome: "unknown", waitEnded: true,
559
+ taskCancelled: false, retrySafe: false, nextAction: "inspect_existing_task_then_follow_up",
560
+ reason: delivery.reason, interruption: { reasonCode: delivery.reasonCode, source: delivery.source,
561
+ observedAt: delivery.observedAt, entryIds: delivery.interruptedBy, backgroundTaskIds: delivery.backgroundTaskIds } } : {}),
534
562
  ...(selectedAccounts ? { accountContext: selectedAccounts, rediscovered: target === "auto" || ![session.name, String(session.pid), session.sessionId].includes(target) } : {}),
535
563
  ...(desktop ? { taskId: session.desktop.taskId, title: session.desktop.title, approvalUi: "unverified", recipientPermissionMode: session.desktop.permissionMode ?? null, recipientPermissionClass: session.desktop.permissionClass ?? null, recipientInboundPolicy: session.inbound?.value ?? null } : {}),
536
564
  ...(sender ? { senderMode: sender.mode, senderApprovalPolicy: sender.approvalPolicy, senderThreadId: sender.threadId, senderTurnId: sender.turnId, senderReview: sender.review } : {}),
@@ -543,6 +571,10 @@ registerTool(
543
571
  }
544
572
  const targetLabel = `${session.desktop?.title ?? session.name ?? session.pid} (pid ${session.pid}, session ${session.sessionId ?? "?"}, via ${session.entrypoint ?? "unknown"}, cwd ${session.cwd ?? "?"})`;
545
573
 
574
+ if (!reply && delivery?.status === "interrupted") {
575
+ return result(`Reply waiting ended for ${targetLabel} because a new conversation command interrupted the original request.\nMessage id: ${msgId}\nThe original task was not cancelled and its result remains unknown. Inspect that existing session/card, then send a scoped follow-up if needed. Do not resend the original action or create a replacement task. This is not a permission rejection.`, true);
576
+ }
577
+
546
578
  if (!reply && delivery && delivery.status !== "delivered") {
547
579
  const classes = desktop && sender ? `Recipient task mode: ${session.desktop.permissionMode ?? "unknown"}${session.desktop.permissionClass ? ` (${session.desktop.permissionClass} class)` : ""}; this sender attested ${sender.mode}.\n` : "";
548
580
  return result(`Claude inbox reported ${delivery.status} for ${targetLabel}.\n${delivery.reason}\n${classes}Message id: ${msgId}\nInspect read_claude_delivery with this message ID. Do not resend, change the sender permission class, or alter recipient permissions to bypass this receipt. The approval UI has not been verified; Claude Desktop declares no peer approval dialog, so do not tell the user an approval button exists without inspecting this exact Desktop task.`, true);
@@ -571,7 +603,7 @@ registerTool(
571
603
  "read_claude_delivery",
572
604
  {
573
605
  title: "Inspect a Claude message receipt without resending",
574
- description: "Read the latest recipient control receipt or correlated reply for a message sent by this MCP process. Also accepts the 64-character requestId returned by target new to inspect creation without reopening. Unknown IDs do not prove non-delivery; retain the original receipt after a reconnect and inspect the existing Claude session before any resend.",
606
+ description: "Read the latest recipient control receipt or correlated reply for a message sent by this MCP process. An interrupted receipt ends only reply waiting: task outcome remains unknown and background work may still run. Inspect the existing task before any scoped follow-up; never resend the original action automatically. Ordinary timeouts remain pending. Also accepts the 64-character requestId returned by target new to inspect creation without reopening. Unknown IDs do not prove non-delivery; retain the original receipt after a reconnect and inspect the existing Claude session before any resend.",
575
607
  inputSchema: { msgId: z.string().describe("The original message ID returned by send_to_claude_session") },
576
608
  annotations: { readOnlyHint: true, openWorldHint: false },
577
609
  },
@@ -719,7 +751,8 @@ registerTool(
719
751
  const visibleInbox = peer.inbox.filter((record) => recordVisible(record, accounts));
720
752
  const visiblePending = [...peer.pendingMessages.keys()].filter((id) => readReceipt(id));
721
753
  const visibleForwarding = replyForwarder.status((record) => recordVisible(record, accounts));
722
- const sender = desktopOnly ? readCodexSenderContext(extra?._meta) : null;
754
+ const sender = desktopOnly ? await readSender(extra?._meta) : null;
755
+ const projectPolicy = rootPolicy.status?.(sender?.status === "verified" ? sender.cwd : undefined);
723
756
  const lines = [
724
757
  `platform: ${PLATFORM_LABEL} (${process.platform}/${process.arch})`,
725
758
  `bridge: claude-bridge ${VERSION}`,
@@ -731,6 +764,7 @@ registerTool(
731
764
  ...(sender?.permissionProfile ? [`sender permission profile: ${sender.permissionProfile}`, `sender approval reviewer: ${sender.approvalsReviewer}`] : []),
732
765
  ...(sender?.review ? [`sender auto review: ${sender.review.autoReview}`, `sender Node REPL review: ${sender.review.nodeReplReview}`] : []),
733
766
  `session policy: ${desktopOnly ? "desktop-only" : "all Claude Code entrypoints"}`,
767
+ ...(projectPolicy ? [`project policy: ${projectPolicy.file}; ${projectPolicy.error ?? `${projectPolicy.grants} grants, ${projectPolicy.denies} revocations; live per operation`}`] : []),
734
768
  `Claude account: ${accounts.claude.status}${accounts.claude.fingerprint ? ` (${accounts.claude.fingerprint.slice(0, 12)})` : ` - ${accounts.claude.reason}`}`,
735
769
  `Codex account: ${accounts.codex.status}${accounts.codex.fingerprint ? ` (${accounts.codex.fingerprint.slice(0, 12)})` : ` - ${accounts.codex.reason}`}`,
736
770
  `live sessions: ${eligible.length}`,
@@ -745,7 +779,7 @@ registerTool(
745
779
  ];
746
780
  const state = runtime.status();
747
781
  lines.push(`runtime pid: ${state.pid}`, `loaded source: ${state.revision}`, `disk source: ${state.diskRevision ?? "unreadable"}`, `runtime state: ${state.current ? "current" : `STALE - ${state.reason}; reconnect this MCP server in the existing task`}`);
748
- return { ...textResult(lines.join("\n"), !state.current), structuredContent: { runtime: state, accounts: { claude: publicAccountState(accounts.claude), codex: publicAccountState(accounts.codex) }, discovery, replyForwarding: visibleForwarding, ...(sender ? { sender } : {}) } };
782
+ return { ...textResult(lines.join("\n"), !state.current || Boolean(projectPolicy?.error)), structuredContent: { runtime: state, accounts: { claude: publicAccountState(accounts.claude), codex: publicAccountState(accounts.codex) }, discovery, replyForwarding: visibleForwarding, ...(projectPolicy ? { projectPolicy } : {}), ...(sender ? { sender } : {}) } };
749
783
  } catch (err) {
750
784
  return failure(err);
751
785
  }
@@ -0,0 +1,54 @@
1
+ import path from "node:path";
2
+
3
+ function worker(entry) {
4
+ return entry?.args?.length === 2 && path.basename(entry.args[0]) === "mcp-supervisor.mjs" && entry.args[1] === "index.mjs";
5
+ }
6
+ const stable = (value) => JSON.stringify(value, Object.keys(value ?? {}).sort());
7
+
8
+ export function planCodeRegistration({ code, desktop, policyFile, name = "codex-bridge" }) {
9
+ if (!path.isAbsolute(policyFile)) throw new Error("Shared project policy path must be absolute");
10
+ const source = code.mcpServers?.[name];
11
+ if (!worker(source)) throw new Error(`Expected an existing supervised ${name} Code registration; no configuration was changed`);
12
+ if (source.env?.CODEX_BRIDGE_HARDENED === "1") throw new Error("Keep the hardened installation's pinned roots; automatic policy migration is not supported");
13
+ const updated = structuredClone(code);
14
+ const updatedDesktop = structuredClone(desktop);
15
+ const replacement = { ...source, env: { ...source.env, CODEX_BRIDGE_PROJECT_POLICY: policyFile } };
16
+ // The explicitly selected shared policy becomes the scope authority. Keep legacy
17
+ // roots intact for rollback, and preserve every other setting and restriction.
18
+ updated.mcpServers[name] = replacement;
19
+ const changes = [{ kind: "user-code-entry", name }];
20
+ const comparable = (entry) => {
21
+ const normalized = structuredClone(entry);
22
+ delete normalized.env?.CODEX_BRIDGE_ALLOWED_ROOTS;
23
+ delete normalized.env?.CODEX_BRIDGE_PROJECT_POLICY;
24
+ return JSON.stringify({ ...normalized, env: stable(normalized.env) });
25
+ };
26
+ for (const [project, config] of Object.entries(updated.projects ?? {})) {
27
+ for (const [alias, entry] of Object.entries(config.mcpServers ?? {})) {
28
+ if (!worker(entry) || path.resolve(entry.args[0]) !== path.resolve(source.args[0])) continue;
29
+ if (comparable(entry) !== comparable(source)) throw new Error(`Project ${project} has a customized ${alias} entry; review its settings instead of replacing it`);
30
+ delete config.mcpServers[alias];
31
+ changes.push({ kind: "remove-duplicate-project-entry", project, name: alias });
32
+ }
33
+ }
34
+ for (const [alias, entry] of Object.entries(updatedDesktop.mcpServers ?? {})) {
35
+ if (!worker(entry) || path.resolve(entry.args[0]) !== path.resolve(source.args[0])) continue;
36
+ if (comparable(entry) !== comparable(source)) throw new Error(`Shared Desktop entry ${alias} has customized settings; review it before migration`);
37
+ delete updatedDesktop.mcpServers[alias];
38
+ changes.push({ kind: "remove-shared-desktop-entry", name: alias });
39
+ }
40
+ return { code: updated, desktop: updatedDesktop, entry: replacement, changes };
41
+ }
42
+
43
+ export function checkCodeRegistration({ code, desktop, name = "codex-bridge" }) {
44
+ const entry = code.mcpServers?.[name];
45
+ const issues = [];
46
+ if (!worker(entry)) issues.push("User-level Claude Code bridge is missing or does not use the supervisor");
47
+ const shared = Object.keys(desktop.mcpServers ?? {}).filter((key) => worker(desktop.mcpServers[key]));
48
+ if (shared.length) issues.push(`Shared Desktop bridge entries can hide the Code caller identity: ${shared.join(", ")}`);
49
+ const overrides = Object.entries(code.projects ?? {}).flatMap(([project, config]) => Object.entries(config.mcpServers ?? {})
50
+ .filter(([, item]) => worker(item)).map(([alias]) => ({ project, alias })));
51
+ if (overrides.length) issues.push("Project-specific bridge entries override or duplicate the user-level registration");
52
+ if (!entry?.env?.CODEX_BRIDGE_PROJECT_POLICY) issues.push("No shared live project policy configured");
53
+ return { ready: issues.length === 0, issues, shared, overrides, policyFile: entry?.env?.CODEX_BRIDGE_PROJECT_POLICY ?? null };
54
+ }