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.
This commit is contained in:
Evan Senter
2025-04-19 19:45:42 +01:00
committed by GitHub
parent 0c9e1ef61b
commit 3fce6cea27
46 changed files with 3946 additions and 3403 deletions

View File

@@ -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;
}
}