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