use crate::context::Context;
use crate::error::Error;
use crate::host::{Access, Kind, Os};
use crate::provider::ProviderId;
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::state::{Park, State};
use crate::{home, park, store, switch, time, usage, words};
use serde_json::{Value, json};
use std::path::PathBuf;
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum Level {
Ok,
Warn,
Fail,
}
pub struct Check {
pub code: &'static str,
pub name: String,
pub level: Level,
pub detail: String,
pub advice: String,
}
#[non_exhaustive]
pub struct Facts {
pub os: Os,
pub security_tool: Option<String>,
pub config_path: PathBuf,
pub config: Result<Value, Error>,
pub identity: Option<claude::Identity>,
pub service: String,
pub account: String,
pub default_slot: bool,
pub storage_dir: String,
pub backend: Result<store::Backend, store::Error>,
pub credential_file: PathBuf,
pub credential: Result<Option<Value>, store::Error>,
pub credential_cost: Option<store::Cost>,
pub credential_parts: Vec<(String, usize)>,
pub home: PathBuf,
pub home_access: Option<Access>,
pub readable_by_others: Vec<(String, Access)>,
pub machine_id_known: bool,
pub hover_rest_env: bool,
pub daemon: Option<daemon::Daemon>,
pub pending_parks: Vec<String>,
pub claude_version: Option<String>,
pub auth_overrides: Vec<crate::settings::Override>,
pub auth_unread: Option<&'static str>,
pub asking_held: Vec<(String, i64)>,
pub state: Result<State, Error>,
pub parks: Vec<ParkFact>,
pub interrupted: bool,
pub stuck: Option<String>,
pub codex: CodexFacts,
pub claude_present: bool,
pub schedule: Option<ScheduleFact>,
pub network: NetworkFact,
pub in_the_app: bool,
pub elevation: crate::host::Elevation,
pub now: i64,
}
pub struct NetworkFact {
pub proxy: Option<ProxyFact>,
pub passed_over: Vec<&'static str>,
}
pub struct ProxyFact {
pub variable: &'static str,
pub address: String,
pub server: String,
pub on_loopback: bool,
pub socks: bool,
pub exempting: Option<&'static str>,
pub exempt: Vec<String>,
}
pub struct ScheduleFact {
pub path: PathBuf,
pub program: Option<PathBuf>,
pub program_found: bool,
pub network: NetworkFact,
pub network_differs: bool,
}
pub struct CodexFacts {
pub home: PathBuf,
pub present: bool,
pub enrolled: usize,
pub backend: &'static str,
pub backend_setting: String,
pub backend_remedy: String,
pub auth_file: PathBuf,
pub auth_access: Option<Access>,
pub login: Result<Option<CodexLogin>, CodexLoginTrouble>,
pub program: Option<PathBuf>,
pub version: Option<String>,
pub running: Option<Vec<crate::holder::Holding>>,
}
pub struct CodexLogin {
pub email: String,
pub account_id: String,
pub fingerprint: String,
}
#[derive(Debug)]
pub enum CodexLoginTrouble {
NotAnAccount(String),
Unusable(String),
}
pub struct ParkFact {
pub provider: ProviderId,
pub label: String,
pub name: String,
pub active: bool,
pub last_used_at: Option<i64>,
pub park: Option<Park>,
pub unreadable: Option<String>,
}
impl ParkFact {
fn typed(&self) -> String {
self.name.clone()
}
}
fn park_facts(ctx: &Context, state: &State) -> Vec<ParkFact> {
let recorded: std::collections::BTreeMap<ProviderId, Option<String>> = ProviderId::ALL
.iter()
.filter(|&&which| state.accounts.iter().any(|a| a.provider() == which))
.map(|&which| {
let found = crate::provider::of(which).recorded_identity(ctx);
(which, found.map(|id| id.account_id))
})
.collect();
state
.accounts
.iter()
.map(|a| ParkFact {
provider: a.provider(),
label: a.label.clone(),
name: state.typed(&a.key()),
last_used_at: a.last_used_at,
active: match recorded.get(&a.provider()).and_then(Option::as_deref) {
Some(uuid) => a.account_uuid == uuid,
None => state.active_for(a.provider()) == Some(a.label.as_str()),
},
park: a.parked.clone(),
unreadable: a.parked.as_ref().and_then(|p| {
park::load(ctx, &a.key(), p).err().map(|e| match e {
Error::ParkedCredentialMissing { .. } => "missing from the vault".into(),
Error::ParkedCredentialCorrupt { detail, .. } => detail,
other => other.to_string(),
})
}),
})
.collect()
}
pub 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 identity = config.as_ref().ok().and_then(claude::identity);
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),
identity: identity.clone(),
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() != "unknown",
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::budget::holds(ctx),
parks: state
.as_ref()
.map(|s| park_facts(ctx, s))
.unwrap_or_default(),
codex: codex_facts(ctx, state.as_ref().ok()),
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),
now: ctx.now(),
}
}
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: std::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<std::path::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::of(ProviderId::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_id,
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>> {
match crate::provider::of(ProviderId::Codex).adoption() {
crate::provider::Adoption::RestartRequired { program, holders } => {
crate::holder::find(ctx, program, holders)
}
crate::provider::Adoption::PollingWithin(_) => Some(Vec::new()),
}
}
fn elevated(elevation: crate::host::Elevation) -> Option<Check> {
let detail = match elevation {
crate::host::Elevation::Normal => return None,
crate::host::Elevation::Elevated { why } => format!("Pitboard runs {why}"),
crate::host::Elevation::Unknown => {
"Pitboard cannot tell whether it runs as root or with sudo".to_string()
}
};
Some(fail(
"elevated",
"runs as",
detail,
"Pitboard changes nothing this way: it reads, and renews and writes nothing. Run it \
as yourself.",
))
}
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 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;
if let Some(check) = elevated(facts.elevation) {
checks.push(check);
}
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.identity {
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.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.identity.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
.identity
.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.push(
match (
facts
.config
.as_ref()
.ok()
.and_then(usage::from_config_cache),
&facts.identity,
) {
(Some(s), Some(id)) if s.account_uuid.as_deref() == Some(id.account_uuid.as_str()) => {
ok(
"usage_cache",
"usage cache",
format!("{} windows, measured for this account", s.windows.len()),
)
}
(Some(_), Some(_)) => warn(
"usage_cache",
"usage cache",
"cached for a different account",
"Its numbers are ignored rather than shown, which is why status may look empty.",
),
(Some(s), None) => ok(
"usage_cache",
"usage cache",
format!("{} windows", s.windows.len()),
),
(None, _) => ok(
"usage_cache",
"usage cache",
"absent; Claude Code writes it after a call that reports usage",
),
},
);
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, codex_parks): (Vec<&ParkFact>, Vec<&ParkFact>) = facts
.parks
.iter()
.partition(|p| p.provider == ProviderId::Claude);
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()
{
checks.push(warn(
"discarded",
"old parked logins",
format!("{} waiting to be deleted", state.discarded.len()),
"Pitboard deletes them on its next change; if they stay, check the keychain is unlocked.",
));
}
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 !claude_here {
checks.retain(|check| !CLAUDE_CODES_OWN.contains(&check.code));
}
checks
}
const CLAUDE_CODES_OWN: &[&str] = &[
"config_file",
"identity",
"slot",
"credential_size",
"credential_store",
"credential",
"usage_cache",
"storage_v5",
"daemon",
"claude_version",
"auth",
];
fn account_code(provider: ProviderId, claude: &'static str, codex: &'static str) -> &'static str {
match provider {
ProviderId::Claude => claude,
ProviderId::Codex => codex,
}
}
const A_TOKEN_LIFETIME: i64 = 30 * 86_400;
fn judge_dormant(park: &ParkFact, now: i64) -> Option<Check> {
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"),
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_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(e) => fail(
"credential",
"credential",
e.to_string(),
"Do not write to the store while this is failing.",
),
}
}
pub 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");
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)
};
};
if let Some(why) = &fact.unreadable {
return fail(
code,
name,
format!("its parked login is unusable: {why}"),
renew,
);
}
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 = |uuid: &str| {
facts
.state
.as_ref()
.ok()
.and_then(|s| s.owner_of_park(uuid))
.map(crate::state::Account::provider)
};
let services = |tools: &mut Vec<ProviderId>| {
tools.sort();
tools.dedup();
if tools.is_empty() {
tools.push(ProviderId::Claude);
}
tools
.iter()
.map(|t| t.service())
.collect::<Vec<_>>()
.join(" and ")
};
let mut asked: Vec<ProviderId> = facts
.state
.as_ref()
.map(|s| {
s.accounts
.iter()
.map(crate::state::Account::provider)
.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) {
detail.push_str(&format!("; {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 = "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}")
}
},
)
}
pub struct Diagnosis {
pub checks: Vec<Check>,
pub environment: Value,
pub redaction: crate::redact::Sheet,
}
pub fn run(ctx: &Context) -> Diagnosis {
if let Err(crate::error::Error::HomeNotAbsolute { variable, path }) =
crate::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,
},
}),
}
}
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: elevated(ctx.host().elevation(ctx))
.into_iter()
.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.identity {
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");
}
}
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");
}
for park in &facts.parks {
if let Some(held) = &park.park {
sheet = sheet
.hide(held.refresh_fingerprint.clone(), "login")
.hide(held.service.clone(), "park");
}
}
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;
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 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": {}})),
identity: Some(claude::Identity {
email: "a@b.c".into(),
account_uuid: "acc".into(),
organization_uuid: "org".into(),
organization_name: None,
subscription: None,
rate_limit_tier: 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()),
parks: Vec::new(),
interrupted: false,
stuck: None,
codex: no_codex(),
claude_present: true,
schedule: None,
network: direct(),
in_the_app: false,
elevation: crate::host::Elevation::Normal,
now: NOW,
}
}
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,
label: label.into(),
name: crate::state::Key::new(ProviderId::Claude, label).typed(),
active: false,
park: Some(Park {
service: format!("pitboard-park-{label}-1"),
parked_at: NOW - 86_400,
refresh_fingerprint: "f".into(),
access_expires_at: None,
refresh_expires_at,
}),
unreadable: None,
}
}
fn codex_parked(label: &str, refresh_expires_at: Option<i64>) -> ParkFact {
ParkFact {
provider: ProviderId::Codex,
name: crate::state::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]
fn a_world_readable_park_is_found_on_the_disk() {
use crate::host::fs::testing;
let root = std::env::temp_dir().join(format!(
"pitboard-doctor-modes-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
struct Scratch(std::path::PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let _guard = Scratch(root.clone());
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]
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_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.identity = 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,
label: "work".into(),
name: crate::state::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,
refresh_fingerprint: "f".into(),
access_expires_at: Some(f.now + 3600),
refresh_expires_at: Some(f.now + 30 * 86_400),
}),
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()),
started_at: Some(1_790_079_766_317),
origin: Some("transient".into()),
launch_target: None,
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]
fn a_schedule_whose_pitboard_is_gone_is_found_on_the_disk() {
let root = std::env::temp_dir().join(format!(
"pitboard-doctor-schedule-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
struct Scratch(std::path::PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let _guard = Scratch(root.clone());
std::fs::create_dir_all(&root).expect("a scratch home");
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]
fn the_schedules_proxy_is_read_from_what_install_wrote() {
let root = std::env::temp_dir().join(format!(
"pitboard-doctor-schedule-proxy-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
struct Scratch(std::path::PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let _guard = Scratch(root.clone());
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(std::sync::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("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,
label: "empty".into(),
name: crate::state::Key::new(ProviderId::Claude, "empty").typed(),
active: false,
park: None,
unreadable: None,
},
ParkFact {
provider: ProviderId::Claude,
last_used_at: None,
label: "live".into(),
name: crate::state::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!["pitboard-park-x-1".into()],
..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]
fn an_account_is_active_by_its_own_tools_record() {
use crate::host::memory::MemoryHost;
let root = std::env::temp_dir().join(format!(
"pitboard-doctor-active-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("a scratch home");
struct Scratch(std::path::PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let _guard = Scratch(root.clone());
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": "alpha-uuid",
"emailAddress": "a@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(),
account_uuid: uuid.into(),
email: format!("{label}@example.com"),
parked: None,
last_used_at: None,
detail,
};
let claude = || crate::state::Detail::Claude {
organization_uuid: "org".into(),
oauth_account: json!({}),
};
let codex = || crate::state::Detail::Codex {
workspace_id: None,
plan: None,
};
let mut state = State {
accounts: vec![
account("alpha", "alpha-uuid", claude()),
account("work", "work-acc", codex()),
account("home", "home-acc", codex()),
],
..State::default()
};
state.set_active(ProviderId::Codex, Some("home".into()));
let active = |facts: &[ParkFact]| -> Vec<String> {
facts
.iter()
.filter(|p| p.active)
.map(ParkFact::typed)
.collect()
};
assert_eq!(active(&park_facts(&ctx, &state)), ["alpha", "codex/home"]);
let live = crate::provider::of(ProviderId::Codex)
.live(&ctx)
.expect("Codex keeps its login in a file here");
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"]);
}
#[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]
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]
fn a_version_is_read_out_of_each_way_codex_is_installed() {
use crate::host::fs::testing;
let root = std::env::temp_dir().join(format!(
"pitboard-doctor-codex-version-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
struct Scratch(std::path::PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let _guard = Scratch(root.clone());
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]
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(std::sync::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]
fn codex_facts_are_read_off_the_disk() {
use crate::host::fs::testing;
let root = std::env::temp_dir().join(format!(
"pitboard-doctor-codex-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
struct Scratch(std::path::PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let _guard = Scratch(root.clone());
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(crate::error::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 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]
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);
}
}