From d45adfd479fc2d63e7562e1157d18b2e1849e187 Mon Sep 17 00:00:00 2001 From: Harry Brundage Date: Fri, 20 Mar 2026 08:00:19 -0400 Subject: [PATCH 1/2] fix(credentials): read Claude Code OAuth tokens from macOS Keychain Claude Code now stores OAuth credentials in the macOS Keychain (service "Claude Code-credentials") instead of JSON files on disk. This adds a macOS-only fallback that reads from the Keychain via the `security` CLI when the file-based credential paths are not found. Also refactors the OAuth JSON parsing into a shared helper that handles both RFC 3339 string and epoch-millis number expiry formats, matching the Keychain entry's numeric expiresAt field. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/packages/agent-credentials/src/lib.rs | 176 +++++++++++++++++-- 1 file changed, 162 insertions(+), 14 deletions(-) diff --git a/server/packages/agent-credentials/src/lib.rs b/server/packages/agent-credentials/src/lib.rs index b2c22253..6ab094ed 100644 --- a/server/packages/agent-credentials/src/lib.rs +++ b/server/packages/agent-credentials/src/lib.rs @@ -6,6 +6,9 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use time::OffsetDateTime; +#[cfg(target_os = "macos")] +use std::process::Command; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ProviderCredentials { pub api_key: String, @@ -90,20 +93,15 @@ pub fn extract_claude_credentials( Some(value) => value, None => continue, }; - let access = read_string_field(&data, &["claudeAiOauth", "accessToken"]); - if let Some(token) = access { - if let Some(expires_at) = read_string_field(&data, &["claudeAiOauth", "expiresAt"]) - { - if is_expired_rfc3339(&expires_at) { - continue; - } - } - return Some(ProviderCredentials { - api_key: token, - source: "claude-code".to_string(), - auth_type: AuthType::Oauth, - provider: "anthropic".to_string(), - }); + if let Some(cred) = extract_claude_oauth_from_json(&data) { + return Some(cred); + } + } + + #[cfg(target_os = "macos")] + { + if let Some(cred) = extract_claude_oauth_from_keychain() { + return Some(cred); } } } @@ -111,6 +109,56 @@ pub fn extract_claude_credentials( None } +fn extract_claude_oauth_from_json(data: &Value) -> Option { + let access = read_string_field(data, &["claudeAiOauth", "accessToken"])?; + if access.is_empty() { + return None; + } + + // Check expiry — the field can be an RFC 3339 string or an epoch-millis number + if let Some(expires_str) = read_string_field(data, &["claudeAiOauth", "expiresAt"]) { + if is_expired_rfc3339(&expires_str) { + return None; + } + } else if let Some(expires_ms) = data + .get("claudeAiOauth") + .and_then(|v| v.get("expiresAt")) + .and_then(Value::as_i64) + { + if expires_ms < current_epoch_millis() { + return None; + } + } + + Some(ProviderCredentials { + api_key: access, + source: "claude-code".to_string(), + auth_type: AuthType::Oauth, + provider: "anthropic".to_string(), + }) +} + +#[cfg(target_os = "macos")] +fn extract_claude_oauth_from_keychain() -> Option { + let output = Command::new("security") + .args([ + "find-generic-password", + "-s", + "Claude Code-credentials", + "-w", + ]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let json_str = String::from_utf8(output.stdout).ok()?; + let data: Value = serde_json::from_str(json_str.trim()).ok()?; + extract_claude_oauth_from_json(&data) +} + pub fn extract_codex_credentials( options: &CredentialExtractionOptions, ) -> Option { @@ -500,6 +548,106 @@ mod tests { ); } + #[test] + fn extract_claude_oauth_from_json_with_epoch_millis_expiry() { + let future_ms = current_epoch_millis() + 3_600_000; // 1 hour from now + let data = serde_json::json!({ + "claudeAiOauth": { + "accessToken": "sk-ant-oat01-test-token", + "expiresAt": future_ms, + } + }); + let cred = extract_claude_oauth_from_json(&data).expect("should extract valid oauth"); + assert_eq!(cred.api_key, "sk-ant-oat01-test-token"); + assert_eq!(cred.source, "claude-code"); + assert_eq!(cred.auth_type, AuthType::Oauth); + assert_eq!(cred.provider, "anthropic"); + } + + #[test] + fn extract_claude_oauth_from_json_expired_epoch_millis() { + let past_ms = current_epoch_millis() - 3_600_000; // 1 hour ago + let data = serde_json::json!({ + "claudeAiOauth": { + "accessToken": "sk-ant-oat01-expired", + "expiresAt": past_ms, + } + }); + assert!( + extract_claude_oauth_from_json(&data).is_none(), + "should reject expired token" + ); + } + + #[test] + fn extract_claude_oauth_from_json_with_rfc3339_expiry() { + let data = serde_json::json!({ + "claudeAiOauth": { + "accessToken": "sk-ant-oat01-rfc-token", + "expiresAt": "2099-01-01T00:00:00Z", + } + }); + let cred = extract_claude_oauth_from_json(&data).expect("should extract valid oauth"); + assert_eq!(cred.api_key, "sk-ant-oat01-rfc-token"); + } + + #[test] + fn extract_claude_oauth_from_json_expired_rfc3339() { + let data = serde_json::json!({ + "claudeAiOauth": { + "accessToken": "sk-ant-oat01-old", + "expiresAt": "2020-01-01T00:00:00Z", + } + }); + assert!( + extract_claude_oauth_from_json(&data).is_none(), + "should reject expired rfc3339 token" + ); + } + + #[test] + fn extract_claude_oauth_from_json_empty_access_token() { + let data = serde_json::json!({ + "claudeAiOauth": { + "accessToken": "", + "expiresAt": 9999999999999_i64, + } + }); + assert!( + extract_claude_oauth_from_json(&data).is_none(), + "should reject empty access token" + ); + } + + #[test] + fn extract_claude_oauth_from_json_no_expiry() { + let data = serde_json::json!({ + "claudeAiOauth": { + "accessToken": "sk-ant-oat01-no-expiry", + } + }); + let cred = + extract_claude_oauth_from_json(&data).expect("should accept token without expiry"); + assert_eq!(cred.api_key, "sk-ant-oat01-no-expiry"); + } + + #[test] + fn extract_claude_oauth_from_json_with_extra_fields() { + let future_ms = current_epoch_millis() + 3_600_000; + let data = serde_json::json!({ + "claudeAiOauth": { + "accessToken": "sk-ant-oat01-full", + "refreshToken": "sk-ant-ort01-refresh", + "expiresAt": future_ms, + "scopes": ["user:inference"], + "subscriptionType": "max", + }, + "mcpOAuth": {} + }); + let cred = extract_claude_oauth_from_json(&data).expect("should extract oauth"); + assert_eq!(cred.api_key, "sk-ant-oat01-full"); + } + #[test] fn extract_all_credentials_prefers_api_key_over_oauth_env() { with_env( From 035bcd05573ac21af73517a4ff4924f8715c7746 Mon Sep 17 00:00:00 2001 From: Harry Brundage Date: Thu, 26 Mar 2026 10:48:06 -0400 Subject: [PATCH 2/2] feat(credentials): detect Claude Code apiKeyHelper as valid credentials When Claude Code is configured with an apiKeyHelper in ~/.claude/settings.json (e.g. for corporate proxies like Shopify's llm-gateway), treat that as having valid Anthropic credentials so the agents manifest endpoint reports credentials_available=true. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/packages/agent-credentials/src/lib.rs | 91 ++++++++++++++++++- .../packages/agent-management/src/testing.rs | 5 + server/packages/sandbox-agent/src/cli.rs | 1 + 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/server/packages/agent-credentials/src/lib.rs b/server/packages/agent-credentials/src/lib.rs index 6ab094ed..686c38f3 100644 --- a/server/packages/agent-credentials/src/lib.rs +++ b/server/packages/agent-credentials/src/lib.rs @@ -22,6 +22,7 @@ pub struct ProviderCredentials { pub enum AuthType { ApiKey, Oauth, + ApiKeyHelper, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -106,6 +107,22 @@ pub fn extract_claude_credentials( } } + // Check for apiKeyHelper in Claude Code settings — if configured, Claude Code + // can obtain credentials dynamically via an external command (e.g. a corporate proxy) + let settings_path = home_dir.join(".claude").join("settings.json"); + if let Some(settings) = read_json_file(&settings_path) { + if let Some(helper) = read_string_field(&settings, &["apiKeyHelper"]) { + if !helper.is_empty() { + return Some(ProviderCredentials { + api_key: String::new(), + source: "claude-code-api-key-helper".to_string(), + auth_type: AuthType::ApiKeyHelper, + provider: "anthropic".to_string(), + }); + } + } + } + None } @@ -407,7 +424,11 @@ pub fn get_openai_api_key(options: &CredentialExtractionOptions) -> Option Result<(), TestA })?, ); } + AuthType::ApiKeyHelper => { + // The agent obtains its own credentials via apiKeyHelper; + // we don't have a static token to health-check with. + return Ok(()); + } } headers.insert( "anthropic-version", diff --git a/server/packages/sandbox-agent/src/cli.rs b/server/packages/sandbox-agent/src/cli.rs index 000ea41e..dd8282b0 100644 --- a/server/packages/sandbox-agent/src/cli.rs +++ b/server/packages/sandbox-agent/src/cli.rs @@ -1192,6 +1192,7 @@ fn summarize_credential(credential: &ProviderCredentials, reveal: bool) -> Crede auth_type: match credential.auth_type { AuthType::ApiKey => "api_key".to_string(), AuthType::Oauth => "oauth".to_string(), + AuthType::ApiKeyHelper => "api_key_helper".to_string(), }, api_key, redacted: !reveal,