@minhspark/codex-mcp-bridge 1.13.6 → 1.13.7
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 +19 -0
- package/README.md +4 -4
- package/package.json +1 -1
- package/scripts/install-native-relay.mjs +1 -1
- package/src/claude-bridge.mjs +21 -17
- package/src/claude-desktop-context.mjs +41 -2
- package/src/claude-inbound-policy.mjs +50 -0
- package/src/codex-sender-context.mjs +5 -3
- package/src/index.mjs +1 -1
- package/src/native-relay-companion.mjs +1 -1
- package/src/peer-protocol.mjs +25 -5
- package/src/recipient-preflight.mjs +26 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.13.7] - 2026-09-07
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Correlate a Desktop reply to a message that arrived while the recipient was mid-turn. Claude Code absorbs such a message into the running turn and records it as a `queued_command` attachment whose `source_uuid` is the message id, under a fresh uuid; only the idle shape (a user entry whose uuid is the message id) was recognised, so the reply appeared in the app while the bridge reported `reply_timeout` and kept the message pending. Both shapes now resolve to the turn's closing text. A reply taken from an absorbed turn is marked `replyAbsorbed: true` in receipts, the tool result says so, and the text forwarded to Codex carries the same note, because that closing text may not address the message.
|
|
12
|
+
- Detect a Claude Desktop task record rewritten during inspection by comparing its bytes, not only its stat fields. A same-size rewrite inside one filesystem timestamp tick (about 16 ms on Windows) left size, times and inode unchanged, so the replace-detection test failed intermittently on Windows CI and the guard could miss such a replacement.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Read the recipient session's effective `crossSessionInbound` (managed, user, project, and local settings with Claude's tightening precedence) and refuse a send at preflight with `CLAUDE_RECIPIENT_INBOUND_POLICY` when it is `refuse` or `hold`; an explicit `accept` sends without a class check. Otherwise read the recipient task's permission mode from Claude Desktop metadata, report it with its Claude parity class in `list_claude_sessions`, and refuse with `CLAUDE_RECIPIENT_CLASS_MISMATCH` when that class differs from the verified sender class, naming both classes and the user-only remedies on either side, since Claude Desktop cannot show the approval dialog such a hold would need. Both checks repeat before writing; an unknown mode leaves the decision to Claude. Only a plain alphabetic mode value is exposed. Receipts carry `senderApprovalPolicy`, `recipientPermissionMode`, `recipientPermissionClass`, and `recipientInboundPolicy`, and a held receipt names both classes.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Document Claude's symmetric inbound parity: a prompting-class Desktop recipient holds a `bypass` sender just as a bypassing recipient holds a `prompting` one, so a `held` receipt means the two tasks run in different permission classes and only the user can align them, by running the recipient in the matching class or setting `crossSessionInbound` to `accept` there. The bridge never adjusts the attested class. Describe `node_repl_auto_review_required` as the per-model catalog attribute observed live alongside a user reviewer, note that `auto_review_enabled: true` with a user reviewer is unobserved, and pin the held-receipt wording in the tool contract test.
|
|
21
|
+
|
|
22
|
+
### Upgrade notes
|
|
23
|
+
|
|
24
|
+
- Reconnect the MCP connections in both Desktop clients after upgrading; a bridge process started before this version reports stale source and refuses new sends once the files change.
|
|
25
|
+
|
|
7
26
|
## [1.13.6] - 2026-09-06
|
|
8
27
|
|
|
9
28
|
### Fixed
|
package/README.md
CHANGED
|
@@ -392,7 +392,7 @@ Desktop task mode (`CODEX_BRIDGE_DESKTOP_TASKS=1` or the shared setting written
|
|
|
392
392
|
|
|
393
393
|
`claude_bridge_status` reports the destination policy, eligible session count, and excluded non-Desktop count. Send receipts include the destination's `entrypoint`, `cwd`, and `sessionId`. A correlated reply confirms receipt in that session; it does not independently prove that its conversation is visible in the Desktop UI. Desktop sends require `expectedCwd`, an absolute independently verified project directory. The bridge resolves both paths and refuses a missing, nonexistent, parent, or different directory before sending; it rechecks the destination after queueing. Reconnect the bridge's MCP connection after upgrading to load the enforcement code.
|
|
394
394
|
|
|
395
|
-
Desktop session discovery includes the native task title and task ID, matched through Claude Desktop's local session metadata. Use `list_claude_sessions({expectedCwd})` for the intended project; an empty result is not permission to substitute a different project. Sending additionally requires `expectedTaskId` from the intended task, alongside `target` and `expectedCwd`. The bridge verifies the exact CLI session ID, unique native task ID, canonical cwd, archive state, optional bridge ID, and live process start identity. It repeats these checks at dispatch. Missing, stale, ambiguous, or unreadable metadata blocks the send rather than guessing from a generated name such as `pcc4sh-19`.
|
|
395
|
+
Desktop session discovery includes the native task title and task ID, matched through Claude Desktop's local session metadata. Use `list_claude_sessions({expectedCwd})` for the intended project; an empty result is not permission to substitute a different project. Sending additionally requires `expectedTaskId` from the intended task, alongside `target` and `expectedCwd`. The bridge verifies the exact CLI session ID, unique native task ID, canonical cwd, archive state, optional bridge ID, and live process start identity. It repeats these checks at dispatch. Missing, stale, ambiguous, or unreadable metadata blocks the send rather than guessing from a generated name such as `pcc4sh-19`. Each matched Desktop row also reports the task's permission mode from that metadata and its Claude parity class (`bypass` for `bypassPermissions`; `prompting` for `default`, `acceptEdits`, `auto`, and `dontAsk`; no class for `plan` or an unknown value).
|
|
396
396
|
|
|
397
397
|
A `held` receipt confirms only that the recipient has withheld a message. It does not prove an approval button is available in Claude Desktop. Inspect that exact task before directing the user to approve; when the UI offers no approval route, preserve the receipt and report the boundary. A preflight failure is distinct: it returns `sent: false` and no message ID, so nothing was submitted to the recipient.
|
|
398
398
|
|
|
@@ -400,13 +400,13 @@ Sends to the same Claude session run in order. An outstanding message blocks all
|
|
|
400
400
|
|
|
401
401
|
Both bridge status tools report the loaded source fingerprint and process identity. Source or Desktop-routing configuration changes make the running process stale and block new sends until reconnect, while Claude inbox/receipt reads remain available. This guard applies only after the version containing it has loaded; upgrading files cannot retrofit the guard into an older process already running.
|
|
402
402
|
|
|
403
|
-
Claude's [inbound permission controls](https://code.claude.com/docs/en/cross-session-messaging#control-inbound-messages) remain authoritative. A
|
|
403
|
+
Claude's [inbound permission controls](https://code.claude.com/docs/en/cross-session-messaging#control-inbound-messages) remain authoritative, and their default rule is symmetric: when no `crossSessionInbound` value applies, a message is delivered only when the sender's attested class matches the recipient's. A recipient that bypasses permission prompts holds every sender that does not attest `bypass`; a recipient that prompts (default, `acceptEdits`, `auto`, `dontAsk`) holds every sender that attests `bypass`. The bridge attests the sender's true class from the verified Codex turn and never adjusts it to suit a recipient. Claude Desktop does not declare the `peer_inbound_approval` dialog kind to Claude Code, so a message held by a Desktop-hosted session has no approval button there; Claude Code keeps it until `dialogExpiry` (five minutes by default) and then reports it as expired. A `held` receipt in Desktop mode therefore means the two tasks run in different permission classes, not that the sender was misread. Report that to the user: only they can run the recipient task in the matching class or set `crossSessionInbound` to `accept` in that session's own settings. Because that outcome is predictable, `send_to_claude_session` first reads the recipient session's effective `crossSessionInbound` from the same files Claude Desktop loads (managed settings, then the user's `~/.claude/settings.json`, with the recipient project's `.claude/settings.json` and `settings.local.json` able only to tighten it) and refuses at preflight with `CLAUDE_RECIPIENT_INBOUND_POLICY` when that value is `refuse` or `hold`, since nothing could reach Claude or be approved. An explicit `accept` sends without a class check, because Claude delivers regardless of class. Otherwise the parity default applies: the recipient task's permission mode from Claude Desktop metadata is compared with the verified sender class and a mismatch is refused with `CLAUDE_RECIPIENT_CLASS_MISMATCH`, naming both classes and the user-only remedies on either side; both checks repeat immediately before writing. A task whose mode is unknown is sent as before and Claude decides. Flags passed at launch such as `--settings` are invisible to the bridge, so put the value in a settings file. `list_claude_sessions` shows an explicit inbound value with its source, and receipts carry `senderApprovalPolicy`, `recipientPermissionMode`, `recipientPermissionClass`, and `recipientInboundPolicy`; a held receipt repeats both classes. Do not change `CLAUDE_BRIDGE_PERMISSION_MODE`, fabricate a sender class, or edit recipient settings on the user's behalf. Legacy CLI configuration must truthfully represent every session using that MCP entry; Desktop sends derive their class from the verified calling turn. If the Desktop UI does not expose the pending approval, keep the original receipt rather than creating a CLI session.
|
|
404
404
|
|
|
405
|
-
Claude Desktop disables the CLI-native `SendMessage` tool. For Desktop targets, the bridge requests an ordinary answer in the destination conversation and reads only a completed assistant turn descended from the injected message
|
|
405
|
+
Claude Desktop disables the CLI-native `SendMessage` tool. For Desktop targets, the bridge requests an ordinary answer in the destination conversation and reads only a completed assistant turn descended from the transcript entry that records the injected message. Unrelated human prompts and sidechains are excluded. Late answers remain available through the inbox while the bridge process is running. This does not change Desktop tool permissions or the receiver's inbound policy. Claude Code records the injected message in one of two shapes: an idle recipient starts a new turn with a user entry whose uuid is the message id, while a busy recipient absorbs the message into its running turn as a `queued_command` attachment whose `source_uuid` is the message id, and that turn's closing text is the reply. Both shapes are recognised; before 1.13.7 only the idle one was, so a reply to an absorbed message was visible in the app yet reported as `reply_timeout` with the message still pending. A reply taken from an absorbed turn is that turn's closing text, which may not address the message, so the receipt marks it `replyAbsorbed: true`, the tool result says so, and the text forwarded to Codex carries the same note.
|
|
406
406
|
|
|
407
407
|
In Desktop-only mode, the sender is resolved separately for every MCP call. Codex supplies `x-codex-turn-metadata` with the calling task and turn IDs; the bridge matches them to one active, local Codex Desktop rollout and reads its effective permission profile and approval settings. The diagnostic `sandbox_mode` label is never used to authorize a send. Missing or invalid host review flags, a completed or superseded turn, or an unsupported permission profile blocks sending before any message bytes are written. Status distinguishes enabled, disabled, missing, and invalid review flags. Do not supply fabricated MCP metadata or use another process to make a blocked send succeed.
|
|
408
408
|
|
|
409
|
-
Only an explicitly disabled permission profile with a matching full-access sandbox and user approval reviewer is supported. With `approval_policy: never` it maps to `bypass`; with `on-request`, `on-failure`, or `untrusted` it maps to `prompting`. The class answers the question Claude's parity gate asks, whether a human still prompts the sender, so Codex's two host review flags never change it: `node_repl_auto_review_required` is a model catalog
|
|
409
|
+
Only an explicitly disabled permission profile with a matching full-access sandbox and user approval reviewer is supported. With `approval_policy: never` it maps to `bypass`; with `on-request`, `on-failure`, or `untrusted` it maps to `prompting`. The class answers the question Claude's parity gate asks, whether a human still prompts the sender, so Codex's two host review flags never change it: `node_repl_auto_review_required` is a per-model catalog attribute that Codex Desktop derives from `autoReview.requiredOnModels`, describing automated review of Node REPL code, and `auto_review_enabled` names an automated Guardian reviewer; neither adds a human prompt. The live `gpt-6-astra` task carried `node_repl_auto_review_required: true` together with `approvals_reviewer: user` and `auto_review_enabled: false`, and its messages are delivered to a bypassing Claude Desktop task. `auto_review_enabled: true` alongside a user reviewer has not been observed; the class still follows `approval_policy` there. Both flags must still be present as booleans and are reported in status and preserved in receipts as evidence. Other profiles remain unsupported and fail closed. The receiver may still require approval for a prompting sender. The context, approval policy, and review flags are checked again before each connection attempt and immediately before writing, so queued messages cannot inherit another turn's permissions.
|
|
410
410
|
|
|
411
411
|
`CLAUDE_BRIDGE_PERMISSION_MODE` remains a legacy CLI setting and is ignored for Desktop sends. A manually bound relay task is not proof of the sender's identity. Correlated Desktop replies retain the original sending task ID even if the relay binding later changes.
|
|
412
412
|
|
package/package.json
CHANGED
|
@@ -20,7 +20,7 @@ import { exitForVersionRequest } from "../src/cli-version.mjs";
|
|
|
20
20
|
|
|
21
21
|
exitForVersionRequest(import.meta.url);
|
|
22
22
|
|
|
23
|
-
const VERSION = "1.13.
|
|
23
|
+
const VERSION = "1.13.7";
|
|
24
24
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
25
25
|
const entry = path.join(root, "src", "native-relay-companion.mjs");
|
|
26
26
|
const serverName = process.env.CODEX_NATIVE_RELAY_NAME ?? "codex-native-relay";
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -11,12 +11,14 @@ import { exitForVersionRequest } from "./cli-version.mjs";
|
|
|
11
11
|
import { desktopTasksConfigured } from "./native-relay.mjs";
|
|
12
12
|
import { createRuntimeState } from "./runtime-state.mjs";
|
|
13
13
|
import { readClaudeDesktopContext } from "./claude-desktop-context.mjs";
|
|
14
|
+
import { readClaudeInboundPolicy } from "./claude-inbound-policy.mjs";
|
|
15
|
+
import { assertRecipientClass, preflightFailure } from "./recipient-preflight.mjs";
|
|
14
16
|
import { readCodexSenderContext } from "./codex-sender-context.mjs";
|
|
15
17
|
import { ReplyForwarder } from "./reply-forwarder.mjs";
|
|
16
18
|
|
|
17
19
|
exitForVersionRequest(import.meta.url);
|
|
18
20
|
|
|
19
|
-
const VERSION = "1.13.
|
|
21
|
+
const VERSION = "1.13.7";
|
|
20
22
|
const FORWARD_MIN_INTERVAL_MS = 5000;
|
|
21
23
|
const FORWARD_MAX_PER_SESSION = 50;
|
|
22
24
|
|
|
@@ -57,7 +59,7 @@ const replyForwarder = new ReplyForwarder({
|
|
|
57
59
|
throw Object.assign(new Error("Bridge routing changed; the reply was not forwarded. Inspect the original receipt before reconnecting."), { code: "REPLY_ROUTING_CHANGED" });
|
|
58
60
|
}
|
|
59
61
|
},
|
|
60
|
-
deliver: (threadId, record) => delivery.deliver(threadId, `[message from Claude session ${record.fromSocket ?? "?"}]\n\n${record.text}`),
|
|
62
|
+
deliver: (threadId, record) => delivery.deliver(threadId, `[message from Claude session ${record.fromSocket ?? "?"}]\n${record.absorbed ? "[This reply is the closing text of a turn that absorbed the message while it was running; it may not address the message.]\n" : ""}\n${record.text}`),
|
|
61
63
|
});
|
|
62
64
|
|
|
63
65
|
function readReceipt(msgId) {
|
|
@@ -80,19 +82,14 @@ const missingDesktopSession = "No live Claude Desktop session with an exact matc
|
|
|
80
82
|
|
|
81
83
|
function formatSessionRow(s) {
|
|
82
84
|
const started = s.startedAt ? new Date(s.startedAt).toISOString().replace("T", " ").slice(0, 16) : "?";
|
|
83
|
-
const
|
|
85
|
+
const permission = s.desktop?.status === "matched" ? `\n permission: ${s.desktop.permissionMode ?? "unknown"}${s.desktop.permissionClass ? ` (${s.desktop.permissionClass} class)` : ""}${s.inbound?.value ? `\n inbound: crossSessionInbound ${s.inbound.value} (${s.inbound.source} settings)` : ""}` : "";
|
|
86
|
+
const task = s.desktop ? `\n Desktop task: ${s.desktop.title ?? "unverified"}\n task ID: ${s.desktop.taskId ?? "unverified"}${permission}\n mapping: ${s.desktop.status}${s.desktop.reason ? ` - ${s.desktop.reason}` : ""}` : "";
|
|
84
87
|
return `- ${s.name ?? "(unnamed)"} [pid ${s.pid}]\n session: ${s.sessionId ?? "?"}\n cwd: ${s.cwd ?? "?"}\n started: ${started} kind: ${s.kind ?? "?"} via: ${s.entrypoint ?? "?"}${task}`;
|
|
85
88
|
}
|
|
86
89
|
|
|
87
|
-
function preflightFailure(code, reason) {
|
|
88
|
-
const error = new Error(`${reason} No message was sent.`);
|
|
89
|
-
error.preflight = { status: "blocked", code, reason, sent: false };
|
|
90
|
-
return error;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
90
|
function withDesktopContext(session) {
|
|
94
91
|
return session.entrypoint === "claude-desktop"
|
|
95
|
-
? { ...session, desktop: readClaudeDesktopContext(session) } : session;
|
|
92
|
+
? { ...session, desktop: readClaudeDesktopContext(session), inbound: readClaudeInboundPolicy(session.cwd) } : session;
|
|
96
93
|
}
|
|
97
94
|
|
|
98
95
|
function assertDesktopTask(session, expectedTaskId) {
|
|
@@ -139,6 +136,7 @@ const server = new McpServer(
|
|
|
139
136
|
"Read the Desktop task title and task ID as well as the exact project directory and sessionId before sending. " +
|
|
140
137
|
"The host's current MCP turn metadata identifies the sender; unknown or stale permission context blocks sending. " +
|
|
141
138
|
"The sender class follows the verified approval policy; automated review flags are reported but never change it. " +
|
|
139
|
+
"A Desktop recipient whose settings refuse or hold inbound messages, or whose task metadata shows a different permission class without an explicit accept, is refused before sending, because Claude Desktop cannot show the approval dialog. " +
|
|
142
140
|
"Never launch a CLI session or an external app-server as a substitute. A receipt confirms a reply, not visual verification in the app. " +
|
|
143
141
|
"A held receipt does not prove that Desktop exposes an approval button; verify the UI before asking the user to approve.",
|
|
144
142
|
},
|
|
@@ -222,6 +220,7 @@ server.registerTool(
|
|
|
222
220
|
assertClaudeSessionProcess(session);
|
|
223
221
|
}
|
|
224
222
|
const sender = desktopOnly ? assertSender(extra?._meta) : null;
|
|
223
|
+
if (desktopOnly) assertRecipientClass(session, sender);
|
|
225
224
|
await peer.start();
|
|
226
225
|
|
|
227
226
|
const wait = waitSec ?? 180;
|
|
@@ -229,7 +228,8 @@ server.registerTool(
|
|
|
229
228
|
const text = desktop ? `${message}\n\n[Bridge response routing: reply with ordinary text in this conversation. The bridge reads the response associated with this message from the local transcript; no cross-session reply tool is needed.]` : message;
|
|
230
229
|
const { msgId, reply, delivery } = await peer.sendAndWait(session.socket, text, {
|
|
231
230
|
timeoutMs: wait * 1000,
|
|
232
|
-
...(sender ? { permissionMode: sender.mode, replyThreadId: sender.threadId, senderReview: sender.review } : {}),
|
|
231
|
+
...(sender ? { permissionMode: sender.mode, replyThreadId: sender.threadId, senderReview: sender.review, senderApprovalPolicy: sender.approvalPolicy } : {}),
|
|
232
|
+
...(desktop ? { recipient: { permissionMode: session.desktop.permissionMode ?? null, permissionClass: session.desktop.permissionClass ?? null, inboundPolicy: session.inbound?.value ?? null } } : {}),
|
|
233
233
|
beforeSend: () => {
|
|
234
234
|
runtime.assertCurrent();
|
|
235
235
|
const current = findClaudeSession(session.sessionId ?? String(session.pid), { desktopOnly });
|
|
@@ -239,25 +239,28 @@ server.registerTool(
|
|
|
239
239
|
if (desktopOnly || expectedCwd !== undefined) assertClaudeSessionCwd(current, expectedCwd);
|
|
240
240
|
if (desktopOnly) {
|
|
241
241
|
assertClaudeSessionProcess(current);
|
|
242
|
-
|
|
242
|
+
const refreshed = withDesktopContext(current);
|
|
243
|
+
assertDesktopTask(refreshed, expectedTaskId);
|
|
243
244
|
const active = assertSender(extra?._meta);
|
|
244
245
|
if (active.threadId !== sender.threadId || active.turnId !== sender.turnId || active.cwd !== sender.cwd || active.mode !== sender.mode || active.approvalPolicy !== sender.approvalPolicy || JSON.stringify(active.review) !== JSON.stringify(sender.review)) {
|
|
245
246
|
throw preflightFailure("CODEX_SENDER_CONTEXT_CHANGED", "The sender's active turn or permissions changed while this message was queued.");
|
|
246
247
|
}
|
|
248
|
+
assertRecipientClass(refreshed, active);
|
|
247
249
|
}
|
|
248
250
|
},
|
|
249
251
|
...(desktop ? { transcriptSession: session } : {}),
|
|
250
252
|
});
|
|
251
253
|
const status = reply ? "reply_received" : delivery?.status ?? (wait === 0 ? "sent_unconfirmed" : "reply_timeout");
|
|
252
254
|
const receipt = { status, msgId, target: session.name ?? String(session.pid), sessionId: session.sessionId, cwd: session.cwd, entrypoint: session.entrypoint, waitSec: wait,
|
|
253
|
-
...(desktop ? { taskId: session.desktop.taskId, title: session.desktop.title, approvalUi: "unverified" } : {}),
|
|
254
|
-
...(sender ? { senderMode: sender.mode, senderThreadId: sender.threadId, senderTurnId: sender.turnId, senderReview: sender.review } : {}),
|
|
255
|
-
...(reply ? { source: reply.source ?? "peer", forwarding: replyForwarder.read(msgId) ?? reply.forwardingError ?? null } : {}) };
|
|
255
|
+
...(desktop ? { taskId: session.desktop.taskId, title: session.desktop.title, approvalUi: "unverified", recipientPermissionMode: session.desktop.permissionMode ?? null, recipientPermissionClass: session.desktop.permissionClass ?? null, recipientInboundPolicy: session.inbound?.value ?? null } : {}),
|
|
256
|
+
...(sender ? { senderMode: sender.mode, senderApprovalPolicy: sender.approvalPolicy, senderThreadId: sender.threadId, senderTurnId: sender.turnId, senderReview: sender.review } : {}),
|
|
257
|
+
...(reply ? { source: reply.source ?? "peer", ...(reply.absorbed ? { replyAbsorbed: true } : {}), forwarding: replyForwarder.read(msgId) ?? reply.forwardingError ?? null } : {}) };
|
|
256
258
|
const result = (text, isError = false) => ({ ...textResult(text, isError), structuredContent: { receipt } });
|
|
257
259
|
const targetLabel = `${session.desktop?.title ?? session.name ?? session.pid} (pid ${session.pid}, session ${session.sessionId ?? "?"}, via ${session.entrypoint ?? "unknown"}, cwd ${session.cwd ?? "?"})`;
|
|
258
260
|
|
|
259
261
|
if (!reply && delivery && delivery.status !== "delivered") {
|
|
260
|
-
|
|
262
|
+
const classes = desktop && sender ? `Recipient task mode: ${session.desktop.permissionMode ?? "unknown"}${session.desktop.permissionClass ? ` (${session.desktop.permissionClass} class)` : ""}; this sender attested ${sender.mode}.\n` : "";
|
|
263
|
+
return result(`Claude inbox reported ${delivery.status} for ${targetLabel}.\n${delivery.reason}\n${classes}Message id: ${msgId}\nInspect read_claude_delivery with this message ID. Do not resend, change the sender permission class, or alter recipient permissions to bypass this receipt. The approval UI has not been verified; Claude Desktop declares no peer approval dialog, so do not tell the user an approval button exists without inspecting this exact Desktop task.`, true);
|
|
261
264
|
}
|
|
262
265
|
|
|
263
266
|
if (wait === 0) {
|
|
@@ -271,7 +274,8 @@ server.registerTool(
|
|
|
271
274
|
true,
|
|
272
275
|
);
|
|
273
276
|
}
|
|
274
|
-
|
|
277
|
+
const absorbedNote = reply.absorbed ? "\nThe recipient was mid-turn when this message arrived; the reply is the closing text of a turn that absorbed the message and may not address it." : "";
|
|
278
|
+
return result(`Reply received from ${targetLabel}.\nMessage id: ${msgId}${absorbedNote}\n\n--- Claude reply ---\n${reply.text}`);
|
|
275
279
|
} catch (err) {
|
|
276
280
|
return failure(err);
|
|
277
281
|
}
|
|
@@ -12,8 +12,22 @@ const VERSION_FIELDS = ["size", "mtimeMs", "ctimeMs", "ino", "dev"];
|
|
|
12
12
|
|
|
13
13
|
const sameVersion = (left, right) => VERSION_FIELDS.every((field) => left[field] === right[field]);
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Claude's inbound parity gate groups bypassPermissions as one class and
|
|
17
|
+
* default, acceptEdits, auto and dontAsk as the prompting class. Plan counts
|
|
18
|
+
* as bypass only when bypass is available to that session, which the record
|
|
19
|
+
* does not say, so it and any unknown value yield no class rather than a guess.
|
|
20
|
+
*/
|
|
21
|
+
const PERMISSION_CLASSES = { bypassPermissions: "bypass", default: "prompting", acceptEdits: "prompting", auto: "prompting", dontAsk: "prompting" };
|
|
22
|
+
|
|
23
|
+
const PERMISSION_MODE = /^[A-Za-z]{1,64}$/;
|
|
24
|
+
|
|
25
|
+
const permissionModeOf = (mode) => typeof mode === "string" && PERMISSION_MODE.test(mode) ? mode : null;
|
|
26
|
+
|
|
27
|
+
const permissionClassOf = (mode) => typeof mode === "string" && Object.hasOwn(PERMISSION_CLASSES, mode) ? PERMISSION_CLASSES[mode] : null;
|
|
28
|
+
|
|
15
29
|
function result(status, reason, task = {}) {
|
|
16
|
-
return { status, taskId: task.taskId ?? null, title: task.title ?? null, cwd: task.cwd ?? null, reason };
|
|
30
|
+
return { status, taskId: task.taskId ?? null, title: task.title ?? null, cwd: task.cwd ?? null, permissionMode: task.permissionMode ?? null, permissionClass: task.permissionClass ?? null, reason };
|
|
17
31
|
}
|
|
18
32
|
|
|
19
33
|
function sessionsRoot(platform, env) {
|
|
@@ -62,6 +76,7 @@ function readMetadata(file, budget) {
|
|
|
62
76
|
const current = fs.lstatSync(file);
|
|
63
77
|
if (!sameVersion(before, after) || current.isSymbolicLink() || !sameVersion(current, after)) throw new Error("metadata changed");
|
|
64
78
|
budget.versions.set(file, current);
|
|
79
|
+
budget.contents.set(file, bytes);
|
|
65
80
|
const data = JSON.parse(bytes.toString("utf8"));
|
|
66
81
|
if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("invalid metadata");
|
|
67
82
|
return {
|
|
@@ -72,12 +87,33 @@ function readMetadata(file, budget) {
|
|
|
72
87
|
cwd: data.cwd,
|
|
73
88
|
title: data.title,
|
|
74
89
|
isArchived: data.isArchived,
|
|
90
|
+
permissionMode: data.permissionMode,
|
|
75
91
|
};
|
|
76
92
|
} finally {
|
|
77
93
|
fs.closeSync(descriptor);
|
|
78
94
|
}
|
|
79
95
|
}
|
|
80
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Stat fields cannot prove a record is unchanged: a same-size rewrite inside
|
|
99
|
+
* one filesystem timestamp tick (about 16 ms on Windows) keeps size, times
|
|
100
|
+
* and inode identical. The final consistency pass therefore compares the
|
|
101
|
+
* bytes themselves; the records are small JSON files, so the extra read is
|
|
102
|
+
* cheap and the check is deterministic on every platform.
|
|
103
|
+
*/
|
|
104
|
+
function rereadMetadata(file) {
|
|
105
|
+
const descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
106
|
+
try {
|
|
107
|
+
const stat = fs.fstatSync(descriptor);
|
|
108
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) throw new Error("file limit");
|
|
109
|
+
const bytes = Buffer.alloc(stat.size);
|
|
110
|
+
if (fs.readSync(descriptor, bytes, 0, bytes.length, 0) !== bytes.length) throw new Error("short read");
|
|
111
|
+
return bytes;
|
|
112
|
+
} finally {
|
|
113
|
+
fs.closeSync(descriptor);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
81
117
|
function canonicalDirectory(directory) {
|
|
82
118
|
if (typeof directory !== "string" || !path.isAbsolute(directory)) throw new Error("invalid cwd");
|
|
83
119
|
const canonical = fs.realpathSync.native(directory);
|
|
@@ -98,7 +134,7 @@ export function readClaudeDesktopContext(session, { platform = process.platform,
|
|
|
98
134
|
try {
|
|
99
135
|
if (!fs.existsSync(directory)) return result("missing", "Claude Desktop task metadata is not available on this host.");
|
|
100
136
|
const files = metadataFiles(directory);
|
|
101
|
-
const budget = { bytes: 0, versions: new Map() };
|
|
137
|
+
const budget = { bytes: 0, versions: new Map(), contents: new Map() };
|
|
102
138
|
records = files.map((file) => readMetadata(file, budget));
|
|
103
139
|
const currentFiles = metadataFiles(directory);
|
|
104
140
|
if (files.length !== currentFiles.length || files.some((file, index) => file !== currentFiles[index])) {
|
|
@@ -107,6 +143,7 @@ export function readClaudeDesktopContext(session, { platform = process.platform,
|
|
|
107
143
|
for (const file of files) {
|
|
108
144
|
const current = fs.lstatSync(file);
|
|
109
145
|
if (!current.isFile() || !sameVersion(current, budget.versions.get(file))) throw new Error("metadata changed");
|
|
146
|
+
if (!rereadMetadata(file).equals(budget.contents.get(file))) throw new Error("metadata changed");
|
|
110
147
|
}
|
|
111
148
|
} catch {
|
|
112
149
|
return result("mismatch", "Claude Desktop task metadata could not be read completely and consistently; no task identity is confirmed.");
|
|
@@ -156,5 +193,7 @@ export function readClaudeDesktopContext(session, { platform = process.platform,
|
|
|
156
193
|
taskId: record.taskId,
|
|
157
194
|
title: record.title,
|
|
158
195
|
cwd: taskCwd,
|
|
196
|
+
permissionMode: permissionModeOf(record.permissionMode),
|
|
197
|
+
permissionClass: permissionClassOf(permissionModeOf(record.permissionMode)),
|
|
159
198
|
});
|
|
160
199
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { homeDir } from "./platform.mjs";
|
|
5
|
+
|
|
6
|
+
const VALUES = new Set(["accept", "hold", "refuse"]);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Claude Code consults crossSessionInbound before comparing permission
|
|
10
|
+
* classes: an explicit value always wins, managed settings override every
|
|
11
|
+
* other source, and a project or local file may only tighten (refuse over
|
|
12
|
+
* hold over the user's accept). An unrecognised value holds messages while it
|
|
13
|
+
* is present. Flags such as --settings cannot be seen from outside the
|
|
14
|
+
* process; Claude Desktop launches its sessions with the user, project and
|
|
15
|
+
* local sources, which is what this reads.
|
|
16
|
+
*/
|
|
17
|
+
function managedSettingsFile(platform, env) {
|
|
18
|
+
if (platform === "darwin") return "/Library/Application Support/ClaudeCode/managed-settings.json";
|
|
19
|
+
if (platform === "win32") return path.join(env.ProgramData || "C:\\ProgramData", "ClaudeCode", "managed-settings.json");
|
|
20
|
+
return "/etc/claude-code/managed-settings.json";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readValue(file) {
|
|
24
|
+
let text;
|
|
25
|
+
try { text = fs.readFileSync(file, "utf8"); }
|
|
26
|
+
catch { return undefined; }
|
|
27
|
+
try {
|
|
28
|
+
const data = JSON.parse(text);
|
|
29
|
+
const value = data && typeof data === "object" && !Array.isArray(data) ? data.crossSessionInbound : undefined;
|
|
30
|
+
if (value === undefined) return undefined;
|
|
31
|
+
return VALUES.has(value) ? value : "invalid";
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readClaudeInboundPolicy(cwd, { platform = process.platform, env = process.env, home = homeDir(), managedFile = managedSettingsFile(platform, env) } = {}) {
|
|
38
|
+
const managed = readValue(managedFile);
|
|
39
|
+
if (managed !== undefined) return { value: managed === "invalid" ? "refuse" : managed, source: "managed" };
|
|
40
|
+
const sources = [
|
|
41
|
+
["user", readValue(path.join(home, ".claude", "settings.json"))],
|
|
42
|
+
...(typeof cwd === "string" && path.isAbsolute(cwd)
|
|
43
|
+
? [["project", readValue(path.join(cwd, ".claude", "settings.json"))], ["local", readValue(path.join(cwd, ".claude", "settings.local.json"))]]
|
|
44
|
+
: []),
|
|
45
|
+
];
|
|
46
|
+
for (const [source, value] of sources) if (value === "refuse") return { value: "refuse", source };
|
|
47
|
+
for (const [source, value] of sources) if (value === "hold" || value === "invalid") return { value: "hold", source };
|
|
48
|
+
if (sources[0][1] === "accept") return { value: "accept", source: "user" };
|
|
49
|
+
return { value: null, source: null };
|
|
50
|
+
}
|
|
@@ -100,9 +100,11 @@ function readState(file, maxBytes) {
|
|
|
100
100
|
* Claude's inbound parity gate groups sessions by one question: does a human
|
|
101
101
|
* still prompt the sender? Codex answers it with approval_policy. Its two host
|
|
102
102
|
* review flags describe automated review instead - node_repl_auto_review_required
|
|
103
|
-
* is a model catalog
|
|
104
|
-
*
|
|
105
|
-
*
|
|
103
|
+
* is a per-model catalog attribute (Codex Desktop derives it from
|
|
104
|
+
* autoReview.requiredOnModels; the live gpt-6-astra task carried it with a
|
|
105
|
+
* user reviewer and auto review disabled) and auto_review_enabled names a
|
|
106
|
+
* Guardian reviewer - so they are required as evidence and reported, but
|
|
107
|
+
* never move a sender between classes.
|
|
106
108
|
* Downgrading them held every send from such a model behind an approval
|
|
107
109
|
* dialog that Claude Desktop does not render.
|
|
108
110
|
*/
|
package/src/index.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import { exitForVersionRequest } from "./cli-version.mjs";
|
|
|
23
23
|
|
|
24
24
|
exitForVersionRequest(import.meta.url);
|
|
25
25
|
|
|
26
|
-
const VERSION = "1.13.
|
|
26
|
+
const VERSION = "1.13.7";
|
|
27
27
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
28
28
|
|
|
29
29
|
function errorResponse(code, message) {
|
package/src/peer-protocol.mjs
CHANGED
|
@@ -284,17 +284,35 @@ export function readTranscript(sessionId, cwd, limit = 10) {
|
|
|
284
284
|
return { file, messages: messages.slice(-limit) };
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Claude Code records an injected peer message in one of two shapes. An idle
|
|
289
|
+
* recipient starts a new turn with a user entry whose uuid is the message id
|
|
290
|
+
* (origin.msg_id names it as well). A busy recipient absorbs the message into
|
|
291
|
+
* the running turn instead: the entry is a queued_command attachment under a
|
|
292
|
+
* fresh uuid whose source_uuid is the message id, and the reply is that
|
|
293
|
+
* turn's closing text. Both were measured on Claude Code 2.1.260-2.1.263.
|
|
294
|
+
*/
|
|
295
|
+
function injectedMessageRoot(entry, msgId) {
|
|
296
|
+
const role = entry?.message?.role;
|
|
297
|
+
if (role === "user" && (entry.uuid === msgId || entry.origin?.msg_id === msgId)) return "idle";
|
|
298
|
+
const attachment = entry?.type === "attachment" ? entry.attachment : null;
|
|
299
|
+
if (attachment?.type === "queued_command" && (attachment.source_uuid === msgId || attachment.origin?.msg_id === msgId)) return "absorbed";
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
287
303
|
export function readTranscriptReply(sessionId, cwd, msgId) {
|
|
288
304
|
const file = findTranscriptFile(sessionId, cwd);
|
|
289
305
|
let lines;
|
|
290
306
|
try { lines = fs.readFileSync(file, "utf8").split("\n"); }
|
|
291
307
|
catch { return null; }
|
|
292
308
|
const descendants = new Set();
|
|
309
|
+
let absorbed = false;
|
|
293
310
|
for (const line of lines) {
|
|
294
311
|
let entry;
|
|
295
312
|
try { entry = JSON.parse(line); } catch { continue; }
|
|
296
313
|
if (entry.isSidechain) continue;
|
|
297
|
-
|
|
314
|
+
const root = injectedMessageRoot(entry, msgId);
|
|
315
|
+
if (root) { absorbed = root === "absorbed"; if (typeof entry.uuid === "string") descendants.add(entry.uuid); continue; }
|
|
298
316
|
if (!descendants.has(entry.parentUuid)) continue;
|
|
299
317
|
const role = entry.message?.role;
|
|
300
318
|
const content = entry.message?.content;
|
|
@@ -302,7 +320,7 @@ export function readTranscriptReply(sessionId, cwd, msgId) {
|
|
|
302
320
|
if (typeof entry.uuid === "string") descendants.add(entry.uuid);
|
|
303
321
|
if (role !== "assistant" || !["end_turn", "stop_sequence"].includes(entry.message.stop_reason)) continue;
|
|
304
322
|
const text = Array.isArray(content) ? content.filter((part) => part.type === "text").map((part) => part.text ?? "").join("\n") : String(content ?? "");
|
|
305
|
-
if (text.trim()) return { text: text.trim(), msgId: entry.uuid, source: "transcript", inReplyTo: msgId };
|
|
323
|
+
if (text.trim()) return { text: text.trim(), msgId: entry.uuid, source: "transcript", inReplyTo: msgId, absorbed };
|
|
306
324
|
}
|
|
307
325
|
return null;
|
|
308
326
|
}
|
|
@@ -594,7 +612,7 @@ export class PeerEndpoint {
|
|
|
594
612
|
return frame.msg_id;
|
|
595
613
|
}
|
|
596
614
|
|
|
597
|
-
async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next", transcriptSession, beforeSend, permissionMode = this.permissionMode, replyThreadId, senderReview } = {}) {
|
|
615
|
+
async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next", transcriptSession, beforeSend, permissionMode = this.permissionMode, replyThreadId, senderReview, senderApprovalPolicy, recipient } = {}) {
|
|
598
616
|
const previous = this.requestQueues.get(targetSocket) ?? Promise.resolve();
|
|
599
617
|
const pending = previous.catch(() => {}).then(async () => {
|
|
600
618
|
await beforeSend?.();
|
|
@@ -612,7 +630,7 @@ export class PeerEndpoint {
|
|
|
612
630
|
this.unconfirmedReplies.set(targetSocket, unconfirmed + 1);
|
|
613
631
|
let msgId = crypto.randomUUID();
|
|
614
632
|
this.pendingMessages.set(msgId, { targetSocket, transcriptSession });
|
|
615
|
-
this.sentMessages.set(msgId, { targetSocket, transcriptSession, sentAt: since, replyThreadId, permissionMode, ...(senderReview ? { senderReview: { ...senderReview } } : {}) });
|
|
633
|
+
this.sentMessages.set(msgId, { targetSocket, transcriptSession, sentAt: since, replyThreadId, permissionMode, senderApprovalPolicy, ...(senderReview ? { senderReview: { ...senderReview } } : {}), ...(recipient ? { recipient: { ...recipient } } : {}) });
|
|
616
634
|
if (transcriptSession && !this.responsePoll) {
|
|
617
635
|
this.responsePoll = globalThis.setInterval(() => this.#refreshTranscriptReplies(), 250);
|
|
618
636
|
this.responsePoll.unref();
|
|
@@ -715,10 +733,12 @@ export class PeerEndpoint {
|
|
|
715
733
|
taskId: session?.desktop?.taskId ?? null,
|
|
716
734
|
title: session?.desktop?.title ?? null,
|
|
717
735
|
senderMode: sent.permissionMode ?? null,
|
|
736
|
+
...(sent.senderApprovalPolicy !== undefined ? { senderApprovalPolicy: sent.senderApprovalPolicy ?? null } : {}),
|
|
718
737
|
...(sent.senderReview ? { senderReview: { ...sent.senderReview } } : {}),
|
|
738
|
+
...(sent.recipient ? { recipientPermissionMode: sent.recipient.permissionMode ?? null, recipientPermissionClass: sent.recipient.permissionClass ?? null, recipientInboundPolicy: sent.recipient.inboundPolicy ?? null } : {}),
|
|
719
739
|
replyThreadId: sent.replyThreadId ?? null,
|
|
720
740
|
pending: this.pendingMessages.has(msgId),
|
|
721
|
-
...(sent.reply ? { reply: sent.reply.text, source: sent.reply.source ?? "peer", ...(sent.reply.forwardingError ? { forwardingError: { ...sent.reply.forwardingError } } : {}) } : {}),
|
|
741
|
+
...(sent.reply ? { reply: sent.reply.text, source: sent.reply.source ?? "peer", ...(sent.reply.absorbed ? { replyAbsorbed: true } : {}), ...(sent.reply.forwardingError ? { forwardingError: { ...sent.reply.forwardingError } } : {}) } : {}),
|
|
722
742
|
};
|
|
723
743
|
}
|
|
724
744
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function preflightFailure(code, reason) {
|
|
2
|
+
const error = new Error(`${reason} No message was sent.`);
|
|
3
|
+
error.preflight = { status: "blocked", code, reason, sent: false };
|
|
4
|
+
return error;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Claude consults crossSessionInbound before comparing permission classes,
|
|
9
|
+
* then holds any message whose attested sender class differs from the
|
|
10
|
+
* recipient's. Claude Desktop cannot show the approval dialog, so a hold
|
|
11
|
+
* there expires unapproved. Both predictable outcomes are refused here with
|
|
12
|
+
* the setting or both classes named. The recipient's mode comes from Desktop
|
|
13
|
+
* task metadata; an unknown mode leaves the decision to Claude. This runs at
|
|
14
|
+
* request time and again immediately before the socket write.
|
|
15
|
+
*/
|
|
16
|
+
export function assertRecipientClass(session, sender) {
|
|
17
|
+
if (!sender) return;
|
|
18
|
+
const inbound = session.inbound ?? { value: null, source: null };
|
|
19
|
+
if (inbound.value === "refuse" || inbound.value === "hold") {
|
|
20
|
+
throw preflightFailure("CLAUDE_RECIPIENT_INBOUND_POLICY", `The recipient session's ${inbound.source} settings set crossSessionInbound to ${inbound.value}, so Claude would ${inbound.value === "refuse" ? "drop this message without delivering it" : "hold this message, and Claude Desktop declares no peer approval dialog, so it would expire unapproved"}. Report this to the user: only they can change that setting. Do not edit recipient settings.`);
|
|
21
|
+
}
|
|
22
|
+
if (inbound.value === "accept") return;
|
|
23
|
+
const recipient = session.desktop?.permissionClass ?? null;
|
|
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 approval policy so its class matches, or set crossSessionInbound to accept in the recipient session's own settings. Do not change the attested sender class or recipient settings yourself.`);
|
|
26
|
+
}
|