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)]
8use 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
47fn 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
61pub struct RunCommandTool {
71 ctx: Arc<SandboxContext>,
72 schema: OnceLock<ToolSchema>,
73}
74
75impl RunCommandTool {
76 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
242pub struct OpenShellTool {
246 ctx: Arc<SandboxContext>,
247 schema: OnceLock<ToolSchema>,
248}
249
250impl OpenShellTool {
251 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 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
334pub struct ShellWriteTool {
338 ctx: Arc<SandboxContext>,
339 schema: OnceLock<ToolSchema>,
340}
341
342impl ShellWriteTool {
343 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
404pub struct ShellReadTool {
408 ctx: Arc<SandboxContext>,
409 schema: OnceLock<ToolSchema>,
410}
411
412impl ShellReadTool {
413 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 let timeout_backup = Duration::from_millis(0);
462 session.set_expect_timeout(Some(timeout_backup));
463
464 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 String::new()
473 }
474 };
475
476 session.set_expect_timeout(Some(Duration::from_secs(30)));
478
479 Ok(ToolOutput {
480 content,
481 ..Default::default()
482 })
483 })
484 }
485}
486
487pub struct ShellExpectTool {
493 ctx: Arc<SandboxContext>,
494 schema: OnceLock<ToolSchema>,
495}
496
497impl ShellExpectTool {
498 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
605pub struct ShellExpectCasesTool {
612 ctx: Arc<SandboxContext>,
613 schema: OnceLock<ToolSchema>,
614}
615
616impl ShellExpectCasesTool {
617 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 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 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 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
774pub struct ShellBatchTool {
780 ctx: Arc<SandboxContext>,
781 schema: OnceLock<ToolSchema>,
782}
783
784impl ShellBatchTool {
785 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 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
1029pub struct ShellSignalTool {
1033 ctx: Arc<SandboxContext>,
1034 schema: OnceLock<ToolSchema>,
1035}
1036
1037impl ShellSignalTool {
1038 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}