@minhspark/codex-mcp-bridge 1.20.1 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +26 -0
- package/README.md +57 -15
- package/assets/README.md +30 -0
- package/assets/agent-pets.svg +1 -0
- package/docs/daily-handoffs.md +106 -0
- package/docs/desktop-handoff-design.md +180 -0
- package/docs/new-machine-setup.md +112 -0
- package/docs/project-onboarding.md +122 -0
- package/package.json +8 -2
- package/plugin.json +1 -1
- package/scripts/bridge-projects.mjs +44 -0
- package/scripts/check.mjs +2 -1
- package/scripts/collect-repo-analytics.mjs +5 -2
- package/scripts/configure-message-automation.mjs +18 -0
- package/scripts/install-claude-code.mjs +64 -0
- package/scripts/install-claude-desktop.mjs +48 -5
- package/scripts/install-native-relay.mjs +6 -1
- package/scripts/install-npm-footer.mjs +13 -1
- package/scripts/publish-repo-analytics.mjs +24 -1
- package/src/app-server-client.mjs +5 -2
- package/src/bridge-readiness.mjs +23 -0
- package/src/card-settings-sync.mjs +125 -0
- package/src/claude-bridge.mjs +54 -20
- package/src/claude-code-registration.mjs +54 -0
- package/src/claude-sender-context.mjs +1 -1
- package/src/claude-session-creation.mjs +10 -2
- package/src/codex-mcp-registration.mjs +1 -1
- package/src/codex-native-response.mjs +139 -107
- package/src/codex-sender-context.mjs +62 -61
- package/src/desktop-reply-receipts.mjs +87 -0
- package/src/desktop-task-receipts.mjs +30 -7
- package/src/hardened-root-policy.mjs +5 -0
- package/src/index.mjs +97 -19
- package/src/mcp-supervisor.mjs +6 -2
- package/src/message-automation.mjs +89 -0
- package/src/native-creation-receipts.mjs +155 -0
- package/src/native-relay-companion.mjs +37 -6
- package/src/native-relay.mjs +12 -3
- package/src/peer-protocol.mjs +192 -18
- package/src/project-manager-registration.mjs +34 -0
- package/src/project-onboarding-tools.mjs +30 -0
- package/src/project-onboarding.mjs +178 -0
- package/src/project-policy.mjs +221 -0
- package/src/project-scope.mjs +33 -5
- package/src/prompt-guidance.mjs +2 -0
- package/src/release-snapshot.mjs +30 -5
- package/src/rollout-reader.mjs +58 -0
- package/src/rollout-segments.mjs +65 -0
- package/src/security-policy.mjs +4 -2
- package/src/sites-connector.mjs +17 -2
- package/src/telemetry.mjs +10 -8
- package/src/thread-delivery.mjs +134 -35
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
if (session.id !== threadId || !
|
|
160
|
-
if (
|
|
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
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
321
|
-
if (
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
214
|
-
const
|
|
215
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
+
}
|