use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use serde_json::{json, Value};
use tokio::io::AsyncReadExt;
use tokio::process::Command;
use tokio::time::timeout;
use tracing::warn;
use crate::error::{Error, Result};
use crate::tools::{Tool, ToolContext};
const OUTPUT_CAP: usize = 256 * 1024;
const DEFAULT_TIMEOUT_SEC: u64 = 30;
const MAX_TIMEOUT_SEC: u64 = 600;
const DRAIN_TIMEOUT_SEC: u64 = 2;
const REAP_TIMEOUT_SEC: u64 = 5;
#[cfg(unix)]
static LIVE_GROUPS: std::sync::Mutex<Vec<u32>> = std::sync::Mutex::new(Vec::new());
pub async fn kill_live_process_groups() {
#[cfg(unix)]
{
let pids: Vec<u32> = std::mem::take(&mut *LIVE_GROUPS.lock().unwrap());
for pid in pids {
kill_group(Some(pid)).await;
}
}
}
pub struct RunCommand;
crate::tool_params! {
struct Args: serde {
command: req_str = "Shell command line.",
working_dir: opt_str = "Optional CWD for the command.",
timeout_sec: opt_u64 min 1 max 600 = "Timeout in seconds (default 30, max 600).",
}
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl Tool for RunCommand {
fn name(&self) -> &str {
"run_command"
}
fn description(&self) -> &str {
"Execute a shell command. Returns { stdout, stderr, exit_code, timed_out }. \
Each stream is capped at 256 KiB; default timeout 30 s, max 600 s. \
Use sparingly — gate with a policy."
}
fn input_schema(&self) -> Value {
Args::schema()
}
async fn execute(&self, args: Value, _ctx: Option<Arc<ToolContext>>) -> Result<Value> {
let args: Args = serde_json::from_value(args)
.map_err(|e| Error::bad_args("run_command", format!("run_command args: {e}")))?;
let timeout_dur = Duration::from_secs(
args.timeout_sec
.unwrap_or(DEFAULT_TIMEOUT_SEC)
.min(MAX_TIMEOUT_SEC),
);
let mut cmd = if cfg!(windows) {
let mut c = Command::new("cmd");
c.args(["/C", &args.command]);
c
} else {
let mut c = Command::new("sh");
c.args(["-c", &args.command]);
c
};
if let Some(dir) = &args.working_dir {
cmd.current_dir(dir);
}
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
#[cfg(unix)]
cmd.process_group(0);
let mut child = cmd
.spawn()
.map_err(|e| Error::other(format!("spawn: {e}")))?;
let mut stdout = child.stdout.take().expect("stdout pipe present");
let mut stderr = child.stderr.take().expect("stderr pipe present");
let cap_out: Capture = Arc::new(std::sync::Mutex::new((Vec::new(), false)));
let cap_err: Capture = Arc::new(std::sync::Mutex::new((Vec::new(), false)));
let stdout_handle = tokio::spawn({
let cap = cap_out.clone();
async move { read_capped_into(&mut stdout, cap).await }
});
let stderr_handle = tokio::spawn({
let cap = cap_err.clone();
async move { read_capped_into(&mut stderr, cap).await }
});
let _child_pid = child.id();
#[cfg(unix)]
if let Some(pid) = _child_pid {
LIVE_GROUPS.lock().unwrap().push(pid);
}
let wait = child.wait();
let result = timeout(timeout_dur, wait).await;
let (exit_code, timed_out) = match result {
Ok(Ok(status)) => (status.code(), false),
Ok(Err(e)) => {
warn!(?e, "child wait failed");
kill_group(child.id()).await;
let _ = child.start_kill();
(None, false)
}
Err(_) => {
kill_group(child.id()).await;
if let Err(e) = child.start_kill() {
warn!(?e, "kill after timeout failed");
}
let _ = timeout(Duration::from_secs(REAP_TIMEOUT_SEC), child.wait()).await;
(None, true)
}
};
#[cfg(unix)]
if let Some(pid) = _child_pid {
LIVE_GROUPS.lock().unwrap().retain(|p| *p != pid);
}
let out_gave_up = drain_bounded(stdout_handle).await;
let err_gave_up = drain_bounded(stderr_handle).await;
let (stdout, stdout_truncated) = std::mem::take(&mut *cap_out.lock().unwrap());
let (stderr, stderr_truncated) = std::mem::take(&mut *cap_err.lock().unwrap());
Ok(json!({
"exit_code": exit_code,
"timed_out": timed_out,
"stdout": String::from_utf8_lossy(&stdout).into_owned(),
"stderr": String::from_utf8_lossy(&stderr).into_owned(),
"stdout_truncated": stdout_truncated,
"stderr_truncated": stderr_truncated,
"capture_timed_out": out_gave_up || err_gave_up,
}))
}
}
#[cfg(unix)]
async fn kill_group(pid: Option<u32>) {
if let Some(pid) = pid {
let _ = Command::new("kill")
.args(["-9", &format!("-{pid}")])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
}
}
#[cfg(not(unix))]
async fn kill_group(_pid: Option<u32>) {}
type Capture = Arc<std::sync::Mutex<(Vec<u8>, bool)>>;
async fn drain_bounded(handle: tokio::task::JoinHandle<()>) -> bool {
let abort = handle.abort_handle();
match timeout(Duration::from_secs(DRAIN_TIMEOUT_SEC), handle).await {
Ok(_) => false,
Err(_) => {
abort.abort();
warn!("pipe drain timed out — a survivor still holds the write end; keeping captured bytes");
true
}
}
}
async fn read_capped_into(reader: &mut (impl tokio::io::AsyncRead + Unpin), cap: Capture) {
let mut scratch = [0u8; 8 * 1024];
loop {
match reader.read(&mut scratch).await {
Ok(0) => break,
Ok(n) => {
let cap_hit = {
let mut guard = cap.lock().unwrap();
let remaining = OUTPUT_CAP.saturating_sub(guard.0.len());
if remaining == 0 {
guard.1 = true;
true
} else {
let take = remaining.min(n);
guard.0.extend_from_slice(&scratch[..take]);
if take < n {
guard.1 = true;
}
false
}
};
if cap_hit {
while let Ok(n) = reader.read(&mut scratch).await {
if n == 0 {
break;
}
}
break;
}
}
Err(_) => break,
}
}
}
#[cfg(test)]
mod schema_tests {
use super::Args;
use serde_json::json;
#[test]
fn schema_is_byte_identical_to_the_frozen_original() {
let frozen = json!({
"type": "object",
"properties": {
"command": { "type": "string", "description": "Shell command line." },
"working_dir": { "type": "string", "description": "Optional CWD for the command." },
"timeout_sec": { "type": "integer", "minimum": 1, "maximum": 600, "description": "Timeout in seconds (default 30, max 600)." }
},
"required": ["command"]
});
assert_eq!(Args::schema().to_string(), frozen.to_string());
}
#[test]
fn serde_parse_matches_the_old_derive() {
let a: Args = serde_json::from_value(json!({"command": "echo hi"})).unwrap();
assert_eq!((a.command.as_str(), a.working_dir, a.timeout_sec), ("echo hi", None, None));
let a: Args =
serde_json::from_value(json!({"command": "ls", "timeout_sec": 5, "working_dir": "/tmp"}))
.unwrap();
assert_eq!((a.timeout_sec, a.working_dir.as_deref()), (Some(5), Some("/tmp")));
assert!(serde_json::from_value::<Args>(json!({})).is_err());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn runs_simple_echo() {
let tool = RunCommand;
let cmd = if cfg!(windows) {
"echo hello"
} else {
"printf 'hello'"
};
let out = tool.execute(json!({"command": cmd}), None).await.unwrap();
let stdout = out["stdout"].as_str().unwrap();
assert!(stdout.contains("hello"), "stdout was: {stdout:?}");
assert_eq!(out["exit_code"].as_i64(), Some(0));
assert_eq!(out["timed_out"].as_bool(), Some(false));
}
#[tokio::test]
async fn surfaces_nonzero_exit_code() {
let tool = RunCommand;
let cmd = if cfg!(windows) { "exit /B 7" } else { "exit 7" };
let out = tool.execute(json!({"command": cmd}), None).await.unwrap();
assert_eq!(out["exit_code"].as_i64(), Some(7));
}
#[cfg(unix)]
#[tokio::test]
async fn background_grandchild_neither_hangs_nor_loses_output() {
let tool = RunCommand;
let t0 = std::time::Instant::now();
let out = tool
.execute(json!({"command": "sleep 30 & echo hi"}), None)
.await
.unwrap();
assert!(t0.elapsed() < Duration::from_secs(10), "drain must be bounded");
assert!(out["stdout"].as_str().unwrap().contains("hi"), "bytes kept: {out:?}");
assert_eq!(out["exit_code"].as_i64(), Some(0));
assert_eq!(out["capture_timed_out"].as_bool(), Some(true));
}
#[tokio::test]
async fn enforces_timeout() {
let tool = RunCommand;
let cmd = if cfg!(windows) {
"ping -n 5 127.0.0.1 >NUL"
} else {
"sleep 5"
};
let out = tool
.execute(json!({"command": cmd, "timeout_sec": 1}), None)
.await
.unwrap();
assert_eq!(out["timed_out"].as_bool(), Some(true));
}
}