From d51712da54aebdcc612252373b4c354be8eaebf8 Mon Sep 17 00:00:00 2001 From: euxaristia Date: Tue, 25 Aug 2026 19:23:53 -0400 Subject: [PATCH] Read the Claude Code session from the macOS login keychain. load_claude() looked for ~/.claude/.credentials.json and, failing that, three hard-coded keyring pairs: cairn-code:oauth:claude, cairn-code:claude, and claude:credentials. macOS Claude Code writes none of them. It writes no credentials file at all, and keeps the OAuth session in the login keychain under the service "Claude Code-credentials", keyed by the OS username rather than a fixed account name. So every macOS install reported Claude as UNCONFIGURED while Linux and Windows, which do get the file, worked. Look the entry up by service alone. keyring::read() now takes an optional account: None drops -a on macOS and username on Linux, and falls back to the bare service as the target name on Windows. Guessing the username would be the alternative, and this crate has no business doing that. Take the live credential rather than the first one found, as load_grok() does for the same reason. Only Claude Code rewrites either copy, so a leftover credentials file from an older install would otherwise shadow the keychain entry it actually refreshes, and the refresh probe could never recover: it would refresh a store the loader had already decided to ignore. Refs #38 --- src/credentials/keyring.rs | 43 ++++++++++++------- src/credentials/mod.rs | 84 ++++++++++++++++++++++++++++---------- src/model.rs | 3 +- 3 files changed, 92 insertions(+), 38 deletions(-) diff --git a/src/credentials/keyring.rs b/src/credentials/keyring.rs index dc2a722..4375089 100644 --- a/src/credentials/keyring.rs +++ b/src/credentials/keyring.rs @@ -7,14 +7,19 @@ //! The value is written by go-keyring, which base64-encodes payloads behind a //! `go-keyring-base64:` marker and, on Windows, stores the entry as a generic //! credential named `service:account`. +//! +//! An account of `None` matches on the service alone. Claude Code's macOS +//! entry needs that: it is keyed by the OS username, which this crate has no +//! business guessing. use crate::credentials::headless; use std::process::Command; const GO_KEYRING_BASE64: &str = "go-keyring-base64:"; -/// Fetch a secret, decoding UTF-16LE and go-keyring's base64 wrapper when present. -pub fn read(service: &str, account: &str) -> Option> { +/// Fetch a secret, decoding UTF-16LE and go-keyring's base64 wrapper when +/// present. `account` of `None` matches any account under the service. +pub fn read(service: &str, account: Option<&str>) -> Option> { let raw = read_raw(service, account)?; let text = decode_secret_bytes(&raw); let trimmed = text.trim(); @@ -43,7 +48,12 @@ fn decode_secret_bytes(raw: &[u8]) -> String { } #[cfg(target_os = "windows")] -fn read_raw(service: &str, account: &str) -> Option> { +fn read_raw(service: &str, account: Option<&str>) -> Option> { + let Some(account) = account else { + // Nothing on Windows can enumerate a target by service alone, so the + // service name is the only candidate. + return read_target(service); + }; for target_name in [ format!("{service}:{account}"), format!("{account}.{service}"), @@ -89,24 +99,27 @@ fn read_target(target_name: &str) -> Option> { } #[cfg(target_os = "macos")] -fn read_raw(service: &str, account: &str) -> Option> { - run(Command::new("security").args([ - "find-generic-password", - "-s", - service, - "-a", - account, - "-w", - ])) +fn read_raw(service: &str, account: Option<&str>) -> Option> { + let mut command = Command::new("security"); + command.args(["find-generic-password", "-s", service]); + if let Some(account) = account { + command.args(["-a", account]); + } + run(command.arg("-w")) } #[cfg(all(unix, not(target_os = "macos")))] -fn read_raw(service: &str, account: &str) -> Option> { - run(Command::new("secret-tool").args(["lookup", "service", service, "username", account])) +fn read_raw(service: &str, account: Option<&str>) -> Option> { + let mut command = Command::new("secret-tool"); + command.args(["lookup", "service", service]); + if let Some(account) = account { + command.args(["username", account]); + } + run(&mut command) } #[cfg(not(any(unix, target_os = "windows")))] -fn read_raw(_service: &str, _account: &str) -> Option> { +fn read_raw(_service: &str, _account: Option<&str>) -> Option> { None } diff --git a/src/credentials/mod.rs b/src/credentials/mod.rs index fc48896..a52146e 100644 --- a/src/credentials/mod.rs +++ b/src/credentials/mod.rs @@ -192,29 +192,42 @@ pub fn load_codex() -> Option { }) } -/// The OAuth session Claude Code keeps in `~/.claude/.credentials.json`, -/// falling back to the OS keyring entry from cairn-code if absent. +/// Every keyring entry that may hold a Claude Code session, canonical first. +/// +/// `Claude Code-credentials` is where Claude Code puts the session on macOS, +/// which writes no credentials file at all. That entry is keyed by the OS +/// username rather than a fixed account name, so it is looked up by service +/// alone instead of guessing whose login this is. +const CLAUDE_KEYRING_ENTRIES: [(&str, Option<&str>); 4] = [ + ("Claude Code-credentials", None), + ("cairn-code", Some("oauth:claude")), + ("cairn-code", Some("claude")), + ("claude", Some("credentials")), +]; + +/// The OAuth session Claude Code keeps in `~/.claude/.credentials.json` on +/// Linux and Windows, or in the OS keyring on macOS. +/// +/// Both stores are read and the live one wins, for the reason spelled out on +/// [`load_grok`]: only Claude Code itself rewrites either copy, so whichever +/// one it has stopped touching would otherwise shadow the one it still +/// refreshes, and every call would hand back a permanently expired token. pub fn load_claude() -> Option { - if let Some(root) = read_json(&home_dir().join(".claude").join(".credentials.json")) - && let Some(token) = parse_claude(&root) - { - return Some(token); - } + pick_live(claude_candidates(keyring::read)) +} - for (service, account) in [ - ("cairn-code", "oauth:claude"), - ("cairn-code", "claude"), - ("claude", "credentials"), - ] { - if let Some(data) = keyring::read(service, account) - && let Ok(value) = serde_json::from_slice::(&data) - && let Some(token) = parse_claude(&value) - { - return Some(token); - } - } +fn claude_candidates(read: impl Fn(&str, Option<&str>) -> Option>) -> Vec { + let file = read_json(&home_dir().join(".claude").join(".credentials.json")); + let stored = CLAUDE_KEYRING_ENTRIES + .iter() + .filter_map(|(service, account)| { + serde_json::from_slice::(&read(service, *account)?).ok() + }); - None + file.into_iter() + .chain(stored) + .filter_map(|value| parse_claude(&value)) + .collect() } pub(crate) fn parse_claude(root: &Value) -> Option { @@ -305,7 +318,7 @@ fn grok_candidates() -> Vec { ("grok", "auth"), ("grok", "oauth:grok"), ] { - let Some(data) = keyring::read(service, account) else { + let Some(data) = keyring::read(service, Some(account)) else { continue; }; match serde_json::from_slice::(&data) { @@ -383,7 +396,7 @@ fn parse_grok_entry(entry: &Value) -> Option { /// after. Recent versions keep the live token in the keyring and leave the file /// behind stale, so file-first would report a permanently expired session. pub fn load_antigravity() -> Option { - if let Some(data) = keyring::read("gemini", "antigravity") + if let Some(data) = keyring::read("gemini", Some("antigravity")) && let Ok(value) = serde_json::from_slice::(&data) && let Some(token) = parse_antigravity(&value) { @@ -685,6 +698,33 @@ mod tests { } } + #[test] + fn reads_the_claude_code_keyring_entry_keyed_by_os_username() { + // macOS Claude Code writes no credentials file: the session lives in + // the login keychain under a service whose account is the OS username, + // so the lookup must match on the service alone. + let stored = json!({ + "claudeAiOauth": { + "accessToken": "sk-ant-oat01-access", + "refreshToken": "sk-ant-ort01-refresh", + "expiresAt": 4102444800000i64, + "subscriptionType": "pro" + } + }) + .to_string(); + + let tokens = claude_candidates(|service, account| { + (service == "Claude Code-credentials" && account.is_none()) + .then(|| stored.clone().into_bytes()) + }); + + let token = tokens.first().expect("keychain entry should be read"); + assert_eq!(token.access_token, "sk-ant-oat01-access"); + assert_eq!(token.refresh_token, "sk-ant-ort01-refresh"); + assert_eq!(token.plan_type, "pro"); + assert!(token.is_fresh()); + } + #[test] fn parse_grok_oauth_payload() { let payload = json!({ diff --git a/src/model.rs b/src/model.rs index 63018a1..4503387 100644 --- a/src/model.rs +++ b/src/model.rs @@ -96,7 +96,8 @@ impl Provider { match self { Provider::OpenAi => "API key required. Set with 'limits config set-key openai '", Provider::Claude => { - "API key or Claude CLI login required (~/.claude/.credentials.json)" + "API key or Claude CLI login required (~/.claude/.credentials.json, \ + or the OS keyring on macOS)" } Provider::DeepSeek => { "API key required. Set with 'limits config set-key deepseek '"