Skip to main content

sac/tools/
exec_command.rs

1//! Terminal-backed command execution tools: `exec_command` and `write_stdin`.
2
3use anyhow::{anyhow, Result};
4use serde_json::Value;
5use std::path::PathBuf;
6
7use crate::tools::ToolRuntime;
8use crate::types::{FunctionDef, ToolDefinition};
9
10// ============================================================================
11// exec_command
12// ============================================================================
13
14pub 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
97// ============================================================================
98// write_stdin
99// ============================================================================
100
101pub 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
152// ============================================================================
153// Helpers
154// ============================================================================
155
156fn 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// ============================================================================
179// Tests
180// ============================================================================
181
182#[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    // ------------------------------------------------------------------
208    // exec_command
209    // ------------------------------------------------------------------
210
211    #[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    // ------------------------------------------------------------------
403    // write_stdin
404    // ------------------------------------------------------------------
405
406    #[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        // <C-c> is a signal, not buffered — must pass without terminator
495        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        // <C-c> may kill the process, so output may vary; we just care that
507        // validation didn't reject it.
508        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    // ------------------------------------------------------------------
617    // helpers
618    // ------------------------------------------------------------------
619
620    #[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}