@minhspark/codex-mcp-bridge 1.20.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.codex-plugin/plugin.json +7 -2
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +57 -15
  4. package/assets/README.md +30 -0
  5. package/assets/agent-pets.svg +1 -0
  6. package/docs/daily-handoffs.md +106 -0
  7. package/docs/desktop-handoff-design.md +180 -0
  8. package/docs/new-machine-setup.md +112 -0
  9. package/docs/project-onboarding.md +122 -0
  10. package/package.json +8 -2
  11. package/plugin.json +10 -3
  12. package/scripts/bridge-projects.mjs +44 -0
  13. package/scripts/check.mjs +2 -1
  14. package/scripts/collect-repo-analytics.mjs +5 -2
  15. package/scripts/configure-message-automation.mjs +18 -0
  16. package/scripts/install-claude-code.mjs +64 -0
  17. package/scripts/install-claude-desktop.mjs +48 -5
  18. package/scripts/install-native-relay.mjs +6 -1
  19. package/scripts/install-npm-footer.mjs +13 -1
  20. package/scripts/publish-repo-analytics.mjs +24 -1
  21. package/src/app-server-client.mjs +5 -2
  22. package/src/bridge-readiness.mjs +23 -0
  23. package/src/card-settings-sync.mjs +125 -0
  24. package/src/claude-bridge.mjs +54 -20
  25. package/src/claude-code-registration.mjs +54 -0
  26. package/src/claude-sender-context.mjs +1 -1
  27. package/src/claude-session-creation.mjs +10 -2
  28. package/src/codex-mcp-registration.mjs +1 -1
  29. package/src/codex-native-response.mjs +139 -107
  30. package/src/codex-sender-context.mjs +62 -61
  31. package/src/desktop-reply-receipts.mjs +87 -0
  32. package/src/desktop-task-receipts.mjs +30 -7
  33. package/src/hardened-root-policy.mjs +5 -0
  34. package/src/index.mjs +97 -19
  35. package/src/mcp-supervisor.mjs +6 -2
  36. package/src/message-automation.mjs +89 -0
  37. package/src/native-creation-receipts.mjs +155 -0
  38. package/src/native-relay-companion.mjs +37 -6
  39. package/src/native-relay.mjs +12 -3
  40. package/src/peer-protocol.mjs +192 -18
  41. package/src/project-manager-registration.mjs +34 -0
  42. package/src/project-onboarding-tools.mjs +30 -0
  43. package/src/project-onboarding.mjs +178 -0
  44. package/src/project-policy.mjs +221 -0
  45. package/src/project-scope.mjs +33 -5
  46. package/src/prompt-guidance.mjs +2 -0
  47. package/src/release-snapshot.mjs +30 -5
  48. package/src/rollout-reader.mjs +58 -0
  49. package/src/rollout-segments.mjs +65 -0
  50. package/src/security-policy.mjs +4 -2
  51. package/src/sites-connector.mjs +18 -3
  52. package/src/telemetry.mjs +10 -8
  53. package/src/thread-delivery.mjs +134 -35
