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}