Skip to main content

capo_agent/tools/
bash.rs

1#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
2
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use motosan_agent_tool::{Tool, ToolContext, ToolDef, ToolResult};
9use serde_json::{json, Value};
10use tokio::process::Command;
11use tokio::time::timeout;
12
13use crate::tools::ToolCtx;
14
15const MAX_OUTPUT_BYTES: usize = 30 * 1024;
16const DEFAULT_TIMEOUT_MS: u64 = 120_000;
17const MAX_TIMEOUT_MS: u64 = 600_000;
18
19pub struct BashTool {
20    ctx: Arc<ToolCtx>,
21}
22
23impl BashTool {
24    pub fn new(ctx: Arc<ToolCtx>) -> Self {
25        Self { ctx }
26    }
27}
28
29impl Tool for BashTool {
30    fn def(&self) -> ToolDef {
31        ToolDef {
32            name: "bash".to_string(),
33            description: "Execute a shell command. Default timeout 120s, max 600s. Output capped at 30KB per stream.".to_string(),
34            input_schema: json!({
35                "type": "object",
36                "properties": {
37                    "command": { "type": "string", "description": "Shell command to run via `bash -lc`." },
38                    "description": { "type": "string", "description": "Human-readable one-line summary." },
39                    "timeout_ms": { "type": "integer", "description": "Override timeout in milliseconds (max 600000)." }
40                },
41                "required": ["command"]
42            }),
43        }
44    }
45
46    fn call(
47        &self,
48        args: Value,
49        _ctx: &ToolContext,
50    ) -> Pin<Box<dyn Future<Output = ToolResult> + Send + '_>> {
51        let ctx = Arc::clone(&self.ctx);
52        Box::pin(async move {
53            let command = match args.get("command").and_then(|v| v.as_str()) {
54                Some(c) if !c.is_empty() => c.to_string(),
55                _ => return ToolResult::error("missing or empty 'command' argument"),
56            };
57            let requested_timeout = args
58                .get("timeout_ms")
59                .and_then(|v| v.as_u64())
60                .unwrap_or(DEFAULT_TIMEOUT_MS)
61                .min(MAX_TIMEOUT_MS);
62
63            let started = Instant::now();
64            let mut cmd = Command::new("bash");
65            cmd.arg("-lc")
66                .arg(&command)
67                .current_dir(&ctx.cwd)
68                .kill_on_drop(true);
69
70            let fut = cmd.output();
71            let output = tokio::select! {
72                biased;
73                _ = ctx.cancel_token.cancelled() => {
74                    tracing::debug!(
75                        target: "capo::bash",
76                        "cancelled; up to {} bytes of queued output may be discarded",
77                        MAX_OUTPUT_BYTES * 2,
78                    );
79                    return ToolResult::error("command cancelled by user");
80                }
81                res = timeout(Duration::from_millis(requested_timeout), fut) => match res {
82                    Ok(Ok(out)) => out,
83                    Ok(Err(e)) => return ToolResult::error(format!("failed to spawn bash: {e}")),
84                    Err(_) => return ToolResult::error(format!("command timed out after {requested_timeout}ms")),
85                },
86            };
87
88            let stdout = truncate(output.stdout, MAX_OUTPUT_BYTES);
89            let stderr = truncate(output.stderr, MAX_OUTPUT_BYTES);
90            let exit = output.status.code().unwrap_or(-1);
91            let duration_ms = started.elapsed().as_millis() as u64;
92
93            let body = format!(
94                "exit={exit} duration_ms={duration_ms}\n--- stdout ---\n{}\n--- stderr ---\n{}",
95                String::from_utf8_lossy(&stdout),
96                String::from_utf8_lossy(&stderr),
97            );
98            ToolResult::text(body)
99        })
100    }
101}
102
103fn truncate(buf: Vec<u8>, max: usize) -> Vec<u8> {
104    if buf.len() <= max {
105        return buf;
106    }
107
108    let text = String::from_utf8_lossy(&buf);
109    if text.len() <= max {
110        return text.into_owned().into_bytes();
111    }
112
113    let keep = max / 2;
114    let start_end = floor_char_boundary(&text, keep);
115    let end_start = ceil_char_boundary(&text, text.len().saturating_sub(keep));
116    let omitted = text
117        .len()
118        .saturating_sub(start_end + (text.len().saturating_sub(end_start)));
119
120    let mut out = String::with_capacity(max + 64);
121    out.push_str(&text[..start_end]);
122    out.push_str(&format!("\n... ({omitted} bytes truncated) ...\n"));
123    out.push_str(&text[end_start..]);
124    out.into_bytes()
125}
126
127fn floor_char_boundary(text: &str, limit: usize) -> usize {
128    if limit >= text.len() {
129        return text.len();
130    }
131
132    let mut boundary = 0;
133    for (idx, _) in text.char_indices() {
134        if idx > limit {
135            break;
136        }
137        boundary = idx;
138    }
139    boundary
140}
141
142fn ceil_char_boundary(text: &str, target: usize) -> usize {
143    if target >= text.len() {
144        return text.len();
145    }
146
147    for (idx, _) in text.char_indices() {
148        if idx >= target {
149            return idx;
150        }
151    }
152    text.len()
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::permissions::NoOpPermissionGate;
159    use tempfile::tempdir;
160    use tokio::sync::mpsc;
161
162    fn test_ctx(cwd: &std::path::Path) -> Arc<ToolCtx> {
163        let (tx, _rx) = mpsc::channel(8);
164        Arc::new(ToolCtx::new(cwd, Arc::new(NoOpPermissionGate), tx))
165    }
166
167    #[tokio::test]
168    async fn runs_simple_command_and_returns_exit_zero() {
169        let dir = tempdir().expect("tempdir");
170        let tool = BashTool::new(test_ctx(dir.path()));
171        let result = tool
172            .call(json!({ "command": "echo hello" }), &ToolContext::default())
173            .await;
174        let debug = format!("{result:?}");
175        assert!(debug.contains("exit=0"), "missing exit=0: {debug}");
176        assert!(debug.contains("hello"), "missing stdout hello: {debug}");
177    }
178
179    #[tokio::test]
180    async fn captures_stderr_and_nonzero_exit() {
181        let dir = tempdir().expect("tempdir");
182        let tool = BashTool::new(test_ctx(dir.path()));
183        let result = tool
184            .call(
185                json!({ "command": "echo problem >&2; exit 3" }),
186                &ToolContext::default(),
187            )
188            .await;
189        let debug = format!("{result:?}");
190        assert!(debug.contains("exit=3"), "bad exit: {debug}");
191        assert!(debug.contains("problem"), "missing stderr: {debug}");
192    }
193
194    #[tokio::test]
195    async fn timeout_fires_and_reports_error() {
196        let dir = tempdir().expect("tempdir");
197        let tool = BashTool::new(test_ctx(dir.path()));
198        let result = tool
199            .call(
200                json!({ "command": "sleep 5", "timeout_ms": 200 }),
201                &ToolContext::default(),
202            )
203            .await;
204        let debug = format!("{result:?}");
205        assert!(debug.contains("timed out"), "got: {debug}");
206    }
207
208    #[tokio::test]
209    async fn truncates_large_stdout() {
210        let dir = tempdir().expect("tempdir");
211        let tool = BashTool::new(test_ctx(dir.path()));
212        let result = tool
213            .call(
214                json!({ "command": "yes | head -c 40000" }),
215                &ToolContext::default(),
216            )
217            .await;
218        let debug = format!("{result:?}");
219        assert!(
220            debug.contains("bytes truncated"),
221            "expected truncation: {debug}"
222        );
223        assert!(debug.contains("exit=0"), "pipe exit should be 0: {debug}");
224    }
225
226    #[tokio::test]
227    async fn cancellation_token_aborts_command() {
228        let dir = tempdir().expect("tempdir");
229        let (tx, _rx) = mpsc::channel(8);
230        let ctx = Arc::new(ToolCtx::new(dir.path(), Arc::new(NoOpPermissionGate), tx));
231        let cancel = ctx.cancel_token.clone();
232        let tool = BashTool::new(Arc::clone(&ctx));
233
234        let handle = tokio::spawn(async move {
235            tool.call(json!({ "command": "sleep 10" }), &ToolContext::default())
236                .await
237        });
238        tokio::time::sleep(Duration::from_millis(100)).await;
239        cancel.cancel();
240
241        let result = tokio::time::timeout(Duration::from_secs(2), handle)
242            .await
243            .expect("timeout")
244            .expect("join");
245        let debug = format!("{result:?}");
246        assert!(debug.to_lowercase().contains("cancel"), "got: {debug}");
247    }
248
249    #[test]
250    fn truncate_preserves_utf8_boundaries() {
251        let input = "🙂中文é🙂中文é🙂中文é".repeat(200).into_bytes();
252        let truncated = truncate(input, 128);
253        let text = String::from_utf8(truncated).expect("valid utf8 after truncate");
254        assert!(!text.contains('�'), "replacement char leaked: {text}");
255        assert!(
256            text.contains("bytes truncated"),
257            "missing truncation marker: {text}"
258        );
259    }
260}