@minhspark/codex-mcp-bridge 1.16.0 → 1.16.1

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.
@@ -0,0 +1,333 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ 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
+ const MAX_ROLLOUT_BYTES = 16 * 1024 * 1024;
8
+ const MAX_ENTRIES = 100000;
9
+ const MAX_DIRECTORIES = 4096;
10
+ const MAX_REPLY_BYTES = 1024 * 1024;
11
+ const COMPLETED = new Set(["task_complete", "task_completed", "turn_complete", "turn_completed"]);
12
+ const STARTED = new Set(["task_started", "turn_started"]);
13
+ const FAILED = new Set(["task_aborted", "turn_aborted", "task_failed", "turn_failed"]);
14
+
15
+ function object(value) {
16
+ return value !== null && typeof value === "object" && !Array.isArray(value);
17
+ }
18
+
19
+ function unavailable(reason) {
20
+ return { status: "unavailable", reason: reason?.message ?? String(reason) };
21
+ }
22
+
23
+ function configuredSessions(env) {
24
+ const configuredHome = env.CODEX_HOME || path.join(env.HOME || env.USERPROFILE || os.homedir(), ".codex");
25
+ if (!path.isAbsolute(configuredHome)) throw new Error("The configured Codex home must be absolute");
26
+ const homeStatus = fs.lstatSync(configuredHome);
27
+ if (!homeStatus.isDirectory() || homeStatus.isSymbolicLink()) throw new Error("The configured Codex home is not a regular directory");
28
+ const sessions = path.join(configuredHome, "sessions");
29
+ const status = fs.lstatSync(sessions);
30
+ if (!status.isDirectory() || status.isSymbolicLink()) throw new Error("The Codex sessions path is not a regular directory");
31
+ const canonicalHome = fs.realpathSync.native(configuredHome);
32
+ const canonicalSessions = fs.realpathSync.native(sessions);
33
+ const relative = path.relative(canonicalHome, canonicalSessions);
34
+ if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("The Codex sessions path escapes the configured home");
35
+ return canonicalSessions;
36
+ }
37
+
38
+ function findRollout(threadId, env) {
39
+ if (!UUID.test(threadId)) throw new Error("The Codex task identity is invalid");
40
+ const sessions = configuredSessions(env);
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()) {
56
+ const resolved = fs.realpathSync.native(candidate);
57
+ const relative = path.relative(sessions, resolved);
58
+ if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("A Codex date directory escapes the sessions path");
59
+ queue.push({ directory: resolved, depth: depth + 1 });
60
+ }
61
+ }
62
+ if (depth === 3 && child.name.startsWith("rollout-") && child.name.endsWith(`-${threadId}.jsonl`)) matches.push(candidate);
63
+ }
64
+ }
65
+ if (matches.length !== 1) throw new Error(matches.length ? "Multiple rollouts match the Codex task" : "No rollout matches the Codex task");
66
+ const status = fs.lstatSync(matches[0]);
67
+ if (!status.isFile() || status.isSymbolicLink()) throw new Error("The Codex rollout is not a regular file");
68
+ return { file: matches[0], sessions };
69
+ }
70
+
71
+ function pathIdentity(candidate, kind) {
72
+ const status = fs.lstatSync(candidate);
73
+ if ((kind === "file" ? !status.isFile() : !status.isDirectory()) || status.isSymbolicLink()) throw new Error(`The Codex rollout ${kind === "file" ? "file" : "ancestor"} is not regular`);
74
+ return { path: candidate, dev: status.dev, ino: status.ino };
75
+ }
76
+
77
+ function capturePathBoundary(file, sessions) {
78
+ const resolvedFile = fs.realpathSync.native(file);
79
+ const relative = path.relative(sessions, resolvedFile);
80
+ if (relative.startsWith("..") || path.isAbsolute(relative) || path.relative(file, resolvedFile)) throw new Error("The Codex rollout path escapes its sessions directory");
81
+ const ancestors = [];
82
+ let current = path.dirname(file);
83
+ for (;;) {
84
+ const resolved = fs.realpathSync.native(current);
85
+ const within = path.relative(sessions, resolved);
86
+ if (within.startsWith("..") || path.isAbsolute(within) || path.relative(current, resolved)) throw new Error("A Codex rollout ancestor redirects outside its sessions directory");
87
+ ancestors.push(pathIdentity(current, "directory"));
88
+ if (!path.relative(sessions, current)) break;
89
+ const parent = path.dirname(current);
90
+ if (parent === current) throw new Error("The Codex rollout path has no sessions ancestor");
91
+ current = parent;
92
+ }
93
+ return { file: pathIdentity(file, "file"), ancestors };
94
+ }
95
+
96
+ function samePathBoundary(expected, current) {
97
+ return expected.file.dev === current.file.dev && expected.file.ino === current.file.ino &&
98
+ expected.ancestors.length === current.ancestors.length && expected.ancestors.every((entry, index) =>
99
+ entry.path === current.ancestors[index].path && entry.dev === current.ancestors[index].dev && entry.ino === current.ancestors[index].ino);
100
+ }
101
+
102
+ function readStable(found, maxBytes) {
103
+ const { file, sessions } = found;
104
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_ROLLOUT_BYTES) throw new Error("The Codex rollout read limit is invalid");
105
+ const beforeBoundary = capturePathBoundary(file, sessions);
106
+ const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
107
+ try {
108
+ const before = fs.fstatSync(descriptor);
109
+ if (before.dev !== beforeBoundary.file.dev || before.ino !== beforeBoundary.file.ino) throw new Error("The Codex rollout changed while opening");
110
+ if (!before.isFile() || before.size === 0 || before.size > maxBytes) throw new Error("The Codex rollout is empty or exceeds the bounded read limit");
111
+ const data = Buffer.alloc(before.size);
112
+ let offset = 0;
113
+ while (offset < data.length) {
114
+ const count = fs.readSync(descriptor, data, offset, data.length - offset, offset);
115
+ if (!count) throw new Error("The Codex rollout changed while reading");
116
+ offset += count;
117
+ }
118
+ const after = fs.fstatSync(descriptor);
119
+ const afterBoundary = capturePathBoundary(file, sessions);
120
+ if (!samePathBoundary(beforeBoundary, afterBoundary) || before.size !== after.size || before.mtimeMs !== after.mtimeMs ||
121
+ before.ctimeMs !== after.ctimeMs || before.ino !== after.ino || before.dev !== after.dev ||
122
+ before.ino !== afterBoundary.file.ino || before.dev !== afterBoundary.file.dev) throw new Error("The Codex rollout changed while reading");
123
+ if (data.at(-1) !== 0x0a) throw new Error("The Codex rollout has an incomplete final record");
124
+ return { data, identity: { dev: before.dev, ino: before.ino } };
125
+ } finally {
126
+ fs.closeSync(descriptor);
127
+ }
128
+ }
129
+
130
+ function parseRecords(data) {
131
+ const records = [];
132
+ let offset = 0;
133
+ while (offset < data.length) {
134
+ const newline = data.indexOf(0x0a, offset);
135
+ if (newline < 0) throw new Error("The Codex rollout has an incomplete final record");
136
+ const raw = data.subarray(offset, newline).toString("utf8");
137
+ const start = offset;
138
+ offset = newline + 1;
139
+ if (!raw) continue;
140
+ const record = JSON.parse(raw);
141
+ if (!object(record) || !object(record.payload)) throw new Error("The Codex rollout contains an invalid record");
142
+ records.push({ record, start });
143
+ }
144
+ return records;
145
+ }
146
+
147
+ function canonicalDirectory(value, label) {
148
+ if (typeof value !== "string" || !path.isAbsolute(value)) throw new Error(`${label} is missing or invalid`);
149
+ const status = fs.lstatSync(value);
150
+ if (!status.isDirectory() || status.isSymbolicLink()) throw new Error(`${label} is not a regular directory`);
151
+ return fs.realpathSync.native(value);
152
+ }
153
+
154
+ function validateSession(records, threadId, expectedCwd) {
155
+ const sessions = records.filter(({ record }) => record.type === "session_meta").map(({ record }) => record.payload);
156
+ if (sessions.length !== 1) throw new Error("The Codex rollout has ambiguous session identity");
157
+ const session = sessions[0];
158
+ if (session.id !== threadId || session.originator !== "Codex Desktop" || session.source !== "vscode") throw new Error("The rollout does not confirm the exact native Codex Desktop task");
159
+ if (canonicalDirectory(session.cwd, "The rollout workspace") !== expectedCwd) throw new Error("The rollout workspace does not match the selected native task");
160
+ }
161
+
162
+ function delegationOutput(executorThreadId, prompt) {
163
+ return `<codex_delegation>\n <source_thread_id>${executorThreadId}</source_thread_id>\n <input>${prompt}</input>\n</codex_delegation>`;
164
+ }
165
+
166
+ function normalizedText(value) {
167
+ return value.replace(/\r\n?/g, "\n").normalize("NFC");
168
+ }
169
+
170
+ function replyHash(text) {
171
+ return crypto.createHash("sha256").update(normalizedText(text), "utf8").digest("hex");
172
+ }
173
+
174
+ function recordBelongsToTurn(entry, threadId, turnId) {
175
+ const payload = entry.record.payload;
176
+ const metadata = object(payload.internal_chat_message_metadata_passthrough) ? payload.internal_chat_message_metadata_passthrough : {};
177
+ const turnFields = [payload.turn_id, payload.root_turn_id, metadata.turn_id, metadata.root_turn_id].filter((value) => value !== undefined);
178
+ if (!turnFields.includes(turnId)) return false;
179
+ if (turnFields.some((value) => value !== turnId)) throw new Error("The native response contains contradictory turn identity");
180
+ const threadFields = [payload.thread_id, metadata.thread_id].filter((value) => value !== undefined);
181
+ if (threadFields.some((value) => value !== threadId)) throw new Error("The native response contains contradictory task identity");
182
+ return true;
183
+ }
184
+
185
+ function responseItemText(payload) {
186
+ if (!Array.isArray(payload.content) || payload.content.some((item) => !object(item) || item.type !== "output_text" || typeof item.text !== "string")) {
187
+ throw new Error("The native final response contains unsupported content");
188
+ }
189
+ return payload.content.map((item) => item.text).join("");
190
+ }
191
+
192
+ function completedEventText(item) {
193
+ if (!object(item) || item.type !== "AgentMessage" || item.phase !== "final_answer") return null;
194
+ if (!Array.isArray(item.content) || item.content.some((part) => !object(part) || part.type !== "Text" || typeof part.text !== "string")) {
195
+ throw new Error("The native completed assistant item contains unsupported content");
196
+ }
197
+ return item.content.map((part) => part.text).join("");
198
+ }
199
+
200
+ function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch } = {}) {
201
+ const turns = records.filter((entry) => recordBelongsToTurn(entry, threadId, turnId));
202
+ const starts = turns.filter(({ record }) => record.type === "event_msg" && STARTED.has(record.payload.type));
203
+ const contexts = turns.filter(({ record }) => record.type === "turn_context");
204
+ const completions = turns.filter(({ record }) => record.type === "event_msg" && COMPLETED.has(record.payload.type));
205
+ const failures = turns.filter(({ record }) => record.type === "event_msg" &&
206
+ (FAILED.has(record.payload.type) || record.payload.error || ["failed", "aborted", "interrupted"].includes(record.payload.status)));
207
+ if (failures.length || starts.length !== 1 || contexts.length !== 1 || completions.length !== 1 ||
208
+ canonicalDirectory(contexts[0].record.payload.cwd, "The observed turn workspace") !== cwd ||
209
+ starts[0].start >= contexts[0].start || contexts[0].start >= completions[0].start) {
210
+ throw new Error("The native turn lifecycle is missing, ambiguous, failed, or out of order");
211
+ }
212
+
213
+ let dispatch = null;
214
+ if (requireDispatch) {
215
+ const dispatches = turns.filter(({ record }) => record.type === "response_item" && record.payload.type === "function_call_output" &&
216
+ record.payload.namespace === "codex_app" && record.payload.name === "send_message_to_thread");
217
+ if (dispatches.length !== 1 || dispatches[0].record.payload.output !== requireDispatch.output ||
218
+ contexts[0].start >= dispatches[0].start || dispatches[0].start >= completions[0].start) {
219
+ throw new Error("The newly observed turn is not correlated to the exact native dispatch");
220
+ }
221
+ dispatch = dispatches[0];
222
+ }
223
+
224
+ const allAssistantEntries = turns.filter(({ record }) => record.type === "response_item" && record.payload.type === "message" && record.payload.role === "assistant");
225
+ const finalEntries = allAssistantEntries.filter(({ record }) => record.payload.phase === "final_answer");
226
+ if (!finalEntries.length && allAssistantEntries.length) throw new Error("The completed native turn has assistant items but no final assistant reply");
227
+ const assistantItems = [];
228
+ const ids = new Set();
229
+ for (const entry of finalEntries) {
230
+ const { payload } = entry.record;
231
+ if (typeof payload.id !== "string" || !payload.id.trim() || ids.has(payload.id) ||
232
+ entry.start <= (dispatch?.start ?? contexts[0].start) || entry.start >= completions[0].start) {
233
+ throw new Error("The native final response item identity is missing, duplicated, or out of order");
234
+ }
235
+ ids.add(payload.id);
236
+ const text = responseItemText(payload);
237
+ if (!text.trim()) throw new Error("The native final response item is empty");
238
+ assistantItems.push({ id: payload.id, text });
239
+ }
240
+
241
+ const eventItems = new Map();
242
+ for (const { record, start } of turns) {
243
+ if (record.type !== "event_msg" || record.payload.type !== "item_completed") continue;
244
+ const eventItem = record.payload.item;
245
+ if (ids.has(eventItem?.id) && (eventItem.type !== "AgentMessage" || eventItem.phase !== "final_answer")) {
246
+ throw new Error("The native assistant item representations conflict in type or phase");
247
+ }
248
+ const text = completedEventText(record.payload.item);
249
+ if (text === null) continue;
250
+ const id = record.payload.item.id;
251
+ if (typeof id !== "string" || !id.trim() || eventItems.has(id) || start <= contexts[0].start || start >= completions[0].start) {
252
+ throw new Error("The native completed assistant item identity is missing, duplicated, or out of order");
253
+ }
254
+ eventItems.set(id, text);
255
+ }
256
+ for (const item of assistantItems) {
257
+ if (eventItems.has(item.id) && eventItems.get(item.id) !== item.text) throw new Error("The native assistant item representations conflict");
258
+ }
259
+ if ([...eventItems.keys()].some((id) => !ids.has(id))) throw new Error("The native completed assistant item has no matching authoritative response item");
260
+
261
+ const text = assistantItems.map((item) => item.text).join("\n\n");
262
+ if (Buffer.byteLength(text, "utf8") > MAX_REPLY_BYTES) throw new Error("The native final response exceeds the bounded reply limit");
263
+ return {
264
+ status: assistantItems.length ? "completed" : "completed_no_reply",
265
+ threadId,
266
+ turnId,
267
+ source: "codex_desktop_rollout",
268
+ assistantItems,
269
+ text,
270
+ replySha256: replyHash(text),
271
+ };
272
+ }
273
+
274
+ export function captureCodexRolloutWatermark({ threadId, expectedCwd }, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
275
+ try {
276
+ const cwd = canonicalDirectory(expectedCwd, "The selected native task workspace");
277
+ const found = findRollout(threadId, env);
278
+ const snapshot = readStable(found, maxRolloutBytes);
279
+ const records = parseRecords(snapshot.data);
280
+ validateSession(records, threadId, cwd);
281
+ return {
282
+ status: "available",
283
+ threadId,
284
+ cwd,
285
+ file: found.file,
286
+ size: snapshot.data.length,
287
+ prefixSha256: crypto.createHash("sha256").update(snapshot.data).digest("hex"),
288
+ identity: snapshot.identity,
289
+ };
290
+ } catch (error) {
291
+ return unavailable(error);
292
+ }
293
+ }
294
+
295
+ export function inspectCodexNativeTurn({ threadId, turnId, expectedCwd }, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
296
+ try {
297
+ if (!UUID.test(threadId) || !UUID.test(turnId)) throw new Error("The native turn identity is invalid");
298
+ const cwd = canonicalDirectory(expectedCwd, "The selected native task workspace");
299
+ const found = findRollout(threadId, env);
300
+ const snapshot = readStable(found, maxRolloutBytes);
301
+ const records = parseRecords(snapshot.data);
302
+ validateSession(records, threadId, cwd);
303
+ return inspectTurnRecords(records, threadId, turnId, cwd);
304
+ } catch (error) {
305
+ return { ...unavailable(error), threadId, turnId, source: "codex_desktop_rollout", assistantItems: [], text: "", replySha256: null };
306
+ }
307
+ }
308
+
309
+ export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId, expectedCwd, executorThreadId, prompt, watermark }, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
310
+ try {
311
+ if (!UUID.test(threadId) || !UUID.test(turnId) || !UUID.test(executorThreadId)) throw new Error("The native response identity is invalid");
312
+ if (turnId === previousTurnId) throw new Error("The observed turn was already present before dispatch");
313
+ if (typeof prompt !== "string" || !prompt.length) throw new Error("The exact dispatched prompt is unavailable");
314
+ if (!object(watermark) || watermark.status !== "available" || watermark.threadId !== threadId) throw new Error("No trusted pre-send rollout watermark is available");
315
+ const cwd = canonicalDirectory(expectedCwd, "The selected native task workspace");
316
+ if (watermark.cwd !== cwd) throw new Error("The selected native task workspace changed after dispatch");
317
+ const found = findRollout(threadId, env);
318
+ if (found.file !== watermark.file) throw new Error("The selected native task rollout changed after dispatch");
319
+ const snapshot = readStable(found, maxRolloutBytes);
320
+ 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");
321
+ const prefix = snapshot.data.subarray(0, watermark.size);
322
+ if (crypto.createHash("sha256").update(prefix).digest("hex") !== watermark.prefixSha256) throw new Error("The pre-send Codex rollout history changed after dispatch");
323
+ const records = parseRecords(snapshot.data);
324
+ validateSession(records, threadId, cwd);
325
+ const tail = records.filter(({ start }) => start >= watermark.size);
326
+ const inspected = inspectTurnRecords(tail, threadId, turnId, cwd, {
327
+ requireDispatch: { output: delegationOutput(executorThreadId, prompt) },
328
+ });
329
+ return { ...inspected, observationStatus: inspected.status };
330
+ } catch (error) {
331
+ return { ...unavailable(error), threadId, turnId, source: "codex_desktop_rollout", assistantItems: [], text: "", replySha256: null };
332
+ }
333
+ }
@@ -0,0 +1,95 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const enabled = (env) => env.CODEX_BRIDGE_HARDENED === "1";
5
+
6
+ function directoryIdentity(resolved) {
7
+ const stat = fs.statSync(resolved, { bigint: true });
8
+ if (!stat.isDirectory()) throw new Error("Path is not a directory");
9
+ return `${stat.dev}:${stat.ino}:${stat.birthtimeNs ?? stat.birthtimeMs}`;
10
+ }
11
+
12
+ function canonicalDirectory(value, label) {
13
+ if (typeof value !== "string" || !value.trim() || !path.isAbsolute(value)) throw new Error(`${label} must be an absolute existing directory`);
14
+ let resolved;
15
+ try { resolved = fs.realpathSync.native(value); } catch { throw new Error(`${label} does not resolve`); }
16
+ return { path: resolved, identity: directoryIdentity(resolved) };
17
+ }
18
+
19
+ function contains(root, candidate) {
20
+ const relative = path.relative(root, candidate);
21
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
22
+ }
23
+
24
+ function assertStrictProfile(env) {
25
+ for (const [name, expected] of [
26
+ ["CODEX_BRIDGE_DESKTOP_TASKS", "1"],
27
+ ["CODEX_BRIDGE_THREAD_POLICY", "roots"],
28
+ ["CODEX_BRIDGE_REMAP", "0"],
29
+ ["CODEX_BRIDGE_AUTOSTART", "0"],
30
+ ]) {
31
+ if (env[name] !== expected) throw new Error(`CODEX_BRIDGE_HARDENED=1 requires ${name}=${expected}`);
32
+ }
33
+ if (typeof env.CODEX_BRIDGE_PATH_MAP === "string" && env.CODEX_BRIDGE_PATH_MAP.trim()) {
34
+ throw new Error("CODEX_BRIDGE_HARDENED=1 requires CODEX_BRIDGE_PATH_MAP to be unset");
35
+ }
36
+ const threadOverrides = String(env.CODEX_BRIDGE_ALLOWED_THREADS ?? "").split(",").map((value) => value.trim()).filter(Boolean);
37
+ if (threadOverrides.includes("*")) throw new Error("CODEX_BRIDGE_HARDENED=1 rejects wildcard thread ownership overrides");
38
+ }
39
+
40
+ /** The deployment profile deliberately has no permissive default. */
41
+ export function createHardenedRootPolicy(env = process.env) {
42
+ if (!enabled(env)) return {
43
+ enabled: false,
44
+ assert: (candidate) => candidate,
45
+ allows: () => true,
46
+ capture: (candidate) => Object.freeze({ path: candidate, identity: null }),
47
+ recheck: (binding) => binding?.path,
48
+ same: (left, right) => path.resolve(left) === path.resolve(right),
49
+ };
50
+ assertStrictProfile(env);
51
+ const raw = env.CODEX_BRIDGE_ALLOWED_ROOTS;
52
+ if (typeof raw !== "string" || !raw.trim() || raw.includes("*")) throw new Error("CODEX_BRIDGE_HARDENED=1 requires explicit CODEX_BRIDGE_ALLOWED_ROOTS");
53
+ const inputs = raw.split(path.delimiter).map((item) => item.trim()).filter(Boolean);
54
+ const configured = inputs.map((item) => {
55
+ const root = canonicalDirectory(item, "Allowed root");
56
+ if (path.parse(root.path).root === root.path) throw new Error("An allowed root cannot be a drive or filesystem root");
57
+ return Object.freeze({ input: item, ...root });
58
+ });
59
+ if (!configured.length) throw new Error("CODEX_BRIDGE_HARDENED=1 requires at least one allowed root");
60
+ if (new Set(configured.map((root) => root.path.toLowerCase())).size !== configured.length) throw new Error("Allowed roots must resolve to distinct directories");
61
+
62
+ const assertRootsCurrent = () => {
63
+ for (const root of configured) {
64
+ const current = canonicalDirectory(root.input, "Allowed root");
65
+ if (current.path !== root.path || current.identity !== root.identity) throw new Error("An allowed root was retargeted or replaced after startup");
66
+ }
67
+ };
68
+ const capture = (candidate, label = "Working directory") => {
69
+ assertRootsCurrent();
70
+ const current = canonicalDirectory(candidate, label);
71
+ if (!configured.some((root) => contains(root.path, current.path))) throw new Error(`${label} is outside CODEX_BRIDGE_ALLOWED_ROOTS`);
72
+ return Object.freeze(current);
73
+ };
74
+ const recheck = (binding, label = "Working directory") => {
75
+ if (!binding || typeof binding.path !== "string" || typeof binding.identity !== "string") throw new Error(`${label} has no captured directory identity`);
76
+ const current = capture(binding.path, label);
77
+ if (current.path !== binding.path || current.identity !== binding.identity) throw new Error(`${label} was retargeted or replaced while the operation was pending`);
78
+ return current.path;
79
+ };
80
+ return Object.freeze({
81
+ enabled: true,
82
+ roots: Object.freeze(configured.map((root) => root.path)),
83
+ allows(candidate) {
84
+ try { capture(candidate); return true; } catch { return false; }
85
+ },
86
+ assert(candidate, label = "Working directory") { return capture(candidate, label).path; },
87
+ capture,
88
+ recheck,
89
+ same(left, right) {
90
+ try { return capture(left).path === capture(right).path; } catch { return false; }
91
+ },
92
+ });
93
+ }
94
+
95
+ export function hardenedBridgeEnabled(env = process.env) { return enabled(env); }
package/src/index.mjs CHANGED
@@ -33,7 +33,8 @@ import { assertClaudeSenderContext, readClaudeSenderContext, requireClaudeSender
33
33
 
34
34
  exitForVersionRequest(import.meta.url);
35
35
 
36
- const VERSION = "1.16.0";
36
+ const VERSION = "1.16.1";
37
+ void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
37
38
  const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
38
39
 
39
40
  /**
@@ -309,7 +310,13 @@ function formatTurn(result, { desktop = false } = {}) {
309
310
  if (result.errors.length) {
310
311
  lines.push(`errors: ${result.errors.map((e) => e.message ?? JSON.stringify(e)).join(" | ")}`);
311
312
  }
312
- lines.push("", "--- Codex reply ---", result.text || "(no assistant text was produced)");
313
+ if (result.responseStatus) lines.push(`response: ${result.responseStatus}`);
314
+ if (result.assistantItems?.length) lines.push(`assistant item IDs: ${result.assistantItems.map((item) => item.id).join(", ")}`);
315
+ if (result.replySha256) lines.push(`reply sha256: ${result.replySha256}`);
316
+ if (result.observationStatus === "unavailable") lines.push(`response observation: unavailable${result.observationReason ? ` - ${result.observationReason}` : ""}`);
317
+ lines.push("", "--- Codex reply ---", result.text || (result.observationStatus === "unavailable"
318
+ ? "(assistant response unavailable; inspect the original Codex Desktop task and do not resend automatically)"
319
+ : result.responseStatus === "completed_no_reply" ? "(the exact completed turn produced no assistant reply)" : "(no assistant text was produced)"));
313
320
  if (result.status === "timeout") {
314
321
  lines.push(
315
322
  "",
@@ -539,7 +546,7 @@ registerTool(
539
546
  }
540
547
  }
541
548
  try {
542
- const result = await desktopTasks.wait(threadId, { timeoutMs: Math.max(0, deadline - Date.now()), previousTurnId: delivered.previousTurnId });
549
+ const result = await desktopTasks.wait(threadId, { timeoutMs: Math.max(0, deadline - Date.now()), previousTurnId: delivered.previousTurnId, responseObservation: delivered.responseObservation });
543
550
  return textResult([...notes, formatTurn(result, { desktop: true })].join("\n"), result.status === "failed" || result.status === "systemError");
544
551
  } catch (err) {
545
552
  return textResult([...notes, `threadId: ${threadId}`, `Task was accepted; observation failed: ${err.message}. Do not resend.`].join("\n"), true);
@@ -735,9 +742,10 @@ registerTool(
735
742
  "read_codex_thread",
736
743
  {
737
744
  title: "Read a Codex thread",
738
- description: "Read the recent conversation of a Codex thread without sending anything.",
745
+ description: "Read a Codex thread without sending anything. In Desktop mode, pass the exact turnId returned by send_to_codex_thread for authoritative assistant item IDs, text, and reply hash; without turnId the native recent-history view may omit items.",
739
746
  inputSchema: {
740
747
  threadId: z.string().describe("Codex thread id"),
748
+ turnId: z.string().optional().describe("Exact turn id to inspect authoritatively in Codex Desktop mode"),
741
749
  limit: z.number().int().min(1).max(50).optional().describe("How many recent messages to show (default 10)"),
742
750
  },
743
751
  annotations: {
@@ -745,10 +753,14 @@ registerTool(
745
753
  openWorldHint: true,
746
754
  },
747
755
  },
748
- async ({ threadId, limit }) => {
756
+ async ({ threadId, turnId, limit }) => {
749
757
  try {
750
758
  if (desktopTasksEnabled) {
751
759
  const deadline = desktopOperation.getStore()?.deadline ?? Date.now() + DESKTOP_TOOL_BUDGET_MS;
760
+ if (turnId) {
761
+ const response = await desktopTasks.inspectNativeTurn(threadId, turnId, undefined, { deadline });
762
+ return textResult(JSON.stringify(response, null, 2), response.status === "unavailable");
763
+ }
752
764
  await desktopTasks.inspect(threadId, undefined, { deadline });
753
765
  const response = await desktopTasks.request("read_thread", { threadId, hostId: "local", turnLimit: Math.min(limit ?? 10, 10) }, { deadline });
754
766
  return textResult(JSON.stringify(response, null, 2));