diff --git a/.env.example b/.env.example index dabed097..4ed81838 100644 --- a/.env.example +++ b/.env.example @@ -2,18 +2,27 @@ DATABASE_URL=postgres://localhost/ironclaw DATABASE_POOL_SIZE=10 -# LLM Provider (NEAR AI) -# NEAR AI provides a unified interface to all models with user authentication -# Session token is stored in ~/.ironclaw/session.json and managed automatically. -# On first run, the agent will open a browser for OAuth authentication. -NEARAI_MODEL=claude-3-5-sonnet-20241022 +# LLM Provider +# LLM_BACKEND=nearai # default +# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil + +# === NEAR AI Chat (Responses API, session token auth) === +# Default mode. Uses browser OAuth (GitHub/Google) on first run. +# Session token stored in ~/.ironclaw/session.json automatically. +# For hosting providers: set NEARAI_SESSION_TOKEN env var directly. +NEARAI_MODEL=zai-org/GLM-5-FP8 NEARAI_BASE_URL=https://private.near.ai NEARAI_AUTH_URL=https://private.near.ai -# NEARAI_SESSION_PATH=~/.ironclaw/session.json # optional, default shown +# NEARAI_SESSION_TOKEN=sess_... # hosting providers: set this +# NEARAI_SESSION_PATH=~/.ironclaw/session.json # optional, default shown + +# === NEAR AI Cloud (Chat Completions API, API key auth) === +# Auto-selected when NEARAI_API_KEY is set. Get a key from cloud.near.ai. +# NEARAI_API_KEY=... +# NEARAI_BASE_URL=https://cloud-api.near.ai # default for cloud mode +# NEARAI_API_MODE=chat_completions # auto-detected from API key # Local LLM Providers (Ollama, LM Studio, vLLM, LiteLLM) -# LLM_BACKEND=nearai # default -# Possible values: nearai, ollama, openai_compatible, openai, anthropic # === Ollama === # OLLAMA_MODEL=llama3.2 diff --git a/CLAUDE.md b/CLAUDE.md index 6229097a..d77565ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -339,9 +339,14 @@ LIBSQL_PATH=~/.ironclaw/ironclaw.db # libSQL local path (default) # LIBSQL_AUTH_TOKEN=xxx # Required with LIBSQL_URL # NEAR AI (when LLM_BACKEND=nearai, the default) -NEARAI_SESSION_TOKEN=sess_... -NEARAI_MODEL=claude-3-5-sonnet-20241022 +# Two modes: "NEAR AI Chat" (session token) or "NEAR AI Cloud" (API key) +# NEAR AI Chat (Responses API, default): +NEARAI_SESSION_TOKEN=sess_... # session token for chat-api NEARAI_BASE_URL=https://private.near.ai +# NEAR AI Cloud (Chat Completions API, auto-selected when API key is set): +# NEARAI_API_KEY=... # API key from cloud.near.ai +# NEARAI_BASE_URL=https://cloud-api.near.ai +NEARAI_MODEL=claude-3-5-sonnet-20241022 # Agent settings AGENT_NAME=ironclaw @@ -403,7 +408,9 @@ TINFOIL_MODEL=kimi-k2-5 # Default model IronClaw supports multiple LLM backends via the `LLM_BACKEND` env var: `nearai` (default), `openai`, `anthropic`, `ollama`, `openai_compatible`, and `tinfoil`. -**NEAR AI** -- Uses the NEAR AI chat-api (`https://api.near.ai/v1/responses`) which provides unified access to multiple models, user authentication via session tokens (`sess_xxx`, 37 characters), and usage tracking/billing through NEAR AI. +**NEAR AI Chat** -- Uses the NEAR AI Responses API (`https://private.near.ai/v1/responses`). Authenticates with session tokens (`sess_xxx`) obtained via browser OAuth (GitHub/Google). Supports response chaining (delta-only follow-up messages) for efficient multi-turn conversations. This is the default mode when no `NEARAI_API_KEY` is set. Set `NEARAI_SESSION_TOKEN` env var for hosting providers that inject tokens via environment. Configure with `NEARAI_BASE_URL` (default: `https://private.near.ai`). + +**NEAR AI Cloud** -- Uses the OpenAI-compatible Chat Completions API (`https://cloud-api.near.ai/v1/chat/completions`). Authenticates with API keys from `cloud.near.ai`. Auto-selected when `NEARAI_API_KEY` is set (or explicitly via `NEARAI_API_MODE=chat_completions`). Tool messages are flattened to plain text for compatibility. Configure with `NEARAI_API_KEY` and `NEARAI_BASE_URL` (default: `https://cloud-api.near.ai`). **Tinfoil** -- Private inference via `https://inference.tinfoil.sh/v1`. Runs models inside hardware-attested TEEs so neither Tinfoil nor the cloud provider can see prompts or responses. Uses the OpenAI-compatible Chat Completions API only (not the Responses API, so tool calls are adapted to chat format). Configure with `TINFOIL_API_KEY` and `TINFOIL_MODEL` (default: `kimi-k2-5`). diff --git a/deploy/env.example b/deploy/env.example index 046d5b0a..45a17c9f 100644 --- a/deploy/env.example +++ b/deploy/env.example @@ -2,12 +2,15 @@ # Do not use placeholder passwords in production. DATABASE_URL=postgres://ironclaw:CHANGE_ME@localhost:5432/ironclaw -# NEAR AI -NEARAI_SESSION_TOKEN=CHANGE_ME +# NEAR AI Cloud (API key auth, Chat Completions API) +# Get an API key from https://cloud.near.ai +NEARAI_API_KEY=CHANGE_ME NEARAI_MODEL=claude-3-5-sonnet-20241022 -NEARAI_BASE_URL=https://private.near.ai -NEARAI_AUTH_URL=https://private.near.ai -NEARAI_API_MODE=chat_completions +NEARAI_BASE_URL=https://cloud-api.near.ai + +# Or use NEAR AI Chat (session token auth, Responses API): +# NEARAI_SESSION_TOKEN=sess_... +# NEARAI_BASE_URL=https://private.near.ai # Agent AGENT_NAME=ironclaw diff --git a/src/bootstrap.rs b/src/bootstrap.rs index 90ce74c8..90429645 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -103,7 +103,67 @@ pub fn save_bootstrap_env(vars: &[(&str, &str)]) -> std::io::Result<()> { let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); content.push_str(&format!("{}=\"{}\"\n", key, escaped)); } - std::fs::write(&path, content) + std::fs::write(&path, &content)?; + restrict_file_permissions(&path)?; + Ok(()) +} + +/// Update or add a single variable in `~/.ironclaw/.env`, preserving existing content. +/// +/// Unlike `save_bootstrap_env` (which overwrites the entire file), this +/// reads the current `.env`, replaces the line for `key` if it exists, +/// or appends it otherwise. Use this when writing a single bootstrap var +/// outside the wizard (which manages the full set via `save_bootstrap_env`). +pub fn upsert_bootstrap_var(key: &str, value: &str) -> std::io::Result<()> { + let path = ironclaw_env_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); + let new_line = format!("{}=\"{}\"", key, escaped); + let prefix = format!("{}=", key); + + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + + let mut found = false; + let mut result = String::new(); + for line in existing.lines() { + if line.starts_with(&prefix) { + if !found { + result.push_str(&new_line); + result.push('\n'); + found = true; + } + // Skip duplicate lines for this key + continue; + } + result.push_str(line); + result.push('\n'); + } + + if !found { + result.push_str(&new_line); + result.push('\n'); + } + + std::fs::write(&path, result)?; + restrict_file_permissions(&path)?; + Ok(()) +} + +/// Set restrictive file permissions (0o600) on Unix systems. +/// +/// The `.env` file may contain database credentials and API keys, +/// so it should only be readable by the owner. +fn restrict_file_permissions(_path: &std::path::Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + std::fs::set_permissions(_path, perms)?; + } + Ok(()) } /// Write `DATABASE_URL` to `~/.ironclaw/.env`. diff --git a/src/cli/oauth_defaults.rs b/src/cli/oauth_defaults.rs index 8ea89c3c..bd4ff640 100644 --- a/src/cli/oauth_defaults.rs +++ b/src/cli/oauth_defaults.rs @@ -62,6 +62,18 @@ pub fn builtin_credentials(secret_name: &str) -> Option { /// `http://localhost:9876/callback` (or `/auth/callback` for NEAR AI). pub const OAUTH_CALLBACK_PORT: u16 = 9876; +/// Returns the OAuth callback base URL. +/// +/// Checks `IRONCLAW_OAUTH_CALLBACK_URL` env var first (useful for remote/VPS +/// deployments where `127.0.0.1` is unreachable from the user's browser), +/// then falls back to `http://127.0.0.1:{OAUTH_CALLBACK_PORT}`. +pub fn callback_url() -> String { + std::env::var("IRONCLAW_OAUTH_CALLBACK_URL") + .ok() + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| format!("http://127.0.0.1:{}", OAUTH_CALLBACK_PORT)) +} + /// Error from the OAuth callback listener. #[derive(Debug, thiserror::Error)] pub enum OAuthCallbackError { @@ -297,7 +309,54 @@ pub fn landing_html(provider_name: &str, success: bool) -> String { #[cfg(test)] mod tests { - use crate::cli::oauth_defaults::{builtin_credentials, landing_html}; + use std::sync::Mutex; + + use crate::cli::oauth_defaults::{builtin_credentials, callback_url, landing_html}; + + /// Serializes env-mutating tests to prevent parallel races. + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + #[test] + fn test_callback_url_default() { + let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); + // Clear the env var to test default behavior + let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok(); + // SAFETY: Under ENV_MUTEX, no concurrent env access. + unsafe { + std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL"); + } + let url = callback_url(); + assert_eq!(url, "http://127.0.0.1:9876"); + // Restore + unsafe { + if let Some(val) = original { + std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val); + } + } + } + + #[test] + fn test_callback_url_env_override() { + let _guard = ENV_MUTEX.lock().expect("env mutex poisoned"); + let original = std::env::var("IRONCLAW_OAUTH_CALLBACK_URL").ok(); + // SAFETY: Under ENV_MUTEX, no concurrent env access. + unsafe { + std::env::set_var( + "IRONCLAW_OAUTH_CALLBACK_URL", + "https://myserver.example.com:9876", + ); + } + let url = callback_url(); + assert_eq!(url, "https://myserver.example.com:9876"); + // Restore + unsafe { + if let Some(val) = original { + std::env::set_var("IRONCLAW_OAUTH_CALLBACK_URL", val); + } else { + std::env::remove_var("IRONCLAW_OAUTH_CALLBACK_URL"); + } + } + } #[test] fn test_unknown_provider_returns_none() { diff --git a/src/config/llm.rs b/src/config/llm.rs index 2e315702..a7564011 100644 --- a/src/config/llm.rs +++ b/src/config/llm.rs @@ -122,12 +122,15 @@ pub struct LlmConfig { } /// API mode for NEAR AI. +/// +/// - `Responses` = **NEAR AI Chat** (`private.near.ai`, session token auth) +/// - `ChatCompletions` = **NEAR AI Cloud** (`cloud-api.near.ai`, API key auth) #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum NearAiApiMode { - /// Use the Responses API (chat-api proxy) - session-based auth + /// NEAR AI Chat: Responses API with session token auth #[default] Responses, - /// Use the Chat Completions API (cloud-api) - API key auth + /// NEAR AI Cloud: Chat Completions API with API key auth ChatCompletions, } @@ -148,7 +151,7 @@ impl std::str::FromStr for NearAiApiMode { } } -/// NEAR AI chat-api configuration. +/// NEAR AI configuration (shared by Chat and Cloud modes). #[derive(Debug, Clone)] pub struct NearAiConfig { /// Model to use (e.g., "claude-3-5-sonnet-20241022", "gpt-4o") @@ -156,15 +159,17 @@ pub struct NearAiConfig { /// Cheap/fast model for lightweight tasks (heartbeat, routing, evaluation). /// Falls back to the main model if not set. pub cheap_model: Option, - /// Base URL for the NEAR AI API (default: https://private.near.ai). + /// Base URL for the NEAR AI API. + /// Chat mode default: `https://private.near.ai` + /// Cloud mode default: `https://cloud-api.near.ai` pub base_url: String, /// Base URL for auth/refresh endpoints (default: https://private.near.ai) pub auth_base_url: String, /// Path to session file (default: ~/.ironclaw/session.json) pub session_path: PathBuf, - /// API mode: "responses" (chat-api) or "chat_completions" (cloud-api) + /// API mode: NEAR AI Chat (Responses) or NEAR AI Cloud (ChatCompletions) pub api_mode: NearAiApiMode, - /// API key for cloud-api (required for chat_completions mode) + /// API key for NEAR AI Cloud (required for ChatCompletions mode) pub api_key: Option, /// Optional fallback model for failover (default: None). /// When set, a secondary provider is created with this model and wrapped @@ -243,8 +248,13 @@ impl LlmConfig { .to_string() }), cheap_model: optional_env("NEARAI_CHEAP_MODEL")?, - base_url: optional_env("NEARAI_BASE_URL")? - .unwrap_or_else(|| "https://private.near.ai".to_string()), + base_url: optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| { + if api_mode == NearAiApiMode::ChatCompletions { + "https://cloud-api.near.ai".to_string() + } else { + "https://private.near.ai".to_string() + } + }), auth_base_url: optional_env("NEARAI_AUTH_URL")? .unwrap_or_else(|| "https://private.near.ai".to_string()), session_path: optional_env("NEARAI_SESSION_PATH")? diff --git a/src/config/mod.rs b/src/config/mod.rs index 24c823ef..85b30834 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -219,6 +219,7 @@ pub async fn inject_llm_keys_from_secrets( ("llm_openai_api_key", "OPENAI_API_KEY"), ("llm_anthropic_api_key", "ANTHROPIC_API_KEY"), ("llm_compatible_api_key", "LLM_API_KEY"), + ("llm_nearai_api_key", "NEARAI_API_KEY"), ]; let mut injected = HashMap::new(); diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 55738ab6..ee4f66a7 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -75,14 +75,16 @@ pub fn create_llm_provider_with_config( NearAiApiMode::Responses => { tracing::info!( model = %config.model, - "Using Responses API (chat-api) with session auth" + base_url = %config.base_url, + "Using NEAR AI Chat (Responses API, session token auth)" ); Ok(Arc::new(NearAiProvider::new(config.clone(), session)?)) } NearAiApiMode::ChatCompletions => { tracing::info!( model = %config.model, - "Using Chat Completions API (cloud-api) with API key auth" + base_url = %config.base_url, + "Using NEAR AI Cloud (Chat Completions API, API key auth)" ); Ok(Arc::new(NearAiChatProvider::new(config.clone())?)) } diff --git a/src/llm/nearai.rs b/src/llm/nearai.rs index 27d90622..3d171646 100644 --- a/src/llm/nearai.rs +++ b/src/llm/nearai.rs @@ -1,7 +1,9 @@ -//! NEAR AI Chat API provider implementation. +//! NEAR AI Chat provider implementation (Responses API). //! -//! This provider uses the NEAR AI chat-api which provides a unified interface -//! to multiple LLM models (OpenAI, Anthropic, etc.) with user authentication. +//! This provider uses the NEAR AI Responses API (`private.near.ai`) which +//! provides a unified interface to multiple LLM models with session token +//! authentication. Supports response chaining for efficient multi-turn +//! conversations. use std::collections::HashMap; use std::sync::Arc; diff --git a/src/llm/nearai_chat.rs b/src/llm/nearai_chat.rs index 68472ed8..02d60dd3 100644 --- a/src/llm/nearai_chat.rs +++ b/src/llm/nearai_chat.rs @@ -1,7 +1,8 @@ -//! NEAR AI Chat Completions API provider implementation. +//! NEAR AI Cloud provider implementation (Chat Completions API). //! -//! This provider uses the standard OpenAI-compatible chat completions API -//! with API key authentication (for cloud-api). +//! This provider uses the NEAR AI Cloud API (`cloud-api.near.ai`) which +//! exposes an OpenAI-compatible chat completions endpoint with API key +//! authentication. use async_trait::async_trait; use reqwest::Client; @@ -17,7 +18,7 @@ use crate::llm::provider::{ Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, }; -/// NEAR AI Chat Completions API provider. +/// NEAR AI Cloud provider (Chat Completions API, API key auth). pub struct NearAiChatProvider { client: Client, config: NearAiConfig, @@ -26,10 +27,10 @@ pub struct NearAiChatProvider { } impl NearAiChatProvider { - /// Create a new NEAR AI chat completions provider with API key auth. + /// Create a new NEAR AI Cloud provider with API key auth. /// /// By default this enables tool-message flattening for compatibility with - /// providers that reject `role: "tool"` messages (e.g. NEAR cloud-api). + /// providers that reject `role: "tool"` messages. pub fn new(config: NearAiConfig) -> Result { Self::new_with_flatten(config, true) } diff --git a/src/llm/session.rs b/src/llm/session.rs index b9932c21..5a628f7b 100644 --- a/src/llm/session.rs +++ b/src/llm/session.rs @@ -217,38 +217,43 @@ impl SessionManager { self.initiate_login().await } - /// Start the OAuth login flow. + /// Start the login flow. /// - /// 1. Bind the fixed callback port + /// Shows the auth method menu FIRST (before binding any listener), so + /// that the API-key path can skip network binding entirely. This is + /// important for remote/headless servers where `127.0.0.1` is + /// unreachable from the user's browser. + /// + /// For OAuth paths (GitHub, Google): + /// 1. Bind the callback listener /// 2. Print the auth URL and attempt to open browser /// 3. Wait for OAuth callback with session token /// 4. Save and return the token + /// + /// For NEAR AI Cloud API key: + /// 1. Prompt user for API key from cloud.near.ai + /// 2. Set NEARAI_API_KEY env var and save to bootstrap .env + /// 3. No session token saved (different auth model) async fn initiate_login(&self) -> Result<(), LlmError> { - use crate::cli::oauth_defaults::{self, OAUTH_CALLBACK_PORT}; + use crate::cli::oauth_defaults; - let listener = oauth_defaults::bind_callback_listener() - .await - .map_err(|e| LlmError::SessionRenewalFailed { - provider: "nearai".to_string(), - reason: e.to_string(), - })?; + let cb_url = oauth_defaults::callback_url(); - let callback_url = format!("http://127.0.0.1:{}", OAUTH_CALLBACK_PORT); - - // Show auth provider menu + // Show auth provider menu BEFORE binding the listener println!(); println!("╔════════════════════════════════════════════════════════════════╗"); println!("║ NEAR AI Authentication ║"); println!("╠════════════════════════════════════════════════════════════════╣"); println!("║ Choose an authentication method: ║"); println!("║ ║"); - println!("║ [1] GitHub ║"); - println!("║ [2] Google ║"); + println!("║ [1] GitHub (requires localhost browser access) ║"); + println!("║ [2] Google (requires localhost browser access) ║"); println!("║ [3] NEAR Wallet (coming soon) ║"); + println!("║ [4] NEAR AI Cloud API key ║"); println!("║ ║"); println!("╚════════════════════════════════════════════════════════════════╝"); println!(); - print!("Enter choice [1-3]: "); + print!("Enter choice [1-4]: "); // Flush stdout to ensure prompt is displayed use std::io::Write; @@ -263,23 +268,8 @@ impl SessionManager { reason: format!("Failed to read input: {}", e), })?; - let (auth_provider, auth_url) = match choice.trim() { - "1" | "" => { - let url = format!( - "{}/v1/auth/github?frontend_callback={}", - self.config.auth_base_url, - urlencoding::encode(&callback_url) - ); - ("github", url) - } - "2" => { - let url = format!( - "{}/v1/auth/google?frontend_callback={}", - self.config.auth_base_url, - urlencoding::encode(&callback_url) - ); - ("google", url) - } + match choice.trim() { + "4" => return self.api_key_login().await, "3" => { println!(); println!("NEAR Wallet authentication is not yet implemented."); @@ -289,12 +279,41 @@ impl SessionManager { reason: "NEAR Wallet auth not yet implemented".to_string(), }); } - _ => { + "1" | "" | "2" => {} // handled below after listener bind + other => { return Err(LlmError::SessionRenewalFailed { provider: "nearai".to_string(), - reason: format!("Invalid choice: {}", choice.trim()), + reason: format!("Invalid choice: {}", other), }); } + } + + // OAuth paths: bind the callback listener now + let listener = oauth_defaults::bind_callback_listener() + .await + .map_err(|e| LlmError::SessionRenewalFailed { + provider: "nearai".to_string(), + reason: e.to_string(), + })?; + + let (auth_provider, auth_url) = match choice.trim() { + "2" => { + let url = format!( + "{}/v1/auth/google?frontend_callback={}", + self.config.auth_base_url, + urlencoding::encode(&cb_url) + ); + ("google", url) + } + _ => { + // "1" or "" (default) + let url = format!( + "{}/v1/auth/github?frontend_callback={}", + self.config.auth_base_url, + urlencoding::encode(&cb_url) + ); + ("github", url) + } }; println!(); @@ -341,6 +360,63 @@ impl SessionManager { Ok(()) } + /// NEAR AI Cloud API key entry flow. + /// + /// Prompts the user to enter a NEAR AI Cloud API key from + /// cloud.near.ai. The key is set as `NEARAI_API_KEY` env var so + /// `LlmConfig::resolve()` auto-selects ChatCompletions mode, and + /// saved to `~/.ironclaw/.env` for persistence across restarts. + /// No session token is saved and no `/v1/users/me` validation is + /// performed (different auth model). + async fn api_key_login(&self) -> Result<(), LlmError> { + println!(); + println!("NEAR AI Cloud API key"); + println!("─────────────────────"); + println!(); + println!(" 1. Open https://cloud.near.ai in your browser"); + println!(" 2. Sign in and navigate to API Keys"); + println!(" 3. Create or copy an existing API key"); + println!(); + + let key_secret = + crate::setup::secret_input("API key").map_err(|e| LlmError::SessionRenewalFailed { + provider: "nearai".to_string(), + reason: format!("Failed to read input: {}", e), + })?; + + use secrecy::ExposeSecret; + let key = key_secret.expose_secret().to_string(); + if key.is_empty() { + return Err(LlmError::SessionRenewalFailed { + provider: "nearai".to_string(), + reason: "API key cannot be empty".to_string(), + }); + } + + // Set env var so Config picks it up immediately + // (LlmConfig::resolve() auto-selects ChatCompletions mode when + // NEARAI_API_KEY is present). + // + // SAFETY: called during single-threaded interactive login flow. + #[allow(unused_unsafe)] + unsafe { + std::env::set_var("NEARAI_API_KEY", &key); + } + + // Persist to ~/.ironclaw/.env so the key survives restarts + // (bootstrap layer — available before DB is connected). + // Uses upsert to avoid clobbering existing bootstrap vars. + if let Err(e) = crate::bootstrap::upsert_bootstrap_var("NEARAI_API_KEY", &key) { + tracing::warn!("Failed to save API key to bootstrap .env: {}", e); + } + + println!(); + crate::setup::print_success("NEAR AI Cloud API key saved."); + println!(); + + Ok(()) + } + /// Save session data to disk and (if available) to the database. async fn save_session(&self, token: &str, auth_provider: Option<&str>) -> Result<(), LlmError> { let session = SessionData { @@ -508,20 +584,21 @@ impl SessionManager { } } -/// Create a session manager from a config, migrating from env var if present. +/// Create a session manager from a config, loading env var if present. +/// +/// When `NEARAI_SESSION_TOKEN` is set, it takes precedence over file-based +/// tokens. This supports hosting providers that inject the token via env var. pub async fn create_session_manager(config: SessionConfig) -> Arc { let manager = SessionManager::new_async(config).await; - // Check for legacy env var and migrate if present and no file token - if !manager.has_token().await - && let Ok(token) = std::env::var("NEARAI_SESSION_TOKEN") + // NEARAI_SESSION_TOKEN env var always takes precedence over file-based + // tokens. Hosting providers set this env var and expect it to be used + // directly — no file persistence needed. + if let Ok(token) = std::env::var("NEARAI_SESSION_TOKEN") && !token.is_empty() { - tracing::info!("Migrating session token from NEARAI_SESSION_TOKEN env var to file"); - manager.set_token(SecretString::from(token.clone())).await; - if let Err(e) = manager.save_session(&token, None).await { - tracing::warn!("Failed to save migrated session: {}", e); - } + tracing::info!("Using session token from NEARAI_SESSION_TOKEN env var"); + manager.set_token(SecretString::from(token)).await; } Arc::new(manager) diff --git a/src/settings.rs b/src/settings.rs index fd4d45f9..540c571e 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -1229,4 +1229,107 @@ mod tests { assert_eq!(s.tunnel.cf_token, Some("cf_tok_xyz".to_string())); assert!(s.tunnel.ts_funnel); } + + /// Simulates the wizard recovery scenario: + /// + /// 1. A prior partial run saved steps 1-4 to the DB + /// 2. User re-runs the wizard, Step 1 sets a new database_url + /// 3. Prior settings are loaded from the DB + /// 4. Step 1's fresh choices must win over stale DB values + /// + /// This tests the ordering: load DB → merge_from(step1_overrides). + #[test] + fn wizard_recovery_step1_overrides_stale_db() { + // Simulate prior partial run (steps 1-4 completed): + let prior_run = Settings { + database_backend: Some("postgres".to_string()), + database_url: Some("postgres://old-host/ironclaw".to_string()), + llm_backend: Some("anthropic".to_string()), + selected_model: Some("claude-sonnet-4-5".to_string()), + embeddings: EmbeddingsSettings { + enabled: true, + provider: "openai".to_string(), + ..Default::default() + }, + ..Default::default() + }; + + // Save to DB and reload (simulates persistence round-trip) + let db_map = prior_run.to_db_map(); + let from_db = Settings::from_db_map(&db_map); + + // Step 1 of the new wizard run: user enters a NEW database_url + let mut step1_settings = Settings::default(); + step1_settings.database_backend = Some("postgres".to_string()); + step1_settings.database_url = Some("postgres://new-host/ironclaw".to_string()); + + // Wizard flow: load DB → merge_from(step1_overrides) + let mut current = step1_settings.clone(); + // try_load_existing_settings: merge DB into current + current.merge_from(&from_db); + // Re-apply Step 1 choices on top + current.merge_from(&step1_settings); + + // Step 1's fresh database_url wins over stale DB value + assert_eq!( + current.database_url, + Some("postgres://new-host/ironclaw".to_string()), + "Step 1 fresh choice must override stale DB value" + ); + + // Prior run's steps 2-4 settings are preserved + assert_eq!( + current.llm_backend, + Some("anthropic".to_string()), + "Prior run's LLM backend must be recovered" + ); + assert_eq!( + current.selected_model, + Some("claude-sonnet-4-5".to_string()), + "Prior run's model must be recovered" + ); + assert!( + current.embeddings.enabled, + "Prior run's embeddings setting must be recovered" + ); + } + + /// Verifies that persisting defaults doesn't clobber prior settings + /// when the merge ordering is correct. + #[test] + fn wizard_recovery_defaults_dont_clobber_prior() { + // Prior run saved non-default settings + let prior_run = Settings { + llm_backend: Some("openai".to_string()), + selected_model: Some("gpt-4o".to_string()), + heartbeat: HeartbeatSettings { + enabled: true, + interval_secs: 900, + ..Default::default() + }, + ..Default::default() + }; + let db_map = prior_run.to_db_map(); + let from_db = Settings::from_db_map(&db_map); + + // New wizard run: Step 1 only sets DB fields (rest is default) + let step1 = Settings { + database_backend: Some("libsql".to_string()), + ..Default::default() + }; + + // Correct merge ordering + let mut current = step1.clone(); + current.merge_from(&from_db); + current.merge_from(&step1); + + // Prior settings preserved (Step 1 doesn't touch these) + assert_eq!(current.llm_backend, Some("openai".to_string())); + assert_eq!(current.selected_model, Some("gpt-4o".to_string())); + assert!(current.heartbeat.enabled); + assert_eq!(current.heartbeat.interval_secs, 900); + + // Step 1's choice applied + assert_eq!(current.database_backend, Some("libsql".to_string())); + } } diff --git a/src/setup/README.md b/src/setup/README.md index 9c72d390..dfdd950d 100644 --- a/src/setup/README.md +++ b/src/setup/README.md @@ -167,7 +167,8 @@ env-var mode or skipped secrets. | Provider | Auth Method | Secret Name | Env Var | |----------|-------------|-------------|---------| -| NEAR AI | Browser OAuth | (session token) | `NEARAI_SESSION_TOKEN` | +| NEAR AI Chat | Browser OAuth or session token | - | `NEARAI_SESSION_TOKEN` | +| NEAR AI Cloud | API key | `llm_nearai_api_key` | `NEARAI_API_KEY` | | Anthropic | API key | `anthropic_api_key` | `ANTHROPIC_API_KEY` | | OpenAI | API key | `openai_api_key` | `OPENAI_API_KEY` | | Ollama | None | - | - | @@ -180,8 +181,18 @@ env-var mode or skipped secrets. 4. **Cache key in `self.llm_api_key`** for model fetching in Step 4 **NEAR AI** (`setup_nearai`): -- Calls `session_manager.ensure_authenticated()` which opens browser -- Session token saved to `~/.ironclaw/session.json` +- Calls `session_manager.ensure_authenticated()` which shows the auth menu: + - Options 1-2 (GitHub/Google): browser OAuth → **NEAR AI Chat** mode + (Responses API at `private.near.ai`, session token auth) + - Option 4: NEAR AI Cloud API key → **NEAR AI Cloud** mode + (Chat Completions API at `cloud-api.near.ai`, API key auth) +- **NEAR AI Chat** path: session token saved to `~/.ironclaw/session.json`. + Hosting providers can set `NEARAI_SESSION_TOKEN` env var directly (takes + precedence over file-based tokens). +- **NEAR AI Cloud** path: `NEARAI_API_KEY` saved to `~/.ironclaw/.env` + (bootstrap) and encrypted secrets store (`llm_nearai_api_key`). + `LlmConfig::resolve()` auto-selects `ChatCompletions` mode when the + API key is present. **`self.llm_api_key` caching:** The wizard caches the API key as `Option` so that Step 4 (model fetching) and Step 5 @@ -372,25 +383,60 @@ heartbeat.enabled = "true" heartbeat.interval_secs = "300" ``` +### Incremental Persistence + +Settings are persisted **after every successful step**, not just at the end. +This prevents data loss if a later step fails (e.g., the user enters an +API key in step 3 but step 5 crashes — they won't need to re-enter it). + +**`persist_after_step()`** is called after each step in `run()` and: +1. Writes bootstrap vars to `~/.ironclaw/.env` via `write_bootstrap_env()` +2. Writes all current settings to the database via `persist_settings()` +3. Silently ignores errors (e.g., if called before Step 1 establishes a DB) + +**`try_load_existing_settings()`** is called after Step 1 establishes a +database connection. It loads any previously saved settings from the +database using `get_all_settings("default")` → `Settings::from_db_map()` +→ `merge_from()`. This recovers progress from prior partial wizard runs. + +**Ordering after Step 1 is critical:** + +``` +step_database() → sets DB fields in self.settings +let step1 = self.settings.clone() → snapshot Step 1 choices +try_load_existing_settings() → merge DB values into self.settings +self.settings.merge_from(&step1) → re-apply Step 1 (fresh wins over stale) +persist_after_step() → save merged state +``` + +This ordering ensures: +- Prior progress (steps 2-7 from a previous partial run) is recovered +- Fresh Step 1 choices override stale DB values (not the reverse) +- The first DB persist doesn't clobber prior settings with defaults + ### save_and_summarize() Final step of the wizard: ``` 1. Mark onboard_completed = true -2. Write ALL settings to database (try postgres pool, then libSQL backend) -3. Write bootstrap vars to ~/.ironclaw/.env: - - DATABASE_BACKEND (always) - - DATABASE_URL (if postgres) - - LIBSQL_PATH (if libsql) - - LIBSQL_URL (if turso sync) - - LLM_BACKEND (always, when set) - - LLM_BASE_URL (if openai_compatible) - - OLLAMA_BASE_URL (if ollama) - - ONBOARD_COMPLETED (always, "true") +2. Call persist_settings() for final write (idempotent — ensures + onboard_completed flag is saved) +3. Call write_bootstrap_env() for final .env write (idempotent) 4. Print configuration summary ``` +Bootstrap vars written to `~/.ironclaw/.env`: +- `DATABASE_BACKEND` (always) +- `DATABASE_URL` (if postgres) +- `LIBSQL_PATH` (if libsql) +- `LIBSQL_URL` (if turso sync) +- `LLM_BACKEND` (always, when set) +- `LLM_BASE_URL` (if openai_compatible) +- `OLLAMA_BASE_URL` (if ollama) +- `NEARAI_API_KEY` (if API key auth path) +- `ONBOARD_COMPLETED` (always, "true") + **Invariant:** Both Layer 1 and Layer 2 must be written. If the database write fails, the wizard returns an error and the `.env` file is not written. @@ -498,9 +544,9 @@ anthropic_api_key → encrypted API key | `confirm(label, default)` | `[Y/n]` or `[y/N]` prompt | | `print_header(text)` | Bold section header with underline | | `print_step(n, total, text)` | `[1/7] Step Name` | -| `print_success(text)` | Green checkmark prefix | -| `print_error(text)` | Red X prefix | -| `print_info(text)` | Blue info prefix | +| `print_success(text)` | Green `✓` prefix (ANSI color), message in default color | +| `print_error(text)` | Red `✗` prefix (ANSI color), message in default color | +| `print_info(text)` | Blue `ℹ` prefix (ANSI color), message in default color | `select_many` uses `crossterm` raw mode for arrow key navigation. Must properly restore terminal state on all exit paths. @@ -523,6 +569,30 @@ Must properly restore terminal state on all exit paths. - May need `gnome-keyring` daemon running - Collection unlock may prompt for password +### Remote Server Authentication + +On remote/VPS servers, the browser-based OAuth flow for NEAR AI may not +work because `http://127.0.0.1:9876` is unreachable from the user's +local browser. + +**Solutions:** + +1. **NEAR AI Cloud API key (option 4 in auth menu):** Get an API key + from `https://cloud.near.ai` and paste it into the terminal. No + local listener is needed. The key is saved to `~/.ironclaw/.env` + and the encrypted secrets store. Uses the OpenAI-compatible + ChatCompletions API mode. + +2. **Custom callback URL:** Set `IRONCLAW_OAUTH_CALLBACK_URL` to a + publicly accessible URL (e.g., via SSH tunnel or reverse proxy) that + forwards to port 9876 on the server: + ```bash + export IRONCLAW_OAUTH_CALLBACK_URL=https://myserver.example.com:9876 + ``` + +The `callback_url()` function in `oauth_defaults.rs` checks this env var +and falls back to `http://127.0.0.1:{OAUTH_CALLBACK_PORT}`. + ### URL Passwords - `#` is common in URL-encoded passwords (`%23` decoded) diff --git a/src/setup/prompts.rs b/src/setup/prompts.rs index ce075572..8b50af8c 100644 --- a/src/setup/prompts.rs +++ b/src/setup/prompts.rs @@ -293,19 +293,31 @@ pub fn print_step(current: usize, total: usize, name: &str) { println!(); } -/// Print a success message with checkmark. +/// Print a success message with green checkmark. pub fn print_success(message: &str) { - println!("✓ {}", message); + let mut stdout = io::stdout(); + let _ = execute!(stdout, SetForegroundColor(Color::Green)); + print!("✓"); + let _ = execute!(stdout, ResetColor); + println!(" {}", message); } -/// Print an error message. +/// Print an error message with red X. pub fn print_error(message: &str) { - eprintln!("✗ {}", message); + let mut stderr = io::stderr(); + let _ = execute!(stderr, SetForegroundColor(Color::Red)); + eprint!("✗"); + let _ = execute!(stderr, ResetColor); + eprintln!(" {}", message); } -/// Print an info message. +/// Print an info message with blue info icon. pub fn print_info(message: &str) { - println!(" {}", message); + let mut stdout = io::stdout(); + let _ = execute!(stdout, SetForegroundColor(Color::Blue)); + print!("ℹ"); + let _ = execute!(stdout, ResetColor); + println!(" {}", message); } /// Read a simple line of input with a prompt. @@ -358,4 +370,15 @@ mod tests { super::print_step(1, 3, "Test Step"); super::print_step(3, 3, "Final Step"); } + + #[test] + fn test_print_functions_do_not_panic() { + super::print_success("operation completed"); + super::print_error("something went wrong"); + super::print_info("here is some information"); + // Also test with empty strings + super::print_success(""); + super::print_error(""); + super::print_info(""); + } } diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 7947d511..407ec855 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -125,6 +125,11 @@ impl SetupWizard { } /// 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_header("IronClaw Setup Wizard"); @@ -141,9 +146,22 @@ impl SetupWizard { 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 { @@ -152,18 +170,22 @@ impl SetupWizard { } 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"); @@ -172,6 +194,7 @@ impl SetupWizard { // Step 8: Heartbeat print_step(8, total_steps, "Background Tasks"); self.step_heartbeat()?; + self.persist_after_step().await; } // Save settings and print summary @@ -802,6 +825,20 @@ impl SetupWizard { .map_err(|e| SetupError::Auth(e.to_string()))?; self.session_manager = Some(session); + + // If the user chose the API key path, NEARAI_API_KEY is now set + // in the environment. Persist it to the encrypted secrets store + // so inject_llm_keys_from_secrets() can load it on future runs. + if let Ok(api_key) = std::env::var("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(()) } @@ -1719,110 +1756,211 @@ impl SetupWizard { 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("default", &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("default", &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`. + /// + /// These are the chicken-and-egg settings needed before the database is + /// connected (DATABASE_BACKEND, DATABASE_URL, LLM_BACKEND, etc.). + fn write_bootstrap_env(&self) -> Result<(), SetupError> { + let mut env_vars: Vec<(&str, String)> = Vec::new(); + + if let Some(ref backend) = self.settings.database_backend { + env_vars.push(("DATABASE_BACKEND", backend.clone())); + } + if let Some(ref url) = self.settings.database_url { + env_vars.push(("DATABASE_URL", url.clone())); + } + if let Some(ref path) = self.settings.libsql_path { + env_vars.push(("LIBSQL_PATH", path.clone())); + } + if let Some(ref url) = self.settings.libsql_url { + env_vars.push(("LIBSQL_URL", url.clone())); + } + + // LLM bootstrap vars: same chicken-and-egg problem as DATABASE_BACKEND. + // Config::from_env() needs the backend before the DB is connected. + if let Some(ref backend) = self.settings.llm_backend { + env_vars.push(("LLM_BACKEND", backend.clone())); + } + if let Some(ref url) = self.settings.openai_compatible_base_url { + env_vars.push(("LLM_BASE_URL", url.clone())); + } + if let Some(ref url) = self.settings.ollama_base_url { + env_vars.push(("OLLAMA_BASE_URL", url.clone())); + } + + // Preserve NEARAI_API_KEY if present (set by API key auth flow) + if let Ok(api_key) = std::env::var("NEARAI_API_KEY") + && !api_key.is_empty() + { + env_vars.push(("NEARAI_API_KEY", api_key)); + } + + // 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", "true".to_string())); + } + + if !env_vars.is_empty() { + let pairs: Vec<(&str, &str)> = env_vars.iter().map(|(k, v)| (*k, v.as_str())).collect(); + crate::bootstrap::save_bootstrap_env(&pairs).map_err(|e| { + SetupError::Io(std::io::Error::other(format!( + "Failed to save bootstrap env to .env: {}", + e + ))) + })?; + } + + Ok(()) + } + + /// 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("default").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("default").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 summary. async fn save_and_summarize(&mut self) -> Result<(), SetupError> { self.settings.onboard_completed = true; - // Write all settings to the database (whichever backend is active). - { - let db_map = self.settings.to_db_map(); - let saved = false; + // Final persist (idempotent — earlier incremental saves already wrote + // most settings, but this ensures onboard_completed is saved). + let saved = self.persist_settings().await?; - #[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("default", &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("default", &db_map) - .await - .map_err(|e| { - SetupError::Database(format!( - "Failed to save settings to database: {}", - e - )) - })?; - true - } else { - false - } - } else { - saved - }; - - if !saved { - return Err(SetupError::Database( - "No database connection, cannot save settings".to_string(), - )); - } + if !saved { + return Err(SetupError::Database( + "No database connection, cannot save settings".to_string(), + )); } - // Persist database bootstrap vars to ~/.ironclaw/.env. - // These are the chicken-and-egg settings: we need them to decide - // which database to connect to, so they can't live in the database. - { - let mut env_vars: Vec<(&str, String)> = Vec::new(); - - if let Some(ref backend) = self.settings.database_backend { - env_vars.push(("DATABASE_BACKEND", backend.clone())); - } - if let Some(ref url) = self.settings.database_url { - env_vars.push(("DATABASE_URL", url.clone())); - } - if let Some(ref path) = self.settings.libsql_path { - env_vars.push(("LIBSQL_PATH", path.clone())); - } - if let Some(ref url) = self.settings.libsql_url { - env_vars.push(("LIBSQL_URL", url.clone())); - } - - // LLM bootstrap vars: same chicken-and-egg problem as DATABASE_BACKEND. - // Config::from_env() needs the backend before the DB is connected. - if let Some(ref backend) = self.settings.llm_backend { - env_vars.push(("LLM_BACKEND", backend.clone())); - } - if let Some(ref url) = self.settings.openai_compatible_base_url { - env_vars.push(("LLM_BASE_URL", url.clone())); - } - if let Some(ref url) = self.settings.ollama_base_url { - env_vars.push(("OLLAMA_BASE_URL", url.clone())); - } - - // Always write ONBOARD_COMPLETED so that check_onboard_needed() - // (which runs before the DB is connected) knows to skip re-onboarding. - env_vars.push(("ONBOARD_COMPLETED", "true".to_string())); - - if !env_vars.is_empty() { - let pairs: Vec<(&str, &str)> = - env_vars.iter().map(|(k, v)| (*k, v.as_str())).collect(); - crate::bootstrap::save_bootstrap_env(&pairs).map_err(|e| { - SetupError::Io(std::io::Error::other(format!( - "Failed to save bootstrap env to .env: {}", - e - ))) - })?; - } - } + // Write bootstrap env (also idempotent) + self.write_bootstrap_env()?; println!(); print_success("Configuration saved to database");