diff --git a/packages/cli/test-setup.ts b/packages/cli/test-setup.ts index cc0ac0023..c26e57fa5 100644 --- a/packages/cli/test-setup.ts +++ b/packages/cli/test-setup.ts @@ -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'; diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 693b03ec1..73047dbea 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -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 }), diff --git a/packages/core/src/utils/shell-utils.test.ts b/packages/core/src/utils/shell-utils.test.ts index 7a02ba4a7..561df685f 100644 --- a/packages/core/src/utils/shell-utils.test.ts +++ b/packages/core/src/utils/shell-utils.test.ts @@ -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', () => { diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index f0cd2bb13..14aa87d82 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -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()); diff --git a/packages/vscode-ide-companion/src/services/acpConnection.test.ts b/packages/vscode-ide-companion/src/services/acpConnection.test.ts index 376ee1d0a..7fc33db95 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.test.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.test.ts @@ -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;