Skip to main content

pitboard_core/
doctor.rs

1//! `pitboard doctor`: check, on this machine, that what pitboard relies on about Claude Code
2//! still holds, and say which assumption broke when one has. Gathering is kept apart from
3//! judging so every judgement can be tested.
4//!
5//! Codex has a section of its own, after everything about Claude Code, and only on a
6//! machine where Codex has been run or has accounts enrolled. Its checks are coded
7//! `codex_...` so a program can tell them from Claude Code's, which read exactly as they did
8//! before there was a second tool.
9
10use 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    /// Stable, snake_case, safe for a program to branch on.
32    pub code: &'static str,
33    pub name: String,
34    pub level: Level,
35    pub detail: String,
36    /// What the user should do. Empty when there is nothing to do.
37    pub advice: String,
38}
39
40/// Everything read from the machine, so judging it touches nothing. Each check that is
41/// added reads something more, so it cannot be built outside this crate.
42#[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    /// What the live login costs against the store's ceiling, where there is one.
56    pub credential_cost: Option<store::Cost>,
57    /// What is taking up the room, largest first: (what it is, bytes).
58    pub credential_parts: Vec<(String, usize)>,
59    pub home: PathBuf,
60    pub home_mode: Option<u32>,
61    /// Anything on this disk holding a login that somebody other than the owner can read:
62    /// (path, mode). Empty on a machine with a keychain and a tidy plaintext fallback,
63    /// and the whole security story on a machine without one.
64    pub readable_by_others: Vec<(String, u32)>,
65    pub machine_id_known: bool,
66    /// `CLAUDE_CODE_HOVER_REST`, which switches on the successor credential backend.
67    pub hover_rest_env: bool,
68    /// Claude Code's supervisor daemon, where one has ever run for this slot.
69    pub daemon: Option<daemon::Daemon>,
70    /// Names pitboard wrote down before creating a park and has not resolved yet.
71    pub pending_parks: Vec<String>,
72    /// Which Claude Code is installed here, read off disk.
73    pub claude_version: Option<String>,
74    /// Every reason a session here would authenticate as something other than the stored
75    /// login, read from settings files as well as from this process's environment.
76    pub auth_overrides: Vec<crate::settings::Override>,
77    /// Accounts pitboard is not asking Anthropic about yet, and for how long: (uuid, seconds).
78    pub asking_held: Vec<(String, i64)>,
79    pub state: Result<State, Error>,
80    /// Each enrolled account's parked login, read back from the vault.
81    pub parks: Vec<ParkFact>,
82    pub interrupted: bool,
83    /// What is read about Codex here, for the section that is about it.
84    pub codex: CodexFacts,
85    /// Whether Claude Code is on this machine at all: installed, run once, signed in, or
86    /// holding enrolled accounts. A machine that uses only Codex is not told Claude Code is
87    /// broken.
88    pub claude_present: bool,
89    /// The daily renewal schedule, where this home has one installed.
90    pub schedule: Option<ScheduleFact>,
91    pub now: i64,
92}
93
94/// The daily renewal schedule as it is installed, read from the file pitboard wrote and
95/// never by asking the scheduler.
96pub struct ScheduleFact {
97    /// The file the platform's scheduler reads.
98    pub path: PathBuf,
99    /// The pitboard it runs, where the file names one the way pitboard writes it.
100    pub program: Option<PathBuf>,
101    /// Whether that pitboard is still there to be run.
102    pub program_found: bool,
103}
104
105/// What is read about Codex CLI on this machine.
106///
107/// Read without running it and without touching a keychain item Codex created for itself:
108/// its home, its configuration, the one file it keeps its login in by default, and which
109/// `codex` processes are running.
110pub struct CodexFacts {
111    /// `CODEX_HOME`, or `~/.codex`.
112    pub home: PathBuf,
113    /// Whether that directory exists. It does once Codex has been run here, and not before.
114    pub present: bool,
115    /// How many Codex accounts pitboard has enrolled.
116    pub enrolled: usize,
117    /// Where Codex is configured to keep its login, in Codex's own words:
118    /// `cli_auth_credentials_store`'s `file`, `keyring`, `auto` or `ephemeral`, or `secrets`
119    /// for a keychain store with `[features] secret_auth_storage`, which Codex has no one
120    /// word for.
121    pub backend: &'static str,
122    /// Where the default store keeps it.
123    pub auth_file: PathBuf,
124    /// That file's mode, where there is such a file.
125    pub auth_mode: Option<u32>,
126    /// Whose login the file holds, or why that could not be told. `Ok(None)` for no file,
127    /// and for a store pitboard does not read.
128    pub login: Result<Option<CodexLogin>, CodexLoginTrouble>,
129    /// Where the `codex` pitboard would run is, where it is anywhere.
130    pub program: Option<PathBuf>,
131    /// Which Codex that is, read off the path it is installed at. `None` both where there
132    /// is no `codex` and where its path does not say; [`CodexFacts::program`] tells which.
133    pub version: Option<String>,
134    /// Every `codex` running as this user, by pid. `None` where that could not be asked.
135    pub running: Option<Vec<u32>>,
136}
137
138/// Whose a Codex login is, as its own ID token says. Nothing in here is a secret: the
139/// fingerprint is a handle on the refresh token, never the token.
140pub struct CodexLogin {
141    pub email: String,
142    pub account_id: String,
143    /// Empty where the login holds no refresh token.
144    pub fingerprint: String,
145}
146
147/// Why Codex's login names no account pitboard can handle.
148///
149/// Two answers rather than one, because they call for different things. A login signed in
150/// some way pitboard does not switch, such as with an API key, is somebody's choice and
151/// nothing is wrong with it; a login that cannot be read, or that mixes two accounts, is.
152#[derive(Debug)]
153pub enum CodexLoginTrouble {
154    /// Signed in, and not with an account pitboard parks or switches. Says why, in Codex's
155    /// terms.
156    NotAnAccount(String),
157    /// Unreadable, or read and not one account's login.
158    Unusable(String),
159}
160
161pub struct ParkFact {
162    /// Which tool the account belongs to, which is what decides how it is named back to a
163    /// person: bare for Claude Code, `codex/work` for Codex.
164    pub provider: ProviderId,
165    pub label: String,
166    /// The name a command on this machine takes for it: qualified where another tool has an
167    /// account of the same name, since a bare one would then be ambiguous.
168    pub name: String,
169    pub active: bool,
170    /// When this account was last switched to, where that is recorded.
171    pub last_used_at: Option<i64>,
172    pub park: Option<Park>,
173    /// Why it cannot be read back, if it cannot.
174    pub unreadable: Option<String>,
175}
176
177impl ParkFact {
178    /// The account's name as a command here would take it.
179    fn typed(&self) -> String {
180        self.name.clone()
181    }
182}
183
184fn park_facts(ctx: &Context, state: &State) -> Vec<ParkFact> {
185    // Who each tool's own record says is signed in, asked once per tool and only of a tool
186    // that has accounts here. Offline for every tool: Claude Code's config, a Codex login's
187    // own claims. Deciding it from Claude Code's config alone read a signed-in Codex
188    // account as one with nothing parked to switch to.
189    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            // What the account's own tool says, when it says anything. pitboard's own
206            // record of its last switch says nothing about a sign-in made elsewhere.
207            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
283/// The schedule, where this home has one.
284fn 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
299/// What is taking up the room in a credential document, largest first.
300///
301/// A login that will not fit is almost never the login: on one real machine the OAuth block
302/// was 506 bytes and eleven MCP server tokens were 3679. Saying "8503 of 4032 bytes" leaves
303/// a person to guess which of those to do something about, and the answer is in the
304/// document pitboard has already read.
305fn 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            // The usual culprit, and the one a person can act on server by server.
314            ("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
330/// Every file on this machine that holds a usable login and is not private to its owner.
331///
332/// Claude Code has no keyring backend outside macOS and Windows, so on Linux its own login
333/// is a plaintext file it chmods to 0600, and pitboard's parked logins are plaintext files
334/// beside it. That is not pitboard weakening anything, but it does mean the only thing
335/// between a parked OAuth token and everyone else with an account on the machine is a mode
336/// bit, and a mode bit is something a backup restore, a `cp`, an rsync or a careless umask
337/// quietly changes. So it is looked at rather than assumed.
338fn loose_logins(ctx: &Context) -> Vec<(String, u32)> {
339    // Group and other, read or write. Anything there is somebody who is not the owner.
340    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
362/// What is read about Codex here: its home, its configuration, the file it keeps its login
363/// in by default, what is installed and what is running.
364fn 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    // The program pitboard would run, as the context names it and where the context looks:
369    // an app started from Finder has no shell `PATH` and passes the login shell's, and a
370    // test names a program of its own.
371    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        // Read only from the default store. A keychain item Codex created for itself
383        // trusts the `codex` binary alone, and reading it would put a permission prompt in
384        // front of somebody who only asked for a diagnosis.
385        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
398/// Codex's own word for where it keeps its login, as `cli_auth_credentials_store` spells
399/// it in `config.toml`.
400///
401/// Every store by name, with no catch-all: a store this build does not know is a compile
402/// error here, rather than a report calling it something the configuration does not say.
403fn 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        // A keychain store with `[features] secret_auth_storage`: an encrypted file whose key
410        // is in the keychain. Codex has no one word for it, so it gets the name of the
411        // directory it keeps the file in.
412        codex::Backend::Secrets => "secrets",
413    }
414}
415
416/// Whose login Codex's file holds, from the login's own ID token and with no network call.
417fn 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    // Whether it is one account's login at all, which is the question a park asks of it.
424    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
438/// Which Codex `program` is, read off the path it resolves to and never by running it.
439///
440/// Running `codex --version` would start the program this is trying to describe. Each way
441/// Codex is installed puts the version within two directories of the program it resolves
442/// to, and only there is looked at: the standalone installer's
443/// `releases/0.154.0-<target>/bin/codex`, Homebrew's `Caskroom/codex/0.154.0/`, and npm's
444/// `@openai/codex/package.json` beside the `bin` it runs from. Directory names are read
445/// before any file is opened, so a standalone install inside `~/.codex` is named without
446/// reading anything in it, and nothing further up the path is ever read.
447fn 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/// Every `codex` running as this user, by pid.
481///
482/// Asked of `pgrep`, with a deadline, because macOS offers no way to list processes that
483/// does not mean either a helper or a system call pitboard does not otherwise make.
484#[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        // Found some, or found none: both answers.
496        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/// Every `codex` running as this user, by pid, read out of `/proc`.
507#[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    // Claude Code's own checks, where there is a Claude Code, or where there is no other
566    // tool either: a new machine is told what to do first, as it always was. A machine
567    // that uses only Codex is not told to run a program it does not use.
568    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    // A login that grows past the ceiling cannot be switched at all, and it grows by things
632    // done elsewhere, so it is worth saying before the day it refuses.
633    if let Some(price) = facts.credential_cost {
634        let (bytes, limit) = (price.needs, price.limit);
635        checks.push(if price.refused() {
636            // The refusal was asked for. Say what it will cost when the day comes rather
637            // than only on the day itself.
638            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            // Not a fault: `security` takes this much of a command from stdin and no more,
651            // and the argument line is the only other way it offers. Claude Code writes
652            // this same login that way itself on every refresh.
653            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        // The one wrong diagnosis in this file. If Claude Code's config names somebody as
690        // signed in, "nothing is signed in" is not an observation, it is pitboard looking
691        // in the wrong place, and it is the failure that would follow Claude Code moving
692        // where it keeps a login.
693        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                // The one unreadable state that has a command of its own.
822                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    // Claude Code's accounts here; every other tool's go in its own section, so a program
832    // reading codes can tell them apart and Claude Code's column is Claude Code's alone.
833    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    // Only where there is a Codex to say something about. A machine that has never run it
880    // reads exactly as it did before pitboard knew Codex existed.
881    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
890/// The checks that are about Claude Code's own files and settings, rather than about
891/// pitboard or the machine. Named once, so a new one is added here or is always shown.
892const 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
906/// The code an account's check goes under: Claude Code's as it always was, and every other
907/// tool's in that tool's own namespace, so `codex_` is enough to find everything about
908/// Codex.
909fn 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
916/// A refresh token's own life, from a real renewal answer: thirty days. An account that has
917/// been parked for longer than that without being switched to has had its login kept alive
918/// purely by pitboard, through at least one whole token lifetime, for nobody.
919const A_TOKEN_LIFETIME: i64 = 30 * 86_400;
920
921/// An account nobody has come back to.
922///
923/// pitboard renews a parked login for as long as the account is enrolled, so one enrolled
924/// once and never used again keeps a live, continuously rotated refresh token on this
925/// machine indefinitely. That is a defensible thing to do and an indefensible thing to do
926/// silently. Nothing is dropped on a timer pitboard chose: the threshold here is the
927/// token's own lifetime, and all it does is say so.
928fn 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            // What `/logout` leaves: the account's keys gone, the machine's still there. That
960            // is nobody signed in, which the switch and enrolment already read it as.
961            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
1013/// A parked login this close to expiring is worth renewing now.
1014pub 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
1057/// Claude Code's successor credential backend.
1058///
1059/// Measured in 2.1.278: the flag does not move the login out of the keychain. The live
1060/// chain is built as keychain-with-plaintext-fallback either way, and the flag only decides
1061/// what backs the fallback half, and only for a caller that hands a backend in. An ordinary
1062/// `claude` hands none in, so the fallback stays `<storage dir>/.credentials.json`. The
1063/// combination worth saying something about is the flag on *and* the login living in the
1064/// fallback, because that is the one case where what pitboard reads may not be what a
1065/// session reads.
1066fn 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
1094/// The three things taking up the most room, for a check that would otherwise leave a
1095/// person guessing which of them to do something about.
1096fn 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
1109/// Whether pitboard is waiting before asking Anthropic about anything.
1110///
1111/// Ordinarily nothing is waiting: an account is asked about whenever its tightest limit
1112/// could have moved by a percentage point, and that is the floor rather than a wait. A wait
1113/// means Anthropic asked for less traffic or could not be reached, and a person watching a
1114/// number not move deserves to know which.
1115fn judge_asking(facts: &Facts) -> Check {
1116    // Which services pitboard asks, named by the tools that have accounts here, and which
1117    // of them are being held back: a hold is a service's answer, so blaming the wrong one
1118    // sends somebody to look at a service that is answering normally.
1119    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
1182/// Whether moving the stored login would change anything a session sees.
1183///
1184/// Claude Code resolves this from layered settings, so a managed policy or a line in a
1185/// person's own `settings.json` can make every switch pitboard performs a no-op. Read from
1186/// files rather than from this process's environment, because an app launched from Finder
1187/// has no environment to read and is the surface most likely to be used on a machine that
1188/// needs the answer.
1189fn 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
1212/// Which Claude Code is installed, against which one pitboard's facts were read.
1213///
1214/// Stated rather than warned about. Claude Code ships several times a week, so a mismatch
1215/// is the ordinary state of the world within days of a release and warning about it would
1216/// be noise on every machine. What is worth a warning is an assumption that has actually
1217/// stopped holding, which is a probe's job and not a version number's.
1218fn 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
1239/// A name written down before a park was created, still unresolved. Ordinarily there is
1240/// nothing here: the next change resolves every one of them. What is left is an item that
1241/// could not be read, which on macOS is a locked keychain and nothing worse.
1242fn 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
1256/// The schedule runs a pitboard by its path, and the path can stop leading anywhere after
1257/// it was written: an upgrade that deletes the version it named, an app moved or thrown
1258/// away. The scheduler then fails once a day where nobody looks, and the parked logins it
1259/// was keeping alive run out. Nothing is said where there is no schedule.
1260///
1261/// An app up to 0.3.0 scheduled itself rather than a command line, and that app renews
1262/// nothing when it is started with `renew`, so that is said as well.
1263fn 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
1299/// How to write the schedule again. There is an app only on macOS.
1300fn 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
1310/// Claude Code's supervisor daemon is a second writer of the login, on a schedule nobody
1311/// typed. It takes the same write lock, so it cannot write underneath a switch, but a
1312/// person reading a diagnosis should be able to see that it is there.
1313fn 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
1337/// Codex's section: where it keeps its login, whether pitboard can read it, its accounts,
1338/// and what a switch cannot reach.
1339///
1340/// Every code starts `codex_`. Three kinds of finding, judged differently:
1341///
1342/// - A fault, such as a login nobody can read. It stops pitboard handling an account it has
1343///   enrolled, so it fails where there are Codex accounts, and is a warning where there are
1344///   none, because something is wrong with Codex even if nothing pitboard does is broken.
1345/// - A choice, such as a keychain store or an API key. Nothing is wrong with it. Where Codex
1346///   accounts are enrolled it is still said: a keychain store puts every one of them out of
1347///   reach, which fails, and an API key only means there is nothing to switch from until
1348///   somebody signs in with an account, which is worth a look. Where none are, it is stated
1349///   and nothing more, so somebody who uses pitboard for Claude Code alone is not handed a
1350///   warning about a setting they chose and pitboard has no business with.
1351/// - A fact, such as how many sessions are running, which is only ever stated.
1352fn 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        // A choice, and one that leaves nothing pitboard can park or switch.
1378        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    // The file and what is in it are only worth a word where the file is the store: a
1406    // keychain store deletes it on purpose.
1407    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            // No file, which the check above has already said.
1457            Ok(None) => {}
1458            // Signed in on purpose some way pitboard does not switch. Nothing to sign in
1459            // again for, and nothing broken.
1460            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    // Each Codex account, the way Claude Code's are judged, in this section and under this
1484    // section's codes.
1485    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
1506/// A few pids and how many more, because somebody with twenty sessions open needs to know
1507/// there are twenty, not which twenty.
1508fn 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
1517/// Which Codex is installed, against which one pitboard's facts were read. Stated rather
1518/// than warned about, for the reason Claude Code's is.
1519fn 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            // Installed some way that does not put the version in its path, such as behind
1527            // a version manager's shim. Found is not the same as missing.
1528            (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
1543/// The checks, and where Claude Code's files were found, for a program to read rather than
1544/// parse out of the checks' wording.
1545pub struct Diagnosis {
1546    pub checks: Vec<Check>,
1547    pub environment: Value,
1548    /// What must not leave this machine in a report. A person reading their own diagnosis
1549    /// should see their own email; the thing they paste somewhere else should not carry it.
1550    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's, beside Claude Code's rather than among them, so nothing a program
1565            // already reads here moves.
1566            "codex": {
1567                "home": facts.codex.home,
1568                "present": facts.codex.present,
1569                "backend": facts.codex.backend,
1570                // Unknown for a store pitboard does not read, rather than a guess.
1571                "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
1579/// Everything in a diagnosis that names a person or an account.
1580///
1581/// The salt is this machine and this moment, so identifiers line up inside one report and
1582/// two reports from one machine do not line up with each other. It is never printed.
1583fn 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            // A Codex account's workspace names the organisation it belongs to.
1611            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    // Codex's live login, which need not be an account anybody enrolled.
1621    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    // A fingerprint is not a token, and it still identifies one login across reports.
1628    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
1646/// No check failed. Warnings are advice; a failure means an assumption broke.
1647pub 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    /// A machine that has never run Codex.
1705    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    /// Codex's section for a machine with no Codex accounts parked.
1721    fn codex_checks(codex: &CodexFacts) -> Vec<Check> {
1722        judge_codex(codex, &[], NOW)
1723    }
1724
1725    /// A machine whose Codex is signed in, with its login where it should be.
1726    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    /// A Codex account's park, named as a command here would take it.
1762    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    /// What the check above is given, read off a real disk.
1786    ///
1787    /// Everything else here hands `evaluate` its facts, so nothing until now had ever
1788    /// looked at a file's mode. A gatherer that returns an empty list whatever the disk
1789    /// says would pass every one of those tests.
1790    #[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    /// On a machine with no keychain every parked login is a plaintext OAuth token in a
1830    /// file, and the only thing between it and everyone else with an account here is a
1831    /// mode bit. A backup restore, a `cp -r`, an rsync or a careless umask changes one
1832    /// quietly, and nothing else in pitboard would ever mention it.
1833    #[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        // The advice has to be something a person can run, not a description of a problem.
1848        assert!(found.advice.contains("chmod go-rwx"), "{}", found.advice);
1849        assert!(!healthy(&checks));
1850    }
1851
1852    /// A document whose `claudeAiOauth` is there and is not an object is a shape that
1853    /// moved, which nothing should be written into until it is understood.
1854    #[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            // On the keychain, the flag changes nothing pitboard reads.
1907            let checks = evaluate(&f);
1908            assert_eq!(check(&checks, "storage_v5").level, Level::Ok);
1909            // In the fallback, it is the one case worth saying something about.
1910            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        // The fallback store on its own is not the successor backend.
1922        f.backend = Ok(store::Backend::File);
1923        let checks = evaluate(&f);
1924        assert_eq!(check(&checks, "storage_v5").level, Level::Ok);
1925    }
1926
1927    /// The failure that would follow Claude Code moving where it keeps a login: not an
1928    /// absence, a mismatch, and the difference is what decides whether the advice is "sign
1929    /// in" or "pitboard is looking in the wrong place".
1930    #[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        // Nobody signed in at all is an ordinary state with an ordinary answer.
1940        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    /// An account nobody has come back to keeps a live, continuously rotated refresh token
1979    /// on this machine for as long as it stays enrolled. Nothing is dropped on a timer
1980    /// pitboard chose; the threshold is the token's own lifetime and all it does is say so.
1981    /// A login that will not fit is almost never the login. On one real machine the OAuth
1982    /// block was 506 bytes and eleven MCP server tokens were 3679, and the check said
1983    /// "8503 of 4032 bytes" and left the person to guess which of those to act on.
1984    #[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        // One server is not worth breaking apart; the key says it already.
2027        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        // A month is the token's own life. Inside it, there is nothing to say.
2056        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        // Neither is one that is signed in, nor one holding nothing.
2063        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    /// The way back works from the command line everywhere, and names the app only where
2160    /// there is one.
2161    #[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    /// What the check above is given, read off a real disk: a schedule written the way
2174    /// `pitboard schedule install` writes it, whose pitboard is then taken away.
2175    #[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    /// The advice has to be something a person can type and have it act on the right
2312    /// account. `pitboard enroll work --sign-in` about a Codex account would enroll a
2313    /// Claude Code one.
2314    #[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        // Both tools have a `work`, so a bare `work` would be refused as ambiguous, and the
2320        // name gathered for Claude Code's is the qualified one.
2321        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    /// Whether an account is the one signed in is its own tool's question. Asked of Claude
2358    /// Code's config for every account, a signed-in Codex account read as one with nothing
2359    /// parked to switch to, and the advice was to sign in again.
2360    #[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        // Nothing signed in to Codex: pitboard's own record of its last switch stands in.
2428        assert_eq!(active(&park_facts(&ctx, &state)), ["alpha", "codex/home"]);
2429
2430        // Codex's login names `work`, whatever pitboard last recorded.
2431        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    /// A machine that has never run Codex reads exactly as it did before pitboard knew
2451    /// Codex existed; one that has gets a section of its own, and nothing of Claude Code's
2452    /// moves.
2453    #[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        // Accounts enrolled on a machine whose Codex home has gone still get the section.
2488        // The way back is signing in: a switch refuses while nothing is signed in, so
2489        // advice to switch would send somebody to a command that fails.
2490        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    /// A keychain store is Codex's to use and pitboard's to leave alone, so it is said
2506    /// rather than read, and a store in memory holds nothing to switch. Each is a choice,
2507    /// not a fault: it fails for somebody with Codex accounts enrolled, because every one of
2508    /// them is out of reach, and is only stated for anybody else. It used to be a warning
2509    /// whoever it was, so a person with Codex accounts saw a healthy report on a machine
2510    /// where `pitboard use codex/...` refused, and a person with none was handed something
2511    /// to look at about a setting they chose.
2512    #[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    /// Signed in with an API key is somebody's choice, and nothing about it is broken or
2558    /// unreadable. It used to be reported as a login that "cannot be read", failing the
2559    /// whole report for anybody with Codex accounts and telling them to sign in with
2560    /// `codex` again.
2561    #[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    /// Every check about a Codex account is in Codex's section and under a Codex code, so
2599    /// `codex_` finds everything about Codex. A Codex account's park used to be judged among
2600    /// Claude Code's, under Claude Code's codes and in Claude Code's column.
2601    #[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    /// Found and not named is not missing. A `codex` behind a version manager's shim runs
2651    /// perfectly well from a path that says nothing about its version.
2652    #[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    /// Each way Codex is installed, laid out in a scratch directory, and a shim that names
2675    /// nothing. Only the two directories above the program are looked at, names first, so a
2676    /// standalone install is named without opening any file inside it.
2677    #[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        // A package.json beside it that would say otherwise, to show it is never opened.
2704        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        // Three levels up is too far: nothing further than two directories is read.
2726        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    /// A running codex holds the account it started with for as long as it runs, which is
2761    /// the one thing a switch cannot reach. Said, never warned about: running it is the
2762    /// point of having it.
2763    #[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    /// What a pasted report must not carry, now that it can carry a Codex login too.
2806    #[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    /// What the section is judged on, read off a real disk: a scratch Codex home with a
2821    /// login in it, in the file Codex keeps it in.
2822    #[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        // A `codex` of this test's own. Looked up on `PATH`, it would be this machine's, and
2841        // the standalone installer keeps that inside the real `~/.codex`.
2842        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        // A program is what can be run, as the installer leaves it.
2860        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        // Signed in with an API key: a choice, said in Codex's terms.
2895        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        // One account's tokens under another's id, which a running codex leaves when it
2910        // refreshes in the middle of a switch: not one account's login.
2911        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        // A keychain store with the encrypted-file feature is named as what it is, never as
2945        // plain `keyring`, which the configuration does not say.
2946        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    /// A machine that uses only Codex is not told Claude Code is broken, nor to run a
2963    /// program it does not use. Everything about pitboard itself is still checked.
2964    #[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    /// With neither tool present, a new machine is told what to do first, as it always was.
2987    #[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    /// What `/logout` leaves is nobody signed in, not a login whose shape moved.
2996    #[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}