@minhspark/codex-mcp-bridge 1.12.2 → 1.12.4
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 +25 -0
- package/README.md +22 -10
- package/package.json +1 -1
- package/scripts/check-claude-bridge.mjs +7 -1
- package/scripts/install-codex-mcp.mjs +3 -0
- package/scripts/install-native-relay.mjs +1 -1
- package/src/claude-bridge.mjs +27 -10
- package/src/index.mjs +1 -1
- package/src/native-relay-companion.mjs +7 -2
- package/src/native-relay.mjs +171 -6
- package/src/peer-protocol.mjs +198 -40
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## [1.12.4] - 2026-09-05
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Authenticate Windows peer connections with the destination peer key, publish keys under the canonical socket hash, use Claude-compatible reply pipe names, and encode reply addresses. Incoming unauthenticated Windows frames are refused.
|
|
10
|
+
- Keep receiver policy receipts separate from replies and correlate them to the original message and destination. Socket writes no longer report confirmed delivery; timeout and held/refused outcomes fail the roundtrip diagnostic without automatic retries.
|
|
11
|
+
- Read ordinary Claude Desktop responses from the local transcript using the injected UUID and assistant ancestry, preserving Desktop's disabled native reply tool. Late correlated responses remain available while the bridge runs.
|
|
12
|
+
- Declare sender permission mode only when explicitly configured, and preserve receiver approval decisions. Status reports unknown sender mode instead of inventing one.
|
|
13
|
+
|
|
14
|
+
### Tests
|
|
15
|
+
|
|
16
|
+
- Added authenticated transport, missing/bad auth, canonical key and address, receiver status, MCP outcome, and transcript-correlation regressions. Live Windows testing confirmed authentication and returned an actual `held` receipt; the Desktop assistant roundtrip remains subject to recipient approval.
|
|
17
|
+
|
|
18
|
+
## [1.12.3] - 2026-09-05
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Discover the native tools pipe from the launching Desktop app-server's `mcp_servers.codex_app.env` override when Desktop does not pass it to custom MCP servers. Explicit pipe settings keep precedence; discovery rejects unrelated processes, ambiguous settings, and remote Windows pipes without scanning other sessions or persisting a restart-specific address.
|
|
23
|
+
- Cancel pending pipe discovery when the client closes, and share discovery between concurrent connection requests so a closed client cannot send a delayed message.
|
|
24
|
+
- Report a reachable shared companion in native relay status when another MCP instance owns the listening endpoint.
|
|
25
|
+
|
|
26
|
+
### Tests
|
|
27
|
+
|
|
28
|
+
- Added Windows/POSIX command-line parsing, parent discovery, environment precedence, real mock-pipe delivery, cancellation, concurrent connection, and shared-companion regressions.
|
|
29
|
+
|
|
5
30
|
## [1.12.2] - 2026-09-05
|
|
6
31
|
|
|
7
32
|
### Fixed
|
package/README.md
CHANGED
|
@@ -330,7 +330,7 @@ Under `owned`, a thread a human opened is **unreachable rather than merely restr
|
|
|
330
330
|
| Tool | What it does | Hints |
|
|
331
331
|
|---|---|---|
|
|
332
332
|
| `list_claude_sessions` | Lists Claude Code sessions running on this machine (name, pid, sessionId, cwd, entrypoint). | read-only |
|
|
333
|
-
| `send_to_claude_session` |
|
|
333
|
+
| `send_to_claude_session` | Sends to a Claude inbox and waits for a correlated reply. Receipt statuses distinguish `reply_received`, `sent_unconfirmed`, `reply_timeout`, and receiver policy outcomes such as `held` or `refused`. `waitSec: 0` sends without waiting for confirmation. Prefer an exact `sessionId` because session names can change. | destructive |
|
|
334
334
|
| `read_claude_inbox` | Reads **and clears** messages Claude pushed over on its own, including replies that arrived late. | destructive |
|
|
335
335
|
| `read_claude_transcript` | Reads a Claude session's recent conversation without sending anything. | read-only |
|
|
336
336
|
| `bind_codex_thread` | Binds a Codex thread so every message from Claude is relayed into it, **visible in the Codex desktop app**. Pass an empty string to stop. | writes |
|
|
@@ -340,25 +340,37 @@ Every tool declares MCP annotation hints (`readOnlyHint`, `destructiveHint`, `id
|
|
|
340
340
|
|
|
341
341
|
Waited sends to the same Claude session run in order. If an earlier send timed out or used `waitSec: 0` and its reply has not arrived, a new waited send returns `PEER_REPLY_PENDING` before delivering another message. Wait for the earlier reply and inspect `read_claude_inbox`, or use `waitSec: 0` if another asynchronous message is intended. Different destination sessions remain independent.
|
|
342
342
|
|
|
343
|
+
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 UUID. 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.
|
|
344
|
+
|
|
345
|
+
`CLAUDE_BRIDGE_PERMISSION_MODE` optionally declares the **actual sender** permission class (`prompting` or `bypass`). The installer preserves this setting when supplied. Leave it unset when the sender's effective mode is unknown. Never choose a value just to match a recipient: Claude applies its own permission-mode and inbound-policy checks and may hold a message for user approval. The bridge reports that decision and does not retry it automatically.
|
|
346
|
+
|
|
347
|
+
`npm run check:claude` without `CLAUDE_TARGET` checks discovery only. With a target it requires `reply_received`; a socket write, a held message, or a timeout fails the roundtrip check.
|
|
348
|
+
|
|
343
349
|
### How each side sees the other
|
|
344
350
|
|
|
345
|
-
- **Claude sees Codex:** `claude-bridge` registers
|
|
351
|
+
- **Claude sees Codex:** `claude-bridge` registers a peer under `~/.claude/sessions/`. CLI sessions can use their permitted peer messaging tools to reply; Desktop replies use the correlated transcript path described above. The default name is `codex-<pid>`; `bind_codex_thread` renames it to `codex-<first 8 chars of threadId>`.
|
|
346
352
|
- **Codex sees Claude:** `list_claude_sessions` reads that same registry, and `read_claude_transcript` shows what a Claude session is working on.
|
|
347
|
-
- **Visible in chat:**
|
|
353
|
+
- **Visible in chat:** messages accepted by Claude appear in the target conversation. Received replies can be relayed into the bound Codex task. A `held` receipt means the receiver has not released the message to Claude yet.
|
|
354
|
+
|
|
355
|
+
### Peer protocol
|
|
348
356
|
|
|
349
|
-
|
|
357
|
+
Claude Code advertises local inboxes in `~/.claude/sessions/<pid>.json`. The bridge uses a Unix socket on macOS/Linux and `\\.\pipe\LOCAL\cc-msg-<32 hex characters>` on Windows. Frames are **NDJSON**, one message per line. A Windows connection starts with an authentication line using the destination's peer key; it never uses the separate child-process token:
|
|
358
|
+
|
|
359
|
+
```json
|
|
360
|
+
{"type":"auth","token":"<destination peer token>"}
|
|
361
|
+
```
|
|
350
362
|
|
|
351
|
-
|
|
363
|
+
The message carries an explicit UUID for transcript correlation:
|
|
352
364
|
|
|
353
365
|
```json
|
|
354
|
-
{"msgV":1,"msg_id":"<uuid>","type":"user","message":{"role":"user",
|
|
355
|
-
"content":"<cross-session-message from=\"uds:/tmp/cc-socks/<pid>.sock\"
|
|
366
|
+
{"msgV":1,"msg_id":"<uuid>","uuid":"<uuid>","type":"user","message":{"role":"user",
|
|
367
|
+
"content":"<cross-session-message from=\"uds:/tmp/cc-socks/<pid>.sock\">\n...\n</cross-session-message>"},
|
|
356
368
|
"priority":"next","from":"uds:/tmp/cc-socks/<pid>.sock"}
|
|
357
369
|
```
|
|
358
370
|
|
|
359
|
-
|
|
371
|
+
Peer key names are `<pid>.<sha256(canonical socket)>.key`. Windows pipe names are normalized for hashing, and reply addresses are percent-encoded on the wire. Authentication and control frames are not user replies. `peer_message_status` receipts are matched to the original message ID and destination. A successful socket write alone is not a delivery acknowledgement.
|
|
360
372
|
|
|
361
|
-
|
|
373
|
+
See [Claude Code cross-session messaging](https://code.claude.com/docs/en/cross-session-messaging) for authentication and inbound-policy behavior. Wire compatibility was checked against installed Claude Code 2.1.260; peer internals can change between releases.
|
|
362
374
|
|
|
363
375
|
### Ping-pong guard
|
|
364
376
|
|
|
@@ -424,7 +436,7 @@ Resolution order is `CODEX_RELAY_ID` → that file → an error naming both. Nev
|
|
|
424
436
|
|
|
425
437
|
A companion that cannot be reached before sending falls back to the app-server path. Once a request has been written, a refusal, timeout, or lost acknowledgement does not trigger another delivery: the first attempt may already have succeeded. Invalid or oversized messages are also refused rather than passed to another backend.
|
|
426
438
|
|
|
427
|
-
|
|
439
|
+
The companion uses `CODEX_APP_TOOLS_PIPE_PATH` when inherited. Desktop builds that supply it only to their bundled `codex_app` MCP are also supported: the companion reads the exact `mcp_servers.codex_app.env` override from its launching app-server. It never picks a pipe from another session or saves a restart-specific address. The native connection remains separate from MCP stdio. If neither source provides a valid pipe, the relay stays unavailable; if a configured pipe is late during startup, it retries in the background. When several MCP instances start, status identifies a reachable shared companion instead of reporting that the relay is down.
|
|
428
440
|
|
|
429
441
|
> ⚠️ `codex_app.send_message_to_thread` and the native tools pipe are **Codex Desktop internals with no public documentation**, on the same footing as the Claude peer protocol above. That is why the relay is Windows/macOS, feature-detected, optional and fallback-safe. If Codex changes it, the two places to fix are `NATIVE_DISPATCH_METHOD` and `nativeDispatchParams()` in `src/native-relay.mjs`; `CODEX_NATIVE_RELAY_METHOD` overrides the method name without a release. The request the companion sends is:
|
|
430
442
|
>
|
package/package.json
CHANGED
|
@@ -34,7 +34,13 @@ try {
|
|
|
34
34
|
arguments: { target, message, waitSec },
|
|
35
35
|
});
|
|
36
36
|
console.log("\n--- send_to_claude_session ---\n" + sent.content[0].text);
|
|
37
|
-
if (sent.isError) throw new Error("Claude message
|
|
37
|
+
if (sent.isError) throw new Error("Claude message roundtrip failed; receipt or outcome is unconfirmed");
|
|
38
|
+
if (sent.structuredContent?.receipt?.status !== "reply_received") {
|
|
39
|
+
throw new Error("Claude message roundtrip unconfirmed: an actual reply is required; use CLAUDE_WAIT greater than 0");
|
|
40
|
+
}
|
|
41
|
+
console.log("\nClaude message roundtrip passed: reply received.");
|
|
42
|
+
} else {
|
|
43
|
+
console.log("\nDiscovery checks passed. Message roundtrip was not tested; set CLAUDE_TARGET to verify a reply.");
|
|
38
44
|
}
|
|
39
45
|
} finally {
|
|
40
46
|
await client.close();
|
|
@@ -11,6 +11,8 @@ const codexBin = resolveCodexBin(process.env.CODEX_EXE);
|
|
|
11
11
|
const serverName = process.env.CLAUDE_BRIDGE_NAME ?? "claude-bridge";
|
|
12
12
|
const entry = path.join(root, "src", "claude-bridge.mjs");
|
|
13
13
|
const remove = process.argv.includes("--remove");
|
|
14
|
+
const permissionMode = process.env.CLAUDE_BRIDGE_PERMISSION_MODE;
|
|
15
|
+
if (!remove && permissionMode && !["bypass", "prompting"].includes(permissionMode)) throw new Error("CLAUDE_BRIDGE_PERMISSION_MODE must be bypass or prompting");
|
|
14
16
|
|
|
15
17
|
if (!fs.existsSync(entry)) throw new Error(`bridge entry point missing: ${entry}`);
|
|
16
18
|
if (!path.isAbsolute(codexBin) || !fs.existsSync(codexBin)) {
|
|
@@ -33,6 +35,7 @@ try {
|
|
|
33
35
|
const args = ["mcp", "add", serverName];
|
|
34
36
|
const peerName = process.env.CLAUDE_BRIDGE_PEER_NAME;
|
|
35
37
|
if (peerName) args.push("--env", `CLAUDE_BRIDGE_PEER_NAME=${peerName}`);
|
|
38
|
+
if (permissionMode) args.push("--env", `CLAUDE_BRIDGE_PERMISSION_MODE=${permissionMode}`);
|
|
36
39
|
args.push("--", process.execPath, entry);
|
|
37
40
|
|
|
38
41
|
run(args);
|
|
@@ -8,7 +8,7 @@ import { CodexAppServerClient } from "../src/app-server-client.mjs";
|
|
|
8
8
|
import { bootstrapRelayThread, readRelayConfig, relayConfigPath, relaySocketPath } from "../src/native-relay.mjs";
|
|
9
9
|
import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir, resolveCodexBin, spawnEnv } from "../src/platform.mjs";
|
|
10
10
|
|
|
11
|
-
const VERSION = "1.12.
|
|
11
|
+
const VERSION = "1.12.4";
|
|
12
12
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
13
|
const entry = path.join(root, "src", "native-relay-companion.mjs");
|
|
14
14
|
const serverName = process.env.CODEX_NATIVE_RELAY_NAME ?? "codex-native-relay";
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { PLATFORM_LABEL } from "./platform.mjs";
|
|
|
8
8
|
import { PeerEndpoint, findClaudeSession, listClaudeSessions, readTranscript } from "./peer-protocol.mjs";
|
|
9
9
|
import { createThreadDelivery } from "./thread-delivery.mjs";
|
|
10
10
|
|
|
11
|
-
const VERSION = "1.12.
|
|
11
|
+
const VERSION = "1.12.4";
|
|
12
12
|
const FORWARD_MIN_INTERVAL_MS = 5000;
|
|
13
13
|
const FORWARD_MAX_PER_SESSION = 50;
|
|
14
14
|
|
|
@@ -91,7 +91,7 @@ const server = new McpServer(
|
|
|
91
91
|
{
|
|
92
92
|
instructions:
|
|
93
93
|
"Talk to a live Claude Code session from Codex. list_claude_sessions finds the session, " +
|
|
94
|
-
"send_to_claude_session
|
|
94
|
+
"send_to_claude_session sends to its peer transport and waits for a reply to confirm receipt. " +
|
|
95
95
|
"This bridge registers itself as a peer, so Claude sees it in its own agent list and can " +
|
|
96
96
|
"message back; bind_codex_thread relays those messages into a Codex thread.",
|
|
97
97
|
},
|
|
@@ -130,8 +130,8 @@ server.registerTool(
|
|
|
130
130
|
{
|
|
131
131
|
title: "Send a message to a Claude session",
|
|
132
132
|
description:
|
|
133
|
-
"
|
|
134
|
-
"
|
|
133
|
+
"Send a message to a running Claude Code session's peer transport and wait for its reply. " +
|
|
134
|
+
"A socket write alone does not confirm that Claude received the message. Set waitSec to 0 to send without confirmation. " +
|
|
135
135
|
"A waited send is refused while earlier messages to that session still await replies; " +
|
|
136
136
|
"wait for those replies and read_claude_inbox before trying again.",
|
|
137
137
|
inputSchema: {
|
|
@@ -159,17 +159,33 @@ server.registerTool(
|
|
|
159
159
|
if (!session) return textResult(`No live Claude session matches "${target}".`, true);
|
|
160
160
|
|
|
161
161
|
const wait = waitSec ?? 180;
|
|
162
|
-
const
|
|
163
|
-
const
|
|
162
|
+
const desktop = session.entrypoint === "claude-desktop";
|
|
163
|
+
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;
|
|
164
|
+
const { msgId, reply, delivery } = await peer.sendAndWait(session.socket, text, {
|
|
165
|
+
timeoutMs: wait * 1000,
|
|
166
|
+
...(desktop ? { transcriptSession: session } : {}),
|
|
167
|
+
});
|
|
168
|
+
const status = reply ? "reply_received" : delivery?.status ?? (wait === 0 ? "sent_unconfirmed" : "reply_timeout");
|
|
169
|
+
const receipt = { status, msgId, target: session.name ?? String(session.pid), sessionId: session.sessionId, waitSec: wait, ...(reply ? { source: reply.source ?? "peer" } : {}) };
|
|
170
|
+
const result = (text, isError = false) => ({ ...textResult(text, isError), structuredContent: { receipt } });
|
|
171
|
+
const targetLabel = `${session.name ?? session.pid} (pid ${session.pid}, session ${session.sessionId ?? "?"})`;
|
|
164
172
|
|
|
165
|
-
if (
|
|
173
|
+
if (!reply && delivery && delivery.status !== "delivered") {
|
|
174
|
+
return result(`Claude inbox reported ${delivery.status} for ${targetLabel}.\n${delivery.reason}\nMessage id: ${msgId}`, true);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (wait === 0) {
|
|
178
|
+
return result(`Sent to peer transport for ${targetLabel}.\nClaude receipt unconfirmed; not waiting for a reply.\nMessage id: ${msgId}`);
|
|
179
|
+
}
|
|
166
180
|
|
|
167
181
|
if (!reply) {
|
|
168
|
-
return
|
|
169
|
-
|
|
182
|
+
return result(
|
|
183
|
+
`Sent to peer transport for ${targetLabel}.\n\nNo reply within ${wait}s; Claude receipt and outcome remain unconfirmed. ` +
|
|
184
|
+
`Do not automatically retry: the message may still be processed. Check read_claude_inbox for a late reply.\nMessage id: ${msgId}`,
|
|
185
|
+
true,
|
|
170
186
|
);
|
|
171
187
|
}
|
|
172
|
-
return
|
|
188
|
+
return result(`Reply received from ${targetLabel}.\nMessage id: ${msgId}\n\n--- Claude reply ---\n${reply.text}`);
|
|
173
189
|
} catch (err) {
|
|
174
190
|
return failure(err);
|
|
175
191
|
}
|
|
@@ -296,6 +312,7 @@ server.registerTool(
|
|
|
296
312
|
`bridge: claude-bridge ${VERSION}`,
|
|
297
313
|
`peer name: ${peer.name} (Claude sees this in its agent list)`,
|
|
298
314
|
`peer socket: ${peer.socketPath}`,
|
|
315
|
+
`sender mode: ${peer.permissionMode ?? "unknown (recipient may hold messages for approval)"}`,
|
|
299
316
|
`live sessions: ${sessions.length}`,
|
|
300
317
|
`relay thread: ${forwarding.threadId ?? "(none - use bind_codex_thread)"}`,
|
|
301
318
|
`delivery: ${delivery.describe()}`,
|
package/src/index.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
import { runTurn } from "./turn.mjs";
|
|
24
24
|
import { BridgeSecurityPolicy } from "./security-policy.mjs";
|
|
25
25
|
|
|
26
|
-
const VERSION = "1.12.
|
|
26
|
+
const VERSION = "1.12.4";
|
|
27
27
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
28
28
|
|
|
29
29
|
/**
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from "./native-relay.mjs";
|
|
18
18
|
import { IS_WINDOWS, PLATFORM_LABEL } from "./platform.mjs";
|
|
19
19
|
|
|
20
|
-
const VERSION = "1.12.
|
|
20
|
+
const VERSION = "1.12.4";
|
|
21
21
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
22
22
|
|
|
23
23
|
function errorResponse(code, message) {
|
|
@@ -201,6 +201,10 @@ export class RelaySocketServer {
|
|
|
201
201
|
});
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
async isListening() {
|
|
205
|
+
return this.started || this.#socketIsLive();
|
|
206
|
+
}
|
|
207
|
+
|
|
204
208
|
#handleConnection(socket) {
|
|
205
209
|
let buffer = Buffer.alloc(0);
|
|
206
210
|
let handled = false;
|
|
@@ -350,6 +354,7 @@ if (invokedDirectly) {
|
|
|
350
354
|
},
|
|
351
355
|
},
|
|
352
356
|
async () => {
|
|
357
|
+
const listening = await relay.isListening();
|
|
353
358
|
let executor = "(unconfigured)";
|
|
354
359
|
try {
|
|
355
360
|
const resolved = resolveRelayThreadId();
|
|
@@ -364,7 +369,7 @@ if (invokedDirectly) {
|
|
|
364
369
|
text: [
|
|
365
370
|
`platform: ${PLATFORM_LABEL} (${process.platform}/${process.arch})`,
|
|
366
371
|
`companion: codex-native-relay ${VERSION}`,
|
|
367
|
-
`relay socket: ${relay.started ? relay.socketPath : `${relay.socketPath} (not listening)`}`,
|
|
372
|
+
`relay socket: ${relay.started ? relay.socketPath : `${relay.socketPath} (${listening ? "shared companion listening" : "not listening"})`}`,
|
|
368
373
|
`executor: ${executor}`,
|
|
369
374
|
`dispatch: ${process.env.CODEX_NATIVE_RELAY_METHOD ?? NATIVE_DISPATCH_METHOD}`,
|
|
370
375
|
`native pipe: ${nativeTools.socketPath ?? "unavailable (requires Codex Desktop)"}`,
|
package/src/native-relay.mjs
CHANGED
|
@@ -2,6 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import net from "node:net";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
5
7
|
|
|
6
8
|
import { IS_MACOS, IS_WINDOWS, PLATFORM_LABEL, homeDir } from "./platform.mjs";
|
|
7
9
|
|
|
@@ -139,14 +141,168 @@ export function nativeDispatchParams({ executorThreadId, targetThreadId, message
|
|
|
139
141
|
};
|
|
140
142
|
}
|
|
141
143
|
|
|
144
|
+
const execFileAsync = promisify(execFile);
|
|
145
|
+
|
|
146
|
+
function splitDesktopCommandLine(commandLine, platform) {
|
|
147
|
+
const args = [];
|
|
148
|
+
let value = "";
|
|
149
|
+
let quote = null;
|
|
150
|
+
let depth = 0;
|
|
151
|
+
for (let index = 0; index < commandLine.length; index++) {
|
|
152
|
+
const char = commandLine[index];
|
|
153
|
+
if (platform === "win32" && char === "\\") {
|
|
154
|
+
let end = index;
|
|
155
|
+
while (commandLine[end] === "\\") end++;
|
|
156
|
+
const count = end - index;
|
|
157
|
+
if (commandLine[end] === '"') {
|
|
158
|
+
value += "\\".repeat(Math.floor(count / 2));
|
|
159
|
+
if (count % 2) value += '"';
|
|
160
|
+
else quote = quote ? null : '"';
|
|
161
|
+
index = end;
|
|
162
|
+
} else {
|
|
163
|
+
value += "\\".repeat(count);
|
|
164
|
+
index = end - 1;
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (platform !== "win32" && depth > 0) {
|
|
169
|
+
value += char;
|
|
170
|
+
if (quote) {
|
|
171
|
+
if (char === "\\" && quote === '"') value += commandLine[++index] ?? "";
|
|
172
|
+
else if (char === quote) quote = null;
|
|
173
|
+
} else if (char === '"' || char === "'") quote = char;
|
|
174
|
+
else if (char === "{" || char === "[") depth++;
|
|
175
|
+
else if (char === "}" || char === "]") depth--;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (char === '"' || (platform !== "win32" && char === "'")) {
|
|
179
|
+
if (!quote) quote = char;
|
|
180
|
+
else if (quote === char) quote = null;
|
|
181
|
+
else value += char;
|
|
182
|
+
} else if (!quote && /\s/.test(char)) {
|
|
183
|
+
if (value) args.push(value);
|
|
184
|
+
value = "";
|
|
185
|
+
} else {
|
|
186
|
+
value += char;
|
|
187
|
+
if (platform !== "win32" && !quote && char === "{") depth++;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (quote || depth) return [];
|
|
191
|
+
if (value) args.push(value);
|
|
192
|
+
return args;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function inlineTableValues(table) {
|
|
196
|
+
const text = table.trim();
|
|
197
|
+
if (!text.startsWith("{") || !text.endsWith("}")) return null;
|
|
198
|
+
const entries = [];
|
|
199
|
+
let start = 1;
|
|
200
|
+
let quote = null;
|
|
201
|
+
let depth = 0;
|
|
202
|
+
for (let index = 1; index < text.length - 1; index++) {
|
|
203
|
+
const char = text[index];
|
|
204
|
+
if (quote) {
|
|
205
|
+
if (char === "\\" && quote === '"') index++;
|
|
206
|
+
else if (char === quote) quote = null;
|
|
207
|
+
} else if (char === '"' || char === "'") quote = char;
|
|
208
|
+
else if (char === "{" || char === "[") depth++;
|
|
209
|
+
else if (char === "}" || char === "]") {
|
|
210
|
+
if (--depth < 0) return null;
|
|
211
|
+
} else if (char === "," && depth === 0) {
|
|
212
|
+
entries.push(text.slice(start, index));
|
|
213
|
+
start = index + 1;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (quote || depth) return null;
|
|
217
|
+
entries.push(text.slice(start, -1));
|
|
218
|
+
const values = new Map();
|
|
219
|
+
for (const entry of entries) {
|
|
220
|
+
if (!entry.trim()) continue;
|
|
221
|
+
const match = entry.match(/^\s*(?:"([A-Za-z_][A-Za-z0-9_-]*)"|'([A-Za-z_][A-Za-z0-9_-]*)'|([A-Za-z_][A-Za-z0-9_-]*))\s*=\s*([\s\S]+)$/);
|
|
222
|
+
if (!match) return null;
|
|
223
|
+
const key = match[1] ?? match[2] ?? match[3];
|
|
224
|
+
if (values.has(key)) return null;
|
|
225
|
+
values.set(key, match[4].trim());
|
|
226
|
+
}
|
|
227
|
+
return values;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function nativeToolsPipeFromCommandLine(commandLine, { platform = process.platform } = {}) {
|
|
231
|
+
if (typeof commandLine !== "string" || /[\r\n\0]/.test(commandLine)) return null;
|
|
232
|
+
const args = splitDesktopCommandLine(commandLine, platform);
|
|
233
|
+
const paths = platform === "win32" ? path.win32 : path.posix;
|
|
234
|
+
if (!/^codex(?:\.exe)?$/i.test(paths.basename(args[0] ?? ""))) return null;
|
|
235
|
+
const overrides = [];
|
|
236
|
+
let appServer = false;
|
|
237
|
+
for (let index = 1; index < args.length; index++) {
|
|
238
|
+
const arg = args[index];
|
|
239
|
+
if (arg === "-c" || arg === "--config") {
|
|
240
|
+
overrides.push(args[++index] ?? "");
|
|
241
|
+
} else if (arg.startsWith("--config=")) overrides.push(arg.slice(9));
|
|
242
|
+
else if (arg === "app-server") appServer = true;
|
|
243
|
+
else if (!arg.startsWith("-") && !appServer) return null;
|
|
244
|
+
}
|
|
245
|
+
if (!appServer) return null;
|
|
246
|
+
const candidates = [];
|
|
247
|
+
for (const override of overrides) {
|
|
248
|
+
const match = override.match(/^mcp_servers\.codex_app\s*=\s*([\s\S]+)$/);
|
|
249
|
+
if (!match) continue;
|
|
250
|
+
const config = inlineTableValues(match[1]);
|
|
251
|
+
if (!config) return null;
|
|
252
|
+
const env = inlineTableValues(config.get("env") ?? "{}");
|
|
253
|
+
if (!env) return null;
|
|
254
|
+
const raw = env.get("CODEX_APP_TOOLS_PIPE_PATH");
|
|
255
|
+
if (raw === undefined) continue;
|
|
256
|
+
let candidate;
|
|
257
|
+
try {
|
|
258
|
+
candidate = raw.startsWith('"') ? JSON.parse(raw) : /^'[^']*'$/.test(raw) ? raw.slice(1, -1) : null;
|
|
259
|
+
} catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
if (typeof candidate !== "string" || /[\r\n\0]/.test(candidate)) return null;
|
|
263
|
+
if (platform === "win32" ? !/^\\\\\.\\pipe\\[^\\]/i.test(candidate) : !path.posix.isAbsolute(candidate)) return null;
|
|
264
|
+
candidates.push(candidate);
|
|
265
|
+
}
|
|
266
|
+
return candidates.length && new Set(candidates).size === 1 ? candidates[0] : null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function readParentCommandLine(parentPid, platform) {
|
|
270
|
+
if (!Number.isSafeInteger(parentPid) || parentPid <= 0) return null;
|
|
271
|
+
const options = { timeout: 5000, maxBuffer: 128 * 1024, windowsHide: true };
|
|
272
|
+
if (platform === "win32") {
|
|
273
|
+
const powershell = path.win32.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
274
|
+
const script = `$p=Get-CimInstance Win32_Process -Filter 'ProcessId=${parentPid}'; if ($p.Name -eq 'codex.exe') { $p.CommandLine | ConvertTo-Json -Compress }`;
|
|
275
|
+
const { stdout } = await execFileAsync(powershell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], options);
|
|
276
|
+
return stdout.trim() ? JSON.parse(stdout.trim()) : null;
|
|
277
|
+
}
|
|
278
|
+
const { stdout } = await execFileAsync("/bin/ps", ["-ww", "-p", String(parentPid), "-o", "args="], options);
|
|
279
|
+
return stdout.trim();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function resolveNativeToolsPipePath({
|
|
283
|
+
env = process.env,
|
|
284
|
+
parentPid = process.ppid,
|
|
285
|
+
platform = process.platform,
|
|
286
|
+
readParentCommandLine: readParent = readParentCommandLine,
|
|
287
|
+
} = {}) {
|
|
288
|
+
if (env.CODEX_APP_TOOLS_PIPE_PATH) return env.CODEX_APP_TOOLS_PIPE_PATH;
|
|
289
|
+
try {
|
|
290
|
+
return nativeToolsPipeFromCommandLine(await readParent(parentPid, platform), { platform });
|
|
291
|
+
} catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
142
296
|
export class NativeToolsClient {
|
|
143
|
-
constructor({ env = process.env, socketPath = env.CODEX_APP_TOOLS_PIPE_PATH, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
297
|
+
constructor({ env = process.env, socketPath = env.CODEX_APP_TOOLS_PIPE_PATH, timeoutMs = DEFAULT_TIMEOUT_MS, resolveSocketPath = () => resolveNativeToolsPipePath({ env }) } = {}) {
|
|
144
298
|
this.env = env;
|
|
145
299
|
this.socketPath = socketPath;
|
|
146
300
|
this.timeoutMs = timeoutMs;
|
|
301
|
+
this.resolveSocketPath = resolveSocketPath;
|
|
147
302
|
this.socket = null;
|
|
148
303
|
this.connectingSocket = null;
|
|
149
304
|
this.connecting = null;
|
|
305
|
+
this.connectionGeneration = 0;
|
|
150
306
|
this.pending = new Map();
|
|
151
307
|
this.nextId = 1;
|
|
152
308
|
}
|
|
@@ -154,10 +310,17 @@ export class NativeToolsClient {
|
|
|
154
310
|
async connect() {
|
|
155
311
|
if (this.connecting) return this.connecting;
|
|
156
312
|
if (this.socket && !this.socket.destroyed) return;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
313
|
+
const generation = this.connectionGeneration;
|
|
314
|
+
this.connecting = (async () => {
|
|
315
|
+
const socketPath = this.socketPath || await this.resolveSocketPath();
|
|
316
|
+
if (generation !== this.connectionGeneration) {
|
|
317
|
+
throw new NativeRelayError("Native tools client closed while discovering the Desktop pipe", "NATIVE_PIPE_UNAVAILABLE");
|
|
318
|
+
}
|
|
319
|
+
this.socketPath = socketPath;
|
|
320
|
+
if (!this.socketPath) {
|
|
321
|
+
throw new NativeRelayError("CODEX_APP_TOOLS_PIPE_PATH is missing from the environment and parent Desktop app-server configuration; launch the companion from Codex Desktop", "NATIVE_PIPE_UNAVAILABLE");
|
|
322
|
+
}
|
|
323
|
+
return new Promise((resolve, reject) => {
|
|
161
324
|
const socket = net.connect({ path: this.socketPath });
|
|
162
325
|
this.connectingSocket = socket;
|
|
163
326
|
let buffer = Buffer.alloc(0);
|
|
@@ -215,7 +378,8 @@ export class NativeToolsClient {
|
|
|
215
378
|
});
|
|
216
379
|
socket.on("error", (err) => fail(new NativeRelayError(`Codex Desktop native pipe failed: ${err.message}`, connected ? "NATIVE_DELIVERY_UNCONFIRMED" : "NATIVE_PIPE_UNAVAILABLE")));
|
|
217
380
|
socket.on("close", () => fail(new NativeRelayError("Codex Desktop native tools pipe closed before confirming delivery", connected ? "NATIVE_DELIVERY_UNCONFIRMED" : "NATIVE_PIPE_UNAVAILABLE")));
|
|
218
|
-
|
|
381
|
+
});
|
|
382
|
+
})();
|
|
219
383
|
try {
|
|
220
384
|
await this.connecting;
|
|
221
385
|
} finally {
|
|
@@ -264,6 +428,7 @@ export class NativeToolsClient {
|
|
|
264
428
|
}
|
|
265
429
|
|
|
266
430
|
close() {
|
|
431
|
+
this.connectionGeneration++;
|
|
267
432
|
const socket = this.socket;
|
|
268
433
|
this.socket = null;
|
|
269
434
|
for (const pending of this.pending.values()) {
|
package/src/peer-protocol.mjs
CHANGED
|
@@ -21,7 +21,7 @@ const SOCKET_DIR = "/tmp/cc-socks";
|
|
|
21
21
|
* directory to create, no mode to chmod and no file to unlink beforehand.
|
|
22
22
|
*/
|
|
23
23
|
function peerSocketPath(pid) {
|
|
24
|
-
return IS_WINDOWS ? `\\\\.\\pipe\\LOCAL\\cc-
|
|
24
|
+
return IS_WINDOWS ? `\\\\.\\pipe\\LOCAL\\cc-msg-${crypto.randomBytes(16).toString("hex")}` : path.join(SOCKET_DIR, `${pid}.sock`);
|
|
25
25
|
}
|
|
26
26
|
const PEER_PROTOCOL_VERSION = 1;
|
|
27
27
|
const CLAUDE_VERSION_HINT = "2.1.229";
|
|
@@ -35,32 +35,78 @@ const projectsDir = () => path.join(homeDir(), ".claude", "projects");
|
|
|
35
35
|
* listens for peer messages on a local socket or Windows named pipe. Messages are newline-delimited
|
|
36
36
|
* JSON; the wrapper element is what Claude renders in its chat surface.
|
|
37
37
|
*/
|
|
38
|
-
export function buildFrame({ text, fromSocket, priority = "next" }) {
|
|
38
|
+
export function buildFrame({ text, fromSocket, priority = "next", permissionMode }) {
|
|
39
|
+
const msgId = crypto.randomUUID();
|
|
40
|
+
const from = `uds:${fromSocket.replace(/[^A-Za-z0-9:_/.\-]/gu, (character) => Array.from(Buffer.from(character), (byte) => `%${byte.toString(16).toUpperCase().padStart(2, "0")}`).join(""))}`;
|
|
41
|
+
const mode = ["bypass", "prompting"].includes(permissionMode) ? ` from-mode="${permissionMode}"` : "";
|
|
39
42
|
return {
|
|
40
43
|
msgV: PEER_PROTOCOL_VERSION,
|
|
41
|
-
msg_id:
|
|
44
|
+
msg_id: msgId,
|
|
45
|
+
uuid: msgId,
|
|
42
46
|
type: "user",
|
|
43
47
|
message: {
|
|
44
48
|
role: "user",
|
|
45
|
-
content: `<cross-session-message from="
|
|
49
|
+
content: `<cross-session-message from="${from}"${mode}>\n${text}\n</cross-session-message>`,
|
|
46
50
|
},
|
|
47
51
|
priority,
|
|
48
|
-
from
|
|
52
|
+
from,
|
|
49
53
|
};
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
export function parseFrame(line) {
|
|
53
57
|
const frame = JSON.parse(line);
|
|
58
|
+
if (frame?.type !== "user" || typeof frame.message?.content !== "string") return null;
|
|
54
59
|
const raw = frame?.message?.content ?? "";
|
|
55
60
|
const inner = raw.match(/<cross-session-message[^>]*>\n?([\s\S]*?)\n?<\/cross-session-message>/);
|
|
56
61
|
return {
|
|
57
62
|
msgId: frame.msg_id ?? null,
|
|
58
63
|
from: frame.from ?? null,
|
|
59
|
-
fromSocket: (frame.from
|
|
64
|
+
fromSocket: decodePeerAddress(frame.from),
|
|
60
65
|
text: (inner ? inner[1] : raw).trim(),
|
|
61
66
|
};
|
|
62
67
|
}
|
|
63
68
|
|
|
69
|
+
function decodePeerAddress(from) {
|
|
70
|
+
if (typeof from !== "string" || !from.startsWith("uds:")) return null;
|
|
71
|
+
try { return decodeURIComponent(from.slice(4)) || null; }
|
|
72
|
+
catch { return null; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function peerKeyPath(pid, socket) {
|
|
76
|
+
const pipe = /^[\\/]{2}[.?][\\/]pipe[\\/](?:(LOCAL)[\\/])?([^\\/]+)$/i.exec(socket);
|
|
77
|
+
let canonical;
|
|
78
|
+
if (pipe && !/[. ]$/.test(pipe[2]) && ![".", ".."].includes(pipe[2])) {
|
|
79
|
+
canonical = `\\\\.\\pipe\\${pipe[1] ? "local\\" : ""}${pipe[2].replace(/[A-Z]/g, (letter) => letter.toLowerCase())}`;
|
|
80
|
+
} else if (!IS_WINDOWS && path.isAbsolute(socket)) {
|
|
81
|
+
canonical = path.resolve(socket);
|
|
82
|
+
} else {
|
|
83
|
+
throw new Error("Refusing a non-local or non-canonical peer socket");
|
|
84
|
+
}
|
|
85
|
+
return path.join(sessionsDir(), `${pid}.${crypto.createHash("sha256").update(canonical).digest("hex")}.key`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readPeerToken(socket) {
|
|
89
|
+
peerKeyPath(process.pid, socket);
|
|
90
|
+
const candidates = listClaudeSessions({ includeBridges: true }).filter((entry) => {
|
|
91
|
+
try { return peerKeyPath(entry.pid, entry.socket) === peerKeyPath(entry.pid, socket); }
|
|
92
|
+
catch { return false; }
|
|
93
|
+
});
|
|
94
|
+
if (candidates.length !== 1) {
|
|
95
|
+
if (IS_WINDOWS) throw new Error("No unique live session owns the destination inbox; message was not sent");
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const key = JSON.parse(fs.readFileSync(peerKeyPath(candidates[0].pid, socket), "utf8"));
|
|
100
|
+
if (typeof key.peerToken !== "string" || !/^[0-9a-f]{32}$/i.test(key.peerToken)) throw new Error("Invalid peer key");
|
|
101
|
+
const identity = IS_WINDOWS ? key.procStartFt : key.procStart;
|
|
102
|
+
if (identity && identity !== readProcessStart(candidates[0].pid)) throw new Error("Peer process identity changed");
|
|
103
|
+
return key.peerToken;
|
|
104
|
+
} catch {
|
|
105
|
+
if (IS_WINDOWS) throw new Error("The destination inbox authentication key is missing or invalid; message was not sent");
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
64
110
|
/**
|
|
65
111
|
* MCP servers are spawned with an empty PATH, so `ps` must be addressed by its
|
|
66
112
|
* absolute path - a bare lookup throws ENOENT and takes the whole server down
|
|
@@ -69,7 +115,11 @@ export function parseFrame(line) {
|
|
|
69
115
|
*/
|
|
70
116
|
function readProcessStart(pid) {
|
|
71
117
|
try {
|
|
72
|
-
|
|
118
|
+
if (IS_WINDOWS) {
|
|
119
|
+
const shell = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
120
|
+
return execFileSync(shell, ["-NoProfile", "-Command", `(Get-Process -Id ${Number(pid)}).StartTime.ToUniversalTime().ToFileTimeUtc().ToString()`], { timeout: 3000, windowsHide: true }).toString().trim();
|
|
121
|
+
}
|
|
122
|
+
return execFileSync(PS_BIN, ["-o", "lstart=", "-p", String(pid)], { env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, timeout: 3000 }).toString().trim();
|
|
73
123
|
} catch {
|
|
74
124
|
return "";
|
|
75
125
|
}
|
|
@@ -195,6 +245,29 @@ export function readTranscript(sessionId, cwd, limit = 10) {
|
|
|
195
245
|
return { file, messages: messages.slice(-limit) };
|
|
196
246
|
}
|
|
197
247
|
|
|
248
|
+
export function readTranscriptReply(sessionId, cwd, msgId) {
|
|
249
|
+
const file = findTranscriptFile(sessionId, cwd);
|
|
250
|
+
let lines;
|
|
251
|
+
try { lines = fs.readFileSync(file, "utf8").split("\n"); }
|
|
252
|
+
catch { return null; }
|
|
253
|
+
const descendants = new Set();
|
|
254
|
+
for (const line of lines) {
|
|
255
|
+
let entry;
|
|
256
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
257
|
+
if (entry.isSidechain) continue;
|
|
258
|
+
if (entry.uuid === msgId && entry.message?.role === "user") { descendants.add(msgId); continue; }
|
|
259
|
+
if (!descendants.has(entry.parentUuid)) continue;
|
|
260
|
+
const role = entry.message?.role;
|
|
261
|
+
const content = entry.message?.content;
|
|
262
|
+
if (role === "user" && !(Array.isArray(content) && content.every((part) => part.type === "tool_result"))) continue;
|
|
263
|
+
if (typeof entry.uuid === "string") descendants.add(entry.uuid);
|
|
264
|
+
if (role !== "assistant" || !["end_turn", "stop_sequence"].includes(entry.message.stop_reason)) continue;
|
|
265
|
+
const text = Array.isArray(content) ? content.filter((part) => part.type === "text").map((part) => part.text ?? "").join("\n") : String(content ?? "");
|
|
266
|
+
if (text.trim()) return { text: text.trim(), msgId: entry.uuid, source: "transcript", inReplyTo: msgId };
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
198
271
|
/**
|
|
199
272
|
* Registers this process as a peer that Claude sessions can see in their agent
|
|
200
273
|
* list and reply to. Without a registered socket, Claude has no address to
|
|
@@ -216,6 +289,11 @@ export class PeerEndpoint {
|
|
|
216
289
|
this.requestQueues = new Map();
|
|
217
290
|
this.unconfirmedReplies = new Map();
|
|
218
291
|
this.started = false;
|
|
292
|
+
this.peerToken = null;
|
|
293
|
+
this.permissionMode = process.env.CLAUDE_BRIDGE_PERMISSION_MODE;
|
|
294
|
+
this.deliveryReceipts = new Map();
|
|
295
|
+
this.pendingMessages = new Map();
|
|
296
|
+
this.responsePoll = null;
|
|
219
297
|
}
|
|
220
298
|
|
|
221
299
|
/**
|
|
@@ -250,8 +328,8 @@ export class PeerEndpoint {
|
|
|
250
328
|
this.#sweepDeadBridges();
|
|
251
329
|
const procStart = readProcessStart(this.pid);
|
|
252
330
|
const peerToken = crypto.randomBytes(16).toString("hex");
|
|
253
|
-
|
|
254
|
-
this.keyPath =
|
|
331
|
+
this.peerToken = peerToken;
|
|
332
|
+
this.keyPath = peerKeyPath(this.pid, this.socketPath);
|
|
255
333
|
|
|
256
334
|
if (!IS_WINDOWS) fs.mkdirSync(SOCKET_DIR, { recursive: true });
|
|
257
335
|
fs.mkdirSync(sessionsDir(), { recursive: true });
|
|
@@ -264,12 +342,13 @@ export class PeerEndpoint {
|
|
|
264
342
|
});
|
|
265
343
|
if (!IS_WINDOWS) fs.chmodSync(this.socketPath, 0o600);
|
|
266
344
|
|
|
345
|
+
const processIdentity = procStart ? (IS_WINDOWS ? { procStartFt: procStart } : { procStart }) : {};
|
|
267
346
|
this.registry = {
|
|
268
347
|
pid: this.pid,
|
|
269
348
|
sessionId: crypto.randomUUID(),
|
|
270
349
|
cwd: this.cwd,
|
|
271
350
|
startedAt: Date.now(),
|
|
272
|
-
|
|
351
|
+
...processIdentity,
|
|
273
352
|
version: CLAUDE_VERSION_HINT,
|
|
274
353
|
peerProtocol: PEER_PROTOCOL_VERSION,
|
|
275
354
|
kind: "interactive",
|
|
@@ -279,7 +358,7 @@ export class PeerEndpoint {
|
|
|
279
358
|
nameSource: "derived",
|
|
280
359
|
};
|
|
281
360
|
fs.writeFileSync(this.registryPath, JSON.stringify(this.registry), { mode: 0o600 });
|
|
282
|
-
fs.writeFileSync(this.keyPath, JSON.stringify({ peerToken,
|
|
361
|
+
fs.writeFileSync(this.keyPath, JSON.stringify({ peerToken, ...processIdentity }), { mode: 0o600 });
|
|
283
362
|
|
|
284
363
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
285
364
|
process.on(signal, () => {
|
|
@@ -295,37 +374,91 @@ export class PeerEndpoint {
|
|
|
295
374
|
|
|
296
375
|
#handleConnection(socket) {
|
|
297
376
|
let buffer = "";
|
|
377
|
+
let authenticated = false;
|
|
378
|
+
let firstLine = true;
|
|
298
379
|
socket.setEncoding("utf8");
|
|
299
380
|
socket.on("data", (chunk) => {
|
|
300
381
|
buffer += chunk;
|
|
382
|
+
if (buffer.length > 1048576) { socket.destroy(); return; }
|
|
301
383
|
let index;
|
|
302
384
|
while ((index = buffer.indexOf("\n")) >= 0) {
|
|
303
385
|
const line = buffer.slice(0, index).trim();
|
|
304
386
|
buffer = buffer.slice(index + 1);
|
|
305
|
-
if (!line)
|
|
387
|
+
if (!line) {
|
|
388
|
+
if (IS_WINDOWS && !authenticated) { socket.destroy(); return; }
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
306
391
|
let message;
|
|
307
392
|
try {
|
|
393
|
+
const frame = JSON.parse(line);
|
|
394
|
+
if (firstLine && frame?.type === "auth") {
|
|
395
|
+
firstLine = false;
|
|
396
|
+
const supplied = typeof frame.token === "string" ? Buffer.from(frame.token) : Buffer.alloc(0);
|
|
397
|
+
const expected = Buffer.from(this.peerToken ?? "");
|
|
398
|
+
authenticated = expected.length > 0 && supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected);
|
|
399
|
+
if (!authenticated) { socket.destroy(); return; }
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
firstLine = false;
|
|
403
|
+
if (IS_WINDOWS && !authenticated) { socket.destroy(); return; }
|
|
404
|
+
if (frame?.type === "control" && frame.action === "peer_message_status") {
|
|
405
|
+
if (typeof frame.orig_msg_id === "string" && typeof frame.from === "string") {
|
|
406
|
+
const receipt = {
|
|
407
|
+
status: frame.status_detail === "refused" ? "refused" : frame.status,
|
|
408
|
+
reason: typeof frame.reason === "string" ? frame.reason : "",
|
|
409
|
+
fromSocket: decodePeerAddress(frame.from),
|
|
410
|
+
};
|
|
411
|
+
const pending = this.pendingMessages.get(frame.orig_msg_id);
|
|
412
|
+
if (pending?.targetSocket === receipt.fromSocket) {
|
|
413
|
+
this.deliveryReceipts.set(frame.orig_msg_id, receipt);
|
|
414
|
+
if (["refused", "denied", "expired", "dropped"].includes(receipt.status)) this.#removePendingReply(receipt.fromSocket, frame.orig_msg_id);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
308
419
|
message = parseFrame(line);
|
|
309
420
|
} catch {
|
|
310
|
-
|
|
421
|
+
if (IS_WINDOWS && !authenticated) { socket.destroy(); return; }
|
|
422
|
+
this.log("ignored malformed peer frame");
|
|
311
423
|
continue;
|
|
312
424
|
}
|
|
313
|
-
|
|
314
|
-
this
|
|
315
|
-
this.#removePendingReply(record.fromSocket);
|
|
316
|
-
this.log(`inbox <- ${record.fromSocket ?? "?"}: ${record.text.slice(0, 120)}`);
|
|
317
|
-
for (const listener of [...this.listeners]) {
|
|
318
|
-
try {
|
|
319
|
-
listener(record);
|
|
320
|
-
} catch (err) {
|
|
321
|
-
this.log(`peer listener error: ${err.message}`);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
425
|
+
if (!message) continue;
|
|
426
|
+
this.#receiveMessage(message);
|
|
324
427
|
}
|
|
325
428
|
});
|
|
326
429
|
socket.on("error", (err) => this.log(`peer socket error: ${err.message}`));
|
|
327
430
|
}
|
|
328
431
|
|
|
432
|
+
#receiveMessage(message) {
|
|
433
|
+
const record = { ...message, receivedAt: Date.now(), sequence: ++this.messageSequence };
|
|
434
|
+
this.inbox.push(record);
|
|
435
|
+
this.#removePendingReply(record.fromSocket, record.inReplyTo);
|
|
436
|
+
this.log(`inbox <- ${record.fromSocket ?? "?"}: ${record.text.slice(0, 120)}`);
|
|
437
|
+
for (const listener of [...this.listeners]) {
|
|
438
|
+
try { listener(record); }
|
|
439
|
+
catch (err) { this.log(`peer listener error: ${err.message}`); }
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
#refreshTranscriptReplies() {
|
|
444
|
+
for (const [msgId, pending] of this.pendingMessages) {
|
|
445
|
+
if (!pending.transcriptSession) continue;
|
|
446
|
+
const { sessionId, cwd } = pending.transcriptSession;
|
|
447
|
+
const file = findTranscriptFile(sessionId, cwd);
|
|
448
|
+
let stamp;
|
|
449
|
+
try { const stat = fs.statSync(file); stamp = `${stat.mtimeMs}:${stat.size}`; }
|
|
450
|
+
catch { continue; }
|
|
451
|
+
if (stamp === pending.transcriptStamp) continue;
|
|
452
|
+
pending.transcriptStamp = stamp;
|
|
453
|
+
const reply = readTranscriptReply(sessionId, cwd, msgId);
|
|
454
|
+
if (reply) this.#receiveMessage({ ...reply, fromSocket: pending.targetSocket });
|
|
455
|
+
}
|
|
456
|
+
if (![...this.pendingMessages.values()].some((entry) => entry.transcriptSession)) {
|
|
457
|
+
globalThis.clearInterval(this.responsePoll);
|
|
458
|
+
this.responsePoll = null;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
329
462
|
/**
|
|
330
463
|
* Codex starts one bridge per session, so several peers advertise at once.
|
|
331
464
|
* Renaming to the bound thread is what lets a human tell them apart in
|
|
@@ -347,9 +480,11 @@ export class PeerEndpoint {
|
|
|
347
480
|
return () => this.listeners.delete(listener);
|
|
348
481
|
}
|
|
349
482
|
|
|
350
|
-
async send(targetSocket, text, { priority = "next" } = {}) {
|
|
351
|
-
const frame = buildFrame({ text, fromSocket: this.socketPath, priority });
|
|
352
|
-
|
|
483
|
+
async send(targetSocket, text, { priority = "next", msgId } = {}) {
|
|
484
|
+
const frame = buildFrame({ text, fromSocket: this.socketPath, priority, permissionMode: this.permissionMode });
|
|
485
|
+
if (msgId) frame.uuid = frame.msg_id = msgId;
|
|
486
|
+
const token = readPeerToken(targetSocket);
|
|
487
|
+
const line = (token ? JSON.stringify({ type: "auth", token }) + "\n" : "") + JSON.stringify(frame) + "\n";
|
|
353
488
|
for (let attempt = 1; attempt <= PEER_SEND_ATTEMPTS; attempt += 1) {
|
|
354
489
|
try {
|
|
355
490
|
await new Promise((resolve, reject) => {
|
|
@@ -399,7 +534,7 @@ export class PeerEndpoint {
|
|
|
399
534
|
return frame.msg_id;
|
|
400
535
|
}
|
|
401
536
|
|
|
402
|
-
async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next" } = {}) {
|
|
537
|
+
async sendAndWait(targetSocket, text, { timeoutMs = 120000, priority = "next", transcriptSession } = {}) {
|
|
403
538
|
const previous = this.requestQueues.get(targetSocket) ?? Promise.resolve();
|
|
404
539
|
const pending = previous.catch(() => {}).then(async () => {
|
|
405
540
|
const unconfirmed = this.unconfirmedReplies.get(targetSocket) ?? 0;
|
|
@@ -414,16 +549,29 @@ export class PeerEndpoint {
|
|
|
414
549
|
const since = Date.now();
|
|
415
550
|
const afterSequence = this.messageSequence;
|
|
416
551
|
this.unconfirmedReplies.set(targetSocket, unconfirmed + 1);
|
|
417
|
-
let msgId;
|
|
552
|
+
let msgId = crypto.randomUUID();
|
|
553
|
+
this.pendingMessages.set(msgId, { targetSocket, transcriptSession });
|
|
418
554
|
try {
|
|
419
|
-
|
|
555
|
+
const sentId = await this.send(targetSocket, text, { priority, msgId });
|
|
556
|
+
if (sentId !== msgId) {
|
|
557
|
+
const pendingMessage = this.pendingMessages.get(msgId);
|
|
558
|
+
this.pendingMessages.delete(msgId);
|
|
559
|
+
msgId = sentId;
|
|
560
|
+
if (pendingMessage) this.pendingMessages.set(msgId, pendingMessage);
|
|
561
|
+
}
|
|
420
562
|
} catch (err) {
|
|
421
|
-
this.#removePendingReply(targetSocket);
|
|
563
|
+
this.#removePendingReply(targetSocket, msgId);
|
|
422
564
|
throw err;
|
|
423
565
|
}
|
|
566
|
+
if (transcriptSession && !this.responsePoll && this.pendingMessages.has(msgId)) {
|
|
567
|
+
this.responsePoll = globalThis.setInterval(() => this.#refreshTranscriptReplies(), 250);
|
|
568
|
+
this.responsePoll.unref();
|
|
569
|
+
}
|
|
424
570
|
const reply = timeoutMs > 0
|
|
425
|
-
? await this.waitForReply(targetSocket, { timeoutMs, since, afterSequence })
|
|
571
|
+
? await this.waitForReply(targetSocket, { timeoutMs, since, afterSequence, msgId })
|
|
426
572
|
: null;
|
|
573
|
+
const delivery = this.deliveryReceipts.get(msgId);
|
|
574
|
+
if (delivery?.fromSocket === targetSocket) return { msgId, reply, delivery };
|
|
427
575
|
return { msgId, reply };
|
|
428
576
|
});
|
|
429
577
|
this.requestQueues.set(targetSocket, pending);
|
|
@@ -434,7 +582,9 @@ export class PeerEndpoint {
|
|
|
434
582
|
}
|
|
435
583
|
}
|
|
436
584
|
|
|
437
|
-
#removePendingReply(fromSocket) {
|
|
585
|
+
#removePendingReply(fromSocket, msgId) {
|
|
586
|
+
const key = msgId ?? [...this.pendingMessages].find(([, entry]) => entry.targetSocket === fromSocket)?.[0];
|
|
587
|
+
if (key) this.pendingMessages.delete(key);
|
|
438
588
|
const pending = this.unconfirmedReplies.get(fromSocket) ?? 0;
|
|
439
589
|
if (pending > 1) this.unconfirmedReplies.set(fromSocket, pending - 1);
|
|
440
590
|
else this.unconfirmedReplies.delete(fromSocket);
|
|
@@ -444,22 +594,27 @@ export class PeerEndpoint {
|
|
|
444
594
|
* Claude answers with a fresh msg_id rather than an in-reply-to field, so a
|
|
445
595
|
* reply is matched by origin socket and arrival time.
|
|
446
596
|
*/
|
|
447
|
-
waitForReply(fromSocket, { timeoutMs = 120000, since = Date.now(), afterSequence = null } = {}) {
|
|
597
|
+
waitForReply(fromSocket, { timeoutMs = 120000, since = Date.now(), afterSequence = null, msgId } = {}) {
|
|
448
598
|
const matches = (record) => record.fromSocket === fromSocket
|
|
449
599
|
&& (afterSequence === null ? record.receivedAt >= since : record.sequence > afterSequence);
|
|
450
600
|
const existing = this.inbox.find(matches);
|
|
451
601
|
if (existing) return Promise.resolve(existing);
|
|
452
602
|
return new Promise((resolve) => {
|
|
453
|
-
const
|
|
603
|
+
const finish = (reply) => {
|
|
454
604
|
unsubscribe();
|
|
455
|
-
|
|
456
|
-
|
|
605
|
+
globalThis.clearTimeout(timer);
|
|
606
|
+
globalThis.clearInterval(poll);
|
|
607
|
+
resolve(reply);
|
|
608
|
+
};
|
|
609
|
+
const timer = globalThis.setTimeout(() => finish(null), timeoutMs);
|
|
457
610
|
const unsubscribe = this.onMessage((record) => {
|
|
458
611
|
if (!matches(record)) return;
|
|
459
|
-
|
|
460
|
-
unsubscribe();
|
|
461
|
-
resolve(record);
|
|
612
|
+
finish(record);
|
|
462
613
|
});
|
|
614
|
+
const poll = msgId ? globalThis.setInterval(() => {
|
|
615
|
+
const receipt = this.deliveryReceipts.get(msgId);
|
|
616
|
+
if (receipt?.fromSocket === fromSocket && ["held", "refused", "denied", "expired", "dropped"].includes(receipt.status)) { finish(null); return; }
|
|
617
|
+
}, 250) : null;
|
|
463
618
|
});
|
|
464
619
|
}
|
|
465
620
|
|
|
@@ -470,6 +625,9 @@ export class PeerEndpoint {
|
|
|
470
625
|
}
|
|
471
626
|
|
|
472
627
|
stop() {
|
|
628
|
+
globalThis.clearInterval(this.responsePoll);
|
|
629
|
+
this.responsePoll = null;
|
|
630
|
+
this.pendingMessages.clear();
|
|
473
631
|
try {
|
|
474
632
|
this.server?.close();
|
|
475
633
|
} catch {}
|