Skip to main content

kaish_kernel/tools/builtin/
spawn.rs

1//! spawn — Spawn an external command as a subprocess.
2//!
3//! Unlike `exec` (which replaces the process), `spawn` runs a command as a
4//! child process and captures its output. Use this when you need explicit
5//! control over env, cwd, timeout, or stdin piping.
6//!
7//! # Examples
8//!
9//! ```kaish
10//! spawn command="/usr/bin/jq" argv=["-r", ".foo"]
11//! spawn command="/bin/echo" argv=["hello", "world"]
12//! spawn command="/usr/bin/env" env={"MY_VAR": "value"}
13//! spawn command="cargo" cwd="/workspace"             # with working directory
14//! spawn command="sleep" argv=["10"] timeout=1000     # with 1 second timeout
15//! ```
16
17use async_trait::async_trait;
18use std::path::Path;
19use std::time::Duration;
20use tokio::process::Command;
21
22use crate::ast::Value;
23use crate::interpreter::ExecResult;
24use crate::tools::{ExecContext, ParamSchema, Tool, ToolArgs, ToolSchema};
25
26/// Spawn tool: runs an external command as a subprocess and captures output.
27pub struct Spawn;
28
29#[async_trait]
30impl Tool for Spawn {
31    fn name(&self) -> &str {
32        "spawn"
33    }
34
35    fn schema(&self) -> ToolSchema {
36        ToolSchema::new("spawn", "Spawn an external command as a subprocess")
37            .param(ParamSchema::required(
38                "command",
39                "string",
40                "Command to execute (name or path)",
41            ))
42            .param(ParamSchema::optional(
43                "argv",
44                "string",
45                Value::Null,
46                "Arguments as JSON array (e.g. [\"arg1\", \"arg2\"]) or single string",
47            ))
48            .param(ParamSchema::optional(
49                "env",
50                "string",
51                Value::Null,
52                "Environment variables as JSON object string",
53            ))
54            .param(ParamSchema::optional(
55                "cwd",
56                "string",
57                Value::Null,
58                "Working directory for the command",
59            ))
60            .param(ParamSchema::optional(
61                "timeout",
62                "int",
63                Value::Null,
64                "Timeout in milliseconds (command killed if exceeded)",
65            ))
66            .param(ParamSchema::optional(
67                "clear_env",
68                "bool",
69                Value::Bool(false),
70                "Start with empty environment",
71            ))
72            .example("Run a command", "spawn command=\"cargo\" argv=[\"build\"]")
73            .example("With timeout", "spawn command=\"sleep\" argv=[\"10\"] timeout=1000")
74    }
75
76    async fn execute(&self, args: ToolArgs, ctx: &mut ExecContext) -> ExecResult {
77        if !ctx.allow_external_commands {
78            return ExecResult::failure(1,
79                "spawn: external commands are disabled (allow_external_commands=false)");
80        }
81
82        // Get command (required)
83        let command_name = match args.get_string("command", 0) {
84            Some(cmd) => cmd,
85            None => return ExecResult::failure(1, "spawn: command parameter required"),
86        };
87
88        // Resolve command path (PATH lookup if not absolute)
89        let command = if command_name.starts_with('/') || command_name.starts_with("./") {
90            command_name.clone()
91        } else {
92            // Try to find in PATH
93            let path_var = ctx
94                .scope
95                .get("PATH")
96                .map(value_to_string)
97                .unwrap_or_else(|| std::env::var("PATH").unwrap_or_default());
98
99            match resolve_in_path(&command_name, &path_var) {
100                Some(resolved) => resolved,
101                None => command_name.clone(), // Fall back to name, let OS report error
102            }
103        };
104
105        // Get argv (optional)
106        let argv = args
107            .get_named("argv")
108            .or_else(|| args.get_positional(1))
109            .map(extract_string_array)
110            .unwrap_or_default();
111
112        // Get env (optional)
113        let env_vars = args
114            .get_named("env")
115            .map(extract_string_object)
116            .unwrap_or_default();
117
118        // Get cwd (optional)
119        let cwd = args.get_string("cwd", usize::MAX);
120
121        // Get timeout (optional, in milliseconds)
122        let timeout_ms: Option<u64> = args
123            .get_named("timeout")
124            .and_then(|v| match v {
125                Value::Int(i) => Some(*i as u64),
126                Value::String(s) => s.parse().ok(),
127                _ => None,
128            });
129
130        // Get clear_env flag
131        let clear_env = args.has_flag("clear_env");
132
133        // Build command
134        let mut cmd = Command::new(&command);
135        cmd.args(&argv);
136
137        // Set working directory if specified
138        if let Some(ref dir) = cwd {
139            let vfs_cwd = ctx.resolve_path(dir);
140            // Resolve VFS path to real filesystem path
141            let real_cwd = match ctx.backend.resolve_real_path(&vfs_cwd) {
142                Some(p) => p,
143                None => {
144                    return ExecResult::failure(
145                        1,
146                        format!("spawn: cwd '{}' is not on a real filesystem", vfs_cwd.display()),
147                    )
148                }
149            };
150            cmd.current_dir(&real_cwd);
151        }
152
153        if clear_env {
154            cmd.env_clear();
155        }
156
157        for (key, value) in &env_vars {
158            cmd.env(key, value);
159        }
160
161        // Handle stdin
162        let stdin_data = ctx.read_stdin_to_string().await;
163        cmd.stdin(if stdin_data.is_some() {
164            std::process::Stdio::piped()
165        } else {
166            std::process::Stdio::null()
167        });
168        cmd.stdout(std::process::Stdio::piped());
169        cmd.stderr(std::process::Stdio::piped());
170
171        // Spawn the process
172        let mut child = match cmd.spawn() {
173            Ok(child) => child,
174            Err(e) => return ExecResult::failure(127, format!("spawn: {}: {}", command, e)),
175        };
176
177        // Write stdin if present
178        if let Some(data) = stdin_data
179            && let Some(mut stdin) = child.stdin.take() {
180                use tokio::io::AsyncWriteExt;
181                if let Err(e) = stdin.write_all(data.as_bytes()).await {
182                    return ExecResult::failure(1, format!("spawn: failed to write stdin: {}", e));
183                }
184            }
185
186        // Wait with optional timeout
187        if let Some(ms) = timeout_ms {
188            let timeout = Duration::from_millis(ms);
189            match tokio::time::timeout(timeout, child.wait_with_output()).await {
190                Ok(Ok(output)) => {
191                    let code = output.status.code().unwrap_or(-1) as i64;
192                    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
193                    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
194                    ExecResult::from_output(code, stdout, stderr)
195                }
196                Ok(Err(e)) => ExecResult::failure(1, format!("spawn: failed to wait: {}", e)),
197                Err(_) => {
198                    // Timeout - process is still running but we can't kill it
199                    // because wait_with_output took ownership. Return timeout error.
200                    ExecResult::failure(124, format!("spawn: {}: timed out after {}ms", command, ms))
201                }
202            }
203        } else {
204            match child.wait_with_output().await {
205                Ok(output) => {
206                    let code = output.status.code().unwrap_or(-1) as i64;
207                    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
208                    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
209                    ExecResult::from_output(code, stdout, stderr)
210                }
211                Err(e) => ExecResult::failure(1, format!("spawn: failed to wait: {}", e)),
212            }
213        }
214    }
215}
216
217/// Resolve a command name in PATH.
218///
219/// Searches each directory in `path_var` (colon-separated) for an executable
220/// named `name`. Returns the full path if found.
221pub fn resolve_in_path(name: &str, path_var: &str) -> Option<String> {
222    for dir in path_var.split(':') {
223        if dir.is_empty() {
224            continue;
225        }
226
227        let full_path = format!("{}/{}", dir, name);
228        let path = Path::new(&full_path);
229
230        if path.is_file() {
231            #[cfg(unix)]
232            {
233                use std::os::unix::fs::PermissionsExt;
234                if let Ok(metadata) = path.metadata() {
235                    let mode = metadata.permissions().mode();
236                    if mode & 0o111 != 0 {
237                        return Some(full_path);
238                    }
239                }
240            }
241
242            #[cfg(not(unix))]
243            {
244                return Some(full_path);
245            }
246        }
247    }
248
249    None
250}
251
252/// Convert a Value to a string.
253fn value_to_string(value: &Value) -> String {
254    match value {
255        Value::Null => String::new(),
256        Value::Bool(b) => b.to_string(),
257        Value::Int(i) => i.to_string(),
258        Value::Float(f) => f.to_string(),
259        Value::String(s) => s.clone(),
260        Value::Json(json) => json.to_string(),
261        Value::Blob(blob) => format!("[blob: {} {}]", blob.formatted_size(), blob.content_type),
262    }
263}
264
265/// Extract an array of strings from a Value.
266///
267/// Supports:
268/// - JSON array (Value::Json): use elements directly
269/// - JSON array string: parse and extract string items
270/// - Plain string: one-element array (no implicit splitting)
271fn extract_string_array(value: &Value) -> Vec<String> {
272    match value {
273        Value::Json(serde_json::Value::Array(arr)) => {
274            arr.iter().map(|v| match v {
275                serde_json::Value::String(s) => s.clone(),
276                other => other.to_string(),
277            }).collect()
278        }
279        Value::String(s) => {
280            // Try to parse as JSON array
281            if s.starts_with('[')
282                && let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(s) {
283                    return arr
284                        .iter()
285                        .filter_map(|v| v.as_str().map(String::from))
286                        .collect();
287                }
288            // Plain string is one argument — no implicit whitespace splitting
289            vec![s.clone()]
290        }
291        _ => vec![],
292    }
293}
294
295/// Extract a string→string mapping from a Value.
296///
297/// Supports:
298/// - String: parse as JSON object
299fn extract_string_object(value: &Value) -> Vec<(String, String)> {
300    match value {
301        Value::String(s) => {
302            if let Ok(obj) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(s) {
303                return obj
304                    .iter()
305                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
306                    .collect();
307            }
308            vec![]
309        }
310        _ => vec![],
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::vfs::{MemoryFs, VfsRouter};
318    use std::sync::Arc;
319
320    fn make_ctx() -> ExecContext {
321        let mut vfs = VfsRouter::new();
322        vfs.mount("/", MemoryFs::new());
323        ExecContext::new(Arc::new(vfs))
324    }
325
326    #[tokio::test]
327    async fn test_spawn_echo() {
328        let mut ctx = make_ctx();
329        let mut args = ToolArgs::new();
330        args.named
331            .insert("command".to_string(), Value::String("/bin/echo".into()));
332        // Args are now space-separated strings or JSON arrays
333        args.named.insert(
334            "argv".to_string(),
335            Value::String("hello".into()),
336        );
337
338        let result = Spawn.execute(args, &mut ctx).await;
339        assert!(result.ok());
340        assert_eq!(result.text_out().trim(), "hello");
341    }
342
343    #[tokio::test]
344    async fn test_spawn_with_stdin() {
345        let mut ctx = make_ctx();
346        ctx.set_stdin("hello world".to_string());
347
348        let mut args = ToolArgs::new();
349        args.named
350            .insert("command".to_string(), Value::String("/bin/cat".into()));
351
352        let result = Spawn.execute(args, &mut ctx).await;
353        assert!(result.ok());
354        assert_eq!(&*result.text_out(), "hello world");
355    }
356
357    #[tokio::test]
358    async fn test_spawn_with_env() {
359        let mut ctx = make_ctx();
360        let mut args = ToolArgs::new();
361        args.named
362            .insert("command".to_string(), Value::String("/usr/bin/env".into()));
363        // Env is now a JSON object string
364        args.named.insert(
365            "env".to_string(),
366            Value::String(r#"{"MY_TEST_VAR": "test_value"}"#.into()),
367        );
368        args.flags.insert("clear_env".to_string());
369
370        let result = Spawn.execute(args, &mut ctx).await;
371        assert!(result.ok());
372        assert!(result.text_out().contains("MY_TEST_VAR=test_value"));
373    }
374
375    #[tokio::test]
376    async fn test_spawn_missing_command() {
377        let mut ctx = make_ctx();
378        let args = ToolArgs::new();
379
380        let result = Spawn.execute(args, &mut ctx).await;
381        assert!(!result.ok());
382        assert!(result.err.contains("command parameter required"));
383    }
384
385    #[tokio::test]
386    async fn test_spawn_nonexistent_command() {
387        let mut ctx = make_ctx();
388        let mut args = ToolArgs::new();
389        args.named.insert(
390            "command".to_string(),
391            Value::String("/nonexistent/command/path".into()),
392        );
393
394        let result = Spawn.execute(args, &mut ctx).await;
395        assert!(!result.ok());
396        assert_eq!(result.code, 127);
397    }
398
399    #[tokio::test]
400    async fn test_spawn_path_resolution() {
401        let mut ctx = make_ctx();
402        let mut args = ToolArgs::new();
403        // Use command name instead of full path
404        args.named
405            .insert("command".to_string(), Value::String("echo".into()));
406        args.named.insert(
407            "argv".to_string(),
408            Value::String(r#"["hello", "from", "PATH"]"#.into()),
409        );
410
411        let result = Spawn.execute(args, &mut ctx).await;
412        assert!(result.ok());
413        assert!(result.text_out().contains("hello from PATH"));
414    }
415
416    #[tokio::test]
417    async fn test_spawn_with_cwd() {
418        // Need LocalFs for real path resolution (spawn cwd requires real filesystem)
419        let mut vfs = VfsRouter::new();
420        vfs.mount("/", MemoryFs::new());
421        vfs.mount("/tmp", crate::vfs::LocalFs::new("/tmp"));
422        let mut ctx = ExecContext::new(Arc::new(vfs));
423
424        let mut args = ToolArgs::new();
425        args.named
426            .insert("command".to_string(), Value::String("pwd".into()));
427        args.named
428            .insert("cwd".to_string(), Value::String("/tmp".into()));
429
430        let result = Spawn.execute(args, &mut ctx).await;
431        assert!(result.ok(), "spawn failed: {}", result.err);
432        // Output should contain /tmp (or its resolved path like /private/tmp on macOS)
433        assert!(result.text_out().contains("tmp"), "expected tmp in output: {}", result.text_out());
434    }
435
436    #[tokio::test]
437    async fn test_spawn_with_timeout() {
438        let mut ctx = make_ctx();
439        let mut args = ToolArgs::new();
440        args.named
441            .insert("command".to_string(), Value::String("sleep".into()));
442        args.named
443            .insert("argv".to_string(), Value::String("10".into()));
444        // Timeout after 100ms
445        args.named
446            .insert("timeout".to_string(), Value::Int(100));
447
448        let result = Spawn.execute(args, &mut ctx).await;
449        assert!(!result.ok());
450        assert_eq!(result.code, 124); // Timeout exit code
451        assert!(result.err.contains("timed out"));
452    }
453
454    #[tokio::test]
455    async fn test_spawn_no_timeout_when_fast() {
456        let mut ctx = make_ctx();
457        let mut args = ToolArgs::new();
458        args.named
459            .insert("command".to_string(), Value::String("echo".into()));
460        args.named
461            .insert("argv".to_string(), Value::String("quick".into()));
462        // Long timeout that won't trigger
463        args.named
464            .insert("timeout".to_string(), Value::Int(10000));
465
466        let result = Spawn.execute(args, &mut ctx).await;
467        assert!(result.ok());
468        assert!(result.text_out().contains("quick"));
469    }
470}