Skip to main content

fno_agents/
loop_dispatch.rs

1//! Shellout dispatcher that wraps the bash driver-lib contract.
2//!
3//! ## Design: the shellout seam (grilled decision 8)
4//!
5//! The Rust `Dispatcher` trait exists so a future daemon/PTY implementation can
6//! be wired in as a drop-in replacement without touching the loop runtime or the
7//! `TargetQueue`. This file implements the bash-shellout side only: it sources
8//! `driver-<name>.sh` and calls `driver_invoke`, delegating all session logic to
9//! the bash lib. The Rust side NEVER reimplements driver behavior; it only manages
10//! process lifecycle, env passthrough, and exit-code collection.
11//!
12//! The seam is stable once the trait is locked (Task 1.1). A future PTY
13//! dispatcher can implement `Dispatcher` + `Session` and be swapped in by the
14//! CLI flag `--dispatcher pty` without changing any other code.
15//!
16//! ## Binary resolution (preflight)
17//!
18//! Mirrors `scripts/run-target-loop.sh:144-150`. The Rust side validates the
19//! driver whitelist and binary availability before any dispatch, so a missing
20//! binary fails loudly at startup rather than inside iteration N.
21
22use crate::loop_runtime::{DispatchCtx, Dispatcher, LoopError, Session, Unit};
23use std::os::unix::process::ExitStatusExt;
24use std::path::{Path, PathBuf};
25use std::process::{Child, Command};
26
27// ── public API ─────────────────────────────────────────────────────────────────
28
29/// Validate the driver name and confirm the driver lib file exists, the
30/// driver binary is on PATH, and the lib defines `driver_invoke`.
31///
32/// `driver`: one of `claude-code`, `hermes`, `openclaw` (whitelist-enforced).
33/// `lib_dir`: directory containing `driver-<driver>.sh`.
34/// `cli_alias`: optional CLI alias from `--cli` flag (F2). Precedence for
35///   binary resolution: `$CLAUDE_CLI` env > `cli_alias` > `$CLI` env > "claude".
36///
37/// Returns the resolved path to the driver lib file on success.
38/// Returns `LoopError::Config` for whitelist/path/function errors,
39/// `LoopError::Dispatch` for a missing binary (the caller maps that to exit 77).
40pub fn preflight(
41    driver: &str,
42    lib_dir: &Path,
43    cli_alias: Option<&str>,
44) -> Result<PathBuf, LoopError> {
45    // Whitelist enforced exactly like run-target-loop.sh:144-150 to prevent
46    // path traversal and shell injection via driver names.
47    const ALLOWED: &[&str] = &["claude-code", "hermes", "openclaw"];
48    if !ALLOWED.contains(&driver) {
49        return Err(LoopError::Config(format!(
50            "invalid dispatcher '{driver}': must be one of {:?} (whitelist)",
51            ALLOWED
52        )));
53    }
54
55    // Lib file must exist.
56    let lib_path = lib_dir.join(format!("driver-{driver}.sh"));
57    if !lib_path.exists() {
58        return Err(LoopError::Config(format!(
59            "driver lib not found: {}",
60            lib_path.display()
61        )));
62    }
63
64    // F2: binary resolution uses cli_alias (not process env CLI) so preflight
65    // checks the same binary the dispatcher will actually use.
66    let binary = resolve_driver_binary(driver, cli_alias);
67    if which_binary(&binary).is_none() {
68        return Err(LoopError::Dispatch(format!(
69            "missing binary '{binary}': required by dispatcher '{driver}' but not found on PATH"
70        )));
71    }
72
73    // F5: probe that the lib defines driver_invoke (a lib without it produces
74    // an infinite budget-burning re-dispatch loop; fail loudly at preflight).
75    {
76        let lib_str = lib_path.to_str().ok_or_else(|| {
77            LoopError::Config(format!(
78                "driver lib path is not valid UTF-8: {}",
79                lib_path.display()
80            ))
81        })?;
82        let probe_script = r#"source "$1" && type driver_invoke >/dev/null 2>&1"#;
83        let probe = std::process::Command::new("bash")
84            .arg("-c")
85            .arg(probe_script)
86            .arg("_")
87            .arg(lib_str)
88            .output()
89            .map_err(|e| LoopError::Config(format!("driver_invoke probe bash failed: {e}")))?;
90        if !probe.status.success() {
91            return Err(LoopError::Config(format!(
92                "driver lib '{}' does not define driver_invoke (required function missing)",
93                lib_path.display()
94            )));
95        }
96    }
97
98    Ok(lib_path)
99}
100
101/// Query `driver_default_max()` from the driver lib via a single bash shellout.
102///
103/// Parses stdout as `u64`. Used when `--max-iterations` is absent.
104pub fn driver_default_max(lib: &Path) -> Result<u64, LoopError> {
105    let lib_str = lib.to_str().ok_or_else(|| {
106        LoopError::Config(format!(
107            "driver lib path is not valid UTF-8: {}",
108            lib.display()
109        ))
110    })?;
111    let script = format!("source {:?} && driver_default_max", lib_str);
112    let out = Command::new("bash")
113        .arg("-c")
114        .arg(&script)
115        .output()
116        .map_err(|e| LoopError::Dispatch(format!("bash shellout for driver_default_max: {e}")))?;
117    let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
118    raw.parse::<u64>().map_err(|_| {
119        LoopError::Dispatch(format!(
120            "driver_default_max returned non-integer stdout: {:?}",
121            raw
122        ))
123    })
124}
125
126/// Resolve the binary name for a given driver name.
127///
128/// F2: takes an explicit `cli_alias` parameter (from `--cli` flag) instead of
129/// reading only the process-global `CLI` env var. Precedence (mirrors
130/// driver-claude-code.sh binary resolution):
131///   1. `$CLAUDE_CLI` env var (explicit override)
132///   2. `cli_alias` (from `--cli` flag, placed in child env as `CLI`)
133///   3. `$CLI` env var (legacy path)
134///   4. `"claude"` default
135///
136/// Passing `cli_alias` explicitly avoids `set_var` (process-global mutation
137/// that is a footgun in tests). The child env receives `CLI=<alias>` via the
138/// static env list; this function reflects that same value without touching the
139/// parent process environment.
140pub fn resolve_driver_binary(driver: &str, cli_alias: Option<&str>) -> String {
141    match driver {
142        "claude-code" => {
143            // 1. $CLAUDE_CLI env var.
144            if let Ok(v) = std::env::var("CLAUDE_CLI") {
145                if !v.is_empty() {
146                    return v;
147                }
148            }
149            // 2. Explicit cli_alias from --cli flag.
150            if let Some(a) = cli_alias {
151                if !a.is_empty() {
152                    return a.to_string();
153                }
154            }
155            // 3. $CLI env var (legacy).
156            if let Ok(v) = std::env::var("CLI") {
157                if !v.is_empty() {
158                    return v;
159                }
160            }
161            // 4. Default.
162            "claude".to_string()
163        }
164        "hermes" => "hermes-agent".to_string(),
165        "openclaw" => "openclaw".to_string(),
166        _ => "claude".to_string(), // unreachable after whitelist check
167    }
168}
169
170/// Walk `$PATH` to find a binary. Returns `Some(path)` on success.
171/// Does not use an external crate; pure std.
172pub fn which_binary(name: &str) -> Option<PathBuf> {
173    // If the name contains a path separator, check it directly.
174    if name.contains('/') {
175        let p = PathBuf::from(name);
176        if p.is_file() {
177            return Some(p);
178        }
179        return None;
180    }
181    let path_var = std::env::var("PATH").unwrap_or_default();
182    for dir in path_var.split(':') {
183        if dir.is_empty() {
184            continue;
185        }
186        let candidate = PathBuf::from(dir).join(name);
187        if candidate.is_file() {
188            // Check any executable bit (owner, group, or other) so that
189            // root-owned binaries with mode 0o555 are recognised correctly.
190            use std::os::unix::fs::PermissionsExt;
191            if let Ok(meta) = std::fs::metadata(&candidate) {
192                if meta.permissions().mode() & 0o111 != 0 {
193                    return Some(candidate);
194                }
195            }
196        }
197    }
198    None
199}
200
201// ── ShelloutDispatcher ────────────────────────────────────────────────────────
202
203/// A live session wrapping a bash `driver_invoke` child process.
204pub struct ShelloutSession {
205    child: Child,
206}
207
208impl Session for ShelloutSession {
209    fn wait(&mut self) -> Result<i32, LoopError> {
210        let status = self.child.wait().map_err(LoopError::Io)?;
211        // F4: when status.code() is None the process died by signal. Use the
212        // shell convention 128+N (e.g. SIGTERM=15 -> 143, SIGKILL=9 -> 137)
213        // so consumers can distinguish signal deaths from clean non-zero exits.
214        // This value is recorded in the node_failed event's exit_code field.
215        Ok(status
216            .code()
217            .unwrap_or_else(|| 128 + status.signal().unwrap_or(0)))
218    }
219}
220
221/// Dispatcher that sources a driver lib and calls `driver_invoke` in bash.
222///
223/// Static env vars are wired once at construction; `CURRENT_ITER` is injected
224/// per-dispatch by `Dispatcher::run`.
225pub struct ShelloutDispatcher {
226    /// Resolved path to `driver-<name>.sh`.
227    driver_lib: PathBuf,
228    /// Static env vars passed to every invocation.
229    env: Vec<(String, String)>,
230    /// Working directory for the bash process.
231    cwd: PathBuf,
232}
233
234impl ShelloutDispatcher {
235    /// Construct a ShelloutDispatcher. `driver_lib` must be the resolved lib path
236    /// (from `preflight`); `env` is the static passthrough list; `cwd` is the
237    /// project root.
238    pub fn new(driver_lib: PathBuf, env: Vec<(String, String)>, cwd: PathBuf) -> Self {
239        Self {
240            driver_lib,
241            env,
242            cwd,
243        }
244    }
245}
246
247impl Dispatcher for ShelloutDispatcher {
248    fn run(&self, _unit: &Unit, ctx: &DispatchCtx) -> Result<Box<dyn Session>, LoopError> {
249        let lib_str = self
250            .driver_lib
251            .to_str()
252            .ok_or_else(|| LoopError::Dispatch("driver lib path is not valid UTF-8".to_string()))?;
253
254        // Source the driver lib, call driver_invoke in a subshell so that an
255        // `exit` inside driver_invoke terminates only the subshell (not the outer
256        // bash -c process). Capture its exit code, then best-effort call
257        // driver_persist_history. driver_persist_history populates HISTORY_FILE so
258        // the NEXT iteration carries the prior transcript (hermes/openclaw contract,
259        // mirrors run-target-loop.sh:451). It runs after EVERY iteration including
260        // terminal ones (on terminal iterations the loop exits anyway so it is
261        // harmless) -- keeping the shellout branch-free. The >/dev/null redirect
262        // suppresses any incidental output; || true prevents a non-existent or
263        // failing persist function from aborting the script (not all drivers
264        // define it, and failure is non-fatal).
265        let script = r#"source "$FNO_DRIVER_LIB" && (driver_invoke); rc=$?; driver_persist_history >/dev/null 2>&1 || true; exit $rc"#;
266
267        let mut cmd = Command::new("bash");
268        cmd.arg("-c").arg(script);
269        cmd.env("FNO_DRIVER_LIB", lib_str);
270        cmd.env("CURRENT_ITER", ctx.iteration.to_string());
271        cmd.current_dir(&self.cwd);
272
273        // Passthrough static env vars.
274        for (k, v) in &self.env {
275            cmd.env(k, v);
276        }
277
278        let child = cmd
279            .spawn()
280            .map_err(|e| LoopError::Dispatch(format!("spawn bash driver_invoke: {e}")))?;
281
282        Ok(Box::new(ShelloutSession { child }))
283    }
284}