use crate::context::Context;
use crate::error::Error;
use crate::host::{Access, Kind, Os};
use crate::provider::claude::daemon;
use crate::provider::claude::live as claude_live;
use crate::provider::claude::paths as claude;
use crate::provider::claude::slot;
use crate::provider::codex::paths as codex;
use crate::provider::{LoginKind, ProviderId};
use crate::service::Stored;
use crate::state::{Key, Park, State};
use crate::{home, park, store, switch, time, words};
use serde_json::{Value, json};
use std::fmt::Write as _;
use std::path::PathBuf;
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum Level {
Ok,
Warn,
Fail,
}
#[derive(Debug)]
pub struct Check {
pub code: &'static str,
pub name: String,
pub level: Level,
pub detail: String,
pub advice: String,
}
pub(crate) struct Facts {
pub(crate) os: Os,
pub(crate) security_tool: Option<String>,
pub(crate) config_path: PathBuf,
pub(crate) config: Result<Value, Error>,
pub(crate) config_account: Option<claude::ConfigAccount>,
pub(crate) service: String,
pub(crate) account: String,
pub(crate) default_slot: bool,
pub(crate) storage_dir: String,
pub(crate) backend: Result<store::Backend, store::Error>,
pub(crate) credential_file: PathBuf,
pub(crate) credential: Result<Option<Value>, store::Error>,
pub(crate) credential_cost: Option<store::Cost>,
pub(crate) credential_parts: Vec<(String, usize)>,
pub(crate) home: PathBuf,
pub(crate) home_access: Option<Access>,
pub(crate) readable_by_others: Vec<(String, Access)>,
pub(crate) machine_id_known: bool,
pub(crate) hover_rest_env: bool,
pub(crate) daemon: Option<daemon::Daemon>,
pub(crate) pending_parks: Vec<String>,
pub(crate) claude_version: Option<String>,
pub(crate) auth_overrides: Vec<crate::settings::Override>,
pub(crate) auth_unread: Option<&'static str>,
pub(crate) asking_held: Vec<(String, i64)>,
pub(crate) state: Result<State, Error>,
pub(crate) in_use: Option<crate::in_use::Known>,
pub(crate) parks: Vec<ParkFact>,
pub(crate) interrupted: bool,
pub(crate) stuck: Option<String>,
pub(crate) codex: CodexFacts,
pub claude_present: bool,
pub desktop: Option<DesktopFacts>,
pub(crate) schedule: Option<ScheduleFact>,
pub(crate) network: NetworkFact,
pub(crate) in_the_app: bool,
pub(crate) elevation: crate::host::Elevation,
pub(crate) fallback_login: Option<FallbackLogin>,
pub(crate) floor: crate::host::Floor,
pub(crate) now: i64,
}
pub(crate) struct NetworkFact {
pub(crate) proxy: Option<ProxyFact>,
pub(crate) passed_over: Vec<&'static str>,
}
pub(crate) struct ProxyFact {
pub(crate) variable: &'static str,
pub(crate) address: String,
pub(crate) server: String,
pub(crate) on_loopback: bool,
pub(crate) socks: bool,
pub(crate) exempting: Option<&'static str>,
pub(crate) exempt: Vec<String>,
}
pub(crate) struct ScheduleFact {
pub(crate) path: PathBuf,
pub(crate) program: Option<PathBuf>,
pub(crate) program_found: bool,
pub(crate) network: NetworkFact,
pub(crate) network_differs: bool,
}
pub(crate) struct CodexFacts {
pub(crate) home: PathBuf,
pub(crate) present: bool,
pub(crate) enrolled: usize,
pub(crate) backend: &'static str,
pub(crate) backend_setting: String,
pub(crate) backend_remedy: String,
pub(crate) auth_file: PathBuf,
pub(crate) auth_access: Option<Access>,
pub(crate) login: Result<Option<CodexLogin>, CodexLoginTrouble>,
pub(crate) program: Option<PathBuf>,
pub(crate) version: Option<String>,
pub(crate) running: Option<Vec<crate::holder::Holding>>,
}
pub(crate) struct CodexLogin {
pub(crate) email: String,
pub(crate) account_id: String,
pub(crate) fingerprint: String,
}
#[derive(Debug)]
pub(crate) enum CodexLoginTrouble {
NotAnAccount(String),
Unusable(String),
}
pub(crate) struct ParkFact {
pub(crate) provider: ProviderId,
pub(crate) name: String,
pub(crate) active: bool,
pub(crate) last_used_at: Option<i64>,
pub(crate) park: Option<Park>,
pub(crate) unreadable: Option<Unreadable>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Unreadable {
Locked,
Broken(String),
}
pub(crate) struct FallbackLogin {
pub(crate) path: PathBuf,
pub(crate) fingerprint: Result<Option<String>, store::Error>,
}
impl ParkFact {
fn typed(&self) -> String {
self.name.clone()
}
}
fn park_facts(ctx: &Context, state: &State) -> Vec<ParkFact> {
let in_use: std::collections::BTreeMap<ProviderId, Option<crate::provider::Owner>> =
ProviderId::ALL
.iter()
.filter(|&&which| state.accounts.iter().any(|a| a.provider() == which))
.map(|&which| {
let known = crate::in_use::known(ctx, state, which);
(which, known.owner().cloned())
})
.collect();
state
.accounts
.iter()
.map(|a| ParkFact {
provider: a.provider(),
name: state.typed(&a.key()),
last_used_at: a.last_used_at,
active: in_use
.get(&a.provider())
.and_then(Option::as_ref)
.is_some_and(|owner| a.owned_by(owner)),
park: a.parked.clone(),
unreadable: a.parked.as_ref().and_then(|p| {
let tool = match crate::provider::of(a.provider()) {
LoginKind::Document(tool) => tool,
LoginKind::Folder(_) => return None,
};
park::load(ctx, tool, &a.key(), p).err().map(|e| match e {
Error::Store(store::Error::Locked) => Unreadable::Locked,
Error::ParkedCredentialMissing { .. } => {
Unreadable::Broken("missing from the vault".into())
}
Error::ParkedCredentialCorrupt { detail, .. } => Unreadable::Broken(detail),
other => Unreadable::Broken(other.to_string()),
})
}),
})
.collect()
}
pub struct DesktopFacts {
root: Result<PathBuf, String>,
own_home: Option<PathBuf>,
refused: Option<crate::provider::folder::Unfit>,
present: bool,
settings:
Option<Result<(crate::provider::folder::Sidecar, bool), crate::provider::folder::Unread>>,
unread: Vec<String>,
known: Option<crate::in_use::Known>,
signed_out: bool,
parks_dir: PathBuf,
private: Option<crate::host::Private>,
one_volume: Option<bool>,
parked_here: Vec<(String, Result<bool, String>)>,
build: Option<String>,
}
fn claude_desktop() -> Option<&'static dyn crate::provider::folder::Folder> {
match crate::provider::of(ProviderId::Desktop) {
LoginKind::Folder(app) => Some(app),
LoginKind::Document(_) => None,
}
}
fn desktop_facts(ctx: &Context, state: &State) -> Option<DesktopFacts> {
let tool = ProviderId::Desktop;
let app = claude_desktop()?;
let enrolled = crate::status::shown(state, tool);
if !enrolled && !state.aside.iter().any(|aside| aside.tool == tool) {
return None;
}
let root = app.root(ctx).map_err(|e| e.to_string());
let mut facts = DesktopFacts {
own_home: root
.as_ref()
.err()
.and_then(|_| crate::provider::desktop::paths::home_elsewhere(ctx)),
root: root.clone(),
refused: None,
present: false,
settings: None,
unread: Vec::new(),
known: None,
signed_out: false,
parks_dir: store::folder::parks_dir(ctx),
private: crate::host::fs::private_dir(&store::folder::parks_dir(ctx)).ok(),
one_volume: root.as_ref().ok().and_then(|root| {
crate::host::fs::same_volume(root, &store::folder::parks_dir(ctx)).ok()
}),
parked_here: state
.accounts
.iter()
.filter_map(|a| a.parked.as_ref())
.filter(|park| park.kind == crate::state::ParkKind::Folder)
.map(|park| {
let at = store::folder::at(ctx, &park.service);
let folder = match std::fs::symlink_metadata(at) {
Ok(found) => Ok(found.is_dir()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(e.to_string()),
};
(park.service.clone(), folder)
})
.collect(),
build: desktop_build(ctx),
};
if let Ok(root) = &root {
facts.refused = app.refuses(root);
facts.present = root.is_dir();
}
if let (true, Ok(root)) = (enrolled, &root) {
let settings = app.settings(root);
let (known, signed_out) = switch::folder::judged(state, app, settings.as_ref().ok());
facts.known = Some(known);
facts.signed_out = signed_out.is_some();
facts.settings = Some(settings.map(|read| (read.sidecar, read.file.is_some())));
facts.unread = std::fs::read_dir(root)
.into_iter()
.flatten()
.flatten()
.map(|entry| entry.file_name())
.filter(|name| app.classify(name) == crate::provider::folder::Class::Unread)
.map(|name| name.to_string_lossy().into_owned())
.collect();
facts.unread.sort();
}
Some(facts)
}
pub(crate) fn gather(ctx: &Context) -> Facts {
let config = claude::load_config(ctx);
let state = crate::state::load(ctx);
let service = claude::live_service(ctx);
let home = home::dir(ctx);
let config_account = config.as_ref().ok().and_then(claude::config_account);
Facts {
os: crate::host::OS,
security_tool: crate::host::OS
.secrets_tool()
.filter(|p| std::fs::metadata(p).is_ok())
.map(str::to_string),
config_path: claude::config_file(ctx),
config_account,
config,
account: slot::account_name(ctx),
default_slot: claude::is_default_slot(ctx),
storage_dir: claude::storage_dir(ctx),
backend: store::resolve(&claude_live::chain(ctx), &service),
credential_file: claude_live::credential_file(ctx),
credential_cost: store::read_raw(&claude_live::chain(ctx), &service)
.ok()
.flatten()
.and_then(|raw| store::cost(&claude_live::chain(ctx), &service, &raw)),
credential_parts: store::read(&claude_live::chain(ctx), &service)
.ok()
.flatten()
.map(|doc| parts_of(&doc))
.unwrap_or_default(),
credential: store::read(&claude_live::chain(ctx), &service),
home_access: crate::host::fs::access(&home),
readable_by_others: loose_logins(ctx),
home,
machine_id_known: crate::state::machine_id() != crate::state::UNKNOWN_MACHINE,
hover_rest_env: ctx.hover_rest,
daemon: daemon::read(ctx),
pending_parks: crate::pending::outstanding(ctx, state.as_ref().ok()),
claude_version: claude::installed_version(ctx),
auth_overrides: crate::settings::overrides(ctx),
auth_unread: crate::settings::unread(),
asking_held: crate::usage::budget::holds(ctx),
in_use: state
.as_ref()
.ok()
.map(|s| crate::in_use::known(ctx, s, ProviderId::Claude)),
parks: state
.as_ref()
.map(|s| park_facts(ctx, s))
.unwrap_or_default(),
codex: codex_facts(ctx, state.as_ref().ok()),
desktop: state.as_ref().ok().and_then(|s| desktop_facts(ctx, s)),
stuck: state
.as_ref()
.ok()
.and_then(|s| switch::stuck(ctx, s, switch::Asking::Nobody))
.map(|refusal| refusal.to_string()),
claude_present: claude::config_file(ctx).exists()
|| claude::program(ctx).is_some()
|| store::read_raw(&claude_live::chain(ctx), &service)
.is_ok_and(|found| found.is_some())
|| state.as_ref().is_ok_and(|s| {
s.accounts
.iter()
.any(|a| a.provider() == ProviderId::Claude)
}),
state,
interrupted: switch::interrupted(ctx),
service,
schedule: schedule_fact(ctx),
network: network_fact(&ctx.proxy),
in_the_app: ctx.caller == "app",
elevation: ctx.host().elevation(ctx),
fallback_login: fallback_login(ctx),
floor: ctx.host().floor(),
now: ctx.now(),
}
}
fn fallback_login(ctx: &Context) -> Option<FallbackLogin> {
let held = claude_live::behind(ctx)?;
Some(FallbackLogin {
path: claude_live::credential_file(ctx),
fingerprint: held.map(|held| {
claude_live::login_in(&held)
.map(|document| crate::provider::claude::document::fingerprint_of(&document))
}),
})
}
fn network_fact(proxies: &crate::proxy::Proxies) -> NetworkFact {
NetworkFact {
proxy: proxies
.variable()
.zip(proxies.address())
.map(|(variable, address)| ProxyFact {
variable,
address,
server: proxies.server().unwrap_or_default(),
on_loopback: proxies.on_loopback(),
socks: proxies.socks(),
exempting: proxies.exempting(),
exempt: proxies.exempt_hosts(),
}),
passed_over: proxies.passed_over().to_vec(),
}
}
fn schedule_fact(ctx: &Context) -> Option<ScheduleFact> {
if !crate::schedule::serves(ctx) {
return None;
}
let crate::schedule::Installed::Yes { path, .. } = crate::schedule::status(ctx) else {
return None;
};
let program = crate::schedule::installed_program(ctx);
let proxies = crate::schedule::installed_proxies(ctx).unwrap_or_default();
Some(ScheduleFact {
program_found: program.as_deref().is_some_and(std::path::Path::is_file),
program,
path,
network: network_fact(&proxies),
network_differs: !proxies.same_way(&ctx.proxy),
})
}
fn parts_of(document: &Value) -> Vec<(String, usize)> {
let weigh = |value: &Value| serde_json::to_string(value).map(|s| s.len()).unwrap_or(0);
let Some(root) = document.as_object() else {
return Vec::new();
};
let mut parts: Vec<(String, usize)> = root
.iter()
.flat_map(|(key, value)| match (key.as_str(), value.as_object()) {
("mcpOAuth", Some(servers)) if servers.len() > 1 => servers
.iter()
.map(|(server, held)| (format!("mcpOAuth {server}"), weigh(held)))
.collect(),
_ => vec![(key.clone(), weigh(value))],
})
.collect();
parts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
parts
}
fn loose_logins(ctx: &Context) -> Vec<(String, Access)> {
let mut loose = Vec::new();
let mut look = |path: PathBuf| {
if let Some(access) = crate::host::fs::access(&path)
&& access.shared
{
loose.push((path.display().to_string(), access));
}
};
look(claude_live::credential_file(ctx));
let vault = store::vault_dir(ctx);
look(vault.clone());
if let Ok(entries) = std::fs::read_dir(&vault) {
let mut parks: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
parks.sort();
for park in parks {
look(park);
}
}
loose
}
fn codex_facts(ctx: &Context, state: Option<&State>) -> CodexFacts {
let store = codex::store(ctx);
let backend = store.backend;
let auth_file = codex::auth_file(ctx);
let home = codex::home(ctx);
let program = crate::provider::program_of(ctx, ProviderId::Codex);
CodexFacts {
present: home.is_dir(),
enrolled: state.map_or(0, |s| {
s.accounts
.iter()
.filter(|a| a.provider() == ProviderId::Codex)
.count()
}),
backend: backend_name(backend),
backend_setting: match backend {
codex::Backend::Unknown => store.why_unknown().unwrap_or_default(),
_ => store.setting(),
},
backend_remedy: match backend {
codex::Backend::File => String::new(),
codex::Backend::Unknown => store.while_unknown().unwrap_or_default(),
_ => store.to_use_the_file(),
},
auth_access: crate::host::fs::access(&auth_file),
login: if backend == codex::Backend::File {
codex_login(ctx)
} else {
Ok(None)
},
version: program.as_deref().and_then(codex_version),
program,
running: running_codex(ctx),
auth_file,
home,
}
}
fn backend_name(backend: codex::Backend) -> &'static str {
match backend {
codex::Backend::File => "file",
codex::Backend::Keyring => "keyring",
codex::Backend::Either => "auto",
codex::Backend::Ephemeral => "ephemeral",
codex::Backend::Secrets => "secrets",
codex::Backend::Unknown => "unknown",
}
}
const PROJECTS_NOT_READ: &str = "Pitboard does not read a project's own \
`.codex/config.toml`, which applies only inside that project: in a trusted project that \
sets `cli_auth_credentials_store`, Codex keeps its login somewhere else.";
fn codex_login(ctx: &Context) -> Result<Option<CodexLogin>, CodexLoginTrouble> {
let tool = crate::provider::codex();
let unusable = |e: crate::provider::ProviderError| CodexLoginTrouble::Unusable(e.to_string());
let Some(credential) = tool.read_live(ctx).map_err(unusable)? else {
return Ok(None);
};
tool.slice(&credential.raw).map_err(|e| match e {
crate::provider::ProviderError::Unsupported { reason, .. } => {
CodexLoginTrouble::NotAnAccount(reason)
}
other => unusable(other),
})?;
let found = tool.identify(ctx, &credential).map_err(unusable)?;
Ok(Some(CodexLogin {
email: found.email,
account_id: found.account_uuid,
fingerprint: tool.fingerprint(&credential.raw),
}))
}
fn codex_version(program: &std::path::Path) -> Option<String> {
let resolved = std::fs::canonicalize(program).ok()?;
let near: Vec<&std::path::Path> = resolved.ancestors().skip(1).take(2).collect();
near.iter()
.find_map(|dir| {
dir.file_name()
.and_then(|n| n.to_str())
.and_then(|name| name.split('-').next())
.filter(|leading| looks_like_a_version(leading))
.map(str::to_owned)
})
.or_else(|| {
near.iter()
.find_map(|dir| codex_package_version(&dir.join("package.json")))
})
}
fn looks_like_a_version(name: &str) -> bool {
let parts: Vec<&str> = name.split('.').collect();
parts.len() == 3
&& parts
.iter()
.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
}
fn codex_package_version(path: &std::path::Path) -> Option<String> {
let json: Value = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
if json.get("name")?.as_str()? != "@openai/codex" {
return None;
}
Some(json.get("version")?.as_str()?.to_string())
}
fn running_codex(ctx: &Context) -> Option<Vec<crate::holder::Holding>> {
let crate::provider::Adoption::RestartRequired { program, holders } =
crate::provider::codex().adoption(None)
else {
return Some(Vec::new());
};
crate::holder::find(ctx, program, holders)
}
fn elevated(os: Os, elevation: crate::host::Elevation) -> Option<Check> {
let said = match elevation {
crate::host::Elevation::Normal => return None,
crate::host::Elevation::Elevated { why } => words::elevated(os, Some(why)),
crate::host::Elevation::Unknown => words::elevated(os, None),
};
Some(fail(
"elevated",
"runs as",
said.because,
format!(
"Pitboard changes nothing this way: it reads, and renews and writes nothing. {}",
said.way_out
),
))
}
fn too_old(floor: crate::host::Floor) -> Option<Check> {
let said = match floor {
crate::host::Floor::Met => return None,
crate::host::Floor::Below { build } => words::too_old(Some(build)),
crate::host::Floor::Unknown => words::too_old(None),
};
Some(fail(
"system_too_old",
"runs on",
said.because,
format!(
"Pitboard changes nothing here: it reads, and renews and writes nothing. {}",
said.way_out
),
))
}
fn ok(code: &'static str, name: impl Into<String>, detail: impl Into<String>) -> Check {
Check {
code,
name: name.into(),
level: Level::Ok,
detail: detail.into(),
advice: String::new(),
}
}
fn warn(
code: &'static str,
name: impl Into<String>,
detail: impl Into<String>,
advice: impl Into<String>,
) -> Check {
Check {
code,
name: name.into(),
level: Level::Warn,
detail: detail.into(),
advice: advice.into(),
}
}
fn fail(
code: &'static str,
name: impl Into<String>,
detail: impl Into<String>,
advice: impl Into<String>,
) -> Check {
Check {
code,
name: name.into(),
level: Level::Fail,
detail: detail.into(),
advice: advice.into(),
}
}
fn running(command: Option<String>) -> String {
command.map_or_else(|| ".".into(), |command| format!(": `{command}`."))
}
pub(crate) fn evaluate(facts: &Facts) -> Vec<Check> {
let mut checks = Vec::new();
let codex_here = facts.codex.present || facts.codex.enrolled > 0;
let claude_here = facts.claude_present || !(codex_here || facts.desktop.is_some());
checks.extend(too_old(facts.floor));
checks.extend(elevated(facts.os, facts.elevation));
if let Some(tool) = facts.os.secrets_tool() {
checks.push(match &facts.security_tool {
Some(path) => ok("security_tool", "security tool", path.clone()),
None => fail(
"security_tool",
"security tool",
format!("{tool} is missing"),
"Pitboard reads the keychain the same way Claude Code does. Without it, nothing works.",
),
});
}
checks.push(match &facts.config {
Ok(v) => ok(
"config_file",
"config file",
format!(
"{} ({} keys)",
facts.config_path.display(),
v.as_object().map_or(0, serde_json::Map::len)
),
),
Err(e) => fail(
"config_file",
"config file",
e.to_string(),
"Run `claude` once.",
),
});
checks.push(match &facts.config_account {
Some(id) => ok(
"identity",
"identity",
format!("{} · org {}", id.email, id.organization_uuid),
),
None => warn(
"identity",
"identity",
"Claude Code has not recorded a signed-in account",
"It writes this after its first successful call. Run `claude` once.",
),
});
checks.extend(judge_in_use(facts));
checks.push(ok(
"slot",
"slot",
if facts.default_slot {
format!(
"default · {} · account {}",
facts.service, facts.account
)
} else {
format!(
"{} · account {} (selected by {})",
facts.service, facts.account, facts.storage_dir
)
},
));
if let Some(price) = facts.credential_cost {
let (bytes, limit) = (price.needs, price.limit);
checks.push(if price.refused() {
fail(
"credential_size",
"login size",
format!(
"{bytes} of {limit} bytes, and PITBOARD_NO_ARGV refuses that{}",
biggest(&facts.credential_parts)
),
"There is no third way to write a login this size. Sign out of MCP servers \
you no longer use to make it smaller, or unset PITBOARD_NO_ARGV and accept \
the argument-line write.",
)
} else if price.over() {
warn(
"credential_size",
"login size",
format!(
"{bytes} of {limit} bytes: written on the argument line{}",
biggest(&facts.credential_parts)
),
"A login this size can only be written by passing it as an argument, where \
a process running as you could read it while the call lasts. Signing out \
of MCP servers you no longer use makes it smaller; PITBOARD_NO_ARGV=1 \
refuses the switch instead.",
)
} else {
ok(
"credential_size",
"login size",
format!("{bytes} of {limit} bytes"),
)
});
}
checks.push(match &facts.backend {
Ok(store::Backend::Keychain) => ok("credential_store", "credential store", "keychain"),
Ok(store::Backend::File) => {
let detail = format!("plaintext file · {}", facts.credential_file.display());
match facts.os {
Os::MacOs => warn(
"credential_store",
"credential store",
detail,
"Claude Code fell back to a file, which means a keychain write failed at some point.",
),
Os::Linux => ok("credential_store", "credential store", detail),
Os::Windows => warn(
"credential_store",
"credential store",
detail,
"Claude Code may keep its login in Credential Manager instead, which \
Pitboard does not read yet.",
),
}
}
Ok(store::Backend::Unknown) => fail(
"credential_store",
"credential store",
"a store Pitboard does not read yet",
"Treat this as unknown, never as empty.",
),
Ok(store::Backend::Absent) if facts.config_account.is_some() => fail(
"credential_store",
"credential store",
format!(
"Claude Code's config says {} is signed in, and no store Pitboard reads \
holds that login",
facts
.config_account
.as_ref()
.map(|i| i.email.as_str())
.unwrap_or("somebody")
),
"Pitboard will not write a login where nobody reads it. Check for a Pitboard \
update; if there is none, this is worth reporting.",
),
Ok(store::Backend::Absent) => warn(
"credential_store",
"credential store",
"no credential in any backend",
"Nothing is signed in for this slot.",
),
Err(e) => fail(
"credential_store",
"credential store",
e.to_string(),
"Treat this as unknown, never as empty.",
),
});
checks.push(judge_credential(facts));
checks.extend(judge_fallback_login(facts));
checks.push(match &facts.home_access {
None => ok(
"home",
"Pitboard home",
format!("{} (not created yet)", facts.home.display()),
),
Some(access) if !access.shared => {
ok("home", "Pitboard home", facts.home.display().to_string())
}
Some(access) => {
warn(
"home",
"Pitboard home",
format!("{} is {}", facts.home.display(), access.described),
format!(
"Park names contain account identifiers, so only you should read it{}",
running(facts.os.make_private_command(
Kind::Directory,
&[&facts.home.display().to_string()]
))
),
)
}
});
checks.push(if facts.readable_by_others.is_empty() {
ok(
"private_on_disk",
"logins on disk",
"nothing holding a login is readable by anyone else",
)
} else {
let names: Vec<String> = facts
.readable_by_others
.iter()
.map(|(path, access)| format!("{path} is {}", access.described))
.collect();
fail(
"private_on_disk",
"logins on disk",
names.join("; "),
format!(
"These hold usable OAuth tokens in plain text, which is how Claude Code \
stores them where there is no keychain. Anyone else on this machine can \
read them{}",
running(
facts.os.make_private_command(
Kind::Any,
&facts
.readable_by_others
.iter()
.map(|(path, _)| path.as_str())
.collect::<Vec<_>>()
)
),
),
)
});
checks.push(match &facts.state {
Ok(state) => ok(
"state",
"accounts",
match state.accounts.len() {
0 => "none enrolled yet".to_string(),
1 => "1 enrolled".to_string(),
n => format!("{n} enrolled"),
},
),
Err(e) => fail(
"state",
"accounts",
e.to_string(),
match e {
Error::StateWrongMachine { .. } => {
"Run `pitboard adopt` to keep these accounts on this computer. The \
logins they came with are dropped, because a login belongs to the \
computer that signed in."
}
_ => "Pitboard will not switch until its account list can be read.",
},
),
});
let claude_parks: Vec<&ParkFact> = facts
.parks
.iter()
.filter(|p| p.provider == ProviderId::Claude)
.collect();
let codex_parks: Vec<&ParkFact> = facts
.parks
.iter()
.filter(|p| p.provider == ProviderId::Codex)
.collect();
checks.extend(claude_parks.iter().map(|p| judge_park(p, facts.now)));
if let Some(refusal) = &facts.stuck {
checks.push(warn(
"interrupted_switch",
"interrupted switch",
refusal.clone(),
"If it still cannot be finished, `pitboard abandon` gives up on the switch and \
keeps every login.",
));
} else if facts.interrupted {
checks.push(warn(
"interrupted_switch",
"interrupted switch",
"a switch did not finish",
"The next `pitboard use`, `enroll` or `forget` finishes it before anything else.",
));
}
if let Ok(state) = &facts.state
&& !state.discarded.is_empty()
{
let folders = state
.discarded
.iter()
.filter(|d| d.kind == crate::state::ParkKind::Folder)
.count();
let vault = state.discarded.len() - folders;
let (detail, advice) = match (vault, folders) {
(vault, 0) => (
format!("{vault} waiting to be deleted"),
"Pitboard deletes them on its next change; if they stay, check the keychain is unlocked.",
),
(0, folders) => (
format!("{folders} folder(s) waiting to be deleted"),
"Pitboard deletes them on its next change.",
),
(vault, folders) => (
format!("{vault} waiting to be deleted, and {folders} folder(s)"),
"Pitboard deletes them on its next change; if they stay, check the keychain is unlocked.",
),
};
checks.push(warn("discarded", "old parked logins", detail, advice));
}
if !facts.machine_id_known {
checks.push(warn(
"machine_id",
"machine id",
"this machine has no stable identifier",
"Pitboard cannot tell this machine from another that also lacks one, so it cannot \
refuse state copied between them. Never copy ~/.pitboard between machines.",
));
}
checks.push(judge_storage_v5(facts));
checks.push(judge_daemon(facts));
checks.push(judge_pending(facts));
checks.extend(judge_schedule(facts));
checks.extend(judge_schedule_proxy(facts));
checks.push(judge_claude_version(facts));
checks.push(judge_auth(facts));
checks.push(judge_asking(facts));
checks.push(judge_network(&facts.network));
checks.extend(
claude_parks
.iter()
.filter_map(|p| judge_dormant(p, facts.now)),
);
if codex_here || !codex_parks.is_empty() {
checks.extend(judge_codex(facts.os, &facts.codex, &codex_parks, facts.now));
}
if let (Some(desktop), Ok(state)) = (&facts.desktop, &facts.state) {
checks.extend(judge_desktop(desktop, state, &facts.parks, facts.now));
}
if !claude_here {
checks.retain(|check| !CLAUDE_CODES_OWN.contains(&check.code));
}
checks
}
const CLAUDE_CODES_OWN: &[&str] = &[
"config_file",
"identity",
"in_use",
"slot",
"credential_size",
"credential_store",
"credential",
"fallback_login",
"storage_v5",
"daemon",
"claude_version",
"auth",
];
pub fn section(code: &str) -> Option<ProviderId> {
ProviderId::ALL
.iter()
.copied()
.filter(|tool| *tool != crate::label::DEFAULT)
.find(|tool| {
code.strip_prefix(tool.code())
.is_some_and(|rest| rest.starts_with('_'))
})
}
fn account_code(
provider: ProviderId,
claude: &'static str,
codex: &'static str,
desktop: &'static str,
) -> &'static str {
match provider {
ProviderId::Claude => claude,
ProviderId::Codex => codex,
ProviderId::Desktop => desktop,
}
}
const A_TOKEN_LIFETIME: i64 = 30 * 86_400;
fn judge_dormant(park: &ParkFact, now: i64) -> Option<Check> {
match crate::provider::of(park.provider) {
LoginKind::Document(_) => {}
LoginKind::Folder(_) => return None,
}
if park.active || park.park.is_none() {
return None;
}
let dormant_for = now - park.last_used_at?;
if dormant_for < A_TOKEN_LIFETIME {
return None;
}
Some(warn(
account_code(
park.provider,
"dormant_account",
"codex_dormant_account",
"desktop_dormant_account",
),
format!("account {}", park.typed()),
format!(
"not switched to for {}; Pitboard has kept its login alive that whole time",
words::span(dormant_for)
),
format!(
"Every `pitboard` renews it, so its refresh token is rotated and kept live on \
this machine for as long as it stays enrolled. If you are not coming back to \
it, `pitboard forget {}` deletes the login and the record.",
park.typed()
),
))
}
fn judge_in_use(facts: &Facts) -> Option<Check> {
let (code, name) = ("in_use", "in use");
let known = facts.in_use.as_ref()?;
let state = facts.state.as_ref().ok()?;
let enrolled = facts.parks.iter().any(|p| p.provider == ProviderId::Claude);
if known.last.is_none() && !enrolled {
return None;
}
let ask = "Run `pitboard status`, which asks Anthropic whose login Claude Code has stored.";
let Some(last) = &known.last else {
return Some(warn(
code,
name,
"Anthropic has not been asked whose login Claude Code has stored",
ask,
));
};
let account = Stored::of(state, ProviderId::Claude, last.owner.as_ref());
let in_use = account.said("no account");
let config =
Stored::of(state, ProviderId::Claude, known.own_record.as_ref()).said("no account");
let when = time::moment(last.known_at, facts.now);
let then = match &last.owner {
Some(_) => format!("Anthropic named {in_use} {when}"),
None => format!("Pitboard found no login stored {when}"),
};
match known.doubt {
Some(crate::in_use::Doubt::NeverEstablished) => {
return Some(warn(
code,
name,
format!(
"Anthropic has not been asked whose login Claude Code has stored since \
Pitboard was updated; {in_use} is the account it last switched to"
),
ask,
));
}
Some(crate::in_use::Doubt::NamedMoved) => {
return Some(warn(
code,
name,
format!(
"Claude Code's config has named {config} since {then}, so another login \
may be stored"
),
ask,
));
}
Some(crate::in_use::Doubt::LoginUnreadable) | None => {}
}
if known.named_another(ProviderId::Claude).is_some() {
return Some(warn(
code,
name,
format!("{in_use}, as Anthropic said {when}, and Claude Code's config names {config}"),
format!(
"`/status` in Claude Code shows {config}. {}",
account.how_to_name()
),
));
}
let stored = match &facts.credential {
Ok(Some(document)) if document.get("claudeAiOauth").is_some() => {
Some(Some(crate::provider::claude_code().fingerprint(document)))
}
Ok(_) => Some(None),
Err(_) => None,
};
let renewed = "the next `pitboard status` asks whose it is";
Some(ok(
code,
name,
match (&last.owner, stored) {
(Some(_), Some(Some(now))) if now.is_empty() || now != last.login => format!(
"{in_use}, as Anthropic said {when} of a login renewed or replaced since; \
{renewed}"
),
(Some(_), Some(None)) => {
format!("{in_use}, as Anthropic said {when}; no login is stored since")
}
(Some(_), _) => format!("{in_use}, as Anthropic said {when}"),
(None, Some(Some(_))) => {
format!("no login stored, as Pitboard found {when}; one is since, and {renewed}")
}
(None, _) => format!("no login stored, as Pitboard found {when}"),
},
))
}
fn judge_credential(facts: &Facts) -> Check {
match &facts.credential {
Ok(Some(doc)) => {
let keys: Vec<&str> = doc
.as_object()
.map(|o| o.keys().map(String::as_str).collect())
.unwrap_or_default();
if doc.get("claudeAiOauth").is_none() {
return warn(
"credential",
"credential",
format!("signed out; the document keeps only {keys:?}"),
"Nothing is signed in for this slot.",
);
}
let Some(oauth) = doc.get("claudeAiOauth").and_then(Value::as_object) else {
return fail(
"credential",
"credential",
format!("claudeAiOauth is not an object; the keys are {keys:?}"),
"The credential's shape changed. Do not switch accounts until this is understood.",
);
};
let fingerprint = oauth
.get("refreshToken")
.and_then(Value::as_str)
.map(store::fingerprint)
.unwrap_or_else(|| "none".into());
let days = oauth
.get("refreshTokenExpiresAt")
.and_then(Value::as_i64)
.map_or(-1, |ms| (ms / 1000 - facts.now) / 86_400);
let detail = format!("refresh {fingerprint} · {days} days left · keys {keys:?}");
if days < 3 {
warn(
"credential",
"credential",
detail,
"This login expires soon and will need signing in again.",
)
} else {
ok("credential", "credential", detail)
}
}
Ok(None) => warn(
"credential",
"credential",
"nothing stored",
"Nothing is signed in for this slot.",
),
Err(store::Error::Locked) => fail(
"credential",
"credential",
"the keychain is locked, and cannot ask to be unlocked from here",
format!(
"Unlock it with `security unlock-keychain`, or run Pitboard from a desktop \
session. Until then a Claude Code session here keeps the login it last read \
and follows no switch{}",
match facts
.fallback_login
.as_ref()
.map(|left| (left.path.display(), &left.fingerprint))
{
Some((path, Ok(Some(_)))) => {
format!(", and one started here signs in with the login in {path}.")
}
Some((path, Err(_))) => format!(
", and whether one started here signs in with what {path} holds cannot \
be told, since Pitboard could not read it."
),
Some((_, Ok(None))) | None => ", and one started here is signed out.".into(),
}
),
),
Err(e) => fail(
"credential",
"credential",
e.to_string(),
"Do not write to the store while this is failing.",
),
}
}
fn judge_fallback_login(facts: &Facts) -> Option<Check> {
let left = facts.fallback_login.as_ref()?;
let path = left.path.display();
let running = format!(
"While it is there, Claude Code sessions already running at a switch {}.",
words::kept_until_renewed()
);
let (detail, advice) = match &left.fingerprint {
Ok(Some(fingerprint)) => (
format!(
"{path} · refresh {}",
if fingerprint.is_empty() {
"none"
} else {
fingerprint.as_str()
}
),
format!(
"Claude Code signs in with this wherever it cannot read the keychain, such as \
in a session started over SSH, and no switch reaches it. A sign-in made where \
the keychain could not be read leaves one, as `/login` over SSH does. \
{running} `pitboard stow` keeps this login for its account, where Pitboard \
holds no other it can switch to, then deletes the file, which leaves one login \
for every session."
),
),
Ok(None) => (
format!("{path} · no login in it"),
format!(
"It holds no Claude Code login. {running} `pitboard stow` deletes it, which \
lets them follow a switch."
),
),
Err(e) => (
format!("{path} · not read: {e}"),
format!(
"Pitboard could not read it, so whether it holds a login, which a session that \
cannot read the keychain signs in with, cannot be told, and `pitboard stow` \
cannot put it away. {running} Deleting it lets them follow a switch, and takes \
whatever it holds with it: `rm {path}`."
),
),
};
Some(warn("fallback_login", "fallback login", detail, advice))
}
pub(crate) const RENEW_WITHIN: i64 = 3 * 86_400;
pub fn renewal_due(refresh_expires_at: i64, now: i64) -> bool {
(1..RENEW_WITHIN).contains(&(refresh_expires_at - now))
}
fn judge_park(fact: &ParkFact, now: i64) -> Check {
let code = account_code(
fact.provider,
"parked_login",
"codex_parked_login",
"desktop_parked_login",
);
let name = format!("account {}", fact.typed());
let renew = format!("Run `pitboard enroll {} --sign-in`.", fact.typed());
let Some(park) = &fact.park else {
return if fact.active {
ok(code, name, "signed in; parked when you switch away")
} else {
warn(code, name, "nothing parked to switch to", renew)
};
};
match &fact.unreadable {
Some(Unreadable::Locked) => {
return warn(
code,
name,
"not read: the keychain is locked",
"Unlock it, then run `pitboard doctor` again.",
);
}
Some(Unreadable::Broken(why)) => {
return fail(
code,
name,
format!("its parked login is unusable: {why}"),
renew,
);
}
None => {}
}
let Some(at) = park.refresh_expires_at else {
return ok(code, name, "parked");
};
let life = words::parked_life_column(at, now);
if !park.restorable_at(now) {
let when = time::moment(at, now);
warn(code, name, format!("its parked login {life} {when}"), renew)
} else if renewal_due(at, now) {
warn(code, name, format!("its parked login {life}"), renew)
} else {
ok(code, name, format!("parked, {life}"))
}
}
fn judge_storage_v5(facts: &Facts) -> Check {
let flag_on = facts
.config
.as_ref()
.ok()
.and_then(|c| c.get("cachedGrowthBookFeatures"))
.and_then(|f| f.get("tengu_hover_rest"))
.and_then(Value::as_bool)
.unwrap_or(false);
if !(facts.hover_rest_env || flag_on) {
return ok("storage_v5", "storage v5", "inactive");
}
match facts.backend {
Ok(store::Backend::File) => warn(
"storage_v5",
"storage v5",
"switched on, and this login is in the fallback store",
"Pitboard reads the plaintext file. If Claude Code was given a backend of its \
own, that is not the same file. Check for an update before switching.",
),
_ => ok(
"storage_v5",
"storage v5",
"switched on; the keychain is still where the login is",
),
}
}
fn biggest(parts: &[(String, usize)]) -> String {
let named: Vec<String> = parts
.iter()
.take(3)
.map(|(what, bytes)| format!("{what} {bytes}"))
.collect();
if named.is_empty() {
String::new()
} else {
format!(". Mostly: {}", named.join(", "))
}
}
fn judge_asking(facts: &Facts) -> Check {
let tool_of = |id: &str| {
facts
.state
.as_ref()
.ok()
.and_then(|s| s.owner_of_park(id))
.map(crate::state::Account::provider)
};
let services = |tools: &mut Vec<ProviderId>| {
tools.sort();
if tools.is_empty() {
tools.push(ProviderId::Claude);
}
let mut named: Vec<&str> = Vec::new();
for service in tools.iter().map(|t| t.service()) {
if !named.contains(&service) {
named.push(service);
}
}
named.join(" and ")
};
let mut asked: Vec<ProviderId> = facts
.state
.as_ref()
.map(|s| {
s.accounts
.iter()
.map(crate::state::Account::provider)
.filter(|tool| tool.reads_usage())
.collect()
})
.unwrap_or_default();
let name = format!("asking {}", services(&mut asked));
let mut holding: Vec<ProviderId> = facts
.asking_held
.iter()
.filter_map(|(uuid, _)| tool_of(uuid))
.collect();
let holders = services(&mut holding);
match facts.asking_held.len() {
0 => ok("asking", name, "nothing is being held back"),
n => {
let longest = facts
.asking_held
.iter()
.map(|(_, seconds)| *seconds)
.max()
.unwrap_or_default();
warn(
"asking",
name,
format!(
"{n} account(s) not being asked about for up to {}",
words::span(longest)
),
format!(
"{holders} asked for less traffic, or could not be reached. The numbers \
shown are the last ones measured until then; `pitboard status --fresh` \
does not override a wait {holders} asked for."
),
)
}
}
}
fn judge_network(network: &NetworkFact) -> Check {
const NAME: &str = "network";
let (passed, set_right) = match network.passed_over.as_slice() {
[] => (None, ""),
[one] => (
Some(format!(
"{one} holds no proxy's address Pitboard can read, so it is passed over"
)),
"Set it to a proxy's address, such as http://proxy.example.com:8080, or unset it.",
),
many => (
Some(format!(
"{} hold no proxy's address Pitboard can read, so they are passed over",
words::listed(many.iter().map(|n| (*n).to_string()).collect())
)),
"Set each to a proxy's address, such as http://proxy.example.com:8080, or unset them.",
),
};
let Some(proxy) = &network.proxy else {
return match passed {
None => ok("network", NAME, "direct: no variable names a proxy"),
Some(passed) => warn("network", NAME, format!("direct: {passed}"), set_right),
};
};
let fails = requests_fail(proxy);
let mut detail = if !proxy.socks {
format!("through {}, from {}", proxy.address, proxy.variable)
} else {
format!(
"{}: {}, {}",
if fails { "requests fail" } else { "direct" },
crate::proxy::refused(proxy.variable),
proxy.address
)
};
for clause in exempting(proxy).iter().chain(&passed) {
let _ = write!(detail, "; {clause}");
}
if fails {
let (each, them) = if network.passed_over.is_empty() {
(proxy.variable.to_string(), "it")
} else {
let mut names: Vec<String> = network
.passed_over
.iter()
.map(|name| (*name).to_string())
.collect();
names.push(proxy.variable.to_string());
(format!("each of {}", words::listed(names)), "them")
};
return warn(
"network",
NAME,
detail,
format!(
"Pitboard goes through HTTP and HTTPS proxies only. For its requests to go \
out, set {each} to an HTTP proxy's address, such as \
http://proxy.example.com:8080, or unset {them}."
),
);
}
match passed {
None => ok("network", NAME, detail),
Some(_) => warn("network", NAME, detail, set_right),
}
}
fn requests_fail(proxy: &ProxyFact) -> bool {
proxy.socks && proxy.exempt.len() < crate::proxy::called().len()
}
fn exempting(proxy: &ProxyFact) -> Option<String> {
proxy.exempting.map(|exempting| {
if proxy.exempt.is_empty() {
format!("{exempting} exempts none of the hosts Pitboard calls")
} else if proxy.exempt.len() == crate::proxy::called().len() {
format!("{exempting} exempts every host Pitboard calls, which it reaches directly")
} else {
format!(
"{exempting} exempts {}, which Pitboard reaches directly",
words::listed(proxy.exempt.clone())
)
}
})
}
fn judge_auth(facts: &Facts) -> Check {
if facts.auth_overrides.is_empty() {
let read = if facts.fallback_login.is_some() {
"the stored login, which is what Pitboard moves, once a session reads it again"
} else {
"the stored login, which is what Pitboard moves"
};
return ok(
"auth_source",
"what a session authenticates with",
match facts.auth_unread {
None => read.to_string(),
Some(unread) => format!("{read}, as far as it reads: {unread}"),
},
);
}
let named: Vec<String> = facts
.auth_overrides
.iter()
.map(ToString::to_string)
.collect();
warn(
"auth_source",
"what a session authenticates with",
format!("something else: {}", named.join("; ")),
"Claude Code here authenticates with that rather than with the stored login, so \
switching accounts changes nothing a session would notice. Remove it, or accept \
that Pitboard is moving a login nothing reads.",
)
}
fn judge_claude_version(facts: &Facts) -> Check {
let verified = crate::provider::claude::assumptions::VERIFIED_AGAINST;
match facts.claude_version.as_deref() {
None => ok(
"claude_version",
"Claude Code build",
format!("not found here; Pitboard's facts were read from {verified}"),
),
Some(installed) if installed == verified => ok(
"claude_version",
"Claude Code build",
format!("{installed}, which is what Pitboard's facts were read from"),
),
Some(installed) => ok(
"claude_version",
"Claude Code build",
format!("{installed} installed; Pitboard's facts were read from {verified}"),
),
}
}
fn judge_pending(facts: &Facts) -> Check {
match facts.pending_parks.len() {
0 => ok("pending_parks", "parks being reclaimed", "none outstanding"),
n => warn(
"pending_parks",
"parks being reclaimed",
format!("{n} could not be read this time"),
"Pitboard wrote these names down before creating a login in them and cannot \
read them back to find out what is there. Unlock the keychain and run any \
Pitboard command; it resolves them before doing anything else.",
),
}
}
fn judge_schedule(facts: &Facts) -> Option<Check> {
let again = again(facts.os);
let schedule = facts.schedule.as_ref()?;
Some(match &schedule.program {
Some(program) if !schedule.program_found => fail(
"schedule",
"renewal schedule",
format!("runs {}, which is not there any more", program.display()),
again,
),
Some(program) if crate::schedule::an_apps_own_program(program) => fail(
"schedule",
"renewal schedule",
format!(
"runs {}, which is the app itself and not a command line",
program.display()
),
again,
),
Some(program) => ok(
"schedule",
"renewal schedule",
format!("daily · runs {}", program.display()),
),
None => warn(
"schedule",
"renewal schedule",
format!(
"{} does not say which Pitboard it runs",
schedule.path.display()
),
again,
),
})
}
fn judge_schedule_proxy(facts: &Facts) -> Option<Check> {
let schedule = facts.schedule.as_ref()?;
if !schedule.network_differs {
return None;
}
let here = if facts.in_the_app {
"the app"
} else {
"this run"
};
let installed = match &schedule.network.proxy {
None => "the schedule was installed with no proxy".to_string(),
Some(proxy) => format!("the schedule was installed with {}", proxy_named(proxy)),
};
let this_run = match &facts.network.proxy {
None => format!("{here} goes directly"),
Some(proxy) if proxy.socks => format!("{here} is given {}", proxy_named(proxy)),
Some(proxy) => format!("{here} goes through {}", proxy_named(proxy)),
};
let detail = match (&schedule.network.proxy, &facts.network.proxy) {
(Some(its), Some(mine)) if proxy_named(its) == proxy_named(mine) => format!(
"the schedule was installed with {here}'s proxy, {}, but with another user name \
or password",
proxy_named(its)
),
_ => format!("{installed}; {this_run}"),
};
if facts.in_the_app {
return Some(Check {
code: "schedule_proxy",
name: "renewal schedule's proxy".into(),
level: Level::Ok,
detail,
advice: "The app runs with the environment the system started it with, not a \
terminal's. Turning daily renewal off and on in Settings gives the \
schedule the app's proxy variables; `pitboard schedule install` in a \
terminal gives it that terminal's."
.into(),
});
}
let advice = match &facts.network.proxy {
Some(proxy) if requests_fail(proxy) => format!(
"The schedule's runs are given the proxy variables it was installed with. \
Installed again from here, they would be given this run's SOCKS proxy, which \
Pitboard does not use yet, and their requests would fail. Set {} to an HTTP \
proxy's address, or unset it, then install it again with `pitboard schedule \
install`, which takes the proxy variables set where it runs.",
proxy.variable
),
_ => "The schedule's runs are given the proxy variables it was installed with. To give \
them this run's, install it again from here with `pitboard schedule install`, \
which takes the proxy variables set where it runs."
.to_string(),
};
Some(warn(
"schedule_proxy",
"renewal schedule's proxy",
detail,
advice,
))
}
fn proxy_named(proxy: &ProxyFact) -> String {
let named = format!("{}, from {}", proxy.address, proxy.variable);
match proxy.exempting {
Some(exempting) if !proxy.exempt.is_empty() => format!(
"{named}, {exempting} exempting {}",
words::listed(proxy.exempt.clone())
),
_ => named,
}
}
fn again(os: Os) -> &'static str {
if has_app(os) {
"Turn daily renewal off and on again: in the app's Settings, or with \
`pitboard schedule uninstall` and then `pitboard schedule install`."
} else {
"Turn daily renewal off and on again with `pitboard schedule uninstall` and then \
`pitboard schedule install`."
}
}
fn has_app(os: Os) -> bool {
match os {
Os::MacOs => true,
Os::Linux | Os::Windows => false,
}
}
fn judge_daemon(facts: &Facts) -> Check {
let Some(d) = &facts.daemon else {
return ok("claude_daemon", "Claude Code daemon", "none has run here");
};
let version = d
.version
.as_deref()
.map(|v| format!("Claude Code {v}"))
.unwrap_or_else(|| "an unrecorded version".into());
if d.running {
ok(
"claude_daemon",
"Claude Code daemon",
format!("running, {version}, pid {}", d.pid),
)
} else {
ok(
"claude_daemon",
"Claude Code daemon",
format!("not running; {version} ran here last"),
)
}
}
fn judge_codex(os: Os, facts: &CodexFacts, parks: &[&ParkFact], now: i64) -> Vec<Check> {
let mut checks = Vec::new();
let enrolled = facts.enrolled > 0 || !parks.is_empty();
let broken = |code, name: &str, detail: String, advice: String| {
if enrolled {
fail(code, name, detail, advice)
} else {
warn(code, name, detail, advice)
}
};
let file = facts.backend == "file";
let setting = &facts.backend_setting;
let described = match facts.backend {
"ephemeral" => format!("in memory only ({setting})"),
"unknown" => format!("cannot tell: {setting}"),
other => format!("{other} ({setting})"),
};
checks.push(match facts.backend {
"file" => Check {
advice: PROJECTS_NOT_READ.into(),
..ok(
"codex_backend",
"Codex login store",
format!("file ({setting}) · {}", facts.auth_file.display()),
)
},
"unknown" => broken(
"codex_backend",
"Codex login store",
described,
format!(
"{}. Pitboard parks and switches no Codex login while it cannot tell where \
Codex keeps it.",
facts.backend_remedy
),
),
other if enrolled => fail(
"codex_backend",
"Codex login store",
described,
match other {
"ephemeral" => format!(
"Codex keeps nothing at rest, so there is no login Pitboard can park or \
switch. {}.",
facts.backend_remedy
),
_ => format!(
"Pitboard reads only Codex's file store, auth.json, and will not touch the \
keychain item Codex created for itself, because every read of it would \
ask you for permission, so no enrolled Codex account can be switched to. \
{}.",
facts.backend_remedy
),
},
),
_ => ok(
"codex_backend",
"Codex login store",
format!("{described}; Pitboard switches Codex accounts only in the file store"),
),
});
if file {
checks.push(match &facts.auth_access {
None if enrolled => warn(
"codex_auth_file",
"Codex login file",
format!(
"{} is absent: nothing is signed in to Codex",
facts.auth_file.display()
),
"Sign in to one of your enrolled accounts with `codex login`. A switch \
needs an account signed in to park, so `pitboard use` cannot put one \
back while nothing is.",
),
None => ok(
"codex_auth_file",
"Codex login file",
"absent; nothing is signed in to Codex",
),
Some(access) if access.shared => warn(
"codex_auth_file",
"Codex login file",
format!("{} is {}", facts.auth_file.display(), access.described),
format!(
"It holds a usable login in plain text, and Codex sets 0600 only when it \
creates the file, never on a later write{}",
running(os.make_private_command(
Kind::File,
&[&facts.auth_file.display().to_string()]
))
),
),
Some(access) => ok(
"codex_auth_file",
"Codex login file",
format!("{} · {}", facts.auth_file.display(), access.described),
),
});
match &facts.login {
Ok(Some(login)) => checks.push(ok(
"codex_login",
"Codex login",
format!(
"{} · account {} · refresh {}",
login.email,
login.account_id,
if login.fingerprint.is_empty() {
"none"
} else {
login.fingerprint.as_str()
}
),
)),
Ok(None) => {}
Err(CodexLoginTrouble::NotAnAccount(why)) if enrolled => checks.push(warn(
"codex_login",
"Codex login",
why.clone(),
"Pitboard parks and switches Codex's ChatGPT sign-ins, so `pitboard use \
codex/<label>` refuses while Codex is signed in this way rather than replace \
a login it has nowhere to park. `codex login` signs in with a ChatGPT \
account.",
)),
Err(CodexLoginTrouble::NotAnAccount(why)) => {
checks.push(ok("codex_login", "Codex login", why.clone()));
}
Err(CodexLoginTrouble::Unusable(why)) => checks.push(broken(
"codex_login",
"Codex login",
format!("it cannot be used: {why}"),
"Pitboard will not park or switch a Codex login it cannot read as one \
account's. Signing in with `codex` again writes a fresh one."
.into(),
)),
}
}
checks.extend(parks.iter().map(|p| judge_park(p, now)));
checks.extend(parks.iter().filter_map(|p| judge_dormant(p, now)));
checks.push(judge_codex_version(facts));
checks.push(judge_codex_running(facts.running.as_deref()));
checks
}
fn judge_codex_running(running: Option<&[crate::holder::Holding]>) -> Check {
let (detail, advice) = match running {
None => ("could not tell".to_string(), String::new()),
Some([]) => ("none".to_string(), String::new()),
Some(holding) => (
format!(
"{}; each keeps using the account it started with until it is started again",
crate::holder::described_with_pids(holding)
),
crate::holder::remedies(holding, "to take a switch"),
),
};
Check {
advice,
..ok("codex_running", "running Codex", detail)
}
}
fn judge_codex_version(facts: &CodexFacts) -> Check {
let verified = crate::provider::codex::assumptions::VERIFIED_AGAINST;
ok(
"codex_version",
"Codex build",
match (facts.program.as_deref(), facts.version.as_deref()) {
(None, _) => format!("not found here; Pitboard's facts were read from {verified}"),
(Some(program), None) => format!(
"{}, whose path does not say which version it is; Pitboard's facts were \
read from {verified}",
program.display()
),
(Some(_), Some(installed)) if installed == verified => {
format!("{installed}, which is what Pitboard's facts were read from")
}
(Some(_), Some(installed)) => {
format!("{installed} installed; Pitboard's facts were read from {verified}")
}
},
)
}
fn judge_desktop(facts: &DesktopFacts, state: &State, parks: &[ParkFact], now: i64) -> Vec<Check> {
use crate::provider::folder::{Sidecar, Unfit, Unread};
let mut checks = Vec::new();
let folder = "Claude data folder";
match &facts.root {
Err(reason) => {
let advice = facts.own_home.as_ref().map_or_else(String::new, |own| {
format!(
"Run Pitboard with `HOME` unset, or set to {}.",
own.display()
)
});
checks.push(fail("desktop_data_folder", folder, reason, advice));
}
Ok(root) => {
let shown = root.display();
checks.push(match facts.refused {
Some(unfit @ Unfit::Link) => fail(
"desktop_data_folder",
folder,
format!("{shown} {unfit}"),
"Pitboard moves no Claude Desktop login through a link.",
),
Some(unfit @ Unfit::ThirdParty(_)) => fail(
"desktop_data_folder",
folder,
format!("{shown} {unfit}"),
"Pitboard moves no Claude Desktop login beside a third-party deployment.",
),
None if facts.present => ok("desktop_data_folder", folder, shown.to_string()),
None => warn(
"desktop_data_folder",
folder,
format!("{shown} does not exist"),
"Run `pitboard enroll desktop/<label> --sign-in`, then open Claude and sign \
in: Pitboard waits for it to quit.",
),
});
}
}
if let (Ok(root), Some(settings)) = (&facts.root, &facts.settings) {
let name = "Claude settings";
checks.push(match settings {
Ok((Sidecar::Valid, _)) => ok(
"desktop_settings",
name,
"config.json read, with a settings write Claude left, which Pitboard reads as \
Claude will",
),
Ok((Sidecar::Stale, _)) => ok(
"desktop_settings",
name,
"config.json read; a stale settings write beside it, which Claude drops when it \
opens",
),
Ok((Sidecar::None, true)) => ok("desktop_settings", name, "config.json read"),
Ok((Sidecar::None, false)) => ok("desktop_settings", name, "no config.json yet"),
Err(Unread::Unknown { what, then }) => fail(
"desktop_settings",
name,
format!("{} {what}", root.display()),
then.said().trim_end(),
),
Err(Unread::Unreadable(detail)) => fail("desktop_settings", name, detail.clone(), ""),
});
if facts.present {
let name = "Claude data folder layout";
checks.push(if facts.unread.is_empty() {
ok(
"desktop_layout",
name,
"every entry is one Pitboard has read",
)
} else {
let names = facts.unread.iter().map(|n| format!("`{n}`")).collect();
warn(
"desktop_layout",
name,
format!(
"holds {}, which this Pitboard has not read",
words::listed(names)
),
"Pitboard starts no move of a Claude Desktop login while they are there. \
Update Pitboard, or report them at \
https://github.com/datlechin/pitboard/issues if no release reads them yet.",
)
});
}
}
checks.extend(judge_desktop_login(facts, state, now));
let in_use = state.account_in_use(ProviderId::Desktop).map(|a| a.key());
for fact in parks.iter().filter(|p| p.provider == ProviderId::Desktop) {
checks.push(judge_desktop_park(facts, fact, now));
}
let typed = |label: &str| state.typed(&Key::new(ProviderId::Desktop, label));
if let Some(moving) = state.moving(ProviderId::Desktop) {
checks.push(warn(
"desktop_move",
"interrupted Claude Desktop move",
format!(
"a move of `{}`'s login was interrupted {}",
typed(&moving.label),
time::moment(moving.started_at, now)
),
"Keep Claude closed: the next change to a Claude Desktop account settles it first. \
If it cannot, `pitboard abandon` keeps every folder and moves on.",
));
}
if let Some(awaited) = state.awaiting(ProviderId::Desktop)
&& let Some(put_back) = state.by_id(ProviderId::Desktop, &awaited.put_back)
{
let put_back = state.typed(&put_back.key());
checks.push(warn(
"desktop_awaiting",
"awaited sign-in",
format!(
"Claude is signed out for a sign-in since {}, and `{put_back}` is parked",
time::moment(awaited.since, now)
),
format!(
"Sign in inside Claude, quit it with Command-Q, then run `pitboard enroll {}`. \
To go back, run `pitboard use {put_back}`.",
typed(&awaited.label)
),
));
}
let parks_dir = facts.parks_dir.display();
match facts.private {
Some(crate::host::Private::Missing) | None => {}
Some(found) => {
let name = "parked Claude Desktop logins";
checks.push(match found {
crate::host::Private::Yes if facts.one_volume == Some(false) => fail(
"desktop_parks_folder",
name,
format!(
"{parks_dir} is on another volume than {}",
facts.root.as_ref().map_or_else(
|_| "Claude's data".into(),
|root| root.display().to_string()
)
),
"Keep Pitboard's folder on the volume your home is on.",
),
crate::host::Private::Yes => ok(
"desktop_parks_folder",
name,
format!("private · {parks_dir}"),
),
crate::host::Private::Shared => fail(
"desktop_parks_folder",
name,
format!("{parks_dir} can be reached by others"),
format!("Run `chmod 700 {parks_dir}`."),
),
crate::host::Private::Link => fail(
"desktop_parks_folder",
name,
format!("{parks_dir} is a link"),
"Pitboard parks no Claude Desktop login through a link.",
),
crate::host::Private::NotOwn => fail(
"desktop_parks_folder",
name,
format!("{parks_dir} is not yours"),
"Pitboard parks no Claude Desktop login in a folder another user owns.",
),
crate::host::Private::NotADirectory | crate::host::Private::Missing => fail(
"desktop_parks_folder",
name,
format!("{parks_dir} is not a folder"),
"Pitboard parks no Claude Desktop login there.",
),
});
}
}
let asides: Vec<&crate::state::Aside> = state
.aside
.iter()
.filter(|aside| aside.tool == ProviderId::Desktop)
.collect();
if !asides.is_empty() {
let (holding, parts): (Vec<&crate::state::Aside>, Vec<&crate::state::Aside>) =
asides.iter().partition(|aside| aside.login.is_some());
let name = "folders set aside";
let n = asides.len();
let account = |aside: &crate::state::Aside| {
aside
.account
.as_deref()
.and_then(|id| state.owner_of_park(id))
};
let kept_beside = |aside: &crate::state::Aside| {
account(aside).is_some_and(|a| a.parked.is_some() || in_use.as_ref() == Some(&a.key()))
};
let said = |aside: &crate::state::Aside| {
let when = time::moment(aside.at, now);
let typed = account(aside).map(|a| state.typed(&a.key()));
match (aside.why, typed) {
(crate::state::SetAside::AnotherMachine, Some(typed)) => {
format!("`{typed}`'s login from another Mac")
}
(crate::state::SetAside::Superseded, Some(typed)) if kept_beside(aside) => {
format!("`{typed}`'s login from before a sign-in again, set aside {when}")
}
(
crate::state::SetAside::Superseded | crate::state::SetAside::GivenUp,
Some(typed),
) => format!("`{typed}`'s earlier login, set aside {when}"),
_ => format!("a login no account here holds, set aside {when}"),
}
};
let mut rest: Vec<String> = parts
.iter()
.filter_map(|aside| {
let when = time::moment(aside.at, now);
match (aside.why, account(aside).map(|a| state.typed(&a.key()))) {
(_, Some(typed)) => {
Some(format!("parts of `{typed}`'s login, set aside {when}"))
}
(crate::state::SetAside::InTheWay, None) => None,
(_, None) => Some(format!(
"parts of a login no account here holds, set aside {when}"
)),
}
})
.collect();
let only_signed_out = rest.is_empty();
if rest.len() < parts.len() {
rest.push("what Claude made while signed out".into());
}
let mut detail = match holding.as_slice() {
[] => format!("{n} in {parks_dir}, none holding a login"),
held => format!(
"{n} in {parks_dir}, {} holding a login: {}",
held.len(),
words::listed(held.iter().map(|aside| said(aside)).collect())
),
};
if !rest.is_empty() {
let (whose, only) = match holding.is_empty() {
true if only_signed_out => ("they hold", "only "),
true => ("they hold", ""),
false => ("the rest hold", ""),
};
detail.push_str(&format!("; {whose} {only}{}", words::listed(rest)));
}
checks.push(if holding.iter().all(|aside| kept_beside(aside)) {
ok("desktop_set_aside", name, detail)
} else {
warn(
"desktop_set_aside",
name,
detail,
"`pitboard repair` gives one back to its account where it can. `pitboard \
forget` of an account deletes those set aside for it, and `pitboard \
uninstall` deletes them all.",
)
});
}
checks.push(judge_desktop_build(facts));
checks
}
fn judge_desktop_login(facts: &DesktopFacts, state: &State, now: i64) -> Option<Check> {
let known = facts.known.as_ref()?;
let name = "Claude Desktop in use";
let tool = ProviderId::Desktop;
let enroll = "Open Claude, check which account it shows, quit it with Command-Q, then run \
`pitboard enroll desktop/<label>` under that account's name.";
let Some(last) = &known.last else {
return Some(warn(
"desktop_login",
name,
"Pitboard has not read who is signed in to Claude",
"The next change to a Claude Desktop account reads it, with Claude closed.",
));
};
let when = time::moment(last.known_at, now);
let who = Stored::of(state, tool, last.owner.as_ref());
Some(match known.doubt {
Some(crate::in_use::Doubt::LoginUnreadable) => warn(
"desktop_login",
name,
format!(
"Claude's settings could not be read, so this is as Pitboard last read it {when}"
),
"`Claude settings` says why.",
),
Some(crate::in_use::Doubt::NamedMoved) => warn(
"desktop_login",
name,
words::desktop_named(&Stored::of(state, tool, known.own_record.as_ref()), &who),
enroll,
),
_ if facts.signed_out => warn(
"desktop_login",
name,
format!(
"{}, as Pitboard last read it {when}; both token caches are emptied since, so \
Claude looks signed out",
who.said("nobody")
),
"The next change to a Claude Desktop account reads who is signed in, with Claude \
closed.",
),
_ => match state.account_in_use(tool) {
Some(account) if account.parked.is_some() => {
let typed = state.typed(&account.key());
warn(
"desktop_login",
name,
format!("`{typed}` is recorded in use and also holds a parked login"),
format!(
"Open Claude, check which account it shows, and quit it with \
Command-Q. If it shows `{typed}`, run `pitboard enroll {typed}`: its \
older parked login is set aside, and nothing is deleted. If it shows \
another account, run `pitboard enroll desktop/<label>` under that \
account's name."
),
)
}
_ => ok(
"desktop_login",
name,
format!("{}, as Pitboard last read it {when}", who.said("nobody")),
),
},
})
}
fn judge_desktop_park(facts: &DesktopFacts, fact: &ParkFact, now: i64) -> Check {
let name = format!("account {}", fact.typed());
let renew = format!("Run `pitboard enroll {} --sign-in`.", fact.typed());
let code = "desktop_parked_login";
let Some(park) = &fact.park else {
return if fact.active {
ok(code, name, "signed in; parked when you switch away")
} else {
warn(code, name, "nothing parked to switch to", renew)
};
};
let here = facts
.parked_here
.iter()
.find(|(service, _)| *service == park.service)
.map(|(_, folder)| folder);
match here {
Some(Ok(true)) => {}
Some(Err(e)) => {
return warn(
code,
name,
format!(
"not read: Pitboard could not look at it in {} ({e})",
facts.parks_dir.display()
),
format!(
"Make {}, and each folder it is in, readable to you, then run `pitboard \
doctor` again.",
facts.parks_dir.display()
),
);
}
Some(Ok(false)) | None => {
return fail(
code,
name,
format!(
"its parked login is missing from {}",
facts.parks_dir.display()
),
format!(
"Run `pitboard repair`. If it gives nothing back, run `pitboard enroll {} \
--sign-in`.",
fact.typed()
),
);
}
}
let Some(at) = park.session_expires_at else {
return ok(code, name, "parked");
};
let life = words::session_life_column(at, now);
if at > now {
ok(code, name, format!("parked, {life}"))
} else {
warn(
code,
name,
format!("parked, {life}"),
format!(
"If Claude asks you to sign in once you use it, run `pitboard enroll {} \
--sign-in`.",
fact.typed()
),
)
}
}
fn judge_desktop_build(facts: &DesktopFacts) -> Check {
use crate::provider::desktop::assumptions::{READ_FROM_THE_ARCHIVE, VERIFIED_AGAINST};
match &facts.build {
Some(build) => ok(
"desktop_build",
"Claude",
format!(
"Claude {build}; Pitboard's facts were read from {VERIFIED_AGAINST} and \
{READ_FROM_THE_ARCHIVE}, and it checks the folder before each move, not the build"
),
),
None => ok("desktop_build", "Claude", "not found in /Applications"),
}
}
#[derive(Debug)]
pub struct Diagnosis {
pub checks: Vec<Check>,
pub environment: Value,
pub redaction: crate::redact::Sheet,
}
pub(crate) fn run(ctx: &Context) -> Diagnosis {
if let Err(Error::HomeNotAbsolute { variable, path }) = home::check_absolute(ctx) {
return unplaced(ctx, variable, &path);
}
let facts = gather(ctx);
Diagnosis {
redaction: redaction_for(ctx, &facts),
checks: evaluate(&facts),
environment: json!({
"config_file": facts.config_path,
"storage_dir": facts.storage_dir,
"credential_service": facts.service,
"credential_store": facts.backend.as_ref().map_or("unreadable", |b| b.name()),
"home": facts.home,
"codex": {
"home": facts.codex.home,
"present": facts.codex.present,
"backend": facts.codex.backend,
"login_present": (facts.codex.backend == "file")
.then_some(facts.codex.auth_access.is_some()),
"version": facts.codex.version,
},
"desktop": desktop_environment(ctx, &facts),
}),
}
}
fn desktop_environment(ctx: &Context, facts: &Facts) -> Value {
let (root, build) = match &facts.desktop {
Some(desktop) => (desktop.root.clone().ok(), desktop.build.clone()),
None => (
claude_desktop().and_then(|app| app.root(ctx).ok()),
desktop_build(ctx),
),
};
json!({
"data_folder": root,
"present": root.as_ref().is_some_and(|root| root.is_dir()),
"build": build,
})
}
fn desktop_build(ctx: &Context) -> Option<String> {
ctx.host()
.app_version(std::path::Path::new("/Applications/Claude.app"))
}
fn unplaced(ctx: &Context, variable: &str, path: &std::path::Path) -> Diagnosis {
let holds = if path.as_os_str().is_empty() {
"empty".to_string()
} else {
format!("`{}`, which is not a full path", path.display())
};
let homes = fail(
"homes",
"homes",
format!("{variable} is {holds}"),
"Set it to a full path, or unset it. Pitboard checks nothing else until then: \
everything it reads is under these folders.",
);
Diagnosis {
checks: too_old(ctx.host().floor())
.into_iter()
.chain(elevated(crate::host::OS, ctx.host().elevation(ctx)))
.chain(std::iter::once(homes))
.collect(),
environment: json!({}),
redaction: crate::redact::Sheet::new(
format!("{}:{}", crate::state::machine_id(), ctx.now_millis()),
ctx.home().to_string_lossy(),
),
}
}
fn redaction_for(ctx: &Context, facts: &Facts) -> crate::redact::Sheet {
let mut sheet = crate::redact::Sheet::new(
format!("{}:{}", crate::state::machine_id(), ctx.now_millis()),
ctx.home().to_string_lossy(),
)
.hide(facts.account.clone(), "user");
if let Some(id) = &facts.config_account {
sheet = sheet
.hide(id.email.clone(), "email")
.hide(id.account_uuid.clone(), "account")
.hide(id.organization_uuid.clone(), "org");
if let Some(name) = &id.organization_name {
sheet = sheet.hide(name.clone(), "org");
}
}
let named = facts
.in_use
.iter()
.flat_map(|known| known.owner().into_iter().chain(&known.own_record));
for owner in named {
sheet = sheet
.hide(owner.email.clone(), "email")
.hide(owner.account_uuid.clone(), "account")
.hide(owner.organization_uuid.clone(), "org");
}
if let Ok(state) = &facts.state {
for account in &state.accounts {
sheet = sheet
.hide(account.email.clone(), "email")
.hide(account.account_uuid.clone(), "account")
.hide(
account
.claude()
.map_or_else(String::new, |c| c.organization_uuid.to_string()),
"org",
);
if let crate::state::Detail::Codex {
workspace_id: Some(workspace),
..
} = &account.detail
{
sheet = sheet.hide(workspace.clone(), "org");
}
}
}
if let Ok(Some(login)) = &facts.codex.login {
sheet = sheet
.hide(login.email.clone(), "email")
.hide(login.account_id.clone(), "account")
.hide(login.fingerprint.clone(), "login");
}
if let Ok(Some(doc)) = &facts.credential
&& let Some(fingerprint) = doc
.get("claudeAiOauth")
.map(crate::provider::claude::document::fingerprint_of)
.filter(|f| !f.is_empty())
{
sheet = sheet.hide(fingerprint, "login");
}
if let Some(fingerprint) = facts
.fallback_login
.as_ref()
.and_then(|left| left.fingerprint.as_ref().ok().cloned().flatten())
.filter(|fingerprint| !fingerprint.is_empty())
{
sheet = sheet.hide(fingerprint, "login");
}
for park in &facts.parks {
if let Some(held) = &park.park {
sheet = sheet
.hide(held.login.clone(), "login")
.hide(held.service.clone(), "park");
}
}
if let Ok(state) = &facts.state {
if let Some(record) = state.in_use(ProviderId::Desktop) {
sheet = sheet.hide(record.login.clone(), "login");
}
for aside in &state.aside {
sheet = sheet
.hide(aside.name.clone(), "park")
.hide(aside.login.clone().unwrap_or_default(), "login");
}
}
let schedules = facts
.schedule
.as_ref()
.and_then(|s| s.network.proxy.as_ref());
for proxy in facts.network.proxy.iter().chain(schedules) {
if !proxy.on_loopback {
sheet = sheet.hide(proxy.server.clone(), "proxy");
}
}
sheet
}
pub fn healthy(checks: &[Check]) -> bool {
checks.iter().all(|c| c.level != Level::Fail)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::service::Permit;
use std::sync::Arc;
fn mode(mode: u32) -> Access {
Access {
shared: mode & 0o077 != 0,
described: format!("mode {mode:o}"),
}
}
fn direct() -> NetworkFact {
NetworkFact {
proxy: None,
passed_over: Vec::new(),
}
}
fn desktop() -> DesktopFacts {
DesktopFacts {
one_volume: Some(true),
root: Ok(PathBuf::from(
"/Users/dana/Library/Application Support/Claude",
)),
own_home: None,
refused: None,
present: true,
settings: Some(Ok((crate::provider::folder::Sidecar::None, true))),
unread: Vec::new(),
known: None,
signed_out: false,
parks_dir: PathBuf::from("/Users/dana/.pitboard/parks"),
private: Some(crate::host::Private::Yes),
parked_here: Vec::new(),
build: None,
}
}
fn desktop_account(label: &str, parked: Option<Park>) -> crate::state::Account {
crate::state::Account {
label: label.into(),
id: format!("desktop_{label}"),
account_uuid: label.into(),
email: String::new(),
parked,
last_used_at: None,
replaced_at: None,
plan: None,
detail: crate::state::Detail::Desktop {},
}
}
fn folder_park(service: &str, session_expires_at: Option<i64>) -> Park {
Park {
service: service.into(),
kind: crate::state::ParkKind::Folder,
parked_at: NOW - 86_400,
login: "f".into(),
access_expires_at: None,
refresh_expires_at: None,
session_expires_at,
}
}
#[test]
fn section_names_the_tool_whose_checks_these_are() {
for code in [
"claude_version",
"claude_daemon",
"credential",
"state",
"schedule_proxy",
"system_too_old",
"desktop",
"codexy",
] {
assert_eq!(section(code), None, "{code}");
}
for code in ["codex_backend", "codex_login", "codex_parked_login"] {
assert_eq!(section(code), Some(ProviderId::Codex), "{code}");
}
let mut state = State::default();
state
.accounts
.push(desktop_account("work", Some(folder_park("p", None))));
let checks = judge_desktop(&desktop(), &state, &[], NOW);
assert!(!checks.is_empty());
for check in checks {
assert_eq!(
section(check.code),
Some(ProviderId::Desktop),
"{}",
check.code
);
}
}
fn said(checks: &[Check], code: &str) -> Vec<(Level, String)> {
checks
.iter()
.filter(|c| c.code == code)
.map(|c| (c.level, c.detail.clone()))
.collect()
}
#[test]
fn an_interrupted_move_and_a_parks_folder_on_another_volume_are_said() {
let mut state = State::default();
state.accounts.push(desktop_account("work", None));
state.moving.insert(
"desktop".into(),
crate::state::Moving {
half: crate::state::Half::Leave,
started_at: NOW - 300,
id: "desktop_work".into(),
label: "work".into(),
park: "pitboard-park-desktop_work-1".into(),
login: "f0".into(),
items: vec!["Cookies".into()],
keys: std::collections::BTreeMap::new(),
asides: std::collections::BTreeMap::new(),
slot: String::new(),
awaited: None,
},
);
let mut facts = desktop();
facts.one_volume = Some(false);
let checks = judge_desktop(&facts, &state, &[], NOW);
let moved = said(&checks, "desktop_move");
assert_eq!(moved.len(), 1, "{moved:?}");
assert_eq!(moved[0].0, Level::Warn);
assert!(
moved[0]
.1
.starts_with("a move of `desktop/work`'s login was interrupted"),
"{moved:?}"
);
assert_eq!(
said(&checks, "desktop_parks_folder"),
[(
Level::Fail,
"/Users/dana/.pitboard/parks is on another volume than /Users/dana/Library/\
Application Support/Claude"
.into()
)]
);
state.moving.clear();
facts.one_volume = Some(true);
let checks = judge_desktop(&facts, &state, &[], NOW);
assert!(said(&checks, "desktop_move").is_empty());
assert_eq!(said(&checks, "desktop_parks_folder")[0].0, Level::Ok);
}
#[test]
fn claude_desktops_checks_say_what_the_index_and_the_settings_say() {
use crate::provider::folder::{Sidecar, Unfit, Unread};
let mut state = State::default();
state.accounts.push(desktop_account("work", None));
state.accounts.push(desktop_account(
"home",
Some(folder_park("home-1", Some(NOW + 90_000))),
));
state.accounts.push(desktop_account(
"old",
Some(folder_park("old-1", Some(NOW - 5))),
));
state
.accounts
.push(desktop_account("lost", Some(folder_park("lost-1", None))));
state.accounts.push(desktop_account("none", None));
let work = crate::in_use::InUse {
owner: Some(state.accounts[0].owner()),
login: "f0".into(),
known_at: NOW - 60,
named: Some("desktop_work".into()),
};
state.in_use.insert("desktop".into(), work.clone());
let parks: Vec<ParkFact> = state
.accounts
.iter()
.map(|a| ParkFact {
provider: ProviderId::Desktop,
name: format!("desktop/{}", a.label),
active: a.label == "work",
last_used_at: None,
park: a.parked.clone(),
unreadable: None,
})
.collect();
let mut facts = desktop();
facts.known = Some(crate::in_use::Known {
last: Some(work),
doubt: None,
own_record: Some(state.accounts[0].owner()),
});
facts.parked_here = vec![("home-1".into(), Ok(true)), ("old-1".into(), Ok(true))];
facts.build = Some("2.31226.1".into());
let checks = judge_desktop(&facts, &state, &parks, NOW);
let codes: Vec<&str> = checks.iter().map(|c| c.code).collect();
assert_eq!(
codes,
[
"desktop_data_folder",
"desktop_settings",
"desktop_layout",
"desktop_login",
"desktop_parked_login",
"desktop_parked_login",
"desktop_parked_login",
"desktop_parked_login",
"desktop_parked_login",
"desktop_parks_folder",
"desktop_build",
]
);
assert_eq!(
said(&checks, "desktop_parked_login"),
[
(Level::Ok, "signed in; parked when you switch away".into()),
(Level::Ok, "parked, sign-in may lapse in 1d 1h".into()),
(Level::Warn, "parked, sign-in may have lapsed".into()),
(
Level::Fail,
"its parked login is missing from /Users/dana/.pitboard/parks".into()
),
(Level::Warn, "nothing parked to switch to".into()),
]
);
assert_eq!(
said(&checks, "desktop_settings"),
[(Level::Ok, "config.json read".into())]
);
let login = said(&checks, "desktop_login");
assert!(
login[0]
.1
.starts_with("`desktop/work`, as Pitboard last read it"),
"{login:?}"
);
assert_eq!(
said(&checks, "desktop_build")[0].1,
"Claude 2.31226.1; Pitboard's facts were read from 2.19675.0 and 2.7032.0, and it \
checks the folder before each move, not the build"
);
assert!(checks.iter().all(|c| !c.code.contains("dormant")));
facts.unread = vec!["Network".into(), "Partitions".into()];
facts.settings = Some(Err(Unread::Unknown {
what: "has a settings file, `config.json`, that is not valid JSON".into(),
then: crate::error::Then::CopyAside,
}));
facts.private = Some(crate::host::Private::Shared);
facts.signed_out = true;
facts.build = None;
let checks = judge_desktop(&facts, &state, &parks, NOW);
assert_eq!(
said(&checks, "desktop_layout"),
[(
Level::Warn,
"holds `Network` and `Partitions`, which this Pitboard has not read".into()
)]
);
let settings = checks
.iter()
.find(|c| c.code == "desktop_settings")
.unwrap();
assert_eq!(settings.level, Level::Fail);
assert!(
settings.advice.starts_with("Claude resets a settings file"),
"{}",
settings.advice
);
assert!(
settings.advice.ends_with("then run this again."),
"{}",
settings.advice
);
assert_eq!(said(&checks, "desktop_parks_folder")[0].0, Level::Fail);
assert!(
said(&checks, "desktop_login")[0]
.1
.contains("looks signed out")
);
assert_eq!(
said(&checks, "desktop_build")[0].1,
"not found in /Applications"
);
for (unfit, detail, advice) in [
(
Unfit::Link,
"/Users/dana/Library/Application Support/Claude is a link",
"Pitboard moves no Claude Desktop login through a link.",
),
(
Unfit::ThirdParty("Claude-3p"),
"/Users/dana/Library/Application Support/Claude has a `Claude-3p` folder beside \
it, which a third-party deployment of Claude uses",
"Pitboard moves no Claude Desktop login beside a third-party deployment.",
),
] {
facts.refused = Some(unfit);
let checks = judge_desktop(&facts, &state, &parks, NOW);
let folder = checks
.iter()
.find(|c| c.code == "desktop_data_folder")
.unwrap();
assert_eq!(
(folder.level, folder.detail.as_str(), folder.advice.as_str()),
(Level::Fail, detail, advice)
);
}
facts.refused = None;
facts.settings = Some(Ok((Sidecar::Valid, true)));
facts.root = Err("`HOME` names another folder than this account's own".into());
facts.own_home = Some(PathBuf::from("/Users/dana"));
let checks = judge_desktop(&facts, &state, &parks, NOW);
let folder = checks
.iter()
.find(|c| c.code == "desktop_data_folder")
.unwrap();
assert_eq!(
(folder.level, folder.detail.as_str(), folder.advice.as_str()),
(
Level::Fail,
"`HOME` names another folder than this account's own",
"Run Pitboard with `HOME` unset, or set to /Users/dana."
)
);
assert!(said(&checks, "desktop_settings").is_empty());
}
#[test]
#[cfg_attr(
windows,
ignore = "Claude Desktop is macOS only: Windows refuses its folder calls"
)]
fn claude_desktops_facts_come_from_one_read_of_its_settings() {
use crate::provider::folder::Sidecar;
let m = switch::harness::desktop_machine("doctor-facts");
let state = crate::state::load(&m.ctx).expect("state");
let (facts, reads) =
crate::fault::counting("settings.read", || desktop_facts(&m.ctx, &state));
let facts = facts.expect("an account is enrolled");
assert_eq!(reads, 1);
assert_eq!(facts.settings, Some(Ok((Sidecar::None, true))));
let known = facts.known.expect("judged");
assert_eq!(known.doubt, None);
assert_eq!(
known.own_record.map(|owner| owner.account_uuid).as_deref(),
Some(switch::harness::WORK.uuid)
);
assert!(!facts.signed_out);
std::fs::remove_file(m.ctx_home().join("claude/data/config.json")).expect("removed");
let facts = desktop_facts(&m.ctx, &state).expect("an account is enrolled");
assert_eq!(facts.settings, Some(Ok((Sidecar::None, false))));
let checks = judge_desktop(&facts, &state, &[], switch::harness::NOW);
assert_eq!(
said(&checks, "desktop_settings"),
[(Level::Ok, "no config.json yet".into())]
);
}
#[test]
#[cfg_attr(
windows,
ignore = "Claude Desktop is macOS only: Windows refuses its folder calls"
)]
fn doctor_says_the_build_of_the_claude_installed() {
let m = switch::harness::desktop_machine("doctor-build");
m.mem.app_at(
std::path::Path::new("/Applications/Claude.app"),
"2.31226.1",
);
let diagnosis = run(&m.ctx);
let build = diagnosis
.checks
.iter()
.find(|c| c.code == "desktop_build")
.expect("the build is said");
assert!(
build.detail.starts_with("Claude 2.31226.1; "),
"{}",
build.detail
);
assert_eq!(diagnosis.environment["desktop"]["build"], "2.31226.1");
}
#[test]
#[cfg_attr(
windows,
ignore = "Claude Desktop is macOS only: Windows refuses its folder calls"
)]
fn a_park_doctor_cannot_look_at_is_never_said_missing() {
let m = switch::harness::desktop_pair("doctor-park-unread");
let state = crate::state::load(&m.ctx).expect("state");
let parks_dir = store::folder::parks_dir(&m.ctx);
crate::host::fs::testing::deny_reading(&parks_dir).expect("parks shut");
let facts = desktop_facts(&m.ctx, &state);
crate::host::fs::testing::make_private(&parks_dir).expect("parks open again");
let facts = facts.expect("an account is enrolled");
let parks = park_facts(&m.ctx, &state);
let checks = judge_desktop(&facts, &state, &parks, switch::harness::NOW);
let home = checks
.iter()
.find(|c| c.code == "desktop_parked_login" && c.name == "account desktop/home")
.map(|c| (c.level, c.detail.clone(), c.advice.clone()))
.expect("home's park is checked");
assert_eq!(home.0, Level::Warn, "{home:?}");
assert!(
home.1.starts_with(&format!(
"not read: Pitboard could not look at it in {} (",
parks_dir.display()
)),
"{home:?}"
);
assert!(!home.2.contains("--sign-in"), "{home:?}");
}
#[test]
#[cfg_attr(
windows,
ignore = "Claude Desktop is macOS only: Windows refuses its folder calls"
)]
fn a_data_folder_is_found_where_only_a_folder_set_aside_is_left() {
use crate::state::{Aside, SetAside};
let m = switch::harness::desktop_pair("doctor-aside-only");
let mut state = crate::state::load(&m.ctx).expect("state");
state
.accounts
.retain(|account| account.provider() != ProviderId::Desktop);
state.aside.push(Aside {
name: "pitboard-aside-1".into(),
tool: ProviderId::Desktop,
at: NOW,
why: SetAside::Orphan,
account: None,
login: None,
});
let facts = desktop_facts(&m.ctx, &state).expect("a folder set aside shows Claude Desktop");
let parks = park_facts(&m.ctx, &state);
let checks = judge_desktop(&facts, &state, &parks, switch::harness::NOW);
let folder = checks
.iter()
.find(|c| c.code == "desktop_data_folder")
.map(|c| (c.level, c.detail.clone()))
.expect("the data folder is checked");
assert_eq!(folder.0, Level::Ok, "{folder:?}");
}
#[test]
fn a_claude_desktop_login_is_redacted_from_a_report() {
use crate::state::{Aside, SetAside};
let uuid = "6f1c0a52-1111-4a6e-9d0b-2f6c1a9e7b10";
let kept = format!("pitboard-park-desktop_{uuid}-1");
let mut state = State::default();
state.in_use.insert(
"desktop".into(),
crate::in_use::InUse {
owner: None,
login: "0123456789abcdef".into(),
known_at: NOW,
named: None,
},
);
state.aside.push(Aside {
name: kept.clone(),
tool: ProviderId::Desktop,
at: NOW,
why: SetAside::Orphan,
account: None,
login: Some("fedcba9876543210".into()),
});
let mut f = facts();
f.state = Ok(state);
let ctx = Context::new(PathBuf::from("/home/x"));
let text = format!("read 0123456789abcdef in use; {kept} holds fedcba9876543210");
let hidden = redaction_for(&ctx, &f).over(&text);
for secret in ["0123456789abcdef", "fedcba9876543210", kept.as_str(), uuid] {
assert!(text.contains(secret), "{text}");
assert!(!hidden.contains(secret), "{hidden}");
}
}
#[test]
fn a_folder_set_aside_warns_only_where_its_account_holds_nothing_else() {
use crate::state::{Aside, SetAside};
let aside = |name: &str, why, account: Option<&str>, login: Option<&str>| Aside {
name: name.into(),
tool: ProviderId::Desktop,
at: NOW - 3_600,
why,
account: account.map(str::to_owned),
login: login.map(str::to_owned),
};
let mut state = State::default();
state.accounts.push(desktop_account("work", None));
state
.accounts
.push(desktop_account("home", Some(folder_park("home-2", None))));
state.accounts.push(desktop_account("lost", None));
state.in_use.insert(
"desktop".into(),
crate::in_use::InUse {
owner: Some(state.accounts[0].owner()),
login: "f".into(),
known_at: NOW,
named: None,
},
);
let set_aside = |state: &State| {
let checks = judge_desktop(&desktop(), state, &[], NOW);
said(&checks, "desktop_set_aside")
};
assert!(set_aside(&state).is_empty(), "none set aside, nothing said");
state
.aside
.push(aside("pitboard-aside-1", SetAside::InTheWay, None, None));
let none = set_aside(&state);
assert_eq!(none[0].0, Level::Ok);
assert_eq!(
none[0].1,
"1 in /Users/dana/.pitboard/parks, none holding a login; they hold only what Claude \
made while signed out"
);
state.aside.push(aside(
"home-0",
SetAside::Superseded,
Some("desktop_home"),
None,
));
state.aside.push(aside(
"work-0",
SetAside::GivenUp,
Some("desktop_work"),
None,
));
state.aside.push(aside(
"gone-0",
SetAside::GivenUp,
Some("desktop_gone"),
None,
));
let parts = set_aside(&state);
assert_eq!(parts[0].0, Level::Ok);
for said in [
"4 in /Users/dana/.pitboard/parks, none holding a login",
"parts of `desktop/home`'s login, set aside",
"parts of `desktop/work`'s login, set aside",
"parts of a login no account here holds, set aside",
"what Claude made while signed out",
] {
assert!(parts[0].1.contains(said), "{said}: {parts:?}");
}
assert!(!parts[0].1.contains("only what Claude made"), "{parts:?}");
state.aside.truncate(1);
state.aside.push(aside(
"home-1",
SetAside::Superseded,
Some("desktop_home"),
Some("a"),
));
state.aside.push(aside(
"work-1",
SetAside::Superseded,
Some("desktop_work"),
Some("b"),
));
let kept = set_aside(&state);
assert_eq!(
kept[0].0,
Level::Ok,
"its account holds a newer park, or is in use"
);
assert!(
kept[0]
.1
.starts_with("3 in /Users/dana/.pitboard/parks, 2 holding a login: ")
);
assert!(
kept[0]
.1
.contains("`desktop/home`'s login from before a sign-in again, set aside"),
"{kept:?}"
);
assert!(
kept[0]
.1
.ends_with("; the rest hold what Claude made while signed out"),
"{kept:?}"
);
state.aside.push(aside(
"lost-1",
SetAside::AnotherMachine,
Some("desktop_lost"),
Some("c"),
));
state
.aside
.push(aside("x-1", SetAside::Orphan, None, Some("d")));
let warned = set_aside(&state);
assert_eq!(warned[0].0, Level::Warn);
assert!(
warned[0]
.1
.contains("`desktop/lost`'s login from another Mac"),
"{warned:?}"
);
assert!(
warned[0]
.1
.contains("a login no account here holds, set aside"),
"{warned:?}"
);
}
#[test]
fn claude_desktop_named_since_signed_out_reads_as_the_warning_does() {
let mut state = State::default();
state.accounts.push(desktop_account("work", None));
let nobody = crate::in_use::InUse {
owner: None,
login: String::new(),
known_at: NOW - 60,
named: None,
};
state.in_use.insert("desktop".into(), nobody.clone());
let mut facts = desktop();
facts.known = Some(crate::in_use::Known {
last: Some(nobody),
doubt: Some(crate::in_use::Doubt::NamedMoved),
own_record: Some(state.accounts[0].owner()),
});
let checks = judge_desktop(&facts, &state, &[], NOW);
let login = said(&checks, "desktop_login");
let warned = crate::service::Warning::InUseUnconfirmed {
tool: ProviderId::Desktop,
last: Stored::Nothing,
named: Stored::Account("desktop/work".into()),
asked: true,
}
.to_string();
assert_eq!(
login[0].1,
"Claude's settings have named `desktop/work` since Pitboard last read Claude signed \
out, so somebody may have signed in"
);
assert!(warned.starts_with(&login[0].1), "{warned}");
}
#[test]
fn asking_names_each_service_it_asks_once() {
let account = |label: &str, detail| crate::state::Account {
label: label.into(),
id: label.into(),
account_uuid: label.into(),
email: String::new(),
parked: None,
last_used_at: None,
replaced_at: None,
plan: None,
detail,
};
let claude = || crate::state::Detail::Claude {
organization_uuid: "org".into(),
oauth_account: json!({}),
};
let codex = || crate::state::Detail::Codex { workspace_id: None };
let desktop = || crate::state::Detail::Desktop {};
let named = |accounts: Vec<crate::state::Account>| {
let mut f = facts();
f.state = Ok(State {
accounts,
..State::default()
});
judge_asking(&f).name
};
assert_eq!(named(vec![account("work", desktop())]), "asking Anthropic");
assert_eq!(
named(vec![account("alpha", claude()), account("work", desktop())]),
"asking Anthropic"
);
assert_eq!(
named(vec![
account("alpha", claude()),
account("main", codex()),
account("work", desktop()),
]),
"asking Anthropic and OpenAI"
);
}
fn facts() -> Facts {
Facts {
os: crate::host::OS,
security_tool: Some("/usr/bin/security".into()),
config_path: PathBuf::from("/home/x/.claude.json"),
config: Ok(json!({"oauthAccount": {}})),
config_account: Some(claude::ConfigAccount {
email: "a@b.c".into(),
account_uuid: "acc".into(),
organization_uuid: "org".into(),
organization_name: None,
}),
credential_parts: Vec::new(),
credential_cost: Some(store::Cost {
needs: 900,
limit: 4032,
second_route: true,
}),
service: "Claude Code-credentials".into(),
account: "someone".into(),
default_slot: true,
storage_dir: "/home/x/.claude".into(),
backend: Ok(store::Backend::Keychain),
credential_file: PathBuf::from("/home/x/.claude/.credentials.json"),
credential: Ok(Some(json!({"claudeAiOauth": {
"refreshToken": "r",
"refreshTokenExpiresAt": 2_000_000_000_000i64
}}))),
home: PathBuf::from("/home/x/.pitboard"),
home_access: Some(mode(0o700)),
readable_by_others: Vec::new(),
machine_id_known: true,
hover_rest_env: false,
daemon: None,
pending_parks: Vec::new(),
claude_version: Some(crate::provider::claude::assumptions::VERIFIED_AGAINST.into()),
auth_overrides: Vec::new(),
auth_unread: None,
asking_held: Vec::new(),
state: Ok(State::default()),
in_use: Some(in_use_as_named()),
parks: Vec::new(),
interrupted: false,
stuck: None,
codex: no_codex(),
desktop: None,
claude_present: true,
schedule: None,
network: direct(),
in_the_app: false,
elevation: crate::host::Elevation::Normal,
fallback_login: None,
floor: crate::host::Floor::Met,
now: NOW,
}
}
fn in_use_as_named() -> crate::in_use::Known {
let owner = crate::provider::Owner {
account_uuid: "acc".into(),
email: "a@b.c".into(),
organization_uuid: "org".into(),
};
let stored = json!({"claudeAiOauth": {"refreshToken": "r"}});
crate::in_use::Known {
last: Some(crate::in_use::InUse {
owner: Some(owner.clone()),
login: crate::provider::document(ProviderId::Claude).fingerprint(&stored),
known_at: NOW - 60,
named: Some(crate::state::new_id(ProviderId::Claude, &owner)),
}),
doubt: None,
own_record: Some(owner),
}
}
fn no_codex() -> CodexFacts {
CodexFacts {
home: PathBuf::from("/home/x/.codex"),
present: false,
enrolled: 0,
backend: "file",
backend_setting: "Codex's default".into(),
backend_remedy: String::new(),
auth_file: PathBuf::from("/home/x/.codex/auth.json"),
auth_access: None,
login: Ok(None),
program: None,
version: None,
running: Some(Vec::new()),
}
}
#[test]
fn the_auth_check_says_what_it_did_not_read() {
let mut facts = facts();
assert_eq!(
judge_auth(&facts).detail,
"the stored login, which is what Pitboard moves"
);
facts.auth_unread = Some("Claude Code's managed settings are not read on Windows yet");
let check = judge_auth(&facts);
assert_eq!(check.level, Level::Ok);
assert_eq!(
check.detail,
"the stored login, which is what Pitboard moves, as far as it reads: Claude Code's \
managed settings are not read on Windows yet"
);
}
#[test]
fn advice_names_a_command_only_where_there_is_one() {
assert_eq!(
running(Os::MacOs.make_private_command(Kind::File, &["/a"])),
": `chmod 600 /a`."
);
assert_eq!(
running(Os::Linux.make_private_command(Kind::Directory, &["/a", "/b"])),
": `chmod 700 /a /b`."
);
assert_eq!(Os::Windows.make_private_command(Kind::Any, &["/a"]), None);
assert_eq!(running(None), ".");
}
fn codex_checks(codex: &CodexFacts) -> Vec<Check> {
judge_codex(crate::host::OS, codex, &[], NOW)
}
fn with_codex() -> CodexFacts {
CodexFacts {
present: true,
auth_access: Some(mode(0o600)),
login: Ok(Some(CodexLogin {
email: "w@example.com".into(),
account_id: "work-account".into(),
fingerprint: "0123456789abcdef".into(),
})),
program: Some(PathBuf::from("/usr/local/bin/codex")),
version: Some(crate::provider::codex::assumptions::VERIFIED_AGAINST.into()),
..no_codex()
}
}
const NOW: i64 = 1_789_935_600;
fn parked(label: &str, refresh_expires_at: Option<i64>) -> ParkFact {
ParkFact {
provider: ProviderId::Claude,
last_used_at: None,
name: Key::new(ProviderId::Claude, label).typed(),
active: false,
park: Some(Park {
service: format!("pitboard-park-{label}-1"),
parked_at: NOW - 86_400,
kind: crate::state::ParkKind::Vault,
login: "f".into(),
access_expires_at: None,
refresh_expires_at,
session_expires_at: None,
}),
unreadable: None,
}
}
fn codex_parked(label: &str, refresh_expires_at: Option<i64>) -> ParkFact {
ParkFact {
provider: ProviderId::Codex,
name: Key::new(ProviderId::Codex, label).typed(),
..parked(label, refresh_expires_at)
}
}
fn named<'a>(checks: &'a [Check], name: &str) -> &'a Check {
checks.iter().find(|c| c.name == name).expect(name)
}
fn check<'a>(checks: &'a [Check], code: &str) -> &'a Check {
checks.iter().find(|c| c.code == code).expect(code)
}
#[test]
fn a_healthy_machine_reports_no_failures() {
let checks = evaluate(&facts());
assert!(checks.iter().all(|c| c.level != Level::Fail));
assert!(healthy(&checks));
}
#[test]
#[cfg_attr(windows, ignore = "W15: files made private to the person on Windows")]
fn a_world_readable_park_is_found_on_the_disk() {
use crate::host::fs::testing;
let scratch = tempfile::tempdir().expect("a scratch home");
let root = scratch.path().to_path_buf();
let ctx = Context::new(root.clone()).with_pitboard_home(root.join(".pitboard"));
let vault = store::vault_dir(&ctx);
crate::host::fs::create_private_dir(Permit::for_a_test(), &vault).expect("a vault");
assert!(
loose_logins(&ctx).is_empty(),
"a private vault is not loose"
);
let park = vault.join("pitboard-park-x.json");
std::fs::write(&park, "{}").expect("a park");
testing::open_to_others(&park).expect("opened to others");
let found = loose_logins(&ctx);
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(Some(&found[0].1), crate::host::fs::access(&park).as_ref());
assert!(found[0].0.ends_with("pitboard-park-x.json"), "{found:?}");
testing::make_private(&park).expect("made private");
assert!(loose_logins(&ctx).is_empty(), "a private park is not loose");
}
#[test]
#[cfg_attr(
windows,
ignore = "W15: who else can read a login on Windows, and the command that makes one private"
)]
fn a_login_anyone_on_this_machine_can_read_is_a_failure() {
let mut f = facts();
assert_eq!(check(&evaluate(&f), "private_on_disk").level, Level::Ok);
f.readable_by_others = vec![
(
"/home/a/.pitboard/vault/pitboard-park-x.json".into(),
mode(0o644),
),
("/home/a/.claude/.credentials.json".into(), mode(0o640)),
];
let checks = evaluate(&f);
let found = check(&checks, "private_on_disk");
assert_eq!(found.level, Level::Fail);
assert!(found.detail.contains("mode 644"), "{}", found.detail);
assert!(found.detail.contains("mode 640"), "{}", found.detail);
assert!(found.advice.contains("chmod go-rwx"), "{}", found.advice);
assert!(!healthy(&checks));
}
#[test]
fn a_credential_whose_login_is_not_an_object_is_a_failure() {
let mut f = facts();
f.credential = Ok(Some(json!({"claudeAiOauth": "something else"})));
let checks = evaluate(&f);
assert_eq!(check(&checks, "credential").level, Level::Fail);
assert!(!healthy(&checks));
}
#[test]
fn an_unreadable_store_fails_rather_than_reporting_nothing_stored() {
let mut f = facts();
f.credential = Err(store::Error::Unreadable("security exited 1".into()));
f.backend = Err(store::Error::Unreadable("security exited 1".into()));
let checks = evaluate(&f);
assert_eq!(check(&checks, "credential").level, Level::Fail);
assert_eq!(check(&checks, "credential_store").level, Level::Fail);
}
#[test]
fn a_locked_keychain_is_one_failure_and_sends_nobody_to_sign_in_again() {
let mut f = facts();
f.credential = Err(store::Error::Locked);
let mut behind = parked("work", Some(NOW + 20 * 86_400));
behind.unreadable = Some(Unreadable::Locked);
f.parks = vec![behind];
let checks = evaluate(&f);
let credential = check(&checks, "credential");
assert_eq!(credential.level, Level::Fail);
assert_eq!(
credential.detail,
"the keychain is locked, and cannot ask to be unlocked from here"
);
assert!(
credential.advice.contains("`security unlock-keychain`")
&& credential.advice.contains("keeps the login it last read"),
"{}",
credential.advice
);
let park = named(&checks, "account work");
assert_eq!(park.level, Level::Warn, "{}", park.detail);
assert_eq!(park.detail, "not read: the keychain is locked");
assert!(!park.advice.contains("--sign-in"), "{}", park.advice);
assert_eq!(
checks.iter().filter(|c| c.level == Level::Fail).count(),
1,
"one cause, one failure"
);
f.fallback_login = Some(FallbackLogin {
path: f.credential_file.clone(),
fingerprint: Ok(Some("0123456789abcdef".into())),
});
let advice = check(&evaluate(&f), "credential").advice.clone();
assert!(
advice.contains("signs in with the login in /home/x/.claude/.credentials.json"),
"{advice}"
);
}
#[test]
fn a_login_left_behind_the_keychain_is_said_with_what_reads_it() {
let mut f = facts();
assert!(evaluate(&f).iter().all(|c| c.code != "fallback_login"));
f.fallback_login = Some(FallbackLogin {
path: f.credential_file.clone(),
fingerprint: Ok(Some("0123456789abcdef".into())),
});
let checks = evaluate(&f);
let said = check(&checks, "fallback_login");
assert_eq!(said.level, Level::Warn);
assert_eq!(said.name, "fallback login");
assert_eq!(
said.detail,
"/home/x/.claude/.credentials.json · refresh 0123456789abcdef"
);
assert!(
said.advice.contains("over SSH")
&& said.advice.contains(
"While it is there, Claude Code sessions already running at a switch keep \
the account they are on until their login is next renewed"
)
&& said.advice.ends_with(
"`pitboard stow` keeps this login for its account, where Pitboard holds no \
other it can switch to, then deletes the file, which leaves one login for \
every session."
),
"{}",
said.advice
);
assert!(!said.advice.contains("rm "), "{}", said.advice);
assert!(healthy(&checks), "nothing Pitboard does is stopped by it");
}
#[test]
fn a_file_behind_the_keychain_with_no_login_in_it_is_said_with_what_it_holds_back() {
let mut f = facts();
f.fallback_login = Some(FallbackLogin {
path: f.credential_file.clone(),
fingerprint: Ok(None),
});
let checks = evaluate(&f);
let said = check(&checks, "fallback_login");
assert_eq!(said.level, Level::Warn);
assert_eq!(
said.detail,
"/home/x/.claude/.credentials.json · no login in it"
);
assert_eq!(
said.advice,
"It holds no Claude Code login. While it is there, Claude Code sessions already \
running at a switch keep the account they are on until their login is next \
renewed, or until they are started again. `pitboard stow` deletes it, which lets \
them follow a switch."
);
assert!(
check(&checks, "auth_source")
.detail
.ends_with(", once a session reads it again"),
"{}",
check(&checks, "auth_source").detail
);
f.credential = Err(store::Error::Locked);
let advice = check(&evaluate(&f), "credential").advice.clone();
assert!(
advice.ends_with("one started here is signed out."),
"{advice}"
);
}
#[test]
fn a_file_behind_the_keychain_that_cannot_be_read_is_said_with_why() {
let mut f = facts();
f.fallback_login = Some(FallbackLogin {
path: f.credential_file.clone(),
fingerprint: Err(store::Error::Unreadable(
"cannot read /home/x/.claude/.credentials.json: Permission denied".into(),
)),
});
let checks = evaluate(&f);
let said = check(&checks, "fallback_login");
assert_eq!(said.level, Level::Warn);
assert_eq!(
said.detail,
"/home/x/.claude/.credentials.json · not read: the credential store could not be \
read: cannot read /home/x/.claude/.credentials.json: Permission denied"
);
assert_eq!(
said.advice,
"Pitboard could not read it, so whether it holds a login, which a session that \
cannot read the keychain signs in with, cannot be told, and `pitboard stow` cannot \
put it away. While it is there, Claude Code sessions already running at a switch \
keep the account they are on until their login is next renewed, or until they are \
started again. Deleting it lets them follow a switch, and takes whatever it holds \
with it: `rm /home/x/.claude/.credentials.json`."
);
assert!(healthy(&checks), "nothing Pitboard does is stopped by it");
f.credential = Err(store::Error::Locked);
let advice = check(&evaluate(&f), "credential").advice.clone();
assert!(
advice.ends_with(
"whether one started here signs in with what /home/x/.claude/.credentials.json \
holds cannot be told, since Pitboard could not read it."
),
"{advice}"
);
}
#[test]
#[cfg_attr(
windows,
ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
)]
fn a_login_left_behind_the_keychain_is_found_in_its_file() {
use crate::host::memory::MemoryHost;
use crate::store::memory::Fault;
let host = MemoryHost::new();
let ctx = Context::for_unit_test().with_memory_stores(Arc::clone(&host));
let service = claude::live_service(&ctx);
let file = host.file_at(claude_live::credential_file(&ctx));
let login = |token: &str| json!({"claudeAiOauth": {"refreshToken": token}}).to_string();
host.live().plant(&service, &login("in use"));
assert!(fallback_login(&ctx).is_none());
file.plant(&service, &login("left"));
let found = fallback_login(&ctx).expect("a login behind the keychain's");
assert_eq!(found.path, claude_live::credential_file(&ctx));
assert_eq!(
found.fingerprint.expect("readable"),
Some(store::fingerprint("left"))
);
file.plant(&service, &json!({"mcpOAuth": {}}).to_string());
let found = fallback_login(&ctx).expect("what `/logout` leaves is still there");
assert_eq!(
found.fingerprint.expect("readable"),
None,
"and holds no login"
);
file.plant(&service, &login("left"));
file.fault(
&service,
Fault::UnreadableContents("permission denied".into()),
);
let found = fallback_login(&ctx).expect("what cannot be read is still there");
assert!(
matches!(found.fingerprint, Err(store::Error::Unreadable(_))),
"and what it holds is not guessed"
);
file.heal(&service);
host.live().delete_everything();
assert!(
fallback_login(&ctx).is_none(),
"the file is the login in use when the keychain holds none"
);
}
#[test]
fn a_park_behind_a_locked_keychain_is_read_as_locked() {
use crate::host::memory::MemoryHost;
use crate::store::memory::Fault;
let host = MemoryHost::new();
let ctx = Context::for_unit_test().with_memory_stores(Arc::clone(&host));
let state = State {
accounts: vec![crate::state::Account {
label: "work".into(),
id: "work-uuid".into(),
account_uuid: "work-uuid".into(),
email: "work@example.com".into(),
parked: parked("work", Some(NOW + 20 * 86_400)).park,
last_used_at: None,
replaced_at: None,
plan: None,
detail: crate::state::Detail::Claude {
organization_uuid: "org".into(),
oauth_account: json!({}),
},
}],
..State::default()
};
assert_eq!(
park_facts(&ctx, &state)[0].unreadable,
Some(Unreadable::Broken("missing from the vault".into()))
);
host.vault().fault_all(Fault::Locked);
assert_eq!(
park_facts(&ctx, &state)[0].unreadable,
Some(Unreadable::Locked)
);
}
#[test]
fn a_login_about_to_expire_is_flagged() {
let mut f = facts();
f.credential = Ok(Some(json!({"claudeAiOauth": {
"refreshToken": "r",
"refreshTokenExpiresAt": (f.now + 86_400) * 1000
}})));
assert_eq!(check(&evaluate(&f), "credential").level, Level::Warn);
}
#[test]
fn a_loose_home_directory_is_flagged() {
let mut f = facts();
f.home_access = Some(mode(0o755));
let checks = evaluate(&f);
let home = check(&checks, "home");
assert_eq!(home.level, Level::Warn);
assert!(home.detail.contains("755"));
}
#[test]
fn the_successor_backend_alone_does_not_move_the_login() {
let server_on = || {
let mut f = facts();
f.config = Ok(json!({"cachedGrowthBookFeatures": {"tengu_hover_rest": true}}));
f
};
let env_on = || {
let mut f = facts();
f.hover_rest_env = true;
f
};
for mut f in [server_on(), env_on()] {
let checks = evaluate(&f);
assert_eq!(check(&checks, "storage_v5").level, Level::Ok);
f.backend = Ok(store::Backend::File);
let checks = evaluate(&f);
assert_eq!(check(&checks, "storage_v5").level, Level::Warn);
}
}
#[test]
fn the_successor_backend_is_quiet_when_it_is_off() {
let mut f = facts();
let checks = evaluate(&f);
assert_eq!(check(&checks, "storage_v5").level, Level::Ok);
f.backend = Ok(store::Backend::File);
let checks = evaluate(&f);
assert_eq!(check(&checks, "storage_v5").level, Level::Ok);
}
#[test]
fn a_file_store_is_the_whole_answer_only_on_linux() {
let mut f = facts();
f.backend = Ok(store::Backend::File);
for (os, level, advice) in [
(Os::Linux, Level::Ok, ""),
(
Os::MacOs,
Level::Warn,
"Claude Code fell back to a file, which means a keychain write failed at some \
point.",
),
(
Os::Windows,
Level::Warn,
"Claude Code may keep its login in Credential Manager instead, which Pitboard \
does not read yet.",
),
] {
f.os = os;
let checks = evaluate(&f);
let store = check(&checks, "credential_store");
assert_eq!(
(store.level, store.advice.as_str()),
(level, advice),
"{os:?}"
);
assert!(store.detail.starts_with("plaintext file"), "{os:?}");
}
}
#[test]
fn a_config_that_names_somebody_signed_in_with_no_login_anywhere_is_a_failure() {
let mut f = facts();
f.backend = Ok(store::Backend::Absent);
let checks = evaluate(&f);
let store = check(&checks, "credential_store");
assert_eq!(store.level, Level::Fail);
assert!(store.detail.contains("a@b.c"));
f.config_account = None;
let checks = evaluate(&f);
let store = check(&checks, "credential_store");
assert_eq!(store.level, Level::Warn);
assert!(store.advice.contains("Nothing is signed in"));
}
#[test]
fn a_login_past_the_ceiling_reads_differently_when_the_way_past_it_is_refused() {
let mut f = facts();
let checks = evaluate(&f);
assert_eq!(check(&checks, "credential_size").level, Level::Ok);
f.credential_cost = Some(store::Cost {
needs: 8503,
limit: 4032,
second_route: true,
});
let checks = evaluate(&f);
let written = check(&checks, "credential_size");
assert_eq!(written.level, Level::Warn);
assert!(written.detail.contains("argument line"));
f.credential_cost = Some(store::Cost {
needs: 8503,
limit: 4032,
second_route: false,
});
let checks = evaluate(&f);
let refused = check(&checks, "credential_size");
assert_eq!(
refused.level,
Level::Fail,
"there is no third way to write it"
);
assert!(refused.detail.contains("PITBOARD_NO_ARGV"));
}
#[test]
fn a_login_too_big_says_what_is_taking_up_the_room() {
let mut f = facts();
f.credential_cost = Some(store::Cost {
needs: 8503,
limit: 4032,
second_route: true,
});
f.credential_parts = parts_of(&json!({
"claudeAiOauth": {"refreshToken": "r"},
"mcpOAuth": {
"one": {"token": "x".repeat(300)},
"two": {"token": "y".repeat(200)},
"three": {"token": "z".repeat(100)},
},
}));
let checks = evaluate(&f);
let size = check(&checks, "credential_size");
assert!(size.detail.contains("mcpOAuth one"), "{}", size.detail);
assert!(size.detail.contains("mcpOAuth two"), "{}", size.detail);
assert!(
!size.detail.contains("claudeAiOauth"),
"three is enough to act on, and the login itself is never the problem: {}",
size.detail
);
}
#[test]
fn what_takes_up_the_room_is_listed_largest_first_and_named_per_server() {
let parts = parts_of(&json!({
"claudeAiOauth": {"refreshToken": "r"},
"mcpOAuth": {"small": {"t": "x"}, "large": {"t": "y".repeat(500)}},
}));
let names: Vec<&str> = parts.iter().map(|(what, _)| what.as_str()).collect();
assert_eq!(names[0], "mcpOAuth large", "largest first: {names:?}");
assert!(names.contains(&"claudeAiOauth"));
assert!(
names.contains(&"mcpOAuth small"),
"each server is named, because that is what a person signs out of"
);
let single = parts_of(&json!({"mcpOAuth": {"only": {"t": "x"}}}));
assert_eq!(single.len(), 1);
assert_eq!(single[0].0, "mcpOAuth");
}
#[test]
fn an_account_nobody_has_come_back_to_is_said_out_loud() {
let mut f = facts();
f.parks = vec![ParkFact {
provider: ProviderId::Claude,
name: Key::new(ProviderId::Claude, "work").typed(),
active: false,
last_used_at: Some(f.now - 31 * 86_400),
park: Some(Park {
service: "pitboard-park-acc-1".into(),
parked_at: f.now - 31 * 86_400,
kind: crate::state::ParkKind::Vault,
login: "f".into(),
access_expires_at: Some(f.now + 3600),
refresh_expires_at: Some(f.now + 30 * 86_400),
session_expires_at: None,
}),
unreadable: None,
}];
let checks = evaluate(&f);
let dormant = check(&checks, "dormant_account");
assert_eq!(dormant.level, Level::Warn);
assert!(dormant.advice.contains("pitboard forget work"));
f.parks[0].last_used_at = Some(f.now - 29 * 86_400);
assert!(
!evaluate(&f).iter().any(|c| c.code == "dormant_account"),
"an account used within a token's lifetime is not dormant"
);
f.parks[0].last_used_at = Some(f.now - 400 * 86_400);
f.parks[0].active = true;
assert!(!evaluate(&f).iter().any(|c| c.code == "dormant_account"));
f.parks[0].active = false;
f.parks[0].park = None;
assert!(!evaluate(&f).iter().any(|c| c.code == "dormant_account"));
}
#[test]
fn the_daemon_is_reported_without_being_a_problem() {
let mut f = facts();
let checks = evaluate(&f);
assert!(check(&checks, "claude_daemon").detail.contains("none"));
f.daemon = Some(daemon::Daemon {
pid: 4321,
version: Some("2.1.278".into()),
running: true,
});
let checks = evaluate(&f);
let running = check(&checks, "claude_daemon");
assert_eq!(running.level, Level::Ok);
assert!(running.detail.contains("running"));
assert!(running.detail.contains("2.1.278"));
assert!(running.detail.contains("4321"));
f.daemon.as_mut().expect("set above").running = false;
let checks = evaluate(&f);
let stopped = check(&checks, "claude_daemon");
assert_eq!(stopped.level, Level::Ok);
assert!(stopped.detail.contains("not running"));
}
#[test]
fn a_schedule_is_judged_by_whether_the_pitboard_it_runs_is_still_there() {
let mut f = facts();
assert!(
evaluate(&f).iter().all(|c| c.code != "schedule"),
"nothing is said where there is no schedule"
);
f.schedule = Some(ScheduleFact {
path: PathBuf::from("/home/x/Library/LaunchAgents/com.datlechin.pitboard.renew.plist"),
program: Some(PathBuf::from("/opt/homebrew/bin/pitboard")),
program_found: true,
network: direct(),
network_differs: false,
});
let checks = evaluate(&f);
let found = check(&checks, "schedule");
assert_eq!(found.level, Level::Ok);
assert!(found.detail.contains("/opt/homebrew/bin/pitboard"));
f.schedule.as_mut().expect("set above").program_found = false;
let checks = evaluate(&f);
let gone = check(&checks, "schedule");
assert_eq!(gone.level, Level::Fail, "every renewal from now on fails");
assert!(gone.detail.contains("/opt/homebrew/bin/pitboard"));
assert_eq!(gone.advice, again(crate::host::OS));
f.schedule = Some(ScheduleFact {
path: PathBuf::from("/home/x/Library/LaunchAgents/com.datlechin.pitboard.renew.plist"),
program: Some(PathBuf::from(
"/Applications/Pitboard.app/Contents/MacOS/Pitboard",
)),
program_found: true,
network: direct(),
network_differs: false,
});
let checks = evaluate(&f);
let the_app = check(&checks, "schedule");
assert_eq!(
the_app.level,
Level::Fail,
"0.3.0's app renews nothing when started with `renew`"
);
assert!(
the_app.detail.contains("the app itself"),
"{}",
the_app.detail
);
f.schedule.as_mut().expect("set above").program = Some(PathBuf::from(
"/Applications/Pitboard.app/Contents/Helpers/pitboard",
));
assert_eq!(
check(&evaluate(&f), "schedule").level,
Level::Ok,
"the command line an app comes with is a command line"
);
f.schedule.as_mut().expect("set above").program = None;
let checks = evaluate(&f);
let unread = check(&checks, "schedule");
assert_eq!(unread.level, Level::Warn);
assert!(!unread.advice.is_empty());
}
#[test]
fn a_broken_schedule_is_written_again_by_whatever_this_machine_has() {
let mac = again(Os::MacOs);
assert!(
mac.contains("the app's Settings") && mac.contains("pitboard schedule install"),
"{mac}"
);
let linux = again(Os::Linux);
assert!(linux.contains("pitboard schedule install"), "{linux}");
assert!(!linux.contains("app"), "there is no app here: {linux}");
}
#[test]
#[cfg_attr(windows, ignore = "W25: Task Scheduler")]
fn a_schedule_whose_pitboard_is_gone_is_found_on_the_disk() {
let scratch = tempfile::tempdir().expect("a scratch home");
let root = scratch.path().to_path_buf();
let program = root.join("bin/pitboard");
let ctx = Context::new(root.clone())
.with_memory_stores(crate::host::memory::MemoryHost::new())
.with_schedule_program(program.clone());
assert!(schedule_fact(&ctx).is_none(), "nothing installed yet");
std::fs::create_dir_all(root.join("bin")).expect("a bin");
std::fs::write(&program, "").expect("a Pitboard");
crate::schedule::install(&ctx, Permit::for_a_test()).expect("installed");
let fact = schedule_fact(&ctx).expect("installed");
assert_eq!(fact.program.as_deref(), Some(program.as_path()));
assert!(fact.program_found);
std::fs::remove_file(&program).expect("taken away");
assert!(!schedule_fact(&ctx).expect("still installed").program_found);
assert!(
schedule_fact(&ctx.with_pitboard_home(root.join("elsewhere"))).is_none(),
"a Pitboard pointed at another home has no schedule"
);
}
fn scheduled(network: NetworkFact, network_differs: bool) -> ScheduleFact {
ScheduleFact {
path: PathBuf::from("/home/x/.config/systemd/user/pitboard-renew.timer"),
program: Some(PathBuf::from("/usr/local/bin/pitboard")),
program_found: true,
network,
network_differs,
}
}
fn by(proxy: ProxyFact) -> NetworkFact {
NetworkFact {
proxy: Some(proxy),
passed_over: Vec::new(),
}
}
#[test]
fn a_schedule_whose_proxy_differs_from_this_runs_is_said() {
let mut f = facts();
f.schedule = Some(scheduled(direct(), false));
assert!(
evaluate(&f).iter().all(|c| c.code != "schedule_proxy"),
"nothing is said where they go the same way"
);
f.schedule = Some(scheduled(by(through(None, &[])), true));
let checks = evaluate(&f);
let line = check(&checks, "schedule_proxy");
assert_eq!(
(line.level, line.name.as_str(), line.detail.as_str()),
(
Level::Warn,
"renewal schedule's proxy",
"the schedule was installed with http://***:***@proxy.example.com:3128, from \
HTTPS_PROXY; this run goes directly"
)
);
assert!(
line.advice.contains("`pitboard schedule install`"),
"{}",
line.advice
);
let on_loopback = || ProxyFact {
variable: "ALL_PROXY",
address: "http://127.0.0.1:3128".into(),
server: "127.0.0.1:3128".into(),
on_loopback: true,
..through(Some("NO_PROXY"), &["chatgpt.com"])
};
f.network = by(on_loopback());
f.schedule = Some(scheduled(direct(), true));
assert_eq!(
check(&evaluate(&f), "schedule_proxy").detail,
"the schedule was installed with no proxy; this run goes through \
http://127.0.0.1:3128, from ALL_PROXY, NO_PROXY exempting chatgpt.com"
);
let socks = || ProxyFact {
address: "socks5h://127.0.0.1:1080".into(),
server: "127.0.0.1:1080".into(),
socks: true,
..on_loopback()
};
f.network = by(socks());
let checks = evaluate(&f);
let line = check(&checks, "schedule_proxy");
assert_eq!(
(line.detail.as_str(), line.advice.as_str()),
(
"the schedule was installed with no proxy; this run is given \
socks5h://127.0.0.1:1080, from ALL_PROXY, NO_PROXY exempting chatgpt.com",
"The schedule's runs are given the proxy variables it was installed with. \
Installed again from here, they would be given this run's SOCKS proxy, which \
Pitboard does not use yet, and their requests would fail. Set ALL_PROXY to an \
HTTP proxy's address, or unset it, then install it again with `pitboard \
schedule install`, which takes the proxy variables set where it runs."
)
);
f.network = by(ProxyFact {
exempt: [
"api.anthropic.com",
"platform.claude.com",
"auth.openai.com",
"chatgpt.com",
]
.map(String::from)
.to_vec(),
..socks()
});
let checks = evaluate(&f);
let advice = &check(&checks, "schedule_proxy").advice;
assert!(
advice.starts_with(
"The schedule's runs are given the proxy variables it was installed with. To \
give them this run's, install it again from here"
),
"{advice}"
);
f.network = by(through(None, &[]));
f.schedule = Some(scheduled(by(through(None, &[])), true));
assert_eq!(
check(&evaluate(&f), "schedule_proxy").detail,
"the schedule was installed with this run's proxy, \
http://***:***@proxy.example.com:3128, from HTTPS_PROXY, but with another user \
name or password"
);
}
#[test]
fn in_the_app_a_schedules_other_proxy_is_said_without_a_warning() {
let mut f = facts();
f.in_the_app = true;
f.schedule = Some(scheduled(direct(), false));
assert!(evaluate(&f).iter().all(|c| c.code != "schedule_proxy"));
f.schedule = Some(scheduled(by(through(None, &[])), true));
let checks = evaluate(&f);
let line = check(&checks, "schedule_proxy");
assert_eq!(
(line.level, line.name.as_str(), line.detail.as_str()),
(
Level::Ok,
"renewal schedule's proxy",
"the schedule was installed with http://***:***@proxy.example.com:3128, from \
HTTPS_PROXY; the app goes directly"
)
);
for said in ["Settings", "`pitboard schedule install` in a terminal"] {
assert!(line.advice.contains(said), "{said}: {}", line.advice);
}
f.network = by(through(None, &[]));
assert_eq!(
check(&evaluate(&f), "schedule_proxy").detail,
"the schedule was installed with the app's proxy, \
http://***:***@proxy.example.com:3128, from HTTPS_PROXY, but with another user \
name or password"
);
}
#[test]
#[cfg_attr(windows, ignore = "W25: Task Scheduler")]
fn the_schedules_proxy_is_read_from_what_install_wrote() {
let scratch = tempfile::tempdir().expect("a scratch home");
let root = scratch.path().to_path_buf();
let program = root.join("bin/pitboard");
std::fs::create_dir_all(root.join("bin")).expect("a bin");
std::fs::write(&program, "").expect("a Pitboard");
let machine = crate::host::memory::MemoryHost::new();
let home = root.to_string_lossy().into_owned();
let run = |proxy: Option<&str>| {
let env: crate::context::Environment = [("HOME", home.as_str())]
.into_iter()
.chain(proxy.map(|address| ("HTTPS_PROXY", address)))
.collect();
Context::for_command_line(&env)
.with_memory_stores(Arc::clone(&machine))
.with_schedule_program(program.clone())
};
let gathered = |ctx: &Context| {
let mut f = facts();
f.schedule = schedule_fact(ctx);
f.network = network_fact(&ctx.proxy);
f
};
let installer = run(Some("http://alice:s3cret@proxy.example.com:3128"));
crate::schedule::install(&installer, Permit::for_a_test()).expect("installed");
let same = gathered(&installer);
assert!(!same.schedule.as_ref().expect("installed").network_differs);
assert!(evaluate(&same).iter().all(|c| c.code != "schedule_proxy"));
let changed = gathered(&run(Some("http://alice:changed@proxy.example.com:3128")));
let detail = check(&evaluate(&changed), "schedule_proxy").detail.clone();
assert!(
detail.ends_with("but with another user name or password"),
"{detail}"
);
for secret in ["alice", "s3cret", "changed"] {
assert!(!detail.contains(secret), "{secret}: {detail}");
}
let direct = run(None);
let f = gathered(&direct);
let detail = check(&evaluate(&f), "schedule_proxy").detail.clone();
assert_eq!(
detail,
"the schedule was installed with http://***:***@proxy.example.com:3128, from \
HTTPS_PROXY; this run goes directly"
);
let hidden = redaction_for(&direct, &f).over(&detail);
assert!(
hidden.starts_with("the schedule was installed with http://***:***@<proxy ")
&& !hidden.contains("proxy.example.com"),
"{hidden}"
);
}
#[test]
fn every_failure_and_warning_tells_the_user_something() {
let mut f = facts();
f.credential = Ok(Some(json!({"slackTag": {}})));
f.home_access = Some(mode(0o755));
for c in evaluate(&f) {
if c.level != Level::Ok {
assert!(!c.advice.is_empty(), "{} has no advice", c.code);
}
}
}
#[test]
fn advice_reads_as_it_was_written() {
let mut f = facts();
f.credential = Ok(Some(json!({"slackTag": {}})));
f.home_access = Some(mode(0o755));
f.readable_by_others = vec![("/home/a/.claude/.credentials.json".into(), mode(0o644))];
for c in evaluate(&f) {
assert!(!c.advice.contains(" "), "{}: {:?}", c.code, c.advice);
}
}
fn through(exempting: Option<&'static str>, exempt: &[&str]) -> ProxyFact {
ProxyFact {
variable: "HTTPS_PROXY",
address: "http://***:***@proxy.example.com:3128".into(),
server: "proxy.example.com:3128".into(),
on_loopback: false,
socks: false,
exempting,
exempt: exempt.iter().map(|host| (*host).to_string()).collect(),
}
}
fn network(proxy: Option<ProxyFact>, passed_over: &[&'static str]) -> Check {
let mut f = facts();
f.network = NetworkFact {
proxy,
passed_over: passed_over.to_vec(),
};
let checks = evaluate(&f);
let found = check(&checks, "network");
Check {
code: found.code,
name: found.name.clone(),
level: found.level,
detail: found.detail.clone(),
advice: found.advice.clone(),
}
}
#[test]
fn the_network_check_names_the_proxy_and_the_variable_it_came_from() {
let direct = network(None, &[]);
assert_eq!(
(direct.level, direct.name.as_str(), direct.detail.as_str()),
(Level::Ok, "network", "direct: no variable names a proxy")
);
for (exempting, exempt, said) in [
(None, &[][..], ""),
(
Some("NO_PROXY"),
&[],
"; NO_PROXY exempts none of the hosts Pitboard calls",
),
(
Some("no_proxy"),
&["api.anthropic.com"],
"; no_proxy exempts api.anthropic.com, which Pitboard reaches directly",
),
(
Some("NO_PROXY"),
&["api.anthropic.com", "platform.claude.com"],
"; NO_PROXY exempts api.anthropic.com and platform.claude.com, which Pitboard \
reaches directly",
),
(
Some("NO_PROXY"),
&[
"api.anthropic.com",
"platform.claude.com",
"auth.openai.com",
"chatgpt.com",
],
"; NO_PROXY exempts every host Pitboard calls, which it reaches directly",
),
] {
let check = network(Some(through(exempting, exempt)), &[]);
assert_eq!(check.level, Level::Ok, "{said}");
assert_eq!(
check.detail,
format!("through http://***:***@proxy.example.com:3128, from HTTPS_PROXY{said}")
);
assert!(check.advice.is_empty(), "{}", check.advice);
}
}
fn network_read_from(pairs: &[(&'static str, &str)]) -> Check {
let env: crate::context::Environment = pairs
.iter()
.copied()
.chain([("HOME", "/nowhere")])
.collect();
let fact = network_fact(&Context::for_command_line(&env).proxy);
network(fact.proxy, &fact.passed_over)
}
#[test]
fn a_socks_proxy_is_a_warning_naming_its_variable() {
let socks = network_read_from(&[("all_proxy", "socks5://127.0.0.1:7890")]);
assert_eq!(
(socks.level, socks.detail.as_str(), socks.advice.as_str()),
(
Level::Warn,
"requests fail: Pitboard does not use SOCKS proxies yet, and all_proxy names \
one, socks5://127.0.0.1:7890",
"Pitboard goes through HTTP and HTTPS proxies only. For its requests to go \
out, set all_proxy to an HTTP proxy's address, such as \
http://proxy.example.com:8080, or unset it."
)
);
let some = network_read_from(&[
("ALL_PROXY", "socks5h://alice:s3cret@p.example"),
("NO_PROXY", "chatgpt.com"),
]);
assert_eq!(
(some.level, some.detail.as_str()),
(
Level::Warn,
"requests fail: Pitboard does not use SOCKS proxies yet, and ALL_PROXY names \
one, socks5h://***:***@p.example:1080; NO_PROXY exempts chatgpt.com, which \
Pitboard reaches directly"
)
);
let passed = network_read_from(&[
("ALL_PROXY", "not a proxy"),
("HTTPS_PROXY", "socks://p.example"),
]);
assert_eq!(
(passed.level, passed.detail.as_str(), passed.advice.as_str()),
(
Level::Warn,
"requests fail: Pitboard does not use SOCKS proxies yet, and HTTPS_PROXY \
names one, socks5://p.example:1080; ALL_PROXY holds no proxy's address \
Pitboard can read, so it is passed over",
"Pitboard goes through HTTP and HTTPS proxies only. For its requests to go \
out, set each of ALL_PROXY and HTTPS_PROXY to an HTTP proxy's address, such \
as http://proxy.example.com:8080, or unset them."
)
);
let every = network_read_from(&[("HTTPS_PROXY", "socks4://10.0.0.1"), ("no_proxy", "*")]);
assert_eq!(
(every.level, every.detail.as_str(), every.advice.as_str()),
(
Level::Ok,
"direct: Pitboard does not use SOCKS proxies yet, and HTTPS_PROXY names one, \
socks4://10.0.0.1:1080; no_proxy exempts every host Pitboard calls, which it \
reaches directly",
""
)
);
}
#[test]
fn a_variable_pitboard_cannot_read_is_a_warning() {
let passed = network(None, &["HTTPS_PROXY"]);
assert_eq!(passed.level, Level::Warn);
assert_eq!(
passed.detail,
"direct: HTTPS_PROXY holds no proxy's address Pitboard can read, so it is passed \
over"
);
assert_eq!(
passed.advice,
"Set it to a proxy's address, such as http://proxy.example.com:8080, or unset it."
);
let both = ProxyFact {
variable: "HTTP_PROXY",
address: "http://proxy.example.com:8080".into(),
..through(None, &[])
};
let passed = network(Some(both), &["ALL_PROXY", "HTTPS_PROXY"]);
assert_eq!(passed.level, Level::Warn);
assert_eq!(
passed.detail,
"through http://proxy.example.com:8080, from HTTP_PROXY; ALL_PROXY and HTTPS_PROXY \
hold no proxy's address Pitboard can read, so they are passed over"
);
assert_eq!(
passed.advice,
"Set each to a proxy's address, such as http://proxy.example.com:8080, or unset \
them."
);
}
#[test]
fn a_proxys_host_is_redacted_from_a_report_unless_it_is_on_loopback() {
let ctx = Context::new(PathBuf::from("/home/x"));
let mut f = facts();
f.network.proxy = Some(through(None, &[]));
let detail = check(&evaluate(&f), "network").detail.clone();
let hidden = redaction_for(&ctx, &f).over(&detail);
assert!(detail.contains("proxy.example.com:3128"), "{detail}");
assert!(!hidden.contains("proxy.example.com"), "{hidden}");
assert!(!hidden.contains("3128"), "{hidden}");
assert!(
hidden.starts_with("through http://***:***@<proxy ")
&& hidden.contains("from HTTPS_PROXY"),
"{hidden}"
);
f.network.proxy = Some(ProxyFact {
address: "http://127.0.0.1:7890".into(),
server: "127.0.0.1:7890".into(),
on_loopback: true,
..through(None, &[])
});
let detail = check(&evaluate(&f), "network").detail.clone();
assert_eq!(redaction_for(&ctx, &f).over(&detail), detail);
}
#[test]
fn the_network_is_read_from_the_context() {
let env: crate::context::Environment = [
("HOME", "/nowhere"),
("HTTPS_PROXY", "http://alice:s3cret@proxy.example.com:3128"),
("NO_PROXY", ".anthropic.com,localhost"),
]
.into_iter()
.collect();
let fact = network_fact(&Context::for_command_line(&env).proxy);
let proxy = fact.proxy.expect("a proxy");
assert_eq!(proxy.variable, "HTTPS_PROXY");
assert_eq!(proxy.address, "http://***:***@proxy.example.com:3128");
assert_eq!(proxy.server, "proxy.example.com:3128");
assert!(!proxy.on_loopback && !proxy.socks);
assert_eq!(proxy.exempting, Some("NO_PROXY"));
assert_eq!(proxy.exempt, ["api.anthropic.com"]);
assert!(fact.passed_over.is_empty());
let unit = network_fact(&Context::for_unit_test().proxy);
assert!(unit.proxy.is_none() && unit.passed_over.is_empty());
for (address, shown, on_loopback) in [
("socks5://localhost:7890", "socks5://localhost:7890", true),
("socks4://127.0.0.1", "socks4://127.0.0.1:1080", true),
("socks5h://[::1]:1080", "socks5h://[::1]:1080", true),
("socks4a://10.0.0.1", "socks4a://10.0.0.1:1080", false),
("socks://p.example", "socks5://p.example:1080", false),
(
"https://Proxy.Example.com",
"https://Proxy.Example.com:443",
false,
),
] {
let env: crate::context::Environment = [("HOME", "/nowhere"), ("ALL_PROXY", address)]
.into_iter()
.collect();
let proxy = network_fact(&Context::for_command_line(&env).proxy)
.proxy
.expect("a proxy");
assert_eq!(
(proxy.address.as_str(), proxy.on_loopback, proxy.socks),
(shown, on_loopback, shown.starts_with("socks")),
"{address}"
);
}
}
#[test]
fn a_machine_without_a_stable_identifier_is_flagged() {
let mut f = facts();
f.machine_id_known = false;
let checks = evaluate(&f);
assert_eq!(check(&checks, "machine_id").level, Level::Warn);
assert!(evaluate(&facts()).iter().all(|c| c.code != "machine_id"));
}
#[test]
fn each_check_is_told_apart_by_code_and_name() {
let mut f = facts();
f.parks = vec![parked("work", None), parked("personal", None)];
let checks = evaluate(&f);
let mut keys: Vec<(&str, &str)> =
checks.iter().map(|c| (c.code, c.name.as_str())).collect();
keys.sort_unstable();
let before = keys.len();
keys.dedup();
assert_eq!(keys.len(), before);
}
#[test]
fn every_parked_login_is_judged_and_a_way_back_is_offered() {
let mut f = facts();
let mut unusable = parked("broken", Some(NOW + 30 * 86_400));
unusable.unreadable = Some(Unreadable::Broken("missing from the vault".into()));
f.parks = vec![
parked("fine", Some(NOW + 20 * 86_400)),
parked("soon", Some(NOW + 86_400)),
parked("gone", Some(NOW - 1)),
unusable,
ParkFact {
provider: ProviderId::Claude,
last_used_at: None,
name: Key::new(ProviderId::Claude, "empty").typed(),
active: false,
park: None,
unreadable: None,
},
ParkFact {
provider: ProviderId::Claude,
last_used_at: None,
name: Key::new(ProviderId::Claude, "live").typed(),
active: true,
park: None,
unreadable: None,
},
];
let checks = evaluate(&f);
for (name, level) in [
("account fine", Level::Ok),
("account soon", Level::Warn),
("account gone", Level::Warn),
("account broken", Level::Fail),
("account empty", Level::Warn),
("account live", Level::Ok),
] {
let c = named(&checks, name);
assert_eq!(c.level, level, "{name}: {}", c.detail);
if level != Level::Ok {
let label = name.trim_start_matches("account ");
assert!(
c.advice
.contains(&format!("pitboard enroll {label} --sign-in"))
);
}
}
assert_eq!(
named(&checks, "account fine").detail,
"parked, good for 20d 0h"
);
assert_eq!(
named(&checks, "account soon").detail,
"its parked login expires in 1d 0h"
);
assert_eq!(
named(&checks, "account gone").detail,
format!("its parked login expired {}", time::moment(NOW - 1, NOW))
);
}
#[test]
fn a_parked_login_is_due_from_three_days_before_it_expires() {
assert!(!renewal_due(NOW + RENEW_WITHIN, NOW));
assert!(renewal_due(NOW + RENEW_WITHIN - 1, NOW));
assert!(renewal_due(NOW + 1, NOW));
assert!(!renewal_due(NOW, NOW));
assert!(!renewal_due(NOW - 60, NOW));
}
#[test]
fn an_interrupted_switch_and_leftover_parks_are_reported() {
let mut f = facts();
f.interrupted = true;
f.state = Ok(State {
discarded: vec![crate::state::Dropped {
service: "pitboard-park-x-1".into(),
kind: crate::state::ParkKind::Vault,
forgotten: false,
}],
..State::default()
});
let checks = evaluate(&f);
assert_eq!(check(&checks, "interrupted_switch").level, Level::Warn);
assert_eq!(check(&checks, "discarded").level, Level::Warn);
assert!(healthy(&checks), "neither stops Pitboard working");
}
#[test]
fn a_switch_the_next_change_cannot_finish_says_why_and_the_way_out() {
let mut f = facts();
f.interrupted = true;
f.stuck = Some("an earlier switch from `work` to `personal` was interrupted".into());
let checks = evaluate(&f);
let said = check(&checks, "interrupted_switch");
assert_eq!(said.level, Level::Warn);
assert_eq!(
said.detail,
"an earlier switch from `work` to `personal` was interrupted"
);
assert!(
said.advice.contains("`pitboard abandon`"),
"{}",
said.advice
);
assert_eq!(
checks
.iter()
.filter(|c| c.code == "interrupted_switch")
.count(),
1,
"said once"
);
}
#[test]
fn advice_names_a_codex_account_the_way_it_is_typed() {
let mut f = facts();
let mut codex = codex_parked("work", Some(NOW - 1));
codex.last_used_at = Some(NOW - 400 * 86_400);
let mut claude = parked("work", Some(NOW - 1));
claude.name = "claude/work".into();
claude.last_used_at = Some(NOW - 400 * 86_400);
f.parks = vec![claude, codex];
let checks = evaluate(&f);
let codex_park = named(&checks, "account codex/work");
assert!(
codex_park
.advice
.contains("`pitboard enroll codex/work --sign-in`"),
"{}",
codex_park.advice
);
let claude_park = named(&checks, "account claude/work");
assert!(
claude_park
.advice
.contains("`pitboard enroll claude/work --sign-in`"),
"a name a command here takes, which a bare `work` is not: {}",
claude_park.advice
);
let dormant: Vec<&Check> = checks
.iter()
.filter(|c| c.code.ends_with("dormant_account"))
.collect();
assert_eq!(dormant.len(), 2);
assert!(
dormant.iter().any(|c| c.name == "account codex/work"
&& c.advice.contains("`pitboard forget codex/work`"))
);
assert!(dormant.iter().any(|c| c.name == "account claude/work"
&& c.advice.contains("`pitboard forget claude/work`")));
}
#[test]
#[cfg_attr(windows, ignore = "W21: switching Codex on Windows")]
fn an_account_is_active_by_its_own_tools_record() {
use crate::host::memory::MemoryHost;
let scratch = tempfile::tempdir().expect("a scratch home");
let root = scratch.path().to_path_buf();
let ctx = Context::new(root.clone())
.with_pitboard_home(root.join(".pitboard"))
.with_codex_home(root.join("codex").to_string_lossy().into())
.with_memory_stores(MemoryHost::new());
std::fs::write(
root.join(".claude.json"),
json!({"oauthAccount": {
"accountUuid": "beta-uuid",
"emailAddress": "b@example.com",
"organizationUuid": "org",
}})
.to_string(),
)
.expect("a Claude Code config");
let account =
|label: &str, uuid: &str, detail: crate::state::Detail| crate::state::Account {
label: label.into(),
id: uuid.into(),
account_uuid: uuid.into(),
email: format!("{label}@example.com"),
parked: None,
last_used_at: None,
replaced_at: None,
plan: None,
detail,
};
let claude = || crate::state::Detail::Claude {
organization_uuid: "org".into(),
oauth_account: json!({}),
};
let codex = || crate::state::Detail::Codex { workspace_id: None };
let mut state = State {
accounts: vec![
account("alpha", "alpha-uuid", claude()),
account("beta", "beta-uuid", claude()),
account("work", "work-acc", codex()),
account("home", "home-acc", codex()),
],
..State::default()
};
let alpha = crate::in_use::InUse::of(&state.accounts[0], "alpha-refresh", NOW);
state.identified(ProviderId::Claude, alpha, NOW);
let home = crate::in_use::InUse::of(&state.accounts[3], "home-refresh", NOW);
state.identified(ProviderId::Codex, home, NOW);
let active = |facts: &[ParkFact]| -> Vec<String> {
facts
.iter()
.filter(|p| p.active)
.map(ParkFact::typed)
.collect()
};
assert_eq!(active(&park_facts(&ctx, &state)), ["alpha"]);
let live = crate::provider::document(ProviderId::Codex)
.live(&ctx)
.expect("Codex keeps its login in a file here");
store::write_raw(
&live.chain,
Permit::for_a_test(),
&live.service,
"{\"auth_mo",
)
.expect("half a login");
assert_eq!(active(&park_facts(&ctx, &state)), ["alpha", "codex/home"]);
let login = json!({
"auth_mode": "chatgpt",
"tokens": {
"id_token": crate::provider::jwt::unsigned(&json!({
"email": "work@example.com",
"https://api.openai.com/auth": {"chatgpt_account_id": "work-acc"},
})),
"access_token": "a",
"refresh_token": "r",
"account_id": "work-acc",
},
});
store::write_raw(
&live.chain,
Permit::for_a_test(),
&live.service,
&login.to_string(),
)
.expect("a login");
assert_eq!(active(&park_facts(&ctx, &state)), ["alpha", "codex/work"]);
}
fn in_use(facts: &Facts) -> Vec<String> {
facts
.parks
.iter()
.filter(|p| p.active)
.map(ParkFact::typed)
.collect()
}
#[test]
#[cfg_attr(
windows,
ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
)]
fn the_account_in_use_is_the_one_anthropic_named() {
use crate::switch::harness::{NOW, config_names, machine};
let m = machine("doctor-in-use");
let at = time::moment(NOW, NOW);
let facts = gather(&m.ctx);
let checks = evaluate(&facts);
let said = check(&checks, "in_use");
assert_eq!(said.level, Level::Ok);
assert_eq!(said.detail, format!("`here`, as Anthropic said {at}"));
assert_eq!(in_use(&facts), ["here"]);
config_names(&m, "there");
crate::service::Pitboard::new(m.ctx.clone())
.status(false)
.expect("a read");
let facts = gather(&m.ctx);
let checks = evaluate(&facts);
let said = check(&checks, "in_use");
assert_eq!(said.level, Level::Warn);
assert_eq!(
said.detail,
format!("`here`, as Anthropic said {at}, and Claude Code's config names `there`")
);
assert_eq!(
said.advice,
"`/status` in Claude Code shows `there`. `pitboard use here` writes `here` into the \
config."
);
assert_eq!(in_use(&facts), ["here"]);
}
#[test]
#[cfg_attr(
windows,
ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
)]
fn a_renewed_login_is_not_a_warning() {
use crate::switch::harness::{NOW, document, machine};
let m = machine("doctor-renewed");
m.sign_in(&document("here-renewed"));
let checks = evaluate(&gather(&m.ctx));
let said = check(&checks, "in_use");
assert_eq!(said.level, Level::Ok);
assert_eq!(
said.detail,
format!(
"`here`, as Anthropic said {} of a login renewed or replaced since; the next \
`pitboard status` asks whose it is",
time::moment(NOW, NOW)
)
);
assert_eq!(m.api.calls(), 0);
}
#[test]
#[cfg_attr(
windows,
ignore = "W23: Claude Code's Credential Manager store, which a machine in memory plays"
)]
fn a_config_that_moved_since_is_a_warning() {
use crate::switch::harness::{NOW, config_names, machine};
let m = machine("doctor-config-moved");
config_names(&m, "there");
let facts = gather(&m.ctx);
let checks = evaluate(&facts);
let said = check(&checks, "in_use");
assert_eq!(said.level, Level::Warn);
assert_eq!(
said.detail,
format!(
"Claude Code's config has named `there` since Anthropic named `here` {}, so \
another login may be stored",
time::moment(NOW, NOW)
)
);
assert_eq!(
said.advice,
"Run `pitboard status`, which asks Anthropic whose login Claude Code has stored."
);
assert_eq!(in_use(&facts), ["here"]);
assert_eq!(m.api.calls(), 0);
}
#[test]
fn codex_has_a_section_only_where_there_is_a_codex() {
let without = evaluate(&facts());
assert!(without.iter().all(|c| !c.code.starts_with("codex_")));
let mut f = facts();
f.codex = with_codex();
let with = evaluate(&f);
let claude = |checks: &[Check]| -> Vec<(String, String, String)> {
checks
.iter()
.filter(|c| !c.code.starts_with("codex_"))
.map(|c| (c.code.to_string(), c.detail.clone(), c.advice.clone()))
.collect()
};
assert_eq!(claude(&without), claude(&with), "Claude Code's checks move");
for code in [
"codex_backend",
"codex_auth_file",
"codex_login",
"codex_version",
"codex_running",
] {
assert_eq!(check(&with, code).level, Level::Ok, "{code}");
}
assert!(healthy(&with));
let login = check(&with, "codex_login");
assert!(login.detail.contains("w@example.com"), "{}", login.detail);
assert!(
login.detail.contains("0123456789abcdef"),
"{}",
login.detail
);
f.codex = CodexFacts {
enrolled: 1,
..no_codex()
};
let checks = evaluate(&f);
let file = check(&checks, "codex_auth_file");
assert_eq!(file.level, Level::Warn);
assert!(file.advice.contains("`codex login`"), "{}", file.advice);
assert!(
!file.advice.contains("pitboard use codex/"),
"{}",
file.advice
);
}
#[test]
fn each_codex_store_is_judged_for_what_pitboard_can_do_with_it() {
let judged = |backend: &'static str, enrolled: usize| {
let codex = CodexFacts {
backend,
enrolled,
backend_setting: format!(
"`cli_auth_credentials_store = \"{backend}\"` in /home/x/.codex/config.toml"
),
backend_remedy: "To use the file store, set `cli_auth_credentials_store = \
\"file\"` in /home/x/.codex/config.toml, then sign in again \
with `codex login`"
.into(),
..with_codex()
};
codex_checks(&codex)
};
for store in ["keyring", "auto", "secrets", "ephemeral"] {
let checks = judged(store, 1);
let found = check(&checks, "codex_backend");
assert_eq!(found.level, Level::Fail, "{store}");
assert!(found.detail.contains(store), "{}", found.detail);
assert!(
found.detail.contains("/home/x/.codex/config.toml"),
"says which file set it: {}",
found.detail
);
assert!(
found.advice.contains(
"set `cli_auth_credentials_store = \"file\"` in /home/x/.codex/config.toml"
),
"says the line and where it goes: {}",
found.advice
);
assert!(!healthy(&checks), "{store}");
assert!(
checks.iter().all(|c| c.code != "codex_login"),
"a store other than the file is not read, so there is nothing to say about \
its login"
);
let checks = judged(store, 0);
let found = check(&checks, "codex_backend");
assert_eq!(
found.level,
Level::Ok,
"nothing Pitboard does is broken for somebody with no Codex accounts: {store}"
);
assert!(found.detail.contains(store), "{}", found.detail);
assert!(found.detail.contains("file store"), "{}", found.detail);
}
for store in ["keyring", "auto", "secrets"] {
let found = judged(store, 1);
let advice = &check(&found, "codex_backend").advice;
assert!(advice.contains("will not touch"), "{advice}");
}
}
#[test]
fn the_file_store_says_what_chose_it_and_what_is_not_read() {
let checks = codex_checks(&with_codex());
let found = check(&checks, "codex_backend");
assert_eq!(found.level, Level::Ok);
assert_eq!(
found.detail,
"file (Codex's default) · /home/x/.codex/auth.json"
);
assert!(
found
.advice
.contains("does not read a project's own `.codex/config.toml`"),
"{}",
found.advice
);
}
#[test]
fn a_codex_store_nobody_can_tell_is_broken() {
let unknown = |enrolled| CodexFacts {
backend: "unknown",
backend_setting: "/etc/codex/config.toml is not TOML Codex can read, at line 3".into(),
backend_remedy: "Codex 0.160.0 does not start until that is put right".into(),
enrolled,
..with_codex()
};
for (enrolled, level) in [(1, Level::Fail), (0, Level::Warn)] {
let checks = codex_checks(&unknown(enrolled));
let found = check(&checks, "codex_backend");
assert_eq!(found.level, level);
assert_eq!(
found.detail,
"cannot tell: /etc/codex/config.toml is not TOML Codex can read, at line 3"
);
assert_eq!(
found.advice,
"Codex 0.160.0 does not start until that is put right. Pitboard parks and \
switches no Codex login while it cannot tell where Codex keeps it."
);
assert!(checks.iter().all(|c| c.code != "codex_login"));
}
}
#[test]
#[cfg_attr(windows, ignore = "W21: switching Codex on Windows")]
fn a_store_an_administrator_pinned_is_gathered_and_said() {
let host = crate::host::memory::MemoryHost::new();
let ctx = Context::for_unit_test().with_memory_stores(host.clone());
host.administers(
"/etc/codex/requirements.toml",
"cli_auth_credentials_store = \"keyring\"\n",
);
let codex = CodexFacts {
enrolled: 1,
..codex_facts(&ctx, None)
};
assert_eq!(codex.backend, "keyring");
assert_eq!(
codex.backend_setting,
"pinned to `keyring` by /etc/codex/requirements.toml"
);
let checks = codex_checks(&codex);
let found = check(&checks, "codex_backend");
assert_eq!(found.level, Level::Fail);
assert_eq!(
found.detail,
"keyring (pinned to `keyring` by /etc/codex/requirements.toml)"
);
assert!(
found.advice.ends_with(
"No line in your own config.toml can change it: \
/etc/codex/requirements.toml pins it, and only an administrator can change \
that."
),
"{}",
found.advice
);
host.administers("/etc/codex/requirements.toml", "x = \n");
let unknown = codex_facts(&ctx, None);
assert_eq!(unknown.backend, "unknown");
assert_eq!(
unknown.backend_setting,
"/etc/codex/requirements.toml is not TOML Codex can read, at line 1"
);
assert_eq!(
unknown.backend_remedy,
"Codex 0.160.0 does not start until that is put right"
);
assert!(matches!(unknown.login, Ok(None)), "nothing is read");
}
#[test]
fn a_codex_login_with_an_api_key_is_a_choice_rather_than_a_fault() {
let api_key = || CodexFacts {
login: Err(CodexLoginTrouble::NotAnAccount(
"Codex is signed in with an API key rather than a ChatGPT account, so there \
is no account login to park or switch"
.into(),
)),
..with_codex()
};
let checks = codex_checks(&api_key());
let login = check(&checks, "codex_login");
assert_eq!(
login.level,
Level::Ok,
"stated, for somebody with no Codex accounts"
);
assert!(login.detail.contains("API key"), "{}", login.detail);
assert!(checks.iter().all(|c| c.level == Level::Ok));
let checks = codex_checks(&CodexFacts {
enrolled: 2,
..api_key()
});
let login = check(&checks, "codex_login");
assert_eq!(login.level, Level::Warn);
assert!(healthy(&checks), "nothing is broken");
assert!(!login.detail.contains("cannot"), "{}", login.detail);
assert!(login.advice.contains("`codex login`"), "{}", login.advice);
assert!(
!login.advice.contains("again"),
"nothing to sign in again for: {}",
login.advice
);
}
#[test]
fn a_codex_account_is_judged_in_codex_section_under_a_codex_code() {
let claude_only = {
let mut f = facts();
f.parks = vec![parked("work", Some(NOW + 20 * 86_400))];
f.codex = with_codex();
evaluate(&f)
};
let mut f = facts();
let mut codex = codex_parked("work", Some(NOW - 1));
codex.last_used_at = Some(NOW - 400 * 86_400);
f.parks = vec![parked("work", Some(NOW + 20 * 86_400)), codex];
f.codex = CodexFacts {
enrolled: 1,
..with_codex()
};
let checks = evaluate(&f);
let about_codex: Vec<&Check> = checks
.iter()
.filter(|c| c.name.contains("codex/"))
.collect();
let codes: Vec<&str> = about_codex.iter().map(|c| c.code).collect();
assert_eq!(codes, ["codex_parked_login", "codex_dormant_account"]);
let section = checks
.iter()
.position(|c| c.code == "codex_backend")
.expect("a Codex section");
let first = checks
.iter()
.position(|c| c.name.contains("codex/"))
.unwrap();
assert!(first > section, "after the Codex heading");
let claude = |checks: &[Check]| -> Vec<(&'static str, String)> {
checks
.iter()
.filter(|c| !c.code.starts_with("codex_"))
.map(|c| (c.code, c.name.clone()))
.collect()
};
assert_eq!(
claude(&checks),
claude(&claude_only),
"Claude Code's section is Claude Code's accounts alone"
);
}
#[test]
fn a_codex_whose_version_cannot_be_read_is_not_called_missing() {
let found = CodexFacts {
program: Some(PathBuf::from("/home/x/.volta/bin/codex")),
version: None,
..with_codex()
};
let version = check(&codex_checks(&found), "codex_version").detail.clone();
assert!(!version.contains("not found"), "{version}");
assert!(version.contains("/home/x/.volta/bin/codex"), "{version}");
let missing = CodexFacts {
program: None,
version: None,
..with_codex()
};
let version = check(&codex_checks(&missing), "codex_version")
.detail
.clone();
assert!(version.starts_with("not found here"), "{version}");
}
#[test]
#[cfg_attr(windows, ignore = "W15: files made private to the person on Windows")]
fn a_version_is_read_out_of_each_way_codex_is_installed() {
use crate::host::fs::testing;
let scratch = tempfile::tempdir().expect("a scratch home");
let root = scratch.path().to_path_buf();
let place = |at: &str| {
let path = root.join(at);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "").unwrap();
path
};
let standalone =
place("codex/packages/standalone/releases/0.154.0-aarch64-apple-darwin/bin/codex");
std::fs::write(
standalone.with_file_name("package.json"),
r#"{"name": "@openai/codex", "version": "9.9.9"}"#,
)
.unwrap();
let link = root.join("bin/codex");
std::fs::create_dir_all(link.parent().unwrap()).unwrap();
testing::link(&standalone, &link).expect("a link");
assert_eq!(codex_version(&link).as_deref(), Some("0.154.0"));
let cask = place("Caskroom/codex/0.153.2/codex-aarch64-apple-darwin");
assert_eq!(codex_version(&cask).as_deref(), Some("0.153.2"));
let npm = place("lib/node_modules/@openai/codex/bin/codex.js");
std::fs::write(
root.join("lib/node_modules/@openai/codex/package.json"),
r#"{"name": "@openai/codex", "version": "0.150.1"}"#,
)
.unwrap();
assert_eq!(codex_version(&npm).as_deref(), Some("0.150.1"));
let shim = place("volta/bin/volta-shim");
std::fs::write(
root.join("package.json"),
r#"{"name": "@openai/codex", "version": "1.2.3"}"#,
)
.unwrap();
assert_eq!(codex_version(&shim), None);
}
#[test]
#[cfg_attr(
windows,
ignore = "W15: who else can read a login on Windows, and the command that makes one private"
)]
fn a_codex_login_anybody_can_read_or_nobody_can_parse_is_said() {
let mut codex = with_codex();
codex.auth_access = Some(mode(0o644));
let checks = codex_checks(&codex);
let file = check(&checks, "codex_auth_file");
assert_eq!(file.level, Level::Warn);
assert!(file.detail.contains("mode 644"), "{}", file.detail);
assert!(file.advice.contains("chmod 600"), "{}", file.advice);
codex.auth_access = Some(mode(0o600));
codex.login = Err(CodexLoginTrouble::Unusable(
"its id token is not readable".into(),
));
assert_eq!(
check(&codex_checks(&codex), "codex_login").level,
Level::Warn
);
codex.enrolled = 2;
let checks = codex_checks(&codex);
let login = check(&checks, "codex_login");
assert_eq!(login.level, Level::Fail);
assert!(login.detail.contains("not readable"), "{}", login.detail);
}
#[test]
fn running_codex_processes_are_reported_as_a_fact() {
use crate::holder::classify;
use crate::host::Process;
let at = |pid: u32, path: &str| Process {
pid,
path: PathBuf::from(path),
};
let holders = crate::provider::codex::holders::HOLDERS;
let mut codex = with_codex();
codex.running = Some(classify(&[at(4321, "codex"), at(99, "codex")], holders));
let checks = codex_checks(&codex);
let running = check(&checks, "codex_running");
assert_eq!(running.level, Level::Ok);
assert!(
running
.detail
.starts_with("2 `codex` sessions (pid 4321, 99)"),
"{}",
running.detail
);
assert!(
running.detail.contains("started again"),
"{}",
running.detail
);
assert_eq!(
running.advice,
"Quit them and start again to take a switch."
);
let many: Vec<Process> = (1..=23).map(|pid| at(pid, "codex")).collect();
codex.running = Some(classify(&many, holders));
let detail = check(&codex_checks(&codex), "codex_running").detail.clone();
assert!(detail.starts_with("23 `codex` sessions"), "{detail}");
assert!(detail.contains("1, 2, 3 and 20 more"), "{detail}");
codex.running = Some(classify(
&[at(
7,
"/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/\
Contents/MacOS/codex",
)],
holders,
));
let checks = codex_checks(&codex);
let app = check(&checks, "codex_running");
assert!(
app.detail.starts_with("the ChatGPT app (pid 7)"),
"{}",
app.detail
);
assert_eq!(
app.advice,
"Quit ChatGPT with Command-Q and open it again to take a switch."
);
codex.running = None;
assert!(
check(&codex_checks(&codex), "codex_running")
.detail
.contains("could not tell")
);
}
#[test]
fn doctor_and_a_switch_see_the_same_codex_running() {
use crate::host::memory::MemoryHost;
let host = MemoryHost::new();
host.runs_at(
"codex",
&[
"codex",
"/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/\
MacOS/codex",
],
);
let ctx = Context::new(std::env::temp_dir()).with_memory_stores(Arc::clone(&host));
let seen = running_codex(&ctx).expect("readable");
assert_eq!(
seen.iter().map(|h| h.holder.kind).collect::<Vec<_>>(),
["chatgpt_app", "session"]
);
assert_eq!(
switch::StillHolding::These(seen),
switch::still_holding(&ctx, ProviderId::Codex)
);
host.runs_at("codex", &[]);
assert_eq!(running_codex(&ctx), Some(Vec::new()));
assert_eq!(
switch::still_holding(&ctx, ProviderId::Codex),
switch::StillHolding::Nothing
);
host.without_a_process_list();
assert_eq!(running_codex(&ctx), None);
assert_eq!(
switch::still_holding(&ctx, ProviderId::Codex),
switch::StillHolding::Unknown
);
}
#[test]
fn every_codex_failure_and_warning_tells_the_user_something() {
for backend in ["file", "keyring", "auto", "ephemeral", "secrets", "unknown"] {
let codex = CodexFacts {
backend,
enrolled: 1,
auth_access: Some(mode(0o666)),
login: Err(CodexLoginTrouble::Unusable("unreadable".into())),
..with_codex()
};
for c in codex_checks(&codex) {
assert!(c.code.starts_with("codex_"), "{}", c.code);
if c.level != Level::Ok {
assert!(!c.advice.is_empty(), "{} has no advice", c.code);
}
}
}
}
#[test]
fn a_codex_login_is_redacted_from_a_report() {
let mut f = facts();
f.codex = with_codex();
let ctx = Context::new(PathBuf::from("/home/x"));
let sheet = redaction_for(&ctx, &f);
let login = check(&evaluate(&f), "codex_login").detail.clone();
let hidden = sheet.over(&login);
for secret in ["w@example.com", "work-account", "0123456789abcdef"] {
assert!(login.contains(secret), "{login}");
assert!(!hidden.contains(secret), "{hidden}");
}
}
#[test]
#[cfg_attr(windows, ignore = "W21: switching Codex on Windows")]
fn codex_facts_are_read_off_the_disk() {
use crate::host::fs::testing;
let scratch = tempfile::tempdir().expect("a scratch home");
let root = scratch.path().to_path_buf();
let home = root.join("codex");
let program = root.join("bin/codex");
let ctx = Context::new(root.clone())
.with_pitboard_home(root.join(".pitboard"))
.with_codex_home(home.to_string_lossy().into())
.with_codex_program(program.clone());
let absent = codex_facts(&ctx, None);
assert!(!absent.present);
assert_eq!(absent.backend, "file");
assert_eq!(absent.auth_access, None);
assert!(matches!(absent.login, Ok(None)));
assert_eq!(absent.program, None, "the one named, and it is not there");
assert_eq!(absent.version, None);
let installed = root.join("releases/0.154.0-aarch64-apple-darwin/bin/codex");
std::fs::create_dir_all(installed.parent().unwrap()).unwrap();
std::fs::write(&installed, "").unwrap();
testing::make_runnable(&installed).expect("runnable");
std::fs::create_dir_all(program.parent().unwrap()).unwrap();
testing::link(&installed, &program).expect("a link");
let found = codex_facts(&ctx, None);
assert_eq!(found.program.as_deref(), Some(program.as_path()));
assert_eq!(found.version.as_deref(), Some("0.154.0"));
std::fs::create_dir_all(&home).expect("a Codex home");
let auth = home.join("auth.json");
std::fs::write(
&auth,
json!({
"auth_mode": "chatgpt",
"tokens": {
"id_token": crate::provider::jwt::unsigned(&json!({
"email": "w@example.com",
"https://api.openai.com/auth": {"chatgpt_account_id": "work-acc"},
})),
"access_token": "a",
"refresh_token": "r",
"account_id": "work-acc",
},
})
.to_string(),
)
.expect("a login");
testing::open_to_others(&auth).expect("opened to others");
let found = codex_facts(&ctx, None);
assert!(found.present);
assert!(found.auth_access.expect("there").shared);
let login = found.login.expect("readable").expect("there");
assert_eq!(login.email, "w@example.com");
assert_eq!(login.fingerprint.len(), 16);
std::fs::write(
&auth,
json!({"auth_mode": "apikey", "OPENAI_API_KEY": "sk-not-a-real-key"}).to_string(),
)
.unwrap();
match codex_facts(&ctx, None).login {
Err(CodexLoginTrouble::NotAnAccount(why)) => {
assert!(why.contains("API key"), "{why}");
assert!(!why.contains("sk-"), "never the key itself: {why}");
}
Err(other) => panic!("an API key is not an account: {other:?}"),
Ok(_) => panic!("an API key is not an account"),
}
let mixed = json!({
"auth_mode": "chatgpt",
"tokens": {
"id_token": crate::provider::jwt::unsigned(&json!({
"email": "w@example.com",
"https://api.openai.com/auth": {"chatgpt_account_id": "work-acc"},
})),
"access_token": "a",
"refresh_token": "r",
"account_id": "home-acc",
},
});
std::fs::write(&auth, mixed.to_string()).unwrap();
assert!(
matches!(
codex_facts(&ctx, None).login,
Err(CodexLoginTrouble::Unusable(_))
),
"a login mixing two accounts is one Pitboard cannot use"
);
std::fs::write(
home.join("config.toml"),
"cli_auth_credentials_store = \"ephemeral\"\n",
)
.expect("a config");
let ephemeral = codex_facts(&ctx, None);
assert_eq!(ephemeral.backend, "ephemeral");
assert!(
matches!(ephemeral.login, Ok(None)),
"a store Pitboard does not handle is not read"
);
std::fs::write(
home.join("config.toml"),
"cli_auth_credentials_store = \"auto\"\n[features]\nsecret_auth_storage = true\n",
)
.expect("a config");
let secrets = codex_facts(&ctx, None);
assert_eq!(secrets.backend, "secrets");
let config = home.join("config.toml");
assert_eq!(
secrets.backend_setting,
format!(
"`cli_auth_credentials_store = \"auto\"` in {config}, with \
`secret_auth_storage` in {config}",
config = config.display()
)
);
assert_eq!(
secrets.backend_remedy,
format!(
"To use the file store, set `cli_auth_credentials_store = \"file\"` in \
{}, then sign in again with `codex login`",
config.display()
)
);
}
#[test]
fn a_version_is_read_out_of_the_path_codex_is_installed_at() {
assert!(looks_like_a_version("0.154.0"));
assert!(!looks_like_a_version("releases"));
assert!(!looks_like_a_version("0.154"));
assert!(!looks_like_a_version("v0.154.0"));
}
#[test]
fn a_machine_with_only_codex_is_not_judged_on_claude_code() {
let mut facts = facts();
facts.claude_present = false;
facts.config = Err(Error::ClaudeConfigMissing {
path: PathBuf::from("/nowhere/.claude.json"),
});
facts.codex.present = true;
let checks = evaluate(&facts);
for own in CLAUDE_CODES_OWN {
assert!(
checks.iter().all(|c| c.code != *own),
"{own} is about Claude Code, which is not here"
);
}
assert!(
checks.iter().any(|c| c.code == "state"),
"Pitboard's own still is"
);
assert!(checks.iter().any(|c| c.code.starts_with("codex_")));
}
#[test]
fn windows_is_told_how_it_runs_and_which_build_it_needs() {
let mut facts = facts();
facts.os = Os::Windows;
let refusing = |facts: &Facts| -> Vec<(&'static str, String, String)> {
evaluate(facts)
.into_iter()
.filter(|c| ["elevated", "system_too_old"].contains(&c.code))
.map(|c| (c.code, c.detail, c.advice))
.collect()
};
assert!(refusing(&facts).is_empty());
facts.elevation = crate::host::Elevation::Elevated {
why: crate::host::token::AS_ADMINISTRATOR,
};
assert_eq!(
refusing(&facts),
[(
"elevated",
"Pitboard runs as administrator".to_string(),
"Pitboard changes nothing this way: it reads, and renews and writes nothing. Run \
it from a terminal that is not elevated (not Run as administrator)."
.to_string()
)]
);
facts.elevation = crate::host::Elevation::Elevated {
why: crate::host::token::IN_EVERY_PROGRAM,
};
facts.floor = crate::host::Floor::Below { build: 22631 };
assert_eq!(
refusing(&facts),
[
(
"system_too_old",
"Pitboard runs on Windows build 22631, older than Windows 11 24H2 (build \
26100)"
.to_string(),
"Pitboard changes nothing here: it reads, and renews and writes nothing. It \
changes things on Windows 11 24H2 and later, and on Windows Server 2025: \
update Windows to use it here."
.to_string()
),
(
"elevated",
"Pitboard runs elevated, as every program this Windows account starts does"
.to_string(),
"Pitboard changes nothing this way: it reads, and renews and writes nothing. \
That is so with User Account Control off and in the built-in Administrator \
account: run it from a standard account, or turn User Account Control on."
.to_string()
),
]
);
}
#[test]
fn a_home_that_is_not_a_full_path_still_hears_why_nothing_changes() {
use crate::context::Environment;
let env: Environment = [("HOME", "")].into_iter().collect();
let host = crate::host::memory::MemoryHost::new();
host.runs_on(crate::host::Floor::Below { build: 22631 });
host.runs_with(crate::host::Elevation::Unknown);
let ctx = Context::for_command_line(&env).with_memory_stores(host);
let codes: Vec<&str> = run(&ctx).checks.iter().map(|c| c.code).collect();
assert_eq!(codes, ["system_too_old", "elevated", "homes"]);
}
#[test]
fn a_machine_with_neither_tool_still_hears_about_claude_code() {
let mut facts = facts();
facts.claude_present = false;
let checks = evaluate(&facts);
assert!(checks.iter().any(|c| c.code == "config_file"));
}
#[test]
#[cfg_attr(windows, ignore = "W14: Pitboard's own folder on Windows")]
fn doctor_checks_nothing_under_a_home_that_is_not_a_full_path() {
use crate::context::Environment;
for (pairs, said) in [
(&[("HOME", "")][..], "HOME is empty"),
(
&[("HOME", "/Users/x"), ("CODEX_HOME", "codex")],
"CODEX_HOME is `codex`, which is not a full path",
),
] {
let env: Environment = pairs.iter().copied().collect();
let ctx = Context::for_command_line(&env)
.with_memory_stores(crate::host::memory::MemoryHost::new());
let diagnosis = run(&ctx);
let checks: Vec<(&str, Level, &str)> = diagnosis
.checks
.iter()
.map(|c| (c.code, c.level, c.detail.as_str()))
.collect();
assert_eq!(checks, [("homes", Level::Fail, said)], "{pairs:?}");
assert!(
diagnosis.checks[0]
.advice
.starts_with("Set it to a full path")
);
assert_eq!(diagnosis.environment, json!({}));
}
}
#[test]
fn a_signed_out_credential_is_said_to_be_one() {
let mut facts = facts();
facts.credential = Ok(Some(serde_json::json!({"mcpOAuth": {"server": {}}})));
let check = judge_credential(&facts);
assert!(matches!(check.level, Level::Warn), "{}", check.detail);
assert!(check.detail.contains("signed out"), "{}", check.detail);
}
}