1use 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#[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 Meterless,
29}
30
31#[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 pub next_action: String,
41}
42
43#[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 pub overall: ReadinessStatus,
51 pub warnings: Vec<String>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum DrainDecision {
57 Proceed { warnings: Vec<String> },
59 Park { reason: String },
61 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
118pub 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
136pub 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 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 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 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 let overall = match overall {
202 ReadinessStatus::Ok => ReadinessStatus::Meterless, 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 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 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
321const LOCAL_REACHABILITY_TIMEOUT: Duration = Duration::from_millis(1500);
324
325fn 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
391fn 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 BackendKind::Kimi => &["provider", "list"],
403 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 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
462fn 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
477fn 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 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 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 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 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 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 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 #[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 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 assert!(dumped.contains("HOME="), "{dumped}");
822 }
823
824 #[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}