diff --git a/src/llm/config.rs b/src/llm/config.rs index aea0478a..4ac82761 100644 --- a/src/llm/config.rs +++ b/src/llm/config.rs @@ -246,8 +246,8 @@ impl NearAiConfig { } else { "https://private.near.ai" }; - let base_url = - std::env::var("NEARAI_BASE_URL").unwrap_or_else(|_| default_base.to_string()); + let base_url = crate::config::helpers::env_or_override("NEARAI_BASE_URL") + .unwrap_or_else(|| default_base.to_string()); Self { model: String::new(), diff --git a/src/llm/models.rs b/src/llm/models.rs index fcf09beb..6346cd75 100644 --- a/src/llm/models.rs +++ b/src/llm/models.rs @@ -332,8 +332,8 @@ pub(crate) async fn fetch_openai_compatible_models( /// Uses [`NearAiConfig::for_model_discovery()`] to construct a minimal NEAR AI /// config, then wraps it in an `LlmConfig` with session config for auth. pub(crate) fn build_nearai_model_fetch_config() -> crate::config::LlmConfig { - let auth_base_url = - std::env::var("NEARAI_AUTH_URL").unwrap_or_else(|_| "https://private.near.ai".to_string()); + let auth_base_url = crate::config::helpers::env_or_override("NEARAI_AUTH_URL") + .unwrap_or_else(|| "https://private.near.ai".to_string()); crate::config::LlmConfig { backend: "nearai".to_string(), diff --git a/src/setup/README.md b/src/setup/README.md index 7e3c9fa8..57ca8d6d 100644 --- a/src/setup/README.md +++ b/src/setup/README.md @@ -216,8 +216,8 @@ env-var mode or skipped secrets. |----------|-------------|-------------|---------| | 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` | +| Anthropic | API key | `llm_anthropic_api_key` | `ANTHROPIC_API_KEY` | +| OpenAI | API key | `llm_openai_api_key` | `OPENAI_API_KEY` | | Ollama | None | - | - | | OpenRouter | API key | `llm_openrouter_api_key` | `OPENROUTER_API_KEY` | | OpenAI-compatible | Optional API key | `llm_compatible_api_key` | `LLM_API_KEY` | @@ -406,26 +406,24 @@ Contains only the settings needed BEFORE database connection. Written by ```env DATABASE_BACKEND="libsql" LIBSQL_PATH="/Users/name/.ironclaw/ironclaw.db" -LLM_BACKEND="openai_compatible" -LLM_BASE_URL="http://my-vllm:8000/v1" +SECRETS_MASTER_KEY="..." # only if env key source selected +ONBOARD_COMPLETED="true" ``` -Or for PostgreSQL + NEAR AI: +Or for PostgreSQL: ```env DATABASE_BACKEND="postgres" DATABASE_URL="postgres://user:pass@localhost/ironclaw" -LLM_BACKEND="nearai" -``` - -Or for Ollama: -```env -LLM_BACKEND="ollama" -OLLAMA_BASE_URL="http://localhost:11434" +SECRETS_MASTER_KEY="..." +ONBOARD_COMPLETED="true" ``` **Why separate?** Chicken-and-egg: you need `DATABASE_BACKEND` to know -which database to connect to, and `LLM_BACKEND` to know whether to -attempt NEAR AI session auth -- neither can be stored in the database. +which database to connect to, and `SECRETS_MASTER_KEY` to decrypt the +secrets store — neither can be stored in the database. LLM settings +(`LLM_BACKEND`, base URLs, model names) are persisted to the DB via +`persist_settings()` and loaded after connection. API keys are stored +encrypted in the secrets DB. **Layer 2: Database settings table** (everything else) @@ -487,16 +485,20 @@ Final step of the wizard: 4. Print configuration summary ``` -Bootstrap vars written to `~/.ironclaw/.env`: +Bootstrap vars written to `~/.ironclaw/.env` (only true chicken-and-egg vars +that are needed before the DB is connected): - `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) +- `SECRETS_MASTER_KEY` (if env key source selected in Step 2) - `ONBOARD_COMPLETED` (always, "true") +- Channel/sandbox vars: `CLAUDE_CODE_ENABLED`, `SIGNAL_HTTP_URL`, `SIGNAL_ACCOUNT`, etc. (channel init may precede DB) + +LLM settings (`LLM_BACKEND`, `LLM_BASE_URL`, model, API keys) are persisted +to the DB via `persist_settings()` and loaded by `Config::from_db_with_toml()` +after connection. API keys are stored encrypted in the secrets DB and injected +via `inject_llm_keys_from_secrets()`. **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. @@ -586,7 +588,7 @@ in the database `secrets` table. The wizard writes secrets like: ``` telegram_bot_token → encrypted bot token telegram_webhook_secret → encrypted webhook HMAC secret -anthropic_api_key → encrypted API key +llm_anthropic_api_key → encrypted API key ``` --- diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index aca5b91e..ec49f039 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -2654,16 +2654,17 @@ impl SetupWizard { /// 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.). + /// 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). /// - /// **Credentials are NOT written here.** API keys and OAuth tokens live - /// only in the encrypted secrets DB. `LlmConfig::resolve()` defers - /// gracefully when credentials are missing during early startup, and the - /// re-resolution in `AppBuilder::build_all()` fills them in after - /// `inject_llm_keys_from_secrets()` loads from encrypted storage. + /// **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 registry = crate::llm::ProviderRegistry::load(); let mut env_vars: Vec<(String, String)> = Vec::new(); if let Some(ref backend) = self.settings.database_backend { @@ -2679,66 +2680,6 @@ impl SetupWizard { env_vars.push(("LIBSQL_URL".to_string(), 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".to_string(), backend.clone())); - } - if let Some(ref url) = self.settings.openai_compatible_base_url { - env_vars.push(("LLM_BASE_URL".to_string(), url.clone())); - } - if let Some(ref url) = self.settings.ollama_base_url { - env_vars.push(("OLLAMA_BASE_URL".to_string(), url.clone())); - } - if let Some(ref region) = self.settings.bedrock_region { - env_vars.push(("BEDROCK_REGION".to_string(), region.clone())); - } - if self.settings.llm_backend.as_deref() == Some("bedrock") { - if let Some(ref model) = self.settings.selected_model { - env_vars.push(("BEDROCK_MODEL".to_string(), model.clone())); - } - if let Some(ref cross) = self.settings.bedrock_cross_region { - env_vars.push(("BEDROCK_CROSS_REGION".to_string(), cross.clone())); - } - if let Some(ref profile) = self.settings.bedrock_profile { - env_vars.push(("AWS_PROFILE".to_string(), profile.clone())); - } - } - - // Model name: same chicken-and-egg — Config::from_env() resolves the - // model before the DB is connected, so we must persist it to .env. - // Write the backend-specific env var so the correct resolution path - // picks it up (looked up from the provider registry). - // Bedrock model is already written above as BEDROCK_MODEL, skip here. - if self.settings.llm_backend.as_deref() != Some("bedrock") - && let Some(ref model) = self.settings.selected_model - { - let backend_str = self.settings.llm_backend.as_deref().unwrap_or("nearai"); - let model_env = registry.model_env_var(backend_str); - env_vars.push((model_env.to_string(), model.clone())); - } - - // Also write provider-specific base URL env var if the provider - // defines one (e.g., GROQ doesn't need LLM_BASE_URL since its - // default is compiled in, but it doesn't hurt to be explicit). - if let Some(ref backend) = self.settings.llm_backend - && let Some(def) = registry.find(backend) - && let Some(ref base_url_env) = def.base_url_env - && let Some(ref base_url) = def.default_base_url - && base_url_env != "LLM_BASE_URL" - && base_url_env != "OLLAMA_BASE_URL" - { - env_vars.push((base_url_env.clone(), base_url.clone())); - } - - // Preserve NEARAI_API_KEY if present (set by API key auth flow - // via the thread-safe runtime env overlay). - if let Some(api_key) = crate::config::helpers::env_or_override("NEARAI_API_KEY") - && !api_key.is_empty() - { - env_vars.push(("NEARAI_API_KEY".to_string(), api_key)); - } - // 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 { @@ -3924,6 +3865,39 @@ mod tests { ); } + /// 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 = ENV_MUTEX.lock().unwrap(); + 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