diff --git a/docs/design/2026-08-31-peer-messaging-inbox-auth.md b/docs/design/2026-08-31-peer-messaging-inbox-auth.md new file mode 100644 index 00000000000..bab64038416 --- /dev/null +++ b/docs/design/2026-08-31-peer-messaging-inbox-auth.md @@ -0,0 +1,74 @@ +# 跨会话消息 inbox 认证令牌 + +状态:按本设计实施中。 + +## 目标与边界 + +给同机跨会话消息(`agents.crossSessionMessaging`)的接收端 socket 增加连接级认证:每个 +inbox 生成一个随机令牌,通过本会话的 session registry 记录(0600)发布;连接方必须在首行 +发送认证行,之后的帧才会被解析。同时把本会话的 socket 路径与令牌导出为环境变量,允许本会话 +启动的子进程(脚本、hook)向本会话注入消息。 + +动机:现状的访问控制只有文件权限(socket 0600 / 目录 0700)。这在 POSIX 上足够挡住其他 +uid,但 (1) 无法迁移到没有该权限语义的传输上——Windows 命名管道支持依赖连接级认证;(2) +socket 路径按 PID 可猜测,而令牌把"能连上"收紧为"能读到接收方的注册记录";(3) 没有认证就 +无法安全开放子进程注入。 + +本期不做:内核级对端凭证(`SO_PEERCRED` 需 native addon,列为已知差距并写入注释)、 +`from` 字段的身份认证(令牌只认证到 inbox 的连接,`from` 仍仅用于回复路由)、Windows 命名 +管道(后续 PR,依赖本期)。 + +## 设计 + +**令牌生成与发布**。`PeerMessaging.start()` 生成 32 字节随机 hex 令牌,传给 +`startPeerInbox`(作为准入要求)并与 `ipcPath` 一起 patch 进 registry 记录(新增可选字段 +`ipcToken`)。记录 0600,同 uid 才可读——令牌的分发即文件权限,与发现(`ipcPath`)同源、 +同可用性:能发现你的 peer 必然能读到你的令牌。registry schema 版本不变(新增可选字段, +旧读者忽略)。 + +**线协议**。连接的首行必须是认证行 `{"msgV":1,"type":"auth","token":""}`。校验通过 +后,后续行按现有帧协议解析;首行不是有效认证行或令牌不符,连接立即断开。认证行不是 +`PeerFrame` 的成员——它是连接层的准入,不进入 `onFrame`。probe(只连接不发数据)不受影响。 + +**回执方向**。用户帧新增可选 `replyToken`:发送方附上自己 inbox 的令牌,接收方用它向 +`from` 地址回执(held/delivered/denied/expired/misaddressed)。不选"接收方查 registry 反查 +令牌":`replyToken` 随帧走免去每次回执的目录扫描,且在同 uid 威胁模型下不引入新暴露——能收 +到你消息的 peer 本就能从注册表读到你的令牌。 + +**已接受的权衡**。PID 复用把地址换了主人时(旧进程死、新进程占同一 PID),拿着旧记录的发送 +方会带旧令牌拨新 inbox,被静默断连——收不到现状会有的 `misaddressed` 回执,账本停在 +pending。发送方每次 `sendToPeer` 都现读注册表,令牌与地址同刻取得,这个窗口只有毫秒级; +同进程内的会话切换(`/clear`、`/resume`,令牌不变)仍走 `misaddressed` 路径不受影响。认证 +先于帧读取的协议必然如此。 + +**兼容性**。功能在实验开关后面,直接收紧、不做协商:新收件端一律要求认证;新发送端在目标 +记录带 `ipcToken` 时发认证行,不带时省略(目标是旧收件端时,旧端把认证行当无法解析的行跳 +过,仍兼容)。旧发送端 → 新收件端会被拒收,属于文档化的实验期破坏。 + +**环境变量**。inbox 绑定并发布成功后设置 `QWEN_CODE_MESSAGING_SOCKET`(本会话 socket 路 +径)与 `QWEN_CODE_MESSAGING_TOKEN`(本会话令牌),子进程继承后可注入消息,走同一入站闸门 +(accept/hold/refuse 判定不变)。close 时清除。 + +## 改动面 + +- `packages/core/src/services/session-registry.ts`:记录新增 `ipcToken?`。 +- `packages/core/src/ipc/peer-frames.ts`:认证行的构造与解析;用户帧 `replyToken?`。 +- `packages/core/src/ipc/uds-inbox.ts`:`requiredToken` 选项与逐连接认证状态;安全模型注释 + 更新。 +- `packages/core/src/ipc/uds-client.ts`:`sendPeerFrame`/`sendDeliveryStatus` 支持携带 + 认证行。 +- `packages/core/src/ipc/peer-directory.ts`:`PeerSessionInfo.ipcToken?`(不进 + `list_agents` 输出——该工具输出为显式字段投影)。 +- `packages/core/src/ipc/peer-send.ts`:发送时携带目标令牌与自身 `replyToken`。 +- `packages/core/src/config/config.ts`、`packages/cli/src/ui/startInteractiveUI.tsx`、 + `packages/cli/src/peerMessaging/peer-messaging.ts`:令牌生成、发布、回执携带、环境变量。 +- `docs/users/features/commands.md` 第 6 节。 + +## 验收 + +- 无认证行 / 错误令牌 / 令牌前发帧:连接断开,帧不进闸门,不产生回执。 +- 正确令牌:行为与现状一致(gate 判定、held、回执、misaddressed 均不变)。 +- 旧收件端(无 `requiredToken`)收到带认证行的发送:认证行被跳过,帧正常送达。 +- 回执沿 `replyToken` 认证送达;无 `replyToken` 的帧回执按旧格式发出(旧发件端场景)。 +- registry 往返:`ipcToken` 写入、读回、`list_agents` 与 `qwen sessions ps` 输出不含令牌。 +- 环境变量在 inbox 就绪后可见,`socat`/`nc` 注入路径可用。 diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index 1d9224e6eca..2d32845ea69 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -836,3 +836,27 @@ agents again) — or released after a hold — a notice appears in the sending session's transcript (`Message to : …`). The model that sent it is not told; if the other session replies, the reply arrives as a cross-session message. + +### Inbox authentication and scripted injection + +Each session's inbox requires a per-session token: a connection must +present it on its first line before any message is read, and sessions +exchange tokens automatically through the same registry records they +discover each other by. Sessions from a build without token support can +receive from a newer one, but their sends to it are dropped. + +A session exports its own inbox address and token to child processes as +`QWEN_CODE_MESSAGING_SOCKET` and `QWEN_CODE_MESSAGING_TOKEN`, so a script +or hook the session runs can send a message back into it: + +```bash +{ printf '%s\n' \ + '{"msgV":1,"type":"auth","token":"'"$QWEN_CODE_MESSAGING_TOKEN"'"}' \ + '{"msgV":1,"msgId":"note-1","type":"user","priority":"next","message":{"role":"user","content":"build finished"}}'; \ +} | socat - UNIX-CONNECT:"$QWEN_CODE_MESSAGING_SOCKET" +``` + +An injected message goes through the same inbound gate as one from +another session: it is marked as not coming from the user, and +`agents.crossSessionInbound` (or the mode-parity default) decides whether +it is delivered or held for review. diff --git a/packages/cli/src/commands/sessions/ps.test.ts b/packages/cli/src/commands/sessions/ps.test.ts index cc2097106a5..f6a1b6d37f7 100644 --- a/packages/cli/src/commands/sessions/ps.test.ts +++ b/packages/cli/src/commands/sessions/ps.test.ts @@ -154,6 +154,18 @@ describe('qwen sessions ps', () => { expect(stdout[0]).not.toContain('\n'); }); + it('strips the inbox auth token from the JSON output', async () => { + listLiveSessions.mockResolvedValue([ + record({ ipcPath: '/tmp/a.sock', ipcToken: 'secret-token' }), + ]); + await run({ json: true }); + + const emitted = JSON.parse(stdout[0]); + expect(emitted.ipcPath).toBe('/tmp/a.sock'); + expect(emitted).not.toHaveProperty('ipcToken'); + expect(stdout[0]).not.toContain('secret-token'); + }); + it('prints nothing on stdout for an empty JSON listing', async () => { listLiveSessions.mockResolvedValue([]); await run({ json: true }); diff --git a/packages/cli/src/commands/sessions/ps.ts b/packages/cli/src/commands/sessions/ps.ts index 88ff5a769b3..b0a91f9c42d 100644 --- a/packages/cli/src/commands/sessions/ps.ts +++ b/packages/cli/src/commands/sessions/ps.ts @@ -107,8 +107,11 @@ async function handlePs(argv: PsArgs): Promise { // with none of the table path's terminal sanitization. That keeps // the output honest data for tooling (and matches the sibling // `sessions list --json`); consumers that RENDER these values in a - // terminal own the sanitization. - writeStdoutLine(JSON.stringify(record)); + // terminal own the sanitization. The inbox token is the one + // exception — a credential, not data: tooling that really needs it + // can read the record file, but it must not spill into logs and + // pipelines by default. + writeStdoutLine(JSON.stringify({ ...record, ipcToken: undefined })); } return; } diff --git a/packages/cli/src/peerMessaging/peer-messaging.test.ts b/packages/cli/src/peerMessaging/peer-messaging.test.ts index ddc1db1460e..8a932edcf00 100644 --- a/packages/cli/src/peerMessaging/peer-messaging.test.ts +++ b/packages/cli/src/peerMessaging/peer-messaging.test.ts @@ -29,6 +29,8 @@ import { } from '@qwen-code/qwen-code-core'; import { MAX_ACCEPTED_BACKLOG, + MESSAGING_SOCKET_ENV, + MESSAGING_TOKEN_ENV, PeerMessaging, type PeerQueuedDelivery, } from './peer-messaging.js'; @@ -57,6 +59,21 @@ vi.mock('node:fs/promises', async (importOriginal) => { const isWindows = process.platform === 'win32'; +/** + * Injected through the `ipcToken` seam so every staged sender — including + * one racing the bind window, where the generated token would not be + * observable yet — can authenticate to the inbox under test. + */ +const TEST_TOKEN = 'test-inbox-token'; + +function send( + socketPath: string, + frame: PeerFrame, + options: { authToken?: string } = { authToken: TEST_TOKEN }, +): Promise { + return sendPeerFrame(socketPath, frame, options); +} + let tmpDir: string; let messaging: PeerMessaging | null = null; /** Stands in for the peer that sent us something, to collect receipts. */ @@ -115,6 +132,7 @@ async function start( getApprovalMode: () => mode, getPolicySetting: () => undefined, updateSessionRegistryIpcPath: async () => {}, + ipcToken: TEST_TOKEN, ...extra, }); if (!started) throw new Error('peer messaging failed to start'); @@ -129,7 +147,7 @@ async function start( describe.skipIf(isWindows)('PeerMessaging', () => { it('delivers an accepted message wrapped in an envelope', async () => { const { messaging: m, submitted } = await start(ApprovalMode.DEFAULT); - await sendPeerFrame( + await send( m.socketPath!, buildUserFrame({ content: 'check the tests over there', @@ -162,7 +180,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { []; m.onReceipt((receipt) => seen.push(receipt)); - await sendPeerFrame( + await send( m.socketPath!, buildDeliveryStatusFrame({ status: 'held', @@ -189,7 +207,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { const seen: unknown[] = []; m.onReceipt((receipt) => seen.push(receipt)); - await sendPeerFrame( + await send( m.socketPath!, buildDeliveryStatusFrame({ status: 'denied', @@ -213,7 +231,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { const seen: unknown[] = []; m.onReceipt((receipt) => seen.push(receipt)); - await sendPeerFrame( + await send( m.socketPath!, buildDeliveryStatusFrame({ status: 'denied', @@ -239,7 +257,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { const seen: unknown[] = []; m.onReceipt((receipt) => seen.push(receipt)); - await sendPeerFrame( + await send( m.socketPath!, buildDeliveryStatusFrame({ status: 'held', @@ -272,7 +290,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { }); m.onReceipt((receipt) => seen.push(receipt)); - await sendPeerFrame( + await send( m.socketPath!, buildDeliveryStatusFrame({ status: 'expired', origMsgId: 'sent-0002' }), ); @@ -291,7 +309,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { from: sender.socketPath, toSessionId: 'session-before', }); - await sendPeerFrame(m.socketPath!, frame); + await send(m.socketPath!, frame); await settle(); expect(submitted).toHaveLength(0); @@ -307,7 +325,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { it('admits a pinned message when it has no session id to judge it against', async () => { const { messaging: m, submitted } = await start(ApprovalMode.DEFAULT); - await sendPeerFrame( + await send( m.socketPath!, buildUserFrame({ content: 'hello', @@ -325,7 +343,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { getSessionId: () => 'session-now', reassertSessionRecord: reassert, }); - await sendPeerFrame( + await send( m.socketPath!, buildUserFrame({ content: 'stale', @@ -351,6 +369,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { getApprovalMode: () => ApprovalMode.DEFAULT, getPolicySetting: () => undefined, updateSessionRegistryIpcPath: async () => {}, + ipcToken: TEST_TOKEN, getSessionId: () => 'session-now', }); // Wait until the listener exists and its chmod is being held. @@ -363,7 +382,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { from: sender.socketPath, toSessionId: 'session-before', }); - await sendPeerFrame(path.join(tmpDir, 'socks', 'self.sock'), frame); + await send(path.join(tmpDir, 'socks', 'self.sock'), frame); await settle(); chmodControl.release?.(); const started = await starting; @@ -389,7 +408,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { const { messaging: m, submitted } = await start(ApprovalMode.DEFAULT, { getSessionId: () => 'session-now', }); - await sendPeerFrame( + await send( m.socketPath!, buildUserFrame({ content: 'hello', @@ -405,7 +424,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { const { messaging: m, submitted } = await start(ApprovalMode.DEFAULT, { getSessionId: () => 'session-now', }); - await sendPeerFrame( + await send( m.socketPath!, buildUserFrame({ content: 'hello', from: '/tmp/peer.sock' }), ); @@ -419,7 +438,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { getSessionId: () => current, }); current = 'session-b'; - await sendPeerFrame( + await send( m.socketPath!, buildUserFrame({ content: 'after /clear', @@ -440,6 +459,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { getApprovalMode: () => mode, getPolicySetting: () => undefined, updateSessionRegistryIpcPath: async () => {}, + ipcToken: TEST_TOKEN, getSessionId: () => current, }); if (!started) throw new Error('peer messaging failed to start'); @@ -455,7 +475,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { fromMode: 'prompting', toSessionId: 'session-a', }); - await sendPeerFrame(started.socketPath!, frame); + await send(started.socketPath!, frame); await settle(); expect(started.getHeld()).toHaveLength(1); @@ -480,6 +500,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { getApprovalMode: () => ApprovalMode.DEFAULT, getPolicySetting: () => undefined, updateSessionRegistryIpcPath: async () => {}, + ipcToken: TEST_TOKEN, getSessionId: () => current, }); if (!started) throw new Error('peer messaging failed to start'); @@ -494,7 +515,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { from: sender.socketPath, toSessionId: 'session-a', }); - await sendPeerFrame(started.socketPath!, frame); + await send(started.socketPath!, frame); await settle(); expect(queued).toEqual([ { @@ -526,7 +547,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { it('holds a message when the receiver bypasses prompts and the sender says nothing', async () => { const { messaging: m, submitted } = await start(ApprovalMode.YOLO); - await sendPeerFrame( + await send( m.socketPath!, buildUserFrame({ content: 'run the deploy', from: '/tmp/peer.sock' }), ); @@ -539,7 +560,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { it('releases a held message when approved', async () => { const { messaging: m, submitted } = await start(ApprovalMode.YOLO); - await sendPeerFrame( + await send( m.socketPath!, buildUserFrame({ content: 'run the deploy', from: '/tmp/peer.sock' }), ); @@ -559,12 +580,13 @@ describe.skipIf(isWindows)('PeerMessaging', () => { getApprovalMode: () => ApprovalMode.DEFAULT, getPolicySetting: () => undefined, updateSessionRegistryIpcPath: async () => {}, + ipcToken: TEST_TOKEN, }); await vi.waitFor(() => { expect(fsSync.existsSync(socketPath)).toBe(true); }); - await sendPeerFrame( + await send( socketPath, buildUserFrame({ content: 'early frame', from: '/tmp/peer.sock' }), ); @@ -590,11 +612,12 @@ describe.skipIf(isWindows)('PeerMessaging', () => { getApprovalMode: () => ApprovalMode.DEFAULT, getPolicySetting: () => undefined, updateSessionRegistryIpcPath: async () => {}, + ipcToken: TEST_TOKEN, }); if (!started) throw new Error('peer messaging failed to start'); messaging = started; - await sendPeerFrame( + await send( started.socketPath!, buildUserFrame({ content: 'early bird', from: '/tmp/peer.sock' }), ); @@ -617,7 +640,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { content: 'hi', from: sender.socketPath, }); - await sendPeerFrame(m.socketPath!, frame); + await send(m.socketPath!, frame); await settle(); expect(receipts).toHaveLength(1); @@ -633,7 +656,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { const { messaging: m } = await start(ApprovalMode.YOLO); const frame = buildUserFrame({ content: 'hi', from: sender.socketPath }); - await sendPeerFrame(m.socketPath!, frame); + await send(m.socketPath!, frame); await settle(); expect(receipts.map((r) => (r as { status: string }).status)).toEqual([ 'held', @@ -652,7 +675,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { const { messaging: m } = await start(ApprovalMode.YOLO); const frame = buildUserFrame({ content: 'hi', from: sender.socketPath }); - await sendPeerFrame(m.socketPath!, frame); + await send(m.socketPath!, frame); await settle(); await m.close(); @@ -668,7 +691,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { it('does not try to answer a sender that gave no reply address', async () => { const { messaging: m } = await start(ApprovalMode.DEFAULT); await expect( - sendPeerFrame(m.socketPath!, buildUserFrame({ content: 'anonymous' })), + send(m.socketPath!, buildUserFrame({ content: 'anonymous' })), ).resolves.toBeUndefined(); await settle(); expect(receipts).toHaveLength(0); @@ -676,7 +699,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { it('ignores an inbound control frame instead of treating it as a message', async () => { const { messaging: m, submitted } = await start(ApprovalMode.DEFAULT); - await sendPeerFrame(m.socketPath!, { + await send(m.socketPath!, { msgV: 1, msgId: 'c1', type: 'control', @@ -696,6 +719,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { getApprovalMode: () => mode, getPolicySetting: () => undefined, updateSessionRegistryIpcPath: async () => {}, + ipcToken: TEST_TOKEN, }); if (!started) throw new Error('peer messaging failed to start'); messaging = started; @@ -704,7 +728,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { return true; }); - await sendPeerFrame( + await send( started.socketPath!, buildUserFrame({ content: 'later', from: '/tmp/peer.sock' }), ); @@ -720,7 +744,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { // start() binds the socket before it returns, so a hold can park // before the UI subscribes; the subscriber must still hear about it. const { messaging: m } = await start(ApprovalMode.YOLO); - await sendPeerFrame( + await send( m.socketPath!, buildUserFrame({ content: 'early hold', from: '/tmp/peer.sock' }), ); @@ -742,6 +766,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { getApprovalMode: () => ApprovalMode.DEFAULT, getPolicySetting: () => undefined, updateSessionRegistryIpcPath: async () => {}, + ipcToken: TEST_TOKEN, }); if (!started) throw new Error('peer messaging failed to start'); messaging = started; @@ -757,7 +782,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { const overflow = 5; for (let i = 0; i < MAX_ACCEPTED_BACKLOG + overflow; i++) { - await sendPeerFrame( + await send( started.socketPath!, buildUserFrame({ content: `flood ${i}`, from: sender.socketPath }), ); @@ -783,13 +808,14 @@ describe.skipIf(isWindows)('PeerMessaging', () => { getApprovalMode: () => ApprovalMode.DEFAULT, getPolicySetting: () => undefined, updateSessionRegistryIpcPath: async () => {}, + ipcToken: TEST_TOKEN, }); if (!started) throw new Error('peer messaging failed to start'); messaging = started; const overflow = 5; for (let i = 0; i < MAX_ACCEPTED_BACKLOG + overflow; i++) { - await sendPeerFrame( + await send( started.socketPath!, buildUserFrame({ content: `early ${i}`, from: sender.socketPath }), ); @@ -828,7 +854,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { const heldCount = 40; for (let i = 0; i < heldCount; i++) { - await sendPeerFrame( + await send( m.socketPath!, buildUserFrame({ content: `hold ${i}`, from: sender.socketPath }), ); @@ -850,6 +876,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { getApprovalMode: () => ApprovalMode.DEFAULT, getPolicySetting: () => undefined, updateSessionRegistryIpcPath: async () => {}, + ipcToken: TEST_TOKEN, }); if (!started) throw new Error('peer messaging failed to start'); messaging = started; @@ -859,7 +886,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { content: 'early bird', from: sender.socketPath, }); - await sendPeerFrame(started.socketPath!, frame); + await send(started.socketPath!, frame); await settle(); expect(receipts.map((r) => (r as { status: string }).status)).toEqual([ 'delivered', @@ -889,6 +916,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { getApprovalMode: () => ApprovalMode.DEFAULT, getPolicySetting: () => undefined, updateSessionRegistryIpcPath: async () => {}, + ipcToken: TEST_TOKEN, }); if (!started) throw new Error('peer messaging failed to start'); messaging = started; @@ -908,8 +936,8 @@ describe.skipIf(isWindows)('PeerMessaging', () => { content: 'waiting', from: sender.socketPath, }); - await sendPeerFrame(started.socketPath!, consumed); - await sendPeerFrame(started.socketPath!, waiting); + await send(started.socketPath!, consumed); + await send(started.socketPath!, waiting); await settle(); expect(queued).toHaveLength(2); @@ -937,6 +965,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { getApprovalMode: () => ApprovalMode.DEFAULT, getPolicySetting: () => undefined, updateSessionRegistryIpcPath: async () => {}, + ipcToken: TEST_TOKEN, }); if (!started) throw new Error('peer messaging failed to start'); messaging = started; @@ -945,7 +974,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { buildUserFrame({ content: `mixed ${i}`, from: sender.socketPath }), ); for (const frame of frames) { - await sendPeerFrame(started.socketPath!, frame); + await send(started.socketPath!, frame); } await settle(); @@ -982,6 +1011,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { getApprovalMode: () => mode, getPolicySetting: () => undefined, updateSessionRegistryIpcPath: async () => {}, + ipcToken: TEST_TOKEN, }); if (!started) throw new Error('peer messaging failed to start'); messaging = started; @@ -991,13 +1021,13 @@ describe.skipIf(isWindows)('PeerMessaging', () => { content: 'BODY-1', from: sender.socketPath, }); - await sendPeerFrame(started.socketPath!, target); + await send(started.socketPath!, target); await settle(); started.recordHeldListing(started.getHeld()); // Evict the target with newer holds, then release them again. for (let i = 0; i < MAX_HELD_MESSAGES; i++) { - await sendPeerFrame( + await send( started.socketPath!, buildUserFrame({ content: `evict ${i}`, from: sender.socketPath }), ); @@ -1008,7 +1038,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { // Prune the target's tombstone with MAX_SETTLED_IDS fresh settlements. for (let i = 0; i < MAX_SETTLED_IDS; i++) { - await sendPeerFrame( + await send( started.socketPath!, buildUserFrame({ content: `churn ${i}`, from: sender.socketPath }), ); @@ -1016,7 +1046,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { // The id is re-admittable now; ids and order match the old listing. mode = ApprovalMode.YOLO; - await sendPeerFrame(started.socketPath!, { + await send(started.socketPath!, { ...target, message: { role: 'user', content: 'BODY-2' }, }); @@ -1032,3 +1062,85 @@ describe.skipIf(isWindows)('PeerMessaging', () => { messaging = null; }); }); + +describe.skipIf(isWindows)('inbox auth wiring', () => { + it('drops an unauthenticated frame before the gate sees it', async () => { + const { messaging: m, submitted } = await start(ApprovalMode.DEFAULT); + await send( + m.socketPath!, + buildUserFrame({ content: 'no token', from: '/tmp/peer.sock' }), + {}, + ).catch(() => { + // The inbox may reset the connection mid-write. + }); + await settle(); + expect(submitted).toHaveLength(0); + expect(m.getHeld()).toHaveLength(0); + }); + + it('publishes the token alongside the socket path, and clears both', async () => { + const published: Array<[string | undefined, string | undefined]> = []; + const started = await PeerMessaging.start({ + socketPath: path.join(tmpDir, 'socks', 'self.sock'), + getApprovalMode: () => ApprovalMode.DEFAULT, + getPolicySetting: () => undefined, + updateSessionRegistryIpcPath: async (ipcPath, ipcToken) => { + published.push([ipcPath, ipcToken]); + }, + ipcToken: TEST_TOKEN, + }); + if (!started) throw new Error('peer messaging failed to start'); + messaging = started; + + expect(published).toEqual([[started.socketPath, TEST_TOKEN]]); + expect(process.env[MESSAGING_SOCKET_ENV]).toBe(started.socketPath); + expect(process.env[MESSAGING_TOKEN_ENV]).toBe(TEST_TOKEN); + + await started.close(); + messaging = null; + expect(published[1]).toEqual([undefined, undefined]); + expect(process.env[MESSAGING_SOCKET_ENV]).toBeUndefined(); + expect(process.env[MESSAGING_TOKEN_ENV]).toBeUndefined(); + }); + + it('authenticates receipts with the reply token the frame offered', async () => { + // The sender's inbox requires its own token; a receipt can only land + // if it carries the replyToken from the original frame. + const SENDER_TOKEN = 'sender-inbox-token'; + const sender = await startPeerInbox({ + socketPath: path.join(tmpDir, 'socks', 'sender.sock'), + requiredToken: SENDER_TOKEN, + onFrame: (frame) => receipts.push(frame), + }); + if (!sender) throw new Error('sender inbox failed to start'); + senderInbox = sender; + + const { messaging: m } = await start(ApprovalMode.DEFAULT, { + getSessionId: () => 'session-now', + }); + const withToken = buildUserFrame({ + content: 'stale pin', + from: sender.socketPath, + replyToken: SENDER_TOKEN, + toSessionId: 'session-before', + }); + await send(m.socketPath!, withToken); + await settle(); + expect(receipts).toHaveLength(1); + expect(receipts[0]).toMatchObject({ + type: 'control', + status: 'misaddressed', + origMsgId: withToken.msgId, + }); + + // Without a replyToken the receipt bounces off the sender's own auth. + const withoutToken = buildUserFrame({ + content: 'stale pin, old sender', + from: sender.socketPath, + toSessionId: 'session-before', + }); + await send(m.socketPath!, withoutToken); + await settle(); + expect(receipts).toHaveLength(1); + }); +}); diff --git a/packages/cli/src/peerMessaging/peer-messaging.ts b/packages/cli/src/peerMessaging/peer-messaging.ts index 0b028796cd3..25516e51c59 100644 --- a/packages/cli/src/peerMessaging/peer-messaging.ts +++ b/packages/cli/src/peerMessaging/peer-messaging.ts @@ -18,6 +18,7 @@ * that it needed to wait. */ +import { randomBytes } from 'node:crypto'; import { type ApprovalMode, createDebugLogger, @@ -43,9 +44,18 @@ const debugLogger = createDebugLogger('PEER_MESSAGING'); export interface PeerQueuedDelivery { msgId: string; from?: string; + replyToken?: string; toSessionId?: string; } +/** + * Where child processes of this session find its inbox, so a script or + * hook it runs can inject a message back into it (through the same + * inbound gate as any peer). Cleared when the inbox closes. + */ +export const MESSAGING_SOCKET_ENV = 'QWEN_CODE_MESSAGING_SOCKET'; +export const MESSAGING_TOKEN_ENV = 'QWEN_CODE_MESSAGING_TOKEN'; + /** * Submit an already-formatted message into the session's input queue. * Returns false when the queue is too full to take it — the frame is then @@ -82,7 +92,10 @@ export interface PeerReceipt { export interface PeerMessagingOptions { getApprovalMode: () => ApprovalMode | null; getPolicySetting: () => InboundPolicy | undefined; - updateSessionRegistryIpcPath: (ipcPath: string | undefined) => Promise; + updateSessionRegistryIpcPath: ( + ipcPath: string | undefined, + ipcToken?: string, + ) => Promise; /** * Apply a receipt to the send it answers, returning the send only when * the receipt moved it to a new state. Defaults to the send-side @@ -106,6 +119,12 @@ export interface PeerMessagingOptions { */ getSessionId?: () => string; socketPath?: string; + /** + * Overrides the generated inbox token. A test seam like `socketPath`: + * a frame staged before `start` resolves must already authenticate, + * and the generated token is not observable until after. + */ + ipcToken?: string; } export class PeerMessaging { @@ -113,6 +132,7 @@ export class PeerMessaging { private gate: InboundGate | null = null; private updateSessionRegistryIpcPath: ( ipcPath: string | undefined, + ipcToken?: string, ) => Promise = async () => {}; private getSessionId: (() => string) | null = null; private settleSentMessage: ( @@ -159,11 +179,15 @@ export class PeerMessaging { deliver: (frame) => messaging.deliver(frame), reportStatus: (frame, status) => { if (!frame.from) return; - return sendDeliveryStatus(frame.from, { - status, - origMsgId: frame.msgId, - from: messaging.inbox?.socketPath, - }); + return sendDeliveryStatus( + frame.from, + { + status, + origMsgId: frame.msgId, + from: messaging.inbox?.socketPath, + }, + frame.replyToken, + ); }, onHeldChange: (held) => messaging.emitHeldChange(held), }); @@ -181,10 +205,12 @@ export class PeerMessaging { options.settleSentMessage ?? settleSentPeerMessage; messaging.reassertSessionRecord = options.reassertSessionRecord ?? null; + const ipcToken = options.ipcToken ?? randomBytes(32).toString('hex'); const inbox = await startPeerInbox({ ...(options.socketPath !== undefined ? { socketPath: options.socketPath } : {}), + requiredToken: ipcToken, onFrame: (frame) => messaging.onFrame(frame), }); if (!inbox) return null; @@ -196,7 +222,15 @@ export class PeerMessaging { // Advertise the address only once the socket is actually accepting. // Publishing it earlier would hand peers an address that refuses // connections, which reads to them as "the session just exited". - await messaging.updateSessionRegistryIpcPath(inbox.socketPath); + // The token travels in the same record: discovering the address and + // being able to authenticate to it are one capability. + await messaging.updateSessionRegistryIpcPath(inbox.socketPath, ipcToken); + + // Exported even if the registry publish above failed: children inherit + // the environment, not the record, and the inbox is accepting either + // way. + process.env[MESSAGING_SOCKET_ENV] = inbox.socketPath; + process.env[MESSAGING_TOKEN_ENV] = ipcToken; return messaging; } @@ -331,6 +365,8 @@ export class PeerMessaging { await this.gate?.shutdown(); await this.settleUnconsumed(); await this.inbox?.close(); + delete process.env[MESSAGING_SOCKET_ENV]; + delete process.env[MESSAGING_TOKEN_ENV]; await this.updateSessionRegistryIpcPath(undefined); } @@ -349,11 +385,15 @@ export class PeerMessaging { const receipts = dropped .filter((frame) => frame.from !== undefined) .map((frame) => - sendDeliveryStatus(frame.from!, { - status: 'expired', - origMsgId: frame.msgId, - from: this.inbox?.socketPath, - }), + sendDeliveryStatus( + frame.from!, + { + status: 'expired', + origMsgId: frame.msgId, + from: this.inbox?.socketPath, + }, + frame.replyToken, + ), ); await Promise.allSettled(receipts); } @@ -400,11 +440,15 @@ export class PeerMessaging { `refusing peer message ${frame.msgId}: addressed to session ${frame.toSessionId}, this is ${ownSessionId}`, ); if (frame.from) { - void sendDeliveryStatus(frame.from, { - status: 'misaddressed', - origMsgId: frame.msgId, - from: this.inbox?.socketPath, - }); + void sendDeliveryStatus( + frame.from, + { + status: 'misaddressed', + origMsgId: frame.msgId, + from: this.inbox?.socketPath, + }, + frame.replyToken, + ); } void this.reassertSessionRecord?.().catch((error) => { debugLogger.debug( @@ -468,6 +512,9 @@ export class PeerMessaging { { msgId: frame.msgId, ...(frame.from !== undefined ? { from: frame.from } : {}), + ...(frame.replyToken !== undefined + ? { replyToken: frame.replyToken } + : {}), ...(frame.toSessionId !== undefined ? { toSessionId: frame.toSessionId } : {}), @@ -490,11 +537,15 @@ export class PeerMessaging { `dropping queued peer message ${delivery.msgId}: addressed to session ${delivery.toSessionId}, this is ${ownSessionId}`, ); if (delivery.from) { - void sendDeliveryStatus(delivery.from, { - status: 'misaddressed', - origMsgId: delivery.msgId, - from: this.inbox?.socketPath, - }); + void sendDeliveryStatus( + delivery.from, + { + status: 'misaddressed', + origMsgId: delivery.msgId, + from: this.inbox?.socketPath, + }, + delivery.replyToken, + ); } return false; } diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx index f187dfb638f..50bb9ecc50c 100644 --- a/packages/cli/src/ui/startInteractiveUI.tsx +++ b/packages/cli/src/ui/startInteractiveUI.tsx @@ -451,8 +451,8 @@ export async function startInteractiveUI( settings.merged.agents?.crossSessionInbound as | InboundPolicy | undefined, - updateSessionRegistryIpcPath: (ipcPath) => - config.updateSessionRegistryIpcPath(ipcPath), + updateSessionRegistryIpcPath: (ipcPath, ipcToken) => + config.updateSessionRegistryIpcPath(ipcPath, ipcToken), getSessionId: () => config.getSessionId(), reassertSessionRecord: () => config.reassertSessionRegistryRecord(), }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 7ba4ce1a115..9ad566fc7ff 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -4565,11 +4565,12 @@ export class Config { /** Serialize the peer inbox address with every other registry patch. */ async updateSessionRegistryIpcPath( ipcPath: string | undefined, + ipcToken?: string, ): Promise { if (!this.sessionRegistryActive) return; let applied = false; this.queueSessionRegistryWrite(async () => { - applied = await patchSessionRecord({ ipcPath }); + applied = await patchSessionRecord({ ipcPath, ipcToken }); if (ipcPath === undefined || applied) return; // The advertise is one-shot: no later patch re-asserts ipcPath, and // every skip is transient (the fd-pressure window on this process's @@ -4578,7 +4579,7 @@ export class Config { // session would keep a live inbox no peer can ever discover. for (let attempt = 0; attempt < 2 && !applied; attempt += 1) { await delay(250); - applied = await patchSessionRecord({ ipcPath }); + applied = await patchSessionRecord({ ipcPath, ipcToken }); } if (!applied) { this.debugLogger.warn( diff --git a/packages/core/src/ipc/peer-directory.ts b/packages/core/src/ipc/peer-directory.ts index 6cee3377baa..185afb830c8 100644 --- a/packages/core/src/ipc/peer-directory.ts +++ b/packages/core/src/ipc/peer-directory.ts @@ -37,6 +37,12 @@ export interface PeerSessionInfo { cwd: string; pid: number; ipcPath: string; + /** + * Inbox auth token from the peer's record. Absent for a peer written by + * a build without tokens. Never printed: `list_agents` projects + * explicit fields, and this must stay out of any model-visible output. + */ + ipcToken?: string; startedAt: number; } @@ -74,6 +80,7 @@ export function toPeerSessionInfo( cwd: flattenPeerLabel(record.cwd), pid: record.pid, ipcPath: record.ipcPath, + ...(record.ipcToken !== undefined ? { ipcToken: record.ipcToken } : {}), startedAt: record.startedAt, }; } diff --git a/packages/core/src/ipc/peer-frames.test.ts b/packages/core/src/ipc/peer-frames.test.ts index b9b614e5b18..f1bbf5d43f1 100644 --- a/packages/core/src/ipc/peer-frames.test.ts +++ b/packages/core/src/ipc/peer-frames.test.ts @@ -6,11 +6,13 @@ import { describe, it, expect } from 'vitest'; import { + buildAuthLine, buildDeliveryStatusFrame, buildUserFrame, canonicalizeMsgId, describeDeliveryStatus, encodePeerFrame, + parsePeerAuthLine, parsePeerFrame, PEER_FRAME_VERSION, } from './peer-frames.js'; @@ -245,6 +247,12 @@ describe('round trip', () => { expect('toSessionId' in buildUserFrame({ content: 'hi' })).toBe(false); }); + it('round-trips the reply token, and omits its key when absent', () => { + const frame = buildUserFrame({ content: 'hi', replyToken: 'tok' }); + expect(parsePeerFrame(encodePeerFrame(frame).trimEnd())).toEqual(frame); + expect('replyToken' in buildUserFrame({ content: 'hi' })).toBe(false); + }); + it('survives content containing newlines', () => { const frame = buildUserFrame({ content: 'line one\nline two' }); const encoded = encodePeerFrame(frame); @@ -282,3 +290,33 @@ describe('delivery status frames', () => { expect(frame.origMsgId).toBe('abc'); }); }); + +describe('auth lines', () => { + it('round-trips a token on one newline-terminated line', () => { + const line = buildAuthLine('tok-123'); + expect(line.endsWith('\n')).toBe(true); + expect(line.indexOf('\n')).toBe(line.length - 1); + expect(parsePeerAuthLine(line.trimEnd())).toBe('tok-123'); + }); + + it('is not a peer frame — a tokenless inbox skips it as unparseable', () => { + expect(parsePeerFrame(buildAuthLine('tok').trimEnd())).toBeNull(); + }); + + it('rejects everything that is not exactly an auth line', () => { + expect(parsePeerAuthLine('not json')).toBeNull(); + expect(parsePeerAuthLine(line({ ...validUser }))).toBeNull(); + expect(parsePeerAuthLine(line({ msgV: 1, type: 'auth' }))).toBeNull(); + expect( + parsePeerAuthLine(line({ msgV: 1, type: 'auth', token: '' })), + ).toBeNull(); + expect( + parsePeerAuthLine(line({ msgV: 1, type: 'auth', token: 42 })), + ).toBeNull(); + expect( + parsePeerAuthLine( + line({ msgV: PEER_FRAME_VERSION + 1, type: 'auth', token: 'tok' }), + ), + ).toBeNull(); + }); +}); diff --git a/packages/core/src/ipc/peer-frames.ts b/packages/core/src/ipc/peer-frames.ts index ca4271f8b22..6d40f7aae43 100644 --- a/packages/core/src/ipc/peer-frames.ts +++ b/packages/core/src/ipc/peer-frames.ts @@ -61,6 +61,15 @@ export interface PeerUserFrame { type: 'user'; /** Reply address: the sender's own socket path, or absent if it has none. */ from?: string; + /** + * Auth token for the sender's own inbox at `from`, so the receiver can + * authenticate its delivery receipts. Carried in the frame rather than + * looked up from the registry per receipt: a peer this session accepted + * a message from could read the token from the sender's 0600 record + * anyway, so nothing new is exposed. Untrusted like every field here — + * a wrong value just makes the best-effort receipt bounce. + */ + replyToken?: string; /** Sender's display name, for the envelope shown to the model. */ fromName?: string; /** @@ -166,11 +175,13 @@ export function parsePeerFrame(line: string): PeerFrame | null { const priority = parsed['priority']; const fromMode = parsed['fromMode']; const toSessionId = optionalString(parsed['toSessionId']); + const replyToken = optionalString(parsed['replyToken']); return { msgV, msgId, type: 'user', from: optionalString(parsed['from']), + ...(replyToken !== undefined ? { replyToken } : {}), fromName: optionalString(parsed['fromName']), ...(fromMode === 'bypass' || fromMode === 'prompting' ? { fromMode } @@ -219,6 +230,7 @@ export function encodePeerFrame(frame: PeerFrame): string { export interface BuildUserFrameFields { content: string; from?: string; + replyToken?: string; fromName?: string; fromMode?: 'bypass' | 'prompting'; toSessionId?: string; @@ -231,6 +243,9 @@ export function buildUserFrame(fields: BuildUserFrameFields): PeerUserFrame { msgId: randomUUID(), type: 'user', ...(fields.from !== undefined ? { from: fields.from } : {}), + ...(fields.replyToken !== undefined + ? { replyToken: fields.replyToken } + : {}), ...(fields.fromName !== undefined ? { fromName: fields.fromName } : {}), ...(fields.fromMode !== undefined ? { fromMode: fields.fromMode } : {}), ...(fields.toSessionId !== undefined @@ -265,6 +280,37 @@ export function describeDeliveryStatus(status: PeerDeliveryStatus): string { } } +/** + * The connection-level admission line, not a member of {@link PeerFrame}: + * an inbox that requires a token reads it off the first line of a + * connection before any frame is parsed, and it never reaches `onFrame`. + * + * Shaped like a frame (`msgV` + `type`) so an inbox that does NOT require + * a token — an older build — sees an unknown `type` in `parsePeerFrame`, + * skips the line, and reads the frames after it: a sender can therefore + * always lead with the auth line when it has the peer's token, without + * knowing which side of the upgrade the peer is on. + */ +export function buildAuthLine(token: string): string { + return `${JSON.stringify({ msgV: PEER_FRAME_VERSION, type: 'auth', token })}\n`; +} + +/** The token an auth line presents, or null if the line is not one. */ +export function parsePeerAuthLine(line: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (!isRecord(parsed)) return null; + const msgV = parsed['msgV']; + if (typeof msgV !== 'number' || msgV > PEER_FRAME_VERSION) return null; + if (parsed['type'] !== 'auth') return null; + const token = parsed['token']; + return typeof token === 'string' && token.length > 0 ? token : null; +} + export function buildDeliveryStatusFrame(fields: { status: PeerDeliveryStatus; origMsgId: string; diff --git a/packages/core/src/ipc/peer-send.test.ts b/packages/core/src/ipc/peer-send.test.ts index e613d53faf6..147a5a426f4 100644 --- a/packages/core/src/ipc/peer-send.test.ts +++ b/packages/core/src/ipc/peer-send.test.ts @@ -167,6 +167,37 @@ describe('sendToPeer', () => { }); }); + it('authenticates with the target token and offers its own for receipts', async () => { + readOwnSessionRecord.mockResolvedValue({ ...SELF, ipcToken: 'own-token' }); + listMessageablePeers.mockResolvedValue([ + { ...peer('s1', 'app-ab'), ipcToken: 'target-token' }, + ]); + + await sendToPeer({ + target: 'app-ab', + message: 'hi', + approvalMode: ApprovalMode.DEFAULT, + }); + + const [, frame, options] = sendPeerFrame.mock.calls[0]; + expect(frame).toMatchObject({ replyToken: 'own-token' }); + expect(options).toEqual({ authToken: 'target-token' }); + }); + + it('omits tokens for records written before tokens existed', async () => { + listMessageablePeers.mockResolvedValue([peer('s1', 'app-ab')]); + + await sendToPeer({ + target: 'app-ab', + message: 'hi', + approvalMode: ApprovalMode.DEFAULT, + }); + + const [, frame, options] = sendPeerFrame.mock.calls[0]; + expect(frame).not.toHaveProperty('replyToken'); + expect(options).toEqual({}); + }); + it('asserts bypass when this session no longer reviews its actions', async () => { listMessageablePeers.mockResolvedValue([peer('s1', 'app-ab')]); for (const mode of [ diff --git a/packages/core/src/ipc/peer-send.ts b/packages/core/src/ipc/peer-send.ts index 1e29b678201..0026e325806 100644 --- a/packages/core/src/ipc/peer-send.ts +++ b/packages/core/src/ipc/peer-send.ts @@ -314,6 +314,8 @@ export async function sendToPeer( const frame = buildUserFrame({ content: options.message, from: self.ipcPath, + // Our own inbox token, so the receiver's receipts authenticate back. + ...(self.ipcToken !== undefined ? { replyToken: self.ipcToken } : {}), fromName: self.name, // Pin the frame to the session the name resolved to. The address is // keyed by PID, and PIDs get reused: if that session has since been @@ -336,7 +338,9 @@ export async function sendToPeer( state: 'pending', }); try { - await sendPeerFrame(peer.ipcPath, frame); + await sendPeerFrame(peer.ipcPath, frame, { + ...(peer.ipcToken !== undefined ? { authToken: peer.ipcToken } : {}), + }); return { kind: 'sent', peer, address }; } catch (error) { if ( diff --git a/packages/core/src/ipc/uds-client.ts b/packages/core/src/ipc/uds-client.ts index a5e7d0322cd..2606295ba7b 100644 --- a/packages/core/src/ipc/uds-client.ts +++ b/packages/core/src/ipc/uds-client.ts @@ -17,6 +17,7 @@ import * as net from 'node:net'; import { createDebugLogger } from '../utils/debugLogger.js'; import { + buildAuthLine, buildDeliveryStatusFrame, encodePeerFrame, MAX_FRAME_BYTES, @@ -69,6 +70,18 @@ export class PeerSendError extends Error { } } +export interface SendPeerFrameOptions { + timeoutMs?: number; + /** + * The receiver's inbox token, sent as an auth line ahead of the frame. + * Omitted when the receiver's record advertises none — an inbox that + * requires one then drops the connection, which is the documented + * old-sender/new-receiver break; an inbox that requires none skips the + * line as unparseable, so leading with it is always safe. + */ + authToken?: string; +} + /** * Write one frame to `socketPath`. * @@ -87,8 +100,9 @@ export class PeerSendError extends Error { export function sendPeerFrame( socketPath: string, frame: PeerFrame, - timeoutMs: number = SEND_TIMEOUT_MS, + options: SendPeerFrameOptions = {}, ): Promise { + const timeoutMs = options.timeoutMs ?? SEND_TIMEOUT_MS; return new Promise((resolve, reject) => { if (!isLocalIpcPath(socketPath)) { reject( @@ -149,7 +163,14 @@ export function sendPeerFrame( }, timeoutMs); socket.on('error', fail); socket.on('connect', () => { - socket.end(encoded); + // The auth line rides in the same write as the frame: the receiver + // reads lines in order, and a separate write would only open a + // window for a partial flush to strand the frame unauthenticated. + socket.end( + options.authToken !== undefined + ? buildAuthLine(options.authToken) + encoded + : encoded, + ); }); socket.on('close', () => { if (settled) return; @@ -172,9 +193,12 @@ export function sendPeerFrame( export async function sendDeliveryStatus( socketPath: string, fields: { status: PeerDeliveryStatus; origMsgId: string; from?: string }, + authToken?: string, ): Promise { try { - await sendPeerFrame(socketPath, buildDeliveryStatusFrame(fields)); + await sendPeerFrame(socketPath, buildDeliveryStatusFrame(fields), { + ...(authToken !== undefined ? { authToken } : {}), + }); } catch (error) { debugLogger.debug( `delivery-status (${fields.status}) to ${socketPath} failed: ${ diff --git a/packages/core/src/ipc/uds-inbox.test.ts b/packages/core/src/ipc/uds-inbox.test.ts index f24662b0cb7..78b392417d7 100644 --- a/packages/core/src/ipc/uds-inbox.test.ts +++ b/packages/core/src/ipc/uds-inbox.test.ts @@ -17,6 +17,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { MAX_FRAME_BYTES, + buildAuthLine, buildUserFrame, encodePeerFrame, type PeerFrame, @@ -281,6 +282,81 @@ describe.skipIf(isWindows)('framing', () => { }); }); +describe.skipIf(isWindows)('inbox auth', () => { + const TOKEN = 'a'.repeat(64); + + async function listenWithToken(): Promise { + const started = await startPeerInbox({ + socketPath: path.join(tmpDir, 'socks', 'auth.sock'), + requiredToken: TOKEN, + onFrame: (frame) => received.push(frame), + }); + if (!started) throw new Error('inbox failed to start'); + inbox = started; + return started; + } + + it('delivers a frame preceded by the right token', async () => { + const started = await listenWithToken(); + await sendPeerFrame(started.socketPath, buildUserFrame({ content: 'hi' }), { + authToken: TOKEN, + }); + await settle(); + expect(received).toHaveLength(1); + }); + + it('drops the connection on a wrong token, frames unread', async () => { + const started = await listenWithToken(); + await writeRaw(started.socketPath, [ + buildAuthLine('b'.repeat(64)) + + encodePeerFrame(buildUserFrame({ content: 'stolen' })), + ]).catch(() => { + // The server may reset the connection mid-write. + }); + await settle(); + expect(received).toHaveLength(0); + }); + + it('drops a connection whose first line is a frame, not an auth line', async () => { + const started = await listenWithToken(); + await writeRaw(started.socketPath, [ + encodePeerFrame(buildUserFrame({ content: 'unauthenticated' })) + + buildAuthLine(TOKEN) + + encodePeerFrame(buildUserFrame({ content: 'late auth' })), + ]).catch(() => {}); + await settle(); + // Neither the pre-auth frame nor anything after the destroy arrives. + expect(received).toHaveLength(0); + }); + + it('reads several frames after one auth line on the same connection', async () => { + const started = await listenWithToken(); + await writeRaw(started.socketPath, [ + buildAuthLine(TOKEN) + + encodePeerFrame(buildUserFrame({ content: 'one' })) + + encodePeerFrame(buildUserFrame({ content: 'two' })), + ]); + await settle(); + expect( + received.map( + (f) => (f as { message: { content: string } }).message.content, + ), + ).toEqual(['one', 'two']); + }); + + it('an inbox without a required token skips a leading auth line', async () => { + // The old-receiver case: a sender always leads with the auth line + // when it has a token, and a pre-token inbox must read past it. + const started = await listen(); + await sendPeerFrame(started.socketPath, buildUserFrame({ content: 'hi' }), { + authToken: TOKEN, + }); + await settle(); + expect(received).toHaveLength(1); + expect(received[0]).toMatchObject({ message: { content: 'hi' } }); + }); +}); + describe.skipIf(isWindows)('client errors', () => { it('reports ENOENT for a socket that does not exist', async () => { const missing = path.join(tmpDir, 'nope.sock'); @@ -338,7 +414,9 @@ describe.skipIf(isWindows)('client errors', () => { try { const startedAt = Date.now(); await expect( - sendPeerFrame(dribblePath, buildUserFrame({ content: 'hi' }), 500), + sendPeerFrame(dribblePath, buildUserFrame({ content: 'hi' }), { + timeoutMs: 500, + }), ).rejects.toMatchObject({ name: 'PeerSendError', code: 'ETIMEDOUT' }); expect(Date.now() - startedAt).toBeLessThan(3000); } finally { @@ -363,15 +441,15 @@ describe.skipIf(isWindows)('client errors', () => { const pending: Array> = []; for (let i = 0; i < MAX_CONCURRENT_SENDS; i += 1) { pending.push( - sendPeerFrame( - stallPath, - buildUserFrame({ content: 'hi' }), - 1000, - ).catch(() => {}), + sendPeerFrame(stallPath, buildUserFrame({ content: 'hi' }), { + timeoutMs: 1000, + }).catch(() => {}), ); } await expect( - sendPeerFrame(stallPath, buildUserFrame({ content: 'hi' }), 1000), + sendPeerFrame(stallPath, buildUserFrame({ content: 'hi' }), { + timeoutMs: 1000, + }), ).rejects.toMatchObject({ name: 'PeerSendError', code: 'EBUSY' }); await Promise.all(pending); } finally { diff --git a/packages/core/src/ipc/uds-inbox.ts b/packages/core/src/ipc/uds-inbox.ts index 2e019c5e7c9..7b2ab4ee74e 100644 --- a/packages/core/src/ipc/uds-inbox.ts +++ b/packages/core/src/ipc/uds-inbox.ts @@ -8,16 +8,22 @@ * Server side of same-machine peer messaging: one UNIX domain socket per * session, accepting NDJSON frames. * - * Access control is filesystem permissions and nothing else. The socket - * directory is 0700 and the socket itself is 0600, so only this uid can - * connect. Node cannot read `SO_PEERCRED` without a native addon, so a - * frame's claimed origin is *not* authenticated beyond that: any process - * running as this user can write any `from` it likes. Everything - * downstream is built on that assumption — the inbound gate decides - * whether a message may act, and the envelope tells the model the content - * is not from its user. + * Access control is filesystem permissions plus a connection token. The + * socket directory is 0700 and the socket itself is 0600, so only this + * uid can connect; when `requiredToken` is set, a connection must also + * present it on its first line before any frame is read, which narrows + * "can reach the socket path" to "can read this session's 0600 registry + * record" and is what a permissionless transport (a named pipe) will rely + * on entirely. The token authenticates the connection, not the sender: + * Node cannot read `SO_PEERCRED` without a native addon, so a frame's + * claimed `from` is still unauthenticated and kept only for reply + * routing — any process holding the token can write any `from` it likes. + * Everything downstream is built on that assumption: the inbound gate + * decides whether a message may act, and the envelope tells the model the + * content is not from its user. */ +import { timingSafeEqual } from 'node:crypto'; import * as fsSync from 'node:fs'; import * as fs from 'node:fs/promises'; import * as net from 'node:net'; @@ -25,6 +31,7 @@ import * as path from 'node:path'; import { createDebugLogger } from '../utils/debugLogger.js'; import { MAX_FRAME_BYTES, + parsePeerAuthLine, parsePeerFrame, type PeerFrame, } from './peer-frames.js'; @@ -58,6 +65,12 @@ export const CONNECTION_IDLE_TIMEOUT_MS = 30_000; export interface PeerInboxOptions { /** Defaults to this process's resolved socket path. */ socketPath?: string; + /** + * When set, a connection's first line must be an auth line presenting + * exactly this token; anything else drops the connection unread. Unset + * admits every connection, which only tests use. + */ + requiredToken?: string; /** Called for each well-formed frame. Must not throw. */ onFrame: (frame: PeerFrame) => void; } @@ -166,8 +179,30 @@ export async function startPeerInbox( socket.destroy(); }); + let authed = options.requiredToken === undefined; + // destroy() does not stop lines already buffered from this chunk, and + // a failed line followed by a *valid* auth line must not resurrect + // the connection — the refusal is terminal. + let refused = false; const read = createLineReader( (line) => { + if (refused) return; + if (!authed) { + const presented = parsePeerAuthLine(line); + if ( + presented !== null && + tokenMatches(options.requiredToken!, presented) + ) { + authed = true; + return; + } + debugLogger.debug( + 'dropping a connection whose first line did not authenticate: no valid auth line, or token mismatch', + ); + refused = true; + socket.destroy(); + return; + } const frame = parsePeerFrame(line); if (frame === null) { debugLogger.debug( @@ -276,6 +311,17 @@ export async function startPeerInbox( }; } +/** + * Constant-time comparison. A same-uid peer has better channels than a + * byte-by-byte timing oracle, but a permissionless transport (the named + * pipe this token exists for) may not share that property. + */ +function tokenMatches(expected: string, presented: string): boolean { + const a = Buffer.from(expected); + const b = Buffer.from(presented); + return a.length === b.length && timingSafeEqual(a, b); +} + function describe(error: unknown): string { return error instanceof Error ? `${error.name}: ${error.message}` diff --git a/packages/core/src/services/session-registry.test.ts b/packages/core/src/services/session-registry.test.ts index 8a2687da48e..a1944f9fa87 100644 --- a/packages/core/src/services/session-registry.test.ts +++ b/packages/core/src/services/session-registry.test.ts @@ -1386,6 +1386,21 @@ describe('readOwnSessionRecord', () => { }); }); + it('round-trips the inbox token beside the address, dropping both on clear', async () => { + await registerSession({ sessionId: 's1', cwd: '/w/app' }); + await patchSessionRecord({ ipcPath: '/tmp/self.sock', ipcToken: 'tok-1' }); + expect(await readOwnSessionRecord()).toMatchObject({ + ipcPath: '/tmp/self.sock', + ipcToken: 'tok-1', + }); + + await patchSessionRecord({ ipcPath: undefined, ipcToken: undefined }); + const cleared = await readOwnSessionRecord(); + expect(cleared).not.toBeNull(); + expect(cleared).not.toHaveProperty('ipcPath'); + expect(cleared).not.toHaveProperty('ipcToken'); + }); + it("is null for a foreign record sitting at this pid's path", async () => { // Same guard patchSessionRecord applies: a record whose pid does not // match this process is not ours to read back, whatever its filename. diff --git a/packages/core/src/services/session-registry.ts b/packages/core/src/services/session-registry.ts index ac240a0dcc4..77452c96fed 100644 --- a/packages/core/src/services/session-registry.ts +++ b/packages/core/src/services/session-registry.ts @@ -142,6 +142,15 @@ export interface SessionRegistryRecord { * fine. */ ipcPath?: string; + /** + * Token a connection to `ipcPath` must present on its first line before + * any frame is read. Published here because the record is 0600: being + * able to read the token is the same capability as being able to + * discover the socket at all, so senders get both in one read. Absent + * on records written before the field existed — such an inbox cannot + * be authenticated to and refuses frames. + */ + ipcToken?: string; } export interface RegisterSessionFields { @@ -716,6 +725,7 @@ async function readRecord(filePath: string): Promise { const pidNs = value['pidNs']; const qwenVersion = value['qwenVersion']; const ipcPath = value['ipcPath']; + const ipcToken = value['ipcToken']; return { status: 'ok', @@ -733,6 +743,9 @@ async function readRecord(filePath: string): Promise { // messageable", and a record written before this field existed must // read back identically to one written after it. ...(typeof ipcPath === 'string' && ipcPath.length > 0 ? { ipcPath } : {}), + ...(typeof ipcToken === 'string' && ipcToken.length > 0 + ? { ipcToken } + : {}), }, }; }