//! 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}", commit_attribution(ctx)); 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(u) = owner { if let Some(cfg) = crate::gitcreds::global_config(&ctx.app, &u) { c.env("GIT_CONFIG_GLOBAL", cfg); } c.envs(crate::gitcreds::identity_env(&ctx.app, &u.id)); } c } }) } /// Directory with the git wrapper that adds the co-author trailer to commits. fn git_wrapper_dir() -> String { std::env::var("CODECRAFT_GIT_WRAPPER").unwrap_or_else(|_| "/usr/local/lib/codecraft/bin".into()) } /// Shell lines that make `git commit` in this command carry the co-author trailer of /// the session's model (see docker/git-coauthor). Set in the script itself, so it /// works inside the sandbox (which clears the environment) too. fn commit_attribution(ctx: &ToolCtx) -> String { let template = ctx.app.config().server.commit_trailer.clone(); let dir = git_wrapper_dir(); if template.trim().is_empty() || !std::path::Path::new(&dir).join("git").exists() { return String::new(); } let Ok(session) = ctx.app.session(&ctx.session_id) else { return String::new() }; let (provider, model_id) = session.model.split_once('/').unwrap_or(("", session.model.as_str())); let name = ctx.app.registry().resolve(&session.model).ok().and_then(|(_, m)| m.name).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| model_id.to_string()); let trailer = commit_trailer(&template, &name, provider, model_id); format!("export PATH={}:\"$PATH\" CODECRAFT_COMMIT_TRAILER={}\n", shell_quote(&dir), shell_quote(&trailer)) } /// The trailer text for a model ("Co-Authored-By: DeepSeek V4.1 Flash (opencode-go) via CodeCraft"). pub fn commit_trailer(template: &str, model: &str, provider: &str, model_id: &str) -> String { template.replace("{model}", model).replace("{provider}", provider).replace("{model_id}", model_id).replace(['\n', '\r'], " ").trim().to_string() } fn shell_quote(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } 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() } #[cfg(test)] mod commit_tests { use super::*; #[test] fn commit_trailer_text() { assert_eq!( commit_trailer(crate::config::DEFAULT_COMMIT_TRAILER, "DeepSeek V4.1 Flash", "opencode-go", "deepseek-v4.1-flash"), "Co-Authored-By: DeepSeek V4.1 Flash (opencode-go) via CodeCraft" ); assert_eq!(commit_trailer("By {model_id}\nx", "M", "p", "m-1"), "By m-1 x"); assert_eq!(shell_quote("it's"), "'it'\\''s'"); } }