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)]
168#[cfg(not(target_os = "windows"))]
169mod tests {
170 use super::*;
171 use tempfile::TempDir;
172
173 #[tokio::test]
174 async fn runs_echo_in_workspace() {
175 let tmp = TempDir::new().unwrap();
176 let out = RunShell::new(tmp.path())
177 .execute(json!({"command": "echo hello && pwd"}))
178 .await
179 .unwrap();
180 assert!(out.contains("exit: 0"));
181 assert!(out.contains("hello"));
182 }
183
184 #[tokio::test]
185 async fn captures_nonzero_status() {
186 let tmp = TempDir::new().unwrap();
187 let out = RunShell::new(tmp.path())
188 .execute(json!({"command": "exit 7"}))
189 .await
190 .unwrap();
191 assert!(out.contains("exit: 7"));
192 }
193
194 #[tokio::test]
195 async fn enforces_timeout() {
196 let tmp = TempDir::new().unwrap();
197 let tool = RunShell::new(tmp.path()).with_timeout(Duration::from_millis(150));
198 let err = tool
199 .execute(json!({"command": "sleep 5"}))
200 .await
201 .unwrap_err();
202 assert!(matches!(err, Error::Tool { .. }));
203 }
204
205 #[tokio::test]
206 async fn runs_in_subdir_when_cwd_given() {
207 let tmp = TempDir::new().unwrap();
208 let sub = tmp.path().join("sub");
210 std::fs::create_dir(&sub).unwrap();
211 std::fs::write(sub.join("marker.txt"), "content").unwrap();
212
213 let out = RunShell::new(tmp.path())
214 .execute(json!({"command": "ls", "cwd": "sub"}))
215 .await
216 .unwrap();
217
218 assert!(out.contains("exit: 0"));
219 assert!(out.contains("marker.txt"));
220 }
221
222 #[tokio::test]
223 async fn rejects_cwd_outside_workspace() {
224 let tmp = TempDir::new().unwrap();
225 let err = RunShell::new(tmp.path())
226 .execute(json!({"command": "echo hello", "cwd": "../escape"}))
227 .await
228 .unwrap_err();
229
230 assert!(matches!(err, Error::BadToolArgs { ref name, .. } if name == "run_shell"));
231 let err_msg = format!("{err}");
232 assert!(err_msg.contains("cwd"));
233 }
234
235 #[tokio::test]
236 async fn accepts_dot_cwd_as_root() {
237 let tmp = TempDir::new().unwrap();
238 let out = RunShell::new(tmp.path())
239 .execute(json!({"command": "pwd", "cwd": "."}))
240 .await
241 .unwrap();
242
243 assert!(out.contains("exit: 0"));
244 assert!(out.contains("--- stdout ---"));
246 }
247
248 #[tokio::test]
249 async fn existing_no_cwd_call_still_works() {
250 let tmp = TempDir::new().unwrap();
251 let out = RunShell::new(tmp.path())
252 .execute(json!({"command": "echo hello"}))
253 .await
254 .unwrap();
255
256 assert!(out.contains("exit: 0"));
257 assert!(out.contains("hello"));
258 }
259
260 #[tokio::test]
262 async fn env_overrides_and_errors() {
263 let tmp = TempDir::new().unwrap();
264 let tool = RunShell::new(tmp.path());
265
266 let out = tool
268 .execute(json!({"command": "echo $RECURSIVE_TEST_VAR", "env": {"RECURSIVE_TEST_VAR": "hello"}}))
269 .await
270 .unwrap();
271 assert!(out.contains("exit: 0"));
272 assert!(out.contains("hello"));
273
274 let err = tool
276 .execute(json!({"command": "echo x", "env": {"MY_KEY": 42}}))
277 .await
278 .unwrap_err();
279 assert!(matches!(err, Error::BadToolArgs { .. }));
280 let err_msg = format!("{err}");
281 assert!(
282 err_msg.contains("MY_KEY"),
283 "error should mention the offending key: {err_msg}"
284 );
285
286 let out = tool
288 .execute(json!({"command": "echo hello"}))
289 .await
290 .unwrap();
291 assert!(out.contains("exit: 0"));
292 assert!(out.contains("hello"));
293 }
294}