diff options
Diffstat (limited to 'packages/server/src/tools/shell.ts')
| -rw-r--r-- | packages/server/src/tools/shell.ts | 313 |
1 files changed, 0 insertions, 313 deletions
diff --git a/packages/server/src/tools/shell.ts b/packages/server/src/tools/shell.ts deleted file mode 100644 index 4efc3500..00000000 --- a/packages/server/src/tools/shell.ts +++ /dev/null @@ -1,313 +0,0 @@ -/** - * @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 }; - } -} |
