fix: prevent bogus shell permission rules in tests

This commit is contained in:
LaZzyMan 2026-03-20 17:55:33 +08:00
parent fbf5ed57d6
commit fcd31e2adf
5 changed files with 64 additions and 2 deletions

View file

@ -9,4 +9,10 @@ if (process.env['NO_COLOR'] !== undefined) {
delete process.env['NO_COLOR'];
}
// Avoid writing per-session debug log files during CLI tests.
// Individual tests can still opt in by overriding this env var explicitly.
if (process.env['QWEN_DEBUG_LOG_FILE'] === undefined) {
process.env['QWEN_DEBUG_LOG_FILE'] = '0';
}
import './src/test-utils/customMatchers.js';

View file

@ -949,6 +949,24 @@ describe('ShellTool', () => {
);
});
it('should not surface file descriptor redirects as standalone commands in confirmation details', async () => {
const params = {
command: 'npm run build 2>&1 | head -100',
is_background: false,
};
const invocation = shellTool.build(params);
const permission = await invocation.getDefaultPermission();
expect(permission).toBe('ask');
const details = (await invocation.getConfirmationDetails(
new AbortController().signal,
)) as { rootCommand: string; permissionRules: string[] };
expect(details.rootCommand).toBe('npm');
expect(details.permissionRules).toEqual(['Bash(npm run *)']);
});
it('should throw an error if validation fails', () => {
expect(() =>
shellTool.build({ command: '', is_background: false }),

View file

@ -439,6 +439,16 @@ describe('getCommandRoots', () => {
const result = getCommandRoots('ls\n\ngrep foo');
expect(result).toEqual(['ls', 'grep']);
});
it('should not treat file descriptor redirection as a command separator', () => {
const result = getCommandRoots('npm run build 2>&1 | head -100');
expect(result).toEqual(['npm', 'head']);
});
it('should not treat >| redirection as a pipeline separator', () => {
const result = getCommandRoots('echo hello >| out.txt');
expect(result).toEqual(['echo']);
});
});
describe('stripShellWrapper', () => {

View file

@ -126,6 +126,16 @@ export function splitCommands(command: string): string[] {
let inDoubleQuotes = false;
let i = 0;
const previousNonWhitespaceChar = (index: number): string | undefined => {
for (let j = index - 1; j >= 0; j--) {
const ch = command[j];
if (ch && !/\s/.test(ch)) {
return ch;
}
}
return undefined;
};
while (i < command.length) {
const char = command[i];
const nextChar = command[i + 1];
@ -145,14 +155,30 @@ export function splitCommands(command: string): string[] {
if (!inSingleQuotes && !inDoubleQuotes) {
if (
(char === '&' && nextChar === '&') ||
(char === '|' && nextChar === '|')
(char === '|' && (nextChar === '|' || nextChar === '&'))
) {
commands.push(currentCommand.trim());
currentCommand = '';
i++; // Skip the next character
} else if (char === ';' || char === '&' || char === '|') {
} else if (char === ';') {
commands.push(currentCommand.trim());
currentCommand = '';
} else if (char === '&') {
const prevChar = previousNonWhitespaceChar(i);
if (prevChar === '>' || prevChar === '<') {
currentCommand += char;
} else {
commands.push(currentCommand.trim());
currentCommand = '';
}
} else if (char === '|') {
const prevChar = previousNonWhitespaceChar(i);
if (prevChar === '>') {
currentCommand += char;
} else {
commands.push(currentCommand.trim());
currentCommand = '';
}
} else if (char === '\r' && nextChar === '\n') {
// Windows-style \r\n newline - treat as command separator
commands.push(currentCommand.trim());

View file

@ -72,6 +72,7 @@ describe('AcpConnection readTextFile error mapping', () => {
const prompt = vi.fn().mockResolvedValue({});
const onEndTurn = vi.fn();
const conn = new AcpConnection() as unknown as {
child: { killed: boolean; exitCode: number | null } | null;
sdkConnection: {
prompt: (params: {
sessionId: string;
@ -92,6 +93,7 @@ describe('AcpConnection readTextFile error mapping', () => {
},
];
conn.child = { killed: false, exitCode: null };
conn.sdkConnection = { prompt };
conn.sessionId = 'session-1';
conn.onEndTurn = onEndTurn;