diff --git a/server/packages/agent-credentials/src/lib.rs b/server/packages/agent-credentials/src/lib.rs index b2c22253..686c38f3 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, @@ -19,6 +22,7 @@ pub struct ProviderCredentials { pub enum AuthType { ApiKey, Oauth, + ApiKeyHelper, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -90,18 +94,29 @@ 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; - } - } + 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); + } + } + } + + // 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: token, - source: "claude-code".to_string(), - auth_type: AuthType::Oauth, + api_key: String::new(), + source: "claude-code-api-key-helper".to_string(), + auth_type: AuthType::ApiKeyHelper, provider: "anthropic".to_string(), }); } @@ -111,6 +126,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 { @@ -359,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,