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