diff options
| author | Evan Senter <[email protected]> | 2025-04-19 19:45:42 +0100 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-04-19 19:45:42 +0100 |
| commit | 3fce6cea27d3e6129d6c06e528b62e1b11bf7094 (patch) | |
| tree | 244b8e9ab94f902d65d4bda8739a6538e377ed17 /packages/server/src/tools/terminal.ts | |
| parent | 0c9e1ef61be7db53e6e73b7208b649cd8cbed6c3 (diff) | |
Starting to modularize into separate cli / server packages. (#55)
* Starting to move a lot of code into packages/server
* More of the massive refactor, builds and runs, some issues though.
* Fixing outstanding issue with double messages.
* Fixing a minor UI issue.
* Fixing the build post-merge.
* Running formatting.
* Addressing comments.
Diffstat (limited to 'packages/server/src/tools/terminal.ts')
| -rw-r--r-- | packages/server/src/tools/terminal.ts | 256 |
1 files changed, 256 insertions, 0 deletions
diff --git a/packages/server/src/tools/terminal.ts b/packages/server/src/tools/terminal.ts new file mode 100644 index 00000000..6366106c --- /dev/null +++ b/packages/server/src/tools/terminal.ts @@ -0,0 +1,256 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn, SpawnOptions } from 'child_process'; +import path from 'path'; +import { BaseTool, ToolResult } from './tools.js'; +import { SchemaValidator } from '../utils/schemaValidator.js'; +import { getErrorMessage } from '../utils/errors.js'; + +export interface TerminalToolParams { + command: string; +} + +const MAX_OUTPUT_LENGTH = 10000; +const DEFAULT_EXEC_TIMEOUT_MS = 5 * 60 * 1000; + +const BANNED_COMMAND_ROOTS = [ + 'alias', + 'bg', + 'command', + 'declare', + 'dirs', + 'disown', + 'enable', + 'eval', + 'exec', + 'exit', + 'export', + 'fc', + 'fg', + 'getopts', + 'hash', + 'history', + 'jobs', + 'kill', + 'let', + 'local', + 'logout', + 'popd', + 'printf', + 'pushd', + 'read', + 'readonly', + 'set', + 'shift', + 'shopt', + 'source', + 'suspend', + 'test', + 'times', + 'trap', + 'type', + 'typeset', + 'ulimit', + 'umask', + 'unalias', + 'unset', + 'wait', + 'curl', + 'wget', + 'nc', + 'telnet', + 'ssh', + 'scp', + 'ftp', + 'sftp', + 'http', + 'https', + 'rsync', + 'lynx', + 'w3m', + 'links', + 'elinks', + 'httpie', + 'xh', + 'http-prompt', + 'chrome', + 'firefox', + 'safari', + 'edge', + 'xdg-open', + 'open', +]; + +/** + * Simplified implementation of the Terminal tool logic for single command execution. + */ +export class TerminalLogic extends BaseTool<TerminalToolParams, ToolResult> { + static readonly Name = 'execute_bash_command'; + private readonly rootDirectory: string; + + constructor(rootDirectory: string) { + super( + TerminalLogic.Name, + '', // Display name handled by CLI wrapper + '', // Description handled by CLI wrapper + { + type: 'object', + properties: { + command: { + description: `The exact bash command or sequence of commands (using ';' or '&&') to execute. Must adhere to usage guidelines. Example: 'npm install && npm run build'`, + type: 'string', + }, + }, + required: ['command'], + }, + ); + this.rootDirectory = path.resolve(rootDirectory); + } + + validateParams(params: TerminalToolParams): string | null { + if ( + this.schema.parameters && + !SchemaValidator.validate( + this.schema.parameters as Record<string, unknown>, + params, + ) + ) { + return "Parameters failed schema validation (expecting only 'command')."; + } + const commandOriginal = params.command.trim(); + if (!commandOriginal) { + return 'Command cannot be empty.'; + } + const commandParts = commandOriginal.split(/[\s;&&|]+/); + for (const part of commandParts) { + if (!part) continue; + const cleanPart = + part + .replace(/^[^a-zA-Z0-9]+/, '') + .split(/[/\\]/) + .pop() || part.replace(/^[^a-zA-Z0-9]+/, ''); + if (cleanPart && BANNED_COMMAND_ROOTS.includes(cleanPart.toLowerCase())) { + return `Command contains a banned keyword: '${cleanPart}'. Banned list includes network tools, session control, etc.`; + } + } + return null; + } + + getDescription(params: TerminalToolParams): string { + return params.command; + } + + async execute( + params: TerminalToolParams, + executionCwd?: string, + timeout: number = DEFAULT_EXEC_TIMEOUT_MS, + ): Promise<ToolResult> { + const validationError = this.validateParams(params); + if (validationError) { + return { + llmContent: `Command rejected: ${params.command}\nReason: ${validationError}`, + returnDisplay: `Error: ${validationError}`, + }; + } + + const cwd = executionCwd ? path.resolve(executionCwd) : this.rootDirectory; + if (!cwd.startsWith(this.rootDirectory) && cwd !== this.rootDirectory) { + const message = `Execution CWD validation failed: Attempted path "${cwd}" resolves outside the allowed root directory "${this.rootDirectory}".`; + return { + llmContent: `Command rejected: ${params.command}\nReason: ${message}`, + returnDisplay: `Error: ${message}`, + }; + } + + return new Promise((resolve) => { + const spawnOptions: SpawnOptions = { + cwd, + shell: true, + env: { ...process.env }, + stdio: 'pipe', + windowsHide: true, + timeout: timeout, + }; + let stdout = ''; + let stderr = ''; + let processError: Error | null = null; + let timedOut = false; + + try { + const child = spawn(params.command, spawnOptions); + child.stdout!.on('data', (data) => { + stdout += data.toString(); + if (stdout.length > MAX_OUTPUT_LENGTH) { + stdout = this.truncateOutput(stdout); + child.stdout!.pause(); + } + }); + child.stderr!.on('data', (data) => { + stderr += data.toString(); + if (stderr.length > MAX_OUTPUT_LENGTH) { + stderr = this.truncateOutput(stderr); + child.stderr!.pause(); + } + }); + child.on('error', (err) => { + processError = err; + console.error( + `TerminalLogic spawn error for "${params.command}":`, + err, + ); + }); + child.on('close', (code, signal) => { + const exitCode = code ?? (signal ? -1 : -2); + if (signal === 'SIGTERM' || signal === 'SIGKILL') { + if (child.killed && timeout > 0) timedOut = true; + } + const finalStdout = this.truncateOutput(stdout); + const finalStderr = this.truncateOutput(stderr); + let llmContent = `Command: ${params.command}\nExecuted in: ${cwd}\nExit Code: ${exitCode}\n`; + if (timedOut) llmContent += `Status: Timed Out after ${timeout}ms\n`; + if (processError) + llmContent += `Process Error: ${processError.message}\n`; + llmContent += `Stdout:\n${finalStdout}\nStderr:\n${finalStderr}`; + let displayOutput = finalStderr.trim() || finalStdout.trim(); + if (timedOut) + displayOutput = `Timeout: ${displayOutput || 'No output before timeout'}`; + else if (exitCode !== 0 && !displayOutput) + displayOutput = `Failed (Exit Code: ${exitCode})`; + else if (exitCode === 0 && !displayOutput) + displayOutput = `Success (no output)`; + resolve({ + llmContent, + returnDisplay: displayOutput.trim() || `Exit Code: ${exitCode}`, + }); + }); + } catch (spawnError: unknown) { + const errMsg = getErrorMessage(spawnError); + console.error( + `TerminalLogic failed to spawn "${params.command}":`, + spawnError, + ); + resolve({ + llmContent: `Failed to start command: ${params.command}\nError: ${errMsg}`, + returnDisplay: `Error spawning command: ${errMsg}`, + }); + } + }); + } + + private truncateOutput( + output: string, + limit: number = MAX_OUTPUT_LENGTH, + ): string { + if (output.length > limit) { + return ( + output.substring(0, limit) + + `\n... [Output truncated at ${limit} characters]` + ); + } + return output; + } +} |
