1use anyhow::{anyhow, Result};
4use serde_json::Value;
5use std::path::PathBuf;
6
7use crate::tools::ToolRuntime;
8use crate::types::{FunctionDef, ToolDefinition};
9
10pub fn exec_command_definition() -> ToolDefinition {
15 ToolDefinition {
16 def_type: "function".to_string(),
17 function: FunctionDef {
18 name: "exec_command".to_string(),
19 description: "Execute a shell command.\n\n\
20Use tty=false (default) as the bash tool for one-shot shell commands like `cargo build`, `npm test`, `git status`, `ls`, etc. \
21The command runs to completion, returns output + exit code, and treats yield_time_ms as a timeout.\n\n\
22Use tty=true for:\n\
23- Interactive REPLs (python, node, etc.)\n\
24- Long-running multi-step workflows where you need shell state to persist across calls \
25(cd into a directory, set env vars, then run commands)\n\
26- When you need more than one command in the same shell session\n\n\
27When tty=true, yield_time_ms only controls how long to wait for output before returning; it does not kill the session.\n\n\
28When tty=true, you get a session_name back. Use write_stdin to continue interacting with that session."
29 .to_string(),
30 parameters: serde_json::json!({
31 "type": "object",
32 "properties": {
33 "cmd": { "type": "string", "description": "Shell command to execute" },
34 "workdir": { "type": "string", "description": "Working directory for the command (default: project root)" },
35 "tty": { "type": "boolean", "description": "Use false as the bash tool for one-shot shell commands; use true for an interactive/persistent PTY session (default: false)" },
36 "yield_time_ms": { "type": "number", "description": "For tty=false, command timeout in milliseconds (default: 30000, max: 30000). For tty=true, maximum time to wait for terminal output without killing the session (default: 500, max: 30000)." },
37 "max_output_chars": { "type": "number", "description": "Maximum characters of output to return (default: 8000, head-tail truncated if exceeded)" }
38 },
39 "required": ["cmd"]
40 }),
41 },
42 }
43}
44
45pub async fn execute_exec_command(args: &Value, runtime: &ToolRuntime) -> Result<String> {
46 let manager = &runtime.terminal_manager;
47 let cmd = require_str(args, "cmd")?;
48 let tty = args.get("tty").and_then(|v| v.as_bool()).unwrap_or(false);
49 let default_yield_ms = if tty { 500 } else { 30_000 };
50 let yield_ms = clamp_yield(
51 args.get("yield_time_ms")
52 .and_then(|v| v.as_u64())
53 .unwrap_or(default_yield_ms),
54 );
55 let max_output = args
56 .get("max_output_chars")
57 .and_then(|v| v.as_u64())
58 .unwrap_or(8000) as usize;
59 let cwd = args
60 .get("workdir")
61 .and_then(|v| v.as_str())
62 .map(PathBuf::from);
63
64 if !tty {
65 let output = manager
66 .exec_one_shot(
67 &cmd,
68 cwd,
69 120,
70 40,
71 yield_ms,
72 max_output,
73 runtime.sandbox.as_ref(),
74 )
75 .await?;
76 return Ok(serde_json::to_string_pretty(&output)?);
77 }
78
79 let session_name = make_session_name();
80 let _info = manager
81 .create(session_name.clone(), cwd, 120, 40, runtime.sandbox.as_ref())
82 .await?;
83
84 if cmd.trim().is_empty() {
85 let output = manager
86 .write_stdin(&session_name, "", yield_ms, max_output)
87 .await?;
88 return Ok(serde_json::to_string_pretty(&output)?);
89 }
90
91 let output = manager
92 .write_stdin(&session_name, &format!("{}\r", cmd), yield_ms, max_output)
93 .await?;
94 Ok(serde_json::to_string_pretty(&output)?)
95}
96
97pub fn write_stdin_definition() -> ToolDefinition {
102 ToolDefinition {
103 def_type: "function".to_string(),
104 function: FunctionDef {
105 name: "write_stdin".to_string(),
106 description: "Send input to a persistent terminal session created by exec_command with tty=true. \
107Also use this to poll for output by sending empty input.\n\n\
108Supports key notation: <RET> (Enter), <C-c> (Ctrl+C), <C-d> (Ctrl+D), <TAB>, <BSPC> (Backspace), \
109<UP>/<DOWN>/<LEFT>/<RIGHT>."
110 .to_string(),
111 parameters: serde_json::json!({
112 "type": "object",
113 "properties": {
114 "session_id": { "type": "string", "description": "Session ID returned by exec_command" },
115 "chars": { "type": "string", "description": "Input to send to the terminal. Supports key notation: <RET>, <C-c>, <C-d>, <TAB>, <BSPC>, <UP>/<DOWN>/<LEFT>/<RIGHT>. Leave empty to just poll for output." },
116 "yield_time_ms": { "type": "number", "description": "Maximum time to wait for output in milliseconds (default: 500)" },
117 "max_output_chars": { "type": "number", "description": "Maximum characters of output to return (default: 8000)" }
118 },
119 "required": ["session_id"]
120 }),
121 },
122 }
123}
124
125pub async fn execute_write_stdin(args: &Value, runtime: &ToolRuntime) -> Result<String> {
126 let manager = &runtime.terminal_manager;
127 let session_id = require_str(args, "session_id")?;
128 let chars = args.get("chars").and_then(|v| v.as_str()).unwrap_or("");
129 let yield_ms = clamp_yield(
130 args.get("yield_time_ms")
131 .and_then(|v| v.as_u64())
132 .unwrap_or(500),
133 );
134 let max_output = args
135 .get("max_output_chars")
136 .and_then(|v| v.as_u64())
137 .unwrap_or(8000) as usize;
138
139 if !manager.contains(&session_id).await {
140 return Err(anyhow!(
141 "terminal session '{}' not found - it may have been closed or expired",
142 session_id
143 ));
144 }
145
146 let output = manager
147 .write_stdin(&session_id, chars, yield_ms, max_output)
148 .await?;
149 Ok(serde_json::to_string_pretty(&output)?)
150}
151
152fn require_str(args: &Value, key: &str) -> Result<String> {
157 args.get(key)
158 .and_then(|v| v.as_str())
159 .map(|s| s.to_string())
160 .ok_or_else(|| anyhow!("missing required argument '{}'", key))
161}
162
163fn clamp_yield(ms: u64) -> u64 {
164 if ms > 30_000 {
165 30_000
166 } else {
167 ms
168 }
169}
170
171fn make_session_name() -> String {
172 use std::sync::atomic::{AtomicU64, Ordering};
173 static COUNTER: AtomicU64 = AtomicU64::new(1);
174 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
175 format!("shell-{}", n)
176}
177
178#[cfg(test)]
183mod tests {
184 use super::*;
185 use crate::events::EventSink;
186 use serde_json::json;
187 use std::collections::HashSet;
188 use std::sync::Arc;
189 use tokio::sync::Mutex;
190
191 fn test_runtime() -> ToolRuntime {
192 ToolRuntime {
193 store_path: PathBuf::new(),
194 session_id: None,
195 worker_executable: None,
196 active_threads: Arc::new(Mutex::new(HashSet::new())),
197 event_sink: EventSink::none(),
198 sandbox: None,
199 mcp: None,
200 skills: None,
201 activated_skills: Arc::new(Mutex::new(HashSet::new())),
202 terminal_manager: crate::terminal::TerminalManager::new(),
203 thread_timeout_secs: crate::tools::thread::DEFAULT_THREAD_TIMEOUT_SECS,
204 }
205 }
206
207 #[tokio::test]
212 async fn exec_command_definition_shape() {
213 let def = exec_command_definition();
214 assert_eq!(def.function.name, "exec_command");
215 assert!(def.function.description.contains("tty=true"));
216 assert!(def
217 .function
218 .parameters
219 .get("required")
220 .and_then(|v| v.as_array())
221 .is_some());
222 }
223
224 #[tokio::test]
225 async fn exec_command_missing_cmd_fails() {
226 let result = execute_exec_command(&json!({}), &test_runtime()).await;
227 assert!(result.is_err());
228 assert!(result
229 .unwrap_err()
230 .to_string()
231 .contains("missing required argument"));
232 }
233
234 #[tokio::test]
235 async fn exec_command_one_shot_echo() {
236 let result = execute_exec_command(
237 &json!({ "cmd": "echo hello-world", "tty": false, "yield_time_ms": 2000 }),
238 &test_runtime(),
239 )
240 .await;
241 assert!(result.is_ok(), "error: {:?}", result.err());
242 let output = result.unwrap();
243 assert!(output.contains("hello-world"), "got: {}", output);
244 let parsed: Value = serde_json::from_str(&output).unwrap();
245 assert!(parsed["session_name"].is_null());
246 assert_eq!(parsed["exit_code"].as_i64(), Some(0));
247 }
248
249 #[tokio::test]
250 async fn exec_command_one_shot_multiline() {
251 let result = execute_exec_command(
252 &json!({ "cmd": "echo line1 && echo line2", "tty": false, "yield_time_ms": 2000 }),
253 &test_runtime(),
254 )
255 .await;
256 assert!(result.is_ok());
257 let output = result.unwrap();
258 assert!(
259 output.contains("line1") && output.contains("line2"),
260 "got: {}",
261 output
262 );
263 }
264
265 #[tokio::test]
266 async fn exec_command_one_shot_nonzero_exit_code() {
267 let output = execute_exec_command(
268 &json!({ "cmd": "echo failure >&2; exit 7", "tty": false }),
269 &test_runtime(),
270 )
271 .await
272 .unwrap();
273 let parsed: Value = serde_json::from_str(&output).unwrap();
274 assert!(parsed["output"].as_str().unwrap().contains("failure"));
275 assert_eq!(parsed["exit_code"].as_i64(), Some(7));
276 assert!(parsed["session_name"].is_null());
277 }
278
279 #[tokio::test]
280 async fn exec_command_one_shot_returns_on_early_exit() {
281 let start = std::time::Instant::now();
282 let output = execute_exec_command(
283 &json!({ "cmd": "sleep 0.05; echo done", "tty": false, "yield_time_ms": 30000 }),
284 &test_runtime(),
285 )
286 .await
287 .unwrap();
288 assert!(
289 start.elapsed() < std::time::Duration::from_secs(2),
290 "one-shot command waited for yield_time_ms"
291 );
292 let parsed: Value = serde_json::from_str(&output).unwrap();
293 assert_eq!(parsed["exit_code"].as_i64(), Some(0));
294 assert!(parsed["output"].as_str().unwrap().contains("done"));
295 }
296
297 #[tokio::test]
298 async fn exec_command_one_shot_yield_time_is_timeout() {
299 let start = std::time::Instant::now();
300 let output = execute_exec_command(
301 &json!({ "cmd": "echo before; sleep 5; echo SHOULD_NOT_PRINT", "tty": false, "yield_time_ms": 100 }),
302 &test_runtime(),
303 )
304 .await
305 .unwrap();
306 assert!(
307 start.elapsed() < std::time::Duration::from_secs(2),
308 "one-shot command did not time out promptly"
309 );
310 let parsed: Value = serde_json::from_str(&output).unwrap();
311 let text = parsed["output"].as_str().unwrap();
312 assert!(text.contains("timed out after 100ms"), "got: {}", text);
313 assert!(text.contains("before"), "got: {}", text);
314 assert!(!text.contains("SHOULD_NOT_PRINT"), "got: {}", text);
315 assert!(parsed["exit_code"].is_null(), "got: {}", output);
316 assert!(parsed["session_name"].is_null());
317 }
318
319 #[tokio::test]
320 async fn exec_command_one_shot_timeout_kills_child_processes() {
321 let unique = std::time::SystemTime::now()
322 .duration_since(std::time::UNIX_EPOCH)
323 .expect("time went backwards")
324 .as_nanos();
325 let marker = std::env::temp_dir().join(format!("sac_exec_timeout_leak_{}", unique));
326 let cmd = format!("(sleep 1; touch {}) & wait", marker.display());
327
328 let output = execute_exec_command(
329 &json!({ "cmd": cmd, "tty": false, "yield_time_ms": 100 }),
330 &test_runtime(),
331 )
332 .await
333 .unwrap();
334 let parsed: Value = serde_json::from_str(&output).unwrap();
335 assert!(parsed["exit_code"].is_null(), "got: {}", output);
336
337 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
338 assert!(
339 !marker.exists(),
340 "timed-out command left a child process running"
341 );
342 }
343
344 #[tokio::test]
345 async fn exec_command_one_shot_large_output_keeps_tail() {
346 let output = execute_exec_command(
347 &json!({
348 "cmd": "for i in $(seq 1 120); do echo line$i; done",
349 "tty": false,
350 "max_output_chars": 200
351 }),
352 &test_runtime(),
353 )
354 .await
355 .unwrap();
356 let parsed: Value = serde_json::from_str(&output).unwrap();
357 let text = parsed["output"].as_str().unwrap();
358 assert!(text.contains("line1"), "got: {}", text);
359 assert!(text.contains("line120"), "got: {}", text);
360 assert_eq!(parsed["output_truncated"].as_bool(), Some(true));
361 }
362
363 #[tokio::test]
364 async fn exec_command_persistent_creates_session() {
365 let result = execute_exec_command(
366 &json!({ "cmd": "echo persistent-test", "tty": true, "yield_time_ms": 2000 }),
367 &test_runtime(),
368 )
369 .await;
370 assert!(result.is_ok(), "error: {:?}", result.err());
371 let output = result.unwrap();
372 let parsed: Value = serde_json::from_str(&output).unwrap();
373 let session_name = parsed["session_name"].as_str().unwrap();
374 assert!(session_name.starts_with("shell-"), "got: {}", session_name);
375 assert!(output.contains("persistent-test"), "got: {}", output);
376 }
377
378 #[tokio::test]
379 async fn exec_command_persistent_empty_cmd() {
380 let result = execute_exec_command(
381 &json!({ "cmd": "", "tty": true, "yield_time_ms": 2000 }),
382 &test_runtime(),
383 )
384 .await;
385 assert!(result.is_ok(), "error: {:?}", result.err());
386 let parsed: Value = serde_json::from_str(&result.unwrap()).unwrap();
387 assert!(parsed["session_name"].is_string());
388 }
389
390 #[tokio::test]
391 async fn exec_command_persistent_empty_cmd_respects_yield_time() {
392 let started = std::time::Instant::now();
393 let result = execute_exec_command(
394 &json!({ "cmd": "", "tty": true, "yield_time_ms": 900 }),
395 &test_runtime(),
396 )
397 .await;
398 assert!(result.is_ok(), "error: {:?}", result.err());
399 assert!(started.elapsed() >= std::time::Duration::from_millis(800));
400 }
401
402 #[tokio::test]
407 async fn write_stdin_definition_shape() {
408 let def = write_stdin_definition();
409 assert_eq!(def.function.name, "write_stdin");
410 let required = def
411 .function
412 .parameters
413 .get("required")
414 .and_then(|v| v.as_array())
415 .unwrap();
416 assert!(required.iter().any(|v| v.as_str() == Some("session_id")));
417 }
418
419 #[tokio::test]
420 async fn write_stdin_missing_session_id_fails() {
421 assert!(execute_write_stdin(&json!({}), &test_runtime())
422 .await
423 .is_err());
424 }
425
426 #[tokio::test]
427 async fn write_stdin_session_not_found() {
428 let result = execute_write_stdin(
429 &json!({ "session_id": "nonexistent", "chars": "echo hi<RET>" }),
430 &test_runtime(),
431 )
432 .await;
433 assert!(result.unwrap_err().to_string().contains("not found"));
434 }
435
436 #[tokio::test]
437 async fn write_stdin_poll_output() {
438 let runtime = test_runtime();
439 runtime
440 .terminal_manager
441 .create("test-poll".to_string(), None, 120, 40, None)
442 .await
443 .unwrap();
444 runtime
445 .terminal_manager
446 .write_stdin("test-poll", "echo poll-me\n", 2000, 8000)
447 .await
448 .unwrap();
449 let result = execute_write_stdin(
450 &json!({ "session_id": "test-poll", "chars": "", "yield_time_ms": 500 }),
451 &runtime,
452 )
453 .await;
454 assert!(result.is_ok(), "error: {:?}", result.err());
455 runtime.terminal_manager.remove("test-poll").await.ok();
456 }
457
458 #[tokio::test]
459 async fn write_stdin_send_input() {
460 let runtime = test_runtime();
461 runtime
462 .terminal_manager
463 .create("test-input".to_string(), None, 120, 40, None)
464 .await
465 .unwrap();
466 let result = execute_write_stdin(
467 &json!({ "session_id": "test-input", "chars": "echo from-stdin<RET>", "yield_time_ms": 2000 }),
468 &runtime,
469 ).await;
470 assert!(result.is_ok(), "error: {:?}", result.err());
471 assert!(result.unwrap().contains("from-stdin"));
472 runtime.terminal_manager.remove("test-input").await.ok();
473 }
474
475 #[tokio::test]
476 async fn write_stdin_allows_raw_text_without_terminator() {
477 let runtime = test_runtime();
478 runtime
479 .terminal_manager
480 .create("test-raw".to_string(), None, 120, 40, None)
481 .await
482 .unwrap();
483 let result = execute_write_stdin(
484 &json!({ "session_id": "test-raw", "chars": "echo buffered", "yield_time_ms": 100 }),
485 &runtime,
486 )
487 .await;
488 assert!(result.is_ok(), "raw text was rejected: {:?}", result.err());
489 runtime.terminal_manager.remove("test-raw").await.ok();
490 }
491
492 #[tokio::test]
493 async fn write_stdin_allows_pure_control_key_c_c() {
494 let runtime = test_runtime();
496 runtime
497 .terminal_manager
498 .create("test-ctrl".to_string(), None, 120, 40, None)
499 .await
500 .unwrap();
501 let result = execute_write_stdin(
502 &json!({ "session_id": "test-ctrl", "chars": "<C-c>", "yield_time_ms": 1000 }),
503 &runtime,
504 )
505 .await;
506 assert!(result.is_ok(), "validation error: {:?}", result.err());
509 runtime.terminal_manager.remove("test-ctrl").await.ok();
510 }
511
512 #[tokio::test]
513 async fn write_stdin_allows_pure_control_key_c_z() {
514 let runtime = test_runtime();
515 runtime
516 .terminal_manager
517 .create("test-ctrz".to_string(), None, 120, 40, None)
518 .await
519 .unwrap();
520 let result = execute_write_stdin(
521 &json!({ "session_id": "test-ctrz", "chars": "<C-z>", "yield_time_ms": 1000 }),
522 &runtime,
523 )
524 .await;
525 assert!(result.is_ok(), "validation error: {:?}", result.err());
526 runtime.terminal_manager.remove("test-ctrz").await.ok();
527 }
528
529 #[tokio::test]
530 async fn write_stdin_allows_arrow_key_without_terminator() {
531 let runtime = test_runtime();
532 runtime
533 .terminal_manager
534 .create("test-arrow".to_string(), None, 120, 40, None)
535 .await
536 .unwrap();
537 let result = execute_write_stdin(
538 &json!({ "session_id": "test-arrow", "chars": "<UP>", "yield_time_ms": 1000 }),
539 &runtime,
540 )
541 .await;
542 assert!(result.is_ok(), "validation error: {:?}", result.err());
543 runtime.terminal_manager.remove("test-arrow").await.ok();
544 }
545
546 #[tokio::test]
547 async fn write_stdin_allows_tab_without_terminator() {
548 let runtime = test_runtime();
549 runtime
550 .terminal_manager
551 .create("test-tab".to_string(), None, 120, 40, None)
552 .await
553 .unwrap();
554 let result = execute_write_stdin(
555 &json!({ "session_id": "test-tab", "chars": "<TAB>", "yield_time_ms": 1000 }),
556 &runtime,
557 )
558 .await;
559 assert!(result.is_ok(), "validation error: {:?}", result.err());
560 runtime.terminal_manager.remove("test-tab").await.ok();
561 }
562
563 #[tokio::test]
564 async fn write_stdin_returns_exit_metadata_and_clears_session() {
565 let runtime = test_runtime();
566 runtime
567 .terminal_manager
568 .create("test-exit".to_string(), None, 120, 40, None)
569 .await
570 .unwrap();
571 let result = execute_write_stdin(
572 &json!({ "session_id": "test-exit", "chars": "exit<RET>", "yield_time_ms": 2000 }),
573 &runtime,
574 )
575 .await
576 .unwrap();
577 let parsed: Value = serde_json::from_str(&result).unwrap();
578
579 let mut final_parsed = parsed.clone();
580 for _ in 0..10 {
581 if final_parsed["session_name"].is_null()
582 && final_parsed["exit_code"].as_i64() == Some(0)
583 {
584 break;
585 }
586 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
587 let poll = execute_write_stdin(
588 &json!({ "session_id": "test-exit", "chars": "", "yield_time_ms": 200 }),
589 &runtime,
590 )
591 .await;
592 match poll {
593 Ok(output) => {
594 final_parsed = serde_json::from_str(&output).unwrap();
595 }
596 Err(error) => {
597 assert!(
598 error.to_string().contains("not found"),
599 "unexpected error while polling exit: {}",
600 error
601 );
602 break;
603 }
604 }
605 }
606
607 assert!(
608 final_parsed["session_name"].is_null(),
609 "got: {}",
610 final_parsed
611 );
612 assert_eq!(final_parsed["exit_code"].as_i64(), Some(0));
613 assert!(runtime.terminal_manager.get("test-exit").await.is_none());
614 }
615
616 #[tokio::test]
621 async fn clamp_yield_edge() {
622 assert_eq!(clamp_yield(0), 0);
623 assert_eq!(clamp_yield(15_000), 15_000);
624 assert_eq!(clamp_yield(60_000), 30_000);
625 }
626
627 #[tokio::test]
628 async fn make_session_name_increments() {
629 let a = make_session_name();
630 let b = make_session_name();
631 assert_ne!(a, b);
632 assert!(a.starts_with("shell-"));
633 assert!(b.starts_with("shell-"));
634 }
635}