// tools/bash.rs — Execute shell commands // // Runs commands through bash -c with a configurable timeout. // Uses tokio's async process spawning so timeouts actually work. // // Processes are tracked in a shared ProcessTracker so the TUI can // display running commands and the user can kill them (Ctrl+K). use anyhow::{Context, Result}; use serde::Deserialize; use serde_json::json; use std::process::Stdio; use tokio::io::AsyncReadExt; use super::{ToolDef, default_timeout}; /// RAII guard that SIGTERMs the process group on drop. /// Ensures child processes are cleaned up when a task is aborted. struct KillOnDrop(u32); // pid impl Drop for KillOnDrop { fn drop(&mut self) { if self.0 != 0 { unsafe { libc::kill(-(self.0 as i32), libc::SIGTERM); } } } } #[derive(Deserialize)] struct Args { command: String, #[serde(default = "default_timeout")] timeout_secs: u64, } pub fn definition() -> ToolDef { ToolDef::new( "bash", "Execute a bash command and return its output. \ Use for git operations, building, running tests, and other terminal tasks.", json!({ "type": "object", "properties": { "command": { "type": "string", "description": "The bash command to execute" }, "timeout_secs": { "type": "integer", "description": "Timeout in seconds (default 120)" } }, "required": ["command"] }), ) } pub async fn run_bash(args: &serde_json::Value) -> Result { let a: Args = serde_json::from_value(args.clone()) .context("invalid bash arguments")?; let command = &a.command; let timeout_secs = a.timeout_secs; let mut child = tokio::process::Command::new("bash") .arg("-c") .arg(command) .stdout(Stdio::piped()) .stderr(Stdio::piped()) // Create a process group so we can kill the whole tree .process_group(0) .spawn() .with_context(|| format!("Failed to spawn: {}", command))?; let pid = child.id().unwrap_or(0); let kill_guard = KillOnDrop(pid); // Take ownership of stdout/stderr handles before waiting, // so we can still kill the child on timeout. let mut stdout_handle = child.stdout.take().unwrap(); let mut stderr_handle = child.stderr.take().unwrap(); let timeout = std::time::Duration::from_secs(timeout_secs); let work = async { let mut stdout_buf = Vec::new(); let mut stderr_buf = Vec::new(); let (_, _, status) = tokio::try_join!( async { stdout_handle.read_to_end(&mut stdout_buf).await.map_err(anyhow::Error::from) }, async { stderr_handle.read_to_end(&mut stderr_buf).await.map_err(anyhow::Error::from) }, async { child.wait().await.map_err(anyhow::Error::from) }, )?; Ok::<_, anyhow::Error>((stdout_buf, stderr_buf, status)) }; let result = match tokio::time::timeout(timeout, work).await { Ok(Ok((stdout_buf, stderr_buf, status))) => { let stdout = String::from_utf8_lossy(&stdout_buf); let stderr = String::from_utf8_lossy(&stderr_buf); let mut result = String::new(); if !stdout.is_empty() { result.push_str(&stdout); } if !stderr.is_empty() { if !result.is_empty() { result.push('\n'); } result.push_str("STDERR:\n"); result.push_str(&stderr); } // Detect if killed by signal (SIGTERM = 15) if let Some(signal) = status.code() { if signal == -1 || !status.success() { result.push_str(&format!("\nExit code: {}", signal)); } } #[cfg(unix)] { use std::os::unix::process::ExitStatusExt; if let Some(sig) = status.signal() { if sig == libc::SIGTERM { result.push_str("\n(killed by user)"); } } } if result.is_empty() { result = "(no output)".to_string(); } Ok(super::truncate_output(result, 30000)) } Ok(Err(e)) => { Err(anyhow::anyhow!("Command failed: {}", e)) } Err(_) => { // Timeout — KillOnDrop will SIGTERM the process group Err(anyhow::anyhow!("Command timed out after {}s: {}", timeout_secs, command)) } }; // Process completed normally — defuse the kill guard std::mem::forget(kill_guard); result }