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`, `opencode`
33///   (whitelist-enforced).
34/// `lib_dir`: directory containing `driver-<driver>.sh`.
35/// `cli_alias`: optional CLI alias from `--cli` flag (F2). Precedence for
36///   binary resolution: `$CLAUDE_CLI` env > `cli_alias` > `$CLI` env > "claude".
37///
38/// Returns the resolved path to the driver lib file on success.
39/// Returns `LoopError::Config` for whitelist/path/function errors,
40/// `LoopError::Dispatch` for a missing binary (the caller maps that to exit 77).
41pub fn preflight(
42    driver: &str,
43    lib_dir: &Path,
44    cli_alias: Option<&str>,
45) -> Result<PathBuf, LoopError> {
46    // Whitelist enforced exactly like run-target-loop.sh:144-150 to prevent
47    // path traversal and shell injection via driver names.
48    const ALLOWED: &[&str] = &["claude-code", "hermes", "openclaw", "opencode"];
49    if !ALLOWED.contains(&driver) {
50        return Err(LoopError::Config(format!(
51            "invalid dispatcher '{driver}': must be one of {:?} (whitelist)",
52            ALLOWED
53        )));
54    }
55
56    // Lib file must exist.
57    let lib_path = lib_dir.join(format!("driver-{driver}.sh"));
58    if !lib_path.exists() {
59        return Err(LoopError::Config(format!(
60            "driver lib not found: {}",
61            lib_path.display()
62        )));
63    }
64
65    // F2: binary resolution uses cli_alias (not process env CLI) so preflight
66    // checks the same binary the dispatcher will actually use.
67    let binary = resolve_driver_binary(driver, cli_alias);
68    if which_binary(&binary).is_none() {
69        return Err(LoopError::Dispatch(format!(
70            "missing binary '{binary}': required by dispatcher '{driver}' but not found on PATH"
71        )));
72    }
73
74    // F5: probe that the lib defines driver_invoke (a lib without it produces
75    // an infinite budget-burning re-dispatch loop; fail loudly at preflight).
76    {
77        let lib_str = lib_path.to_str().ok_or_else(|| {
78            LoopError::Config(format!(
79                "driver lib path is not valid UTF-8: {}",
80                lib_path.display()
81            ))
82        })?;
83        let probe_script = r#"source "$1" && type driver_invoke >/dev/null 2>&1"#;
84        let probe = std::process::Command::new("bash")
85            .arg("-c")
86            .arg(probe_script)
87            .arg("_")
88            .arg(lib_str)
89            .output()
90            .map_err(|e| LoopError::Config(format!("driver_invoke probe bash failed: {e}")))?;
91        if !probe.status.success() {
92            return Err(LoopError::Config(format!(
93                "driver lib '{}' does not define driver_invoke (required function missing)",
94                lib_path.display()
95            )));
96        }
97    }
98
99    Ok(lib_path)
100}
101
102/// Query `driver_default_max()` from the driver lib via a single bash shellout.
103///
104/// Parses stdout as `u64`. Used when `--max-iterations` is absent.
105pub fn driver_default_max(lib: &Path) -> Result<u64, LoopError> {
106    let lib_str = lib.to_str().ok_or_else(|| {
107        LoopError::Config(format!(
108            "driver lib path is not valid UTF-8: {}",
109            lib.display()
110        ))
111    })?;
112    let script = format!("source {:?} && driver_default_max", lib_str);
113    let out = Command::new("bash")
114        .arg("-c")
115        .arg(&script)
116        .output()
117        .map_err(|e| LoopError::Dispatch(format!("bash shellout for driver_default_max: {e}")))?;
118    let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
119    raw.parse::<u64>().map_err(|_| {
120        LoopError::Dispatch(format!(
121            "driver_default_max returned non-integer stdout: {:?}",
122            raw
123        ))
124    })
125}
126
127// ── shared `fno` shellout helpers ─────────────────────────────────────────────
128
129/// Build a [`Command`] for the `fno` binary.
130///
131/// Binary resolution: `fno_bin` (the path/name given by the caller, overridden
132/// by `$FNO_BIN` for tests). If `FNO_BIN` is set and non-empty it wins;
133/// otherwise `fno_bin` is used as-is (callers pass `"fno"` for production and a
134/// tempdir stub path for tests).
135pub(crate) fn fno_cmd(fno_bin: &str) -> Command {
136    let binary = std::env::var("FNO_BIN")
137        .ok()
138        .filter(|s| !s.is_empty())
139        .unwrap_or_else(|| fno_bin.to_string());
140    Command::new(binary)
141}
142
143/// Run a spawn closure, retrying briefly on ETXTBSY ("Text file busy", os error
144/// 26). The spawned file is the `fno` / `fno-agents` binary: a concurrent
145/// `fno update` relinks it in place, and under `cargo test` a sibling thread
146/// that just wrote+exec'd a stub leaves a transient write-fd open in another
147/// thread's fork window; either way the kernel can refuse the exec with
148/// ETXTBSY. The condition clears within microseconds once the writing fd closes,
149/// so a bounded retry turns a hard spawn failure into a short wait. Any other
150/// error, and the successful value, passes through unchanged.
151pub(crate) fn retry_etxtbsy<T>(
152    mut spawn: impl FnMut() -> std::io::Result<T>,
153) -> std::io::Result<T> {
154    const MAX_RETRIES: u32 = 5;
155    let mut attempt: u32 = 0;
156    loop {
157        match spawn() {
158            Err(e) if e.raw_os_error() == Some(libc::ETXTBSY) && attempt < MAX_RETRIES => {
159                attempt += 1;
160                std::thread::sleep(std::time::Duration::from_millis(2 * u64::from(attempt)));
161            }
162            other => return other,
163        }
164    }
165}
166
167/// Resolve the binary name for a given driver name.
168///
169/// F2: takes an explicit `cli_alias` parameter (from `--cli` flag) instead of
170/// reading only the process-global `CLI` env var. Precedence (mirrors
171/// driver-claude-code.sh binary resolution):
172///   1. `$CLAUDE_CLI` env var (explicit override)
173///   2. `cli_alias` (from `--cli` flag, placed in child env as `CLI`)
174///   3. `$CLI` env var (legacy path)
175///   4. `"claude"` default
176///
177/// Passing `cli_alias` explicitly avoids `set_var` (process-global mutation
178/// that is a footgun in tests). The child env receives `CLI=<alias>` via the
179/// static env list; this function reflects that same value without touching the
180/// parent process environment.
181pub fn resolve_driver_binary(driver: &str, cli_alias: Option<&str>) -> String {
182    match driver {
183        "claude-code" => {
184            // 1. $CLAUDE_CLI env var.
185            if let Ok(v) = std::env::var("CLAUDE_CLI") {
186                if !v.is_empty() {
187                    return v;
188                }
189            }
190            // 2. Explicit cli_alias from --cli flag.
191            if let Some(a) = cli_alias {
192                if !a.is_empty() {
193                    return a.to_string();
194                }
195            }
196            // 3. $CLI env var (legacy).
197            if let Ok(v) = std::env::var("CLI") {
198                if !v.is_empty() {
199                    return v;
200                }
201            }
202            // 4. Default.
203            "claude".to_string()
204        }
205        "hermes" => "hermes-agent".to_string(),
206        "openclaw" => "openclaw".to_string(),
207        "opencode" => "opencode".to_string(),
208        _ => "claude".to_string(), // unreachable after whitelist check
209    }
210}
211
212/// Walk `$PATH` to find a binary. Returns `Some(path)` on success.
213/// Does not use an external crate; pure std.
214pub fn which_binary(name: &str) -> Option<PathBuf> {
215    // If the name contains a path separator, check it directly.
216    if name.contains('/') {
217        let p = PathBuf::from(name);
218        if p.is_file() {
219            return Some(p);
220        }
221        return None;
222    }
223    let path_var = std::env::var("PATH").unwrap_or_default();
224    for dir in path_var.split(':') {
225        if dir.is_empty() {
226            continue;
227        }
228        let candidate = PathBuf::from(dir).join(name);
229        if candidate.is_file() {
230            // Check any executable bit (owner, group, or other) so that
231            // root-owned binaries with mode 0o555 are recognised correctly.
232            use std::os::unix::fs::PermissionsExt;
233            if let Ok(meta) = std::fs::metadata(&candidate) {
234                if meta.permissions().mode() & 0o111 != 0 {
235                    return Some(candidate);
236                }
237            }
238        }
239    }
240    None
241}
242
243// ── launch-time headroom picking (x-7d45) ─────────────────────────────────────
244
245/// The single env var a picked account contributes to the driver's environment.
246const PICKED_ENV_KEY: &str = "CLAUDE_CONFIG_DIR";
247
248/// The exact verb this file shells, as one named constant.
249///
250/// It is a constant so a cross-language test can assert this argv still resolves
251/// to a real command. That check is not ceremony: this verb was spelled
252/// `fno providers pick` until the surface was renamed to `fno config accounts`,
253/// and because every failure here is advisory the loop would have degraded
254/// silently forever rather than failing loudly once.
255pub const PICK_ARGV: [&str; 5] = ["config", "accounts", "pick", "--if-armed", "--print-env"];
256
257/// One picked account's complete env overlay: `(key, value)` pairs where an
258/// EMPTY value means "clear this variable in the child".
259type PickedEnv = Vec<(String, String)>;
260
261/// Interpret a `fno config accounts pick --if-armed --print-env` result.
262///
263/// Pure, so the advisory contract is testable without a live `fno`. Success is
264/// exit 0 plus at least one `CLAUDE_CONFIG_DIR=<non-empty>` line; the verb also
265/// emits the auth vars to clear as `KEY=` and those are carried through, because
266/// applying half an overlay is what lets an inherited ANTHROPIC_API_KEY bill a
267/// different account than the receipt names. The verb's non-zero exits (3 = every
268/// launchable candidate exhausted, 4 = no launchable candidate, 5 = picking not
269/// armed) are ordinary answers here, not errors.
270fn interpret_pick(ok: bool, stdout: &str, stderr: &str) -> Result<PickedEnv, String> {
271    if !ok {
272        let reason = stderr
273            .lines()
274            .map(str::trim)
275            .filter(|l| !l.is_empty())
276            .next_back()
277            .unwrap_or("no reason given");
278        return Err(reason.to_string());
279    }
280    let mut env: PickedEnv = Vec::new();
281    let mut pinned = false;
282    for line in stdout.lines().map(str::trim).filter(|l| !l.is_empty()) {
283        match line.split_once('=') {
284            Some((k, v)) if !k.is_empty() => {
285                if !v.is_empty() {
286                    pinned = true;
287                }
288                env.push((k.to_string(), v.to_string()));
289            }
290            _ => return Err(format!("unparseable pick output: {line:?}")),
291        }
292    }
293    if !pinned {
294        // A drifted verb must never have its output half-applied: with nothing
295        // but clear-lines there is no account, only a scrubbed environment.
296        // The pin is ANY value-carrying key, not CLAUDE_CONFIG_DIR specifically
297        // - a claude api_key record's overlay is an ANTHROPIC_API_KEY and is
298        // just as valid an account, and requiring the config dir would have the
299        // loop reject an overlay Python accepts.
300        return Err("pick output carried no account pin".to_string());
301    }
302    Ok(env)
303}
304
305/// True when this dispatcher drives `claude`, the only harness that reads
306/// `CLAUDE_CONFIG_DIR`.
307///
308/// An opencode / hermes / openclaw loop would gain nothing from a claude
309/// account pin, and applying the overlay would still CLEAR that run's inherited
310/// Anthropic credentials while logging "account picked" - a receipt describing
311/// something that did not happen, to a worker that cannot act on it.
312fn drives_claude(driver_lib: &Path) -> bool {
313    driver_lib
314        .file_name()
315        .and_then(|n| n.to_str())
316        .is_some_and(|n| n == "driver-claude-code.sh")
317}
318
319/// True when applying `picked` would silently undo a route this run pins.
320///
321/// A loop launched with an explicit provider route (an `ANTHROPIC_BASE_URL` +
322/// `ANTHROPIC_AUTH_TOKEN` pair for a non-Anthropic endpoint, or a pinned model
323/// tier) is already committed. The overlay's clear-list names exactly those
324/// vars, so a static env that sets one is a deliberate routing decision the pick
325/// would scrub mid-flight - moving the run to a claude account while its receipt
326/// claimed only to have picked one. Deriving the check FROM the clear-list is
327/// what keeps it from becoming a second, drifting copy of that list here.
328///
329/// The mirror of the Python seam declining to pick for a `--route`/`--role`
330/// spawn, for the same reason: endpoint, auth and model are one route, and
331/// half-composing it is what bills the wrong account.
332fn pick_would_undo_a_route(picked: &[(String, String)], static_env: &[(String, String)]) -> bool {
333    picked.iter().filter(|(_, v)| v.is_empty()).any(|(k, _)| {
334        // The static passthrough list is only half the picture: a loop started
335        // from a shell that already exported ANTHROPIC_BASE_URL inherits it
336        // through the process environment without it ever appearing here, and
337        // clearing it would move that run to a different provider just the same.
338        static_env.iter().any(|(ek, _)| ek == k)
339            || std::env::var_os(k).is_some_and(|v| !v.is_empty())
340    })
341}
342
343/// Ask `fno config accounts pick` which account the next iteration should launch on.
344///
345/// Shells the verb rather than reimplementing the predicate: headroom, combo
346/// order, launchability AND the `pick_on_launch` opt-in have exactly one
347/// implementation, and it is not this one - `--if-armed` is what lets the verb
348/// honor the knob on this caller's behalf, so a default-off install can never
349/// have the loop change which account it bills. Every failure mode - a stale
350/// `fno`, an absent `fno`, a refusal - is an `Err` the caller logs and ignores,
351/// so the loop cannot be wedged by it.
352fn pick_account_env() -> Result<PickedEnv, String> {
353    let out = Command::new("fno")
354        .args(PICK_ARGV)
355        .output()
356        .map_err(|e| format!("could not run `fno config accounts pick`: {e}"))?;
357    interpret_pick(
358        out.status.success(),
359        &String::from_utf8_lossy(&out.stdout),
360        &String::from_utf8_lossy(&out.stderr),
361    )
362}
363
364// ── ShelloutDispatcher ────────────────────────────────────────────────────────
365
366/// A live session wrapping a bash `driver_invoke` child process.
367pub struct ShelloutSession {
368    child: Child,
369    /// Path the driver redirects claude stdout+stderr into (env `OUTPUT_FILE`),
370    /// read after exit to classify a claude bg-guard refusal (x-4504). `None`
371    /// when the dispatcher env carried no `OUTPUT_FILE`. The driver truncates
372    /// this file at the start of every `driver_invoke`, so after `wait()` it
373    /// holds exactly this iteration's output.
374    output_file: Option<PathBuf>,
375}
376
377impl Session for ShelloutSession {
378    fn wait(&mut self) -> Result<i32, LoopError> {
379        let status = self.child.wait().map_err(LoopError::Io)?;
380        // F4: when status.code() is None the process died by signal. Use the
381        // shell convention 128+N (e.g. SIGTERM=15 -> 143, SIGKILL=9 -> 137)
382        // so consumers can distinguish signal deaths from clean non-zero exits.
383        // This value is recorded in the node_failed event's exit_code field.
384        Ok(status
385            .code()
386            .unwrap_or_else(|| 128 + status.signal().unwrap_or(0)))
387    }
388
389    fn output_tail(&self) -> Option<String> {
390        use std::io::{Read, Seek, SeekFrom};
391        // The guard message is short and near the end; read only the last 8 KiB
392        // (seek, don't slurp) so a large transcript can never balloon memory.
393        const MAX_TAIL: u64 = 8 * 1024;
394        let path = self.output_file.as_ref()?;
395        let mut file = std::fs::File::open(path).ok()?;
396        // A metadata failure is not proof the file is gone (could be transient);
397        // fall through and read from the start rather than bailing.
398        if let Ok(len) = file.metadata().map(|m| m.len()) {
399            let _ = file.seek(SeekFrom::Start(len.saturating_sub(MAX_TAIL)));
400        }
401        let mut buf = Vec::new();
402        file.take(MAX_TAIL).read_to_end(&mut buf).ok()?;
403        Some(String::from_utf8_lossy(&buf).into_owned())
404    }
405}
406
407/// Dispatcher that sources a driver lib and calls `driver_invoke` in bash.
408///
409/// Static env vars are wired once at construction; `CURRENT_ITER` is injected
410/// per-dispatch by `Dispatcher::run`.
411pub struct ShelloutDispatcher {
412    /// Resolved path to `driver-<name>.sh`.
413    driver_lib: PathBuf,
414    /// Static env vars passed to every invocation.
415    env: Vec<(String, String)>,
416    /// Working directory for the bash process.
417    cwd: PathBuf,
418}
419
420impl ShelloutDispatcher {
421    /// Construct a ShelloutDispatcher. `driver_lib` must be the resolved lib path
422    /// (from `preflight`); `env` is the static passthrough list; `cwd` is the
423    /// project root.
424    pub fn new(driver_lib: PathBuf, env: Vec<(String, String)>, cwd: PathBuf) -> Self {
425        Self {
426            driver_lib,
427            env,
428            cwd,
429        }
430    }
431}
432
433impl Dispatcher for ShelloutDispatcher {
434    fn run(&self, _unit: &Unit, ctx: &DispatchCtx) -> Result<Box<dyn Session>, LoopError> {
435        let lib_str = self
436            .driver_lib
437            .to_str()
438            .ok_or_else(|| LoopError::Dispatch("driver lib path is not valid UTF-8".to_string()))?;
439
440        // Source the driver lib, call driver_invoke in a subshell so that an
441        // `exit` inside driver_invoke terminates only the subshell (not the outer
442        // bash -c process). Capture its exit code, then best-effort call
443        // driver_persist_history. driver_persist_history populates HISTORY_FILE so
444        // the NEXT iteration carries the prior transcript (hermes/openclaw contract,
445        // mirrors run-target-loop.sh:451). It runs after EVERY iteration including
446        // terminal ones (on terminal iterations the loop exits anyway so it is
447        // harmless) -- keeping the shellout branch-free. The >/dev/null redirect
448        // suppresses any incidental output; || true prevents a non-existent or
449        // failing persist function from aborting the script (not all drivers
450        // define it, and failure is non-fatal).
451        let script = r#"source "$FNO_DRIVER_LIB" && (driver_invoke); rc=$?; driver_persist_history >/dev/null 2>&1 || true; exit $rc"#;
452
453        let mut cmd = Command::new("bash");
454        cmd.arg("-c").arg(script);
455        cmd.env("FNO_DRIVER_LIB", lib_str);
456        cmd.env("CURRENT_ITER", ctx.iteration.to_string());
457        cmd.current_dir(&self.cwd);
458
459        // Passthrough static env vars.
460        for (k, v) in &self.env {
461            cmd.env(k, v);
462        }
463
464        // Launch-time headroom picking. A fresh process is a fresh credential
465        // read, so the iteration boundary already IS the pre-emptive handoff
466        // moment - no threshold, watcher, or new trigger machinery needed. This
467        // is the ONE call site: every driver's harness process is a child of the
468        // bash spawned below, so all of them inherit the pick, whereas wiring it
469        // into `driver_invoke` would mean one copy per driver lib. An
470        // operator-pinned CLAUDE_CONFIG_DIR in the static env always wins, and a
471        // refusal is advisory - the iteration proceeds on today's env.
472        if drives_claude(&self.driver_lib) && !self.env.iter().any(|(k, _)| k == PICKED_ENV_KEY) {
473            let iter = ctx.iteration;
474            match pick_account_env() {
475                // A loop launched with an explicit route (ANTHROPIC_BASE_URL +
476                // ANTHROPIC_AUTH_TOKEN for a non-Anthropic endpoint, or a pinned
477                // model tier) is already committed to a provider. Applying a
478                // pick would scrub exactly those vars and silently move the run
479                // to a claude account mid-flight. The verb's own clear-list is
480                // what identifies them, so there is no second copy of it here -
481                // the mirror of the Python seam declining to pick for a --route
482                // or --role spawn.
483                Ok(picked) if pick_would_undo_a_route(&picked, &self.env) => {
484                    eprintln!(
485                        "loop: iteration {iter} account not picked \
486                         (this run pins its own provider route)"
487                    );
488                }
489                Ok(picked) => {
490                    // Name the pin whatever it is. Reporting only on
491                    // CLAUDE_CONFIG_DIR would let an api_key account's overlay
492                    // change which account is billed with no receipt at all,
493                    // and an unannounced billing change is the one thing this
494                    // feature must never do.
495                    if let Some((key, value)) = picked.iter().find(|(_, v)| !v.is_empty()) {
496                        // Announce every pick, but NEVER the pin's value unless
497                        // it is the config dir. An api_key account's pin IS its
498                        // ANTHROPIC_API_KEY, so echoing the value would write
499                        // the secret to the loop's log on every iteration.
500                        if key == PICKED_ENV_KEY {
501                            eprintln!("loop: iteration {iter} account picked -> {value}");
502                        } else {
503                            eprintln!("loop: iteration {iter} account picked -> pinned via {key}");
504                        }
505                    }
506                    for (key, value) in &picked {
507                        // An empty value is the verb saying "clear this": an
508                        // inherited ANTHROPIC_API_KEY or routed base URL outranks
509                        // CLAUDE_CONFIG_DIR, so leaving one behind would bill an
510                        // account the receipt does not name.
511                        if value.is_empty() {
512                            cmd.env_remove(key);
513                        } else {
514                            cmd.env(key, value);
515                        }
516                    }
517                }
518                Err(reason) => {
519                    eprintln!("loop: iteration {iter} account not picked ({reason})");
520                }
521            }
522        }
523
524        let child = cmd
525            .spawn()
526            .map_err(|e| LoopError::Dispatch(format!("spawn bash driver_invoke: {e}")))?;
527
528        // Capture OUTPUT_FILE (the driver's stdout+stderr sink) so the walk can
529        // classify a claude bg-guard refusal after exit (x-4504).
530        let output_file = self
531            .env
532            .iter()
533            .find(|(k, _)| k == "OUTPUT_FILE")
534            .map(|(_, v)| PathBuf::from(v));
535
536        Ok(Box::new(ShelloutSession { child, output_file }))
537    }
538}
539
540#[cfg(test)]
541mod tests {
542    use super::{
543        interpret_pick, pick_would_undo_a_route, resolve_driver_binary, retry_etxtbsy,
544        PICKED_ENV_KEY,
545    };
546
547    fn pair(k: &str, v: &str) -> (String, String) {
548        (k.to_string(), v.to_string())
549    }
550
551    #[test]
552    fn retry_etxtbsy_passes_success_through_without_retry() {
553        let mut calls = 0u32;
554        let r: std::io::Result<u8> = retry_etxtbsy(|| {
555            calls += 1;
556            Ok(7)
557        });
558        assert_eq!(r.unwrap(), 7);
559        assert_eq!(calls, 1, "a successful spawn must not retry");
560    }
561
562    #[test]
563    fn retry_etxtbsy_retries_then_succeeds() {
564        // Simulate ETXTBSY clearing after a couple of attempts.
565        let mut calls = 0u32;
566        let r: std::io::Result<u8> = retry_etxtbsy(|| {
567            calls += 1;
568            if calls < 3 {
569                Err(std::io::Error::from_raw_os_error(libc::ETXTBSY))
570            } else {
571                Ok(42)
572            }
573        });
574        assert_eq!(r.unwrap(), 42);
575        assert_eq!(calls, 3, "must retry past transient ETXTBSY");
576    }
577
578    #[test]
579    fn retry_etxtbsy_does_not_swallow_other_errors() {
580        // A non-ETXTBSY error returns immediately, no retry.
581        let mut calls = 0u32;
582        let r: std::io::Result<u8> = retry_etxtbsy(|| {
583            calls += 1;
584            Err(std::io::Error::from_raw_os_error(libc::ENOENT))
585        });
586        assert_eq!(r.unwrap_err().raw_os_error(), Some(libc::ENOENT));
587        assert_eq!(calls, 1, "a non-ETXTBSY error must not retry");
588    }
589
590    #[test]
591    fn retry_etxtbsy_gives_up_after_max_retries() {
592        // Persistent ETXTBSY surfaces after the bounded retry budget (1 initial
593        // + 5 retries = 6 calls) rather than spinning forever.
594        let mut calls = 0u32;
595        let r: std::io::Result<u8> = retry_etxtbsy(|| {
596            calls += 1;
597            Err(std::io::Error::from_raw_os_error(libc::ETXTBSY))
598        });
599        assert_eq!(r.unwrap_err().raw_os_error(), Some(libc::ETXTBSY));
600        assert_eq!(calls, 6, "1 initial attempt + MAX_RETRIES(5)");
601    }
602
603    // A GLM/zai loop pins ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN. The pick
604    // would scrub both and pin a claude config dir, silently moving the run to a
605    // different provider mid-flight while claiming only to have picked an
606    // account.
607    #[test]
608    fn a_pick_that_would_scrub_a_pinned_route_is_declined() {
609        let picked = vec![
610            pair("ANTHROPIC_BASE_URL", ""),
611            pair("ANTHROPIC_AUTH_TOKEN", ""),
612            pair("CLAUDE_CONFIG_DIR", "/alt"),
613        ];
614        let routed = vec![
615            pair(
616                "ANTHROPIC_BASE_URL",
617                "https://open.bigmodel.cn/api/anthropic",
618            ),
619            pair("OUTPUT_FILE", "/tmp/out"),
620        ];
621        assert!(pick_would_undo_a_route(&picked, &routed));
622    }
623
624    #[test]
625    fn an_unrouted_loop_still_gets_its_pick() {
626        let picked = vec![
627            pair("ANTHROPIC_BASE_URL", ""),
628            pair("CLAUDE_CONFIG_DIR", "/alt"),
629        ];
630        let plain = vec![pair("OUTPUT_FILE", "/tmp/out"), pair("CLI", "claude")];
631        assert!(!pick_would_undo_a_route(&picked, &plain));
632    }
633
634    #[test]
635    fn only_the_clear_list_blocks_a_pick_not_the_pin_itself() {
636        // CLAUDE_CONFIG_DIR arrives with a VALUE, so it is not part of the
637        // clear-list and must not make every pick look like a route conflict.
638        let picked = vec![pair("CLAUDE_CONFIG_DIR", "/alt")];
639        let same_key = vec![pair("CLAUDE_CONFIG_DIR", "/other")];
640        assert!(!pick_would_undo_a_route(&picked, &same_key));
641    }
642
643    #[test]
644    fn a_picked_account_yields_its_config_dir() {
645        let env = interpret_pick(true, "CLAUDE_CONFIG_DIR=/Users/x/.claude-alt\n", "")
646            .expect("a pinned config dir is a successful pick");
647        assert_eq!(
648            env,
649            vec![(
650                "CLAUDE_CONFIG_DIR".to_string(),
651                "/Users/x/.claude-alt".to_string()
652            )]
653        );
654    }
655
656    // The scrub half of the overlay: an inherited ANTHROPIC_API_KEY or routed
657    // base URL outranks CLAUDE_CONFIG_DIR, so applying only the pin would let
658    // the worker bill an account the receipt does not name.
659    #[test]
660    fn auth_vars_to_clear_are_carried_as_empty_values() {
661        let stdout = "ANTHROPIC_API_KEY=\nANTHROPIC_BASE_URL=\nCLAUDE_CONFIG_DIR=/alt\n";
662        let env = interpret_pick(true, stdout, "").expect("overlay parses");
663        assert_eq!(env.len(), 3);
664        assert_eq!(env[0], ("ANTHROPIC_API_KEY".to_string(), String::new()));
665        assert_eq!(env[1], ("ANTHROPIC_BASE_URL".to_string(), String::new()));
666        assert_eq!(
667            env[2],
668            ("CLAUDE_CONFIG_DIR".to_string(), "/alt".to_string())
669        );
670    }
671
672    // AC: the opt-in is honored on this path too. Exit 5 is the verb declining
673    // because providers.quota.pick_on_launch is false, and it must read as an
674    // ordinary "not picked", never as a pick.
675    #[test]
676    fn a_disarmed_picker_declines_with_its_reason() {
677        let stderr = "pick: launch picking is not armed (providers.quota.pick_on_launch = false)\n";
678        assert_eq!(
679            interpret_pick(false, "", stderr),
680            Err(
681                "pick: launch picking is not armed (providers.quota.pick_on_launch = false)"
682                    .to_string()
683            )
684        );
685    }
686
687    // AC13-ERR: a refusing picker is an answer, not a wedge. The reason reaches
688    // the log so an operator can tell "exhausted" from "not set up".
689    #[test]
690    fn a_refusal_surfaces_its_reason_instead_of_erroring_out() {
691        let stderr = "  readyrule: exhausted\npick: every launchable candidate is exhausted\n";
692        assert_eq!(
693            interpret_pick(false, "", stderr),
694            Err("pick: every launchable candidate is exhausted".to_string())
695        );
696        assert!(interpret_pick(false, "", "").is_err());
697    }
698
699    // A receipt must never carry credential material. For an api_key account
700    // the pin IS the secret, so the announcement names the KEY and stops; only
701    // CLAUDE_CONFIG_DIR, a filesystem path, is safe to echo. This pins the
702    // decision the receipt code makes, so a later edit cannot re-introduce the
703    // value into the log.
704    #[test]
705    fn only_the_config_dir_pin_is_safe_to_echo() {
706        assert_eq!(PICKED_ENV_KEY, "CLAUDE_CONFIG_DIR");
707        for secret_key in ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] {
708            assert_ne!(
709                secret_key, PICKED_ENV_KEY,
710                "a secret-bearing pin must not take the echo-the-value branch"
711            );
712        }
713    }
714
715    #[test]
716    fn unparseable_output_is_declined_rather_than_guessed() {
717        // A drifted verb must not have its output half-applied: with no pinned
718        // config dir there is no account, only a scrubbed environment.
719        assert!(interpret_pick(true, "readyrule\n", "").is_err());
720        assert!(interpret_pick(true, "CLAUDE_CONFIG_DIR=\n", "").is_err());
721        assert!(interpret_pick(true, "ANTHROPIC_API_KEY=\n", "").is_err());
722        assert!(interpret_pick(true, "=/tmp\n", "").is_err());
723        assert!(interpret_pick(true, "", "").is_err());
724    }
725
726    // AC12-CON: one call site, all harnesses. A picker call inside any driver
727    // lib would be a second copy of one decision - the shape this wiring exists
728    // to avoid.
729    #[test]
730    fn no_driver_lib_calls_the_picker_itself() {
731        let lib_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../scripts/lib");
732        let mut checked = 0;
733        for entry in std::fs::read_dir(&lib_dir).expect("scripts/lib is readable") {
734            let path = entry.expect("dir entry").path();
735            let name = path
736                .file_name()
737                .and_then(|n| n.to_str())
738                .unwrap_or("")
739                .to_string();
740            if !name.starts_with("driver-") || !name.ends_with(".sh") {
741                continue;
742            }
743            let body = std::fs::read_to_string(&path).expect("driver lib is readable");
744            assert!(
745                !body.contains("providers pick"),
746                "{name} calls the picker itself; the loop dispatcher is the one call site"
747            );
748            checked += 1;
749        }
750        // Positive control: a glob that stops matching would otherwise pass
751        // vacuously and report coverage it does not have.
752        assert!(checked >= 4, "expected the driver libs, scanned {checked}");
753    }
754
755    // AC1-EDGE: opencode resolves to the `opencode` binary (loop-wrapper path,
756    // x-6007). The loop-wrapper drivers have fixed binary names (no env/alias
757    // precedence, unlike claude-code).
758    #[test]
759    fn loop_wrapper_drivers_resolve_to_fixed_binaries() {
760        assert_eq!(resolve_driver_binary("opencode", None), "opencode");
761        assert_eq!(resolve_driver_binary("openclaw", None), "openclaw");
762        assert_eq!(resolve_driver_binary("hermes", None), "hermes-agent");
763    }
764}