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}