Let the machine's own CLI refresh an expired token, and retry once

A 401 from the usage endpoint means the stored access token has expired.
Refreshing it here is not an option: Anthropic's OAuth rotates the refresh
token, so a second refresher invalidates the CLI's copy and forces a
re-login on a machine that usually has a live session on it. So run the CLI
there instead and re-read what it wrote.

`doctor` rather than `auth status`: probed against 2.1.258 with an invalid
token, `auth status` answers loggedIn:true from the file alone and never
reaches the network. The same probe showed a failed refresh blanks both
tokens, which is why this stays on the 401 path.

Also gives ProviderConfig one program() so the CLI's default path is not
written down twice.
This commit is contained in:
iris committed 2026-09-05 12:07:34 -04:00
1 parent 7b63330aaa
commit eff5c8b0c0
4 files changed
+154 -32

No files matched your search

+127 -30
View File
@@ -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<Value, Refused> {
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<Value, UsageState> {
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<Box<dyn UsagePro
setup: setup.id.clone(),
setup_name: setup.name.clone(),
transport: Transport::for_setup(setup),
program: provider.program().to_string(),
})),
// Nothing at all until a test has asked for something: an
// echo session costs nothing, so the honest answer is no row
@@ -644,14 +712,42 @@ mod tests {
use crate::config::DriverKind;
#[test]
fn an_expired_login_is_not_reported_as_an_unreachable_endpoint() {
let stale = why(&ureq::Error::StatusCode(401));
assert!(stale.contains("expired"), "{stale}");
assert!(!stale.contains("unreachable"), "{stale}");
fn a_refusal_the_endpoint_answered_is_not_reported_as_an_unreachable_one() {
assert!(why(&ureq::Error::StatusCode(500)).contains("HTTP 500"));
assert!(why(&ureq::Error::HostNotFound).contains("unreachable"));
}
#[test]
fn an_expired_login_says_where_to_log_in_rather_than_naming_the_network() {
let provider = ClaudeUsage {
setup: "far".to_string(),
setup_name: "somewhere else".to_string(),
transport: Transport::for_setup(&unreachable_setup()),
program: "/opt/claude".to_string(),
};
// The machine cannot be reached, so the refresh attempt fails there
// rather than at the endpoint -- and the message still has to name the
// machine and the command, since that is all anybody gets to act on.
let UsageState::Failed { detail } = provider
.after_cli_refresh("stale")
.expect_err("an unreachable machine cannot refresh anything")
else {
panic!("an expired login is a fault to report, not a logged-out machine");
};
assert!(detail.contains("somewhere else"), "{detail}");
assert!(detail.contains("/opt/claude doctor"), "{detail}");
assert!(
!detail.contains("stale"),
"the token must never be quoted back"
);
let UsageState::Failed { detail } = provider.still_expired() else {
panic!("still expired is a fault");
};
assert!(detail.contains("/opt/claude /login"), "{detail}");
assert!(!detail.contains("unreachable"), "{detail}");
}
#[test]
fn parses_the_limits_array_defensively() {
// Trimmed from a live 2026-08-24 response.
@@ -706,6 +802,7 @@ mod tests {
setup: "far".to_string(),
setup_name: "somewhere else".to_string(),
transport: Transport::for_setup(&unreachable_setup()),
program: "claude".to_string(),
};
let snapshot = provider.fetch();
// The distinction the old single `error` string could not make: this