//! Shell command execution with timeout, cancellation and live output. use super::{Tool, ToolCtx, ToolOutput, arg_str, truncate}; use anyhow::Result; use async_trait::async_trait; use serde_json::{Value, json}; use std::process::Stdio; use std::time::{Duration, Instant}; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; const DEFAULT_TIMEOUT_MS: u64 = 120_000; const MAX_TIMEOUT_MS: u64 = 600_000; const MAX_OUTPUT: usize = 30_000; pub struct Bash; #[async_trait] impl Tool for Bash { fn name(&self) -> &str { "bash" } fn description(&self) -> String { "Run a shell command (bash) in the session working directory. stdout and stderr are \ combined. Default timeout 2 minutes, max 10 minutes (timeout_ms). The working directory \ does not persist between calls – use absolute paths or `cd dir && ...`. Prefer the \ dedicated read/edit/glob/grep tools over cat/sed/find/grep. For long-running processes \ (dev servers, watchers, long builds) set run_in_background=true: you get a shell id, \ read new output with bash_output and stop it with kill_shell." .into() } fn schema(&self) -> Value { json!({ "type": "object", "properties": { "command": {"type": "string"}, "description": {"type": "string", "description": "5-10 word description of what the command does"}, "timeout_ms": {"type": "integer"}, "run_in_background": {"type": "boolean", "description": "Keep running; read output with bash_output"} }, "required": ["command"] }) } fn title(&self, input: &Value) -> String { input["description"] .as_str() .map(String::from) .unwrap_or_else(|| format!("Run: {}", input["command"].as_str().unwrap_or("?"))) } async fn run(&self, input: Value, ctx: &ToolCtx) -> Result { let command = arg_str(&input, "command")?; let timeout = Duration::from_millis( input["timeout_ms"].as_u64().unwrap_or(DEFAULT_TIMEOUT_MS).min(MAX_TIMEOUT_MS), ); let mut cmd = match shell_command(ctx, command) { Ok(c) => c, Err(e) => return Ok(e), }; if input["run_in_background"].as_bool().unwrap_or(false) { let id = crate::shells::spawn(&ctx.app, &ctx.session_id, command, cmd)?; return Ok(ToolOutput::text(format!( "Started in the background with shell id {id}. Read its output with bash_output (shell_id \"{id}\") and stop it with kill_shell." )) .with_meta(json!({"shell_id": id}))); } cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).kill_on_drop(true); // Own process group so the whole tree can be killed on abort/timeout. cmd.process_group(0); let mut child = cmd.spawn()?; let pid = child.id(); let stdout = child.stdout.take().unwrap(); // split() instead of lines(): non-UTF-8 output must not end the read loop. let mut lines = BufReader::new(stdout).split(b'\n'); let mut output = String::new(); let started = Instant::now(); let mut last_progress = Instant::now(); let deadline = tokio::time::sleep(timeout); tokio::pin!(deadline); let kill_group = |pid: Option| { if let Some(pid) = pid { // SAFETY: plain syscall; negative pid targets the process group. unsafe { libc::kill(-(pid as i32), libc::SIGKILL); } } }; let status = loop { tokio::select! { line = lines.next_segment() => match line { Ok(Some(l)) => { if output.len() < MAX_OUTPUT * 4 { output.push_str(&String::from_utf8_lossy(&l)); output.push('\n'); } if last_progress.elapsed() > Duration::from_millis(300) { ctx.progress(tail(&output, 4000)); last_progress = Instant::now(); } } Ok(None) | Err(_) => break child.wait().await?, }, _ = &mut deadline => { kill_group(pid); let _ = child.wait().await; output.push_str(&format!("\n[command timed out after {}s and was killed]", timeout.as_secs())); return Ok(ToolOutput::error(truncate(&output, MAX_OUTPUT))); } _ = ctx.cancel.cancelled() => { kill_group(pid); let _ = child.wait().await; return Ok(ToolOutput::error("[command aborted by user]")); } } }; let code = status.code().unwrap_or(-1); let mut text = truncate(output.trim_end(), MAX_OUTPUT); if text.is_empty() { text = "(no output)".into(); } if code != 0 { text.push_str(&format!("\n[exit code {code}]")); } Ok(ToolOutput { content: text, is_error: code != 0, meta: Some(json!({"exit_code": code, "duration_ms": started.elapsed().as_millis() as u64})), ..Default::default() }) } } /// The bash command for a script, sandboxed for non-admin users. pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result { // stderr of every command (not just the last one) goes into the output. let script = format!("exec 2>&1\n{command}"); Ok(match &ctx.sandbox { Some(spec) => { if !crate::sandbox::available() { return Err(ToolOutput::error( "Shell commands are disabled for this account: the server's sandbox (bubblewrap) is not available. Ask an admin.", )); } crate::sandbox::command(spec, &ctx.cwd, "bash", &["-c", &script]) } None => { let mut c = Command::new("bash"); c.arg("-c").arg(&script).current_dir(&ctx.cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat"); // The session owner's git credentials and identity. let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id).and_then(|id| ctx.app.store.user(&id).ok().flatten()); if let Some(cfg) = owner.and_then(|u| crate::gitcreds::global_config(&ctx.app, &u)) { c.env("GIT_CONFIG_GLOBAL", cfg); } c } }) } fn tail(s: &str, max: usize) -> String { if s.len() <= max { return s.to_string(); } let mut start = s.len() - max; while !s.is_char_boundary(start) { start += 1; } s[start..].to_string() }