Skip to content
Open
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
72 changes: 72 additions & 0 deletions packages/cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,78 @@ describe('resolveBootstrapRoute', () => {
expect(resolveBootstrapRoute(['mcp', '--help'])).toBe('mcp');
});

it('does not let the answer payload trigger a bootstrap intercept', () => {
// The free-text tail of `sessions answer <session>` must not reach the
// interceptors as flag tokens: each of these printed help/version and
// exited 0, telling the driving script the answer was delivered when
// it was not (issue #11193). The separator fences the payload off, so
// the argv routes to the full parser instead.
expect(
resolveBootstrapRoute([
'sessions',
'answer',
'0f8e1c42',
'please',
'--help',
'me',
]),
).toBe('default');
expect(
resolveBootstrapRoute([
'sessions',
'answer',
'0f8e1c42',
'yes',
'please',
'help',
]),
).toBe('default');
expect(
resolveBootstrapRoute([
'sessions',
'answer',
'0f8e1c42',
'please',
'--version',
'now',
]),
).toBe('default');
// A user-supplied separator already fenced the payload; the route must
// not change (and the separator must not be doubled).
expect(
resolveBootstrapRoute([
'sessions',
'answer',
'0f8e1c42',
'--',
'please',
'--version',
'now',
]),
).toBe('default');
});

it('keeps the intercepts for every other command and shape', () => {
// The fence is scoped to the answer payload: version tokens elsewhere
// keep the fail-closed intercept (issue #11193's guardrail — demoting
// those executes subcommands).
expect(resolveBootstrapRoute(['sessions', 'list', '-v'])).toBe('version');
expect(resolveBootstrapRoute(['sessions', 'ps', '--version'])).toBe(
'version',
);
expect(
resolveBootstrapRoute(['mcp', 'remove', 'victim', '-v', 'help']),
).toBe('version');
// `qwen sessions answer --help` (no id) and a bare `--help` payload
// keep routing to the parser, which shows the command's help.
expect(resolveBootstrapRoute(['sessions', 'answer', '--help'])).toBe(
'default',
);
expect(
resolveBootstrapRoute(['sessions', 'answer', '0f8e1c42', '--help']),
).toBe('default');
});

