1use crate::context::Context;
11use crate::error::Error;
12use crate::host::{Access, Kind, Os};
13use crate::provider::ProviderId;
14use crate::provider::claude::daemon;
15use crate::provider::claude::live as claude_live;
16use crate::provider::claude::paths as claude;
17use crate::provider::claude::slot;
18use crate::provider::codex::paths as codex;
19use crate::state::{Park, State};
20use crate::{home, park, store, switch, time, usage, words};
21use serde_json::{Value, json};
22use std::path::PathBuf;
23
24#[derive(Debug, PartialEq, Clone, Copy)]
25pub enum Level {
26 Ok,
27 Warn,
28 Fail,
29}
30
31pub struct Check {
32 pub code: &'static str,
34 pub name: String,
35 pub level: Level,
36 pub detail: String,
37 pub advice: String,
39}
40
41#[non_exhaustive]
44pub struct Facts {
45 pub os: Os,
47 pub security_tool: Option<String>,
48 pub config_path: PathBuf,
49 pub config: Result<Value, Error>,
50 pub identity: Option<claude::Identity>,
51 pub service: String,
52 pub account: String,
53 pub default_slot: bool,
54 pub storage_dir: String,
55 pub backend: Result<store::Backend, store::Error>,
56 pub credential_file: PathBuf,
57 pub credential: Result<Option<Value>, store::Error>,
58 pub credential_cost: Option<store::Cost>,
60 pub credential_parts: Vec<(String, usize)>,
62 pub home: PathBuf,
63 pub home_access: Option<Access>,
64 pub readable_by_others: Vec<(String, Access)>,
68 pub machine_id_known: bool,
69 pub hover_rest_env: bool,
71 pub daemon: Option<daemon::Daemon>,
73 pub pending_parks: Vec<String>,
75 pub claude_version: Option<String>,
77 pub auth_overrides: Vec<crate::settings::Override>,
80 pub auth_unread: Option<&'static str>,
83 pub asking_held: Vec<(String, i64)>,
85 pub state: Result<State, Error>,
86 pub parks: Vec<ParkFact>,
88 pub interrupted: bool,
89 pub stuck: Option<String>,
92 pub codex: CodexFacts,
94 pub claude_present: bool,
98 pub schedule: Option<ScheduleFact>,
100 pub network: NetworkFact,
102 pub in_the_app: bool,
106 pub elevation: crate::host::Elevation,
109 pub fallback_login: Option<FallbackLogin>,
111 pub now: i64,
112}
113
114pub struct NetworkFact {
118 pub proxy: Option<ProxyFact>,
120 pub passed_over: Vec<&'static str>,
123}
124
125pub struct ProxyFact {
127 pub variable: &'static str,
129 pub address: String,
131 pub server: String,
134 pub on_loopback: bool,
136 pub socks: bool,
140 pub exempting: Option<&'static str>,
142 pub exempt: Vec<String>,
144}
145
146pub struct ScheduleFact {
149 pub path: PathBuf,
151 pub program: Option<PathBuf>,
153 pub program_found: bool,
155 pub network: NetworkFact,
158 pub network_differs: bool,
161}
162
163pub struct CodexFacts {
169 pub home: PathBuf,
171 pub present: bool,
173 pub enrolled: usize,
175 pub backend: &'static str,
181 pub backend_setting: String,
185 pub backend_remedy: String,
189 pub auth_file: PathBuf,
191 pub auth_access: Option<Access>,
193 pub login: Result<Option<CodexLogin>, CodexLoginTrouble>,
196 pub program: Option<PathBuf>,
198 pub version: Option<String>,
201 pub running: Option<Vec<crate::holder::Holding>>,
204}
205
206pub struct CodexLogin {
209 pub email: String,
210 pub account_id: String,
211 pub fingerprint: String,
213}
214
215#[derive(Debug)]
221pub enum CodexLoginTrouble {
222 NotAnAccount(String),
225 Unusable(String),
227}
228
229pub struct ParkFact {
230 pub provider: ProviderId,
233 pub label: String,
234 pub name: String,
237 pub active: bool,
238 pub last_used_at: Option<i64>,
240 pub park: Option<Park>,
241 pub unreadable: Option<Unreadable>,
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
247pub enum Unreadable {
248 Locked,
250 Broken(String),
252}
253
254pub struct FallbackLogin {
257 pub path: PathBuf,
258 pub fingerprint: String,
260}
261
262impl ParkFact {
263 fn typed(&self) -> String {
265 self.name.clone()
266 }
267}
268
269fn park_facts(ctx: &Context, state: &State) -> Vec<ParkFact> {
270 let recorded: std::collections::BTreeMap<ProviderId, Option<crate::api::Owner>> =
275 ProviderId::ALL
276 .iter()
277 .filter(|&&which| state.accounts.iter().any(|a| a.provider() == which))
278 .map(|&which| {
279 let found = crate::provider::of(which).recorded_identity(ctx);
280 (which, found.map(crate::api::Owner::from))
281 })
282 .collect();
283 state
284 .accounts
285 .iter()
286 .map(|a| ParkFact {
287 provider: a.provider(),
288 label: a.label.clone(),
289 name: state.typed(&a.key()),
290 last_used_at: a.last_used_at,
291 active: match recorded.get(&a.provider()).and_then(Option::as_ref) {
294 Some(owner) => a.owned_by(owner),
295 None => state.active_for(a.provider()) == Some(a.label.as_str()),
296 },
297 park: a.parked.clone(),
298 unreadable: a.parked.as_ref().and_then(|p| {
299 park::load(ctx, &a.key(), p).err().map(|e| match e {
300 Error::Store(store::Error::Locked) => Unreadable::Locked,
301 Error::ParkedCredentialMissing { .. } => {
302 Unreadable::Broken("missing from the vault".into())
303 }
304 Error::ParkedCredentialCorrupt { detail, .. } => Unreadable::Broken(detail),
305 other => Unreadable::Broken(other.to_string()),
306 })
307 }),
308 })
309 .collect()
310}
311
312pub fn gather(ctx: &Context) -> Facts {
313 let config = claude::load_config(ctx);
314 let state = crate::state::load(ctx);
315 let service = claude::live_service(ctx);
316 let home = home::dir(ctx);
317 let identity = config.as_ref().ok().and_then(claude::identity);
318 Facts {
319 os: crate::host::OS,
320 security_tool: crate::host::OS
321 .secrets_tool()
322 .filter(|p| std::fs::metadata(p).is_ok())
323 .map(str::to_string),
324 config_path: claude::config_file(ctx),
325 identity: identity.clone(),
326 config,
327 account: slot::account_name(ctx),
328 default_slot: claude::is_default_slot(ctx),
329 storage_dir: claude::storage_dir(ctx),
330 backend: store::resolve(&claude_live::chain(ctx), &service),
331 credential_file: claude_live::credential_file(ctx),
332 credential_cost: store::read_raw(&claude_live::chain(ctx), &service)
333 .ok()
334 .flatten()
335 .and_then(|raw| store::cost(&claude_live::chain(ctx), &service, &raw)),
336 credential_parts: store::read(&claude_live::chain(ctx), &service)
337 .ok()
338 .flatten()
339 .map(|doc| parts_of(&doc))
340 .unwrap_or_default(),
341 credential: store::read(&claude_live::chain(ctx), &service),
342 home_access: crate::host::fs::access(&home),
343 readable_by_others: loose_logins(ctx),
344 home,
345 machine_id_known: crate::state::machine_id() != "unknown",
346 hover_rest_env: ctx.hover_rest,
347 daemon: daemon::read(ctx),
348 pending_parks: crate::pending::outstanding(ctx, state.as_ref().ok()),
349 claude_version: claude::installed_version(ctx),
350 auth_overrides: crate::settings::overrides(ctx),
351 auth_unread: crate::settings::unread(),
352 asking_held: crate::budget::holds(ctx),
353 parks: state
354 .as_ref()
355 .map(|s| park_facts(ctx, s))
356 .unwrap_or_default(),
357 codex: codex_facts(ctx, state.as_ref().ok()),
358 stuck: state
361 .as_ref()
362 .ok()
363 .and_then(|s| switch::stuck(ctx, s, switch::Asking::Nobody))
364 .map(|refusal| refusal.to_string()),
365 claude_present: claude::config_file(ctx).exists()
366 || claude::program(ctx).is_some()
367 || store::read_raw(&claude_live::chain(ctx), &service)
368 .is_ok_and(|found| found.is_some())
369 || state.as_ref().is_ok_and(|s| {
370 s.accounts
371 .iter()
372 .any(|a| a.provider() == ProviderId::Claude)
373 }),
374 state,
375 interrupted: switch::interrupted(ctx),
376 service,
377 schedule: schedule_fact(ctx),
378 network: network_fact(&ctx.proxy),
379 in_the_app: ctx.caller == "app",
380 elevation: ctx.host().elevation(ctx),
381 fallback_login: fallback_login(ctx),
382 now: ctx.now(),
383 }
384}
385
386fn fallback_login(ctx: &Context) -> Option<FallbackLogin> {
389 let document = claude_live::fallback_login(ctx).ok()??;
390 Some(FallbackLogin {
391 path: claude_live::credential_file(ctx),
392 fingerprint: crate::provider::claude::document::fingerprint_of(&document),
393 })
394}
395
396fn network_fact(proxies: &crate::proxy::Proxies) -> NetworkFact {
398 NetworkFact {
399 proxy: proxies
400 .variable()
401 .zip(proxies.address())
402 .map(|(variable, address)| ProxyFact {
403 variable,
404 address,
405 server: proxies.server().unwrap_or_default(),
406 on_loopback: proxies.on_loopback(),
407 socks: proxies.socks(),
408 exempting: proxies.exempting(),
409 exempt: proxies.exempt_hosts(),
410 }),
411 passed_over: proxies.passed_over().to_vec(),
412 }
413}
414
415fn schedule_fact(ctx: &Context) -> Option<ScheduleFact> {
417 if !crate::schedule::serves(ctx) {
418 return None;
419 }
420 let crate::schedule::Installed::Yes { path, .. } = crate::schedule::status(ctx) else {
421 return None;
422 };
423 let program = crate::schedule::installed_program(ctx);
424 let proxies = crate::schedule::installed_proxies(ctx).unwrap_or_default();
425 Some(ScheduleFact {
426 program_found: program.as_deref().is_some_and(std::path::Path::is_file),
427 program,
428 path,
429 network: network_fact(&proxies),
430 network_differs: !proxies.same_way(&ctx.proxy),
431 })
432}
433
434fn parts_of(document: &Value) -> Vec<(String, usize)> {
441 let weigh = |value: &Value| serde_json::to_string(value).map(|s| s.len()).unwrap_or(0);
442 let Some(root) = document.as_object() else {
443 return Vec::new();
444 };
445 let mut parts: Vec<(String, usize)> = root
446 .iter()
447 .flat_map(|(key, value)| match (key.as_str(), value.as_object()) {
448 ("mcpOAuth", Some(servers)) if servers.len() > 1 => servers
450 .iter()
451 .map(|(server, held)| (format!("mcpOAuth {server}"), weigh(held)))
452 .collect(),
453 _ => vec![(key.clone(), weigh(value))],
454 })
455 .collect();
456 parts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
457 parts
458}
459
460fn loose_logins(ctx: &Context) -> Vec<(String, Access)> {
469 let mut loose = Vec::new();
470 let mut look = |path: std::path::PathBuf| {
471 if let Some(access) = crate::host::fs::access(&path)
472 && access.shared
473 {
474 loose.push((path.display().to_string(), access));
475 }
476 };
477 look(claude_live::credential_file(ctx));
478 let vault = store::vault_dir(ctx);
479 look(vault.clone());
480 if let Ok(entries) = std::fs::read_dir(&vault) {
481 let mut parks: Vec<std::path::PathBuf> = entries.flatten().map(|e| e.path()).collect();
482 parks.sort();
483 for park in parks {
484 look(park);
485 }
486 }
487 loose
488}
489
490fn codex_facts(ctx: &Context, state: Option<&State>) -> CodexFacts {
493 let store = codex::store(ctx);
494 let backend = store.backend;
495 let auth_file = codex::auth_file(ctx);
496 let home = codex::home(ctx);
497 let program = crate::provider::program_of(ctx, ProviderId::Codex);
501 CodexFacts {
502 present: home.is_dir(),
503 enrolled: state.map_or(0, |s| {
504 s.accounts
505 .iter()
506 .filter(|a| a.provider() == ProviderId::Codex)
507 .count()
508 }),
509 backend: backend_name(backend),
510 backend_setting: match backend {
511 codex::Backend::Unknown => store.why_unknown().unwrap_or_default(),
512 _ => store.setting(),
513 },
514 backend_remedy: match backend {
515 codex::Backend::File => String::new(),
516 codex::Backend::Unknown => store.while_unknown().unwrap_or_default(),
517 _ => store.to_use_the_file(),
518 },
519 auth_access: crate::host::fs::access(&auth_file),
520 login: if backend == codex::Backend::File {
524 codex_login(ctx)
525 } else {
526 Ok(None)
527 },
528 version: program.as_deref().and_then(codex_version),
529 program,
530 running: running_codex(ctx),
531 auth_file,
532 home,
533 }
534}
535
536fn backend_name(backend: codex::Backend) -> &'static str {
542 match backend {
543 codex::Backend::File => "file",
544 codex::Backend::Keyring => "keyring",
545 codex::Backend::Either => "auto",
546 codex::Backend::Ephemeral => "ephemeral",
547 codex::Backend::Secrets => "secrets",
551 codex::Backend::Unknown => "unknown",
553 }
554}
555
556const PROJECTS_NOT_READ: &str = "Pitboard does not read a project's own \
558 `.codex/config.toml`, which applies only inside that project: in a trusted project that \
559 sets `cli_auth_credentials_store`, Codex keeps its login somewhere else.";
560
561fn codex_login(ctx: &Context) -> Result<Option<CodexLogin>, CodexLoginTrouble> {
563 let tool = crate::provider::of(ProviderId::Codex);
564 let unusable = |e: crate::provider::ProviderError| CodexLoginTrouble::Unusable(e.to_string());
565 let Some(credential) = tool.read_live(ctx).map_err(unusable)? else {
566 return Ok(None);
567 };
568 tool.slice(&credential.raw).map_err(|e| match e {
570 crate::provider::ProviderError::Unsupported { reason, .. } => {
571 CodexLoginTrouble::NotAnAccount(reason)
572 }
573 other => unusable(other),
574 })?;
575 let found = tool.identify(ctx, &credential).map_err(unusable)?;
576 Ok(Some(CodexLogin {
577 email: found.email,
578 account_id: found.account_id,
579 fingerprint: tool.fingerprint(&credential.raw),
580 }))
581}
582
583fn codex_version(program: &std::path::Path) -> Option<String> {
593 let resolved = std::fs::canonicalize(program).ok()?;
594 let near: Vec<&std::path::Path> = resolved.ancestors().skip(1).take(2).collect();
595 near.iter()
596 .find_map(|dir| {
597 dir.file_name()
598 .and_then(|n| n.to_str())
599 .and_then(|name| name.split('-').next())
600 .filter(|leading| looks_like_a_version(leading))
601 .map(str::to_owned)
602 })
603 .or_else(|| {
604 near.iter()
605 .find_map(|dir| codex_package_version(&dir.join("package.json")))
606 })
607}
608
609fn looks_like_a_version(name: &str) -> bool {
610 let parts: Vec<&str> = name.split('.').collect();
611 parts.len() == 3
612 && parts
613 .iter()
614 .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
615}
616
617fn codex_package_version(path: &std::path::Path) -> Option<String> {
618 let json: Value = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
619 if json.get("name")?.as_str()? != "@openai/codex" {
620 return None;
621 }
622 Some(json.get("version")?.as_str()?.to_string())
623}
624
625fn running_codex(ctx: &Context) -> Option<Vec<crate::holder::Holding>> {
628 match crate::provider::of(ProviderId::Codex).adoption() {
629 crate::provider::Adoption::RestartRequired { program, holders } => {
630 crate::holder::find(ctx, program, holders)
631 }
632 crate::provider::Adoption::PollingWithin(_) => Some(Vec::new()),
633 }
634}
635
636fn elevated(elevation: crate::host::Elevation) -> Option<Check> {
640 let detail = match elevation {
641 crate::host::Elevation::Normal => return None,
642 crate::host::Elevation::Elevated { why } => format!("Pitboard runs {why}"),
643 crate::host::Elevation::Unknown => {
644 "Pitboard cannot tell whether it runs as root or with sudo".to_string()
645 }
646 };
647 Some(fail(
648 "elevated",
649 "runs as",
650 detail,
651 "Pitboard changes nothing this way: it reads, and renews and writes nothing. Run it \
652 as yourself.",
653 ))
654}
655
656fn ok(code: &'static str, name: impl Into<String>, detail: impl Into<String>) -> Check {
657 Check {
658 code,
659 name: name.into(),
660 level: Level::Ok,
661 detail: detail.into(),
662 advice: String::new(),
663 }
664}
665fn warn(
666 code: &'static str,
667 name: impl Into<String>,
668 detail: impl Into<String>,
669 advice: impl Into<String>,
670) -> Check {
671 Check {
672 code,
673 name: name.into(),
674 level: Level::Warn,
675 detail: detail.into(),
676 advice: advice.into(),
677 }
678}
679fn fail(
680 code: &'static str,
681 name: impl Into<String>,
682 detail: impl Into<String>,
683 advice: impl Into<String>,
684) -> Check {
685 Check {
686 code,
687 name: name.into(),
688 level: Level::Fail,
689 detail: detail.into(),
690 advice: advice.into(),
691 }
692}
693
694fn running(command: Option<String>) -> String {
698 command.map_or_else(|| ".".into(), |command| format!(": `{command}`."))
699}
700
701pub fn evaluate(facts: &Facts) -> Vec<Check> {
702 let mut checks = Vec::new();
703 let codex_here = facts.codex.present || facts.codex.enrolled > 0;
707 let claude_here = facts.claude_present || !codex_here;
708
709 if let Some(check) = elevated(facts.elevation) {
712 checks.push(check);
713 }
714
715 if let Some(tool) = facts.os.secrets_tool() {
716 checks.push(match &facts.security_tool {
717 Some(path) => ok("security_tool", "security tool", path.clone()),
718 None => fail(
719 "security_tool",
720 "security tool",
721 format!("{tool} is missing"),
722 "Pitboard reads the keychain the same way Claude Code does. Without it, nothing works.",
723 ),
724 });
725 }
726
727 checks.push(match &facts.config {
728 Ok(v) => ok(
729 "config_file",
730 "config file",
731 format!(
732 "{} ({} keys)",
733 facts.config_path.display(),
734 v.as_object().map_or(0, serde_json::Map::len)
735 ),
736 ),
737 Err(e) => fail(
738 "config_file",
739 "config file",
740 e.to_string(),
741 "Run `claude` once.",
742 ),
743 });
744
745 checks.push(match &facts.identity {
746 Some(id) => ok(
747 "identity",
748 "identity",
749 format!("{} · org {}", id.email, id.organization_uuid),
750 ),
751 None => warn(
752 "identity",
753 "identity",
754 "Claude Code has not recorded a signed-in account",
755 "It writes this after its first successful call. Run `claude` once.",
756 ),
757 });
758
759 checks.push(ok(
760 "slot",
761 "slot",
762 if facts.default_slot {
763 format!(
764 "default · {} · account {}",
765 facts.service, facts.account
766 )
767 } else {
768 format!(
769 "{} · account {} (selected by {})",
770 facts.service, facts.account, facts.storage_dir
771 )
772 },
773 ));
774
775 if let Some(price) = facts.credential_cost {
778 let (bytes, limit) = (price.needs, price.limit);
779 checks.push(if price.refused() {
780 fail(
783 "credential_size",
784 "login size",
785 format!(
786 "{bytes} of {limit} bytes, and PITBOARD_NO_ARGV refuses that{}",
787 biggest(&facts.credential_parts)
788 ),
789 "There is no third way to write a login this size. Sign out of MCP servers \
790 you no longer use to make it smaller, or unset PITBOARD_NO_ARGV and accept \
791 the argument-line write.",
792 )
793 } else if price.over() {
794 warn(
798 "credential_size",
799 "login size",
800 format!(
801 "{bytes} of {limit} bytes: written on the argument line{}",
802 biggest(&facts.credential_parts)
803 ),
804 "A login this size can only be written by passing it as an argument, where \
805 a process running as you could read it while the call lasts. Signing out \
806 of MCP servers you no longer use makes it smaller; PITBOARD_NO_ARGV=1 \
807 refuses the switch instead.",
808 )
809 } else {
810 ok(
811 "credential_size",
812 "login size",
813 format!("{bytes} of {limit} bytes"),
814 )
815 });
816 }
817
818 checks.push(match &facts.backend {
819 Ok(store::Backend::Keychain) => ok("credential_store", "credential store", "keychain"),
820 Ok(store::Backend::File) => {
821 let detail = format!("plaintext file · {}", facts.credential_file.display());
822 match facts.os {
823 Os::MacOs => warn(
824 "credential_store",
825 "credential store",
826 detail,
827 "Claude Code fell back to a file, which means a keychain write failed at some point.",
828 ),
829 Os::Linux => ok("credential_store", "credential store", detail),
830 Os::Windows => warn(
836 "credential_store",
837 "credential store",
838 detail,
839 "Claude Code may keep its login in Credential Manager instead, which \
840 Pitboard does not read yet.",
841 ),
842 }
843 }
844 Ok(store::Backend::Unknown) => fail(
845 "credential_store",
846 "credential store",
847 "a store Pitboard does not read yet",
848 "Treat this as unknown, never as empty.",
849 ),
850 Ok(store::Backend::Absent) if facts.identity.is_some() => fail(
855 "credential_store",
856 "credential store",
857 format!(
858 "Claude Code's config says {} is signed in, and no store Pitboard reads \
859 holds that login",
860 facts
861 .identity
862 .as_ref()
863 .map(|i| i.email.as_str())
864 .unwrap_or("somebody")
865 ),
866 "Pitboard will not write a login where nobody reads it. Check for a Pitboard \
867 update; if there is none, this is worth reporting.",
868 ),
869 Ok(store::Backend::Absent) => warn(
870 "credential_store",
871 "credential store",
872 "no credential in any backend",
873 "Nothing is signed in for this slot.",
874 ),
875 Err(e) => fail(
876 "credential_store",
877 "credential store",
878 e.to_string(),
879 "Treat this as unknown, never as empty.",
880 ),
881 });
882
883 checks.push(judge_credential(facts));
884 checks.extend(judge_fallback_login(facts));
885
886 checks.push(
887 match (
888 facts
889 .config
890 .as_ref()
891 .ok()
892 .and_then(usage::from_config_cache),
893 &facts.identity,
894 ) {
895 (Some(s), Some(id)) if s.account_uuid.as_deref() == Some(id.account_uuid.as_str()) => {
896 ok(
897 "usage_cache",
898 "usage cache",
899 format!("{} windows, measured for this account", s.windows.len()),
900 )
901 }
902 (Some(_), Some(_)) => warn(
903 "usage_cache",
904 "usage cache",
905 "cached for a different account",
906 "Its numbers are ignored rather than shown, which is why status may look empty.",
907 ),
908 (Some(s), None) => ok(
909 "usage_cache",
910 "usage cache",
911 format!("{} windows", s.windows.len()),
912 ),
913 (None, _) => ok(
914 "usage_cache",
915 "usage cache",
916 "absent; Claude Code writes it after a call that reports usage",
917 ),
918 },
919 );
920
921 checks.push(match &facts.home_access {
922 None => ok(
923 "home",
924 "Pitboard home",
925 format!("{} (not created yet)", facts.home.display()),
926 ),
927 Some(access) if !access.shared => {
928 ok("home", "Pitboard home", facts.home.display().to_string())
929 }
930 Some(access) => {
931 warn(
932 "home",
933 "Pitboard home",
934 format!("{} is {}", facts.home.display(), access.described),
935 format!(
936 "Park names contain account identifiers, so only you should read it{}",
937 running(facts.os.make_private_command(
938 Kind::Directory,
939 &[&facts.home.display().to_string()]
940 ))
941 ),
942 )
943 }
944 });
945
946 checks.push(if facts.readable_by_others.is_empty() {
947 ok(
948 "private_on_disk",
949 "logins on disk",
950 "nothing holding a login is readable by anyone else",
951 )
952 } else {
953 let names: Vec<String> = facts
954 .readable_by_others
955 .iter()
956 .map(|(path, access)| format!("{path} is {}", access.described))
957 .collect();
958 fail(
959 "private_on_disk",
960 "logins on disk",
961 names.join("; "),
962 format!(
963 "These hold usable OAuth tokens in plain text, which is how Claude Code \
964 stores them where there is no keychain. Anyone else on this machine can \
965 read them{}",
966 running(
967 facts.os.make_private_command(
968 Kind::Any,
969 &facts
970 .readable_by_others
971 .iter()
972 .map(|(path, _)| path.as_str())
973 .collect::<Vec<_>>()
974 )
975 ),
976 ),
977 )
978 });
979
980 checks.push(match &facts.state {
981 Ok(state) => ok(
982 "state",
983 "accounts",
984 match state.accounts.len() {
985 0 => "none enrolled yet".to_string(),
986 1 => "1 enrolled".to_string(),
987 n => format!("{n} enrolled"),
988 },
989 ),
990 Err(e) => fail(
991 "state",
992 "accounts",
993 e.to_string(),
994 match e {
995 Error::StateWrongMachine { .. } => {
997 "Run `pitboard adopt` to keep these accounts on this computer. The \
998 logins they came with are dropped, because a login belongs to the \
999 computer that signed in."
1000 }
1001 _ => "Pitboard will not switch until its account list can be read.",
1002 },
1003 ),
1004 });
1005 let (claude_parks, codex_parks): (Vec<&ParkFact>, Vec<&ParkFact>) = facts
1008 .parks
1009 .iter()
1010 .partition(|p| p.provider == ProviderId::Claude);
1011 checks.extend(claude_parks.iter().map(|p| judge_park(p, facts.now)));
1012 if let Some(refusal) = &facts.stuck {
1013 checks.push(warn(
1014 "interrupted_switch",
1015 "interrupted switch",
1016 refusal.clone(),
1017 "If it still cannot be finished, `pitboard abandon` gives up on the switch and \
1018 keeps every login.",
1019 ));
1020 } else if facts.interrupted {
1021 checks.push(warn(
1022 "interrupted_switch",
1023 "interrupted switch",
1024 "a switch did not finish",
1025 "The next `pitboard use`, `enroll` or `forget` finishes it before anything else.",
1026 ));
1027 }
1028 if let Ok(state) = &facts.state
1029 && !state.discarded.is_empty()
1030 {
1031 checks.push(warn(
1032 "discarded",
1033 "old parked logins",
1034 format!("{} waiting to be deleted", state.discarded.len()),
1035 "Pitboard deletes them on its next change; if they stay, check the keychain is unlocked.",
1036 ));
1037 }
1038
1039 if !facts.machine_id_known {
1040 checks.push(warn(
1041 "machine_id",
1042 "machine id",
1043 "this machine has no stable identifier",
1044 "Pitboard cannot tell this machine from another that also lacks one, so it cannot \
1045 refuse state copied between them. Never copy ~/.pitboard between machines.",
1046 ));
1047 }
1048
1049 checks.push(judge_storage_v5(facts));
1050 checks.push(judge_daemon(facts));
1051 checks.push(judge_pending(facts));
1052 checks.extend(judge_schedule(facts));
1053 checks.extend(judge_schedule_proxy(facts));
1054 checks.push(judge_claude_version(facts));
1055 checks.push(judge_auth(facts));
1056 checks.push(judge_asking(facts));
1057 checks.push(judge_network(&facts.network));
1058 checks.extend(
1059 claude_parks
1060 .iter()
1061 .filter_map(|p| judge_dormant(p, facts.now)),
1062 );
1063 if codex_here || !codex_parks.is_empty() {
1066 checks.extend(judge_codex(facts.os, &facts.codex, &codex_parks, facts.now));
1067 }
1068 if !claude_here {
1069 checks.retain(|check| !CLAUDE_CODES_OWN.contains(&check.code));
1070 }
1071 checks
1072}
1073
1074const CLAUDE_CODES_OWN: &[&str] = &[
1077 "config_file",
1078 "identity",
1079 "slot",
1080 "credential_size",
1081 "credential_store",
1082 "credential",
1083 "fallback_login",
1084 "usage_cache",
1085 "storage_v5",
1086 "daemon",
1087 "claude_version",
1088 "auth",
1089];
1090
1091fn account_code(provider: ProviderId, claude: &'static str, codex: &'static str) -> &'static str {
1095 match provider {
1096 ProviderId::Claude => claude,
1097 ProviderId::Codex => codex,
1098 }
1099}
1100
1101const A_TOKEN_LIFETIME: i64 = 30 * 86_400;
1105
1106fn judge_dormant(park: &ParkFact, now: i64) -> Option<Check> {
1114 if park.active || park.park.is_none() {
1115 return None;
1116 }
1117 let dormant_for = now - park.last_used_at?;
1118 if dormant_for < A_TOKEN_LIFETIME {
1119 return None;
1120 }
1121 Some(warn(
1122 account_code(park.provider, "dormant_account", "codex_dormant_account"),
1123 format!("account {}", park.typed()),
1124 format!(
1125 "not switched to for {}; Pitboard has kept its login alive that whole time",
1126 words::span(dormant_for)
1127 ),
1128 format!(
1129 "Every `pitboard` renews it, so its refresh token is rotated and kept live on \
1130 this machine for as long as it stays enrolled. If you are not coming back to \
1131 it, `pitboard forget {}` deletes the login and the record.",
1132 park.typed()
1133 ),
1134 ))
1135}
1136
1137fn judge_credential(facts: &Facts) -> Check {
1138 match &facts.credential {
1139 Ok(Some(doc)) => {
1140 let keys: Vec<&str> = doc
1141 .as_object()
1142 .map(|o| o.keys().map(String::as_str).collect())
1143 .unwrap_or_default();
1144 if doc.get("claudeAiOauth").is_none() {
1147 return warn(
1148 "credential",
1149 "credential",
1150 format!("signed out; the document keeps only {keys:?}"),
1151 "Nothing is signed in for this slot.",
1152 );
1153 }
1154 let Some(oauth) = doc.get("claudeAiOauth").and_then(Value::as_object) else {
1155 return fail(
1156 "credential",
1157 "credential",
1158 format!("claudeAiOauth is not an object; the keys are {keys:?}"),
1159 "The credential's shape changed. Do not switch accounts until this is understood.",
1160 );
1161 };
1162 let fingerprint = oauth
1163 .get("refreshToken")
1164 .and_then(Value::as_str)
1165 .map(store::fingerprint)
1166 .unwrap_or_else(|| "none".into());
1167 let days = oauth
1168 .get("refreshTokenExpiresAt")
1169 .and_then(Value::as_i64)
1170 .map_or(-1, |ms| (ms / 1000 - facts.now) / 86_400);
1171 let detail = format!("refresh {fingerprint} · {days} days left · keys {keys:?}");
1172 if days < 3 {
1173 warn(
1174 "credential",
1175 "credential",
1176 detail,
1177 "This login expires soon and will need signing in again.",
1178 )
1179 } else {
1180 ok("credential", "credential", detail)
1181 }
1182 }
1183 Ok(None) => warn(
1184 "credential",
1185 "credential",
1186 "nothing stored",
1187 "Nothing is signed in for this slot.",
1188 ),
1189 Err(store::Error::Locked) => fail(
1193 "credential",
1194 "credential",
1195 "the keychain is locked, and cannot ask to be unlocked from here",
1196 format!(
1197 "Unlock it with `security unlock-keychain`, or run Pitboard from a desktop \
1198 session. Until then a Claude Code session here keeps the login it last read \
1199 and follows no switch{}",
1200 match &facts.fallback_login {
1201 Some(left) => format!(
1202 ", and one started here signs in with the login in {}.",
1203 left.path.display()
1204 ),
1205 None => ", and one started here is signed out.".into(),
1206 }
1207 ),
1208 ),
1209 Err(e) => fail(
1210 "credential",
1211 "credential",
1212 e.to_string(),
1213 "Do not write to the store while this is failing.",
1214 ),
1215 }
1216}
1217
1218fn judge_fallback_login(facts: &Facts) -> Option<Check> {
1226 let left = facts.fallback_login.as_ref()?;
1227 let path = left.path.display();
1228 let fingerprint = if left.fingerprint.is_empty() {
1229 "none"
1230 } else {
1231 left.fingerprint.as_str()
1232 };
1233 Some(warn(
1234 "fallback_login",
1235 "fallback login",
1236 format!("{path} · refresh {fingerprint}"),
1237 format!(
1238 "Claude Code signs in with this wherever it cannot read the keychain, such as in a \
1239 session started over SSH, and no switch reaches it. A sign-in made where the \
1240 keychain could not be read leaves one, as `/login` over SSH does. Deleting it \
1241 leaves one login for every session: `rm {path}`."
1242 ),
1243 ))
1244}
1245
1246pub const RENEW_WITHIN: i64 = 3 * 86_400;
1248
1249pub fn renewal_due(refresh_expires_at: i64, now: i64) -> bool {
1256 (1..RENEW_WITHIN).contains(&(refresh_expires_at - now))
1257}
1258
1259fn judge_park(fact: &ParkFact, now: i64) -> Check {
1260 let code = account_code(fact.provider, "parked_login", "codex_parked_login");
1261 let name = format!("account {}", fact.typed());
1262 let renew = format!("Run `pitboard enroll {} --sign-in`.", fact.typed());
1263 let Some(park) = &fact.park else {
1264 return if fact.active {
1265 ok(code, name, "signed in; parked when you switch away")
1266 } else {
1267 warn(code, name, "nothing parked to switch to", renew)
1268 };
1269 };
1270 match &fact.unreadable {
1271 Some(Unreadable::Locked) => {
1272 return warn(
1273 code,
1274 name,
1275 "not read: the keychain is locked",
1276 "Unlock it, then run `pitboard doctor` again.",
1277 );
1278 }
1279 Some(Unreadable::Broken(why)) => {
1280 return fail(
1281 code,
1282 name,
1283 format!("its parked login is unusable: {why}"),
1284 renew,
1285 );
1286 }
1287 None => {}
1288 }
1289 let Some(at) = park.refresh_expires_at else {
1290 return ok(code, name, "parked");
1291 };
1292 let life = words::parked_life_column(at, now);
1294 if !park.restorable_at(now) {
1295 let when = time::moment(at, now);
1296 warn(code, name, format!("its parked login {life} {when}"), renew)
1297 } else if renewal_due(at, now) {
1298 warn(code, name, format!("its parked login {life}"), renew)
1299 } else {
1300 ok(code, name, format!("parked, {life}"))
1301 }
1302}
1303
1304fn judge_storage_v5(facts: &Facts) -> Check {
1314 let flag_on = facts
1315 .config
1316 .as_ref()
1317 .ok()
1318 .and_then(|c| c.get("cachedGrowthBookFeatures"))
1319 .and_then(|f| f.get("tengu_hover_rest"))
1320 .and_then(Value::as_bool)
1321 .unwrap_or(false);
1322 if !(facts.hover_rest_env || flag_on) {
1323 return ok("storage_v5", "storage v5", "inactive");
1324 }
1325 match facts.backend {
1326 Ok(store::Backend::File) => warn(
1327 "storage_v5",
1328 "storage v5",
1329 "switched on, and this login is in the fallback store",
1330 "Pitboard reads the plaintext file. If Claude Code was given a backend of its \
1331 own, that is not the same file. Check for an update before switching.",
1332 ),
1333 _ => ok(
1334 "storage_v5",
1335 "storage v5",
1336 "switched on; the keychain is still where the login is",
1337 ),
1338 }
1339}
1340
1341fn biggest(parts: &[(String, usize)]) -> String {
1344 let named: Vec<String> = parts
1345 .iter()
1346 .take(3)
1347 .map(|(what, bytes)| format!("{what} {bytes}"))
1348 .collect();
1349 if named.is_empty() {
1350 String::new()
1351 } else {
1352 format!(". Mostly: {}", named.join(", "))
1353 }
1354}
1355
1356fn judge_asking(facts: &Facts) -> Check {
1363 let tool_of = |id: &str| {
1367 facts
1368 .state
1369 .as_ref()
1370 .ok()
1371 .and_then(|s| s.owner_of_park(id))
1372 .map(crate::state::Account::provider)
1373 };
1374 let services = |tools: &mut Vec<ProviderId>| {
1375 tools.sort();
1376 tools.dedup();
1377 if tools.is_empty() {
1378 tools.push(ProviderId::Claude);
1379 }
1380 tools
1381 .iter()
1382 .map(|t| t.service())
1383 .collect::<Vec<_>>()
1384 .join(" and ")
1385 };
1386 let mut asked: Vec<ProviderId> = facts
1387 .state
1388 .as_ref()
1389 .map(|s| {
1390 s.accounts
1391 .iter()
1392 .map(crate::state::Account::provider)
1393 .collect()
1394 })
1395 .unwrap_or_default();
1396 let name = format!("asking {}", services(&mut asked));
1397 let mut holding: Vec<ProviderId> = facts
1398 .asking_held
1399 .iter()
1400 .filter_map(|(uuid, _)| tool_of(uuid))
1401 .collect();
1402 let holders = services(&mut holding);
1403 match facts.asking_held.len() {
1404 0 => ok("asking", name, "nothing is being held back"),
1405 n => {
1406 let longest = facts
1407 .asking_held
1408 .iter()
1409 .map(|(_, seconds)| *seconds)
1410 .max()
1411 .unwrap_or_default();
1412 warn(
1413 "asking",
1414 name,
1415 format!(
1416 "{n} account(s) not being asked about for up to {}",
1417 words::span(longest)
1418 ),
1419 format!(
1420 "{holders} asked for less traffic, or could not be reached. The numbers \
1421 shown are the last ones measured until then; `pitboard status --fresh` \
1422 does not override a wait {holders} asked for."
1423 ),
1424 )
1425 }
1426 }
1427}
1428
1429fn judge_network(network: &NetworkFact) -> Check {
1440 const NAME: &str = "network";
1441 let (passed, set_right) = match network.passed_over.as_slice() {
1442 [] => (None, ""),
1443 [one] => (
1444 Some(format!(
1445 "{one} holds no proxy's address Pitboard can read, so it is passed over"
1446 )),
1447 "Set it to a proxy's address, such as http://proxy.example.com:8080, or unset it.",
1448 ),
1449 many => (
1450 Some(format!(
1451 "{} hold no proxy's address Pitboard can read, so they are passed over",
1452 words::listed(many.iter().map(|n| (*n).to_string()).collect())
1453 )),
1454 "Set each to a proxy's address, such as http://proxy.example.com:8080, or unset them.",
1455 ),
1456 };
1457 let Some(proxy) = &network.proxy else {
1458 return match passed {
1459 None => ok("network", NAME, "direct: no variable names a proxy"),
1460 Some(passed) => warn("network", NAME, format!("direct: {passed}"), set_right),
1461 };
1462 };
1463 let fails = requests_fail(proxy);
1464 let mut detail = if !proxy.socks {
1465 format!("through {}, from {}", proxy.address, proxy.variable)
1466 } else {
1467 format!(
1468 "{}: {}, {}",
1469 if fails { "requests fail" } else { "direct" },
1470 crate::proxy::refused(proxy.variable),
1471 proxy.address
1472 )
1473 };
1474 for clause in exempting(proxy).iter().chain(&passed) {
1475 detail.push_str(&format!("; {clause}"));
1476 }
1477 if fails {
1478 let (each, them) = if network.passed_over.is_empty() {
1479 (proxy.variable.to_string(), "it")
1480 } else {
1481 let mut names: Vec<String> = network
1482 .passed_over
1483 .iter()
1484 .map(|name| (*name).to_string())
1485 .collect();
1486 names.push(proxy.variable.to_string());
1487 (format!("each of {}", words::listed(names)), "them")
1488 };
1489 return warn(
1490 "network",
1491 NAME,
1492 detail,
1493 format!(
1494 "Pitboard goes through HTTP and HTTPS proxies only. For its requests to go \
1495 out, set {each} to an HTTP proxy's address, such as \
1496 http://proxy.example.com:8080, or unset {them}."
1497 ),
1498 );
1499 }
1500 match passed {
1501 None => ok("network", NAME, detail),
1502 Some(_) => warn("network", NAME, detail, set_right),
1503 }
1504}
1505
1506fn requests_fail(proxy: &ProxyFact) -> bool {
1510 proxy.socks && proxy.exempt.len() < crate::proxy::called().len()
1511}
1512
1513fn exempting(proxy: &ProxyFact) -> Option<String> {
1516 proxy.exempting.map(|exempting| {
1517 if proxy.exempt.is_empty() {
1518 format!("{exempting} exempts none of the hosts Pitboard calls")
1519 } else if proxy.exempt.len() == crate::proxy::called().len() {
1520 format!("{exempting} exempts every host Pitboard calls, which it reaches directly")
1521 } else {
1522 format!(
1523 "{exempting} exempts {}, which Pitboard reaches directly",
1524 words::listed(proxy.exempt.clone())
1525 )
1526 }
1527 })
1528}
1529
1530fn judge_auth(facts: &Facts) -> Check {
1541 if facts.auth_overrides.is_empty() {
1542 let read = "the stored login, which is what Pitboard moves";
1543 return ok(
1544 "auth_source",
1545 "what a session authenticates with",
1546 match facts.auth_unread {
1547 None => read.to_string(),
1548 Some(unread) => format!("{read}, as far as it reads: {unread}"),
1549 },
1550 );
1551 }
1552 let named: Vec<String> = facts
1553 .auth_overrides
1554 .iter()
1555 .map(ToString::to_string)
1556 .collect();
1557 warn(
1558 "auth_source",
1559 "what a session authenticates with",
1560 format!("something else: {}", named.join("; ")),
1561 "Claude Code here authenticates with that rather than with the stored login, so \
1562 switching accounts changes nothing a session would notice. Remove it, or accept \
1563 that Pitboard is moving a login nothing reads.",
1564 )
1565}
1566
1567fn judge_claude_version(facts: &Facts) -> Check {
1574 let verified = crate::provider::claude::assumptions::VERIFIED_AGAINST;
1575 match facts.claude_version.as_deref() {
1576 None => ok(
1577 "claude_version",
1578 "Claude Code build",
1579 format!("not found here; Pitboard's facts were read from {verified}"),
1580 ),
1581 Some(installed) if installed == verified => ok(
1582 "claude_version",
1583 "Claude Code build",
1584 format!("{installed}, which is what Pitboard's facts were read from"),
1585 ),
1586 Some(installed) => ok(
1587 "claude_version",
1588 "Claude Code build",
1589 format!("{installed} installed; Pitboard's facts were read from {verified}"),
1590 ),
1591 }
1592}
1593
1594fn judge_pending(facts: &Facts) -> Check {
1598 match facts.pending_parks.len() {
1599 0 => ok("pending_parks", "parks being reclaimed", "none outstanding"),
1600 n => warn(
1601 "pending_parks",
1602 "parks being reclaimed",
1603 format!("{n} could not be read this time"),
1604 "Pitboard wrote these names down before creating a login in them and cannot \
1605 read them back to find out what is there. Unlock the keychain and run any \
1606 Pitboard command; it resolves them before doing anything else.",
1607 ),
1608 }
1609}
1610
1611fn judge_schedule(facts: &Facts) -> Option<Check> {
1619 let again = again(facts.os);
1620 let schedule = facts.schedule.as_ref()?;
1621 Some(match &schedule.program {
1622 Some(program) if !schedule.program_found => fail(
1623 "schedule",
1624 "renewal schedule",
1625 format!("runs {}, which is not there any more", program.display()),
1626 again,
1627 ),
1628 Some(program) if crate::schedule::an_apps_own_program(program) => fail(
1629 "schedule",
1630 "renewal schedule",
1631 format!(
1632 "runs {}, which is the app itself and not a command line",
1633 program.display()
1634 ),
1635 again,
1636 ),
1637 Some(program) => ok(
1638 "schedule",
1639 "renewal schedule",
1640 format!("daily · runs {}", program.display()),
1641 ),
1642 None => warn(
1643 "schedule",
1644 "renewal schedule",
1645 format!(
1646 "{} does not say which Pitboard it runs",
1647 schedule.path.display()
1648 ),
1649 again,
1650 ),
1651 })
1652}
1653
1654fn judge_schedule_proxy(facts: &Facts) -> Option<Check> {
1671 let schedule = facts.schedule.as_ref()?;
1672 if !schedule.network_differs {
1673 return None;
1674 }
1675 let here = if facts.in_the_app {
1676 "the app"
1677 } else {
1678 "this run"
1679 };
1680 let installed = match &schedule.network.proxy {
1681 None => "the schedule was installed with no proxy".to_string(),
1682 Some(proxy) => format!("the schedule was installed with {}", proxy_named(proxy)),
1683 };
1684 let this_run = match &facts.network.proxy {
1687 None => format!("{here} goes directly"),
1688 Some(proxy) if proxy.socks => format!("{here} is given {}", proxy_named(proxy)),
1689 Some(proxy) => format!("{here} goes through {}", proxy_named(proxy)),
1690 };
1691 let detail = match (&schedule.network.proxy, &facts.network.proxy) {
1692 (Some(its), Some(mine)) if proxy_named(its) == proxy_named(mine) => format!(
1694 "the schedule was installed with {here}'s proxy, {}, but with another user name \
1695 or password",
1696 proxy_named(its)
1697 ),
1698 _ => format!("{installed}; {this_run}"),
1699 };
1700 if facts.in_the_app {
1701 return Some(Check {
1702 code: "schedule_proxy",
1703 name: "renewal schedule's proxy".into(),
1704 level: Level::Ok,
1705 detail,
1706 advice: "The app runs with the environment the system started it with, not a \
1707 terminal's. Turning daily renewal off and on in Settings gives the \
1708 schedule the app's proxy variables; `pitboard schedule install` in a \
1709 terminal gives it that terminal's."
1710 .into(),
1711 });
1712 }
1713 let advice = match &facts.network.proxy {
1716 Some(proxy) if requests_fail(proxy) => format!(
1717 "The schedule's runs are given the proxy variables it was installed with. \
1718 Installed again from here, they would be given this run's SOCKS proxy, which \
1719 Pitboard does not use yet, and their requests would fail. Set {} to an HTTP \
1720 proxy's address, or unset it, then install it again with `pitboard schedule \
1721 install`, which takes the proxy variables set where it runs.",
1722 proxy.variable
1723 ),
1724 _ => "The schedule's runs are given the proxy variables it was installed with. To give \
1725 them this run's, install it again from here with `pitboard schedule install`, \
1726 which takes the proxy variables set where it runs."
1727 .to_string(),
1728 };
1729 Some(warn(
1730 "schedule_proxy",
1731 "renewal schedule's proxy",
1732 detail,
1733 advice,
1734 ))
1735}
1736
1737fn proxy_named(proxy: &ProxyFact) -> String {
1740 let named = format!("{}, from {}", proxy.address, proxy.variable);
1741 match proxy.exempting {
1742 Some(exempting) if !proxy.exempt.is_empty() => format!(
1743 "{named}, {exempting} exempting {}",
1744 words::listed(proxy.exempt.clone())
1745 ),
1746 _ => named,
1747 }
1748}
1749
1750fn again(os: Os) -> &'static str {
1753 if has_app(os) {
1754 "Turn daily renewal off and on again: in the app's Settings, or with \
1755 `pitboard schedule uninstall` and then `pitboard schedule install`."
1756 } else {
1757 "Turn daily renewal off and on again with `pitboard schedule uninstall` and then \
1758 `pitboard schedule install`."
1759 }
1760}
1761
1762fn has_app(os: Os) -> bool {
1765 match os {
1766 Os::MacOs => true,
1767 Os::Linux | Os::Windows => false,
1768 }
1769}
1770
1771fn judge_daemon(facts: &Facts) -> Check {
1775 let Some(d) = &facts.daemon else {
1776 return ok("claude_daemon", "Claude Code daemon", "none has run here");
1777 };
1778 let version = d
1779 .version
1780 .as_deref()
1781 .map(|v| format!("Claude Code {v}"))
1782 .unwrap_or_else(|| "an unrecorded version".into());
1783 if d.running {
1784 ok(
1785 "claude_daemon",
1786 "Claude Code daemon",
1787 format!("running, {version}, pid {}", d.pid),
1788 )
1789 } else {
1790 ok(
1791 "claude_daemon",
1792 "Claude Code daemon",
1793 format!("not running; {version} ran here last"),
1794 )
1795 }
1796}
1797
1798fn judge_codex(os: Os, facts: &CodexFacts, parks: &[&ParkFact], now: i64) -> Vec<Check> {
1814 let mut checks = Vec::new();
1815 let enrolled = facts.enrolled > 0 || !parks.is_empty();
1816 let broken = |code, name: &str, detail: String, advice: String| {
1817 if enrolled {
1818 fail(code, name, detail, advice)
1819 } else {
1820 warn(code, name, detail, advice)
1821 }
1822 };
1823 let file = facts.backend == "file";
1824 let setting = &facts.backend_setting;
1825 let described = match facts.backend {
1826 "ephemeral" => format!("in memory only ({setting})"),
1827 "unknown" => format!("cannot tell: {setting}"),
1828 other => format!("{other} ({setting})"),
1829 };
1830 checks.push(match facts.backend {
1831 "file" => Check {
1832 advice: PROJECTS_NOT_READ.into(),
1833 ..ok(
1834 "codex_backend",
1835 "Codex login store",
1836 format!("file ({setting}) · {}", facts.auth_file.display()),
1837 )
1838 },
1839 "unknown" => broken(
1842 "codex_backend",
1843 "Codex login store",
1844 described,
1845 format!(
1846 "{}. Pitboard parks and switches no Codex login while it cannot tell where \
1847 Codex keeps it.",
1848 facts.backend_remedy
1849 ),
1850 ),
1851 other if enrolled => fail(
1853 "codex_backend",
1854 "Codex login store",
1855 described,
1856 match other {
1857 "ephemeral" => format!(
1858 "Codex keeps nothing at rest, so there is no login Pitboard can park or \
1859 switch. {}.",
1860 facts.backend_remedy
1861 ),
1862 _ => format!(
1863 "Pitboard reads only Codex's file store, auth.json, and will not touch the \
1864 keychain item Codex created for itself, because every read of it would \
1865 ask you for permission, so no enrolled Codex account can be switched to. \
1866 {}.",
1867 facts.backend_remedy
1868 ),
1869 },
1870 ),
1871 _ => ok(
1872 "codex_backend",
1873 "Codex login store",
1874 format!("{described}; Pitboard switches Codex accounts only in the file store"),
1875 ),
1876 });
1877
1878 if file {
1881 checks.push(match &facts.auth_access {
1882 None if enrolled => warn(
1883 "codex_auth_file",
1884 "Codex login file",
1885 format!(
1886 "{} is absent: nothing is signed in to Codex",
1887 facts.auth_file.display()
1888 ),
1889 "Sign in to one of your enrolled accounts with `codex login`. A switch \
1890 needs an account signed in to park, so `pitboard use` cannot put one \
1891 back while nothing is.",
1892 ),
1893 None => ok(
1894 "codex_auth_file",
1895 "Codex login file",
1896 "absent; nothing is signed in to Codex",
1897 ),
1898 Some(access) if access.shared => warn(
1899 "codex_auth_file",
1900 "Codex login file",
1901 format!("{} is {}", facts.auth_file.display(), access.described),
1902 format!(
1903 "It holds a usable login in plain text, and Codex sets 0600 only when it \
1904 creates the file, never on a later write{}",
1905 running(os.make_private_command(
1906 Kind::File,
1907 &[&facts.auth_file.display().to_string()]
1908 ))
1909 ),
1910 ),
1911 Some(access) => ok(
1912 "codex_auth_file",
1913 "Codex login file",
1914 format!("{} · {}", facts.auth_file.display(), access.described),
1915 ),
1916 });
1917 match &facts.login {
1918 Ok(Some(login)) => checks.push(ok(
1919 "codex_login",
1920 "Codex login",
1921 format!(
1922 "{} · account {} · refresh {}",
1923 login.email,
1924 login.account_id,
1925 if login.fingerprint.is_empty() {
1926 "none"
1927 } else {
1928 login.fingerprint.as_str()
1929 }
1930 ),
1931 )),
1932 Ok(None) => {}
1934 Err(CodexLoginTrouble::NotAnAccount(why)) if enrolled => checks.push(warn(
1937 "codex_login",
1938 "Codex login",
1939 why.clone(),
1940 "Pitboard parks and switches Codex's ChatGPT sign-ins, so `pitboard use \
1941 codex/<label>` refuses while Codex is signed in this way rather than replace \
1942 a login it has nowhere to park. `codex login` signs in with a ChatGPT \
1943 account.",
1944 )),
1945 Err(CodexLoginTrouble::NotAnAccount(why)) => {
1946 checks.push(ok("codex_login", "Codex login", why.clone()));
1947 }
1948 Err(CodexLoginTrouble::Unusable(why)) => checks.push(broken(
1949 "codex_login",
1950 "Codex login",
1951 format!("it cannot be used: {why}"),
1952 "Pitboard will not park or switch a Codex login it cannot read as one \
1953 account's. Signing in with `codex` again writes a fresh one."
1954 .into(),
1955 )),
1956 }
1957 }
1958
1959 checks.extend(parks.iter().map(|p| judge_park(p, now)));
1962 checks.extend(parks.iter().filter_map(|p| judge_dormant(p, now)));
1963
1964 checks.push(judge_codex_version(facts));
1965 checks.push(judge_codex_running(facts.running.as_deref()));
1966 checks
1967}
1968
1969fn judge_codex_running(running: Option<&[crate::holder::Holding]>) -> Check {
1972 let (detail, advice) = match running {
1973 None => ("could not tell".to_string(), String::new()),
1974 Some([]) => ("none".to_string(), String::new()),
1975 Some(holding) => (
1976 format!(
1977 "{}; each keeps using the account it started with until it is started again",
1978 crate::holder::described_with_pids(holding)
1979 ),
1980 crate::holder::remedies(holding, "to take a switch"),
1981 ),
1982 };
1983 Check {
1984 advice,
1985 ..ok("codex_running", "running Codex", detail)
1986 }
1987}
1988
1989fn judge_codex_version(facts: &CodexFacts) -> Check {
1992 let verified = crate::provider::codex::assumptions::VERIFIED_AGAINST;
1993 ok(
1994 "codex_version",
1995 "Codex build",
1996 match (facts.program.as_deref(), facts.version.as_deref()) {
1997 (None, _) => format!("not found here; Pitboard's facts were read from {verified}"),
1998 (Some(program), None) => format!(
2001 "{}, whose path does not say which version it is; Pitboard's facts were \
2002 read from {verified}",
2003 program.display()
2004 ),
2005 (Some(_), Some(installed)) if installed == verified => {
2006 format!("{installed}, which is what Pitboard's facts were read from")
2007 }
2008 (Some(_), Some(installed)) => {
2009 format!("{installed} installed; Pitboard's facts were read from {verified}")
2010 }
2011 },
2012 )
2013}
2014
2015pub struct Diagnosis {
2018 pub checks: Vec<Check>,
2019 pub environment: Value,
2020 pub redaction: crate::redact::Sheet,
2023}
2024
2025pub fn run(ctx: &Context) -> Diagnosis {
2026 if let Err(crate::error::Error::HomeNotAbsolute { variable, path }) =
2027 crate::home::check_absolute(ctx)
2028 {
2029 return unplaced(ctx, variable, &path);
2030 }
2031 let facts = gather(ctx);
2032 Diagnosis {
2033 redaction: redaction_for(ctx, &facts),
2034 checks: evaluate(&facts),
2035 environment: json!({
2036 "config_file": facts.config_path,
2037 "storage_dir": facts.storage_dir,
2038 "credential_service": facts.service,
2039 "credential_store": facts.backend.as_ref().map_or("unreadable", |b| b.name()),
2040 "home": facts.home,
2041 "codex": {
2044 "home": facts.codex.home,
2045 "present": facts.codex.present,
2046 "backend": facts.codex.backend,
2047 "login_present": (facts.codex.backend == "file")
2049 .then_some(facts.codex.auth_access.is_some()),
2050 "version": facts.codex.version,
2051 },
2052 }),
2053 }
2054}
2055
2056fn unplaced(ctx: &Context, variable: &str, path: &std::path::Path) -> Diagnosis {
2062 let holds = if path.as_os_str().is_empty() {
2063 "empty".to_string()
2064 } else {
2065 format!("`{}`, which is not a full path", path.display())
2066 };
2067 let homes = fail(
2068 "homes",
2069 "homes",
2070 format!("{variable} is {holds}"),
2071 "Set it to a full path, or unset it. Pitboard checks nothing else until then: \
2072 everything it reads is under these folders.",
2073 );
2074 Diagnosis {
2075 checks: elevated(ctx.host().elevation(ctx))
2076 .into_iter()
2077 .chain(std::iter::once(homes))
2078 .collect(),
2079 environment: json!({}),
2080 redaction: crate::redact::Sheet::new(
2081 format!("{}:{}", crate::state::machine_id(), ctx.now_millis()),
2082 ctx.home().to_string_lossy(),
2083 ),
2084 }
2085}
2086
2087fn redaction_for(ctx: &Context, facts: &Facts) -> crate::redact::Sheet {
2092 let mut sheet = crate::redact::Sheet::new(
2093 format!("{}:{}", crate::state::machine_id(), ctx.now_millis()),
2094 ctx.home().to_string_lossy(),
2095 )
2096 .hide(facts.account.clone(), "user");
2097
2098 if let Some(id) = &facts.identity {
2099 sheet = sheet
2100 .hide(id.email.clone(), "email")
2101 .hide(id.account_uuid.clone(), "account")
2102 .hide(id.organization_uuid.clone(), "org");
2103 if let Some(name) = &id.organization_name {
2104 sheet = sheet.hide(name.clone(), "org");
2105 }
2106 }
2107 if let Ok(state) = &facts.state {
2108 for account in &state.accounts {
2109 sheet = sheet
2110 .hide(account.email.clone(), "email")
2111 .hide(account.account_uuid.clone(), "account")
2112 .hide(
2113 account
2114 .claude()
2115 .map_or_else(String::new, |c| c.organization_uuid.to_string()),
2116 "org",
2117 );
2118 if let crate::state::Detail::Codex {
2120 workspace_id: Some(workspace),
2121 ..
2122 } = &account.detail
2123 {
2124 sheet = sheet.hide(workspace.clone(), "org");
2125 }
2126 }
2127 }
2128 if let Ok(Some(login)) = &facts.codex.login {
2130 sheet = sheet
2131 .hide(login.email.clone(), "email")
2132 .hide(login.account_id.clone(), "account")
2133 .hide(login.fingerprint.clone(), "login");
2134 }
2135 if let Ok(Some(doc)) = &facts.credential
2137 && let Some(fingerprint) = doc
2138 .get("claudeAiOauth")
2139 .map(crate::provider::claude::document::fingerprint_of)
2140 .filter(|f| !f.is_empty())
2141 {
2142 sheet = sheet.hide(fingerprint, "login");
2143 }
2144 if let Some(left) = facts
2145 .fallback_login
2146 .as_ref()
2147 .filter(|left| !left.fingerprint.is_empty())
2148 {
2149 sheet = sheet.hide(left.fingerprint.clone(), "login");
2150 }
2151 for park in &facts.parks {
2152 if let Some(held) = &park.park {
2153 sheet = sheet
2154 .hide(held.refresh_fingerprint.clone(), "login")
2155 .hide(held.service.clone(), "park");
2156 }
2157 }
2158 let schedules = facts
2163 .schedule
2164 .as_ref()
2165 .and_then(|s| s.network.proxy.as_ref());
2166 for proxy in facts.network.proxy.iter().chain(schedules) {
2167 if !proxy.on_loopback {
2168 sheet = sheet.hide(proxy.server.clone(), "proxy");
2169 }
2170 }
2171 sheet
2172}
2173
2174pub fn healthy(checks: &[Check]) -> bool {
2176 checks.iter().all(|c| c.level != Level::Fail)
2177}
2178
2179#[cfg(test)]
2180mod tests {
2181 use super::*;
2182 use crate::service::Permit;
2183 use std::sync::Arc;
2184
2185 fn mode(mode: u32) -> Access {
2187 Access {
2188 shared: mode & 0o077 != 0,
2189 described: format!("mode {mode:o}"),
2190 }
2191 }
2192
2193 fn direct() -> NetworkFact {
2195 NetworkFact {
2196 proxy: None,
2197 passed_over: Vec::new(),
2198 }
2199 }
2200
2201 fn facts() -> Facts {
2202 Facts {
2203 os: crate::host::OS,
2204 security_tool: Some("/usr/bin/security".into()),
2205 config_path: PathBuf::from("/home/x/.claude.json"),
2206 config: Ok(json!({"oauthAccount": {}})),
2207 identity: Some(claude::Identity {
2208 email: "a@b.c".into(),
2209 account_uuid: "acc".into(),
2210 organization_uuid: "org".into(),
2211 organization_name: None,
2212 subscription: None,
2213 rate_limit_tier: None,
2214 }),
2215 credential_parts: Vec::new(),
2216 credential_cost: Some(store::Cost {
2217 needs: 900,
2218 limit: 4032,
2219 second_route: true,
2220 }),
2221 service: "Claude Code-credentials".into(),
2222 account: "someone".into(),
2223 default_slot: true,
2224 storage_dir: "/home/x/.claude".into(),
2225 backend: Ok(store::Backend::Keychain),
2226 credential_file: PathBuf::from("/home/x/.claude/.credentials.json"),
2227 credential: Ok(Some(json!({"claudeAiOauth": {
2228 "refreshToken": "r",
2229 "refreshTokenExpiresAt": 2_000_000_000_000i64
2230 }}))),
2231 home: PathBuf::from("/home/x/.pitboard"),
2232 home_access: Some(mode(0o700)),
2233 readable_by_others: Vec::new(),
2234 machine_id_known: true,
2235 hover_rest_env: false,
2236 daemon: None,
2237 pending_parks: Vec::new(),
2238 claude_version: Some(crate::provider::claude::assumptions::VERIFIED_AGAINST.into()),
2239 auth_overrides: Vec::new(),
2240 auth_unread: None,
2241 asking_held: Vec::new(),
2242 state: Ok(State::default()),
2243 parks: Vec::new(),
2244 interrupted: false,
2245 stuck: None,
2246 codex: no_codex(),
2247 claude_present: true,
2248 schedule: None,
2249 network: direct(),
2250 in_the_app: false,
2251 elevation: crate::host::Elevation::Normal,
2252 fallback_login: None,
2253 now: NOW,
2254 }
2255 }
2256
2257 fn no_codex() -> CodexFacts {
2259 CodexFacts {
2260 home: PathBuf::from("/home/x/.codex"),
2261 present: false,
2262 enrolled: 0,
2263 backend: "file",
2264 backend_setting: "Codex's default".into(),
2265 backend_remedy: String::new(),
2266 auth_file: PathBuf::from("/home/x/.codex/auth.json"),
2267 auth_access: None,
2268 login: Ok(None),
2269 program: None,
2270 version: None,
2271 running: Some(Vec::new()),
2272 }
2273 }
2274
2275 #[test]
2279 fn the_auth_check_says_what_it_did_not_read() {
2280 let mut facts = facts();
2281 assert_eq!(
2282 judge_auth(&facts).detail,
2283 "the stored login, which is what Pitboard moves"
2284 );
2285 facts.auth_unread = Some("Claude Code's managed settings are not read on Windows yet");
2286 let check = judge_auth(&facts);
2287 assert_eq!(check.level, Level::Ok);
2288 assert_eq!(
2289 check.detail,
2290 "the stored login, which is what Pitboard moves, as far as it reads: Claude Code's \
2291 managed settings are not read on Windows yet"
2292 );
2293 }
2294
2295 #[test]
2299 fn advice_names_a_command_only_where_there_is_one() {
2300 assert_eq!(
2301 running(Os::MacOs.make_private_command(Kind::File, &["/a"])),
2302 ": `chmod 600 /a`."
2303 );
2304 assert_eq!(
2305 running(Os::Linux.make_private_command(Kind::Directory, &["/a", "/b"])),
2306 ": `chmod 700 /a /b`."
2307 );
2308 assert_eq!(Os::Windows.make_private_command(Kind::Any, &["/a"]), None);
2309 assert_eq!(running(None), ".");
2310 }
2311
2312 fn codex_checks(codex: &CodexFacts) -> Vec<Check> {
2314 judge_codex(crate::host::OS, codex, &[], NOW)
2315 }
2316
2317 fn with_codex() -> CodexFacts {
2319 CodexFacts {
2320 present: true,
2321 auth_access: Some(mode(0o600)),
2322 login: Ok(Some(CodexLogin {
2323 email: "w@example.com".into(),
2324 account_id: "work-account".into(),
2325 fingerprint: "0123456789abcdef".into(),
2326 })),
2327 program: Some(PathBuf::from("/usr/local/bin/codex")),
2328 version: Some(crate::provider::codex::assumptions::VERIFIED_AGAINST.into()),
2329 ..no_codex()
2330 }
2331 }
2332
2333 const NOW: i64 = 1_789_935_600;
2334
2335 fn parked(label: &str, refresh_expires_at: Option<i64>) -> ParkFact {
2336 ParkFact {
2337 provider: ProviderId::Claude,
2338 last_used_at: None,
2339 label: label.into(),
2340 name: crate::state::Key::new(ProviderId::Claude, label).typed(),
2341 active: false,
2342 park: Some(Park {
2343 service: format!("pitboard-park-{label}-1"),
2344 parked_at: NOW - 86_400,
2345 refresh_fingerprint: "f".into(),
2346 access_expires_at: None,
2347 refresh_expires_at,
2348 }),
2349 unreadable: None,
2350 }
2351 }
2352
2353 fn codex_parked(label: &str, refresh_expires_at: Option<i64>) -> ParkFact {
2355 ParkFact {
2356 provider: ProviderId::Codex,
2357 name: crate::state::Key::new(ProviderId::Codex, label).typed(),
2358 ..parked(label, refresh_expires_at)
2359 }
2360 }
2361
2362 fn named<'a>(checks: &'a [Check], name: &str) -> &'a Check {
2363 checks.iter().find(|c| c.name == name).expect(name)
2364 }
2365
2366 fn check<'a>(checks: &'a [Check], code: &str) -> &'a Check {
2367 checks.iter().find(|c| c.code == code).expect(code)
2368 }
2369
2370 #[test]
2371 fn a_healthy_machine_reports_no_failures() {
2372 let checks = evaluate(&facts());
2373 assert!(checks.iter().all(|c| c.level != Level::Fail));
2374 assert!(healthy(&checks));
2375 }
2376
2377 #[test]
2383 fn a_world_readable_park_is_found_on_the_disk() {
2384 use crate::host::fs::testing;
2385
2386 let root = std::env::temp_dir().join(format!(
2387 "pitboard-doctor-modes-{}-{:?}",
2388 std::process::id(),
2389 std::thread::current().id()
2390 ));
2391 let _ = std::fs::remove_dir_all(&root);
2392 struct Scratch(std::path::PathBuf);
2393 impl Drop for Scratch {
2394 fn drop(&mut self) {
2395 let _ = std::fs::remove_dir_all(&self.0);
2396 }
2397 }
2398 let _guard = Scratch(root.clone());
2399
2400 let ctx = Context::new(root.clone()).with_pitboard_home(root.join(".pitboard"));
2401 let vault = store::vault_dir(&ctx);
2402 crate::host::fs::create_private_dir(Permit::for_a_test(), &vault).expect("a vault");
2403 assert!(
2404 loose_logins(&ctx).is_empty(),
2405 "a private vault is not loose"
2406 );
2407
2408 let park = vault.join("pitboard-park-x.json");
2409 std::fs::write(&park, "{}").expect("a park");
2410 testing::open_to_others(&park).expect("opened to others");
2411 let found = loose_logins(&ctx);
2412 assert_eq!(found.len(), 1, "{found:?}");
2413 assert_eq!(Some(&found[0].1), crate::host::fs::access(&park).as_ref());
2414 assert!(found[0].0.ends_with("pitboard-park-x.json"), "{found:?}");
2415
2416 testing::make_private(&park).expect("made private");
2417 assert!(loose_logins(&ctx).is_empty(), "a private park is not loose");
2418 }
2419
2420 #[test]
2425 fn a_login_anyone_on_this_machine_can_read_is_a_failure() {
2426 let mut f = facts();
2427 assert_eq!(check(&evaluate(&f), "private_on_disk").level, Level::Ok);
2428
2429 f.readable_by_others = vec![
2430 (
2431 "/home/a/.pitboard/vault/pitboard-park-x.json".into(),
2432 mode(0o644),
2433 ),
2434 ("/home/a/.claude/.credentials.json".into(), mode(0o640)),
2435 ];
2436 let checks = evaluate(&f);
2437 let found = check(&checks, "private_on_disk");
2438 assert_eq!(found.level, Level::Fail);
2439 assert!(found.detail.contains("mode 644"), "{}", found.detail);
2440 assert!(found.detail.contains("mode 640"), "{}", found.detail);
2441 assert!(found.advice.contains("chmod go-rwx"), "{}", found.advice);
2443 assert!(!healthy(&checks));
2444 }
2445
2446 #[test]
2449 fn a_credential_whose_login_is_not_an_object_is_a_failure() {
2450 let mut f = facts();
2451 f.credential = Ok(Some(json!({"claudeAiOauth": "something else"})));
2452 let checks = evaluate(&f);
2453 assert_eq!(check(&checks, "credential").level, Level::Fail);
2454 assert!(!healthy(&checks));
2455 }
2456
2457 #[test]
2458 fn an_unreadable_store_fails_rather_than_reporting_nothing_stored() {
2459 let mut f = facts();
2460 f.credential = Err(store::Error::Unreadable("security exited 1".into()));
2461 f.backend = Err(store::Error::Unreadable("security exited 1".into()));
2462 let checks = evaluate(&f);
2463 assert_eq!(check(&checks, "credential").level, Level::Fail);
2464 assert_eq!(check(&checks, "credential_store").level, Level::Fail);
2465 }
2466
2467 #[test]
2471 fn a_locked_keychain_is_one_failure_and_sends_nobody_to_sign_in_again() {
2472 let mut f = facts();
2473 f.credential = Err(store::Error::Locked);
2474 let mut behind = parked("work", Some(NOW + 20 * 86_400));
2475 behind.unreadable = Some(Unreadable::Locked);
2476 f.parks = vec![behind];
2477
2478 let checks = evaluate(&f);
2479 let credential = check(&checks, "credential");
2480 assert_eq!(credential.level, Level::Fail);
2481 assert_eq!(
2482 credential.detail,
2483 "the keychain is locked, and cannot ask to be unlocked from here"
2484 );
2485 assert!(
2486 credential.advice.contains("`security unlock-keychain`")
2487 && credential.advice.contains("keeps the login it last read"),
2488 "{}",
2489 credential.advice
2490 );
2491 let park = named(&checks, "account work");
2492 assert_eq!(park.level, Level::Warn, "{}", park.detail);
2493 assert_eq!(park.detail, "not read: the keychain is locked");
2494 assert!(!park.advice.contains("--sign-in"), "{}", park.advice);
2495 assert_eq!(
2496 checks.iter().filter(|c| c.level == Level::Fail).count(),
2497 1,
2498 "one cause, one failure"
2499 );
2500
2501 f.fallback_login = Some(FallbackLogin {
2504 path: f.credential_file.clone(),
2505 fingerprint: "0123456789abcdef".into(),
2506 });
2507 let advice = check(&evaluate(&f), "credential").advice.clone();
2508 assert!(
2509 advice.contains("signs in with the login in /home/x/.claude/.credentials.json"),
2510 "{advice}"
2511 );
2512 }
2513
2514 #[test]
2517 fn a_login_left_behind_the_keychain_is_said_with_what_reads_it() {
2518 let mut f = facts();
2519 assert!(evaluate(&f).iter().all(|c| c.code != "fallback_login"));
2520
2521 f.fallback_login = Some(FallbackLogin {
2522 path: f.credential_file.clone(),
2523 fingerprint: "0123456789abcdef".into(),
2524 });
2525 let checks = evaluate(&f);
2526 let said = check(&checks, "fallback_login");
2527 assert_eq!(said.level, Level::Warn);
2528 assert_eq!(said.name, "fallback login");
2529 assert_eq!(
2530 said.detail,
2531 "/home/x/.claude/.credentials.json · refresh 0123456789abcdef"
2532 );
2533 assert!(
2534 said.advice.contains("over SSH")
2535 && said
2536 .advice
2537 .contains("`rm /home/x/.claude/.credentials.json`"),
2538 "{}",
2539 said.advice
2540 );
2541 assert!(healthy(&checks), "nothing Pitboard does is stopped by it");
2542 }
2543
2544 #[test]
2547 fn a_login_left_behind_the_keychain_is_found_in_its_file() {
2548 use crate::host::memory::MemoryHost;
2549
2550 let host = MemoryHost::new();
2551 let ctx = Context::for_unit_test().with_memory_stores(Arc::clone(&host));
2552 let service = claude::live_service(&ctx);
2553 let file = host.file_at(claude_live::credential_file(&ctx));
2554 let login = |token: &str| json!({"claudeAiOauth": {"refreshToken": token}}).to_string();
2555
2556 host.live().plant(&service, &login("in use"));
2557 assert!(fallback_login(&ctx).is_none());
2558
2559 file.plant(&service, &login("left"));
2560 let found = fallback_login(&ctx).expect("a login behind the keychain's");
2561 assert_eq!(found.path, claude_live::credential_file(&ctx));
2562 assert_eq!(found.fingerprint, store::fingerprint("left"));
2563
2564 file.plant(&service, &json!({"mcpOAuth": {}}).to_string());
2565 assert!(fallback_login(&ctx).is_none(), "what `/logout` leaves");
2566
2567 file.plant(&service, &login("left"));
2568 host.live().delete_everything();
2569 assert!(
2570 fallback_login(&ctx).is_none(),
2571 "the file is the login in use when the keychain holds none"
2572 );
2573 }
2574
2575 #[test]
2578 fn a_park_behind_a_locked_keychain_is_read_as_locked() {
2579 use crate::host::memory::MemoryHost;
2580 use crate::store::memory::Fault;
2581
2582 let host = MemoryHost::new();
2583 let ctx = Context::for_unit_test().with_memory_stores(Arc::clone(&host));
2584 let state = State {
2585 accounts: vec![crate::state::Account {
2586 label: "work".into(),
2587 id: "work-uuid".into(),
2588 account_uuid: "work-uuid".into(),
2589 email: "work@example.com".into(),
2590 parked: parked("work", Some(NOW + 20 * 86_400)).park,
2591 last_used_at: None,
2592 detail: crate::state::Detail::Claude {
2593 organization_uuid: "org".into(),
2594 oauth_account: json!({}),
2595 },
2596 }],
2597 ..State::default()
2598 };
2599 assert_eq!(
2600 park_facts(&ctx, &state)[0].unreadable,
2601 Some(Unreadable::Broken("missing from the vault".into()))
2602 );
2603 host.vault().fault_all(Fault::Locked);
2604 assert_eq!(
2605 park_facts(&ctx, &state)[0].unreadable,
2606 Some(Unreadable::Locked)
2607 );
2608 }
2609
2610 #[test]
2611 fn a_login_about_to_expire_is_flagged() {
2612 let mut f = facts();
2613 f.credential = Ok(Some(json!({"claudeAiOauth": {
2614 "refreshToken": "r",
2615 "refreshTokenExpiresAt": (f.now + 86_400) * 1000
2616 }})));
2617 assert_eq!(check(&evaluate(&f), "credential").level, Level::Warn);
2618 }
2619
2620 #[test]
2621 fn a_loose_home_directory_is_flagged() {
2622 let mut f = facts();
2623 f.home_access = Some(mode(0o755));
2624 let checks = evaluate(&f);
2625 let home = check(&checks, "home");
2626 assert_eq!(home.level, Level::Warn);
2627 assert!(home.detail.contains("755"));
2628 }
2629
2630 #[test]
2631 fn the_successor_backend_alone_does_not_move_the_login() {
2632 let server_on = || {
2633 let mut f = facts();
2634 f.config = Ok(json!({"cachedGrowthBookFeatures": {"tengu_hover_rest": true}}));
2635 f
2636 };
2637 let env_on = || {
2638 let mut f = facts();
2639 f.hover_rest_env = true;
2640 f
2641 };
2642 for mut f in [server_on(), env_on()] {
2643 let checks = evaluate(&f);
2645 assert_eq!(check(&checks, "storage_v5").level, Level::Ok);
2646 f.backend = Ok(store::Backend::File);
2648 let checks = evaluate(&f);
2649 assert_eq!(check(&checks, "storage_v5").level, Level::Warn);
2650 }
2651 }
2652
2653 #[test]
2654 fn the_successor_backend_is_quiet_when_it_is_off() {
2655 let mut f = facts();
2656 let checks = evaluate(&f);
2657 assert_eq!(check(&checks, "storage_v5").level, Level::Ok);
2658 f.backend = Ok(store::Backend::File);
2660 let checks = evaluate(&f);
2661 assert_eq!(check(&checks, "storage_v5").level, Level::Ok);
2662 }
2663
2664 #[test]
2668 fn a_file_store_is_the_whole_answer_only_on_linux() {
2669 let mut f = facts();
2670 f.backend = Ok(store::Backend::File);
2671 for (os, level, advice) in [
2672 (Os::Linux, Level::Ok, ""),
2673 (
2674 Os::MacOs,
2675 Level::Warn,
2676 "Claude Code fell back to a file, which means a keychain write failed at some \
2677 point.",
2678 ),
2679 (
2680 Os::Windows,
2681 Level::Warn,
2682 "Claude Code may keep its login in Credential Manager instead, which Pitboard \
2683 does not read yet.",
2684 ),
2685 ] {
2686 f.os = os;
2687 let checks = evaluate(&f);
2688 let store = check(&checks, "credential_store");
2689 assert_eq!(
2690 (store.level, store.advice.as_str()),
2691 (level, advice),
2692 "{os:?}"
2693 );
2694 assert!(store.detail.starts_with("plaintext file"), "{os:?}");
2695 }
2696 }
2697
2698 #[test]
2702 fn a_config_that_names_somebody_signed_in_with_no_login_anywhere_is_a_failure() {
2703 let mut f = facts();
2704 f.backend = Ok(store::Backend::Absent);
2705 let checks = evaluate(&f);
2706 let store = check(&checks, "credential_store");
2707 assert_eq!(store.level, Level::Fail);
2708 assert!(store.detail.contains("a@b.c"));
2709
2710 f.identity = None;
2712 let checks = evaluate(&f);
2713 let store = check(&checks, "credential_store");
2714 assert_eq!(store.level, Level::Warn);
2715 assert!(store.advice.contains("Nothing is signed in"));
2716 }
2717
2718 #[test]
2719 fn a_login_past_the_ceiling_reads_differently_when_the_way_past_it_is_refused() {
2720 let mut f = facts();
2721 let checks = evaluate(&f);
2722 assert_eq!(check(&checks, "credential_size").level, Level::Ok);
2723
2724 f.credential_cost = Some(store::Cost {
2725 needs: 8503,
2726 limit: 4032,
2727 second_route: true,
2728 });
2729 let checks = evaluate(&f);
2730 let written = check(&checks, "credential_size");
2731 assert_eq!(written.level, Level::Warn);
2732 assert!(written.detail.contains("argument line"));
2733
2734 f.credential_cost = Some(store::Cost {
2735 needs: 8503,
2736 limit: 4032,
2737 second_route: false,
2738 });
2739 let checks = evaluate(&f);
2740 let refused = check(&checks, "credential_size");
2741 assert_eq!(
2742 refused.level,
2743 Level::Fail,
2744 "there is no third way to write it"
2745 );
2746 assert!(refused.detail.contains("PITBOARD_NO_ARGV"));
2747 }
2748
2749 #[test]
2756 fn a_login_too_big_says_what_is_taking_up_the_room() {
2757 let mut f = facts();
2758 f.credential_cost = Some(store::Cost {
2759 needs: 8503,
2760 limit: 4032,
2761 second_route: true,
2762 });
2763 f.credential_parts = parts_of(&json!({
2764 "claudeAiOauth": {"refreshToken": "r"},
2765 "mcpOAuth": {
2766 "one": {"token": "x".repeat(300)},
2767 "two": {"token": "y".repeat(200)},
2768 "three": {"token": "z".repeat(100)},
2769 },
2770 }));
2771
2772 let checks = evaluate(&f);
2773 let size = check(&checks, "credential_size");
2774 assert!(size.detail.contains("mcpOAuth one"), "{}", size.detail);
2775 assert!(size.detail.contains("mcpOAuth two"), "{}", size.detail);
2776 assert!(
2777 !size.detail.contains("claudeAiOauth"),
2778 "three is enough to act on, and the login itself is never the problem: {}",
2779 size.detail
2780 );
2781 }
2782
2783 #[test]
2784 fn what_takes_up_the_room_is_listed_largest_first_and_named_per_server() {
2785 let parts = parts_of(&json!({
2786 "claudeAiOauth": {"refreshToken": "r"},
2787 "mcpOAuth": {"small": {"t": "x"}, "large": {"t": "y".repeat(500)}},
2788 }));
2789 let names: Vec<&str> = parts.iter().map(|(what, _)| what.as_str()).collect();
2790 assert_eq!(names[0], "mcpOAuth large", "largest first: {names:?}");
2791 assert!(names.contains(&"claudeAiOauth"));
2792 assert!(
2793 names.contains(&"mcpOAuth small"),
2794 "each server is named, because that is what a person signs out of"
2795 );
2796
2797 let single = parts_of(&json!({"mcpOAuth": {"only": {"t": "x"}}}));
2799 assert_eq!(single.len(), 1);
2800 assert_eq!(single[0].0, "mcpOAuth");
2801 }
2802
2803 #[test]
2804 fn an_account_nobody_has_come_back_to_is_said_out_loud() {
2805 let mut f = facts();
2806 f.parks = vec![ParkFact {
2807 provider: ProviderId::Claude,
2808 label: "work".into(),
2809 name: crate::state::Key::new(ProviderId::Claude, "work").typed(),
2810 active: false,
2811 last_used_at: Some(f.now - 31 * 86_400),
2812 park: Some(Park {
2813 service: "pitboard-park-acc-1".into(),
2814 parked_at: f.now - 31 * 86_400,
2815 refresh_fingerprint: "f".into(),
2816 access_expires_at: Some(f.now + 3600),
2817 refresh_expires_at: Some(f.now + 30 * 86_400),
2818 }),
2819 unreadable: None,
2820 }];
2821 let checks = evaluate(&f);
2822 let dormant = check(&checks, "dormant_account");
2823 assert_eq!(dormant.level, Level::Warn);
2824 assert!(dormant.advice.contains("pitboard forget work"));
2825
2826 f.parks[0].last_used_at = Some(f.now - 29 * 86_400);
2828 assert!(
2829 !evaluate(&f).iter().any(|c| c.code == "dormant_account"),
2830 "an account used within a token's lifetime is not dormant"
2831 );
2832
2833 f.parks[0].last_used_at = Some(f.now - 400 * 86_400);
2835 f.parks[0].active = true;
2836 assert!(!evaluate(&f).iter().any(|c| c.code == "dormant_account"));
2837 f.parks[0].active = false;
2838 f.parks[0].park = None;
2839 assert!(!evaluate(&f).iter().any(|c| c.code == "dormant_account"));
2840 }
2841
2842 #[test]
2843 fn the_daemon_is_reported_without_being_a_problem() {
2844 let mut f = facts();
2845 let checks = evaluate(&f);
2846 assert!(check(&checks, "claude_daemon").detail.contains("none"));
2847
2848 f.daemon = Some(daemon::Daemon {
2849 pid: 4321,
2850 version: Some("2.1.278".into()),
2851 started_at: Some(1_790_079_766_317),
2852 origin: Some("transient".into()),
2853 launch_target: None,
2854 running: true,
2855 });
2856 let checks = evaluate(&f);
2857 let running = check(&checks, "claude_daemon");
2858 assert_eq!(running.level, Level::Ok);
2859 assert!(running.detail.contains("running"));
2860 assert!(running.detail.contains("2.1.278"));
2861 assert!(running.detail.contains("4321"));
2862
2863 f.daemon.as_mut().expect("set above").running = false;
2864 let checks = evaluate(&f);
2865 let stopped = check(&checks, "claude_daemon");
2866 assert_eq!(stopped.level, Level::Ok);
2867 assert!(stopped.detail.contains("not running"));
2868 }
2869
2870 #[test]
2871 fn a_schedule_is_judged_by_whether_the_pitboard_it_runs_is_still_there() {
2872 let mut f = facts();
2873 assert!(
2874 evaluate(&f).iter().all(|c| c.code != "schedule"),
2875 "nothing is said where there is no schedule"
2876 );
2877
2878 f.schedule = Some(ScheduleFact {
2879 path: PathBuf::from("/home/x/Library/LaunchAgents/com.datlechin.pitboard.renew.plist"),
2880 program: Some(PathBuf::from("/opt/homebrew/bin/pitboard")),
2881 program_found: true,
2882 network: direct(),
2883 network_differs: false,
2884 });
2885 let checks = evaluate(&f);
2886 let found = check(&checks, "schedule");
2887 assert_eq!(found.level, Level::Ok);
2888 assert!(found.detail.contains("/opt/homebrew/bin/pitboard"));
2889
2890 f.schedule.as_mut().expect("set above").program_found = false;
2891 let checks = evaluate(&f);
2892 let gone = check(&checks, "schedule");
2893 assert_eq!(gone.level, Level::Fail, "every renewal from now on fails");
2894 assert!(gone.detail.contains("/opt/homebrew/bin/pitboard"));
2895 assert_eq!(gone.advice, again(crate::host::OS));
2896
2897 f.schedule = Some(ScheduleFact {
2898 path: PathBuf::from("/home/x/Library/LaunchAgents/com.datlechin.pitboard.renew.plist"),
2899 program: Some(PathBuf::from(
2900 "/Applications/Pitboard.app/Contents/MacOS/Pitboard",
2901 )),
2902 program_found: true,
2903 network: direct(),
2904 network_differs: false,
2905 });
2906 let checks = evaluate(&f);
2907 let the_app = check(&checks, "schedule");
2908 assert_eq!(
2909 the_app.level,
2910 Level::Fail,
2911 "0.3.0's app renews nothing when started with `renew`"
2912 );
2913 assert!(
2914 the_app.detail.contains("the app itself"),
2915 "{}",
2916 the_app.detail
2917 );
2918 f.schedule.as_mut().expect("set above").program = Some(PathBuf::from(
2919 "/Applications/Pitboard.app/Contents/Helpers/pitboard",
2920 ));
2921 assert_eq!(
2922 check(&evaluate(&f), "schedule").level,
2923 Level::Ok,
2924 "the command line an app comes with is a command line"
2925 );
2926
2927 f.schedule.as_mut().expect("set above").program = None;
2928 let checks = evaluate(&f);
2929 let unread = check(&checks, "schedule");
2930 assert_eq!(unread.level, Level::Warn);
2931 assert!(!unread.advice.is_empty());
2932 }
2933
2934 #[test]
2937 fn a_broken_schedule_is_written_again_by_whatever_this_machine_has() {
2938 let mac = again(Os::MacOs);
2939 assert!(
2940 mac.contains("the app's Settings") && mac.contains("pitboard schedule install"),
2941 "{mac}"
2942 );
2943 let linux = again(Os::Linux);
2944 assert!(linux.contains("pitboard schedule install"), "{linux}");
2945 assert!(!linux.contains("app"), "there is no app here: {linux}");
2946 }
2947
2948 #[test]
2951 fn a_schedule_whose_pitboard_is_gone_is_found_on_the_disk() {
2952 let root = std::env::temp_dir().join(format!(
2953 "pitboard-doctor-schedule-{}-{:?}",
2954 std::process::id(),
2955 std::thread::current().id()
2956 ));
2957 let _ = std::fs::remove_dir_all(&root);
2958 struct Scratch(std::path::PathBuf);
2959 impl Drop for Scratch {
2960 fn drop(&mut self) {
2961 let _ = std::fs::remove_dir_all(&self.0);
2962 }
2963 }
2964 let _guard = Scratch(root.clone());
2965 std::fs::create_dir_all(&root).expect("a scratch home");
2966
2967 let program = root.join("bin/pitboard");
2968 let ctx = Context::new(root.clone())
2969 .with_memory_stores(crate::host::memory::MemoryHost::new())
2970 .with_schedule_program(program.clone());
2971 assert!(schedule_fact(&ctx).is_none(), "nothing installed yet");
2972
2973 std::fs::create_dir_all(root.join("bin")).expect("a bin");
2974 std::fs::write(&program, "").expect("a Pitboard");
2975 crate::schedule::install(&ctx, Permit::for_a_test()).expect("installed");
2976 let fact = schedule_fact(&ctx).expect("installed");
2977 assert_eq!(fact.program.as_deref(), Some(program.as_path()));
2978 assert!(fact.program_found);
2979
2980 std::fs::remove_file(&program).expect("taken away");
2981 assert!(!schedule_fact(&ctx).expect("still installed").program_found);
2982
2983 assert!(
2984 schedule_fact(&ctx.with_pitboard_home(root.join("elsewhere"))).is_none(),
2985 "a Pitboard pointed at another home has no schedule"
2986 );
2987 }
2988
2989 fn scheduled(network: NetworkFact, network_differs: bool) -> ScheduleFact {
2991 ScheduleFact {
2992 path: PathBuf::from("/home/x/.config/systemd/user/pitboard-renew.timer"),
2993 program: Some(PathBuf::from("/usr/local/bin/pitboard")),
2994 program_found: true,
2995 network,
2996 network_differs,
2997 }
2998 }
2999
3000 fn by(proxy: ProxyFact) -> NetworkFact {
3002 NetworkFact {
3003 proxy: Some(proxy),
3004 passed_over: Vec::new(),
3005 }
3006 }
3007
3008 #[test]
3013 fn a_schedule_whose_proxy_differs_from_this_runs_is_said() {
3014 let mut f = facts();
3015 f.schedule = Some(scheduled(direct(), false));
3016 assert!(
3017 evaluate(&f).iter().all(|c| c.code != "schedule_proxy"),
3018 "nothing is said where they go the same way"
3019 );
3020
3021 f.schedule = Some(scheduled(by(through(None, &[])), true));
3022 let checks = evaluate(&f);
3023 let line = check(&checks, "schedule_proxy");
3024 assert_eq!(
3025 (line.level, line.name.as_str(), line.detail.as_str()),
3026 (
3027 Level::Warn,
3028 "renewal schedule's proxy",
3029 "the schedule was installed with http://***:***@proxy.example.com:3128, from \
3030 HTTPS_PROXY; this run goes directly"
3031 )
3032 );
3033 assert!(
3034 line.advice.contains("`pitboard schedule install`"),
3035 "{}",
3036 line.advice
3037 );
3038
3039 let on_loopback = || ProxyFact {
3040 variable: "ALL_PROXY",
3041 address: "http://127.0.0.1:3128".into(),
3042 server: "127.0.0.1:3128".into(),
3043 on_loopback: true,
3044 ..through(Some("NO_PROXY"), &["chatgpt.com"])
3045 };
3046 f.network = by(on_loopback());
3047 f.schedule = Some(scheduled(direct(), true));
3048 assert_eq!(
3049 check(&evaluate(&f), "schedule_proxy").detail,
3050 "the schedule was installed with no proxy; this run goes through \
3051 http://127.0.0.1:3128, from ALL_PROXY, NO_PROXY exempting chatgpt.com"
3052 );
3053
3054 let socks = || ProxyFact {
3058 address: "socks5h://127.0.0.1:1080".into(),
3059 server: "127.0.0.1:1080".into(),
3060 socks: true,
3061 ..on_loopback()
3062 };
3063 f.network = by(socks());
3064 let checks = evaluate(&f);
3065 let line = check(&checks, "schedule_proxy");
3066 assert_eq!(
3067 (line.detail.as_str(), line.advice.as_str()),
3068 (
3069 "the schedule was installed with no proxy; this run is given \
3070 socks5h://127.0.0.1:1080, from ALL_PROXY, NO_PROXY exempting chatgpt.com",
3071 "The schedule's runs are given the proxy variables it was installed with. \
3072 Installed again from here, they would be given this run's SOCKS proxy, which \
3073 Pitboard does not use yet, and their requests would fail. Set ALL_PROXY to an \
3074 HTTP proxy's address, or unset it, then install it again with `pitboard \
3075 schedule install`, which takes the proxy variables set where it runs."
3076 )
3077 );
3078 f.network = by(ProxyFact {
3081 exempt: [
3082 "api.anthropic.com",
3083 "platform.claude.com",
3084 "auth.openai.com",
3085 "chatgpt.com",
3086 ]
3087 .map(String::from)
3088 .to_vec(),
3089 ..socks()
3090 });
3091 let checks = evaluate(&f);
3092 let advice = &check(&checks, "schedule_proxy").advice;
3093 assert!(
3094 advice.starts_with(
3095 "The schedule's runs are given the proxy variables it was installed with. To \
3096 give them this run's, install it again from here"
3097 ),
3098 "{advice}"
3099 );
3100
3101 f.network = by(through(None, &[]));
3102 f.schedule = Some(scheduled(by(through(None, &[])), true));
3103 assert_eq!(
3104 check(&evaluate(&f), "schedule_proxy").detail,
3105 "the schedule was installed with this run's proxy, \
3106 http://***:***@proxy.example.com:3128, from HTTPS_PROXY, but with another user \
3107 name or password"
3108 );
3109 }
3110
3111 #[test]
3116 fn in_the_app_a_schedules_other_proxy_is_said_without_a_warning() {
3117 let mut f = facts();
3118 f.in_the_app = true;
3119 f.schedule = Some(scheduled(direct(), false));
3120 assert!(evaluate(&f).iter().all(|c| c.code != "schedule_proxy"));
3121
3122 f.schedule = Some(scheduled(by(through(None, &[])), true));
3123 let checks = evaluate(&f);
3124 let line = check(&checks, "schedule_proxy");
3125 assert_eq!(
3126 (line.level, line.name.as_str(), line.detail.as_str()),
3127 (
3128 Level::Ok,
3129 "renewal schedule's proxy",
3130 "the schedule was installed with http://***:***@proxy.example.com:3128, from \
3131 HTTPS_PROXY; the app goes directly"
3132 )
3133 );
3134 for said in ["Settings", "`pitboard schedule install` in a terminal"] {
3135 assert!(line.advice.contains(said), "{said}: {}", line.advice);
3136 }
3137
3138 f.network = by(through(None, &[]));
3139 assert_eq!(
3140 check(&evaluate(&f), "schedule_proxy").detail,
3141 "the schedule was installed with the app's proxy, \
3142 http://***:***@proxy.example.com:3128, from HTTPS_PROXY, but with another user \
3143 name or password"
3144 );
3145 }
3146
3147 #[test]
3152 fn the_schedules_proxy_is_read_from_what_install_wrote() {
3153 let root = std::env::temp_dir().join(format!(
3154 "pitboard-doctor-schedule-proxy-{}-{:?}",
3155 std::process::id(),
3156 std::thread::current().id()
3157 ));
3158 let _ = std::fs::remove_dir_all(&root);
3159 struct Scratch(std::path::PathBuf);
3160 impl Drop for Scratch {
3161 fn drop(&mut self) {
3162 let _ = std::fs::remove_dir_all(&self.0);
3163 }
3164 }
3165 let _guard = Scratch(root.clone());
3166 let program = root.join("bin/pitboard");
3167 std::fs::create_dir_all(root.join("bin")).expect("a bin");
3168 std::fs::write(&program, "").expect("a Pitboard");
3169 let machine = crate::host::memory::MemoryHost::new();
3170 let home = root.to_string_lossy().into_owned();
3171 let run = |proxy: Option<&str>| {
3172 let env: crate::context::Environment = [("HOME", home.as_str())]
3173 .into_iter()
3174 .chain(proxy.map(|address| ("HTTPS_PROXY", address)))
3175 .collect();
3176 Context::for_command_line(&env)
3177 .with_memory_stores(std::sync::Arc::clone(&machine))
3178 .with_schedule_program(program.clone())
3179 };
3180 let gathered = |ctx: &Context| {
3181 let mut f = facts();
3182 f.schedule = schedule_fact(ctx);
3183 f.network = network_fact(&ctx.proxy);
3184 f
3185 };
3186 let installer = run(Some("http://alice:s3cret@proxy.example.com:3128"));
3187 crate::schedule::install(&installer, Permit::for_a_test()).expect("installed");
3188
3189 let same = gathered(&installer);
3190 assert!(!same.schedule.as_ref().expect("installed").network_differs);
3191 assert!(evaluate(&same).iter().all(|c| c.code != "schedule_proxy"));
3192
3193 let changed = gathered(&run(Some("http://alice:changed@proxy.example.com:3128")));
3194 let detail = check(&evaluate(&changed), "schedule_proxy").detail.clone();
3195 assert!(
3196 detail.ends_with("but with another user name or password"),
3197 "{detail}"
3198 );
3199 for secret in ["alice", "s3cret", "changed"] {
3200 assert!(!detail.contains(secret), "{secret}: {detail}");
3201 }
3202
3203 let direct = run(None);
3204 let f = gathered(&direct);
3205 let detail = check(&evaluate(&f), "schedule_proxy").detail.clone();
3206 assert_eq!(
3207 detail,
3208 "the schedule was installed with http://***:***@proxy.example.com:3128, from \
3209 HTTPS_PROXY; this run goes directly"
3210 );
3211 let hidden = redaction_for(&direct, &f).over(&detail);
3212 assert!(
3213 hidden.starts_with("the schedule was installed with http://***:***@<proxy ")
3214 && !hidden.contains("proxy.example.com"),
3215 "{hidden}"
3216 );
3217 }
3218
3219 #[test]
3220 fn every_failure_and_warning_tells_the_user_something() {
3221 let mut f = facts();
3222 f.credential = Ok(Some(json!({"slackTag": {}})));
3223 f.home_access = Some(mode(0o755));
3224 for c in evaluate(&f) {
3225 if c.level != Level::Ok {
3226 assert!(!c.advice.is_empty(), "{} has no advice", c.code);
3227 }
3228 }
3229 }
3230
3231 #[test]
3234 fn advice_reads_as_it_was_written() {
3235 let mut f = facts();
3236 f.credential = Ok(Some(json!({"slackTag": {}})));
3237 f.home_access = Some(mode(0o755));
3238 f.readable_by_others = vec![("/home/a/.claude/.credentials.json".into(), mode(0o644))];
3239 for c in evaluate(&f) {
3240 assert!(!c.advice.contains(" "), "{}: {:?}", c.code, c.advice);
3241 }
3242 }
3243
3244 fn through(exempting: Option<&'static str>, exempt: &[&str]) -> ProxyFact {
3246 ProxyFact {
3247 variable: "HTTPS_PROXY",
3248 address: "http://***:***@proxy.example.com:3128".into(),
3249 server: "proxy.example.com:3128".into(),
3250 on_loopback: false,
3251 socks: false,
3252 exempting,
3253 exempt: exempt.iter().map(|host| (*host).to_string()).collect(),
3254 }
3255 }
3256
3257 fn network(proxy: Option<ProxyFact>, passed_over: &[&'static str]) -> Check {
3259 let mut f = facts();
3260 f.network = NetworkFact {
3261 proxy,
3262 passed_over: passed_over.to_vec(),
3263 };
3264 let checks = evaluate(&f);
3265 let found = check(&checks, "network");
3266 Check {
3267 code: found.code,
3268 name: found.name.clone(),
3269 level: found.level,
3270 detail: found.detail.clone(),
3271 advice: found.advice.clone(),
3272 }
3273 }
3274
3275 #[test]
3279 fn the_network_check_names_the_proxy_and_the_variable_it_came_from() {
3280 let direct = network(None, &[]);
3281 assert_eq!(
3282 (direct.level, direct.name.as_str(), direct.detail.as_str()),
3283 (Level::Ok, "network", "direct: no variable names a proxy")
3284 );
3285 for (exempting, exempt, said) in [
3286 (None, &[][..], ""),
3287 (
3288 Some("NO_PROXY"),
3289 &[],
3290 "; NO_PROXY exempts none of the hosts Pitboard calls",
3291 ),
3292 (
3293 Some("no_proxy"),
3294 &["api.anthropic.com"],
3295 "; no_proxy exempts api.anthropic.com, which Pitboard reaches directly",
3296 ),
3297 (
3298 Some("NO_PROXY"),
3299 &["api.anthropic.com", "platform.claude.com"],
3300 "; NO_PROXY exempts api.anthropic.com and platform.claude.com, which Pitboard \
3301 reaches directly",
3302 ),
3303 (
3304 Some("NO_PROXY"),
3305 &[
3306 "api.anthropic.com",
3307 "platform.claude.com",
3308 "auth.openai.com",
3309 "chatgpt.com",
3310 ],
3311 "; NO_PROXY exempts every host Pitboard calls, which it reaches directly",
3312 ),
3313 ] {
3314 let check = network(Some(through(exempting, exempt)), &[]);
3315 assert_eq!(check.level, Level::Ok, "{said}");
3316 assert_eq!(
3317 check.detail,
3318 format!("through http://***:***@proxy.example.com:3128, from HTTPS_PROXY{said}")
3319 );
3320 assert!(check.advice.is_empty(), "{}", check.advice);
3321 }
3322 }
3323
3324 fn network_read_from(pairs: &[(&'static str, &str)]) -> Check {
3327 let env: crate::context::Environment = pairs
3328 .iter()
3329 .copied()
3330 .chain([("HOME", "/nowhere")])
3331 .collect();
3332 let fact = network_fact(&Context::for_command_line(&env).proxy);
3333 network(fact.proxy, &fact.passed_over)
3334 }
3335
3336 #[test]
3344 fn a_socks_proxy_is_a_warning_naming_its_variable() {
3345 let socks = network_read_from(&[("all_proxy", "socks5://127.0.0.1:7890")]);
3346 assert_eq!(
3347 (socks.level, socks.detail.as_str(), socks.advice.as_str()),
3348 (
3349 Level::Warn,
3350 "requests fail: Pitboard does not use SOCKS proxies yet, and all_proxy names \
3351 one, socks5://127.0.0.1:7890",
3352 "Pitboard goes through HTTP and HTTPS proxies only. For its requests to go \
3353 out, set all_proxy to an HTTP proxy's address, such as \
3354 http://proxy.example.com:8080, or unset it."
3355 )
3356 );
3357
3358 let some = network_read_from(&[
3359 ("ALL_PROXY", "socks5h://alice:s3cret@p.example"),
3360 ("NO_PROXY", "chatgpt.com"),
3361 ]);
3362 assert_eq!(
3363 (some.level, some.detail.as_str()),
3364 (
3365 Level::Warn,
3366 "requests fail: Pitboard does not use SOCKS proxies yet, and ALL_PROXY names \
3367 one, socks5h://***:***@p.example:1080; NO_PROXY exempts chatgpt.com, which \
3368 Pitboard reaches directly"
3369 )
3370 );
3371
3372 let passed = network_read_from(&[
3373 ("ALL_PROXY", "not a proxy"),
3374 ("HTTPS_PROXY", "socks://p.example"),
3375 ]);
3376 assert_eq!(
3377 (passed.level, passed.detail.as_str(), passed.advice.as_str()),
3378 (
3379 Level::Warn,
3380 "requests fail: Pitboard does not use SOCKS proxies yet, and HTTPS_PROXY \
3381 names one, socks5://p.example:1080; ALL_PROXY holds no proxy's address \
3382 Pitboard can read, so it is passed over",
3383 "Pitboard goes through HTTP and HTTPS proxies only. For its requests to go \
3384 out, set each of ALL_PROXY and HTTPS_PROXY to an HTTP proxy's address, such \
3385 as http://proxy.example.com:8080, or unset them."
3386 )
3387 );
3388
3389 let every = network_read_from(&[("HTTPS_PROXY", "socks4://10.0.0.1"), ("no_proxy", "*")]);
3390 assert_eq!(
3391 (every.level, every.detail.as_str(), every.advice.as_str()),
3392 (
3393 Level::Ok,
3394 "direct: Pitboard does not use SOCKS proxies yet, and HTTPS_PROXY names one, \
3395 socks4://10.0.0.1:1080; no_proxy exempts every host Pitboard calls, which it \
3396 reaches directly",
3397 ""
3398 )
3399 );
3400 }
3401
3402 #[test]
3405 fn a_variable_pitboard_cannot_read_is_a_warning() {
3406 let passed = network(None, &["HTTPS_PROXY"]);
3407 assert_eq!(passed.level, Level::Warn);
3408 assert_eq!(
3409 passed.detail,
3410 "direct: HTTPS_PROXY holds no proxy's address Pitboard can read, so it is passed \
3411 over"
3412 );
3413 assert_eq!(
3414 passed.advice,
3415 "Set it to a proxy's address, such as http://proxy.example.com:8080, or unset it."
3416 );
3417 let both = ProxyFact {
3418 variable: "HTTP_PROXY",
3419 address: "http://proxy.example.com:8080".into(),
3420 ..through(None, &[])
3421 };
3422 let passed = network(Some(both), &["ALL_PROXY", "HTTPS_PROXY"]);
3423 assert_eq!(passed.level, Level::Warn);
3424 assert_eq!(
3425 passed.detail,
3426 "through http://proxy.example.com:8080, from HTTP_PROXY; ALL_PROXY and HTTPS_PROXY \
3427 hold no proxy's address Pitboard can read, so they are passed over"
3428 );
3429 assert_eq!(
3430 passed.advice,
3431 "Set each to a proxy's address, such as http://proxy.example.com:8080, or unset \
3432 them."
3433 );
3434 }
3435
3436 #[test]
3441 fn a_proxys_host_is_redacted_from_a_report_unless_it_is_on_loopback() {
3442 let ctx = Context::new(PathBuf::from("/home/x"));
3443 let mut f = facts();
3444 f.network.proxy = Some(through(None, &[]));
3445 let detail = check(&evaluate(&f), "network").detail.clone();
3446 let hidden = redaction_for(&ctx, &f).over(&detail);
3447 assert!(detail.contains("proxy.example.com:3128"), "{detail}");
3448 assert!(!hidden.contains("proxy.example.com"), "{hidden}");
3449 assert!(!hidden.contains("3128"), "{hidden}");
3450 assert!(
3451 hidden.starts_with("through http://***:***@<proxy ")
3452 && hidden.contains("from HTTPS_PROXY"),
3453 "{hidden}"
3454 );
3455
3456 f.network.proxy = Some(ProxyFact {
3457 address: "http://127.0.0.1:7890".into(),
3458 server: "127.0.0.1:7890".into(),
3459 on_loopback: true,
3460 ..through(None, &[])
3461 });
3462 let detail = check(&evaluate(&f), "network").detail.clone();
3463 assert_eq!(redaction_for(&ctx, &f).over(&detail), detail);
3464 }
3465
3466 #[test]
3469 fn the_network_is_read_from_the_context() {
3470 let env: crate::context::Environment = [
3471 ("HOME", "/nowhere"),
3472 ("HTTPS_PROXY", "http://alice:s3cret@proxy.example.com:3128"),
3473 ("NO_PROXY", ".anthropic.com,localhost"),
3474 ]
3475 .into_iter()
3476 .collect();
3477 let fact = network_fact(&Context::for_command_line(&env).proxy);
3478 let proxy = fact.proxy.expect("a proxy");
3479 assert_eq!(proxy.variable, "HTTPS_PROXY");
3480 assert_eq!(proxy.address, "http://***:***@proxy.example.com:3128");
3481 assert_eq!(proxy.server, "proxy.example.com:3128");
3482 assert!(!proxy.on_loopback && !proxy.socks);
3483 assert_eq!(proxy.exempting, Some("NO_PROXY"));
3484 assert_eq!(proxy.exempt, ["api.anthropic.com"]);
3485 assert!(fact.passed_over.is_empty());
3486
3487 let unit = network_fact(&Context::for_unit_test().proxy);
3488 assert!(unit.proxy.is_none() && unit.passed_over.is_empty());
3489
3490 for (address, shown, on_loopback) in [
3491 ("socks5://localhost:7890", "socks5://localhost:7890", true),
3492 ("socks4://127.0.0.1", "socks4://127.0.0.1:1080", true),
3493 ("socks5h://[::1]:1080", "socks5h://[::1]:1080", true),
3494 ("socks4a://10.0.0.1", "socks4a://10.0.0.1:1080", false),
3495 ("socks://p.example", "socks5://p.example:1080", false),
3496 (
3497 "https://Proxy.Example.com",
3498 "https://Proxy.Example.com:443",
3499 false,
3500 ),
3501 ] {
3502 let env: crate::context::Environment = [("HOME", "/nowhere"), ("ALL_PROXY", address)]
3503 .into_iter()
3504 .collect();
3505 let proxy = network_fact(&Context::for_command_line(&env).proxy)
3506 .proxy
3507 .expect("a proxy");
3508 assert_eq!(
3509 (proxy.address.as_str(), proxy.on_loopback, proxy.socks),
3510 (shown, on_loopback, shown.starts_with("socks")),
3511 "{address}"
3512 );
3513 }
3514 }
3515
3516 #[test]
3517 fn a_machine_without_a_stable_identifier_is_flagged() {
3518 let mut f = facts();
3519 f.machine_id_known = false;
3520 let checks = evaluate(&f);
3521 assert_eq!(check(&checks, "machine_id").level, Level::Warn);
3522 assert!(evaluate(&facts()).iter().all(|c| c.code != "machine_id"));
3523 }
3524
3525 #[test]
3526 fn each_check_is_told_apart_by_code_and_name() {
3527 let mut f = facts();
3528 f.parks = vec![parked("work", None), parked("personal", None)];
3529 let checks = evaluate(&f);
3530 let mut keys: Vec<(&str, &str)> =
3531 checks.iter().map(|c| (c.code, c.name.as_str())).collect();
3532 keys.sort_unstable();
3533 let before = keys.len();
3534 keys.dedup();
3535 assert_eq!(keys.len(), before);
3536 }
3537
3538 #[test]
3539 fn every_parked_login_is_judged_and_a_way_back_is_offered() {
3540 let mut f = facts();
3541 let mut unusable = parked("broken", Some(NOW + 30 * 86_400));
3542 unusable.unreadable = Some(Unreadable::Broken("missing from the vault".into()));
3543 f.parks = vec![
3544 parked("fine", Some(NOW + 20 * 86_400)),
3545 parked("soon", Some(NOW + 86_400)),
3546 parked("gone", Some(NOW - 1)),
3547 unusable,
3548 ParkFact {
3549 provider: ProviderId::Claude,
3550 last_used_at: None,
3551 label: "empty".into(),
3552 name: crate::state::Key::new(ProviderId::Claude, "empty").typed(),
3553 active: false,
3554 park: None,
3555 unreadable: None,
3556 },
3557 ParkFact {
3558 provider: ProviderId::Claude,
3559 last_used_at: None,
3560 label: "live".into(),
3561 name: crate::state::Key::new(ProviderId::Claude, "live").typed(),
3562 active: true,
3563 park: None,
3564 unreadable: None,
3565 },
3566 ];
3567 let checks = evaluate(&f);
3568 for (name, level) in [
3569 ("account fine", Level::Ok),
3570 ("account soon", Level::Warn),
3571 ("account gone", Level::Warn),
3572 ("account broken", Level::Fail),
3573 ("account empty", Level::Warn),
3574 ("account live", Level::Ok),
3575 ] {
3576 let c = named(&checks, name);
3577 assert_eq!(c.level, level, "{name}: {}", c.detail);
3578 if level != Level::Ok {
3579 let label = name.trim_start_matches("account ");
3580 assert!(
3581 c.advice
3582 .contains(&format!("pitboard enroll {label} --sign-in"))
3583 );
3584 }
3585 }
3586 assert_eq!(
3589 named(&checks, "account fine").detail,
3590 "parked, good for 20d 0h"
3591 );
3592 assert_eq!(
3593 named(&checks, "account soon").detail,
3594 "its parked login expires in 1d 0h"
3595 );
3596 assert_eq!(
3597 named(&checks, "account gone").detail,
3598 format!("its parked login expired {}", time::moment(NOW - 1, NOW))
3599 );
3600 }
3601
3602 #[test]
3606 fn a_parked_login_is_due_from_three_days_before_it_expires() {
3607 assert!(!renewal_due(NOW + RENEW_WITHIN, NOW));
3608 assert!(renewal_due(NOW + RENEW_WITHIN - 1, NOW));
3609 assert!(renewal_due(NOW + 1, NOW));
3610 assert!(!renewal_due(NOW, NOW));
3611 assert!(!renewal_due(NOW - 60, NOW));
3612 }
3613
3614 #[test]
3615 fn an_interrupted_switch_and_leftover_parks_are_reported() {
3616 let mut f = facts();
3617 f.interrupted = true;
3618 f.state = Ok(State {
3619 discarded: vec!["pitboard-park-x-1".into()],
3620 ..State::default()
3621 });
3622 let checks = evaluate(&f);
3623 assert_eq!(check(&checks, "interrupted_switch").level, Level::Warn);
3624 assert_eq!(check(&checks, "discarded").level, Level::Warn);
3625 assert!(healthy(&checks), "neither stops Pitboard working");
3626 }
3627
3628 #[test]
3632 fn a_switch_the_next_change_cannot_finish_says_why_and_the_way_out() {
3633 let mut f = facts();
3634 f.interrupted = true;
3635 f.stuck = Some("an earlier switch from `work` to `personal` was interrupted".into());
3636 let checks = evaluate(&f);
3637 let said = check(&checks, "interrupted_switch");
3638 assert_eq!(said.level, Level::Warn);
3639 assert_eq!(
3640 said.detail,
3641 "an earlier switch from `work` to `personal` was interrupted"
3642 );
3643 assert!(
3644 said.advice.contains("`pitboard abandon`"),
3645 "{}",
3646 said.advice
3647 );
3648 assert_eq!(
3649 checks
3650 .iter()
3651 .filter(|c| c.code == "interrupted_switch")
3652 .count(),
3653 1,
3654 "said once"
3655 );
3656 }
3657
3658 #[test]
3662 fn advice_names_a_codex_account_the_way_it_is_typed() {
3663 let mut f = facts();
3664 let mut codex = codex_parked("work", Some(NOW - 1));
3665 codex.last_used_at = Some(NOW - 400 * 86_400);
3666 let mut claude = parked("work", Some(NOW - 1));
3669 claude.name = "claude/work".into();
3670 claude.last_used_at = Some(NOW - 400 * 86_400);
3671 f.parks = vec![claude, codex];
3672 let checks = evaluate(&f);
3673
3674 let codex_park = named(&checks, "account codex/work");
3675 assert!(
3676 codex_park
3677 .advice
3678 .contains("`pitboard enroll codex/work --sign-in`"),
3679 "{}",
3680 codex_park.advice
3681 );
3682 let claude_park = named(&checks, "account claude/work");
3683 assert!(
3684 claude_park
3685 .advice
3686 .contains("`pitboard enroll claude/work --sign-in`"),
3687 "a name a command here takes, which a bare `work` is not: {}",
3688 claude_park.advice
3689 );
3690
3691 let dormant: Vec<&Check> = checks
3692 .iter()
3693 .filter(|c| c.code.ends_with("dormant_account"))
3694 .collect();
3695 assert_eq!(dormant.len(), 2);
3696 assert!(
3697 dormant.iter().any(|c| c.name == "account codex/work"
3698 && c.advice.contains("`pitboard forget codex/work`"))
3699 );
3700 assert!(dormant.iter().any(|c| c.name == "account claude/work"
3701 && c.advice.contains("`pitboard forget claude/work`")));
3702 }
3703
3704 #[test]
3708 fn an_account_is_active_by_its_own_tools_record() {
3709 use crate::host::memory::MemoryHost;
3710
3711 let root = std::env::temp_dir().join(format!(
3712 "pitboard-doctor-active-{}-{:?}",
3713 std::process::id(),
3714 std::thread::current().id()
3715 ));
3716 let _ = std::fs::remove_dir_all(&root);
3717 std::fs::create_dir_all(&root).expect("a scratch home");
3718 struct Scratch(std::path::PathBuf);
3719 impl Drop for Scratch {
3720 fn drop(&mut self) {
3721 let _ = std::fs::remove_dir_all(&self.0);
3722 }
3723 }
3724 let _guard = Scratch(root.clone());
3725 let ctx = Context::new(root.clone())
3726 .with_pitboard_home(root.join(".pitboard"))
3727 .with_codex_home(root.join("codex").to_string_lossy().into())
3728 .with_memory_stores(MemoryHost::new());
3729 std::fs::write(
3730 root.join(".claude.json"),
3731 json!({"oauthAccount": {
3732 "accountUuid": "alpha-uuid",
3733 "emailAddress": "a@example.com",
3734 "organizationUuid": "org",
3735 }})
3736 .to_string(),
3737 )
3738 .expect("a Claude Code config");
3739
3740 let account =
3741 |label: &str, uuid: &str, detail: crate::state::Detail| crate::state::Account {
3742 label: label.into(),
3743 id: uuid.into(),
3744 account_uuid: uuid.into(),
3745 email: format!("{label}@example.com"),
3746 parked: None,
3747 last_used_at: None,
3748 detail,
3749 };
3750 let claude = || crate::state::Detail::Claude {
3751 organization_uuid: "org".into(),
3752 oauth_account: json!({}),
3753 };
3754 let codex = || crate::state::Detail::Codex {
3755 workspace_id: None,
3756 plan: None,
3757 };
3758 let mut state = State {
3759 accounts: vec![
3760 account("alpha", "alpha-uuid", claude()),
3761 account("work", "work-acc", codex()),
3762 account("home", "home-acc", codex()),
3763 ],
3764 ..State::default()
3765 };
3766 state.set_active(ProviderId::Codex, Some("home".into()));
3767 let active = |facts: &[ParkFact]| -> Vec<String> {
3768 facts
3769 .iter()
3770 .filter(|p| p.active)
3771 .map(ParkFact::typed)
3772 .collect()
3773 };
3774
3775 assert_eq!(active(&park_facts(&ctx, &state)), ["alpha", "codex/home"]);
3777
3778 let live = crate::provider::of(ProviderId::Codex)
3780 .live(&ctx)
3781 .expect("Codex keeps its login in a file here");
3782 let login = json!({
3783 "auth_mode": "chatgpt",
3784 "tokens": {
3785 "id_token": crate::provider::jwt::unsigned(&json!({
3786 "email": "work@example.com",
3787 "https://api.openai.com/auth": {"chatgpt_account_id": "work-acc"},
3788 })),
3789 "access_token": "a",
3790 "refresh_token": "r",
3791 "account_id": "work-acc",
3792 },
3793 });
3794 store::write_raw(
3795 &live.chain,
3796 Permit::for_a_test(),
3797 &live.service,
3798 &login.to_string(),
3799 )
3800 .expect("a login");
3801 assert_eq!(active(&park_facts(&ctx, &state)), ["alpha", "codex/work"]);
3802 }
3803
3804 #[test]
3808 fn codex_has_a_section_only_where_there_is_a_codex() {
3809 let without = evaluate(&facts());
3810 assert!(without.iter().all(|c| !c.code.starts_with("codex_")));
3811
3812 let mut f = facts();
3813 f.codex = with_codex();
3814 let with = evaluate(&f);
3815 let claude = |checks: &[Check]| -> Vec<(String, String, String)> {
3816 checks
3817 .iter()
3818 .filter(|c| !c.code.starts_with("codex_"))
3819 .map(|c| (c.code.to_string(), c.detail.clone(), c.advice.clone()))
3820 .collect()
3821 };
3822 assert_eq!(claude(&without), claude(&with), "Claude Code's checks move");
3823 for code in [
3824 "codex_backend",
3825 "codex_auth_file",
3826 "codex_login",
3827 "codex_version",
3828 "codex_running",
3829 ] {
3830 assert_eq!(check(&with, code).level, Level::Ok, "{code}");
3831 }
3832 assert!(healthy(&with));
3833 let login = check(&with, "codex_login");
3834 assert!(login.detail.contains("w@example.com"), "{}", login.detail);
3835 assert!(
3836 login.detail.contains("0123456789abcdef"),
3837 "{}",
3838 login.detail
3839 );
3840
3841 f.codex = CodexFacts {
3845 enrolled: 1,
3846 ..no_codex()
3847 };
3848 let checks = evaluate(&f);
3849 let file = check(&checks, "codex_auth_file");
3850 assert_eq!(file.level, Level::Warn);
3851 assert!(file.advice.contains("`codex login`"), "{}", file.advice);
3852 assert!(
3853 !file.advice.contains("pitboard use codex/"),
3854 "{}",
3855 file.advice
3856 );
3857 }
3858
3859 #[test]
3867 fn each_codex_store_is_judged_for_what_pitboard_can_do_with_it() {
3868 let judged = |backend: &'static str, enrolled: usize| {
3869 let codex = CodexFacts {
3870 backend,
3871 enrolled,
3872 backend_setting: format!(
3873 "`cli_auth_credentials_store = \"{backend}\"` in /home/x/.codex/config.toml"
3874 ),
3875 backend_remedy: "To use the file store, set `cli_auth_credentials_store = \
3876 \"file\"` in /home/x/.codex/config.toml, then sign in again \
3877 with `codex login`"
3878 .into(),
3879 ..with_codex()
3880 };
3881 codex_checks(&codex)
3882 };
3883
3884 for store in ["keyring", "auto", "secrets", "ephemeral"] {
3885 let checks = judged(store, 1);
3886 let found = check(&checks, "codex_backend");
3887 assert_eq!(found.level, Level::Fail, "{store}");
3888 assert!(found.detail.contains(store), "{}", found.detail);
3889 assert!(
3890 found.detail.contains("/home/x/.codex/config.toml"),
3891 "says which file set it: {}",
3892 found.detail
3893 );
3894 assert!(
3895 found.advice.contains(
3896 "set `cli_auth_credentials_store = \"file\"` in /home/x/.codex/config.toml"
3897 ),
3898 "says the line and where it goes: {}",
3899 found.advice
3900 );
3901 assert!(!healthy(&checks), "{store}");
3902 assert!(
3903 checks.iter().all(|c| c.code != "codex_login"),
3904 "a store other than the file is not read, so there is nothing to say about \
3905 its login"
3906 );
3907
3908 let checks = judged(store, 0);
3909 let found = check(&checks, "codex_backend");
3910 assert_eq!(
3911 found.level,
3912 Level::Ok,
3913 "nothing Pitboard does is broken for somebody with no Codex accounts: {store}"
3914 );
3915 assert!(found.detail.contains(store), "{}", found.detail);
3916 assert!(found.detail.contains("file store"), "{}", found.detail);
3917 }
3918 for store in ["keyring", "auto", "secrets"] {
3919 let found = judged(store, 1);
3920 let advice = &check(&found, "codex_backend").advice;
3921 assert!(advice.contains("will not touch"), "{advice}");
3922 }
3923 }
3924
3925 #[test]
3929 fn the_file_store_says_what_chose_it_and_what_is_not_read() {
3930 let checks = codex_checks(&with_codex());
3931 let found = check(&checks, "codex_backend");
3932 assert_eq!(found.level, Level::Ok);
3933 assert_eq!(
3934 found.detail,
3935 "file (Codex's default) · /home/x/.codex/auth.json"
3936 );
3937 assert!(
3938 found
3939 .advice
3940 .contains("does not read a project's own `.codex/config.toml`"),
3941 "{}",
3942 found.advice
3943 );
3944 }
3945
3946 #[test]
3949 fn a_codex_store_nobody_can_tell_is_broken() {
3950 let unknown = |enrolled| CodexFacts {
3951 backend: "unknown",
3952 backend_setting: "/etc/codex/config.toml is not TOML Codex can read, at line 3".into(),
3953 backend_remedy: "Codex 0.160.0 does not start until that is put right".into(),
3954 enrolled,
3955 ..with_codex()
3956 };
3957 for (enrolled, level) in [(1, Level::Fail), (0, Level::Warn)] {
3958 let checks = codex_checks(&unknown(enrolled));
3959 let found = check(&checks, "codex_backend");
3960 assert_eq!(found.level, level);
3961 assert_eq!(
3962 found.detail,
3963 "cannot tell: /etc/codex/config.toml is not TOML Codex can read, at line 3"
3964 );
3965 assert_eq!(
3966 found.advice,
3967 "Codex 0.160.0 does not start until that is put right. Pitboard parks and \
3968 switches no Codex login while it cannot tell where Codex keeps it."
3969 );
3970 assert!(checks.iter().all(|c| c.code != "codex_login"));
3971 }
3972 }
3973
3974 #[test]
3977 fn a_store_an_administrator_pinned_is_gathered_and_said() {
3978 let host = crate::host::memory::MemoryHost::new();
3979 let ctx = Context::for_unit_test().with_memory_stores(host.clone());
3980 host.administers(
3981 "/etc/codex/requirements.toml",
3982 "cli_auth_credentials_store = \"keyring\"\n",
3983 );
3984 let codex = CodexFacts {
3985 enrolled: 1,
3986 ..codex_facts(&ctx, None)
3987 };
3988 assert_eq!(codex.backend, "keyring");
3989 assert_eq!(
3990 codex.backend_setting,
3991 "pinned to `keyring` by /etc/codex/requirements.toml"
3992 );
3993 let checks = codex_checks(&codex);
3994 let found = check(&checks, "codex_backend");
3995 assert_eq!(found.level, Level::Fail);
3996 assert_eq!(
3997 found.detail,
3998 "keyring (pinned to `keyring` by /etc/codex/requirements.toml)"
3999 );
4000 assert!(
4001 found.advice.ends_with(
4002 "No line in your own config.toml can change it: \
4003 /etc/codex/requirements.toml pins it, and only an administrator can change \
4004 that."
4005 ),
4006 "{}",
4007 found.advice
4008 );
4009
4010 host.administers("/etc/codex/requirements.toml", "x = \n");
4011 let unknown = codex_facts(&ctx, None);
4012 assert_eq!(unknown.backend, "unknown");
4013 assert_eq!(
4014 unknown.backend_setting,
4015 "/etc/codex/requirements.toml is not TOML Codex can read, at line 1"
4016 );
4017 assert_eq!(
4018 unknown.backend_remedy,
4019 "Codex 0.160.0 does not start until that is put right"
4020 );
4021 assert!(matches!(unknown.login, Ok(None)), "nothing is read");
4022 }
4023
4024 #[test]
4029 fn a_codex_login_with_an_api_key_is_a_choice_rather_than_a_fault() {
4030 let api_key = || CodexFacts {
4031 login: Err(CodexLoginTrouble::NotAnAccount(
4032 "Codex is signed in with an API key rather than a ChatGPT account, so there \
4033 is no account login to park or switch"
4034 .into(),
4035 )),
4036 ..with_codex()
4037 };
4038
4039 let checks = codex_checks(&api_key());
4040 let login = check(&checks, "codex_login");
4041 assert_eq!(
4042 login.level,
4043 Level::Ok,
4044 "stated, for somebody with no Codex accounts"
4045 );
4046 assert!(login.detail.contains("API key"), "{}", login.detail);
4047 assert!(checks.iter().all(|c| c.level == Level::Ok));
4048
4049 let checks = codex_checks(&CodexFacts {
4050 enrolled: 2,
4051 ..api_key()
4052 });
4053 let login = check(&checks, "codex_login");
4054 assert_eq!(login.level, Level::Warn);
4055 assert!(healthy(&checks), "nothing is broken");
4056 assert!(!login.detail.contains("cannot"), "{}", login.detail);
4057 assert!(login.advice.contains("`codex login`"), "{}", login.advice);
4058 assert!(
4059 !login.advice.contains("again"),
4060 "nothing to sign in again for: {}",
4061 login.advice
4062 );
4063 }
4064
4065 #[test]
4069 fn a_codex_account_is_judged_in_codex_section_under_a_codex_code() {
4070 let claude_only = {
4071 let mut f = facts();
4072 f.parks = vec![parked("work", Some(NOW + 20 * 86_400))];
4073 f.codex = with_codex();
4074 evaluate(&f)
4075 };
4076
4077 let mut f = facts();
4078 let mut codex = codex_parked("work", Some(NOW - 1));
4079 codex.last_used_at = Some(NOW - 400 * 86_400);
4080 f.parks = vec![parked("work", Some(NOW + 20 * 86_400)), codex];
4081 f.codex = CodexFacts {
4082 enrolled: 1,
4083 ..with_codex()
4084 };
4085 let checks = evaluate(&f);
4086
4087 let about_codex: Vec<&Check> = checks
4088 .iter()
4089 .filter(|c| c.name.contains("codex/"))
4090 .collect();
4091 let codes: Vec<&str> = about_codex.iter().map(|c| c.code).collect();
4092 assert_eq!(codes, ["codex_parked_login", "codex_dormant_account"]);
4093 let section = checks
4094 .iter()
4095 .position(|c| c.code == "codex_backend")
4096 .expect("a Codex section");
4097 let first = checks
4098 .iter()
4099 .position(|c| c.name.contains("codex/"))
4100 .unwrap();
4101 assert!(first > section, "after the Codex heading");
4102
4103 let claude = |checks: &[Check]| -> Vec<(&'static str, String)> {
4104 checks
4105 .iter()
4106 .filter(|c| !c.code.starts_with("codex_"))
4107 .map(|c| (c.code, c.name.clone()))
4108 .collect()
4109 };
4110 assert_eq!(
4111 claude(&checks),
4112 claude(&claude_only),
4113 "Claude Code's section is Claude Code's accounts alone"
4114 );
4115 }
4116
4117 #[test]
4120 fn a_codex_whose_version_cannot_be_read_is_not_called_missing() {
4121 let found = CodexFacts {
4122 program: Some(PathBuf::from("/home/x/.volta/bin/codex")),
4123 version: None,
4124 ..with_codex()
4125 };
4126 let version = check(&codex_checks(&found), "codex_version").detail.clone();
4127 assert!(!version.contains("not found"), "{version}");
4128 assert!(version.contains("/home/x/.volta/bin/codex"), "{version}");
4129
4130 let missing = CodexFacts {
4131 program: None,
4132 version: None,
4133 ..with_codex()
4134 };
4135 let version = check(&codex_checks(&missing), "codex_version")
4136 .detail
4137 .clone();
4138 assert!(version.starts_with("not found here"), "{version}");
4139 }
4140
4141 #[test]
4145 fn a_version_is_read_out_of_each_way_codex_is_installed() {
4146 use crate::host::fs::testing;
4147
4148 let root = std::env::temp_dir().join(format!(
4149 "pitboard-doctor-codex-version-{}-{:?}",
4150 std::process::id(),
4151 std::thread::current().id()
4152 ));
4153 let _ = std::fs::remove_dir_all(&root);
4154 struct Scratch(std::path::PathBuf);
4155 impl Drop for Scratch {
4156 fn drop(&mut self) {
4157 let _ = std::fs::remove_dir_all(&self.0);
4158 }
4159 }
4160 let _guard = Scratch(root.clone());
4161 let place = |at: &str| {
4162 let path = root.join(at);
4163 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
4164 std::fs::write(&path, "").unwrap();
4165 path
4166 };
4167
4168 let standalone =
4169 place("codex/packages/standalone/releases/0.154.0-aarch64-apple-darwin/bin/codex");
4170 std::fs::write(
4172 standalone.with_file_name("package.json"),
4173 r#"{"name": "@openai/codex", "version": "9.9.9"}"#,
4174 )
4175 .unwrap();
4176 let link = root.join("bin/codex");
4177 std::fs::create_dir_all(link.parent().unwrap()).unwrap();
4178 testing::link(&standalone, &link).expect("a link");
4179 assert_eq!(codex_version(&link).as_deref(), Some("0.154.0"));
4180
4181 let cask = place("Caskroom/codex/0.153.2/codex-aarch64-apple-darwin");
4182 assert_eq!(codex_version(&cask).as_deref(), Some("0.153.2"));
4183
4184 let npm = place("lib/node_modules/@openai/codex/bin/codex.js");
4185 std::fs::write(
4186 root.join("lib/node_modules/@openai/codex/package.json"),
4187 r#"{"name": "@openai/codex", "version": "0.150.1"}"#,
4188 )
4189 .unwrap();
4190 assert_eq!(codex_version(&npm).as_deref(), Some("0.150.1"));
4191
4192 let shim = place("volta/bin/volta-shim");
4194 std::fs::write(
4195 root.join("package.json"),
4196 r#"{"name": "@openai/codex", "version": "1.2.3"}"#,
4197 )
4198 .unwrap();
4199 assert_eq!(codex_version(&shim), None);
4200 }
4201
4202 #[test]
4203 fn a_codex_login_anybody_can_read_or_nobody_can_parse_is_said() {
4204 let mut codex = with_codex();
4205 codex.auth_access = Some(mode(0o644));
4206 let checks = codex_checks(&codex);
4207 let file = check(&checks, "codex_auth_file");
4208 assert_eq!(file.level, Level::Warn);
4209 assert!(file.detail.contains("mode 644"), "{}", file.detail);
4210 assert!(file.advice.contains("chmod 600"), "{}", file.advice);
4211
4212 codex.auth_access = Some(mode(0o600));
4213 codex.login = Err(CodexLoginTrouble::Unusable(
4214 "its id token is not readable".into(),
4215 ));
4216 assert_eq!(
4217 check(&codex_checks(&codex), "codex_login").level,
4218 Level::Warn
4219 );
4220 codex.enrolled = 2;
4221 let checks = codex_checks(&codex);
4222 let login = check(&checks, "codex_login");
4223 assert_eq!(login.level, Level::Fail);
4224 assert!(login.detail.contains("not readable"), "{}", login.detail);
4225 }
4226
4227 #[test]
4231 fn running_codex_processes_are_reported_as_a_fact() {
4232 use crate::holder::classify;
4233 use crate::host::Process;
4234 let at = |pid: u32, path: &str| Process {
4235 pid,
4236 path: PathBuf::from(path),
4237 };
4238 let holders = crate::provider::codex::holders::HOLDERS;
4239 let mut codex = with_codex();
4240 codex.running = Some(classify(&[at(4321, "codex"), at(99, "codex")], holders));
4241 let checks = codex_checks(&codex);
4242 let running = check(&checks, "codex_running");
4243 assert_eq!(running.level, Level::Ok);
4244 assert!(
4245 running
4246 .detail
4247 .starts_with("2 `codex` sessions (pid 4321, 99)"),
4248 "{}",
4249 running.detail
4250 );
4251 assert!(
4252 running.detail.contains("started again"),
4253 "{}",
4254 running.detail
4255 );
4256 assert_eq!(
4257 running.advice,
4258 "Quit them and start again to take a switch."
4259 );
4260
4261 let many: Vec<Process> = (1..=23).map(|pid| at(pid, "codex")).collect();
4262 codex.running = Some(classify(&many, holders));
4263 let detail = check(&codex_checks(&codex), "codex_running").detail.clone();
4264 assert!(detail.starts_with("23 `codex` sessions"), "{detail}");
4265 assert!(detail.contains("1, 2, 3 and 20 more"), "{detail}");
4266
4267 codex.running = Some(classify(
4268 &[at(
4269 7,
4270 "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/\
4271 Contents/MacOS/codex",
4272 )],
4273 holders,
4274 ));
4275 let checks = codex_checks(&codex);
4276 let app = check(&checks, "codex_running");
4277 assert!(
4278 app.detail.starts_with("the ChatGPT app (pid 7)"),
4279 "{}",
4280 app.detail
4281 );
4282 assert_eq!(
4283 app.advice,
4284 "Quit ChatGPT with Command-Q and open it again to take a switch."
4285 );
4286
4287 codex.running = None;
4288 assert!(
4289 check(&codex_checks(&codex), "codex_running")
4290 .detail
4291 .contains("could not tell")
4292 );
4293 }
4294
4295 #[test]
4301 fn doctor_and_a_switch_see_the_same_codex_running() {
4302 use crate::host::memory::MemoryHost;
4303 let host = MemoryHost::new();
4304 host.runs_at(
4305 "codex",
4306 &[
4307 "codex",
4308 "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/\
4309 MacOS/codex",
4310 ],
4311 );
4312 let ctx =
4313 Context::new(std::env::temp_dir()).with_memory_stores(std::sync::Arc::clone(&host));
4314 let seen = running_codex(&ctx).expect("readable");
4315 assert_eq!(
4316 seen.iter().map(|h| h.holder.kind).collect::<Vec<_>>(),
4317 ["chatgpt_app", "session"]
4318 );
4319 assert_eq!(
4320 switch::StillHolding::These(seen),
4321 switch::still_holding(&ctx, ProviderId::Codex)
4322 );
4323
4324 host.runs_at("codex", &[]);
4325 assert_eq!(running_codex(&ctx), Some(Vec::new()));
4326 assert_eq!(
4327 switch::still_holding(&ctx, ProviderId::Codex),
4328 switch::StillHolding::Nothing
4329 );
4330
4331 host.without_a_process_list();
4332 assert_eq!(running_codex(&ctx), None);
4333 assert_eq!(
4334 switch::still_holding(&ctx, ProviderId::Codex),
4335 switch::StillHolding::Unknown
4336 );
4337 }
4338
4339 #[test]
4340 fn every_codex_failure_and_warning_tells_the_user_something() {
4341 for backend in ["file", "keyring", "auto", "ephemeral", "secrets", "unknown"] {
4342 let codex = CodexFacts {
4343 backend,
4344 enrolled: 1,
4345 auth_access: Some(mode(0o666)),
4346 login: Err(CodexLoginTrouble::Unusable("unreadable".into())),
4347 ..with_codex()
4348 };
4349 for c in codex_checks(&codex) {
4350 assert!(c.code.starts_with("codex_"), "{}", c.code);
4351 if c.level != Level::Ok {
4352 assert!(!c.advice.is_empty(), "{} has no advice", c.code);
4353 }
4354 }
4355 }
4356 }
4357
4358 #[test]
4360 fn a_codex_login_is_redacted_from_a_report() {
4361 let mut f = facts();
4362 f.codex = with_codex();
4363 let ctx = Context::new(PathBuf::from("/home/x"));
4364 let sheet = redaction_for(&ctx, &f);
4365 let login = check(&evaluate(&f), "codex_login").detail.clone();
4366 let hidden = sheet.over(&login);
4367 for secret in ["w@example.com", "work-account", "0123456789abcdef"] {
4368 assert!(login.contains(secret), "{login}");
4369 assert!(!hidden.contains(secret), "{hidden}");
4370 }
4371 }
4372
4373 #[test]
4376 fn codex_facts_are_read_off_the_disk() {
4377 use crate::host::fs::testing;
4378
4379 let root = std::env::temp_dir().join(format!(
4380 "pitboard-doctor-codex-{}-{:?}",
4381 std::process::id(),
4382 std::thread::current().id()
4383 ));
4384 let _ = std::fs::remove_dir_all(&root);
4385 struct Scratch(std::path::PathBuf);
4386 impl Drop for Scratch {
4387 fn drop(&mut self) {
4388 let _ = std::fs::remove_dir_all(&self.0);
4389 }
4390 }
4391 let _guard = Scratch(root.clone());
4392 let home = root.join("codex");
4393 let program = root.join("bin/codex");
4396 let ctx = Context::new(root.clone())
4397 .with_pitboard_home(root.join(".pitboard"))
4398 .with_codex_home(home.to_string_lossy().into())
4399 .with_codex_program(program.clone());
4400
4401 let absent = codex_facts(&ctx, None);
4402 assert!(!absent.present);
4403 assert_eq!(absent.backend, "file");
4404 assert_eq!(absent.auth_access, None);
4405 assert!(matches!(absent.login, Ok(None)));
4406 assert_eq!(absent.program, None, "the one named, and it is not there");
4407 assert_eq!(absent.version, None);
4408
4409 let installed = root.join("releases/0.154.0-aarch64-apple-darwin/bin/codex");
4410 std::fs::create_dir_all(installed.parent().unwrap()).unwrap();
4411 std::fs::write(&installed, "").unwrap();
4412 testing::make_runnable(&installed).expect("runnable");
4414 std::fs::create_dir_all(program.parent().unwrap()).unwrap();
4415 testing::link(&installed, &program).expect("a link");
4416 let found = codex_facts(&ctx, None);
4417 assert_eq!(found.program.as_deref(), Some(program.as_path()));
4418 assert_eq!(found.version.as_deref(), Some("0.154.0"));
4419
4420 std::fs::create_dir_all(&home).expect("a Codex home");
4421 let auth = home.join("auth.json");
4422 std::fs::write(
4423 &auth,
4424 json!({
4425 "auth_mode": "chatgpt",
4426 "tokens": {
4427 "id_token": crate::provider::jwt::unsigned(&json!({
4428 "email": "w@example.com",
4429 "https://api.openai.com/auth": {"chatgpt_account_id": "work-acc"},
4430 })),
4431 "access_token": "a",
4432 "refresh_token": "r",
4433 "account_id": "work-acc",
4434 },
4435 })
4436 .to_string(),
4437 )
4438 .expect("a login");
4439 testing::open_to_others(&auth).expect("opened to others");
4440 let found = codex_facts(&ctx, None);
4441 assert!(found.present);
4442 assert!(found.auth_access.expect("there").shared);
4443 let login = found.login.expect("readable").expect("there");
4444 assert_eq!(login.email, "w@example.com");
4445 assert_eq!(login.fingerprint.len(), 16);
4446
4447 std::fs::write(
4449 &auth,
4450 json!({"auth_mode": "apikey", "OPENAI_API_KEY": "sk-not-a-real-key"}).to_string(),
4451 )
4452 .unwrap();
4453 match codex_facts(&ctx, None).login {
4454 Err(CodexLoginTrouble::NotAnAccount(why)) => {
4455 assert!(why.contains("API key"), "{why}");
4456 assert!(!why.contains("sk-"), "never the key itself: {why}");
4457 }
4458 Err(other) => panic!("an API key is not an account: {other:?}"),
4459 Ok(_) => panic!("an API key is not an account"),
4460 }
4461
4462 let mixed = json!({
4465 "auth_mode": "chatgpt",
4466 "tokens": {
4467 "id_token": crate::provider::jwt::unsigned(&json!({
4468 "email": "w@example.com",
4469 "https://api.openai.com/auth": {"chatgpt_account_id": "work-acc"},
4470 })),
4471 "access_token": "a",
4472 "refresh_token": "r",
4473 "account_id": "home-acc",
4474 },
4475 });
4476 std::fs::write(&auth, mixed.to_string()).unwrap();
4477 assert!(
4478 matches!(
4479 codex_facts(&ctx, None).login,
4480 Err(CodexLoginTrouble::Unusable(_))
4481 ),
4482 "a login mixing two accounts is one Pitboard cannot use"
4483 );
4484
4485 std::fs::write(
4486 home.join("config.toml"),
4487 "cli_auth_credentials_store = \"ephemeral\"\n",
4488 )
4489 .expect("a config");
4490 let ephemeral = codex_facts(&ctx, None);
4491 assert_eq!(ephemeral.backend, "ephemeral");
4492 assert!(
4493 matches!(ephemeral.login, Ok(None)),
4494 "a store Pitboard does not handle is not read"
4495 );
4496
4497 std::fs::write(
4500 home.join("config.toml"),
4501 "cli_auth_credentials_store = \"auto\"\n[features]\nsecret_auth_storage = true\n",
4502 )
4503 .expect("a config");
4504 let secrets = codex_facts(&ctx, None);
4505 assert_eq!(secrets.backend, "secrets");
4506 let config = home.join("config.toml");
4507 assert_eq!(
4508 secrets.backend_setting,
4509 format!(
4510 "`cli_auth_credentials_store = \"auto\"` in {config}, with \
4511 `secret_auth_storage` in {config}",
4512 config = config.display()
4513 )
4514 );
4515 assert_eq!(
4516 secrets.backend_remedy,
4517 format!(
4518 "To use the file store, set `cli_auth_credentials_store = \"file\"` in \
4519 {}, then sign in again with `codex login`",
4520 config.display()
4521 )
4522 );
4523 }
4524
4525 #[test]
4526 fn a_version_is_read_out_of_the_path_codex_is_installed_at() {
4527 assert!(looks_like_a_version("0.154.0"));
4528 assert!(!looks_like_a_version("releases"));
4529 assert!(!looks_like_a_version("0.154"));
4530 assert!(!looks_like_a_version("v0.154.0"));
4531 }
4532
4533 #[test]
4536 fn a_machine_with_only_codex_is_not_judged_on_claude_code() {
4537 let mut facts = facts();
4538 facts.claude_present = false;
4539 facts.config = Err(crate::error::Error::ClaudeConfigMissing {
4540 path: PathBuf::from("/nowhere/.claude.json"),
4541 });
4542 facts.codex.present = true;
4543 let checks = evaluate(&facts);
4544 for own in CLAUDE_CODES_OWN {
4545 assert!(
4546 checks.iter().all(|c| c.code != *own),
4547 "{own} is about Claude Code, which is not here"
4548 );
4549 }
4550 assert!(
4551 checks.iter().any(|c| c.code == "state"),
4552 "Pitboard's own still is"
4553 );
4554 assert!(checks.iter().any(|c| c.code.starts_with("codex_")));
4555 }
4556
4557 #[test]
4559 fn a_machine_with_neither_tool_still_hears_about_claude_code() {
4560 let mut facts = facts();
4561 facts.claude_present = false;
4562 let checks = evaluate(&facts);
4563 assert!(checks.iter().any(|c| c.code == "config_file"));
4564 }
4565
4566 #[test]
4570 fn doctor_checks_nothing_under_a_home_that_is_not_a_full_path() {
4571 use crate::context::Environment;
4572 for (pairs, said) in [
4573 (&[("HOME", "")][..], "HOME is empty"),
4574 (
4575 &[("HOME", "/Users/x"), ("CODEX_HOME", "codex")],
4576 "CODEX_HOME is `codex`, which is not a full path",
4577 ),
4578 ] {
4579 let env: Environment = pairs.iter().copied().collect();
4580 let ctx = Context::for_command_line(&env)
4581 .with_memory_stores(crate::host::memory::MemoryHost::new());
4582 let diagnosis = run(&ctx);
4583 let checks: Vec<(&str, Level, &str)> = diagnosis
4584 .checks
4585 .iter()
4586 .map(|c| (c.code, c.level, c.detail.as_str()))
4587 .collect();
4588 assert_eq!(checks, [("homes", Level::Fail, said)], "{pairs:?}");
4589 assert!(
4590 diagnosis.checks[0]
4591 .advice
4592 .starts_with("Set it to a full path")
4593 );
4594 assert_eq!(diagnosis.environment, json!({}));
4595 }
4596 }
4597
4598 #[test]
4600 fn a_signed_out_credential_is_said_to_be_one() {
4601 let mut facts = facts();
4602 facts.credential = Ok(Some(serde_json::json!({"mcpOAuth": {"server": {}}})));
4603 let check = judge_credential(&facts);
4604 assert!(matches!(check.level, Level::Warn), "{}", check.detail);
4605 assert!(check.detail.contains("signed out"), "{}", check.detail);
4606 }
4607}