// 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 std::sync::Arc; use std::time::Instant; use tokio::io::AsyncReadExt; use tokio::sync::Mutex; use crate::types::ToolDef; #[derive(Deserialize)] struct Args { command: String, #[serde(default = "default_timeout")] timeout_secs: u64, } fn default_timeout() -> u64 { 120 } /// Info about a running child process, visible to the TUI. #[derive(Debug, Clone)] pub struct ProcessInfo { pub pid: u32, pub command: String, pub started: Instant, } /// Shared tracker for running child processes. Allows the TUI to /// display what's running and kill processes by PID. #[derive(Debug, Clone, Default)] pub struct ProcessTracker { inner: Arc>>, } impl ProcessTracker { pub fn new() -> Self { Self::default() } async fn register(&self, pid: u32, command: &str) { self.inner.lock().await.push(ProcessInfo { pid, command: if command.len() > 120 { format!("{}...", &command[..120]) } else { command.to_string() }, started: Instant::now(), }); } async fn unregister(&self, pid: u32) { self.inner.lock().await.retain(|p| p.pid != pid); } /// Snapshot of currently running processes. pub async fn list(&self) -> Vec { self.inner.lock().await.clone() } /// Kill a process by PID. Returns true if the signal was sent. pub async fn kill(&self, pid: u32) -> bool { // SIGTERM the process group (negative PID kills the group) let ret = unsafe { libc::kill(-(pid as i32), libc::SIGTERM) }; if ret != 0 { // Try just the process unsafe { libc::kill(pid as i32, libc::SIGTERM) }; } // Don't unregister — let the normal exit path do that // so the tool result says "killed by user" true } } 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, tracker: &ProcessTracker) -> 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); tracker.register(pid, command).await; // 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 — kill the process group tracker.kill(pid).await; Err(anyhow::anyhow!("Command timed out after {}s: {}", timeout_secs, command)) } }; tracker.unregister(pid).await; result }