Skip to main content

kranz_engine/
backend_readiness.rs

1//! Backend readiness / quota preflight before queue drain.
2//!
3//! Lightweight probe of the backends a queued mission will use. Park on hard
4//! failures; requeue/delay on rate limits; warn+proceed when quota is unknown
5//! or the provider exposes no meter (never invent a "0% quota" bar).
6
7use crate::config;
8use crate::error::Result;
9use crate::paths::MissionPaths;
10use crate::types::{BackendKind, MissionConfig, MissionState, Role, SandboxEnforce};
11use serde::{Deserialize, Serialize};
12use std::io::Read;
13use std::net::{TcpStream, ToSocketAddrs};
14use std::path::Path;
15use std::time::{Duration, Instant};
16
17/// Probe outcome enum (ticket acceptance surface).
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum ReadinessStatus {
21    Ok,
22    Missing,
23    Unauthenticated,
24    RateLimited,
25    Unsupported,
26    Unknown,
27    /// Provider has no quota API — treat like unknown for drain (warn+proceed).
28    Meterless,
29}
30
31/// One role's readiness row.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub struct RoleReadiness {
35    pub role: String,
36    pub backend: String,
37    pub status: ReadinessStatus,
38    pub detail: String,
39    /// Operator-facing next action (install binary, login, fix model, …).
40    pub next_action: String,
41}
42
43/// Aggregate verdict for a mission before claim.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct ReadinessReport {
47    pub mission_id: String,
48    pub roles: Vec<RoleReadiness>,
49    /// Worst actionable status across roles (drives drain policy).
50    pub overall: ReadinessStatus,
51    pub warnings: Vec<String>,
52}
53
54/// What the drain loop should do with this probe result.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum DrainDecision {
57    /// Claim and run.
58    Proceed { warnings: Vec<String> },
59    /// Remove from queue / park ticket with reason; do not claim.
60    Park { reason: String },
61    /// Leave queued; delay before retrying (rate limit).
62    RequeueDelay { reason: String, delay: Duration },
63}
64
65impl ReadinessReport {
66    pub fn drain_decision(&self) -> DrainDecision {
67        match self.overall {
68            ReadinessStatus::Ok => DrainDecision::Proceed {
69                warnings: self.warnings.clone(),
70            },
71            ReadinessStatus::Unknown | ReadinessStatus::Meterless => DrainDecision::Proceed {
72                warnings: {
73                    let mut w = self.warnings.clone();
74                    if w.is_empty() {
75                        w.push(
76                            "backend quota unknown/meterless — proceeding without a fabricated \
77                             quota bar"
78                                .into(),
79                        );
80                    }
81                    w
82                },
83            },
84            ReadinessStatus::RateLimited => {
85                let reason = self
86                    .roles
87                    .iter()
88                    .find(|r| r.status == ReadinessStatus::RateLimited)
89                    .map(|r| r.detail.clone())
90                    .unwrap_or_else(|| "backend rate limited".into());
91                DrainDecision::RequeueDelay {
92                    reason,
93                    delay: Duration::from_secs(60),
94                }
95            }
96            ReadinessStatus::Missing
97            | ReadinessStatus::Unauthenticated
98            | ReadinessStatus::Unsupported => {
99                let reason = self
100                    .roles
101                    .iter()
102                    .find(|r| {
103                        matches!(
104                            r.status,
105                            ReadinessStatus::Missing
106                                | ReadinessStatus::Unauthenticated
107                                | ReadinessStatus::Unsupported
108                        )
109                    })
110                    .map(|r| format!("{}: {} — {}", r.role, r.detail, r.next_action))
111                    .unwrap_or_else(|| "backend not ready".into());
112                DrainDecision::Park { reason }
113            }
114        }
115    }
116}
117
118/// Probe readiness for a queued mission using its persisted config (or layered
119/// repo config when state is missing).
120pub fn probe_mission(repo_root: &Path, mission_id: &str) -> Result<ReadinessReport> {
121    let cfg = load_mission_config(repo_root, mission_id)?;
122    Ok(probe_config(mission_id, repo_root, &cfg))
123}
124
125fn load_mission_config(repo_root: &Path, mission_id: &str) -> Result<MissionConfig> {
126    let paths = MissionPaths::new(repo_root, mission_id);
127    if paths.state_file().is_file() {
128        let text = std::fs::read_to_string(paths.state_file())?;
129        if let Ok(state) = serde_json::from_str::<MissionState>(&text) {
130            return Ok(state.config);
131        }
132    }
133    config::load(repo_root)
134}
135
136/// Pure-ish probe against an already-loaded config (tests inject stubs via
137/// env/PATH; binary discovery is the live side effect).
138pub fn probe_config(mission_id: &str, repo_root: &Path, cfg: &MissionConfig) -> ReadinessReport {
139    let mut roles = Vec::new();
140    let mut warnings = Vec::new();
141
142    // Config validation first — invalid model / sandbox mismatch parks.
143    if let Err(e) = config::validate(cfg) {
144        let detail = e.to_string();
145        let status = ReadinessStatus::Unsupported;
146        roles.push(RoleReadiness {
147            role: "config".into(),
148            backend: "n/a".into(),
149            status,
150            detail: detail.clone(),
151            next_action: "fix .kranz/config.json / mission config and re-queue".into(),
152        });
153        return ReadinessReport {
154            mission_id: mission_id.to_string(),
155            roles,
156            overall: status,
157            warnings,
158        };
159    }
160
161    for role in [
162        Role::Orchestrator,
163        Role::Worker,
164        Role::ValidatorScrutiny,
165        Role::ValidatorFunctional,
166    ] {
167        roles.push(probe_role(role, cfg));
168    }
169
170    // Sandbox resolve: hard unsupported when enforce is on and tooling missing.
171    if cfg.worker.sandbox.enforce != SandboxEnforce::Off {
172        let mission_dir = MissionPaths::new(repo_root, mission_id).mission_dir();
173        let (_resolved, warn) =
174            crate::sandbox::resolve_for_session(&cfg.worker.sandbox, repo_root, &mission_dir);
175        if let Some(w) = warn {
176            let lower = w.to_ascii_lowercase();
177            if lower.contains("unsupported") || lower.contains("not available") {
178                roles.push(RoleReadiness {
179                    role: "worker.sandbox".into(),
180                    backend: "sandbox".into(),
181                    status: ReadinessStatus::Unsupported,
182                    detail: w,
183                    next_action: "set worker.sandbox.enforce to \"off\" or install sandbox tooling"
184                        .into(),
185                });
186            } else {
187                warnings.push(w);
188            }
189        }
190    }
191
192    // Quota: no provider API wired — always meteless/unknown for honesty.
193    warnings.push(
194        "provider quota not queried (meterless/unknown) — proceeding does not imply headroom"
195            .into(),
196    );
197
198    let overall = worst_status(roles.iter().map(|r| r.status));
199    // Unknown/meterless from quota alone shouldn't override Ok binaries —
200    // we only attached a warning. Overall stays the worst *role* status.
201    let overall = match overall {
202        ReadinessStatus::Ok => ReadinessStatus::Meterless, // quota unknown → warn+proceed
203        other => other,
204    };
205
206    ReadinessReport {
207        mission_id: mission_id.to_string(),
208        roles,
209        overall,
210        warnings,
211    }
212}
213
214fn probe_role(role: Role, cfg: &MissionConfig) -> RoleReadiness {
215    let role_key = match role {
216        Role::Orchestrator => "orchestrator",
217        Role::Worker => "worker",
218        Role::ValidatorScrutiny => "validatorScrutiny",
219        Role::ValidatorFunctional => "validatorFunctional",
220    };
221    let kind = cfg.backend_kind(role);
222    let backend = kind.as_str().to_string();
223
224    // The local HTTP backend has no CLI binary to discover; its readiness
225    // probe is a best-effort, short-timeout reachability check of the role's
226    // baseUrl instead. Never panics and never blocks indefinitely: a bad URL
227    // or an unreachable endpoint downgrades to a not-ready/uncertain status
228    // rather than an error.
229    if kind == BackendKind::Local {
230        let base_url = cfg.role(role).base_url.as_deref();
231        let (status, detail, next_action) = probe_local_reachability(base_url);
232        return RoleReadiness {
233            role: role_key.into(),
234            backend,
235            status,
236            detail,
237            next_action,
238        };
239    }
240
241    // The ACP backend has no binary-discovery or login-status convention:
242    // its readiness probe only confirms the configured command exists (the
243    // initialize handshake at session start is the real probe). Never
244    // panics; a missing/relative command downgrades to an honest Unknown.
245    if kind == BackendKind::Acp {
246        let configured = cfg.role(role).acp_command.clone();
247        let (status, detail, next_action) = match configured.as_deref() {
248            Some(command) if !command.trim().is_empty() => (
249                ReadinessStatus::Unknown,
250                format!(
251                    "acp agent {command:?} configured; no cheap probe — the initialize \
252                     handshake at session start is the real probe"
253                ),
254                "none".into(),
255            ),
256            _ => (
257                ReadinessStatus::Unknown,
258                "acp backend has no acpCommand configured".into(),
259                "set the role's acpCommand and re-queue".into(),
260            ),
261        };
262        return RoleReadiness {
263            role: role_key.into(),
264            backend,
265            status,
266            detail,
267            next_action,
268        };
269    }
270
271    let discover = match kind {
272        BackendKind::Claude => crate::backend_claude::discover_claude_binary(None),
273        BackendKind::Codex => crate::backend_codex::discover_codex_binary(None),
274        BackendKind::Droid => crate::backend_droid::discover_droid_binary(None),
275        BackendKind::Kimi => crate::backend_kimi::discover_kimi_binary(None),
276        BackendKind::Cursor => crate::backend_cursor::discover_cursor_binary(None),
277        BackendKind::Local | BackendKind::Acp => unreachable!("handled above"),
278    };
279
280    match discover {
281        Ok(binary) => match probe_cli_login(&binary, kind) {
282            AuthProbe::Ok => RoleReadiness {
283                role: role_key.into(),
284                backend,
285                status: ReadinessStatus::Ok,
286                detail: "binary found; login probe ok/unknown".into(),
287                next_action: "none".into(),
288            },
289            AuthProbe::Unauthenticated(detail) => RoleReadiness {
290                role: role_key.into(),
291                backend: backend.clone(),
292                status: ReadinessStatus::Unauthenticated,
293                detail,
294                next_action: format!("authenticate the {backend} CLI and re-queue"),
295            },
296            AuthProbe::RateLimited(detail) => RoleReadiness {
297                role: role_key.into(),
298                backend,
299                status: ReadinessStatus::RateLimited,
300                detail,
301                next_action: "wait for rate-limit reset, then drain again".into(),
302            },
303            AuthProbe::Unknown(detail) => RoleReadiness {
304                role: role_key.into(),
305                backend,
306                status: ReadinessStatus::Ok,
307                detail: format!("binary found; auth probe inconclusive ({detail})"),
308                next_action: "none".into(),
309            },
310        },
311        Err(e) => RoleReadiness {
312            role: role_key.into(),
313            backend: backend.clone(),
314            status: ReadinessStatus::Missing,
315            detail: e.to_string(),
316            next_action: format!("install the {backend} CLI on PATH and re-queue"),
317        },
318    }
319}
320
321/// Bound applied to the local backend's TCP reachability check — this is a
322/// preflight hint, not a guarantee, so it must never stall the drain loop.
323const LOCAL_REACHABILITY_TIMEOUT: Duration = Duration::from_millis(1500);
324
325/// Best-effort, short-timeout reachability probe for a local role's
326/// `base_url`. Never panics and never blocks past
327/// [`LOCAL_REACHABILITY_TIMEOUT`]: a missing/invalid `base_url` or a
328/// connection failure both downgrade to an indeterminate status rather than
329/// propagating an error.
330fn probe_local_reachability(base_url: Option<&str>) -> (ReadinessStatus, String, String) {
331    let Some(base_url) = base_url else {
332        return (
333            ReadinessStatus::Unknown,
334            "local backend has no baseUrl configured".into(),
335            "set the role's baseUrl and re-queue".into(),
336        );
337    };
338
339    let url = match reqwest::Url::parse(base_url) {
340        Ok(url) => url,
341        Err(e) => {
342            return (
343                ReadinessStatus::Unknown,
344                format!("baseUrl {base_url:?} is not a valid URL: {e}"),
345                "fix the role's baseUrl and re-queue".into(),
346            );
347        }
348    };
349
350    let (Some(host), Some(port)) = (url.host_str(), url.port_or_known_default()) else {
351        return (
352            ReadinessStatus::Unknown,
353            format!("baseUrl {base_url:?} has no resolvable host/port"),
354            "fix the role's baseUrl and re-queue".into(),
355        );
356    };
357
358    let addr = match (host, port).to_socket_addrs() {
359        Ok(mut addrs) => addrs.next(),
360        Err(_) => None,
361    };
362    let Some(addr) = addr else {
363        return (
364            ReadinessStatus::Missing,
365            format!("baseUrl host {host:?} did not resolve to an address"),
366            "verify the local endpoint is running and reachable, then re-queue".into(),
367        );
368    };
369
370    match TcpStream::connect_timeout(&addr, LOCAL_REACHABILITY_TIMEOUT) {
371        Ok(_) => (
372            ReadinessStatus::Ok,
373            format!("local endpoint {base_url} reachable"),
374            "none".into(),
375        ),
376        Err(e) => (
377            ReadinessStatus::Missing,
378            format!("local endpoint {base_url} unreachable: {e}"),
379            "start the local endpoint / verify baseUrl, then re-queue".into(),
380        ),
381    }
382}
383
384enum AuthProbe {
385    Ok,
386    Unauthenticated(String),
387    RateLimited(String),
388    Unknown(String),
389}
390
391/// Bounded login probe (≤3s). Prefer an explicit auth-status subcommand when
392/// the CLI supports it; never treat a missing subcommand as unauthenticated.
393fn probe_cli_login(binary: &Path, kind: BackendKind) -> AuthProbe {
394    let args: &[&str] =
395        match kind {
396            BackendKind::Claude => &["auth", "status"],
397            BackendKind::Codex => &["login", "status"],
398            BackendKind::Droid => return AuthProbe::Unknown("no auth-status subcommand".into()),
399            // No scriptable auth-status subcommand; `provider list` is the
400            // documented free read-only probe (docs/scoping/kimi-cli-backend.md
401            // §2) that shows the OAuth-managed provider when authenticated.
402            BackendKind::Kimi => &["provider", "list"],
403            // `agent models` is the free read-only probe that distinguishes
404            // BOTH failure modes the scoping doc's preflight requires
405            // (docs/scoping/cursor-cli-backend.md): unauthenticated ("Not
406            // logged in") vs. authenticated-but-zero-entitled-models ("No
407            // models available for this account") — the honest
408            // model-availability diagnostic of probe item 5.
409            BackendKind::Cursor => &["models"],
410            BackendKind::Local => {
411                return AuthProbe::Unknown("local backend has no CLI to probe".into())
412            }
413            BackendKind::Acp => return AuthProbe::Unknown(
414                "acp backend has no auth-status convention; the initialize handshake is the probe"
415                    .into(),
416            ),
417        };
418    match run_bounded(binary, args, Duration::from_secs(3), auth_env_for(kind)) {
419        Ok((code, out)) => {
420            let lower = out.to_ascii_lowercase();
421            if kind == BackendKind::Kimi && lower.contains("no providers configured") {
422                return AuthProbe::Unauthenticated(out);
423            }
424            // An authenticated cursor account with zero entitled models fails
425            // every session deterministically (probe item 5); park with the
426            // real cause named rather than letting a mission discover it.
427            if kind == BackendKind::Cursor && lower.contains("no models available") {
428                return AuthProbe::Unauthenticated(format!(
429                    "cursor account has no entitled models ({out}); provision at least one \
430                     model for the account (docs/scoping/cursor-cli-backend.md item 5)"
431                ));
432            }
433            if lower.contains("not logged")
434                || lower.contains("not authenticated")
435                || lower.contains("unauthenticated")
436                || (lower.contains("please run") && lower.contains("login"))
437            {
438                return AuthProbe::Unauthenticated(out);
439            }
440            if (lower.contains("rate") && lower.contains("limit"))
441                || lower.contains("429")
442                || (lower.contains("quota") && lower.contains("exceed"))
443            {
444                return AuthProbe::RateLimited(out);
445            }
446            if code == 0 {
447                AuthProbe::Ok
448            } else if lower.contains("unknown")
449                || lower.contains("unrecognized")
450                || lower.contains("invalid command")
451                || lower.contains("no such command")
452            {
453                AuthProbe::Unknown(format!("auth status unsupported: {out}"))
454            } else {
455                AuthProbe::Unknown(format!("exit {code}: {out}"))
456            }
457        }
458        Err(e) => AuthProbe::Unknown(e),
459    }
460}
461
462/// The ONE ambient var each backend's CLI may authenticate with — the same
463/// per-backend injection `agent_env::agent_session_env` performs for a
464/// session. A login probe that could not see it would report a
465/// key-authenticated operator as unauthenticated, so it is the single
466/// credential the cleared probe env carries ([`run_bounded`]).
467fn auth_env_for(kind: BackendKind) -> Option<&'static str> {
468    match kind {
469        BackendKind::Claude => Some("ANTHROPIC_API_KEY"),
470        BackendKind::Codex => Some("OPENAI_API_KEY"),
471        BackendKind::Cursor => Some("CURSOR_API_KEY"),
472        BackendKind::Kimi => Some("KIMI_API_KEY"),
473        BackendKind::Droid | BackendKind::Local | BackendKind::Acp => None,
474    }
475}
476
477/// Run one bounded readiness probe with a CLEARED environment (2026-09-01
478/// adversarial audit, H5): the discovery and readiness probes were the only
479/// children the engine spawned with the operator's whole environment, so a
480/// repo-named or PATH-shadowed CLI collected `GH_TOKEN`, `SLACK_*`, `AWS_*`
481/// and every API key on its first invocation. The probe env is
482/// `agent_env::probe_child_env` — the session allowlist WITHOUT the scratch
483/// relocation, because a login probe has to read the operator's real config
484/// to answer the question it is asked — plus `auth_env`, the one ambient
485/// credential this backend's CLI may authenticate with.
486fn run_bounded(
487    binary: &Path,
488    args: &[&str],
489    timeout: Duration,
490    auth_env: Option<&str>,
491) -> std::result::Result<(i32, String), String> {
492    use std::process::{Command, Stdio};
493    let extra: Vec<(String, String)> = auth_env
494        .and_then(|name| {
495            std::env::var_os(name)
496                .filter(|value| !value.is_empty())
497                .map(|value| (name.to_string(), value.to_string_lossy().into_owned()))
498        })
499        .into_iter()
500        .collect();
501    let mut command = Command::new(binary);
502    command
503        .args(args)
504        .stdin(Stdio::null())
505        .stdout(Stdio::piped())
506        .stderr(Stdio::piped())
507        .env_clear()
508        .envs(crate::agent_env::probe_child_env(&extra));
509    let mut child = command.spawn().map_err(|e| format!("spawn failed: {e}"))?;
510    let start = Instant::now();
511    loop {
512        match child.try_wait() {
513            Ok(Some(status)) => {
514                let mut stdout = String::new();
515                let mut stderr = String::new();
516                if let Some(mut out) = child.stdout.take() {
517                    let _ = out.read_to_string(&mut stdout);
518                }
519                if let Some(mut err) = child.stderr.take() {
520                    let _ = err.read_to_string(&mut stderr);
521                }
522                let combined = format!("{stdout}{stderr}").trim().to_string();
523                return Ok((status.code().unwrap_or(-1), combined));
524            }
525            Ok(None) if start.elapsed() >= timeout => {
526                let _ = child.kill();
527                let _ = child.wait();
528                return Err(format!("timed out after {}s", timeout.as_secs()));
529            }
530            Ok(None) => std::thread::sleep(Duration::from_millis(20)),
531            Err(e) => {
532                let _ = child.kill();
533                return Err(format!("wait failed: {e}"));
534            }
535        }
536    }
537}
538
539fn worst_status(statuses: impl Iterator<Item = ReadinessStatus>) -> ReadinessStatus {
540    // Priority: missing/unauth/unsupported > rate_limited > ok > unknown/meterless
541    let mut worst = ReadinessStatus::Ok;
542    for s in statuses {
543        worst = match (worst, s) {
544            (_, ReadinessStatus::Missing) => ReadinessStatus::Missing,
545            (ReadinessStatus::Missing, _) => ReadinessStatus::Missing,
546            (_, ReadinessStatus::Unauthenticated) => ReadinessStatus::Unauthenticated,
547            (ReadinessStatus::Unauthenticated, _) => ReadinessStatus::Unauthenticated,
548            (_, ReadinessStatus::Unsupported) => ReadinessStatus::Unsupported,
549            (ReadinessStatus::Unsupported, _) => ReadinessStatus::Unsupported,
550            (_, ReadinessStatus::RateLimited) => ReadinessStatus::RateLimited,
551            (ReadinessStatus::RateLimited, _) => ReadinessStatus::RateLimited,
552            (ReadinessStatus::Ok, other) => other,
553            (a, _) => a,
554        };
555    }
556    worst
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use crate::types::MissionConfig;
563
564    #[test]
565    fn unknown_quota_proceeds_with_warning() {
566        // Default config on a machine with claude may be Ok or Missing —
567        // force overall Meterless path via drain_decision.
568        let report = ReadinessReport {
569            mission_id: "m-1".into(),
570            roles: vec![RoleReadiness {
571                role: "worker".into(),
572                backend: "claude".into(),
573                status: ReadinessStatus::Ok,
574                detail: "ok".into(),
575                next_action: "none".into(),
576            }],
577            overall: ReadinessStatus::Meterless,
578            warnings: vec![],
579        };
580        match report.drain_decision() {
581            DrainDecision::Proceed { warnings } => {
582                assert!(!warnings.is_empty());
583            }
584            other => panic!("expected Proceed, got {other:?}"),
585        }
586    }
587
588    #[test]
589    fn missing_binary_parks() {
590        let report = ReadinessReport {
591            mission_id: "m-1".into(),
592            roles: vec![RoleReadiness {
593                role: "worker".into(),
594                backend: "codex".into(),
595                status: ReadinessStatus::Missing,
596                detail: "no codex binary".into(),
597                next_action: "install codex".into(),
598            }],
599            overall: ReadinessStatus::Missing,
600            warnings: vec![],
601        };
602        assert!(matches!(
603            report.drain_decision(),
604            DrainDecision::Park { .. }
605        ));
606    }
607
608    #[test]
609    fn missing_kimi_binary_parks() {
610        let report = ReadinessReport {
611            mission_id: "m-1".into(),
612            roles: vec![RoleReadiness {
613                role: "worker".into(),
614                backend: "kimi".into(),
615                status: ReadinessStatus::Missing,
616                detail: "no kimi binary".into(),
617                next_action: "install kimi".into(),
618            }],
619            overall: ReadinessStatus::Missing,
620            warnings: vec![],
621        };
622        assert!(matches!(
623            report.drain_decision(),
624            DrainDecision::Park { .. }
625        ));
626    }
627
628    #[test]
629    fn probe_role_classifies_kimi_backend() {
630        let mut cfg = MissionConfig::default();
631        cfg.worker.backend = Some("kimi".into());
632        let role = probe_role(crate::types::Role::Worker, &cfg);
633        assert_eq!(role.backend, "kimi");
634        // Discovery either finds the binary (Ok/Unauthenticated/RateLimited)
635        // or reports Missing — never panics, always a real classification.
636        assert!(matches!(
637            role.status,
638            ReadinessStatus::Ok
639                | ReadinessStatus::Missing
640                | ReadinessStatus::Unauthenticated
641                | ReadinessStatus::RateLimited
642                | ReadinessStatus::Meterless
643        ));
644    }
645
646    #[test]
647    fn rate_limited_requeues() {
648        let report = ReadinessReport {
649            mission_id: "m-1".into(),
650            roles: vec![RoleReadiness {
651                role: "orchestrator".into(),
652                backend: "claude".into(),
653                status: ReadinessStatus::RateLimited,
654                detail: "429".into(),
655                next_action: "wait".into(),
656            }],
657            overall: ReadinessStatus::RateLimited,
658            warnings: vec![],
659        };
660        match report.drain_decision() {
661            DrainDecision::RequeueDelay { delay, .. } => {
662                assert!(delay.as_secs() >= 1);
663            }
664            other => panic!("expected RequeueDelay, got {other:?}"),
665        }
666    }
667
668    #[test]
669    fn invalid_model_parks_via_validate() {
670        let mut cfg = MissionConfig::default();
671        cfg.orchestrator.model = "not-a-real-model-xyz".into();
672        let report = probe_config("m-x", Path::new("/tmp"), &cfg);
673        assert_eq!(report.overall, ReadinessStatus::Unsupported);
674        assert!(matches!(
675            report.drain_decision(),
676            DrainDecision::Park { .. }
677        ));
678    }
679
680    #[test]
681    fn enforced_sandbox_on_non_claude_backend_parks_via_validate() {
682        // The fail-closed sandbox/backend check in config::validate flows
683        // through the readiness probe's config-validation-first block, so a
684        // queued mission with the bad pair parks before it ever runs.
685        let mut cfg = MissionConfig::default();
686        cfg.worker.backend = Some("codex".into());
687        cfg.worker.sandbox.enforce = SandboxEnforce::FsNet;
688        let report = probe_config("m-sandbox", Path::new("/tmp"), &cfg);
689        assert_eq!(report.overall, ReadinessStatus::Unsupported);
690        let config_row = report
691            .roles
692            .iter()
693            .find(|r| r.role == "config")
694            .expect("config validation row");
695        assert!(
696            config_row.detail.contains("codex"),
697            "detail must name the backend: {}",
698            config_row.detail
699        );
700        assert!(
701            config_row.detail.contains("fs+net"),
702            "detail must name the enforce mode: {}",
703            config_row.detail
704        );
705        match report.drain_decision() {
706            DrainDecision::Park { reason } => {
707                assert!(reason.contains("codex"), "{reason}");
708            }
709            other => panic!("expected Park, got {other:?}"),
710        }
711    }
712
713    #[test]
714    fn enforced_sandbox_on_claude_backend_does_not_park_on_config() {
715        let mut cfg = MissionConfig::default();
716        cfg.worker.sandbox.enforce = SandboxEnforce::FsNet;
717        let report = probe_config("m-sandbox-ok", Path::new("/tmp"), &cfg);
718        assert!(
719            !report
720                .roles
721                .iter()
722                .any(|r| r.role == "config" && r.status == ReadinessStatus::Unsupported),
723            "claude + enforced sandbox must not produce a config rejection: {:?}",
724            report.roles
725        );
726    }
727
728    #[test]
729    fn passing_default_config_does_not_park_on_quota() {
730        let cfg = MissionConfig::default();
731        // May be Missing if claude absent in CI — either Proceed or Park(missing),
732        // but never Park for meteless alone when binaries are Ok.
733        let report = probe_config("m-ok", Path::new("/tmp"), &cfg);
734        match report.drain_decision() {
735            DrainDecision::Proceed { .. } => {}
736            DrainDecision::Park { reason } => {
737                assert!(
738                    reason.contains("Missing")
739                        || reason.to_ascii_lowercase().contains("install")
740                        || reason.to_ascii_lowercase().contains("binary")
741                        || reason.to_ascii_lowercase().contains("not found")
742                        || reason.to_ascii_lowercase().contains("could not"),
743                    "unexpected park reason: {reason}"
744                );
745            }
746            DrainDecision::RequeueDelay { .. } => panic!("default config should not rate-limit"),
747        }
748    }
749
750    #[test]
751    fn local_role_with_missing_base_url_is_unknown_not_panic() {
752        let (status, detail, _next_action) = probe_local_reachability(None);
753        assert_eq!(status, ReadinessStatus::Unknown);
754        assert!(detail.to_ascii_lowercase().contains("baseurl"));
755    }
756
757    #[test]
758    fn local_role_with_unreachable_base_url_is_not_ready_not_panic() {
759        // Port 1 is a reserved/unassigned TCP port — nothing should be
760        // listening there, so this must fail fast (bounded by
761        // LOCAL_REACHABILITY_TIMEOUT) rather than hang or panic.
762        let (status, detail, next_action) = probe_local_reachability(Some("http://127.0.0.1:1/v1"));
763        assert!(
764            matches!(status, ReadinessStatus::Missing | ReadinessStatus::Unknown),
765            "expected a not-ready/uncertain status, got {status:?}"
766        );
767        assert!(!detail.is_empty());
768        assert_ne!(next_action, "none");
769    }
770
771    #[test]
772    fn local_role_with_invalid_base_url_is_unknown_not_panic() {
773        let (status, _detail, _next_action) = probe_local_reachability(Some("not-a-url"));
774        assert_eq!(status, ReadinessStatus::Unknown);
775    }
776
777    /// H5 (2026-09-01 adversarial audit), the readiness half: `run_bounded`
778    /// spawned the login probe with the engine's whole ambient environment,
779    /// so a PATH-shadowed `claude`/`codex`/`kimi`/`cursor` collected every
780    /// operator credential on its first invocation. Only the allowlist and
781    /// this backend's ONE auth var cross now.
782    #[cfg(unix)]
783    #[test]
784    fn login_probe_spawns_with_a_cleared_env_carrying_only_its_auth_var() {
785        use std::os::unix::fs::PermissionsExt as _;
786
787        let _guard = crate::agent_env::EnvTestGuard::engage(&[
788            ("KRANZ_SECRET_TEST", "leaked-to-the-probe"),
789            ("GH_TOKEN", "ghp_poison"),
790            ("ANTHROPIC_API_KEY", "sk-ant-allowed"),
791        ]);
792
793        let dir = tempfile::tempdir().unwrap();
794        let stub = dir.path().join("env-dumping-cli");
795        std::fs::write(&stub, "#!/bin/sh\nenv\n").unwrap();
796        std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
797
798        let (code, dumped) = run_bounded(
799            &stub,
800            &["auth", "status"],
801            Duration::from_secs(5),
802            auth_env_for(BackendKind::Claude),
803        )
804        .unwrap();
805
806        assert_eq!(code, 0, "{dumped}");
807        for secret in ["KRANZ_SECRET_TEST", "GH_TOKEN"] {
808            assert!(
809                !dumped.contains(secret),
810                "{secret} reached the login probe:\n{dumped}"
811            );
812        }
813        // The one credential the CLI may authenticate with DOES cross, or a
814        // key-authenticated operator would be reported unauthenticated.
815        assert!(
816            dumped.contains(&format!("ANTHROPIC_API_KEY={}", "sk-ant-allowed")),
817            "the backend's own auth var must reach its login probe:\n{dumped}"
818        );
819        // And HOME stays real: `claude auth status` reads the operator's own
820        // config to answer the question it is asked.
821        assert!(dumped.contains("HOME="), "{dumped}");
822    }
823
824    /// A backend with no auth-var convention carries none — the probe env is
825    /// then the bare allowlist.
826    #[cfg(unix)]
827    #[test]
828    fn login_probe_without_an_auth_var_carries_no_credential() {
829        use std::os::unix::fs::PermissionsExt as _;
830
831        let _guard =
832            crate::agent_env::EnvTestGuard::engage(&[("ANTHROPIC_API_KEY", "sk-ant-poison")]);
833        let dir = tempfile::tempdir().unwrap();
834        let stub = dir.path().join("env-dumping-cli");
835        std::fs::write(&stub, "#!/bin/sh\nenv\n").unwrap();
836        std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
837
838        let (_code, dumped) = run_bounded(
839            &stub,
840            &["--help"],
841            Duration::from_secs(5),
842            auth_env_for(BackendKind::Droid),
843        )
844        .unwrap();
845
846        assert!(!dumped.contains("ANTHROPIC_API_KEY"), "{dumped}");
847    }
848
849    #[test]
850    fn local_probe_role_never_panics_and_reports_local_backend() {
851        let mut cfg = MissionConfig::default();
852        cfg.worker.backend = Some("local".into());
853        cfg.worker.base_url = Some("http://127.0.0.1:1/v1".into());
854        cfg.worker.model = "my-local-model".into();
855        let role = probe_role(crate::types::Role::Worker, &cfg);
856        assert_eq!(role.backend, "local");
857        assert!(matches!(
858            role.status,
859            ReadinessStatus::Missing | ReadinessStatus::Unknown
860        ));
861    }
862}