synwire_sandbox/plugin/expect_engine.rs
1//! Expect engine backed by [`expectrl`] — implements goexpect-equivalent semantics.
2//!
3//! This module provides:
4//!
5//! - [`PtyStream`]: a wrapper around `OwnedFd` that implements `Read + Write +
6//! NonBlocking` for use with expectrl's `Session`.
7//! - [`StubProcess`]: a `Healthcheck` impl for processes managed externally
8//! (by runc/runsc) rather than spawned by expectrl.
9//! - [`ExpectCase`], [`BatchStep`], etc.: types for the LLM tool layer.
10//!
11//! # goexpect compatibility
12//!
13//! | goexpect | expectrl | Our tool |
14//! |----------|----------|----------|
15//! | `Expect(re, timeout)` | `session.expect(Regex("..."))` | `shell_expect` |
16//! | `ExpectSwitchCase([]Caser)` | `session.expect(Any::boxed(..))` | `shell_expect_cases` |
17//! | `ExpectBatch([]Batcher)` | Sequential send+expect | `shell_batch` |
18//! | `Send(string)` | `session.send(string)` | `shell_write` |
19//! | `SendSignal(sig)` | External kill | `shell_signal` |
20//! | Tags: OK/Fail/Continue/Next | [`CaseTag`] | flow control |
21//!
22//! # macOS compatibility
23//!
24//! `expectrl` handles platform differences internally. On macOS it uses
25//! `posix_openpt` / `grantpt` / `unlockpt` for PTY allocation and
26//! `fcntl(O_NONBLOCK)` for non-blocking I/O — no Linux-specific APIs needed
27//! in this module.
28
29use std::io::{self, Read, Write};
30use std::os::fd::{AsRawFd, OwnedFd};
31
32use serde::{Deserialize, Serialize};
33
34// ── PtyStream ────────────────────────────────────────────────────────────────
35
36/// A stream wrapper around an `OwnedFd` that implements the traits expectrl
37/// needs: `Read + Write + NonBlocking`.
38///
39/// Used to wrap the PTY controller fd received from the OCI runtime's console
40/// socket. Works on both Linux and macOS since it only uses POSIX `read`,
41/// `write`, and `fcntl`.
42#[derive(Debug)]
43pub struct PtyStream {
44 fd: OwnedFd,
45}
46
47impl PtyStream {
48 /// Wrap an owned PTY controller file descriptor.
49 pub const fn new(fd: OwnedFd) -> Self {
50 Self { fd }
51 }
52
53 /// Access the underlying fd.
54 pub const fn as_fd(&self) -> &OwnedFd {
55 &self.fd
56 }
57}
58
59impl Read for PtyStream {
60 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
61 nix::unistd::read(self.fd.as_raw_fd(), buf).map_err(io::Error::from)
62 }
63}
64
65impl Write for PtyStream {
66 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
67 nix::unistd::write(&self.fd, buf).map_err(io::Error::from)
68 }
69
70 fn flush(&mut self) -> io::Result<()> {
71 Ok(())
72 }
73}
74
75impl expectrl::process::NonBlocking for PtyStream {
76 fn set_blocking(&mut self, on: bool) -> io::Result<()> {
77 let raw_fd = self.fd.as_raw_fd();
78 let flags =
79 nix::fcntl::fcntl(raw_fd, nix::fcntl::FcntlArg::F_GETFL).map_err(io::Error::from)?;
80 let mut oflags = nix::fcntl::OFlag::from_bits_truncate(flags);
81 if on {
82 oflags.remove(nix::fcntl::OFlag::O_NONBLOCK);
83 } else {
84 oflags.insert(nix::fcntl::OFlag::O_NONBLOCK);
85 }
86 let _rc = nix::fcntl::fcntl(raw_fd, nix::fcntl::FcntlArg::F_SETFL(oflags))
87 .map_err(io::Error::from)?;
88 Ok(())
89 }
90}
91
92// ── StubProcess ──────────────────────────────────────────────────────────────
93
94/// A stub process for use with expectrl's `Session` when the actual process
95/// is managed externally (by runc/runsc).
96///
97/// `is_alive()` always returns `true` — the real liveness check is done via
98/// the `ProcessRegistry` and the OCI runtime lifecycle.
99#[derive(Debug)]
100pub struct StubProcess;
101
102impl expectrl::process::Healthcheck for StubProcess {
103 type Status = bool;
104
105 fn get_status(&self) -> io::Result<Self::Status> {
106 Ok(true)
107 }
108
109 fn is_alive(&self) -> io::Result<bool> {
110 Ok(true)
111 }
112}
113
114// ── Types for LLM tool layer ─────────────────────────────────────────────────
115
116/// Flow control tag for expect cases (maps to goexpect's `Tag`).
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[non_exhaustive]
119pub enum CaseTag {
120 /// Match accepted — stop matching and return success.
121 #[serde(rename = "ok")]
122 Ok,
123 /// Match indicates failure — stop and return error.
124 #[serde(rename = "fail")]
125 Fail,
126 /// Match found but keep trying — retry from the current buffer position.
127 #[serde(rename = "continue")]
128 Continue,
129 /// Skip to the next batch step without consuming the match.
130 #[serde(rename = "next")]
131 Next,
132 /// Requires human intervention — hand off to user.
133 #[serde(rename = "needs_user")]
134 NeedsUser,
135}
136
137/// A single case in a switch/case expect operation.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ExpectCase {
140 /// Regex pattern to match.
141 pub pattern: String,
142 /// Flow control tag when this case matches.
143 pub tag: CaseTag,
144 /// Optional auto-response to send when this case matches.
145 /// Supports `$1`, `$2` etc. for captured group substitution.
146 #[serde(default)]
147 pub respond: Option<String>,
148 /// Human-readable label for the case (returned to the LLM).
149 #[serde(default)]
150 pub label: Option<String>,
151}
152
153/// A single step in a batch sequence.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[serde(tag = "type")]
156#[non_exhaustive]
157pub enum BatchStep {
158 /// Send text to the PTY.
159 #[serde(rename = "send")]
160 Send {
161 /// Text to send (use `\n` for Enter).
162 input: String,
163 },
164 /// Wait for a single regex pattern.
165 #[serde(rename = "expect")]
166 Expect {
167 /// Regex pattern to match.
168 pattern: String,
169 /// Per-step timeout override (seconds).
170 #[serde(default)]
171 timeout_secs: Option<u64>,
172 },
173 /// Wait for one of several patterns (switch/case).
174 #[serde(rename = "expect_cases")]
175 ExpectCases {
176 /// Cases to match against.
177 cases: Vec<ExpectCase>,
178 /// Per-step timeout override (seconds).
179 #[serde(default)]
180 timeout_secs: Option<u64>,
181 },
182 /// Send an OS signal to the session's process.
183 #[serde(rename = "signal")]
184 Signal {
185 /// Signal name (e.g., "SIGINT", "SIGTERM").
186 signal: String,
187 },
188}
189
190/// Result of a single batch step.
191#[derive(Debug, Clone, Serialize)]
192pub struct BatchStepResult {
193 /// Step index (0-based).
194 pub index: usize,
195 /// The step type that was executed.
196 pub step_type: String,
197 /// Output captured during this step (for expect steps).
198 #[serde(skip_serializing_if = "Option::is_none")]
199 pub output: Option<String>,
200 /// Captured regex groups (for expect steps).
201 #[serde(skip_serializing_if = "Vec::is_empty")]
202 pub captures: Vec<String>,
203 /// Which case matched (for `expect_cases` steps).
204 #[serde(skip_serializing_if = "Option::is_none")]
205 pub matched_case: Option<usize>,
206 /// Tag of the matched case.
207 #[serde(skip_serializing_if = "Option::is_none")]
208 pub tag: Option<CaseTag>,
209 /// Label of the matched case.
210 #[serde(skip_serializing_if = "Option::is_none")]
211 pub label: Option<String>,
212 /// Whether this step succeeded.
213 pub success: bool,
214 /// Error message if the step failed.
215 #[serde(skip_serializing_if = "Option::is_none")]
216 pub error: Option<String>,
217}
218
219/// Substitute `$1`, `$2`, etc. in `template` with captured groups.
220pub fn expand_captures(template: &str, captures: &[String]) -> String {
221 let mut result = template.to_string();
222 for (i, cap) in captures.iter().enumerate() {
223 let placeholder = format!("${i}");
224 result = result.replace(&placeholder, cap);
225 }
226 result
227}
228
229/// Extract captures from expectrl's `Captures` into a `Vec<String>`.
230pub fn extract_matches(captures: &expectrl::Captures) -> Vec<String> {
231 let mut result = Vec::new();
232 for m in captures.matches() {
233 result.push(String::from_utf8_lossy(m).into_owned());
234 }
235 result
236}
237
238/// Create an expectrl `Session` from a PTY controller fd.
239///
240/// The returned session is ready for `expect`, `send`, `check` etc.
241/// The `StubProcess` reports the process as always alive — actual lifecycle
242/// management is handled by the OCI runtime.
243pub fn session_from_fd(fd: OwnedFd) -> io::Result<expectrl::Session<StubProcess, PtyStream>> {
244 let stream = PtyStream::new(fd);
245 expectrl::Session::new(StubProcess, stream)
246}