@@ -0,0 +1,65 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
5
+
6
+ // Continuations retain session_meta.id but append a second UUID to the filename.
7
+ // Filename/mtime ordering is not evidence of which turn is currently active.
8
+ export function findRolloutSegments(sessions, threadId) {
9
+ if (!new RegExp(`^${UUID}$`).test(threadId)) throw new Error("The Codex task identity is invalid");
10
+ const root = fs.lstatSync(sessions);
11
+ if (!root.isDirectory() || root.isSymbolicLink()) throw new Error("The Codex sessions path is not a regular directory");
12
+ const canonical = fs.realpathSync.native(sessions);
13
+ const pattern = new RegExp(`^rollout-.+-${threadId}(?:_${UUID})?\\.jsonl$`);
14
+ const queue = [{ directory: canonical, depth: 0 }], matches = [];
15
+ let entries = 0, directories = 0;
16
+ while (queue.length) {
17
+ const { directory, depth } = queue.pop();
18
+ if (++directories > 4096) throw new Error("The bounded Codex sessions scan exceeded its directory limit");
19
+ const children = fs.readdirSync(directory, { withFileTypes: true });
20
+ entries += children.length;
21
+ if (entries > 100000) throw new Error("The bounded Codex sessions scan exceeded its entry limit");
22
+ for (const child of children) {
23
+ const candidate = path.join(directory, child.name);
24
+ if (depth < 3 && (depth === 0 ? /^\d{4}$/ : /^\d{2}$/).test(child.name)) {
25
+ if (child.isSymbolicLink()) throw new Error("The Codex sessions scan encountered a linked date directory");
26
+ if (child.isDirectory()) {
27
+ const resolved = fs.realpathSync.native(candidate);
28
+ const relative = path.relative(canonical, resolved);
29
+ if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("A Codex date directory escapes the sessions path");
30
+ queue.push({ directory: resolved, depth: depth + 1 });
31
+ }
32
+ }
33
+ if (depth === 3 && pattern.test(child.name)) {
34
+ if (!child.isFile() || child.isSymbolicLink()) throw new Error("The Codex rollout is not a regular file");
35
+ matches.push(candidate);
36
+ if (matches.length > 64) throw new Error("The Codex task exceeds the 64-segment discovery limit");
37
+ }
38
+ }
39
+ }
40
+ if (!matches.length) throw new Error("No rollout matches the calling Codex task");
41
+ return matches.sort();
42
+ }
43
+
44
+ // Only independently retrieved native metadata may be supplied here. Tool
45
+ // arguments, process globals, and manual relay bindings are not ownership proof.
46
+ export function confirmsDesktopTask(evidence, threadId, cwd, turnId) {
47
+ const thread = evidence?.thread;
48
+ if (thread?.id !== threadId || thread.kind !== "codex" || thread.hostId !== "local"
49
+ || typeof thread.cwd !== "string" || !path.isAbsolute(thread.cwd)
50
+ || (turnId !== undefined && evidence.latestTurnId !== turnId)) return false;
51
+ return !path.relative(fs.realpathSync.native(thread.cwd), fs.realpathSync.native(cwd));
52
+ }
53
+
54
+ export function isLegacyCliSession(session) {
55
+ return session?.originator === "codex-tui" && session.source === "cli";
56
+ }
57
+
58
+ export function assertRolloutSetStable(sessions, threadId, snapshots) {
59
+ const files = findRolloutSegments(sessions, threadId);
60
+ if (files.length !== snapshots.length || files.some((file, i) => file !== snapshots[i].file)) throw new Error("The rollout segment set changed while reading");
61
+ for (const { file, version } of snapshots) {
62
+ const current = fs.lstatSync(file);
63
+ if (!current.isFile() || current.isSymbolicLink() || Object.entries(version).some(([key, value]) => current[key] !== value)) throw new Error("A rollout segment changed while reading");
64
+ }
65
+ }
@@ -212,11 +212,13 @@ export class BridgeSecurityPolicy {
212
212
  }
213
213
 
