diff options
| author | Tommaso Sciortino <[email protected]> | 2025-05-30 18:25:47 -0700 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-05-30 18:25:47 -0700 |
| commit | 21fba832d1b4ea7af43fb887d9b2b38fcf8210d0 (patch) | |
| tree | 7200d2fac3a55c385e0a2dac34b5282c942364bc /packages/core/src/tools/shell.ts | |
| parent | c81148a0cc8489f657901c2cc7247c0834075e1a (diff) | |
Rename server->core (#638)
Diffstat (limited to 'packages/core/src/tools/shell.ts')
| -rw-r--r-- | packages/core/src/tools/shell.ts | 313 |
1 files changed, 313 insertions, 0 deletions
diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts new file mode 100644 index 00000000..4efc3500 --- /dev/null +++ b/packages/core/src/tools/shell.ts @@ -0,0 +1,313 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import crypto from 'crypto'; +import { Config } from '../config/config.js'; +import { + BaseTool, + ToolResult, + ToolCallConfirmationDetails, + ToolExecuteConfirmationDetails, + ToolConfirmationOutcome, +} from './tools.js'; +import { SchemaValidator } from '../utils/schemaValidator.js'; +import { getErrorMessage } from '../utils/errors.js'; +export interface ShellToolParams { + command: string; + description?: string; + directory?: string; +} +import { spawn } from 'child_process'; + +const OUTPUT_UPDATE_INTERVAL_MS = 1000; + +export class ShellTool extends BaseTool<ShellToolParams, ToolResult> { + static Name: string = 'execute_bash_command'; + private whitelist: Set<string> = new Set(); + + constructor(private readonly config: Config) { + const toolDisplayName = 'Shell'; + const descriptionUrl = new URL('shell.md', import.meta.url); + const toolDescription = fs.readFileSync(descriptionUrl, 'utf-8'); + const schemaUrl = new URL('shell.json', import.meta.url); + const toolParameterSchema = JSON.parse(fs.readFileSync(schemaUrl, 'utf-8')); + super( + ShellTool.Name, + toolDisplayName, + toolDescription, + toolParameterSchema, + false, // output is not markdown + true, // output can be updated + ); + } + + getDescription(params: ShellToolParams): string { + let description = `${params.command}`; + // append optional [in directory] + // note description is needed even if validation fails due to absolute path + if (params.directory) { + description += ` [in ${params.directory}]`; + } + // append optional (description), replacing any line breaks with spaces + if (params.description) { + description += ` (${params.description.replace(/\n/g, ' ')})`; + } + return description; + } + + getCommandRoot(command: string): string | undefined { + return command + .trim() // remove leading and trailing whitespace + .replace(/[{}()]/g, '') // remove all grouping operators + .split(/[\s;&|]+/)[0] // split on any whitespace or separator or chaining operators and take first part + ?.split(/[/\\]/) // split on any path separators (or return undefined if previous line was undefined) + .pop(); // take last part and return command root (or undefined if previous line was empty) + } + + validateToolParams(params: ShellToolParams): string | null { + if ( + !SchemaValidator.validate( + this.parameterSchema as Record<string, unknown>, + params, + ) + ) { + return `Parameters failed schema validation.`; + } + if (!params.command.trim()) { + return 'Command cannot be empty.'; + } + if (!this.getCommandRoot(params.command)) { + return 'Could not identify command root to obtain permission from user.'; + } + if (params.directory) { + if (path.isAbsolute(params.directory)) { + return 'Directory cannot be absolute. Must be relative to the project root directory.'; + } + const directory = path.resolve( + this.config.getTargetDir(), + params.directory, + ); + if (!fs.existsSync(directory)) { + return 'Directory must exist.'; + } + } + return null; + } + + async shouldConfirmExecute( + params: ShellToolParams, + _abortSignal: AbortSignal, + ): Promise<ToolCallConfirmationDetails | false> { + if (this.validateToolParams(params)) { + return false; // skip confirmation, execute call will fail immediately + } + const rootCommand = this.getCommandRoot(params.command)!; // must be non-empty string post-validation + if (this.whitelist.has(rootCommand)) { + return false; // already approved and whitelisted + } + const confirmationDetails: ToolExecuteConfirmationDetails = { + type: 'exec', + title: 'Confirm Shell Command', + command: params.command, + rootCommand, + onConfirm: async (outcome: ToolConfirmationOutcome) => { + if (outcome === ToolConfirmationOutcome.ProceedAlways) { + this.whitelist.add(rootCommand); + } + }, + }; + return confirmationDetails; + } + + async execute( + params: ShellToolParams, + abortSignal: AbortSignal, + updateOutput?: (chunk: string) => void, + ): Promise<ToolResult> { + const validationError = this.validateToolParams(params); + if (validationError) { + return { + llmContent: [ + `Command rejected: ${params.command}`, + `Reason: ${validationError}`, + ].join('\n'), + returnDisplay: `Error: ${validationError}`, + }; + } + + // wrap command to append subprocess pids (via pgrep) to temporary file + const tempFileName = `shell_pgrep_${crypto.randomBytes(6).toString('hex')}.tmp`; + const tempFilePath = path.join(os.tmpdir(), tempFileName); + + let command = params.command.trim(); + if (!command.endsWith('&')) command += ';'; + command = `{ ${command} }; __code=$?; pgrep -g 0 >${tempFilePath} 2>&1; exit $__code;`; + + // spawn command in specified directory (or project root if not specified) + const shell = spawn('bash', ['-c', command], { + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, // ensure subprocess starts its own process group (esp. in Linux) + cwd: path.resolve(this.config.getTargetDir(), params.directory || ''), + }); + + let exited = false; + let stdout = ''; + let output = ''; + let lastUpdateTime = Date.now(); + + const appendOutput = (str: string) => { + output += str; + if ( + updateOutput && + Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS + ) { + updateOutput(output); + lastUpdateTime = Date.now(); + } + }; + + shell.stdout.on('data', (data: Buffer) => { + // continue to consume post-exit for background processes + // removing listeners can overflow OS buffer and block subprocesses + // destroying (e.g. shell.stdout.destroy()) can terminate subprocesses via SIGPIPE + if (!exited) { + const str = data.toString(); + stdout += str; + appendOutput(str); + } + }); + + let stderr = ''; + shell.stderr.on('data', (data: Buffer) => { + if (!exited) { + const str = data.toString(); + stderr += str; + appendOutput(str); + } + }); + + let error: Error | null = null; + shell.on('error', (err: Error) => { + error = err; + // remove wrapper from user's command in error message + error.message = error.message.replace(command, params.command); + }); + + let code: number | null = null; + let processSignal: NodeJS.Signals | null = null; + const exitHandler = ( + _code: number | null, + _signal: NodeJS.Signals | null, + ) => { + exited = true; + code = _code; + processSignal = _signal; + }; + shell.on('exit', exitHandler); + + const abortHandler = async () => { + if (shell.pid && !exited) { + try { + // attempt to SIGTERM process group (negative PID) + // fall back to SIGKILL (to group) after 200ms + process.kill(-shell.pid, 'SIGTERM'); + await new Promise((resolve) => setTimeout(resolve, 200)); + if (shell.pid && !exited) { + process.kill(-shell.pid, 'SIGKILL'); + } + } catch (_e) { + // if group kill fails, fall back to killing just the main process + try { + if (shell.pid) { + shell.kill('SIGKILL'); + } + } catch (_e) { + console.error(`failed to kill shell process ${shell.pid}: ${_e}`); + } + } + } + }; + abortSignal.addEventListener('abort', abortHandler); + + // wait for the shell to exit + await new Promise((resolve) => shell.on('exit', resolve)); + + abortSignal.removeEventListener('abort', abortHandler); + + // parse pids (pgrep output) from temporary file and remove it + const backgroundPIDs: number[] = []; + if (fs.existsSync(tempFilePath)) { + const pgrepLines = fs + .readFileSync(tempFilePath, 'utf8') + .split('\n') + .filter(Boolean); + for (const line of pgrepLines) { + if (!/^\d+$/.test(line)) { + console.error(`pgrep: ${line}`); + } + const pid = Number(line); + // exclude the shell subprocess pid + if (pid !== shell.pid) { + backgroundPIDs.push(pid); + } + } + fs.unlinkSync(tempFilePath); + } else { + if (!abortSignal.aborted) { + console.error('missing pgrep output'); + } + } + + let llmContent = ''; + if (abortSignal.aborted) { + llmContent = 'Command was cancelled by user before it could complete.'; + if (output.trim()) { + llmContent += ` Below is the output (on stdout and stderr) before it was cancelled:\n${output}`; + } else { + llmContent += ' There was no output before it was cancelled.'; + } + } else { + llmContent = [ + `Command: ${params.command}`, + `Directory: ${params.directory || '(root)'}`, + `Stdout: ${stdout || '(empty)'}`, + `Stderr: ${stderr || '(empty)'}`, + `Error: ${error ?? '(none)'}`, + `Exit Code: ${code ?? '(none)'}`, + `Signal: ${processSignal ?? '(none)'}`, + `Background PIDs: ${backgroundPIDs.length ? backgroundPIDs.join(', ') : '(none)'}`, + ].join('\n'); + } + + let returnDisplayMessage = ''; + if (this.config.getDebugMode()) { + returnDisplayMessage = llmContent; + } else { + if (output.trim()) { + returnDisplayMessage = output; + } else { + // Output is empty, let's provide a reason if the command failed or was cancelled + if (abortSignal.aborted) { + returnDisplayMessage = 'Command cancelled by user.'; + } else if (processSignal) { + returnDisplayMessage = `Command terminated by signal: ${processSignal}`; + } else if (error) { + // If error is not null, it's an Error object (or other truthy value) + returnDisplayMessage = `Command failed: ${getErrorMessage(error)}`; + } else if (code !== null && code !== 0) { + returnDisplayMessage = `Command exited with code: ${code}`; + } + // If output is empty and command succeeded (code 0, no error/signal/abort), + // returnDisplayMessage will remain empty, which is fine. + } + } + + return { llmContent, returnDisplay: returnDisplayMessage }; + } +} |
