Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/users/features/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -836,3 +836,31 @@ agents again) — or released after a hold — a notice appears in the
sending session's transcript (`Message to <name>: …`). 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"}}'; \
Comment thread
qqqys marked this conversation as resolved.
Comment thread
qqqys marked this conversation as resolved.
} | 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.
46 changes: 46 additions & 0 deletions packages/cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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']]) {
Comment thread
qqqys marked this conversation as resolved.
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'];

Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
TOP_LEVEL_HELP_OPTIONS,
TOP_LEVEL_USAGE,
} from './config/top-level-options.js';
import { clearInheritedPeerMessagingEnv } from './peerMessaging/env.js';
Comment thread
qqqys marked this conversation as resolved.
import { normalizeServeFastPathArgv } from './utils/serve-fast-path-argv.js';
import { initStartupProfiler } from './utils/startupProfiler.js';
import { initCpuProfiler } from './utils/cpuProfiler.js';
Expand Down Expand Up @@ -491,6 +492,17 @@ async function parseYargsCommand(
export async function runCliEntry(
rawArgv: readonly string[] = process.argv.slice(2),
): Promise<void> {
// 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) {
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/commands/sessions/ps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/commands/sessions/ps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,11 @@ async function handlePs(argv: PsArgs): Promise<void> {
// 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;
}
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/llm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Comment thread
qqqys marked this conversation as resolved.
Comment thread
qqqys marked this conversation as resolved.
// Bridge core-package startup events (Config.initialize, MCP discovery,
// LlmClient.setTools) into the cli's startup profiler. Gated on
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/src/peerMessaging/env.ts
Original file line number Diff line number Diff line change
@@ -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];
}
Loading
Loading