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::error::{Error, Result};
17use crate::llm::ToolSpec;
18
19#[derive(Debug, Clone)]
20pub struct RunShell {
21 pub root: PathBuf,
22 pub timeout: Duration,
23 pub max_output_bytes: usize,
24}
25
26impl RunShell {
27 pub fn new(root: impl Into<PathBuf>) -> Self {
28 Self {
29 root: root.into(),
30 timeout: Duration::from_secs(300),
31 max_output_bytes: 128 * 1024,
32 }
33 }
34
35 pub fn with_timeout(mut self, t: Duration) -> Self {
36 self.timeout = t;
37 self
38 }
39}
40
41#[async_trait]
42impl Tool for RunShell {
43 fn spec(&self) -> ToolSpec {
44 ToolSpec {
45 name: "run_shell".into(),
46 description:
47 "Run a shell command (sh -c) from the workspace root, or from an optional subdirectory inside it via `cwd`."
48 .into(),
49 parameters: json!({
50 "type": "object",
51 "properties": {
52 "command": {
53 "type": "string",
54 "description": "Command line to execute via sh -c"
55 },
56 "cwd": {
57 "type": "string",
58 "description": "Optional subdirectory (relative to workspace root) to run the command in. Must stay inside the workspace."
59 },
60 "env": {
61 "type": "object",
62 "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.",
63 "additionalProperties": {
64 "type": "string"
65 }
66 }
67 },
68 "required": ["command"]
69 }),
70 }
71 }
72
73 async fn execute(&self, args: Value) -> Result<String> {
74 let command = args["command"].as_str().ok_or_else(|| Error::BadToolArgs {
75 name: "run_shell".into(),
76 message: "missing `command`".into(),
77 })?;
78
79 let cwd = if let Some(rel) = args.get("cwd").and_then(|v| v.as_str()) {
81 resolve_within(&self.root, rel).map_err(|e| Error::BadToolArgs {
82 name: "run_shell".into(),
83 message: format!("cwd: {e}"),
84 })?
85 } else {
86 self.root.clone()
87 };
88
89 let mut cmd = Command::new("/bin/sh");
90 cmd.arg("-c").arg(command);
91 cmd.current_dir(&cwd);
92 cmd.stdout(Stdio::piped());
93 cmd.stderr(Stdio::piped());
94
95 if let Some(env_map) = args.get("env").and_then(|v| v.as_object()) {
97 for (key, val) in env_map {
98 let val_str = val.as_str().ok_or_else(|| Error::BadToolArgs {
99 name: "run_shell".to_string(),
100 message: format!("env value for `{key}` must be a string, got {:?}", val),
101 })?;
102 cmd.env(key, val_str);
103 }
104 }
105
106 let mut child = cmd.spawn().map_err(|e| Error::Tool {
107 name: "run_shell".into(),
108 message: format!("spawn failed: {e}"),
109 })?;
110
111 let mut stdout = child.stdout.take().expect("stdout piped");
112 let mut stderr = child.stderr.take().expect("stderr piped");
113
114 let max = self.max_output_bytes;
115 let stdout_task = tokio::spawn(async move { read_capped(&mut stdout, max).await });
116 let stderr_task = tokio::spawn(async move { read_capped(&mut stderr, max).await });
117
118 let wait = child.wait();
119 let status = match tokio::time::timeout(self.timeout, wait).await {
120 Ok(s) => s.map_err(|e| Error::Tool {
121 name: "run_shell".into(),
122 message: format!("wait failed: {e}"),
123 })?,
124 Err(_) => {
125 return Err(Error::Tool {
126 name: "run_shell".into(),
127 message: format!("command timed out after {:?}", self.timeout),
128 });
129 }
130 };
131
132 let out = stdout_task.await.unwrap_or_default();
133 let err = stderr_task.await.unwrap_or_default();
134 let code = status
135 .code()
136 .map(|c| c.to_string())
137 .unwrap_or_else(|| "signal".into());
138
139 Ok(format!(
140 "exit: {code}\n--- stdout ---\n{out}\n--- stderr ---\n{err}"
141 ))
142 }
143}
144
145async fn read_capped<R: AsyncReadExt + Unpin>(reader: &mut R, max: usize) -> String {
146 let mut buf = Vec::with_capacity(8 * 1024);
147 let mut tmp = [0u8; 8 * 1024];
148 loop {
149 match reader.read(&mut tmp).await {
150 Ok(0) => break,
151 Ok(n) => {
152 if buf.len() + n > max {
153 let take = max.saturating_sub(buf.len());
154 buf.extend_from_slice(&tmp[..take]);
155 buf.extend_from_slice(b"\n... [output truncated]");
156 let _ = tokio::io::copy(reader, &mut tokio::io::sink()).await;
157 break;
158 }
159 buf.extend_from_slice(&tmp[..n]);
160 }
161 Err(_) => break,
162 }
163 }
164 String::from_utf8_lossy(&buf).into_owned()
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use tempfile::TempDir;
171
172 #[tokio::test]
173 async fn runs_echo_in_workspace() {
174 let tmp = TempDir::new().unwrap();
175 let out = RunShell::new(tmp.path())
176 .execute(json!({"command": "echo hello && pwd"}))
177 .await
178 .unwrap();
179 assert!(out.contains("exit: 0"));
180 assert!(out.contains("hello"));
181 }
182
183 #[tokio::test]
184 async fn captures_nonzero_status() {
185 let tmp = TempDir::new().unwrap();
186 let out = RunShell::new(tmp.path())
187 .execute(json!({"command": "exit 7"}))
188 .await
189 .unwrap();
190 assert!(out.contains("exit: 7"));
191 }
192
193 #[tokio::test]
194 async fn enforces_timeout() {
195 let tmp = TempDir::new().unwrap();
196 let tool = RunShell::new(tmp.path()).with_timeout(Duration::from_millis(150));
197 let err = tool
198 .execute(json!({"command": "sleep 5"}))
199 .await
200 .unwrap_err();
201 assert!(matches!(err, Error::Tool { .. }));
202 }
203
204 #[tokio::test]
205 async fn runs_in_subdir_when_cwd_given() {
206 let tmp = TempDir::new().unwrap();
207 let sub = tmp.path().join("sub");
209 std::fs::create_dir(&sub).unwrap();
210 std::fs::write(sub.join("marker.txt"), "content").unwrap();
211
212 let out = RunShell::new(tmp.path())
213 .execute(json!({"command": "ls", "cwd": "sub"}))
214 .await
215 .unwrap();
216
217 assert!(out.contains("exit: 0"));
218 assert!(out.contains("marker.txt"));
219 }
220
221 #[tokio::test]
222 async fn rejects_cwd_outside_workspace() {
223 let tmp = TempDir::new().unwrap();
224 let err = RunShell::new(tmp.path())
225 .execute(json!({"command": "echo hello", "cwd": "../escape"}))
226 .await
227 .unwrap_err();
228
229 assert!(matches!(err, Error::BadToolArgs { ref name, .. } if name == "run_shell"));
230 let err_msg = format!("{err}");
231 assert!(err_msg.contains("cwd"));
232 }
233
234 #[tokio::test]
235 async fn accepts_dot_cwd_as_root() {
236 let tmp = TempDir::new().unwrap();
237 let out = RunShell::new(tmp.path())
238 .execute(json!({"command": "pwd", "cwd": "."}))
239 .await
240 .unwrap();
241
242 assert!(out.contains("exit: 0"));
243 assert!(out.contains("--- stdout ---"));
245 }
246
247 #[tokio::test]
248 async fn existing_no_cwd_call_still_works() {
249 let tmp = TempDir::new().unwrap();
250 let out = RunShell::new(tmp.path())
251 .execute(json!({"command": "echo hello"}))
252 .await
253 .unwrap();
254
255 assert!(out.contains("exit: 0"));
256 assert!(out.contains("hello"));
257 }
258
259 #[tokio::test]
261 async fn env_overrides_and_errors() {
262 let tmp = TempDir::new().unwrap();
263 let tool = RunShell::new(tmp.path());
264
265 let out = tool
267 .execute(json!({"command": "echo $RECURSIVE_TEST_VAR", "env": {"RECURSIVE_TEST_VAR": "hello"}}))
268 .await
269 .unwrap();
270 assert!(out.contains("exit: 0"));
271 assert!(out.contains("hello"));
272
273 let err = tool
275 .execute(json!({"command": "echo x", "env": {"MY_KEY": 42}}))
276 .await
277 .unwrap_err();
278 assert!(matches!(err, Error::BadToolArgs { .. }));
279 let err_msg = format!("{err}");
280 assert!(
281 err_msg.contains("MY_KEY"),
282 "error should mention the offending key: {err_msg}"
283 );
284
285 let out = tool
287 .execute(json!({"command": "echo hello"}))
288 .await
289 .unwrap();
290 assert!(out.contains("exit: 0"));
291 assert!(out.contains("hello"));
292 }
293}