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 ,
2025-10-16 17:25:30 -07:00
beforeAll ,
2025-07-25 21:56:49 -04:00
beforeEach ,
afterEach ,
type Mock ,
} from 'vitest' ;
2025-11-06 15:03:52 -08:00
const mockPlatform = vi . hoisted ( ( ) = > vi . fn ( ) ) ;
2025-07-25 21:56:49 -04:00
const mockShellExecutionService = vi . hoisted ( ( ) = > vi . fn ( ) ) ;
2026-01-30 09:53:09 -08:00
const mockShellBackground = vi . hoisted ( ( ) = > vi . fn ( ) ) ;
2025-07-25 21:56:49 -04:00
vi . mock ( '../services/shellExecutionService.js' , ( ) = > ( {
2026-01-30 09:53:09 -08:00
ShellExecutionService : {
execute : mockShellExecutionService ,
background : mockShellBackground ,
} ,
2025-07-25 21:56:49 -04:00
} ) ) ;
2025-11-06 15:03:52 -08:00
vi . mock ( 'node:os' , async ( importOriginal ) = > {
const actualOs = await importOriginal < typeof os > ( ) ;
return {
. . . actualOs ,
default : {
. . . actualOs ,
platform : mockPlatform ,
} ,
platform : mockPlatform ,
} ;
} ) ;
2025-07-25 21:56:49 -04:00
vi . mock ( 'crypto' ) ;
vi . mock ( '../utils/summarizer.js' ) ;
2025-12-12 15:02:19 -08:00
import { initializeShellParsers } from '../utils/shell-utils.js' ;
2025-06-29 15:32:26 -04:00
import { ShellTool } from './shell.js' ;
2026-01-30 09:53:09 -08:00
import { debugLogger } from '../index.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' ;
2025-08-25 22:11:27 +02:00
import * as fs from 'node:fs' ;
import * as os from 'node:os' ;
import { EOL } from 'node:os' ;
import * as path from 'node:path' ;
2026-01-27 13:17:40 -08:00
import { isSubpath } from '../utils/paths.js' ;
2025-08-25 22:11:27 +02:00
import * as crypto from 'node:crypto' ;
2025-07-12 21:09:12 -07:00
import * as summarizer from '../utils/summarizer.js' ;
2025-08-26 15:26:16 -04:00
import { ToolErrorType } from './tool-error.js' ;
2026-01-04 00:19:00 -05:00
import { ToolConfirmationOutcome } from './tools.js' ;
2025-07-25 21:56:49 -04:00
import { OUTPUT_UPDATE_INTERVAL_MS } from './shell.js' ;
2025-10-17 21:07:26 -04:00
import { SHELL_TOOL_NAME } from './tool-names.js' ;
2025-11-06 15:03:52 -08:00
import { WorkspaceContext } from '../utils/workspaceContext.js' ;
2026-01-04 00:19:00 -05:00
import {
createMockMessageBus ,
getMockMessageBusInstance ,
} from '../test-utils/mock-message-bus.js' ;
import {
MessageBusType ,
type UpdatePolicy ,
} from '../confirmation-bus/types.js' ;
import { type MessageBus } from '../confirmation-bus/message-bus.js' ;
interface TestableMockMessageBus extends MessageBus {
defaultToolDecision : 'allow' | 'deny' | 'ask_user' ;
}
2025-06-29 15:32:26 -04:00
2025-10-16 17:25:30 -07:00
const originalComSpec = process . env [ 'ComSpec' ] ;
const itWindowsOnly = process . platform === 'win32' ? it : it.skip ;
2025-07-25 21:56:49 -04:00
describe ( 'ShellTool' , ( ) = > {
2025-11-06 15:03:52 -08:00
beforeAll ( async ( ) = > {
await initializeShellParsers ( ) ;
} ) ;
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-11-06 15:03:52 -08:00
let tempRootDir : string ;
2025-07-12 21:09:12 -07:00
beforeEach ( ( ) = > {
2025-07-25 21:56:49 -04:00
vi . clearAllMocks ( ) ;
2025-11-06 15:03:52 -08:00
tempRootDir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'shell-test-' ) ) ;
fs . mkdirSync ( path . join ( tempRootDir , 'subdir' ) ) ;
2025-07-25 21:56:49 -04:00
mockConfig = {
2025-10-15 12:44:07 -07:00
getAllowedTools : vi.fn ( ) . mockReturnValue ( [ ] ) ,
getApprovalMode : vi.fn ( ) . mockReturnValue ( 'strict' ) ,
2025-07-25 21:56:49 -04:00
getCoreTools : vi.fn ( ) . mockReturnValue ( [ ] ) ,
2025-11-07 12:18:35 -08:00
getExcludeTools : vi.fn ( ) . mockReturnValue ( new Set ( [ ] ) ) ,
2025-07-25 21:56:49 -04:00
getDebugMode : vi.fn ( ) . mockReturnValue ( false ) ,
2025-11-06 15:03:52 -08:00
getTargetDir : vi.fn ( ) . mockReturnValue ( tempRootDir ) ,
2025-07-25 21:56:49 -04:00
getSummarizeToolOutputConfig : vi.fn ( ) . mockReturnValue ( undefined ) ,
2025-09-13 10:33:12 -07:00
getWorkspaceContext : vi
. fn ( )
2025-11-06 15:03:52 -08:00
. mockReturnValue ( new WorkspaceContext ( tempRootDir ) ) ,
2026-01-27 13:17:40 -08:00
storage : {
getProjectTempDir : vi.fn ( ) . mockReturnValue ( '/tmp/project' ) ,
} ,
isPathAllowed ( this : Config , absolutePath : string ) : boolean {
const workspaceContext = this . getWorkspaceContext ( ) ;
if ( workspaceContext . isPathWithinWorkspace ( absolutePath ) ) {
return true ;
}
const projectTempDir = this . storage . getProjectTempDir ( ) ;
return isSubpath ( path . resolve ( projectTempDir ) , absolutePath ) ;
} ,
validatePathAccess ( this : Config , absolutePath : string ) : string | null {
if ( this . isPathAllowed ( absolutePath ) ) {
return null ;
}
const workspaceDirs = this . getWorkspaceContext ( ) . getDirectories ( ) ;
const projectTempDir = this . storage . getProjectTempDir ( ) ;
return ` Path not in workspace: Attempted path " ${ absolutePath } " resolves outside the allowed workspace directories: ${ workspaceDirs . join ( ', ' ) } or the project temp directory: ${ projectTempDir } ` ;
} ,
getGeminiClient : vi.fn ( ) . mockReturnValue ( { } ) ,
getShellToolInactivityTimeout : vi.fn ( ) . mockReturnValue ( 1000 ) ,
2025-10-08 13:28:19 -07:00
getEnableInteractiveShell : vi.fn ( ) . mockReturnValue ( false ) ,
2026-02-09 10:51:13 -08:00
getEnableShellOutputEfficiency : vi.fn ( ) . mockReturnValue ( true ) ,
2026-01-27 13:17:40 -08:00
sanitizationConfig : { } ,
2025-07-12 21:09:12 -07:00
} as unknown as Config ;
2026-01-04 00:19:00 -05:00
const bus = createMockMessageBus ( ) ;
const mockBus = getMockMessageBusInstance (
bus ,
) as unknown as TestableMockMessageBus ;
mockBus . defaultToolDecision = 'ask_user' ;
// Simulate policy update
bus . subscribe ( MessageBusType . UPDATE_POLICY , ( msg : UpdatePolicy ) = > {
if ( msg . commandPrefix ) {
const prefixes = Array . isArray ( msg . commandPrefix )
? msg . commandPrefix
: [ msg . commandPrefix ] ;
const current = mockConfig . getAllowedTools ( ) || [ ] ;
( mockConfig . getAllowedTools as Mock ) . mockReturnValue ( [
. . . current ,
. . . prefixes ,
] ) ;
// Simulate Policy Engine allowing the tool after update
mockBus . defaultToolDecision = 'allow' ;
}
} ) ;
shellTool = new ShellTool ( mockConfig , bus ) ;
2025-07-12 21:09:12 -07:00
2025-11-06 15:03:52 -08:00
mockPlatform . mockReturnValue ( 'linux' ) ;
2025-07-25 21:56:49 -04:00
( vi . mocked ( crypto . randomBytes ) as Mock ) . mockReturnValue (
Buffer . from ( 'abcdef' , 'hex' ) ,
2025-07-12 21:09:12 -07:00
) ;
2025-10-16 17:25:30 -07:00
process . env [ 'ComSpec' ] =
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' ;
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 ;
} ) ,
} ;
} ) ;
2026-01-30 09:53:09 -08:00
mockShellBackground . mockImplementation ( ( ) = > {
resolveExecutionPromise ( {
output : '' ,
rawOutput : Buffer.from ( '' ) ,
exitCode : null ,
signal : null ,
error : null ,
aborted : false ,
pid : 12345 ,
executionMethod : 'child_process' ,
backgrounded : true ,
} ) ;
} ) ;
2025-07-12 21:09:12 -07:00
} ) ;
2025-07-15 10:22:31 -07:00
2025-10-16 17:25:30 -07:00
afterEach ( ( ) = > {
2025-11-06 15:03:52 -08:00
if ( fs . existsSync ( tempRootDir ) ) {
fs . rmSync ( tempRootDir , { recursive : true , force : true } ) ;
}
2025-10-16 17:25:30 -07:00
if ( originalComSpec === undefined ) {
delete process . env [ 'ComSpec' ] ;
} else {
process . env [ 'ComSpec' ] = originalComSpec ;
}
} ) ;
2025-08-13 12:27:09 -07:00
describe ( 'build' , ( ) = > {
it ( 'should return an invocation for a valid command' , ( ) = > {
2025-10-16 17:25:30 -07:00
const invocation = shellTool . build ( { command : 'goodCommand --safe' } ) ;
2025-08-13 12:27:09 -07:00
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-11-06 15:03:52 -08:00
it ( 'should return an invocation for a valid relative directory path' , ( ) = > {
const invocation = shellTool . build ( {
command : 'ls' ,
dir_path : 'subdir' ,
} ) ;
expect ( invocation ) . toBeDefined ( ) ;
2025-09-13 10:33:12 -07:00
} ) ;
it ( 'should throw an error for a directory outside the workspace' , ( ) = > {
2025-11-06 15:03:52 -08:00
const outsidePath = path . resolve ( tempRootDir , '../outside' ) ;
2025-09-13 10:33:12 -07:00
expect ( ( ) = >
2025-11-06 15:03:52 -08:00
shellTool . build ( { command : 'ls' , dir_path : outsidePath } ) ,
2026-01-27 13:17:40 -08:00
) . toThrow ( /Path not in workspace/ ) ;
2025-09-13 10:33:12 -07:00
} ) ;
it ( 'should return an invocation for a valid absolute directory path' , ( ) = > {
const invocation = shellTool . build ( {
command : 'ls' ,
2025-11-06 15:03:52 -08:00
dir_path : path.join ( tempRootDir , 'subdir' ) ,
2025-09-13 10:33:12 -07:00
} ) ;
expect ( invocation ) . toBeDefined ( ) ;
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' ,
exitCode : 0 ,
signal : null ,
error : null ,
aborted : false ,
pid : 12345 ,
2025-08-19 16:03:51 -07:00
executionMethod : 'child_process' ,
2025-07-25 21:56:49 -04:00
. . . 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 } ) ;
2025-11-06 15:03:52 -08:00
// Simulate pgrep output file creation by the shell command
const tmpFile = path . join ( os . tmpdir ( ) , 'shell_pgrep_abcdef.tmp' ) ;
fs . writeFileSync ( tmpFile , ` 54321 ${ EOL } 54322 ${ EOL } ` ) ;
2025-07-25 21:56:49 -04:00
const result = await promise ;
const wrappedCommand = ` { my-command & }; __code= $ ?; pgrep -g 0 > ${ tmpFile } 2>&1; exit $ __code; ` ;
expect ( mockShellExecutionService ) . toHaveBeenCalledWith (
wrappedCommand ,
2025-11-06 15:03:52 -08:00
tempRootDir ,
2025-07-25 21:56:49 -04:00
expect . any ( Function ) ,
2025-11-26 13:43:33 -08:00
expect . any ( AbortSignal ) ,
2025-08-19 16:03:51 -07:00
false ,
2026-01-27 13:17:40 -08:00
{ pager : 'cat' , sanitizationConfig : { } } ,
2025-07-25 21:56:49 -04:00
) ;
expect ( result . llmContent ) . toContain ( 'Background PIDs: 54322' ) ;
2025-11-06 15:03:52 -08:00
// The file should be deleted by the tool
expect ( fs . existsSync ( tmpFile ) ) . toBe ( false ) ;
2025-07-25 21:56:49 -04:00
} ) ;
2025-11-06 15:03:52 -08:00
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>&1; exit $ __code; ` ;
expect ( mockShellExecutionService ) . toHaveBeenCalledWith (
wrappedCommand ,
subdir ,
expect . any ( Function ) ,
2025-11-26 13:43:33 -08:00
expect . any ( AbortSignal ) ,
2025-11-06 15:03:52 -08:00
false ,
2026-01-27 13:17:40 -08:00
{ pager : 'cat' , sanitizationConfig : { } } ,
2025-09-13 10:33:12 -07:00
) ;
2025-11-06 15:03:52 -08:00
} ) ;
it ( 'should use the provided relative directory as cwd' , async ( ) = > {
2025-09-13 10:33:12 -07:00
const invocation = shellTool . build ( {
command : 'ls' ,
2025-11-06 15:03:52 -08:00
dir_path : 'subdir' ,
2025-09-13 10:33:12 -07:00
} ) ;
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 ,
2025-11-06 15:03:52 -08:00
path . join ( tempRootDir , 'subdir' ) ,
2025-09-13 10:33:12 -07:00
expect . any ( Function ) ,
2025-11-26 13:43:33 -08:00
expect . any ( AbortSignal ) ,
2025-09-13 10:33:12 -07:00
false ,
2026-01-27 13:17:40 -08:00
{ pager : 'cat' , sanitizationConfig : { } } ,
2025-09-13 10:33:12 -07:00
) ;
} ) ;
2026-01-30 09:53:09 -08:00
it ( 'should handle is_background parameter by calling ShellExecutionService.background' , async ( ) = > {
vi . useFakeTimers ( ) ;
const invocation = shellTool . build ( {
command : 'sleep 10' ,
is_background : true ,
} ) ;
const promise = invocation . execute ( mockAbortSignal ) ;
// We need to provide a PID for the background logic to trigger
resolveShellExecution ( { pid : 12345 } ) ;
// Advance time to trigger the background timeout
await vi . advanceTimersByTimeAsync ( 250 ) ;
expect ( mockShellBackground ) . toHaveBeenCalledWith ( 12345 ) ;
await promise ;
} ) ;
2025-11-05 11:53:03 -05:00
itWindowsOnly (
'should not wrap command on windows' ,
async ( ) = > {
2025-11-06 15:03:52 -08:00
mockPlatform . mockReturnValue ( 'win32' ) ;
2025-11-05 11:53:03 -05:00
const invocation = shellTool . build ( { command : 'dir' } ) ;
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' ,
2025-11-06 15:03:52 -08:00
tempRootDir ,
2025-11-05 11:53:03 -05:00
expect . any ( Function ) ,
2025-11-26 13:43:33 -08:00
expect . any ( AbortSignal ) ,
2025-11-05 11:53:03 -05:00
false ,
2026-01-27 13:17:40 -08:00
{ pager : 'cat' , sanitizationConfig : { } } ,
2025-11-05 11:53:03 -05:00
) ;
} ,
20000 ,
) ;
2025-07-25 21:56:49 -04:00
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' ,
rawOutput : Buffer.from ( 'err' ) ,
signal : null ,
aborted : false ,
pid : 12345 ,
2025-08-19 16:03:51 -07:00
executionMethod : 'child_process' ,
2025-07-25 21:56:49 -04:00
} ) ;
const result = await promise ;
expect ( result . llmContent ) . toContain ( 'Error: wrapped command failed' ) ;
expect ( result . llmContent ) . not . toContain ( 'pgrep' ) ;
} ) ;
2025-08-26 15:26:16 -04:00
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' ) ;
} ) ;
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-07-25 21:56:49 -04:00
it ( 'should summarize output when configured' , async ( ) = > {
( mockConfig . getSummarizeToolOutputConfig as Mock ) . mockReturnValue ( {
2025-10-17 21:07:26 -04:00
[ SHELL_TOOL_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' ) ,
exitCode : 0 ,
signal : null ,
error : null ,
aborted : false ,
pid : 12345 ,
2025-08-19 16:03:51 -07:00
executionMethod : 'child_process' ,
2025-07-25 21:56:49 -04:00
} ) ;
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 (
2025-11-11 08:10:50 -08:00
mockConfig ,
{ model : 'summarizer-shell' } ,
2025-07-25 21:56:49 -04:00
expect . any ( String ) ,
mockConfig . getGeminiClient ( ) ,
mockAbortSignal ,
) ;
expect ( result . llmContent ) . toBe ( 'summarized output' ) ;
expect ( result . returnDisplay ) . toBe ( 'long output' ) ;
} ) ;
2025-07-15 10:22:31 -07:00
2025-11-26 13:43:33 -08:00
it ( 'should NOT start a timeout if timeoutMs is <= 0' , async ( ) = > {
// Mock the timeout config to be 0
( mockConfig . getShellToolInactivityTimeout as Mock ) . mockReturnValue ( 0 ) ;
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.
} ) ;
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 ( ( ) = > {
2025-11-06 15:03:52 -08:00
// 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 , '' ) ;
2025-07-25 21:56:49 -04:00
throw error ;
} ) ;
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' ) ;
2025-11-06 15:03:52 -08:00
expect ( fs . existsSync ( tmpFile ) ) . toBe ( false ) ;
2025-07-25 21:56:49 -04:00
} ) ;
2025-07-15 10:22:31 -07:00
2026-01-30 09:53:09 -08:00
it ( 'should not log "missing pgrep output" when process is backgrounded' , async ( ) = > {
vi . useFakeTimers ( ) ;
const debugErrorSpy = vi . spyOn ( debugLogger , 'error' ) ;
const invocation = shellTool . build ( {
command : 'sleep 10' ,
is_background : true ,
} ) ;
const promise = invocation . execute ( mockAbortSignal ) ;
// Advance time to trigger backgrounding
await vi . advanceTimersByTimeAsync ( 200 ) ;
await promise ;
expect ( debugErrorSpy ) . not . toHaveBeenCalledWith ( 'missing pgrep output' ) ;
} ) ;
2025-07-25 21:56:49 -04:00
describe ( 'Streaming to `updateOutput`' , ( ) = > {
let updateOutputMock : Mock ;
beforeEach ( ( ) = > {
2026-01-30 09:53:09 -08:00
vi . useFakeTimers ( { toFake : [ 'Date' , 'setTimeout' , 'clearTimeout' ] } ) ;
2025-07-25 21:56:49 -04:00
updateOutputMock = vi . fn ( ) ;
} ) ;
afterEach ( ( ) = > {
vi . useRealTimers ( ) ;
} ) ;
2025-07-24 10:13:00 -07:00
2025-07-25 21:56:49 -04:00
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 : '' ,
exitCode : 0 ,
signal : null ,
error : null ,
aborted : false ,
pid : 12345 ,
2025-08-19 16:03:51 -07:00
executionMethod : 'child_process' ,
2025-07-25 21:56:49 -04:00
} ) ;
await promise ;
} ) ;
2026-01-30 09:53:09 -08:00
it ( 'should NOT call updateOutput if the command is backgrounded' , async ( ) = > {
const invocation = shellTool . build ( {
command : 'sleep 10' ,
is_background : true ,
} ) ;
const promise = invocation . execute ( mockAbortSignal , updateOutputMock ) ;
mockShellOutputCallback ( { type : 'data' , chunk : 'some output' } ) ;
expect ( updateOutputMock ) . not . toHaveBeenCalled ( ) ;
// We need to provide a PID for the background logic to trigger
resolveShellExecution ( { pid : 12345 } ) ;
// Advance time to trigger the background timeout
await vi . advanceTimersByTimeAsync ( 250 ) ;
expect ( mockShellBackground ) . toHaveBeenCalledWith ( 12345 ) ;
await promise ;
} ) ;
2025-07-25 21:56:49 -04:00
} ) ;
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' , ( ) = > {
2026-01-04 00:19:00 -05:00
it ( 'should request confirmation for a new command and allowlist it on "Always"' , async ( ) = > {
2025-07-25 21:56:49 -04:00
const params = { command : 'npm install' } ;
2025-08-13 12:27:09 -07:00
const invocation = shellTool . build ( params ) ;
2026-01-04 00:19:00 -05:00
// Accessing protected messageBus for testing purposes
const bus = ( shellTool as unknown as { messageBus : MessageBus } )
. messageBus ;
const mockBus = getMockMessageBusInstance (
bus ,
) as unknown as TestableMockMessageBus ;
// Initially needs confirmation
mockBus . defaultToolDecision = 'ask_user' ;
2025-08-13 12:27:09 -07:00
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' ) ;
2026-01-04 00:19:00 -05:00
if ( confirmation && confirmation . type === 'exec' ) {
await confirmation . onConfirm ( ToolConfirmationOutcome . ProceedAlways ) ;
}
// After "Always", it should be allowlisted in the mock engine
mockBus . defaultToolDecision = 'allow' ;
const secondInvocation = shellTool . build ( { command : 'npm test' } ) ;
const secondConfirmation = await secondInvocation . shouldConfirmExecute (
new AbortController ( ) . signal ,
) ;
expect ( secondConfirmation ) . toBe ( false ) ;
2025-07-25 21:56:49 -04:00
} ) ;
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' , ( ) = > {
2025-11-06 15:03:52 -08:00
mockPlatform . mockReturnValue ( 'win32' ) ;
2026-01-04 00:19:00 -05:00
const shellTool = new ShellTool ( mockConfig , createMockMessageBus ( ) ) ;
2025-08-15 13:31:00 -07:00
expect ( shellTool . description ) . toMatchSnapshot ( ) ;
2025-08-15 12:08:29 -07:00
} ) ;
it ( 'should return the non-windows description when not on windows' , ( ) = > {
2025-11-06 15:03:52 -08:00
mockPlatform . mockReturnValue ( 'linux' ) ;
2026-01-04 00:19:00 -05:00
const shellTool = new ShellTool ( mockConfig , createMockMessageBus ( ) ) ;
2025-08-15 13:31:00 -07:00
expect ( shellTool . description ) . toMatchSnapshot ( ) ;
2025-08-15 12:08:29 -07:00
} ) ;
2026-02-09 10:51:13 -08:00
it ( 'should not include efficiency guidelines when disabled' , ( ) = > {
mockPlatform . mockReturnValue ( 'linux' ) ;
vi . mocked ( mockConfig . getEnableShellOutputEfficiency ) . mockReturnValue (
false ,
) ;
const shellTool = new ShellTool ( mockConfig , createMockMessageBus ( ) ) ;
expect ( shellTool . description ) . not . toContain ( 'Efficiency Guidelines:' ) ;
} ) ;
2025-08-15 12:08:29 -07:00
} ) ;
2026-01-19 20:07:28 -08:00
2026-01-26 10:12:21 -08:00
describe ( 'llmContent output format' , ( ) = > {
const mockAbortSignal = new AbortController ( ) . signal ;
const resolveShellExecution = (
result : Partial < ShellExecutionResult > = { } ,
) = > {
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 not include Command in output' , async ( ) = > {
const invocation = shellTool . build ( { command : 'echo hello' } ) ;
const promise = invocation . execute ( mockAbortSignal ) ;
resolveShellExecution ( { output : 'hello' , exitCode : 0 } ) ;
const result = await promise ;
expect ( result . llmContent ) . not . toContain ( 'Command:' ) ;
} ) ;
it ( 'should not include Directory in output' , async ( ) = > {
const invocation = shellTool . build ( { command : 'ls' , dir_path : 'subdir' } ) ;
const promise = invocation . execute ( mockAbortSignal ) ;
resolveShellExecution ( { output : 'file.txt' , exitCode : 0 } ) ;
const result = await promise ;
expect ( result . llmContent ) . not . toContain ( 'Directory:' ) ;
} ) ;
it ( 'should not include Exit Code when command succeeds (exit code 0)' , async ( ) = > {
const invocation = shellTool . build ( { command : 'echo hello' } ) ;
const promise = invocation . execute ( mockAbortSignal ) ;
resolveShellExecution ( { output : 'hello' , exitCode : 0 } ) ;
const result = await promise ;
expect ( result . llmContent ) . not . toContain ( 'Exit Code:' ) ;
} ) ;
it ( 'should include Exit Code when command fails (non-zero exit code)' , async ( ) = > {
const invocation = shellTool . build ( { command : 'false' } ) ;
const promise = invocation . execute ( mockAbortSignal ) ;
resolveShellExecution ( { output : '' , exitCode : 1 } ) ;
const result = await promise ;
expect ( result . llmContent ) . toContain ( 'Exit Code: 1' ) ;
} ) ;
it ( 'should not include Error when there is no process error' , async ( ) = > {
const invocation = shellTool . build ( { command : 'echo hello' } ) ;
const promise = invocation . execute ( mockAbortSignal ) ;
resolveShellExecution ( { output : 'hello' , exitCode : 0 , error : null } ) ;
const result = await promise ;
expect ( result . llmContent ) . not . toContain ( 'Error:' ) ;
} ) ;
it ( 'should include Error when there is a process error' , async ( ) = > {
const invocation = shellTool . build ( { command : 'bad-command' } ) ;
const promise = invocation . execute ( mockAbortSignal ) ;
resolveShellExecution ( {
output : '' ,
exitCode : 1 ,
error : new Error ( 'spawn ENOENT' ) ,
} ) ;
const result = await promise ;
expect ( result . llmContent ) . toContain ( 'Error: spawn ENOENT' ) ;
} ) ;
it ( 'should not include Signal when there is no signal' , async ( ) = > {
const invocation = shellTool . build ( { command : 'echo hello' } ) ;
const promise = invocation . execute ( mockAbortSignal ) ;
resolveShellExecution ( { output : 'hello' , exitCode : 0 , signal : null } ) ;
const result = await promise ;
expect ( result . llmContent ) . not . toContain ( 'Signal:' ) ;
} ) ;
it ( 'should include Signal when process was killed by signal' , async ( ) = > {
const invocation = shellTool . build ( { command : 'sleep 100' } ) ;
const promise = invocation . execute ( mockAbortSignal ) ;
resolveShellExecution ( {
output : '' ,
exitCode : null ,
signal : 9 , // SIGKILL
} ) ;
const result = await promise ;
expect ( result . llmContent ) . toContain ( 'Signal: 9' ) ;
} ) ;
it ( 'should not include Background PIDs when there are none' , async ( ) = > {
const invocation = shellTool . build ( { command : 'echo hello' } ) ;
const promise = invocation . execute ( mockAbortSignal ) ;
resolveShellExecution ( { output : 'hello' , exitCode : 0 } ) ;
const result = await promise ;
expect ( result . llmContent ) . not . toContain ( 'Background PIDs:' ) ;
} ) ;
it ( 'should not include Process Group PGID when pid is not set' , async ( ) = > {
const invocation = shellTool . build ( { command : 'echo hello' } ) ;
const promise = invocation . execute ( mockAbortSignal ) ;
resolveShellExecution ( { output : 'hello' , exitCode : 0 , pid : undefined } ) ;
const result = await promise ;
expect ( result . llmContent ) . not . toContain ( 'Process Group PGID:' ) ;
} ) ;
it ( 'should have minimal output for successful command' , async ( ) = > {
const invocation = shellTool . build ( { command : 'echo hello' } ) ;
const promise = invocation . execute ( mockAbortSignal ) ;
resolveShellExecution ( { output : 'hello' , exitCode : 0 , pid : undefined } ) ;
const result = await promise ;
// Should only contain Output field
expect ( result . llmContent ) . toBe ( 'Output: hello' ) ;
} ) ;
} ) ;
2026-01-19 20:07:28 -08:00
describe ( 'getConfirmationDetails' , ( ) = > {
it ( 'should annotate sub-commands with redirection correctly' , async ( ) = > {
const shellTool = new ShellTool ( mockConfig , createMockMessageBus ( ) ) ;
const command = 'mkdir -p baz && echo "hello" > baz/test.md && ls' ;
const invocation = shellTool . build ( { command } ) ;
// @ts-expect-error - getConfirmationDetails is protected
const details = await invocation . getConfirmationDetails (
new AbortController ( ) . signal ,
) ;
expect ( details ) . not . toBe ( false ) ;
if ( details && details . type === 'exec' ) {
expect ( details . rootCommand ) . toBe ( 'mkdir, echo, redirection (>), ls' ) ;
}
} ) ;
it ( 'should annotate all redirected sub-commands' , async ( ) = > {
const shellTool = new ShellTool ( mockConfig , createMockMessageBus ( ) ) ;
const command = 'cat < input.txt && grep "foo" > output.txt' ;
const invocation = shellTool . build ( { command } ) ;
// @ts-expect-error - getConfirmationDetails is protected
const details = await invocation . getConfirmationDetails (
new AbortController ( ) . signal ,
) ;
expect ( details ) . not . toBe ( false ) ;
if ( details && details . type === 'exec' ) {
expect ( details . rootCommand ) . toBe (
'cat, redirection (<), grep, redirection (>)' ,
) ;
}
} ) ;
it ( 'should annotate sub-commands with pipes correctly' , async ( ) = > {
const shellTool = new ShellTool ( mockConfig , createMockMessageBus ( ) ) ;
const command = 'ls | grep "baz"' ;
const invocation = shellTool . build ( { command } ) ;
// @ts-expect-error - getConfirmationDetails is protected
const details = await invocation . getConfirmationDetails (
new AbortController ( ) . signal ,
) ;
expect ( details ) . not . toBe ( false ) ;
if ( details && details . type === 'exec' ) {
expect ( details . rootCommand ) . toBe ( 'ls, grep' ) ;
}
} ) ;
} ) ;
2026-02-09 15:46:23 -05:00
describe ( 'getSchema' , ( ) = > {
it ( 'should return the base schema when no modelId is provided' , ( ) = > {
const schema = shellTool . getSchema ( ) ;
expect ( schema . name ) . toBe ( SHELL_TOOL_NAME ) ;
expect ( schema . description ) . toMatchSnapshot ( ) ;
} ) ;
it ( 'should return the schema from the resolver when modelId is provided' , ( ) = > {
const modelId = 'gemini-2.0-flash' ;
const schema = shellTool . getSchema ( modelId ) ;
expect ( schema . name ) . toBe ( SHELL_TOOL_NAME ) ;
expect ( schema . description ) . toMatchSnapshot ( ) ;
} ) ;
} ) ;
2025-07-25 12:25:32 -07:00
} ) ;