Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-08-12 16:58:25 -04:00
commit 715080bbfe

View file

@ -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