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