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::Tool;
15use crate::error::{Error, Result};
16use crate::llm::ToolSpec;
17
18#[derive(Debug, Clone)]
19pub struct RunShell {
20 pub root: PathBuf,
21 pub timeout: Duration,
22 pub max_output_bytes: usize,
23}
24
25impl RunShell {
26 pub fn new(root: impl Into<PathBuf>) -> Self {
27 Self {
28 root: root.into(),
29 timeout: Duration::from_secs(300),
30 max_output_bytes: 128 * 1024,
31 }
32 }
33
34 pub fn with_timeout(mut self, t: Duration) -> Self {
35 self.timeout = t;
36 self
37 }
38}
39
40#[async_trait]
41impl Tool for RunShell {
42 fn spec(&self) -> ToolSpec {
43 ToolSpec {
44 name: "run_shell".into(),
45 description:
46 "Run a shell command (sh -c) from the workspace root. Returns combined stdout/stderr and exit status."
47 .into(),
48 parameters: json!({
49 "type": "object",
50 "properties": {
51 "command": {"type": "string", "description": "Command line to execute via sh -c"}
52 },
53 "required": ["command"]
54 }),
55 }
56 }
57
58 async fn execute(&self, args: Value) -> Result<String> {
59 let command = args["command"].as_str().ok_or_else(|| Error::BadToolArgs {
60 name: "run_shell".into(),
61 message: "missing `command`".into(),
62 })?;
63
64 let mut cmd = Command::new("/bin/sh");
65 cmd.arg("-c").arg(command);
66 cmd.current_dir(&self.root);
67 cmd.stdout(Stdio::piped());
68 cmd.stderr(Stdio::piped());
69
70 let mut child = cmd.spawn().map_err(|e| Error::Tool {
71 name: "run_shell".into(),
72 message: format!("spawn failed: {e}"),
73 })?;
74
75 let mut stdout = child.stdout.take().expect("stdout piped");
76 let mut stderr = child.stderr.take().expect("stderr piped");
77
78 let max = self.max_output_bytes;
79 let stdout_task = tokio::spawn(async move { read_capped(&mut stdout, max).await });
80 let stderr_task = tokio::spawn(async move { read_capped(&mut stderr, max).await });
81
82 let wait = child.wait();
83 let status = match tokio::time::timeout(self.timeout, wait).await {
84 Ok(s) => s.map_err(|e| Error::Tool {
85 name: "run_shell".into(),
86 message: format!("wait failed: {e}"),
87 })?,
88 Err(_) => {
89 return Err(Error::Tool {
90 name: "run_shell".into(),
91 message: format!("command timed out after {:?}", self.timeout),
92 });
93 }
94 };
95
96 let out = stdout_task.await.unwrap_or_default();
97 let err = stderr_task.await.unwrap_or_default();
98 let code = status
99 .code()
100 .map(|c| c.to_string())
101 .unwrap_or_else(|| "signal".into());
102
103 Ok(format!(
104 "exit: {code}\n--- stdout ---\n{out}\n--- stderr ---\n{err}"
105 ))
106 }
107}
108
109async fn read_capped<R: AsyncReadExt + Unpin>(reader: &mut R, max: usize) -> String {
110 let mut buf = Vec::with_capacity(8 * 1024);
111 let mut tmp = [0u8; 8 * 1024];
112 loop {
113 match reader.read(&mut tmp).await {
114 Ok(0) => break,
115 Ok(n) => {
116 if buf.len() + n > max {
117 let take = max.saturating_sub(buf.len());
118 buf.extend_from_slice(&tmp[..take]);
119 buf.extend_from_slice(b"\n... [output truncated]");
120 let _ = tokio::io::copy(reader, &mut tokio::io::sink()).await;
121 break;
122 }
123 buf.extend_from_slice(&tmp[..n]);
124 }
125 Err(_) => break,
126 }
127 }
128 String::from_utf8_lossy(&buf).into_owned()
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use tempfile::TempDir;
135
136 #[tokio::test]
137 async fn runs_echo_in_workspace() {
138 let tmp = TempDir::new().unwrap();
139 let out = RunShell::new(tmp.path())
140 .execute(json!({"command": "echo hello && pwd"}))
141 .await
142 .unwrap();
143 assert!(out.contains("exit: 0"));
144 assert!(out.contains("hello"));
145 }
146
147 #[tokio::test]
148 async fn captures_nonzero_status() {
149 let tmp = TempDir::new().unwrap();
150 let out = RunShell::new(tmp.path())
151 .execute(json!({"command": "exit 7"}))
152 .await
153 .unwrap();
154 assert!(out.contains("exit: 7"));
155 }
156
157 #[tokio::test]
158 async fn enforces_timeout() {
159 let tmp = TempDir::new().unwrap();
160 let tool = RunShell::new(tmp.path()).with_timeout(Duration::from_millis(150));
161 let err = tool
162 .execute(json!({"command": "sleep 5"}))
163 .await
164 .unwrap_err();
165 assert!(matches!(err, Error::Tool { .. }));
166 }
167}