Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 28 additions & 15 deletions src/credentials/keyring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u8>> {
/// 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<Vec<u8>> {
let raw = read_raw(service, account)?;
let text = decode_secret_bytes(&raw);
let trimmed = text.trim();
Expand Down Expand Up @@ -43,7 +48,12 @@ fn decode_secret_bytes(raw: &[u8]) -> String {
}

#[cfg(target_os = "windows")]
fn read_raw(service: &str, account: &str) -> Option<Vec<u8>> {
fn read_raw(service: &str, account: Option<&str>) -> Option<Vec<u8>> {
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}"),
Expand Down Expand Up @@ -89,24 +99,27 @@ fn read_target(target_name: &str) -> Option<Vec<u8>> {
}

#[cfg(target_os = "macos")]
fn read_raw(service: &str, account: &str) -> Option<Vec<u8>> {
run(Command::new("security").args([
"find-generic-password",
"-s",
service,
"-a",
account,
"-w",
]))
fn read_raw(service: &str, account: Option<&str>) -> Option<Vec<u8>> {
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<Vec<u8>> {
run(Command::new("secret-tool").args(["lookup", "service", service, "username", account]))
fn read_raw(service: &str, account: Option<&str>) -> Option<Vec<u8>> {
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<Vec<u8>> {
fn read_raw(_service: &str, _account: Option<&str>) -> Option<Vec<u8>> {
None
}

Expand Down
84 changes: 62 additions & 22 deletions src/credentials/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,29 +192,42 @@ pub fn load_codex() -> Option<Token> {
})
}

/// 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<Token> {
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::<Value>(&data)
&& let Some(token) = parse_claude(&value)
{
return Some(token);
}
}
fn claude_candidates(read: impl Fn(&str, Option<&str>) -> Option<Vec<u8>>) -> Vec<Token> {
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::<Value>(&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<Token> {
Expand Down Expand Up @@ -305,7 +318,7 @@ fn grok_candidates() -> Vec<Token> {
("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::<Value>(&data) {
Expand Down Expand Up @@ -383,7 +396,7 @@ fn parse_grok_entry(entry: &Value) -> Option<Token> {
/// 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<Token> {
if let Some(data) = keyring::read("gemini", "antigravity")
if let Some(data) = keyring::read("gemini", Some("antigravity"))
&& let Ok(value) = serde_json::from_slice::<Value>(&data)
&& let Some(token) = parse_antigravity(&value)
{
Expand Down Expand Up @@ -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!({
Expand Down
3 changes: 2 additions & 1 deletion src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ impl Provider {
match self {
Provider::OpenAi => "API key required. Set with 'limits config set-key openai <key>'",
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 <key>'"
Expand Down
Loading