use std::io::Read;
use std::process::{Command, Stdio};
use std::sync::atomic::Ordering;
use std::thread;
use std::time::{Duration, Instant};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::Value;
use crate::ToolKind;
use super::{parse_args, safe_join, schema_for, Keep, Tool, ToolCtx, ToolOutcome};
const DEFAULT_BASH_TIMEOUT_MS: u64 = 120_000;
#[derive(Deserialize, JsonSchema)]
struct BashArgs {
command: String,
#[allow(dead_code)] description: Option<String>,
workdir: Option<String>,
timeout: Option<u64>,
}
pub(super) struct Bash;
impl Tool for Bash {
fn id(&self) -> &str {
"bash"
}
fn description(&self) -> &str {
"Run a shell command in the working directory and return its combined \
output. Prefer read/write/edit for file work; use bash for builds, \
tests, git, and search."
}
fn parameters(&self) -> Value {
schema_for::<BashArgs>()
}
fn kind(&self) -> ToolKind {
ToolKind::Execute
}
fn mutating(&self) -> bool {
true
}
fn permission_subject(&self, args: &Value) -> Option<String> {
args.get("command").and_then(Value::as_str).map(str::to_owned)
}
fn keep_output(&self) -> Keep {
Keep::HeadAndTail
}
fn execute(&self, args: &Value, ctx: &ToolCtx) -> ToolOutcome {
let a: BashArgs = match parse_args(args) {
Ok(a) => a,
Err(o) => return o,
};
let timeout = a.timeout.filter(|&t| t > 0).unwrap_or(DEFAULT_BASH_TIMEOUT_MS);
run_bash(ctx, &a.command, a.workdir.as_deref(), timeout)
}
}
enum BashEnd {
Exited(std::process::ExitStatus),
TimedOut,
Cancelled,
WaitErr(String),
}
fn run_bash(ctx: &ToolCtx, command: &str, workdir: Option<&str>, timeout_ms: u64) -> ToolOutcome {
let dir = match workdir {
Some(w) => match safe_join(ctx.cwd, w) {
Some(d) => d,
None => return ToolOutcome::err(format!("workdir `{w}` escapes the working directory")),
},
None => ctx.cwd.to_path_buf(),
};
let mut child = match Command::new("sh")
.arg("-c")
.arg(command)
.current_dir(&dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => return ToolOutcome::err(format!("failed to run command: {e}")),
};
let out_h = drain(child.stdout.take());
let err_h = drain(child.stderr.take());
let start = Instant::now();
let limit = Duration::from_millis(timeout_ms);
let end = loop {
match child.try_wait() {
Ok(Some(status)) => break BashEnd::Exited(status),
Ok(None) => {}
Err(e) => break BashEnd::WaitErr(e.to_string()),
}
if ctx.cancel.load(Ordering::SeqCst) {
let _ = child.kill();
let _ = child.wait(); break BashEnd::Cancelled;
}
if start.elapsed() >= limit {
let _ = child.kill();
let _ = child.wait();
break BashEnd::TimedOut;
}
thread::sleep(Duration::from_millis(40));
};
let stdout = out_h.join().unwrap_or_default();
let stderr = err_h.join().unwrap_or_default();
let mut body = stdout;
if !stderr.trim().is_empty() {
if !body.is_empty() && !body.ends_with('\n') {
body.push('\n');
}
body.push_str(&format!("[stderr]\n{stderr}"));
}
match end {
BashEnd::Exited(s) if s.success() => {
ToolOutcome::ok(if body.is_empty() { "(no output)".to_owned() } else { body })
}
BashEnd::Exited(s) => {
let code = s.code().map_or_else(|| "signal".to_owned(), |c| c.to_string());
ToolOutcome::err(format!("(exit {code})\n{body}"))
}
BashEnd::TimedOut => {
ToolOutcome::err(format!("(timed out after {timeout_ms}ms; process killed)\n{body}"))
}
BashEnd::Cancelled => ToolOutcome::err(format!("(cancelled; process killed)\n{body}")),
BashEnd::WaitErr(e) => ToolOutcome::err(format!("(error waiting on command: {e})\n{body}")),
}
}
fn drain<R: Read + Send + 'static>(pipe: Option<R>) -> thread::JoinHandle<String> {
thread::spawn(move || {
let mut s = String::new();
if let Some(mut r) = pipe {
let _ = r.read_to_string(&mut s);
}
s
})
}