@minhspark/codex-mcp-bridge 1.16.0 → 1.17.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/CHANGELOG.md +30 -0
- package/README.md +225 -565
- package/package.json +3 -1
- package/scripts/collect-repo-analytics.mjs +173 -0
- package/scripts/install-native-relay.mjs +1 -1
- package/scripts/publish-repo-analytics.mjs +208 -0
- package/scripts/refresh-hosted-analytics.mjs +37 -0
- package/scripts/render-repo-analytics.mjs +50 -0
- package/scripts/usage-summary.sql +19 -0
- package/src/claude-bridge-launcher.mjs +2 -1
- package/src/claude-bridge.mjs +102 -16
- package/src/claude-session-router.mjs +16 -0
- package/src/codex-bridge-launcher.mjs +2 -1
- package/src/codex-native-response.mjs +333 -0
- package/src/codex-sender-context.mjs +5 -4
- package/src/hardened-root-policy.mjs +95 -0
- package/src/index.mjs +17 -5
- package/src/native-relay-companion.mjs +268 -23
- package/src/native-relay.mjs +3 -0
- package/src/peer-protocol.mjs +114 -20
- package/src/reply-forwarder.mjs +10 -3
- package/src/security-policy.mjs +4 -0
- package/src/telemetry-config.mjs +2 -0
- package/src/telemetry.mjs +149 -0
- package/src/thread-delivery.mjs +89 -11
- package/src/turn.mjs +35 -3
- package/src/vscode-bridge.mjs +162 -0
- package/src/vscode-delivery.mjs +19 -0
- package/src/vscode-ipc.mjs +80 -0
- package/src/windows-pipe-acl.mjs +27 -0
- package/src/windows-pipe-acl.ps1 +85 -0
|
@@ -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
|
+
}
|
|
@@ -29,7 +29,7 @@ function reviewFlag(metadata, field) {
|
|
|
29
29
|
return "invalid";
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
function findRollout(sessions, threadId) {
|
|
32
|
+
export function findRollout(sessions, threadId) {
|
|
33
33
|
if (!fs.lstatSync(sessions).isDirectory()) throw new Error("The Codex sessions path is not a regular directory");
|
|
34
34
|
const queue = [{ directory: sessions, depth: 0 }];
|
|
35
35
|
const matches = [];
|
|
@@ -57,7 +57,7 @@ function findRollout(sessions, threadId) {
|
|
|
57
57
|
return matches[0];
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
function readState(file, maxBytes) {
|
|
60
|
+
export function readState(file, maxBytes) {
|
|
61
61
|
const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
62
62
|
try {
|
|
63
63
|
const before = fs.fstatSync(descriptor);
|
|
@@ -120,7 +120,7 @@ function permissionClass(context, metadata) {
|
|
|
120
120
|
throw new Error("The caller's effective approval policy is unsupported");
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
export function readCodexSenderContext(meta, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
|
|
123
|
+
export function readCodexSenderContext(meta, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES, originator = "Codex Desktop" } = {}) {
|
|
124
124
|
const metadata = object(meta) ? meta[METADATA_KEY] : undefined;
|
|
125
125
|
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");
|
|
126
126
|
const identity = { threadId: metadata.thread_id, turnId: metadata.turn_id,
|
|
@@ -134,7 +134,8 @@ export function readCodexSenderContext(meta, { env = process.env, maxRolloutByte
|
|
|
134
134
|
const file = findRollout(sessions, identity.threadId);
|
|
135
135
|
const state = readState(file, Math.min(MAX_ROLLOUT_BYTES, maxRolloutBytes));
|
|
136
136
|
const { session, context, lifecycle } = state;
|
|
137
|
-
if (
|
|
137
|
+
if (!["Codex Desktop", "codex_vscode"].includes(originator)) throw new Error("Unsupported calling host");
|
|
138
|
+
if (session?.id !== identity.threadId || session.originator !== originator || session.source !== "vscode") throw new Error(`The caller rollout does not confirm a root ${originator} task`);
|
|
138
139
|
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");
|
|
139
140
|
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");
|
|
140
141
|
const cwd = fs.realpathSync.native(context.cwd);
|
|
@@ -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.
|
|
36
|
+
const VERSION = "1.17.0";
|
|
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(
|
|
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
|
|
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));
|