From d8570f4d5accc3c66653688ae69f367dd5848210 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 29 Aug 2026 06:12:17 -0400 Subject: [PATCH] Ask each machine about its own limits, not this one about all of them `ClaudeUsage` read `~/.claude/.credentials.json` on the machine running the backend and asked the API about that account, once, globally. But a session runs wherever its setup says, so the numbers on the usage screen belonged to the backend's account rather than to the account that spent the tokens. That is not a rounding error in the layout this project is aiming at. `ai-server` belongs on the host; the host has no `claude` CLI at all and the VM is a remote. So the screen would have reported "is Claude Code logged in on this machine?" while every session ran fine on a machine whose limits nobody could see. It looked correct only because backend and CLI happen to be the same box today. Usage is now per machine, asked through the same `Transport` the sessions use -- `ssh host sh -c 'cat $HOME/...'` for a remote, unchanged for the local one. `$HOME` is left for the far shell to expand, since a path built here is this machine's home directory and over ssh that is somebody else's. Machines with no Claude provider are not asked and get no row: they have no Claude limits, and a row about them would be a fact about nothing. The snapshot gains the states it could not say. `available` plus an `error` string made three different situations look identical, and the one that suffered was the harmless one: a machine nobody has logged in on is a decision somebody made, with nothing to fix, and it read as broken. `notLoggedIn`, `unreachable` and `failed` are now distinct, and which one a failed read is gets decided in `why_no_credentials` rather than at the call site. Supporting changes: `ssh::command` builds a `std::process::Command` that tokio converts from, so a blocking caller can use the one place that knows what a correct ssh invocation is; `Transport::capture_blocking` is that caller's door. The cache is keyed by machine and service rather than by position, since the set is no longer fixed at startup -- a positional cache would hand one machine's numbers to another the moment a setup was added. A cached snapshot still picks up a rename immediately, because the name has nothing to do with the poll interval. Verified against a real ssh setup (loopback, per AGENTS.md) with five machines, all four states seen: local `ok`, loopback-over-ssh `ok`, unreachable host `unreachable` carrying ssh's own message, a claude-less machine correctly absent, and -- with the backend's HOME emptied -- local `notLoggedIn` while the ssh machine still reported real windows, which is the production shape. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8 --- server/src/usage.rs | 69 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 11 deletions(-) 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();