From 11c5e254228cab6fd41e4e559c87a400a169d765 Mon Sep 17 00:00:00 2001 From: "firat.sertgoz" Date: Sun, 8 Mar 2026 00:59:17 +0400 Subject: [PATCH] feat(setup): Anthropic OAuth onboarding with setup-token support (#384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(setup): add Anthropic OAuth and Codex OAuth onboarding flows Add OAuth token authentication as an alternative to API keys during onboarding for both Anthropic (via `claude login`) and OpenAI/Codex (via `~/.codex/auth.json`). Key changes: - New `AnthropicOAuthProvider` using `Authorization: Bearer` header (rig-core hardcodes `x-api-key` which rejects OAuth tokens) - Wizard auth method selector: "Direct API Key" vs "OAuth Token" for both Anthropic and OpenAI providers - Codex token extraction from `$CODEX_HOME/auth.json` / `~/.codex/auth.json` - Claude Code sandbox sub-step in Docker setup (checks for credentials) - Secret injection mappings for `ANTHROPIC_OAUTH_TOKEN` and `CODEX_OAUTH_TOKEN` - `CODEX_OAUTH_TOKEN` falls back to `OPENAI_API_KEY` (same Bearer auth) Supersedes #143 which had a broken auth flow (OAuth token sent as x-api-key → 401). Credit to @bigguybobby for the original approach. Co-Authored-By: Claude Opus 4.6 * fix: persist OAuth tokens in bootstrap .env and re-extract at startup OAuth tokens stored only in the secrets DB were invisible to Config::from_env() which runs before the DB connects (chicken-and-egg). Two fixes: 1. write_bootstrap_env() now persists ANTHROPIC_OAUTH_TOKEN and CODEX_OAUTH_TOKEN to ~/.ironclaw/.env (same pattern as NEARAI_API_KEY) 2. main.rs re-extracts a fresh token from the OS credential store (macOS Keychain / ~/.claude/.credentials.json) before config resolution, handling token expiry (8-12h) gracefully Co-Authored-By: Claude Opus 4.6 * fix: persist all LLM credentials in bootstrap .env, not just NEAR AI All providers had the same chicken-and-egg issue: API keys stored in the secrets DB were invisible to Config::from_env() which runs before DB connects. Only NEARAI_API_KEY was written to bootstrap .env. Now write_bootstrap_env() persists all credential env vars: NEARAI_API_KEY, ANTHROPIC_API_KEY, ANTHROPIC_OAUTH_TOKEN, OPENAI_API_KEY, CODEX_OAUTH_TOKEN, LLM_API_KEY, TINFOIL_API_KEY. Also: setup_api_key_provider() now sets the env var during the wizard session so write_bootstrap_env() can pick it up. Co-Authored-By: Claude Opus 4.6 * fix: address security review findings for OAuth onboarding - Extract "oauth-placeholder" to named OAUTH_PLACEHOLDER constant shared across config and wizard to prevent silent drift - Document plaintext credential tradeoff in write_bootstrap_env (API keys stored with 0o600 permissions, recommend full-disk encryption) - Add blocking "Press Enter" wait in Anthropic OAuth retry flow so user has time to run `claude login` in another terminal - Add escape hatch from manual OAuth paste back to API key flow (empty input switches to setup_api_key_provider) - Fix Retry-After header: parse u64 seconds into Duration before passing to LlmError::RateLimited - Make config::llm module pub(crate) for constant visibility - Use .bearer_auth() instead of manual format!("Bearer {}") - Remove response body from debug log (may contain PII) - Update Anthropic API version to 2024-10-22 Co-Authored-By: Claude Opus 4.6 * security: remove plaintext credentials from bootstrap .env Credentials (API keys, OAuth tokens) were being written in plaintext to ~/.ironclaw/.env to work around a chicken-and-egg problem: Config::from_env() runs before the encrypted secrets DB is connected. Instead of storing secrets on disk, LlmConfig::resolve() now defers gracefully when credentials are missing — it returns None for the provider config instead of hard-erroring with MissingRequired. After the DB connects, AppBuilder::build_all() loads secrets from encrypted storage via inject_llm_keys_from_secrets() and re-resolves the config. For Anthropic OAuth tokens (which expire in 8-12h), the secret injection step also tries the OS credential store (macOS Keychain / Linux credentials.json) for a fresh token, overriding the potentially stale copy in the DB. Changes: - LlmConfig::resolve(): OpenAI, Anthropic, OpenAI-compatible, and Tinfoil all return None instead of MissingRequired when credentials are absent - write_bootstrap_env(): no longer writes any credential env vars - inject_llm_keys_from_secrets(): refreshes Anthropic OAuth from OS credential store before overlay is finalized - main.rs: removed OAuth re-extraction hack (no longer needed) Co-Authored-By: Claude Opus 4.6 * fix: load OS credential store tokens even without secrets DB The OAuth token extraction from macOS Keychain / Linux credentials files was only running inside inject_llm_keys_from_secrets(), which requires the encrypted secrets DB. When no master key is configured, init_secrets() returned early — skipping both DB secret loading AND OS credential store extraction, leaving the Anthropic OAuth token unavailable. Split into two paths: - inject_llm_keys_from_secrets(): loads from encrypted DB + OS stores - inject_os_credentials(): loads from OS stores only (no DB needed) init_secrets() now calls inject_os_credentials() and re-resolves config even in the no-master-key early-return path, so `claude login` tokens are always available regardless of secrets DB state. Co-Authored-By: Claude Opus 4.6 * fix: add anthropic-beta header required for OAuth authentication Anthropic's api.anthropic.com requires the `anthropic-beta: oauth-2025-04-20` header to accept OAuth Bearer tokens. Without it, the API returns 401 "OAuth authentication is currently not supported." Also reverts API version to 2023-06-01 since the OAuth beta flag does not support the 2024-10-22 version (returns 400 "not a valid version"). This was the same bug that caused PR #143's 401 errors — the beta header was missing entirely. Co-Authored-By: Claude Opus 4.6 * fix: Anthropic and OpenAI model resolution respects selected_model The Anthropic and OpenAI config resolution ignored settings.selected_model entirely, only checking the provider-specific env var (ANTHROPIC_MODEL, OPENAI_MODEL) and falling back to a hardcoded default. This meant the model chosen during onboarding wizard was silently overridden. Now follows the same pattern as NearAI and OpenAI-compatible: env var > settings.selected_model > hardcoded default. Also deduplicated the Anthropic config construction (two identical branches for API key vs OAuth now share model/base_url resolution). Co-Authored-By: Claude Opus 4.6 * test: add provider resolution tests for all LLM backends Covers deferred resolution (no credentials → None instead of error), credential presence, model selection fallback chain, and OAuth token routing for Anthropic, OpenAI, Tinfoil, Ollama, and NearAI. Co-Authored-By: Claude Opus 4.6 * fix: handle nested tokens.access_token format in Codex auth.json Codex CLI stores OAuth tokens in a nested format under tokens.access_token (ChatGPT OAuth flow), not at the top level. Also adds ENV_MUTEX to Codex token tests for thread safety. Co-Authored-By: Claude Opus 4.6 * refactor: remove Codex OAuth onboarding (incompatible with OpenAI API) Codex CLI OAuth tokens use a different endpoint (chatgpt.com/backend-api/codex) and the Responses API wire format, not api.openai.com with Chat Completions. The tokens lack the model.request scope needed for the platform API, so they can't be used as drop-in OPENAI_API_KEY replacements. Removes: extract_codex_oauth_token(), wizard Codex OAuth flow, CODEX_OAUTH_TOKEN env var support, and related tests. OpenAI onboarding now uses direct API key only. Co-Authored-By: Claude Opus 4.6 * style: fix formatting for CI (cargo fmt) Co-Authored-By: Claude Opus 4.6 * fix: address Gemini review feedback - Use ? operator for ANTHROPIC_MODEL/BASE_URL env resolution instead of .ok().flatten() to propagate ConfigErrors consistently - Skip Tool messages without tool_call_id with a warning instead of using unwrap_or_default() which would send empty string to Anthropic - Extract credential check into closure to reduce duplication in Claude Code sandbox setup Co-Authored-By: Claude Opus 4.6 * refactor(review): address PR review feedback for OAuth onboarding - Gate ANTHROPIC_OAUTH_TOKEN resolution to Anthropic provider only (was needlessly checked for all registry providers) - Add 3 regression tests for OAuth config resolution: - oauth_token sets placeholder api_key - real api_key takes priority over oauth - non-Anthropic providers don't pick up oauth_token - Validate OAuth token prefix (sk-ant-oat) in wizard to catch accidentally pasted API keys - Improve error body read handling in AnthropicOAuthProvider (was silently swallowing read errors with unwrap_or_default) - Remove extra blank line in write_bootstrap_env - Remove stale blank line in RegistryProviderConfig doc comment [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * fix: address PR #384 review comments Blocker: - Replace OnceLock with LazyLock> for INJECTED_VARS so both inject_os_credentials() and inject_llm_keys_from_secrets() merge data instead of the second caller silently dropping its entries. High: - Add 401 retry with OS credential store re-extraction in AnthropicOAuthProvider, recovering from expired OAuth tokens (~8-12h) without manual intervention. - Fix comment in app.rs: ~/.codex/auth.json → ~/.claude/.credentials.json. Medium: - Remove unsafe { std::env::set_var } from wizard; use thread-safe inject_single_var() overlay instead (safe on multi-threaded Tokio). - Add post-init validation in AppBuilder: fail early with clear error when LLM_BACKEND is set but no credentials were resolved after secret injection. - Add sk-ant-oat prefix validation in parse_oauth_access_token(). - Only route to AnthropicOAuthProvider when api_key is missing or equals OAUTH_PLACEHOLDER (API key takes priority over OAuth token). - Teach fetch_anthropic_models() to use Bearer auth when only OAuth token is available (model listing no longer fails for OAuth-only users). Low: - Use optional_env() in wizard credential checks to read from injected overlay, not just raw env vars. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * style: cargo fmt Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: ilblackdragon@gmail.com --- .env.example | 12 + src/app.rs | 28 ++ src/config/helpers.rs | 9 +- src/config/llm.rs | 147 ++++++++- src/config/mod.rs | 78 ++++- src/config/sandbox.rs | 21 +- src/llm/anthropic_oauth.rs | 641 +++++++++++++++++++++++++++++++++++++ src/llm/mod.rs | 19 ++ src/main.rs | 5 +- src/settings.rs | 5 + src/setup/wizard.rs | 226 ++++++++++++- 11 files changed, 1162 insertions(+), 29 deletions(-) create mode 100644 src/llm/anthropic_oauth.rs diff --git a/.env.example b/.env.example index 9c41a62d..258fd79f 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,18 @@ DATABASE_POOL_SIZE=10 # LLM_BACKEND=nearai # default # Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil +# === Anthropic Direct === +# Two auth modes: +# 1. API key: Set ANTHROPIC_API_KEY (from console.anthropic.com/settings/keys) +# 2. OAuth token: Set ANTHROPIC_OAUTH_TOKEN (from `claude login`) +# OAuth tokens use Authorization: Bearer instead of x-api-key header. +# ANTHROPIC_API_KEY=sk-ant-... +# ANTHROPIC_OAUTH_TOKEN=sk-ant-oat01-... # from `claude login` credentials +# ANTHROPIC_MODEL=claude-sonnet-4-20250514 + +# === OpenAI Direct === +# OPENAI_API_KEY=sk-... + # === NEAR AI (Chat Completions API) === # Two auth modes: # 1. Session token (default): Uses browser OAuth (GitHub/Google) on first run. diff --git a/src/app.rs b/src/app.rs index d273df41..766aff30 100644 --- a/src/app.rs +++ b/src/app.rs @@ -244,11 +244,28 @@ impl AppBuilder { let master_key = match self.config.secrets.master_key() { Some(k) => k, None => { + // No secrets DB available, but we can still load tokens from + // OS credential stores (e.g., Anthropic OAuth via Claude Code's + // macOS Keychain / Linux ~/.claude/.credentials.json). + crate::config::inject_os_credentials(); + // Consume unused handles #[cfg(feature = "libsql")] { self.libsql_db.take(); } + + // Re-resolve config with OS credentials + if let Some(ref db) = self.db { + let toml_path = self.toml_path.as_deref(); + if let Ok(refreshed) = + Config::from_db_with_toml(db.as_ref(), "default", toml_path).await + { + self.config = refreshed; + tracing::debug!("LlmConfig re-resolved after OS credential injection"); + } + } + return Ok(()); } }; @@ -665,6 +682,17 @@ impl AppBuilder { self.init_database().await?; self.init_secrets().await?; + // Post-init validation: if a non-nearai backend was selected but + // credentials were never resolved (deferred resolution found no keys), + // fail early with a clear error instead of a confusing runtime failure. + if self.config.llm.backend != "nearai" && self.config.llm.provider.is_none() { + let backend = &self.config.llm.backend; + anyhow::bail!( + "LLM_BACKEND={backend} is configured but no credentials were found. \ + Set the appropriate API key environment variable or run the setup wizard." + ); + } + let (llm, cheap_llm, recording_handle) = if let Some(llm) = self.llm_override.take() { (llm, None, None) } else { diff --git a/src/config/helpers.rs b/src/config/helpers.rs index 8db271d4..d6521b38 100644 --- a/src/config/helpers.rs +++ b/src/config/helpers.rs @@ -25,8 +25,13 @@ pub(crate) fn optional_env(key: &str) -> Result, ConfigError> { } // Fall back to thread-safe overlay (secrets injected from DB) - if let Some(val) = INJECTED_VARS.get().and_then(|map| map.get(key)) { - return Ok(Some(val.clone())); + if let Some(val) = INJECTED_VARS + .lock() + .unwrap_or_else(|p| p.into_inner()) + .get(key) + .cloned() + { + return Ok(Some(val)); } Ok(None) diff --git a/src/config/llm.rs b/src/config/llm.rs index a06c0f1d..a2670cc9 100644 --- a/src/config/llm.rs +++ b/src/config/llm.rs @@ -9,6 +9,13 @@ use crate::llm::registry::{ProviderProtocol, ProviderRegistry}; use crate::llm::session::SessionConfig; use crate::settings::Settings; +/// Sentinel value used as `api_key` when only an OAuth token is present. +/// +/// When we only have an OAuth token the provider factory in `llm/mod.rs` +/// checks for this value and routes to `AnthropicOAuthProvider`, so this +/// placeholder is never sent over the wire. +pub const OAUTH_PLACEHOLDER: &str = "oauth-placeholder"; + /// Prompt cache retention policy for Anthropic. /// /// Controls Anthropic's automatic prompt caching via a top-level @@ -66,6 +73,7 @@ pub struct RegistryProviderConfig { /// Provider identifier (e.g., "groq", "openai", "tinfoil"). pub provider_id: String, /// API key (optional for some providers like Ollama). + /// For Anthropic OAuth, this is set to `OAUTH_PLACEHOLDER`. pub api_key: Option, /// Base URL for the API endpoint. pub base_url: String, @@ -73,6 +81,9 @@ pub struct RegistryProviderConfig { pub model: String, /// Extra HTTP headers injected into every request. pub extra_headers: Vec<(String, String)>, + /// OAuth token for providers that support Bearer auth (e.g. Anthropic via `claude login`). + /// When set, the provider factory routes to the OAuth-specific provider implementation. + pub oauth_token: Option, } /// LLM provider configuration. @@ -366,6 +377,22 @@ impl LlmConfig { Vec::new() }; + // Resolve OAuth token (Anthropic-specific: `claude login` flow). + // Only check for OAuth token when the provider is actually Anthropic. + let oauth_token = if canonical_id == "anthropic" { + optional_env("ANTHROPIC_OAUTH_TOKEN")?.map(SecretString::from) + } else { + None + }; + let api_key = if api_key.is_none() && oauth_token.is_some() { + // OAuth token present but no API key: use a placeholder so the + // config block is populated. The provider factory will route to + // the OAuth provider instead of rig-core's x-api-key client. + Some(SecretString::from(OAUTH_PLACEHOLDER.to_string())) + } else { + api_key + }; + Ok(RegistryProviderConfig { protocol, provider_id: canonical_id.to_string(), @@ -373,6 +400,7 @@ impl LlmConfig { base_url, model, extra_headers, + oauth_token, }) } } @@ -677,8 +705,6 @@ mod tests { #[test] fn backend_alias_normalized_to_canonical_id() { - // When the user sets LLM_BACKEND to an alias (e.g., "open_ai"), - // LlmConfig.backend should resolve to the canonical ID ("openai"). let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); clear_openai_compatible_env(); // SAFETY: Under ENV_MUTEX. @@ -705,8 +731,6 @@ mod tests { #[test] fn unknown_backend_falls_back_to_openai_compatible() { - // An unrecognized LLM_BACKEND should fall back to the openai_compatible - // provider definition instead of erroring. let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); clear_openai_compatible_env(); // SAFETY: Under ENV_MUTEX. @@ -717,7 +741,6 @@ mod tests { let settings = Settings::default(); let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed"); - // Falls back to openai_compatible since "some_custom_provider" is unknown assert_eq!(cfg.backend, "openai_compatible"); let provider = cfg.provider.expect("should have provider config"); assert_eq!(provider.provider_id, "openai_compatible"); @@ -759,7 +782,6 @@ mod tests { #[test] fn base_url_resolution_priority() { - // Env var > settings > registry default let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); clear_openai_compatible_env(); @@ -800,6 +822,119 @@ mod tests { } } + // ── OAuth resolution tests ────────────────────────────────────── + + /// Clear all Anthropic-related env vars. + fn clear_anthropic_env() { + // SAFETY: Only called under ENV_MUTEX in tests. + unsafe { + std::env::remove_var("LLM_BACKEND"); + std::env::remove_var("ANTHROPIC_API_KEY"); + std::env::remove_var("ANTHROPIC_OAUTH_TOKEN"); + std::env::remove_var("ANTHROPIC_MODEL"); + std::env::remove_var("ANTHROPIC_BASE_URL"); + } + } + + #[test] + fn anthropic_oauth_token_sets_placeholder_api_key() { + use secrecy::ExposeSecret; + + let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); + clear_anthropic_env(); + // SAFETY: Under ENV_MUTEX. + unsafe { + std::env::set_var("ANTHROPIC_OAUTH_TOKEN", "sk-ant-oat01-test-token"); + } + + let settings = Settings { + llm_backend: Some("anthropic".to_string()), + ..Default::default() + }; + let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed"); + let provider = cfg.provider.expect("provider config should be present"); + + assert_eq!( + provider + .api_key + .as_ref() + .map(|k| k.expose_secret().to_string()), + Some(OAUTH_PLACEHOLDER.to_string()), + "api_key should be the OAuth placeholder when only OAuth token is set" + ); + assert!( + provider.oauth_token.is_some(), + "oauth_token should be populated" + ); + assert_eq!( + provider.oauth_token.as_ref().unwrap().expose_secret(), + "sk-ant-oat01-test-token" + ); + + clear_anthropic_env(); + } + + #[test] + fn anthropic_api_key_takes_priority_over_oauth() { + use secrecy::ExposeSecret; + + let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); + clear_anthropic_env(); + // SAFETY: Under ENV_MUTEX. + unsafe { + std::env::set_var("ANTHROPIC_API_KEY", "sk-ant-real-key"); + std::env::set_var("ANTHROPIC_OAUTH_TOKEN", "sk-ant-oat01-test-token"); + } + + let settings = Settings { + llm_backend: Some("anthropic".to_string()), + ..Default::default() + }; + let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed"); + let provider = cfg.provider.expect("provider config should be present"); + + assert_eq!( + provider + .api_key + .as_ref() + .map(|k| k.expose_secret().to_string()), + Some("sk-ant-real-key".to_string()), + "real API key should take priority over OAuth placeholder" + ); + assert!( + provider.oauth_token.is_some(), + "oauth_token should still be populated" + ); + + clear_anthropic_env(); + } + + #[test] + fn non_anthropic_provider_has_no_oauth_token() { + let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); + clear_anthropic_env(); + // SAFETY: Under ENV_MUTEX. + unsafe { + std::env::set_var("ANTHROPIC_OAUTH_TOKEN", "sk-ant-oat01-test-token"); + } + + let settings = Settings { + llm_backend: Some("openai".to_string()), + ..Default::default() + }; + let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed"); + let provider = cfg.provider.expect("provider config should be present"); + + assert!( + provider.oauth_token.is_none(), + "non-Anthropic providers should not pick up ANTHROPIC_OAUTH_TOKEN" + ); + + clear_anthropic_env(); + } + + // ── Cache retention tests ─────────────────────────────────────── + #[test] fn cache_retention_from_str_primary_values() { assert_eq!( diff --git a/src/config/mod.rs b/src/config/mod.rs index 74099ed8..8e4b4254 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -13,7 +13,7 @@ mod embeddings; mod heartbeat; pub(crate) mod helpers; mod hygiene; -mod llm; +pub(crate) mod llm; mod routines; mod safety; mod sandbox; @@ -24,7 +24,7 @@ mod tunnel; mod wasm; use std::collections::HashMap; -use std::sync::OnceLock; +use std::sync::{LazyLock, Mutex}; use crate::error::ConfigError; use crate::settings::Settings; @@ -53,7 +53,12 @@ pub use crate::llm::session::SessionConfig; /// Used by `inject_llm_keys_from_secrets()` to make API keys available to /// `optional_env()` without unsafe `set_var` calls. `optional_env()` checks /// real env vars first, then falls back to this overlay. -static INJECTED_VARS: OnceLock> = OnceLock::new(); +/// +/// Uses `Mutex` instead of `OnceLock` so that both +/// `inject_os_credentials()` and `inject_llm_keys_from_secrets()` can merge +/// their data. Whichever runs first initialises the map; the second merges in. +static INJECTED_VARS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); /// Main configuration for the agent. #[derive(Debug, Clone)] @@ -285,6 +290,9 @@ impl Config { /// env-var-first resolution in `LlmConfig::resolve()`. Keys in the overlay /// are read by `optional_env()` before falling back to `std::env::var()`, /// so explicit env vars always win. +/// +/// Also loads tokens from OS credential stores (macOS Keychain, Linux +/// credentials files) which don't require the secrets DB. pub async fn inject_llm_keys_from_secrets( secrets: &dyn crate::secrets::SecretsStore, user_id: &str, @@ -292,7 +300,10 @@ pub async fn inject_llm_keys_from_secrets( // Static mappings for well-known providers. // The registry's setup hints define secret_name -> env_var mappings, // so new providers added to providers.json get injection automatically. - let mut mappings: Vec<(&str, &str)> = vec![("llm_nearai_api_key", "NEARAI_API_KEY")]; + let mut mappings: Vec<(&str, &str)> = vec![ + ("llm_nearai_api_key", "NEARAI_API_KEY"), + ("llm_anthropic_oauth_token", "ANTHROPIC_OAUTH_TOKEN"), + ]; // Dynamically discover secret->env mappings from the provider registry. // Uses selectable() which deduplicates user overrides correctly. @@ -331,5 +342,62 @@ pub async fn inject_llm_keys_from_secrets( } } - let _ = INJECTED_VARS.set(injected); + inject_os_credential_store_tokens(&mut injected); + + merge_injected_vars(injected); +} + +/// Load tokens from OS credential stores (no DB required). +/// +/// Called unconditionally during startup — even when the encrypted secrets DB +/// is unavailable (no master key, no DB connection). This ensures OAuth tokens +/// from `claude login` (macOS Keychain / Linux credentials.json) +/// are available for config resolution. +pub fn inject_os_credentials() { + let mut injected = HashMap::new(); + inject_os_credential_store_tokens(&mut injected); + merge_injected_vars(injected); +} + +/// Merge new entries into the global injected-vars overlay. +/// +/// New keys are inserted; existing keys are overwritten (later callers win, +/// e.g. fresh OS credential store tokens override stale DB copies). +fn merge_injected_vars(new_entries: HashMap) { + if new_entries.is_empty() { + return; + } + match INJECTED_VARS.lock() { + Ok(mut map) => map.extend(new_entries), + Err(poisoned) => poisoned.into_inner().extend(new_entries), + } +} + +/// Inject a single key-value pair into the overlay. +/// +/// Used by the setup wizard to make credentials available to `optional_env()` +/// without calling `unsafe { std::env::set_var }`. +pub fn inject_single_var(key: &str, value: &str) { + match INJECTED_VARS.lock() { + Ok(mut map) => { + map.insert(key.to_string(), value.to_string()); + } + Err(poisoned) => { + poisoned + .into_inner() + .insert(key.to_string(), value.to_string()); + } + } +} + +/// Shared helper: extract tokens from OS credential stores into the overlay map. +fn inject_os_credential_store_tokens(injected: &mut HashMap) { + // Try the OS credential store for a fresh Anthropic OAuth token. + // Tokens from `claude login` expire in 8-12h, so the DB copy may be stale. + // A fresh extraction from macOS Keychain / Linux credentials.json wins + // over the (possibly expired) copy stored in the encrypted secrets DB. + if let Some(fresh) = crate::config::ClaudeCodeConfig::extract_oauth_token() { + injected.insert("ANTHROPIC_OAUTH_TOKEN".to_string(), fresh); + tracing::debug!("Refreshed ANTHROPIC_OAUTH_TOKEN from OS credential store"); + } } diff --git a/src/config/sandbox.rs b/src/config/sandbox.rs index e70a4447..22fe090b 100644 --- a/src/config/sandbox.rs +++ b/src/config/sandbox.rs @@ -233,9 +233,14 @@ impl ClaudeCodeConfig { /// Expected shape: `{"claudeAiOauth": {"accessToken": "sk-ant-oat01-..."}}` fn parse_oauth_access_token(json: &str) -> Option { let creds: serde_json::Value = serde_json::from_str(json).ok()?; - creds["claudeAiOauth"]["accessToken"] - .as_str() - .map(String::from) + let token = creds["claudeAiOauth"]["accessToken"].as_str()?; + // Validate that the token looks like a real OAuth token before using it. + // Claude CLI tokens start with "sk-ant-oat". + if !token.starts_with("sk-ant-oat") { + tracing::debug!("Ignoring credential store token with unexpected prefix"); + return None; + } + Some(token.to_string()) } #[cfg(test)] @@ -401,14 +406,14 @@ mod tests { fn parse_oauth_token_nested_extra_fields() { let json = r#"{ "claudeAiOauth": { - "accessToken": "sk-ant-real-token", + "accessToken": "sk-ant-oat01-real-token", "refreshToken": "rt-abc", "expiresAt": 1700000000 } }"#; assert_eq!( parse_oauth_access_token(json), - Some("sk-ant-real-token".to_string()) + Some("sk-ant-oat01-real-token".to_string()) ); } @@ -418,6 +423,12 @@ mod tests { assert_eq!(parse_oauth_access_token(json), None); } + #[test] + fn parse_oauth_token_rejects_invalid_prefix() { + let json = r#"{"claudeAiOauth": {"accessToken": "not-an-oauth-token"}}"#; + assert_eq!(parse_oauth_access_token(json), None); + } + // ── default_claude_code_allowed_tools ─────────────────────────── #[test] diff --git a/src/llm/anthropic_oauth.rs b/src/llm/anthropic_oauth.rs new file mode 100644 index 00000000..c79c86f8 --- /dev/null +++ b/src/llm/anthropic_oauth.rs @@ -0,0 +1,641 @@ +//! Anthropic OAuth provider (direct HTTP, `Authorization: Bearer`). +//! +//! This provider exists because the `rig-core` Anthropic client hardcodes the +//! `x-api-key` header, which is rejected by Anthropic's OAuth tokens from +//! `claude login`. OAuth tokens require `Authorization: Bearer ` instead. +//! +//! Pattern follows `nearai_chat.rs`: direct HTTP calls via `reqwest::Client`. + +use async_trait::async_trait; +use reqwest::Client; +use rust_decimal::Decimal; +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; + +use crate::config::RegistryProviderConfig; +use crate::error::LlmError; +use crate::llm::costs; +use crate::llm::provider::{ + ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall, + ToolCompletionRequest, ToolCompletionResponse, +}; + +const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages"; +/// OAuth beta requires 2023-06-01; the 2024-10-22 version is not valid with the beta flag. +const ANTHROPIC_API_VERSION: &str = "2023-06-01"; +/// Required beta flag to enable OAuth Bearer auth on api.anthropic.com. +/// Without this header, the API returns 401 "OAuth authentication is currently not supported." +const ANTHROPIC_OAUTH_BETA: &str = "oauth-2025-04-20"; +const DEFAULT_MAX_TOKENS: u32 = 8192; + +/// Anthropic provider using OAuth Bearer authentication. +pub struct AnthropicOAuthProvider { + client: Client, + token: SecretString, + model: String, + base_url: Option, + active_model: std::sync::RwLock, +} + +impl AnthropicOAuthProvider { + pub fn new(config: &RegistryProviderConfig) -> Result { + let token = config + .oauth_token + .clone() + .ok_or_else(|| LlmError::AuthFailed { + provider: "anthropic_oauth".to_string(), + })?; + + let client = Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|e| LlmError::RequestFailed { + provider: "anthropic_oauth".to_string(), + reason: format!("Failed to build HTTP client: {}", e), + })?; + + let active_model = std::sync::RwLock::new(config.model.clone()); + let base_url = if config.base_url.is_empty() { + None + } else { + Some(config.base_url.clone()) + }; + + Ok(Self { + client, + token, + model: config.model.clone(), + base_url, + active_model, + }) + } + + fn api_url(&self) -> String { + if let Some(ref base) = self.base_url { + let base = base.trim_end_matches('/'); + format!("{}/v1/messages", base) + } else { + ANTHROPIC_API_URL.to_string() + } + } + + async fn send_request Deserialize<'de>>( + &self, + body: &AnthropicRequest, + ) -> Result { + let url = self.api_url(); + + tracing::debug!("Sending request to Anthropic OAuth: {}", url); + + let response = self + .client + .post(&url) + .bearer_auth(self.token.expose_secret()) + .header("anthropic-version", ANTHROPIC_API_VERSION) + .header("anthropic-beta", ANTHROPIC_OAUTH_BETA) + .header("Content-Type", "application/json") + .json(body) + .send() + .await + .map_err(|e| LlmError::RequestFailed { + provider: "anthropic_oauth".to_string(), + reason: e.to_string(), + })?; + + let status = response.status(); + + if !status.is_success() { + // Parse Retry-After header before consuming the body. + let retry_after = response + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .map(std::time::Duration::from_secs); + + let response_text = response + .text() + .await + .unwrap_or_else(|e| format!("(failed to read error body: {e})")); + + if status.as_u16() == 401 { + // OAuth tokens from `claude login` expire in ~8-12h. Attempt + // to re-extract a fresh token from the OS credential store + // (macOS Keychain / Linux credentials file) before giving up. + if let Some(fresh) = crate::config::ClaudeCodeConfig::extract_oauth_token() { + let fresh_token = SecretString::from(fresh); + // Retry once with the refreshed token + let retry = self + .client + .post(&url) + .bearer_auth(fresh_token.expose_secret()) + .header("anthropic-version", ANTHROPIC_API_VERSION) + .header("anthropic-beta", ANTHROPIC_OAUTH_BETA) + .header("Content-Type", "application/json") + .json(body) + .send() + .await + .map_err(|e| LlmError::RequestFailed { + provider: "anthropic_oauth".to_string(), + reason: e.to_string(), + })?; + if retry.status().is_success() { + let text = retry.text().await.map_err(|e| LlmError::RequestFailed { + provider: "anthropic_oauth".to_string(), + reason: format!("Failed to read response body: {}", e), + })?; + return serde_json::from_str(&text).map_err(|e| { + let truncated = crate::agent::truncate_for_preview(&text, 512); + LlmError::InvalidResponse { + provider: "anthropic_oauth".to_string(), + reason: format!("JSON parse error: {}. Raw: {}", e, truncated), + } + }); + } + tracing::warn!( + "Anthropic OAuth 401 retry with refreshed token also failed ({})", + retry.status() + ); + } + return Err(LlmError::AuthFailed { + provider: "anthropic_oauth".to_string(), + }); + } + if status.as_u16() == 429 { + return Err(LlmError::RateLimited { + provider: "anthropic_oauth".to_string(), + retry_after, + }); + } + let truncated = crate::agent::truncate_for_preview(&response_text, 512); + return Err(LlmError::RequestFailed { + provider: "anthropic_oauth".to_string(), + reason: format!("HTTP {}: {}", status, truncated), + }); + } + + let response_text = response.text().await.map_err(|e| LlmError::RequestFailed { + provider: "anthropic_oauth".to_string(), + reason: format!("Failed to read response body: {}", e), + })?; + + tracing::debug!( + "Anthropic OAuth response: status={}, bytes={}", + status, + response_text.len() + ); + + serde_json::from_str(&response_text).map_err(|e| { + let truncated = crate::agent::truncate_for_preview(&response_text, 512); + LlmError::InvalidResponse { + provider: "anthropic_oauth".to_string(), + reason: format!("JSON parse error: {}. Raw: {}", e, truncated), + } + }) + } +} + +#[async_trait] +impl LlmProvider for AnthropicOAuthProvider { + async fn complete(&self, req: CompletionRequest) -> Result { + let model = req.model.unwrap_or_else(|| self.active_model_name()); + let (system, messages) = convert_messages(req.messages); + + let request = AnthropicRequest { + model, + messages, + system, + max_tokens: req.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS), + temperature: req.temperature, + tools: None, + tool_choice: None, + }; + + let response: AnthropicResponse = self.send_request(&request).await?; + let (content, _tool_calls) = extract_response_content(&response); + + let finish_reason = match response.stop_reason.as_deref() { + Some("end_turn") | Some("stop") => FinishReason::Stop, + Some("max_tokens") => FinishReason::Length, + Some("tool_use") => FinishReason::ToolUse, + _ => FinishReason::Unknown, + }; + + Ok(CompletionResponse { + content: content.unwrap_or_default(), + finish_reason, + input_tokens: response.usage.input_tokens, + output_tokens: response.usage.output_tokens, + cache_creation_input_tokens: response.usage.cache_creation_input_tokens, + cache_read_input_tokens: response.usage.cache_read_input_tokens, + }) + } + + async fn complete_with_tools( + &self, + req: ToolCompletionRequest, + ) -> Result { + let model = req.model.unwrap_or_else(|| self.active_model_name()); + let (system, messages) = convert_messages(req.messages); + + let tools: Vec = req + .tools + .into_iter() + .map(|t| AnthropicTool { + name: t.name, + description: t.description, + input_schema: t.parameters, + }) + .collect(); + + // Map tool_choice from OpenAI format to Anthropic format + let tool_choice = req.tool_choice.map(|tc| match tc.as_str() { + "auto" => AnthropicToolChoice { + choice_type: "auto".to_string(), + name: None, + }, + "required" => AnthropicToolChoice { + choice_type: "any".to_string(), + name: None, + }, + "none" => AnthropicToolChoice { + choice_type: "none".to_string(), + name: None, + }, + specific => AnthropicToolChoice { + choice_type: "tool".to_string(), + name: Some(specific.to_string()), + }, + }); + + let request = AnthropicRequest { + model, + messages, + system, + max_tokens: req.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS), + temperature: req.temperature, + tools: if tools.is_empty() { None } else { Some(tools) }, + tool_choice, + }; + + let response: AnthropicResponse = self.send_request(&request).await?; + let (content, tool_calls) = extract_response_content(&response); + + let finish_reason = match response.stop_reason.as_deref() { + Some("end_turn") | Some("stop") => FinishReason::Stop, + Some("max_tokens") => FinishReason::Length, + Some("tool_use") => FinishReason::ToolUse, + _ => { + if !tool_calls.is_empty() { + FinishReason::ToolUse + } else { + FinishReason::Unknown + } + } + }; + + Ok(ToolCompletionResponse { + content, + tool_calls, + finish_reason, + input_tokens: response.usage.input_tokens, + output_tokens: response.usage.output_tokens, + cache_creation_input_tokens: response.usage.cache_creation_input_tokens, + cache_read_input_tokens: response.usage.cache_read_input_tokens, + }) + } + + fn model_name(&self) -> &str { + &self.model + } + + fn cost_per_token(&self) -> (Decimal, Decimal) { + let model = self.active_model_name(); + costs::model_cost(&model).unwrap_or_else(costs::default_cost) + } + + fn active_model_name(&self) -> String { + match self.active_model.read() { + Ok(guard) => guard.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + } + } + + fn set_model(&self, model: &str) -> Result<(), LlmError> { + match self.active_model.write() { + Ok(mut guard) => { + *guard = model.to_string(); + } + Err(poisoned) => { + *poisoned.into_inner() = model.to_string(); + } + } + Ok(()) + } +} + +// --- Anthropic Messages API types --- + +#[derive(Debug, Serialize)] +struct AnthropicRequest { + model: String, + messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + system: Option, + max_tokens: u32, + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + tool_choice: Option, +} + +#[derive(Debug, Serialize)] +struct AnthropicMessage { + role: String, + content: AnthropicContent, +} + +/// Anthropic content can be a simple string or a list of content blocks. +#[derive(Debug, Serialize)] +#[serde(untagged)] +enum AnthropicContent { + Text(String), + Blocks(Vec), +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type")] +enum AnthropicContentBlock { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "tool_use")] + ToolUse { + id: String, + name: String, + input: serde_json::Value, + }, + #[serde(rename = "tool_result")] + ToolResult { + tool_use_id: String, + content: String, + }, +} + +#[derive(Debug, Serialize)] +struct AnthropicTool { + name: String, + description: String, + input_schema: serde_json::Value, +} + +#[derive(Debug, Serialize)] +struct AnthropicToolChoice { + #[serde(rename = "type")] + choice_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, +} + +#[derive(Debug, Deserialize)] +struct AnthropicResponse { + content: Vec, + #[serde(default)] + stop_reason: Option, + usage: AnthropicUsage, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +enum AnthropicResponseBlock { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "tool_use")] + ToolUse { + id: String, + name: String, + input: serde_json::Value, + }, +} + +#[derive(Debug, Deserialize)] +struct AnthropicUsage { + #[serde(default)] + input_tokens: u32, + #[serde(default)] + output_tokens: u32, + #[serde(default)] + cache_creation_input_tokens: u32, + #[serde(default)] + cache_read_input_tokens: u32, +} + +/// Convert ChatMessage list to Anthropic format. +/// +/// Extracts system messages to the top-level `system` parameter (Anthropic +/// doesn't allow system messages in the `messages` array). Tool-call/tool-result +/// pairs are converted to content blocks. +fn convert_messages(messages: Vec) -> (Option, Vec) { + let mut system_parts: Vec = Vec::new(); + let mut anthropic_msgs: Vec = Vec::new(); + + for msg in messages { + match msg.role { + Role::System => { + if !msg.content.is_empty() { + system_parts.push(msg.content); + } + } + Role::User => { + anthropic_msgs.push(AnthropicMessage { + role: "user".to_string(), + content: AnthropicContent::Text(msg.content), + }); + } + Role::Assistant => { + if let Some(tool_calls) = msg.tool_calls { + // Assistant message with tool calls → content blocks + let mut blocks: Vec = Vec::new(); + if !msg.content.is_empty() { + blocks.push(AnthropicContentBlock::Text { text: msg.content }); + } + for tc in tool_calls { + blocks.push(AnthropicContentBlock::ToolUse { + id: tc.id, + name: tc.name, + input: tc.arguments, + }); + } + anthropic_msgs.push(AnthropicMessage { + role: "assistant".to_string(), + content: AnthropicContent::Blocks(blocks), + }); + } else { + anthropic_msgs.push(AnthropicMessage { + role: "assistant".to_string(), + content: AnthropicContent::Text(msg.content), + }); + } + } + Role::Tool => { + let Some(tool_call_id) = msg.tool_call_id else { + tracing::warn!("Skipping Tool message without tool_call_id"); + continue; + }; + // Tool results go into a user message with tool_result blocks + let block = AnthropicContentBlock::ToolResult { + tool_use_id: tool_call_id, + content: msg.content, + }; + // If the last message is already a user message with blocks, + // append to it (Anthropic requires consecutive tool results + // in one user message). + if let Some(last) = anthropic_msgs.last_mut() + && last.role == "user" + && let AnthropicContent::Blocks(ref mut blocks) = last.content + { + blocks.push(block); + continue; + } + anthropic_msgs.push(AnthropicMessage { + role: "user".to_string(), + content: AnthropicContent::Blocks(vec![block]), + }); + } + } + } + + let system = if system_parts.is_empty() { + None + } else { + Some(system_parts.join("\n\n")) + }; + + (system, anthropic_msgs) +} + +/// Extract text content and tool calls from an Anthropic response. +fn extract_response_content(response: &AnthropicResponse) -> (Option, Vec) { + let mut text_parts: Vec = Vec::new(); + let mut tool_calls: Vec = Vec::new(); + + for block in &response.content { + match block { + AnthropicResponseBlock::Text { text } => { + text_parts.push(text.clone()); + } + AnthropicResponseBlock::ToolUse { id, name, input } => { + tool_calls.push(ToolCall { + id: id.clone(), + name: name.clone(), + arguments: input.clone(), + }); + } + } + } + + let content = if text_parts.is_empty() { + None + } else { + Some(text_parts.join("")) + }; + + (content, tool_calls) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_convert_messages_extracts_system() { + let messages = vec![ + ChatMessage::system("You are helpful."), + ChatMessage::user("Hello"), + ]; + let (system, msgs) = convert_messages(messages); + assert_eq!(system, Some("You are helpful.".to_string())); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].role, "user"); + } + + #[test] + fn test_convert_messages_multiple_systems() { + let messages = vec![ + ChatMessage::system("System 1"), + ChatMessage::system("System 2"), + ChatMessage::user("Hello"), + ]; + let (system, msgs) = convert_messages(messages); + assert_eq!(system, Some("System 1\n\nSystem 2".to_string())); + assert_eq!(msgs.len(), 1); + } + + #[test] + fn test_convert_messages_tool_calls() { + let tool_calls = vec![ToolCall { + id: "call_1".to_string(), + name: "search".to_string(), + arguments: serde_json::json!({"q": "test"}), + }]; + let messages = vec![ + ChatMessage::user("Search for test"), + ChatMessage::assistant_with_tool_calls(Some("Let me search.".to_string()), tool_calls), + ChatMessage::tool_result("call_1", "search", "found it"), + ]; + let (system, msgs) = convert_messages(messages); + assert!(system.is_none()); + assert_eq!(msgs.len(), 3); + assert_eq!(msgs[0].role, "user"); + assert_eq!(msgs[1].role, "assistant"); + // Tool result should be a user message + assert_eq!(msgs[2].role, "user"); + } + + #[test] + fn test_extract_response_text_only() { + let response = AnthropicResponse { + content: vec![AnthropicResponseBlock::Text { + text: "Hello!".to_string(), + }], + stop_reason: Some("end_turn".to_string()), + usage: AnthropicUsage { + input_tokens: 10, + output_tokens: 5, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }; + let (content, tool_calls) = extract_response_content(&response); + assert_eq!(content, Some("Hello!".to_string())); + assert!(tool_calls.is_empty()); + } + + #[test] + fn test_extract_response_with_tool_use() { + let response = AnthropicResponse { + content: vec![ + AnthropicResponseBlock::Text { + text: "Let me search.".to_string(), + }, + AnthropicResponseBlock::ToolUse { + id: "call_1".to_string(), + name: "search".to_string(), + input: serde_json::json!({"q": "test"}), + }, + ], + stop_reason: Some("tool_use".to_string()), + usage: AnthropicUsage { + input_tokens: 20, + output_tokens: 15, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }; + let (content, tool_calls) = extract_response_content(&response); + assert_eq!(content, Some("Let me search.".to_string())); + assert_eq!(tool_calls.len(), 1); + assert_eq!(tool_calls[0].name, "search"); + } +} diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 136ea240..81a33940 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -7,6 +7,7 @@ //! - **Ollama**: Local model inference //! - **OpenAI-compatible**: Any endpoint that speaks the OpenAI API +mod anthropic_oauth; pub mod circuit_breaker; pub mod costs; pub mod failover; @@ -178,6 +179,24 @@ fn create_openai_compat_from_registry( fn create_anthropic_from_registry( config: &RegistryProviderConfig, ) -> Result, LlmError> { + // Route to OAuth provider when an OAuth token is present and no real API + // key was provided. When both are set, the API key takes priority (standard + // x-api-key auth via rig-core). + let api_key_is_placeholder = config + .api_key + .as_ref() + .is_some_and(|k| k.expose_secret() == crate::config::llm::OAUTH_PLACEHOLDER); + if config.oauth_token.is_some() && (config.api_key.is_none() || api_key_is_placeholder) { + tracing::info!( + provider = %config.provider_id, + model = %config.model, + base_url = if config.base_url.is_empty() { "default" } else { &config.base_url }, + "Using Anthropic OAuth API" + ); + let provider = anthropic_oauth::AnthropicOAuthProvider::new(config)?; + return Ok(Arc::new(provider)); + } + use crate::config::CacheRetention; use crate::config::helpers::optional_env; use rig::providers::anthropic; diff --git a/src/main.rs b/src/main.rs index 3a6f4ff4..814d26a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -158,7 +158,10 @@ async fn async_main() -> anyhow::Result<()> { wizard.run().await?; } - // Load initial config from env + disk + optional TOML (before DB is available) + // Load initial config from env + disk + optional TOML (before DB is available). + // Credentials may be missing at this point — that's fine. LlmConfig::resolve() + // defers gracefully, and AppBuilder::build_all() re-resolves after loading + // secrets from the encrypted DB. let toml_path = cli.config.as_deref(); let config = match Config::from_env_with_toml(toml_path).await { Ok(c) => c, diff --git a/src/settings.rs b/src/settings.rs index fb262523..8e62feaf 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -498,6 +498,10 @@ pub struct SandboxSettings { /// Additional domains to allow through the network proxy. #[serde(default)] pub extra_allowed_domains: Vec, + + /// Whether Claude Code sandbox mode is enabled. + #[serde(default)] + pub claude_code_enabled: bool, } fn default_sandbox_policy() -> String { @@ -531,6 +535,7 @@ impl Default for SandboxSettings { image: default_sandbox_image(), auto_pull_image: true, extra_allowed_domains: Vec::new(), + claude_code_enabled: false, } } } diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 319db296..20f138bf 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -22,6 +22,7 @@ use crate::bootstrap::ironclaw_base_dir; use crate::channels::wasm::{ ChannelCapabilitiesFile, available_channel_names, install_bundled_channel, }; +use crate::config::llm::OAUTH_PLACEHOLDER; use crate::llm::{SessionConfig, SessionManager}; use crate::secrets::{SecretsCrypto, SecretsStore}; use crate::settings::{KeySource, Settings}; @@ -886,6 +887,11 @@ impl SetupWizard { return Ok(()); }; + // Anthropic has a custom flow: API key or OAuth token from `claude login`. + if provider_id == "anthropic" { + return self.setup_anthropic().await; + } + match setup { crate::llm::registry::SetupHint::ApiKey { secret_name, @@ -991,6 +997,112 @@ impl SetupWizard { Ok(()) } + /// Anthropic provider setup: API key or OAuth token from `claude login`. + async fn setup_anthropic(&mut self) -> Result<(), SetupError> { + let options = &["Direct API Key", "OAuth Token (from `claude login`)"]; + let choice = select_one("How do you want to authenticate with Anthropic?", options) + .map_err(SetupError::Io)?; + + if choice == 0 { + // Standard API key flow + self.setup_api_key_provider( + "anthropic", + "ANTHROPIC_API_KEY", + "llm_anthropic_api_key", + "Anthropic API key", + "https://console.anthropic.com/settings/keys", + None, + ) + .await + } else { + // OAuth token flow + self.setup_anthropic_oauth().await + } + } + + /// Anthropic OAuth setup: extract token from `claude login` credentials. + async fn setup_anthropic_oauth(&mut self) -> Result<(), SetupError> { + self.settings.llm_backend = Some("anthropic".to_string()); + if self.settings.selected_model.is_some() { + self.settings.selected_model = None; + } + + // Try to extract existing OAuth token from Claude Code credentials + if let Some(token) = crate::config::ClaudeCodeConfig::extract_oauth_token() { + print_info(&format!("Found OAuth token: {}", mask_api_key(&token))); + if confirm("Use this token?", true).map_err(SetupError::Io)? { + return self.save_anthropic_oauth_token(&token).await; + } + } else { + print_info("No OAuth token found from `claude login`."); + print_info("Run `claude login` in a terminal to authenticate, then retry."); + println!(); + + if confirm("Retry after running `claude login`?", true).map_err(SetupError::Io)? { + // Block until the user has run `claude login` in another terminal + input("Press Enter after running `claude login` in another terminal...") + .map_err(SetupError::Io)?; + if let Some(token) = crate::config::ClaudeCodeConfig::extract_oauth_token() { + print_info(&format!("Found OAuth token: {}", mask_api_key(&token))); + return self.save_anthropic_oauth_token(&token).await; + } + print_error("Still no OAuth token found."); + } + } + + // Fallback: let user paste the token manually, or switch to API key + print_info("You can paste your OAuth token directly (starts with sk-ant-oat01-)."); + print_info("Or press Enter with no input to switch to the API key flow."); + let token = secret_input("Anthropic OAuth token").map_err(SetupError::Io)?; + let token_str = token.expose_secret(); + if token_str.is_empty() { + print_info("Switching to API key flow..."); + return self + .setup_api_key_provider( + "anthropic", + "ANTHROPIC_API_KEY", + "llm_anthropic_api_key", + "Anthropic API key", + "https://console.anthropic.com/settings/keys", + None, + ) + .await; + } + self.save_anthropic_oauth_token(token_str).await + } + + /// Save an Anthropic OAuth token to secrets and set env for immediate use. + async fn save_anthropic_oauth_token(&mut self, token: &str) -> Result<(), SetupError> { + // Validate token format to catch accidentally pasted API keys + if !token.starts_with("sk-ant-oat") { + print_error("Token doesn't look like an OAuth token (expected prefix: sk-ant-oat)."); + print_info("If you have an API key instead, use the 'Direct API Key' option."); + return Err(SetupError::Config("Invalid OAuth token format".to_string())); + } + + // Store in secrets if available + if let Ok(ctx) = self.init_secrets_context().await { + let key = SecretString::from(token.to_string()); + ctx.save_secret("llm_anthropic_oauth_token", &key) + .await + .map_err(|e| SetupError::Config(format!("Failed to save OAuth token: {e}")))?; + print_success("OAuth token encrypted and saved"); + } else { + print_info("Secrets not available. Set ANTHROPIC_OAUTH_TOKEN in your environment."); + } + + // Make the token visible to `optional_env()` for subsequent config + // resolution (model selection step). Uses the thread-safe overlay + // instead of `std::env::set_var` to avoid UB on multi-threaded runtimes. + crate::config::inject_single_var("ANTHROPIC_OAUTH_TOKEN", token); + + // Cache for model fetching + self.llm_api_key = Some(SecretString::from(token.to_string())); + + print_success("Anthropic OAuth configured"); + Ok(()) + } + /// Shared setup flow for API-key-based providers. async fn setup_api_key_provider( &mut self, @@ -1052,6 +1164,11 @@ impl SetupWizard { )); } + // Make key visible to `optional_env()` for subsequent config resolution. + // Uses the thread-safe overlay instead of `std::env::set_var` to avoid + // UB on multi-threaded runtimes. + crate::config::inject_single_var(env_var, key_str); + // Cache key in memory for model fetching later in the wizard self.llm_api_key = Some(SecretString::from(key_str.to_string())); @@ -1988,6 +2105,67 @@ impl SetupWizard { } } + // Claude Code sandbox sub-step (only if Docker sandbox is enabled) + if self.settings.sandbox.enabled { + self.step_claude_code_sandbox().await?; + } + + Ok(()) + } + + /// Claude Code sandbox sub-step: enable Claude CLI inside Docker containers. + async fn step_claude_code_sandbox(&mut self) -> Result<(), SetupError> { + println!(); + print_info("Claude Code mode lets the agent delegate complex tasks to Claude CLI"); + print_info("running inside sandboxed Docker containers."); + println!(); + + if !confirm("Enable Claude Code sandbox mode?", false).map_err(SetupError::Io)? { + self.settings.sandbox.claude_code_enabled = false; + return Ok(()); + } + + // Check for Anthropic credentials (API key or OAuth token). + // Uses `optional_env()` which reads both real env vars and the + // injected overlay (secrets DB, wizard-set values). + let has_credentials = || { + let has_api_key = crate::config::helpers::optional_env("ANTHROPIC_API_KEY") + .ok() + .flatten() + .is_some_and(|v| !v.is_empty() && v != OAUTH_PLACEHOLDER); + let has_oauth = crate::config::ClaudeCodeConfig::extract_oauth_token().is_some() + || crate::config::helpers::optional_env("ANTHROPIC_OAUTH_TOKEN") + .ok() + .flatten() + .is_some_and(|v| !v.is_empty()); + has_api_key || has_oauth + }; + + if has_credentials() { + self.settings.sandbox.claude_code_enabled = true; + print_success("Claude Code sandbox enabled"); + } else { + print_error("No Anthropic credentials found."); + print_info( + "Claude Code needs ANTHROPIC_API_KEY or an OAuth token from `claude login`.", + ); + println!(); + + if confirm("Retry after setting up credentials?", false).map_err(SetupError::Io)? { + if has_credentials() { + self.settings.sandbox.claude_code_enabled = true; + print_success("Claude Code sandbox enabled"); + } else { + self.settings.sandbox.claude_code_enabled = false; + print_info("No credentials found. Claude Code disabled for now."); + print_info("Set ANTHROPIC_API_KEY or run `claude login` and enable later."); + } + } else { + self.settings.sandbox.claude_code_enabled = false; + print_info("Claude Code disabled. Enable with CLAUDE_CODE_ENABLED=true later."); + } + } + Ok(()) } @@ -2081,6 +2259,12 @@ impl SetupWizard { /// /// These are the chicken-and-egg settings needed before the database is /// connected (DATABASE_BACKEND, DATABASE_URL, LLM_BACKEND, etc.). + /// + /// **Credentials are NOT written here.** API keys and OAuth tokens live + /// only in the encrypted secrets DB. `LlmConfig::resolve()` defers + /// gracefully when credentials are missing during early startup, and the + /// re-resolution in `AppBuilder::build_all()` fills them in after + /// `inject_llm_keys_from_secrets()` loads from encrypted storage. fn write_bootstrap_env(&self) -> Result<(), SetupError> { let registry = crate::llm::ProviderRegistry::load(); let mut env_vars: Vec<(String, String)> = Vec::new(); @@ -2146,6 +2330,11 @@ impl SetupWizard { env_vars.push(("ONBOARD_COMPLETED".to_string(), "true".to_string())); } + // Claude Code sandbox mode + if self.settings.sandbox.claude_code_enabled { + env_vars.push(("CLAUDE_CODE_ENABLED".to_string(), "true".to_string())); + } + // Signal channel env vars (chicken-and-egg: config resolves before DB). if let Some(ref url) = self.settings.channels.signal_http_url { env_vars.push(("SIGNAL_HTTP_URL".to_string(), url.clone())); @@ -2513,22 +2702,39 @@ async fn fetch_anthropic_models(cached_key: Option<&str>) -> Vec<(String, String let api_key = cached_key .map(String::from) .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok()) - .filter(|k| !k.is_empty()); + .filter(|k| !k.is_empty() && k != crate::config::llm::OAUTH_PLACEHOLDER); - let api_key = match api_key { - Some(k) => k, - None => return static_defaults, + // Fall back to OAuth token if no API key + let oauth_token = if api_key.is_none() { + crate::config::helpers::optional_env("ANTHROPIC_OAUTH_TOKEN") + .ok() + .flatten() + .filter(|t| !t.is_empty()) + } else { + None + }; + + let (key_or_token, is_oauth) = match (api_key, oauth_token) { + (Some(k), _) => (k, false), + (None, Some(t)) => (t, true), + (None, None) => return static_defaults, }; let client = reqwest::Client::new(); - let resp = match client + let mut request = client .get("https://api.anthropic.com/v1/models") - .header("x-api-key", &api_key) .header("anthropic-version", "2023-06-01") - .timeout(std::time::Duration::from_secs(5)) - .send() - .await - { + .timeout(std::time::Duration::from_secs(5)); + + if is_oauth { + request = request + .bearer_auth(&key_or_token) + .header("anthropic-beta", "oauth-2025-04-20"); + } else { + request = request.header("x-api-key", &key_or_token); + } + + let resp = match request.send().await { Ok(r) if r.status().is_success() => r, _ => return static_defaults, };