2025-06-29 15:32:26 -04:00
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
2025-07-25 21:56:49 -04:00
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' ;
2025-06-29 15:32:26 -04:00
import { ShellTool } from './shell.js' ;
2025-07-25 21:56:49 -04:00
import { type Config } from '../config/config.js' ;
import {
type ShellExecutionResult ,
type ShellOutputEvent ,
} from '../services/shellExecutionService.js' ;
import * as fs from 'fs' ;
import * as os from 'os' ;
import * as path from 'path' ;
import * as crypto from 'crypto' ;
2025-07-12 21:09:12 -07:00
import * as summarizer from '../utils/summarizer.js' ;
2025-07-25 21:56:49 -04:00
import { ToolConfirmationOutcome } from './tools.js' ;
import { OUTPUT_UPDATE_INTERVAL_MS } from './shell.js' ;
2025-07-31 05:38:20 +09:00
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js' ;
2025-06-29 15:32:26 -04:00
2025-07-25 21:56:49 -04:00
describe ( 'ShellTool' , ( ) = > {
2025-07-12 21:09:12 -07:00
let shellTool : ShellTool ;
2025-07-25 21:56:49 -04:00
let mockConfig : Config ;
let mockShellOutputCallback : ( event : ShellOutputEvent ) = > void ;
let resolveExecutionPromise : ( result : ShellExecutionResult ) = > void ;
2025-07-12 21:09:12 -07:00
beforeEach ( ( ) = > {
2025-07-25 21:56:49 -04:00
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 ) ,
2025-07-31 05:38:20 +09:00
getWorkspaceContext : ( ) = > createMockWorkspaceContext ( '.' ) ,
2025-07-25 21:56:49 -04:00
getGeminiClient : vi.fn ( ) ,
2025-07-12 21:09:12 -07:00
} as unknown as Config ;
2025-07-25 21:56:49 -04:00
shellTool = new ShellTool ( mockConfig ) ;
2025-07-12 21:09:12 -07:00
2025-07-25 21:56:49 -04:00
vi . mocked ( os . platform ) . mockReturnValue ( 'linux' ) ;
vi . mocked ( os . tmpdir ) . mockReturnValue ( '/tmp' ) ;
( vi . mocked ( crypto . randomBytes ) as Mock ) . mockReturnValue (
Buffer . from ( 'abcdef' , 'hex' ) ,
2025-07-12 21:09:12 -07:00
) ;
2025-08-15 10:27:33 -07:00
// Capture the output callback to simulate streaming events from the service
2025-07-25 21:56:49 -04:00
mockShellExecutionService . mockImplementation ( ( _cmd , _cwd , callback ) = > {
mockShellOutputCallback = callback ;
return {
pid : 12345 ,
result : new Promise ( ( resolve ) = > {
resolveExecutionPromise = resolve ;
} ) ,
} ;
} ) ;
2025-07-12 21:09:12 -07:00
} ) ;
2025-07-15 10:22:31 -07:00
2025-07-25 21:56:49 -04:00
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 ) ;
} ) ;
2025-07-15 10:22:31 -07:00
2025-07-25 21:56:49 -04:00
it ( 'should block a command with command substitution using $()' , ( ) = > {
expect ( isCommandAllowed ( 'echo $(rm -rf /)' , mockConfig ) . allowed ) . toBe (
false ,
) ;
} ) ;
} ) ;
2025-07-15 10:22:31 -07:00
2025-08-13 12:27:09 -07:00
describe ( 'build' , ( ) = > {
it ( 'should return an invocation for a valid command' , ( ) = > {
const invocation = shellTool . build ( { command : 'ls -l' } ) ;
expect ( invocation ) . toBeDefined ( ) ;
2025-07-25 21:56:49 -04:00
} ) ;
2025-08-13 12:27:09 -07:00
it ( 'should throw an error for an empty command' , ( ) = > {
expect ( ( ) = > shellTool . build ( { command : ' ' } ) ) . toThrow (
2025-07-25 21:56:49 -04:00
'Command cannot be empty.' ,
) ;
} ) ;
2025-07-15 10:22:31 -07:00
2025-08-13 12:27:09 -07:00
it ( 'should throw an error for a non-existent directory' , ( ) = > {
2025-07-25 21:56:49 -04:00
vi . mocked ( fs . existsSync ) . mockReturnValue ( false ) ;
2025-08-13 12:27:09 -07:00
expect ( ( ) = >
shellTool . build ( { command : 'ls' , directory : 'rel/path' } ) ,
) . toThrow (
"Directory 'rel/path' is not a registered workspace directory." ,
) ;
2025-07-25 21:56:49 -04:00
} ) ;
2025-07-15 10:22:31 -07:00
} ) ;
2025-07-25 21:56:49 -04:00
describe ( 'execute' , ( ) = > {
const mockAbortSignal = new AbortController ( ) . signal ;
const resolveShellExecution = (
result : Partial < ShellExecutionResult > = { } ,
) = > {
const fullResult : ShellExecutionResult = {
rawOutput : Buffer.from ( result . output || '' ) ,
output : 'Success' ,
2025-08-15 10:27:33 -07:00
stdout : 'Success' ,
stderr : '' ,
2025-07-25 21:56:49 -04:00
exitCode : 0 ,
signal : null ,
error : null ,
aborted : false ,
pid : 12345 ,
. . . result ,
} ;
resolveExecutionPromise ( fullResult ) ;
} ;
it ( 'should wrap command on linux and parse pgrep output' , async ( ) = > {
2025-08-13 12:27:09 -07:00
const invocation = shellTool . build ( { command : 'my-command &' } ) ;
const promise = invocation . execute ( mockAbortSignal ) ;
2025-07-25 21:56:49 -04:00
resolveShellExecution ( { pid : 54321 } ) ;
vi . mocked ( fs . existsSync ) . mockReturnValue ( true ) ;
2025-08-15 10:27:33 -07:00
vi . mocked ( fs . readFileSync ) . mockReturnValue ( '54321\n54322\n' ) ; // Service PID and background PID
2025-07-25 21:56:49 -04:00
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 ,
expect . any ( String ) ,
expect . any ( Function ) ,
mockAbortSignal ,
) ;
expect ( result . llmContent ) . toContain ( 'Background PIDs: 54322' ) ;
expect ( vi . mocked ( fs . unlinkSync ) ) . toHaveBeenCalledWith ( tmpFile ) ;
} ) ;
it ( 'should not wrap command on windows' , async ( ) = > {
vi . mocked ( os . platform ) . mockReturnValue ( 'win32' ) ;
2025-08-13 12:27:09 -07:00
const invocation = shellTool . build ( { command : 'dir' } ) ;
const promise = invocation . execute ( mockAbortSignal ) ;
resolveShellExecution ( {
2025-07-25 21:56:49 -04:00
rawOutput : Buffer.from ( '' ) ,
output : '' ,
2025-08-15 10:27:33 -07:00
stdout : '' ,
stderr : '' ,
2025-07-25 21:56:49 -04:00
exitCode : 0 ,
signal : null ,
error : null ,
aborted : false ,
pid : 12345 ,
} ) ;
await promise ;
expect ( mockShellExecutionService ) . toHaveBeenCalledWith (
'dir' ,
expect . any ( String ) ,
expect . any ( Function ) ,
mockAbortSignal ,
) ;
} ) ;
it ( 'should format error messages correctly' , async ( ) = > {
const error = new Error ( 'wrapped command failed' ) ;
2025-08-13 12:27:09 -07:00
const invocation = shellTool . build ( { command : 'user-command' } ) ;
const promise = invocation . execute ( mockAbortSignal ) ;
2025-07-25 21:56:49 -04:00
resolveShellExecution ( {
error ,
exitCode : 1 ,
output : 'err' ,
2025-08-15 10:27:33 -07:00
stderr : 'err' ,
2025-07-25 21:56:49 -04:00
rawOutput : Buffer.from ( 'err' ) ,
2025-08-15 10:27:33 -07:00
stdout : '' ,
2025-07-25 21:56:49 -04:00
signal : null ,
aborted : false ,
pid : 12345 ,
} ) ;
const result = await promise ;
2025-08-15 10:27:33 -07:00
// The final llmContent should contain the user's command, not the wrapper
2025-07-25 21:56:49 -04:00
expect ( result . llmContent ) . toContain ( 'Error: wrapped command failed' ) ;
expect ( result . llmContent ) . not . toContain ( 'pgrep' ) ;
} ) ;
2025-08-13 12:27:09 -07:00
it ( 'should throw an error for invalid parameters' , ( ) = > {
expect ( ( ) = > shellTool . build ( { command : '' } ) ) . toThrow (
'Command cannot be empty.' ,
2025-08-08 04:33:42 -07:00
) ;
} ) ;
2025-08-13 12:27:09 -07:00
it ( 'should throw an error for invalid directory' , ( ) = > {
2025-08-08 04:33:42 -07:00
vi . mocked ( fs . existsSync ) . mockReturnValue ( false ) ;
2025-08-13 12:27:09 -07:00
expect ( ( ) = >
shellTool . build ( { command : 'ls' , directory : 'nonexistent' } ) ,
) . toThrow (
` Directory 'nonexistent' is not a registered workspace directory. ` ,
2025-08-08 04:33:42 -07:00
) ;
} ) ;
2025-07-25 21:56:49 -04:00
it ( 'should summarize output when configured' , async ( ) = > {
( mockConfig . getSummarizeToolOutputConfig as Mock ) . mockReturnValue ( {
2025-07-15 10:22:31 -07:00
[ shellTool . name ] : { tokenBudget : 1000 } ,
2025-07-25 21:56:49 -04:00
} ) ;
vi . mocked ( summarizer . summarizeToolOutput ) . mockResolvedValue (
'summarized output' ,
) ;
2025-07-15 10:22:31 -07:00
2025-08-13 12:27:09 -07:00
const invocation = shellTool . build ( { command : 'ls' } ) ;
const promise = invocation . execute ( mockAbortSignal ) ;
2025-07-25 21:56:49 -04:00
resolveExecutionPromise ( {
output : 'long output' ,
rawOutput : Buffer.from ( 'long output' ) ,
2025-08-15 10:27:33 -07:00
stdout : 'long output' ,
stderr : '' ,
2025-07-25 21:56:49 -04:00
exitCode : 0 ,
signal : null ,
error : null ,
aborted : false ,
pid : 12345 ,
} ) ;
2025-07-15 10:22:31 -07:00
2025-07-25 21:56:49 -04:00
const result = await promise ;
2025-07-15 10:22:31 -07:00
2025-07-25 21:56:49 -04:00
expect ( summarizer . summarizeToolOutput ) . toHaveBeenCalledWith (
expect . any ( String ) ,
mockConfig . getGeminiClient ( ) ,
mockAbortSignal ,
1000 ,
) ;
expect ( result . llmContent ) . toBe ( 'summarized output' ) ;
expect ( result . returnDisplay ) . toBe ( 'long output' ) ;
} ) ;
2025-07-15 10:22:31 -07:00
2025-07-25 21:56:49 -04:00
it ( 'should clean up the temp file on synchronous execution error' , async ( ) = > {
const error = new Error ( 'sync spawn error' ) ;
mockShellExecutionService . mockImplementation ( ( ) = > {
throw error ;
} ) ;
2025-08-15 10:27:33 -07:00
vi . mocked ( fs . existsSync ) . mockReturnValue ( true ) ; // Pretend the file exists
2025-07-15 10:22:31 -07:00
2025-08-13 12:27:09 -07:00
const invocation = shellTool . build ( { command : 'a-command' } ) ;
await expect ( invocation . execute ( mockAbortSignal ) ) . rejects . toThrow ( error ) ;
2025-07-15 10:22:31 -07:00
2025-07-25 21:56:49 -04:00
const tmpFile = path . join ( os . tmpdir ( ) , 'shell_pgrep_abcdef.tmp' ) ;
expect ( vi . mocked ( fs . unlinkSync ) ) . toHaveBeenCalledWith ( tmpFile ) ;
} ) ;
2025-07-15 10:22:31 -07:00
2025-07-25 21:56:49 -04:00
describe ( 'Streaming to `updateOutput`' , ( ) = > {
let updateOutputMock : Mock ;
beforeEach ( ( ) = > {
vi . useFakeTimers ( { toFake : [ 'Date' ] } ) ;
updateOutputMock = vi . fn ( ) ;
} ) ;
afterEach ( ( ) = > {
vi . useRealTimers ( ) ;
} ) ;
2025-07-24 10:13:00 -07:00
2025-07-25 21:56:49 -04:00
it ( 'should throttle text output updates' , async ( ) = > {
2025-08-13 12:27:09 -07:00
const invocation = shellTool . build ( { command : 'stream' } ) ;
const promise = invocation . execute ( mockAbortSignal , updateOutputMock ) ;
2025-07-25 21:56:49 -04:00
2025-08-15 10:27:33 -07:00
// First chunk, should be throttled.
2025-07-25 21:56:49 -04:00
mockShellOutputCallback ( {
type : 'data' ,
2025-08-15 10:27:33 -07:00
stream : 'stdout' ,
2025-07-25 21:56:49 -04:00
chunk : 'hello ' ,
} ) ;
expect ( updateOutputMock ) . not . toHaveBeenCalled ( ) ;
2025-08-15 10:27:33 -07:00
// Advance time past the throttle interval.
2025-07-25 21:56:49 -04:00
await vi . advanceTimersByTimeAsync ( OUTPUT_UPDATE_INTERVAL_MS + 1 ) ;
2025-08-15 10:27:33 -07:00
// Send a second chunk. THIS event triggers the update with the CUMULATIVE content.
2025-07-25 21:56:49 -04:00
mockShellOutputCallback ( {
type : 'data' ,
2025-08-15 10:27:33 -07:00
stream : 'stderr' ,
chunk : 'world' ,
2025-07-25 21:56:49 -04:00
} ) ;
// It should have been called once now with the combined output.
expect ( updateOutputMock ) . toHaveBeenCalledOnce ( ) ;
2025-08-15 10:27:33 -07:00
expect ( updateOutputMock ) . toHaveBeenCalledWith ( 'hello \nworld' ) ;
2025-07-25 21:56:49 -04:00
resolveExecutionPromise ( {
rawOutput : Buffer.from ( '' ) ,
output : '' ,
2025-08-15 10:27:33 -07:00
stdout : '' ,
stderr : '' ,
2025-07-25 21:56:49 -04:00
exitCode : 0 ,
signal : null ,
error : null ,
aborted : false ,
pid : 12345 ,
} ) ;
await promise ;
} ) ;
it ( 'should immediately show binary detection message and throttle progress' , async ( ) = > {
2025-08-13 12:27:09 -07:00
const invocation = shellTool . build ( { command : 'cat img' } ) ;
const promise = invocation . execute ( mockAbortSignal , updateOutputMock ) ;
2025-07-24 10:13:00 -07:00
2025-07-25 21:56:49 -04:00
mockShellOutputCallback ( { type : 'binary_detected' } ) ;
expect ( updateOutputMock ) . toHaveBeenCalledOnce ( ) ;
expect ( updateOutputMock ) . toHaveBeenCalledWith (
'[Binary output detected. Halting stream...]' ,
) ;
2025-07-24 10:13:00 -07:00
2025-07-25 21:56:49 -04:00
mockShellOutputCallback ( {
type : 'binary_progress' ,
bytesReceived : 1024 ,
} ) ;
expect ( updateOutputMock ) . toHaveBeenCalledOnce ( ) ;
2025-08-15 10:27:33 -07:00
// Advance time past the throttle interval.
2025-07-25 21:56:49 -04:00
await vi . advanceTimersByTimeAsync ( OUTPUT_UPDATE_INTERVAL_MS + 1 ) ;
2025-08-15 10:27:33 -07:00
// Send a SECOND progress event. This one will trigger the flush.
2025-07-25 21:56:49 -04:00
mockShellOutputCallback ( {
type : 'binary_progress' ,
bytesReceived : 2048 ,
} ) ;
2025-08-15 10:27:33 -07:00
// Now it should be called a second time with the latest progress.
2025-07-25 21:56:49 -04:00
expect ( updateOutputMock ) . toHaveBeenCalledTimes ( 2 ) ;
expect ( updateOutputMock ) . toHaveBeenLastCalledWith (
'[Receiving binary output... 2.0 KB received]' ,
) ;
resolveExecutionPromise ( {
rawOutput : Buffer.from ( '' ) ,
output : '' ,
2025-08-15 10:27:33 -07:00
stdout : '' ,
stderr : '' ,
2025-07-25 21:56:49 -04:00
exitCode : 0 ,
signal : null ,
error : null ,
aborted : false ,
pid : 12345 ,
} ) ;
await promise ;
} ) ;
} ) ;
2025-07-24 10:13:00 -07:00
} ) ;
2025-07-25 12:25:32 -07:00
2025-07-25 21:56:49 -04:00
describe ( 'shouldConfirmExecute' , ( ) = > {
it ( 'should request confirmation for a new command and whitelist it on "Always"' , async ( ) = > {
const params = { command : 'npm install' } ;
2025-08-13 12:27:09 -07:00
const invocation = shellTool . build ( params ) ;
const confirmation = await invocation . shouldConfirmExecute (
2025-07-25 21:56:49 -04:00
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
2025-08-13 12:27:09 -07:00
const secondInvocation = shellTool . build ( { command : 'npm test' } ) ;
const secondConfirmation = await secondInvocation . shouldConfirmExecute (
2025-07-25 21:56:49 -04:00
new AbortController ( ) . signal ,
) ;
expect ( secondConfirmation ) . toBe ( false ) ;
} ) ;
2025-08-13 12:27:09 -07:00
it ( 'should throw an error if validation fails' , ( ) = > {
expect ( ( ) = > shellTool . build ( { command : '' } ) ) . toThrow ( ) ;
2025-07-25 21:56:49 -04:00
} ) ;
2025-07-25 12:25:32 -07:00
} ) ;
2025-08-15 12:08:29 -07:00
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 )
. toEqual ( ` This tool executes a given shell command as \` cmd.exe /c <command> \` . Command can start background processes using \` start /b \` .
The following information is returned:
Command: Executed command.
Directory: Directory (relative to project root) where command was executed, or \` (root) \` .
Stdout: Output on stdout stream. Can be \` (empty) \` or partial on error and for any unwaited background processes.
Stderr: Output on stderr stream. Can be \` (empty) \` or partial on error and for any unwaited background processes.
Error: Error or \` (none) \` if no error was reported for the subprocess.
Exit Code: Exit code or \` (none) \` if terminated by signal.
Signal: Signal number or \` (none) \` if no signal was received.
Background PIDs: List of background processes started or \` (none) \` .
Process Group PGID: Process group started or \` (none) \` ` ) ;
} ) ;
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 )
. toEqual ( ` This tool executes a given shell command as \` bash -c <command> \` . Command can start background processes using \` & \` . Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \` kill -- -PGID \` or signaled as \` kill -s SIGNAL -- -PGID \` .
The following information is returned:
Command: Executed command.
Directory: Directory (relative to project root) where command was executed, or \` (root) \` .
Stdout: Output on stdout stream. Can be \` (empty) \` or partial on error and for any unwaited background processes.
Stderr: Output on stderr stream. Can be \` (empty) \` or partial on error and for any unwaited background processes.
Error: Error or \` (none) \` if no error was reported for the subprocess.
Exit Code: Exit code or \` (none) \` if terminated by signal.
Signal: Signal number or \` (none) \` if no signal was received.
Background PIDs: List of background processes started or \` (none) \` .
Process Group PGID: Process group started or \` (none) \` ` ) ;
} ) ;
} ) ;
2025-07-25 12:25:32 -07:00
} ) ;
2025-07-31 05:38:20 +09:00
2025-08-13 12:27:09 -07:00
describe ( 'build' , ( ) = > {
it ( 'should return an invocation for valid directory' , ( ) = > {
2025-07-31 05:38:20 +09:00
const config = {
getCoreTools : ( ) = > undefined ,
getExcludeTools : ( ) = > undefined ,
getTargetDir : ( ) = > '/root' ,
getWorkspaceContext : ( ) = >
createMockWorkspaceContext ( '/root' , [ '/users/test' ] ) ,
} as unknown as Config ;
const shellTool = new ShellTool ( config ) ;
2025-08-13 12:27:09 -07:00
const invocation = shellTool . build ( {
2025-07-31 05:38:20 +09:00
command : 'ls' ,
directory : 'test' ,
} ) ;
2025-08-13 12:27:09 -07:00
expect ( invocation ) . toBeDefined ( ) ;
2025-07-31 05:38:20 +09:00
} ) ;
2025-08-13 12:27:09 -07:00
it ( 'should throw an error for directory outside workspace' , ( ) = > {
2025-07-31 05:38:20 +09:00
const config = {
getCoreTools : ( ) = > undefined ,
getExcludeTools : ( ) = > undefined ,
getTargetDir : ( ) = > '/root' ,
getWorkspaceContext : ( ) = >
createMockWorkspaceContext ( '/root' , [ '/users/test' ] ) ,
} as unknown as Config ;
const shellTool = new ShellTool ( config ) ;
2025-08-13 12:27:09 -07:00
expect ( ( ) = >
shellTool . build ( {
command : 'ls' ,
directory : 'test2' ,
} ) ,
) . toThrow ( 'is not a registered workspace directory' ) ;
2025-07-31 05:38:20 +09:00
} ) ;
} ) ;