//! Main setup wizard orchestration. //! //! The wizard guides users through: //! 1. Database connection //! 2. Security (secrets master key) //! 3. Inference provider (NEAR AI, Anthropic, OpenAI, GitHub Copilot, OpenAI Codex, Ollama, OpenAI-compatible) //! 4. Model selection //! 5. Embeddings //! 6. Channel configuration //! 7. Extensions (tool installation from registry) //! 8. Docker sandbox //! 9. Heartbeat (background tasks) use std::collections::{HashMap, HashSet}; use std::sync::Arc; #[cfg(feature = "postgres")] use deadpool_postgres::Config as PoolConfig; use secrecy::{ExposeSecret, SecretString}; use crate::bootstrap::ironclaw_base_dir; use crate::channels::wasm::{ ChannelCapabilitiesFile, available_channel_names, install_bundled_channel, }; use crate::config::OAUTH_PLACEHOLDER; use crate::llm::models::{ build_nearai_model_fetch_config, fetch_anthropic_models, fetch_ollama_models, fetch_openai_compatible_models, fetch_openai_models, }; #[cfg(test)] use crate::llm::models::{is_openai_chat_model, sort_openai_models}; use crate::llm::{SessionConfig, SessionManager}; use crate::secrets::{SecretsCrypto, SecretsStore}; use crate::settings::{KeySource, Settings}; use crate::setup::channels::{ SecretsContext, setup_http, setup_signal, setup_tunnel, setup_wasm_channel, }; use crate::setup::prompts::{ confirm, input, optional_input, print_banner, print_error, print_header, print_info, print_step, print_success, secret_input, select_many, select_one, }; // unused const, keep commented for clarity / future use // const CHANNEL_INDEX_CLI: usize = 0; const CHANNEL_INDEX_HTTP: usize = 1; const CHANNEL_INDEX_SIGNAL: usize = 2; /// Setup wizard error. #[derive(Debug, thiserror::Error)] pub enum SetupError { #[error("I/O error: {0}")] Io(#[from] std::io::Error), #[error("Authentication error: {0}")] Auth(String), #[error("Database error: {0}")] Database(String), #[error("Configuration error: {0}")] Config(String), #[error("Channel setup error: {0}")] Channel(String), #[error("User cancelled")] Cancelled, } impl From for SetupError { fn from(e: crate::setup::channels::ChannelSetupError) -> Self { SetupError::Channel(e.to_string()) } } /// Setup wizard configuration. #[derive(Debug, Clone, Default)] pub struct SetupConfig { /// Skip authentication step (use existing session). pub skip_auth: bool, /// Only reconfigure channels. pub channels_only: bool, /// Only reconfigure LLM provider and model selection. pub provider_only: bool, /// Quick setup: auto-defaults everything except LLM provider and model. pub quick: bool, /// Run only specific setup steps (e.g. "provider", "channels", "model", "database", "security"). pub steps: Vec, } /// Interactive setup wizard for IronClaw. pub struct SetupWizard { config: SetupConfig, settings: Settings, owner_id: String, session_manager: Option>, /// Database pool (created during setup, postgres only). #[cfg(feature = "postgres")] db_pool: Option, /// libSQL backend (created during setup, libsql only). #[cfg(feature = "libsql")] db_backend: Option, /// Secrets crypto (created during setup). secrets_crypto: Option>, /// Cached API key from provider setup (used by model fetcher without env mutation). llm_api_key: Option, } impl SetupWizard { fn owner_id(&self) -> &str { &self.owner_id } fn fallback_with_default_owner( config: SetupConfig, settings: Settings, error: &crate::error::ConfigError, ) -> Self { tracing::warn!("Falling back to default owner scope for setup wizard: {error}"); Self { config, settings, owner_id: "default".to_string(), session_manager: None, #[cfg(feature = "postgres")] db_pool: None, #[cfg(feature = "libsql")] db_backend: None, secrets_crypto: None, llm_api_key: None, } } fn from_bootstrap_settings( config: SetupConfig, settings: Settings, ) -> Result { let owner_id = crate::config::resolve_owner_id(&settings)?; Ok(Self { config, settings, owner_id, session_manager: None, #[cfg(feature = "postgres")] db_pool: None, #[cfg(feature = "libsql")] db_backend: None, secrets_crypto: None, llm_api_key: None, }) } /// Create a new setup wizard. pub fn new() -> Self { let settings = crate::config::load_bootstrap_settings(None).unwrap_or_default(); Self::from_bootstrap_settings(SetupConfig::default(), settings.clone()).unwrap_or_else( |e| Self::fallback_with_default_owner(SetupConfig::default(), settings, &e), ) } /// Create a wizard with custom configuration. pub fn with_config(config: SetupConfig) -> Self { let settings = crate::config::load_bootstrap_settings(None).unwrap_or_default(); Self::from_bootstrap_settings(config.clone(), settings.clone()) .unwrap_or_else(|e| Self::fallback_with_default_owner(config, settings, &e)) } /// Create a wizard with custom configuration and bootstrap TOML overlay. pub fn try_with_config_and_toml( config: SetupConfig, toml_path: Option<&std::path::Path>, ) -> Result { let settings = crate::config::load_bootstrap_settings(toml_path)?; Self::from_bootstrap_settings(config, settings) } /// Set the session manager (for reusing existing auth). pub fn with_session(mut self, session: Arc) -> Self { self.session_manager = Some(session); self } /// Run the setup wizard. /// /// Settings are persisted incrementally after each successful step so /// that progress is not lost if a later step fails. On re-run, existing /// settings are loaded from the database after Step 1 establishes a /// connection, so users don't have to re-enter everything. pub async fn run(&mut self) -> Result<(), SetupError> { print_banner(); print_header("IronClaw Setup Wizard"); if !self.config.steps.is_empty() { // Selective step mode: reconnect to existing DB and load settings, // then run only the requested steps. self.reconnect_existing_db().await?; let valid_steps = ["provider", "channels", "model", "database", "security"]; for s in &self.config.steps { if !valid_steps.contains(&s.as_str()) { return Err(SetupError::Config(format!( "Unknown step '{}'. Valid steps: {}", s, valid_steps.join(", ") ))); } } let total = self.config.steps.len(); for (i, step_name) in self.config.steps.clone().iter().enumerate() { let step_num = i + 1; match step_name.as_str() { "database" => { print_step(step_num, total, "Database Connection"); self.step_database().await?; } "security" => { print_step(step_num, total, "Security"); self.step_security().await?; } "provider" => { print_step(step_num, total, "Inference Provider"); self.step_inference_provider().await?; } "model" => { print_step(step_num, total, "Model Selection"); self.step_model_selection().await?; } "channels" => { print_step(step_num, total, "Channel Configuration"); self.step_channels().await?; } _ => {} // already validated above } self.persist_after_step().await; } self.save_and_summarize().await?; return Ok(()); } if self.config.channels_only { // Channels-only mode: reconnect to existing DB and load settings // before running the channel step, so secrets and save work. self.reconnect_existing_db().await?; print_step(1, 1, "Channel Configuration"); self.step_channels().await?; } else if self.config.provider_only { // Provider-only mode: reconnect to existing DB, then run just // inference provider + model selection steps. self.reconnect_existing_db().await?; print_step(1, 2, "Inference Provider"); self.step_inference_provider().await?; self.persist_after_step().await; print_step(2, 2, "Model Selection"); self.step_model_selection().await?; self.persist_after_step().await; } else if self.config.quick { // Quick mode: auto-default database + security, only ask for // LLM provider + model. Designed for first-run experience. self.auto_setup_database().await?; // Load existing settings from DB (if any prior partial run) let step1_settings = self.settings.clone(); self.try_load_existing_settings().await; self.settings.merge_from(&step1_settings); self.auto_setup_security().await?; self.persist_after_step().await; // Pre-populate backend from env so step_inference_provider // can offer "Keep current provider?" instead of asking from scratch. if self.settings.llm_backend.is_none() { if let Ok(b) = std::env::var("LLM_BACKEND") { self.settings.llm_backend = Some(b); } else if std::env::var("NEARAI_API_KEY").is_ok() { self.settings.llm_backend = Some("nearai".to_string()); } else if std::env::var("ANTHROPIC_API_KEY").is_ok() || std::env::var("ANTHROPIC_OAUTH_TOKEN").is_ok() { self.settings.llm_backend = Some("anthropic".to_string()); } else if std::env::var("OPENAI_API_KEY").is_ok() { self.settings.llm_backend = Some("openai".to_string()); } else if std::env::var("OPENROUTER_API_KEY").is_ok() { self.settings.llm_backend = Some("openrouter".to_string()); } } if let Ok(api_key) = std::env::var("NEARAI_API_KEY") && !api_key.is_empty() && self.settings.llm_backend.as_deref() == Some("nearai") { // NEARAI_API_KEY is set and backend auto-detected — skip interactive prompts print_info("NEARAI_API_KEY found — using NEAR AI provider"); if let Ok(ctx) = self.init_secrets_context().await { let key = SecretString::from(api_key.clone()); if let Err(e) = ctx.save_secret("llm_nearai_api_key", &key).await { tracing::warn!("Failed to persist NEARAI_API_KEY to secrets: {}", e); } } self.llm_api_key = Some(SecretString::from(api_key)); if self.settings.selected_model.is_none() { let default = crate::llm::DEFAULT_MODEL; self.settings.selected_model = Some(default.to_string()); print_info(&format!("Using default model: {default}")); } self.persist_after_step().await; } else if self.settings.llm_backend.as_deref() == Some("anthropic") && let Some(api_key) = Self::detect_anthropic_key() { // Anthropic key detected — skip interactive prompts print_info("Anthropic credentials found — using Anthropic provider"); let secret_name = if api_key.starts_with("sk-ant-oat") { "llm_anthropic_oauth_token" } else { "llm_anthropic_api_key" }; if let Ok(ctx) = self.init_secrets_context().await { let key = SecretString::from(api_key.clone()); if let Err(e) = ctx.save_secret(secret_name, &key).await { tracing::warn!("Failed to persist Anthropic key to secrets: {}", e); } } self.llm_api_key = Some(SecretString::from(api_key)); let registry = crate::llm::ProviderRegistry::load(); if self.settings.selected_model.is_none() { let default = registry .find("anthropic") .map(|d| d.default_model.as_str()) .unwrap_or("claude-sonnet-4-20250514"); self.settings.selected_model = Some(default.to_string()); print_info(&format!("Using default model: {default}")); } self.persist_after_step().await; } else if let Ok(api_key) = std::env::var("OPENAI_API_KEY") && !api_key.is_empty() && self.settings.llm_backend.as_deref() == Some("openai") { // OpenAI key detected — skip interactive prompts print_info("OPENAI_API_KEY found — using OpenAI provider"); if let Ok(ctx) = self.init_secrets_context().await { let key = SecretString::from(api_key.clone()); if let Err(e) = ctx.save_secret("llm_openai_api_key", &key).await { tracing::warn!("Failed to persist OPENAI_API_KEY to secrets: {}", e); } } self.llm_api_key = Some(SecretString::from(api_key)); let registry = crate::llm::ProviderRegistry::load(); if self.settings.selected_model.is_none() { let default = registry .find("openai") .map(|d| d.default_model.as_str()) .unwrap_or("gpt-5-mini"); self.settings.selected_model = Some(default.to_string()); print_info(&format!("Using default model: {default}")); } self.persist_after_step().await; } else if let Ok(api_key) = std::env::var("OPENROUTER_API_KEY") && !api_key.is_empty() && self.settings.llm_backend.as_deref() == Some("openrouter") { // OpenRouter key detected — skip interactive prompts print_info("OPENROUTER_API_KEY found — using OpenRouter provider"); if let Ok(ctx) = self.init_secrets_context().await { let key = SecretString::from(api_key.clone()); if let Err(e) = ctx.save_secret("llm_openrouter_api_key", &key).await { tracing::warn!("Failed to persist OPENROUTER_API_KEY to secrets: {}", e); } } self.llm_api_key = Some(SecretString::from(api_key)); let registry = crate::llm::ProviderRegistry::load(); if self.settings.selected_model.is_none() { let default = registry .find("openrouter") .map(|d| d.default_model.as_str()) .unwrap_or("openai/gpt-4o"); self.settings.selected_model = Some(default.to_string()); print_info(&format!("Using default model: {default}")); } self.persist_after_step().await; } else { print_step(1, 2, "Inference Provider"); self.step_inference_provider().await?; self.persist_after_step().await; print_step(2, 2, "Model Selection"); self.step_model_selection().await?; self.persist_after_step().await; } } else { let total_steps = 9; // Step 1: Database print_step(1, total_steps, "Database Connection"); self.step_database().await?; // After establishing a DB connection, load any previously saved // settings so we recover progress from prior partial runs. // We must load BEFORE persisting, otherwise persist_after_step() // would overwrite prior settings with defaults. // Save Step 1 choices first so they aren't clobbered by stale // DB values (merge_from only applies non-default fields). let step1_settings = self.settings.clone(); self.try_load_existing_settings().await; self.settings.merge_from(&step1_settings); self.persist_after_step().await; // Step 2: Security print_step(2, total_steps, "Security"); self.step_security().await?; self.persist_after_step().await; // Step 3: Inference provider selection (unless skipped) if !self.config.skip_auth { print_step(3, total_steps, "Inference Provider"); self.step_inference_provider().await?; } else { print_info("Skipping inference provider setup (using existing config)"); } self.persist_after_step().await; // Step 4: Model selection print_step(4, total_steps, "Model Selection"); self.step_model_selection().await?; self.persist_after_step().await; // Step 5: Embeddings print_step(5, total_steps, "Embeddings (Semantic Search)"); self.step_embeddings()?; self.persist_after_step().await; // Step 6: Channel configuration print_step(6, total_steps, "Channel Configuration"); self.step_channels().await?; self.persist_after_step().await; // Step 7: Extensions (tools) print_step(7, total_steps, "Extensions"); self.step_extensions().await?; // Step 8: Docker Sandbox print_step(8, total_steps, "Docker Sandbox"); self.step_docker_sandbox().await?; self.persist_after_step().await; // Step 9: Heartbeat print_step(9, total_steps, "Background Tasks"); self.step_heartbeat()?; self.persist_after_step().await; // Personal onboarding now happens conversationally during the // user's first interaction with the assistant (see bootstrap // block in workspace/mod.rs system_prompt_for_context). } // Save settings and print summary self.save_and_summarize().await?; Ok(()) } /// Reconnect to the existing database and load settings. /// /// Used by channels-only mode (and future single-step modes) so that /// `init_secrets_context()` and `save_and_summarize()` have a live /// database connection and the wizard's `self.settings` reflects the /// previously saved configuration. async fn reconnect_existing_db(&mut self) -> Result<(), SetupError> { // Determine backend from env (set by bootstrap .env loaded in main). let backend = std::env::var("DATABASE_BACKEND").unwrap_or_else(|_| "postgres".to_string()); // Try libsql first if that's the configured backend. #[cfg(feature = "libsql")] if backend == "libsql" || backend == "turso" || backend == "sqlite" { return self.reconnect_libsql().await; } // Try postgres (either explicitly configured or as default). #[cfg(feature = "postgres")] { let _ = &backend; return self.reconnect_postgres().await; } #[allow(unreachable_code)] Err(SetupError::Database( "No database configured. Run full setup first (ironclaw onboard).".to_string(), )) } /// Reconnect to an existing PostgreSQL database and load settings. #[cfg(feature = "postgres")] async fn reconnect_postgres(&mut self) -> Result<(), SetupError> { let url = std::env::var("DATABASE_URL").map_err(|_| { SetupError::Database( "DATABASE_URL not set. Run full setup first (ironclaw onboard).".to_string(), ) })?; self.test_database_connection_postgres(&url).await?; self.settings.database_backend = Some("postgres".to_string()); self.settings.database_url = Some(url.clone()); // Load existing settings from DB, then restore connection fields that // may not be persisted in the settings map. if let Some(ref pool) = self.db_pool { let store = crate::history::Store::from_pool(pool.clone()); if let Ok(map) = store.get_all_settings(self.owner_id()).await { self.settings = Settings::from_db_map(&map); self.settings.database_backend = Some("postgres".to_string()); self.settings.database_url = Some(url); } } Ok(()) } /// Reconnect to an existing libSQL database and load settings. #[cfg(feature = "libsql")] async fn reconnect_libsql(&mut self) -> Result<(), SetupError> { let path = std::env::var("LIBSQL_PATH").unwrap_or_else(|_| { crate::config::default_libsql_path() .to_string_lossy() .to_string() }); let turso_url = std::env::var("LIBSQL_URL").ok(); let turso_token = std::env::var("LIBSQL_AUTH_TOKEN").ok(); self.test_database_connection_libsql(&path, turso_url.as_deref(), turso_token.as_deref()) .await?; self.settings.database_backend = Some("libsql".to_string()); self.settings.libsql_path = Some(path.clone()); if let Some(ref url) = turso_url { self.settings.libsql_url = Some(url.clone()); } // Load existing settings from DB, then restore connection fields that // may not be persisted in the settings map. if let Some(ref db) = self.db_backend { use crate::db::SettingsStore as _; if let Ok(map) = db.get_all_settings(self.owner_id()).await { self.settings = Settings::from_db_map(&map); self.settings.database_backend = Some("libsql".to_string()); self.settings.libsql_path = Some(path); if let Some(url) = turso_url { self.settings.libsql_url = Some(url); } } } Ok(()) } /// Step 1: Database connection. async fn step_database(&mut self) -> Result<(), SetupError> { // When both features are compiled, let the user choose. // If DATABASE_BACKEND is already set in the environment, respect it. #[cfg(all(feature = "postgres", feature = "libsql"))] { // Check if a backend is already pinned via env var let env_backend = std::env::var("DATABASE_BACKEND").ok(); if let Some(ref backend) = env_backend { if backend == "libsql" || backend == "turso" || backend == "sqlite" { return self.step_database_libsql().await; } if backend != "postgres" && backend != "postgresql" { print_info(&format!( "Unknown DATABASE_BACKEND '{}', defaulting to PostgreSQL", backend )); } return self.step_database_postgres().await; } // Interactive selection let pre_selected = self.settings.database_backend.as_deref().map(|b| match b { "libsql" | "turso" | "sqlite" => 1, _ => 0, }); print_info("Which database backend would you like to use?"); println!(); let options = &[ "PostgreSQL - production-grade, requires a running server", "libSQL - embedded SQLite, zero dependencies, optional Turso cloud sync", ]; let choice = select_one("Select a database backend:", options).map_err(SetupError::Io)?; // If the user picked something different from what was pre-selected, clear // stale connection settings so the next step starts fresh. if let Some(prev) = pre_selected && prev != choice { self.settings.database_url = None; self.settings.libsql_path = None; self.settings.libsql_url = None; } match choice { 1 => return self.step_database_libsql().await, _ => return self.step_database_postgres().await, } } #[cfg(all(feature = "postgres", not(feature = "libsql")))] { return self.step_database_postgres().await; } #[cfg(all(feature = "libsql", not(feature = "postgres")))] { return self.step_database_libsql().await; } } /// Step 1 (postgres): Database connection via PostgreSQL URL. #[cfg(feature = "postgres")] async fn step_database_postgres(&mut self) -> Result<(), SetupError> { self.settings.database_backend = Some("postgres".to_string()); let existing_url = std::env::var("DATABASE_URL") .ok() .or_else(|| self.settings.database_url.clone()); if let Some(ref url) = existing_url { let display_url = mask_password_in_url(url); print_info(&format!("Existing database URL: {}", display_url)); if confirm("Use this database?", true).map_err(SetupError::Io)? { if let Err(e) = self.test_database_connection_postgres(url).await { print_error(&format!("Connection failed: {}", e)); print_info("Let's configure a new database URL."); } else { print_success("Database connection successful"); self.settings.database_url = Some(url.clone()); return Ok(()); } } } println!(); print_info("Enter your PostgreSQL connection URL."); print_info("Format: postgres://user:password@host:port/database"); println!(); loop { let url = input("Database URL").map_err(SetupError::Io)?; if url.is_empty() { print_error("Database URL is required."); continue; } print_info("Testing connection..."); match self.test_database_connection_postgres(&url).await { Ok(()) => { print_success("Database connection successful"); if confirm("Run database migrations?", true).map_err(SetupError::Io)? { self.run_migrations_postgres().await?; } self.settings.database_url = Some(url); return Ok(()); } Err(e) => { print_error(&format!("Connection failed: {}", e)); if !confirm("Try again?", true).map_err(SetupError::Io)? { return Err(SetupError::Database( "Database connection failed".to_string(), )); } } } } } /// Step 1 (libsql): Database connection via local file or Turso remote replica. #[cfg(feature = "libsql")] async fn step_database_libsql(&mut self) -> Result<(), SetupError> { self.settings.database_backend = Some("libsql".to_string()); let default_path = crate::config::default_libsql_path(); let default_path_str = default_path.to_string_lossy().to_string(); // Check for existing configuration let existing_path = std::env::var("LIBSQL_PATH") .ok() .or_else(|| self.settings.libsql_path.clone()); if let Some(ref path) = existing_path { print_info(&format!("Existing database path: {}", path)); if confirm("Use this database?", true).map_err(SetupError::Io)? { let turso_url = std::env::var("LIBSQL_URL") .ok() .or_else(|| self.settings.libsql_url.clone()); let turso_token = std::env::var("LIBSQL_AUTH_TOKEN").ok(); match self .test_database_connection_libsql( path, turso_url.as_deref(), turso_token.as_deref(), ) .await { Ok(()) => { print_success("Database connection successful"); self.settings.libsql_path = Some(path.clone()); if let Some(url) = turso_url { self.settings.libsql_url = Some(url); } return Ok(()); } Err(e) => { print_error(&format!("Connection failed: {}", e)); print_info("Let's configure a new database path."); } } } } println!(); print_info("IronClaw uses an embedded SQLite database (libSQL)."); print_info("No external database server required."); println!(); let path_input = optional_input( "Database file path", Some(&format!("default: {}", default_path_str)), ) .map_err(SetupError::Io)?; let db_path = path_input.unwrap_or(default_path_str.clone()); // Ask about Turso cloud sync println!(); let use_turso = confirm("Enable Turso cloud sync (remote replica)?", false).map_err(SetupError::Io)?; let (turso_url, turso_token) = if use_turso { print_info("Enter your Turso database URL and auth token."); print_info("Format: libsql://your-db.turso.io"); println!(); let url = input("Turso URL").map_err(SetupError::Io)?; if url.is_empty() { print_error("Turso URL is required for cloud sync."); (None, None) } else { let token_secret = secret_input("Auth token").map_err(SetupError::Io)?; let token = token_secret.expose_secret().to_string(); if token.is_empty() { print_error("Auth token is required for cloud sync."); (None, None) } else { (Some(url), Some(token)) } } } else { (None, None) }; print_info("Testing connection..."); match self .test_database_connection_libsql(&db_path, turso_url.as_deref(), turso_token.as_deref()) .await { Ok(()) => { print_success("Database connection successful"); // Always run migrations for libsql (they're idempotent) self.run_migrations_libsql().await?; self.settings.libsql_path = Some(db_path); if let Some(url) = turso_url { self.settings.libsql_url = Some(url); } Ok(()) } Err(e) => Err(SetupError::Database(format!("Connection failed: {}", e))), } } /// Test PostgreSQL connection and store the pool. /// /// After connecting, validates: /// 1. PostgreSQL version >= 15 (required for pgvector compatibility) /// 2. pgvector extension is available (required for embeddings/vector search) #[cfg(feature = "postgres")] async fn test_database_connection_postgres(&mut self, url: &str) -> Result<(), SetupError> { let mut cfg = PoolConfig::new(); cfg.url = Some(url.to_string()); cfg.pool = Some(deadpool_postgres::PoolConfig { max_size: 5, ..Default::default() }); let pool = crate::db::tls::create_pool(&cfg, crate::config::SslMode::from_env()) .map_err(|e| SetupError::Database(format!("Failed to create pool: {}", e)))?; let client = pool .get() .await .map_err(|e| SetupError::Database(format!("Failed to connect: {}", e)))?; // Check PostgreSQL server version (need 15+ for pgvector) let version_row = client .query_one("SHOW server_version", &[]) .await .map_err(|e| SetupError::Database(format!("Failed to query server version: {}", e)))?; let version_str: &str = version_row.get(0); let major_version = version_str .split('.') .next() .and_then(|v| v.parse::().ok()) .unwrap_or(0); const MIN_PG_MAJOR_VERSION: u32 = 15; if major_version < MIN_PG_MAJOR_VERSION { return Err(SetupError::Database(format!( "PostgreSQL {} detected. IronClaw requires PostgreSQL {} or later for pgvector support.\n\ Upgrade: https://www.postgresql.org/download/", version_str, MIN_PG_MAJOR_VERSION ))); } // Check if pgvector extension is available let pgvector_row = client .query_opt( "SELECT 1 FROM pg_available_extensions WHERE name = 'vector'", &[], ) .await .map_err(|e| { SetupError::Database(format!("Failed to check pgvector availability: {}", e)) })?; if pgvector_row.is_none() { return Err(SetupError::Database(format!( "pgvector extension not found on your PostgreSQL server.\n\n\ Install it:\n \ macOS: brew install pgvector\n \ Ubuntu: apt install postgresql-{0}-pgvector\n \ Docker: use the pgvector/pgvector:pg{0} image\n \ Source: https://github.com/pgvector/pgvector#installation\n\n\ Then restart PostgreSQL and re-run: ironclaw onboard", major_version ))); } self.db_pool = Some(pool); Ok(()) } /// Test libSQL connection and store the backend. #[cfg(feature = "libsql")] async fn test_database_connection_libsql( &mut self, path: &str, turso_url: Option<&str>, turso_token: Option<&str>, ) -> Result<(), SetupError> { use crate::db::libsql::LibSqlBackend; use std::path::Path; let db_path = Path::new(path); let backend = if let (Some(url), Some(token)) = (turso_url, turso_token) { LibSqlBackend::new_remote_replica(db_path, url, token) .await .map_err(|e| SetupError::Database(format!("Failed to connect: {}", e)))? } else { LibSqlBackend::new_local(db_path) .await .map_err(|e| SetupError::Database(format!("Failed to open database: {}", e)))? }; self.db_backend = Some(backend); Ok(()) } /// Run PostgreSQL migrations. #[cfg(feature = "postgres")] async fn run_migrations_postgres(&self) -> Result<(), SetupError> { if let Some(ref pool) = self.db_pool { use refinery::embed_migrations; embed_migrations!("migrations"); if !self.config.quick { print_info("Running migrations..."); } tracing::debug!("Running PostgreSQL migrations..."); let mut client = pool .get() .await .map_err(|e| SetupError::Database(format!("Pool error: {}", e)))?; migrations::runner() .run_async(&mut **client) .await .map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?; if !self.config.quick { print_success("Migrations applied"); } tracing::debug!("PostgreSQL migrations applied"); } Ok(()) } /// Run libSQL migrations. #[cfg(feature = "libsql")] async fn run_migrations_libsql(&self) -> Result<(), SetupError> { if let Some(ref backend) = self.db_backend { use crate::db::Database; if !self.config.quick { print_info("Running migrations..."); } tracing::debug!("Running libSQL migrations..."); backend .run_migrations() .await .map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?; if !self.config.quick { print_success("Migrations applied"); } tracing::debug!("libSQL migrations applied"); } Ok(()) } /// Step 2: Security (secrets master key). async fn step_security(&mut self) -> Result<(), SetupError> { // Check current configuration let env_key_exists = std::env::var("SECRETS_MASTER_KEY").is_ok(); if env_key_exists { print_info("Secrets master key found in SECRETS_MASTER_KEY environment variable."); self.settings.secrets_master_key_source = KeySource::Env; print_success("Security configured (env var)"); return Ok(()); } // Try to retrieve existing key from keychain. We use get_master_key() // instead of has_master_key() so we can cache the key bytes and build // SecretsCrypto eagerly, avoiding redundant keychain accesses later // (each access triggers macOS system dialogs). print_info("Checking OS keychain for existing master key..."); if let Ok(keychain_key_bytes) = crate::secrets::keychain::get_master_key().await { let key_hex: String = keychain_key_bytes .iter() .map(|b| format!("{:02x}", b)) .collect(); self.secrets_crypto = Some(Arc::new( SecretsCrypto::new(SecretString::from(key_hex)) .map_err(|e| SetupError::Config(e.to_string()))?, )); print_info("Existing master key found in OS keychain."); if confirm("Use existing keychain key?", true).map_err(SetupError::Io)? { self.settings.secrets_master_key_source = KeySource::Keychain; print_success("Security configured (keychain)"); return Ok(()); } // User declined the existing key; clear the cached crypto so a fresh // key can be generated below. self.secrets_crypto = None; } // Offer options println!(); print_info("The secrets master key encrypts sensitive data like API tokens."); print_info("Choose where to store it:"); println!(); let options = [ "OS Keychain (recommended for local installs)", "Environment variable (for CI/Docker)", "Skip (disable secrets features)", ]; let choice = select_one("Select storage method:", &options).map_err(SetupError::Io)?; match choice { 0 => { // Generate and store in keychain print_info("Generating master key..."); let key = crate::secrets::keychain::generate_master_key(); crate::secrets::keychain::store_master_key(&key) .await .map_err(|e| { SetupError::Config(format!("Failed to store in keychain: {}", e)) })?; // Also create crypto instance let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); self.secrets_crypto = Some(Arc::new( SecretsCrypto::new(SecretString::from(key_hex)) .map_err(|e| SetupError::Config(e.to_string()))?, )); self.settings.secrets_master_key_source = KeySource::Keychain; print_success("Master key generated and stored in OS keychain"); } 1 => { // Env var mode — generate key, init crypto, and persist to .env let key_hex = crate::secrets::keychain::generate_master_key_hex(); // Initialize crypto so subsequent wizard steps (channel setup, // API key storage) can encrypt secrets immediately. self.secrets_crypto = Some(Arc::new( SecretsCrypto::new(SecretString::from(key_hex.clone())) .map_err(|e| SetupError::Config(e.to_string()))?, )); // Make visible to optional_env() for any subsequent config resolution. crate::config::inject_single_var("SECRETS_MASTER_KEY", &key_hex); // Store hex for write_bootstrap_env to persist to ~/.ironclaw/.env. self.settings.secrets_master_key_hex = Some(key_hex.clone()); println!(); print_info("Master key generated and will be saved to ~/.ironclaw/.env"); println!(); println!(" SECRETS_MASTER_KEY={}", key_hex); println!(); print_info("You can also copy this to another .env file or CI secrets."); self.settings.secrets_master_key_source = KeySource::Env; print_success("Configured for environment variable"); } _ => { self.settings.secrets_master_key_source = KeySource::None; print_info("Secrets features disabled. Channel tokens must be set via env vars."); } } Ok(()) } /// Auto-setup database with zero prompts (quick mode). /// /// Uses existing env vars if present, otherwise defaults to libsql at the /// standard path. Falls back to the interactive `step_database()` only when /// just the postgres feature is compiled (can't auto-default postgres). async fn auto_setup_database(&mut self) -> Result<(), SetupError> { // If DATABASE_URL or LIBSQL_PATH already set, respect existing config #[cfg(feature = "postgres")] let env_backend = std::env::var("DATABASE_BACKEND").ok(); #[cfg(feature = "postgres")] if let Some(ref backend) = env_backend && (backend == "postgres" || backend == "postgresql") { if let Ok(url) = std::env::var("DATABASE_URL") { print_info("Using existing PostgreSQL configuration"); self.settings.database_backend = Some("postgres".to_string()); self.settings.database_url = Some(url); return Ok(()); } // Postgres configured but no URL — fall through to interactive return self.step_database().await; } #[cfg(feature = "postgres")] if let Ok(url) = std::env::var("DATABASE_URL") { print_info("Using existing PostgreSQL configuration"); self.settings.database_backend = Some("postgres".to_string()); self.settings.database_url = Some(url); return Ok(()); } // Auto-default to libsql if the feature is compiled #[cfg(feature = "libsql")] { self.settings.database_backend = Some("libsql".to_string()); let existing_path = std::env::var("LIBSQL_PATH") .ok() .or_else(|| self.settings.libsql_path.clone()); let db_path = existing_path.unwrap_or_else(|| { crate::config::default_libsql_path() .to_string_lossy() .to_string() }); let turso_url = std::env::var("LIBSQL_URL").ok(); let turso_token = std::env::var("LIBSQL_AUTH_TOKEN").ok(); self.test_database_connection_libsql( &db_path, turso_url.as_deref(), turso_token.as_deref(), ) .await?; self.run_migrations_libsql().await?; self.settings.libsql_path = Some(db_path.clone()); if let Some(url) = turso_url { self.settings.libsql_url = Some(url); } print_success(&format!("Using embedded database at {}", db_path)); return Ok(()); } // Only postgres feature compiled — can't auto-default, use interactive #[allow(unreachable_code)] { self.step_database().await } } /// Auto-setup security with zero prompts (quick mode). /// /// Silently configures the master key: uses existing env var or keychain /// key if available, otherwise generates and stores one automatically /// (keychain on macOS, env var fallback). async fn auto_setup_security(&mut self) -> Result<(), SetupError> { // Check env var first if std::env::var("SECRETS_MASTER_KEY").is_ok() { self.settings.secrets_master_key_source = KeySource::Env; print_success("Security configured (env var)"); return Ok(()); } // Try existing keychain key (no prompts — get_master_key may show // OS dialogs on macOS, but that's unavoidable for keychain access) if let Ok(keychain_key_bytes) = crate::secrets::keychain::get_master_key().await { let key_hex: String = keychain_key_bytes .iter() .map(|b| format!("{:02x}", b)) .collect(); self.secrets_crypto = Some(Arc::new( SecretsCrypto::new(SecretString::from(key_hex)) .map_err(|e| SetupError::Config(e.to_string()))?, )); self.settings.secrets_master_key_source = KeySource::Keychain; print_success("Security configured (keychain)"); return Ok(()); } // No existing key — generate one // Try keychain first (preferred on macOS) let key = crate::secrets::keychain::generate_master_key(); if crate::secrets::keychain::store_master_key(&key) .await .is_ok() { let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); self.secrets_crypto = Some(Arc::new( SecretsCrypto::new(SecretString::from(key_hex)) .map_err(|e| SetupError::Config(e.to_string()))?, )); self.settings.secrets_master_key_source = KeySource::Keychain; print_success("Master key stored in OS keychain"); return Ok(()); } // Keychain unavailable — fall back to env var mode let key_hex = crate::secrets::keychain::generate_master_key_hex(); self.secrets_crypto = Some(Arc::new( SecretsCrypto::new(SecretString::from(key_hex.clone())) .map_err(|e| SetupError::Config(e.to_string()))?, )); crate::config::inject_single_var("SECRETS_MASTER_KEY", &key_hex); self.settings.secrets_master_key_hex = Some(key_hex); self.settings.secrets_master_key_source = KeySource::Env; print_success("Master key stored in ~/.ironclaw/.env"); Ok(()) } /// Step 3: Inference provider selection. /// /// Uses the provider registry to dynamically build the selection menu. /// NearAI is always first (special auth), then all registry providers /// that have setup hints. async fn step_inference_provider(&mut self) -> Result<(), SetupError> { let registry = crate::llm::ProviderRegistry::load(); // Show current provider if already configured if let Some(current) = self.settings.llm_backend.clone() { let display = if current == "nearai" { "NEAR AI".to_string() } else if let Some(def) = registry.find(¤t) { def.setup .as_ref() .map(|s| s.display_name().to_string()) .unwrap_or_else(|| def.id.clone()) } else { match current.as_str() { "nearai" => "NEAR AI".to_string(), "gemini_oauth" | "gemini-oauth" => "Gemini API (OAuth)".to_string(), _ => { if let Some(def) = registry.find(¤t) { def.setup .as_ref() .map(|s| s.display_name().to_string()) .unwrap_or_else(|| def.id.clone()) } else { current.clone() } } } }; print_info(&format!("Current provider: {}", display)); println!(); let is_known = current == "nearai" || current == "bedrock" || current == "gemini_oauth" || current == "gemini-oauth" || current == "openai_codex" || registry.is_known(¤t); if is_known && confirm("Keep current provider?", true).map_err(SetupError::Io)? { if current == "bedrock" { print_info("Keeping existing AWS Bedrock configuration."); return Ok(()); } if current == "gemini_oauth" || current == "gemini-oauth" { print_info("Keeping existing Gemini CLI OAuth configuration."); return Ok(()); } if current == "openai_codex" { print_info("Keeping existing OpenAI Codex configuration."); return Ok(()); } return self.run_provider_setup(¤t, ®istry).await; } if !is_known { print_info(&format!( "Unknown provider '{}', please select a supported provider.", current )); } } print_info("Select your inference provider:"); println!(); // Build menu: NearAI first, then Gemini OAuth, then OpenAI Codex, then registry providers, then Bedrock let selectable = registry.selectable(); // Detect which providers have API keys already set in the environment. let detected_env: HashMap<&str, bool> = [ ("nearai", std::env::var("NEARAI_API_KEY").is_ok()), ( "anthropic", std::env::var("ANTHROPIC_API_KEY").is_ok() || std::env::var("ANTHROPIC_OAUTH_TOKEN").is_ok(), ), ("openai", std::env::var("OPENAI_API_KEY").is_ok()), ("openrouter", std::env::var("OPENROUTER_API_KEY").is_ok()), ] .into_iter() .collect(); // Helper: build a label for a provider entry, prepending a checkmark if detected. let make_label = |id: &str, name: &str, desc: &str| -> String { if detected_env.get(id).copied().unwrap_or(false) { format!("\u{2713} {:<15}- {}", name, desc) } else { format!(" {:<15}- {}", name, desc) } }; // Collect all entries as (provider_id, label, is_detected). struct ProviderEntry { id: String, label: String, detected: bool, } let mut entries: Vec = Vec::with_capacity(2 + selectable.len()); entries.push(ProviderEntry { id: "nearai".to_string(), label: make_label("nearai", "NEAR AI", "multi-model access via NEAR account"), detected: detected_env.get("nearai").copied().unwrap_or(false), }); entries.push(ProviderEntry { id: "gemini_oauth".to_string(), label: make_label( "gemini_oauth", "Gemini CLI", "Official Gemini API via Gemini CLI OAuth", ), detected: false, }); entries.push(ProviderEntry { id: "openai_codex".to_string(), label: make_label( "openai_codex", "OpenAI Codex", "ChatGPT subscription (Plus/Pro/Max)", ), detected: false, }); for def in &selectable { let display_name = def .setup .as_ref() .map(|s| s.display_name()) .unwrap_or(&def.id); entries.push(ProviderEntry { id: def.id.clone(), label: make_label(&def.id, display_name, &def.description), detected: detected_env.get(def.id.as_str()).copied().unwrap_or(false), }); } // Bedrock is a special case (native AWS SDK, not registry-based) entries.push(ProviderEntry { id: "bedrock".to_string(), label: make_label( "bedrock", "AWS Bedrock", "Claude & other models via AWS (IAM, SSO)", ), detected: false, }); // Sort: detected providers first, preserving relative order within each group. entries.sort_by_key(|e| !e.detected); let mut options: Vec = Vec::with_capacity(entries.len()); let mut provider_ids: Vec = Vec::with_capacity(entries.len()); for entry in &entries { options.push(entry.label.clone()); provider_ids.push(entry.id.clone()); } let option_refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect(); let choice = select_one("Provider:", &option_refs).map_err(SetupError::Io)?; let selected_id = &provider_ids[choice]; if selected_id == "bedrock" { self.setup_bedrock().await?; } else if selected_id == "gemini_oauth" { self.setup_gemini_oauth().await?; } else { self.run_provider_setup(selected_id, ®istry).await?; } Ok(()) } /// Run the setup flow for a specific provider. /// /// NearAI has its own special flow. Registry providers dispatch /// based on their `SetupHint` kind. async fn run_provider_setup( &mut self, provider_id: &str, registry: &crate::llm::ProviderRegistry, ) -> Result<(), SetupError> { if provider_id == "nearai" { return self.setup_nearai().await; } if provider_id == "openai_codex" { return self.setup_openai_codex().await; } let def = registry .find(provider_id) .ok_or_else(|| SetupError::Config(format!("Unknown provider: {}", provider_id)))?; // Providers without a setup hint (e.g., user-defined providers configured // purely via env vars) skip credential setup and go to model selection. let Some(setup) = def.setup.as_ref() else { print_info(&format!( "Provider '{}' has no setup wizard. Configure via environment variables.", provider_id )); self.set_llm_backend_preserving_model(provider_id); return Ok(()); }; // Anthropic has a custom flow: API key or OAuth token from `claude login`. if provider_id == "anthropic" { return self.setup_anthropic().await; } if provider_id == "github_copilot" { return self.setup_github_copilot().await; } match setup { crate::llm::registry::SetupHint::ApiKey { secret_name, key_url, display_name, .. } => { let env_var = def.api_key_env.as_deref().unwrap_or("LLM_API_KEY"); let url = key_url.as_deref().unwrap_or("the provider's website"); // Only store base URL for providers that resolve through // LLM_BASE_URL (openai_compatible, openrouter). Other providers // like groq/nvidia have their own base_url_env and don't need // this backward-compat setting. if def.base_url_env.as_deref() == Some("LLM_BASE_URL") && let Some(ref base_url) = def.default_base_url { self.settings.openai_compatible_base_url = Some(base_url.clone()); } self.setup_api_key_provider( &def.id, env_var, secret_name, &format!("{display_name} API key"), url, Some(display_name), ) .await?; } crate::llm::registry::SetupHint::Ollama { .. } => { self.setup_ollama_generic(def)?; } crate::llm::registry::SetupHint::OpenAiCompatible { secret_name, display_name, .. } => { self.setup_openai_compatible_generic(&def.id, secret_name, display_name) .await?; } } Ok(()) } /// Detect an Anthropic credential from the environment. /// /// Checks `ANTHROPIC_API_KEY` first, then `ANTHROPIC_OAUTH_TOKEN`. /// Returns the key/token string if found, or `None`. fn detect_anthropic_key() -> Option { if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") && !key.is_empty() { return Some(key); } if let Ok(token) = std::env::var("ANTHROPIC_OAUTH_TOKEN") && !token.is_empty() { return Some(token); } None } /// Update the selected LLM backend while preserving the current model when /// the backend did not actually change. fn set_llm_backend_preserving_model(&mut self, backend: &str) { let backend_changed = self.settings.llm_backend.as_deref() != Some(backend); self.settings.llm_backend = Some(backend.to_string()); if backend_changed { self.settings.selected_model = None; } } /// NEAR AI provider setup (extracted from the old step_authentication). async fn setup_nearai(&mut self) -> Result<(), SetupError> { self.set_llm_backend_preserving_model("nearai"); // Check if NEARAI_API_KEY is already provided via environment or runtime overlay if let Some(existing) = crate::config::helpers::env_or_override("NEARAI_API_KEY") && !existing.is_empty() { print_info(&format!( "NEARAI_API_KEY found: {}", mask_api_key(&existing) )); if confirm("Use this key?", true).map_err(SetupError::Io)? { if let Ok(ctx) = self.init_secrets_context().await { let key = SecretString::from(existing.clone()); if let Err(e) = ctx.save_secret("llm_nearai_api_key", &key).await { tracing::warn!("Failed to persist NEARAI_API_KEY to secrets: {}", e); } } self.llm_api_key = Some(SecretString::from(existing)); print_success("NEAR AI configured (from env)"); return Ok(()); } } // Check if we already have a session if let Some(ref session) = self.session_manager && session.has_token().await { print_info("Existing session found. Validating..."); match session.ensure_authenticated().await { Ok(()) => { print_success("NEAR AI session valid"); return Ok(()); } Err(e) => { print_info(&format!("Session invalid: {}. Re-authenticating...", e)); } } } // Create session manager if we don't have one let session = if let Some(ref s) = self.session_manager { Arc::clone(s) } else { let config = SessionConfig { session_path: crate::config::llm::default_session_path(), ..SessionConfig::default() }; Arc::new(SessionManager::new(config)) }; // Trigger authentication flow session .ensure_authenticated() .await .map_err(|e| SetupError::Auth(e.to_string()))?; self.session_manager = Some(session); // Persist session token to the database so the runtime can load it // via `attach_store()` → `load_session_from_db()` without the // backwards-compat fallback. The session manager saved to disk but // doesn't have a DB store attached during onboarding. self.persist_session_to_db().await; // If the user chose the API key path, NEARAI_API_KEY is now set // in the runtime env overlay. Persist it to the encrypted secrets // store so inject_llm_keys_from_secrets() can load it on future runs. if let Some(api_key) = crate::config::helpers::env_or_override("NEARAI_API_KEY") && !api_key.is_empty() && let Ok(ctx) = self.init_secrets_context().await { let key = SecretString::from(api_key); if let Err(e) = ctx.save_secret("llm_nearai_api_key", &key).await { tracing::warn!("Failed to persist NEARAI_API_KEY to secrets: {}", e); } } print_success("NEAR AI configured"); 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 } } async fn setup_github_copilot(&mut self) -> Result<(), SetupError> { print_info("GitHub Copilot authentication:"); let options = &[ "GitHub device login (recommended)", "Paste an existing token (from IDE or personal access token)", ]; let choice = select_one("Auth method:", options).map_err(SetupError::Io)?; match choice { 0 => self.setup_github_copilot_device_login().await, _ => self.setup_github_copilot_paste_token().await, } } async fn setup_github_copilot_paste_token(&mut self) -> Result<(), SetupError> { self.set_llm_backend_preserving_model("github_copilot"); print_info("Paste your GitHub token (requires an active Copilot subscription)."); print_info("Sources: `gh auth token`, or the oauth_token field in"); print_info("~/.config/github-copilot/apps.json (VS Code) or ~/.config/gh/hosts.yml."); let token_secret = secret_input("GitHub Copilot token").map_err(SetupError::Io)?; let token = token_secret.expose_secret().trim().to_string(); if token.is_empty() { return Err(SetupError::Auth("No token provided".to_string())); } let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(15)) .build() .map_err(|e| SetupError::Auth(format!("Failed to create HTTP client: {e}")))?; self.save_github_copilot_token(&client, &token).await } async fn setup_github_copilot_device_login(&mut self) -> Result<(), SetupError> { self.set_llm_backend_preserving_model("github_copilot"); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(15)) .build() .map_err(|e| SetupError::Auth(format!("Failed to create HTTP client: {e}")))?; let device = crate::llm::github_copilot_auth::request_device_code(&client) .await .map_err(|e| SetupError::Auth(e.to_string()))?; print_info("Authorize IronClaw with GitHub Copilot in your browser."); print_info(&format!("Verification URL: {}", device.verification_uri)); print_info(&format!("One-time code: {}", device.user_code)); if let Err(e) = open::that(&device.verification_uri) { tracing::debug!( url = %device.verification_uri, error = %e, "Failed to open GitHub Copilot device login URL" ); print_info("Open the URL above manually if your browser did not launch."); } else { print_info("Opened your browser to GitHub device login."); } print_info("Waiting for GitHub authorization..."); let token = crate::llm::github_copilot_auth::wait_for_device_login(&client, &device) .await .map_err(|e| SetupError::Auth(e.to_string()))?; self.save_github_copilot_token(&client, &token).await } async fn save_github_copilot_token( &mut self, client: &reqwest::Client, token: &str, ) -> Result<(), SetupError> { crate::llm::github_copilot_auth::validate_token(client, token) .await .map_err(|e| SetupError::Auth(e.to_string()))?; if let Ok(ctx) = self.init_secrets_context().await { let key = SecretString::from(token.to_string()); ctx.save_secret("llm_github_copilot_token", &key) .await .map_err(|e| SetupError::Config(format!("Failed to save GitHub token: {e}")))?; print_success("GitHub Copilot token encrypted and saved"); } else { print_info("Secrets not available. Set GITHUB_COPILOT_TOKEN in your environment."); } crate::config::inject_single_var("GITHUB_COPILOT_TOKEN", token); self.llm_api_key = Some(SecretString::from(token.to_string())); print_success("GitHub Copilot configured"); Ok(()) } /// Anthropic OAuth setup: extract token from `claude login` credentials. async fn setup_anthropic_oauth(&mut self) -> Result<(), SetupError> { self.set_llm_backend_preserving_model("anthropic"); // 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, backend: &str, env_var: &str, secret_name: &str, prompt_label: &str, hint_url: &str, override_display_name: Option<&str>, ) -> Result<(), SetupError> { let display_name = override_display_name.unwrap_or(match backend { "anthropic" => "Anthropic", "openai" => "OpenAI", other => other, }); self.set_llm_backend_preserving_model(backend); // Check env var first if let Ok(existing) = std::env::var(env_var) { print_info(&format!("{env_var} found: {}", mask_api_key(&existing))); if confirm("Use this key?", true).map_err(SetupError::Io)? { // Persist env-provided key to secrets store for future runs if let Ok(ctx) = self.init_secrets_context().await { let key = SecretString::from(existing.clone()); if let Err(e) = ctx.save_secret(secret_name, &key).await { tracing::warn!("Failed to persist env key to secrets: {}", e); } } self.llm_api_key = Some(SecretString::from(existing)); print_success(&format!("{display_name} configured (from env)")); return Ok(()); } } println!(); print_info(&format!("Get your API key from: {hint_url}")); println!(); let key = secret_input(prompt_label).map_err(SetupError::Io)?; let key_str = key.expose_secret(); if key_str.is_empty() { return Err(SetupError::Config("API key cannot be empty".to_string())); } // Store in secrets if available if let Ok(ctx) = self.init_secrets_context().await { ctx.save_secret(secret_name, &key) .await .map_err(|e| SetupError::Config(format!("Failed to save API key: {e}")))?; print_success("API key encrypted and saved"); } else { print_info(&format!( "Secrets not available. Set {env_var} in your environment." )); } // 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())); print_success(&format!("{display_name} configured")); Ok(()) } /// OpenAI Codex (ChatGPT subscription) setup: device code OAuth flow. async fn setup_openai_codex(&mut self) -> Result<(), SetupError> { self.settings.llm_backend = Some("openai_codex".to_string()); if self.settings.selected_model.is_some() { self.settings.selected_model = None; } use crate::config::OpenAiCodexConfig; use crate::llm::OpenAiCodexSessionManager; let config = OpenAiCodexConfig::default(); let mgr = OpenAiCodexSessionManager::new(config).map_err(|e| { SetupError::Config(format!("OpenAI Codex session manager init failed: {}", e)) })?; mgr.device_code_login().await.map_err(|e| { SetupError::Config(format!("OpenAI Codex authentication failed: {}", e)) })?; print_success("OpenAI Codex configured (ChatGPT subscription)"); Ok(()) } /// Generic Ollama-style setup: just needs a base URL, no API key. fn setup_ollama_generic( &mut self, def: &crate::llm::ProviderDefinition, ) -> Result<(), SetupError> { self.set_llm_backend_preserving_model(&def.id); let default_url = self .settings .ollama_base_url .as_deref() .or(def.default_base_url.as_deref()) .unwrap_or("http://localhost:11434"); let display_name = def .setup .as_ref() .map(|s| s.display_name()) .unwrap_or(&def.id); let url_input = optional_input( &format!("{display_name} base URL"), Some(&format!("default: {}", default_url)), ) .map_err(SetupError::Io)?; let url = url_input.unwrap_or_else(|| default_url.to_string()); self.settings.ollama_base_url = Some(url.clone()); print_success(&format!("{display_name} configured ({})", url)); Ok(()) } /// AWS Bedrock provider setup: region, auth, and cross-region config. async fn setup_bedrock(&mut self) -> Result<(), SetupError> { self.set_llm_backend_preserving_model("bedrock"); // Region let default_region = self .settings .bedrock_region .as_deref() .unwrap_or("us-east-1"); let region_input = optional_input("AWS region", Some(&format!("default: {}", default_region))) .map_err(SetupError::Io)?; let region = region_input.unwrap_or_else(|| default_region.to_string()); self.settings.bedrock_region = Some(region.clone()); // Auth method print_info("Select authentication method:"); println!(); let auth_options = &[ "AWS default credentials (env vars, ~/.aws/credentials, IAM roles)", "AWS named profile (SSO / assume-role)", ]; let auth_choice = select_one("Auth:", auth_options).map_err(SetupError::Io)?; match auth_choice { 0 => { // Default AWS credentials — clear any stale named profile self.settings.bedrock_profile = None; print_info( "Using default AWS credential chain (env vars, ~/.aws/credentials, IAM roles).", ); } 1 => { // Named profile let profile = input("AWS profile name (from ~/.aws/config)").map_err(SetupError::Io)?; if profile.trim().is_empty() { // Empty input clears any previously configured profile self.settings.bedrock_profile = None; print_info("AWS profile cleared; using default AWS credential chain instead."); } else { self.settings.bedrock_profile = Some(profile.clone()); print_success(&format!("AWS profile '{}' saved", profile)); } } _ => return Err(SetupError::Config("Invalid auth selection".to_string())), } self.setup_bedrock_cross_region() } /// Bedrock cross-region inference prefix selection (sub-step of setup_bedrock). fn setup_bedrock_cross_region(&mut self) -> Result<(), SetupError> { print_info("Cross-region inference routes requests across AWS regions for capacity:"); println!(); let cross_options = &[ "us - route within US regions (recommended for us-east-1)", "global - route to any AWS region worldwide", "eu - route within European regions", "apac - route within Asia-Pacific regions", "none - single-region only (no cross-region routing)", ]; let cross_choice = select_one("Cross-region:", cross_options).map_err(SetupError::Io)?; let cross_region = match cross_choice { 0 => Some("us".to_string()), 1 => Some("global".to_string()), 2 => Some("eu".to_string()), 3 => Some("apac".to_string()), 4 => None, _ => None, }; self.settings.bedrock_cross_region = cross_region; let region = self .settings .bedrock_region .as_deref() .unwrap_or("us-east-1"); print_success(&format!("AWS Bedrock configured (region: {})", region)); Ok(()) } /// Generic OpenAI-compatible setup: base URL + optional API key. async fn setup_openai_compatible_generic( &mut self, backend_id: &str, secret_name: &str, display_name: &str, ) -> Result<(), SetupError> { self.set_llm_backend_preserving_model(backend_id); let existing_url = self .settings .openai_compatible_base_url .clone() .or_else(|| std::env::var("LLM_BASE_URL").ok()); let url = if let Some(ref u) = existing_url { let url_input = optional_input("Base URL", Some(&format!("current: {}", u))) .map_err(SetupError::Io)?; url_input.unwrap_or_else(|| u.clone()) } else { input("Base URL (e.g., http://localhost:8000/v1)").map_err(SetupError::Io)? }; if url.is_empty() { return Err(SetupError::Config(format!( "Base URL is required for {display_name}" ))); } self.settings.openai_compatible_base_url = Some(url.clone()); // Optional API key if confirm("Does this endpoint require an API key?", false).map_err(SetupError::Io)? { let key = secret_input("API key").map_err(SetupError::Io)?; let key_str = key.expose_secret(); if !key_str.is_empty() { if let Ok(ctx) = self.init_secrets_context().await { ctx.save_secret(secret_name, &key) .await .map_err(|e| SetupError::Config(format!("Failed to save API key: {e}")))?; print_success("API key encrypted and saved"); } else { print_info("Secrets not available. Set the API key in your environment."); } } } print_success(&format!("{display_name} configured ({})", url)); Ok(()) } async fn setup_gemini_oauth(&mut self) -> Result<(), SetupError> { self.settings.llm_backend = Some("gemini_oauth".to_string()); print_info("Starting Gemini CLI OAuth authentication..."); println!(); let creds_path = crate::config::GeminiOauthConfig::default_credentials_path(); let cred_manager = crate::llm::gemini_oauth::CredentialManager::new(&creds_path).map_err(|e| { SetupError::Config(format!( "Failed to initialize Gemini credential manager: {}", e )) })?; match cred_manager.get_valid_credential().await { Ok(cred) => { print_success("Gemini CLI authentication successful!"); if let Some(ref pid) = cred.project_id { print_info(&format!("Cloud Code project: {}", pid)); } } Err(e) => { return Err(SetupError::Config(format!( "Gemini CLI authentication failed: {}. Please try again.", e ))); } } println!(); print_success("Gemini API configured via Gemini CLI"); Ok(()) } /// Step 4: Model selection. /// /// Branches on the selected LLM backend and fetches models from the /// appropriate provider API, with static defaults as fallback. async fn step_model_selection(&mut self) -> Result<(), SetupError> { // Show current model if already configured if let Some(ref current) = self.settings.selected_model { print_info(&format!("Current model: {}", current)); println!(); let options = ["Keep current model", "Change model"]; let choice = select_one("What would you like to do?", &options).map_err(SetupError::Io)?; if choice == 0 { print_success(&format!("Keeping {}", current)); return Ok(()); } } let backend = self.settings.llm_backend.as_deref().unwrap_or("nearai"); let registry = crate::llm::ProviderRegistry::load(); match backend { "nearai" => { // NEAR AI: use existing provider list_models() let fetched = self.fetch_nearai_models().await; let models = if fetched.is_empty() { crate::llm::default_models() } else { fetched.iter().map(|m| (m.clone(), m.clone())).collect() }; self.select_from_model_list(&models)?; } "gemini_oauth" | "gemini-oauth" => { let default_models: Vec<(String, String)> = vec![ ( "gemini-3.1-pro-preview".into(), "Gemini 3.1 Pro (Latest, strongest reasoning)".into(), ), ( "gemini-3.1-pro-preview-customtools".into(), "Gemini 3.1 Pro Custom Tools (Enhanced tool use)".into(), ), ( "gemini-3-pro-preview".into(), "Gemini 3 Pro (Preview)".into(), ), ( "gemini-3-flash-preview".into(), "Gemini 3 Flash (Fast preview with thinking)".into(), ), ( "gemini-3.1-flash-lite-preview".into(), "Gemini 3.1 Flash Lite (Preview, lightweight)".into(), ), ( "gemini-2.5-pro".into(), "Gemini 2.5 Pro (Stable, strong reasoning)".into(), ), ( "gemini-2.5-flash".into(), "Gemini 2.5 Flash (Fast, good quality)".into(), ), ( "gemini-2.5-flash-lite".into(), "Gemini 2.5 Flash Lite (Fastest, lightweight)".into(), ), ]; self.select_from_model_list(&default_models)?; } "bedrock" => { let model_id = input("Bedrock model ID (e.g., anthropic.claude-v3-sonnet-20240229-v1:0)") .map_err(SetupError::Io)?; if model_id.is_empty() { return Err(SetupError::Config("Model ID is required".to_string())); } self.settings.selected_model = Some(model_id.clone()); print_success(&format!("Selected {}", model_id)); } _ => { if let Some(def) = registry.find(backend) { let can_list = def .setup .as_ref() .map(|s| s.can_list_models()) .unwrap_or(false); if can_list { // Try to fetch models from the provider's /v1/models endpoint let cached_key = self .llm_api_key .as_ref() .map(|k| k.expose_secret().to_string()); let models = match backend { "anthropic" => fetch_anthropic_models(cached_key.as_deref()).await, "openai" => fetch_openai_models(cached_key.as_deref()).await, "ollama" => { let base_url = self .settings .ollama_base_url .as_deref() .or(def.default_base_url.as_deref()) .unwrap_or("http://localhost:11434"); let models = fetch_ollama_models(base_url).await; if models.is_empty() { print_info( "No models found. Pull one first: ollama pull llama3", ); } models } _ => { // Generic OpenAI-compatible model listing let base_url = def.default_base_url.as_deref().unwrap_or(""); fetch_openai_compatible_models(base_url, cached_key.as_deref()) .await } }; // Apply models_filter from setup hint let models = if let Some(filter) = def.setup.as_ref().and_then(|s| s.models_filter()) { let filter_lower = filter.to_lowercase(); models .into_iter() .filter(|(id, _)| id.to_lowercase().contains(&filter_lower)) .collect() } else { models }; if models.is_empty() { // Fall back to manual entry let default = &def.default_model; let model_id = input(&format!("Model name (default: {default})")) .map_err(SetupError::Io)?; let model_id = if model_id.is_empty() { default.clone() } else { model_id }; self.settings.selected_model = Some(model_id.clone()); print_success(&format!("Selected {}", model_id)); } else { self.select_from_model_list(&models)?; } } else { // Manual model entry let default = &def.default_model; let model_id = input(&format!("Model name (default: {default})")) .map_err(SetupError::Io)?; let model_id = if model_id.is_empty() { default.clone() } else { model_id }; self.settings.selected_model = Some(model_id.clone()); print_success(&format!("Selected {}", model_id)); } } else { // Unknown provider, manual entry let model_id = input("Model name (e.g., meta-llama/Llama-3-8b-chat-hf)") .map_err(SetupError::Io)?; if model_id.is_empty() { return Err(SetupError::Config("Model name is required".to_string())); } self.settings.selected_model = Some(model_id.clone()); print_success(&format!("Selected {}", model_id)); } } } Ok(()) } /// Present a model list to the user, with a "Custom model ID" escape hatch. /// /// Each entry is `(model_id, display_label)`. fn select_from_model_list(&mut self, models: &[(String, String)]) -> Result<(), SetupError> { println!("Available models:"); println!(); let mut options: Vec<&str> = models.iter().map(|(_, desc)| desc.as_str()).collect(); options.push("Custom model ID"); let choice = select_one("Select a model:", &options).map_err(SetupError::Io)?; let selected = if choice == options.len() - 1 { loop { let raw = input("Enter model ID").map_err(SetupError::Io)?; let trimmed = raw.trim().to_string(); if trimmed.is_empty() { println!("Model ID cannot be empty."); continue; } break trimmed; } } else { models[choice].0.clone() }; self.settings.selected_model = Some(selected.clone()); print_success(&format!("Selected {}", selected)); Ok(()) } /// Fetch available models from the NEAR AI API. /// /// Uses [`build_nearai_model_fetch_config`] to construct the provider config, /// which reads `NEARAI_API_KEY` from the environment when present. async fn fetch_nearai_models(&self) -> Vec { let session = match self.session_manager { Some(ref s) => Arc::clone(s), None => return vec![], }; use crate::llm::create_llm_provider; let config = build_nearai_model_fetch_config(); match create_llm_provider(&config, session).await { Ok(provider) => match provider.list_models().await { Ok(models) => models, Err(e) => { print_info(&format!("Could not fetch models: {}. Using defaults.", e)); vec![] } }, Err(e) => { print_info(&format!( "Could not initialize provider: {}. Using defaults.", e )); vec![] } } } /// Step 5: Embeddings configuration. fn step_embeddings(&mut self) -> Result<(), SetupError> { print_info("Embeddings enable semantic search in your workspace memory."); println!(); if !confirm("Enable semantic search?", true).map_err(SetupError::Io)? { self.settings.embeddings.enabled = false; print_info("Embeddings disabled. Workspace will use keyword search only."); return Ok(()); } let backend = self.settings.llm_backend.as_deref().unwrap_or("nearai"); let has_openai_key = std::env::var("OPENAI_API_KEY").is_ok() || (backend == "openai" && self.llm_api_key.is_some()); let has_nearai = backend == "nearai" || self.session_manager.is_some(); // If the LLM backend is OpenAI and we already have a key, default to OpenAI embeddings if backend == "openai" && has_openai_key { self.settings.embeddings.enabled = true; self.settings.embeddings.provider = "openai".to_string(); self.settings.embeddings.model = "text-embedding-3-small".to_string(); print_success("Embeddings enabled via OpenAI (using existing API key)"); return Ok(()); } // If no NEAR AI session and no OpenAI key, only OpenAI is viable if !has_nearai && !has_openai_key { print_info("No NEAR AI session or OpenAI key found for embeddings."); print_info("Set OPENAI_API_KEY in your environment to enable embeddings."); self.settings.embeddings.enabled = false; return Ok(()); } let mut options = Vec::new(); if has_nearai { options.push("NEAR AI (uses same auth, no extra cost)"); } options.push("OpenAI (requires API key)"); let choice = select_one("Select embeddings provider:", &options).map_err(SetupError::Io)?; // Map choice back to provider name let provider = if has_nearai && choice == 0 { "nearai" } else { "openai" }; match provider { "nearai" => { self.settings.embeddings.enabled = true; self.settings.embeddings.provider = "nearai".to_string(); self.settings.embeddings.model = "text-embedding-3-small".to_string(); print_success("Embeddings enabled via NEAR AI"); } _ => { if !has_openai_key { print_info("OPENAI_API_KEY not set in environment."); print_info("Add it to your .env file or environment to enable embeddings."); } self.settings.embeddings.enabled = true; self.settings.embeddings.provider = "openai".to_string(); self.settings.embeddings.model = "text-embedding-3-small".to_string(); print_success("Embeddings configured for OpenAI"); } } Ok(()) } /// Initialize secrets context for channel setup. async fn init_secrets_context(&mut self) -> Result { // Get crypto (should be set from step 2, or load from keychain/env) let crypto = if let Some(ref c) = self.secrets_crypto { Arc::clone(c) } else { // Try to load master key from keychain or env let key = if let Ok(env_key) = std::env::var("SECRETS_MASTER_KEY") { env_key } else if let Ok(keychain_key) = crate::secrets::keychain::get_master_key().await { keychain_key.iter().map(|b| format!("{:02x}", b)).collect() } else { return Err(SetupError::Config( "Secrets not configured. Run full setup or set SECRETS_MASTER_KEY.".to_string(), )); }; let crypto = Arc::new( SecretsCrypto::new(SecretString::from(key)) .map_err(|e| SetupError::Config(e.to_string()))?, ); self.secrets_crypto = Some(Arc::clone(&crypto)); crypto }; // Create backend-appropriate secrets store. // Use runtime dispatch based on the user's selected backend. // Default to whichever backend is compiled in. When only libsql is // available, we must not default to "postgres" or we'd skip store creation. let default_backend = { #[cfg(feature = "postgres")] { "postgres" } #[cfg(not(feature = "postgres"))] { "libsql" } }; let selected_backend = self .settings .database_backend .as_deref() .unwrap_or(default_backend); match selected_backend { #[cfg(feature = "libsql")] "libsql" | "turso" | "sqlite" => { if let Some(store) = self.create_libsql_secrets_store(&crypto)? { return Ok(SecretsContext::from_store(store, self.owner_id())); } // Fallback to postgres if libsql store creation returned None #[cfg(feature = "postgres")] if let Some(store) = self.create_postgres_secrets_store(&crypto).await? { return Ok(SecretsContext::from_store(store, self.owner_id())); } } #[cfg(feature = "postgres")] _ => { if let Some(store) = self.create_postgres_secrets_store(&crypto).await? { return Ok(SecretsContext::from_store(store, self.owner_id())); } // Fallback to libsql if postgres store creation returned None #[cfg(feature = "libsql")] if let Some(store) = self.create_libsql_secrets_store(&crypto)? { return Ok(SecretsContext::from_store(store, self.owner_id())); } } #[cfg(not(feature = "postgres"))] _ => {} } Err(SetupError::Config( "No database backend available for secrets storage".to_string(), )) } /// Create a PostgreSQL secrets store from the current pool. #[cfg(feature = "postgres")] async fn create_postgres_secrets_store( &mut self, crypto: &Arc, ) -> Result>, SetupError> { let pool = if let Some(ref p) = self.db_pool { p.clone() } else { // Fall back to creating one from settings/env let url = self .settings .database_url .clone() .or_else(|| std::env::var("DATABASE_URL").ok()); if let Some(url) = url { self.test_database_connection_postgres(&url).await?; self.run_migrations_postgres().await?; match self.db_pool.clone() { Some(pool) => pool, None => { return Err(SetupError::Database( "Database pool not initialized after connection test".to_string(), )); } } } else { return Ok(None); } }; let store: Arc = Arc::new(crate::secrets::PostgresSecretsStore::new( pool, Arc::clone(crypto), )); Ok(Some(store)) } /// Create a libSQL secrets store from the current backend. #[cfg(feature = "libsql")] fn create_libsql_secrets_store( &self, crypto: &Arc, ) -> Result>, SetupError> { if let Some(ref backend) = self.db_backend { let store: Arc = Arc::new(crate::secrets::LibSqlSecretsStore::new( backend.shared_db(), Arc::clone(crypto), )); Ok(Some(store)) } else { Ok(None) } } /// Step 6: Channel configuration. async fn step_channels(&mut self) -> Result<(), SetupError> { // First, configure tunnel (shared across all channels that need webhooks) match setup_tunnel(&self.settings).await { Ok(tunnel_settings) => { self.settings.tunnel = tunnel_settings; } Err(e) => { print_info(&format!("Tunnel setup skipped: {}", e)); } } println!(); // Discover available WASM channels let channels_dir = ironclaw_base_dir().join("channels"); let mut discovered_channels = discover_wasm_channels(&channels_dir).await; let installed_names: HashSet = discovered_channels .iter() .map(|(name, _)| name.clone()) .collect(); // Build channel list from registry (if available) + bundled + discovered let wasm_channel_names = build_channel_options(&discovered_channels); // Build options list dynamically let mut options: Vec<(String, bool)> = vec![ ("CLI/TUI (always enabled)".to_string(), true), ( "HTTP webhook".to_string(), self.settings.channels.http_enabled, ), ("Signal".to_string(), self.settings.channels.signal_enabled), ]; let non_wasm_count = options.len(); // Add available WASM channels (installed + bundled + registry) for name in &wasm_channel_names { let is_enabled = self.settings.channels.wasm_channels.contains(name); let label = if installed_names.contains(name) { format!("{} (installed)", capitalize_first(name)) } else { format!("{} (will install)", capitalize_first(name)) }; options.push((label, is_enabled)); } let options_refs: Vec<(&str, bool)> = options.iter().map(|(s, b)| (s.as_str(), *b)).collect(); let selected = select_many("Which channels do you want to enable?", &options_refs) .map_err(SetupError::Io)?; let selected_wasm_channels: Vec = wasm_channel_names .iter() .enumerate() .filter_map(|(idx, name)| { if selected.contains(&(non_wasm_count + idx)) { Some(name.clone()) } else { None } }) .collect(); // Install selected channels that aren't already on disk let mut any_installed = false; // Try bundled channels first (pre-compiled artifacts from channels-src/) if let Some(installed) = install_selected_bundled_channels( &channels_dir, &selected_wasm_channels, &installed_names, ) .await? && !installed.is_empty() { print_success(&format!( "Installed bundled channels: {}", installed.join(", ") )); any_installed = true; } let installed_from_registry = install_selected_registry_channels( &channels_dir, &selected_wasm_channels, &installed_names, ) .await; if !installed_from_registry.is_empty() { print_success(&format!( "Built from registry: {}", installed_from_registry.join(", ") )); any_installed = true; } // Re-discover after installs if any_installed { discovered_channels = discover_wasm_channels(&channels_dir).await; } // Determine if we need secrets context let needs_secrets = selected.contains(&CHANNEL_INDEX_HTTP) || !selected_wasm_channels.is_empty(); let secrets = if needs_secrets { match self.init_secrets_context().await { Ok(ctx) => Some(ctx), Err(e) => { print_info(&format!("Secrets not available: {}", e)); print_info("Channel tokens must be set via environment variables."); None } } } else { None }; // HTTP channel if selected.contains(&CHANNEL_INDEX_HTTP) { println!(); if let Some(ref ctx) = secrets { let result = setup_http(ctx).await?; self.settings.channels.http_enabled = result.enabled; self.settings.channels.http_port = Some(result.port); } else { self.settings.channels.http_enabled = true; self.settings.channels.http_port = Some(8080); print_info("HTTP webhook enabled on port 8080 (set HTTP_WEBHOOK_SECRET in env)"); } } else { self.settings.channels.http_enabled = false; } // Signal channel if selected.contains(&CHANNEL_INDEX_SIGNAL) { println!(); let result = setup_signal(&self.settings).await?; self.settings.channels.signal_enabled = result.enabled; self.settings.channels.signal_http_url = Some(result.http_url); self.settings.channels.signal_account = Some(result.account); self.settings.channels.signal_allow_from = Some(result.allow_from); self.settings.channels.signal_allow_from_groups = Some(result.allow_from_groups); self.settings.channels.signal_dm_policy = Some(result.dm_policy); self.settings.channels.signal_group_policy = Some(result.group_policy); self.settings.channels.signal_group_allow_from = Some(result.group_allow_from); } else { self.settings.channels.signal_enabled = false; self.settings.channels.signal_http_url = None; self.settings.channels.signal_account = None; self.settings.channels.signal_allow_from = None; self.settings.channels.signal_allow_from_groups = None; self.settings.channels.signal_dm_policy = None; self.settings.channels.signal_group_policy = None; self.settings.channels.signal_group_allow_from = None; } let discovered_by_name: HashMap = discovered_channels.into_iter().collect(); // Process selected WASM channels let mut enabled_wasm_channels = Vec::new(); for channel_name in selected_wasm_channels { println!(); if let Some(ref ctx) = secrets { let result = if let Some(cap_file) = discovered_by_name.get(&channel_name) { if !cap_file.setup.required_secrets.is_empty() { setup_wasm_channel(ctx, &channel_name, &cap_file.setup).await? } else { print_info(&format!( "No setup configuration found for {}", channel_name )); crate::setup::channels::WasmChannelSetupResult { enabled: true, channel_name: channel_name.clone(), } } } else { print_info(&format!( "Channel '{}' is selected but not available on disk.", channel_name )); continue; }; if result.enabled { enabled_wasm_channels.push(result.channel_name); } } else { // No secrets context, just enable the channel print_info(&format!( "{} enabled (configure tokens via environment)", capitalize_first(&channel_name) )); enabled_wasm_channels.push(channel_name.clone()); } } self.settings.channels.wasm_channels = enabled_wasm_channels; Ok(()) } /// Step 7: Extensions (tools) installation from registry. async fn step_extensions(&mut self) -> Result<(), SetupError> { let catalog = match load_registry_catalog() { Some(c) => c, None => { print_info("Extension registry not found. Skipping tool installation."); print_info("Install tools manually with: ironclaw tool install "); return Ok(()); } }; let tools: Vec<_> = catalog .list(Some(crate::registry::manifest::ManifestKind::Tool), None) .into_iter() .cloned() .collect(); if tools.is_empty() { print_info("No tools found in registry."); return Ok(()); } print_info("Available tools from the extension registry:"); print_info("Select which tools to install. You can install more later with:"); print_info(" ironclaw registry install "); println!(); // Check which tools are already installed let tools_dir = ironclaw_base_dir().join("tools"); let installed_tools = discover_installed_tools(&tools_dir).await; // Build options: show display_name + description, pre-check "default" tagged + already installed let mut options: Vec<(String, bool)> = Vec::new(); for tool in &tools { let is_installed = installed_tools.contains(&tool.name); let is_default = tool.tags.contains(&"default".to_string()); let status = if is_installed { " (installed)" } else { "" }; let auth_hint = tool .auth_summary .as_ref() .and_then(|a| a.method.as_deref()) .map(|m| format!(" [{}]", m)) .unwrap_or_default(); let label = format!( "{}{}{} - {}", tool.display_name, auth_hint, status, tool.description ); options.push((label, is_default || is_installed)); } let options_refs: Vec<(&str, bool)> = options.iter().map(|(s, b)| (s.as_str(), *b)).collect(); let selected = select_many("Which tools do you want to install?", &options_refs) .map_err(SetupError::Io)?; if selected.is_empty() { print_info("No tools selected."); return Ok(()); } // Install selected tools that aren't already on disk let repo_root = catalog.root().parent().unwrap_or(catalog.root()); let installer = crate::registry::installer::RegistryInstaller::new( repo_root.to_path_buf(), tools_dir.clone(), ironclaw_base_dir().join("channels"), ); let mut installed_count = 0; let mut auth_needed: Vec = Vec::new(); for idx in &selected { let tool = &tools[*idx]; if installed_tools.contains(&tool.name) { continue; // Already installed, skip } match installer.install_with_source_fallback(tool, false).await { Ok(outcome) => { print_success(&format!("Installed {}", outcome.name)); for warning in &outcome.warnings { print_info(&format!("{}: {}", outcome.name, warning)); } installed_count += 1; // Track auth needs if let Some(auth) = &tool.auth_summary && auth.method.as_deref() != Some("none") && auth.method.is_some() { let provider = auth.provider.as_deref().unwrap_or(&tool.name); // Only mention unique providers (Google tools share auth) let hint = format!(" {} - ironclaw tool auth {}", provider, tool.name); if !auth_needed .iter() .any(|h| h.starts_with(&format!(" {} -", provider))) { auth_needed.push(hint); } } } Err(e) => { print_error(&format!("Failed to install {}: {}", tool.display_name, e)); } } } if installed_count > 0 { println!(); print_success(&format!("{} tool(s) installed.", installed_count)); } if !auth_needed.is_empty() { println!(); print_info("Some tools need authentication. Run after setup:"); for hint in &auth_needed { print_info(hint); } } Ok(()) } /// Step 8: Docker Sandbox -- check Docker installation and availability. async fn step_docker_sandbox(&mut self) -> Result<(), SetupError> { print_info("IronClaw can execute code, run builds, and use tools inside Docker"); print_info("containers. This keeps your system safe -- commands from the LLM run"); print_info("in an isolated sandbox with no access to your credentials, limited"); print_info("filesystem access, and network traffic restricted to an allowlist."); println!(); print_info("Without Docker, code execution tools (shell, file write) run directly"); print_info("on your machine with no isolation."); println!(); if !confirm("Enable Docker sandbox?", false).map_err(SetupError::Io)? { self.settings.sandbox.enabled = false; print_info("Sandbox disabled. You can enable it later with SANDBOX_ENABLED=true."); return Ok(()); } // Check Docker availability let detection = crate::sandbox::detect::check_docker().await; match detection.status { crate::sandbox::detect::DockerStatus::Available => { self.settings.sandbox.enabled = true; print_success("Docker is installed and running. Sandbox enabled."); } crate::sandbox::detect::DockerStatus::NotInstalled | crate::sandbox::detect::DockerStatus::NotRunning => { println!(); let not_installed = detection.status == crate::sandbox::detect::DockerStatus::NotInstalled; if not_installed { print_error("Docker is not installed."); print_info(detection.platform.install_hint()); } else { print_error("Docker is installed but not running."); print_info(detection.platform.start_hint()); } println!(); let retry_prompt = if not_installed { "Retry after installing Docker?" } else { "Retry after starting Docker?" }; if confirm(retry_prompt, false).map_err(SetupError::Io)? { let retry = crate::sandbox::detect::check_docker().await; if retry.status.is_ok() { self.settings.sandbox.enabled = true; print_success(if not_installed { "Docker is now available. Sandbox enabled." } else { "Docker is now running. Sandbox enabled." }); } else { self.settings.sandbox.enabled = false; print_info(if not_installed { "Docker still not available. Sandbox disabled for now." } else { "Docker still not responding. Sandbox disabled for now." }); } } else { self.settings.sandbox.enabled = false; print_info(if not_installed { "Sandbox disabled. Install Docker and set SANDBOX_ENABLED=true later." } else { "Sandbox disabled. Start Docker and set SANDBOX_ENABLED=true later." }); } } crate::sandbox::detect::DockerStatus::Disabled => { self.settings.sandbox.enabled = false; } } // 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(()) } /// Step 9: Heartbeat configuration. fn step_heartbeat(&mut self) -> Result<(), SetupError> { print_info("Heartbeat runs periodic background tasks (e.g., checking your calendar,"); print_info("monitoring for notifications, running scheduled workflows)."); println!(); if !confirm("Enable heartbeat?", false).map_err(SetupError::Io)? { self.settings.heartbeat.enabled = false; print_info("Heartbeat disabled."); return Ok(()); } self.settings.heartbeat.enabled = true; // Interval let interval_str = optional_input("Check interval in minutes", Some("default: 30")) .map_err(SetupError::Io)?; if let Some(s) = interval_str { if let Ok(mins) = s.parse::() { self.settings.heartbeat.interval_secs = mins * 60; } } else { self.settings.heartbeat.interval_secs = 1800; // 30 minutes } // Notify channel let notify_channel = optional_input("Notify channel on findings", Some("e.g., telegram")) .map_err(SetupError::Io)?; self.settings.heartbeat.notify_channel = notify_channel; print_success(&format!( "Heartbeat enabled (every {} minutes)", self.settings.heartbeat.interval_secs / 60 )); Ok(()) } /// Persist current settings to the database. /// /// Returns `Ok(true)` if settings were saved, `Ok(false)` if no database /// connection is available yet (e.g., before Step 1 completes). async fn persist_settings(&self) -> Result { let db_map = self.settings.to_db_map(); let saved = false; #[cfg(feature = "postgres")] let saved = if !saved { if let Some(ref pool) = self.db_pool { let store = crate::history::Store::from_pool(pool.clone()); store .set_all_settings(self.owner_id(), &db_map) .await .map_err(|e| { SetupError::Database(format!("Failed to save settings to database: {}", e)) })?; true } else { false } } else { saved }; #[cfg(feature = "libsql")] let saved = if !saved { if let Some(ref backend) = self.db_backend { use crate::db::SettingsStore as _; backend .set_all_settings(self.owner_id(), &db_map) .await .map_err(|e| { SetupError::Database(format!("Failed to save settings to database: {}", e)) })?; true } else { false } } else { saved }; Ok(saved) } /// Write bootstrap environment variables to `~/.ironclaw/.env`. /// /// Only true chicken-and-egg settings are written here — things needed /// before the database is connected: `DATABASE_BACKEND`, `DATABASE_URL`, /// `LIBSQL_PATH`, `SECRETS_MASTER_KEY`, `ONBOARD_COMPLETED`, and /// channel config vars (Signal, Claude Code sandbox). /// /// **LLM settings and credentials are NOT written here.** `LLM_BACKEND`, /// base URLs, and model names are persisted to the DB via /// `persist_settings()` and loaded by `Config::from_db_with_toml()`. /// API keys live only in the encrypted secrets DB and are injected via /// `inject_llm_keys_from_secrets()` after DB init. fn write_bootstrap_env(&self) -> Result<(), SetupError> { let mut env_vars: Vec<(String, String)> = Vec::new(); if let Some(ref backend) = self.settings.database_backend { env_vars.push(("DATABASE_BACKEND".to_string(), backend.clone())); } if let Some(ref url) = self.settings.database_url { env_vars.push(("DATABASE_URL".to_string(), url.clone())); } if let Some(ref path) = self.settings.libsql_path { env_vars.push(("LIBSQL_PATH".to_string(), path.clone())); } if let Some(ref url) = self.settings.libsql_url { env_vars.push(("LIBSQL_URL".to_string(), url.clone())); } // Secrets master key (env var mode): write to .env so it's available // on next startup before the DB is connected. if let Some(ref key_hex) = self.settings.secrets_master_key_hex { env_vars.push(("SECRETS_MASTER_KEY".to_string(), key_hex.clone())); } // Always write ONBOARD_COMPLETED so that check_onboard_needed() // (which runs before the DB is connected) knows to skip re-onboarding. if self.settings.onboard_completed { 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())); } if let Some(ref account) = self.settings.channels.signal_account { env_vars.push(("SIGNAL_ACCOUNT".to_string(), account.clone())); } if let Some(ref allow_from) = self.settings.channels.signal_allow_from { env_vars.push(("SIGNAL_ALLOW_FROM".to_string(), allow_from.clone())); } if let Some(ref allow_from_groups) = self.settings.channels.signal_allow_from_groups && !allow_from_groups.is_empty() { env_vars.push(( "SIGNAL_ALLOW_FROM_GROUPS".to_string(), allow_from_groups.clone(), )); } if let Some(ref dm_policy) = self.settings.channels.signal_dm_policy { env_vars.push(("SIGNAL_DM_POLICY".to_string(), dm_policy.clone())); } if let Some(ref group_policy) = self.settings.channels.signal_group_policy { env_vars.push(("SIGNAL_GROUP_POLICY".to_string(), group_policy.clone())); } if let Some(ref group_allow_from) = self.settings.channels.signal_group_allow_from && !group_allow_from.is_empty() { env_vars.push(( "SIGNAL_GROUP_ALLOW_FROM".to_string(), group_allow_from.clone(), )); } if !env_vars.is_empty() { let pairs: Vec<(&str, &str)> = env_vars .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); crate::bootstrap::upsert_bootstrap_vars(&pairs).map_err(|e| { SetupError::Io(std::io::Error::other(format!( "Failed to save bootstrap env to .env: {}", e ))) })?; } Ok(()) } /// Persist the NEAR AI session token to the database. /// /// The session manager writes to disk during `ensure_authenticated()` but /// doesn't have a DB store attached during onboarding. This reads the /// session file from disk and stores it under the `nearai.session_token` /// key so the runtime's `attach_store()` finds it without fallback. /// /// Best-effort: silently ignores errors (no DB connection yet, no /// session file, etc.). async fn persist_session_to_db(&self) { let session_path = crate::config::llm::default_session_path(); let data = match std::fs::read_to_string(&session_path) { Ok(d) if !d.trim().is_empty() => d, _ => return, }; let value: serde_json::Value = match serde_json::from_str(&data) { Ok(v) => v, Err(_) => return, }; #[cfg(feature = "postgres")] if let Some(ref pool) = self.db_pool { let store = crate::history::Store::from_pool(pool.clone()); if let Err(e) = store .set_setting(self.owner_id(), "nearai.session_token", &value) .await { tracing::debug!("Could not persist session token to postgres: {}", e); } else { tracing::debug!("Session token persisted to database"); return; } } #[cfg(feature = "libsql")] if let Some(ref backend) = self.db_backend { use crate::db::SettingsStore as _; if let Err(e) = backend .set_setting(self.owner_id(), "nearai.session_token", &value) .await { tracing::debug!("Could not persist session token to libsql: {}", e); } else { tracing::debug!("Session token persisted to database"); } } } /// Persist settings to DB and bootstrap .env after each step. /// /// Silently ignores errors (e.g., DB not connected yet before step 1 /// completes). This is best-effort incremental persistence. async fn persist_after_step(&self) { // Write bootstrap .env (always possible) if let Err(e) = self.write_bootstrap_env() { tracing::debug!("Could not write bootstrap env after step: {}", e); } // Persist to DB match self.persist_settings().await { Ok(true) => tracing::debug!("Settings persisted to database after step"), Ok(false) => tracing::debug!("No DB connection yet, skipping settings persist"), Err(e) => tracing::debug!("Could not persist settings after step: {}", e), } } /// Load previously saved settings from the database after Step 1 /// establishes a connection. /// /// This enables recovery from partial onboarding runs: if the user /// completed steps 1-4 previously but step 5 failed, re-running /// the wizard will pre-populate settings from the database. /// /// **Callers must re-apply any wizard choices made before this call** /// via `self.settings.merge_from(&step_settings)`, since `merge_from` /// prefers the `other` argument's non-default values. Without this, /// stale DB values would overwrite fresh user choices. async fn try_load_existing_settings(&mut self) { let loaded = false; #[cfg(feature = "postgres")] let loaded = if !loaded { if let Some(ref pool) = self.db_pool { let store = crate::history::Store::from_pool(pool.clone()); match store.get_all_settings(self.owner_id()).await { Ok(db_map) if !db_map.is_empty() => { let existing = Settings::from_db_map(&db_map); self.settings.merge_from(&existing); tracing::info!("Loaded {} existing settings from database", db_map.len()); true } Ok(_) => false, Err(e) => { tracing::debug!("Could not load existing settings: {}", e); false } } } else { false } } else { loaded }; #[cfg(feature = "libsql")] let loaded = if !loaded { if let Some(ref backend) = self.db_backend { use crate::db::SettingsStore as _; match backend.get_all_settings(self.owner_id()).await { Ok(db_map) if !db_map.is_empty() => { let existing = Settings::from_db_map(&db_map); self.settings.merge_from(&existing); tracing::info!("Loaded {} existing settings from database", db_map.len()); true } Ok(_) => false, Err(e) => { tracing::debug!("Could not load existing settings: {}", e); false } } } else { false } } else { loaded }; // Suppress unused variable warning when only one backend is compiled. let _ = loaded; } /// Save settings to the database and `~/.ironclaw/.env`, then print /// a warm completion card with the 3 key facts. async fn save_and_summarize(&mut self) -> Result<(), SetupError> { use crate::cli::fmt; self.settings.onboard_completed = true; // Final persist (idempotent — earlier incremental saves already wrote // most settings, but this ensures onboard_completed is saved). let saved = self.persist_settings().await?; if !saved { return Err(SetupError::Database( "No database connection, cannot save settings".to_string(), )); } // Write bootstrap env (also idempotent) self.write_bootstrap_env()?; // ── Completion card ─────────────────────────────────── let sep = fmt::separator(38); println!(); println!(" {}", sep); println!(); // Title line: checkmark + "ironclaw is ready" println!( " {}\u{2713}{} {}ironclaw is ready{}", fmt::success(), fmt::reset(), fmt::bold_accent(), fmt::reset(), ); println!(); // Fact 1: Provider + model let provider_display = match self.settings.llm_backend.as_deref() { Some("nearai") => "NEAR AI".to_string(), Some("anthropic") => "Anthropic".to_string(), Some("openai") => "OpenAI".to_string(), Some("ollama") => "Ollama".to_string(), Some("openai_compatible") => "OpenAI-compatible".to_string(), Some("bedrock") => "AWS Bedrock".to_string(), Some("openai_codex") => "OpenAI Codex".to_string(), Some("gemini_oauth") => "Gemini CLI".to_string(), Some(other) => other.to_string(), None => "unknown".to_string(), }; let model_suffix = if let Some(ref model) = self.settings.selected_model { // Truncate long model names (char-based to avoid UTF-8 panic) let display = if model.chars().count() > 30 { let truncated: String = model.chars().take(27).collect(); format!("{}...", truncated) } else { model.clone() }; format!(" ({})", display) } else { String::new() }; let provider_value = format!("{}{}", provider_display, model_suffix); println!( " {}provider{} {}{}{}", fmt::dim(), fmt::reset(), fmt::accent(), provider_value, fmt::reset(), ); // Fact 2: Database let db_display = match self.settings.database_backend.as_deref() { Some("libsql") => "libSQL".to_string(), Some("postgres") | Some("postgresql") => "PostgreSQL".to_string(), Some(other) => other.to_string(), None => "unknown".to_string(), }; println!( " {}database{} {}{}{}", fmt::dim(), fmt::reset(), fmt::accent(), db_display, fmt::reset(), ); // Fact 3: Security let security_display = match self.settings.secrets_master_key_source { KeySource::Keychain => "OS keychain", KeySource::Env => "environment variable", KeySource::None => "disabled", }; println!( " {}security{} {}{}{}", fmt::dim(), fmt::reset(), fmt::accent(), security_display, fmt::reset(), ); println!(); println!(" {}", sep); println!(); // Action hints println!( " {}Start chatting:{} {}ironclaw{}", fmt::dim(), fmt::reset(), fmt::bold_accent(), fmt::reset(), ); println!( " {}Full setup:{} {}ironclaw onboard{}", fmt::dim(), fmt::reset(), fmt::bold_accent(), fmt::reset(), ); println!(); if self.config.quick { print_info( "Tip: Run `ironclaw onboard` to configure channels, extensions, embeddings, and more.", ); println!(); } Ok(()) } } impl Default for SetupWizard { fn default() -> Self { Self::new() } } /// Mask password in a database URL for display. #[cfg(feature = "postgres")] fn mask_password_in_url(url: &str) -> String { // URL format: scheme://user:password@host/database // Find "://" to locate start of credentials let Some(scheme_end) = url.find("://") else { return url.to_string(); }; let credentials_start = scheme_end + 3; // After "://" // Find "@" to locate end of credentials let Some(at_pos) = url[credentials_start..].find('@') else { return url.to_string(); }; let at_abs = credentials_start + at_pos; // Find ":" in the credentials section (separates user from password) let credentials = &url[credentials_start..at_abs]; let Some(colon_pos) = credentials.find(':') else { return url.to_string(); }; // Build masked URL: scheme://user:****@host/database let scheme = &url[..credentials_start]; // "postgres://" let username = &credentials[..colon_pos]; // "user" let after_at = &url[at_abs..]; // "@localhost/db" format!("{}{}:****{}", scheme, username, after_at) } /// Discover WASM channels in a directory. /// /// Returns a list of (channel_name, capabilities_file) pairs. async fn discover_wasm_channels(dir: &std::path::Path) -> Vec<(String, ChannelCapabilitiesFile)> { let mut channels = Vec::new(); if !dir.is_dir() { return channels; } let mut entries = match tokio::fs::read_dir(dir).await { Ok(e) => e, Err(_) => return channels, }; while let Ok(Some(entry)) = entries.next_entry().await { let path = entry.path(); // Look for .capabilities.json files let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); if !filename.ends_with(".capabilities.json") { continue; } // Extract channel name let name = filename.trim_end_matches(".capabilities.json").to_string(); if name.is_empty() { continue; } // Check if corresponding .wasm file exists let wasm_path = dir.join(format!("{}.wasm", name)); if !wasm_path.exists() { continue; } // Parse capabilities file match tokio::fs::read(&path).await { Ok(bytes) => match ChannelCapabilitiesFile::from_bytes(&bytes) { Ok(cap_file) => { channels.push((name, cap_file)); } Err(e) => { tracing::warn!( path = %path.display(), error = %e, "Failed to parse channel capabilities file" ); } }, Err(e) => { tracing::warn!( path = %path.display(), error = %e, "Failed to read channel capabilities file" ); } } } // Sort by name for consistent ordering channels.sort_by(|a, b| a.0.cmp(&b.0)); channels } /// Mask an API key for display: show first 6 + last 4 chars. /// /// Uses char-based indexing to avoid panicking on multi-byte UTF-8. fn mask_api_key(key: &str) -> String { let chars: Vec = key.chars().collect(); if chars.len() < 12 { let prefix: String = chars.iter().take(4).collect(); return format!("{prefix}..."); } let prefix: String = chars[..6].iter().collect(); let suffix: String = chars[chars.len() - 4..].iter().collect(); format!("{prefix}...{suffix}") } /// Capitalize the first letter of a string. fn capitalize_first(s: &str) -> String { let mut chars = s.chars(); match chars.next() { None => String::new(), Some(first) => first.to_uppercase().chain(chars).collect(), } } #[cfg(test)] async fn install_missing_bundled_channels( channels_dir: &std::path::Path, already_installed: &HashSet, ) -> Result, SetupError> { let mut installed = Vec::new(); for name in available_channel_names().iter().copied() { if already_installed.contains(name) { continue; } install_bundled_channel(name, channels_dir, false) .await .map_err(SetupError::Channel)?; installed.push(name.to_string()); } Ok(installed) } /// Build channel options from discovered channels + bundled + registry catalog. /// /// Returns a deduplicated, sorted list of channel names available for selection. fn build_channel_options(discovered: &[(String, ChannelCapabilitiesFile)]) -> Vec { let mut names: Vec = discovered.iter().map(|(name, _)| name.clone()).collect(); // Add bundled channels for bundled in available_channel_names().iter().copied() { if !names.iter().any(|name| name == bundled) { names.push(bundled.to_string()); } } // Add registry channels if let Some(catalog) = load_registry_catalog() { for manifest in catalog.list(Some(crate::registry::manifest::ManifestKind::Channel), None) { if !names.iter().any(|n| n == &manifest.name) { names.push(manifest.name.clone()); } } } names.sort(); names } /// Try to load the registry catalog. Falls back to embedded manifests when /// the `registry/` directory cannot be found (e.g. running from an installed binary). fn load_registry_catalog() -> Option { crate::registry::catalog::RegistryCatalog::load_or_embedded().ok() } /// Install selected channels from the registry that aren't already on disk /// and weren't handled by the bundled installer. async fn install_selected_registry_channels( channels_dir: &std::path::Path, selected_channels: &[String], already_installed: &HashSet, ) -> Vec { let catalog = match load_registry_catalog() { Some(c) => c, None => return Vec::new(), }; let repo_root = catalog .root() .parent() .unwrap_or(catalog.root()) .to_path_buf(); let bundled: HashSet<&str> = available_channel_names().iter().copied().collect(); let mut installed = Vec::new(); for name in selected_channels { // Skip if already installed or handled by bundled installer if already_installed.contains(name) || bundled.contains(name.as_str()) { continue; } // Check if already on disk (may have been installed between bundled and here) let wasm_on_disk = channels_dir.join(format!("{}.wasm", name)).exists() || channels_dir.join(format!("{}-channel.wasm", name)).exists(); if wasm_on_disk { continue; } // Look up in registry let manifest = match catalog.get(&format!("channels/{}", name)) { Some(m) => m, None => continue, }; let installer = crate::registry::installer::RegistryInstaller::new( repo_root.clone(), ironclaw_base_dir().join("tools"), channels_dir.to_path_buf(), ); match installer .install_with_source_fallback(manifest, false) .await { Ok(outcome) => { for warning in &outcome.warnings { crate::setup::prompts::print_info(&format!("{}: {}", name, warning)); } installed.push(name.clone()); } Err(e) => { tracing::warn!( channel = %name, error = %e, "Failed to install channel from registry" ); crate::setup::prompts::print_error(&format!( "Failed to install channel '{}': {}", name, e )); } } } installed } /// Discover which tools are already installed in the tools directory. /// /// Returns a set of tool names (the stem of .wasm files). async fn discover_installed_tools(tools_dir: &std::path::Path) -> HashSet { let mut names = HashSet::new(); if !tools_dir.is_dir() { return names; } let mut entries = match tokio::fs::read_dir(tools_dir).await { Ok(e) => e, Err(_) => return names, }; while let Ok(Some(entry)) = entries.next_entry().await { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) == Some("wasm") && let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { names.insert(stem.to_string()); } } names } async fn install_selected_bundled_channels( channels_dir: &std::path::Path, selected_channels: &[String], already_installed: &HashSet, ) -> Result>, SetupError> { let bundled: HashSet<&str> = available_channel_names().iter().copied().collect(); let selected_missing: HashSet = selected_channels .iter() .filter(|name| bundled.contains(name.as_str()) && !already_installed.contains(*name)) .cloned() .collect(); if selected_missing.is_empty() { return Ok(None); } let mut installed = Vec::new(); for name in selected_missing { install_bundled_channel(&name, channels_dir, false) .await .map_err(SetupError::Channel)?; installed.push(name); } installed.sort(); Ok(Some(installed)) } #[cfg(test)] mod tests { use std::collections::HashSet; #[cfg(unix)] use std::ffi::OsString; use tempfile::tempdir; use super::*; use crate::config::helpers::lock_env; #[test] fn test_wizard_creation() { let wizard = SetupWizard::new(); assert!(!wizard.config.skip_auth); assert!(!wizard.config.channels_only); } #[test] fn test_wizard_with_config() { let config = SetupConfig { skip_auth: true, channels_only: false, provider_only: false, quick: false, steps: vec![], }; let wizard = SetupWizard::with_config(config); assert!(wizard.config.skip_auth); } #[test] fn test_wizard_owner_id_uses_resolved_env_scope() { let _guard = lock_env(); let _owner = EnvGuard::set("IRONCLAW_OWNER_ID", " wizard-owner "); let wizard = SetupWizard::new(); assert_eq!(wizard.owner_id(), "wizard-owner"); // safety: test-only assertion } #[test] fn test_wizard_owner_id_uses_toml_scope() { let _guard = lock_env(); let _owner = EnvGuard::clear("IRONCLAW_OWNER_ID"); let dir = tempdir().unwrap(); // safety: test-only tempdir setup let path = dir.path().join("config.toml"); std::fs::write(&path, "owner_id = \"toml-owner\"\n").unwrap(); // safety: test-only fixture write let wizard = SetupWizard::try_with_config_and_toml(Default::default(), Some(&path)) .expect("wizard should load owner_id from TOML"); // safety: test-only assertion assert_eq!(wizard.owner_id(), "toml-owner"); // safety: test-only assertion } #[test] #[cfg(unix)] fn test_try_with_config_and_toml_propagates_invalid_owner_env() { use std::os::unix::ffi::OsStringExt; let _guard = lock_env(); let original = std::env::var_os("IRONCLAW_OWNER_ID"); unsafe { std::env::set_var("IRONCLAW_OWNER_ID", OsString::from_vec(vec![0x66, 0x80])); } let result = SetupWizard::try_with_config_and_toml(Default::default(), None); unsafe { if let Some(value) = original { std::env::set_var("IRONCLAW_OWNER_ID", value); } else { std::env::remove_var("IRONCLAW_OWNER_ID"); } } assert!(result.is_err()); // safety: test-only assertion } #[test] #[cfg(feature = "postgres")] fn test_mask_password_in_url() { assert_eq!( mask_password_in_url("postgres://user:secret@localhost/db"), "postgres://user:****@localhost/db" ); // URL without password assert_eq!( mask_password_in_url("postgres://localhost/db"), "postgres://localhost/db" ); } #[test] fn test_capitalize_first() { assert_eq!(capitalize_first("telegram"), "Telegram"); assert_eq!(capitalize_first("CAPS"), "CAPS"); assert_eq!(capitalize_first(""), ""); } #[test] fn test_mask_api_key() { assert_eq!( mask_api_key("sk-ant-api03-abcdef1234567890"), "sk-ant...7890" ); assert_eq!(mask_api_key("short"), "shor..."); assert_eq!(mask_api_key("exactly12ch"), "exac..."); assert_eq!(mask_api_key("exactly12chr"), "exactl...2chr"); assert_eq!(mask_api_key(""), "..."); // Multi-byte chars should not panic assert_eq!(mask_api_key("日本語キー"), "日本語キ..."); } #[tokio::test] async fn test_install_missing_bundled_channels_installs_telegram() { // WASM artifacts only exist in dev builds (not CI). Skip gracefully // rather than fail when the telegram channel hasn't been compiled. if !available_channel_names().contains(&"telegram") { eprintln!("skipping: telegram WASM artifacts not built"); return; } let dir = tempdir().unwrap(); // safety: test-only tempdir setup let installed = HashSet::::new(); install_missing_bundled_channels(dir.path(), &installed) .await .unwrap(); // safety: test-only assertion assert!(dir.path().join("telegram.wasm").exists()); assert!(dir.path().join("telegram.capabilities.json").exists()); } #[test] fn test_build_channel_options_includes_available_when_missing() { let discovered = Vec::new(); let options = build_channel_options(&discovered); let available = available_channel_names(); // All available (built) channels should appear for name in &available { assert!( options.contains(&name.to_string()), "expected '{}' in options", name ); } } #[test] fn test_build_channel_options_dedupes_available() { let discovered = vec![(String::from("telegram"), ChannelCapabilitiesFile::default())]; let options = build_channel_options(&discovered); // telegram should appear exactly once despite being both discovered and available assert_eq!( options.iter().filter(|n| *n == "telegram").count(), 1, "telegram should not be duplicated" ); } #[tokio::test] async fn test_fetch_anthropic_models_static_fallback() { // With no API key, should return static defaults let _guard = EnvGuard::clear("ANTHROPIC_API_KEY"); let models = fetch_anthropic_models(None).await; assert!(!models.is_empty()); assert!( models.iter().any(|(id, _)| id.contains("claude")), "static defaults should include a Claude model" ); } #[tokio::test] async fn test_fetch_openai_models_static_fallback() { let _guard = EnvGuard::clear("OPENAI_API_KEY"); let models = fetch_openai_models(None).await; assert!(!models.is_empty()); assert_eq!(models[0].0, "gpt-5.3-codex"); assert!( models.iter().any(|(id, _)| id.contains("gpt")), "static defaults should include a GPT model" ); } #[test] fn test_github_copilot_setup_preserves_model_for_same_backend() { let mut wizard = SetupWizard::new(); wizard.settings.llm_backend = Some("github_copilot".to_string()); wizard.settings.selected_model = Some("gpt-4o".to_string()); wizard.set_llm_backend_preserving_model("github_copilot"); assert_eq!(wizard.settings.selected_model.as_deref(), Some("gpt-4o")); assert_eq!( wizard.settings.llm_backend.as_deref(), Some("github_copilot") ); } #[test] fn test_github_copilot_setup_clears_stale_model_on_switch() { let mut wizard = SetupWizard::new(); wizard.settings.llm_backend = Some("openai".to_string()); wizard.settings.selected_model = Some("gpt-5".to_string()); wizard.set_llm_backend_preserving_model("github_copilot"); assert!(wizard.settings.selected_model.is_none()); assert_eq!( wizard.settings.llm_backend.as_deref(), Some("github_copilot") ); } #[test] fn test_is_openai_chat_model_includes_gpt5_and_filters_non_chat_variants() { assert!(is_openai_chat_model("gpt-5")); assert!(is_openai_chat_model("gpt-5-mini-2026-01-01")); assert!(is_openai_chat_model("o3-2025-04-16")); assert!(!is_openai_chat_model("chatgpt-image-latest")); assert!(!is_openai_chat_model("gpt-4o-realtime-preview")); assert!(!is_openai_chat_model("gpt-4o-mini-transcribe")); assert!(!is_openai_chat_model("text-embedding-3-large")); } #[test] fn test_sort_openai_models_prioritizes_best_models_first() { let mut models = vec![ ("gpt-4o-mini".to_string(), "gpt-4o-mini".to_string()), ("gpt-5-mini".to_string(), "gpt-5-mini".to_string()), ("o3".to_string(), "o3".to_string()), ("gpt-4.1".to_string(), "gpt-4.1".to_string()), ("gpt-5".to_string(), "gpt-5".to_string()), ]; sort_openai_models(&mut models); let ordered: Vec = models.into_iter().map(|(id, _)| id).collect(); assert_eq!( ordered, vec![ "gpt-5".to_string(), "gpt-5-mini".to_string(), "o3".to_string(), "gpt-4.1".to_string(), "gpt-4o-mini".to_string(), ] ); } #[tokio::test] async fn test_fetch_ollama_models_unreachable_fallback() { // Point at a port nothing listens on let models = fetch_ollama_models("http://127.0.0.1:1").await; assert!(!models.is_empty(), "should fall back to static defaults"); } #[tokio::test] async fn test_discover_wasm_channels_empty_dir() { let dir = tempdir().unwrap(); // safety: test-only tempdir setup let channels = discover_wasm_channels(dir.path()).await; assert!(channels.is_empty()); } #[tokio::test] async fn test_discover_wasm_channels_nonexistent_dir() { let channels = discover_wasm_channels( &std::env::temp_dir().join("ironclaw_nonexistent_dir_abcxyz123"), ) .await; assert!(channels.is_empty()); } /// RAII guard that sets/clears an env var for the duration of a test. struct EnvGuard { key: &'static str, original: Option, } impl EnvGuard { fn set(key: &'static str, value: &str) -> Self { let original = std::env::var(key).ok(); unsafe { std::env::set_var(key, value); } Self { key, original } } fn clear(key: &'static str) -> Self { let original = std::env::var(key).ok(); unsafe { std::env::remove_var(key); } Self { key, original } } } impl Drop for EnvGuard { fn drop(&mut self) { unsafe { if let Some(ref val) = self.original { std::env::set_var(self.key, val); } else { std::env::remove_var(self.key); } } } } #[test] fn test_set_llm_backend_preserves_model_when_backend_unchanged() { let mut wizard = SetupWizard::new(); wizard.settings.llm_backend = Some("openai".to_string()); wizard.settings.selected_model = Some("gpt-4o".to_string()); wizard.set_llm_backend_preserving_model("openai"); assert_eq!(wizard.settings.llm_backend.as_deref(), Some("openai")); assert_eq!(wizard.settings.selected_model.as_deref(), Some("gpt-4o")); } #[test] fn test_set_llm_backend_clears_model_when_backend_was_unset() { let mut wizard = SetupWizard::new(); wizard.settings.selected_model = Some("gpt-4o".to_string()); wizard.set_llm_backend_preserving_model("openai"); assert_eq!(wizard.settings.llm_backend.as_deref(), Some("openai")); assert_eq!(wizard.settings.selected_model, None); } #[test] fn test_set_llm_backend_clears_model_when_backend_changes() { let mut wizard = SetupWizard::new(); wizard.settings.llm_backend = Some("openai".to_string()); wizard.settings.selected_model = Some("gpt-4o".to_string()); wizard.set_llm_backend_preserving_model("anthropic"); assert_eq!(wizard.settings.llm_backend.as_deref(), Some("anthropic")); assert_eq!(wizard.settings.selected_model, None); } /// Regression test for #600: re-running provider setup for the same backend /// must NOT clear selected_model. Only switching to a different backend should. #[test] fn test_same_provider_preserves_selected_model() { let mut wizard = SetupWizard::new(); wizard.settings.llm_backend = Some("ollama".to_string()); wizard.settings.selected_model = Some("llama3".to_string()); // Simulate re-entering the same provider -- model should survive // (This is the check that each setup_* function now performs) if wizard.settings.llm_backend.as_deref() != Some("ollama") { wizard.settings.selected_model = None; } wizard.settings.llm_backend = Some("ollama".to_string()); assert_eq!( wizard.settings.selected_model.as_deref(), Some("llama3"), "model should be preserved when re-selecting the same provider" ); } /// Regression test for #600: switching to a different provider must clear /// selected_model since the old model may not be valid for the new backend. #[test] fn test_different_provider_clears_selected_model() { let mut wizard = SetupWizard::new(); wizard.settings.llm_backend = Some("ollama".to_string()); wizard.settings.selected_model = Some("llama3".to_string()); // Simulate switching to a different provider -- model should be cleared if wizard.settings.llm_backend.as_deref() != Some("openai") { wizard.settings.selected_model = None; } wizard.settings.llm_backend = Some("openai".to_string()); assert!( wizard.settings.selected_model.is_none(), "model should be cleared when switching providers" ); } /// Regression: Bedrock setup_bedrock() should preserve selected_model /// when re-entering the same provider (matches pattern from #600). #[test] fn test_bedrock_same_provider_preserves_model() { let mut wizard = SetupWizard::new(); wizard.settings.llm_backend = Some("bedrock".to_string()); wizard.settings.selected_model = Some("anthropic.claude-opus-4-6-v1".to_string()); // Simulate the conditional clearing logic from setup_bedrock() if wizard.settings.llm_backend.as_deref() != Some("bedrock") { wizard.settings.selected_model = None; } wizard.settings.llm_backend = Some("bedrock".to_string()); assert_eq!( wizard.settings.selected_model.as_deref(), Some("anthropic.claude-opus-4-6-v1"), "bedrock model should be preserved when re-selecting bedrock" ); } /// Regression: switching from another provider to bedrock must clear /// selected_model, and choosing "default credentials" must clear /// bedrock_profile. #[test] fn test_bedrock_clears_stale_profile_on_default_creds() { let mut wizard = SetupWizard::new(); wizard.settings.llm_backend = Some("bedrock".to_string()); wizard.settings.bedrock_profile = Some("old-sso-profile".to_string()); // Simulate auth_choice == 0 (default credentials) clearing the profile wizard.settings.bedrock_profile = None; assert!( wizard.settings.bedrock_profile.is_none(), "bedrock_profile should be cleared when selecting default credentials" ); } /// Regression: empty profile input in named-profile auth should clear /// any previously configured profile instead of leaving it stale. #[test] fn test_bedrock_empty_profile_clears_existing() { let mut wizard = SetupWizard::new(); wizard.settings.bedrock_profile = Some("old-profile".to_string()); // Simulate auth_choice == 1 with empty input let profile = "".to_string(); if profile.trim().is_empty() { wizard.settings.bedrock_profile = None; } else { wizard.settings.bedrock_profile = Some(profile); } assert!( wizard.settings.bedrock_profile.is_none(), "empty profile input should clear existing bedrock_profile" ); } #[tokio::test] async fn test_run_provider_setup_no_setup_hint() { // A provider with setup: None should not error. It should set the // backend and return Ok, allowing env-var-only configured providers // to be kept during re-onboarding. let mut wizard = SetupWizard::new(); let mut providers: Vec = serde_json::from_str(include_str!("../../providers.json")).unwrap(); // Add a provider with no setup hint providers.push(crate::llm::registry::ProviderDefinition { id: "custom_no_setup".to_string(), aliases: vec![], protocol: crate::llm::registry::ProviderProtocol::OpenAiCompletions, default_base_url: Some("http://localhost:9999/v1".to_string()), base_url_env: None, base_url_required: false, api_key_env: None, api_key_required: false, model_env: "CUSTOM_MODEL".to_string(), default_model: "custom-model".to_string(), description: "Custom provider with no setup wizard".to_string(), extra_headers_env: None, setup: None, unsupported_params: vec![], }); let registry = crate::llm::ProviderRegistry::new(providers); let result = wizard .run_provider_setup("custom_no_setup", ®istry) .await; assert!(result.is_ok(), "setup: None provider should not error"); assert_eq!( wizard.settings.llm_backend.as_deref(), Some("custom_no_setup"), "backend should be set even without setup hint" ); } /// Regression test for #666: env-var security option must initialize /// secrets_crypto so subsequent steps can encrypt API keys. #[test] fn test_env_var_security_initializes_crypto() { use crate::secrets::SecretsCrypto; use secrecy::SecretString; // Simulate what option 1 in step_security() does after the fix: let key_hex = crate::secrets::keychain::generate_master_key_hex(); // The fix: create SecretsCrypto from the generated key. // Before the fix, this was skipped, leaving secrets_crypto = None. let crypto = SecretsCrypto::new(SecretString::from(key_hex.clone())); assert!( crypto.is_ok(), "generated key hex must produce valid SecretsCrypto" ); // Verify the key is stored for bootstrap env persistence. let settings = Settings { secrets_master_key_hex: Some(key_hex), ..Settings::default() }; assert!(settings.secrets_master_key_hex.is_some()); } /// Regression test for #799: `fetch_nearai_models` hardcoded `api_key: None`, /// causing the auth prompt to re-appear during model selection when the user /// had authenticated via NEAR AI Cloud API key (option 4). #[test] fn test_build_nearai_model_fetch_config_picks_up_api_key_env() { use secrecy::ExposeSecret; let _lock = lock_env(); let _guard = EnvGuard::set("NEARAI_API_KEY", "test-cloud-api-key-12345"); let _guard2 = EnvGuard::clear("NEARAI_BASE_URL"); let config = build_nearai_model_fetch_config(); assert!( config.nearai.api_key.is_some(), "config should include NEARAI_API_KEY from env" ); assert_eq!( config.nearai.api_key.as_ref().unwrap().expose_secret(), "test-cloud-api-key-12345" ); // With API key, base_url must point to cloud-api (not private.near.ai) assert_eq!( config.nearai.base_url, "https://cloud-api.near.ai", "API key auth must use cloud-api base URL for model fetching" ); } /// Regression test for #799: when NEARAI_API_KEY is absent or empty, /// the config should have `api_key: None` (session token path). #[test] fn test_build_nearai_model_fetch_config_none_when_no_api_key() { let _lock = lock_env(); let _guard = EnvGuard::clear("NEARAI_API_KEY"); let _guard2 = EnvGuard::clear("NEARAI_BASE_URL"); let config = build_nearai_model_fetch_config(); assert!( config.nearai.api_key.is_none(), "config should have no api_key when env var is absent" ); // Without API key, base_url must point to private.near.ai (session token) assert_eq!( config.nearai.base_url, "https://private.near.ai", "session-token auth must use private.near.ai base URL" ); } /// Regression test for #799: empty NEARAI_API_KEY should be treated as absent. #[test] fn test_build_nearai_model_fetch_config_none_when_empty_api_key() { let _lock = lock_env(); let _guard = EnvGuard::set("NEARAI_API_KEY", ""); let config = build_nearai_model_fetch_config(); assert!( config.nearai.api_key.is_none(), "config should have no api_key when env var is empty" ); } /// Regression: API key set via inject_single_var (the path used by /// setup_api_key_provider during onboarding) must be picked up by /// for_model_discovery() so model listing uses cloud-api auth /// instead of falling back to session-token auth. #[test] fn test_model_discovery_picks_up_injected_var() { use secrecy::ExposeSecret; let _lock = lock_env(); let _guard = EnvGuard::clear("NEARAI_API_KEY"); let _guard2 = EnvGuard::clear("NEARAI_BASE_URL"); crate::config::inject_single_var("NEARAI_API_KEY", "injected-wizard-key"); let config = build_nearai_model_fetch_config(); // Clean up: empty values are treated as unset by env_or_override() // at every layer (real env, runtime overrides, INJECTED_VARS). crate::config::inject_single_var("NEARAI_API_KEY", ""); assert!( config.nearai.api_key.is_some(), "for_model_discovery must read NEARAI_API_KEY from inject_single_var overlay" ); assert_eq!( config.nearai.api_key.as_ref().unwrap().expose_secret(), "injected-wizard-key" ); assert_eq!( config.nearai.base_url, "https://cloud-api.near.ai", "API key from overlay must select cloud-api base URL" ); } /// Regression: API key set via set_runtime_env (interactive api_key_login /// path) must be picked up by build_nearai_model_fetch_config so that /// model listing doesn't fall back to session-token auth and re-trigger /// the NEAR AI authentication menu. #[test] fn test_build_nearai_model_fetch_config_picks_up_runtime_env() { let _lock = lock_env(); // Ensure the real env var is unset so the only source is the overlay. let _guard = EnvGuard::clear("NEARAI_API_KEY"); crate::config::helpers::set_runtime_env("NEARAI_API_KEY", "test-key-from-overlay"); let config = build_nearai_model_fetch_config(); // Clean up runtime overlay crate::config::helpers::set_runtime_env("NEARAI_API_KEY", ""); assert!( config.nearai.api_key.is_some(), "config must pick up NEARAI_API_KEY from runtime overlay" ); assert_eq!( config.nearai.base_url, "https://cloud-api.near.ai", "API key auth must use cloud-api base URL" ); } }