@minhspark/codex-mcp-bridge 1.18.1 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -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/codex-sender-context.mjs +91 -13
- package/src/desktop-task-receipts.mjs +12 -4
- package/src/index.mjs +24 -13
- package/src/native-relay-companion.mjs +1 -1
- package/src/native-relay.mjs +2 -0
- package/src/recipient-preflight.mjs +1 -1
- package/src/thread-delivery.mjs +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.19.0] - 2026-09-29
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- 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.
|
|
20
|
+
- 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`.
|
|
21
|
+
|
|
22
|
+
## [1.18.2] - 2026-09-26
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Restore native relay compatibility with Codex Desktop 26.924 by including the required caller source in native tool requests, allowing pipe discovery and Desktop operations to recover automatically.
|
|
27
|
+
|
|
7
28
|
## [1.18.1] - 2026-09-25
|
|
8
29
|
|
|
9
30
|
### 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.0";
|
|
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.0";
|
|
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}`}`,
|
|
@@ -9,6 +9,12 @@ 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 PROMPTING_POLICIES = new Set(["on-request", "on-failure", "untrusted"]);
|
|
13
|
+
const GRANULAR_CATEGORIES = ["sandbox_approval", "rules", "mcp_elicitations"];
|
|
14
|
+
const MANAGED_REVIEWERS = new Set(["user", "auto_review"]);
|
|
15
|
+
const MANAGED_SANDBOXES = new Set(["read-only", "workspace-write"]);
|
|
16
|
+
const ENTRY_ACCESS = new Set(["read", "write", "deny", "none"]);
|
|
17
|
+
const WRITABLE_SPECIAL_PATHS = new Set(["project_roots", "current_working_directory", "tmpdir", "slash_tmp"]);
|
|
12
18
|
|
|
13
19
|
function object(value) {
|
|
14
20
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -97,14 +103,79 @@ export function readState(file, maxBytes) {
|
|
|
97
103
|
}
|
|
98
104
|
|
|
99
105
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
* Codex writes AskForApproval as a string, or as {granular: {...}} whose
|
|
107
|
+
* boolean categories choose which approval requests reach the reviewer. The
|
|
108
|
+
* label is a plain string so receipts and the queued-send comparison can
|
|
109
|
+
* compare it directly.
|
|
110
|
+
*/
|
|
111
|
+
function approvalPolicyLabel(policy) {
|
|
112
|
+
if (policy === "never" || PROMPTING_POLICIES.has(policy)) return policy;
|
|
113
|
+
if (!exactObject(policy, ["granular"]) || !object(policy.granular)) return null;
|
|
114
|
+
const categories = Object.entries(policy.granular);
|
|
115
|
+
if (!GRANULAR_CATEGORIES.every((category) => Object.hasOwn(policy.granular, category)) || categories.some(([, allowed]) => typeof allowed !== "boolean")) return null;
|
|
116
|
+
return `granular(${categories.filter(([, allowed]) => allowed).map(([category]) => category).sort().join(",")})`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function rootPath(value) {
|
|
120
|
+
let local = value.replace(/^file:/i, "");
|
|
121
|
+
if (local.startsWith("///")) local = local.slice(2);
|
|
122
|
+
local = local.replace(/^\/(?=[a-z]:)/i, "").replace(/^\\\\\?\\UNC\\/i, "\\\\");
|
|
123
|
+
const { base } = path.win32.parse(path.win32.normalize(local || "/"));
|
|
124
|
+
return !base || base === ".";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A write grant on the file-system root, on a literal root path, or on a
|
|
129
|
+
* target whose breadth cannot be judged (a glob or an unrecognised special
|
|
130
|
+
* path) would leave the sandbox as open as full access, so such a profile
|
|
131
|
+
* gets no class. Read and deny entries only narrow what the sandbox exposes,
|
|
132
|
+
* so an unrecognised special path among them is ignored, as Codex ignores it.
|
|
133
|
+
*/
|
|
134
|
+
function assertSandboxEntry(entry) {
|
|
135
|
+
const target = object(entry) ? entry.path : undefined;
|
|
136
|
+
const known = object(target) && (
|
|
137
|
+
(target.type === "path" && exactObject(target, ["type", "path"]) && typeof target.path === "string")
|
|
138
|
+
|| (target.type === "glob_pattern" && exactObject(target, ["type", "pattern"]) && typeof target.pattern === "string")
|
|
139
|
+
|| (target.type === "special" && exactObject(target, ["type", "value"]) && object(target.value) && typeof target.value.kind === "string"));
|
|
140
|
+
if (!known || !ENTRY_ACCESS.has(entry.access) || Object.keys(entry).some((key) => !["path", "access", "missing_path_behavior"].includes(key))
|
|
141
|
+
|| (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");
|
|
142
|
+
if (entry.access === "write" && (target.type === "glob_pattern" || (target.type === "path" ? rootPath(target.path) : !WRITABLE_SPECIAL_PATHS.has(target.value.kind)))) {
|
|
143
|
+
throw new Error("The caller's managed sandbox grants write access beyond specific directories; no permission class was inferred");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function assertManagedSandbox(context) {
|
|
148
|
+
const { permission_profile: profile, sandbox_policy: sandbox } = context;
|
|
149
|
+
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");
|
|
150
|
+
const fileSystem = profile.file_system;
|
|
151
|
+
if (object(fileSystem) && fileSystem.type === "unrestricted") throw new Error("The caller's managed sandbox leaves the file system unrestricted; no permission class was inferred");
|
|
152
|
+
if (!object(fileSystem) || fileSystem.type !== "restricted" || !Array.isArray(fileSystem.entries)
|
|
153
|
+
|| Object.keys(fileSystem).some((key) => !["type", "entries", "glob_scan_max_depth"].includes(key))
|
|
154
|
+
|| (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");
|
|
155
|
+
for (const entry of fileSystem.entries) assertSandboxEntry(entry);
|
|
156
|
+
if (!object(sandbox) || !MANAGED_SANDBOXES.has(sandbox.type)) throw new Error("The caller's effective sandbox policy does not match its managed permission profile");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Claude's inbound parity gate asks one question: does the sender bypass
|
|
161
|
+
* permission checks? Claude puts only bypassPermissions in that class and
|
|
162
|
+
* counts dontAsk (deny without asking) and auto (a classifier approves) as
|
|
163
|
+
* prompting.
|
|
164
|
+
*
|
|
165
|
+
* For a Codex task with no sandbox, approval_policy decides: never is bypass,
|
|
166
|
+
* and a policy that routes requests for approval is prompting. A task inside
|
|
167
|
+
* a Codex-managed sandbox that restricts the file system never bypasses the
|
|
168
|
+
* checks, whatever its approval policy or reviewer: a sandbox that never asks
|
|
169
|
+
* is dontAsk, and escalations decided by the auto_review subagent are auto.
|
|
170
|
+
* Such a sender is always prompting, never bypass. Codex Desktop's "Ask for
|
|
171
|
+
* approval" and "Approve for me" run this way; only "Full access" is bypass.
|
|
172
|
+
*
|
|
173
|
+
* The two host review flags describe automated review instead -
|
|
174
|
+
* node_repl_auto_review_required is a per-model catalog attribute (Codex
|
|
175
|
+
* Desktop derives it from autoReview.requiredOnModels; the live gpt-6-astra
|
|
176
|
+
* task carried it with a user reviewer and auto review disabled) and
|
|
177
|
+
* auto_review_enabled names a Guardian reviewer - so they are required as
|
|
178
|
+
* evidence and reported, but never move a sender between classes.
|
|
108
179
|
* Downgrading them held every send from such a model behind an approval
|
|
109
180
|
* dialog that Claude Desktop does not render.
|
|
110
181
|
*/
|
|
@@ -112,11 +183,18 @@ function permissionClass(context, metadata) {
|
|
|
112
183
|
for (const field of ["auto_review_enabled", "node_repl_auto_review_required"]) {
|
|
113
184
|
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
185
|
}
|
|
186
|
+
const approvalPolicy = approvalPolicyLabel(context.approval_policy);
|
|
187
|
+
if (object(context.permission_profile) && context.permission_profile.type === "managed") {
|
|
188
|
+
if (!MANAGED_REVIEWERS.has(context.approvals_reviewer)) throw new Error("The caller's effective approval reviewer is unverified");
|
|
189
|
+
assertManagedSandbox(context);
|
|
190
|
+
if (!approvalPolicy) throw new Error("The caller's effective approval policy is unsupported");
|
|
191
|
+
return { mode: "prompting", approvalPolicy, permissionProfile: "managed" };
|
|
192
|
+
}
|
|
115
193
|
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
|
|
194
|
+
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
195
|
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 (
|
|
196
|
+
if (approvalPolicy === "never") return { mode: "bypass", approvalPolicy, permissionProfile: "disabled" };
|
|
197
|
+
if (PROMPTING_POLICIES.has(approvalPolicy)) return { mode: "prompting", approvalPolicy, permissionProfile: "disabled" };
|
|
120
198
|
throw new Error("The caller's effective approval policy is unsupported");
|
|
121
199
|
}
|
|
122
200
|
|
|
@@ -140,8 +218,8 @@ export function readCodexSenderContext(meta, { env = process.env, maxRolloutByte
|
|
|
140
218
|
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
219
|
const cwd = fs.realpathSync.native(context.cwd);
|
|
142
220
|
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.
|
|
221
|
+
const { mode, approvalPolicy, permissionProfile } = permissionClass(context, metadata);
|
|
222
|
+
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
223
|
} catch (error) {
|
|
146
224
|
return unavailable(error?.code ? `Caller evidence could not be read (${error.code}); no sender permission class was inferred` : error.message, identity);
|
|
147
225
|
}
|
|
@@ -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.0";
|
|
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. " +
|
|
@@ -702,9 +711,10 @@ registerTool(
|
|
|
702
711
|
"start_codex_thread",
|
|
703
712
|
{
|
|
704
713
|
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;
|
|
714
|
+
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
715
|
inputSchema: {
|
|
707
716
|
cwd: z.string().describe("Absolute working directory for the new Codex session"),
|
|
717
|
+
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
718
|
prompt: z.string().min(1).optional().describe(`Initial task; required with CODEX_BRIDGE_DESKTOP_TASKS=1, starts immediately. ${PROMPT_FIELD_HINT}`),
|
|
709
719
|
model: z.string().optional().describe("Model override, e.g. gpt-5.6-luna"),
|
|
710
720
|
name: z.string().min(1).max(200).optional().describe("Optional title to show for the new Codex session"),
|
|
@@ -716,12 +726,13 @@ registerTool(
|
|
|
716
726
|
openWorldHint: true,
|
|
717
727
|
},
|
|
718
728
|
},
|
|
719
|
-
async ({ cwd, model, name, prompt }) => {
|
|
729
|
+
async ({ cwd, model, name, prompt, requestId }) => {
|
|
720
730
|
try {
|
|
721
731
|
if (desktopTasksEnabled) {
|
|
722
732
|
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 });
|
|
733
|
+
return await delegateDesktopTask({ cwd, model, name, prompt, requestId, waitForReply: false });
|
|
724
734
|
}
|
|
735
|
+
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
736
|
if (prompt) throw new Error("Use delegate_to_codex to send an initial prompt in app-server mode.");
|
|
726
737
|
const created = await createCodexThread({ cwd, model, name });
|
|
727
738
|
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.0";
|
|
38
38
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
39
39
|
|
|
40
40
|
function errorResponse(code, message, sent) {
|
package/src/native-relay.mjs
CHANGED
|
@@ -168,6 +168,7 @@ export function resolveRelayThreadId(env = process.env) {
|
|
|
168
168
|
export function nativeDispatchParams({ executorThreadId, targetThreadId, message }) {
|
|
169
169
|
return {
|
|
170
170
|
arguments: { threadId: targetThreadId, prompt: message },
|
|
171
|
+
callerSource: "codex",
|
|
171
172
|
callId: `codex-native-relay-${randomUUID()}`,
|
|
172
173
|
namespace: "codex_app",
|
|
173
174
|
threadId: executorThreadId,
|
|
@@ -246,6 +247,7 @@ export function nativeDesktopOperationParams({ executorThreadId, operation, argu
|
|
|
246
247
|
validateDesktopOperation(operation, args);
|
|
247
248
|
return {
|
|
248
249
|
arguments: args,
|
|
250
|
+
callerSource: "codex",
|
|
249
251
|
callId: `codex-native-relay-${randomUUID()}`,
|
|
250
252
|
namespace: "codex_app",
|
|
251
253
|
threadId: executorThreadId,
|
|
@@ -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
|
@@ -197,12 +197,12 @@ export class DesktopTaskDelivery {
|
|
|
197
197
|
return { projectId: project.projectId, projectName: project.label, projectAssignmentStatus: "verified" };
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
async create({ cwd, prompt, name, dedupeName = name, model, effort, deadline = this.now() + DESKTOP_TOOL_BUDGET_MS }) {
|
|
200
|
+
async create({ cwd, prompt, name, dedupeName = name, requestId, model, effort, deadline = this.now() + DESKTOP_TOOL_BUDGET_MS }) {
|
|
201
201
|
this.security.assertCwd(cwd);
|
|
202
202
|
cwd = realpathSync.native(cwd);
|
|
203
203
|
this.security.assertCwd(cwd);
|
|
204
204
|
dedupeName = dedupeName?.normalize("NFC").trim().replace(/\s+/g, " ") || undefined;
|
|
205
|
-
const identity = this.receipts.key({ cwd, prompt, name: dedupeName });
|
|
205
|
+
const identity = this.receipts.key({ cwd, prompt, name: dedupeName, requestId });
|
|
206
206
|
const accountContext = this.accountContext?.();
|
|
207
207
|
if (this.accountContext && !accountContext) throw new Error("Desktop creation requires the original verified account context.");
|
|
208
208
|
const options = { cwd, promptHash: identity.promptHash, deadline, accountContext };
|