kaish_kernel/tools/builtin/
spawn.rs1use 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
26pub 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 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 let command = if command_name.starts_with('/') || command_name.starts_with("./") {
90 command_name.clone()
91 } else {
92 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(), }
103 };
104
105 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 let env_vars = args
114 .get_named("env")
115 .map(extract_string_object)
116 .unwrap_or_default();
117
118 let cwd = args.get_string("cwd", usize::MAX);
120
121 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 let clear_env = args.has_flag("clear_env");
132
133 let mut cmd = Command::new(&command);
135 cmd.args(&argv);
136
137 if let Some(ref dir) = cwd {
139 let vfs_cwd = ctx.resolve_path(dir);
140 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 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 let mut child = match cmd.spawn() {
173 Ok(child) => child,
174 Err(e) => return ExecResult::failure(127, format!("spawn: {}: {}", command, e)),
175 };
176
177 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 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 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
217pub 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
252fn 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
265fn 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 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 vec![s.clone()]
290 }
291 _ => vec![],
292 }
293}
294
295fn 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.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 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 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 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 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 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); 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 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}