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}