@minhspark/codex-mcp-bridge 1.18.2 → 1.19.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.
- package/CHANGELOG.md +26 -0
- package/README.md +10 -3
- package/package.json +1 -1
- package/scripts/install-native-relay.mjs +1 -1
- package/src/claude-bridge.mjs +4 -2
- package/src/claude-sender-context.mjs +94 -8
- package/src/codex-native-response.mjs +8 -5
- package/src/codex-sender-context.mjs +95 -14
- package/src/desktop-task-receipts.mjs +12 -4
- package/src/index.mjs +36 -14
- package/src/native-relay-companion.mjs +1 -1
- package/src/recipient-preflight.mjs +1 -1
- package/src/thread-delivery.mjs +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,32 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.19.1] - 2026-09-29
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Recognize `codex_work_desktop` as a local Desktop sender while retaining exact host task/turn, workspace, and lifecycle checks (#84). Its permission profile is classified like that of any other Codex Desktop task, including the managed sandboxes supported since 1.19.0 (#83). Claude's inbound policy and permission parity checks are unchanged.
|
|
12
|
+
- Read completed native replies from `codex_work_desktop` tasks and correlate both raw and XML-escaped Desktop dispatch envelopes to the exact sent prompt (#84). Task, workspace, turn, executor, and pre-send watermark checks remain required for automatic reply observation.
|
|
13
|
+
- Preserve confirmed native send acceptance when the overall response deadline expires (#84). The timeout does not release the underlying operation's thread lock, retry a prompt, or turn an unconfirmed send into a successful delivery.
|
|
14
|
+
- Avoid a Windows Node 24.13.0 native crash when preparing supervisor and MCP contract test fixtures from a Unicode checkout path by awaiting asynchronous directory copies (#84).
|
|
15
|
+
- Stop refusing Claude → Codex callers on Windows because Windows PowerShell started slowly (#42). The caller check reads the process ancestry in a fresh Windows PowerShell that first compiles its Toolhelp helper, and the five-second fail-closed deadline covered that startup as well as the read. On a cold or contended machine the startup alone has taken 5 to 15 seconds: CI refused callers with `INSPECTION_TIMEOUT` before the script had begun (5,036 ms) and while the helper was still compiling (5,017 ms), although the read itself takes about 60 ms. The helper now reports when it is ready, and the five-second deadline starts there. Getting ready has its own 25-second ceiling, so a whole read still ends within the 30 seconds the bridge gives its other PowerShell identity reads. Either expiry still refuses the caller, and a slow start is reported as `INSPECTOR_START_TIMEOUT`. The helper also writes its JSON itself, so the timed read no longer loads PowerShell's JSON serializer.
|
|
16
|
+
- Lock `ip-address` at 10.7.2 for development installs (#87). 10.5.0 misclassified IPv6 link-local and NAT64 local-use addresses. The bridge's stdio servers never load the rate-limiting code that uses it, and the published package ships no lockfile, so installs from npm resolve their own versions.
|
|
17
|
+
|
|
18
|
+
## [1.19.0] - 2026-09-29
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Give each independent Codex Desktop task its own creation identity with an optional `requestId` UUID on `delegate_to_codex` and `start_codex_thread` (#82). Creation receipts were keyed by directory and title, or by the exact prompt when no title was given, so a second independent task with the same title returned the earlier task and its brief was never sent. A fresh `requestId` creates a separate task even with the same directory, title and prompt, and retrying with the same ID recovers the original receipt after a bridge restart without resending the prompt. Calls without `requestId` keep the previous deduplication. Legacy app-server mode rejects `requestId` before creating anything, because it cannot deduplicate creations durably.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Guide agents to open a new Codex conversation for each independent task when the user asks for one or has given standing instructions to, and to continue unfinished work, fixes and results in the task's original thread (#82). The server instructions, tool descriptions and README previously told agents to prefer an existing task and to create one only on an explicit request.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- Verify Codex → Claude Desktop senders that run inside a Codex-managed sandbox (#83). Codex Desktop 26.924 starts ordinary tasks with a `managed` permission profile, a `workspace-write` sandbox, and a `granular` approval policy, and its permission selector may offer only **Ask for approval** and **Approve for me**; the bridge accepted only the disabled full-access profile, so every send from such a task stopped with `CODEX_SENDER_CONTEXT_UNVERIFIED`. A managed profile whose file system is restricted now attests `prompting` with a `user` or `auto_review` reviewer and any approval policy, matching Claude, which counts `dontAsk` and `auto` as prompting; it can never attest `bypass`, which still requires Full access with `approval_policy: never`. The bridge still fails closed when the managed file system is unrestricted, grants write access to the file-system root, a literal root path, a glob, or an unrecognised special path, or has any unrecognised shape. A granular policy is reported as a stable `granular(<allowed categories>)` label, and `claude_bridge_status` shows the sender's permission profile and approval reviewer, which a queued send now also pins. A class mismatch against a Claude task in `bypassPermissions` names the Full access requirement.
|
|
31
|
+
- Retry removing an integration test's temporary home on Windows while the bridge and the PowerShell probes it started are still exiting (#86). They keep that directory as their working directory after the SDK's close() sends SIGKILL, so the removal failed a Windows CI run with `EPERM`.
|
|
32
|
+
|
|
7
33
|
## [1.18.2] - 2026-09-26
|
|
8
34
|
|
|
9
35
|
### Fixed
|
package/README.md
CHANGED
|
@@ -220,17 +220,24 @@ The registered supervisor should report auto-reload enabled. Next, list the inte
|
|
|
220
220
|
|
|
221
221
|
| Direction | Tools |
|
|
222
222
|
|---|---|
|
|
223
|
-
|
|
|
223
|
+
| Continue unfinished Codex work | Verify the original `threadId`, then `send_to_codex_thread` |
|
|
224
224
|
| Codex → Claude | `list_claude_sessions`, then `send_to_claude_session` |
|
|
225
|
-
|
|
|
225
|
+
| Start independent Codex work | `delegate_to_codex` or `start_codex_thread` with `cwd`, initial `prompt`, and a fresh UUID `requestId` in Desktop mode |
|
|
226
226
|
|
|
227
227
|
Example requests:
|
|
228
228
|
|
|
229
|
+
- **In Claude:** “For each independent new task or feature, create a new Codex conversation and send the initial brief there. Continue unfinished work in its original conversation.”
|
|
229
230
|
- **In Claude:** “Send this review request to my existing Codex task in this project and wait for its reply.”
|
|
230
231
|
- **In Codex:** “Send this result to my Claude Desktop Code session in this project and confirm its reply.”
|
|
231
232
|
|
|
232
233
|
Use the exact project directory and destination task. If several Claude sessions match, specify the task ID. A `reply_received` receipt confirms a reply; a timeout does not mean the task stopped, so inspect it before retrying.
|
|
233
234
|
|
|
235
|
+
An explicit request for a new conversation, or standing user instructions such as the example above, authorizes creation for independent work. Keep fixes, clarifications and results for unfinished work in its original conversation. A completed assistant turn does not by itself mean the whole task is finished; matching a project or finding a recent task does not make it the right destination for new work.
|
|
236
|
+
|
|
237
|
+
In Desktop mode, generate a fresh UUID `requestId` for each independent task and retain it on every retry of that creation. Different request IDs create separate tasks even with the same directory, title and prompt. The same ID recovers the original receipt after a bridge restart; an edited title or prompt is not resent. Continue that task with its returned `threadId`. Pending or uncertain creation remains blocked: inspect the original receipt/task instead of changing the ID to retry.
|
|
238
|
+
|
|
239
|
+
For backward compatibility, calls without `requestId` retain the previous deduplication by directory and explicit title (or exact prompt when no title is supplied). They can return an older task with the same title. Use a fresh request ID for independent new work; keep the old call shape when recovering an earlier legacy creation. Legacy app-server mode rejects `requestId` before creating anything because durable creation deduplication is only supported through Desktop native delivery.
|
|
240
|
+
|
|
234
241
|
The sending tools ask agents to write each prompt in English with these sections, dropping any that do not apply: `Goal`, `Context`, `Task`, `Scope`, `Constraints`, `Done when`, `Reply format`. The first line names the sender, the project and the purpose. Text the user supplied is sent unchanged.
|
|
235
242
|
|
|
236
243
|
```text
|
|
@@ -384,4 +391,4 @@ npm test
|
|
|
384
391
|
|
|
385
392
|
On Windows, use `node --test --test-concurrency=2` (also avoids npm versions that reject forwarded flags). CI tests Node 22 and 24 on Linux, macOS, and Windows. Tests use isolated fixtures and do not spend model quota. For the optional telemetry endpoint, also run `deno test --allow-env supabase/functions/bridge-usage/contract-check.ts`.
|
|
386
393
|
|
|
387
|
-
[Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md) · [MIT license](LICENSE)
|
|
394
|
+
[Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md) · [MIT license](LICENSE)
|
package/package.json
CHANGED
|
@@ -22,7 +22,7 @@ import { createReleaseSnapshot, snapshotRoot } from "../src/release-snapshot.mjs
|
|
|
22
22
|
|
|
23
23
|
exitForVersionRequest(import.meta.url);
|
|
24
24
|
|
|
25
|
-
const VERSION = "1.
|
|
25
|
+
const VERSION = "1.19.1";
|
|
26
26
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
27
|
const entry = path.join(root, "src", "mcp-supervisor.mjs");
|
|
28
28
|
const entryArgs = ["native-relay-companion.mjs"];
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { AGENT_PROMPT_GUIDANCE, PROMPT_FIELD_HINT } from "./prompt-guidance.mjs"
|
|
|
26
26
|
|
|
27
27
|
exitForVersionRequest(import.meta.url);
|
|
28
28
|
|
|
29
|
-
const VERSION = "1.
|
|
29
|
+
const VERSION = "1.19.1";
|
|
30
30
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
31
31
|
const FORWARD_MIN_INTERVAL_MS = 5000;
|
|
32
32
|
const FORWARD_MAX_PER_SESSION = 50;
|
|
@@ -482,7 +482,8 @@ registerTool(
|
|
|
482
482
|
rootPolicy.recheck(scopeBindings.sender, "Codex sender working directory");
|
|
483
483
|
rootPolicy.recheck(scopeBindings.recipient, "Claude recipient working directory");
|
|
484
484
|
}
|
|
485
|
-
if (active.threadId !== sender.threadId || active.turnId !== sender.turnId || active.cwd !== sender.cwd || active.mode !== sender.mode || active.approvalPolicy !== sender.approvalPolicy
|
|
485
|
+
if (active.threadId !== sender.threadId || active.turnId !== sender.turnId || active.cwd !== sender.cwd || active.mode !== sender.mode || active.approvalPolicy !== sender.approvalPolicy
|
|
486
|
+
|| active.permissionProfile !== sender.permissionProfile || active.approvalsReviewer !== sender.approvalsReviewer || JSON.stringify(active.review) !== JSON.stringify(sender.review)) {
|
|
486
487
|
throw preflightFailure("CODEX_SENDER_CONTEXT_CHANGED", "The sender's active turn or permissions changed while this message was queued.");
|
|
487
488
|
}
|
|
488
489
|
assertRecipientClass(refreshed, active);
|
|
@@ -690,6 +691,7 @@ registerTool(
|
|
|
690
691
|
`sender mode: ${sender?.mode ?? (desktopOnly ? "unverified - Desktop sends blocked" : peer.permissionMode ?? "unknown")}`,
|
|
691
692
|
...(sender ? [`sender context: ${sender.status} (${sender.source ?? "unavailable"})`, `sender task: ${sender.threadId ?? "unknown"}`, `sender turn: ${sender.turnId ?? "unknown"}`, ...(sender.reason ? [`sender detail: ${sender.reason}`] : [])] : []),
|
|
692
693
|
...(sender?.approvalPolicy ? [`sender approval policy: ${sender.approvalPolicy}`] : []),
|
|
694
|
+
...(sender?.permissionProfile ? [`sender permission profile: ${sender.permissionProfile}`, `sender approval reviewer: ${sender.approvalsReviewer}`] : []),
|
|
693
695
|
...(sender?.review ? [`sender auto review: ${sender.review.autoReview}`, `sender Node REPL review: ${sender.review.nodeReplReview}`] : []),
|
|
694
696
|
`session policy: ${desktopOnly ? "desktop-only" : "all Claude Code entrypoints"}`,
|
|
695
697
|
`Claude account: ${accounts.claude.status}${accounts.claude.fingerprint ? ` (${accounts.claude.fingerprint.slice(0, 12)})` : ` - ${accounts.claude.reason}`}`,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
4
|
import { listClaudeSessions } from "./peer-protocol.mjs";
|
|
@@ -6,12 +6,35 @@ import { readClaudeDesktopContext } from "./claude-desktop-context.mjs";
|
|
|
6
6
|
|
|
7
7
|
const execute = promisify(execFile);
|
|
8
8
|
const MAX_ANCESTORS = 8;
|
|
9
|
+
const INSPECTION_OUTPUT_LIMIT = 16384;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The five-second deadline is what keeps the caller check fail-closed: a read
|
|
13
|
+
* that cannot answer in time leaves the caller unverified. On Windows the read
|
|
14
|
+
* runs in a fresh Windows PowerShell that must first compile its Toolhelp
|
|
15
|
+
* helper, and the deadline used to cover that startup too. Starting is the
|
|
16
|
+
* machine's cost, not the read's: CI refused callers with INSPECTION_TIMEOUT
|
|
17
|
+
* both before the script had begun (5,036ms) and while the helper was still
|
|
18
|
+
* compiling (5,017ms), a cold start here has been measured between 5s and 15s,
|
|
19
|
+
* and the read itself takes milliseconds once the helper exists.
|
|
20
|
+
*
|
|
21
|
+
* The helper therefore reports on stderr when it is ready, and the five-second
|
|
22
|
+
* deadline starts there. Getting ready has its own ceiling, so a whole read
|
|
23
|
+
* still ends within the 30s the bridge gives its other PowerShell identity
|
|
24
|
+
* reads (peer-protocol.mjs). Either expiry kills the process and refuses the
|
|
25
|
+
* caller; nothing is read before the helper is ready, so the wait adds no
|
|
26
|
+
* staleness.
|
|
27
|
+
*/
|
|
28
|
+
const INSPECTION_TIMEOUT_MS = 5000;
|
|
29
|
+
const INSPECTOR_START_TIMEOUT_MS = 25000;
|
|
30
|
+
const INSPECTOR_READY_MARKER = "BRIDGE_ANCESTRY_READY";
|
|
9
31
|
const WINDOWS_ANCESTRY_SOURCE = `
|
|
10
32
|
using System;
|
|
11
33
|
using System.Collections.Generic;
|
|
12
34
|
using System.ComponentModel;
|
|
13
35
|
using System.Globalization;
|
|
14
36
|
using System.Runtime.InteropServices;
|
|
37
|
+
using System.Text;
|
|
15
38
|
|
|
16
39
|
public static class ClaudeProcessAncestry {
|
|
17
40
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
@@ -83,6 +106,17 @@ public static class ClaudeProcessAncestry {
|
|
|
83
106
|
}
|
|
84
107
|
return rows.ToArray();
|
|
85
108
|
}
|
|
109
|
+
|
|
110
|
+
public static string ReadJson(uint firstPid, int limit) {
|
|
111
|
+
var json = new StringBuilder("[");
|
|
112
|
+
foreach (var row in Read(firstPid, limit)) {
|
|
113
|
+
if (json.Length > 1) json.Append(',');
|
|
114
|
+
json.Append(@"{""pid"":").Append(row.pid.ToString(CultureInfo.InvariantCulture))
|
|
115
|
+
.Append(@",""parentPid"":").Append(row.parentPid.ToString(CultureInfo.InvariantCulture))
|
|
116
|
+
.Append(@",""processStart"":""").Append(row.processStart).Append(@"""}");
|
|
117
|
+
}
|
|
118
|
+
return json.Append(']').ToString();
|
|
119
|
+
}
|
|
86
120
|
}`;
|
|
87
121
|
|
|
88
122
|
function parseWindowsAncestry(stdout, parentPid, limit) {
|
|
@@ -105,26 +139,77 @@ function parseWindowsAncestry(stdout, parentPid, limit) {
|
|
|
105
139
|
return rows;
|
|
106
140
|
}
|
|
107
141
|
|
|
108
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Runs a process the way execFile does - the same stdout and stderr strings,
|
|
144
|
+
* maxBuffer limit, and exit-code and spawn errors - except that `timeout`
|
|
145
|
+
* starts once `readyMarker` appears on stderr, and `startupTimeout` bounds the
|
|
146
|
+
* time before it. An expiry kills the process and rejects with ETIMEDOUT and
|
|
147
|
+
* the phase that ran out. The result also carries the pid, readyAfterMs and
|
|
148
|
+
* elapsedMs so tests can report where the time went.
|
|
149
|
+
*/
|
|
150
|
+
export function runProcessInspector(command, args, { timeout, startupTimeout, readyMarker, maxBuffer = Infinity, windowsHide = true } = {}) {
|
|
151
|
+
return new Promise((resolve, reject) => {
|
|
152
|
+
const started = performance.now();
|
|
153
|
+
const child = spawn(command, args, { windowsHide, stdio: ["ignore", "pipe", "pipe"] });
|
|
154
|
+
const output = { stdout: [], stderr: [] };
|
|
155
|
+
const size = { stdout: 0, stderr: 0 };
|
|
156
|
+
let readyAfterMs = null;
|
|
157
|
+
let settled = false;
|
|
158
|
+
let timer;
|
|
159
|
+
const text = (stream) => Buffer.concat(output[stream]).toString("utf8");
|
|
160
|
+
const settle = (error) => {
|
|
161
|
+
if (settled) return;
|
|
162
|
+
settled = true;
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
if (error && child.pid !== undefined && child.exitCode === null && child.signalCode === null) child.kill();
|
|
165
|
+
const result = { stdout: text("stdout"), stderr: text("stderr"), pid: child.pid, readyAfterMs, elapsedMs: Math.round(performance.now() - started) };
|
|
166
|
+
if (error) reject(Object.assign(error, result));
|
|
167
|
+
else resolve(result);
|
|
168
|
+
};
|
|
169
|
+
const arm = (phase, ms) => {
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
timer = setTimeout(() => settle(Object.assign(new Error(`Process inspection ${phase} deadline elapsed`), { code: "ETIMEDOUT", phase, killed: true, signal: "SIGTERM" })), ms);
|
|
172
|
+
};
|
|
173
|
+
if (readyMarker) arm("startup", startupTimeout);
|
|
174
|
+
else arm("inspection", timeout);
|
|
175
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
176
|
+
child[stream].on("data", (chunk) => {
|
|
177
|
+
if (settled) return;
|
|
178
|
+
output[stream].push(chunk);
|
|
179
|
+
size[stream] += chunk.length;
|
|
180
|
+
if (size[stream] > maxBuffer) return settle(Object.assign(new RangeError(`${stream} maxBuffer length exceeded`), { code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" }));
|
|
181
|
+
if (stream === "stderr" && readyMarker && readyAfterMs === null && text("stderr").includes(readyMarker)) {
|
|
182
|
+
readyAfterMs = Math.round(performance.now() - started);
|
|
183
|
+
arm("inspection", timeout);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
child.on("error", settle);
|
|
188
|
+
child.on("close", (code, signal) => settle(code === 0 ? null : Object.assign(new Error(`Process inspection exited with ${signal ?? code}`), { code, signal })));
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function readProcessAncestry({ parentPid = process.ppid, platform = process.platform, maxDepth = MAX_ANCESTORS, run } = {}) {
|
|
109
193
|
if (!Number.isSafeInteger(parentPid) || parentPid <= 0 || parentPid > 0xffffffff) return [];
|
|
110
194
|
const limit = Number.isInteger(maxDepth) && maxDepth > 0 ? Math.min(maxDepth, MAX_ANCESTORS) : MAX_ANCESTORS;
|
|
111
|
-
const options = { timeout: 5000, maxBuffer: 16384, windowsHide: true };
|
|
112
195
|
if (platform === "win32") {
|
|
113
196
|
const shell = path.win32.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
114
|
-
const script = `$ErrorActionPreference='Stop'; $PSModuleAutoLoadingPreference='None'; Import-Module -Name ([IO.Path]::Combine($PSHOME,'Modules','Microsoft.PowerShell.Utility','Microsoft.PowerShell.Utility.psd1')) -ErrorAction Stop; Add-Type -TypeDefinition @'\n${WINDOWS_ANCESTRY_SOURCE}\n'@\
|
|
115
|
-
const { stdout } = await run(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")],
|
|
197
|
+
const script = `$ErrorActionPreference='Stop'; $PSModuleAutoLoadingPreference='None'; Import-Module -Name ([IO.Path]::Combine($PSHOME,'Modules','Microsoft.PowerShell.Utility','Microsoft.PowerShell.Utility.psd1')) -ErrorAction Stop; Add-Type -TypeDefinition @'\n${WINDOWS_ANCESTRY_SOURCE}\n'@\n[Console]::Error.WriteLine('${INSPECTOR_READY_MARKER}')\n[Console]::Out.Write([ClaudeProcessAncestry]::ReadJson(${parentPid},${limit}))`;
|
|
198
|
+
const { stdout } = await (run ?? runProcessInspector)(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], {
|
|
199
|
+
timeout: INSPECTION_TIMEOUT_MS, startupTimeout: INSPECTOR_START_TIMEOUT_MS, readyMarker: INSPECTOR_READY_MARKER, maxBuffer: INSPECTION_OUTPUT_LIMIT, windowsHide: true,
|
|
200
|
+
});
|
|
116
201
|
return parseWindowsAncestry(stdout, parentPid, limit);
|
|
117
202
|
}
|
|
118
203
|
const rows = [];
|
|
119
204
|
const seen = new Set();
|
|
120
205
|
let next = parentPid;
|
|
121
|
-
const deadline = Date.now() +
|
|
206
|
+
const deadline = Date.now() + INSPECTION_TIMEOUT_MS;
|
|
122
207
|
while (next > 0 && !seen.has(next) && rows.length < limit) {
|
|
123
208
|
seen.add(next);
|
|
124
209
|
const remaining = deadline - Date.now();
|
|
125
210
|
if (remaining <= 0) throw new Error("process inspection deadline elapsed");
|
|
126
|
-
const { stdout } = await run("/bin/ps", ["-p", String(next), "-o", "pid=,ppid=,lstart="], {
|
|
127
|
-
|
|
211
|
+
const { stdout } = await (run ?? execute)("/bin/ps", ["-p", String(next), "-o", "pid=,ppid=,lstart="], {
|
|
212
|
+
timeout: remaining, maxBuffer: INSPECTION_OUTPUT_LIMIT, windowsHide: true, env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
|
|
128
213
|
});
|
|
129
214
|
const match = stdout.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
|
130
215
|
if (!match) break;
|
|
@@ -140,6 +225,7 @@ function senderResult(status, reason, values = {}) {
|
|
|
140
225
|
}
|
|
141
226
|
|
|
142
227
|
function senderFailureCode(error) {
|
|
228
|
+
if (error?.code === "ETIMEDOUT" && error.phase === "startup") return "INSPECTOR_START_TIMEOUT";
|
|
143
229
|
if (error?.code === "ETIMEDOUT" || (error?.killed && ["SIGTERM", "SIGKILL"].includes(error.signal))) return "INSPECTION_TIMEOUT";
|
|
144
230
|
if (error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return "OUTPUT_LIMIT";
|
|
145
231
|
if (["ENOENT", "EACCES", "EPERM"].includes(error?.code)) return error.code;
|
|
@@ -8,6 +8,7 @@ const MAX_ROLLOUT_BYTES = 16 * 1024 * 1024;
|
|
|
8
8
|
const MAX_ENTRIES = 100000;
|
|
9
9
|
const MAX_DIRECTORIES = 4096;
|
|
10
10
|
const MAX_REPLY_BYTES = 1024 * 1024;
|
|
11
|
+
const DESKTOP_ORIGINATORS = new Set(["Codex Desktop", "codex_work_desktop"]);
|
|
11
12
|
const COMPLETED = new Set(["task_complete", "task_completed", "turn_complete", "turn_completed"]);
|
|
12
13
|
const STARTED = new Set(["task_started", "turn_started"]);
|
|
13
14
|
const FAILED = new Set(["task_aborted", "turn_aborted", "task_failed", "turn_failed"]);
|
|
@@ -155,12 +156,14 @@ function validateSession(records, threadId, expectedCwd) {
|
|
|
155
156
|
const sessions = records.filter(({ record }) => record.type === "session_meta").map(({ record }) => record.payload);
|
|
156
157
|
if (sessions.length !== 1) throw new Error("The Codex rollout has ambiguous session identity");
|
|
157
158
|
const session = sessions[0];
|
|
158
|
-
if (session.id !== threadId || session.originator
|
|
159
|
+
if (session.id !== threadId || !DESKTOP_ORIGINATORS.has(session.originator) || session.source !== "vscode") throw new Error("The rollout does not confirm the exact native Codex Desktop task");
|
|
159
160
|
if (canonicalDirectory(session.cwd, "The rollout workspace") !== expectedCwd) throw new Error("The rollout workspace does not match the selected native task");
|
|
160
161
|
}
|
|
161
162
|
|
|
162
|
-
function
|
|
163
|
-
|
|
163
|
+
function delegationOutputs(executorThreadId, prompt) {
|
|
164
|
+
const escaped = prompt.replace(/[&<>]/g, (character) => ({ "&": "&", "<": "<", ">": ">" })[character]);
|
|
165
|
+
// Older Desktop rollouts stored raw text; current builds escape XML text content.
|
|
166
|
+
return [prompt, escaped].map((text) => `<codex_delegation>\n <source_thread_id>${executorThreadId}</source_thread_id>\n <input>${text}</input>\n</codex_delegation>`);
|
|
164
167
|
}
|
|
165
168
|
|
|
166
169
|
function normalizedText(value) {
|
|
@@ -214,7 +217,7 @@ function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch }
|
|
|
214
217
|
if (requireDispatch) {
|
|
215
218
|
const dispatches = turns.filter(({ record }) => record.type === "response_item" && record.payload.type === "function_call_output" &&
|
|
216
219
|
record.payload.namespace === "codex_app" && record.payload.name === "send_message_to_thread");
|
|
217
|
-
if (dispatches.length !== 1 || dispatches[0].record.payload.output
|
|
220
|
+
if (dispatches.length !== 1 || !requireDispatch.outputs.includes(dispatches[0].record.payload.output) ||
|
|
218
221
|
contexts[0].start >= dispatches[0].start || dispatches[0].start >= completions[0].start) {
|
|
219
222
|
throw new Error("The newly observed turn is not correlated to the exact native dispatch");
|
|
220
223
|
}
|
|
@@ -324,7 +327,7 @@ export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId,
|
|
|
324
327
|
validateSession(records, threadId, cwd);
|
|
325
328
|
const tail = records.filter(({ start }) => start >= watermark.size);
|
|
326
329
|
const inspected = inspectTurnRecords(tail, threadId, turnId, cwd, {
|
|
327
|
-
requireDispatch: {
|
|
330
|
+
requireDispatch: { outputs: delegationOutputs(executorThreadId, prompt) },
|
|
328
331
|
});
|
|
329
332
|
return { ...inspected, observationStatus: inspected.status };
|
|
330
333
|
} catch (error) {
|
|
@@ -9,6 +9,13 @@ const STARTED = new Set(["task_started", "turn_started"]);
|
|
|
9
9
|
const MAX_ROLLOUT_BYTES = 64 * 1024 * 1024;
|
|
10
10
|
const MAX_ENTRIES = 100000;
|
|
11
11
|
const MAX_DIRECTORIES = 4096;
|
|
12
|
+
const DESKTOP_ORIGINATORS = new Set(["Codex Desktop", "codex_work_desktop"]);
|
|
13
|
+
const PROMPTING_POLICIES = new Set(["on-request", "on-failure", "untrusted"]);
|
|
14
|
+
const GRANULAR_CATEGORIES = ["sandbox_approval", "rules", "mcp_elicitations"];
|
|
15
|
+
const MANAGED_REVIEWERS = new Set(["user", "auto_review"]);
|
|
16
|
+
const MANAGED_SANDBOXES = new Set(["read-only", "workspace-write"]);
|
|
17
|
+
const ENTRY_ACCESS = new Set(["read", "write", "deny", "none"]);
|
|
18
|
+
const WRITABLE_SPECIAL_PATHS = new Set(["project_roots", "current_working_directory", "tmpdir", "slash_tmp"]);
|
|
12
19
|
|
|
13
20
|
function object(value) {
|
|
14
21
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -97,14 +104,79 @@ export function readState(file, maxBytes) {
|
|
|
97
104
|
}
|
|
98
105
|
|
|
99
106
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
* Codex writes AskForApproval as a string, or as {granular: {...}} whose
|
|
108
|
+
* boolean categories choose which approval requests reach the reviewer. The
|
|
109
|
+
* label is a plain string so receipts and the queued-send comparison can
|
|
110
|
+
* compare it directly.
|
|
111
|
+
*/
|
|
112
|
+
function approvalPolicyLabel(policy) {
|
|
113
|
+
if (policy === "never" || PROMPTING_POLICIES.has(policy)) return policy;
|
|
114
|
+
if (!exactObject(policy, ["granular"]) || !object(policy.granular)) return null;
|
|
115
|
+
const categories = Object.entries(policy.granular);
|
|
116
|
+
if (!GRANULAR_CATEGORIES.every((category) => Object.hasOwn(policy.granular, category)) || categories.some(([, allowed]) => typeof allowed !== "boolean")) return null;
|
|
117
|
+
return `granular(${categories.filter(([, allowed]) => allowed).map(([category]) => category).sort().join(",")})`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function rootPath(value) {
|
|
121
|
+
let local = value.replace(/^file:/i, "");
|
|
122
|
+
if (local.startsWith("///")) local = local.slice(2);
|
|
123
|
+
local = local.replace(/^\/(?=[a-z]:)/i, "").replace(/^\\\\\?\\UNC\\/i, "\\\\");
|
|
124
|
+
const { base } = path.win32.parse(path.win32.normalize(local || "/"));
|
|
125
|
+
return !base || base === ".";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A write grant on the file-system root, on a literal root path, or on a
|
|
130
|
+
* target whose breadth cannot be judged (a glob or an unrecognised special
|
|
131
|
+
* path) would leave the sandbox as open as full access, so such a profile
|
|
132
|
+
* gets no class. Read and deny entries only narrow what the sandbox exposes,
|
|
133
|
+
* so an unrecognised special path among them is ignored, as Codex ignores it.
|
|
134
|
+
*/
|
|
135
|
+
function assertSandboxEntry(entry) {
|
|
136
|
+
const target = object(entry) ? entry.path : undefined;
|
|
137
|
+
const known = object(target) && (
|
|
138
|
+
(target.type === "path" && exactObject(target, ["type", "path"]) && typeof target.path === "string")
|
|
139
|
+
|| (target.type === "glob_pattern" && exactObject(target, ["type", "pattern"]) && typeof target.pattern === "string")
|
|
140
|
+
|| (target.type === "special" && exactObject(target, ["type", "value"]) && object(target.value) && typeof target.value.kind === "string"));
|
|
141
|
+
if (!known || !ENTRY_ACCESS.has(entry.access) || Object.keys(entry).some((key) => !["path", "access", "missing_path_behavior"].includes(key))
|
|
142
|
+
|| (Object.hasOwn(entry, "missing_path_behavior") && entry.missing_path_behavior !== "skip")) throw new Error("The caller's managed sandbox has an unsupported file-system entry; no permission class was inferred");
|
|
143
|
+
if (entry.access === "write" && (target.type === "glob_pattern" || (target.type === "path" ? rootPath(target.path) : !WRITABLE_SPECIAL_PATHS.has(target.value.kind)))) {
|
|
144
|
+
throw new Error("The caller's managed sandbox grants write access beyond specific directories; no permission class was inferred");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function assertManagedSandbox(context) {
|
|
149
|
+
const { permission_profile: profile, sandbox_policy: sandbox } = context;
|
|
150
|
+
if (!exactObject(profile, ["type", "file_system", "network"]) || !["restricted", "enabled"].includes(profile.network)) throw new Error("The caller's managed permission profile is unsupported; no permission class was inferred");
|
|
151
|
+
const fileSystem = profile.file_system;
|
|
152
|
+
if (object(fileSystem) && fileSystem.type === "unrestricted") throw new Error("The caller's managed sandbox leaves the file system unrestricted; no permission class was inferred");
|
|
153
|
+
if (!object(fileSystem) || fileSystem.type !== "restricted" || !Array.isArray(fileSystem.entries)
|
|
154
|
+
|| Object.keys(fileSystem).some((key) => !["type", "entries", "glob_scan_max_depth"].includes(key))
|
|
155
|
+
|| (Object.hasOwn(fileSystem, "glob_scan_max_depth") && !(Number.isSafeInteger(fileSystem.glob_scan_max_depth) && fileSystem.glob_scan_max_depth > 0))) throw new Error("The caller's managed file-system sandbox is unsupported; no permission class was inferred");
|
|
156
|
+
for (const entry of fileSystem.entries) assertSandboxEntry(entry);
|
|
157
|
+
if (!object(sandbox) || !MANAGED_SANDBOXES.has(sandbox.type)) throw new Error("The caller's effective sandbox policy does not match its managed permission profile");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Claude's inbound parity gate asks one question: does the sender bypass
|
|
162
|
+
* permission checks? Claude puts only bypassPermissions in that class and
|
|
163
|
+
* counts dontAsk (deny without asking) and auto (a classifier approves) as
|
|
164
|
+
* prompting.
|
|
165
|
+
*
|
|
166
|
+
* For a Codex task with no sandbox, approval_policy decides: never is bypass,
|
|
167
|
+
* and a policy that routes requests for approval is prompting. A task inside
|
|
168
|
+
* a Codex-managed sandbox that restricts the file system never bypasses the
|
|
169
|
+
* checks, whatever its approval policy or reviewer: a sandbox that never asks
|
|
170
|
+
* is dontAsk, and escalations decided by the auto_review subagent are auto.
|
|
171
|
+
* Such a sender is always prompting, never bypass. Codex Desktop's "Ask for
|
|
172
|
+
* approval" and "Approve for me" run this way; only "Full access" is bypass.
|
|
173
|
+
*
|
|
174
|
+
* The two host review flags describe automated review instead -
|
|
175
|
+
* node_repl_auto_review_required is a per-model catalog attribute (Codex
|
|
176
|
+
* Desktop derives it from autoReview.requiredOnModels; the live gpt-6-astra
|
|
177
|
+
* task carried it with a user reviewer and auto review disabled) and
|
|
178
|
+
* auto_review_enabled names a Guardian reviewer - so they are required as
|
|
179
|
+
* evidence and reported, but never move a sender between classes.
|
|
108
180
|
* Downgrading them held every send from such a model behind an approval
|
|
109
181
|
* dialog that Claude Desktop does not render.
|
|
110
182
|
*/
|
|
@@ -112,11 +184,18 @@ function permissionClass(context, metadata) {
|
|
|
112
184
|
for (const field of ["auto_review_enabled", "node_repl_auto_review_required"]) {
|
|
113
185
|
if (!Object.hasOwn(metadata, field) || typeof metadata[field] !== "boolean") throw new Error(`The caller's ${field} review evidence is ${reviewFlag(metadata, field)}; the host must supply an explicit boolean`);
|
|
114
186
|
}
|
|
187
|
+
const approvalPolicy = approvalPolicyLabel(context.approval_policy);
|
|
188
|
+
if (object(context.permission_profile) && context.permission_profile.type === "managed") {
|
|
189
|
+
if (!MANAGED_REVIEWERS.has(context.approvals_reviewer)) throw new Error("The caller's effective approval reviewer is unverified");
|
|
190
|
+
assertManagedSandbox(context);
|
|
191
|
+
if (!approvalPolicy) throw new Error("The caller's effective approval policy is unsupported");
|
|
192
|
+
return { mode: "prompting", approvalPolicy, permissionProfile: "managed" };
|
|
193
|
+
}
|
|
115
194
|
if (context.approvals_reviewer !== "user") throw new Error("The caller's effective approval reviewer is unverified");
|
|
116
|
-
if (!exactObject(context.permission_profile, ["type"]) || context.permission_profile.type !== "disabled") throw new Error("The caller's permission profile is
|
|
195
|
+
if (!exactObject(context.permission_profile, ["type"]) || context.permission_profile.type !== "disabled") throw new Error("The caller's permission profile is unsupported; no permission class was inferred");
|
|
117
196
|
if (!exactObject(context.sandbox_policy, ["type"]) || context.sandbox_policy.type !== "danger-full-access") throw new Error("The caller's effective sandbox policy does not match its disabled permission profile");
|
|
118
|
-
if (
|
|
119
|
-
if (
|
|
197
|
+
if (approvalPolicy === "never") return { mode: "bypass", approvalPolicy, permissionProfile: "disabled" };
|
|
198
|
+
if (PROMPTING_POLICIES.has(approvalPolicy)) return { mode: "prompting", approvalPolicy, permissionProfile: "disabled" };
|
|
120
199
|
throw new Error("The caller's effective approval policy is unsupported");
|
|
121
200
|
}
|
|
122
201
|
|
|
@@ -135,13 +214,15 @@ export function readCodexSenderContext(meta, { env = process.env, maxRolloutByte
|
|
|
135
214
|
const state = readState(file, Math.min(MAX_ROLLOUT_BYTES, maxRolloutBytes));
|
|
136
215
|
const { session, context, lifecycle } = state;
|
|
137
216
|
if (!["Codex Desktop", "codex_vscode"].includes(originator)) throw new Error("Unsupported calling host");
|
|
138
|
-
|
|
217
|
+
const desktop = originator === "Codex Desktop";
|
|
218
|
+
const matchesOriginator = desktop ? DESKTOP_ORIGINATORS.has(session?.originator) : session?.originator === originator;
|
|
219
|
+
if (session?.id !== identity.threadId || !matchesOriginator || session.source !== "vscode") throw new Error(`The caller rollout does not confirm a root ${originator} task`);
|
|
139
220
|
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");
|
|
140
221
|
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");
|
|
141
222
|
const cwd = fs.realpathSync.native(context.cwd);
|
|
142
223
|
if (!fs.statSync(cwd).isDirectory() || path.relative(fs.realpathSync.native(session.cwd), cwd)) throw new Error("The caller's workspace changed from its Desktop session identity");
|
|
143
|
-
const mode = permissionClass(context, metadata);
|
|
144
|
-
return { status: "verified", ...identity, mode, cwd, source: file, approvalPolicy: context.
|
|
224
|
+
const { mode, approvalPolicy, permissionProfile } = permissionClass(context, metadata);
|
|
225
|
+
return { status: "verified", ...identity, mode, cwd, source: file, approvalPolicy, permissionProfile, approvalsReviewer: context.approvals_reviewer, reason: "Host-supplied calling task and active turn match the Desktop rollout's effective permission settings" };
|
|
145
226
|
} catch (error) {
|
|
146
227
|
return unavailable(error?.code ? `Caller evidence could not be read (${error.code}); no sender permission class was inferred` : error.message, identity);
|
|
147
228
|
}
|
|
@@ -5,11 +5,15 @@ import path from "node:path";
|
|
|
5
5
|
import { homeDir } from "./platform.mjs";
|
|
6
6
|
|
|
7
7
|
const HASH = /^[a-f0-9]{64}$/;
|
|
8
|
-
const
|
|
8
|
+
const REQUEST_ID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
|
9
|
+
const FIELDS = new Set(["version", "key", "cwd", "promptHash", "state", "startedAt", "threadId", "projectId", "projectName", "name", "accountContext", "requestId"]);
|
|
9
10
|
const digest = (value) => createHash("sha256").update(value).digest("hex");
|
|
10
11
|
const normalizedName = (name) => name?.normalize("NFC").trim().replace(/\s+/g, " ");
|
|
11
12
|
const canonicalCwd = (cwd) => process.platform === "win32" ? path.normalize(cwd).toLowerCase() : path.normalize(cwd);
|
|
12
13
|
const nonemptyString = (value) => typeof value === "string" && value.trim().length > 0 && value.length <= 8192 && !/[\u0000-\u001f]/.test(value);
|
|
14
|
+
const receiptKey = ({ cwd, name, promptHash, requestId }) => digest(JSON.stringify(requestId
|
|
15
|
+
? [canonicalCwd(cwd), "request", requestId.toLowerCase()]
|
|
16
|
+
: [canonicalCwd(cwd), normalizedName(name) || promptHash]));
|
|
13
17
|
|
|
14
18
|
function assertKey(key) {
|
|
15
19
|
if (typeof key !== "string" || !HASH.test(key)) throw new Error("Invalid Desktop task receipt key; refusing unsafe filesystem access.");
|
|
@@ -23,6 +27,7 @@ function validateReceipt(key, receipt) {
|
|
|
23
27
|
const valid = receipt && typeof receipt === "object" && !Array.isArray(receipt)
|
|
24
28
|
&& Object.keys(receipt).every((field) => FIELDS.has(field))
|
|
25
29
|
&& receipt.version === 1 && receipt.key === key && HASH.test(receipt.promptHash)
|
|
30
|
+
&& (receipt.requestId === undefined || typeof receipt.requestId === "string" && REQUEST_ID.test(receipt.requestId))
|
|
26
31
|
&& nonemptyString(receipt.cwd) && path.isAbsolute(receipt.cwd)
|
|
27
32
|
&& ["pending", "unknown", "known"].includes(receipt.state)
|
|
28
33
|
&& Number.isSafeInteger(receipt.startedAt) && receipt.startedAt >= 0
|
|
@@ -31,7 +36,7 @@ function validateReceipt(key, receipt) {
|
|
|
31
36
|
&& ["threadId", "projectId", "projectName", "name"].every((field) => receipt[field] === undefined || nonemptyString(receipt[field]))
|
|
32
37
|
&& (receipt.state !== "known" || nonemptyString(receipt.threadId));
|
|
33
38
|
if (!valid) throw unsafeReceipt(key);
|
|
34
|
-
const expected =
|
|
39
|
+
const expected = receiptKey(receipt);
|
|
35
40
|
if (expected !== key) throw unsafeReceipt(key);
|
|
36
41
|
return receipt;
|
|
37
42
|
}
|
|
@@ -41,12 +46,15 @@ export class DesktopTaskReceipts {
|
|
|
41
46
|
this.directory = path.resolve(directory);
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
key({ cwd, prompt, name }) {
|
|
49
|
+
key({ cwd, prompt, name, requestId }) {
|
|
45
50
|
if (!nonemptyString(cwd) || !path.isAbsolute(cwd) || typeof prompt !== "string" || (name !== undefined && typeof name !== "string")) {
|
|
46
51
|
throw new Error("Desktop task receipts require an absolute validated cwd, a prompt string, and an optional name string.");
|
|
47
52
|
}
|
|
53
|
+
if (requestId !== undefined && (typeof requestId !== "string" || !REQUEST_ID.test(requestId))) {
|
|
54
|
+
throw new Error("Desktop task creation requestId must be a stable UUID; use a fresh UUID only for independent new work.");
|
|
55
|
+
}
|
|
48
56
|
const promptHash = digest(prompt);
|
|
49
|
-
return { key:
|
|
57
|
+
return { key: receiptKey({ cwd, name, promptHash, requestId }), promptHash, ...(requestId ? { requestId: requestId.toLowerCase() } : {}) };
|
|
50
58
|
}
|
|
51
59
|
|
|
52
60
|
async ensureDirectory(create = false) {
|
package/src/index.mjs
CHANGED
|
@@ -34,7 +34,7 @@ import { AGENT_PROMPT_GUIDANCE, PROMPT_FIELD_HINT } from "./prompt-guidance.mjs"
|
|
|
34
34
|
|
|
35
35
|
exitForVersionRequest(import.meta.url);
|
|
36
36
|
|
|
37
|
-
const VERSION = "1.
|
|
37
|
+
const VERSION = "1.19.1";
|
|
38
38
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
39
39
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
40
40
|
|
|
@@ -169,12 +169,12 @@ function threadNameFor({ cwd, prompt, name }) {
|
|
|
169
169
|
);
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
async function delegateDesktopTask({ cwd, prompt, name, model, effort, timeoutSec, openInApp, waitForReply = true }) {
|
|
172
|
+
async function delegateDesktopTask({ cwd, prompt, name, requestId, model, effort, timeoutSec, openInApp, waitForReply = true }) {
|
|
173
173
|
const deadline = desktopOperation.getStore()?.deadline ?? Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS);
|
|
174
174
|
const workspace = resolveWorkspacePath(cwd);
|
|
175
175
|
const created = await desktopTasks.create({
|
|
176
176
|
cwd: workspace.path, prompt, name: threadNameFor({ cwd: workspace.path, prompt, name }),
|
|
177
|
-
dedupeName: name ?? "", model: model ?? DEFAULT_MODEL, effort: effort ?? DEFAULT_EFFORT, deadline,
|
|
177
|
+
dedupeName: name ?? "", requestId, model: model ?? DEFAULT_MODEL, effort: effort ?? DEFAULT_EFFORT, deadline,
|
|
178
178
|
});
|
|
179
179
|
const notes = [];
|
|
180
180
|
if (workspace.note) notes.push(workspace.note);
|
|
@@ -188,6 +188,7 @@ async function delegateDesktopTask({ cwd, prompt, name, model, effort, timeoutSe
|
|
|
188
188
|
}
|
|
189
189
|
const lines = [
|
|
190
190
|
created.reused ? "Reused the existing Codex Desktop task; the prompt was not resent" : "Delegated through Codex Desktop", `threadId: ${created.threadId}`, `name: ${created.name}`,
|
|
191
|
+
...(requestId ? [`requestId: ${requestId}`] : []),
|
|
191
192
|
`cwd: ${created.cwd}`,
|
|
192
193
|
...(created.projectAssignmentStatus === "unverified"
|
|
193
194
|
? [`expected projectId: ${created.expectedProjectId}`, `expected project: ${created.expectedProjectName ?? "(unnamed)"}`, "project assignment: unverified", created.projectAssignmentNote]
|
|
@@ -195,7 +196,7 @@ async function delegateDesktopTask({ cwd, prompt, name, model, effort, timeoutSe
|
|
|
195
196
|
"permissions: Codex Desktop settings; no external app-server writer",
|
|
196
197
|
...(created.promptChanged ? [created.projectAssignmentStatus === "unverified"
|
|
197
198
|
? "The edited brief was not sent. Inspect this task's project assignment before continuing it."
|
|
198
|
-
: "The edited brief was not sent.
|
|
199
|
+
: "The edited brief was not sent. Continue the same unfinished work with send_to_codex_thread and this threadId. For independent new work, create a task with a fresh requestId; never change requestId merely to retry uncertain delivery."] : []), ...notes,
|
|
199
200
|
];
|
|
200
201
|
if (created.projectAssignmentStatus === "unverified") return textResult([...lines, "status: existing task retained; project assignment needs inspection"].join("\n"));
|
|
201
202
|
if (!waitForReply) return textResult([...lines, created.reused ? "status: existing task; read it to check its current progress" : "status: accepted; the task is running in Desktop"].join("\n"));
|
|
@@ -341,9 +342,12 @@ const server = new McpServer(
|
|
|
341
342
|
{ name: "codex-bridge", version: VERSION },
|
|
342
343
|
{
|
|
343
344
|
instructions:
|
|
344
|
-
"Bridge Claude work into Codex.
|
|
345
|
-
"
|
|
346
|
-
"
|
|
345
|
+
"Bridge Claude work into Codex. When the user requests a new conversation or has given standing authorization to create one for each independent task, " +
|
|
346
|
+
"use delegate_to_codex or start_codex_thread with the initial prompt and a fresh requestId for each independent task or feature in Desktop mode, even in the same project. " +
|
|
347
|
+
"In legacy app-server mode, use delegate_to_codex with the initial prompt and omit requestId; durable creation deduplication is unavailable there. " +
|
|
348
|
+
"Reuse that requestId only for retries of that creation. Continue unfinished work, fixes, clarifications, and results in its verified original threadId with send_to_codex_thread. " +
|
|
349
|
+
"A completed turn alone does not mean the task is finished. Do not choose an old task merely because its project matches or it was recently active. " +
|
|
350
|
+
"Without authorization to create a new conversation, ask before creating it. With Desktop tasks enabled it assigns the exact saved project and starts visibly in " +
|
|
347
351
|
"Codex Desktop using Desktop permissions. Otherwise it releases the bridge writer lock and opens the exact thread in " +
|
|
348
352
|
"Codex Desktop. Use send_to_codex_thread only when an existing threadId is intentional; use " +
|
|
349
353
|
"list_codex_threads or read_codex_thread to inspect sessions and codex_bridge_status to inspect wiring. " +
|
|
@@ -399,9 +403,12 @@ registerTool(
|
|
|
399
403
|
title: "Delegate work to a new Codex session",
|
|
400
404
|
description:
|
|
401
405
|
"Create a named Codex session at the requested project directory, send Claude's prompt into it, " +
|
|
402
|
-
"return Codex's reply, and hand the session to Codex Desktop without leaving the bridge writer lock behind."
|
|
406
|
+
"return Codex's reply, and hand the session to Codex Desktop without leaving the bridge writer lock behind. " +
|
|
407
|
+
"Use for independent new work when the user explicitly or through standing instructions authorizes new conversations. " +
|
|
408
|
+
"In Desktop mode, supply a fresh requestId per independent task and keep it on retries; omit requestId in legacy app-server mode. Use send_to_codex_thread for unfinished work.",
|
|
403
409
|
inputSchema: {
|
|
404
410
|
cwd: z.string().describe("Absolute project directory where Codex must work"),
|
|
411
|
+
requestId: z.string().uuid().optional().describe("Desktop creation identity: fresh UUID for each independent new task; retain exactly on retries. Omitting it preserves legacy title/prompt deduplication and can return an older task. Not supported in legacy app-server mode."),
|
|
405
412
|
prompt: z.string().describe(`The complete task Claude is delegating to Codex. ${PROMPT_FIELD_HINT}`),
|
|
406
413
|
name: z.string().min(1).max(200).optional().describe("Optional Codex session title; otherwise one is derived from the prompt"),
|
|
407
414
|
timeoutSec: z
|
|
@@ -432,12 +439,13 @@ registerTool(
|
|
|
432
439
|
openWorldHint: true,
|
|
433
440
|
},
|
|
434
441
|
},
|
|
435
|
-
async ({ cwd, prompt, name, timeoutSec, model, effort, openInApp, releaseAfterTurn }) => {
|
|
442
|
+
async ({ cwd, prompt, name, requestId, timeoutSec, model, effort, openInApp, releaseAfterTurn }) => {
|
|
436
443
|
const shouldOpen = openInApp ?? DEFAULT_OPEN_IN_APP;
|
|
437
444
|
const shouldRelease = releaseAfterTurn ?? DEFAULT_RELEASE_AFTER_TURN;
|
|
438
445
|
const notes = [];
|
|
439
446
|
try {
|
|
440
|
-
if (desktopTasksEnabled) return await delegateDesktopTask({ cwd, prompt, name, model, effort, timeoutSec, openInApp });
|
|
447
|
+
if (desktopTasksEnabled) return await delegateDesktopTask({ cwd, prompt, name, requestId, model, effort, timeoutSec, openInApp });
|
|
448
|
+
if (requestId !== undefined) throw new Error("Creation requestId requires Desktop native delivery; legacy app-server mode cannot safely deduplicate this request. No task was created.");
|
|
441
449
|
const created = await createCodexThread({ cwd, prompt, name, model });
|
|
442
450
|
if (created.workspace.note) notes.push(created.workspace.note);
|
|
443
451
|
if (shouldOpen && !shouldRelease) {
|
|
@@ -490,6 +498,7 @@ registerTool(
|
|
|
490
498
|
title: "Send a prompt to a Codex thread",
|
|
491
499
|
description:
|
|
492
500
|
"Send a prompt as a new user turn inside an existing Codex thread and wait for Codex to answer. " +
|
|
501
|
+
"Use only for the same unfinished task, including follow-up fixes, clarifications, or results; independent new work belongs in a new conversation when authorized. " +
|
|
493
502
|
"The thread keeps its full history, cwd and model. Use list_codex_threads first if you do not know the threadId. " +
|
|
494
503
|
"Desktop-owned tasks must use Desktop native delivery; an open task is a valid destination. " +
|
|
495
504
|
"If legacy delivery reports an active writer, inspect codex_bridge_status and repair the native relay/configuration. " +
|
|
@@ -529,6 +538,7 @@ registerTool(
|
|
|
529
538
|
},
|
|
530
539
|
async ({ threadId, prompt, timeoutSec, cwd, model, effort, name, openInApp, releaseAfterTurn }) => {
|
|
531
540
|
const deadline = desktopTasksEnabled ? desktopOperation.getStore()?.deadline ?? Date.now() + Math.min((timeoutSec ?? 40) * 1000, DESKTOP_TOOL_BUDGET_MS) : undefined;
|
|
541
|
+
let acceptedDelivery;
|
|
532
542
|
return (desktopTasksEnabled ? desktopTasks : client).withThread(threadId, async () => {
|
|
533
543
|
const notes = [];
|
|
534
544
|
const shouldOpen = openInApp ?? DEFAULT_OPEN_IN_APP;
|
|
@@ -538,6 +548,7 @@ registerTool(
|
|
|
538
548
|
const workspace = cwd ? resolveWorkspacePath(cwd) : null;
|
|
539
549
|
if (workspace) security.assertCwd(workspace.path);
|
|
540
550
|
const delivered = await desktopTasks.send({ threadId, prompt, cwd: workspace?.path, model: model ?? DEFAULT_MODEL, effort: effort ?? DEFAULT_EFFORT, name, deadline });
|
|
551
|
+
acceptedDelivery = delivered;
|
|
541
552
|
notes.push("sent through Codex Desktop; no external app-server writer", `cwd: ${delivered.cwd}`);
|
|
542
553
|
if (shouldOpen) {
|
|
543
554
|
try {
|
|
@@ -608,7 +619,16 @@ registerTool(
|
|
|
608
619
|
} catch (err) {
|
|
609
620
|
return failure(err);
|
|
610
621
|
}
|
|
611
|
-
}, {
|
|
622
|
+
}, {
|
|
623
|
+
deadline,
|
|
624
|
+
onDeadline(error) {
|
|
625
|
+
if (!acceptedDelivery) throw error;
|
|
626
|
+
return {
|
|
627
|
+
...textResult(`sent through Codex Desktop; no external app-server writer\ncwd: ${acceptedDelivery.cwd}\nthreadId: ${threadId}\nstatus: timeout\nTask was accepted; the response deadline elapsed before a completed reply was observed. Inspect the existing task; do not resend.`),
|
|
628
|
+
structuredContent: { threadId, deliveryStatus: "accepted", status: "timeout", responseStatus: "unavailable" },
|
|
629
|
+
};
|
|
630
|
+
},
|
|
631
|
+
}).catch(failure);
|
|
612
632
|
},
|
|
613
633
|
);
|
|
614
634
|
|
|
@@ -702,9 +722,10 @@ registerTool(
|
|
|
702
722
|
"start_codex_thread",
|
|
703
723
|
{
|
|
704
724
|
title: "Start a new Codex thread",
|
|
705
|
-
description: "Start a Codex task. In Desktop mode include the initial prompt to create and assign a visible task atomically;
|
|
725
|
+
description: "Start a new Codex task when the user explicitly or through standing instructions authorizes a new conversation for independent work. In Desktop mode include the initial prompt and a fresh requestId to create and assign a visible task atomically; keep requestId unchanged on retries. Continue unfinished work with send_to_codex_thread and its original threadId. Use delegate_to_codex to also wait for its reply.",
|
|
706
726
|
inputSchema: {
|
|
707
727
|
cwd: z.string().describe("Absolute working directory for the new Codex session"),
|
|
728
|
+
requestId: z.string().uuid().optional().describe("Desktop creation identity: fresh UUID per independent task, same UUID on retries. Omit only for legacy title/prompt deduplication. Not supported in legacy app-server mode."),
|
|
708
729
|
prompt: z.string().min(1).optional().describe(`Initial task; required with CODEX_BRIDGE_DESKTOP_TASKS=1, starts immediately. ${PROMPT_FIELD_HINT}`),
|
|
709
730
|
model: z.string().optional().describe("Model override, e.g. gpt-5.6-luna"),
|
|
710
731
|
name: z.string().min(1).max(200).optional().describe("Optional title to show for the new Codex session"),
|
|
@@ -716,12 +737,13 @@ registerTool(
|
|
|
716
737
|
openWorldHint: true,
|
|
717
738
|
},
|
|
718
739
|
},
|
|
719
|
-
async ({ cwd, model, name, prompt }) => {
|
|
740
|
+
async ({ cwd, model, name, prompt, requestId }) => {
|
|
720
741
|
try {
|
|
721
742
|
if (desktopTasksEnabled) {
|
|
722
743
|
if (!prompt?.trim()) throw new Error("Desktop task creation requires the initial prompt. Use delegate_to_codex, or pass prompt to start_codex_thread. No task was created.");
|
|
723
|
-
return await delegateDesktopTask({ cwd, model, name, prompt, waitForReply: false });
|
|
744
|
+
return await delegateDesktopTask({ cwd, model, name, prompt, requestId, waitForReply: false });
|
|
724
745
|
}
|
|
746
|
+
if (requestId !== undefined) throw new Error("Creation requestId requires Desktop native delivery; legacy app-server mode cannot safely deduplicate this request. No task was created.");
|
|
725
747
|
if (prompt) throw new Error("Use delegate_to_codex to send an initial prompt in app-server mode.");
|
|
726
748
|
const created = await createCodexThread({ cwd, model, name });
|
|
727
749
|
return textResult(
|
|
@@ -34,7 +34,7 @@ import { createHardenedRootPolicy } from "./hardened-root-policy.mjs";
|
|
|
34
34
|
|
|
35
35
|
exitForVersionRequest(import.meta.url);
|
|
36
36
|
|
|
37
|
-
const VERSION = "1.
|
|
37
|
+
const VERSION = "1.19.1";
|
|
38
38
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
39
39
|
|
|
40
40
|
function errorResponse(code, message, sent) {
|
|
@@ -22,5 +22,5 @@ export function assertRecipientClass(session, sender) {
|
|
|
22
22
|
if (inbound.value === "accept") return;
|
|
23
23
|
const recipient = session.desktop?.permissionClass ?? null;
|
|
24
24
|
if (!recipient || recipient === sender.mode) return;
|
|
25
|
-
throw preflightFailure("CLAUDE_RECIPIENT_CLASS_MISMATCH", `The Claude Desktop task "${session.desktop.title}" runs in ${session.desktop.permissionMode} (${recipient} class) while this sender attests ${sender.mode}. With no explicit crossSessionInbound setting, Claude holds a cross-session message whose sender class differs from the recipient's, and Claude Desktop declares no peer approval dialog, so the message would expire unapproved. Report this to the user: they can run that task in the ${sender.mode} class, change this Codex task's
|
|
25
|
+
throw preflightFailure("CLAUDE_RECIPIENT_CLASS_MISMATCH", `The Claude Desktop task "${session.desktop.title}" runs in ${session.desktop.permissionMode} (${recipient} class) while this sender attests ${sender.mode}. With no explicit crossSessionInbound setting, Claude holds a cross-session message whose sender class differs from the recipient's, and Claude Desktop declares no peer approval dialog, so the message would expire unapproved. Report this to the user: they can run that task in the ${sender.mode} class, change this Codex task's permissions so its class matches (a Codex task attests bypass only with Full access and approval policy never), or set crossSessionInbound to accept in the recipient session's own settings. Do not change the attested sender class or recipient settings yourself.`);
|
|
26
26
|
}
|
package/src/thread-delivery.mjs
CHANGED
|
@@ -79,7 +79,7 @@ export class DesktopTaskDelivery {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
async withThread(threadId, operation, { deadline } = {}) {
|
|
82
|
+
async withThread(threadId, operation, { deadline, onDeadline } = {}) {
|
|
83
83
|
const previous = this.threadOperations.get(threadId) ?? Promise.resolve();
|
|
84
84
|
let expired = false;
|
|
85
85
|
const current = previous.catch(() => {}).then(() => {
|
|
@@ -98,6 +98,10 @@ export class DesktopTaskDelivery {
|
|
|
98
98
|
reject(new Error("The Desktop response deadline elapsed. A previous operation may still be running; inspect the existing task before sending anything again."));
|
|
99
99
|
}, Math.max(1, deadline - this.now()));
|
|
100
100
|
})]);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
// Preserve confirmed dispatch state while the underlying operation retains its lock.
|
|
103
|
+
if (expired && onDeadline) return onDeadline(error);
|
|
104
|
+
throw error;
|
|
101
105
|
} finally {
|
|
102
106
|
clearTimeout(timer);
|
|
103
107
|
}
|
|
@@ -197,12 +201,12 @@ export class DesktopTaskDelivery {
|
|
|
197
201
|
return { projectId: project.projectId, projectName: project.label, projectAssignmentStatus: "verified" };
|
|
198
202
|
}
|
|
199
203
|
|
|
200
|
-
async create({ cwd, prompt, name, dedupeName = name, model, effort, deadline = this.now() + DESKTOP_TOOL_BUDGET_MS }) {
|
|
204
|
+
async create({ cwd, prompt, name, dedupeName = name, requestId, model, effort, deadline = this.now() + DESKTOP_TOOL_BUDGET_MS }) {
|
|
201
205
|
this.security.assertCwd(cwd);
|
|
202
206
|
cwd = realpathSync.native(cwd);
|
|
203
207
|
this.security.assertCwd(cwd);
|
|
204
208
|
dedupeName = dedupeName?.normalize("NFC").trim().replace(/\s+/g, " ") || undefined;
|
|
205
|
-
const identity = this.receipts.key({ cwd, prompt, name: dedupeName });
|
|
209
|
+
const identity = this.receipts.key({ cwd, prompt, name: dedupeName, requestId });
|
|
206
210
|
const accountContext = this.accountContext?.();
|
|
207
211
|
if (this.accountContext && !accountContext) throw new Error("Desktop creation requires the original verified account context.");
|
|
208
212
|
const options = { cwd, promptHash: identity.promptHash, deadline, accountContext };
|