feat(setup): Anthropic OAuth onboarding with setup-token support (#384)

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* style: fix formatting for CI (cargo fmt)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* fix: address PR #384 review comments

Blocker:
- Replace OnceLock<HashMap> with LazyLock<Mutex<HashMap>> 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 <[email protected]>

* style: cargo fmt

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: [email protected] <[email protected]>
This commit is contained in:
firat.sertgoz
2026-03-07 20:59:17 +00:00
committed by GitHub
co-authored by Claude Opus 4.6 [email protected] <[email protected]>
parent 12ba79ffc3
commit 11c5e25422
11 changed files with 1162 additions and 29 deletions
+12
View File
@@ -6,6 +6,18 @@ DATABASE_POOL_SIZE=10
# LLM_BACKEND=nearai # default # LLM_BACKEND=nearai # default
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil # 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) === # === NEAR AI (Chat Completions API) ===
# Two auth modes: # Two auth modes:
# 1. Session token (default): Uses browser OAuth (GitHub/Google) on first run. # 1. Session token (default): Uses browser OAuth (GitHub/Google) on first run.
+28
View File
@@ -244,11 +244,28 @@ impl AppBuilder {
let master_key = match self.config.secrets.master_key() { let master_key = match self.config.secrets.master_key() {
Some(k) => k, Some(k) => k,
None => { 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 // Consume unused handles
#[cfg(feature = "libsql")] #[cfg(feature = "libsql")]
{ {
self.libsql_db.take(); 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(()); return Ok(());
} }
}; };
@@ -665,6 +682,17 @@ impl AppBuilder {
self.init_database().await?; self.init_database().await?;
self.init_secrets().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() { let (llm, cheap_llm, recording_handle) = if let Some(llm) = self.llm_override.take() {
(llm, None, None) (llm, None, None)
} else { } else {
+7 -2
View File
@@ -25,8 +25,13 @@ pub(crate) fn optional_env(key: &str) -> Result<Option<String>, ConfigError> {
} }
// Fall back to thread-safe overlay (secrets injected from DB) // Fall back to thread-safe overlay (secrets injected from DB)
if let Some(val) = INJECTED_VARS.get().and_then(|map| map.get(key)) { if let Some(val) = INJECTED_VARS
return Ok(Some(val.clone())); .lock()
.unwrap_or_else(|p| p.into_inner())
.get(key)
.cloned()
{
return Ok(Some(val));
} }
Ok(None) Ok(None)
+141 -6
View File
@@ -9,6 +9,13 @@ use crate::llm::registry::{ProviderProtocol, ProviderRegistry};
use crate::llm::session::SessionConfig; use crate::llm::session::SessionConfig;
use crate::settings::Settings; 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. /// Prompt cache retention policy for Anthropic.
/// ///
/// Controls Anthropic's automatic prompt caching via a top-level /// Controls Anthropic's automatic prompt caching via a top-level
@@ -66,6 +73,7 @@ pub struct RegistryProviderConfig {
/// Provider identifier (e.g., "groq", "openai", "tinfoil"). /// Provider identifier (e.g., "groq", "openai", "tinfoil").
pub provider_id: String, pub provider_id: String,
/// API key (optional for some providers like Ollama). /// API key (optional for some providers like Ollama).
/// For Anthropic OAuth, this is set to `OAUTH_PLACEHOLDER`.
pub api_key: Option<SecretString>, pub api_key: Option<SecretString>,
/// Base URL for the API endpoint. /// Base URL for the API endpoint.
pub base_url: String, pub base_url: String,
@@ -73,6 +81,9 @@ pub struct RegistryProviderConfig {
pub model: String, pub model: String,
/// Extra HTTP headers injected into every request. /// Extra HTTP headers injected into every request.
pub extra_headers: Vec<(String, String)>, 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<SecretString>,
} }
/// LLM provider configuration. /// LLM provider configuration.
@@ -366,6 +377,22 @@ impl LlmConfig {
Vec::new() 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 { Ok(RegistryProviderConfig {
protocol, protocol,
provider_id: canonical_id.to_string(), provider_id: canonical_id.to_string(),
@@ -373,6 +400,7 @@ impl LlmConfig {
base_url, base_url,
model, model,
extra_headers, extra_headers,
oauth_token,
}) })
} }
} }
@@ -677,8 +705,6 @@ mod tests {
#[test] #[test]
fn backend_alias_normalized_to_canonical_id() { 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"); let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_openai_compatible_env(); clear_openai_compatible_env();
// SAFETY: Under ENV_MUTEX. // SAFETY: Under ENV_MUTEX.
@@ -705,8 +731,6 @@ mod tests {
#[test] #[test]
fn unknown_backend_falls_back_to_openai_compatible() { 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"); let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_openai_compatible_env(); clear_openai_compatible_env();
// SAFETY: Under ENV_MUTEX. // SAFETY: Under ENV_MUTEX.
@@ -717,7 +741,6 @@ mod tests {
let settings = Settings::default(); let settings = Settings::default();
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed"); 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"); assert_eq!(cfg.backend, "openai_compatible");
let provider = cfg.provider.expect("should have provider config"); let provider = cfg.provider.expect("should have provider config");
assert_eq!(provider.provider_id, "openai_compatible"); assert_eq!(provider.provider_id, "openai_compatible");
@@ -759,7 +782,6 @@ mod tests {
#[test] #[test]
fn base_url_resolution_priority() { fn base_url_resolution_priority() {
// Env var > settings > registry default
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
clear_openai_compatible_env(); 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] #[test]
fn cache_retention_from_str_primary_values() { fn cache_retention_from_str_primary_values() {
assert_eq!( assert_eq!(
+73 -5
View File
@@ -13,7 +13,7 @@ mod embeddings;
mod heartbeat; mod heartbeat;
pub(crate) mod helpers; pub(crate) mod helpers;
mod hygiene; mod hygiene;
mod llm; pub(crate) mod llm;
mod routines; mod routines;
mod safety; mod safety;
mod sandbox; mod sandbox;
@@ -24,7 +24,7 @@ mod tunnel;
mod wasm; mod wasm;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::OnceLock; use std::sync::{LazyLock, Mutex};
use crate::error::ConfigError; use crate::error::ConfigError;
use crate::settings::Settings; 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 /// Used by `inject_llm_keys_from_secrets()` to make API keys available to
/// `optional_env()` without unsafe `set_var` calls. `optional_env()` checks /// `optional_env()` without unsafe `set_var` calls. `optional_env()` checks
/// real env vars first, then falls back to this overlay. /// real env vars first, then falls back to this overlay.
static INJECTED_VARS: OnceLock<HashMap<String, String>> = OnceLock::new(); ///
/// Uses `Mutex<HashMap>` 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<Mutex<HashMap<String, String>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Main configuration for the agent. /// Main configuration for the agent.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -285,6 +290,9 @@ impl Config {
/// env-var-first resolution in `LlmConfig::resolve()`. Keys in the overlay /// env-var-first resolution in `LlmConfig::resolve()`. Keys in the overlay
/// are read by `optional_env()` before falling back to `std::env::var()`, /// are read by `optional_env()` before falling back to `std::env::var()`,
/// so explicit env vars always win. /// 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( pub async fn inject_llm_keys_from_secrets(
secrets: &dyn crate::secrets::SecretsStore, secrets: &dyn crate::secrets::SecretsStore,
user_id: &str, user_id: &str,
@@ -292,7 +300,10 @@ pub async fn inject_llm_keys_from_secrets(
// Static mappings for well-known providers. // Static mappings for well-known providers.
// The registry's setup hints define secret_name -> env_var mappings, // The registry's setup hints define secret_name -> env_var mappings,
// so new providers added to providers.json get injection automatically. // 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. // Dynamically discover secret->env mappings from the provider registry.
// Uses selectable() which deduplicates user overrides correctly. // 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<String, String>) {
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<String, String>) {
// 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");
}
} }
+16 -5
View File
@@ -233,9 +233,14 @@ impl ClaudeCodeConfig {
/// Expected shape: `{"claudeAiOauth": {"accessToken": "sk-ant-oat01-..."}}` /// Expected shape: `{"claudeAiOauth": {"accessToken": "sk-ant-oat01-..."}}`
fn parse_oauth_access_token(json: &str) -> Option<String> { fn parse_oauth_access_token(json: &str) -> Option<String> {
let creds: serde_json::Value = serde_json::from_str(json).ok()?; let creds: serde_json::Value = serde_json::from_str(json).ok()?;
creds["claudeAiOauth"]["accessToken"] let token = creds["claudeAiOauth"]["accessToken"].as_str()?;
.as_str() // Validate that the token looks like a real OAuth token before using it.
.map(String::from) // 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)] #[cfg(test)]
@@ -401,14 +406,14 @@ mod tests {
fn parse_oauth_token_nested_extra_fields() { fn parse_oauth_token_nested_extra_fields() {
let json = r#"{ let json = r#"{
"claudeAiOauth": { "claudeAiOauth": {
"accessToken": "sk-ant-real-token", "accessToken": "sk-ant-oat01-real-token",
"refreshToken": "rt-abc", "refreshToken": "rt-abc",
"expiresAt": 1700000000 "expiresAt": 1700000000
} }
}"#; }"#;
assert_eq!( assert_eq!(
parse_oauth_access_token(json), 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); 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 ─────────────────────────── // ── default_claude_code_allowed_tools ───────────────────────────
#[test] #[test]
+641
View File
@@ -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 <token>` 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<String>,
active_model: std::sync::RwLock<String>,
}
impl AnthropicOAuthProvider {
pub fn new(config: &RegistryProviderConfig) -> Result<Self, LlmError> {
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<R: for<'de> Deserialize<'de>>(
&self,
body: &AnthropicRequest,
) -> Result<R, LlmError> {
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::<u64>().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<CompletionResponse, LlmError> {
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<ToolCompletionResponse, LlmError> {
let model = req.model.unwrap_or_else(|| self.active_model_name());
let (system, messages) = convert_messages(req.messages);
let tools: Vec<AnthropicTool> = 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<AnthropicMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
system: Option<String>,
max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<AnthropicTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<AnthropicToolChoice>,
}
#[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<AnthropicContentBlock>),
}
#[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<String>,
}
#[derive(Debug, Deserialize)]
struct AnthropicResponse {
content: Vec<AnthropicResponseBlock>,
#[serde(default)]
stop_reason: Option<String>,
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<ChatMessage>) -> (Option<String>, Vec<AnthropicMessage>) {
let mut system_parts: Vec<String> = Vec::new();
let mut anthropic_msgs: Vec<AnthropicMessage> = 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<AnthropicContentBlock> = 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<String>, Vec<ToolCall>) {
let mut text_parts: Vec<String> = Vec::new();
let mut tool_calls: Vec<ToolCall> = 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");
}
}
+19
View File
@@ -7,6 +7,7 @@
//! - **Ollama**: Local model inference //! - **Ollama**: Local model inference
//! - **OpenAI-compatible**: Any endpoint that speaks the OpenAI API //! - **OpenAI-compatible**: Any endpoint that speaks the OpenAI API
mod anthropic_oauth;
pub mod circuit_breaker; pub mod circuit_breaker;
pub mod costs; pub mod costs;
pub mod failover; pub mod failover;
@@ -178,6 +179,24 @@ fn create_openai_compat_from_registry(
fn create_anthropic_from_registry( fn create_anthropic_from_registry(
config: &RegistryProviderConfig, config: &RegistryProviderConfig,
) -> Result<Arc<dyn LlmProvider>, LlmError> { ) -> Result<Arc<dyn LlmProvider>, 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::CacheRetention;
use crate::config::helpers::optional_env; use crate::config::helpers::optional_env;
use rig::providers::anthropic; use rig::providers::anthropic;
+4 -1
View File
@@ -158,7 +158,10 @@ async fn async_main() -> anyhow::Result<()> {
wizard.run().await?; 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 toml_path = cli.config.as_deref();
let config = match Config::from_env_with_toml(toml_path).await { let config = match Config::from_env_with_toml(toml_path).await {
Ok(c) => c, Ok(c) => c,
+5
View File
@@ -498,6 +498,10 @@ pub struct SandboxSettings {
/// Additional domains to allow through the network proxy. /// Additional domains to allow through the network proxy.
#[serde(default)] #[serde(default)]
pub extra_allowed_domains: Vec<String>, pub extra_allowed_domains: Vec<String>,
/// Whether Claude Code sandbox mode is enabled.
#[serde(default)]
pub claude_code_enabled: bool,
} }
fn default_sandbox_policy() -> String { fn default_sandbox_policy() -> String {
@@ -531,6 +535,7 @@ impl Default for SandboxSettings {
image: default_sandbox_image(), image: default_sandbox_image(),
auto_pull_image: true, auto_pull_image: true,
extra_allowed_domains: Vec::new(), extra_allowed_domains: Vec::new(),
claude_code_enabled: false,
} }
} }
} }
+216 -10
View File
@@ -22,6 +22,7 @@ use crate::bootstrap::ironclaw_base_dir;
use crate::channels::wasm::{ use crate::channels::wasm::{
ChannelCapabilitiesFile, available_channel_names, install_bundled_channel, ChannelCapabilitiesFile, available_channel_names, install_bundled_channel,
}; };
use crate::config::llm::OAUTH_PLACEHOLDER;
use crate::llm::{SessionConfig, SessionManager}; use crate::llm::{SessionConfig, SessionManager};
use crate::secrets::{SecretsCrypto, SecretsStore}; use crate::secrets::{SecretsCrypto, SecretsStore};
use crate::settings::{KeySource, Settings}; use crate::settings::{KeySource, Settings};
@@ -886,6 +887,11 @@ impl SetupWizard {
return Ok(()); 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 { match setup {
crate::llm::registry::SetupHint::ApiKey { crate::llm::registry::SetupHint::ApiKey {
secret_name, secret_name,
@@ -991,6 +997,112 @@ impl SetupWizard {
Ok(()) 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. /// Shared setup flow for API-key-based providers.
async fn setup_api_key_provider( async fn setup_api_key_provider(
&mut self, &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 // Cache key in memory for model fetching later in the wizard
self.llm_api_key = Some(SecretString::from(key_str.to_string())); 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(()) Ok(())
} }
@@ -2081,6 +2259,12 @@ impl SetupWizard {
/// ///
/// These are the chicken-and-egg settings needed before the database is /// These are the chicken-and-egg settings needed before the database is
/// connected (DATABASE_BACKEND, DATABASE_URL, LLM_BACKEND, etc.). /// 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> { fn write_bootstrap_env(&self) -> Result<(), SetupError> {
let registry = crate::llm::ProviderRegistry::load(); let registry = crate::llm::ProviderRegistry::load();
let mut env_vars: Vec<(String, String)> = Vec::new(); 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())); 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). // Signal channel env vars (chicken-and-egg: config resolves before DB).
if let Some(ref url) = self.settings.channels.signal_http_url { if let Some(ref url) = self.settings.channels.signal_http_url {
env_vars.push(("SIGNAL_HTTP_URL".to_string(), url.clone())); 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 let api_key = cached_key
.map(String::from) .map(String::from)
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok()) .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 { // Fall back to OAuth token if no API key
Some(k) => k, let oauth_token = if api_key.is_none() {
None => return static_defaults, 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 client = reqwest::Client::new();
let resp = match client let mut request = client
.get("https://api.anthropic.com/v1/models") .get("https://api.anthropic.com/v1/models")
.header("x-api-key", &api_key)
.header("anthropic-version", "2023-06-01") .header("anthropic-version", "2023-06-01")
.timeout(std::time::Duration::from_secs(5)) .timeout(std::time::Duration::from_secs(5));
.send()
.await 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, Ok(r) if r.status().is_success() => r,
_ => return static_defaults, _ => return static_defaults,
}; };