diff --git a/server/src/config.rs b/server/src/config.rs index 475a4cb..7e5fb8f 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -90,6 +90,16 @@ pub struct ProviderConfig { pub models: Vec, } +impl ProviderConfig { + /// The executable to run for this provider: its override, or its kind's + /// default. + pub fn program(&self) -> &str { + self.command + .as_deref() + .unwrap_or(self.kind.default_program()) + } +} + /// How to reach a setup that isn't this machine, with the system `ssh` client /// -- so `~/.ssh/config`, agents and jump hosts all keep working, and there is /// one place to configure connections. A remote session is the identical @@ -194,6 +204,21 @@ impl DriverKind { } } + /// The executable a provider of this kind runs when it names none. + /// + /// Here rather than at each spawn site because it is not only the spawn + /// that runs it: `usage` runs the Claude CLI too, to have it refresh its + /// own OAuth token, and a default that disagreed with the driver's would + /// ask the wrong binary on a machine with two installs. + pub fn default_program(self) -> &'static str { + match self { + Self::ClaudeCli => "claude", + Self::LlamaCpp => "llama-server", + // Echo is translated in-process; nothing is spawned for it. + Self::Echo => "echo", + } + } + /// Whether the conversation exists outside this app, so that deleting the /// session here does not end it. /// diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs index 5bffaaa..69c4d54 100644 --- a/server/src/session/claude.rs +++ b/server/src/session/claude.rs @@ -392,7 +392,7 @@ impl ClaudeDriver { let stdout = create_log(&session_dir.join(STDOUT_LOG))?; let stderr = create_log(&session_dir.join(STDERR_LOG))?; - let program = provider.command.as_deref().unwrap_or("claude"); + let program = provider.program(); let launch = Launch::new(program, args, meta.cwd.as_deref()); let child = transport.spawn( &launch, diff --git a/server/src/session/llama.rs b/server/src/session/llama.rs index 108467d..c9ce819 100644 --- a/server/src/session/llama.rs +++ b/server/src/session/llama.rs @@ -145,7 +145,7 @@ impl LlamaDriver { } } - let program = provider.command.as_deref().unwrap_or("llama-server"); + let program = provider.program(); let launch = Launch::new(program, args, meta.cwd.as_deref()).reaching(forward); // Its output goes to files, not pipes. Not only so the process can // outlive this server: nothing ever read those pipes, so a chatty diff --git a/server/src/usage.rs b/server/src/usage.rs index 78d20ea..1f14a9e 100644 --- a/server/src/usage.rs +++ b/server/src/usage.rs @@ -141,6 +141,10 @@ pub struct ClaudeUsage { pub setup_name: String, /// How to reach that machine. `Here` for the backend's own. pub transport: Transport, + /// The CLI to run there, for the one thing this asks of it: refreshing its + /// own expired token. The provider's, so a machine with the CLI somewhere + /// odd is asked at the same path its sessions run. + pub program: String, } /// Where Claude Code keeps its credentials, as a shell word rather than a path: @@ -198,46 +202,109 @@ impl UsageProvider for ClaudeUsage { Ok(token) => token, Err(state) => return self.snapshot(state, Vec::new()), }; - let text = match ureq::get(USAGE_URL) + let body = match self.call(&token) { + Ok(body) => body, + Err(Refused::Other(detail)) => { + return self.snapshot(UsageState::Failed { detail }, Vec::new()); + } + Err(Refused::Unauthorized) => match self.after_cli_refresh(&token) { + Ok(body) => body, + Err(state) => return self.snapshot(state, Vec::new()), + }, + }; + self.snapshot(UsageState::Ok, parse_windows(&body)) + } +} + +/// Why one call to the usage endpoint did not produce numbers. +/// +/// 401 is apart from the rest because it is the only one with a way out: the +/// endpoint answered, and it means the access token has expired rather than +/// that anything is broken. +enum Refused { + Unauthorized, + Other(String), +} + +impl ClaudeUsage { + /// One call to the endpoint with one token. + fn call(&self, token: &str) -> Result { + let text = ureq::get(USAGE_URL) .header("Authorization", &format!("Bearer {token}")) .header("anthropic-beta", "oauth-2025-04-20") .header("User-Agent", USER_AGENT) .call() .and_then(|mut response| response.body_mut().read_to_string()) - { - Ok(text) => text, // The error string can embed the URL but never the token. - Err(err) => return self.snapshot(UsageState::Failed { detail: why(&err) }, Vec::new()), - }; - let body: Value = match serde_json::from_str(&text) { - Ok(body) => body, - Err(err) => { - return self.snapshot( - UsageState::Failed { - detail: format!("usage endpoint sent non-JSON: {err}"), - }, - Vec::new(), - ); - } - }; - self.snapshot(UsageState::Ok, parse_windows(&body)) + .map_err(|err| match err { + ureq::Error::StatusCode(401) => Refused::Unauthorized, + other => Refused::Other(why(&other)), + })?; + serde_json::from_str(&text) + .map_err(|err| Refused::Other(format!("usage endpoint sent non-JSON: {err}"))) + } + + /// Have the machine's own CLI refresh its token, then ask once more. + /// + /// **The CLI does the refresh, never this.** Anthropic's OAuth rotates the + /// refresh token, so whoever refreshes second presents a dead one and the + /// machine is logged out until somebody runs `/login` on it -- and the + /// machine we would be refreshing on is usually one with a live session of + /// its own. Running the CLI keeps it the only writer of + /// `.credentials.json`. + /// + /// `doctor` rather than the `auth status` it reads like, measured against + /// this CLI (2.1.258) on 2026-09-05 with a deliberately invalid token: + /// `auth status` reports `loggedIn: true` off the file alone and never + /// touches the network, so it would have refreshed nothing while looking + /// like it had. `doctor` resolves the account, which is what makes it + /// refresh, and it spends no quota. The same probe showed what a *failed* + /// refresh does -- the CLI blanks both tokens -- so this must stay on the + /// 401 path, where the access token is already dead, and never be used to + /// refresh speculatively. + /// + /// Only a token that actually changed is retried, so a CLI that refreshed + /// nothing costs one call rather than two, and this cannot become a loop. + fn after_cli_refresh(&self, stale: &str) -> Result { + let launch = Launch::new(&self.program, vec!["doctor".to_string()], None); + if let Err(err) = self.transport.capture_blocking(&launch) { + return Err(UsageState::Failed { + detail: format!( + "the Claude login on {} has expired, and `{} doctor` couldn't be run there to refresh it: {err:#}", + self.setup_name, self.program + ), + }); + } + let fresh = self.access_token()?; + if fresh == stale { + return Err(self.still_expired()); + } + self.call(&fresh).map_err(|err| match err { + Refused::Unauthorized => self.still_expired(), + Refused::Other(detail) => UsageState::Failed { detail }, + }) + } + + /// A login the CLI could not renew: the one state here somebody has to act + /// on, so it says where and what to run. + fn still_expired(&self) -> UsageState { + UsageState::Failed { + detail: format!( + "the Claude login on {} has expired and could not be refreshed; run `{} /login` there", + self.setup_name, self.program + ), + } } } /// What a failed call to the usage endpoint should say. /// /// A status is not a network fault and must not be reported as one: the -/// endpoint answered, and 401 in particular says the stored token has expired -/// -- Claude Code refreshes it as it runs, so a machine whose CLI has been -/// idle long enough hands us a stale one. That is fixable, and the message is -/// the only place anybody finds out how. +/// endpoint answered. 401 never reaches here -- it has its own way out in +/// [`ClaudeUsage::after_cli_refresh`] -- so what is left is a refusal nobody +/// on this side can fix. fn why(err: &ureq::Error) -> String { match err { - ureq::Error::StatusCode(401) => { - format!( - "the Claude login on this machine has expired (401); run `claude` there, or re-run `/login`, to refresh {CREDENTIALS}" - ) - } ureq::Error::StatusCode(code) => format!("usage endpoint refused the request: HTTP {code}"), other => format!("usage endpoint unreachable: {other}"), } @@ -552,6 +619,7 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec