summaryrefslogtreecommitdiff
path: root/packages/server/src/tools/terminal.ts
diff options
context:
space:
mode:
authorEvan Senter <[email protected]>2025-04-19 19:45:42 +0100
committerGitHub <[email protected]>2025-04-19 19:45:42 +0100
commit3fce6cea27d3e6129d6c06e528b62e1b11bf7094 (patch)
tree244b8e9ab94f902d65d4bda8739a6538e377ed17 /packages/server/src/tools/terminal.ts
parent0c9e1ef61be7db53e6e73b7208b649cd8cbed6c3 (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.ts256
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;
+ }
+}