diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index 1d9224e6eca..181b6d9d2a6 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -836,3 +836,31 @@ 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":"'"$(uuidgen)"'","type":"user","priority":"next","message":{"role":"user","content":"build finished"}}'; \ +} | socat - UNIX-CONNECT:"$QWEN_CODE_MESSAGING_SOCKET" +``` + +Give every injection a fresh `msgId`. The receiving gate remembers the +ids it has already settled, so a hook that reuses one is delivered the +first time and silently deduplicated on every run after that. + +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/cli.test.ts b/packages/cli/src/cli.test.ts index d88be8ad88c..f76525fe5d9 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -678,6 +678,8 @@ describe('runCliEntry', () => { process.env['QWEN_CODE_EXTERNAL_TOOL_GUARD_TOKEN'], QWEN_CODE_MANAGED_NPM_UPDATE_VERSION: process.env['QWEN_CODE_MANAGED_NPM_UPDATE_VERSION'], + QWEN_CODE_MESSAGING_SOCKET: process.env['QWEN_CODE_MESSAGING_SOCKET'], + QWEN_CODE_MESSAGING_TOKEN: process.env['QWEN_CODE_MESSAGING_TOKEN'], }; let stdout: string[]; @@ -727,6 +729,16 @@ describe('runCliEntry', () => { process.env['QWEN_CODE_EXTERNAL_TOOL_GUARD_TOKEN'] = savedEnv.QWEN_CODE_EXTERNAL_TOOL_GUARD_TOKEN; } + for (const name of [ + 'QWEN_CODE_MESSAGING_SOCKET', + 'QWEN_CODE_MESSAGING_TOKEN', + ] as const) { + if (savedEnv[name] === undefined) { + delete process.env[name]; + } else { + process.env[name] = savedEnv[name]; + } + } vi.restoreAllMocks(); }); @@ -756,6 +768,40 @@ describe('runCliEntry', () => { expect(mocks.main).not.toHaveBeenCalled(); }); + it('scrubs an inherited messaging pair before the managed update spawns npm', async () => { + // The pair names an ancestor session's inbox and authenticates to it. + // installManagedNpmUpdate spawns npm with the full environment, so a + // pair surviving to here reaches the installed package's lifecycle + // scripts — third-party code able to inject into the live session. + // This route never reaches main(), so the entry-level scrub is the + // only thing standing between them. + process.env['QWEN_CODE_MESSAGING_SOCKET'] = '/tmp/ancestor.sock'; + process.env['QWEN_CODE_MESSAGING_TOKEN'] = 'ancestor-token'; + process.env['QWEN_CODE_MANAGED_NPM_UPDATE_VERSION'] = '2.0.0'; + mocks.installManagedNpmUpdate.mockImplementationOnce(async () => { + expect(process.env['QWEN_CODE_MESSAGING_SOCKET']).toBeUndefined(); + expect(process.env['QWEN_CODE_MESSAGING_TOKEN']).toBeUndefined(); + }); + + await runCliEntry([]); + + expect(mocks.installManagedNpmUpdate).toHaveBeenCalledWith('2.0.0'); + }); + + it('scrubs an inherited messaging pair on the fast paths that never reach main', async () => { + // serve and mcp dispatch without main(), and both hand the full + // environment to the children they start. + for (const argv of [['mcp'], ['serve']]) { + process.env['QWEN_CODE_MESSAGING_SOCKET'] = '/tmp/ancestor.sock'; + process.env['QWEN_CODE_MESSAGING_TOKEN'] = 'ancestor-token'; + + await runCliEntry(argv); + + expect(process.env['QWEN_CODE_MESSAGING_SOCKET']).toBeUndefined(); + expect(process.env['QWEN_CODE_MESSAGING_TOKEN']).toBeUndefined(); + } + }); + it('falls back to getCliVersion when CLI_VERSION is unset', async () => { delete process.env['CLI_VERSION']; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ad89557be9e..bb5937c7f1d 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -22,6 +22,7 @@ import { TOP_LEVEL_HELP_OPTIONS, TOP_LEVEL_USAGE, } from './config/top-level-options.js'; +import { clearInheritedPeerMessagingEnv } from './peerMessaging/env.js'; import { normalizeServeFastPathArgv } from './utils/serve-fast-path-argv.js'; import { initStartupProfiler } from './utils/startupProfiler.js'; import { initCpuProfiler } from './utils/cpuProfiler.js'; @@ -491,6 +492,17 @@ async function parseYargsCommand( export async function runCliEntry( rawArgv: readonly string[] = process.argv.slice(2), ): Promise { + // Before ANY route can start a child: an inherited messaging pair names + // an ancestor session's inbox plus a token that authenticates to it, and + // no route here consumes it — a session that binds its own inbox + // re-exports its own pair from PeerMessaging.start. Leaving it in place + // hands the capability to, among others, the npm lifecycle scripts of a + // managed update (which spawns with the full environment), letting + // third-party code inject into the running session. Same boundary and + // same reason as the guard-token scrub below; that one needs a serve + // carve-out, this one does not. + clearInheritedPeerMessagingEnv(); + const managedUpdateVersion = process.env['QWEN_CODE_MANAGED_NPM_UPDATE_VERSION']; if (managedUpdateVersion) { 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/llm.tsx b/packages/cli/src/llm.tsx index 19f2d163919..95f9a07b132 100644 --- a/packages/cli/src/llm.tsx +++ b/packages/cli/src/llm.tsx @@ -95,6 +95,7 @@ import { initializeWarningHandler } from './utils/warningHandler.js'; import { writeStderrLine, writeStderrLineSafe } from './utils/stdioHelpers.js'; import { sanitizeTerminalText } from './ui/utils/textUtils.js'; import { getHeadlessYoloSafetyWarning } from './utils/headlessSafetyWarnings.js'; +import { clearInheritedPeerMessagingEnv } from './peerMessaging/env.js'; import { initializeLlmOutputLanguage } from './i18n/languageUtils.js'; import { CUSTOM_SANDBOX_IMAGE_ENV_VAR, @@ -353,6 +354,13 @@ function installInteractiveSignalHandlers(wasRaw: boolean): () => void { export async function main() { profileCheckpoint('main_entry'); + // First thing, before any child can be spawned: an inherited messaging + // address/token names the ANCESTOR's inbox, and handing that pair on + // would let this session's hooks inject into the wrong session. Modes + // that never bind an inbox — feature off, headless `-p`, a registration + // that never completes — reach no other scrub, so it happens here for + // all of them. A session that does bind one re-exports its own pair. + clearInheritedPeerMessagingEnv(); const acpStartupProfilerEnabled = isAcpStartupProfilerEnabled(); // Bridge core-package startup events (Config.initialize, MCP discovery, // LlmClient.setTools) into the cli's startup profiler. Gated on diff --git a/packages/cli/src/peerMessaging/env.ts b/packages/cli/src/peerMessaging/env.ts new file mode 100644 index 00000000000..2b7000fa2a1 --- /dev/null +++ b/packages/cli/src/peerMessaging/env.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The environment contract between a session and the processes it spawns. + * + * Kept in its own leaf module — no core imports — so the startup path can + * scrub an inherited pair before anything is spawned without paying for the + * messaging stack it would otherwise pull in. + */ + +/** + * 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'; + +/** + * Drop an inherited address/token pair. + * + * These two variables name one capability: the address a child connects to + * and the token that authenticates it there. A process that inherits them + * from an ancestor session but binds no inbox of its own would otherwise + * pass the ancestor's capability straight down to its own children — a hook + * following the documented injection pattern would then authenticate to the + * ancestor's inbox and, under the default policy, land its message in the + * wrong session's context while reporting success. + * + * Called once at startup, before anything is spawned. A session that does + * bind its own inbox re-exports its own pair on the success path + * ({@link PeerMessaging.start}), so the scrub only ever removes a pair this + * process has no right to hand on. + */ +export function clearInheritedPeerMessagingEnv(): void { + delete process.env[MESSAGING_SOCKET_ENV]; + delete process.env[MESSAGING_TOKEN_ENV]; +} diff --git a/packages/cli/src/peerMessaging/peer-messaging.test.ts b/packages/cli/src/peerMessaging/peer-messaging.test.ts index ddc1db1460e..53f1aa48b7d 100644 --- a/packages/cli/src/peerMessaging/peer-messaging.test.ts +++ b/packages/cli/src/peerMessaging/peer-messaging.test.ts @@ -24,11 +24,14 @@ import { sendPeerFrame, startPeerInbox, trackSentPeerMessageForTest, + type InboundPolicy, type PeerFrame, type PeerInbox, } 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 +60,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. */ @@ -104,6 +122,7 @@ async function start( status: string, ) => { address: string; previous: 'pending' | 'held' } | undefined; reassertSessionRecord?: () => Promise; + getPolicySetting?: () => InboundPolicy | undefined; } = {}, ): Promise<{ messaging: PeerMessaging; @@ -115,6 +134,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 +149,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 +182,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { []; m.onReceipt((receipt) => seen.push(receipt)); - await sendPeerFrame( + await send( m.socketPath!, buildDeliveryStatusFrame({ status: 'held', @@ -189,7 +209,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 +233,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 +259,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 +292,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 +311,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 +327,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 +345,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { getSessionId: () => 'session-now', reassertSessionRecord: reassert, }); - await sendPeerFrame( + await send( m.socketPath!, buildUserFrame({ content: 'stale', @@ -351,6 +371,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 +384,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 +410,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 +426,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 +440,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { getSessionId: () => current, }); current = 'session-b'; - await sendPeerFrame( + await send( m.socketPath!, buildUserFrame({ content: 'after /clear', @@ -440,6 +461,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 +477,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 +502,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 +517,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 +549,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 +562,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 +582,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 +614,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 +642,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 +658,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 +677,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 +693,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 +701,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 +721,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 +730,7 @@ describe.skipIf(isWindows)('PeerMessaging', () => { return true; }); - await sendPeerFrame( + await send( started.socketPath!, buildUserFrame({ content: 'later', from: '/tmp/peer.sock' }), ); @@ -720,7 +746,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 +768,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 +784,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 +810,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 +856,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 +878,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 +888,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 +918,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 +938,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 +967,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 +976,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 +1013,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 +1023,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 +1040,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 +1048,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 +1064,172 @@ 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('drops an inherited address and token when the inbox never binds', async () => { + // A session that binds no inbox of its own must not hand an ancestor's + // capability to its children: a hook following the documented injection + // pattern would authenticate to the ANCESTOR's inbox and land its + // message in the wrong session's context, reporting success. + process.env[MESSAGING_SOCKET_ENV] = '/inherited/ancestor.sock'; + process.env[MESSAGING_TOKEN_ENV] = 'inherited-ancestor-token'; + + // A regular file where the socket's parent directory should be: the + // inbox cannot create the directory, so it never binds. + const blocker = path.join(tmpDir, 'not-a-dir'); + await fs.writeFile(blocker, ''); + + const started = await PeerMessaging.start({ + socketPath: path.join(blocker, 'self.sock'), + getApprovalMode: () => ApprovalMode.DEFAULT, + getPolicySetting: () => undefined, + updateSessionRegistryIpcPath: async () => {}, + ipcToken: TEST_TOKEN, + }); + + expect(started).toBeNull(); + 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); + }); + + it('authenticates a held receipt, not only the misaddressed one', async () => { + // The gate's own reportStatus path — held/denied/delivered — is the one + // a real peer meets first. Without the replyToken the hold signal never + // reaches the sender's token-required inbox and its ledger sits on + // 'pending' forever, with nothing to show the user. + const SENDER_TOKEN = 'held-sender-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; + + // Policy 'hold' parks the message and reports it back. + const { messaging: m } = await start(ApprovalMode.DEFAULT, { + getPolicySetting: () => 'hold', + }); + const frame = buildUserFrame({ + content: 'please review', + from: sender.socketPath, + replyToken: SENDER_TOKEN, + }); + await send(m.socketPath!, frame); + await settle(); + + expect(m.getHeld()).toHaveLength(1); + expect(receipts).toMatchObject([ + { type: 'control', status: 'held', origMsgId: frame.msgId }, + ]); + }); + + it('generates a 64-hex inbox token when none is injected', async () => { + // Every other test injects the token through the seam, so the default + // is the one branch that ships to users: a constant or truncated value + // here would hand every session the same guessable capability. + const published: Array<[string | undefined, string | undefined]> = []; + const started = await PeerMessaging.start({ + socketPath: path.join(tmpDir, 'socks', 'generated.sock'), + getApprovalMode: () => ApprovalMode.DEFAULT, + getPolicySetting: () => undefined, + updateSessionRegistryIpcPath: async (ipcPath, ipcToken) => { + published.push([ipcPath, ipcToken]); + }, + }); + if (!started) throw new Error('peer messaging failed to start'); + messaging = started; + + const token = published[0][1]; + expect(token).toMatch(/^[0-9a-f]{64}$/); + expect(process.env[MESSAGING_TOKEN_ENV]).toBe(token); + + // And it is the token the inbox actually requires. + await sendPeerFrame( + started.socketPath!, + buildUserFrame({ content: 'with the generated token' }), + { authToken: token }, + ); + await settle(); + }); +}); diff --git a/packages/cli/src/peerMessaging/peer-messaging.ts b/packages/cli/src/peerMessaging/peer-messaging.ts index 0b028796cd3..572fc421e4b 100644 --- a/packages/cli/src/peerMessaging/peer-messaging.ts +++ b/packages/cli/src/peerMessaging/peer-messaging.ts @@ -18,6 +18,12 @@ * that it needed to wait. */ +import { randomBytes } from 'node:crypto'; +import { + clearInheritedPeerMessagingEnv, + MESSAGING_SOCKET_ENV, + MESSAGING_TOKEN_ENV, +} from './env.js'; import { type ApprovalMode, createDebugLogger, @@ -43,9 +49,16 @@ const debugLogger = createDebugLogger('PEER_MESSAGING'); export interface PeerQueuedDelivery { msgId: string; from?: string; + replyToken?: string; toSessionId?: string; } +export { + clearInheritedPeerMessagingEnv, + MESSAGING_SOCKET_ENV, + MESSAGING_TOKEN_ENV, +} from './env.js'; + /** * 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 +95,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 +122,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 +135,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 +182,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 +208,20 @@ export class PeerMessaging { options.settleSentMessage ?? settleSentPeerMessage; messaging.reassertSessionRecord = options.reassertSessionRecord ?? null; + // Any pair still in the environment at this point was inherited from an + // ancestor session, and every exit below this line other than a bound + // inbox must leave nothing for children to pick up. Dropped before the + // bind rather than on each failure branch so a future early return + // cannot reintroduce the leak; the success path re-exports this + // session's own pair once the socket is accepting. + clearInheritedPeerMessagingEnv(); + + 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 +233,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 +376,8 @@ export class PeerMessaging { await this.gate?.shutdown(); await this.settleUnconsumed(); await this.inbox?.close(); + // Same pair, same removal as the startup scrub — one writer for it. + clearInheritedPeerMessagingEnv(); await this.updateSessionRegistryIpcPath(undefined); } @@ -349,11 +396,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 +451,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 +523,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 +548,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.test.tsx b/packages/cli/src/ui/startInteractiveUI.test.tsx index c1605844f09..40822c95855 100644 --- a/packages/cli/src/ui/startInteractiveUI.test.tsx +++ b/packages/cli/src/ui/startInteractiveUI.test.tsx @@ -198,6 +198,29 @@ describe('startInteractiveUI cross-session messaging', () => { expect(reassertSessionRegistryRecord).toHaveBeenCalledTimes(1); }); + it('forwards the inbox token, not only the address, into the record', async () => { + // Regressing this callback to `(ipcPath) => …(ipcPath)` type-checks — + // fewer parameters is assignable — and every record would then + // advertise an address with no token: peers resolve it, fail to + // authenticate, and every send is dropped while still reporting 'sent'. + const config = makeConfig(); + + await start(config, enabledSettings); + await vi.waitFor(() => expect(peerMessagingStart).toHaveBeenCalled()); + + const options = peerMessagingStart.mock.calls[0]?.[0] as { + updateSessionRegistryIpcPath: ( + ipcPath: string | undefined, + ipcToken?: string, + ) => Promise; + }; + await options.updateSessionRegistryIpcPath('/run/self.sock', 'tok-abc'); + expect(config.updateSessionRegistryIpcPath).toHaveBeenCalledWith( + '/run/self.sock', + 'tok-abc', + ); + }); + it('reads the session id live, so /clear moves the pin with the session', async () => { // `startNewSession` reassigns Config's session id in place, so /clear, // /new and /resume all leave the same Config answering with a new id. 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.test.ts b/packages/core/src/config/config.test.ts index 651721f0d0a..620e01d89c7 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -7922,6 +7922,30 @@ describe('Server Config (config.ts)', () => { patchSessionRecordSpy.mockRestore(); }); + it('carries the inbox token into the record, on the first patch and the retry', async () => { + // `toMatchObject`/`toEqual` treat { ipcPath } and { ipcPath, ipcToken: + // undefined } as equal, so the existing one-arg call sites pass whether + // or not the token is forwarded. Dropping ipcToken from either patch + // would publish an address peers cannot authenticate to — sends read as + // 'sent' and are silently dropped — with the whole suite still green. + const config = new Config(baseParams); + config.trackSessionRegistration(Promise.resolve(true)); + await expect(config.whenSessionRegistered()).resolves.toBe(true); + const patchSessionRecordSpy = vi + .spyOn(sessionRegistry, 'patchSessionRecord') + // The first patch is skipped, so the retry path carries the token too. + .mockResolvedValueOnce(false) + .mockResolvedValue(true); + + await config.updateSessionRegistryIpcPath('/tmp/peer.sock', 'tok-xyz'); + + expect(patchSessionRecordSpy).toHaveBeenCalledTimes(2); + for (const [patch] of patchSessionRecordSpy.mock.calls) { + expect(patch).toEqual({ ipcPath: '/tmp/peer.sock', ipcToken: 'tok-xyz' }); + } + patchSessionRecordSpy.mockRestore(); + }); + it('gives up on the peer inbox advertise after a bounded retry', async () => { const config = new Config(baseParams); config.trackSessionRegistration(Promise.resolve(true)); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 0f1724e10fa..eb10e04b672 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -4629,11 +4629,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 @@ -4642,7 +4643,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..cabf7073f6a 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,144 @@ 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('drops a wrong-LENGTH token cleanly instead of throwing', async () => { + // timingSafeEqual throws on differing lengths, so the byte-length + // short-circuit in tokenMatches is the only thing keeping a truncated + // QWEN_CODE_MESSAGING_TOKEN a clean fail-closed refusal rather than an + // exception inside the line reader. + const started = await listenWithToken(); + await writeRaw(started.socketPath, [ + buildAuthLine('a'.repeat(32)) + + encodePeerFrame(buildUserFrame({ content: 'short token' })), + ]).catch(() => {}); + await settle(); + expect(received).toHaveLength(0); + // The inbox is still serving: the refusal was per-connection. + await sendPeerFrame(started.socketPath, buildUserFrame({ content: 'ok' }), { + authToken: TOKEN, + }); + await settle(); + expect(received).toHaveLength(1); + }); + + it('does not let one connection refusal brick the inbox', async () => { + // Were `refused` hoisted out of the per-connection closure, a single + // unauthenticated connection — a pre-token build's send, which is only + // meant to be dropped — would silently kill the inbox for the rest of + // the session. + const started = await listenWithToken(); + await writeRaw(started.socketPath, [ + encodePeerFrame(buildUserFrame({ content: 'unauthenticated' })), + ]).catch(() => {}); + await settle(); + expect(received).toHaveLength(0); + + await sendPeerFrame( + started.socketPath, + buildUserFrame({ content: 'after the refusal' }), + { authToken: TOKEN }, + ); + await settle(); + expect(received).toMatchObject([ + { message: { content: 'after the refusal' } }, + ]); + }); + + it('does not let one connection admission admit the next', async () => { + // The other leak direction: a hoisted `authed` would make the first + // legitimate sender open the inbox to every later connection, token or + // not. + const started = await listenWithToken(); + await sendPeerFrame( + started.socketPath, + buildUserFrame({ content: 'authenticated' }), + { authToken: TOKEN }, + ); + await settle(); + expect(received).toHaveLength(1); + + await writeRaw(started.socketPath, [ + encodePeerFrame(buildUserFrame({ content: 'riding on the last auth' })), + ]).catch(() => {}); + await settle(); + expect(received).toHaveLength(1); + }); + + 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 +477,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 +504,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..c3627d0cf95 100644 --- a/packages/core/src/services/session-registry.ts +++ b/packages/core/src/services/session-registry.ts @@ -142,6 +142,18 @@ 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 requires + * no token and admits every connection, so a newer sender reaches it by + * simply not leading with an auth line. The reverse direction is the + * lossy one — a pre-token sender never authenticates, so a token- + * requiring inbox drops what it sends. + */ + ipcToken?: string; } export interface RegisterSessionFields { @@ -716,6 +728,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 +746,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 } + : {}), }, }; }