214
214
  summary() {
215
+ const projectPolicy = this.hardenedRoots.mode === "project-policy" ? this.hardenedRoots.status() : null;
215
216
  return {
216
217
  authorizedThreads: this.allowedThreadIds.size + this.ownedThreadIds.size,
217
218
  allowAllThreads: this.allowAllThreads,
218
- allowedRoots: this.allowAllRoots ? [ALLOW_ALL_ROOTS] : this.allowedRoots,
219
- allowAllRoots: this.allowAllRoots,
219
+ allowedRoots: projectPolicy ? (projectPolicy.error ? [] : this.hardenedRoots.roots) : this.allowAllRoots ? [ALLOW_ALL_ROOTS] : this.allowedRoots,
220
+ allowAllRoots: projectPolicy ? false : this.allowAllRoots,
221
+ ...(projectPolicy ? { projectPolicy } : {}),
220
222
  threadPolicy: this.threadPolicy,
221
223
  approvalPolicy: this.approvalPolicy,
222
224
  sandbox: this.sandbox,
@@ -54,8 +54,23 @@ async function readPrivateJson(file, maxBytes = 2 * 1024 * 1024) {
54
54
  const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
55
55
  try {
56
56
  const current = await handle.stat();
57
- if (!current.isFile() || current.nlink !== 1 || current.ino !== info.ino || current.dev !== info.dev || current.size > maxBytes) throw new Error("Private connector file changed during access");
58
- return JSON.parse(await handle.readFile("utf8"));
57
+ const fields = ["dev", "ino", "size", "mtimeMs", "ctimeMs"];
58
+ const matches = (left, right) => fields.every((field) => left[field] === right[field]);
59
+ if (!current.isFile() || current.nlink !== 1 || current.size > maxBytes || !matches(info, current)) throw new Error("Private connector file changed during access");
60
+ const data = Buffer.alloc(current.size);
61
+ let offset = 0;
62
+ while (offset < data.length) {
63
+ const { bytesRead } = await handle.read(data, offset, data.length - offset, offset);
64
+ if (!bytesRead) throw new Error("Private connector file changed during access");
65
+ offset += bytesRead;
66
+ }
67
+ const after = await handle.stat();
68
+ const entry = await fs.lstat(file);
69
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.nlink !== 1 || !matches(current, after) || !matches(after, entry)) throw new Error("Private connector file changed during access");
70
+ return JSON.parse(data.toString("utf8"));
71
+ } catch (error) {
72
+ if (error.code === "ENOENT") throw new Error("Private connector file changed during access", { cause: error });
73
+ throw error;
59
74
  } finally { await handle.close(); }
60
75
  }
61
76
 
@@ -382,7 +397,7 @@ export async function runSitesConnector({ configFile, once = false, signal, ...d
382
397
  return new SitesConnector({ config, backend, journal, log: (state) => process.stderr.write(`[sites-connector] ${JSON.stringify(state)}\n`), ...dependencies }).run({ once, signal });
383
398
  }
384
399
 
385
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
400
+ if (process.argv[1] && await fs.realpath(process.argv[1]).catch(() => null) === fileURLToPath(import.meta.url)) {
386
401
  const args = process.argv.slice(2);
387
402
  const flags = new Map();
388
403
  let invalid = false;
package/src/telemetry.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, open, rename, unlink, stat } from 'node:fs/promises';
1
+ import { mkdir, readFile, open, rename, unlink, lstat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { randomUUID } from 'node:crypto';
@@ -48,14 +48,16 @@ async function locked(directory, action) {
48
48
  lock = await open(filename, 'wx', 0o600);
49
49
  } catch (error) {
50
50
  if (error.code !== 'EEXIST') throw error;
51
- const info = await stat(filename).catch(() => null);
52
- if (info && Date.now() - info.mtimeMs > 60000) {
53
- await unlink(filename).catch(() => {});
54
- lock = await open(filename, 'wx', 0o600);
55
- } else throw new Error('Telemetry settings are busy. Retry in a few seconds.');
51
+ throw new Error('Telemetry settings are busy. Locks never expire automatically; inspect active telemetry processes before repairing a leftover lock.');
52
+ }
53
+ let owned;
54
+ try { owned = await lock.stat(); return await action(); }
55
+ finally {
56
+ try {
57
+ const current = await lstat(filename).catch(error => { if (error.code !== 'ENOENT') throw error; return null; });
58
+ if (owned && current?.isFile() && !current.isSymbolicLink() && current.nlink === 1 && owned.dev === current.dev && owned.ino === current.ino && owned.birthtimeMs === current.birthtimeMs) await unlink(filename);
59
+ } finally { await lock.close(); }
56
60
  }
57
- try { return await action(); }
58
- finally { await lock.close(); await unlink(filename).catch(() => {}); }
59
61
  }
60
62
 
61
63
  function suppressed(env) {
@@ -2,8 +2,10 @@ import { NativeDesktopRelay, accountRelaySocketPath, desktopTaskSocketPath, desk
2
2
  import { runTurn } from "./turn.mjs";
3
3
  import { realpathSync } from "node:fs";
4
4
  import path from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+ import { creationRequestHash } from "./native-creation-receipts.mjs";
5
7
  import { DesktopTaskReceipts } from "./desktop-task-receipts.mjs";
6
- import { captureCodexRolloutWatermark, inspectCodexNativeTurn, readCodexNativeTurnResponse } from "./codex-native-response.mjs";
8
+ import { captureCodexRolloutWatermark, inspectCodexNativeTurn, readCodexNativeTurnResponse, readCodexNativeCreationResponse } from "./codex-native-response.mjs";
7
9
  import { captureProjectScope, recheckProjectScope } from "./project-scope.mjs";
8
10
  import { requireClaudeSenderContext } from "./claude-sender-context.mjs";
9
11
 
@@ -85,9 +87,10 @@ export class DesktopTaskDelivery {
85
87
  return inspected.projectScope;
86
88
  }
87
89
 
88
- async request(operation, args, { deadline, includeRelayContext = false, projectScope: expectedScope } = {}) {
90
+ async request(operation, args, { deadline, includeRelayContext = false, scopeBinding, creationReceiptId, projectScope: expectedScope } = {}) {
89
91
  try {
90
92
  await this.beforeRequest?.({ operation, args });
93
+ if (scopeBinding) this.security.hardenedRoots.recheck(scopeBinding);
91
94
  const projectScope = expectedScope ?? await this.mutationProjectScope(operation, args, { deadline });
92
95
  if (expectedScope) recheckProjectScope(expectedScope, this.senderProjectScope(expectedScope.recipient.input));
93
96
  const remaining = deadline === undefined ? undefined : deadline - this.now();
@@ -95,15 +98,17 @@ export class DesktopTaskDelivery {
95
98
  const accountContext = this.accountContext?.();
96
99
  const response = await this.relay.requestDesktop(operation, args, {
97
100
  ...(remaining === undefined ? {} : { timeoutMs: remaining }),
98
- ...(this.beforeRequest || projectScope ? { beforeSend: async () => {
101
+ ...(this.beforeRequest || projectScope || scopeBinding ? { beforeSend: async () => {
99
102
  await this.beforeRequest?.({ operation, args, phase: "write" });
100
103
  if (projectScope) {
101
104
  recheckProjectScope(projectScope);
102
105
  recheckProjectScope(projectScope, await this.mutationProjectScope(operation, args, { deadline }));
103
106
  }
104
107
  await this.beforeRequest?.({ operation, args, phase: "dispatch" });
108
+ if (scopeBinding) this.security.hardenedRoots.recheck(scopeBinding);
105
109
  } } : {}),
106
110
  ...(accountContext ? { accountContext } : {}),
111
+ ...(creationReceiptId ? { creationReceiptId } : {}),
107
112
  });
108
113
  return includeRelayContext ? { result: response.result, executorThreadId: response.executorThreadId } : response.result;
109
114
  } catch (err) {
@@ -175,12 +180,16 @@ export class DesktopTaskDelivery {
175
180
  return { rows, coverage: "Codex Desktop's recent/pinned snapshot; local Codex workspaces only. Agent-created tasks visible in the sidebar can be omitted; read a known task ID directly before assuming creation failed." };
176
181
  }
177
182
 
178
- async reuseReceipt(receipt, { cwd, promptHash, deadline, accountContext }) {
183
+ async reuseReceipt(receipt, { cwd, promptHash, deadline, accountContext, includeReplyReceipt }) {
179
184
  if (!receipt) return null;
180
185
  if (accountContext && (!receipt.accountContext || ["claude", "codex"].some((provider) => receipt.accountContext[provider] !== accountContext[provider]))) {
181
186
  throw new Error(`The existing Desktop creation receipt ${receipt.threadId ? `for task ${receipt.threadId} ` : ""}${receipt.accountContext ? "belongs to different accounts" : "has no verified original account binding"}. Its receipt and task ID were retained. No creation or prompt resend was attempted; inspect the explicit existing task before continuing.`);
182
187
  }
183
188
  if (path.relative(cwd, realpathSync.native(receipt.cwd))) throw new Error("The stored Desktop creation receipt belongs to another workspace. No prompt was sent.");
189
+ if (receipt.state === "unknown" && includeReplyReceipt && receipt.deliveryId) return {
190
+ threadId: null, cwd, deliveryId: receipt.deliveryId, deliveryStatus: "unconfirmed", reused: true, name: receipt.name,
191
+ projectId: receipt.projectId, projectName: receipt.projectName, promptChanged: receipt.promptHash !== promptHash,
192
+ };
184
193
  if (receipt.state !== "known") throw new Error(`An earlier Desktop creation is ${receipt.state} and may already have started. ${receipt.threadId ? `threadId: ${receipt.threadId}. ` : ""}Do not resend or create another task; inspect the existing Desktop task and resolve its creation receipt first.`);
185
194
  let response;
186
195
  try {
@@ -194,7 +203,7 @@ export class DesktopTaskDelivery {
194
203
  this.security.assertCwd(thread.cwd);
195
204
  this.security.assertThread(thread.id, thread.cwd);
196
205
  const assignment = await this.receiptProjectAssignment(receipt, cwd, { deadline });
197
- return { threadId: receipt.threadId, name: thread.title ?? receipt.name ?? "(unnamed)", cwd, ...assignment, backend: NATIVE_BACKEND, reused: true, promptChanged: receipt.promptHash !== promptHash };
206
+ return { threadId: receipt.threadId, name: thread.title ?? receipt.name ?? "(unnamed)", cwd, ...assignment, backend: NATIVE_BACKEND, reused: true, promptChanged: receipt.promptHash !== promptHash, ...(includeReplyReceipt && receipt.deliveryId ? { deliveryId: receipt.deliveryId } : {}) };
198
207
  }
199
208
 
200
209
  async receiptProjectAssignment(receipt, cwd, { deadline }) {
@@ -234,16 +243,17 @@ export class DesktopTaskDelivery {
234
243
  return { projectId: project.projectId, projectName: project.label, projectAssignmentStatus: "verified" };
235
244
  }
236
245
 
237
- async create({ cwd, prompt, name, dedupeName = name, requestId, model, effort, deadline = this.now() + DESKTOP_TOOL_BUDGET_MS }) {
246
+ async create({ cwd, prompt, name, dedupeName = name, requestId, model, effort, onAccepted, onPrepared, deadline = this.now() + DESKTOP_TOOL_BUDGET_MS }) {
238
247
  this.security.assertCwd(cwd);
239
248
  const projectScope = this.senderProjectScope(cwd);
240
249
  cwd = realpathSync.native(cwd);
241
250
  this.security.assertCwd(cwd);
251
+ const scopeBinding = this.security.hardenedRoots?.mode === "project-policy" ? this.security.hardenedRoots.capture(cwd) : undefined;
242
252
  dedupeName = dedupeName?.normalize("NFC").trim().replace(/\s+/g, " ") || undefined;
243
253
  const identity = this.receipts.key({ cwd, prompt, name: dedupeName, requestId });
244
254
  const accountContext = this.accountContext?.();
245
255
  if (this.accountContext && !accountContext) throw new Error("Desktop creation requires the original verified account context.");
246
- const options = { cwd, promptHash: identity.promptHash, deadline, accountContext };
256
+ const options = { cwd, promptHash: identity.promptHash, deadline, accountContext, includeReplyReceipt: Boolean(onAccepted || onPrepared) };
247
257
  const reused = await this.reuseReceipt(await this.receipts.read(identity.key), options);
248
258
  if (reused) return reused;
249
259
  const listed = await this.request("list_projects", {}, { deadline });
@@ -254,15 +264,27 @@ export class DesktopTaskDelivery {
254
264
  if (existing) return existing;
255
265
  if (this.now() >= deadline) throw new Error("The response deadline elapsed before Desktop creation. No task was created.");
256
266
  const receipt = { version: 1, ...identity, cwd, state: "pending", startedAt: this.now(), ...(accountContext ? { accountContext: { ...accountContext } } : {}), ...(dedupeName ? { name: dedupeName } : {}), projectId: project.projectId, ...(project.label ? { projectName: project.label } : {}) };
267
+ const creationArgs = { prompt, title: name,
268
+ target: { type: "project", projectId: project.projectId, environment: { type: "local" } },
269
+ ...(model ? { model } : {}), ...(effort ? { thinking: effort } : {}) };
270
+ const creationReceiptId = onPrepared ? randomUUID() : undefined;
271
+ if (onPrepared) {
272
+ // Feature-detect before mutation; old companions refuse this read-only
273
+ // probe. Never create first and discover missing recovery afterwards.
274
+ if (!accountContext) throw new Error("Creation continuation requires verified accounts");
275
+ const requestHash = creationRequestHash(creationArgs);
276
+ const probe = await this.request("get_creation_receipt", { receiptId: creationReceiptId, requestHash }, { deadline });
277
+ if (probe?.status !== "missing") throw new Error("Native creation receipt capability could not be confirmed; no task was created");
278
+ receipt.deliveryId = onPrepared({ threadId: null, cwd, previousTurnId: null, deliveryStatus: "unconfirmed",
279
+ creationObservation: { receiptId: creationReceiptId, requestHash, receiptKey: identity.key, args: creationArgs, accountContext: { ...accountContext } } });
280
+ }
257
281
  await this.receipts.write(identity.key, receipt);
258
282
  let threadId;
259
283
  let creationConfirmed = false;
260
284
  try {
261
- const response = await this.request("create_thread", {
262
- prompt, title: name,
263
- target: { type: "project", projectId: project.projectId, environment: { type: "local" } },
264
- ...(model ? { model } : {}), ...(effort ? { thinking: effort } : {}),
265
- }, { deadline, projectScope });
285
+ const envelope = await this.request("create_thread", creationArgs,
286
+ { deadline, projectScope, scopeBinding, creationReceiptId, includeRelayContext: true });
287
+ const response = envelope.result;
266
288
  const confirmedId = response?.threadId ?? response?.conversationId;
267
289
  threadId = confirmedId;
268
290
  if (!threadId && (response?.status === "outcome-unknown" || response?.firstTurn?.status === "outcome-unknown")) threadId = response?.clientThreadId;
@@ -271,56 +293,130 @@ export class DesktopTaskDelivery {
271
293
  creationConfirmed = true;
272
294
  this.security.registerThread(threadId);
273
295
  if (response.firstTurn && response.firstTurn.status !== "accepted") throw new Error(`Desktop created task ${threadId}; its first turn reports ${response.firstTurn.status ?? "an unconfirmed state"}: ${JSON.stringify(response)}`);
274
- return { threadId, name, cwd, projectId: project.projectId, projectName: project.label, backend: NATIVE_BACKEND };
296
+ const created = { threadId, name, cwd, projectId: project.projectId, projectName: project.label, backend: NATIVE_BACKEND };
297
+ const delivered = { ...created, previousTurnId: null, deliveryStatus: "accepted", responseObservation: {
298
+ operation: "create_thread", threadId, previousTurnId: null, expectedCwd: cwd,
299
+ executorThreadId: envelope.executorThreadId ?? null, prompt,
300
+ accountContext: accountContext ? { ...accountContext } : null,
301
+ },
302
+ };
303
+ const deliveryId = receipt.deliveryId ?? onAccepted?.(delivered);
304
+ if (deliveryId) {
305
+ await this.receipts.write(identity.key, { ...receipt, state: "known", threadId, deliveryId });
306
+ created.deliveryId = deliveryId;
307
+ }
308
+ return created;
275
309
  } catch (err) {
276
310
  if (!creationConfirmed) await this.receipts.write(identity.key, { ...receipt, state: "unknown", ...(typeof threadId === "string" && threadId ? { threadId } : {}) }).catch(() => {});
311
+ const cause = err.cause ?? err;
312
+ if (receipt.deliveryId && cause.reachedCompanion === true && ["RELAY_TIMEOUT", "RELAY_DELIVERY_UNCONFIRMED"].includes(cause.code)) {
313
+ return { threadId: null, cwd, name, projectId: project.projectId, projectName: project.label, deliveryStatus: "unconfirmed", deliveryId: receipt.deliveryId };
314
+ }
277
315
  throw new Error(`${err.message}. ${threadId ? `threadId: ${threadId}. ` : ""}Do not resend the prompt. The creation receipt blocks duplicate tasks even after a bridge restart.`, { cause: err });
278
316
  }
279
317
  });
280
318
  }
281
319
 
320
+ async resolveCreation(delivered, { deadline = this.now() + DESKTOP_TOOL_BUDGET_MS } = {}) {
321
+ const binding = delivered.creationObservation;
322
+ if (!binding) return delivered;
323
+ for (;;) {
324
+ if (this.now() >= deadline) return null;
325
+ this.security.assertCwd(delivered.cwd);
326
+ this.senderProjectScope(delivered.cwd);
327
+ if (!sameAccountContext(binding.accountContext, this.accountContext?.())) throw new Error("Creation receipt account changed");
328
+ const receipt = await this.receipts.read(binding.receiptKey);
329
+ if (!receipt || !sameAccountContext(receipt.accountContext, binding.accountContext) || receipt.cwd !== delivered.cwd ||
330
+ receipt.promptHash !== this.receipts.key({ cwd: delivered.cwd, prompt: binding.args.prompt }).promptHash) throw new Error("Original creation receipt binding changed");
331
+ let envelope;
332
+ try { envelope = await this.request("get_creation_receipt", { receiptId: binding.receiptId, requestHash: binding.requestHash }, { deadline, includeRelayContext: true }); }
333
+ catch (error) { if (this.now() >= deadline && ["RELAY_TIMEOUT", "RELAY_UNREACHABLE"].includes(error.cause?.code)) return null; throw error; }
334
+ const row = envelope.result;
335
+ if (row?.status === "completed") {
336
+ const response = row.result, threadId = response?.threadId;
337
+ if (typeof threadId !== "string" || !threadId.trim() || response.hostId !== "local" || response.status === "outcome-unknown" ||
338
+ response.firstTurn && response.firstTurn.status !== "accepted" || receipt.threadId && receipt.threadId !== threadId) throw new Error("Native creation is not confirmed; do not resend");
339
+ let inspected;
340
+ try { inspected = await this.#inspectMetadata(threadId, delivered.cwd, { deadline }); }
341
+ catch (error) { if (this.now() >= deadline && /deadline|within \d+ms/.test(error.message)) return null; throw error; }
342
+ if (inspected.executorThreadId !== envelope.executorThreadId) throw new Error("Native creation executor changed");
343
+ if (!sameAccountContext(binding.accountContext, this.accountContext?.())) throw new Error("Creation receipt account changed");
344
+ this.security.assertCwd(delivered.cwd);
345
+ this.senderProjectScope(delivered.cwd);
346
+ if (this.security.hardenedRoots?.mode === "project-policy") this.security.hardenedRoots.recheck(inspected.scopeBinding);
347
+ if (inspected.projectScope) recheckProjectScope(inspected.projectScope);
348
+ this.security.registerThread(threadId);
349
+ this.security.assertThread(threadId, inspected.thread.cwd);
350
+ return { threadId, cwd: delivered.cwd, previousTurnId: null, deliveryStatus: "accepted",
351
+ responseObservation: { operation: "create_thread", threadId, previousTurnId: null, expectedCwd: delivered.cwd,
352
+ executorThreadId: envelope.executorThreadId, prompt: binding.args.prompt, accountContext: binding.accountContext } };
353
+ }
354
+ if (row?.status === "failed") throw new Error(`Native creation remains unconfirmed: ${row.error?.message ?? "unknown failure"}. Do not resend.`);
355
+ if (!["missing", "pending"].includes(row?.status)) throw new Error("Invalid native creation receipt response");
356
+ await this.sleep(Math.min(1000, Math.max(0, deadline - this.now())));
357
+ }
358
+ }
359
+
282
360
  async inspect(threadId, cwd, { deadline } = {}) {
283
- const response = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1 }, { deadline });
361
+ const inspected = await this.#inspectMetadata(threadId, cwd, { deadline });
362
+ this.security.assertThread(threadId, inspected.thread.cwd);
363
+ return inspected;
364
+ }
365
+
366
+ async #inspectMetadata(threadId, cwd, { deadline } = {}) {
367
+ const envelope = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1 }, { deadline, includeRelayContext: true });
368
+ const response = envelope.result;
284
369
  const thread = response?.thread;
285
370
  if (thread?.id !== threadId || thread.hostId !== "local" || !thread.cwd) throw new Error("Desktop did not confirm the task's local workspace.");
286
- this.security.assertThread(threadId, thread.cwd);
287
371
  this.security.assertCwd(thread.cwd);
288
372
  const projectScope = this.senderProjectScope(thread.cwd);
289
373
  if (cwd && path.relative(realpathSync.native(cwd), realpathSync.native(thread.cwd))) {
290
374
  throw new Error("Native Desktop delivery cannot change an existing task's workspace; create a new task at the requested cwd.");
291
375
  }
292
- return { thread, latestTurnId: response.turns?.[0]?.id ?? null, projectScope };
376
+ const scopeBinding = this.security.hardenedRoots?.mode === "project-policy" ? this.security.hardenedRoots.capture(thread.cwd) : undefined;
377
+ return { thread, latestTurnId: response.turns?.[0]?.id ?? null, projectScope, scopeBinding, executorThreadId: envelope.executorThreadId ?? null };
293
378
  }
294
379
 
295
- async send({ threadId, prompt, cwd, model, effort, name, deadline }) {
380
+ async send({ threadId, prompt, cwd, model, effort, name, deadline, onPrepared }) {
296
381
  const inspected = await this.inspect(threadId, cwd, { deadline });
297
- if (name) await this.request("set_thread_title", { threadId, title: name.trim().slice(0, 200) }, { deadline, projectScope: inspected.projectScope });
382
+ const scopeBinding = this.security.hardenedRoots?.mode === "project-policy" ? this.security.hardenedRoots.capture(inspected.thread.cwd) : undefined;
383
+ if (name) await this.request("set_thread_title", { threadId, title: name.trim().slice(0, 200) }, { deadline, projectScope: inspected.projectScope, scopeBinding });
298
384
  const expectedCwd = realpathSync.native(inspected.thread.cwd);
299
385
  const accountContext = this.accountContext?.();
300
- const watermark = this.captureResponse({ threadId, expectedCwd });
301
- const envelope = await this.request("send_message_to_thread", {
386
+ const watermark = this.captureResponse({ threadId, expectedCwd, desktopEvidence: inspected });
387
+ // Preserve the exact pre-send observation before any possibly ambiguous write.
388
+ // An unconfirmed receipt permits observation, never resending or an approval bypass.
389
+ const delivered = {
390
+ threadId, cwd: expectedCwd, name: inspected.thread.title, previousTurnId: inspected.latestTurnId, backend: NATIVE_BACKEND,
391
+ deliveryStatus: "unconfirmed",
392
+ responseObservation: {
393
+ threadId, previousTurnId: inspected.latestTurnId, expectedCwd,
394
+ executorThreadId: inspected.executorThreadId, prompt,
395
+ accountContext: accountContext ? { ...accountContext } : null, watermark,
396
+ },
397
+ };
398
+ onPrepared?.(delivered);
399
+ let envelope;
400
+ try { envelope = await this.request("send_message_to_thread", {
302
401
  threadId, prompt,
303
402
  ...(model ? { model } : {}),
304
403
  ...(effort ? { thinking: effort } : {}),
305
- }, { deadline, includeRelayContext: true, projectScope: inspected.projectScope });
404
+ }, { deadline, includeRelayContext: true, projectScope: inspected.projectScope, scopeBinding });
405
+ } catch (error) {
406
+ const cause = error.cause ?? error;
407
+ if (onPrepared && cause.reachedCompanion === true && ["RELAY_TIMEOUT", "RELAY_DELIVERY_UNCONFIRMED"].includes(cause.code)) {
408
+ return { ...delivered, deliveryNote: error.message };
409
+ }
410
+ throw error;
411
+ }
306
412
  const response = envelope.result;
307
413
  if (response?.threadId !== threadId || response?.success === false || response?.isError === true ||
308
414
  (response?.status !== undefined && !["accepted", "sent"].includes(response.status)) ||
309
415
  (response?.firstTurn && response.firstTurn.status !== "accepted")) {
310
416
  throw new Error(`Desktop send is not confirmed for ${threadId}. Do not resend: ${JSON.stringify(response)}`);
311
417
  }
312
- return {
313
- threadId, cwd: expectedCwd, name: inspected.thread.title, previousTurnId: inspected.latestTurnId, backend: NATIVE_BACKEND,
314
- responseObservation: {
315
- threadId,
316
- previousTurnId: inspected.latestTurnId,
317
- expectedCwd,
318
- executorThreadId: envelope.executorThreadId ?? null,
319
- prompt,
320
- accountContext: accountContext ? { ...accountContext } : null,
321
- watermark,
322
- },
323
- };
418
+ if ((envelope.executorThreadId ?? null) !== inspected.executorThreadId) throw new Error("Native executor changed during dispatch; inspect the existing task without resending");
419
+ return { ...delivered, deliveryStatus: "accepted" };
324
420
  }
325
421
 
326
422
  async open(threadId, { deadline } = {}) {
@@ -340,7 +436,10 @@ export class DesktopTaskDelivery {
340
436
  if (realpathSync.native(expectedCwd) !== expectedCwd) throw new Error("The selected native task workspace changed while its response was being observed");
341
437
  };
342
438
  await recheck();
343
- const observed = this.readResponse({ ...responseObservation, threadId, turnId });
439
+ const initial = await this.inspect(threadId, expectedCwd, { deadline });
440
+ if (initial.latestTurnId !== turnId) throw new Error("The selected native task changed before its response could be read; reply content was withheld");
441
+ const read = responseObservation.operation === "create_thread" ? readCodexNativeCreationResponse : this.readResponse;
442
+ const observed = read({ ...responseObservation, threadId, turnId, desktopEvidence: initial });
344
443
  await recheck();
345
444
  const inspected = await this.inspect(threadId, expectedCwd, { deadline });
346
445
  if (inspected.latestTurnId !== turnId) throw new Error("The selected native task changed before its response could be confirmed; reply content was withheld");
@@ -361,7 +460,7 @@ export class DesktopTaskDelivery {
361
460
  if (realpathSync.native(expectedCwd) !== expectedCwd) throw new Error("The selected native task workspace changed while its turn was being inspected");
362
461
  };
363
462
  await recheck();
364
- const result = this.inspectResponse({ threadId, turnId, expectedCwd });
463
+ const result = this.inspectResponse({ threadId, turnId, expectedCwd, desktopEvidence: initial });
365
464
  await recheck();
366
465
  await this.inspect(threadId, expectedCwd, { deadline });
367
466
  await recheck();