diff --git a/server/src/usage.rs b/server/src/usage.rs index b626b37..a731eb6 100644 --- a/server/src/usage.rs +++ b/server/src/usage.rs @@ -150,17 +150,10 @@ impl ClaudeUsage { vec!["-c".to_string(), format!("cat {CREDENTIALS}")], None, ); - let text = self.transport.capture_blocking(&launch).map_err(|err| { - // `cat` failing because the file is absent is a machine with - // nobody logged in, which is a choice; anything else is a - // machine that could not be asked. - let detail = format!("{err:#}"); - if detail.contains("No such file") || detail.contains("not found") { - UsageState::NotLoggedIn - } else { - UsageState::Unreachable { detail } - } - })?; + let text = self + .transport + .capture_blocking(&launch) + .map_err(|err| why_no_credentials(&format!("{err:#}")))?; serde_json::from_str::(&text) .ok() .and_then(|creds| { @@ -219,6 +212,31 @@ impl UsageProvider for ClaudeUsage { } } +/// Which kind of "no credentials" a failed read was. +/// +/// The distinction is the point of having both states. `cat` failing +/// because the file is not there is a machine nobody has logged in on -- +/// a decision somebody made, with nothing to fix. Anything else is a +/// machine this server could not ask, which is a fault and reads as one. +/// +/// Matched on the shell's own words rather than an exit status because +/// there is only one: `cat` exits 1 for a missing file and ssh exits 255 +/// for a connection it could not make, but the message is what survives +/// being wrapped in `sh -c` and passed back through ssh. +fn why_no_credentials(detail: &str) -> UsageState { + // "No such file or directory" is GNU and BSD coreutils; busybox says + // "can't open". Anything unrecognised is treated as unreachable, + // which is the answer that gets looked at rather than ignored. + let missing = ["No such file", "no such file", "can't open", "cannot open"]; + if missing.iter().any(|phrase| detail.contains(phrase)) { + UsageState::NotLoggedIn + } else { + UsageState::Unreachable { + detail: detail.to_string(), + } + } +} + /// Pulls the `limits` array apart, defensively: entries with no percent /// are skipped, unknown kinds keep their raw name as the label rather /// than being dropped -- a new window appearing should show up, not @@ -424,6 +442,35 @@ mod tests { assert!(snapshot.windows.is_empty()); } + #[test] + fn a_missing_credential_file_is_a_choice_and_anything_else_is_a_fault() { + // What a real shell says when nobody has logged in on that + // machine. Nothing to fix, so it must not read as an error. + assert_eq!( + why_no_credentials("cat: /home/x/.claude/.credentials.json: No such file or directory"), + UsageState::NotLoggedIn + ); + assert_eq!( + why_no_credentials("cat: can't open '/home/x/.claude/.credentials.json'"), + UsageState::NotLoggedIn + ); + + // What ssh says when the machine is not there. Worth chasing, and + // the detail is carried so somebody can. + let refused = why_no_credentials("ssh: connect to host vm port 22: Connection refused"); + assert!( + matches!(&refused, UsageState::Unreachable { detail } if detail.contains("refused")), + "{refused:?}" + ); + + // Anything unrecognised errs towards the state that gets looked + // at, rather than silently claiming nobody is logged in. + assert!(matches!( + why_no_credentials("something nobody has seen before"), + UsageState::Unreachable { .. } + )); + } + #[test] fn only_machines_that_can_run_claude_are_asked_about_it() { let mut echo_only = unreachable_setup();