claude: read the credential file instead of stat-comparing it
Tokens are fixed-length, so a rotation changes neither size nor same-tick mtime — the stamp missed real swaps and flaked CI.
This commit is contained in:
parent
16587b74a3
commit
715080bbfe
1 changed files with 6 additions and 40 deletions
|
|
@ -132,7 +132,6 @@ struct TokenState {
|
|||
refresh_token: Option<String>,
|
||||
expires_at_ms: Option<u64>,
|
||||
source_path: Option<PathBuf>,
|
||||
source_stamp: Option<FileStamp>,
|
||||
}
|
||||
|
||||
impl TokenState {
|
||||
|
|
@ -149,13 +148,6 @@ impl TokenState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Identity of the credential file as of the read that produced a `TokenState`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
struct FileStamp {
|
||||
mtime_ns: u64,
|
||||
len: u64,
|
||||
}
|
||||
|
||||
impl ClaudeSubscriptionProvider {
|
||||
/// Build from a provider config entry. Reads `~/.claude/.credentials.json`
|
||||
/// (or the `credential_file` override), fetches `account_uuid` best-effort,
|
||||
|
|
@ -574,14 +566,13 @@ impl LlmProvider for ClaudeSubscriptionProvider {
|
|||
/// the user has left — 429ing on its exhausted quota while a live token sits in
|
||||
/// the file. Measured 2026-08-12: server up since 10:55, file rewritten 15:20,
|
||||
/// 429s from 15:22 with the on-disk token returning 200 to the same request.
|
||||
/// The file is read every call rather than stat-compared: tokens are
|
||||
/// fixed-length, so a rotation changes neither size nor — inside one filesystem
|
||||
/// tick — mtime. A kilobyte ahead of an HTTPS call buys certainty.
|
||||
fn adopt_file_if_changed(token: &mut TokenState) {
|
||||
let Some(path) = token.source_path.clone() else {
|
||||
return;
|
||||
};
|
||||
let stamp = stamp_of(&path);
|
||||
if stamp.is_none() || stamp == token.source_stamp {
|
||||
return;
|
||||
}
|
||||
let Ok(fresh) = load_credentials(&path) else {
|
||||
return; // mid-write, or logged out entirely — what we hold still answers
|
||||
};
|
||||
|
|
@ -590,7 +581,6 @@ fn adopt_file_if_changed(token: &mut TokenState) {
|
|||
.as_deref()
|
||||
.is_some_and(|t| !t.is_empty() && Some(t) != token.access_token.as_deref());
|
||||
if !rotated {
|
||||
token.source_stamp = fresh.source_stamp;
|
||||
return;
|
||||
}
|
||||
info!(
|
||||
|
|
@ -601,20 +591,6 @@ fn adopt_file_if_changed(token: &mut TokenState) {
|
|||
*token = fresh;
|
||||
}
|
||||
|
||||
fn stamp_of(path: &Path) -> Option<FileStamp> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
let mtime_ns = meta
|
||||
.modified()
|
||||
.ok()?
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()?
|
||||
.as_nanos() as u64;
|
||||
Some(FileStamp {
|
||||
mtime_ns,
|
||||
len: meta.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Short, stable id for a credential — enough to see in a log that souveraine
|
||||
/// and `claude` are holding different logins. The token itself never gets there.
|
||||
fn credential_fingerprint(token: &TokenState) -> String {
|
||||
|
|
@ -701,9 +677,9 @@ async fn refresh_token(
|
|||
new_expires,
|
||||
&refresh,
|
||||
) {
|
||||
Ok(true) => token.source_stamp = stamp_of(&path),
|
||||
// Leaving the stamp stale is the recovery: the next call sees the
|
||||
// file has moved and adopts whoever owns it now.
|
||||
Ok(true) => {}
|
||||
// Nothing to undo: the next call reads the file and adopts whoever
|
||||
// owns it now.
|
||||
Ok(false) => warn!("claude credential file holds another login; not writing over it"),
|
||||
Err(e) => warn!("credential write-back failed (continuing in-memory): {e}"),
|
||||
}
|
||||
|
|
@ -786,10 +762,6 @@ fn resolve_credential_path(configured: Option<&str>) -> Option<PathBuf> {
|
|||
|
||||
/// `{ claudeAiOauth: { accessToken, refreshToken, expiresAt(ms), ... }, ... }`
|
||||
fn load_credentials(path: &Path) -> Result<TokenState> {
|
||||
// Stamped before the read: a write landing between the two leaves us with a
|
||||
// stamp older than the file, so the next call re-reads rather than trusting
|
||||
// content it never saw.
|
||||
let stamp = stamp_of(path);
|
||||
let raw =
|
||||
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
let value: Value = serde_json::from_str(&raw).context("parsing credentials json")?;
|
||||
|
|
@ -807,7 +779,6 @@ fn load_credentials(path: &Path) -> Result<TokenState> {
|
|||
.map(str::to_string),
|
||||
expires_at_ms: oauth.get("expiresAt").and_then(Value::as_u64),
|
||||
source_path: Some(path.to_path_buf()),
|
||||
source_stamp: stamp,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1831,11 +1802,6 @@ mod tests {
|
|||
|
||||
adopt_file_if_changed(&mut held);
|
||||
assert_eq!(credential_fingerprint(&held), before);
|
||||
// Stamp caught up, so the next call does not re-read the same file again.
|
||||
assert_eq!(
|
||||
held.source_stamp,
|
||||
stamp_of(&dir.path().join(".credentials.json"))
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of a swap: our refresh succeeds, but by the time it lands
|
||||
|
|
|
|||
Loading…
Reference in a new issue