Skip to main content

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}