summaryrefslogtreecommitdiff
path: root/packages/server/src/tools/shell.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/server/src/tools/shell.ts')
-rw-r--r--packages/server/src/tools/shell.ts313
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 };
- }
-}