/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach, type Mock, } from 'vitest'; const mockShellExecutionService = vi.hoisted(() => vi.fn()); vi.mock('../services/shellExecutionService.js', () => ({ ShellExecutionService: { execute: mockShellExecutionService }, })); vi.mock('fs'); vi.mock('os'); vi.mock('crypto'); vi.mock('../utils/summarizer.js'); import { isCommandAllowed } from '../utils/shell-utils.js'; import { ShellTool } from './shell.js'; import { type Config } from '../config/config.js'; import { type ShellExecutionResult, type ShellOutputEvent, } from '../services/shellExecutionService.js'; import * as fs from 'node:fs'; import * as os from 'node:os'; import { EOL } from 'node:os'; import * as path from 'node:path'; import * as crypto from 'node:crypto'; import * as summarizer from '../utils/summarizer.js'; import { ToolErrorType } from './tool-error.js'; import { ToolConfirmationOutcome } from './tools.js'; import { OUTPUT_UPDATE_INTERVAL_MS } from './shell.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; describe('ShellTool', () => { let shellTool: ShellTool; let mockConfig: Config; let mockShellOutputCallback: (event: ShellOutputEvent) => void; let resolveExecutionPromise: (result: ShellExecutionResult) => void; beforeEach(() => { vi.clearAllMocks(); mockConfig = { getCoreTools: vi.fn().mockReturnValue([]), getExcludeTools: vi.fn().mockReturnValue([]), getDebugMode: vi.fn().mockReturnValue(false), getTargetDir: vi.fn().mockReturnValue('/test/dir'), getSummarizeToolOutputConfig: vi.fn().mockReturnValue(undefined), getWorkspaceContext: vi .fn() .mockReturnValue(createMockWorkspaceContext('/test/dir')), getGeminiClient: vi.fn(), getGitCoAuthor: vi.fn().mockReturnValue({ enabled: true, name: 'Qwen-Coder', email: 'qwen-coder@alibabacloud.com', }), getShouldUseNodePtyShell: vi.fn().mockReturnValue(false), } as unknown as Config; shellTool = new ShellTool(mockConfig); vi.mocked(os.platform).mockReturnValue('linux'); vi.mocked(os.tmpdir).mockReturnValue('/tmp'); (vi.mocked(crypto.randomBytes) as Mock).mockReturnValue( Buffer.from('abcdef', 'hex'), ); // Capture the output callback to simulate streaming events from the service mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => { mockShellOutputCallback = callback; return { pid: 12345, result: new Promise((resolve) => { resolveExecutionPromise = resolve; }), }; }); }); describe('isCommandAllowed', () => { it('should allow a command if no restrictions are provided', () => { (mockConfig.getCoreTools as Mock).mockReturnValue(undefined); (mockConfig.getExcludeTools as Mock).mockReturnValue(undefined); expect(isCommandAllowed('ls -l', mockConfig).allowed).toBe(true); }); it('should block a command with command substitution using $()', () => { expect(isCommandAllowed('echo $(rm -rf /)', mockConfig).allowed).toBe( false, ); }); }); describe('build', () => { it('should return an invocation for a valid command', () => { const invocation = shellTool.build({ command: 'ls -l', is_background: false, }); expect(invocation).toBeDefined(); }); it('should throw an error for an empty command', () => { expect(() => shellTool.build({ command: ' ', is_background: false }), ).toThrow('Command cannot be empty.'); }); it('should throw an error for a relative directory path', () => { expect(() => shellTool.build({ command: 'ls', directory: 'rel/path', is_background: false, }), ).toThrow('Directory must be an absolute path.'); }); it('should throw an error for a directory outside the workspace', () => { (mockConfig.getWorkspaceContext as Mock).mockReturnValue( createMockWorkspaceContext('/test/dir', ['/another/workspace']), ); expect(() => shellTool.build({ command: 'ls', directory: '/not/in/workspace', is_background: false, }), ).toThrow( "Directory '/not/in/workspace' is not within any of the registered workspace directories.", ); }); it('should return an invocation for a valid absolute directory path', () => { (mockConfig.getWorkspaceContext as Mock).mockReturnValue( createMockWorkspaceContext('/test/dir', ['/another/workspace']), ); const invocation = shellTool.build({ command: 'ls', directory: '/test/dir/subdir', is_background: false, }); expect(invocation).toBeDefined(); }); it('should include background indicator in description when is_background is true', () => { const invocation = shellTool.build({ command: 'npm start', is_background: true, }); expect(invocation.getDescription()).toContain('[background]'); }); it('should not include background indicator in description when is_background is false', () => { const invocation = shellTool.build({ command: 'npm test', is_background: false, }); expect(invocation.getDescription()).not.toContain('[background]'); }); }); describe('execute', () => { const mockAbortSignal = new AbortController().signal; const resolveShellExecution = ( result: Partial = {}, ) => { const fullResult: ShellExecutionResult = { rawOutput: Buffer.from(result.output || ''), output: 'Success', exitCode: 0, signal: null, error: null, aborted: false, pid: 12345, executionMethod: 'child_process', ...result, }; resolveExecutionPromise(fullResult); }; it('should wrap command on linux and parse pgrep output', async () => { const invocation = shellTool.build({ command: 'my-command &', is_background: false, }); const promise = invocation.execute(mockAbortSignal); resolveShellExecution({ pid: 54321 }); vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue(`54321${EOL}54322${EOL}`); // Service PID and background PID const result = await promise; const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp'); const wrappedCommand = `{ my-command & }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`; expect(mockShellExecutionService).toHaveBeenCalledWith( wrappedCommand, '/test/dir', expect.any(Function), expect.any(AbortSignal), false, {}, ); expect(result.llmContent).toContain('Background PIDs: 54322'); expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile); }); it('should add ampersand to command when is_background is true and command does not end with &', async () => { const invocation = shellTool.build({ command: 'npm start', is_background: true, }); const promise = invocation.execute(mockAbortSignal); resolveShellExecution({ pid: 54321 }); vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n'); await promise; const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp'); const wrappedCommand = `{ npm start & }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`; expect(mockShellExecutionService).toHaveBeenCalledWith( wrappedCommand, expect.any(String), expect.any(Function), expect.any(AbortSignal), false, {}, ); }); it('should not add extra ampersand when is_background is true and command already ends with &', async () => { const invocation = shellTool.build({ command: 'npm start &', is_background: true, }); const promise = invocation.execute(mockAbortSignal); resolveShellExecution({ pid: 54321 }); vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n'); await promise; const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp'); const wrappedCommand = `{ npm start & }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`; expect(mockShellExecutionService).toHaveBeenCalledWith( wrappedCommand, expect.any(String), expect.any(Function), expect.any(AbortSignal), false, {}, ); }); it('should not add ampersand when is_background is false', async () => { const invocation = shellTool.build({ command: 'npm test', is_background: false, }); const promise = invocation.execute(mockAbortSignal); resolveShellExecution({ pid: 54321 }); vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n'); await promise; const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp'); const wrappedCommand = `{ npm test; }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`; expect(mockShellExecutionService).toHaveBeenCalledWith( wrappedCommand, expect.any(String), expect.any(Function), expect.any(AbortSignal), false, {}, ); }); it('should use the provided directory as cwd', async () => { (mockConfig.getWorkspaceContext as Mock).mockReturnValue( createMockWorkspaceContext('/test/dir'), ); const invocation = shellTool.build({ command: 'ls', directory: '/test/dir/subdir', is_background: false, }); const promise = invocation.execute(mockAbortSignal); resolveShellExecution(); await promise; const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp'); const wrappedCommand = `{ ls; }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`; expect(mockShellExecutionService).toHaveBeenCalledWith( wrappedCommand, '/test/dir/subdir', expect.any(Function), expect.any(AbortSignal), false, {}, ); }); it('should not wrap command on windows', async () => { vi.mocked(os.platform).mockReturnValue('win32'); const invocation = shellTool.build({ command: 'dir', is_background: false, }); const promise = invocation.execute(mockAbortSignal); resolveShellExecution({ rawOutput: Buffer.from(''), output: '', exitCode: 0, signal: null, error: null, aborted: false, pid: 12345, executionMethod: 'child_process', }); await promise; expect(mockShellExecutionService).toHaveBeenCalledWith( 'dir', '/test/dir', expect.any(Function), expect.any(AbortSignal), false, {}, ); }); it('should format error messages correctly', async () => { const error = new Error('wrapped command failed'); const invocation = shellTool.build({ command: 'user-command', is_background: false, }); const promise = invocation.execute(mockAbortSignal); resolveShellExecution({ error, exitCode: 1, output: 'err', rawOutput: Buffer.from('err'), signal: null, aborted: false, pid: 12345, executionMethod: 'child_process', }); const result = await promise; expect(result.llmContent).toContain('Error: wrapped command failed'); expect(result.llmContent).not.toContain('pgrep'); }); it('should return a SHELL_EXECUTE_ERROR for a command failure', async () => { const error = new Error('command failed'); const invocation = shellTool.build({ command: 'user-command', is_background: false, }); const promise = invocation.execute(mockAbortSignal); resolveShellExecution({ error, exitCode: 1, }); const result = await promise; expect(result.error).toBeDefined(); expect(result.error?.type).toBe(ToolErrorType.SHELL_EXECUTE_ERROR); expect(result.error?.message).toBe('command failed'); }); it('should throw an error for invalid parameters', () => { expect(() => shellTool.build({ command: '', is_background: false }), ).toThrow('Command cannot be empty.'); }); it('should throw an error for invalid directory', () => { expect(() => shellTool.build({ command: 'ls', directory: 'nonexistent', is_background: false, }), ).toThrow('Directory must be an absolute path.'); }); it('should summarize output when configured', async () => { (mockConfig.getSummarizeToolOutputConfig as Mock).mockReturnValue({ [shellTool.name]: { tokenBudget: 1000 }, }); vi.mocked(summarizer.summarizeToolOutput).mockResolvedValue( 'summarized output', ); const invocation = shellTool.build({ command: 'ls', is_background: false, }); const promise = invocation.execute(mockAbortSignal); resolveExecutionPromise({ output: 'long output', rawOutput: Buffer.from('long output'), exitCode: 0, signal: null, error: null, aborted: false, pid: 12345, executionMethod: 'child_process', }); const result = await promise; expect(summarizer.summarizeToolOutput).toHaveBeenCalledWith( expect.any(String), mockConfig.getGeminiClient(), expect.any(AbortSignal), 1000, ); expect(result.llmContent).toBe('summarized output'); expect(result.returnDisplay).toBe('long output'); }); it('should clean up the temp file on synchronous execution error', async () => { const error = new Error('sync spawn error'); mockShellExecutionService.mockImplementation(() => { throw error; }); vi.mocked(fs.existsSync).mockReturnValue(true); // Pretend the file exists const invocation = shellTool.build({ command: 'a-command', is_background: false, }); await expect(invocation.execute(mockAbortSignal)).rejects.toThrow(error); const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp'); expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile); }); describe('Streaming to `updateOutput`', () => { let updateOutputMock: Mock; beforeEach(() => { vi.useFakeTimers({ toFake: ['Date'] }); updateOutputMock = vi.fn(); }); afterEach(() => { vi.useRealTimers(); }); it('should immediately show binary detection message and throttle progress', async () => { const invocation = shellTool.build({ command: 'cat img', is_background: false, }); const promise = invocation.execute(mockAbortSignal, updateOutputMock); mockShellOutputCallback({ type: 'binary_detected' }); expect(updateOutputMock).toHaveBeenCalledOnce(); expect(updateOutputMock).toHaveBeenCalledWith( '[Binary output detected. Halting stream...]', ); mockShellOutputCallback({ type: 'binary_progress', bytesReceived: 1024, }); expect(updateOutputMock).toHaveBeenCalledOnce(); // Advance time past the throttle interval. await vi.advanceTimersByTimeAsync(OUTPUT_UPDATE_INTERVAL_MS + 1); // Send a SECOND progress event. This one will trigger the flush. mockShellOutputCallback({ type: 'binary_progress', bytesReceived: 2048, }); // Now it should be called a second time with the latest progress. expect(updateOutputMock).toHaveBeenCalledTimes(2); expect(updateOutputMock).toHaveBeenLastCalledWith( '[Receiving binary output... 2.0 KB received]', ); resolveExecutionPromise({ rawOutput: Buffer.from(''), output: '', exitCode: 0, signal: null, error: null, aborted: false, pid: 12345, executionMethod: 'child_process', }); await promise; }); }); describe('addCoAuthorToGitCommit', () => { it('should add co-author to git commit with double quotes', async () => { const command = 'git commit -m "Initial commit"'; const invocation = shellTool.build({ command, is_background: false }); const promise = invocation.execute(mockAbortSignal); // Mock the shell execution to return success resolveExecutionPromise({ rawOutput: Buffer.from(''), output: '', exitCode: 0, signal: null, error: null, aborted: false, pid: 12345, executionMethod: 'child_process', }); await promise; // Verify that the command was executed with co-author added expect(mockShellExecutionService).toHaveBeenCalledWith( expect.stringContaining( 'Co-authored-by: Qwen-Coder ', ), expect.any(String), expect.any(Function), expect.any(AbortSignal), false, {}, ); }); it('should add co-author to git commit with single quotes', async () => { const command = "git commit -m 'Fix bug'"; const invocation = shellTool.build({ command, is_background: false }); const promise = invocation.execute(mockAbortSignal); resolveExecutionPromise({ rawOutput: Buffer.from(''), output: '', exitCode: 0, signal: null, error: null, aborted: false, pid: 12345, executionMethod: 'child_process', }); await promise; expect(mockShellExecutionService).toHaveBeenCalledWith( expect.stringContaining( 'Co-authored-by: Qwen-Coder ', ), expect.any(String), expect.any(Function), expect.any(AbortSignal), false, {}, ); }); it('should handle git commit with additional flags', async () => { const command = 'git commit -a -m "Add feature"'; const invocation = shellTool.build({ command, is_background: false }); const promise = invocation.execute(mockAbortSignal); resolveExecutionPromise({ rawOutput: Buffer.from(''), output: '', exitCode: 0, signal: null, error: null, aborted: false, pid: 12345, executionMethod: 'child_process', }); await promise; expect(mockShellExecutionService).toHaveBeenCalledWith( expect.stringContaining( 'Co-authored-by: Qwen-Coder ', ), expect.any(String), expect.any(Function), expect.any(AbortSignal), false, {}, ); }); it('should not modify non-git commands', async () => { const command = 'npm install'; const invocation = shellTool.build({ command, is_background: false }); const promise = invocation.execute(mockAbortSignal); resolveExecutionPromise({ rawOutput: Buffer.from(''), output: '', exitCode: 0, signal: null, error: null, aborted: false, pid: 12345, executionMethod: 'child_process', }); await promise; // On Linux, commands are wrapped with pgrep functionality expect(mockShellExecutionService).toHaveBeenCalledWith( expect.stringContaining('npm install'), expect.any(String), expect.any(Function), expect.any(AbortSignal), false, {}, ); }); it('should not modify git commands without -m flag', async () => { const command = 'git commit'; const invocation = shellTool.build({ command, is_background: false }); const promise = invocation.execute(mockAbortSignal); resolveExecutionPromise({ rawOutput: Buffer.from(''), output: '', exitCode: 0, signal: null, error: null, aborted: false, pid: 12345, executionMethod: 'child_process', }); await promise; // On Linux, commands are wrapped with pgrep functionality expect(mockShellExecutionService).toHaveBeenCalledWith( expect.stringContaining('git commit'), expect.any(String), expect.any(Function), expect.any(AbortSignal), false, {}, ); }); it('should handle git commit with escaped quotes in message', async () => { const command = 'git commit -m "Fix \\"quoted\\" text"'; const invocation = shellTool.build({ command, is_background: false }); const promise = invocation.execute(mockAbortSignal); resolveExecutionPromise({ rawOutput: Buffer.from(''), output: '', exitCode: 0, signal: null, error: null, aborted: false, pid: 12345, executionMethod: 'child_process', }); await promise; expect(mockShellExecutionService).toHaveBeenCalledWith( expect.stringContaining( 'Co-authored-by: Qwen-Coder ', ), expect.any(String), expect.any(Function), expect.any(AbortSignal), false, {}, ); }); it('should not add co-author when disabled in config', async () => { // Mock config with disabled co-author (mockConfig.getGitCoAuthor as Mock).mockReturnValue({ enabled: false, name: 'Qwen-Coder', email: 'qwen-coder@alibabacloud.com', }); const command = 'git commit -m "Initial commit"'; const invocation = shellTool.build({ command, is_background: false }); const promise = invocation.execute(mockAbortSignal); resolveExecutionPromise({ rawOutput: Buffer.from(''), output: '', exitCode: 0, signal: null, error: null, aborted: false, pid: 12345, executionMethod: 'child_process', }); await promise; // On Linux, commands are wrapped with pgrep functionality expect(mockShellExecutionService).toHaveBeenCalledWith( expect.stringContaining('git commit -m "Initial commit"'), expect.any(String), expect.any(Function), expect.any(AbortSignal), false, {}, ); }); it('should use custom name and email from config', async () => { // Mock config with custom co-author details (mockConfig.getGitCoAuthor as Mock).mockReturnValue({ enabled: true, name: 'Custom Bot', email: 'custom@example.com', }); const command = 'git commit -m "Test commit"'; const invocation = shellTool.build({ command, is_background: false }); const promise = invocation.execute(mockAbortSignal); resolveExecutionPromise({ rawOutput: Buffer.from(''), output: '', exitCode: 0, signal: null, error: null, aborted: false, pid: 12345, executionMethod: 'child_process', }); await promise; expect(mockShellExecutionService).toHaveBeenCalledWith( expect.stringContaining( 'Co-authored-by: Custom Bot ', ), expect.any(String), expect.any(Function), expect.any(AbortSignal), false, {}, ); }); }); }); describe('shouldConfirmExecute', () => { it('should not request confirmation for read-only commands', async () => { const invocation = shellTool.build({ command: 'ls -la', is_background: false, }); const confirmation = await invocation.shouldConfirmExecute( new AbortController().signal, ); expect(confirmation).toBe(false); }); it('should request confirmation for a new command and whitelist it on "Always"', async () => { const params = { command: 'npm install', is_background: false }; const invocation = shellTool.build(params); const confirmation = await invocation.shouldConfirmExecute( new AbortController().signal, ); expect(confirmation).not.toBe(false); expect(confirmation && confirmation.type).toBe('exec'); // eslint-disable-next-line @typescript-eslint/no-explicit-any await (confirmation as any).onConfirm( ToolConfirmationOutcome.ProceedAlways, ); // Should now be whitelisted const secondInvocation = shellTool.build({ command: 'npm test', is_background: false, }); const secondConfirmation = await secondInvocation.shouldConfirmExecute( new AbortController().signal, ); expect(secondConfirmation).toBe(false); }); it('should throw an error if validation fails', () => { expect(() => shellTool.build({ command: '', is_background: false }), ).toThrow(); }); }); describe('getDescription', () => { it('should return the windows description when on windows', () => { vi.mocked(os.platform).mockReturnValue('win32'); const shellTool = new ShellTool(mockConfig); expect(shellTool.description).toMatchSnapshot(); }); it('should return the non-windows description when not on windows', () => { vi.mocked(os.platform).mockReturnValue('linux'); const shellTool = new ShellTool(mockConfig); expect(shellTool.description).toMatchSnapshot(); }); }); describe('Windows background execution', () => { it('should detect immediate failure in Windows background task', async () => { vi.mocked(os.platform).mockReturnValue('win32'); const mockAbortSignal = new AbortController().signal; const invocation = shellTool.build({ command: 'invalid_command', is_background: true, }); const promise = invocation.execute(mockAbortSignal); // Wait a tick to ensure mockShellOutputCallback is assigned await new Promise((resolve) => setTimeout(resolve, 0)); if (mockShellOutputCallback) { mockShellOutputCallback({ type: 'data', chunk: "'invalid_command' is not recognized as an internal or external command,\r\noperable program or batch file.\r\n", }); } const result = await promise; expect(result.error).toBeDefined(); expect(result.llmContent).toContain('Command failed to start'); }); }); });