@minhspark/codex-mcp-bridge 1.19.1 → 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 CHANGED
@@ -4,6 +4,23 @@ 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
+
17
+ ## [1.19.2] - 2026-09-29
18
+
19
+ ### Fixed
20
+
21
+ - Start both bridges from project-local npm installations. When npm hoists the bridge's dependencies into the project's `node_modules`, the package has no `node_modules` of its own, and preparing the immutable runtime failed with `ENOENT` (`scandir …node_modules`) since 1.15.0; only global installs, which keep a nested `node_modules`, started. The runtime now copies the dependency tree Node resolves from the package (hoisted, nested, scoped, aliased, linked and peer dependencies) and nothing else from the project, so it stays isolated from later installation changes. Its revision also covers the project's npm lock, so an `npm install` that changes those dependencies reloads the bridge.
22
+ - Stop the Codex bridge cleanly when its MCP client disconnects. The bridge ends the Windows PowerShell caller checks it started and waits for them to close, and the supervisor gives its worker up to 6 seconds to exit before stopping it. Before, the supervisor stopped the worker after 1 second without waiting, which left those checks running with the bridge's working directory in use. On Windows, deleting that directory right after a test closed its bridge then failed with `EPERM`; the tests no longer retry that deletion.
23
+
7
24
  ## [1.19.1] - 2026-09-29
8
25
 
9
26
  ### 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 needs the `ANALYTICS_TOKEN` repository secret set to a token with push access to this repository (fine-grained: Contents read/write plus 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 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minhspark/codex-mcp-bridge",
