summaryrefslogtreecommitdiff
path: root/packages/server/src/tools/terminal.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/server/src/tools/terminal.ts')
-rw-r--r--packages/server/src/tools/terminal.ts959
1 files changed, 852 insertions, 107 deletions
diff --git a/packages/server/src/tools/terminal.ts b/packages/server/src/tools/terminal.ts
index 6366106c..eab170ab 100644
--- a/packages/server/src/tools/terminal.ts
+++ b/packages/server/src/tools/terminal.ts
@@ -4,18 +4,42 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { spawn, SpawnOptions } from 'child_process';
+import {
+ spawn,
+ SpawnOptions,
+ ChildProcessWithoutNullStreams,
+} 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';
+import os from 'os';
+import crypto from 'crypto';
+import { promises as fs } from 'fs';
+import {
+ SchemaValidator,
+ getErrorMessage,
+ isNodeError,
+ Config,
+} from '@gemini-code/server';
+import {
+ BaseTool,
+ ToolCallConfirmationDetails,
+ ToolConfirmationOutcome,
+ ToolExecuteConfirmationDetails,
+ ToolResult,
+} from './tools.js';
+import { BackgroundTerminalAnalyzer } from '../utils/BackgroundTerminalAnalyzer.js';
export interface TerminalToolParams {
command: string;
+ description?: string;
+ timeout?: number;
+ runInBackground?: boolean;
}
const MAX_OUTPUT_LENGTH = 10000;
-const DEFAULT_EXEC_TIMEOUT_MS = 5 * 60 * 1000;
+const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
+const MAX_TIMEOUT_OVERRIDE_MS = 10 * 60 * 1000;
+const BACKGROUND_LAUNCH_TIMEOUT_MS = 15 * 1000;
+const BACKGROUND_POLL_TIMEOUT_MS = 30000;
const BANNED_COMMAND_ROOTS = [
'alias',
@@ -85,41 +109,197 @@ const BANNED_COMMAND_ROOTS = [
'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';
+interface QueuedCommand {
+ params: TerminalToolParams;
+ resolve: (result: ToolResult) => void;
+ reject: (error: Error) => void;
+ confirmationDetails: ToolExecuteConfirmationDetails | false;
+}
+
+export class TerminalTool extends BaseTool<TerminalToolParams, ToolResult> {
+ static Name: string = 'execute_bash_command';
private readonly rootDirectory: string;
+ private readonly outputLimit: number;
+ private bashProcess: ChildProcessWithoutNullStreams | null = null;
+ private currentCwd: string;
+ private isExecuting: boolean = false;
+ private commandQueue: QueuedCommand[] = [];
+ private currentCommandCleanup: (() => void) | null = null;
+ private shouldAlwaysExecuteCommands: Map<string, boolean> = new Map();
+ private shellReady: Promise<void>;
+ private resolveShellReady: (() => void) | undefined;
+ private rejectShellReady: ((reason?: unknown) => void) | undefined;
+ private readonly backgroundTerminalAnalyzer: BackgroundTerminalAnalyzer;
+ private readonly config: Config;
- 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',
- },
+ constructor(
+ rootDirectory: string,
+ config: Config,
+ outputLimit: number = MAX_OUTPUT_LENGTH,
+ ) {
+ const toolDisplayName = 'Terminal';
+ const toolDescription = `Executes one or more bash commands sequentially in a secure and persistent interactive shell session. Can run commands in the foreground (waiting for completion) or background (returning after launch, with subsequent status polling).
+
+Core Functionality:
+* Starts in project root: '${path.basename(rootDirectory)}'. Current Directory starts as: ${rootDirectory} (will update based on 'cd' commands).
+* Persistent State: Environment variables and the current working directory (\`pwd\`) persist between calls to this tool.
+* **Execution Modes:**
+ * **Foreground (default):** Waits for the command to complete. Captures stdout, stderr, and exit code. Output is truncated if it exceeds ${outputLimit} characters.
+ * **Background (\`runInBackground: true\`):** Appends \`&\` to the command and redirects its output to temporary files. Returns *after* the command is launched, providing the Process ID (PID) and launch status. Subsequently, the tool **polls** for the background process status for up to ${BACKGROUND_POLL_TIMEOUT_MS / 1000} seconds. Once the process finishes or polling times out, the tool reads the captured stdout/stderr from the temporary files, runs an internal LLM analysis on the output, cleans up the files, and returns the final status, captured output, and analysis.
+* Timeout: Optional timeout per 'execute' call (default: ${DEFAULT_TIMEOUT_MS / 60000} min, max override: ${MAX_TIMEOUT_OVERRIDE_MS / 60000} min for foreground). Background *launch* has a fixed shorter timeout (${BACKGROUND_LAUNCH_TIMEOUT_MS / 1000}s) for the launch attempt itself. Background *polling* has its own timeout (${BACKGROUND_POLL_TIMEOUT_MS / 1000}s). Timeout attempts SIGINT for foreground commands.
+
+Usage Guidance & Restrictions:
+
+1. **Directory/File Verification (IMPORTANT):**
+ * BEFORE executing commands that create files or directories (e.g., \`mkdir foo/bar\`, \`touch new/file.txt\`, \`git clone ...\`), use the dedicated File System tool (e.g., 'list_directory') to verify the target parent directory exists and is the correct location.
+ * Example: Before running \`mkdir foo/bar\`, first use the File System tool to check that \`foo\` exists in the current directory (\`${rootDirectory}\` initially, check current CWD if it changed).
+
+2. **Use Specialized Tools (CRITICAL):**
+ * Do NOT use this tool for filesystem searching (\`find\`, \`grep\`). Use the dedicated Search tool instead.
+ * Do NOT use this tool for reading files (\`cat\`, \`head\`, \`tail\`, \`less\`, \`more\`). Use the dedicated File Reader tool instead.
+ * Do NOT use this tool for listing files (\`ls\`). Use the dedicated File System tool ('list_directory') instead. Relying on this tool's output for directory structure is unreliable due to potential truncation and lack of structured data.
+
+3. **Security & Banned Commands:**
+ * Certain commands are banned for security (e.g., network: ${BANNED_COMMAND_ROOTS.filter((c) => ['curl', 'wget', 'ssh'].includes(c)).join(', ')}; session: ${BANNED_COMMAND_ROOTS.filter((c) => ['exit', 'export', 'kill'].includes(c)).join(', ')}; etc.). The full list is extensive.
+ * If you attempt a banned command, this tool will return an error explaining the restriction. You MUST relay this error clearly to the user.
+
+4. **Command Execution Notes:**
+ * Chain multiple commands using shell operators like ';' or '&&'. Do NOT use newlines within the 'command' parameter string itself (newlines are fine inside quoted arguments).
+ * The shell's current working directory is tracked internally. While \`cd\` is permitted if the user explicitly asks or it's necessary for a workflow, **strongly prefer** using absolute paths or paths relative to the *known* current working directory to avoid errors. Check the '(Executed in: ...)' part of the previous command's output for the CWD.
+ * Good example (if CWD is /workspace/project): \`pytest tests/unit\` or \`ls /workspace/project/data\`
+ * Less preferred: \`cd tests && pytest unit\` (only use if necessary or requested)
+
+5. **Background Tasks (\`runInBackground: true\`):**
+ * Use this for commands that are intended to run continuously (e.g., \`node server.js\`, \`npm start\`).
+ * The tool initially returns success if the process *launches* successfully, along with its PID.
+ * **Polling & Final Result:** The tool then monitors the process. The *final* result (delivered after polling completes or times out) will include:
+ * The final status (completed or timed out).
+ * The complete stdout and stderr captured in temporary files (truncated if necessary).
+ * An LLM-generated analysis/summary of the output.
+ * The initial exit code (usually 0) signifies successful *launching*; the final status indicates completion or timeout after polling.
+
+Use this tool for running build steps (\`npm install\`, \`make\`), linters (\`eslint .\`), test runners (\`pytest\`, \`jest\`), code formatters (\`prettier --write .\`), package managers (\`pip install\`), version control operations (\`git status\`, \`git diff\`), starting background servers/services (\`node server.js --runInBackground true\`), or other safe, standard command-line operations within the project workspace.`;
+ const toolParameterSchema = {
+ 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',
+ },
+ description: {
+ description: `Optional: A brief, user-centric explanation of what the command does and why it's being run. Used for logging and confirmation prompts. Example: 'Install project dependencies'`,
+ type: 'string',
+ },
+ timeout: {
+ description: `Optional execution time limit in milliseconds for FOREGROUND commands. Max ${MAX_TIMEOUT_OVERRIDE_MS}ms (${MAX_TIMEOUT_OVERRIDE_MS / 60000} min). Defaults to ${DEFAULT_TIMEOUT_MS}ms (${DEFAULT_TIMEOUT_MS / 60000} min) if not specified or invalid. Ignored if 'runInBackground' is true.`,
+ type: 'number',
+ },
+ runInBackground: {
+ description: `If true, execute the command in the background using '&'. Defaults to false. Use for servers or long tasks.`,
+ type: 'boolean',
},
- required: ['command'],
},
+ required: ['command'],
+ };
+ super(
+ TerminalTool.Name,
+ toolDisplayName,
+ toolDescription,
+ toolParameterSchema,
);
+ this.config = config;
this.rootDirectory = path.resolve(rootDirectory);
+ this.currentCwd = this.rootDirectory;
+ this.outputLimit = outputLimit;
+ this.shellReady = new Promise((resolve, reject) => {
+ this.resolveShellReady = resolve;
+ this.rejectShellReady = reject;
+ });
+ this.backgroundTerminalAnalyzer = new BackgroundTerminalAnalyzer(config);
+ this.initializeShell();
+ }
+
+ private initializeShell() {
+ if (this.bashProcess) {
+ try {
+ this.bashProcess.kill();
+ } catch {
+ /* Ignore */
+ }
+ }
+ const spawnOptions: SpawnOptions = {
+ cwd: this.rootDirectory,
+ shell: true,
+ env: { ...process.env },
+ stdio: ['pipe', 'pipe', 'pipe'],
+ };
+ try {
+ const bashPath = os.platform() === 'win32' ? 'bash.exe' : 'bash';
+ this.bashProcess = spawn(
+ bashPath,
+ ['-s'],
+ spawnOptions,
+ ) as ChildProcessWithoutNullStreams;
+ this.currentCwd = this.rootDirectory;
+ this.bashProcess.on('error', (err) => {
+ console.error('Persistent Bash Error:', err);
+ this.rejectShellReady?.(err);
+ this.bashProcess = null;
+ this.isExecuting = false;
+ this.clearQueue(
+ new Error(`Persistent bash process failed to start: ${err.message}`),
+ );
+ });
+ this.bashProcess.on('close', (code, signal) => {
+ this.bashProcess = null;
+ this.isExecuting = false;
+ this.rejectShellReady?.(
+ new Error(
+ `Persistent bash process exited (code: ${code}, signal: ${signal})`,
+ ),
+ );
+ this.shellReady = new Promise((resolve, reject) => {
+ this.resolveShellReady = resolve;
+ this.rejectShellReady = reject;
+ });
+ this.clearQueue(
+ new Error(
+ `Persistent bash process exited unexpectedly (code: ${code}, signal: ${signal}). State is lost. Queued commands cancelled.`,
+ ),
+ );
+ if (signal !== 'SIGINT') {
+ setTimeout(() => this.initializeShell(), 1000);
+ }
+ });
+ setTimeout(() => {
+ if (this.bashProcess && !this.bashProcess.killed) {
+ this.resolveShellReady?.();
+ } else if (!this.bashProcess) {
+ // Error likely handled
+ } else {
+ this.rejectShellReady?.(
+ new Error('Shell killed during initialization'),
+ );
+ }
+ }, 1000);
+ } catch (error: unknown) {
+ console.error('Failed to spawn persistent bash:', error);
+ this.rejectShellReady?.(error);
+ this.bashProcess = null;
+ this.clearQueue(
+ new Error(`Failed to spawn persistent bash: ${getErrorMessage(error)}`),
+ );
+ }
}
- validateParams(params: TerminalToolParams): string | null {
+ validateToolParams(params: TerminalToolParams): string | null {
if (
- this.schema.parameters &&
!SchemaValidator.validate(
- this.schema.parameters as Record<string, unknown>,
+ this.parameterSchema as Record<string, unknown>,
params,
)
) {
- return "Parameters failed schema validation (expecting only 'command').";
+ return `Parameters failed schema validation.`;
}
const commandOriginal = params.command.trim();
if (!commandOriginal) {
@@ -137,120 +317,685 @@ export class TerminalLogic extends BaseTool<TerminalToolParams, ToolResult> {
return `Command contains a banned keyword: '${cleanPart}'. Banned list includes network tools, session control, etc.`;
}
}
+ if (
+ params.timeout !== undefined &&
+ (typeof params.timeout !== 'number' || params.timeout <= 0)
+ ) {
+ return 'Timeout must be a positive number of milliseconds.';
+ }
return null;
}
getDescription(params: TerminalToolParams): string {
- return params.command;
+ return params.description || params.command;
}
- async execute(
+ async shouldConfirmExecute(
params: TerminalToolParams,
- executionCwd?: string,
- timeout: number = DEFAULT_EXEC_TIMEOUT_MS,
- ): Promise<ToolResult> {
- const validationError = this.validateParams(params);
+ ): Promise<ToolCallConfirmationDetails | false> {
+ const rootCommand =
+ params.command
+ .trim()
+ .split(/[\s;&&|]+/)[0]
+ ?.split(/[/\\]/)
+ .pop() || 'unknown';
+ if (this.shouldAlwaysExecuteCommands.get(rootCommand)) {
+ return false;
+ }
+ const description = this.getDescription(params);
+ const confirmationDetails: ToolExecuteConfirmationDetails = {
+ title: 'Confirm Shell Command',
+ command: params.command,
+ rootCommand,
+ description: `Execute in '${this.currentCwd}':\n${description}`,
+ onConfirm: async (outcome: ToolConfirmationOutcome) => {
+ if (outcome === ToolConfirmationOutcome.ProceedAlways) {
+ this.shouldAlwaysExecuteCommands.set(rootCommand, true);
+ }
+ },
+ };
+ return confirmationDetails;
+ }
+
+ async execute(params: TerminalToolParams): Promise<ToolResult> {
+ const validationError = this.validateToolParams(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,
+ const queuedItem: QueuedCommand = {
+ params,
+ resolve,
+ reject: (error) =>
+ resolve({
+ llmContent: `Internal tool error for command: ${params.command}\nError: ${error.message}`,
+ returnDisplay: `Internal Tool Error: ${error.message}`,
+ }),
+ confirmationDetails: false,
};
- let stdout = '';
- let stderr = '';
- let processError: Error | null = null;
- let timedOut = false;
+ this.commandQueue.push(queuedItem);
+ setImmediate(() => this.triggerQueueProcessing());
+ });
+ }
- 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();
+ private async triggerQueueProcessing(): Promise<void> {
+ if (this.isExecuting || this.commandQueue.length === 0) {
+ return;
+ }
+ this.isExecuting = true;
+ const { params, resolve, reject } = this.commandQueue.shift()!;
+ try {
+ await this.shellReady;
+ if (!this.bashProcess || this.bashProcess.killed) {
+ throw new Error(
+ 'Persistent bash process is not available or was killed.',
+ );
+ }
+ const result = await this.executeCommandInShell(params);
+ resolve(result);
+ } catch (error: unknown) {
+ console.error(`Error executing command "${params.command}":`, error);
+ if (error instanceof Error) {
+ reject(error);
+ } else {
+ reject(new Error('Unknown error occurred: ' + JSON.stringify(error)));
+ }
+ } finally {
+ this.isExecuting = false;
+ setImmediate(() => this.triggerQueueProcessing());
+ }
+ }
+
+ private executeCommandInShell(
+ params: TerminalToolParams,
+ ): Promise<ToolResult> {
+ let tempStdoutPath: string | null = null;
+ let tempStderrPath: string | null = null;
+ let originalResolve: (value: ToolResult | PromiseLike<ToolResult>) => void;
+ let originalReject: (reason?: unknown) => void;
+ const promise = new Promise<ToolResult>((resolve, reject) => {
+ originalResolve = resolve;
+ originalReject = reject;
+ if (!this.bashProcess) {
+ return reject(
+ new Error('Bash process is not running. Cannot execute command.'),
+ );
+ }
+ const isBackgroundTask = params.runInBackground ?? false;
+ const commandUUID = crypto.randomUUID();
+ const startDelimiter = `::START_CMD_${commandUUID}::`;
+ const endDelimiter = `::END_CMD_${commandUUID}::`;
+ const exitCodeDelimiter = `::EXIT_CODE_${commandUUID}::`;
+ const pidDelimiter = `::PID_${commandUUID}::`;
+ if (isBackgroundTask) {
+ try {
+ const tempDir = os.tmpdir();
+ tempStdoutPath = path.join(tempDir, `term_out_${commandUUID}.log`);
+ tempStderrPath = path.join(tempDir, `term_err_${commandUUID}.log`);
+ } catch (err: unknown) {
+ return reject(
+ new Error(
+ `Failed to determine temporary directory: ${getErrorMessage(err)}`,
+ ),
+ );
+ }
+ }
+ let stdoutBuffer = '';
+ let stderrBuffer = '';
+ let commandOutputStarted = false;
+ let exitCode: number | null = null;
+ let backgroundPid: number | null = null;
+ let receivedEndDelimiter = false;
+ const effectiveTimeout = isBackgroundTask
+ ? BACKGROUND_LAUNCH_TIMEOUT_MS
+ : Math.min(
+ params.timeout ?? DEFAULT_TIMEOUT_MS,
+ MAX_TIMEOUT_OVERRIDE_MS,
+ );
+ let onStdoutData: ((data: Buffer) => void) | null = null;
+ let onStderrData: ((data: Buffer) => void) | null = null;
+ let launchTimeoutId: NodeJS.Timeout | null = null;
+ launchTimeoutId = setTimeout(() => {
+ const timeoutMessage = isBackgroundTask
+ ? `Background command launch timed out after ${effectiveTimeout}ms.`
+ : `Command timed out after ${effectiveTimeout}ms.`;
+ if (!isBackgroundTask && this.bashProcess && !this.bashProcess.killed) {
+ try {
+ this.bashProcess.stdin.write('\x03');
+ } catch (e: unknown) {
+ console.error('Error writing SIGINT on timeout:', e);
}
+ }
+ const listenersToClean = { onStdoutData, onStderrData };
+ cleanupListeners(listenersToClean);
+ if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
+ this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch((err) => {
+ console.warn(
+ `Error cleaning up temp files on timeout: ${err.message}`,
+ );
+ });
+ }
+ originalResolve({
+ llmContent: `Command execution failed: ${timeoutMessage}\nCommand: ${params.command}\nExecuted in: ${this.currentCwd}\n${isBackgroundTask ? 'Mode: Background Launch' : `Mode: Foreground\nTimeout Limit: ${effectiveTimeout}ms`}\nPartial Stdout (Launch):\n${this.truncateOutput(stdoutBuffer)}\nPartial Stderr (Launch):\n${this.truncateOutput(stderrBuffer)}\nNote: ${isBackgroundTask ? 'Launch failed or took too long.' : 'Attempted interrupt (SIGINT). Shell state might be unpredictable if command ignored interrupt.'}`,
+ returnDisplay: `Timeout: ${timeoutMessage}`,
});
- child.stderr!.on('data', (data) => {
- stderr += data.toString();
- if (stderr.length > MAX_OUTPUT_LENGTH) {
- stderr = this.truncateOutput(stderr);
- child.stderr!.pause();
+ }, effectiveTimeout);
+ const processDataChunk = (chunk: string, isStderr: boolean): boolean => {
+ let dataToProcess = chunk;
+ if (!commandOutputStarted) {
+ const startIndex = dataToProcess.indexOf(startDelimiter);
+ if (startIndex !== -1) {
+ commandOutputStarted = true;
+ dataToProcess = dataToProcess.substring(
+ startIndex + startDelimiter.length,
+ );
+ } else {
+ return false;
}
- });
- child.on('error', (err) => {
- processError = err;
+ }
+ const pidIndex = dataToProcess.indexOf(pidDelimiter);
+ if (pidIndex !== -1) {
+ const pidMatch = dataToProcess
+ .substring(pidIndex + pidDelimiter.length)
+ .match(/^(\d+)/);
+ if (pidMatch?.[1]) {
+ backgroundPid = parseInt(pidMatch[1], 10);
+ const pidEndIndex =
+ pidIndex + pidDelimiter.length + pidMatch[1].length;
+ const beforePid = dataToProcess.substring(0, pidIndex);
+ if (isStderr) stderrBuffer += beforePid;
+ else stdoutBuffer += beforePid;
+ dataToProcess = dataToProcess.substring(pidEndIndex);
+ } else {
+ const beforePid = dataToProcess.substring(0, pidIndex);
+ if (isStderr) stderrBuffer += beforePid;
+ else stdoutBuffer += beforePid;
+ dataToProcess = dataToProcess.substring(
+ pidIndex + pidDelimiter.length,
+ );
+ }
+ }
+ const exitCodeIndex = dataToProcess.indexOf(exitCodeDelimiter);
+ if (exitCodeIndex !== -1) {
+ const exitCodeMatch = dataToProcess
+ .substring(exitCodeIndex + exitCodeDelimiter.length)
+ .match(/^(\d+)/);
+ if (exitCodeMatch?.[1]) {
+ exitCode = parseInt(exitCodeMatch[1], 10);
+ const beforeExitCode = dataToProcess.substring(0, exitCodeIndex);
+ if (isStderr) stderrBuffer += beforeExitCode;
+ else stdoutBuffer += beforeExitCode;
+ dataToProcess = dataToProcess.substring(
+ exitCodeIndex +
+ exitCodeDelimiter.length +
+ exitCodeMatch[1].length,
+ );
+ } else {
+ const beforeExitCode = dataToProcess.substring(0, exitCodeIndex);
+ if (isStderr) stderrBuffer += beforeExitCode;
+ else stdoutBuffer += beforeExitCode;
+ dataToProcess = dataToProcess.substring(
+ exitCodeIndex + exitCodeDelimiter.length,
+ );
+ }
+ }
+ const endDelimiterIndex = dataToProcess.indexOf(endDelimiter);
+ if (endDelimiterIndex !== -1) {
+ receivedEndDelimiter = true;
+ const beforeEndDelimiter = dataToProcess.substring(
+ 0,
+ endDelimiterIndex,
+ );
+ if (isStderr) stderrBuffer += beforeEndDelimiter;
+ else stdoutBuffer += beforeEndDelimiter;
+ const afterEndDelimiter = dataToProcess.substring(
+ endDelimiterIndex + endDelimiter.length,
+ );
+ const exitCodeEchoMatch = afterEndDelimiter.match(/^(\d+)/);
+ dataToProcess = exitCodeEchoMatch
+ ? afterEndDelimiter.substring(exitCodeEchoMatch[1].length)
+ : afterEndDelimiter;
+ }
+ if (dataToProcess.length > 0) {
+ if (isStderr) stderrBuffer += dataToProcess;
+ else stdoutBuffer += dataToProcess;
+ }
+ if (receivedEndDelimiter && exitCode !== null) {
+ setImmediate(cleanupAndResolve);
+ return true;
+ }
+ return false;
+ };
+ onStdoutData = (data: Buffer) => processDataChunk(data.toString(), false);
+ onStderrData = (data: Buffer) => processDataChunk(data.toString(), true);
+ const cleanupListeners = (listeners?: {
+ onStdoutData: ((data: Buffer) => void) | null;
+ onStderrData: ((data: Buffer) => void) | null;
+ }) => {
+ if (launchTimeoutId) clearTimeout(launchTimeoutId);
+ launchTimeoutId = null;
+ const stdoutListener = listeners?.onStdoutData ?? onStdoutData;
+ const stderrListener = listeners?.onStderrData ?? onStderrData;
+ if (this.bashProcess && !this.bashProcess.killed) {
+ if (stdoutListener)
+ this.bashProcess.stdout.removeListener('data', stdoutListener);
+ if (stderrListener)
+ this.bashProcess.stderr.removeListener('data', stderrListener);
+ }
+ if (this.currentCommandCleanup === cleanupListeners) {
+ this.currentCommandCleanup = null;
+ }
+ onStdoutData = null;
+ onStderrData = null;
+ };
+ this.currentCommandCleanup = cleanupListeners;
+ const cleanupAndResolve = async () => {
+ if (
+ !this.currentCommandCleanup ||
+ this.currentCommandCleanup !== cleanupListeners
+ ) {
+ if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
+ this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch(
+ (err) => {
+ console.warn(
+ `Error cleaning up temp files for superseded command: ${err.message}`,
+ );
+ },
+ );
+ }
+ return;
+ }
+ const launchStdout = this.truncateOutput(stdoutBuffer);
+ const launchStderr = this.truncateOutput(stderrBuffer);
+ const listenersToClean = { onStdoutData, onStderrData };
+ cleanupListeners(listenersToClean);
+ if (exitCode === null) {
console.error(
- `TerminalLogic spawn error for "${params.command}":`,
- err,
+ `CRITICAL: Command "${params.command}" (background: ${isBackgroundTask}) finished delimiter processing but exitCode is null.`,
);
- });
- child.on('close', (code, signal) => {
- const exitCode = code ?? (signal ? -1 : -2);
- if (signal === 'SIGTERM' || signal === 'SIGKILL') {
- if (child.killed && timeout > 0) timedOut = true;
+ const errorMode = isBackgroundTask
+ ? 'Background Launch'
+ : 'Foreground';
+ if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
+ await this.cleanupTempFiles(tempStdoutPath, tempStderrPath);
+ }
+ originalResolve({
+ llmContent: `Command: ${params.command}\nExecuted in: ${this.currentCwd}\nMode: ${errorMode}\nExit Code: -2 (Internal Error: Exit code not captured)\nStdout (during setup):\n${launchStdout}\nStderr (during setup):\n${launchStderr}`,
+ returnDisplay:
+ `Internal Error: Failed to capture command exit code.\n${launchStdout}\nStderr: ${launchStderr}`.trim(),
+ });
+ return;
+ }
+ let cwdUpdateError = '';
+ if (!isBackgroundTask) {
+ const mightChangeCwd = params.command.trim().startsWith('cd ');
+ if (exitCode === 0 || mightChangeCwd) {
+ try {
+ const latestCwd = await this.getCurrentShellCwd();
+ if (this.currentCwd !== latestCwd) {
+ this.currentCwd = latestCwd;
+ }
+ } catch (e: unknown) {
+ if (exitCode === 0) {
+ cwdUpdateError = `\nWarning: Failed to verify/update current working directory after command: ${getErrorMessage(e)}`;
+ console.error(
+ 'Failed to update CWD after successful command:',
+ e,
+ );
+ }
+ }
}
- 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)
+ }
+ if (isBackgroundTask) {
+ const launchSuccess = exitCode === 0;
+ const pidString =
+ backgroundPid !== null ? backgroundPid.toString() : 'Not Captured';
+ if (
+ launchSuccess &&
+ backgroundPid !== null &&
+ tempStdoutPath &&
+ tempStderrPath
+ ) {
+ this.inspectBackgroundProcess(
+ backgroundPid,
+ params.command,
+ this.currentCwd,
+ launchStdout,
+ launchStderr,
+ tempStdoutPath,
+ tempStderrPath,
+ originalResolve,
+ );
+ } else {
+ const reason =
+ backgroundPid === null
+ ? 'PID not captured'
+ : `Launch failed (Exit Code: ${exitCode})`;
+ const displayMessage = `Failed to launch process in background (${reason})`;
+ console.error(
+ `Background launch failed for command: ${params.command}. Reason: ${reason}`,
+ );
+ if (tempStdoutPath && tempStderrPath) {
+ await this.cleanupTempFiles(tempStdoutPath, tempStderrPath);
+ }
+ originalResolve({
+ llmContent: `Background Command Launch Failed: ${params.command}\nExecuted in: ${this.currentCwd}\nReason: ${reason}\nPID: ${pidString}\nExit Code (Launch): ${exitCode}\nStdout (During Launch):\n${launchStdout}\nStderr (During Launch):\n${launchStderr}`,
+ returnDisplay: displayMessage,
+ });
+ }
+ } else {
+ let displayOutput = '';
+ const stdoutTrimmed = launchStdout.trim();
+ const stderrTrimmed = launchStderr.trim();
+ if (stderrTrimmed) {
+ displayOutput = stderrTrimmed;
+ } else if (stdoutTrimmed) {
+ displayOutput = stdoutTrimmed;
+ }
+ if (exitCode !== 0 && !displayOutput) {
+ displayOutput = `Failed with exit code: ${exitCode}`;
+ } else if (exitCode === 0 && !displayOutput) {
displayOutput = `Success (no output)`;
- resolve({
- llmContent,
+ }
+ originalResolve({
+ llmContent: `Command: ${params.command}\nExecuted in: ${this.currentCwd}\nExit Code: ${exitCode}\nStdout:\n${launchStdout}\nStderr:\n${launchStderr}${cwdUpdateError}`,
returnDisplay: displayOutput.trim() || `Exit Code: ${exitCode}`,
});
- });
- } catch (spawnError: unknown) {
- const errMsg = getErrorMessage(spawnError);
+ }
+ };
+ if (!this.bashProcess || this.bashProcess.killed) {
console.error(
- `TerminalLogic failed to spawn "${params.command}":`,
- spawnError,
+ 'Bash process lost or killed before listeners could be attached.',
+ );
+ if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
+ this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch((err) => {
+ console.warn(
+ `Error cleaning up temp files on attach failure: ${err.message}`,
+ );
+ });
+ }
+ return originalReject(
+ new Error(
+ 'Bash process lost or killed before listeners could be attached.',
+ ),
+ );
+ }
+ if (onStdoutData) this.bashProcess.stdout.on('data', onStdoutData);
+ if (onStderrData) this.bashProcess.stderr.on('data', onStderrData);
+ let commandToWrite: string;
+ if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
+ commandToWrite = `echo "${startDelimiter}"; { { ${params.command} > "${tempStdoutPath}" 2> "${tempStderrPath}"; } & } 2>/dev/null; __LAST_PID=$!; echo "${pidDelimiter}$__LAST_PID" >&2; echo "${exitCodeDelimiter}$?" >&2; echo "${endDelimiter}$?" >&1\n`;
+ } else if (!isBackgroundTask) {
+ commandToWrite = `echo "${startDelimiter}"; ${params.command}; __EXIT_CODE=$?; echo "${exitCodeDelimiter}$__EXIT_CODE" >&2; echo "${endDelimiter}$__EXIT_CODE" >&1\n`;
+ } else {
+ return originalReject(
+ new Error(
+ 'Internal setup error: Missing temporary file paths for background execution.',
+ ),
+ );
+ }
+ try {
+ if (this.bashProcess?.stdin?.writable) {
+ this.bashProcess.stdin.write(commandToWrite, (err) => {
+ if (err) {
+ console.error(
+ `Error writing command "${params.command}" to bash stdin (callback):`,
+ err,
+ );
+ const listenersToClean = { onStdoutData, onStderrData };
+ cleanupListeners(listenersToClean);
+ if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
+ this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch(
+ (e) => console.warn(`Cleanup failed: ${e.message}`),
+ );
+ }
+ originalReject(
+ new Error(
+ `Shell stdin write error: ${err.message}. Command likely did not execute.`,
+ ),
+ );
+ }
+ });
+ } else {
+ throw new Error(
+ 'Shell stdin is not writable or process closed when attempting to write command.',
+ );
+ }
+ } catch (e: unknown) {
+ console.error(
+ `Error writing command "${params.command}" to bash stdin (sync):`,
+ e,
+ );
+ const listenersToClean = { onStdoutData, onStderrData };
+ cleanupListeners(listenersToClean);
+ if (isBackgroundTask && tempStdoutPath && tempStderrPath) {
+ this.cleanupTempFiles(tempStdoutPath, tempStderrPath).catch((err) =>
+ console.warn(`Cleanup failed: ${err.message}`),
+ );
+ }
+ originalReject(
+ new Error(
+ `Shell stdin write exception: ${getErrorMessage(e)}. Command likely did not execute.`,
+ ),
);
- resolve({
- llmContent: `Failed to start command: ${params.command}\nError: ${errMsg}`,
- returnDisplay: `Error spawning command: ${errMsg}`,
- });
}
});
+ return promise;
}
- private truncateOutput(
- output: string,
- limit: number = MAX_OUTPUT_LENGTH,
- ): string {
- if (output.length > limit) {
+ private async inspectBackgroundProcess(
+ pid: number,
+ command: string,
+ cwd: string,
+ initialStdout: string,
+ initialStderr: string,
+ tempStdoutPath: string,
+ tempStderrPath: string,
+ resolve: (value: ToolResult | PromiseLike<ToolResult>) => void,
+ ): Promise<void> {
+ let finalStdout = '';
+ let finalStderr = '';
+ let llmAnalysis = '';
+ let fileReadError = '';
+ try {
+ const { status, summary } = await this.backgroundTerminalAnalyzer.analyze(
+ pid,
+ tempStdoutPath,
+ tempStderrPath,
+ command,
+ );
+ if (status === 'Unknown') llmAnalysis = `LLM analysis failed: ${summary}`;
+ else llmAnalysis = summary;
+ } catch (llmerror: unknown) {
+ console.error(
+ `LLM analysis failed for PID ${pid} command "${command}":`,
+ llmerror,
+ );
+ llmAnalysis = `LLM analysis failed: ${getErrorMessage(llmerror)}`;
+ }
+ try {
+ finalStdout = await fs.readFile(tempStdoutPath, 'utf-8');
+ finalStderr = await fs.readFile(tempStderrPath, 'utf-8');
+ } catch (err: unknown) {
+ console.error(`Error reading temp output files for PID ${pid}:`, err);
+ fileReadError = `\nWarning: Failed to read temporary output files (${getErrorMessage(err)}). Final output may be incomplete.`;
+ }
+ await this.cleanupTempFiles(tempStdoutPath, tempStderrPath);
+ const truncatedFinalStdout = this.truncateOutput(finalStdout);
+ const truncatedFinalStderr = this.truncateOutput(finalStderr);
+ resolve({
+ llmContent: `Background Command: ${command}\nLaunched in: ${cwd}\nPID: ${pid}\n--- LLM Analysis ---\n${llmAnalysis}\n--- Final Stdout (from ${path.basename(tempStdoutPath)}) ---\n${truncatedFinalStdout}\n--- Final Stderr (from ${path.basename(tempStderrPath)}) ---\n${truncatedFinalStderr}\n--- Launch Stdout ---\n${initialStdout}\n--- Launch Stderr ---\n${initialStderr}${fileReadError}`,
+ returnDisplay: `(PID: ${pid}): ${this.truncateOutput(llmAnalysis, 200)}`,
+ });
+ }
+
+ private async cleanupTempFiles(
+ stdoutPath: string | null,
+ stderrPath: string | null,
+ ): Promise<void> {
+ const unlinkQuietly = async (filePath: string | null) => {
+ if (!filePath) return;
+ try {
+ await fs.unlink(filePath);
+ } catch (err: unknown) {
+ if (!isNodeError(err) || err.code !== 'ENOENT') {
+ console.warn(
+ `Failed to delete temporary file '${filePath}': ${getErrorMessage(err)}`,
+ );
+ }
+ }
+ };
+ await Promise.all([unlinkQuietly(stdoutPath), unlinkQuietly(stderrPath)]);
+ }
+
+ private getCurrentShellCwd(): Promise<string> {
+ return new Promise((resolve, reject) => {
+ if (
+ !this.bashProcess ||
+ !this.bashProcess.stdin?.writable ||
+ this.bashProcess.killed
+ ) {
+ return reject(
+ new Error(
+ 'Shell not running, stdin not writable, or killed for PWD check',
+ ),
+ );
+ }
+ const pwdUuid = crypto.randomUUID();
+ const pwdDelimiter = `::PWD_${pwdUuid}::`;
+ let pwdOutput = '';
+ let onPwdData: ((data: Buffer) => void) | null = null;
+ let onPwdError: ((data: Buffer) => void) | null = null;
+ let pwdTimeoutId: NodeJS.Timeout | null = null;
+ let finished = false;
+ const cleanupPwdListeners = (err?: Error) => {
+ if (finished) return;
+ finished = true;
+ if (pwdTimeoutId) clearTimeout(pwdTimeoutId);
+ pwdTimeoutId = null;
+ const stdoutListener = onPwdData;
+ const stderrListener = onPwdError;
+ onPwdData = null;
+ onPwdError = null;
+ if (this.bashProcess && !this.bashProcess.killed) {
+ if (stdoutListener)
+ this.bashProcess.stdout.removeListener('data', stdoutListener);
+ if (stderrListener)
+ this.bashProcess.stderr.removeListener('data', stderrListener);
+ }
+ if (err) {
+ reject(err);
+ } else {
+ resolve(pwdOutput.trim());
+ }
+ };
+ onPwdData = (data: Buffer) => {
+ if (!onPwdData) return;
+ const dataStr = data.toString();
+ const delimiterIndex = dataStr.indexOf(pwdDelimiter);
+ if (delimiterIndex !== -1) {
+ pwdOutput += dataStr.substring(0, delimiterIndex);
+ cleanupPwdListeners();
+ } else {
+ pwdOutput += dataStr;
+ }
+ };
+ onPwdError = (data: Buffer) => {
+ if (!onPwdError) return;
+ const dataStr = data.toString();
+ console.error(`Error during PWD check: ${dataStr}`);
+ cleanupPwdListeners(
+ new Error(
+ `Stderr received during pwd check: ${this.truncateOutput(dataStr, 100)}`,
+ ),
+ );
+ };
+ this.bashProcess.stdout.on('data', onPwdData);
+ this.bashProcess.stderr.on('data', onPwdError);
+ pwdTimeoutId = setTimeout(() => {
+ cleanupPwdListeners(new Error('Timeout waiting for pwd response'));
+ }, 5000);
+ try {
+ const pwdCommand = `printf "%s" "$PWD"; printf "${pwdDelimiter}";\n`;
+ if (this.bashProcess?.stdin?.writable) {
+ this.bashProcess.stdin.write(pwdCommand, (err) => {
+ if (err) {
+ console.error('Error writing pwd command (callback):', err);
+ cleanupPwdListeners(
+ new Error(`Failed to write pwd command: ${err.message}`),
+ );
+ }
+ });
+ } else {
+ throw new Error('Shell stdin not writable for pwd command.');
+ }
+ } catch (e: unknown) {
+ console.error('Exception writing pwd command:', e);
+ cleanupPwdListeners(
+ new Error(`Exception writing pwd command: ${getErrorMessage(e)}`),
+ );
+ }
+ });
+ }
+
+ private truncateOutput(output: string, limit?: number): string {
+ const effectiveLimit = limit ?? this.outputLimit;
+ if (output.length > effectiveLimit) {
return (
- output.substring(0, limit) +
- `\n... [Output truncated at ${limit} characters]`
+ output.substring(0, effectiveLimit) +
+ `\n... [Output truncated at ${effectiveLimit} characters]`
);
}
return output;
}
+
+ private clearQueue(error: Error) {
+ const queue = this.commandQueue;
+ this.commandQueue = [];
+ queue.forEach(({ resolve, params }) =>
+ resolve({
+ llmContent: `Command cancelled: ${params.command}\nReason: ${error.message}`,
+ returnDisplay: `Command Cancelled: ${error.message}`,
+ }),
+ );
+ }
+
+ destroy() {
+ this.rejectShellReady?.(
+ new Error('BashTool destroyed during initialization or operation.'),
+ );
+ this.rejectShellReady = undefined;
+ this.resolveShellReady = undefined;
+ this.clearQueue(new Error('BashTool is being destroyed.'));
+ try {
+ this.currentCommandCleanup?.();
+ } catch (e) {
+ console.warn('Error during current command cleanup:', e);
+ }
+ if (this.bashProcess) {
+ const proc = this.bashProcess;
+ const pid = proc.pid;
+ this.bashProcess = null;
+ proc.stdout?.removeAllListeners();
+ proc.stderr?.removeAllListeners();
+ proc.removeAllListeners('error');
+ proc.removeAllListeners('close');
+ proc.stdin?.end();
+ try {
+ proc.kill('SIGTERM');
+ setTimeout(() => {
+ if (!proc.killed) {
+ proc.kill('SIGKILL');
+ }
+ }, 500);
+ } catch (e: unknown) {
+ console.warn(
+ `Error trying to kill bash process PID: ${pid}: ${getErrorMessage(e)}`,
+ );
+ }
+ }
+ }
}