mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-29 00:49:31 +00:00
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:
+216
-10
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user