3
- "version": "1.19.1",
3
+ "version": "1.19.3",
4
4
  "description": "Two-way MCP bridge between Claude and Codex: prompts into a live Codex thread, messages into a running Claude Code session.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -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.1";
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 githubApi(endpoint, { method = 'GET', body } = {}) {
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
- const file = await githubApi(`repos/${repo}/contents/data.json?ref=analytics`);
9
- const previous = JSON.parse(Buffer.from(file.content, 'base64').toString('utf8'));
10
- const options = { repo, package: '@minhspark/codex-mcp-bridge' };
11
- const snapshot = await collectSources(options);
12
- const history = mergeHistory(previous, options, snapshot);
13
- let usage = previous.usage;
14
- let usageFailed = false;
15
- try {
16
- const response = await fetch('https://vdbwkowdggtcihixowxi.supabase.co/functions/v1/bridge-stats', {
17
- headers: { apikey: TELEMETRY_PUBLIC_KEY }, signal: AbortSignal.timeout(15000),
18
- });
19
- if (!response.ok) throw new Error('Stats unavailable');
20
- const data = await response.json();
21
- if (!data.usage || data.errors?.some(row => row.source === 'usage')) throw new Error('Usage unavailable');
22
- usage = sanitizeAnalytics(history, data.usage).usage;
23
- } catch { usageFailed = true; }
24
- const sha = await publishAnalytics(repo, publicFiles(history, usage));
25
- const ref = await githubApi(`repos/${repo}/git/ref/heads/analytics`);
26
- if (ref.object.sha !== sha) throw new Error('Analytics publication changed during verification');
27
- const directory = path.resolve('public-analytics');
28
- await mkdir(directory, { recursive: true });
29
- for (const [name, content] of Object.entries(publicFiles(history, usage))) await writeFile(path.join(directory, name), content);
30
- console.log(`Published aggregate analytics: ${sha}`);
31
- for (const error of snapshot.errors) {
32
- const status = error.status ? ` (HTTP ${error.status})` : '';
33
- 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.' : '';
34
- console.error(`::warning::${error.source} was not refreshed${status}; previous data retained.${hint}`);
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;
@@ -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.1";
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;
@@ -7,6 +7,13 @@ import { readClaudeDesktopContext } from "./claude-desktop-context.mjs";
7
7
  const execute = promisify(execFile);
8
8
  const MAX_ANCESTORS = 8;
9
9
  const INSPECTION_OUTPUT_LIMIT = 16384;
10
+ const activeInspectors = new Set();
11
+ let inspectorsStopping = false;
12
+
13
+ export async function stopProcessInspectors() {
14
+ inspectorsStopping = true;
15
+ await Promise.all([...activeInspectors].map((inspector) => inspector.stop()));
16
+ }
10
17
 
11
18
  /**
12
19
  * The five-second deadline is what keeps the caller check fail-closed: a read
@@ -148,6 +155,7 @@ function parseWindowsAncestry(stdout, parentPid, limit) {
148
155
  * elapsedMs so tests can report where the time went.
149
156
  */
150
157
  export function runProcessInspector(command, args, { timeout, startupTimeout, readyMarker, maxBuffer = Infinity, windowsHide = true } = {}) {
158
+ if (inspectorsStopping) return Promise.reject(Object.assign(new Error("Process inspection is shutting down"), { code: "ABORT_ERR" }));
151
159
  return new Promise((resolve, reject) => {
152
160
  const started = performance.now();
153
161
  const child = spawn(command, args, { windowsHide, stdio: ["ignore", "pipe", "pipe"] });
@@ -156,36 +164,59 @@ export function runProcessInspector(command, args, { timeout, startupTimeout, re
156
164
  let readyAfterMs = null;
157
165
  let settled = false;
158
166
  let timer;
167
+ let failure;
168
+ let killTimer;
169
+ let closeTimer;
170
+ let resolveClosed;
171
+ const closed = new Promise((resolve) => { resolveClosed = resolve; });
172
+ const inspector = { stop: () => {
173
+ fail(Object.assign(new Error("Process inspection cancelled during shutdown"), { code: "ABORT_ERR" }));
174
+ return closed.then((error) => { if (error) throw error; });
175
+ } };
176
+ activeInspectors.add(inspector);
159
177
  const text = (stream) => Buffer.concat(output[stream]).toString("utf8");
160
178
  const settle = (error) => {
161
179
  if (settled) return;
162
180
  settled = true;
163
181
  clearTimeout(timer);
164
- if (error && child.pid !== undefined && child.exitCode === null && child.signalCode === null) child.kill();
182
+ clearTimeout(killTimer);
183
+ clearTimeout(closeTimer);
184
+ activeInspectors.delete(inspector);
185
+ resolveClosed(error?.cleanupFailed ? error : null);
165
186
  const result = { stdout: text("stdout"), stderr: text("stderr"), pid: child.pid, readyAfterMs, elapsedMs: Math.round(performance.now() - started) };
166
187
  if (error) reject(Object.assign(error, result));
167
188
  else resolve(result);
168
189
  };
190
+ const fail = (error) => {
191
+ if (settled || failure) return;
192
+ failure = error;
193
+ clearTimeout(timer);
194
+ closeTimer = setTimeout(() => settle(Object.assign(failure, { cleanupFailed: true, message: `${failure.message}; helper ${child.pid} did not close within 5000 ms after termination` })), 5000);
195
+ if (child.pid !== undefined && child.exitCode === null && child.signalCode === null) {
196
+ child.kill();
197
+ killTimer = setTimeout(() => child.kill("SIGKILL"), 250);
198
+ }
199
+ };
169
200
  const arm = (phase, ms) => {
170
201
  clearTimeout(timer);
171
- timer = setTimeout(() => settle(Object.assign(new Error(`Process inspection ${phase} deadline elapsed`), { code: "ETIMEDOUT", phase, killed: true, signal: "SIGTERM" })), ms);
202
+ timer = setTimeout(() => fail(Object.assign(new Error(`Process inspection ${phase} deadline elapsed`), { code: "ETIMEDOUT", phase, killed: true, signal: "SIGTERM" })), ms);
172
203
  };
173
204
  if (readyMarker) arm("startup", startupTimeout);
174
205
  else arm("inspection", timeout);
175
206
  for (const stream of ["stdout", "stderr"]) {
176
207
  child[stream].on("data", (chunk) => {
177
- if (settled) return;
208
+ if (settled || failure) return;
178
209
  output[stream].push(chunk);
179
210
  size[stream] += chunk.length;
180
- if (size[stream] > maxBuffer) return settle(Object.assign(new RangeError(`${stream} maxBuffer length exceeded`), { code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" }));
211
+ if (size[stream] > maxBuffer) return fail(Object.assign(new RangeError(`${stream} maxBuffer length exceeded`), { code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" }));
181
212
  if (stream === "stderr" && readyMarker && readyAfterMs === null && text("stderr").includes(readyMarker)) {
182
213
  readyAfterMs = Math.round(performance.now() - started);
183
214
  arm("inspection", timeout);
184
215
  }
185
216
  });
186
217
  }
187
- child.on("error", settle);
188
- child.on("close", (code, signal) => settle(code === 0 ? null : Object.assign(new Error(`Process inspection exited with ${signal ?? code}`), { code, signal })));
218
+ child.on("error", fail);
219
+ child.on("close", (code, signal) => settle(failure ?? (code === 0 ? null : Object.assign(new Error(`Process inspection exited with ${signal ?? code}`), { code, signal }))));
189
220
  });
190
221
  }
191
222
 
package/src/index.mjs CHANGED
@@ -29,12 +29,12 @@ import { exitForVersionRequest } from "./cli-version.mjs";
29
29
  import { createRuntimeState } from "./runtime-state.mjs";
30
30
  import { assertRoutingReload, clientReloadReason, createReloadControl } from "./reload-control.mjs";
31
31
  import { accountIdentity, assertAccountIdentity, publicAccountState, readBridgeAccounts, requireBridgeAccounts } from "./bridge-account-context.mjs";
32
- import { assertClaudeSenderContext, readClaudeSenderContext, requireClaudeSenderContext } from "./claude-sender-context.mjs";
32
+ import { assertClaudeSenderContext, readClaudeSenderContext, requireClaudeSenderContext, stopProcessInspectors } from "./claude-sender-context.mjs";
33
33
  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.1";
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. Use send_to_codex_thread only when an existing threadId is intentional; use " +
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("Override the working directory for this turn"),
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}`]),
@@ -1011,5 +1017,13 @@ registerTool(
1011
1017
 
1012
1018
  const transport = new StdioServerTransport();
1013
1019
  await server.connect(transport);
1020
+ let shutdownPromise;
1021
+ const shutdown = () => shutdownPromise ??= (async () => {
1022
+ client?.close();
1023
+ await server.close();
1024
+ await stopProcessInspectors();
1025
+ })();
1026
+ process.stdin.once("end", () => { void shutdown().catch((error) => log(`shutdown failed: ${error.message}`)); });
1027
+ process.once("disconnect", () => { void shutdown().catch((error) => log(`shutdown failed: ${error.message}`)); });
1014
1028
  reload.listen();
1015
1029
  log(desktopTasksEnabled ? `ready on ${PLATFORM_LABEL} (Codex Desktop only; external app-server disabled)` : `ready on ${PLATFORM_LABEL} (app-server endpoint: ${client.url}, codex: ${client.codexBin})`);
@@ -44,6 +44,7 @@ class Worker {
44
44
  env: { ...env, CODEX_BRIDGE_WORKER: "1", CODEX_BRIDGE_STAGED: "1" },
45
45
  stdio: ["pipe", "pipe", "pipe", "ipc"], serialization: "json",
46
46
  });
47
+ this.exited = new Promise((resolve) => this.process.once("exit", resolve));
47
48
  this.process.stderr.on("data", (data) => process.stderr.write(data));
48
49
  this.process.stdin.on("error", (error) => this.fail(error));
49
50
  this.process.on("error", (error) => this.fail(error));
@@ -110,13 +111,23 @@ class Worker {
110
111
 
111
112
  async stop() {
112
113
  this.retiring = true;
113
- if (this.process.exitCode !== null || this.process.signalCode) return;
114
- await new Promise((resolve) => {
115
- const timer = setTimeout(() => { this.process.kill(); resolve(); }, 1000);
116
- this.process.once("exit", () => { clearTimeout(timer); resolve(); });
114
+ if (this.stopping) return this.stopping;
115
+ this.stopping = (async () => {
116
+ let timer;
117
+ const wait = async (ms) => {
118
+ try {
119
+ return await Promise.race([this.exited.then(() => true), new Promise((resolve) => { timer = setTimeout(() => resolve(false), ms); })]);
120
+ } finally { clearTimeout(timer); }
121
+ };
117
122
  this.process.stdin.end();
118
123
  if (this.process.connected) this.process.disconnect();
119
- });
124
+ if (await wait(6000)) return;
125
+ if (this.process.exitCode === null && this.process.signalCode === null) {
126
+ this.process.kill("SIGKILL");
127
+ }
128
+ if (!await wait(5000)) throw new Error(`Bridge worker ${this.process.pid} did not close after termination`);
129
+ })();
130
+ return this.stopping;
120
131
  }
121
132
  }
122
133
 
@@ -248,13 +259,16 @@ export async function runSupervisor(entry, options = {}) {
248
259
  reloads++;
249
260
  reason = null;
250
261
  if (JSON.stringify(catalog) !== JSON.stringify(oldCatalog)) output({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
251
- await previous.stop();
262
+ await previous.stop().catch((error) => log(`retired ${entry} worker: ${error.message}`));
252
263
  workers.delete(previous);
253
264
  log(`activated ${entry} revision ${revision.slice(0, 12)}; client connection retained`);
254
265
  } catch (error) {
255
266
  reason = `Update deferred: ${error.message}`;
256
267
  retryAt = Date.now() + Math.max(pollMs, 5000);
257
- if (candidate) { await candidate.stop(); workers.delete(candidate); }
268
+ if (candidate) {
269
+ await candidate.stop().catch((stopError) => log(`rejected ${entry} candidate: ${stopError.message}`));
270
+ workers.delete(candidate);
271
+ }
258
272
  if (quiesced && active === previous && !previous.closed) {
259
273
  try { await previous.control("resume"); }
260
274
  catch (resumeError) { failure = `Previous runtime could not resume: ${resumeError.message}`; }
@@ -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.1";
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
+ }
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { createRequire } from "node:module";
5
6
 
6
7
  function digestFiles(root, files) {
7
8
  const hash = createHash("sha256");
@@ -49,14 +50,14 @@ function digestMetadata(cache) {
49
50
  }
50
51
  };
51
52
  return {
52
- digest(directory, files) {
53
+ digest(directory, files, sources) {
53
54
  const previous = directories.get(directory) ?? load(directory);
54
55
  const records = new Map();
55
56
  const hash = createHash("sha256");
56
57
  if (previous.size !== files.length) dirty.add(directory);
57
58
  for (const file of files) {
58
59
  if (!safeRelativePath(file)) throw new Error(`Unsafe release file: ${file}`);
59
- const fullPath = path.join(directory, file);
60
+ const fullPath = sources?.get(file) ?? path.join(directory, file);
60
61
  const before = fs.statSync(fullPath, { bigint: true });
61
62
  if (!before.isFile()) throw new Error(`Unsupported release file: ${file}`);
62
63
  const current = signature(before);
@@ -105,25 +106,101 @@ function digestMetadata(cache) {
105
106
  };
106
107
  }
107
108
 
108
- function inventory(root, prefix = "") {
109
+ function inventory(root, prefix = "", omitModules = false) {
109
110
  const files = [];
110
111
  for (const entry of fs.readdirSync(path.join(root, prefix), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name, "en"))) {
111
112
  if (entry.name === ".bin") continue;
113
+ if (omitModules && !prefix && entry.name === "node_modules") continue;
112
114
  const relative = path.join(prefix, entry.name);
113
115
  const stat = entry.isSymbolicLink() ? fs.statSync(path.join(root, relative)) : entry;
114
- if (stat.isDirectory()) files.push(...inventory(root, relative));
116
+ if (stat.isDirectory()) files.push(...inventory(root, relative, omitModules));
115
117
  else if (stat.isFile()) files.push(relative);
116
118
  else throw new Error(`Unsupported release file: ${relative}`);
117
119
  }
118
120
  return files;
119
121
  }
120
122
 
123
+ const INSTALLATION_LOCKS = ".bridge-installation-locks.json";
124
+ const dependencySearchPaths = (root, name) => createRequire(path.join(root, "package.json")).resolve.paths(name) ?? [];
125
+
126
+ function installationLocks(root) {
127
+ const saved = path.join(root, INSTALLATION_LOCKS);
128
+ if (fs.existsSync(saved)) return fs.readFileSync(saved, "utf8");
129
+ const local = path.join(root, "node_modules");
130
+ const hashes = [];
131
+ for (const directory of dependencySearchPaths(root, "bridge-dependency-lock")) {
132
+ if (directory === local) continue;
133
+ const lock = path.join(directory, ".package-lock.json");
134
+ if (fs.existsSync(lock)) hashes.push(createHash("sha256").update(fs.readFileSync(lock)).digest("hex"));
135
+ }
136
+ return JSON.stringify(hashes);
137
+ }
138
+
139
+ function dependencyFiles(root) {
140
+ const files = new Map();
141
+ if (fs.existsSync(path.join(root, "node_modules"))) {
142
+ for (const file of inventory(root, "node_modules")) files.set(file, path.join(root, file));
143
+ }
144
+ const queue = [{ source: root, destination: "" }];
145
+ const visited = new Set();
146
+ const packageSources = new Map();
147
+ const packageAt = (directory) => {
148
+ if (packageSources.has(directory)) return packageSources.get(directory);
149
+ const manifest = files.get(path.join(directory, "package.json"));
150
+ if (!manifest) return null;
151
+ const source = fs.realpathSync.native(path.dirname(manifest));
152
+ packageSources.set(directory, source);
153
+ return source;
154
+ };
155
+ for (let index = 0; index < queue.length; index++) {
156
+ const { source, destination } = queue[index];
157
+ if (visited.has(destination)) continue;
158
+ visited.add(destination);
159
+ const manifest = JSON.parse(fs.readFileSync(path.join(source, "package.json"), "utf8"));
160
+ const names = new Set([...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.optionalDependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {})]);
161
+ for (const name of [...names].sort()) {
162
+ if (!/^(?:@[a-zA-Z0-9._~-]+\/)?[a-zA-Z0-9._~-]+$/.test(name) || name === "." || name === "..") throw new Error(`Unsafe dependency name: ${name}`);
163
+ let resolved;
164
+ for (const modules of dependencySearchPaths(source, name)) {
165
+ const candidate = path.join(modules, name);
166
+ if (fs.existsSync(path.join(candidate, "package.json"))) { resolved = fs.realpathSync.native(candidate); break; }
167
+ }
168
+ if (!resolved) {
169
+ if (Object.hasOwn(manifest.optionalDependencies ?? {}, name) || manifest.peerDependenciesMeta?.[name]?.optional) continue;
170
+ throw new Error(`Cannot resolve required dependency ${name} from ${source}`);
171
+ }
172
+ let placement;
173
+ let current = destination;
174
+ while (true) {
175
+ if (path.basename(current) !== "node_modules") {
176
+ const candidate = path.join(current, "node_modules", name);
177
+ const installed = packageAt(candidate);
178
+ if (installed) { if (installed === resolved) placement = candidate; break; }
179
+ }
180
+ if (!current) break;
181
+ const parent = path.dirname(current);
182
+ current = parent === "." ? "" : parent;
183
+ }
184
+ if (!placement) {
185
+ const hoisted = path.join("node_modules", name);
186
+ placement = packageAt(hoisted) ? path.join(destination, "node_modules", name) : hoisted;
187
+ const occupied = packageAt(placement);
188
+ if (occupied && occupied !== resolved) throw new Error(`Conflicting dependency resolution for ${name}`);
189
+ for (const file of inventory(resolved, "", true)) files.set(path.join(placement, file), path.join(resolved, file));
190
+ packageSources.set(placement, resolved);
191
+ }
192
+ queue.push({ source: resolved, destination: placement });
193
+ }
194
+ }
195
+ return new Map([...files].sort(([left], [right]) => left.localeCompare(right, "en")));
196
+ }
197
+
121
198
  export function sourceRevision(root) {
122
199
  const files = ["package.json", ...inventory(root, "src")];
123
200
  for (const file of ["package-lock.json", "node_modules/.package-lock.json"]) {
124
201
  if (fs.existsSync(path.join(root, file))) files.push(file);
125
202
  }
126
- return digestFiles(root, files);
203
+ return createHash("sha256").update(digestFiles(root, files)).update(installationLocks(root)).digest("hex");
127
204
  }
128
205
 
129
206
  export function snapshotRoot(env = process.env) {
@@ -153,31 +230,36 @@ export function createReleaseSnapshot(root, { cache = snapshotRoot(), expectedRe
153
230
  if (fs.lstatSync(cache).isSymbolicLink()) throw new Error("The runtime cache cannot be a symbolic link");
154
231
  cache = fs.realpathSync.native(cache);
155
232
  const metadata = digestMetadata(cache);
156
- const dependencies = inventory(root, "node_modules");
157
- const dependencyRevision = metadata.digest(root, dependencies);
233
+ const dependencies = dependencyFiles(root);
234
+ const dependencyRevision = metadata.digest(root, [...dependencies.keys()], dependencies);
158
235
  const key = createHash("sha256").update(expectedRevision).update(dependencyRevision).digest("hex");
159
236
  const target = path.join(cache, key);
160
237
  const validate = (directory) => {
161
238
  if (fs.lstatSync(directory).isSymbolicLink()) throw new Error("The release directory cannot be a symbolic link");
162
- return sourceRevision(directory) === expectedRevision && metadata.digest(directory, inventory(directory, "node_modules")) === dependencyRevision;
239
+ return sourceRevision(directory) === expectedRevision && metadata.digest(directory, inventory(directory, "node_modules").sort((left, right) => left.localeCompare(right, "en"))) === dependencyRevision;
163
240
  };
164
241
  if (fs.existsSync(target)) {
165
242
  if (!validate(target)) throw new Error("An existing immutable runtime failed integrity verification");
166
- if (sourceRevision(root) !== expectedRevision) throw new Error("The installation changed during snapshot verification");
243
+ const currentDependencies = dependencyFiles(root);
244
+ if (sourceRevision(root) !== expectedRevision || metadata.digest(root, [...currentDependencies.keys()], currentDependencies) !== dependencyRevision) {
245
+ throw new Error("The installation changed during snapshot verification");
246
+ }
167
247
  metadata.flush();
168
248
  return { directory: target, revision: expectedRevision, key };
169
249
  }
170
250
  const temporary = path.join(cache, `.preparing-${process.pid}-${randomUUID()}`);
171
251
  try {
172
252
  fs.mkdirSync(temporary, { mode: 0o700 });
173
- const files = ["package.json", ...inventory(root, "src"), ...dependencies];
253
+ const files = ["package.json", ...inventory(root, "src"), ...dependencies.keys()];
174
254
  if (fs.existsSync(path.join(root, "package-lock.json"))) files.push("package-lock.json");
175
255
  const directories = new Set(["src", "node_modules", ...files.map((file) => path.dirname(file))]);
176
256
  for (const directory of directories) fs.mkdirSync(path.join(temporary, directory), { recursive: true, mode: 0o700 });
177
257
  for (const file of files) {
178
- fs.copyFileSync(path.join(root, file), path.join(temporary, file), fs.constants.COPYFILE_FICLONE);
258
+ fs.copyFileSync(dependencies.get(file) ?? path.join(root, file), path.join(temporary, file), fs.constants.COPYFILE_FICLONE);
179
259
  }
180
- if (sourceRevision(root) !== expectedRevision || metadata.digest(root, inventory(root, "node_modules")) !== dependencyRevision || !validate(temporary)) {
260
+ fs.writeFileSync(path.join(temporary, INSTALLATION_LOCKS), installationLocks(root), { flag: "wx", mode: 0o600 });
261
+ const currentDependencies = dependencyFiles(root);
262
+ if (sourceRevision(root) !== expectedRevision || metadata.digest(root, [...currentDependencies.keys()], currentDependencies) !== dependencyRevision || !validate(temporary)) {
181
263
  throw new Error("The installation changed while its immutable runtime was being prepared");
182
264
  }
183
265
  try {
@@ -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
- async request(operation, args, { deadline, includeRelayContext = false } = {}) {
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: () => this.beforeRequest({ operation, args, phase: "write" }) } : {}),
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)) ||