#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, Instant};
use motosan_agent_tool::{Tool, ToolContext, ToolDef, ToolResult};
use serde_json::{json, Value};
use tokio::process::Command;
use tokio::time::timeout;
use crate::tools::ToolCtx;
const MAX_OUTPUT_BYTES: usize = 30 * 1024;
const DEFAULT_TIMEOUT_MS: u64 = 120_000;
const MAX_TIMEOUT_MS: u64 = 600_000;
pub struct BashTool {
ctx: Arc<ToolCtx>,
}
impl BashTool {
pub fn new(ctx: Arc<ToolCtx>) -> Self {
Self { ctx }
}
}
impl Tool for BashTool {
fn def(&self) -> ToolDef {
ToolDef {
name: "bash".to_string(),
description: "Execute a shell command. Default timeout 120s, max 600s. Output capped at 30KB per stream.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"command": { "type": "string", "description": "Shell command to run via `bash -lc`." },
"description": { "type": "string", "description": "Human-readable one-line summary." },
"timeout_ms": { "type": "integer", "description": "Override timeout in milliseconds (max 600000)." }
},
"required": ["command"]
}),
}
}
fn call(
&self,
args: Value,
_ctx: &ToolContext,
) -> Pin<Box<dyn Future<Output = ToolResult> + Send + '_>> {
let ctx = Arc::clone(&self.ctx);
Box::pin(async move {
let command = match args.get("command").and_then(|v| v.as_str()) {
Some(c) if !c.is_empty() => c.to_string(),
_ => return ToolResult::error("missing or empty 'command' argument"),
};
let requested_timeout = args
.get("timeout_ms")
.and_then(|v| v.as_u64())
.unwrap_or(DEFAULT_TIMEOUT_MS)
.min(MAX_TIMEOUT_MS);
let started = Instant::now();
let mut cmd = Command::new("bash");
cmd.arg("-lc")
.arg(&command)
.current_dir(&ctx.cwd)
.kill_on_drop(true);
let fut = cmd.output();
let output = tokio::select! {
biased;
_ = ctx.cancel_token.cancelled() => {
tracing::debug!(
target: "capo::bash",
"cancelled; up to {} bytes of queued output may be discarded",
MAX_OUTPUT_BYTES * 2,
);
return ToolResult::error("command cancelled by user");
}
res = timeout(Duration::from_millis(requested_timeout), fut) => match res {
Ok(Ok(out)) => out,
Ok(Err(e)) => return ToolResult::error(format!("failed to spawn bash: {e}")),
Err(_) => return ToolResult::error(format!("command timed out after {requested_timeout}ms")),
},
};
let stdout = truncate(output.stdout, MAX_OUTPUT_BYTES);
let stderr = truncate(output.stderr, MAX_OUTPUT_BYTES);
let exit = output.status.code().unwrap_or(-1);
let duration_ms = started.elapsed().as_millis() as u64;
let body = format!(
"exit={exit} duration_ms={duration_ms}\n--- stdout ---\n{}\n--- stderr ---\n{}",
String::from_utf8_lossy(&stdout),
String::from_utf8_lossy(&stderr),
);
ToolResult::text(body)
})
}
}
fn truncate(buf: Vec<u8>, max: usize) -> Vec<u8> {
if buf.len() <= max {
return buf;
}
let text = String::from_utf8_lossy(&buf);
if text.len() <= max {
return text.into_owned().into_bytes();
}
let keep = max / 2;
let start_end = floor_char_boundary(&text, keep);
let end_start = ceil_char_boundary(&text, text.len().saturating_sub(keep));
let omitted = text
.len()
.saturating_sub(start_end + (text.len().saturating_sub(end_start)));
let mut out = String::with_capacity(max + 64);
out.push_str(&text[..start_end]);
out.push_str(&format!("\n... ({omitted} bytes truncated) ...\n"));
out.push_str(&text[end_start..]);
out.into_bytes()
}
fn floor_char_boundary(text: &str, limit: usize) -> usize {
if limit >= text.len() {
return text.len();
}
let mut boundary = 0;
for (idx, _) in text.char_indices() {
if idx > limit {
break;
}
boundary = idx;
}
boundary
}
fn ceil_char_boundary(text: &str, target: usize) -> usize {
if target >= text.len() {
return text.len();
}
for (idx, _) in text.char_indices() {
if idx >= target {
return idx;
}
}
text.len()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permissions::NoOpPermissionGate;
use tempfile::tempdir;
use tokio::sync::mpsc;
fn test_ctx(cwd: &std::path::Path) -> Arc<ToolCtx> {
let (tx, _rx) = mpsc::channel(8);
Arc::new(ToolCtx::new(cwd, Arc::new(NoOpPermissionGate), tx))
}
#[tokio::test]
async fn runs_simple_command_and_returns_exit_zero() {
let dir = tempdir().expect("tempdir");
let tool = BashTool::new(test_ctx(dir.path()));
let result = tool
.call(json!({ "command": "echo hello" }), &ToolContext::default())
.await;
let debug = format!("{result:?}");
assert!(debug.contains("exit=0"), "missing exit=0: {debug}");
assert!(debug.contains("hello"), "missing stdout hello: {debug}");
}
#[tokio::test]
async fn captures_stderr_and_nonzero_exit() {
let dir = tempdir().expect("tempdir");
let tool = BashTool::new(test_ctx(dir.path()));
let result = tool
.call(
json!({ "command": "echo problem >&2; exit 3" }),
&ToolContext::default(),
)
.await;
let debug = format!("{result:?}");
assert!(debug.contains("exit=3"), "bad exit: {debug}");
assert!(debug.contains("problem"), "missing stderr: {debug}");
}
#[tokio::test]
async fn timeout_fires_and_reports_error() {
let dir = tempdir().expect("tempdir");
let tool = BashTool::new(test_ctx(dir.path()));
let result = tool
.call(
json!({ "command": "sleep 5", "timeout_ms": 200 }),
&ToolContext::default(),
)
.await;
let debug = format!("{result:?}");
assert!(debug.contains("timed out"), "got: {debug}");
}
#[tokio::test]
async fn truncates_large_stdout() {
let dir = tempdir().expect("tempdir");
let tool = BashTool::new(test_ctx(dir.path()));
let result = tool
.call(
json!({ "command": "yes | head -c 40000" }),
&ToolContext::default(),
)
.await;
let debug = format!("{result:?}");
assert!(
debug.contains("bytes truncated"),
"expected truncation: {debug}"
);
assert!(debug.contains("exit=0"), "pipe exit should be 0: {debug}");
}
#[tokio::test]
async fn cancellation_token_aborts_command() {
let dir = tempdir().expect("tempdir");
let (tx, _rx) = mpsc::channel(8);
let ctx = Arc::new(ToolCtx::new(dir.path(), Arc::new(NoOpPermissionGate), tx));
let cancel = ctx.cancel_token.clone();
let tool = BashTool::new(Arc::clone(&ctx));
let handle = tokio::spawn(async move {
tool.call(json!({ "command": "sleep 10" }), &ToolContext::default())
.await
});
tokio::time::sleep(Duration::from_millis(100)).await;
cancel.cancel();
let result = tokio::time::timeout(Duration::from_secs(2), handle)
.await
.expect("timeout")
.expect("join");
let debug = format!("{result:?}");
assert!(debug.to_lowercase().contains("cancel"), "got: {debug}");
}
#[test]
fn truncate_preserves_utf8_boundaries() {
let input = "ðŸ™‚ä¸æ–‡Ã©ðŸ™‚䏿–‡Ã©ðŸ™‚䏿–‡Ã©".repeat(200).into_bytes();
let truncated = truncate(input, 128);
let text = String::from_utf8(truncated).expect("valid utf8 after truncate");
assert!(!text.contains('�'), "replacement char leaked: {text}");
assert!(
text.contains("bytes truncated"),
"missing truncation marker: {text}"
);
}
}