import { spawn, ChildProcess } from "child_process"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import { logger } from "../utils/globalLogger.js"; import { resolveShellPath } from "../utils/shellResolver.js"; import { toPosixPath } from "../utils/path.js"; import { stripAnsiColors } from "../utils/stringUtils.js"; import { processToolResult } from "../utils/toolResultStorage.js"; import { BASH_MAX_OUTPUT_CHARS } from "../constants/toolLimits.js"; import type { ToolPlugin, ToolResult, ToolContext } from "./types.js"; import { BASH_TOOL_NAME, GLOB_TOOL_NAME, GREP_TOOL_NAME, READ_TOOL_NAME, EDIT_TOOL_NAME, WRITE_TOOL_NAME, } from "../constants/tools.js"; const BASH_DEFAULT_TIMEOUT_MS = 120000; /** * Wrap a user command so we can append CWD tracking (`&& pwd -P`) without the * appended part being affected by trailing here-docs, unbalanced quotes, or * multi-line syntax in the user command. * * The command is single-quoted (with embedded single quotes escaped via the * classic `'"'"'` sequence) and run through `eval`. Because the entire user * command becomes a single literal argument to `eval`, any here-doc inside it * only opens/closes during eval's own second parse pass and cannot leak out to * clobber the trailing `&& pwd -P`. Works on sh/dash/bash and Git Bash. */ function wrapCommandForCwdTracking( command: string, cwdFileForBash: string, ): string { const escaped = command.replace(/'/g, `'"'"'`); return `eval '${escaped}' && pwd -P >| ${cwdFileForBash}`; } // Commands that should not be auto-backgrounded on timeout (e.g. sleep should just be killed) const DISALLOWED_AUTO_BACKGROUND_COMMANDS = ["sleep"]; function isAutobackgroundingAllowed(command: string): boolean { const trimmed = command.trim(); // Get the first word of the command const baseCommand = trimmed.split(/\s+/)[0]; if (!baseCommand) return true; return !DISALLOWED_AUTO_BACKGROUND_COMMANDS.includes(baseCommand); } /** * Bash command execution tool - supports both foreground and background execution */ export const bashTool: ToolPlugin = { name: BASH_TOOL_NAME, isConcurrencySafe: false, config: { type: "function", function: { name: BASH_TOOL_NAME, description: "Run shell command", parameters: { type: "object", properties: { command: { type: "string", description: "The command to execute", }, timeout: { type: "number", description: "Optional timeout in milliseconds (max 600000)", }, description: { type: "string", description: "Clear, concise description of what this command does in 5-10 words.", }, run_in_background: { type: "boolean", description: `Set to true to run this command in the background. Use ${READ_TOOL_NAME} to read the output later.`, }, }, required: ["command"], }, }, }, prompt: () => ` Executes a given bash command with optional timeout, ensuring proper handling and security measures. Each invocation runs in a fresh shell process starting from the project root. IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. Before executing the command, please follow these steps: 1. Directory Verification: - If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location - For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory 2. Command Execution: - Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt") - Examples of proper quoting: - cd "/Users/name/My Documents" (correct) - cd /Users/name/My Documents (incorrect - will fail) - python "/path/with spaces/script.py" (correct) - python /path/with spaces/script.py (incorrect - will fail) - After ensuring proper quoting, execute the command. - Capture the output of the command. Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds (up to ${BASH_DEFAULT_TIMEOUT_MS}ms / ${BASH_DEFAULT_TIMEOUT_MS / 60000} minutes). If not specified, commands will timeout after ${BASH_DEFAULT_TIMEOUT_MS}ms (${BASH_DEFAULT_TIMEOUT_MS / 60000} minutes). - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - If the output exceeds ${BASH_MAX_OUTPUT_CHARS.toLocaleString()} characters, output will be truncated and the full output will be persisted to a file you can read with the Read tool. - You can use the \`run_in_background\` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the ${READ_TOOL_NAME} tool as it becomes available. You do not need to use '&' at the end of the command when using this parameter. - Avoid using ${BASH_TOOL_NAME} with the \`find\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - File search: Use ${GLOB_TOOL_NAME} (NOT find or ls) - Content search: Use ${GREP_TOOL_NAME} (NOT grep or rg) - Read files: Use ${READ_TOOL_NAME} (NOT cat/head/tail) - Edit files: Use ${EDIT_TOOL_NAME} (NOT sed/awk) - Write files: Use ${WRITE_TOOL_NAME} (NOT echo >/cat <, context: ToolContext, ): Promise => { const command = args.command as string; const runInBackground = args.run_in_background as boolean | undefined; const description = args.description as string | undefined; // Set default timeout: BASH_DEFAULT_TIMEOUT_MS for foreground, no timeout for background // When run_in_background is explicitly set, cancel any timeout — the intent is to // let the process run to completion (matching Claude Code behavior where background() // clears the timeout via cleanupListeners). const timeout = runInBackground ? undefined : ((args.timeout as number | undefined) ?? BASH_DEFAULT_TIMEOUT_MS); if (!command || typeof command !== "string") { return { success: false, content: "", error: "Command parameter is required and must be a string", }; } // Resolve shell path: on Windows, use Git Bash; on macOS/Linux, use bash or zsh const shellPath = resolveShellPath(); if (!shellPath) { return { success: false, content: "", error: process.platform === "win32" ? "Git Bash not found. Please install Git for Windows or set WAVE_GIT_BASH_PATH environment variable." : "No suitable shell found. Please ensure bash or zsh is installed, or set WAVE_SHELL environment variable.", }; } // Validate timeout if ( timeout !== undefined && (typeof timeout !== "number" || timeout < 0 || timeout > 600000) ) { return { success: false, content: "", error: "Timeout must be a number between 0 and 600000 milliseconds", }; } // Permission check after validation but before real operation if (context.permissionManager) { try { const permissionContext = context.permissionManager.createContext( BASH_TOOL_NAME, context.permissionMode || "default", context.canUseToolCallback, { command, description, run_in_background: runInBackground, timeout, workdir: context.workdir, }, context.toolCallId, ); const permissionResult = await context.permissionManager.checkPermission(permissionContext); if (permissionResult.behavior === "deny") { return { success: false, content: "", error: `${BASH_TOOL_NAME} operation denied by user, reason: ${permissionResult.message || "No reason provided"}`, }; } } catch { return { success: false, content: "", error: "Permission check failed", }; } } if (runInBackground) { // Background execution const backgroundTaskManager = context?.backgroundTaskManager; if (!backgroundTaskManager) { return { success: false, content: "", error: "Background task manager not available", }; } const { id: taskId } = backgroundTaskManager.startShell( command, undefined, context.workdir, ); const task = backgroundTaskManager.getTask(taskId); const outputPath = task?.outputPath; const backgroundMsg = [ `Command started in background with ID: ${taskId}.`, `You will be notified automatically when it completes.`, outputPath ? `output_file: ${outputPath}` : `Use ${READ_TOOL_NAME} tool with task_id="${taskId}" to read the output.`, ].join("\n"); return { success: true, content: backgroundMsg, shortResult: `Background process ${taskId} started${outputPath ? ` → ${outputPath}` : ""}`, }; } // Foreground execution (original behavior) return new Promise((resolve) => { // Create a temporary file to store the CWD const tempCwdFile = path.join( os.tmpdir(), `wave_cwd_${Date.now()}_${Math.random().toString(36).substring(2, 11)}.tmp`, ); const tempCwdFileForBash = toPosixPath(tempCwdFile); const wrappedCommand = wrapCommandForCwdTracking( command, tempCwdFileForBash, ); const child: ChildProcess = spawn(wrappedCommand, { shell: shellPath, stdio: "pipe", detached: true, cwd: context.workdir, env: { ...process.env, }, }); let outputBuffer = ""; let errorBuffer = ""; let isAborted = false; let isBackgrounded = false; let isFinished = false; // Best-effort cleanup of the temp CWD file — used by abort/error/exit paths const cleanupTempFile = () => { try { if (fs.existsSync(tempCwdFile)) { fs.unlinkSync(tempCwdFile); } } catch { // ignore — best-effort cleanup } }; const updateRealtimeResults = () => { if (isAborted || isBackgrounded || isFinished) return; const combinedOutput = outputBuffer + (errorBuffer ? "\n" + errorBuffer : ""); // Update shortResult: last 3 lines if (context.onShortResultUpdate) { const tail = combinedOutput.slice(-5000); const lines = tail.trim().split("\n"); const shortResult = lines.length <= 3 ? lines.join("\n") : `... +${lines.length - 3} lines\n` + lines.slice(-3).join("\n"); context.onShortResultUpdate(shortResult); } // Update full result (simple truncation for streaming — persistence happens at final result) if (context.onResultUpdate) { const content = combinedOutput.length <= BASH_MAX_OUTPUT_CHARS ? combinedOutput : combinedOutput.substring(0, BASH_MAX_OUTPUT_CHARS) + "\n\n... (output truncated)"; context.onResultUpdate(content); } }; const foregroundTaskId = `bash_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // Register as foreground task if (context.foregroundTaskManager && command) { context.foregroundTaskManager.registerForegroundTask({ id: foregroundTaskId, backgroundHandler: async () => { isBackgrounded = true; if (timeoutHandle) { clearTimeout(timeoutHandle); } const backgroundTaskManager = context.backgroundTaskManager; if (backgroundTaskManager) { const taskId = backgroundTaskManager.adoptProcess( child, command, outputBuffer, errorBuffer, ); const task = backgroundTaskManager.getTask(taskId); const outputPath = task?.outputPath; resolve({ success: true, content: `Command moved to background with ID: ${taskId}.${outputPath ? ` Real-time output: ${outputPath}` : ""}`, shortResult: `Process ${taskId} backgrounded`, isManuallyBackgrounded: true, }); } else { handleAbort( "Failed to background: Background task manager not available", ); } }, }); } // Set up timeout — auto-background if allowed, otherwise kill let timeoutHandle: NodeJS.Timeout | undefined; const shouldAutoBackground = !!context.backgroundTaskManager && isAutobackgroundingAllowed(command); if (timeout && timeout > 0) { timeoutHandle = setTimeout(() => { if (!isAborted && !isBackgrounded) { if (shouldAutoBackground) { // Auto-background: move the process to background task manager instead of killing it isBackgrounded = true; if (timeoutHandle) { clearTimeout(timeoutHandle); } // Unregister foreground task since it's now backgrounded if (context.foregroundTaskManager) { context.foregroundTaskManager.unregisterForegroundTask( foregroundTaskId, ); } const backgroundTaskManager = context.backgroundTaskManager!; const taskId = backgroundTaskManager.adoptProcess( child, command, outputBuffer, errorBuffer, ); const task = backgroundTaskManager.getTask(taskId); const outputPath = task?.outputPath; logger.info( `[Bash] Command timed out after ${timeout}ms, auto-backgrounded as ${taskId}`, ); resolve({ success: true, content: `Command timed out after ${timeout / 1000} seconds and was moved to background with ID: ${taskId}.${outputPath ? ` Real-time output: ${outputPath}` : ""}`, shortResult: `Process ${taskId} auto-backgrounded (timeout)`, }); } else { handleAbort("Command timed out"); } } }, timeout); } // Handle abort signal const handleAbort = (reason = "Command execution was aborted") => { if (!isAborted) { isAborted = true; if (timeoutHandle) { clearTimeout(timeoutHandle); } // Force terminate child process and its children if (child.pid) { try { // Try graceful termination of process group process.kill(-child.pid, "SIGTERM"); // Set timeout for force kill setTimeout(() => { if (child.pid && !child.killed) { try { process.kill(-child.pid, "SIGKILL"); } catch (killError: unknown) { // ESRCH means the process already exited — not an error if ( !(killError instanceof Error) || (killError as NodeJS.ErrnoException).code !== "ESRCH" ) { logger.error("Failed to force kill process:", killError); } } } }, 1000); } catch { // If process group termination fails, try direct child process termination try { child.kill("SIGTERM"); setTimeout(() => { if (!child.killed) { child.kill("SIGKILL"); } }, 1000); } catch (directKillError) { logger.error("Failed to kill child process:", directKillError); } } } cleanupTempFile(); const processedOutput = processToolResult( outputBuffer + (errorBuffer ? "\n" + errorBuffer : ""), BASH_MAX_OUTPUT_CHARS, "bash", ); resolve({ success: false, content: processedOutput ? `${processedOutput}\n\n${reason}` : reason, error: reason, }); } }; // Handle abort signal from context if (context?.abortSignal) { if (context.abortSignal.aborted) { handleAbort(); return; } // Use { once: true } to prevent listener accumulation on signal reuse context.abortSignal.addEventListener("abort", () => handleAbort(), { once: true, }); } child.stdout?.on("data", (data) => { if (!isAborted && !isBackgrounded && !runInBackground) { const chunk = stripAnsiColors(data.toString()); outputBuffer += chunk; updateRealtimeResults(); } }); child.stderr?.on("data", (data) => { if (!isAborted && !isBackgrounded && !runInBackground) { const chunk = stripAnsiColors(data.toString()); errorBuffer += chunk; updateRealtimeResults(); } }); child.on("exit", async (code) => { isFinished = true; if (context.foregroundTaskManager) { context.foregroundTaskManager.unregisterForegroundTask( foregroundTaskId, ); } if (!isAborted && !isBackgrounded) { if (timeoutHandle) { clearTimeout(timeoutHandle); } // Read the new CWD from the temporary file let newCwd: string | undefined; try { if (fs.existsSync(tempCwdFile)) { newCwd = fs.readFileSync(tempCwdFile, "utf8").trim(); // Validate the path exists before calling the callback fs.accessSync(newCwd, fs.constants.F_OK); } } catch (fileError) { logger.warn( `Could not read or validate new CWD from temp file ${tempCwdFile}:`, fileError, ); newCwd = undefined; } finally { cleanupTempFile(); } // If CWD changed, call the onCwdChange callback and add notification let cwdMessage: string | undefined; if (newCwd && newCwd !== context.workdir && context.onCwdChange) { const isInSafeZone = context.permissionManager?.isPathInSafeZone?.(newCwd) ?? true; if (!isInSafeZone && context.originalWorkdir) { context.onCwdChange(context.originalWorkdir); cwdMessage = `Shell cwd was reset to ${context.originalWorkdir}`; } else { context.onCwdChange(newCwd); cwdMessage = `Shell working directory changed to ${newCwd}`; } } const exitCode = code ?? 0; const combinedOutput = outputBuffer + (errorBuffer ? "\n" + errorBuffer : ""); // Prepend CWD change message to output if present const finalOutput = (cwdMessage ? cwdMessage + "\n" : "") + (combinedOutput || `Command executed with exit code: ${exitCode}`); const content = processToolResult( finalOutput, BASH_MAX_OUTPUT_CHARS, "bash", ); const lines = combinedOutput.trim().split("\n"); const shortResult = lines.length <= 3 ? lines.join("\n") : `... +${lines.length - 3} lines\n` + lines.slice(-3).join("\n"); resolve({ success: exitCode === 0, content, shortResult: shortResult || undefined, error: exitCode !== 0 ? `Command failed with exit code: ${exitCode}` : undefined, }); } }); child.on("error", (error) => { isFinished = true; if (context.foregroundTaskManager) { context.foregroundTaskManager.unregisterForegroundTask( foregroundTaskId, ); } if (!isAborted && !isBackgrounded) { if (timeoutHandle) { clearTimeout(timeoutHandle); } cleanupTempFile(); resolve({ success: false, content: "", error: `Failed to execute command: ${error.message}`, }); } }); }); }, formatCompactParams: (params: Record) => { const description = params.description as string; const command = params.command as string; const runInBackground = params.run_in_background as boolean; if (description) { return description; } return `${command}${runInBackground ? " (background)" : ""}`; }, };