1use async_trait::async_trait;
7use serde_json::{json, Value};
8use std::path::PathBuf;
9use std::process::Stdio;
10use std::time::Duration;
11use tokio::io::AsyncReadExt;
12use tokio::process::Command;
13
14use super::resolve_within;
15use super::Tool;
16use crate::acp::ToolKind;
17use crate::error::{Error, Result};
18use crate::llm::ToolSpec;
19
20const MAX_OUTPUT_BYTES_HARD_CAP: usize = 2 * 1024 * 1024;
24
25#[derive(Debug, Clone)]
26pub struct RunShell {
27 pub root: PathBuf,
28 pub timeout: Duration,
29 pub max_output_bytes: usize,
30}
31
32impl RunShell {
33 pub fn new(root: impl Into<PathBuf>) -> Self {
34 Self {
35 root: root.into(),
36 timeout: Duration::from_secs(300),
37 max_output_bytes: 128 * 1024,
38 }
39 }
40
41 pub fn with_timeout(mut self, t: Duration) -> Self {
42 self.timeout = t;
43 self
44 }
45
46 pub fn with_max_output_bytes(mut self, n: usize) -> Self {
52 self.max_output_bytes = n.min(MAX_OUTPUT_BYTES_HARD_CAP);
53 self
54 }
55}
56
57#[async_trait]
58impl Tool for RunShell {
59 fn spec(&self) -> ToolSpec {
60 ToolSpec {
61 name: "Bash".into(),
62 description:
63 "Run a shell command (sh -c) from the workspace root, or from an optional subdirectory inside it via `cwd`."
64 .into(),
65 parameters: json!({
66 "type": "object",
67 "properties": {
68 "command": {
69 "type": "string",
70 "description": "Command line to execute via sh -c"
71 },
72 "cwd": {
73 "type": "string",
74 "description": "Optional subdirectory (relative to workspace root) to run the command in. Must stay inside the workspace."
75 },
76 "env": {
77 "type": "object",
78 "description": "Optional extra env vars set for this command only. Values must be strings; non-string values are rejected. These add to (or override) the inherited env.",
79 "additionalProperties": {
80 "type": "string"
81 }
82 },
83 "max_output_bytes": {
84 "type": "integer",
85 "minimum": 0,
86 "description": "Per-stream (stdout / stderr) byte cap. Defaults to the tool's configured limit (128 KiB). Pass a larger value (≤ 2 MiB) when you need the full output of a verbose command — e.g. `cargo build` error logs — and the default would truncate the relevant lines. The cap is applied per-stream; both stdout and stderr are bounded independently."
87 }
88 },
89 "required": ["command"]
90 }),
91 }
92 }
93
94 fn kind(&self) -> ToolKind {
95 ToolKind::Execute
96 }
97
98 async fn execute(&self, args: Value) -> Result<String> {
99 let command = args["command"].as_str().ok_or_else(|| Error::BadToolArgs {
100 name: "Bash".into(),
101 message: "missing `command`".into(),
102 })?;
103
104 let cwd = if let Some(rel) = args.get("cwd").and_then(|v| v.as_str()) {
106 resolve_within(&self.root, rel).map_err(|e| Error::BadToolArgs {
107 name: "Bash".into(),
108 message: format!("cwd: {e}"),
109 })?
110 } else {
111 self.root.clone()
112 };
113
114 let mut cmd = Command::new("/bin/sh");
115 cmd.arg("-c").arg(command);
116 cmd.current_dir(&cwd);
117 cmd.stdout(Stdio::piped());
118 cmd.stderr(Stdio::piped());
119 cmd.kill_on_drop(true);
127
128 if let Some(env_map) = args.get("env").and_then(|v| v.as_object()) {
130 for (key, val) in env_map {
131 let val_str = val.as_str().ok_or_else(|| Error::BadToolArgs {
132 name: "Bash".to_string(),
133 message: format!("env value for `{key}` must be a string, got {:?}", val),
134 })?;
135 cmd.env(key, val_str);
136 }
137 }
138
139 let mut child = cmd.spawn().map_err(|e| Error::Tool {
140 name: "Bash".into(),
141 call_id: None,
142 message: format!("spawn failed: {e}"),
143 })?;
144
145 let mut stdout = child.stdout.take().ok_or_else(|| Error::Tool {
146 name: "Bash".into(),
147 call_id: None,
148 message: "stdout was not piped".into(),
149 })?;
150 let mut stderr = child.stderr.take().ok_or_else(|| Error::Tool {
151 name: "Bash".into(),
152 call_id: None,
153 message: "stderr was not piped".into(),
154 })?;
155
156 let max = match args.get("max_output_bytes").and_then(|v| v.as_u64()) {
162 Some(n) => (n as usize).min(MAX_OUTPUT_BYTES_HARD_CAP),
163 None => self.max_output_bytes,
164 };
165 let stdout_task = tokio::spawn(async move { read_capped(&mut stdout, max).await });
166 let stderr_task = tokio::spawn(async move { read_capped(&mut stderr, max).await });
167
168 let wait = child.wait();
169 let status = match tokio::time::timeout(self.timeout, wait).await {
170 Ok(s) => s.map_err(|e| Error::Tool {
171 name: "Bash".into(),
172 call_id: None,
173 message: format!("wait failed: {e}"),
174 })?,
175 Err(_) => {
176 let _ = child.start_kill();
183 let _ = child.wait().await;
184 let _ = stdout_task.await;
191 let _ = stderr_task.await;
192 return Err(Error::Tool {
193 name: "Bash".into(),
194 call_id: None,
195 message: format!("command timed out after {:?}", self.timeout),
196 });
197 }
198 };
199
200 let out = stdout_task.await.unwrap_or_default();
201 let err = stderr_task.await.unwrap_or_default();
202 let code = status
203 .code()
204 .map(|c| c.to_string())
205 .unwrap_or_else(|| "signal".into());
206
207 Ok(format!(
208 "exit: {code}\n--- stdout ---\n{out}\n--- stderr ---\n{err}"
209 ))
210 }
211}
212
213async fn read_capped<R: AsyncReadExt + Unpin>(reader: &mut R, max: usize) -> String {
214 let mut buf = Vec::with_capacity(8 * 1024);
215 let mut tmp = [0u8; 8 * 1024];
216 loop {
217 match reader.read(&mut tmp).await {
218 Ok(0) => break,
219 Ok(n) => {
220 if buf.len() + n > max {
221 let take = max.saturating_sub(buf.len());
222 buf.extend_from_slice(&tmp[..take]);
223 buf.extend_from_slice(b"\n... [output truncated]");
224 let _ = tokio::io::copy(reader, &mut tokio::io::sink()).await;
225 break;
226 }
227 buf.extend_from_slice(&tmp[..n]);
228 }
229 Err(_) => break,
230 }
231 }
232 String::from_utf8_lossy(&buf).into_owned()
233}
234
235#[cfg(test)]
236#[cfg(not(target_os = "windows"))]
237mod tests {
238 use super::*;
239 use tempfile::TempDir;
240
241 #[tokio::test]
242 async fn runs_echo_in_workspace() {
243 let tmp = TempDir::new().unwrap();
244 let out = RunShell::new(tmp.path())
245 .execute(json!({"command": "echo hello && pwd"}))
246 .await
247 .unwrap();
248 assert!(out.contains("exit: 0"));
249 assert!(out.contains("hello"));
250 }
251
252 #[tokio::test]
253 async fn captures_nonzero_status() {
254 let tmp = TempDir::new().unwrap();
255 let out = RunShell::new(tmp.path())
256 .execute(json!({"command": "exit 7"}))
257 .await
258 .unwrap();
259 assert!(out.contains("exit: 7"));
260 }
261
262 #[tokio::test]
263 async fn enforces_timeout() {
264 let tmp = TempDir::new().unwrap();
265 let tool = RunShell::new(tmp.path()).with_timeout(Duration::from_millis(150));
266 let err = tool
267 .execute(json!({"command": "sleep 5"}))
268 .await
269 .unwrap_err();
270 assert!(matches!(err, Error::Tool { .. }));
271 }
272
273 #[tokio::test]
281 async fn per_call_max_output_bytes_can_raise_within_hard_cap() {
282 let tmp = TempDir::new().unwrap();
283 let tool = RunShell::new(tmp.path());
284 let wanted_bytes = 200 * 1024;
287 let out = tool
288 .execute(json!({
289 "command": format!("yes x | head -c {}", wanted_bytes),
290 "max_output_bytes": wanted_bytes,
291 }))
292 .await
293 .unwrap();
294 assert!(
295 !out.contains("[output truncated]"),
296 "output was truncated despite the per-call cap: {out}"
297 );
298 }
299
300 #[tokio::test]
301 async fn per_call_max_output_bytes_clamped_to_hard_cap() {
302 let tmp = TempDir::new().unwrap();
303 let tool = RunShell::new(tmp.path());
304 let out = tool
307 .execute(json!({
308 "command": "yes x | head -c 3145728",
309 "max_output_bytes": 4_194_304u32,
310 }))
311 .await
312 .unwrap();
313 assert!(
314 out.contains("[output truncated]"),
315 "hard cap was not enforced: {out}"
316 );
317 }
318
319 #[tokio::test]
328 async fn timeout_kills_child_process() {
329 let tmp = TempDir::new().unwrap();
330 let marker = tmp.path().join("child.pid");
331 let marker_str = marker.to_string_lossy().into_owned();
332 let command = format!("echo $$ > {marker_str} && exec sleep 30");
335 let tool = RunShell::new(tmp.path()).with_timeout(Duration::from_millis(150));
336 let err = tool
337 .execute(json!({ "command": command }))
338 .await
339 .unwrap_err();
340 assert!(matches!(err, Error::Tool { .. }));
341
342 let pid_str = std::fs::read_to_string(&marker)
343 .expect("child should have written its PID before exec");
344 let pid: i32 = pid_str
345 .trim()
346 .parse()
347 .expect("PID file should contain a number");
348
349 let mut dead = false;
353 for _ in 0..50 {
354 let probe = std::process::Command::new("kill")
355 .arg("-0")
356 .arg(pid.to_string())
357 .output()
358 .expect("kill -0 probe should spawn");
359 if !probe.status.success() {
360 dead = true;
361 break;
362 }
363 tokio::time::sleep(Duration::from_millis(100)).await;
364 }
365 assert!(
366 dead,
367 "timed-out child PID {pid} still alive after 5s — orphan"
368 );
369 }
370
371 #[tokio::test]
383 async fn shell_timeout_drains_reader_tasks() {
384 let tmp = TempDir::new().unwrap();
385 let tool = RunShell::new(tmp.path()).with_timeout(Duration::from_millis(200));
386 let result = tokio::time::timeout(
387 Duration::from_secs(5),
388 tool.execute(json!({"command": "exec sleep 30"})),
389 )
390 .await;
391 let err = match result {
392 Ok(r) => r.expect_err("expected the timed-out command to fail"),
393 Err(_) => panic!("execute did not return within 5s — reader tasks not drained"),
394 };
395 match err {
396 Error::Tool { message, .. } => assert!(
397 message.contains("timed out"),
398 "expected a timeout error, got: {message}"
399 ),
400 other => panic!("expected Tool timeout error, got: {other}"),
401 }
402 }
403
404 #[tokio::test]
405 async fn runs_in_subdir_when_cwd_given() {
406 let tmp = TempDir::new().unwrap();
407 let sub = tmp.path().join("sub");
409 std::fs::create_dir(&sub).unwrap();
410 std::fs::write(sub.join("marker.txt"), "content").unwrap();
411
412 let out = RunShell::new(tmp.path())
413 .execute(json!({"command": "ls", "cwd": "sub"}))
414 .await
415 .unwrap();
416
417 assert!(out.contains("exit: 0"));
418 assert!(out.contains("marker.txt"));
419 }
420
421 #[tokio::test]
422 async fn rejects_cwd_outside_workspace() {
423 let tmp = TempDir::new().unwrap();
424 let err = RunShell::new(tmp.path())
425 .execute(json!({"command": "echo hello", "cwd": "../escape"}))
426 .await
427 .unwrap_err();
428
429 assert!(matches!(err, Error::BadToolArgs { ref name, .. } if name == "Bash"));
430 let err_msg = format!("{err}");
431 assert!(err_msg.contains("cwd"));
432 }
433
434 #[tokio::test]
435 async fn accepts_dot_cwd_as_root() {
436 let tmp = TempDir::new().unwrap();
437 let out = RunShell::new(tmp.path())
438 .execute(json!({"command": "pwd", "cwd": "."}))
439 .await
440 .unwrap();
441
442 assert!(out.contains("exit: 0"));
443 assert!(out.contains("--- stdout ---"));
445 }
446
447 #[tokio::test]
448 async fn existing_no_cwd_call_still_works() {
449 let tmp = TempDir::new().unwrap();
450 let out = RunShell::new(tmp.path())
451 .execute(json!({"command": "echo hello"}))
452 .await
453 .unwrap();
454
455 assert!(out.contains("exit: 0"));
456 assert!(out.contains("hello"));
457 }
458
459 #[test]
462 fn with_timeout_stores_value() {
463 let tmp = TempDir::new().unwrap();
465 let d = Duration::from_millis(777);
466 let tool = RunShell::new(tmp.path()).with_timeout(d);
467 assert_eq!(tool.timeout, d, "with_timeout must persist the duration");
468 }
469
470 #[test]
471 fn with_max_output_bytes_clamps_to_hard_cap() {
472 let tmp = TempDir::new().unwrap();
474 let tool = RunShell::new(tmp.path()).with_max_output_bytes(MAX_OUTPUT_BYTES_HARD_CAP * 4);
475 assert_eq!(
476 tool.max_output_bytes, MAX_OUTPUT_BYTES_HARD_CAP,
477 "with_max_output_bytes must clamp at the hard cap"
478 );
479 }
480
481 #[test]
482 fn with_max_output_bytes_within_cap_is_preserved() {
483 let tmp = TempDir::new().unwrap();
485 let tool = RunShell::new(tmp.path()).with_max_output_bytes(64 * 1024);
486 assert_eq!(
487 tool.max_output_bytes,
488 64 * 1024,
489 "with_max_output_bytes must store values within the hard cap unchanged"
490 );
491 }
492
493 #[tokio::test]
495 async fn env_overrides_and_errors() {
496 let tmp = TempDir::new().unwrap();
497 let tool = RunShell::new(tmp.path());
498
499 let out = tool
501 .execute(json!({"command": "echo $RECURSIVE_TEST_VAR", "env": {"RECURSIVE_TEST_VAR": "hello"}}))
502 .await
503 .unwrap();
504 assert!(out.contains("exit: 0"));
505 assert!(out.contains("hello"));
506
507 let err = tool
509 .execute(json!({"command": "echo x", "env": {"MY_KEY": 42}}))
510 .await
511 .unwrap_err();
512 assert!(matches!(err, Error::BadToolArgs { .. }));
513 let err_msg = format!("{err}");
514 assert!(
515 err_msg.contains("MY_KEY"),
516 "error should mention the offending key: {err_msg}"
517 );
518
519 let out = tool
521 .execute(json!({"command": "echo hello"}))
522 .await
523 .unwrap();
524 assert!(out.contains("exit: 0"));
525 assert!(out.contains("hello"));
526 }
527}