it('keeps bundled entrypoint paths out of the route detection', async () => {
expect(resolveBootstrapRoute(['/repo/dist/cli.js', '--help'])).toBe('help');
expect(
Expand Down
12 changes: 11 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from './config/top-level-options.js';
import { clearInheritedPeerMessagingEnv } from './peerMessaging/env.js';
import { normalizeServeFastPathArgv } from './utils/serve-fast-path-argv.js';
import { insertSessionAnswerSeparator } from './utils/session-answer-argv.js';
import { initStartupProfiler } from './utils/startupProfiler.js';
import { initCpuProfiler } from './utils/cpuProfiler.js';
import {
Expand Down Expand Up @@ -322,7 +323,16 @@ function normalizeMcpFastPathArgv(argv: readonly string[]): readonly string[] {
export function resolveBootstrapRoute(
rawArgv: readonly string[],
): BootstrapRoute {
const argv = normalizeServeFastPathArgv(rawArgv);
// Fence the `sessions answer <session> <free text...>` payload off from
// the scans below before they run: an answer that quotes `--help`,
// `--version` or ends in a bare `help` would otherwise be swallowed by
// an intercept that prints help/version and exits 0, telling the driving
// script the answer was delivered when it was not (issue #11193). After
// the separator every later token is positional data to these scans by
// construction, so no intercept fires on the payload.
const argv = insertSessionAnswerSeparator(
normalizeServeFastPathArgv(rawArgv),
);

// Base-parity version intercept (structural close). Base printed the
// version for any `-v`/`--version` token its hasFlag scan reached, no
Expand Down
59 changes: 59 additions & 0 deletions packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,65 @@ describe('parseArguments', () => {
}
});

it('does not let a sessions-answer payload quote help into the help intercept', async () => {
// Issue #11193: a free-text answer that quotes `--help` (or ends in a
// bare `help`) made the root parser print the usage block and exit 0,
// so the driving script read success while the answer was never
// delivered. The separator fences the payload off, so the parse takes
// the strict unknown-command failure instead — loud, non-zero. (The
// yargs parser holds the test runner's process.exit stub, so the
// rejection message itself carries the exit code it asked for.)
const swallowArgv = [
'sessions',
'answer',
'0f8e1c42',
'please',
'--help',
'me',
];
const bareHelpArgv = [
'sessions',
'answer',
'0f8e1c42',
'yes',
'please',
'help',
];
for (const answerArgv of [swallowArgv, bareHelpArgv]) {
process.argv = ['node', 'script.js', ...answerArgv];
await expect(parseArguments()).rejects.toThrow(
'process.exit unexpectedly called with "1"',
);
}
});

it('keeps a bare --help answer showing help rather than delivering it', async () => {
// The carve-out the answer command's positional documents: a payload
// that is exactly `--help` keeps reaching the parser as the help
// flag, so it still shows help (exit 0) instead of answering with the
// literal text `--help`.
process.argv = [
'node',
'script.js',
'sessions',
'answer',
'0f8e1c42',
'--help',
];
const output: string[] = [];
const log = vi.spyOn(console, 'log').mockImplementation((...args) => {
output.push(args.join(' '));
});
try {
await expect(parseArguments()).rejects.toThrow(
'process.exit unexpectedly called with "0"',
);
expect(output.join('')).toContain('qwen sessions');
} finally {
log.mockRestore();
}
});

it('should throw an error when both --prompt and --prompt-interactive are used together', async () => {
process.argv = [
'node',
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import { randomUUID } from 'node:crypto';
import stripJsonComments from 'strip-json-comments';

import { resolvePath } from '../utils/resolvePath.js';
import { insertSessionAnswerSeparator } from '../utils/session-answer-argv.js';
import {
TOP_LEVEL_GLOBAL_OPTIONS,
DEFAULT_COMMAND,
Expand Down Expand Up @@ -579,6 +580,15 @@ export async function parseArguments(): Promise<CliArgs> {
rawArgv = rawArgv.slice(1);
}

// Fence the free-text answer payload off from the root parse: a quoted
// `--help`/`--version` (or a bare trailing `help`) inside `qwen sessions
// answer <session> <text...>` would otherwise print the usage block or
// strip the token from the text and exit 0, so the answer is silently
// never delivered (issue #11193). After the separator the payload is
// positional data to this parse; the answer command splices the first
// `--` back out of its raw-args tail, so the text arrives verbatim.
rawArgv = insertSessionAnswerSeparator(rawArgv);

const yargsInstance = yargs(rawArgv)
.locale('en')
.scriptName('qwen')
Expand Down
161 changes: 161 additions & 0 deletions packages/cli/src/utils/session-answer-argv.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it } from 'vitest';
import { insertSessionAnswerSeparator } from './session-answer-argv.js';

describe('insertSessionAnswerSeparator', () => {
it('inserts -- between the session id and a free-text payload', () => {
expect(
insertSessionAnswerSeparator([
'sessions',
'answer',
'0f8e1c42',
'yes, go ahead',
]),
).toEqual(['sessions', 'answer', '0f8e1c42', '--', 'yes, go ahead']);
});

it('fences a payload that quotes help/version tokens', () => {
// The three shapes from issue #11193's reproduction table.
expect(
insertSessionAnswerSeparator([
'sessions',
'answer',
'0f8e1c42',
'please',
'--help',
'me',
]),
).toEqual([
'sessions',
'answer',
'0f8e1c42',
'--',
'please',
'--help',
'me',
]);
expect(
insertSessionAnswerSeparator([
'sessions',
'answer',
'0f8e1c42',
'yes',
'please',
'help',
]),
).toEqual([
'sessions',
'answer',
'0f8e1c42',
'--',
'yes',
'please',
'help',
]);
expect(
insertSessionAnswerSeparator([
'sessions',
'answer',
'0f8e1c42',
'please',
'--version',
'now',
]),
).toEqual([
'sessions',
'answer',
'0f8e1c42',
'--',
'please',
'--version',
'now',
]);
});

it('does not touch other commands or unprefixed argv', () => {
const untouched: string[][] = [
['--help'],
['--version'],
['sessions', 'list'],
['sessions', 'ps', '--json'],
['mcp', 'remove', 'victim', '-v', 'help'],
['peek', '0f8e1c42'],
['answer', '0f8e1c42', 'yes'],
];
for (const argv of untouched) {
expect(insertSessionAnswerSeparator(argv)).toEqual(argv);
}
});

it('keeps a missing or flag-like session id for the parser to reject', () => {
expect(insertSessionAnswerSeparator(['sessions', 'answer'])).toEqual([
'sessions',
'answer',
]);
// `qwen sessions answer --help` (no id) still routes to the command's
// help; the demandOption error for a missing id is yargs' to raise.
expect(
insertSessionAnswerSeparator(['sessions', 'answer', '--help']),
).toEqual(['sessions', 'answer', '--help']);
expect(insertSessionAnswerSeparator(['sessions', 'answer', '-v'])).toEqual([
'sessions',
'answer',
'-v',
]);
});

it('keeps a bare --help payload showing help (the documented carve-out)', () => {
expect(
insertSessionAnswerSeparator([
'sessions',
'answer',
'0f8e1c42',
'--help',
]),
).toEqual(['sessions', 'answer', '0f8e1c42', '--help']);
expect(
insertSessionAnswerSeparator(['sessions', 'answer', '0f8e1c42', '-h']),
).toEqual(['sessions', 'answer', '0f8e1c42', '-h']);
// Only the bare form: `--help` with a sibling token is answer text.
expect(
insertSessionAnswerSeparator([
'sessions',
'answer',
'0f8e1c42',
'--help',
'me',
]),
).toEqual(['sessions', 'answer', '0f8e1c42', '--', '--help', 'me']);
});

it('does not double a user-supplied separator', () => {
expect(
insertSessionAnswerSeparator([
'sessions',
'answer',
'0f8e1c42',
'--',
'--force',
]),
).toEqual(['sessions', 'answer', '0f8e1c42', '--', '--force']);
});

it('leaves an id with no payload alone', () => {
expect(
insertSessionAnswerSeparator(['sessions', 'answer', '0f8e1c42']),
).toEqual(['sessions', 'answer', '0f8e1c42']);
});

it('returns a new array only when it inserts', () => {
const untouched = ['sessions', 'answer', '0f8e1c42'];
expect(insertSessionAnswerSeparator(untouched)).toBe(untouched);
const fenced = insertSessionAnswerSeparator([...untouched, 'yes']);
expect(fenced).not.toBe(untouched);
expect(untouched).toEqual(['sessions', 'answer', '0f8e1c42']);
});
});
Loading
Loading