@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
@@ -285,7 +285,7 @@ export async function readClaudeSenderContext({ account, parentPid = process.ppi
285
285
  }
286
286
  const sessions = registered.filter((session) => session.entrypoint === "claude-desktop" && session.alive && seen.has(session.pid));
287
287
  if (sessions.length > 1) return senderResult("ambiguous", "Multiple registered Claude Desktop sessions appear in this MCP process's ancestry.");
288
- if (!sessions.length) return senderResult("unavailable", "This MCP process has no registered Claude Desktop Code session in its parent ancestry. Reconnect the bridge inside the intended existing Code task.");
288
+ if (!sessions.length) return senderResult("unavailable", "This MCP process has no registered Claude Desktop Code session in its parent ancestry. Check the user-level Claude Code registration: a shared Desktop MCP entry cannot identify the sending Code task. Reload the existing task after correcting registration; View > Reload may refresh only the page. Do not substitute another session or disable sender verification.");
289
289
  const session = sessions[0];
290
290
  const process = ancestry.find((entry) => entry.pid === session.pid);
291
291
  if (session.processStart && session.processStart !== process.processStart) return senderResult("unavailable", "The calling Claude session's registered process identity changed.");
@@ -48,7 +48,15 @@ function matchesInitialMessage(text, expected) {
48
48
  }
49
49
 
50
50
  function receipt(request) {
51
- return structuredClone({ requestId: request.requestId, status: request.status, cwd: request.cwd, promptSubmitted: request.status === "created" || request.submissionObserved === true, reason: request.reason, ...(request.observedCwd ? { observedCwd: request.observedCwd } : {}), ...(request.sessionId ? { sessionId: request.sessionId, taskId: request.taskId, title: request.title } : {}) });
51
+ const submitted = request.status === "created" || request.submissionObserved === true;
52
+ return structuredClone({ requestId: request.requestId, status: request.status, cwd: request.cwd,
53
+ // false means no verified observation, not proof that Desktop did not submit.
54
+ promptSubmitted: submitted, submissionStatus: submitted ? "observed" : "not_observed",
55
+ reason: request.reason, ...(request.observedCwd ? { observedCwd: request.observedCwd } : {}), ...(request.sessionId ? { sessionId: request.sessionId, taskId: request.taskId, title: request.title } : {}),
56
+ ...(PENDING.has(request.status) ? { directoryVerification: {
57
+ required: true, expectedCwd: request.cwd, verified: false,
58
+ detail: "The prefilled folder chip is not evidence of the actual session directory. Claude Desktop may create a No folder scratch session. An unobserved submission may be outside the authorized discovery scope; do not expand scope or resend to find it. Inspect Desktop before taking further action. For a fresh setup, prefer the intended project's sidebar New session button, verify the actual cwd with a read-only bootstrap, then dispatch work to the verified task. Do not replace a pending request without explicit cancellation.",
59
+ } } : {}) });
52
60
  }
53
61
 
54
62
  export class ClaudeSessionCreation {
@@ -137,7 +145,7 @@ export class ClaudeSessionCreation {
137
145
  await this.open(url);
138
146
  if (autoSubmit) await this.settle();
139
147
  request.status = "awaiting_user";
140
- request.reason = "Claude Desktop was asked to prefill a new session. Confirm the folder if prompted and press Send; no prompt has been submitted by the bridge.";
148
+ request.reason = "Claude Desktop was asked to prefill a new session; the bridge did not press Send. The folder chip alone does not confirm the actual directory. Verify the created task's cwd before dispatching work; absence of a discovered task does not prove that the prompt was not submitted.";
141
149
  } catch {
142
150
  request.reason = "The Desktop launcher failed or returned an uncertain outcome. This request will not be launched again; inspect it to check whether the session was created.";
143
151
  }
@@ -17,7 +17,7 @@ export function stdioMcpRegistration({ name, existing, node, entry, entryArgs =
17
17
 
18
18
  export function codexMcpRegistration({ name, existing, node, entry, entryArgs = [], env, desktopOnly }) {
19
19
  const values = { ...existing?.transport?.env };
20
- for (const key of ["CLAUDE_BRIDGE_PEER_NAME", "CLAUDE_BRIDGE_PERMISSION_MODE", "CODEX_BRIDGE_DESKTOP_TASKS", "CLAUDE_DESKTOP_USER_DATA"]) {
20
+ for (const key of ["CLAUDE_BRIDGE_PEER_NAME", "CLAUDE_BRIDGE_PERMISSION_MODE", "CODEX_BRIDGE_DESKTOP_TASKS", "CLAUDE_DESKTOP_USER_DATA", "CODEX_BRIDGE_PROJECT_POLICY"]) {
21
21
  if (env[key] !== undefined) values[key] = env[key];
22
22
  }
23
23
  values.CODEX_BRIDGE_DESKTOP_TASKS ??= desktopOnly ? "1" : "0";
@@ -2,11 +2,11 @@ import crypto 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 { scanRollout } from "./rollout-reader.mjs";
6
+ import { findRolloutSegments, confirmsDesktopTask, isLegacyCliSession, assertRolloutSetStable } from "./rollout-segments.mjs";
5
7
 
6
8
  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}$/;
7
9
  const MAX_ROLLOUT_BYTES = 16 * 1024 * 1024;
8
- const MAX_ENTRIES = 100000;
9
- const MAX_DIRECTORIES = 4096;
10
10
  const MAX_REPLY_BYTES = 1024 * 1024;
11
11
  const DESKTOP_ORIGINATORS = new Set(["Codex Desktop", "codex_work_desktop"]);
12
12
  const COMPLETED = new Set(["task_complete", "task_completed", "turn_complete", "turn_completed"]);
@@ -36,37 +36,9 @@ function configuredSessions(env) {
36
36
  return canonicalSessions;
37
37
  }
38
38
 
39
- function findRollout(threadId, env) {
40
- if (!UUID.test(threadId)) throw new Error("The Codex task identity is invalid");
39
+ function findRollouts(threadId, env) {
41
40
  const sessions = configuredSessions(env);
42
- const queue = [{ directory: sessions, depth: 0 }];
43
- const matches = [];
44
- let entries = 0;
45
- let directories = 0;
46
- while (queue.length) {
47
- const { directory, depth } = queue.pop();
48
- if (++directories > MAX_DIRECTORIES) throw new Error("The bounded Codex sessions scan exceeded its directory limit");
49
- const children = fs.readdirSync(directory, { withFileTypes: true });
50
- entries += children.length;
51
- if (entries > MAX_ENTRIES) throw new Error("The bounded Codex sessions scan exceeded its entry limit");
52
- for (const child of children) {
53
- const candidate = path.join(directory, child.name);
54
- if (depth < 3 && (depth === 0 ? /^\d{4}$/ : /^\d{2}$/).test(child.name)) {
55
- if (child.isSymbolicLink()) throw new Error("The Codex sessions scan encountered a linked date directory");
56
- if (child.isDirectory()) {
57
- const resolved = fs.realpathSync.native(candidate);
58
- const relative = path.relative(sessions, resolved);
59
- if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("A Codex date directory escapes the sessions path");
60
- queue.push({ directory: resolved, depth: depth + 1 });
61
- }
62
- }
63
- if (depth === 3 && child.name.startsWith("rollout-") && child.name.endsWith(`-${threadId}.jsonl`)) matches.push(candidate);
64
- }
65
- }
66
- if (matches.length !== 1) throw new Error(matches.length ? "Multiple rollouts match the Codex task" : "No rollout matches the Codex task");
67
- const status = fs.lstatSync(matches[0]);
68
- if (!status.isFile() || status.isSymbolicLink()) throw new Error("The Codex rollout is not a regular file");
69
- return { file: matches[0], sessions };
41
+ return findRolloutSegments(sessions, threadId).map((file) => ({ file, sessions }));
70
42
  }
71
43
 
72
44
  function pathIdentity(candidate, kind) {
@@ -100,49 +72,49 @@ function samePathBoundary(expected, current) {
100
72
  entry.path === current.ancestors[index].path && entry.dev === current.ancestors[index].dev && entry.ino === current.ancestors[index].ino);
101
73
  }
102
74
 
103
- function readStable(found, maxBytes) {
104
- const { file, sessions } = found;
105
- if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_ROLLOUT_BYTES) throw new Error("The Codex rollout read limit is invalid");
106
- const beforeBoundary = capturePathBoundary(file, sessions);
107
- const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
108
- try {
109
- const before = fs.fstatSync(descriptor);
110
- if (before.dev !== beforeBoundary.file.dev || before.ino !== beforeBoundary.file.ino) throw new Error("The Codex rollout changed while opening");
111
- if (!before.isFile() || before.size === 0 || before.size > maxBytes) throw new Error("The Codex rollout is empty or exceeds the bounded read limit");
112
- const data = Buffer.alloc(before.size);
113
- let offset = 0;
114
- while (offset < data.length) {
115
- const count = fs.readSync(descriptor, data, offset, data.length - offset, offset);
116
- if (!count) throw new Error("The Codex rollout changed while reading");
117
- offset += count;
75
+ function readStable(found, maxBytes, { threadId, turnId, afterOffset = 0, prefixSize } = {}) {
76
+ for (let attempt = 0; ; attempt++) {
77
+ try { return readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize }); }
78
+ catch (error) {
79
+ if (attempt >= 2 || !/changed while (reading|opening)|incomplete final record/.test(error.message)) throw error;
118
80
  }
119
- const after = fs.fstatSync(descriptor);
120
- const afterBoundary = capturePathBoundary(file, sessions);
121
- if (!samePathBoundary(beforeBoundary, afterBoundary) || before.size !== after.size || before.mtimeMs !== after.mtimeMs ||
122
- before.ctimeMs !== after.ctimeMs || before.ino !== after.ino || before.dev !== after.dev ||
123
- before.ino !== afterBoundary.file.ino || before.dev !== afterBoundary.file.dev) throw new Error("The Codex rollout changed while reading");
124
- if (data.at(-1) !== 0x0a) throw new Error("The Codex rollout has an incomplete final record");
125
- return { data, identity: { dev: before.dev, ino: before.ino } };
126
- } finally {
127
- fs.closeSync(descriptor);
128
81
  }
129
82
  }
130
83
 
131
- function parseRecords(data) {
84
+ function readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize }) {
85
+ const { file, sessions } = found;
86
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_ROLLOUT_BYTES) throw new Error("The Codex rollout read limit is invalid");
87
+ const beforeBoundary = capturePathBoundary(file, sessions);
132
88
  const records = [];
133
- let offset = 0;
134
- while (offset < data.length) {
135
- const newline = data.indexOf(0x0a, offset);
136
- if (newline < 0) throw new Error("The Codex rollout has an incomplete final record");
137
- const raw = data.subarray(offset, newline).toString("utf8");
138
- const start = offset;
139
- offset = newline + 1;
140
- if (!raw) continue;
141
- const record = JSON.parse(raw);
142
- if (!object(record) || !object(record.payload)) throw new Error("The Codex rollout contains an invalid record");
143
- records.push({ record, start });
89
+ const digest = crypto.createHash("sha256");
90
+ let session, latestContext, retainedBytes = 0, turnBeforeWatermark = false;
91
+ const snapshot = scanRollout(file, {
92
+ maxRecordBytes: maxBytes,
93
+ onChunk(chunk, offset) {
94
+ // Verify every byte of the pre-send prefix without keeping it in memory.
95
+ const length = prefixSize === undefined ? chunk.length : Math.max(0, Math.min(chunk.length, prefixSize - offset));
96
+ if (length) digest.update(chunk.subarray(0, length));
97
+ },
98
+ onRecord(record, start, bytes) {
99
+ if (record.type === "session_meta") {
100
+ if (session) throw new Error("The Codex rollout has ambiguous session identity");
101
+ session = record.payload;
102
+ }
103
+ if (record.type === "turn_context") latestContext = record.payload;
104
+ const entry = { record, start };
105
+ if (turnId && recordBelongsToTurn(entry, threadId, turnId)) {
106
+ if (start < afterOffset) { turnBeforeWatermark = true; return; }
107
+ retainedBytes += bytes;
108
+ if (retainedBytes > maxBytes) throw new Error(`The selected native turn exceeds the ${maxBytes}-byte retained-record limit`);
109
+ records.push(entry);
110
+ }
111
+ },
112
+ });
113
+ const afterBoundary = capturePathBoundary(file, sessions);
114
+ if (!samePathBoundary(beforeBoundary, afterBoundary) || snapshot.identity.dev !== beforeBoundary.file.dev || snapshot.identity.ino !== beforeBoundary.file.ino) {
115
+ throw new Error("The Codex rollout changed while reading");
144
116
  }
145
- return records;
117
+ return { ...snapshot, file, sessions, records, retainedBytes, session, latestContext, turnBeforeWatermark, prefixSha256: digest.digest("hex") };
146
118
  }
147
119
 
148
120
  function canonicalDirectory(value, label) {
@@ -152,12 +124,26 @@ function canonicalDirectory(value, label) {
152
124
  return fs.realpathSync.native(value);
153
125
  }
154
126
 
155
- function validateSession(records, threadId, expectedCwd) {
156
- const sessions = records.filter(({ record }) => record.type === "session_meta").map(({ record }) => record.payload);
157
- if (sessions.length !== 1) throw new Error("The Codex rollout has ambiguous session identity");
158
- const session = sessions[0];
159
- if (session.id !== threadId || !DESKTOP_ORIGINATORS.has(session.originator) || session.source !== "vscode") throw new Error("The rollout does not confirm the exact native Codex Desktop task");
160
- if (canonicalDirectory(session.cwd, "The rollout workspace") !== expectedCwd) throw new Error("The rollout workspace does not match the selected native task");
127
+ function validateIdentity({ session }, threadId, expectedCwd, desktopEvidence) {
128
+ if (!session) throw new Error("The Codex rollout is missing its session identity");
129
+ const desktop = DESKTOP_ORIGINATORS.has(session.originator) && session.source === "vscode";
130
+ const adopted = isLegacyCliSession(session) && confirmsDesktopTask(desktopEvidence, threadId, expectedCwd);
131
+ if (session.id !== threadId || (!desktop && !adopted)) throw new Error("The rollout does not confirm the exact native Codex Desktop task");
132
+ if (typeof session.cwd !== "string" || !path.isAbsolute(session.cwd)) throw new Error("The original rollout workspace is missing or invalid");
133
+ }
134
+
135
+ function validateSession(snapshot, threadId, expectedCwd, desktopEvidence) {
136
+ validateIdentity(snapshot, threadId, expectedCwd, desktopEvidence);
137
+ // Current cwd comes from independently inspected native Desktop metadata.
138
+ if (canonicalDirectory(snapshot.latestContext ? snapshot.latestContext.cwd : snapshot.session.cwd, "The rollout workspace") !== expectedCwd) throw new Error("The rollout workspace does not match the selected native task");
139
+ }
140
+
141
+ function uniqueTurn(snapshots) {
142
+ const selected = snapshots.filter((snapshot) => snapshot.records.length);
143
+ if (selected.length !== 1) throw new Error(selected.length
144
+ ? "The native turn spans multiple rollout segments or has ambiguous copies; response withheld"
145
+ : "No rollout segment contains the selected native turn");
146
+ return selected[0];
161
147
  }
162
148
 
163
149
  function delegationOutputs(executorThreadId, prompt) {
@@ -216,7 +202,7 @@ function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch }
216
202
  let dispatch = null;
217
203
  if (requireDispatch) {
218
204
  const dispatches = turns.filter(({ record }) => record.type === "response_item" && record.payload.type === "function_call_output" &&
219
- record.payload.namespace === "codex_app" && record.payload.name === "send_message_to_thread");
205
+ record.payload.namespace === "codex_app" && record.payload.name === (requireDispatch.operation ?? "send_message_to_thread"));
220
206
  if (dispatches.length !== 1 || !requireDispatch.outputs.includes(dispatches[0].record.payload.output) ||
221
207
  contexts[0].start >= dispatches[0].start || dispatches[0].start >= completions[0].start) {
222
208
  throw new Error("The newly observed turn is not correlated to the exact native dispatch");
@@ -274,42 +260,73 @@ function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch }
274
260
  };
275
261
  }
276
262
 
277
- export function captureCodexRolloutWatermark({ threadId, expectedCwd }, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
263
+ export function captureCodexRolloutWatermark({ threadId, expectedCwd, desktopEvidence }, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
278
264
  try {
279
265
  const cwd = canonicalDirectory(expectedCwd, "The selected native task workspace");
280
- const found = findRollout(threadId, env);
281
- const snapshot = readStable(found, maxRolloutBytes);
282
- const records = parseRecords(snapshot.data);
283
- validateSession(records, threadId, cwd);
284
- return {
285
- status: "available",
286
- threadId,
287
- cwd,
288
- file: found.file,
289
- size: snapshot.data.length,
290
- prefixSha256: crypto.createHash("sha256").update(snapshot.data).digest("hex"),
291
- identity: snapshot.identity,
292
- };
266
+ const snapshots = findRollouts(threadId, env).map((found) => ({ ...found, ...readStable(found, maxRolloutBytes) }));
267
+ assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
268
+ for (const snapshot of snapshots) validateIdentity(snapshot, threadId, cwd, desktopEvidence);
269
+ const matches = snapshots.length === 1 ? snapshots : snapshots.filter((snapshot) =>
270
+ desktopEvidence?.latestTurnId && snapshot.latestContext?.turn_id === desktopEvidence.latestTurnId);
271
+ if (matches.length !== 1) throw new Error("Multiple rollouts require an unambiguous current native Desktop turn");
272
+ const selected = matches[0];
273
+ validateSession(selected, threadId, cwd, desktopEvidence);
274
+ const mark = ({ file, size, prefixSha256, identity }) => ({ file, size, prefixSha256, identity });
275
+ return { status: "available", threadId, cwd, ...mark(selected), segments: snapshots.map(mark) };
293
276
  } catch (error) {
294
277
  return unavailable(error);
295
278
  }
296
279
  }
297
280
 
298
- export function inspectCodexNativeTurn({ threadId, turnId, expectedCwd }, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
281
+ // Creation has no pre-send rollout: the confirmed fresh thread ID is its boundary.
282
+ // Require the exact create_thread dispatch, executor and prompt rather than
283
+ // treating any later turn in that thread as the delegated reply.
284
+ export function readCodexNativeCreationResponse({ threadId, turnId, expectedCwd, executorThreadId, prompt, desktopEvidence }, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
285
+ try {
286
+ if (!UUID.test(threadId) || !UUID.test(turnId) || !UUID.test(executorThreadId)) throw new Error("The native creation response identity is invalid");
287
+ if (typeof prompt !== "string" || !prompt.length) throw new Error("The exact creation prompt is unavailable");
288
+ const cwd = canonicalDirectory(expectedCwd, "The created native task workspace");
289
+ let retainedBytes = 0;
290
+ const snapshots = findRollouts(threadId, env).map((found) => {
291
+ const snapshot = readStable(found, maxRolloutBytes, { threadId, turnId });
292
+ retainedBytes += snapshot.retainedBytes;
293
+ if (retainedBytes > maxRolloutBytes) throw new Error("The native turn exceeds the retained-record limit across segments");
294
+ validateIdentity(snapshot, threadId, cwd, desktopEvidence);
295
+ return snapshot;
296
+ });
297
+ assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
298
+ const selected = uniqueTurn(snapshots);
299
+ validateSession(selected, threadId, cwd, desktopEvidence);
300
+ return inspectTurnRecords(selected.records, threadId, turnId, cwd, {
301
+ requireDispatch: { operation: "create_thread", outputs: delegationOutputs(executorThreadId, prompt) },
302
+ });
303
+ } catch (error) {
304
+ return { ...unavailable(error), threadId, turnId, source: "codex_desktop_rollout", assistantItems: [], text: "", replySha256: null };
305
+ }
306
+ }
307
+
308
+ export function inspectCodexNativeTurn({ threadId, turnId, expectedCwd, desktopEvidence }, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
299
309
  try {
300
310
  if (!UUID.test(threadId) || !UUID.test(turnId)) throw new Error("The native turn identity is invalid");
301
311
  const cwd = canonicalDirectory(expectedCwd, "The selected native task workspace");
302
- const found = findRollout(threadId, env);
303
- const snapshot = readStable(found, maxRolloutBytes);
304
- const records = parseRecords(snapshot.data);
305
- validateSession(records, threadId, cwd);
306
- return inspectTurnRecords(records, threadId, turnId, cwd);
312
+ let retainedBytes = 0;
313
+ const snapshots = findRollouts(threadId, env).map((found) => {
314
+ const snapshot = readStable(found, maxRolloutBytes, { threadId, turnId });
315
+ retainedBytes += snapshot.retainedBytes;
316
+ if (retainedBytes > maxRolloutBytes) throw new Error("The native turn exceeds the retained-record limit across segments");
317
+ return snapshot;
318
+ });
319
+ assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
320
+ for (const snapshot of snapshots) validateIdentity(snapshot, threadId, cwd, desktopEvidence);
321
+ const selected = uniqueTurn(snapshots);
322
+ validateSession(selected, threadId, cwd, desktopEvidence);
323
+ return inspectTurnRecords(selected.records, threadId, turnId, cwd);
307
324
  } catch (error) {
308
325
  return { ...unavailable(error), threadId, turnId, source: "codex_desktop_rollout", assistantItems: [], text: "", replySha256: null };
309
326
  }
310
327
  }
311
328
 
312
- export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId, expectedCwd, executorThreadId, prompt, watermark }, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
329
+ export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId, expectedCwd, executorThreadId, prompt, watermark, desktopEvidence }, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
313
330
  try {
314
331
  if (!UUID.test(threadId) || !UUID.test(turnId) || !UUID.test(executorThreadId)) throw new Error("The native response identity is invalid");
315
332
  if (turnId === previousTurnId) throw new Error("The observed turn was already present before dispatch");
@@ -317,16 +334,31 @@ export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId,
317
334
  if (!object(watermark) || watermark.status !== "available" || watermark.threadId !== threadId) throw new Error("No trusted pre-send rollout watermark is available");
318
335
  const cwd = canonicalDirectory(expectedCwd, "The selected native task workspace");
319
336
  if (watermark.cwd !== cwd) throw new Error("The selected native task workspace changed after dispatch");
320
- const found = findRollout(threadId, env);
321
- if (found.file !== watermark.file) throw new Error("The selected native task rollout changed after dispatch");
322
- const snapshot = readStable(found, maxRolloutBytes);
323
- if (snapshot.identity.dev !== watermark.identity?.dev || snapshot.identity.ino !== watermark.identity?.ino || snapshot.data.length < watermark.size) throw new Error("The selected native task rollout identity changed after dispatch");
324
- const prefix = snapshot.data.subarray(0, watermark.size);
325
- if (crypto.createHash("sha256").update(prefix).digest("hex") !== watermark.prefixSha256) throw new Error("The pre-send Codex rollout history changed after dispatch");
326
- const records = parseRecords(snapshot.data);
327
- validateSession(records, threadId, cwd);
328
- const tail = records.filter(({ start }) => start >= watermark.size);
329
- const inspected = inspectTurnRecords(tail, threadId, turnId, cwd, {
337
+ const marks = watermark.segments ?? [watermark];
338
+ if (!Array.isArray(marks) || !marks.length || marks.length > 64
339
+ || new Set(marks.map((mark) => mark.file)).size !== marks.length
340
+ || !marks.some((mark) => mark.file === watermark.file && mark.size === watermark.size && mark.prefixSha256 === watermark.prefixSha256)) throw new Error("The pre-send segment watermarks are invalid");
341
+ for (const mark of marks) if (!Number.isSafeInteger(mark.size) || mark.size < 1) throw new Error("The pre-send rollout watermark size is invalid");
342
+ const found = findRollouts(threadId, env);
343
+ if (marks.some((mark) => !found.some(({ file }) => file === mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
344
+ let retainedBytes = 0;
345
+ const snapshots = found.map((entry) => {
346
+ const mark = marks.find(({ file }) => file === entry.file);
347
+ const snapshot = readStable(entry, maxRolloutBytes, { threadId, turnId, afterOffset: mark?.size ?? 0, prefixSize: mark?.size });
348
+ retainedBytes += snapshot.retainedBytes;
349
+ if (retainedBytes > maxRolloutBytes) throw new Error("The native turn exceeds the retained-record limit across segments");
350
+ if (mark) {
351
+ if (snapshot.identity.dev !== mark.identity?.dev || snapshot.identity.ino !== mark.identity?.ino || snapshot.size < mark.size) throw new Error("The selected native task rollout identity changed after dispatch");
352
+ if (snapshot.prefixSha256 !== mark.prefixSha256) throw new Error("The pre-send Codex rollout history changed after dispatch");
353
+ }
354
+ if (snapshot.turnBeforeWatermark) throw new Error("The observed turn was already present before dispatch");
355
+ validateIdentity(snapshot, threadId, cwd, desktopEvidence);
356
+ return snapshot;
357
+ });
358
+ assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
359
+ const selected = uniqueTurn(snapshots);
360
+ validateSession(selected, threadId, cwd, desktopEvidence);
361
+ const inspected = inspectTurnRecords(selected.records, threadId, turnId, cwd, {
330
362
  requireDispatch: { outputs: delegationOutputs(executorThreadId, prompt) },
331
363
  });
332
364
  return { ...inspected, observationStatus: inspected.status };
@@ -1,14 +1,14 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { scanRollout } from "./rollout-reader.mjs";
5
+ import { findRolloutSegments, confirmsDesktopTask, isLegacyCliSession, assertRolloutSetStable } from "./rollout-segments.mjs";
4
6
 
5
7
  const METADATA_KEY = "x-codex-turn-metadata";
6
8
  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}$/;
7
9
  const LIFECYCLE = new Set(["task_started", "task_complete", "task_completed", "turn_started", "turn_complete", "turn_completed", "turn_aborted", "task_aborted"]);
8
10
  const STARTED = new Set(["task_started", "turn_started"]);
9
11
  const MAX_ROLLOUT_BYTES = 64 * 1024 * 1024;
10
- const MAX_ENTRIES = 100000;
11
- const MAX_DIRECTORIES = 4096;
12
12
  const DESKTOP_ORIGINATORS = new Set(["Codex Desktop", "codex_work_desktop"]);
13
13
  const PROMPTING_POLICIES = new Set(["on-request", "on-failure", "untrusted"]);
14
14
  const GRANULAR_CATEGORIES = ["sandbox_approval", "rules", "mcp_elicitations"];
@@ -37,57 +37,27 @@ function reviewFlag(metadata, field) {
37
37
  }
38
38
 
39
39
  export function findRollout(sessions, threadId) {
40
- if (!fs.lstatSync(sessions).isDirectory()) throw new Error("The Codex sessions path is not a regular directory");
41
- const queue = [{ directory: sessions, depth: 0 }];
42
- const matches = [];
43
- let entries = 0;
44
- let directories = 0;
45
- while (queue.length) {
46
- const { directory, depth } = queue.pop();
47
- if (++directories > MAX_DIRECTORIES) throw new Error("The bounded Codex sessions scan exceeded its directory limit");
48
- const children = fs.readdirSync(directory, { withFileTypes: true });
49
- entries += children.length;
50
- if (entries > MAX_ENTRIES) throw new Error("The bounded Codex sessions scan exceeded its entry limit");
51
- for (const child of children) {
52
- const candidate = path.join(directory, child.name);
53
- if (depth < 3 && (depth === 0 ? /^\d{4}$/ : /^\d{2}$/).test(child.name)) {
54
- if (child.isSymbolicLink()) throw new Error("The Codex sessions scan encountered a linked date directory");
55
- if (child.isDirectory()) queue.push({ directory: candidate, depth: depth + 1 });
56
- }
57
- if (depth === 3 && child.name.startsWith("rollout-") && child.name.endsWith(`-${threadId}.jsonl`)) {
58
- if (!child.isFile() || child.isSymbolicLink()) throw new Error("The sender rollout is not a regular file");
59
- matches.push(candidate);
60
- }
61
- }
62
- }
63
- if (matches.length !== 1) throw new Error(matches.length ? "Multiple rollouts match the calling Codex task" : "No rollout matches the calling Codex task");
64
- return matches[0];
40
+ const files = findRolloutSegments(sessions, threadId);
41
+ if (files.length !== 1) throw new Error("Multiple rollouts match the calling Codex task");
42
+ return files[0];
65
43
  }
66
44
 
67
45
  export function readState(file, maxBytes) {
68
- const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
69
- try {
70
- const before = fs.fstatSync(descriptor);
71
- if (!before.isFile() || before.size === 0 || before.size > maxBytes) throw new Error("The sender rollout is empty or exceeds the bounded read limit");
72
- const data = Buffer.alloc(before.size);
73
- let offset = 0;
74
- while (offset < data.length) {
75
- const count = fs.readSync(descriptor, data, offset, data.length - offset, offset);
76
- if (!count) throw new Error("The sender rollout changed while reading");
77
- offset += count;
46
+ for (let attempt = 0; ; attempt++) {
47
+ try { return readStateOnce(file, maxBytes); }
48
+ catch (error) {
49
+ // A live Desktop task can append its tool event during verification.
50
+ // Retry the entire scan, never reuse partially observed permissions.
51
+ if (attempt >= 2 || !/changed while (reading|opening)|incomplete final record/.test(error.message)) throw error;
78
52
  }
79
- const after = fs.fstatSync(descriptor);
80
- const current = fs.lstatSync(file);
81
- if (!current.isFile() || current.isSymbolicLink() || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ino !== current.ino || before.dev !== current.dev) throw new Error("The sender rollout changed while reading");
82
- const text = data.toString("utf8");
83
- if (!text.endsWith("\n")) throw new Error("The sender rollout has an incomplete final record");
84
- let session;
85
- let context;
86
- let lifecycle;
87
- for (const line of text.split("\n")) {
88
- if (!line) continue;
89
- const record = JSON.parse(line);
90
- if (!object(record) || !object(record.payload)) throw new Error("The sender rollout contains an invalid record");
53
+ }
54
+ }
55
+
56
+ function readStateOnce(file, maxBytes) {
57
+ let session, context, lifecycle;
58
+ const snapshot = scanRollout(file, {
59
+ maxRecordBytes: maxBytes,
60
+ onRecord(record) {
91
61
  if (record.type === "session_meta") {
92
62
  if (session) throw new Error("The sender rollout repeats its session identity");
93
63
  session = record.payload;
@@ -96,11 +66,9 @@ export function readState(file, maxBytes) {
96
66
  } else if (record.type === "event_msg" && LIFECYCLE.has(record.payload.type)) {
97
67
  lifecycle = record.payload;
98
68
  }
99
- }
100
- return { session, context, lifecycle };
101
- } finally {
102
- fs.closeSync(descriptor);
103
- }
69
+ },
70
+ });
71
+ return { session, context, lifecycle, version: snapshot.version };
104
72
  }
105
73
 
106
74
  /**
@@ -199,31 +167,64 @@ function permissionClass(context, metadata) {
199
167
  throw new Error("The caller's effective approval policy is unsupported");
200
168
  }
201
169
 
202
- export function readCodexSenderContext(meta, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES, originator = "Codex Desktop" } = {}) {
170
+ export function readCodexSenderContext(meta, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES, originator = "Codex Desktop", desktopEvidence } = {}) {
203
171
  const metadata = object(meta) ? meta[METADATA_KEY] : undefined;
204
172
  if (!object(metadata) || typeof metadata.thread_id !== "string" || typeof metadata.turn_id !== "string" || !UUID.test(metadata.thread_id) || !UUID.test(metadata.turn_id)) return unavailable("This MCP call has no valid host-supplied Codex task and turn identity");
205
173
  const identity = { threadId: metadata.thread_id, turnId: metadata.turn_id,
206
174
  review: { autoReview: reviewFlag(metadata, "auto_review_enabled"), nodeReplReview: reviewFlag(metadata, "node_repl_auto_review_required") } };
207
175
  try {
208
176
  if (!Number.isSafeInteger(maxRolloutBytes) || maxRolloutBytes < 1) throw new Error("The sender rollout read limit is invalid");
209
- if (metadata.thread_source !== "user") throw new Error("This MCP call is not from a user-owned Codex task");
177
+ // Native create_thread produces a user-visible Desktop task with a distinct
178
+ // host source. It is not a transient subagent. Require live native proof in
179
+ // addition to the root rollout and the caller's own permission context.
180
+ const agentCreated = metadata.thread_source === "agent_created_thread" && originator === "Codex Desktop";
181
+ if (metadata.thread_source !== "user" && !agentCreated) throw new Error(`Unsupported calling task source (host thread_source: ${JSON.stringify(metadata.thread_source) ?? "missing"})`);
210
182
  const configuredHome = env.CODEX_HOME || path.join(env.HOME || env.USERPROFILE || os.homedir(), ".codex");
211
183
  if (!path.isAbsolute(configuredHome)) throw new Error("The configured Codex home must be absolute");
212
184
  const sessions = path.join(configuredHome, "sessions");
213
- const file = findRollout(sessions, identity.threadId);
214
- const state = readState(file, Math.min(MAX_ROLLOUT_BYTES, maxRolloutBytes));
215
- const { session, context, lifecycle } = state;
185
+ const matches = [], snapshots = [];
186
+ for (const file of findRolloutSegments(sessions, identity.threadId)) {
187
+ const state = readState(file, Math.min(MAX_ROLLOUT_BYTES, maxRolloutBytes));
188
+ if (state.session?.id !== identity.threadId) throw new Error("A rollout segment contradicts the calling task identity");
189
+ snapshots.push({ file, version: state.version });
190
+ if (state.context?.turn_id === identity.turnId || state.lifecycle?.turn_id === identity.turnId) matches.push({ file, ...state });
191
+ if (matches.length > 1) throw new Error("Multiple rollout segments match the calling turn");
192
+ }
193
+ assertRolloutSetStable(sessions, identity.threadId, snapshots);
194
+ if (matches.length !== 1) throw new Error(matches.length ? "Multiple rollout segments match the calling turn" : "The calling turn is no longer the latest active Codex turn");
195
+ const { file, session, context, lifecycle } = matches[0];
216
196
  if (!["Codex Desktop", "codex_vscode"].includes(originator)) throw new Error("Unsupported calling host");
217
197
  const desktop = originator === "Codex Desktop";
218
198
  const matchesOriginator = desktop ? DESKTOP_ORIGINATORS.has(session?.originator) : session?.originator === originator;
219
- if (session?.id !== identity.threadId || !matchesOriginator || session.source !== "vscode") throw new Error(`The caller rollout does not confirm a root ${originator} task`);
199
+ const legacy = desktop && isLegacyCliSession(session);
200
+ if ((!matchesOriginator || session.source !== "vscode") && !legacy) throw new Error(`The caller rollout does not confirm a root ${originator} task`);
220
201
  if (context?.turn_id !== identity.turnId || lifecycle?.turn_id !== identity.turnId || !STARTED.has(lifecycle?.type)) throw new Error("The calling turn is no longer the latest active Codex turn");
221
202
  if (typeof context.cwd !== "string" || !path.isAbsolute(context.cwd) || typeof session.cwd !== "string" || !path.isAbsolute(session.cwd)) throw new Error("The caller's workspace is missing or invalid");
222
203
  const cwd = fs.realpathSync.native(context.cwd);
223
- if (!fs.statSync(cwd).isDirectory() || path.relative(fs.realpathSync.native(session.cwd), cwd)) throw new Error("The caller's workspace changed from its Desktop session identity");
204
+ if (!fs.statSync(cwd).isDirectory()) throw new Error("The caller's current workspace is not a directory");
205
+ // Desktop can move a task after creation. Its exact active turn supplies both
206
+ // the effective workspace and permissions; session_meta.cwd is historical.
207
+ // VS Code's same-project policy is deliberately unchanged.
208
+ if (!desktop && path.relative(fs.realpathSync.native(session.cwd), cwd)) throw new Error("The caller's workspace changed from its VS Code session identity");
224
209
  const { mode, approvalPolicy, permissionProfile } = permissionClass(context, metadata);
210
+ if ((legacy || agentCreated) && !confirmsDesktopTask(desktopEvidence, identity.threadId, cwd, identity.turnId)) {
211
+ return { ...unavailable(`This ${agentCreated ? "agent-created" : "CLI-origin"} task requires current native Desktop ownership and exact active-turn confirmation`, identity), requiresDesktopEvidence: true };
212
+ }
225
213
  return { status: "verified", ...identity, mode, cwd, source: file, approvalPolicy, permissionProfile, approvalsReviewer: context.approvals_reviewer, reason: "Host-supplied calling task and active turn match the Desktop rollout's effective permission settings" };
226
214
  } catch (error) {
227
215
  return unavailable(error?.code ? `Caller evidence could not be read (${error.code}); no sender permission class was inferred` : error.message, identity);
228
216
  }
229
217
  }
218
+
219
+ // Confirm adopted CLI and agent-created tasks through the account-bound native relay,
220
+ // then re-read the rollout so a completed/replaced turn cannot reuse the proof.
221
+ export async function resolveCodexSenderContext(meta, { inspectDesktopTask, ...options } = {}) {
222
+ const initial = readCodexSenderContext(meta, options);
223
+ if (!initial.requiresDesktopEvidence || typeof inspectDesktopTask !== "function") return initial;
224
+ try {
225
+ const desktopEvidence = await inspectDesktopTask(initial.threadId);
226
+ return readCodexSenderContext(meta, { ...options, desktopEvidence });
227
+ } catch (error) {
228
+ return { ...initial, reason: `Native Desktop ownership could not be verified: ${error.message}` };
229
+ }
230
+ }