@minhspark/codex-mcp-bridge 1.21.5 → 1.21.6
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 +13 -0
- package/README.md +1 -1
- package/docs/daily-handoffs.md +1 -1
- package/docs/new-macos-setup.md +67 -0
- package/docs/{new-machine-setup.md → new-windows-setup.md} +1 -1
- package/package.json +3 -2
- package/plugin.json +1 -1
- package/scripts/install-native-relay.mjs +1 -1
- package/src/claude-bridge.mjs +1 -1
- package/src/codex-native-response.mjs +66 -26
- package/src/index.mjs +7 -3
- package/src/native-relay-companion.mjs +9 -3
- package/src/native-relay.mjs +12 -10
- package/src/rollout-segments.mjs +1 -1
- package/src/thread-delivery.mjs +30 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.21.6] - 2026-10-12
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Continue observing a delivered Desktop task through bounded `read_thread` snapshots when the `wait_threads` renderer stops returning results after relay recovery. Preserve the original deadline, access checks, and authoritative reply validation without resending the prompt (#122, #123).
|
|
12
|
+
- Keep native requests capped at 128 KiB while accepting responses up to 8 MiB on both relay hops. Reject larger responses explicitly, trim internal metadata reads, omit tool output from default transcript reads, and bound the serialized MCP read result (#123).
|
|
13
|
+
- Identify failed rollout validation checks and record byte offsets in bounded diagnostics without exposing reply contents. Validation requirements remain unchanged; unsupported rollout sequences still fail closed pending concrete evidence (#123).
|
|
14
|
+
|
|
15
|
+
### Upgrade notes
|
|
16
|
+
|
|
17
|
+
- Update both the bridge and native relay companion. Default Desktop transcript reads omit tool outputs and cap each included item at 2,000 characters; use an exact `turnId` for authoritative assistant response inspection.
|
|
18
|
+
- Native frames above 8 MiB, rollout files above 16 MiB, and authoritative replies above 1 MiB remain explicit limits.
|
|
19
|
+
|
|
7
20
|
## [1.21.5] - 2026-10-11
|
|
8
21
|
|
|
9
22
|
### Fixed
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<h1 align="center">codex-mcp-bridge</h1>
|
|
6
6
|
|
|
7
|
-
Local Desktop handoff guides: [daily workflow and directory verification](docs/daily-handoffs.md), [new-
|
|
7
|
+
Local Desktop handoff guides: [daily workflow and directory verification](docs/daily-handoffs.md), [Windows setup](docs/new-windows-setup.md), [macOS setup](docs/new-macos-setup.md), and [project onboarding](docs/project-onboarding.md).
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/@minhspark/codex-mcp-bridge)
|
|
10
10
|
[](https://www.npmjs.com/package/@minhspark/codex-mcp-bridge)
|
package/docs/daily-handoffs.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Install the messaging MCP entries first. Local Card Bridge Desktop is optional
|
|
4
4
|
and is needed only for automatic task-card startup and its settings UI. Neither
|
|
5
5
|
component uploads an entire local project. For initial installation see
|
|
6
|
-
[new-
|
|
6
|
+
[Windows setup](new-windows-setup.md) or [macOS setup](new-macos-setup.md); for grants see
|
|
7
7
|
[project onboarding](project-onboarding.md).
|
|
8
8
|
|
|
9
9
|
Workspace trust, project authorization and host tool permission are separate.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# New macOS setup
|
|
2
|
+
|
|
3
|
+
Connect Claude Desktop's **Code tab** and Codex Desktop through local MCP
|
|
4
|
+
processes and sockets. Both apps still authenticate with their providers and
|
|
5
|
+
use hosted models; local bridge transport does not mean offline inference.
|
|
6
|
+
|
|
7
|
+
## Local installation: required steps
|
|
8
|
+
|
|
9
|
+
The [ChatGPT plugin setup](../README.md#chatgpt-plugin) uses a hosted Sites
|
|
10
|
+
connector. For a local Desktop installation, use the following path instead:
|
|
11
|
+
|
|
12
|
+
1. Install and sign in to Codex Desktop and Claude Desktop under the same macOS
|
|
13
|
+
user. Have Node.js 22+, npm, the Codex CLI, and the Claude Code CLI available.
|
|
14
|
+
Save the intended local project in Codex Desktop and open it in Claude's
|
|
15
|
+
Code tab.
|
|
16
|
+
2. Follow [macOS installation](../README.md#macos-terminal) to install the bridge
|
|
17
|
+
and register the native relay and reverse bridge. Keep bootstrap enabled on
|
|
18
|
+
the first relay install. Do not use the ChatGPT plugin pairing instructions,
|
|
19
|
+
run `codex-sites-connector`, or configure a hosted endpoint.
|
|
20
|
+
3. Follow [Claude Code registration](../README.md#register-claude-code) for the
|
|
21
|
+
forward bridge. Use the intended project directories for
|
|
22
|
+
`CODEX_BRIDGE_ALLOWED_ROOTS`, rather than the example's entire home directory;
|
|
23
|
+
macOS separates multiple roots with `:`. Preserve an existing entry's
|
|
24
|
+
custom settings.
|
|
25
|
+
4. Add [native relay pipe forwarding](../README.md#native-relay-pipe-forwarding)
|
|
26
|
+
to the existing relay entry. Forward `CODEX_APP_TOOLS_PIPE_PATH` by name,
|
|
27
|
+
not its temporary value. The relay needs the trusted app-bundled Node runtime
|
|
28
|
+
selected by the installer; do not substitute an arbitrary Homebrew/nvm Node.
|
|
29
|
+
5. Reconnect the affected MCP servers in the actual Desktop Code conversations,
|
|
30
|
+
or restart the affected app after active work finishes. Complete
|
|
31
|
+
[installation verification](../README.md#verify-the-installation), including
|
|
32
|
+
a message and reply in each direction.
|
|
33
|
+
|
|
34
|
+
There are three registrations: `codex-bridge` in Claude Code, and
|
|
35
|
+
`claude-bridge` plus `codex-native-relay` in Codex Desktop. The relay is transport
|
|
36
|
+
plumbing, not a third agent. Ordinary Claude chat has a separate MCP registry;
|
|
37
|
+
adding a server there does not register it in the Code tab.
|
|
38
|
+
|
|
39
|
+
## Restricted local configuration
|
|
40
|
+
|
|
41
|
+
Local transport does not require Full Access. Keep the intended sandbox,
|
|
42
|
+
approval reviewer, and project restrictions. Before running installers, check
|
|
43
|
+
for `$CODEX_HOME/bridge-full-access.enabled` (normally
|
|
44
|
+
`~/.codex/bridge-full-access.enabled`): a saved opt-in makes them enable Full
|
|
45
|
+
Access even without `--full-access`. Resolve that existing opt-in deliberately
|
|
46
|
+
before installing; `CODEX_BRIDGE_ENFORCE_FULL_ACCESS=0` does not disable it.
|
|
47
|
+
|
|
48
|
+
For a restricted Codex setup, these are top-level configuration settings,
|
|
49
|
+
before any `[table]`:
|
|
50
|
+
|
|
51
|
+
```toml
|
|
52
|
+
approval_policy = "on-request"
|
|
53
|
+
approvals_reviewer = "auto_review"
|
|
54
|
+
sandbox_mode = "workspace-write"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Verify the active Desktop task uses the intended restrictions; defaults alone
|
|
58
|
+
are not evidence of its effective permissions. To suppress optional reporting,
|
|
59
|
+
set `CODEX_BRIDGE_TELEMETRY=0` in all three MCP registrations' environments;
|
|
60
|
+
see [telemetry controls](../README.md#optional-active-install-statistics).
|
|
61
|
+
|
|
62
|
+
For workspace trust, shared project grants, and optional messaging automation,
|
|
63
|
+
use [project onboarding](project-onboarding.md). For target selection,
|
|
64
|
+
permission mismatches, and uncertain deliveries, use
|
|
65
|
+
[daily Desktop handoffs](daily-handoffs.md) and the
|
|
66
|
+
[technical reference](https://github.com/buidangminh23/codex-mcp-bridge/blob/main/REFERENCE.md). Tool discovery or a connected socket
|
|
67
|
+
alone does not establish a completed exchange.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@minhspark/codex-mcp-bridge",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.6",
|
|
4
4
|
"description": "Two-way MCP bridge between Claude and Codex: prompts into a live Codex thread, messages into a running Claude Code session.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -48,7 +48,8 @@
|
|
|
48
48
|
"LICENSE",
|
|
49
49
|
"CHANGELOG.md",
|
|
50
50
|
"docs/daily-handoffs.md",
|
|
51
|
-
"docs/new-
|
|
51
|
+
"docs/new-windows-setup.md",
|
|
52
|
+
"docs/new-macos-setup.md",
|
|
52
53
|
"docs/project-onboarding.md",
|
|
53
54
|
"docs/desktop-handoff-design.md"
|
|
54
55
|
],
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "codex-mcp-bridge",
|
|
4
|
-
"version": "1.21.
|
|
4
|
+
"version": "1.21.6",
|
|
5
5
|
"description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Bui Dang Minh",
|
|
@@ -23,7 +23,7 @@ import { enableCodexFullAccess, fullAccessEnabled } from "../src/codex-full-acce
|
|
|
23
23
|
|
|
24
24
|
exitForVersionRequest(import.meta.url);
|
|
25
25
|
|
|
26
|
-
const VERSION = "1.21.
|
|
26
|
+
const VERSION = "1.21.6";
|
|
27
27
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
28
28
|
const entry = path.join(root, "src", "mcp-supervisor.mjs");
|
|
29
29
|
const entryArgs = ["native-relay-companion.mjs"];
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -29,7 +29,7 @@ import { registerProjectOnboardingTools } from "./project-onboarding-tools.mjs";
|
|
|
29
29
|
|
|
30
30
|
exitForVersionRequest(import.meta.url);
|
|
31
31
|
|
|
32
|
-
const VERSION = "1.21.
|
|
32
|
+
const VERSION = "1.21.6";
|
|
33
33
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
34
34
|
const FORWARD_MIN_INTERVAL_MS = 5000;
|
|
35
35
|
const FORWARD_MAX_PER_SESSION = 50;
|
|
@@ -21,6 +21,15 @@ function unavailable(reason) {
|
|
|
21
21
|
return { status: "unavailable", reason: reason?.message ?? String(reason) };
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
function validationError(message, check, fields = {}) {
|
|
25
|
+
const details = Object.entries({ check, ...fields }).map(([key, value]) => `${key}=${value}`).join("; ");
|
|
26
|
+
return new Error(`${message} [${details}]`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function recordOffsets(entries) {
|
|
30
|
+
return entries.slice(0, 3).map(({ start }) => start).join(",") || "none";
|
|
31
|
+
}
|
|
32
|
+
|
|
24
33
|
function configuredSessions(env) {
|
|
25
34
|
const configuredHome = env.CODEX_HOME || path.join(env.HOME || env.USERPROFILE || os.homedir(), ".codex");
|
|
26
35
|
if (!path.isAbsolute(configuredHome)) throw new Error("The configured Codex home must be absolute");
|
|
@@ -165,23 +174,29 @@ function recordBelongsToTurn(entry, threadId, turnId) {
|
|
|
165
174
|
const metadata = object(payload.internal_chat_message_metadata_passthrough) ? payload.internal_chat_message_metadata_passthrough : {};
|
|
166
175
|
const turnFields = [payload.turn_id, payload.root_turn_id, metadata.turn_id, metadata.root_turn_id].filter((value) => value !== undefined);
|
|
167
176
|
if (!turnFields.includes(turnId)) return false;
|
|
168
|
-
if (turnFields.some((value) => value !== turnId)) throw
|
|
177
|
+
if (turnFields.some((value) => value !== turnId)) throw validationError("The native response contains contradictory turn identity", "turn_identity", { recordByte: entry.start });
|
|
169
178
|
const threadFields = [payload.thread_id, metadata.thread_id].filter((value) => value !== undefined);
|
|
170
|
-
if (threadFields.some((value) => value !== threadId)) throw
|
|
179
|
+
if (threadFields.some((value) => value !== threadId)) throw validationError("The native response contains contradictory task identity", "task_identity", { recordByte: entry.start });
|
|
171
180
|
return true;
|
|
172
181
|
}
|
|
173
182
|
|
|
174
|
-
function responseItemText(
|
|
175
|
-
|
|
176
|
-
|
|
183
|
+
function responseItemText(entry) {
|
|
184
|
+
const { payload } = entry.record;
|
|
185
|
+
const invalidPart = Array.isArray(payload.content)
|
|
186
|
+
? payload.content.findIndex((item) => !object(item) || item.type !== "output_text" || typeof item.text !== "string") : "not_array";
|
|
187
|
+
if (invalidPart !== -1) {
|
|
188
|
+
throw validationError("The native final response contains unsupported content", "response_content", { responseItemByte: entry.start, contentPart: invalidPart });
|
|
177
189
|
}
|
|
178
190
|
return payload.content.map((item) => item.text).join("");
|
|
179
191
|
}
|
|
180
192
|
|
|
181
|
-
function completedEventText(
|
|
193
|
+
function completedEventText(entry) {
|
|
194
|
+
const item = entry.record.payload.item;
|
|
182
195
|
if (!object(item) || item.type !== "AgentMessage" || item.phase !== "final_answer") return null;
|
|
183
|
-
|
|
184
|
-
|
|
196
|
+
const invalidPart = Array.isArray(item.content)
|
|
197
|
+
? item.content.findIndex((part) => !object(part) || part.type !== "Text" || typeof part.text !== "string") : "not_array";
|
|
198
|
+
if (invalidPart !== -1) {
|
|
199
|
+
throw validationError("The native completed assistant item contains unsupported content", "completed_content", { completedEventByte: entry.start, contentPart: invalidPart });
|
|
185
200
|
}
|
|
186
201
|
return item.content.map((part) => part.text).join("");
|
|
187
202
|
}
|
|
@@ -193,10 +208,16 @@ function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch }
|
|
|
193
208
|
const completions = turns.filter(({ record }) => record.type === "event_msg" && COMPLETED.has(record.payload.type));
|
|
194
209
|
const failures = turns.filter(({ record }) => record.type === "event_msg" &&
|
|
195
210
|
(FAILED.has(record.payload.type) || record.payload.error || ["failed", "aborted", "interrupted"].includes(record.payload.status)));
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
211
|
+
const lifecycleError = (check, fields) => validationError("The native turn lifecycle is missing, ambiguous, failed, or out of order", check, fields);
|
|
212
|
+
if (failures.length) throw lifecycleError("lifecycle_failure", { count: failures.length, eventBytes: recordOffsets(failures) });
|
|
213
|
+
for (const [kind, entries] of [["start", starts], ["context", contexts], ["completion", completions]]) {
|
|
214
|
+
if (entries.length !== 1) throw lifecycleError(`lifecycle_${kind}_count`, { count: entries.length, recordBytes: recordOffsets(entries) });
|
|
215
|
+
}
|
|
216
|
+
if (canonicalDirectory(contexts[0].record.payload.cwd, "The observed turn workspace") !== cwd) {
|
|
217
|
+
throw lifecycleError("lifecycle_workspace", { contextByte: contexts[0].start });
|
|
218
|
+
}
|
|
219
|
+
if (starts[0].start >= contexts[0].start || contexts[0].start >= completions[0].start) {
|
|
220
|
+
throw lifecycleError("lifecycle_order", { startByte: starts[0].start, contextByte: contexts[0].start, completionByte: completions[0].start });
|
|
200
221
|
}
|
|
201
222
|
|
|
202
223
|
let dispatch = null;
|
|
@@ -205,47 +226,66 @@ function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch }
|
|
|
205
226
|
record.payload.namespace === "codex_app" && record.payload.name === (requireDispatch.operation ?? "send_message_to_thread"));
|
|
206
227
|
if (dispatches.length !== 1 || !requireDispatch.outputs.includes(dispatches[0].record.payload.output) ||
|
|
207
228
|
contexts[0].start >= dispatches[0].start || dispatches[0].start >= completions[0].start) {
|
|
208
|
-
throw
|
|
229
|
+
throw validationError("The newly observed turn is not correlated to the exact native dispatch", "dispatch_correlation", {
|
|
230
|
+
count: dispatches.length, dispatchBytes: recordOffsets(dispatches), contextByte: contexts[0].start, completionByte: completions[0].start,
|
|
231
|
+
});
|
|
209
232
|
}
|
|
210
233
|
dispatch = dispatches[0];
|
|
211
234
|
}
|
|
212
235
|
|
|
213
236
|
const allAssistantEntries = turns.filter(({ record }) => record.type === "response_item" && record.payload.type === "message" && record.payload.role === "assistant");
|
|
214
237
|
const finalEntries = allAssistantEntries.filter(({ record }) => record.payload.phase === "final_answer");
|
|
215
|
-
if (!finalEntries.length && allAssistantEntries.length) throw
|
|
238
|
+
if (!finalEntries.length && allAssistantEntries.length) throw validationError("The completed native turn has assistant items but no final assistant reply", "missing_final_phase", {
|
|
239
|
+
count: allAssistantEntries.length, responseItemBytes: recordOffsets(allAssistantEntries),
|
|
240
|
+
});
|
|
216
241
|
const assistantItems = [];
|
|
217
|
-
const ids = new
|
|
242
|
+
const ids = new Map();
|
|
218
243
|
for (const entry of finalEntries) {
|
|
219
244
|
const { payload } = entry.record;
|
|
220
245
|
if (typeof payload.id !== "string" || !payload.id.trim() || ids.has(payload.id) ||
|
|
221
246
|
entry.start <= (dispatch?.start ?? contexts[0].start) || entry.start >= completions[0].start) {
|
|
222
|
-
|
|
247
|
+
const check = typeof payload.id !== "string" || !payload.id.trim() ? "response_item_id_missing" : ids.has(payload.id) ? "response_item_id_duplicate" : "response_item_order";
|
|
248
|
+
throw validationError("The native final response item identity is missing, duplicated, or out of order", check, {
|
|
249
|
+
responseItemByte: entry.start, previousItemByte: ids.get(payload.id)?.start ?? "none",
|
|
250
|
+
afterByte: dispatch?.start ?? contexts[0].start, completionByte: completions[0].start,
|
|
251
|
+
});
|
|
223
252
|
}
|
|
224
|
-
ids.
|
|
225
|
-
const text = responseItemText(
|
|
226
|
-
if (!text.trim()) throw
|
|
253
|
+
ids.set(payload.id, entry);
|
|
254
|
+
const text = responseItemText(entry);
|
|
255
|
+
if (!text.trim()) throw validationError("The native final response item is empty", "response_item_empty", { responseItemByte: entry.start });
|
|
227
256
|
assistantItems.push({ id: payload.id, text });
|
|
228
257
|
}
|
|
229
258
|
|
|
230
259
|
const eventItems = new Map();
|
|
231
|
-
for (const
|
|
260
|
+
for (const entry of turns) {
|
|
261
|
+
const { record, start } = entry;
|
|
232
262
|
if (record.type !== "event_msg" || record.payload.type !== "item_completed") continue;
|
|
233
263
|
const eventItem = record.payload.item;
|
|
234
264
|
if (ids.has(eventItem?.id) && (eventItem.type !== "AgentMessage" || eventItem.phase !== "final_answer")) {
|
|
235
|
-
throw
|
|
265
|
+
throw validationError("The native assistant item representations conflict in type or phase", "assistant_representation_type_phase", {
|
|
266
|
+
responseItemByte: ids.get(eventItem.id).start, completedEventByte: start,
|
|
267
|
+
});
|
|
236
268
|
}
|
|
237
|
-
const text = completedEventText(
|
|
269
|
+
const text = completedEventText(entry);
|
|
238
270
|
if (text === null) continue;
|
|
239
271
|
const id = record.payload.item.id;
|
|
240
272
|
if (typeof id !== "string" || !id.trim() || eventItems.has(id) || start <= contexts[0].start || start >= completions[0].start) {
|
|
241
|
-
|
|
273
|
+
const check = typeof id !== "string" || !id.trim() ? "completed_item_id_missing" : eventItems.has(id) ? "completed_item_id_duplicate" : "completed_item_order";
|
|
274
|
+
throw validationError("The native completed assistant item identity is missing, duplicated, or out of order", check, {
|
|
275
|
+
completedEventByte: start, previousEventByte: eventItems.get(id)?.start ?? "none",
|
|
276
|
+
contextByte: contexts[0].start, completionByte: completions[0].start,
|
|
277
|
+
});
|
|
242
278
|
}
|
|
243
|
-
eventItems.set(id, text);
|
|
279
|
+
eventItems.set(id, { text, start });
|
|
244
280
|
}
|
|
245
281
|
for (const item of assistantItems) {
|
|
246
|
-
if (eventItems.has(item.id) && eventItems.get(item.id) !== item.text) throw
|
|
282
|
+
if (eventItems.has(item.id) && eventItems.get(item.id).text !== item.text) throw validationError("The native assistant item representations conflict", "assistant_representation_text", {
|
|
283
|
+
responseItemByte: ids.get(item.id).start, completedEventByte: eventItems.get(item.id).start,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
for (const [id, event] of eventItems) {
|
|
287
|
+
if (!ids.has(id)) throw validationError("The native completed assistant item has no matching authoritative response item", "authoritative_response_missing", { completedEventByte: event.start });
|
|
247
288
|
}
|
|
248
|
-
if ([...eventItems.keys()].some((id) => !ids.has(id))) throw new Error("The native completed assistant item has no matching authoritative response item");
|
|
249
289
|
|
|
250
290
|
const text = assistantItems.map((item) => item.text).join("\n\n");
|
|
251
291
|
if (Buffer.byteLength(text, "utf8") > MAX_REPLY_BYTES) throw new Error("The native final response exceeds the bounded reply limit");
|
package/src/index.mjs
CHANGED
|
@@ -39,7 +39,7 @@ import { PROJECT_SETUP_TOOLS, registerProjectOnboardingTools } from "./project-o
|
|
|
39
39
|
|
|
40
40
|
exitForVersionRequest(import.meta.url);
|
|
41
41
|
|
|
42
|
-
const VERSION = "1.21.
|
|
42
|
+
const VERSION = "1.21.6";
|
|
43
43
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
44
44
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
45
45
|
|
|
@@ -875,8 +875,12 @@ registerTool(
|
|
|
875
875
|
return textResult(JSON.stringify(response, null, 2), response.status === "unavailable");
|
|
876
876
|
}
|
|
877
877
|
await desktopTasks.inspect(threadId, undefined, { deadline });
|
|
878
|
-
const response = await desktopTasks.request("read_thread", { threadId, hostId: "local", turnLimit: Math.min(limit ?? 10, 10) }, { deadline });
|
|
879
|
-
|
|
878
|
+
const response = await desktopTasks.request("read_thread", { threadId, hostId: "local", turnLimit: Math.min(limit ?? 10, 10), includeOutputs: false, maxOutputCharsPerItem: 2000 }, { deadline });
|
|
879
|
+
const result = textResult(JSON.stringify(response, null, 2));
|
|
880
|
+
if (Buffer.byteLength(JSON.stringify(result), "utf8") > 8 * 1024 * 1024) {
|
|
881
|
+
return textResult("DESKTOP_READ_RESPONSE_TOO_LARGE: The formatted Desktop read exceeds 8 MiB. Read fewer turns with limit: 1 or select a specific turnId.", true);
|
|
882
|
+
}
|
|
883
|
+
return result;
|
|
880
884
|
}
|
|
881
885
|
await assertThreadAccess(threadId);
|
|
882
886
|
const res = await client.call("thread/read", { threadId, includeTurns: true });
|
|
@@ -9,6 +9,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
MAX_FRAME_BYTES,
|
|
12
|
+
MAX_RESPONSE_FRAME_BYTES,
|
|
12
13
|
NATIVE_DISPATCH_METHOD,
|
|
13
14
|
NativeToolsClient,
|
|
14
15
|
NativeRelayError,
|
|
@@ -36,7 +37,7 @@ import { NativeCreationReceipts, creationIdValid } from "./native-creation-recei
|
|
|
36
37
|
|
|
37
38
|
exitForVersionRequest(import.meta.url);
|
|
38
39
|
|
|
39
|
-
const VERSION = "1.21.
|
|
40
|
+
const VERSION = "1.21.6";
|
|
40
41
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
41
42
|
|
|
42
43
|
function errorResponse(code, message, sent) {
|
|
@@ -102,7 +103,7 @@ export function createNativeScopeAuthorizer({ dispatchDesktop, env = process.env
|
|
|
102
103
|
};
|
|
103
104
|
const threadFor = async (executorThreadId, threadId, accountContext, projects) => {
|
|
104
105
|
if (typeof threadId !== "string" || !threadId) fail("Operation has no target task");
|
|
105
|
-
const result = await call(executorThreadId, "read_thread", { threadId, hostId: "local", turnLimit: 1 }, accountContext);
|
|
106
|
+
const result = await call(executorThreadId, "read_thread", { threadId, hostId: "local", turnLimit: 1, includeOutputs: false, maxOutputCharsPerItem: 1 }, accountContext);
|
|
106
107
|
return validateThread(result?.thread, threadId, projects);
|
|
107
108
|
};
|
|
108
109
|
const targetIds = (operation, args, targetThreadId) => {
|
|
@@ -613,7 +614,12 @@ export class RelaySocketServer {
|
|
|
613
614
|
|
|
614
615
|
#reply(socket, response) {
|
|
615
616
|
if (socket.destroyed) return;
|
|
616
|
-
|
|
617
|
+
const frame = `${JSON.stringify(response)}\n`;
|
|
618
|
+
if (Buffer.byteLength(frame, "utf8") > MAX_RESPONSE_FRAME_BYTES) {
|
|
619
|
+
socket.end(`${JSON.stringify(errorResponse("RELAY_RESPONSE_TOO_LARGE", `The native relay response exceeds ${MAX_RESPONSE_FRAME_BYTES} bytes; reduce the requested turns or outputs`))}\n`);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
socket.end(frame);
|
|
617
623
|
}
|
|
618
624
|
|
|
619
625
|
stop() {
|
package/src/native-relay.mjs
CHANGED
|
@@ -39,13 +39,8 @@ export const NATIVE_DISPATCH_METHOD = "tools/call";
|
|
|
39
39
|
export const RELAY_PROTOCOL_VERSION = 1;
|
|
40
40
|
export const ACCOUNT_RELAY_PROTOCOL_VERSION = 2;
|
|
41
41
|
|
|
42
|
-
/**
|
|
43
|
-
* A relay frame carries one chat message, so a megabyte-scale line is either a
|
|
44
|
-
* bug or something trying to make the companion buffer without limit. The cap
|
|
45
|
-
* is applied on both halves: the client refuses to send an oversized message,
|
|
46
|
-
* and the companion refuses to accumulate one.
|
|
47
|
-
*/
|
|
48
42
|
export const MAX_FRAME_BYTES = 128 * 1024;
|
|
43
|
+
export const MAX_RESPONSE_FRAME_BYTES = 8 * 1024 * 1024;
|
|
49
44
|
|
|
50
45
|
const DEFAULT_TIMEOUT_MS = 30000;
|
|
51
46
|
const RELAY_SOCKET_NAME = "native-relay.sock";
|
|
@@ -617,7 +612,11 @@ export class NativeToolsClient {
|
|
|
617
612
|
buffer = Buffer.concat([buffer, chunk]);
|
|
618
613
|
while (buffer.length >= 4) {
|
|
619
614
|
const length = buffer.readUInt32LE(0);
|
|
620
|
-
if (
|
|
615
|
+
if (length > MAX_RESPONSE_FRAME_BYTES) {
|
|
616
|
+
fail(new NativeRelayError(`Codex Desktop native response exceeds ${MAX_RESPONSE_FRAME_BYTES} bytes; reduce the requested turns or outputs`, "NATIVE_RESPONSE_TOO_LARGE"));
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (!length) {
|
|
621
620
|
fail(new NativeRelayError("Invalid Codex Desktop native frame length", "NATIVE_BAD_RESPONSE"));
|
|
622
621
|
return;
|
|
623
622
|
}
|
|
@@ -885,16 +884,19 @@ export class NativeDesktopRelay {
|
|
|
885
884
|
})();
|
|
886
885
|
});
|
|
887
886
|
socket.on("data", (chunk) => {
|
|
888
|
-
|
|
889
|
-
|
|
887
|
+
if (settled) return;
|
|
888
|
+
const newline = chunk.indexOf(10);
|
|
889
|
+
const frameChunk = newline < 0 ? chunk : chunk.subarray(0, newline + 1);
|
|
890
|
+
if (buffer.length + frameChunk.length > MAX_RESPONSE_FRAME_BYTES) {
|
|
890
891
|
finish(
|
|
891
892
|
reject,
|
|
892
|
-
new NativeRelayError(
|
|
893
|
+
new NativeRelayError(`The Codex Desktop relay response exceeds ${MAX_RESPONSE_FRAME_BYTES} bytes; reduce the requested turns or outputs`, "RELAY_RESPONSE_TOO_LARGE", {
|
|
893
894
|
reachedCompanion: true,
|
|
894
895
|
}),
|
|
895
896
|
);
|
|
896
897
|
return;
|
|
897
898
|
}
|
|
899
|
+
buffer = Buffer.concat([buffer, frameChunk]);
|
|
898
900
|
const index = buffer.indexOf(10);
|
|
899
901
|
if (index < 0) return;
|
|
900
902
|
try {
|
package/src/rollout-segments.mjs
CHANGED
|
@@ -37,7 +37,7 @@ export function findRolloutSegments(sessions, threadId) {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
-
if (!matches.length) throw new Error(
|
|
40
|
+
if (!matches.length) throw new Error(`No rollout matches the calling Codex task [check=rollout_discovery; directoriesScanned=${directories}; entriesScanned=${entries}; layout=sessions/YYYY/MM/DD/rollout-*-taskId[_continuationId].jsonl]`);
|
|
41
41
|
return matches.sort();
|
|
42
42
|
}
|
|
43
43
|
|
package/src/thread-delivery.mjs
CHANGED
|
@@ -27,6 +27,13 @@ export const NATIVE_BACKEND = "codex-desktop-native";
|
|
|
27
27
|
export const APP_SERVER_BACKEND = "app-server";
|
|
28
28
|
export const DESKTOP_TOOL_BUDGET_MS = 40000;
|
|
29
29
|
const RELEASE_STATUSES = new Set(["completed", "interrupted", "failed"]);
|
|
30
|
+
const METADATA_READ_OPTIONS = { includeOutputs: false, maxOutputCharsPerItem: 1 };
|
|
31
|
+
|
|
32
|
+
function unavailableWaitRenderer(error) {
|
|
33
|
+
const cause = error.cause ?? error;
|
|
34
|
+
return cause.code === "NATIVE_DISPATCH_FAILED" &&
|
|
35
|
+
/^(Codex app tool request failed\.?|The app tool call ended before producing a result\.?)$/.test(cause.message);
|
|
36
|
+
}
|
|
30
37
|
|
|
31
38
|
function sameAccountContext(expected, current) {
|
|
32
39
|
return Boolean(expected && current && ["claude", "codex"].every((provider) =>
|
|
@@ -194,7 +201,7 @@ export class DesktopTaskDelivery {
|
|
|
194
201
|
let response;
|
|
195
202
|
try {
|
|
196
203
|
this.security.assertThread(receipt.threadId, cwd);
|
|
197
|
-
response = await this.request("read_thread", { threadId: receipt.threadId, hostId: "local", turnLimit: 1 }, { deadline });
|
|
204
|
+
response = await this.request("read_thread", { threadId: receipt.threadId, hostId: "local", turnLimit: 1, ...METADATA_READ_OPTIONS }, { deadline });
|
|
198
205
|
} catch (err) {
|
|
199
206
|
throw new Error(`Existing Desktop task ${receipt.threadId} could not be verified. No prompt was resent and no duplicate task was created: ${err.message}`, { cause: err });
|
|
200
207
|
}
|
|
@@ -364,7 +371,7 @@ export class DesktopTaskDelivery {
|
|
|
364
371
|
}
|
|
365
372
|
|
|
366
373
|
async #inspectMetadata(threadId, cwd, { deadline } = {}) {
|
|
367
|
-
const envelope = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1 }, { deadline, includeRelayContext: true });
|
|
374
|
+
const envelope = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1, ...METADATA_READ_OPTIONS }, { deadline, includeRelayContext: true });
|
|
368
375
|
const response = envelope.result;
|
|
369
376
|
const thread = response?.thread;
|
|
370
377
|
if (thread?.id !== threadId || thread.hostId !== "local" || !thread.cwd) throw new Error("Desktop did not confirm the task's local workspace.");
|
|
@@ -471,23 +478,41 @@ export class DesktopTaskDelivery {
|
|
|
471
478
|
const startedAt = this.now();
|
|
472
479
|
let cursor;
|
|
473
480
|
let turnId = null;
|
|
481
|
+
let readSnapshots = false;
|
|
482
|
+
const readSnapshot = async () => {
|
|
483
|
+
const result = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1, ...METADATA_READ_OPTIONS }, { deadline: startedAt + timeoutMs });
|
|
484
|
+
const thread = result?.thread;
|
|
485
|
+
if (thread?.id !== threadId || thread.hostId !== "local" || !thread.cwd) throw new Error("Desktop did not confirm the observed task's local workspace.");
|
|
486
|
+
this.security.assertCwd(thread.cwd);
|
|
487
|
+
this.security.assertThread(threadId, thread.cwd);
|
|
488
|
+
this.senderProjectScope(thread.cwd);
|
|
489
|
+
if (responseObservation?.expectedCwd && realpathSync.native(thread.cwd) !== responseObservation.expectedCwd) throw new Error("The selected native task workspace changed during observation");
|
|
490
|
+
return { polls: [{ thread, latestTurn: result.turns?.[0] ?? null }] };
|
|
491
|
+
};
|
|
474
492
|
const expired = () => ({ threadId, turnId, status: "timeout", text: "", responseStatus: "unavailable", assistantItems: [], replySha256: null, activity: [], errors: [], durationMs: this.now() - startedAt });
|
|
475
493
|
for (;;) {
|
|
476
494
|
if (this.now() - startedAt >= timeoutMs) return expired();
|
|
477
495
|
let response;
|
|
478
496
|
try {
|
|
479
|
-
response = await this.request("wait_threads", {
|
|
497
|
+
response = readSnapshots ? await readSnapshot() : await this.request("wait_threads", {
|
|
480
498
|
targets: [{ threadId, hostId: "local", ...(cursor ? { afterCursor: cursor } : {}) }], timeoutMs: 0,
|
|
481
499
|
}, { deadline: startedAt + timeoutMs });
|
|
482
500
|
} catch (err) {
|
|
483
501
|
if (this.now() - startedAt >= timeoutMs) return expired();
|
|
484
|
-
throw err;
|
|
502
|
+
if (readSnapshots || !unavailableWaitRenderer(err)) throw err;
|
|
503
|
+
readSnapshots = true;
|
|
504
|
+
try {
|
|
505
|
+
response = await readSnapshot();
|
|
506
|
+
} catch (readError) {
|
|
507
|
+
if (this.now() - startedAt >= timeoutMs) return expired();
|
|
508
|
+
throw readError;
|
|
509
|
+
}
|
|
485
510
|
}
|
|
486
511
|
const poll = response?.polls?.find((item) => item.thread?.id === threadId && item.thread?.hostId === "local");
|
|
487
512
|
if (response?.errors?.length || !poll) throw new Error(`Could not observe task ${threadId}; it may still be running. Read it before retrying: ${JSON.stringify(response)}`);
|
|
488
513
|
cursor = poll.cursor;
|
|
489
514
|
const turn = poll.latestTurn;
|
|
490
|
-
const threadStatus = poll.thread.status?.type;
|
|
515
|
+
const threadStatus = typeof poll.thread.status === "string" ? poll.thread.status : poll.thread.status?.type;
|
|
491
516
|
if (["systemError", "waitingOnApproval", "waitingOnUserInput"].includes(threadStatus)) {
|
|
492
517
|
return { threadId, turnId: turn?.id !== previousTurnId ? turn?.id ?? null : null, status: threadStatus, text: "", activity: [], errors: [], durationMs: this.now() - startedAt };
|
|
493
518
|
}
|