1use crate::context::Context;
11use crate::error::Error;
12use crate::provider::ProviderId;
13use crate::provider::claude::daemon;
14use crate::provider::claude::live as claude_live;
15use crate::provider::claude::paths as claude;
16use crate::provider::claude::slot;
17use crate::provider::codex::paths as codex;
18use crate::state::{Park, State};
19use crate::{home, park, store, switch, time, usage};
20use serde_json::{Value, json};
21use std::path::PathBuf;
22
23#[derive(Debug, PartialEq, Clone, Copy)]
24pub enum Level {
25 Ok,
26 Warn,
27 Fail,
28}
29
30pub struct Check {
31 pub code: &'static str,
33 pub name: String,
34 pub level: Level,
35 pub detail: String,
36 pub advice: String,
38}
39
40#[non_exhaustive]
43pub struct Facts {
44 pub security_tool: Option<String>,
45 pub config_path: PathBuf,
46 pub config: Result<Value, Error>,
47 pub identity: Option<claude::Identity>,
48 pub service: String,
49 pub account: String,
50 pub default_slot: bool,
51 pub storage_dir: String,
52 pub backend: Result<store::Backend, store::Error>,
53 pub credential_file: PathBuf,
54 pub credential: Result<Option<Value>, store::Error>,
55 pub credential_cost: Option<store::Cost>,
57 pub credential_parts: Vec<(String, usize)>,
59 pub home: PathBuf,
60 pub home_mode: Option<u32>,
61 pub readable_by_others: Vec<(String, u32)>,
65 pub machine_id_known: bool,
66 pub hover_rest_env: bool,
68 pub daemon: Option<daemon::Daemon>,
70 pub pending_parks: Vec<String>,
72 pub claude_version: Option<String>,
74 pub auth_overrides: Vec<crate::settings::Override>,
77 pub asking_held: Vec<(String, i64)>,
79 pub state: Result<State, Error>,
80 pub parks: Vec<ParkFact>,
82 pub interrupted: bool,
83 pub codex: CodexFacts,
85 pub claude_present: bool,
89 pub schedule: Option<ScheduleFact>,
91 pub now: i64,
92}
93
94pub struct ScheduleFact {
97 pub path: PathBuf,
99 pub program: Option<PathBuf>,
101 pub program_found: bool,
103}
104
105pub struct CodexFacts {
111 pub home: PathBuf,
113 pub present: bool,
115 pub enrolled: usize,
117 pub backend: &'static str,
122 pub auth_file: PathBuf,
124 pub auth_mode: Option<u32>,
126 pub login: Result<Option<CodexLogin>, CodexLoginTrouble>,
129 pub program: Option<PathBuf>,
131 pub version: Option<String>,
134 pub running: Option<Vec<crate::holder::Holding>>,
137}
138
139pub struct CodexLogin {
142 pub email: String,
143 pub account_id: String,
144 pub fingerprint: String,
146}
147
148#[derive(Debug)]
154pub enum CodexLoginTrouble {
155 NotAnAccount(String),
158 Unusable(String),
160}
161
162pub struct ParkFact {
163 pub provider: ProviderId,
166 pub label: String,
167 pub name: String,
170 pub active: bool,
171 pub last_used_at: Option<i64>,
173 pub park: Option<Park>,
174 pub unreadable: Option<String>,
176}
177
178impl ParkFact {
179 fn typed(&self) -> String {
181 self.name.clone()
182 }
183}
184
185fn park_facts(ctx: &Context, state: &State) -> Vec<ParkFact> {
186 let recorded: std::collections::BTreeMap<ProviderId, Option<String>> = ProviderId::ALL
191 .iter()
192 .filter(|&&which| state.accounts.iter().any(|a| a.provider() == which))
193 .map(|&which| {
194 let found = crate::provider::of(which).recorded_identity(ctx);
195 (which, found.map(|id| id.account_id))
196 })
197 .collect();
198 state
199 .accounts
200 .iter()
201 .map(|a| ParkFact {
202 provider: a.provider(),
203 label: a.label.clone(),
204 name: state.typed(&a.key()),
205 last_used_at: a.last_used_at,
206 active: match recorded.get(&a.provider()).and_then(Option::as_deref) {
209 Some(uuid) => a.account_uuid == uuid,
210 None => state.active_for(a.provider()) == Some(a.label.as_str()),
211 },
212 park: a.parked.clone(),
213 unreadable: a.parked.as_ref().and_then(|p| {
214 park::load(ctx, &a.key(), p).err().map(|e| match e {
215 Error::ParkedCredentialMissing { .. } => "missing from the vault".into(),
216 Error::ParkedCredentialCorrupt { detail, .. } => detail,
217 other => other.to_string(),
218 })
219 }),
220 })
221 .collect()
222}
223
224pub fn gather(ctx: &Context) -> Facts {
225 let config = claude::load_config(ctx);
226 let state = crate::state::load(ctx);
227 let service = claude::live_service(ctx);
228 let home = home::dir(ctx);
229 let identity = config.as_ref().ok().and_then(claude::identity);
230 Facts {
231 security_tool: cfg!(target_os = "macos")
232 .then(|| store::SECURITY.to_string())
233 .filter(|p| std::fs::metadata(p).is_ok()),
234 config_path: claude::config_file(ctx),
235 identity: identity.clone(),
236 config,
237 account: slot::account_name(ctx),
238 default_slot: claude::is_default_slot(ctx),
239 storage_dir: claude::storage_dir(ctx),
240 backend: store::resolve(&claude_live::chain(ctx), &service),
241 credential_file: claude_live::credential_file(ctx),
242 credential_cost: store::read_raw(&claude_live::chain(ctx), &service)
243 .ok()
244 .flatten()
245 .and_then(|raw| store::cost(&claude_live::chain(ctx), &service, &raw)),
246 credential_parts: store::read(&claude_live::chain(ctx), &service)
247 .ok()
248 .flatten()
249 .map(|doc| parts_of(&doc))
250 .unwrap_or_default(),
251 credential: store::read(&claude_live::chain(ctx), &service),
252 home_mode: mode_of(&home),
253 readable_by_others: loose_logins(ctx),
254 home,
255 machine_id_known: crate::state::machine_id() != "unknown",
256 hover_rest_env: ctx.hover_rest,
257 daemon: daemon::read(ctx),
258 pending_parks: crate::pending::outstanding(ctx),
259 claude_version: claude::installed_version(ctx),
260 auth_overrides: crate::settings::overrides(ctx),
261 asking_held: crate::budget::holds(ctx),
262 parks: state
263 .as_ref()
264 .map(|s| park_facts(ctx, s))
265 .unwrap_or_default(),
266 codex: codex_facts(ctx, state.as_ref().ok()),
267 claude_present: claude::config_file(ctx).exists()
268 || claude::program(ctx).is_some()
269 || store::read_raw(&claude_live::chain(ctx), &service)
270 .is_ok_and(|found| found.is_some())
271 || state.as_ref().is_ok_and(|s| {
272 s.accounts
273 .iter()
274 .any(|a| a.provider() == ProviderId::Claude)
275 }),
276 state,
277 interrupted: switch::interrupted(ctx),
278 service,
279 schedule: schedule_fact(ctx),
280 now: ctx.now(),
281 }
282}
283
284fn schedule_fact(ctx: &Context) -> Option<ScheduleFact> {
286 if !crate::schedule::serves(ctx) {
287 return None;
288 }
289 let crate::schedule::Installed::Yes { path, .. } = crate::schedule::status(ctx) else {
290 return None;
291 };
292 let program = crate::schedule::installed_program(ctx);
293 Some(ScheduleFact {
294 program_found: program.as_deref().is_some_and(std::path::Path::is_file),
295 program,
296 path,
297 })
298}
299
300fn parts_of(document: &Value) -> Vec<(String, usize)> {
307 let weigh = |value: &Value| serde_json::to_string(value).map(|s| s.len()).unwrap_or(0);
308 let Some(root) = document.as_object() else {
309 return Vec::new();
310 };
311 let mut parts: Vec<(String, usize)> = root
312 .iter()
313 .flat_map(|(key, value)| match (key.as_str(), value.as_object()) {
314 ("mcpOAuth", Some(servers)) if servers.len() > 1 => servers
316 .iter()
317 .map(|(server, held)| (format!("mcpOAuth {server}"), weigh(held)))
318 .collect(),
319 _ => vec![(key.clone(), weigh(value))],
320 })
321 .collect();
322 parts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
323 parts
324}
325
326fn mode_of(path: &std::path::Path) -> Option<u32> {
327 use std::os::unix::fs::PermissionsExt;
328 Some(std::fs::metadata(path).ok()?.permissions().mode() & 0o777)
329}
330
331fn loose_logins(ctx: &Context) -> Vec<(String, u32)> {
340 const SHARED: u32 = 0o077;
342 let mut loose = Vec::new();
343 let mut look = |path: std::path::PathBuf| {
344 if let Some(mode) = mode_of(&path)
345 && mode & SHARED != 0
346 {
347 loose.push((path.display().to_string(), mode));
348 }
349 };
350 look(claude_live::credential_file(ctx));
351 let vault = store::vault_dir(ctx);
352 look(vault.clone());
353 if let Ok(entries) = std::fs::read_dir(&vault) {
354 let mut parks: Vec<std::path::PathBuf> = entries.flatten().map(|e| e.path()).collect();
355 parks.sort();
356 for park in parks {
357 look(park);
358 }
359 }
360 loose
361}
362
363fn codex_facts(ctx: &Context, state: Option<&State>) -> CodexFacts {
366 let backend = codex::backend(ctx);
367 let auth_file = codex::auth_file(ctx);
368 let home = codex::home(ctx);
369 let program = crate::provider::program_of(ctx, ProviderId::Codex);
373 CodexFacts {
374 present: home.is_dir(),
375 enrolled: state.map_or(0, |s| {
376 s.accounts
377 .iter()
378 .filter(|a| a.provider() == ProviderId::Codex)
379 .count()
380 }),
381 backend: backend_name(backend),
382 auth_mode: mode_of(&auth_file),
383 login: if backend == codex::Backend::File {
387 codex_login(ctx)
388 } else {
389 Ok(None)
390 },
391 version: program.as_deref().and_then(codex_version),
392 program,
393 running: running_codex(ctx),
394 auth_file,
395 home,
396 }
397}
398
399fn backend_name(backend: codex::Backend) -> &'static str {
405 match backend {
406 codex::Backend::File => "file",
407 codex::Backend::Keyring => "keyring",
408 codex::Backend::Either => "auto",
409 codex::Backend::Ephemeral => "ephemeral",
410 codex::Backend::Secrets => "secrets",
414 }
415}
416
417fn codex_login(ctx: &Context) -> Result<Option<CodexLogin>, CodexLoginTrouble> {
419 let tool = crate::provider::of(ProviderId::Codex);
420 let unusable = |e: crate::provider::ProviderError| CodexLoginTrouble::Unusable(e.to_string());
421 let Some(credential) = tool.read_live(ctx).map_err(unusable)? else {
422 return Ok(None);
423 };
424 tool.slice(&credential.raw).map_err(|e| match e {
426 crate::provider::ProviderError::Unsupported { reason, .. } => {
427 CodexLoginTrouble::NotAnAccount(reason)
428 }
429 other => unusable(other),
430 })?;
431 let found = tool.identify(ctx, &credential).map_err(unusable)?;
432 Ok(Some(CodexLogin {
433 email: found.email,
434 account_id: found.account_id,
435 fingerprint: tool.fingerprint(&credential.raw),
436 }))
437}
438
439fn codex_version(program: &std::path::Path) -> Option<String> {
449 let resolved = std::fs::canonicalize(program).ok()?;
450 let near: Vec<&std::path::Path> = resolved.ancestors().skip(1).take(2).collect();
451 near.iter()
452 .find_map(|dir| {
453 dir.file_name()
454 .and_then(|n| n.to_str())
455 .and_then(|name| name.split('-').next())
456 .filter(|leading| looks_like_a_version(leading))
457 .map(str::to_owned)
458 })
459 .or_else(|| {
460 near.iter()
461 .find_map(|dir| codex_package_version(&dir.join("package.json")))
462 })
463}
464
465fn looks_like_a_version(name: &str) -> bool {
466 let parts: Vec<&str> = name.split('.').collect();
467 parts.len() == 3
468 && parts
469 .iter()
470 .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
471}
472
473fn codex_package_version(path: &std::path::Path) -> Option<String> {
474 let json: Value = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
475 if json.get("name")?.as_str()? != "@openai/codex" {
476 return None;
477 }
478 Some(json.get("version")?.as_str()?.to_string())
479}
480
481fn running_codex(ctx: &Context) -> Option<Vec<crate::holder::Holding>> {
484 match crate::provider::of(ProviderId::Codex).adoption() {
485 crate::provider::Adoption::RestartRequired { program, holders } => {
486 crate::holder::find(ctx, program, holders)
487 }
488 crate::provider::Adoption::PollingWithin(_) => Some(Vec::new()),
489 }
490}
491
492fn ok(code: &'static str, name: impl Into<String>, detail: impl Into<String>) -> Check {
493 Check {
494 code,
495 name: name.into(),
496 level: Level::Ok,
497 detail: detail.into(),
498 advice: String::new(),
499 }
500}
501fn warn(
502 code: &'static str,
503 name: impl Into<String>,
504 detail: impl Into<String>,
505 advice: impl Into<String>,
506) -> Check {
507 Check {
508 code,
509 name: name.into(),
510 level: Level::Warn,
511 detail: detail.into(),
512 advice: advice.into(),
513 }
514}
515fn fail(
516 code: &'static str,
517 name: impl Into<String>,
518 detail: impl Into<String>,
519 advice: impl Into<String>,
520) -> Check {
521 Check {
522 code,
523 name: name.into(),
524 level: Level::Fail,
525 detail: detail.into(),
526 advice: advice.into(),
527 }
528}
529
530pub fn evaluate(facts: &Facts) -> Vec<Check> {
531 let mut checks = Vec::new();
532 let codex_here = facts.codex.present || facts.codex.enrolled > 0;
536 let claude_here = facts.claude_present || !codex_here;
537
538 if cfg!(target_os = "macos") {
539 checks.push(match &facts.security_tool {
540 Some(path) => ok("security_tool", "security tool", path.clone()),
541 None => fail(
542 "security_tool",
543 "security tool",
544 format!("{} is missing", store::SECURITY),
545 "pitboard reads the keychain the same way Claude Code does. Without it, nothing works.",
546 ),
547 });
548 }
549
550 checks.push(match &facts.config {
551 Ok(v) => ok(
552 "config_file",
553 "config file",
554 format!(
555 "{} ({} keys)",
556 facts.config_path.display(),
557 v.as_object().map_or(0, serde_json::Map::len)
558 ),
559 ),
560 Err(e) => fail(
561 "config_file",
562 "config file",
563 e.to_string(),
564 "Run `claude` once.",
565 ),
566 });
567
568 checks.push(match &facts.identity {
569 Some(id) => ok(
570 "identity",
571 "identity",
572 format!("{} · org {}", id.email, id.organization_uuid),
573 ),
574 None => warn(
575 "identity",
576 "identity",
577 "Claude Code has not recorded a signed-in account",
578 "It writes this after its first successful call. Run `claude` once.",
579 ),
580 });
581
582 checks.push(ok(
583 "slot",
584 "slot",
585 if facts.default_slot {
586 format!(
587 "default · {} · account {}",
588 facts.service, facts.account
589 )
590 } else {
591 format!(
592 "{} · account {} (selected by {})",
593 facts.service, facts.account, facts.storage_dir
594 )
595 },
596 ));
597
598 if let Some(price) = facts.credential_cost {
601 let (bytes, limit) = (price.needs, price.limit);
602 checks.push(if price.refused() {
603 fail(
606 "credential_size",
607 "login size",
608 format!(
609 "{bytes} of {limit} bytes, and PITBOARD_NO_ARGV refuses that{}",
610 biggest(&facts.credential_parts)
611 ),
612 "There is no third way to write a login this size. Sign out of MCP servers \
613 you no longer use to make it smaller, or unset PITBOARD_NO_ARGV and accept \
614 the argument-line write.",
615 )
616 } else if price.over() {
617 warn(
621 "credential_size",
622 "login size",
623 format!(
624 "{bytes} of {limit} bytes: written on the argument line{}",
625 biggest(&facts.credential_parts)
626 ),
627 "A login this size can only be written by passing it as an argument, where \
628 a process running as you could read it while the call lasts. Signing out \
629 of MCP servers you no longer use makes it smaller; PITBOARD_NO_ARGV=1 \
630 refuses the switch instead.",
631 )
632 } else {
633 ok(
634 "credential_size",
635 "login size",
636 format!("{bytes} of {limit} bytes"),
637 )
638 });
639 }
640
641 checks.push(match &facts.backend {
642 Ok(store::Backend::Keychain) => ok("credential_store", "credential store", "keychain"),
643 Ok(store::Backend::File) => {
644 let detail = format!("plaintext file · {}", facts.credential_file.display());
645 if cfg!(target_os = "macos") {
646 warn(
647 "credential_store",
648 "credential store",
649 detail,
650 "Claude Code fell back to a file, which means a keychain write failed at some point.",
651 )
652 } else {
653 ok("credential_store", "credential store", detail)
654 }
655 }
656 Ok(store::Backend::Absent) if facts.identity.is_some() => fail(
661 "credential_store",
662 "credential store",
663 format!(
664 "Claude Code's config says {} is signed in, and no store pitboard reads \
665 holds that login",
666 facts
667 .identity
668 .as_ref()
669 .map(|i| i.email.as_str())
670 .unwrap_or("somebody")
671 ),
672 "pitboard will not write a login where nobody reads it. Check for a pitboard \
673 update; if there is none, this is worth reporting.",
674 ),
675 Ok(store::Backend::Absent) => warn(
676 "credential_store",
677 "credential store",
678 "no credential in any backend",
679 "Nothing is signed in for this slot.",
680 ),
681 Err(e) => fail(
682 "credential_store",
683 "credential store",
684 e.to_string(),
685 "Treat this as unknown, never as empty.",
686 ),
687 });
688
689 checks.push(judge_credential(facts));
690
691 checks.push(
692 match (
693 facts
694 .config
695 .as_ref()
696 .ok()
697 .and_then(usage::from_config_cache),
698 &facts.identity,
699 ) {
700 (Some(s), Some(id)) if s.account_uuid.as_deref() == Some(id.account_uuid.as_str()) => {
701 ok(
702 "usage_cache",
703 "usage cache",
704 format!("{} windows, measured for this account", s.windows.len()),
705 )
706 }
707 (Some(_), Some(_)) => warn(
708 "usage_cache",
709 "usage cache",
710 "cached for a different account",
711 "Its numbers are ignored rather than shown, which is why status may look empty.",
712 ),
713 (Some(s), None) => ok(
714 "usage_cache",
715 "usage cache",
716 format!("{} windows", s.windows.len()),
717 ),
718 (None, _) => ok(
719 "usage_cache",
720 "usage cache",
721 "absent; Claude Code writes it after a call that reports usage",
722 ),
723 },
724 );
725
726 checks.push(match facts.home_mode {
727 None => ok(
728 "home",
729 "pitboard home",
730 format!("{} (not created yet)", facts.home.display()),
731 ),
732 Some(0o700) => ok("home", "pitboard home", facts.home.display().to_string()),
733 Some(mode) => warn(
734 "home",
735 "pitboard home",
736 format!("{} is mode {mode:o}", facts.home.display()),
737 format!(
738 "Park names contain account identifiers, so only you should read it: \
739 `chmod 700 {}`.",
740 facts.home.display()
741 ),
742 ),
743 });
744
745 checks.push(if facts.readable_by_others.is_empty() {
746 ok(
747 "private_on_disk",
748 "logins on disk",
749 "nothing holding a login is readable by anyone else",
750 )
751 } else {
752 let names: Vec<String> = facts
753 .readable_by_others
754 .iter()
755 .map(|(path, mode)| format!("{path} is mode {mode:o}"))
756 .collect();
757 fail(
758 "private_on_disk",
759 "logins on disk",
760 names.join("; "),
761 format!(
762 "These hold usable OAuth tokens in plain text, which is how Claude Code stores them where there is no keychain. Anyone else on this machine can read them: `chmod go-rwx {}`.",
763 facts
764 .readable_by_others
765 .iter()
766 .map(|(path, _)| path.as_str())
767 .collect::<Vec<_>>()
768 .join(" ")
769 ),
770 )
771 });
772
773 checks.push(match &facts.state {
774 Ok(state) => ok(
775 "state",
776 "accounts",
777 match state.accounts.len() {
778 0 => "none enrolled yet".to_string(),
779 1 => "1 enrolled".to_string(),
780 n => format!("{n} enrolled"),
781 },
782 ),
783 Err(e) => fail(
784 "state",
785 "accounts",
786 e.to_string(),
787 match e {
788 Error::StateWrongMachine { .. } => {
790 "Run `pitboard adopt` to keep these accounts on this computer. The \
791 logins they came with are dropped, because a login belongs to the \
792 computer that signed in."
793 }
794 _ => "pitboard will not switch until its account list can be read.",
795 },
796 ),
797 });
798 let (claude_parks, codex_parks): (Vec<&ParkFact>, Vec<&ParkFact>) = facts
801 .parks
802 .iter()
803 .partition(|p| p.provider == ProviderId::Claude);
804 checks.extend(claude_parks.iter().map(|p| judge_park(p, facts.now)));
805 if facts.interrupted {
806 checks.push(warn(
807 "interrupted_switch",
808 "interrupted switch",
809 "a switch did not finish",
810 "The next `pitboard use`, `enroll` or `forget` finishes it before anything else.",
811 ));
812 }
813 if let Ok(state) = &facts.state
814 && !state.discarded.is_empty()
815 {
816 checks.push(warn(
817 "discarded",
818 "old parked logins",
819 format!("{} waiting to be deleted", state.discarded.len()),
820 "pitboard deletes them on its next change; if they stay, check the keychain is unlocked.",
821 ));
822 }
823
824 if !facts.machine_id_known {
825 checks.push(warn(
826 "machine_id",
827 "machine id",
828 "this machine has no stable identifier",
829 "pitboard cannot tell this machine from another that also lacks one, so it cannot \
830 refuse state copied between them. Never copy ~/.pitboard between machines.",
831 ));
832 }
833
834 checks.push(judge_storage_v5(facts));
835 checks.push(judge_daemon(facts));
836 checks.push(judge_pending(facts));
837 checks.extend(judge_schedule(facts));
838 checks.push(judge_claude_version(facts));
839 checks.push(judge_auth(facts));
840 checks.push(judge_asking(facts));
841 checks.extend(
842 claude_parks
843 .iter()
844 .filter_map(|p| judge_dormant(p, facts.now)),
845 );
846 if codex_here || !codex_parks.is_empty() {
849 checks.extend(judge_codex(&facts.codex, &codex_parks, facts.now));
850 }
851 if !claude_here {
852 checks.retain(|check| !CLAUDE_CODES_OWN.contains(&check.code));
853 }
854 checks
855}
856
857const CLAUDE_CODES_OWN: &[&str] = &[
860 "config_file",
861 "identity",
862 "slot",
863 "credential_size",
864 "credential_store",
865 "credential",
866 "usage_cache",
867 "storage_v5",
868 "daemon",
869 "claude_version",
870 "auth",
871];
872
873fn account_code(provider: ProviderId, claude: &'static str, codex: &'static str) -> &'static str {
877 match provider {
878 ProviderId::Claude => claude,
879 ProviderId::Codex => codex,
880 }
881}
882
883const A_TOKEN_LIFETIME: i64 = 30 * 86_400;
887
888fn judge_dormant(park: &ParkFact, now: i64) -> Option<Check> {
896 if park.active || park.park.is_none() {
897 return None;
898 }
899 let dormant_for = now - park.last_used_at?;
900 if dormant_for < A_TOKEN_LIFETIME {
901 return None;
902 }
903 Some(warn(
904 account_code(park.provider, "dormant_account", "codex_dormant_account"),
905 format!("account {}", park.typed()),
906 format!(
907 "not switched to for {}; pitboard has kept its login alive that whole time",
908 time::span(dormant_for)
909 ),
910 format!(
911 "Every `pitboard` renews it, so its refresh token is rotated and kept live on \
912 this machine for as long as it stays enrolled. If you are not coming back to \
913 it, `pitboard forget {}` deletes the login and the record.",
914 park.typed()
915 ),
916 ))
917}
918
919fn judge_credential(facts: &Facts) -> Check {
920 match &facts.credential {
921 Ok(Some(doc)) => {
922 let keys: Vec<&str> = doc
923 .as_object()
924 .map(|o| o.keys().map(String::as_str).collect())
925 .unwrap_or_default();
926 if doc.get("claudeAiOauth").is_none() {
929 return warn(
930 "credential",
931 "credential",
932 format!("signed out; the document keeps only {keys:?}"),
933 "Nothing is signed in for this slot.",
934 );
935 }
936 let Some(oauth) = doc.get("claudeAiOauth").and_then(Value::as_object) else {
937 return fail(
938 "credential",
939 "credential",
940 format!("claudeAiOauth is not an object; the keys are {keys:?}"),
941 "The credential's shape changed. Do not switch accounts until this is understood.",
942 );
943 };
944 let fingerprint = oauth
945 .get("refreshToken")
946 .and_then(Value::as_str)
947 .map(store::fingerprint)
948 .unwrap_or_else(|| "none".into());
949 let days = oauth
950 .get("refreshTokenExpiresAt")
951 .and_then(Value::as_i64)
952 .map_or(-1, |ms| (ms / 1000 - facts.now) / 86_400);
953 let detail = format!("refresh {fingerprint} · {days} days left · keys {keys:?}");
954 if days < 3 {
955 warn(
956 "credential",
957 "credential",
958 detail,
959 "This login expires soon and will need signing in again.",
960 )
961 } else {
962 ok("credential", "credential", detail)
963 }
964 }
965 Ok(None) => warn(
966 "credential",
967 "credential",
968 "nothing stored",
969 "Nothing is signed in for this slot.",
970 ),
971 Err(e) => fail(
972 "credential",
973 "credential",
974 e.to_string(),
975 "Do not write to the store while this is failing.",
976 ),
977 }
978}
979
980pub const RENEW_WITHIN: i64 = 3 * 86_400;
982
983fn judge_park(fact: &ParkFact, now: i64) -> Check {
984 let code = account_code(fact.provider, "parked_login", "codex_parked_login");
985 let name = format!("account {}", fact.typed());
986 let renew = format!("Run `pitboard enroll {} --sign-in`.", fact.typed());
987 let Some(park) = &fact.park else {
988 return if fact.active {
989 ok(code, name, "signed in; parked when you switch away")
990 } else {
991 warn(code, name, "nothing parked to switch to", renew)
992 };
993 };
994 if let Some(why) = &fact.unreadable {
995 return fail(
996 code,
997 name,
998 format!("its parked login is unusable: {why}"),
999 renew,
1000 );
1001 }
1002 match park.refresh_expires_at {
1003 Some(at) if at <= now => warn(
1004 code,
1005 name,
1006 format!("its parked login expired {}", time::moment(at, now)),
1007 renew,
1008 ),
1009 Some(at) if at - now < RENEW_WITHIN => warn(
1010 code,
1011 name,
1012 format!("its parked login expires in {}", time::span(at - now)),
1013 renew,
1014 ),
1015 Some(at) => ok(
1016 code,
1017 name,
1018 format!("parked, good for {}", time::span(at - now)),
1019 ),
1020 None => ok(code, name, "parked"),
1021 }
1022}
1023
1024fn judge_storage_v5(facts: &Facts) -> Check {
1034 let flag_on = facts
1035 .config
1036 .as_ref()
1037 .ok()
1038 .and_then(|c| c.get("cachedGrowthBookFeatures"))
1039 .and_then(|f| f.get("tengu_hover_rest"))
1040 .and_then(Value::as_bool)
1041 .unwrap_or(false);
1042 if !(facts.hover_rest_env || flag_on) {
1043 return ok("storage_v5", "storage v5", "inactive");
1044 }
1045 match facts.backend {
1046 Ok(store::Backend::File) => warn(
1047 "storage_v5",
1048 "storage v5",
1049 "switched on, and this login is in the fallback store",
1050 "pitboard reads the plaintext file. If Claude Code was given a backend of its \
1051 own, that is not the same file. Check for an update before switching.",
1052 ),
1053 _ => ok(
1054 "storage_v5",
1055 "storage v5",
1056 "switched on; the keychain is still where the login is",
1057 ),
1058 }
1059}
1060
1061fn biggest(parts: &[(String, usize)]) -> String {
1064 let named: Vec<String> = parts
1065 .iter()
1066 .take(3)
1067 .map(|(what, bytes)| format!("{what} {bytes}"))
1068 .collect();
1069 if named.is_empty() {
1070 String::new()
1071 } else {
1072 format!(". Mostly: {}", named.join(", "))
1073 }
1074}
1075
1076fn judge_asking(facts: &Facts) -> Check {
1083 let tool_of = |uuid: &str| {
1087 facts
1088 .state
1089 .as_ref()
1090 .ok()
1091 .and_then(|s| s.owner_of_park(uuid))
1092 .map(crate::state::Account::provider)
1093 };
1094 let services = |tools: &mut Vec<ProviderId>| {
1095 tools.sort();
1096 tools.dedup();
1097 if tools.is_empty() {
1098 tools.push(ProviderId::Claude);
1099 }
1100 tools
1101 .iter()
1102 .map(|t| t.service())
1103 .collect::<Vec<_>>()
1104 .join(" and ")
1105 };
1106 let mut asked: Vec<ProviderId> = facts
1107 .state
1108 .as_ref()
1109 .map(|s| {
1110 s.accounts
1111 .iter()
1112 .map(crate::state::Account::provider)
1113 .collect()
1114 })
1115 .unwrap_or_default();
1116 let name = format!("asking {}", services(&mut asked));
1117 let mut holding: Vec<ProviderId> = facts
1118 .asking_held
1119 .iter()
1120 .filter_map(|(uuid, _)| tool_of(uuid))
1121 .collect();
1122 let holders = services(&mut holding);
1123 match facts.asking_held.len() {
1124 0 => ok("asking", name, "nothing is being held back"),
1125 n => {
1126 let longest = facts
1127 .asking_held
1128 .iter()
1129 .map(|(_, seconds)| *seconds)
1130 .max()
1131 .unwrap_or_default();
1132 warn(
1133 "asking",
1134 name,
1135 format!(
1136 "{n} account(s) not being asked about for up to {}",
1137 time::span(longest)
1138 ),
1139 format!(
1140 "{holders} asked for less traffic, or could not be reached. The numbers \
1141 shown are the last ones measured until then; `pitboard status --fresh` \
1142 does not override a wait {holders} asked for."
1143 ),
1144 )
1145 }
1146 }
1147}
1148
1149fn judge_auth(facts: &Facts) -> Check {
1157 if facts.auth_overrides.is_empty() {
1158 return ok(
1159 "auth_source",
1160 "what a session authenticates with",
1161 "the stored login, which is what pitboard moves",
1162 );
1163 }
1164 let named: Vec<String> = facts
1165 .auth_overrides
1166 .iter()
1167 .map(ToString::to_string)
1168 .collect();
1169 warn(
1170 "auth_source",
1171 "what a session authenticates with",
1172 format!("something else: {}", named.join("; ")),
1173 "Claude Code here authenticates with that rather than with the stored login, so \
1174 switching accounts changes nothing a session would notice. Remove it, or accept \
1175 that pitboard is moving a login nothing reads.",
1176 )
1177}
1178
1179fn judge_claude_version(facts: &Facts) -> Check {
1186 let verified = crate::provider::claude::assumptions::VERIFIED_AGAINST;
1187 match facts.claude_version.as_deref() {
1188 None => ok(
1189 "claude_version",
1190 "Claude Code build",
1191 format!("not found here; pitboard's facts were read from {verified}"),
1192 ),
1193 Some(installed) if installed == verified => ok(
1194 "claude_version",
1195 "Claude Code build",
1196 format!("{installed}, which is what pitboard's facts were read from"),
1197 ),
1198 Some(installed) => ok(
1199 "claude_version",
1200 "Claude Code build",
1201 format!("{installed} installed; pitboard's facts were read from {verified}"),
1202 ),
1203 }
1204}
1205
1206fn judge_pending(facts: &Facts) -> Check {
1210 match facts.pending_parks.len() {
1211 0 => ok("pending_parks", "parks being reclaimed", "none outstanding"),
1212 n => warn(
1213 "pending_parks",
1214 "parks being reclaimed",
1215 format!("{n} could not be read this time"),
1216 "pitboard wrote these names down before creating a login in them and cannot \
1217 read them back to find out what is there. Unlock the keychain and run any \
1218 pitboard command; it resolves them before doing anything else.",
1219 ),
1220 }
1221}
1222
1223fn judge_schedule(facts: &Facts) -> Option<Check> {
1231 let again = again(cfg!(target_os = "macos"));
1232 let schedule = facts.schedule.as_ref()?;
1233 Some(match &schedule.program {
1234 Some(program) if !schedule.program_found => fail(
1235 "schedule",
1236 "renewal schedule",
1237 format!("runs {}, which is not there any more", program.display()),
1238 again,
1239 ),
1240 Some(program) if crate::schedule::an_apps_own_program(program) => fail(
1241 "schedule",
1242 "renewal schedule",
1243 format!(
1244 "runs {}, which is the app itself and not a command line",
1245 program.display()
1246 ),
1247 again,
1248 ),
1249 Some(program) => ok(
1250 "schedule",
1251 "renewal schedule",
1252 format!("daily · runs {}", program.display()),
1253 ),
1254 None => warn(
1255 "schedule",
1256 "renewal schedule",
1257 format!(
1258 "{} does not say which pitboard it runs",
1259 schedule.path.display()
1260 ),
1261 again,
1262 ),
1263 })
1264}
1265
1266fn again(macos: bool) -> &'static str {
1268 if macos {
1269 "Turn daily renewal off and on again: in the app's Settings, or with \
1270 `pitboard schedule uninstall` and then `pitboard schedule install`."
1271 } else {
1272 "Turn daily renewal off and on again with `pitboard schedule uninstall` and then \
1273 `pitboard schedule install`."
1274 }
1275}
1276
1277fn judge_daemon(facts: &Facts) -> Check {
1281 let Some(d) = &facts.daemon else {
1282 return ok("claude_daemon", "Claude Code daemon", "none has run here");
1283 };
1284 let version = d
1285 .version
1286 .as_deref()
1287 .map(|v| format!("Claude Code {v}"))
1288 .unwrap_or_else(|| "an unrecorded version".into());
1289 if d.running {
1290 ok(
1291 "claude_daemon",
1292 "Claude Code daemon",
1293 format!("running, {version}, pid {}", d.pid),
1294 )
1295 } else {
1296 ok(
1297 "claude_daemon",
1298 "Claude Code daemon",
1299 format!("not running; {version} ran here last"),
1300 )
1301 }
1302}
1303
1304fn judge_codex(facts: &CodexFacts, parks: &[&ParkFact], now: i64) -> Vec<Check> {
1320 let mut checks = Vec::new();
1321 let enrolled = facts.enrolled > 0 || !parks.is_empty();
1322 let broken = |code, name: &str, detail: String, advice: String| {
1323 if enrolled {
1324 fail(code, name, detail, advice)
1325 } else {
1326 warn(code, name, detail, advice)
1327 }
1328 };
1329 let file = facts.backend == "file";
1330 let config = facts.home.join("config.toml");
1331 let described = match facts.backend {
1332 "ephemeral" => "in memory only (cli_auth_credentials_store = \"ephemeral\")".to_string(),
1333 "secrets" => "secrets (cli_auth_credentials_store with secret_auth_storage in \
1334 config.toml)"
1335 .to_string(),
1336 other => format!("{other} (cli_auth_credentials_store in config.toml)"),
1337 };
1338 checks.push(match facts.backend {
1339 "file" => ok(
1340 "codex_backend",
1341 "Codex login store",
1342 format!("file · {}", facts.auth_file.display()),
1343 ),
1344 other if enrolled => fail(
1346 "codex_backend",
1347 "Codex login store",
1348 described,
1349 match other {
1350 "ephemeral" => format!(
1351 "Codex keeps nothing at rest, so there is no login pitboard can park or \
1352 switch. Remove the setting from {} to use Codex's default file store.",
1353 config.display()
1354 ),
1355 _ => format!(
1356 "pitboard reads only Codex's default file store, auth.json, and will not \
1357 touch the keychain item Codex created for itself, because every read of \
1358 it would ask you for permission, so no enrolled Codex account can be \
1359 switched to. Remove the setting from {} to use the file store, then sign \
1360 in with `codex login`.",
1361 config.display()
1362 ),
1363 },
1364 ),
1365 _ => ok(
1366 "codex_backend",
1367 "Codex login store",
1368 format!("{described}; pitboard switches Codex accounts only in the file store"),
1369 ),
1370 });
1371
1372 if file {
1375 checks.push(match facts.auth_mode {
1376 None if enrolled => warn(
1377 "codex_auth_file",
1378 "Codex login file",
1379 format!(
1380 "{} is absent: nothing is signed in to Codex",
1381 facts.auth_file.display()
1382 ),
1383 "Sign in to one of your enrolled accounts with `codex login`. A switch \
1384 needs an account signed in to park, so `pitboard use` cannot put one \
1385 back while nothing is.",
1386 ),
1387 None => ok(
1388 "codex_auth_file",
1389 "Codex login file",
1390 "absent; nothing is signed in to Codex",
1391 ),
1392 Some(mode) if mode & 0o077 != 0 => warn(
1393 "codex_auth_file",
1394 "Codex login file",
1395 format!("{} is mode {mode:o}", facts.auth_file.display()),
1396 format!(
1397 "It holds a usable login in plain text, and Codex sets 0600 only when it \
1398 creates the file, never on a later write: `chmod 600 {}`.",
1399 facts.auth_file.display()
1400 ),
1401 ),
1402 Some(mode) => ok(
1403 "codex_auth_file",
1404 "Codex login file",
1405 format!("{} · mode {mode:o}", facts.auth_file.display()),
1406 ),
1407 });
1408 match &facts.login {
1409 Ok(Some(login)) => checks.push(ok(
1410 "codex_login",
1411 "Codex login",
1412 format!(
1413 "{} · account {} · refresh {}",
1414 login.email,
1415 login.account_id,
1416 if login.fingerprint.is_empty() {
1417 "none"
1418 } else {
1419 login.fingerprint.as_str()
1420 }
1421 ),
1422 )),
1423 Ok(None) => {}
1425 Err(CodexLoginTrouble::NotAnAccount(why)) if enrolled => checks.push(warn(
1428 "codex_login",
1429 "Codex login",
1430 why.clone(),
1431 "pitboard parks and switches Codex's ChatGPT sign-ins, so `pitboard use \
1432 codex/<label>` refuses while Codex is signed in this way rather than replace \
1433 a login it has nowhere to park. `codex login` signs in with a ChatGPT \
1434 account.",
1435 )),
1436 Err(CodexLoginTrouble::NotAnAccount(why)) => {
1437 checks.push(ok("codex_login", "Codex login", why.clone()));
1438 }
1439 Err(CodexLoginTrouble::Unusable(why)) => checks.push(broken(
1440 "codex_login",
1441 "Codex login",
1442 format!("it cannot be used: {why}"),
1443 "pitboard will not park or switch a Codex login it cannot read as one \
1444 account's. Signing in with `codex` again writes a fresh one."
1445 .into(),
1446 )),
1447 }
1448 }
1449
1450 checks.extend(parks.iter().map(|p| judge_park(p, now)));
1453 checks.extend(parks.iter().filter_map(|p| judge_dormant(p, now)));
1454
1455 checks.push(judge_codex_version(facts));
1456 checks.push(judge_codex_running(facts.running.as_deref()));
1457 checks
1458}
1459
1460fn judge_codex_running(running: Option<&[crate::holder::Holding]>) -> Check {
1463 let (detail, advice) = match running {
1464 None => ("could not tell".to_string(), String::new()),
1465 Some([]) => ("none".to_string(), String::new()),
1466 Some(holding) => (
1467 format!(
1468 "{}; each keeps using the account it started with until it is started again",
1469 crate::holder::described_with_pids(holding)
1470 ),
1471 crate::holder::remedies(holding, "to take a switch"),
1472 ),
1473 };
1474 Check {
1475 advice,
1476 ..ok("codex_running", "running Codex", detail)
1477 }
1478}
1479
1480fn judge_codex_version(facts: &CodexFacts) -> Check {
1483 let verified = crate::provider::codex::assumptions::VERIFIED_AGAINST;
1484 ok(
1485 "codex_version",
1486 "Codex build",
1487 match (facts.program.as_deref(), facts.version.as_deref()) {
1488 (None, _) => format!("not found here; pitboard's facts were read from {verified}"),
1489 (Some(program), None) => format!(
1492 "{}, whose path does not say which version it is; pitboard's facts were \
1493 read from {verified}",
1494 program.display()
1495 ),
1496 (Some(_), Some(installed)) if installed == verified => {
1497 format!("{installed}, which is what pitboard's facts were read from")
1498 }
1499 (Some(_), Some(installed)) => {
1500 format!("{installed} installed; pitboard's facts were read from {verified}")
1501 }
1502 },
1503 )
1504}
1505
1506pub struct Diagnosis {
1509 pub checks: Vec<Check>,
1510 pub environment: Value,
1511 pub redaction: crate::redact::Sheet,
1514}
1515
1516pub fn run(ctx: &Context) -> Diagnosis {
1517 let facts = gather(ctx);
1518 Diagnosis {
1519 redaction: redaction_for(ctx, &facts),
1520 checks: evaluate(&facts),
1521 environment: json!({
1522 "config_file": facts.config_path,
1523 "storage_dir": facts.storage_dir,
1524 "credential_service": facts.service,
1525 "credential_store": facts.backend.as_ref().map_or("unreadable", |b| b.name()),
1526 "home": facts.home,
1527 "codex": {
1530 "home": facts.codex.home,
1531 "present": facts.codex.present,
1532 "backend": facts.codex.backend,
1533 "login_present": (facts.codex.backend == "file")
1535 .then_some(facts.codex.auth_mode.is_some()),
1536 "version": facts.codex.version,
1537 },
1538 }),
1539 }
1540}
1541
1542fn redaction_for(ctx: &Context, facts: &Facts) -> crate::redact::Sheet {
1547 let mut sheet = crate::redact::Sheet::new(
1548 format!("{}:{}", crate::state::machine_id(), ctx.now_millis()),
1549 ctx.home().to_string_lossy(),
1550 )
1551 .hide(facts.account.clone(), "user");
1552
1553 if let Some(id) = &facts.identity {
1554 sheet = sheet
1555 .hide(id.email.clone(), "email")
1556 .hide(id.account_uuid.clone(), "account")
1557 .hide(id.organization_uuid.clone(), "org");
1558 if let Some(name) = &id.organization_name {
1559 sheet = sheet.hide(name.clone(), "org");
1560 }
1561 }
1562 if let Ok(state) = &facts.state {
1563 for account in &state.accounts {
1564 sheet = sheet
1565 .hide(account.email.clone(), "email")
1566 .hide(account.account_uuid.clone(), "account")
1567 .hide(
1568 account
1569 .claude()
1570 .map_or_else(String::new, |c| c.organization_uuid.to_string()),
1571 "org",
1572 );
1573 if let crate::state::Detail::Codex {
1575 workspace_id: Some(workspace),
1576 ..
1577 } = &account.detail
1578 {
1579 sheet = sheet.hide(workspace.clone(), "org");
1580 }
1581 }
1582 }
1583 if let Ok(Some(login)) = &facts.codex.login {
1585 sheet = sheet
1586 .hide(login.email.clone(), "email")
1587 .hide(login.account_id.clone(), "account")
1588 .hide(login.fingerprint.clone(), "login");
1589 }
1590 if let Ok(Some(doc)) = &facts.credential
1592 && let Some(fingerprint) = doc
1593 .get("claudeAiOauth")
1594 .map(crate::provider::claude::document::fingerprint_of)
1595 .filter(|f| !f.is_empty())
1596 {
1597 sheet = sheet.hide(fingerprint, "login");
1598 }
1599 for park in &facts.parks {
1600 if let Some(held) = &park.park {
1601 sheet = sheet
1602 .hide(held.refresh_fingerprint.clone(), "login")
1603 .hide(held.service.clone(), "park");
1604 }
1605 }
1606 sheet
1607}
1608
1609pub fn healthy(checks: &[Check]) -> bool {
1611 checks.iter().all(|c| c.level != Level::Fail)
1612}
1613
1614#[cfg(test)]
1615mod tests {
1616 use super::*;
1617
1618 fn facts() -> Facts {
1619 Facts {
1620 security_tool: Some("/usr/bin/security".into()),
1621 config_path: PathBuf::from("/home/x/.claude.json"),
1622 config: Ok(json!({"oauthAccount": {}})),
1623 identity: Some(claude::Identity {
1624 email: "a@b.c".into(),
1625 account_uuid: "acc".into(),
1626 organization_uuid: "org".into(),
1627 organization_name: None,
1628 subscription: None,
1629 rate_limit_tier: None,
1630 }),
1631 credential_parts: Vec::new(),
1632 credential_cost: Some(store::Cost {
1633 needs: 900,
1634 limit: 4032,
1635 second_route: true,
1636 }),
1637 service: "Claude Code-credentials".into(),
1638 account: "someone".into(),
1639 default_slot: true,
1640 storage_dir: "/home/x/.claude".into(),
1641 backend: Ok(store::Backend::Keychain),
1642 credential_file: PathBuf::from("/home/x/.claude/.credentials.json"),
1643 credential: Ok(Some(json!({"claudeAiOauth": {
1644 "refreshToken": "r",
1645 "refreshTokenExpiresAt": 2_000_000_000_000i64
1646 }}))),
1647 home: PathBuf::from("/home/x/.pitboard"),
1648 home_mode: Some(0o700),
1649 readable_by_others: Vec::new(),
1650 machine_id_known: true,
1651 hover_rest_env: false,
1652 daemon: None,
1653 pending_parks: Vec::new(),
1654 claude_version: Some(crate::provider::claude::assumptions::VERIFIED_AGAINST.into()),
1655 auth_overrides: Vec::new(),
1656 asking_held: Vec::new(),
1657 state: Ok(State::default()),
1658 parks: Vec::new(),
1659 interrupted: false,
1660 codex: no_codex(),
1661 claude_present: true,
1662 schedule: None,
1663 now: NOW,
1664 }
1665 }
1666
1667 fn no_codex() -> CodexFacts {
1669 CodexFacts {
1670 home: PathBuf::from("/home/x/.codex"),
1671 present: false,
1672 enrolled: 0,
1673 backend: "file",
1674 auth_file: PathBuf::from("/home/x/.codex/auth.json"),
1675 auth_mode: None,
1676 login: Ok(None),
1677 program: None,
1678 version: None,
1679 running: Some(Vec::new()),
1680 }
1681 }
1682
1683 fn codex_checks(codex: &CodexFacts) -> Vec<Check> {
1685 judge_codex(codex, &[], NOW)
1686 }
1687
1688 fn with_codex() -> CodexFacts {
1690 CodexFacts {
1691 present: true,
1692 auth_mode: Some(0o600),
1693 login: Ok(Some(CodexLogin {
1694 email: "w@example.com".into(),
1695 account_id: "work-account".into(),
1696 fingerprint: "0123456789abcdef".into(),
1697 })),
1698 program: Some(PathBuf::from("/usr/local/bin/codex")),
1699 version: Some(crate::provider::codex::assumptions::VERIFIED_AGAINST.into()),
1700 ..no_codex()
1701 }
1702 }
1703
1704 const NOW: i64 = 1_789_935_600;
1705
1706 fn parked(label: &str, refresh_expires_at: Option<i64>) -> ParkFact {
1707 ParkFact {
1708 provider: ProviderId::Claude,
1709 last_used_at: None,
1710 label: label.into(),
1711 name: crate::state::Key::new(ProviderId::Claude, label).typed(),
1712 active: false,
1713 park: Some(Park {
1714 service: format!("pitboard-park-{label}-1"),
1715 parked_at: NOW - 86_400,
1716 refresh_fingerprint: "f".into(),
1717 access_expires_at: None,
1718 refresh_expires_at,
1719 }),
1720 unreadable: None,
1721 }
1722 }
1723
1724 fn codex_parked(label: &str, refresh_expires_at: Option<i64>) -> ParkFact {
1726 ParkFact {
1727 provider: ProviderId::Codex,
1728 name: crate::state::Key::new(ProviderId::Codex, label).typed(),
1729 ..parked(label, refresh_expires_at)
1730 }
1731 }
1732
1733 fn named<'a>(checks: &'a [Check], name: &str) -> &'a Check {
1734 checks.iter().find(|c| c.name == name).expect(name)
1735 }
1736
1737 fn check<'a>(checks: &'a [Check], code: &str) -> &'a Check {
1738 checks.iter().find(|c| c.code == code).expect(code)
1739 }
1740
1741 #[test]
1742 fn a_healthy_machine_reports_no_failures() {
1743 let checks = evaluate(&facts());
1744 assert!(checks.iter().all(|c| c.level != Level::Fail));
1745 assert!(healthy(&checks));
1746 }
1747
1748 #[test]
1754 fn a_world_readable_park_is_found_on_the_disk() {
1755 use std::os::unix::fs::PermissionsExt;
1756
1757 let root = std::env::temp_dir().join(format!(
1758 "pitboard-doctor-modes-{}-{:?}",
1759 std::process::id(),
1760 std::thread::current().id()
1761 ));
1762 let _ = std::fs::remove_dir_all(&root);
1763 struct Scratch(std::path::PathBuf);
1764 impl Drop for Scratch {
1765 fn drop(&mut self) {
1766 let _ = std::fs::remove_dir_all(&self.0);
1767 }
1768 }
1769 let _guard = Scratch(root.clone());
1770
1771 let ctx = Context::new(root.clone()).with_pitboard_home(root.join(".pitboard"));
1772 let vault = store::vault_dir(&ctx);
1773 std::fs::create_dir_all(&vault).expect("a vault");
1774 std::fs::set_permissions(&vault, std::fs::Permissions::from_mode(0o700)).unwrap();
1775 assert!(
1776 loose_logins(&ctx).is_empty(),
1777 "a private vault is not loose"
1778 );
1779
1780 let park = vault.join("pitboard-park-x.json");
1781 std::fs::write(&park, "{}").expect("a park");
1782 std::fs::set_permissions(&park, std::fs::Permissions::from_mode(0o644)).unwrap();
1783 let found = loose_logins(&ctx);
1784 assert_eq!(found.len(), 1, "{found:?}");
1785 assert_eq!(found[0].1, 0o644);
1786 assert!(found[0].0.ends_with("pitboard-park-x.json"), "{found:?}");
1787
1788 std::fs::set_permissions(&park, std::fs::Permissions::from_mode(0o600)).unwrap();
1789 assert!(loose_logins(&ctx).is_empty(), "0600 is private");
1790 }
1791
1792 #[test]
1797 fn a_login_anyone_on_this_machine_can_read_is_a_failure() {
1798 let mut f = facts();
1799 assert_eq!(check(&evaluate(&f), "private_on_disk").level, Level::Ok);
1800
1801 f.readable_by_others = vec![
1802 ("/home/a/.pitboard/vault/pitboard-park-x.json".into(), 0o644),
1803 ("/home/a/.claude/.credentials.json".into(), 0o640),
1804 ];
1805 let checks = evaluate(&f);
1806 let found = check(&checks, "private_on_disk");
1807 assert_eq!(found.level, Level::Fail);
1808 assert!(found.detail.contains("mode 644"), "{}", found.detail);
1809 assert!(found.detail.contains("mode 640"), "{}", found.detail);
1810 assert!(found.advice.contains("chmod go-rwx"), "{}", found.advice);
1812 assert!(!healthy(&checks));
1813 }
1814
1815 #[test]
1818 fn a_credential_whose_login_is_not_an_object_is_a_failure() {
1819 let mut f = facts();
1820 f.credential = Ok(Some(json!({"claudeAiOauth": "something else"})));
1821 let checks = evaluate(&f);
1822 assert_eq!(check(&checks, "credential").level, Level::Fail);
1823 assert!(!healthy(&checks));
1824 }
1825
1826 #[test]
1827 fn an_unreadable_store_fails_rather_than_reporting_nothing_stored() {
1828 let mut f = facts();
1829 f.credential = Err(store::Error::Unreadable("security exited 1".into()));
1830 f.backend = Err(store::Error::Unreadable("security exited 1".into()));
1831 let checks = evaluate(&f);
1832 assert_eq!(check(&checks, "credential").level, Level::Fail);
1833 assert_eq!(check(&checks, "credential_store").level, Level::Fail);
1834 }
1835
1836 #[test]
1837 fn a_login_about_to_expire_is_flagged() {
1838 let mut f = facts();
1839 f.credential = Ok(Some(json!({"claudeAiOauth": {
1840 "refreshToken": "r",
1841 "refreshTokenExpiresAt": (f.now + 86_400) * 1000
1842 }})));
1843 assert_eq!(check(&evaluate(&f), "credential").level, Level::Warn);
1844 }
1845
1846 #[test]
1847 fn a_loose_home_directory_is_flagged() {
1848 let mut f = facts();
1849 f.home_mode = Some(0o755);
1850 let checks = evaluate(&f);
1851 let home = check(&checks, "home");
1852 assert_eq!(home.level, Level::Warn);
1853 assert!(home.detail.contains("755"));
1854 }
1855
1856 #[test]
1857 fn the_successor_backend_alone_does_not_move_the_login() {
1858 let server_on = || {
1859 let mut f = facts();
1860 f.config = Ok(json!({"cachedGrowthBookFeatures": {"tengu_hover_rest": true}}));
1861 f
1862 };
1863 let env_on = || {
1864 let mut f = facts();
1865 f.hover_rest_env = true;
1866 f
1867 };
1868 for mut f in [server_on(), env_on()] {
1869 let checks = evaluate(&f);
1871 assert_eq!(check(&checks, "storage_v5").level, Level::Ok);
1872 f.backend = Ok(store::Backend::File);
1874 let checks = evaluate(&f);
1875 assert_eq!(check(&checks, "storage_v5").level, Level::Warn);
1876 }
1877 }
1878
1879 #[test]
1880 fn the_successor_backend_is_quiet_when_it_is_off() {
1881 let mut f = facts();
1882 let checks = evaluate(&f);
1883 assert_eq!(check(&checks, "storage_v5").level, Level::Ok);
1884 f.backend = Ok(store::Backend::File);
1886 let checks = evaluate(&f);
1887 assert_eq!(check(&checks, "storage_v5").level, Level::Ok);
1888 }
1889
1890 #[test]
1894 fn a_config_that_names_somebody_signed_in_with_no_login_anywhere_is_a_failure() {
1895 let mut f = facts();
1896 f.backend = Ok(store::Backend::Absent);
1897 let checks = evaluate(&f);
1898 let store = check(&checks, "credential_store");
1899 assert_eq!(store.level, Level::Fail);
1900 assert!(store.detail.contains("a@b.c"));
1901
1902 f.identity = None;
1904 let checks = evaluate(&f);
1905 let store = check(&checks, "credential_store");
1906 assert_eq!(store.level, Level::Warn);
1907 assert!(store.advice.contains("Nothing is signed in"));
1908 }
1909
1910 #[test]
1911 fn a_login_past_the_ceiling_reads_differently_when_the_way_past_it_is_refused() {
1912 let mut f = facts();
1913 let checks = evaluate(&f);
1914 assert_eq!(check(&checks, "credential_size").level, Level::Ok);
1915
1916 f.credential_cost = Some(store::Cost {
1917 needs: 8503,
1918 limit: 4032,
1919 second_route: true,
1920 });
1921 let checks = evaluate(&f);
1922 let written = check(&checks, "credential_size");
1923 assert_eq!(written.level, Level::Warn);
1924 assert!(written.detail.contains("argument line"));
1925
1926 f.credential_cost = Some(store::Cost {
1927 needs: 8503,
1928 limit: 4032,
1929 second_route: false,
1930 });
1931 let checks = evaluate(&f);
1932 let refused = check(&checks, "credential_size");
1933 assert_eq!(
1934 refused.level,
1935 Level::Fail,
1936 "there is no third way to write it"
1937 );
1938 assert!(refused.detail.contains("PITBOARD_NO_ARGV"));
1939 }
1940
1941 #[test]
1948 fn a_login_too_big_says_what_is_taking_up_the_room() {
1949 let mut f = facts();
1950 f.credential_cost = Some(store::Cost {
1951 needs: 8503,
1952 limit: 4032,
1953 second_route: true,
1954 });
1955 f.credential_parts = parts_of(&json!({
1956 "claudeAiOauth": {"refreshToken": "r"},
1957 "mcpOAuth": {
1958 "one": {"token": "x".repeat(300)},
1959 "two": {"token": "y".repeat(200)},
1960 "three": {"token": "z".repeat(100)},
1961 },
1962 }));
1963
1964 let checks = evaluate(&f);
1965 let size = check(&checks, "credential_size");
1966 assert!(size.detail.contains("mcpOAuth one"), "{}", size.detail);
1967 assert!(size.detail.contains("mcpOAuth two"), "{}", size.detail);
1968 assert!(
1969 !size.detail.contains("claudeAiOauth"),
1970 "three is enough to act on, and the login itself is never the problem: {}",
1971 size.detail
1972 );
1973 }
1974
1975 #[test]
1976 fn what_takes_up_the_room_is_listed_largest_first_and_named_per_server() {
1977 let parts = parts_of(&json!({
1978 "claudeAiOauth": {"refreshToken": "r"},
1979 "mcpOAuth": {"small": {"t": "x"}, "large": {"t": "y".repeat(500)}},
1980 }));
1981 let names: Vec<&str> = parts.iter().map(|(what, _)| what.as_str()).collect();
1982 assert_eq!(names[0], "mcpOAuth large", "largest first: {names:?}");
1983 assert!(names.contains(&"claudeAiOauth"));
1984 assert!(
1985 names.contains(&"mcpOAuth small"),
1986 "each server is named, because that is what a person signs out of"
1987 );
1988
1989 let single = parts_of(&json!({"mcpOAuth": {"only": {"t": "x"}}}));
1991 assert_eq!(single.len(), 1);
1992 assert_eq!(single[0].0, "mcpOAuth");
1993 }
1994
1995 #[test]
1996 fn an_account_nobody_has_come_back_to_is_said_out_loud() {
1997 let mut f = facts();
1998 f.parks = vec![ParkFact {
1999 provider: ProviderId::Claude,
2000 label: "work".into(),
2001 name: crate::state::Key::new(ProviderId::Claude, "work").typed(),
2002 active: false,
2003 last_used_at: Some(f.now - 31 * 86_400),
2004 park: Some(Park {
2005 service: "pitboard-park-acc-1".into(),
2006 parked_at: f.now - 31 * 86_400,
2007 refresh_fingerprint: "f".into(),
2008 access_expires_at: Some(f.now + 3600),
2009 refresh_expires_at: Some(f.now + 30 * 86_400),
2010 }),
2011 unreadable: None,
2012 }];
2013 let checks = evaluate(&f);
2014 let dormant = check(&checks, "dormant_account");
2015 assert_eq!(dormant.level, Level::Warn);
2016 assert!(dormant.advice.contains("pitboard forget work"));
2017
2018 f.parks[0].last_used_at = Some(f.now - 29 * 86_400);
2020 assert!(
2021 !evaluate(&f).iter().any(|c| c.code == "dormant_account"),
2022 "an account used within a token's lifetime is not dormant"
2023 );
2024
2025 f.parks[0].last_used_at = Some(f.now - 400 * 86_400);
2027 f.parks[0].active = true;
2028 assert!(!evaluate(&f).iter().any(|c| c.code == "dormant_account"));
2029 f.parks[0].active = false;
2030 f.parks[0].park = None;
2031 assert!(!evaluate(&f).iter().any(|c| c.code == "dormant_account"));
2032 }
2033
2034 #[test]
2035 fn the_daemon_is_reported_without_being_a_problem() {
2036 let mut f = facts();
2037 let checks = evaluate(&f);
2038 assert!(check(&checks, "claude_daemon").detail.contains("none"));
2039
2040 f.daemon = Some(daemon::Daemon {
2041 pid: 4321,
2042 version: Some("2.1.278".into()),
2043 started_at: Some(1_790_079_766_317),
2044 origin: Some("transient".into()),
2045 launch_target: None,
2046 running: true,
2047 });
2048 let checks = evaluate(&f);
2049 let running = check(&checks, "claude_daemon");
2050 assert_eq!(running.level, Level::Ok);
2051 assert!(running.detail.contains("running"));
2052 assert!(running.detail.contains("2.1.278"));
2053 assert!(running.detail.contains("4321"));
2054
2055 f.daemon.as_mut().expect("set above").running = false;
2056 let checks = evaluate(&f);
2057 let stopped = check(&checks, "claude_daemon");
2058 assert_eq!(stopped.level, Level::Ok);
2059 assert!(stopped.detail.contains("not running"));
2060 }
2061
2062 #[test]
2063 fn a_schedule_is_judged_by_whether_the_pitboard_it_runs_is_still_there() {
2064 let mut f = facts();
2065 assert!(
2066 evaluate(&f).iter().all(|c| c.code != "schedule"),
2067 "nothing is said where there is no schedule"
2068 );
2069
2070 f.schedule = Some(ScheduleFact {
2071 path: PathBuf::from("/home/x/Library/LaunchAgents/com.datlechin.pitboard.renew.plist"),
2072 program: Some(PathBuf::from("/opt/homebrew/bin/pitboard")),
2073 program_found: true,
2074 });
2075 let checks = evaluate(&f);
2076 let found = check(&checks, "schedule");
2077 assert_eq!(found.level, Level::Ok);
2078 assert!(found.detail.contains("/opt/homebrew/bin/pitboard"));
2079
2080 f.schedule.as_mut().expect("set above").program_found = false;
2081 let checks = evaluate(&f);
2082 let gone = check(&checks, "schedule");
2083 assert_eq!(gone.level, Level::Fail, "every renewal from now on fails");
2084 assert!(gone.detail.contains("/opt/homebrew/bin/pitboard"));
2085 assert_eq!(gone.advice, again(cfg!(target_os = "macos")));
2086
2087 f.schedule = Some(ScheduleFact {
2088 path: PathBuf::from("/home/x/Library/LaunchAgents/com.datlechin.pitboard.renew.plist"),
2089 program: Some(PathBuf::from(
2090 "/Applications/Pitboard.app/Contents/MacOS/Pitboard",
2091 )),
2092 program_found: true,
2093 });
2094 let checks = evaluate(&f);
2095 let the_app = check(&checks, "schedule");
2096 assert_eq!(
2097 the_app.level,
2098 Level::Fail,
2099 "0.3.0's app renews nothing when started with `renew`"
2100 );
2101 assert!(
2102 the_app.detail.contains("the app itself"),
2103 "{}",
2104 the_app.detail
2105 );
2106 f.schedule.as_mut().expect("set above").program = Some(PathBuf::from(
2107 "/Applications/Pitboard.app/Contents/Helpers/pitboard",
2108 ));
2109 assert_eq!(
2110 check(&evaluate(&f), "schedule").level,
2111 Level::Ok,
2112 "the command line an app comes with is a command line"
2113 );
2114
2115 f.schedule.as_mut().expect("set above").program = None;
2116 let checks = evaluate(&f);
2117 let unread = check(&checks, "schedule");
2118 assert_eq!(unread.level, Level::Warn);
2119 assert!(!unread.advice.is_empty());
2120 }
2121
2122 #[test]
2125 fn a_broken_schedule_is_written_again_by_whatever_this_machine_has() {
2126 let mac = again(true);
2127 assert!(
2128 mac.contains("the app's Settings") && mac.contains("pitboard schedule install"),
2129 "{mac}"
2130 );
2131 let linux = again(false);
2132 assert!(linux.contains("pitboard schedule install"), "{linux}");
2133 assert!(!linux.contains("app"), "there is no app here: {linux}");
2134 }
2135
2136 #[cfg(any(target_os = "macos", target_os = "linux"))]
2139 #[test]
2140 fn a_schedule_whose_pitboard_is_gone_is_found_on_the_disk() {
2141 let root = std::env::temp_dir().join(format!(
2142 "pitboard-doctor-schedule-{}-{:?}",
2143 std::process::id(),
2144 std::thread::current().id()
2145 ));
2146 let _ = std::fs::remove_dir_all(&root);
2147 struct Scratch(std::path::PathBuf);
2148 impl Drop for Scratch {
2149 fn drop(&mut self) {
2150 let _ = std::fs::remove_dir_all(&self.0);
2151 }
2152 }
2153 let _guard = Scratch(root.clone());
2154 std::fs::create_dir_all(&root).expect("a scratch home");
2155
2156 let program = root.join("bin/pitboard");
2157 let ctx = Context::new(root.clone()).with_schedule_program(program.clone());
2158 assert!(schedule_fact(&ctx).is_none(), "nothing installed yet");
2159
2160 std::fs::create_dir_all(root.join("bin")).expect("a bin");
2161 std::fs::write(&program, "").expect("a pitboard");
2162 crate::schedule::install(&ctx).expect("installed");
2163 let fact = schedule_fact(&ctx).expect("installed");
2164 assert_eq!(fact.program.as_deref(), Some(program.as_path()));
2165 assert!(fact.program_found);
2166
2167 std::fs::remove_file(&program).expect("taken away");
2168 assert!(!schedule_fact(&ctx).expect("still installed").program_found);
2169
2170 assert!(
2171 schedule_fact(&ctx.with_pitboard_home(root.join("elsewhere"))).is_none(),
2172 "a pitboard pointed at another home has no schedule"
2173 );
2174 }
2175
2176 #[test]
2177 fn every_failure_and_warning_tells_the_user_something() {
2178 let mut f = facts();
2179 f.credential = Ok(Some(json!({"slackTag": {}})));
2180 f.home_mode = Some(0o755);
2181 for c in evaluate(&f) {
2182 if c.level != Level::Ok {
2183 assert!(!c.advice.is_empty(), "{} has no advice", c.code);
2184 }
2185 }
2186 }
2187
2188 #[test]
2189 fn a_machine_without_a_stable_identifier_is_flagged() {
2190 let mut f = facts();
2191 f.machine_id_known = false;
2192 let checks = evaluate(&f);
2193 assert_eq!(check(&checks, "machine_id").level, Level::Warn);
2194 assert!(evaluate(&facts()).iter().all(|c| c.code != "machine_id"));
2195 }
2196
2197 #[test]
2198 fn each_check_is_told_apart_by_code_and_name() {
2199 let mut f = facts();
2200 f.parks = vec![parked("work", None), parked("personal", None)];
2201 let checks = evaluate(&f);
2202 let mut keys: Vec<(&str, &str)> =
2203 checks.iter().map(|c| (c.code, c.name.as_str())).collect();
2204 keys.sort_unstable();
2205 let before = keys.len();
2206 keys.dedup();
2207 assert_eq!(keys.len(), before);
2208 }
2209
2210 #[test]
2211 fn every_parked_login_is_judged_and_a_way_back_is_offered() {
2212 let mut f = facts();
2213 let mut unusable = parked("broken", Some(NOW + 30 * 86_400));
2214 unusable.unreadable = Some("missing from the vault".into());
2215 f.parks = vec![
2216 parked("fine", Some(NOW + 20 * 86_400)),
2217 parked("soon", Some(NOW + 86_400)),
2218 parked("gone", Some(NOW - 1)),
2219 unusable,
2220 ParkFact {
2221 provider: ProviderId::Claude,
2222 last_used_at: None,
2223 label: "empty".into(),
2224 name: crate::state::Key::new(ProviderId::Claude, "empty").typed(),
2225 active: false,
2226 park: None,
2227 unreadable: None,
2228 },
2229 ParkFact {
2230 provider: ProviderId::Claude,
2231 last_used_at: None,
2232 label: "live".into(),
2233 name: crate::state::Key::new(ProviderId::Claude, "live").typed(),
2234 active: true,
2235 park: None,
2236 unreadable: None,
2237 },
2238 ];
2239 let checks = evaluate(&f);
2240 for (name, level) in [
2241 ("account fine", Level::Ok),
2242 ("account soon", Level::Warn),
2243 ("account gone", Level::Warn),
2244 ("account broken", Level::Fail),
2245 ("account empty", Level::Warn),
2246 ("account live", Level::Ok),
2247 ] {
2248 let c = named(&checks, name);
2249 assert_eq!(c.level, level, "{name}: {}", c.detail);
2250 if level != Level::Ok {
2251 let label = name.trim_start_matches("account ");
2252 assert!(
2253 c.advice
2254 .contains(&format!("pitboard enroll {label} --sign-in"))
2255 );
2256 }
2257 }
2258 }
2259
2260 #[test]
2261 fn an_interrupted_switch_and_leftover_parks_are_reported() {
2262 let mut f = facts();
2263 f.interrupted = true;
2264 f.state = Ok(State {
2265 discarded: vec!["pitboard-park-x-1".into()],
2266 ..State::default()
2267 });
2268 let checks = evaluate(&f);
2269 assert_eq!(check(&checks, "interrupted_switch").level, Level::Warn);
2270 assert_eq!(check(&checks, "discarded").level, Level::Warn);
2271 assert!(healthy(&checks), "neither stops pitboard working");
2272 }
2273
2274 #[test]
2278 fn advice_names_a_codex_account_the_way_it_is_typed() {
2279 let mut f = facts();
2280 let mut codex = codex_parked("work", Some(NOW - 1));
2281 codex.last_used_at = Some(NOW - 400 * 86_400);
2282 let mut claude = parked("work", Some(NOW - 1));
2285 claude.name = "claude/work".into();
2286 claude.last_used_at = Some(NOW - 400 * 86_400);
2287 f.parks = vec![claude, codex];
2288 let checks = evaluate(&f);
2289
2290 let codex_park = named(&checks, "account codex/work");
2291 assert!(
2292 codex_park
2293 .advice
2294 .contains("`pitboard enroll codex/work --sign-in`"),
2295 "{}",
2296 codex_park.advice
2297 );
2298 let claude_park = named(&checks, "account claude/work");
2299 assert!(
2300 claude_park
2301 .advice
2302 .contains("`pitboard enroll claude/work --sign-in`"),
2303 "a name a command here takes, which a bare `work` is not: {}",
2304 claude_park.advice
2305 );
2306
2307 let dormant: Vec<&Check> = checks
2308 .iter()
2309 .filter(|c| c.code.ends_with("dormant_account"))
2310 .collect();
2311 assert_eq!(dormant.len(), 2);
2312 assert!(
2313 dormant.iter().any(|c| c.name == "account codex/work"
2314 && c.advice.contains("`pitboard forget codex/work`"))
2315 );
2316 assert!(dormant.iter().any(|c| c.name == "account claude/work"
2317 && c.advice.contains("`pitboard forget claude/work`")));
2318 }
2319
2320 #[test]
2324 fn an_account_is_active_by_its_own_tools_record() {
2325 use crate::store::memory::MemoryHost;
2326
2327 let root = std::env::temp_dir().join(format!(
2328 "pitboard-doctor-active-{}-{:?}",
2329 std::process::id(),
2330 std::thread::current().id()
2331 ));
2332 let _ = std::fs::remove_dir_all(&root);
2333 std::fs::create_dir_all(&root).expect("a scratch home");
2334 struct Scratch(std::path::PathBuf);
2335 impl Drop for Scratch {
2336 fn drop(&mut self) {
2337 let _ = std::fs::remove_dir_all(&self.0);
2338 }
2339 }
2340 let _guard = Scratch(root.clone());
2341 let ctx = Context::new(root.clone())
2342 .with_pitboard_home(root.join(".pitboard"))
2343 .with_codex_home(root.join("codex").to_string_lossy().into())
2344 .with_memory_stores(MemoryHost::new());
2345 std::fs::write(
2346 root.join(".claude.json"),
2347 json!({"oauthAccount": {
2348 "accountUuid": "alpha-uuid",
2349 "emailAddress": "a@example.com",
2350 "organizationUuid": "org",
2351 }})
2352 .to_string(),
2353 )
2354 .expect("a Claude Code config");
2355
2356 let account =
2357 |label: &str, uuid: &str, detail: crate::state::Detail| crate::state::Account {
2358 label: label.into(),
2359 account_uuid: uuid.into(),
2360 email: format!("{label}@example.com"),
2361 parked: None,
2362 last_used_at: None,
2363 detail,
2364 };
2365 let claude = || crate::state::Detail::Claude {
2366 organization_uuid: "org".into(),
2367 oauth_account: json!({}),
2368 };
2369 let codex = || crate::state::Detail::Codex {
2370 workspace_id: None,
2371 plan: None,
2372 };
2373 let mut state = State {
2374 accounts: vec![
2375 account("alpha", "alpha-uuid", claude()),
2376 account("work", "work-acc", codex()),
2377 account("home", "home-acc", codex()),
2378 ],
2379 ..State::default()
2380 };
2381 state.set_active(ProviderId::Codex, Some("home".into()));
2382 let active = |facts: &[ParkFact]| -> Vec<String> {
2383 facts
2384 .iter()
2385 .filter(|p| p.active)
2386 .map(ParkFact::typed)
2387 .collect()
2388 };
2389
2390 assert_eq!(active(&park_facts(&ctx, &state)), ["alpha", "codex/home"]);
2392
2393 let live = crate::provider::of(ProviderId::Codex)
2395 .live(&ctx)
2396 .expect("Codex keeps its login in a file here");
2397 let login = json!({
2398 "auth_mode": "chatgpt",
2399 "tokens": {
2400 "id_token": crate::provider::jwt::unsigned(&json!({
2401 "email": "work@example.com",
2402 "https://api.openai.com/auth": {"chatgpt_account_id": "work-acc"},
2403 })),
2404 "access_token": "a",
2405 "refresh_token": "r",
2406 "account_id": "work-acc",
2407 },
2408 });
2409 store::write_raw(&live.chain, &live.service, &login.to_string()).expect("a login");
2410 assert_eq!(active(&park_facts(&ctx, &state)), ["alpha", "codex/work"]);
2411 }
2412
2413 #[test]
2417 fn codex_has_a_section_only_where_there_is_a_codex() {
2418 let without = evaluate(&facts());
2419 assert!(without.iter().all(|c| !c.code.starts_with("codex_")));
2420
2421 let mut f = facts();
2422 f.codex = with_codex();
2423 let with = evaluate(&f);
2424 let claude = |checks: &[Check]| -> Vec<(String, String, String)> {
2425 checks
2426 .iter()
2427 .filter(|c| !c.code.starts_with("codex_"))
2428 .map(|c| (c.code.to_string(), c.detail.clone(), c.advice.clone()))
2429 .collect()
2430 };
2431 assert_eq!(claude(&without), claude(&with), "Claude Code's checks move");
2432 for code in [
2433 "codex_backend",
2434 "codex_auth_file",
2435 "codex_login",
2436 "codex_version",
2437 "codex_running",
2438 ] {
2439 assert_eq!(check(&with, code).level, Level::Ok, "{code}");
2440 }
2441 assert!(healthy(&with));
2442 let login = check(&with, "codex_login");
2443 assert!(login.detail.contains("w@example.com"), "{}", login.detail);
2444 assert!(
2445 login.detail.contains("0123456789abcdef"),
2446 "{}",
2447 login.detail
2448 );
2449
2450 f.codex = CodexFacts {
2454 enrolled: 1,
2455 ..no_codex()
2456 };
2457 let checks = evaluate(&f);
2458 let file = check(&checks, "codex_auth_file");
2459 assert_eq!(file.level, Level::Warn);
2460 assert!(file.advice.contains("`codex login`"), "{}", file.advice);
2461 assert!(
2462 !file.advice.contains("pitboard use codex/"),
2463 "{}",
2464 file.advice
2465 );
2466 }
2467
2468 #[test]
2476 fn each_codex_store_is_judged_for_what_pitboard_can_do_with_it() {
2477 let judged = |backend: &'static str, enrolled: usize| {
2478 let codex = CodexFacts {
2479 backend,
2480 enrolled,
2481 ..with_codex()
2482 };
2483 codex_checks(&codex)
2484 };
2485
2486 for store in ["keyring", "auto", "secrets", "ephemeral"] {
2487 let checks = judged(store, 1);
2488 let found = check(&checks, "codex_backend");
2489 assert_eq!(found.level, Level::Fail, "{store}");
2490 assert!(found.detail.contains(store), "{}", found.detail);
2491 assert!(
2492 found.advice.contains("config.toml"),
2493 "says which setting to remove: {}",
2494 found.advice
2495 );
2496 assert!(!healthy(&checks), "{store}");
2497 assert!(
2498 checks.iter().all(|c| c.code != "codex_login"),
2499 "a store other than the file is not read, so there is nothing to say about \
2500 its login"
2501 );
2502
2503 let checks = judged(store, 0);
2504 let found = check(&checks, "codex_backend");
2505 assert_eq!(
2506 found.level,
2507 Level::Ok,
2508 "nothing pitboard does is broken for somebody with no Codex accounts: {store}"
2509 );
2510 assert!(found.detail.contains(store), "{}", found.detail);
2511 assert!(found.detail.contains("file store"), "{}", found.detail);
2512 }
2513 for store in ["keyring", "auto", "secrets"] {
2514 let found = judged(store, 1);
2515 let advice = &check(&found, "codex_backend").advice;
2516 assert!(advice.contains("will not touch"), "{advice}");
2517 }
2518 }
2519
2520 #[test]
2525 fn a_codex_login_with_an_api_key_is_a_choice_rather_than_a_fault() {
2526 let api_key = || CodexFacts {
2527 login: Err(CodexLoginTrouble::NotAnAccount(
2528 "Codex is signed in with an API key rather than a ChatGPT account, so there \
2529 is no account login to park or switch"
2530 .into(),
2531 )),
2532 ..with_codex()
2533 };
2534
2535 let checks = codex_checks(&api_key());
2536 let login = check(&checks, "codex_login");
2537 assert_eq!(
2538 login.level,
2539 Level::Ok,
2540 "stated, for somebody with no Codex accounts"
2541 );
2542 assert!(login.detail.contains("API key"), "{}", login.detail);
2543 assert!(checks.iter().all(|c| c.level == Level::Ok));
2544
2545 let checks = codex_checks(&CodexFacts {
2546 enrolled: 2,
2547 ..api_key()
2548 });
2549 let login = check(&checks, "codex_login");
2550 assert_eq!(login.level, Level::Warn);
2551 assert!(healthy(&checks), "nothing is broken");
2552 assert!(!login.detail.contains("cannot"), "{}", login.detail);
2553 assert!(login.advice.contains("`codex login`"), "{}", login.advice);
2554 assert!(
2555 !login.advice.contains("again"),
2556 "nothing to sign in again for: {}",
2557 login.advice
2558 );
2559 }
2560
2561 #[test]
2565 fn a_codex_account_is_judged_in_codex_section_under_a_codex_code() {
2566 let claude_only = {
2567 let mut f = facts();
2568 f.parks = vec![parked("work", Some(NOW + 20 * 86_400))];
2569 f.codex = with_codex();
2570 evaluate(&f)
2571 };
2572
2573 let mut f = facts();
2574 let mut codex = codex_parked("work", Some(NOW - 1));
2575 codex.last_used_at = Some(NOW - 400 * 86_400);
2576 f.parks = vec![parked("work", Some(NOW + 20 * 86_400)), codex];
2577 f.codex = CodexFacts {
2578 enrolled: 1,
2579 ..with_codex()
2580 };
2581 let checks = evaluate(&f);
2582
2583 let about_codex: Vec<&Check> = checks
2584 .iter()
2585 .filter(|c| c.name.contains("codex/"))
2586 .collect();
2587 let codes: Vec<&str> = about_codex.iter().map(|c| c.code).collect();
2588 assert_eq!(codes, ["codex_parked_login", "codex_dormant_account"]);
2589 let section = checks
2590 .iter()
2591 .position(|c| c.code == "codex_backend")
2592 .expect("a Codex section");
2593 let first = checks
2594 .iter()
2595 .position(|c| c.name.contains("codex/"))
2596 .unwrap();
2597 assert!(first > section, "after the Codex heading");
2598
2599 let claude = |checks: &[Check]| -> Vec<(&'static str, String)> {
2600 checks
2601 .iter()
2602 .filter(|c| !c.code.starts_with("codex_"))
2603 .map(|c| (c.code, c.name.clone()))
2604 .collect()
2605 };
2606 assert_eq!(
2607 claude(&checks),
2608 claude(&claude_only),
2609 "Claude Code's section is Claude Code's accounts alone"
2610 );
2611 }
2612
2613 #[test]
2616 fn a_codex_whose_version_cannot_be_read_is_not_called_missing() {
2617 let found = CodexFacts {
2618 program: Some(PathBuf::from("/home/x/.volta/bin/codex")),
2619 version: None,
2620 ..with_codex()
2621 };
2622 let version = check(&codex_checks(&found), "codex_version").detail.clone();
2623 assert!(!version.contains("not found"), "{version}");
2624 assert!(version.contains("/home/x/.volta/bin/codex"), "{version}");
2625
2626 let missing = CodexFacts {
2627 program: None,
2628 version: None,
2629 ..with_codex()
2630 };
2631 let version = check(&codex_checks(&missing), "codex_version")
2632 .detail
2633 .clone();
2634 assert!(version.starts_with("not found here"), "{version}");
2635 }
2636
2637 #[test]
2641 fn a_version_is_read_out_of_each_way_codex_is_installed() {
2642 use std::os::unix::fs::symlink;
2643
2644 let root = std::env::temp_dir().join(format!(
2645 "pitboard-doctor-codex-version-{}-{:?}",
2646 std::process::id(),
2647 std::thread::current().id()
2648 ));
2649 let _ = std::fs::remove_dir_all(&root);
2650 struct Scratch(std::path::PathBuf);
2651 impl Drop for Scratch {
2652 fn drop(&mut self) {
2653 let _ = std::fs::remove_dir_all(&self.0);
2654 }
2655 }
2656 let _guard = Scratch(root.clone());
2657 let place = |at: &str| {
2658 let path = root.join(at);
2659 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2660 std::fs::write(&path, "").unwrap();
2661 path
2662 };
2663
2664 let standalone =
2665 place("codex/packages/standalone/releases/0.154.0-aarch64-apple-darwin/bin/codex");
2666 std::fs::write(
2668 standalone.with_file_name("package.json"),
2669 r#"{"name": "@openai/codex", "version": "9.9.9"}"#,
2670 )
2671 .unwrap();
2672 let link = root.join("bin/codex");
2673 std::fs::create_dir_all(link.parent().unwrap()).unwrap();
2674 symlink(&standalone, &link).unwrap();
2675 assert_eq!(codex_version(&link).as_deref(), Some("0.154.0"));
2676
2677 let cask = place("Caskroom/codex/0.153.2/codex-aarch64-apple-darwin");
2678 assert_eq!(codex_version(&cask).as_deref(), Some("0.153.2"));
2679
2680 let npm = place("lib/node_modules/@openai/codex/bin/codex.js");
2681 std::fs::write(
2682 root.join("lib/node_modules/@openai/codex/package.json"),
2683 r#"{"name": "@openai/codex", "version": "0.150.1"}"#,
2684 )
2685 .unwrap();
2686 assert_eq!(codex_version(&npm).as_deref(), Some("0.150.1"));
2687
2688 let shim = place("volta/bin/volta-shim");
2690 std::fs::write(
2691 root.join("package.json"),
2692 r#"{"name": "@openai/codex", "version": "1.2.3"}"#,
2693 )
2694 .unwrap();
2695 assert_eq!(codex_version(&shim), None);
2696 }
2697
2698 #[test]
2699 fn a_codex_login_anybody_can_read_or_nobody_can_parse_is_said() {
2700 let mut codex = with_codex();
2701 codex.auth_mode = Some(0o644);
2702 let checks = codex_checks(&codex);
2703 let file = check(&checks, "codex_auth_file");
2704 assert_eq!(file.level, Level::Warn);
2705 assert!(file.detail.contains("mode 644"), "{}", file.detail);
2706 assert!(file.advice.contains("chmod 600"), "{}", file.advice);
2707
2708 codex.auth_mode = Some(0o600);
2709 codex.login = Err(CodexLoginTrouble::Unusable(
2710 "its id token is not readable".into(),
2711 ));
2712 assert_eq!(
2713 check(&codex_checks(&codex), "codex_login").level,
2714 Level::Warn
2715 );
2716 codex.enrolled = 2;
2717 let checks = codex_checks(&codex);
2718 let login = check(&checks, "codex_login");
2719 assert_eq!(login.level, Level::Fail);
2720 assert!(login.detail.contains("not readable"), "{}", login.detail);
2721 }
2722
2723 #[test]
2727 fn running_codex_processes_are_reported_as_a_fact() {
2728 use crate::holder::classify;
2729 use crate::process::Process;
2730 let at = |pid: u32, path: &str| Process {
2731 pid,
2732 path: PathBuf::from(path),
2733 };
2734 let holders = crate::provider::codex::holders::HOLDERS;
2735 let mut codex = with_codex();
2736 codex.running = Some(classify(&[at(4321, "codex"), at(99, "codex")], holders));
2737 let checks = codex_checks(&codex);
2738 let running = check(&checks, "codex_running");
2739 assert_eq!(running.level, Level::Ok);
2740 assert!(
2741 running
2742 .detail
2743 .starts_with("2 `codex` sessions (pid 4321, 99)"),
2744 "{}",
2745 running.detail
2746 );
2747 assert!(
2748 running.detail.contains("started again"),
2749 "{}",
2750 running.detail
2751 );
2752 assert_eq!(
2753 running.advice,
2754 "Quit them and start again to take a switch."
2755 );
2756
2757 let many: Vec<Process> = (1..=23).map(|pid| at(pid, "codex")).collect();
2758 codex.running = Some(classify(&many, holders));
2759 let detail = check(&codex_checks(&codex), "codex_running").detail.clone();
2760 assert!(detail.starts_with("23 `codex` sessions"), "{detail}");
2761 assert!(detail.contains("1, 2, 3 and 20 more"), "{detail}");
2762
2763 codex.running = Some(classify(
2764 &[at(
2765 7,
2766 "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/\
2767 Contents/MacOS/codex",
2768 )],
2769 holders,
2770 ));
2771 let checks = codex_checks(&codex);
2772 let app = check(&checks, "codex_running");
2773 assert!(
2774 app.detail.starts_with("the ChatGPT app (pid 7)"),
2775 "{}",
2776 app.detail
2777 );
2778 assert_eq!(
2779 app.advice,
2780 "Quit ChatGPT with Command-Q and open it again to take a switch."
2781 );
2782
2783 codex.running = None;
2784 assert!(
2785 check(&codex_checks(&codex), "codex_running")
2786 .detail
2787 .contains("could not tell")
2788 );
2789 }
2790
2791 #[test]
2795 fn doctor_and_a_switch_see_the_same_codex_running() {
2796 use crate::store::memory::MemoryHost;
2797 let host = MemoryHost::new();
2798 host.runs_at(
2799 "codex",
2800 &[
2801 "codex",
2802 "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/\
2803 MacOS/codex",
2804 ],
2805 );
2806 let ctx = Context::new(std::env::temp_dir()).with_memory_stores(host);
2807 let seen = running_codex(&ctx).expect("readable");
2808 assert_eq!(
2809 seen.iter().map(|h| h.holder.kind).collect::<Vec<_>>(),
2810 ["chatgpt_app", "session"]
2811 );
2812 assert_eq!(Some(seen), switch::still_holding(&ctx, ProviderId::Codex));
2813 }
2814
2815 #[test]
2816 fn every_codex_failure_and_warning_tells_the_user_something() {
2817 for backend in ["file", "keyring", "auto", "ephemeral"] {
2818 let codex = CodexFacts {
2819 backend,
2820 enrolled: 1,
2821 auth_mode: Some(0o666),
2822 login: Err(CodexLoginTrouble::Unusable("unreadable".into())),
2823 ..with_codex()
2824 };
2825 for c in codex_checks(&codex) {
2826 assert!(c.code.starts_with("codex_"), "{}", c.code);
2827 if c.level != Level::Ok {
2828 assert!(!c.advice.is_empty(), "{} has no advice", c.code);
2829 }
2830 }
2831 }
2832 }
2833
2834 #[test]
2836 fn a_codex_login_is_redacted_from_a_report() {
2837 let mut f = facts();
2838 f.codex = with_codex();
2839 let ctx = Context::new(PathBuf::from("/home/x"));
2840 let sheet = redaction_for(&ctx, &f);
2841 let login = check(&evaluate(&f), "codex_login").detail.clone();
2842 let hidden = sheet.over(&login);
2843 for secret in ["w@example.com", "work-account", "0123456789abcdef"] {
2844 assert!(login.contains(secret), "{login}");
2845 assert!(!hidden.contains(secret), "{hidden}");
2846 }
2847 }
2848
2849 #[test]
2852 fn codex_facts_are_read_off_the_disk() {
2853 use std::os::unix::fs::PermissionsExt;
2854
2855 let root = std::env::temp_dir().join(format!(
2856 "pitboard-doctor-codex-{}-{:?}",
2857 std::process::id(),
2858 std::thread::current().id()
2859 ));
2860 let _ = std::fs::remove_dir_all(&root);
2861 struct Scratch(std::path::PathBuf);
2862 impl Drop for Scratch {
2863 fn drop(&mut self) {
2864 let _ = std::fs::remove_dir_all(&self.0);
2865 }
2866 }
2867 let _guard = Scratch(root.clone());
2868 let home = root.join("codex");
2869 let program = root.join("bin/codex");
2872 let ctx = Context::new(root.clone())
2873 .with_pitboard_home(root.join(".pitboard"))
2874 .with_codex_home(home.to_string_lossy().into())
2875 .with_codex_program(program.clone());
2876
2877 let absent = codex_facts(&ctx, None);
2878 assert!(!absent.present);
2879 assert_eq!(absent.backend, "file");
2880 assert_eq!(absent.auth_mode, None);
2881 assert!(matches!(absent.login, Ok(None)));
2882 assert_eq!(absent.program, None, "the one named, and it is not there");
2883 assert_eq!(absent.version, None);
2884
2885 let installed = root.join("releases/0.154.0-aarch64-apple-darwin/bin/codex");
2886 std::fs::create_dir_all(installed.parent().unwrap()).unwrap();
2887 std::fs::write(&installed, "").unwrap();
2888 std::fs::set_permissions(&installed, std::fs::Permissions::from_mode(0o755)).unwrap();
2890 std::fs::create_dir_all(program.parent().unwrap()).unwrap();
2891 std::os::unix::fs::symlink(&installed, &program).unwrap();
2892 let found = codex_facts(&ctx, None);
2893 assert_eq!(found.program.as_deref(), Some(program.as_path()));
2894 assert_eq!(found.version.as_deref(), Some("0.154.0"));
2895
2896 std::fs::create_dir_all(&home).expect("a Codex home");
2897 let auth = home.join("auth.json");
2898 std::fs::write(
2899 &auth,
2900 json!({
2901 "auth_mode": "chatgpt",
2902 "tokens": {
2903 "id_token": crate::provider::jwt::unsigned(&json!({
2904 "email": "w@example.com",
2905 "https://api.openai.com/auth": {"chatgpt_account_id": "work-acc"},
2906 })),
2907 "access_token": "a",
2908 "refresh_token": "r",
2909 "account_id": "work-acc",
2910 },
2911 })
2912 .to_string(),
2913 )
2914 .expect("a login");
2915 std::fs::set_permissions(&auth, std::fs::Permissions::from_mode(0o644)).unwrap();
2916 let found = codex_facts(&ctx, None);
2917 assert!(found.present);
2918 assert_eq!(found.auth_mode, Some(0o644));
2919 let login = found.login.expect("readable").expect("there");
2920 assert_eq!(login.email, "w@example.com");
2921 assert_eq!(login.fingerprint.len(), 16);
2922
2923 std::fs::write(
2925 &auth,
2926 json!({"auth_mode": "apikey", "OPENAI_API_KEY": "sk-not-a-real-key"}).to_string(),
2927 )
2928 .unwrap();
2929 match codex_facts(&ctx, None).login {
2930 Err(CodexLoginTrouble::NotAnAccount(why)) => {
2931 assert!(why.contains("API key"), "{why}");
2932 assert!(!why.contains("sk-"), "never the key itself: {why}");
2933 }
2934 Err(other) => panic!("an API key is not an account: {other:?}"),
2935 Ok(_) => panic!("an API key is not an account"),
2936 }
2937
2938 let mixed = json!({
2941 "auth_mode": "chatgpt",
2942 "tokens": {
2943 "id_token": crate::provider::jwt::unsigned(&json!({
2944 "email": "w@example.com",
2945 "https://api.openai.com/auth": {"chatgpt_account_id": "work-acc"},
2946 })),
2947 "access_token": "a",
2948 "refresh_token": "r",
2949 "account_id": "home-acc",
2950 },
2951 });
2952 std::fs::write(&auth, mixed.to_string()).unwrap();
2953 assert!(
2954 matches!(
2955 codex_facts(&ctx, None).login,
2956 Err(CodexLoginTrouble::Unusable(_))
2957 ),
2958 "a login mixing two accounts is one pitboard cannot use"
2959 );
2960
2961 std::fs::write(
2962 home.join("config.toml"),
2963 "cli_auth_credentials_store = \"ephemeral\"\n",
2964 )
2965 .expect("a config");
2966 let ephemeral = codex_facts(&ctx, None);
2967 assert_eq!(ephemeral.backend, "ephemeral");
2968 assert!(
2969 matches!(ephemeral.login, Ok(None)),
2970 "a store pitboard does not handle is not read"
2971 );
2972
2973 std::fs::write(
2976 home.join("config.toml"),
2977 "cli_auth_credentials_store = \"auto\"\n[features]\nsecret_auth_storage = true\n",
2978 )
2979 .expect("a config");
2980 assert_eq!(codex_facts(&ctx, None).backend, "secrets");
2981 }
2982
2983 #[test]
2984 fn a_version_is_read_out_of_the_path_codex_is_installed_at() {
2985 assert!(looks_like_a_version("0.154.0"));
2986 assert!(!looks_like_a_version("releases"));
2987 assert!(!looks_like_a_version("0.154"));
2988 assert!(!looks_like_a_version("v0.154.0"));
2989 }
2990
2991 #[test]
2994 fn a_machine_with_only_codex_is_not_judged_on_claude_code() {
2995 let mut facts = facts();
2996 facts.claude_present = false;
2997 facts.config = Err(crate::error::Error::ClaudeConfigMissing {
2998 path: PathBuf::from("/nowhere/.claude.json"),
2999 });
3000 facts.codex.present = true;
3001 let checks = evaluate(&facts);
3002 for own in CLAUDE_CODES_OWN {
3003 assert!(
3004 checks.iter().all(|c| c.code != *own),
3005 "{own} is about Claude Code, which is not here"
3006 );
3007 }
3008 assert!(
3009 checks.iter().any(|c| c.code == "state"),
3010 "pitboard's own still is"
3011 );
3012 assert!(checks.iter().any(|c| c.code.starts_with("codex_")));
3013 }
3014
3015 #[test]
3017 fn a_machine_with_neither_tool_still_hears_about_claude_code() {
3018 let mut facts = facts();
3019 facts.claude_present = false;
3020 let checks = evaluate(&facts);
3021 assert!(checks.iter().any(|c| c.code == "config_file"));
3022 }
3023
3024 #[test]
3026 fn a_signed_out_credential_is_said_to_be_one() {
3027 let mut facts = facts();
3028 facts.credential = Ok(Some(serde_json::json!({"mcpOAuth": {"server": {}}})));
3029 let check = judge_credential(&facts);
3030 assert!(matches!(check.level, Level::Warn), "{}", check.detail);
3031 assert!(check.detail.contains("signed out"), "{}", check.detail);
3032 }
3033}