@minhspark/codex-mcp-bridge 1.19.2 → 1.19.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/scripts/install-native-relay.mjs +1 -1
- package/scripts/publish-repo-analytics.mjs +10 -2
- package/scripts/refresh-hosted-analytics.mjs +39 -30
- package/src/claude-bridge.mjs +1 -1
- package/src/index.mjs +11 -5
- package/src/native-relay-companion.mjs +1 -1
- package/src/project-scope.mjs +103 -0
- package/src/thread-delivery.mjs +42 -7
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.19.3] - 2026-09-30
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Write the `analytics` branch with the `Repository analytics` workflow's own token. Its hourly commits were pushed with `ANALYTICS_TOKEN`, a personal token, so they were attributed to the repository owner and GitHub showed the owner an "analytics had recent pushes" banner with a "Compare & pull request" button after every run. The commits now come from `github-actions[bot]`. `ANALYTICS_TOKEN` only reads GitHub traffic and no longer needs write access (fine-grained: Administration read).
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Refuse Claude-to-Codex Desktop delivery into another project even when `send_to_codex_thread` omits `cwd` or supplies the recipient's directory. The bridge now compares the independently verified Claude caller with the destination before creation, rename, navigation and prompt delivery, and checks both directory/repository identities and the current native destination again at the socket write. Broad allowed roots and thread overrides cannot authorize a different project. Subfolders and registered worktrees of the same repository remain usable; drive/home repositories do not absorb unrelated projects. Blocked preflight calls report that no Desktop mutation was dispatched.
|
|
16
|
+
|
|
7
17
|
## [1.19.2] - 2026-09-29
|
|
8
18
|
|
|
9
19
|
### Fixed
|
package/README.md
CHANGED
|
@@ -331,7 +331,7 @@ For unresolved failures, [open an issue](https://github.com/buidangminh23/codex-
|
|
|
331
331
|
|
|
332
332
|
## Repository analytics
|
|
333
333
|
|
|
334
|
-
The live dashboard polls the aggregate API every 30 seconds. Installation counts reflect reports received by the server; this is not a count of currently online processes. Public GitHub/npm sources are refreshed with a short cache, but their own statistics may be delayed. GitHub Actions refreshes and archives statistics hourly through the `Repository analytics` workflow, including private GitHub traffic; scheduled runs may be delayed by GitHub. That workflow
|
|
334
|
+
The live dashboard polls the aggregate API every 30 seconds. Installation counts reflect reports received by the server; this is not a count of currently online processes. Public GitHub/npm sources are refreshed with a short cache, but their own statistics may be delayed. GitHub Actions refreshes and archives statistics hourly through the `Repository analytics` workflow, including private GitHub traffic; scheduled runs may be delayed by GitHub. That workflow reads GitHub traffic with the `ANALYTICS_TOKEN` repository secret, a token with push access to this repository (fine-grained: Administration read); the default workflow token cannot read GitHub traffic, so without it the views/clones sources are retained from the last successful run and the run is reported as incomplete while the Pages deployment still succeeds. The `analytics` branch is written with the workflow's own token, so its commits come from `github-actions[bot]` and `ANALYTICS_TOKEN` needs no write access. The README image is a snapshot and may be cached by GitHub; open the live dashboard for automatic updates. Source timestamps show freshness; missing data is unavailable, not zero. Only aggregate figures are published. Installation IDs stay in the private database.
|
|
335
335
|
|
|
336
336
|
Repository owners can view [GitHub traffic](https://github.com/buidangminh23/codex-mcp-bridge/graphs/traffic) for recent views and clones. Downloads and clones include updates, reinstalls, and automation; they do not measure active users. GitHub traffic only covers the recent 14-day window, so collect it regularly to keep a longer history.
|
|
337
337
|
|
package/package.json
CHANGED
|
@@ -22,7 +22,7 @@ import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs
|
|
|
22
22
|
|
|
23
23
|
exitForVersionRequest(import.meta.url);
|
|
24
24
|
|
|
25
|
-
const VERSION = "1.19.
|
|
25
|
+
const VERSION = "1.19.3";
|
|
26
26
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
27
|
const entry = path.join(root, "src", "mcp-supervisor.mjs");
|
|
28
28
|
const entryArgs = ["native-relay-companion.mjs"];
|
|
@@ -150,11 +150,19 @@ export function publicFiles(history, usage) {
|
|
|
150
150
|
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) };
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
export function
|
|
153
|
+
export function githubEnvironment(token, env = process.env) {
|
|
154
|
+
return token ? { ...env, GH_TOKEN: token } : env;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function githubClient(token, api = githubApi) {
|
|
158
|
+
return token ? (endpoint, options = {}) => api(endpoint, { ...options, token }) : api;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function githubApi(endpoint, { method = 'GET', body, token } = {}) {
|
|
154
162
|
return new Promise((resolve, reject) => {
|
|
155
163
|
const args = ['api', '--hostname', 'github.com', '--method', method, endpoint];
|
|
156
164
|
if (body) args.push('--input', '-');
|
|
157
|
-
const child = spawn('gh', args, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
165
|
+
const child = spawn('gh', args, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], env: githubEnvironment(token) });
|
|
158
166
|
let stdout = '';
|
|
159
167
|
let stderr = '';
|
|
160
168
|
const timer = setTimeout(() => child.kill(), 60000);
|
|
@@ -1,37 +1,46 @@
|
|
|
1
1
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
3
4
|
import { collectSources, mergeHistory } from './collect-repo-analytics.mjs';
|
|
4
|
-
import { githubApi, publicFiles, publishAnalytics, sanitizeAnalytics } from './publish-repo-analytics.mjs';
|
|
5
|
+
import { githubApi, githubClient, publicFiles, publishAnalytics, sanitizeAnalytics } from './publish-repo-analytics.mjs';
|
|
5
6
|
import { TELEMETRY_PUBLIC_KEY } from '../src/telemetry-config.mjs';
|
|
6
7
|
|
|
7
8
|
const repo = 'buidangminh23/codex-mcp-bridge';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
await
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
for (const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
9
|
+
|
|
10
|
+
export async function refreshHostedAnalytics({ env = process.env, api = githubApi, collect = collectSources, fetcher = fetch, directory = path.resolve('public-analytics') } = {}) {
|
|
11
|
+
const branch = githubClient(env.ANALYTICS_BRANCH_TOKEN, api);
|
|
12
|
+
const file = await branch(`repos/${repo}/contents/data.json?ref=analytics`);
|
|
13
|
+
const previous = JSON.parse(Buffer.from(file.content, 'base64').toString('utf8'));
|
|
14
|
+
const options = { repo, package: '@minhspark/codex-mcp-bridge' };
|
|
15
|
+
const snapshot = await collect(options);
|
|
16
|
+
const history = mergeHistory(previous, options, snapshot);
|
|
17
|
+
let usage = previous.usage;
|
|
18
|
+
let usageFailed = false;
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetcher('https://vdbwkowdggtcihixowxi.supabase.co/functions/v1/bridge-stats', {
|
|
21
|
+
headers: { apikey: TELEMETRY_PUBLIC_KEY }, signal: AbortSignal.timeout(15000),
|
|
22
|
+
});
|
|
23
|
+
if (!response.ok) throw new Error('Stats unavailable');
|
|
24
|
+
const data = await response.json();
|
|
25
|
+
if (!data.usage || data.errors?.some(row => row.source === 'usage')) throw new Error('Usage unavailable');
|
|
26
|
+
usage = sanitizeAnalytics(history, data.usage).usage;
|
|
27
|
+
} catch { usageFailed = true; }
|
|
28
|
+
const sha = await publishAnalytics(repo, publicFiles(history, usage), branch);
|
|
29
|
+
const ref = await branch(`repos/${repo}/git/ref/heads/analytics`);
|
|
30
|
+
if (ref.object.sha !== sha) throw new Error('Analytics publication changed during verification');
|
|
31
|
+
await mkdir(directory, { recursive: true });
|
|
32
|
+
for (const [name, content] of Object.entries(publicFiles(history, usage))) await writeFile(path.join(directory, name), content);
|
|
33
|
+
return { sha, errors: snapshot.errors, usageFailed };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
37
|
+
const { sha, errors, usageFailed } = await refreshHostedAnalytics();
|
|
38
|
+
console.log(`Published aggregate analytics: ${sha}`);
|
|
39
|
+
for (const error of errors) {
|
|
40
|
+
const status = error.status ? ` (HTTP ${error.status})` : '';
|
|
41
|
+
const hint = error.status === 403 && ['views', 'clones'].includes(error.source) ? ' GitHub traffic requires the ANALYTICS_TOKEN secret to be a token with push access (fine-grained: Administration read); the default workflow token cannot read traffic.' : '';
|
|
42
|
+
console.error(`::warning::${error.source} was not refreshed${status}; previous data retained.${hint}`);
|
|
43
|
+
}
|
|
44
|
+
if (usageFailed) console.error('::warning::Usage was not refreshed; previous data retained.');
|
|
45
|
+
if (errors.length || usageFailed) process.exitCode = 1;
|
|
35
46
|
}
|
|
36
|
-
if (usageFailed) console.error('::warning::Usage was not refreshed; previous data retained.');
|
|
37
|
-
if (snapshot.errors.length || usageFailed) process.exitCode = 1;
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { AGENT_PROMPT_GUIDANCE, PROMPT_FIELD_HINT } from "./prompt-guidance.mjs"
|
|
|
26
26
|
|
|
27
27
|
exitForVersionRequest(import.meta.url);
|
|
28
28
|
|
|
29
|
-
const VERSION = "1.19.
|
|
29
|
+
const VERSION = "1.19.3";
|
|
30
30
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
31
31
|
const FORWARD_MIN_INTERVAL_MS = 5000;
|
|
32
32
|
const FORWARD_MAX_PER_SESSION = 50;
|
package/src/index.mjs
CHANGED
|
@@ -34,7 +34,7 @@ import { AGENT_PROMPT_GUIDANCE, PROMPT_FIELD_HINT } from "./prompt-guidance.mjs"
|
|
|
34
34
|
|
|
35
35
|
exitForVersionRequest(import.meta.url);
|
|
36
36
|
|
|
37
|
-
const VERSION = "1.19.
|
|
37
|
+
const VERSION = "1.19.3";
|
|
38
38
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
39
39
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
40
40
|
|
|
@@ -57,7 +57,7 @@ const security = new BridgeSecurityPolicy();
|
|
|
57
57
|
const desktopTasksEnabled = desktopTasksConfigured();
|
|
58
58
|
const runtime = createRuntimeState({ configuration: desktopTasksConfigured });
|
|
59
59
|
const desktopOperation = new AsyncLocalStorage();
|
|
60
|
-
const desktopTasks = new DesktopTaskDelivery({ security, beforeRequest: beforeDesktopRequest, accountContext: () => desktopOperation.getStore()?.accounts });
|
|
60
|
+
const desktopTasks = new DesktopTaskDelivery({ security, beforeRequest: beforeDesktopRequest, accountContext: () => desktopOperation.getStore()?.accounts, senderContext: () => desktopOperation.getStore()?.caller });
|
|
61
61
|
|
|
62
62
|
async function assertDesktopOperation(context, { verifyProcess = false } = {}) {
|
|
63
63
|
if (!context || context.diagnostic) return;
|
|
@@ -77,7 +77,7 @@ async function beforeDesktopRequest({ operation, args, phase }) {
|
|
|
77
77
|
if (!context) throw new Error("Desktop operations require a verified calling Code session.");
|
|
78
78
|
const mutating = ["create_thread", "send_message_to_thread", "set_thread_title", "navigate_to_codex_page"].includes(operation);
|
|
79
79
|
await assertDesktopOperation(context, { verifyProcess: mutating && phase === "write" });
|
|
80
|
-
if (mutating) {
|
|
80
|
+
if (mutating && phase === "dispatch") {
|
|
81
81
|
context.dispatched = true;
|
|
82
82
|
if (args.threadId) context.threadId = args.threadId;
|
|
83
83
|
}
|
|
@@ -349,7 +349,8 @@ const server = new McpServer(
|
|
|
349
349
|
"A completed turn alone does not mean the task is finished. Do not choose an old task merely because its project matches or it was recently active. " +
|
|
350
350
|
"Without authorization to create a new conversation, ask before creating it. With Desktop tasks enabled it assigns the exact saved project and starts visibly in " +
|
|
351
351
|
"Codex Desktop using Desktop permissions. Otherwise it releases the bridge writer lock and opens the exact thread in " +
|
|
352
|
-
"Codex Desktop.
|
|
352
|
+
"Codex Desktop. Desktop creation and sends always require the verified calling Claude task and the Codex destination to belong to the same project, even when cwd is omitted. " +
|
|
353
|
+
"Use send_to_codex_thread only when an existing threadId is intentional; use " +
|
|
353
354
|
"list_codex_threads or read_codex_thread to inspect sessions and codex_bridge_status to inspect wiring. " +
|
|
354
355
|
AGENT_PROMPT_GUIDANCE,
|
|
355
356
|
},
|
|
@@ -368,6 +369,7 @@ function registerTool(name, definition, handler) {
|
|
|
368
369
|
result.content.push({ type: "text", text: `runtime pid: ${state.pid}\nloaded source: ${state.revision}\nruntime state: current` });
|
|
369
370
|
result.structuredContent = { ...result.structuredContent, runtime: state };
|
|
370
371
|
if (desktopTasksEnabled) {
|
|
372
|
+
result.structuredContent.projectScope = { enabled: true, policy: "verified-sender-same-project", match: "canonical-directory-or-git-repository", checkedBeforeMutation: true };
|
|
371
373
|
result.structuredContent.accounts = { claude: publicAccountState(accounts.claude), codex: publicAccountState(accounts.codex) };
|
|
372
374
|
}
|
|
373
375
|
return result;
|
|
@@ -383,6 +385,9 @@ function registerTool(name, definition, handler) {
|
|
|
383
385
|
await assertDesktopOperation(context);
|
|
384
386
|
const result = await handler(...args);
|
|
385
387
|
await assertDesktopOperation(context);
|
|
388
|
+
if (result.isError && !context.dispatched) {
|
|
389
|
+
result.structuredContent = { ...result.structuredContent, accountContext: context.accounts, operation: { state: "blocked", threadId: context.threadId ?? null } };
|
|
390
|
+
}
|
|
386
391
|
return result;
|
|
387
392
|
} catch (error) {
|
|
388
393
|
return withheldDesktopResult(error, context);
|
|
@@ -513,7 +518,7 @@ registerTool(
|
|
|
513
518
|
.max(3600)
|
|
514
519
|
.optional()
|
|
515
520
|
.describe("How long to observe the task (Desktop caps the entire call at 40s; the task continues and its threadId is returned)"),
|
|
516
|
-
cwd: z.string().optional().describe("
|
|
521
|
+
cwd: z.string().optional().describe("Expected destination directory; Desktop always checks the verified sender's project and cannot change the task's workspace"),
|
|
517
522
|
model: z.string().optional().describe("Override the model for this turn"),
|
|
518
523
|
effort: z
|
|
519
524
|
.enum(["minimal", "low", "medium", "high", "xhigh", "ultra"])
|
|
@@ -950,6 +955,7 @@ registerTool(
|
|
|
950
955
|
`bridge version: ${VERSION}`,
|
|
951
956
|
`node: ${process.version} at ${process.execPath}`,
|
|
952
957
|
"desktop tasks: enabled; Desktop permissions, exact saved project, immediate visibility",
|
|
958
|
+
"project scope: verified Claude sender; same directory or Git repository, checked before each mutation",
|
|
953
959
|
`native relay: ${native.available ? "available; verified through Codex Desktop" : "unavailable"}`,
|
|
954
960
|
`native endpoint: ${native.socketPath}`,
|
|
955
961
|
...(native.available ? [`local projects: ${native.localProjects}`] : [`reason: ${native.reason}`]),
|
|
@@ -34,7 +34,7 @@ import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
|
|
|
34
34
|
|
|
35
35
|
exitForVersionRequest(import.meta.url);
|
|
36
36
|
|
|
37
|
-
const VERSION = "1.19.
|
|
37
|
+
const VERSION = "1.19.3";
|
|
38
38
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
39
39
|
|
|
40
40
|
function errorResponse(code, message, sent) {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
function directory(value) {
|
|
6
|
+
if (typeof value !== "string" || !value.trim() || !path.isAbsolute(value)) throw new Error("Project directory must be an absolute existing directory");
|
|
7
|
+
const resolved = fs.realpathSync.native(value);
|
|
8
|
+
const stat = fs.statSync(resolved, { bigint: true });
|
|
9
|
+
if (!stat.isDirectory()) throw new Error("Project path is not a directory");
|
|
10
|
+
return { input: value, path: resolved, identity: `${stat.dev}:${stat.ino}:${stat.birthtimeNs ?? stat.birthtimeMs}` };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function samePath(left, right) {
|
|
14
|
+
return path.relative(left, right) === "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function metadata(file) {
|
|
18
|
+
const stat = fs.statSync(file);
|
|
19
|
+
if (!stat.isFile() || stat.size > 4096) throw new Error("Invalid project repository metadata");
|
|
20
|
+
return fs.readFileSync(file, "utf8").trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function exists(file) {
|
|
24
|
+
try { fs.lstatSync(file); return true; } catch (error) {
|
|
25
|
+
if (error.code === "ENOENT") return false;
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function gitRepository(gitdir, common = gitdir) {
|
|
31
|
+
const head = metadata(path.join(gitdir.path, "HEAD"));
|
|
32
|
+
if (!/^(?:ref: refs\/[^\s]+|[a-fA-F0-9]{40}|[a-fA-F0-9]{64})$/.test(head)) throw new Error("Invalid project repository HEAD metadata");
|
|
33
|
+
directory(path.join(common.path, "objects"));
|
|
34
|
+
return common;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function repository(cwd) {
|
|
38
|
+
const home = fs.realpathSync.native(os.homedir());
|
|
39
|
+
for (let root = cwd; ; root = path.dirname(root)) {
|
|
40
|
+
if (samePath(root, path.parse(root).root) || samePath(root, home)) return null;
|
|
41
|
+
const marker = path.join(root, ".git");
|
|
42
|
+
if (exists(marker)) {
|
|
43
|
+
if (fs.statSync(marker).isDirectory()) return gitRepository(directory(marker));
|
|
44
|
+
const match = metadata(marker).match(/^gitdir: ([^\r\n]+)$/);
|
|
45
|
+
if (!match) throw new Error("Invalid project gitdir metadata");
|
|
46
|
+
const gitdir = directory(path.resolve(root, match[1]));
|
|
47
|
+
const commonFile = path.join(gitdir.path, "commondir");
|
|
48
|
+
if (!exists(commonFile)) return gitRepository(gitdir);
|
|
49
|
+
const common = gitRepository(directory(path.resolve(gitdir.path, metadata(commonFile))));
|
|
50
|
+
const registered = path.relative(path.join(common.path, "worktrees"), gitdir.path);
|
|
51
|
+
if (!registered || registered === ".." || registered.startsWith(`..${path.sep}`) || path.isAbsolute(registered) || registered.includes(path.sep)) throw new Error("Project worktree is not registered in its common repository");
|
|
52
|
+
const backlink = path.resolve(gitdir.path, metadata(path.join(gitdir.path, "gitdir")));
|
|
53
|
+
if (!samePath(fs.realpathSync.native(backlink), fs.realpathSync.native(marker))) throw new Error("Project worktree backlink does not match its directory");
|
|
54
|
+
return gitRepository(gitdir, common);
|
|
55
|
+
}
|
|
56
|
+
if (path.dirname(root) === root) return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function project(value) {
|
|
61
|
+
const current = directory(value);
|
|
62
|
+
return { ...current, repository: repository(current.path) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sameBinding(left, right) {
|
|
66
|
+
return Boolean(left && right && samePath(left.path, right.path) && left.identity === right.identity);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sameProject(left, right) {
|
|
70
|
+
return sameBinding(left, right) || sameBinding(left.repository, right.repository);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function captureProjectScope(senderCwd, recipientCwd) {
|
|
74
|
+
let sender;
|
|
75
|
+
let recipient;
|
|
76
|
+
try {
|
|
77
|
+
sender = project(senderCwd);
|
|
78
|
+
recipient = project(recipientCwd);
|
|
79
|
+
} catch (cause) {
|
|
80
|
+
const error = new Error(`Project scope could not be verified: ${cause.message}. No message was sent.`, { cause });
|
|
81
|
+
error.code = "PROJECT_SCOPE_UNVERIFIED";
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
if (!sameProject(sender, recipient)) {
|
|
85
|
+
const error = new Error(`Project mismatch: the calling Claude task works in "${sender.path}", but the Codex destination works in "${recipient.path}". Select a task in the sender's project. No message was sent.`);
|
|
86
|
+
error.code = "PROJECT_SCOPE_MISMATCH";
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
return { sender, recipient };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function recheckProjectScope(expected, current = captureProjectScope(expected.sender.input, expected.recipient.input)) {
|
|
93
|
+
for (const side of ["sender", "recipient"]) {
|
|
94
|
+
if (!sameBinding(expected[side], current[side]) ||
|
|
95
|
+
Boolean(expected[side].repository) !== Boolean(current[side].repository) ||
|
|
96
|
+
(expected[side].repository && !sameBinding(expected[side].repository, current[side].repository))) {
|
|
97
|
+
const error = new Error(`The ${side} project directory or repository changed before delivery. No message was sent.`);
|
|
98
|
+
error.code = "PROJECT_SCOPE_CHANGED";
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return current;
|
|
103
|
+
}
|
package/src/thread-delivery.mjs
CHANGED
|
@@ -4,6 +4,8 @@ import { realpathSync } from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { DesktopTaskReceipts } from "./desktop-task-receipts.mjs";
|
|
6
6
|
import { captureCodexRolloutWatermark, inspectCodexNativeTurn, readCodexNativeTurnResponse } from "./codex-native-response.mjs";
|
|
7
|
+
import { captureProjectScope, recheckProjectScope } from "./project-scope.mjs";
|
|
8
|
+
import { requireClaudeSenderContext } from "./claude-sender-context.mjs";
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Which backend puts a message into a Codex thread.
|
|
@@ -48,7 +50,7 @@ export function matchDesktopProject(projects, cwd, { canonicalize = realpathSync
|
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
export class DesktopTaskDelivery {
|
|
51
|
-
constructor({ relay = new NativeDesktopRelay({ socketPath: desktopTaskSocketPath(), accountSocketPath: accountRelaySocketPath() }), security, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now, receipts = new DesktopTaskReceipts(), beforeRequest, accountContext, captureResponse = captureCodexRolloutWatermark, readResponse = readCodexNativeTurnResponse, inspectResponse = inspectCodexNativeTurn } = {}) {
|
|
53
|
+
constructor({ relay = new NativeDesktopRelay({ socketPath: desktopTaskSocketPath(), accountSocketPath: accountRelaySocketPath() }), security, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now, receipts = new DesktopTaskReceipts(), beforeRequest, accountContext, senderContext, captureResponse = captureCodexRolloutWatermark, readResponse = readCodexNativeTurnResponse, inspectResponse = inspectCodexNativeTurn } = {}) {
|
|
52
54
|
this.relay = relay;
|
|
53
55
|
this.security = security;
|
|
54
56
|
this.sleep = sleep;
|
|
@@ -56,25 +58,56 @@ export class DesktopTaskDelivery {
|
|
|
56
58
|
this.receipts = receipts;
|
|
57
59
|
this.beforeRequest = beforeRequest;
|
|
58
60
|
this.accountContext = accountContext;
|
|
61
|
+
this.senderContext = senderContext;
|
|
59
62
|
this.captureResponse = captureResponse;
|
|
60
63
|
this.readResponse = readResponse;
|
|
61
64
|
this.inspectResponse = inspectResponse;
|
|
62
65
|
this.threadOperations = new Map();
|
|
63
66
|
}
|
|
64
67
|
|
|
65
|
-
|
|
68
|
+
senderProjectScope(cwd) {
|
|
69
|
+
if (!this.senderContext) return null;
|
|
70
|
+
const sender = requireClaudeSenderContext(this.senderContext());
|
|
71
|
+
return captureProjectScope(sender.cwd, cwd);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async mutationProjectScope(operation, args, { deadline } = {}) {
|
|
75
|
+
if (!this.senderContext) return null;
|
|
76
|
+
if (operation === "create_thread") {
|
|
77
|
+
const listed = await this.request("list_projects", {}, { deadline });
|
|
78
|
+
const projects = listed?.projects?.filter((project) => project.projectId === args.target?.projectId && project.projectKind === "local" && project.hostId === "local");
|
|
79
|
+
if (projects?.length !== 1) throw new Error("Desktop did not confirm the destination project before creation");
|
|
80
|
+
this.security.assertCwd(projects[0].path);
|
|
81
|
+
return this.senderProjectScope(projects[0].path);
|
|
82
|
+
}
|
|
83
|
+
if (!["send_message_to_thread", "set_thread_title", "navigate_to_codex_page"].includes(operation)) return null;
|
|
84
|
+
const inspected = await this.inspect(args.threadId, undefined, { deadline });
|
|
85
|
+
return inspected.projectScope;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async request(operation, args, { deadline, includeRelayContext = false, projectScope: expectedScope } = {}) {
|
|
66
89
|
try {
|
|
67
90
|
await this.beforeRequest?.({ operation, args });
|
|
91
|
+
const projectScope = expectedScope ?? await this.mutationProjectScope(operation, args, { deadline });
|
|
92
|
+
if (expectedScope) recheckProjectScope(expectedScope, this.senderProjectScope(expectedScope.recipient.input));
|
|
68
93
|
const remaining = deadline === undefined ? undefined : deadline - this.now();
|
|
69
94
|
if (remaining !== undefined && remaining <= 0) throw new Error("The bridge's response deadline has elapsed; this operation was not sent");
|
|
70
95
|
const accountContext = this.accountContext?.();
|
|
71
96
|
const response = await this.relay.requestDesktop(operation, args, {
|
|
72
97
|
...(remaining === undefined ? {} : { timeoutMs: remaining }),
|
|
73
|
-
...(this.beforeRequest ? { beforeSend: () =>
|
|
98
|
+
...(this.beforeRequest || projectScope ? { beforeSend: async () => {
|
|
99
|
+
await this.beforeRequest?.({ operation, args, phase: "write" });
|
|
100
|
+
if (projectScope) {
|
|
101
|
+
recheckProjectScope(projectScope);
|
|
102
|
+
recheckProjectScope(projectScope, await this.mutationProjectScope(operation, args, { deadline }));
|
|
103
|
+
}
|
|
104
|
+
await this.beforeRequest?.({ operation, args, phase: "dispatch" });
|
|
105
|
+
} } : {}),
|
|
74
106
|
...(accountContext ? { accountContext } : {}),
|
|
75
107
|
});
|
|
76
108
|
return includeRelayContext ? { result: response.result, executorThreadId: response.executorThreadId } : response.result;
|
|
77
109
|
} catch (err) {
|
|
110
|
+
if (err.code?.startsWith("PROJECT_SCOPE_") || err.code === "CLAUDE_SENDER_CONTEXT_UNVERIFIED") throw err;
|
|
78
111
|
throw new Error(`Codex Desktop operation ${operation} failed: ${err.message}. Desktop-only mode will not start or use an external app-server. Open Codex Desktop and reconnect its native relay, then inspect the existing task before retrying a send.`, { cause: err });
|
|
79
112
|
}
|
|
80
113
|
}
|
|
@@ -203,6 +236,7 @@ export class DesktopTaskDelivery {
|
|
|
203
236
|
|
|
204
237
|
async create({ cwd, prompt, name, dedupeName = name, requestId, model, effort, deadline = this.now() + DESKTOP_TOOL_BUDGET_MS }) {
|
|
205
238
|
this.security.assertCwd(cwd);
|
|
239
|
+
const projectScope = this.senderProjectScope(cwd);
|
|
206
240
|
cwd = realpathSync.native(cwd);
|
|
207
241
|
this.security.assertCwd(cwd);
|
|
208
242
|
dedupeName = dedupeName?.normalize("NFC").trim().replace(/\s+/g, " ") || undefined;
|
|
@@ -228,7 +262,7 @@ export class DesktopTaskDelivery {
|
|
|
228
262
|
prompt, title: name,
|
|
229
263
|
target: { type: "project", projectId: project.projectId, environment: { type: "local" } },
|
|
230
264
|
...(model ? { model } : {}), ...(effort ? { thinking: effort } : {}),
|
|
231
|
-
}, { deadline });
|
|
265
|
+
}, { deadline, projectScope });
|
|
232
266
|
const confirmedId = response?.threadId ?? response?.conversationId;
|
|
233
267
|
threadId = confirmedId;
|
|
234
268
|
if (!threadId && (response?.status === "outcome-unknown" || response?.firstTurn?.status === "outcome-unknown")) threadId = response?.clientThreadId;
|
|
@@ -251,15 +285,16 @@ export class DesktopTaskDelivery {
|
|
|
251
285
|
if (thread?.id !== threadId || thread.hostId !== "local" || !thread.cwd) throw new Error("Desktop did not confirm the task's local workspace.");
|
|
252
286
|
this.security.assertThread(threadId, thread.cwd);
|
|
253
287
|
this.security.assertCwd(thread.cwd);
|
|
288
|
+
const projectScope = this.senderProjectScope(thread.cwd);
|
|
254
289
|
if (cwd && path.relative(realpathSync.native(cwd), realpathSync.native(thread.cwd))) {
|
|
255
290
|
throw new Error("Native Desktop delivery cannot change an existing task's workspace; create a new task at the requested cwd.");
|
|
256
291
|
}
|
|
257
|
-
return { thread, latestTurnId: response.turns?.[0]?.id ?? null };
|
|
292
|
+
return { thread, latestTurnId: response.turns?.[0]?.id ?? null, projectScope };
|
|
258
293
|
}
|
|
259
294
|
|
|
260
295
|
async send({ threadId, prompt, cwd, model, effort, name, deadline }) {
|
|
261
296
|
const inspected = await this.inspect(threadId, cwd, { deadline });
|
|
262
|
-
if (name) await this.request("set_thread_title", { threadId, title: name.trim().slice(0, 200) }, { deadline });
|
|
297
|
+
if (name) await this.request("set_thread_title", { threadId, title: name.trim().slice(0, 200) }, { deadline, projectScope: inspected.projectScope });
|
|
263
298
|
const expectedCwd = realpathSync.native(inspected.thread.cwd);
|
|
264
299
|
const accountContext = this.accountContext?.();
|
|
265
300
|
const watermark = this.captureResponse({ threadId, expectedCwd });
|
|
@@ -267,7 +302,7 @@ export class DesktopTaskDelivery {
|
|
|
267
302
|
threadId, prompt,
|
|
268
303
|
...(model ? { model } : {}),
|
|
269
304
|
...(effort ? { thinking: effort } : {}),
|
|
270
|
-
}, { deadline, includeRelayContext: true });
|
|
305
|
+
}, { deadline, includeRelayContext: true, projectScope: inspected.projectScope });
|
|
271
306
|
const response = envelope.result;
|
|
272
307
|
if (response?.threadId !== threadId || response?.success === false || response?.isError === true ||
|
|
273
308
|
(response?.status !== undefined && !["accepted", "sent"].includes(response.status)) ||
|