Skip to main content

synwire_sandbox/plugin/
command_tools.rs

1#![allow(
2    clippy::significant_drop_tightening,
3    clippy::option_if_let_else,
4    clippy::cast_possible_wrap,
5    clippy::cast_possible_truncation,
6    clippy::too_many_lines
7)]
8//! LLM-callable tools for spawning and interacting with sandboxed commands.
9//!
10//! These tools are the primary interface for an LLM agent to execute commands:
11//!
12//! - [`RunCommandTool`] — spawn a command, optionally wait for completion
13//! - [`OpenShellTool`] — open an interactive PTY session for HITL
14//! - [`ShellWriteTool`] — send input to a PTY session
15//! - [`ShellReadTool`] — read available output from a PTY session
16//! - [`ShellExpectTool`] — wait for a regex pattern (like `expect(1)`)
17//! - [`ShellExpectCasesTool`] — wait for one of N patterns (switch/case)
18//! - [`ShellBatchTool`] — run a send/expect sequence in one call
19//! - [`ShellSignalTool`] — send an OS signal to a shell session
20//!
21//! # goexpect compatibility
22//!
23//! All expect operations are backed by [`expectrl`], providing:
24//! - Full regex matching with capture groups
25//! - Multi-pattern switch/case via [`expectrl::Any`]
26//! - Configurable timeouts (0 = dump buffer, default = 30s)
27//! - Cross-platform PTY support (Linux + macOS)
28
29use std::sync::{Arc, OnceLock};
30use std::time::Duration;
31
32use serde_json::{Value, json};
33
34use synwire_core::BoxFuture;
35use synwire_core::error::{SynwireError, ToolError};
36use synwire_core::tools::{Tool, ToolOutput, ToolResultStatus, ToolSchema};
37
38use crate::output::OutputMode;
39use crate::process_registry::{ProcessRecord, monitor_child};
40
41use super::context::SandboxContext;
42use super::expect_engine::{
43    BatchStep, BatchStepResult, CaseTag, ExpectCase, expand_captures, extract_matches,
44    session_from_fd,
45};
46
47// ── helpers ──────────────────────────────────────────────────────────────────
48
49fn tool_err(msg: impl Into<String>) -> SynwireError {
50    SynwireError::Tool(ToolError::InvocationFailed {
51        message: msg.into(),
52    })
53}
54
55fn validation_err(msg: impl Into<String>) -> SynwireError {
56    SynwireError::Tool(ToolError::ValidationFailed {
57        message: msg.into(),
58    })
59}
60
61// ── RunCommandTool ──────────────────────────────────────────────────────────
62
63/// LLM tool: run a command inside the sandbox.
64///
65/// Two modes:
66/// - **Oneshot** (`wait: true`, default): blocks until the command exits, returns
67///   exit code + stdout + stderr in a single response.
68/// - **Background** (`wait: false`): returns immediately with the PID. Use
69///   `wait_for_process` and `read_process_output` to poll status and read output.
70pub struct RunCommandTool {
71    ctx: Arc<SandboxContext>,
72    schema: OnceLock<ToolSchema>,
73}
74
75impl RunCommandTool {
76    /// Create a new `run_command` tool.
77    pub const fn new(ctx: Arc<SandboxContext>) -> Self {
78        Self {
79            ctx,
80            schema: OnceLock::new(),
81        }
82    }
83}
84
85impl Tool for RunCommandTool {
86    fn name(&self) -> &'static str {
87        "run_command"
88    }
89
90    fn description(&self) -> &'static str {
91        "Run a command inside the sandbox. By default waits for completion \
92         and returns the exit code, stdout, and stderr. Set wait=false to run in \
93         background and get a PID back — then use wait_for_process and \
94         read_process_output to check status and read output."
95    }
96
97    fn schema(&self) -> &ToolSchema {
98        self.schema.get_or_init(|| ToolSchema {
99            name: "run_command".into(),
100            description: self.description().into(),
101            parameters: json!({
102                "type": "object",
103                "properties": {
104                    "command": {
105                        "type": "string",
106                        "description": "The command to execute (e.g., 'cargo', 'terraform')."
107                    },
108                    "args": {
109                        "type": "array",
110                        "items": { "type": "string" },
111                        "description": "Command arguments.",
112                        "default": []
113                    },
114                    "wait": {
115                        "type": "boolean",
116                        "description": "If true (default), wait for completion. If false, return PID for background monitoring.",
117                        "default": true
118                    },
119                    "timeout_secs": {
120                        "type": "integer",
121                        "description": "Max seconds to wait (only when wait=true). Default: 30.",
122                        "default": 30,
123                        "minimum": 1,
124                        "maximum": 3600
125                    }
126                },
127                "required": ["command"]
128            }),
129        })
130    }
131
132    #[cfg(target_os = "linux")]
133    fn invoke(&self, input: Value) -> BoxFuture<'_, Result<ToolOutput, SynwireError>> {
134        Box::pin(async move {
135            use crate::platform::linux::namespace::NamespaceContainer;
136
137            let command = input["command"]
138                .as_str()
139                .ok_or_else(|| validation_err("'command' is required"))?;
140
141            let args: Vec<String> = input["args"]
142                .as_array()
143                .map(|arr| {
144                    arr.iter()
145                        .filter_map(|v| v.as_str().map(String::from))
146                        .collect()
147                })
148                .unwrap_or_default();
149
150            let wait = input["wait"].as_bool().unwrap_or(true);
151            let timeout_secs = input["timeout_secs"].as_u64().unwrap_or(30);
152
153            let cc = NamespaceContainer::build_config(&self.ctx.config, command, args.clone());
154
155            let capture = self
156                .ctx
157                .container
158                .spawn_captured(&cc, OutputMode::Separate)
159                .map_err(|e| tool_err(format!("spawn failed: {e}")))?;
160
161            let pid = capture
162                .child
163                .id()
164                .ok_or_else(|| tool_err("child has no PID"))?;
165
166            let mut record = ProcessRecord::new(pid, command, args);
167            record.output = Some(Arc::clone(&capture.output));
168            {
169                let mut reg = self.ctx.registry.write().await;
170                reg.insert(record).map_err(|e| tool_err(e.to_string()))?;
171            }
172
173            if wait {
174                let mut child = capture.child;
175                let status =
176                    tokio::time::timeout(Duration::from_secs(timeout_secs), child.wait()).await;
177
178                let (exit_code, timed_out) = match status {
179                    Ok(Ok(s)) => (s.code().unwrap_or(-1), false),
180                    Ok(Err(e)) => return Err(tool_err(format!("wait failed: {e}"))),
181                    Err(_) => {
182                        let _ = child.kill().await;
183                        (-1, true)
184                    }
185                };
186
187                {
188                    let mut reg = self.ctx.registry.write().await;
189                    if timed_out {
190                        reg.mark_signaled(pid, 9);
191                    } else {
192                        reg.mark_exited(pid, exit_code);
193                    }
194                }
195
196                let stdout = capture
197                    .output
198                    .read_stdout()
199                    .map_err(|e| tool_err(e.to_string()))?;
200                let stderr = capture
201                    .output
202                    .read_stderr()
203                    .map_err(|e| tool_err(e.to_string()))?
204                    .unwrap_or_default();
205
206                let result = json!({
207                    "pid": pid, "exit_code": exit_code, "timed_out": timed_out,
208                    "stdout": stdout, "stderr": stderr,
209                });
210
211                Ok(ToolOutput {
212                    content: serde_json::to_string_pretty(&result)
213                        .map_err(|e| tool_err(e.to_string()))?,
214                    status: if exit_code == 0 {
215                        ToolResultStatus::Success
216                    } else {
217                        ToolResultStatus::Failure
218                    },
219                    ..Default::default()
220                })
221            } else {
222                monitor_child(capture.child, pid, Arc::clone(&self.ctx.registry));
223                let result = json!({
224                    "pid": pid, "status": "running",
225                    "hint": "Use wait_for_process to block until exit, or read_process_output to read partial output."
226                });
227                Ok(ToolOutput {
228                    content: serde_json::to_string_pretty(&result)
229                        .map_err(|e| tool_err(e.to_string()))?,
230                    ..Default::default()
231                })
232            }
233        })
234    }
235
236    #[cfg(not(target_os = "linux"))]
237    fn invoke(&self, _input: Value) -> BoxFuture<'_, Result<ToolOutput, SynwireError>> {
238        Box::pin(async { Err(tool_err("run_command is only supported on Linux")) })
239    }
240}
241
242// ── OpenShellTool ───────────────────────────────────────────────────────────
243
244/// LLM tool: open an interactive PTY shell session backed by expectrl.
245pub struct OpenShellTool {
246    ctx: Arc<SandboxContext>,
247    schema: OnceLock<ToolSchema>,
248}
249
250impl OpenShellTool {
251    /// Create a new `open_shell` tool.
252    pub const fn new(ctx: Arc<SandboxContext>) -> Self {
253        Self {
254            ctx,
255            schema: OnceLock::new(),
256        }
257    }
258}
259
260impl Tool for OpenShellTool {
261    fn name(&self) -> &'static str {
262        "open_shell"
263    }
264
265    fn description(&self) -> &'static str {
266        "Open an interactive shell session inside the sandbox. Returns a session_id. \
267         Use shell_expect, shell_write, shell_read, shell_expect_cases, or shell_batch \
268         to interact. For human-in-the-loop scenarios where the user needs to type \
269         (e.g., confirming terraform apply, entering credentials)."
270    }
271
272    fn schema(&self) -> &ToolSchema {
273        self.schema.get_or_init(|| ToolSchema {
274            name: "open_shell".into(),
275            description: self.description().into(),
276            parameters: json!({
277                "type": "object",
278                "properties": {
279                    "shell": { "type": "string", "description": "Shell to launch.", "default": "/bin/sh" }
280                },
281                "required": []
282            }),
283        })
284    }
285
286    #[cfg(target_os = "linux")]
287    fn invoke(&self, input: Value) -> BoxFuture<'_, Result<ToolOutput, SynwireError>> {
288        Box::pin(async move {
289            use crate::platform::linux::namespace::NamespaceContainer;
290
291            let shell = input["shell"].as_str().unwrap_or("/bin/sh");
292            let cc = NamespaceContainer::build_config(&self.ctx.config, shell, vec![]);
293
294            let pty_session = self
295                .ctx
296                .container
297                .spawn_interactive(&cc)
298                .map_err(|e| tool_err(format!("open_shell failed: {e}")))?;
299
300            // Wrap the PTY controller fd in an expectrl session
301            let expect_session = session_from_fd(pty_session.controller)
302                .map_err(|e| tool_err(format!("create expect session: {e}")))?;
303
304            let session_id = uuid::Uuid::new_v4().to_string();
305
306            {
307                let mut sessions = self.ctx.sessions.lock().await;
308                let _ = sessions.insert(session_id.clone(), expect_session);
309            }
310            {
311                let mut children = self.ctx.session_children.lock().await;
312                let _ = children.insert(session_id.clone(), pty_session.child);
313            }
314
315            let result = json!({
316                "session_id": session_id,
317                "shell": shell,
318                "hint": "Use shell_expect to wait for prompts, shell_write to send input, shell_batch for sequences."
319            });
320            Ok(ToolOutput {
321                content: serde_json::to_string_pretty(&result)
322                    .map_err(|e| tool_err(e.to_string()))?,
323                ..Default::default()
324            })
325        })
326    }
327
328    #[cfg(not(target_os = "linux"))]
329    fn invoke(&self, _input: Value) -> BoxFuture<'_, Result<ToolOutput, SynwireError>> {
330        Box::pin(async { Err(tool_err("open_shell is only supported on Linux")) })
331    }
332}
333
334// ── ShellWriteTool ──────────────────────────────────────────────────────────
335
336/// LLM tool: send input to a PTY session via expectrl.
337pub struct ShellWriteTool {
338    ctx: Arc<SandboxContext>,
339    schema: OnceLock<ToolSchema>,
340}
341
342impl ShellWriteTool {
343    /// Create a new `shell_write` tool.
344    pub const fn new(ctx: Arc<SandboxContext>) -> Self {
345        Self {
346            ctx,
347            schema: OnceLock::new(),
348        }
349    }
350}
351
352impl Tool for ShellWriteTool {
353    fn name(&self) -> &'static str {
354        "shell_write"
355    }
356
357    fn description(&self) -> &'static str {
358        "Send input text to an interactive shell session. Use \\n for Enter."
359    }
360
361    fn schema(&self) -> &ToolSchema {
362        self.schema.get_or_init(|| ToolSchema {
363            name: "shell_write".into(),
364            description: self.description().into(),
365            parameters: json!({
366                "type": "object",
367                "properties": {
368                    "session_id": { "type": "string", "description": "Session ID from open_shell." },
369                    "input": { "type": "string", "description": "Text to send. Use \\n for Enter." }
370                },
371                "required": ["session_id", "input"]
372            }),
373        })
374    }
375
376    fn invoke(&self, input: Value) -> BoxFuture<'_, Result<ToolOutput, SynwireError>> {
377        Box::pin(async move {
378            use expectrl::Expect;
379
380            let session_id = input["session_id"]
381                .as_str()
382                .ok_or_else(|| validation_err("'session_id' is required"))?;
383            let text = input["input"]
384                .as_str()
385                .ok_or_else(|| validation_err("'input' is required"))?;
386
387            let mut sessions = self.ctx.sessions.lock().await;
388            let session = sessions
389                .get_mut(session_id)
390                .ok_or_else(|| tool_err(format!("session '{session_id}' not found")))?;
391
392            session
393                .send(text)
394                .map_err(|e| tool_err(format!("send failed: {e}")))?;
395
396            Ok(ToolOutput {
397                content: format!("sent {} bytes to session {session_id}", text.len()),
398                ..Default::default()
399            })
400        })
401    }
402}
403
404// ── ShellReadTool ───────────────────────────────────────────────────────────
405
406/// LLM tool: non-blocking read of available PTY output.
407pub struct ShellReadTool {
408    ctx: Arc<SandboxContext>,
409    schema: OnceLock<ToolSchema>,
410}
411
412impl ShellReadTool {
413    /// Create a new `shell_read` tool.
414    pub const fn new(ctx: Arc<SandboxContext>) -> Self {
415        Self {
416            ctx,
417            schema: OnceLock::new(),
418        }
419    }
420}
421
422impl Tool for ShellReadTool {
423    fn name(&self) -> &'static str {
424        "shell_read"
425    }
426
427    fn description(&self) -> &'static str {
428        "Read available output from a shell session. Non-blocking — returns \
429         empty string if no output is available yet."
430    }
431
432    fn schema(&self) -> &ToolSchema {
433        self.schema.get_or_init(|| ToolSchema {
434            name: "shell_read".into(),
435            description: self.description().into(),
436            parameters: json!({
437                "type": "object",
438                "properties": {
439                    "session_id": { "type": "string", "description": "Session ID from open_shell." }
440                },
441                "required": ["session_id"]
442            }),
443        })
444    }
445
446    fn invoke(&self, input: Value) -> BoxFuture<'_, Result<ToolOutput, SynwireError>> {
447        Box::pin(async move {
448            use expectrl::Expect;
449
450            let session_id = input["session_id"]
451                .as_str()
452                .ok_or_else(|| validation_err("'session_id' is required"))?;
453
454            let mut sessions = self.ctx.sessions.lock().await;
455            let session = sessions
456                .get_mut(session_id)
457                .ok_or_else(|| tool_err(format!("session '{session_id}' not found")))?;
458
459            // Use expectrl's check with a regex that matches anything,
460            // effectively reading whatever is available.
461            let timeout_backup = Duration::from_millis(0);
462            session.set_expect_timeout(Some(timeout_backup));
463
464            // Try to read whatever is in the buffer — Eof or timeout both mean "nothing new"
465            let content = match session.expect(expectrl::Eof) {
466                Ok(captures) => {
467                    let before = captures.before();
468                    String::from_utf8_lossy(before).into_owned()
469                }
470                Err(_) => {
471                    // No data or timeout — check the buffer directly
472                    String::new()
473                }
474            };
475
476            // Restore a reasonable timeout
477            session.set_expect_timeout(Some(Duration::from_secs(30)));
478
479            Ok(ToolOutput {
480                content,
481                ..Default::default()
482            })
483        })
484    }
485}
486
487// ── ShellExpectTool ─────────────────────────────────────────────────────────
488
489/// LLM tool: wait for a regex pattern in PTY output.
490///
491/// Returns all accumulated output up to the match, plus captured groups.
492pub struct ShellExpectTool {
493    ctx: Arc<SandboxContext>,
494    schema: OnceLock<ToolSchema>,
495}
496
497impl ShellExpectTool {
498    /// Create a new `shell_expect` tool.
499    pub const fn new(ctx: Arc<SandboxContext>) -> Self {
500        Self {
501            ctx,
502            schema: OnceLock::new(),
503        }
504    }
505}
506
507impl Tool for ShellExpectTool {
508    fn name(&self) -> &'static str {
509        "shell_expect"
510    }
511
512    fn description(&self) -> &'static str {
513        "Wait for a regex pattern in the shell output. Returns all output \
514         captured up to the match, plus captured groups from the regex. \
515         Use this to detect prompts (e.g., 'Enter a value:', 'password:', \
516         '[y/N]') before deciding to respond or hand off to the user."
517    }
518
519    fn schema(&self) -> &ToolSchema {
520        self.schema.get_or_init(|| ToolSchema {
521            name: "shell_expect".into(),
522            description: self.description().into(),
523            parameters: json!({
524                "type": "object",
525                "properties": {
526                    "session_id": { "type": "string", "description": "Session ID from open_shell." },
527                    "pattern": {
528                        "type": "string",
529                        "description": "Regex pattern to match. Supports capture groups. Examples: 'Enter a value:', 'version (\\d+\\.\\d+)', '\\$\\s*$'."
530                    },
531                    "timeout_secs": {
532                        "type": "integer",
533                        "description": "Max seconds to wait. 0 = check buffer only. Default: 30.",
534                        "default": 30, "minimum": 0, "maximum": 300
535                    }
536                },
537                "required": ["session_id", "pattern"]
538            }),
539        })
540    }
541
542    fn invoke(&self, input: Value) -> BoxFuture<'_, Result<ToolOutput, SynwireError>> {
543        Box::pin(async move {
544            use expectrl::Expect;
545
546            let session_id = input["session_id"]
547                .as_str()
548                .ok_or_else(|| validation_err("'session_id' is required"))?;
549            let pattern = input["pattern"]
550                .as_str()
551                .ok_or_else(|| validation_err("'pattern' is required"))?;
552            let timeout_secs = input["timeout_secs"].as_u64().unwrap_or(30);
553
554            let re = expectrl::Regex(pattern.to_string());
555
556            let mut sessions = self.ctx.sessions.lock().await;
557            let session = sessions
558                .get_mut(session_id)
559                .ok_or_else(|| tool_err(format!("session '{session_id}' not found")))?;
560
561            session.set_expect_timeout(Some(Duration::from_secs(timeout_secs)));
562
563            match session.expect(re) {
564                Ok(captures) => {
565                    let before = String::from_utf8_lossy(captures.before()).into_owned();
566                    let matched_groups = extract_matches(&captures);
567
568                    let mut output = before;
569                    if let Some(full_match) = matched_groups.first() {
570                        output.push_str(full_match);
571                    }
572
573                    let result = json!({
574                        "matched": true,
575                        "pattern": pattern,
576                        "output": output,
577                        "captures": matched_groups,
578                    });
579                    Ok(ToolOutput {
580                        content: serde_json::to_string_pretty(&result)
581                            .map_err(|e| tool_err(e.to_string()))?,
582                        ..Default::default()
583                    })
584                }
585                Err(e) => {
586                    let result = json!({
587                        "matched": false,
588                        "pattern": pattern,
589                        "output": "",
590                        "captures": [],
591                        "reason": e.to_string(),
592                    });
593                    Ok(ToolOutput {
594                        content: serde_json::to_string_pretty(&result)
595                            .map_err(|e| tool_err(e.to_string()))?,
596                        status: ToolResultStatus::Failure,
597                        ..Default::default()
598                    })
599                }
600            }
601        })
602    }
603}
604
605// ── ShellExpectCasesTool ────────────────────────────────────────────────────
606
607/// LLM tool: wait for one of several regex patterns (switch/case).
608///
609/// Maps to goexpect's `ExpectSwitchCase`. Returns which case matched first,
610/// captured groups, and optionally auto-sends a response.
611pub struct ShellExpectCasesTool {
612    ctx: Arc<SandboxContext>,
613    schema: OnceLock<ToolSchema>,
614}
615
616impl ShellExpectCasesTool {
617    /// Create a new `shell_expect_cases` tool.
618    pub const fn new(ctx: Arc<SandboxContext>) -> Self {
619        Self {
620            ctx,
621            schema: OnceLock::new(),
622        }
623    }
624}
625
626impl Tool for ShellExpectCasesTool {
627    fn name(&self) -> &'static str {
628        "shell_expect_cases"
629    }
630
631    fn description(&self) -> &'static str {
632        "Wait for one of several regex patterns (switch/case). Returns which \
633         case matched first, plus captures. Each case has a tag ('ok', 'fail', \
634         'continue', 'needs_user') and an optional auto-response. Use this \
635         when the CLI might show different prompts (success, error, auth prompt)."
636    }
637
638    fn schema(&self) -> &ToolSchema {
639        self.schema.get_or_init(|| ToolSchema {
640            name: "shell_expect_cases".into(),
641            description: self.description().into(),
642            parameters: json!({
643                "type": "object",
644                "properties": {
645                    "session_id": { "type": "string", "description": "Session ID from open_shell." },
646                    "cases": {
647                        "type": "array",
648                        "items": {
649                            "type": "object",
650                            "properties": {
651                                "pattern": { "type": "string", "description": "Regex pattern." },
652                                "tag": {
653                                    "type": "string",
654                                    "enum": ["ok", "fail", "continue", "needs_user", "next"],
655                                    "description": "Flow control tag."
656                                },
657                                "respond": { "type": "string", "description": "Auto-response to send if matched. $1/$2 for captures." },
658                                "label": { "type": "string", "description": "Human-readable label." }
659                            },
660                            "required": ["pattern", "tag"]
661                        },
662                        "description": "Cases to match. First match wins."
663                    },
664                    "timeout_secs": {
665                        "type": "integer", "description": "Max seconds to wait. Default: 30.",
666                        "default": 30, "minimum": 0, "maximum": 300
667                    }
668                },
669                "required": ["session_id", "cases"]
670            }),
671        })
672    }
673
674    fn invoke(&self, input: Value) -> BoxFuture<'_, Result<ToolOutput, SynwireError>> {
675        Box::pin(async move {
676            use expectrl::Expect;
677
678            let session_id = input["session_id"]
679                .as_str()
680                .ok_or_else(|| validation_err("'session_id' is required"))?;
681            let timeout_secs = input["timeout_secs"].as_u64().unwrap_or(30);
682
683            let cases: Vec<ExpectCase> = serde_json::from_value(input["cases"].clone())
684                .map_err(|e| validation_err(format!("invalid 'cases': {e}")))?;
685
686            if cases.is_empty() {
687                return Err(validation_err("'cases' must not be empty"));
688            }
689
690            let mut sessions = self.ctx.sessions.lock().await;
691            let session = sessions
692                .get_mut(session_id)
693                .ok_or_else(|| tool_err(format!("session '{session_id}' not found")))?;
694
695            session.set_expect_timeout(Some(Duration::from_secs(timeout_secs)));
696
697            // Build expectrl needles inside the lock scope (dyn Needle is !Send)
698            let needles: Vec<Box<dyn expectrl::Needle>> = cases
699                .iter()
700                .map(|c| -> Box<dyn expectrl::Needle> {
701                    Box::new(expectrl::Regex(c.pattern.clone()))
702                })
703                .collect();
704            let any = expectrl::Any::boxed(needles);
705
706            match session.expect(any) {
707                Ok(captures) => {
708                    let before = String::from_utf8_lossy(captures.before()).into_owned();
709                    let groups = extract_matches(&captures);
710
711                    // Determine which case matched by checking each pattern
712                    let full_match = groups.first().cloned().unwrap_or_default();
713                    let mut matched_idx = None;
714                    for (i, case) in cases.iter().enumerate() {
715                        if let std::result::Result::Ok(re) = regex::Regex::new(&case.pattern)
716                            && re.is_match(&full_match)
717                        {
718                            matched_idx = Some(i);
719                            break;
720                        }
721                    }
722
723                    let idx = matched_idx.unwrap_or(0);
724                    let matched_case = &cases[idx];
725
726                    // Auto-respond if configured
727                    if let Some(ref respond) = matched_case.respond {
728                        let expanded = expand_captures(respond, &groups);
729                        let _send_result = session.send(&expanded);
730                    }
731
732                    let mut output = before;
733                    output.push_str(&full_match);
734
735                    let result = json!({
736                        "matched": true,
737                        "matched_case": idx,
738                        "tag": matched_case.tag,
739                        "label": matched_case.label,
740                        "output": output,
741                        "captures": groups,
742                    });
743
744                    let status = match matched_case.tag {
745                        CaseTag::Fail => ToolResultStatus::Failure,
746                        _ => ToolResultStatus::Success,
747                    };
748
749                    Ok(ToolOutput {
750                        content: serde_json::to_string_pretty(&result)
751                            .map_err(|e| tool_err(e.to_string()))?,
752                        status,
753                        ..Default::default()
754                    })
755                }
756                Err(e) => {
757                    let result = json!({
758                        "matched": false,
759                        "output": "",
760                        "reason": e.to_string(),
761                    });
762                    Ok(ToolOutput {
763                        content: serde_json::to_string_pretty(&result)
764                            .map_err(|e| tool_err(e.to_string()))?,
765                        status: ToolResultStatus::Failure,
766                        ..Default::default()
767                    })
768                }
769            }
770        })
771    }
772}
773
774// ── ShellBatchTool ──────────────────────────────────────────────────────────
775
776/// LLM tool: run a sequence of send/expect steps in one call.
777///
778/// Maps to goexpect's `ExpectBatch`. Stops on first failure.
779pub struct ShellBatchTool {
780    ctx: Arc<SandboxContext>,
781    schema: OnceLock<ToolSchema>,
782}
783
784impl ShellBatchTool {
785    /// Create a new `shell_batch` tool.
786    pub const fn new(ctx: Arc<SandboxContext>) -> Self {
787        Self {
788            ctx,
789            schema: OnceLock::new(),
790        }
791    }
792}
793
794impl Tool for ShellBatchTool {
795    fn name(&self) -> &'static str {
796        "shell_batch"
797    }
798
799    fn description(&self) -> &'static str {
800        "Run a sequence of send/expect operations in one call. Each step is \
801         either 'send' (write to PTY), 'expect' (wait for pattern), \
802         'expect_cases' (wait for one of N patterns), or 'signal' (send OS signal). \
803         Stops on first failure. Returns results for each completed step."
804    }
805
806    fn schema(&self) -> &ToolSchema {
807        self.schema.get_or_init(|| ToolSchema {
808            name: "shell_batch".into(),
809            description: self.description().into(),
810            parameters: json!({
811                "type": "object",
812                "properties": {
813                    "session_id": { "type": "string", "description": "Session ID from open_shell." },
814                    "steps": {
815                        "type": "array",
816                        "items": {
817                            "type": "object",
818                            "properties": {
819                                "type": { "type": "string", "enum": ["send", "expect", "expect_cases", "signal"] },
820                                "input": { "type": "string", "description": "Text to send (for 'send' steps)." },
821                                "pattern": { "type": "string", "description": "Regex pattern (for 'expect' steps)." },
822                                "cases": { "type": "array", "description": "Cases (for 'expect_cases' steps)." },
823                                "signal": { "type": "string", "description": "Signal name (for 'signal' steps)." },
824                                "timeout_secs": { "type": "integer", "description": "Per-step timeout override." }
825                            },
826                            "required": ["type"]
827                        }
828                    },
829                    "timeout_secs": {
830                        "type": "integer",
831                        "description": "Default timeout for expect steps. Default: 30.",
832                        "default": 30
833                    }
834                },
835                "required": ["session_id", "steps"]
836            }),
837        })
838    }
839
840    fn invoke(&self, input: Value) -> BoxFuture<'_, Result<ToolOutput, SynwireError>> {
841        Box::pin(async move {
842            use expectrl::Expect;
843
844            let session_id = input["session_id"]
845                .as_str()
846                .ok_or_else(|| validation_err("'session_id' is required"))?;
847            let default_timeout = input["timeout_secs"].as_u64().unwrap_or(30);
848
849            let steps: Vec<BatchStep> = serde_json::from_value(input["steps"].clone())
850                .map_err(|e| validation_err(format!("invalid 'steps': {e}")))?;
851
852            let mut results: Vec<BatchStepResult> = Vec::new();
853
854            let mut sessions = self.ctx.sessions.lock().await;
855            let session = sessions
856                .get_mut(session_id)
857                .ok_or_else(|| tool_err(format!("session '{session_id}' not found")))?;
858
859            for (i, step) in steps.iter().enumerate() {
860                let step_result = match step {
861                    BatchStep::Send { input: text } => match session.send(text.as_str()) {
862                        Ok(()) => BatchStepResult {
863                            index: i,
864                            step_type: "send".into(),
865                            output: None,
866                            captures: vec![],
867                            matched_case: None,
868                            tag: None,
869                            label: None,
870                            success: true,
871                            error: None,
872                        },
873                        Err(e) => BatchStepResult {
874                            index: i,
875                            step_type: "send".into(),
876                            output: None,
877                            captures: vec![],
878                            matched_case: None,
879                            tag: None,
880                            label: None,
881                            success: false,
882                            error: Some(e.to_string()),
883                        },
884                    },
885                    BatchStep::Expect {
886                        pattern,
887                        timeout_secs,
888                    } => {
889                        let timeout = timeout_secs.unwrap_or(default_timeout);
890                        session.set_expect_timeout(Some(Duration::from_secs(timeout)));
891
892                        match session.expect(expectrl::Regex(pattern.clone())) {
893                            Ok(captures) => {
894                                let before =
895                                    String::from_utf8_lossy(captures.before()).into_owned();
896                                let groups = extract_matches(&captures);
897                                let full = groups.first().cloned().unwrap_or_default();
898                                BatchStepResult {
899                                    index: i,
900                                    step_type: "expect".into(),
901                                    output: Some(format!("{before}{full}")),
902                                    captures: groups,
903                                    matched_case: None,
904                                    tag: None,
905                                    label: None,
906                                    success: true,
907                                    error: None,
908                                }
909                            }
910                            Err(e) => BatchStepResult {
911                                index: i,
912                                step_type: "expect".into(),
913                                output: None,
914                                captures: vec![],
915                                matched_case: None,
916                                tag: None,
917                                label: None,
918                                success: false,
919                                error: Some(e.to_string()),
920                            },
921                        }
922                    }
923                    BatchStep::ExpectCases {
924                        cases,
925                        timeout_secs,
926                    } => {
927                        let timeout = timeout_secs.unwrap_or(default_timeout);
928                        session.set_expect_timeout(Some(Duration::from_secs(timeout)));
929
930                        let needles: Vec<Box<dyn expectrl::Needle>> = cases
931                            .iter()
932                            .map(|c| -> Box<dyn expectrl::Needle> {
933                                Box::new(expectrl::Regex(c.pattern.clone()))
934                            })
935                            .collect();
936                        let any = expectrl::Any::boxed(needles);
937
938                        match session.expect(any) {
939                            Ok(captures) => {
940                                let before =
941                                    String::from_utf8_lossy(captures.before()).into_owned();
942                                let groups = extract_matches(&captures);
943                                let full = groups.first().cloned().unwrap_or_default();
944
945                                let mut idx = 0;
946                                for (j, case) in cases.iter().enumerate() {
947                                    if let std::result::Result::Ok(re) =
948                                        regex::Regex::new(&case.pattern)
949                                        && re.is_match(&full)
950                                    {
951                                        idx = j;
952                                        break;
953                                    }
954                                }
955
956                                let matched_case = &cases[idx];
957                                if let Some(ref respond) = matched_case.respond {
958                                    let expanded = expand_captures(respond, &groups);
959                                    let _r = session.send(&expanded);
960                                }
961
962                                let success = matched_case.tag != CaseTag::Fail;
963                                BatchStepResult {
964                                    index: i,
965                                    step_type: "expect_cases".into(),
966                                    output: Some(format!("{before}{full}")),
967                                    captures: groups,
968                                    matched_case: Some(idx),
969                                    tag: Some(matched_case.tag.clone()),
970                                    label: matched_case.label.clone(),
971                                    success,
972                                    error: None,
973                                }
974                            }
975                            Err(e) => BatchStepResult {
976                                index: i,
977                                step_type: "expect_cases".into(),
978                                output: None,
979                                captures: vec![],
980                                matched_case: None,
981                                tag: None,
982                                label: None,
983                                success: false,
984                                error: Some(e.to_string()),
985                            },
986                        }
987                    }
988                    BatchStep::Signal { signal } => {
989                        // Signals are handled via the child process, not the PTY session
990                        BatchStepResult {
991                            index: i,
992                            step_type: "signal".into(),
993                            output: None,
994                            captures: vec![],
995                            matched_case: None,
996                            tag: None,
997                            label: None,
998                            success: false,
999                            error: Some(format!("use shell_signal for signal '{signal}'")),
1000                        }
1001                    }
1002                };
1003
1004                let failed = !step_result.success;
1005                results.push(step_result);
1006                if failed {
1007                    break;
1008                }
1009            }
1010
1011            let all_ok = results.iter().all(|r| r.success);
1012            let result =
1013                json!({ "steps": results, "completed": results.len(), "total": steps.len() });
1014
1015            Ok(ToolOutput {
1016                content: serde_json::to_string_pretty(&result)
1017                    .map_err(|e| tool_err(e.to_string()))?,
1018                status: if all_ok {
1019                    ToolResultStatus::Success
1020                } else {
1021                    ToolResultStatus::Failure
1022                },
1023                ..Default::default()
1024            })
1025        })
1026    }
1027}
1028
1029// ── ShellSignalTool ─────────────────────────────────────────────────────────
1030
1031/// LLM tool: send an OS signal to a shell session's process.
1032pub struct ShellSignalTool {
1033    ctx: Arc<SandboxContext>,
1034    schema: OnceLock<ToolSchema>,
1035}
1036
1037impl ShellSignalTool {
1038    /// Create a new `shell_signal` tool.
1039    pub const fn new(ctx: Arc<SandboxContext>) -> Self {
1040        Self {
1041            ctx,
1042            schema: OnceLock::new(),
1043        }
1044    }
1045}
1046
1047impl Tool for ShellSignalTool {
1048    fn name(&self) -> &'static str {
1049        "shell_signal"
1050    }
1051
1052    fn description(&self) -> &'static str {
1053        "Send an OS signal to a shell session's process. Use SIGINT (Ctrl-C) \
1054         to cancel a running command, SIGTERM to terminate gracefully."
1055    }
1056
1057    fn schema(&self) -> &ToolSchema {
1058        self.schema.get_or_init(|| ToolSchema {
1059            name: "shell_signal".into(),
1060            description: self.description().into(),
1061            parameters: json!({
1062                "type": "object",
1063                "properties": {
1064                    "session_id": { "type": "string", "description": "Session ID from open_shell." },
1065                    "signal": {
1066                        "type": "string",
1067                        "enum": ["SIGINT", "SIGTERM", "SIGKILL", "SIGHUP", "SIGSTOP", "SIGCONT"],
1068                        "description": "Signal to send. Default: SIGINT.",
1069                        "default": "SIGINT"
1070                    }
1071                },
1072                "required": ["session_id"]
1073            }),
1074        })
1075    }
1076
1077    #[cfg(any(target_os = "linux", target_os = "macos"))]
1078    fn invoke(&self, input: Value) -> BoxFuture<'_, Result<ToolOutput, SynwireError>> {
1079        Box::pin(async move {
1080            let session_id = input["session_id"]
1081                .as_str()
1082                .ok_or_else(|| validation_err("'session_id' is required"))?;
1083            let signal_name = input["signal"].as_str().unwrap_or("SIGINT");
1084
1085            let children = self.ctx.session_children.lock().await;
1086            let child = children
1087                .get(session_id)
1088                .ok_or_else(|| tool_err(format!("session '{session_id}' not found")))?;
1089
1090            let pid = child
1091                .id()
1092                .ok_or_else(|| tool_err("session process has no PID"))?;
1093
1094            let sig = match signal_name {
1095                "SIGINT" => nix::sys::signal::Signal::SIGINT,
1096                "SIGTERM" => nix::sys::signal::Signal::SIGTERM,
1097                "SIGKILL" => nix::sys::signal::Signal::SIGKILL,
1098                "SIGHUP" => nix::sys::signal::Signal::SIGHUP,
1099                "SIGSTOP" => nix::sys::signal::Signal::SIGSTOP,
1100                "SIGCONT" => nix::sys::signal::Signal::SIGCONT,
1101                other => return Err(validation_err(format!("unknown signal: {other}"))),
1102            };
1103
1104            nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid as i32), sig)
1105                .map_err(|e| tool_err(format!("kill({pid}, {signal_name}): {e}")))?;
1106
1107            Ok(ToolOutput {
1108                content: format!("sent {signal_name} to session {session_id} (pid {pid})"),
1109                ..Default::default()
1110            })
1111        })
1112    }
1113
1114    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1115    fn invoke(&self, _input: Value) -> BoxFuture<'_, Result<ToolOutput, SynwireError>> {
1116        Box::pin(async { Err(tool_err("shell_signal is only supported on Unix")) })
1117    }
1118}