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
+152 -30

No files matched your search

+25
View File
@@ -90,6 +90,16 @@ pub struct ProviderConfig {
pub models: Vec<String>, pub models: Vec<String>,
} }
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 /// 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 /// -- so `~/.ssh/config`, agents and jump hosts all keep working, and there is
/// one place to configure connections. A remote session is the identical /// 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 /// Whether the conversation exists outside this app, so that deleting the
/// session here does not end it. /// session here does not end it.
/// ///
+1 -1
View File
@@ -392,7 +392,7 @@ impl ClaudeDriver {
let stdout = create_log(&session_dir.join(STDOUT_LOG))?; let stdout = create_log(&session_dir.join(STDOUT_LOG))?;
let stderr = create_log(&session_dir.join(STDERR_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 launch = Launch::new(program, args, meta.cwd.as_deref());
let child = transport.spawn( let child = transport.spawn(
&launch, &launch,
+1 -1
View File
@@ -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); 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 // Its output goes to files, not pipes. Not only so the process can
// outlive this server: nothing ever read those pipes, so a chatty // outlive this server: nothing ever read those pipes, so a chatty
+125 -28
View File
@@ -141,6 +141,10 @@ pub struct ClaudeUsage {
pub setup_name: String, pub setup_name: String,
/// How to reach that machine. `Here` for the backend's own. /// How to reach that machine. `Here` for the backend's own.
pub transport: Transport, 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: /// 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, Ok(token) => token,
Err(state) => return self.snapshot(state, Vec::new()), 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("Authorization", &format!("Bearer {token}"))
.header("anthropic-beta", "oauth-2025-04-20") .header("anthropic-beta", "oauth-2025-04-20")
.header("User-Agent", USER_AGENT) .header("User-Agent", USER_AGENT)
.call() .call()
.and_then(|mut response| response.body_mut().read_to_string()) .and_then(|mut response| response.body_mut().read_to_string())
{
Ok(text) => text,
// The error string can embed the URL but never the token. // The error string can embed the URL but never the token.
Err(err) => return self.snapshot(UsageState::Failed { detail: why(&err) }, Vec::new()), .map_err(|err| match err {
}; ureq::Error::StatusCode(401) => Refused::Unauthorized,
let body: Value = match serde_json::from_str(&text) { other => Refused::Other(why(&other)),
Ok(body) => body, })?;
Err(err) => { serde_json::from_str(&text)
return self.snapshot( .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 { UsageState::Failed {
detail: format!("usage endpoint sent non-JSON: {err}"), detail: format!(
}, "the Claude login on {} has expired and could not be refreshed; run `{} /login` there",
Vec::new(), self.setup_name, self.program
); ),
} }
};
self.snapshot(UsageState::Ok, parse_windows(&body))
} }
} }
/// What a failed call to the usage endpoint should say. /// 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 /// 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 /// endpoint answered. 401 never reaches here -- it has its own way out in
/// -- Claude Code refreshes it as it runs, so a machine whose CLI has been /// [`ClaudeUsage::after_cli_refresh`] -- so what is left is a refusal nobody
/// idle long enough hands us a stale one. That is fixable, and the message is /// on this side can fix.
/// the only place anybody finds out how.
fn why(err: &ureq::Error) -> String { fn why(err: &ureq::Error) -> String {
match err { 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}"), ureq::Error::StatusCode(code) => format!("usage endpoint refused the request: HTTP {code}"),
other => format!("usage endpoint unreachable: {other}"), 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: setup.id.clone(),
setup_name: setup.name.clone(), setup_name: setup.name.clone(),
transport: Transport::for_setup(setup), transport: Transport::for_setup(setup),
program: provider.program().to_string(),
})), })),
// Nothing at all until a test has asked for something: an // Nothing at all until a test has asked for something: an
// echo session costs nothing, so the honest answer is no row // echo session costs nothing, so the honest answer is no row
@@ -644,14 +712,42 @@ mod tests {
use crate::config::DriverKind; use crate::config::DriverKind;
#[test] #[test]
fn an_expired_login_is_not_reported_as_an_unreachable_endpoint() { fn a_refusal_the_endpoint_answered_is_not_reported_as_an_unreachable_one() {
let stale = why(&ureq::Error::StatusCode(401));
assert!(stale.contains("expired"), "{stale}");
assert!(!stale.contains("unreachable"), "{stale}");
assert!(why(&ureq::Error::StatusCode(500)).contains("HTTP 500")); assert!(why(&ureq::Error::StatusCode(500)).contains("HTTP 500"));
assert!(why(&ureq::Error::HostNotFound).contains("unreachable")); 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] #[test]
fn parses_the_limits_array_defensively() { fn parses_the_limits_array_defensively() {
// Trimmed from a live 2026-08-24 response. // Trimmed from a live 2026-08-24 response.
@@ -706,6 +802,7 @@ mod tests {
setup: "far".to_string(), setup: "far".to_string(),
setup_name: "somewhere else".to_string(), setup_name: "somewhere else".to_string(),
transport: Transport::for_setup(&unreachable_setup()), transport: Transport::for_setup(&unreachable_setup()),
program: "claude".to_string(),
}; };
let snapshot = provider.fetch(); let snapshot = provider.fetch();
// The distinction the old single `error` string could not make: this // The distinction the old single `error` string could not make: this