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 '"