@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.
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +26 -0
- package/README.md +57 -15
- package/assets/README.md +30 -0
- package/assets/agent-pets.svg +1 -0
- package/docs/daily-handoffs.md +106 -0
- package/docs/desktop-handoff-design.md +180 -0
- package/docs/new-machine-setup.md +112 -0
- package/docs/project-onboarding.md +122 -0
- package/package.json +8 -2
- package/plugin.json +1 -1
- package/scripts/bridge-projects.mjs +44 -0
- package/scripts/check.mjs +2 -1
- package/scripts/collect-repo-analytics.mjs +5 -2
- package/scripts/configure-message-automation.mjs +18 -0
- package/scripts/install-claude-code.mjs +64 -0
- package/scripts/install-claude-desktop.mjs +48 -5
- package/scripts/install-native-relay.mjs +6 -1
- package/scripts/install-npm-footer.mjs +13 -1
- package/scripts/publish-repo-analytics.mjs +24 -1
- package/src/app-server-client.mjs +5 -2
- package/src/bridge-readiness.mjs +23 -0
- package/src/card-settings-sync.mjs +125 -0
- package/src/claude-bridge.mjs +54 -20
- package/src/claude-code-registration.mjs +54 -0
- package/src/claude-sender-context.mjs +1 -1
- package/src/claude-session-creation.mjs +10 -2
- package/src/codex-mcp-registration.mjs +1 -1
- package/src/codex-native-response.mjs +139 -107
- package/src/codex-sender-context.mjs +62 -61
- package/src/desktop-reply-receipts.mjs +87 -0
- package/src/desktop-task-receipts.mjs +30 -7
- package/src/hardened-root-policy.mjs +5 -0
- package/src/index.mjs +97 -19
- package/src/mcp-supervisor.mjs +6 -2
- package/src/message-automation.mjs +89 -0
- package/src/native-creation-receipts.mjs +155 -0
- package/src/native-relay-companion.mjs +37 -6
- package/src/native-relay.mjs +12 -3
- package/src/peer-protocol.mjs +192 -18
- package/src/project-manager-registration.mjs +34 -0
- package/src/project-onboarding-tools.mjs +30 -0
- package/src/project-onboarding.mjs +178 -0
- package/src/project-policy.mjs +221 -0
- package/src/project-scope.mjs +33 -5
- package/src/prompt-guidance.mjs +2 -0
- package/src/release-snapshot.mjs +30 -5
- package/src/rollout-reader.mjs +58 -0
- package/src/rollout-segments.mjs +65 -0
- package/src/security-policy.mjs +4 -2
- package/src/sites-connector.mjs +17 -2
- package/src/telemetry.mjs +10 -8
- 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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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.
|
|
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
|
-
|
|
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\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
|
-
|
|
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
|
+
}
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -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 {
|
|
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.
|
|
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
|
-
|
|
220
|
-
|
|
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 ?
|
|
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
|
+
}
|