From fe53f6993f68185daa8df6c299d310b269ff89b8 Mon Sep 17 00:00:00 2001 From: "ironclaw-ci[bot]" <266877842+ironclaw-ci[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 08:09:34 +0000 Subject: [PATCH] chore: promote staging to staging-promote/57c397bd-23120362128 (2026-03-16 05:35 UTC) (#1236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(setup): extract init logic from wizard into owning modules (#1210) * refactor(setup): extract init logic from wizard into owning modules Move database, LLM model discovery, and secrets initialization logic out of the setup wizard and into their owning modules, following the CLAUDE.md principle that module-specific initialization must live in the owning module as a public factory function. Database (src/db/mod.rs, src/config/database.rs): - Add DatabaseConfig::from_postgres_url() and from_libsql_path() - Add connect_without_migrations() for connectivity testing - Add validate_postgres() returning structured PgDiagnostic results LLM (src/llm/models.rs — new file): - Extract 8 model-fetching functions from wizard.rs (~380 lines) - fetch_anthropic_models, fetch_openai_models, fetch_ollama_models, fetch_openai_compatible_models, build_nearai_model_fetch_config, and OpenAI sorting/filtering helpers Secrets (src/secrets/mod.rs): - Add resolve_master_key() unifying env var + keychain resolution - Add crypto_from_hex() convenience wrapper Wizard restructuring (src/setup/wizard.rs): - Replace cfg-gated db_pool/db_backend fields with generic db: Option> + db_handles: Option - Delete 6 backend-specific methods (reconnect_postgres/libsql, test_database_connection_postgres/libsql, run_migrations_postgres/ libsql, create_postgres/libsql_secrets_store) - Simplify persist_settings, try_load_existing_settings, persist_session_to_db, init_secrets_context to backend-agnostic implementations using the new module factories - Eliminate all references to deadpool_postgres, PoolConfig, LibSqlBackend, Store::from_pool, refinery::embed_migrations Net: -878 lines from wizard, +395 lines in owning modules, +378 new. Co-Authored-By: Claude Opus 4.6 (1M context) * test(settings): add wizard re-run regression tests Add 10 tests covering settings preservation during wizard re-runs: - provider_only rerun preserves channels/embeddings/heartbeat - channels_only rerun preserves provider/model/embeddings - quick mode rerun preserves prior channels and heartbeat - full rerun same provider preserves model through merge - full rerun different provider clears model through merge - incremental persist doesn't clobber prior steps - switching DB backend allows fresh connection settings - merge preserves true booleans when overlay has default false - embeddings survive rerun that skips step 5 These cover the scenarios where re-running the wizard would previously risk resetting models, providers, or channel settings. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(setup): eliminate cfg(feature) gates from wizard methods Replace compile-time #[cfg(feature)] dispatch in the wizard with runtime dispatch via DatabaseBackend enum and cfg!() macro constants. - Merge step_database_postgres + step_database_libsql into step_database using runtime backend selection - Rewrite auto_setup_database without feature gates - Remove cfg(feature = "postgres") from mask_password_in_url (pure fn) - Remove cfg(feature = "postgres") from test_mask_password_in_url Only one internal #[cfg(feature = "postgres")] remains: guarding the call to db::validate_postgres() which is itself feature-gated. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(db): fold PG validation into connect_without_migrations Move PostgreSQL prerequisite validation (version >= 15, pgvector) from the wizard into connect_without_migrations() in the db module. The validation now returns DatabaseError directly with user-facing messages, eliminating the PgDiagnostic enum and the last #[cfg(feature)] gate from the wizard. The wizard's test_database_connection() is now a 5-line method that calls the db module factory and stores the result. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address PR review comments [skip-regression-check] - Use .as_ref().map() to avoid partial move of db_config.libsql_path (gemini-code-assist) - Default to available backend when DATABASE_BACKEND is invalid, not unconditionally to Postgres which may not be compiled (Copilot) - Match DatabaseBackend::Postgres explicitly instead of _ => wildcard in connect_with_handles, connect_without_migrations, and create_secrets_store to avoid silently routing LibSql configs through the Postgres path when libsql feature is disabled (Copilot) - Upgrade Ollama connection failure log from info to warn with the base URL for better visibility in wizard UX (Copilot) - Clarify crypto_from_hex doc: SecretsCrypto validates key length, not hex encoding (Copilot) Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address zmanian's PR review feedback [skip-regression-check] - Update src/setup/README.md to reflect Arc flow - Remove stale "Test PostgreSQL connection" doc comment - Replace unwrap_or(0) in validate_postgres with descriptive error - Add NearAiConfig::for_model_discovery() constructor - Narrow pub to pub(crate) for internal model helpers Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address Copilot review comments (quick-mode postgres gate, empty env vars) [skip-regression-check] - Gate DATABASE_URL auto-detection on POSTGRES_AVAILABLE in quick mode so libsql-only builds don't attempt a postgres connection - Match empty-env-var filtering in key source detection to align with resolve_master_key() behavior - Filter empty strings to None in DatabaseConfig::from_libsql_path() for turso_url/turso_token Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) * fix: Telegram bot token validation fails intermittently (HTTP 404) (#1166) * fix: Telegram bot token validation fails intermittently (HTTP 404) * fix: code style * fix * fix * fix * review fix --------- Co-authored-by: Illia Polosukhin Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Nick Pismenkov <50764773+nickpismenkov@users.noreply.github.com> --- .github/workflows/e2e.yml | 2 +- .gitignore | 6 + src/config/database.rs | 34 + src/db/mod.rs | 151 +- src/extensions/manager.rs | 43 +- src/llm/config.rs | 39 + src/llm/mod.rs | 1 + src/llm/models.rs | 349 +++++ src/secrets/mod.rs | 56 + src/settings.rs | 499 +++++++ src/setup/README.md | 30 +- src/setup/wizard.rs | 1231 ++++------------- .../test_telegram_token_validation.py | 172 +++ 13 files changed, 1607 insertions(+), 1006 deletions(-) create mode 100644 src/llm/models.rs create mode 100644 tests/e2e/scenarios/test_telegram_token_validation.py diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 92f203b3..ee16c0f8 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -52,7 +52,7 @@ jobs: - group: features files: "tests/e2e/scenarios/test_skills.py tests/e2e/scenarios/test_tool_approval.py" - group: extensions - files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py" + files: "tests/e2e/scenarios/test_extensions.py tests/e2e/scenarios/test_extension_oauth.py tests/e2e/scenarios/test_telegram_token_validation.py tests/e2e/scenarios/test_wasm_lifecycle.py tests/e2e/scenarios/test_tool_execution.py tests/e2e/scenarios/test_pairing.py tests/e2e/scenarios/test_oauth_credential_fallback.py tests/e2e/scenarios/test_routine_oauth_credential_injection.py" steps: - uses: actions/checkout@v6 diff --git a/.gitignore b/.gitignore index ed64c242..2577b4a2 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,9 @@ trace_*.json # Local Claude Code settings (machine-specific, should not be committed) .claude/settings.local.json .worktrees/ + +# Python cache +__pycache__/ +*.pyc +*.pyo +*.pyd diff --git a/src/config/database.rs b/src/config/database.rs index 44abc09b..55d8baea 100644 --- a/src/config/database.rs +++ b/src/config/database.rs @@ -170,6 +170,40 @@ impl DatabaseConfig { }) } + /// Create a config from a raw PostgreSQL URL (for wizard/testing). + pub fn from_postgres_url(url: &str, pool_size: usize) -> Self { + Self { + backend: DatabaseBackend::Postgres, + url: SecretString::from(url.to_string()), + pool_size, + ssl_mode: SslMode::from_env(), + libsql_path: None, + libsql_url: None, + libsql_auth_token: None, + } + } + + /// Create a config for a libSQL database (for wizard/testing). + /// + /// Empty strings for `turso_url` and `turso_token` are treated as `None`. + pub fn from_libsql_path( + path: &str, + turso_url: Option<&str>, + turso_token: Option<&str>, + ) -> Self { + let turso_url = turso_url.filter(|s| !s.is_empty()); + let turso_token = turso_token.filter(|s| !s.is_empty()); + Self { + backend: DatabaseBackend::LibSql, + url: SecretString::from("unused://libsql".to_string()), + pool_size: 1, + ssl_mode: SslMode::default(), + libsql_path: Some(PathBuf::from(path)), + libsql_url: turso_url.map(String::from), + libsql_auth_token: turso_token.map(|t| SecretString::from(t.to_string())), + } + } + /// Get the database URL (exposes the secret). pub fn url(&self) -> &str { self.url.expose_secret() diff --git a/src/db/mod.rs b/src/db/mod.rs index a306c14b..6d2eb296 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -104,7 +104,7 @@ pub async fn connect_with_handles( Ok((Arc::new(backend) as Arc, handles)) } #[cfg(feature = "postgres")] - _ => { + crate::config::DatabaseBackend::Postgres => { let pg = postgres::PgBackend::new(config) .await .map_err(|e| DatabaseError::Pool(e.to_string()))?; @@ -115,10 +115,11 @@ pub async fn connect_with_handles( Ok((Arc::new(pg) as Arc, handles)) } - #[cfg(not(feature = "postgres"))] - _ => Err(DatabaseError::Pool( - "No database backend available. Enable 'postgres' or 'libsql' feature.".to_string(), - )), + #[allow(unreachable_patterns)] + _ => Err(DatabaseError::Pool(format!( + "Database backend '{}' is not available. Rebuild with the appropriate feature flag.", + config.backend + ))), } } @@ -161,7 +162,7 @@ pub async fn create_secrets_store( ))) } #[cfg(feature = "postgres")] - _ => { + crate::config::DatabaseBackend::Postgres => { let pg = postgres::PgBackend::new(config) .await .map_err(|e| DatabaseError::Pool(e.to_string()))?; @@ -172,14 +173,142 @@ pub async fn create_secrets_store( crypto, ))) } - #[cfg(not(feature = "postgres"))] - _ => Err(DatabaseError::Pool( - "No database backend available for secrets. Enable 'postgres' or 'libsql' feature." - .to_string(), - )), + #[allow(unreachable_patterns)] + _ => Err(DatabaseError::Pool(format!( + "Database backend '{}' is not available for secrets. Rebuild with the appropriate feature flag.", + config.backend + ))), } } +// ==================== Wizard / testing helpers ==================== + +/// Connect to the database WITHOUT running migrations, validating +/// prerequisites when applicable (PostgreSQL version, pgvector). +/// +/// Returns both the `Database` trait object and backend-specific handles. +/// Used by the wizard to test connectivity before committing — call +/// [`Database::run_migrations`] on the returned trait object when ready. +pub async fn connect_without_migrations( + config: &crate::config::DatabaseConfig, +) -> Result<(Arc, DatabaseHandles), DatabaseError> { + let mut handles = DatabaseHandles::default(); + + match config.backend { + #[cfg(feature = "libsql")] + crate::config::DatabaseBackend::LibSql => { + use secrecy::ExposeSecret as _; + + let default_path = crate::config::default_libsql_path(); + let db_path = config.libsql_path.as_deref().unwrap_or(&default_path); + + let backend = if let Some(ref url) = config.libsql_url { + let token = config.libsql_auth_token.as_ref().ok_or_else(|| { + DatabaseError::Pool( + "LIBSQL_AUTH_TOKEN required when LIBSQL_URL is set".to_string(), + ) + })?; + libsql::LibSqlBackend::new_remote_replica(db_path, url, token.expose_secret()) + .await + .map_err(|e| DatabaseError::Pool(e.to_string()))? + } else { + libsql::LibSqlBackend::new_local(db_path) + .await + .map_err(|e| DatabaseError::Pool(e.to_string()))? + }; + + handles.libsql_db = Some(backend.shared_db()); + + Ok((Arc::new(backend) as Arc, handles)) + } + #[cfg(feature = "postgres")] + crate::config::DatabaseBackend::Postgres => { + let pg = postgres::PgBackend::new(config) + .await + .map_err(|e| DatabaseError::Pool(e.to_string()))?; + + handles.pg_pool = Some(pg.pool()); + + // Validate PostgreSQL prerequisites (version, pgvector) + validate_postgres(&pg.pool()).await?; + + Ok((Arc::new(pg) as Arc, handles)) + } + #[allow(unreachable_patterns)] + _ => Err(DatabaseError::Pool(format!( + "Database backend '{}' is not available. Rebuild with the appropriate feature flag.", + config.backend + ))), + } +} + +/// Validate PostgreSQL prerequisites (version >= 15, pgvector available). +/// +/// Returns `Ok(())` if all prerequisites are met, or a `DatabaseError` +/// with a user-facing message describing the issue. +#[cfg(feature = "postgres")] +async fn validate_postgres(pool: &deadpool_postgres::Pool) -> Result<(), DatabaseError> { + let client = pool + .get() + .await + .map_err(|e| DatabaseError::Pool(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| DatabaseError::Query(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()) + .ok_or_else(|| { + DatabaseError::Pool(format!( + "Could not parse PostgreSQL version from '{}'. \ + Expected a numeric major version (e.g., '15.2').", + version_str + )) + })?; + + const MIN_PG_MAJOR_VERSION: u32 = 15; + + if major_version < MIN_PG_MAJOR_VERSION { + return Err(DatabaseError::Pool(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| { + DatabaseError::Query(format!("Failed to check pgvector availability: {}", e)) + })?; + + if pgvector_row.is_none() { + return Err(DatabaseError::Pool(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 + ))); + } + + Ok(()) +} + // ==================== Sub-traits ==================== // // Each sub-trait groups related persistence methods. The `Database` supertrait diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index e057e2ac..680c4dfc 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -3817,9 +3817,16 @@ impl ExtensionManager { { let token = token_value.trim(); if !token.is_empty() { - let encoded = - url::form_urlencoded::byte_serialize(token.as_bytes()).collect::(); - let url = endpoint_template.replace(&format!("{{{}}}", secret_def.name), &encoded); + // Telegram tokens contain colons (numeric_id:token_part) in the URL path, + // not query parameters, so URL-encoding breaks the endpoint. + // For other extensions, keep encoding to handle special chars in query parameters. + let url = if name == "telegram" { + endpoint_template.replace(&format!("{{{}}}", secret_def.name), token) + } else { + let encoded = + url::form_urlencoded::byte_serialize(token.as_bytes()).collect::(); + endpoint_template.replace(&format!("{{{}}}", secret_def.name), &encoded) + }; // SSRF defense: block private IPs, localhost, cloud metadata endpoints crate::tools::builtin::skill_tools::validate_fetch_url(&url) .map_err(|e| ExtensionError::Other(format!("SSRF blocked: {}", e)))?; @@ -5668,4 +5675,34 @@ mod tests { "Display should contain 'validation failed', got: {msg}" ); } + + #[test] + fn test_telegram_token_colon_preserved_in_validation_url() { + // Regression: Telegram tokens (format: numeric_id:alphanumeric_string) must NOT + // have their colon URL-encoded to %3A, as this breaks the validation endpoint. + // Previously: form_urlencoded::byte_serialize encoded the token, causing 404s. + // Fixed by removing URL-encoding and using the token directly. + let endpoint_template = "https://api.telegram.org/bot{telegram_bot_token}/getMe"; + let secret_name = "telegram_bot_token"; + let token = "123456789:AABBccDDeeFFgg_Test-Token"; + + // Simulate the fixed validation URL building logic + let url = endpoint_template.replace(&format!("{{{}}}", secret_name), token); + + // Verify colon is preserved + let expected = "https://api.telegram.org/bot123456789:AABBccDDeeFFgg_Test-Token/getMe"; + if url != expected { + panic!("URL mismatch: expected {expected}, got {url}"); // safety: test assertion + } + + // Verify it does NOT contain the broken percent-encoded version + if url.contains("%3A") { + panic!("URL contains URL-encoded colon (%3A): {url}"); // safety: test assertion + } + + // Verify the URL contains the original colon + if !url.contains("123456789:AABBccDDeeFFgg_Test-Token") { + panic!("URL missing token: {url}"); // safety: test assertion + } + } } diff --git a/src/llm/config.rs b/src/llm/config.rs index 1902f128..a3e76ef7 100644 --- a/src/llm/config.rs +++ b/src/llm/config.rs @@ -163,3 +163,42 @@ pub struct NearAiConfig { /// Enable cascade mode for smart routing. Default: true. pub smart_routing_cascade: bool, } + +impl NearAiConfig { + /// Create a minimal config suitable for listing available models. + /// + /// Reads `NEARAI_API_KEY` from the environment and selects the + /// appropriate base URL (cloud-api when API key is present, + /// private.near.ai for session-token auth). + pub(crate) fn for_model_discovery() -> Self { + let api_key = std::env::var("NEARAI_API_KEY") + .ok() + .filter(|k| !k.is_empty()) + .map(SecretString::from); + + let default_base = if api_key.is_some() { + "https://cloud-api.near.ai" + } else { + "https://private.near.ai" + }; + let base_url = + std::env::var("NEARAI_BASE_URL").unwrap_or_else(|_| default_base.to_string()); + + Self { + model: String::new(), + cheap_model: None, + base_url, + api_key, + fallback_model: None, + max_retries: 3, + circuit_breaker_threshold: None, + circuit_breaker_recovery_secs: 30, + response_cache_enabled: false, + response_cache_ttl_secs: 3600, + response_cache_max_entries: 1000, + failover_cooldown_secs: 300, + failover_cooldown_threshold: 3, + smart_routing_cascade: true, + } + } +} diff --git a/src/llm/mod.rs b/src/llm/mod.rs index b49e4974..3c9de369 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -29,6 +29,7 @@ pub mod session; pub mod smart_routing; pub mod image_models; +pub mod models; pub mod reasoning_models; pub mod vision_models; diff --git a/src/llm/models.rs b/src/llm/models.rs new file mode 100644 index 00000000..7022d3cf --- /dev/null +++ b/src/llm/models.rs @@ -0,0 +1,349 @@ +//! Model discovery and fetching for multiple LLM providers. + +/// Fetch models from the Anthropic API. +/// +/// Returns `(model_id, display_label)` pairs. Falls back to static defaults on error. +pub(crate) async fn fetch_anthropic_models(cached_key: Option<&str>) -> Vec<(String, String)> { + let static_defaults = vec![ + ( + "claude-opus-4-6".into(), + "Claude Opus 4.6 (latest flagship)".into(), + ), + ("claude-sonnet-4-6".into(), "Claude Sonnet 4.6".into()), + ("claude-opus-4-5".into(), "Claude Opus 4.5".into()), + ("claude-sonnet-4-5".into(), "Claude Sonnet 4.5".into()), + ("claude-haiku-4-5".into(), "Claude Haiku 4.5 (fast)".into()), + ]; + + let api_key = cached_key + .map(String::from) + .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok()) + .filter(|k| !k.is_empty() && k != crate::config::OAUTH_PLACEHOLDER); + + // Fall back to OAuth token if no API key + let oauth_token = if api_key.is_none() { + crate::config::helpers::optional_env("ANTHROPIC_OAUTH_TOKEN") + .ok() + .flatten() + .filter(|t| !t.is_empty()) + } else { + None + }; + + let (key_or_token, is_oauth) = match (api_key, oauth_token) { + (Some(k), _) => (k, false), + (None, Some(t)) => (t, true), + (None, None) => return static_defaults, + }; + + let client = reqwest::Client::new(); + let mut request = client + .get("https://api.anthropic.com/v1/models") + .header("anthropic-version", "2023-06-01") + .timeout(std::time::Duration::from_secs(5)); + + if is_oauth { + request = request + .bearer_auth(&key_or_token) + .header("anthropic-beta", "oauth-2025-04-20"); + } else { + request = request.header("x-api-key", &key_or_token); + } + + let resp = match request.send().await { + Ok(r) if r.status().is_success() => r, + _ => return static_defaults, + }; + + #[derive(serde::Deserialize)] + struct ModelEntry { + id: String, + } + #[derive(serde::Deserialize)] + struct ModelsResponse { + data: Vec, + } + + match resp.json::().await { + Ok(body) => { + let mut models: Vec<(String, String)> = body + .data + .into_iter() + .filter(|m| !m.id.contains("embedding") && !m.id.contains("audio")) + .map(|m| { + let label = m.id.clone(); + (m.id, label) + }) + .collect(); + if models.is_empty() { + return static_defaults; + } + models.sort_by(|a, b| a.0.cmp(&b.0)); + models + } + Err(_) => static_defaults, + } +} + +/// Fetch models from the OpenAI API. +/// +/// Returns `(model_id, display_label)` pairs. Falls back to static defaults on error. +pub(crate) async fn fetch_openai_models(cached_key: Option<&str>) -> Vec<(String, String)> { + let static_defaults = vec![ + ( + "gpt-5.3-codex".into(), + "GPT-5.3 Codex (latest flagship)".into(), + ), + ("gpt-5.2-codex".into(), "GPT-5.2 Codex".into()), + ("gpt-5.2".into(), "GPT-5.2".into()), + ( + "gpt-5.1-codex-mini".into(), + "GPT-5.1 Codex Mini (fast)".into(), + ), + ("gpt-5".into(), "GPT-5".into()), + ("gpt-5-mini".into(), "GPT-5 Mini".into()), + ("gpt-4.1".into(), "GPT-4.1".into()), + ("gpt-4.1-mini".into(), "GPT-4.1 Mini".into()), + ("o4-mini".into(), "o4-mini (fast reasoning)".into()), + ("o3".into(), "o3 (reasoning)".into()), + ]; + + let api_key = cached_key + .map(String::from) + .or_else(|| std::env::var("OPENAI_API_KEY").ok()) + .filter(|k| !k.is_empty()); + + let api_key = match api_key { + Some(k) => k, + None => return static_defaults, + }; + + let client = reqwest::Client::new(); + let resp = match client + .get("https://api.openai.com/v1/models") + .bearer_auth(&api_key) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + { + Ok(r) if r.status().is_success() => r, + _ => return static_defaults, + }; + + #[derive(serde::Deserialize)] + struct ModelEntry { + id: String, + } + #[derive(serde::Deserialize)] + struct ModelsResponse { + data: Vec, + } + + match resp.json::().await { + Ok(body) => { + let mut models: Vec<(String, String)> = body + .data + .into_iter() + .filter(|m| is_openai_chat_model(&m.id)) + .map(|m| { + let label = m.id.clone(); + (m.id, label) + }) + .collect(); + if models.is_empty() { + return static_defaults; + } + sort_openai_models(&mut models); + models + } + Err(_) => static_defaults, + } +} + +pub(crate) fn is_openai_chat_model(model_id: &str) -> bool { + let id = model_id.to_ascii_lowercase(); + + let is_chat_family = id.starts_with("gpt-") + || id.starts_with("chatgpt-") + || id.starts_with("o1") + || id.starts_with("o3") + || id.starts_with("o4") + || id.starts_with("o5"); + + let is_non_chat_variant = id.contains("realtime") + || id.contains("audio") + || id.contains("transcribe") + || id.contains("tts") + || id.contains("embedding") + || id.contains("moderation") + || id.contains("image"); + + is_chat_family && !is_non_chat_variant +} + +pub(crate) fn openai_model_priority(model_id: &str) -> usize { + let id = model_id.to_ascii_lowercase(); + + const EXACT_PRIORITY: &[&str] = &[ + "gpt-5.3-codex", + "gpt-5.2-codex", + "gpt-5.2", + "gpt-5.1-codex-mini", + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", + "o4-mini", + "o3", + "o1", + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4o", + "gpt-4o-mini", + ]; + if let Some(pos) = EXACT_PRIORITY.iter().position(|m| id == *m) { + return pos; + } + + const PREFIX_PRIORITY: &[&str] = &[ + "gpt-5.", "gpt-5-", "o3-", "o4-", "o1-", "gpt-4.1-", "gpt-4o-", "gpt-3.5-", "chatgpt-", + ]; + if let Some(pos) = PREFIX_PRIORITY + .iter() + .position(|prefix| id.starts_with(prefix)) + { + return EXACT_PRIORITY.len() + pos; + } + + EXACT_PRIORITY.len() + PREFIX_PRIORITY.len() + 1 +} + +pub(crate) fn sort_openai_models(models: &mut [(String, String)]) { + models.sort_by(|a, b| { + openai_model_priority(&a.0) + .cmp(&openai_model_priority(&b.0)) + .then_with(|| a.0.cmp(&b.0)) + }); +} + +/// Fetch installed models from a local Ollama instance. +/// +/// Returns `(model_name, display_label)` pairs. Falls back to static defaults on error. +pub(crate) async fn fetch_ollama_models(base_url: &str) -> Vec<(String, String)> { + let static_defaults = vec![ + ("llama3".into(), "llama3".into()), + ("mistral".into(), "mistral".into()), + ("codellama".into(), "codellama".into()), + ]; + + let url = format!("{}/api/tags", base_url.trim_end_matches('/')); + let client = reqwest::Client::new(); + + let resp = match client + .get(&url) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + { + Ok(r) if r.status().is_success() => r, + Ok(_) => return static_defaults, + Err(_) => { + tracing::warn!( + "Could not connect to Ollama at {base_url}. Is it running? Using static defaults." + ); + return static_defaults; + } + }; + + #[derive(serde::Deserialize)] + struct ModelEntry { + name: String, + } + #[derive(serde::Deserialize)] + struct TagsResponse { + models: Vec, + } + + match resp.json::().await { + Ok(body) => { + let models: Vec<(String, String)> = body + .models + .into_iter() + .map(|m| { + let label = m.name.clone(); + (m.name, label) + }) + .collect(); + if models.is_empty() { + return static_defaults; + } + models + } + Err(_) => static_defaults, + } +} + +/// Fetch models from a generic OpenAI-compatible /v1/models endpoint. +/// +/// Used for registry providers like Groq, NVIDIA NIM, etc. +pub(crate) async fn fetch_openai_compatible_models( + base_url: &str, + cached_key: Option<&str>, +) -> Vec<(String, String)> { + if base_url.is_empty() { + return vec![]; + } + + let url = format!("{}/models", base_url.trim_end_matches('/')); + let client = reqwest::Client::new(); + let mut req = client.get(&url).timeout(std::time::Duration::from_secs(5)); + if let Some(key) = cached_key { + req = req.bearer_auth(key); + } + + let resp = match req.send().await { + Ok(r) if r.status().is_success() => r, + _ => return vec![], + }; + + #[derive(serde::Deserialize)] + struct Model { + id: String, + } + #[derive(serde::Deserialize)] + struct ModelsResponse { + data: Vec, + } + + match resp.json::().await { + Ok(body) => body + .data + .into_iter() + .map(|m| { + let label = m.id.clone(); + (m.id, label) + }) + .collect(), + Err(_) => vec![], + } +} + +/// Build the `LlmConfig` used by `fetch_nearai_models` to list available 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()); + + crate::config::LlmConfig { + backend: "nearai".to_string(), + session: crate::llm::session::SessionConfig { + auth_base_url, + session_path: crate::config::llm::default_session_path(), + }, + nearai: crate::config::NearAiConfig::for_model_discovery(), + provider: None, + bedrock: None, + request_timeout_secs: 120, + } +} diff --git a/src/secrets/mod.rs b/src/secrets/mod.rs index 9ebad715..9154b78b 100644 --- a/src/secrets/mod.rs +++ b/src/secrets/mod.rs @@ -109,3 +109,59 @@ pub fn create_secrets_store( store } + +/// Try to resolve an existing master key from env var or OS keychain. +/// +/// Resolution order: +/// 1. `SECRETS_MASTER_KEY` environment variable (hex-encoded) +/// 2. OS keychain (macOS Keychain / Linux secret-service) +/// +/// Returns `None` if no key is available (caller should generate one). +pub async fn resolve_master_key() -> Option { + // 1. Check env var + if let Ok(env_key) = std::env::var("SECRETS_MASTER_KEY") + && !env_key.is_empty() + { + return Some(env_key); + } + + // 2. Try OS keychain + if let Ok(keychain_key_bytes) = keychain::get_master_key().await { + let key_hex: String = keychain_key_bytes + .iter() + .map(|b| format!("{:02x}", b)) + .collect(); + return Some(key_hex); + } + + None +} + +/// Create a `SecretsCrypto` from a master key string. +/// +/// The key is typically hex-encoded (from `generate_master_key_hex` or +/// the `SECRETS_MASTER_KEY` env var), but `SecretsCrypto::new` validates +/// only key length, not encoding. Any sufficiently long string works. +pub fn crypto_from_hex(hex: &str) -> Result, SecretError> { + let crypto = SecretsCrypto::new(secrecy::SecretString::from(hex.to_string()))?; + Ok(std::sync::Arc::new(crypto)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_crypto_from_hex_valid() { + // 32 bytes = 64 hex chars + let hex = "0123456789abcdef".repeat(4); // 64 hex chars + let result = crypto_from_hex(&hex); + assert!(result.is_ok()); // safety: test assertion + } + + #[test] + fn test_crypto_from_hex_invalid() { + let result = crypto_from_hex("too_short"); + assert!(result.is_err()); // safety: test assertion + } +} diff --git a/src/settings.rs b/src/settings.rs index 29bfbae1..1c0b737e 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -1747,4 +1747,503 @@ mod tests { "None selected_model should stay None" ); } + + // === Wizard re-run regression tests === + // + // These tests simulate the merge ordering used by the wizard's `run()` method + // to verify that re-running the wizard (or a subset of steps) doesn't + // accidentally reset settings from prior runs. + + /// Simulates `ironclaw onboard --provider-only` re-running on a fully + /// configured installation. Only provider + model should change; all + /// other settings (channels, embeddings, heartbeat) must survive. + #[test] + fn provider_only_rerun_preserves_unrelated_settings() { + // Prior completed run with everything configured + let prior = Settings { + onboard_completed: true, + database_backend: Some("libsql".to_string()), + libsql_path: Some("/home/user/.ironclaw/ironclaw.db".to_string()), + llm_backend: Some("openai".to_string()), + selected_model: Some("gpt-4o".to_string()), + embeddings: EmbeddingsSettings { + enabled: true, + provider: "openai".to_string(), + model: "text-embedding-3-small".to_string(), + }, + channels: ChannelSettings { + http_enabled: true, + http_port: Some(8080), + signal_enabled: true, + signal_account: Some("+1234567890".to_string()), + wasm_channels: vec!["telegram".to_string()], + ..Default::default() + }, + heartbeat: HeartbeatSettings { + enabled: true, + interval_secs: 900, + ..Default::default() + }, + ..Default::default() + }; + let db_map = prior.to_db_map(); + + // provider_only mode: reconnect_existing_db loads from DB, + // then user picks a new provider + model via step_inference_provider + let mut current = Settings::from_db_map(&db_map); + + // Simulate step_inference_provider: user switches to anthropic + current.llm_backend = Some("anthropic".to_string()); + current.selected_model = None; // cleared because backend changed + + // Simulate step_model_selection: user picks a model + current.selected_model = Some("claude-sonnet-4-5".to_string()); + + // Verify: provider/model changed + assert_eq!(current.llm_backend.as_deref(), Some("anthropic")); + assert_eq!(current.selected_model.as_deref(), Some("claude-sonnet-4-5")); + + // Verify: everything else preserved + assert!(current.channels.http_enabled, "HTTP channel must survive"); + assert_eq!(current.channels.http_port, Some(8080)); + assert!(current.channels.signal_enabled, "Signal must survive"); + assert_eq!( + current.channels.wasm_channels, + vec!["telegram".to_string()], + "WASM channels must survive" + ); + assert!(current.embeddings.enabled, "Embeddings must survive"); + assert_eq!(current.embeddings.provider, "openai"); + assert!(current.heartbeat.enabled, "Heartbeat must survive"); + assert_eq!(current.heartbeat.interval_secs, 900); + assert_eq!( + current.database_backend.as_deref(), + Some("libsql"), + "DB backend must survive" + ); + } + + /// Simulates `ironclaw onboard --channels-only` re-running on a fully + /// configured installation. Only channel settings should change; + /// provider, model, embeddings, heartbeat must survive. + #[test] + fn channels_only_rerun_preserves_unrelated_settings() { + let prior = Settings { + onboard_completed: true, + database_backend: Some("postgres".to_string()), + database_url: Some("postgres://host/db".to_string()), + llm_backend: Some("anthropic".to_string()), + selected_model: Some("claude-sonnet-4-5".to_string()), + embeddings: EmbeddingsSettings { + enabled: true, + provider: "nearai".to_string(), + model: "text-embedding-3-small".to_string(), + }, + heartbeat: HeartbeatSettings { + enabled: true, + interval_secs: 1800, + ..Default::default() + }, + channels: ChannelSettings { + http_enabled: false, + wasm_channels: vec!["telegram".to_string()], + ..Default::default() + }, + ..Default::default() + }; + let db_map = prior.to_db_map(); + + // channels_only mode: reconnect_existing_db loads from DB + let mut current = Settings::from_db_map(&db_map); + + // Simulate step_channels: user enables HTTP and adds discord + current.channels.http_enabled = true; + current.channels.http_port = Some(9090); + current.channels.wasm_channels = vec!["telegram".to_string(), "discord".to_string()]; + + // Verify: channels changed + assert!(current.channels.http_enabled); + assert_eq!(current.channels.http_port, Some(9090)); + assert_eq!(current.channels.wasm_channels.len(), 2); + + // Verify: everything else preserved + assert_eq!(current.llm_backend.as_deref(), Some("anthropic")); + assert_eq!(current.selected_model.as_deref(), Some("claude-sonnet-4-5")); + assert!(current.embeddings.enabled); + assert_eq!(current.embeddings.provider, "nearai"); + assert!(current.heartbeat.enabled); + assert_eq!(current.heartbeat.interval_secs, 1800); + } + + /// Simulates quick mode re-run on an installation that previously + /// completed a full setup. Quick mode only touches DB + security + + /// provider + model; channels, embeddings, heartbeat, extensions + /// should survive via the merge_from ordering. + #[test] + fn quick_mode_rerun_preserves_prior_channels_and_heartbeat() { + let prior = Settings { + onboard_completed: true, + database_backend: Some("libsql".to_string()), + libsql_path: Some("/home/user/.ironclaw/ironclaw.db".to_string()), + llm_backend: Some("openai".to_string()), + selected_model: Some("gpt-4o".to_string()), + channels: ChannelSettings { + http_enabled: true, + http_port: Some(8080), + signal_enabled: true, + wasm_channels: vec!["telegram".to_string()], + ..Default::default() + }, + embeddings: EmbeddingsSettings { + enabled: true, + provider: "openai".to_string(), + model: "text-embedding-3-small".to_string(), + }, + heartbeat: HeartbeatSettings { + enabled: true, + interval_secs: 600, + ..Default::default() + }, + ..Default::default() + }; + let db_map = prior.to_db_map(); + let from_db = Settings::from_db_map(&db_map); + + // Quick mode flow: + // 1. auto_setup_database sets DB fields + let step1 = Settings { + database_backend: Some("libsql".to_string()), + libsql_path: Some("/home/user/.ironclaw/ironclaw.db".to_string()), + ..Default::default() + }; + + // 2. try_load_existing_settings → merge DB → merge step1 on top + let mut current = step1.clone(); + current.merge_from(&from_db); + current.merge_from(&step1); + + // 3. step_inference_provider: user picks anthropic this time + current.llm_backend = Some("anthropic".to_string()); + current.selected_model = None; // cleared because backend changed + + // 4. step_model_selection: user picks model + current.selected_model = Some("claude-opus-4-6".to_string()); + + // Verify: provider/model updated + assert_eq!(current.llm_backend.as_deref(), Some("anthropic")); + assert_eq!(current.selected_model.as_deref(), Some("claude-opus-4-6")); + + // Verify: channels, embeddings, heartbeat survived quick mode + assert!( + current.channels.http_enabled, + "HTTP channel must survive quick mode re-run" + ); + assert_eq!(current.channels.http_port, Some(8080)); + assert!( + current.channels.signal_enabled, + "Signal must survive quick mode re-run" + ); + assert_eq!( + current.channels.wasm_channels, + vec!["telegram".to_string()], + "WASM channels must survive quick mode re-run" + ); + assert!( + current.embeddings.enabled, + "Embeddings must survive quick mode re-run" + ); + assert!( + current.heartbeat.enabled, + "Heartbeat must survive quick mode re-run" + ); + assert_eq!(current.heartbeat.interval_secs, 600); + } + + /// Full wizard re-run where user keeps the same provider. The model + /// selection from the prior run should be pre-populated (not reset). + /// + /// Regression: re-running with the same provider should preserve model. + #[test] + fn full_rerun_same_provider_preserves_model_through_merge() { + let prior = Settings { + onboard_completed: true, + database_backend: Some("postgres".to_string()), + database_url: Some("postgres://host/db".to_string()), + llm_backend: Some("anthropic".to_string()), + selected_model: Some("claude-sonnet-4-5".to_string()), + ..Default::default() + }; + let db_map = prior.to_db_map(); + let from_db = Settings::from_db_map(&db_map); + + // Step 1: user keeps same DB + let step1 = Settings { + database_backend: Some("postgres".to_string()), + database_url: Some("postgres://host/db".to_string()), + ..Default::default() + }; + + let mut current = step1.clone(); + current.merge_from(&from_db); + current.merge_from(&step1); + + // After merge, prior settings recovered + assert_eq!( + current.llm_backend.as_deref(), + Some("anthropic"), + "Prior provider must be recovered from DB" + ); + assert_eq!( + current.selected_model.as_deref(), + Some("claude-sonnet-4-5"), + "Prior model must be recovered from DB" + ); + + // Step 3: user picks same provider (anthropic) + // set_llm_backend_preserving_model checks if backend changed + let backend_changed = current.llm_backend.as_deref() != Some("anthropic"); + current.llm_backend = Some("anthropic".to_string()); + if backend_changed { + current.selected_model = None; + } + + // Model should NOT be cleared since backend didn't change + assert_eq!( + current.selected_model.as_deref(), + Some("claude-sonnet-4-5"), + "Model must survive when re-selecting same provider" + ); + } + + /// Full wizard re-run where user switches provider. Model should be + /// cleared since the old model is invalid for the new backend. + #[test] + fn full_rerun_different_provider_clears_model_through_merge() { + let prior = Settings { + onboard_completed: true, + database_backend: Some("postgres".to_string()), + database_url: Some("postgres://host/db".to_string()), + llm_backend: Some("anthropic".to_string()), + selected_model: Some("claude-sonnet-4-5".to_string()), + ..Default::default() + }; + let db_map = prior.to_db_map(); + let from_db = Settings::from_db_map(&db_map); + + // Step 1 merge + let step1 = Settings { + database_backend: Some("postgres".to_string()), + database_url: Some("postgres://host/db".to_string()), + ..Default::default() + }; + let mut current = step1.clone(); + current.merge_from(&from_db); + current.merge_from(&step1); + + // Step 3: user switches to openai + let backend_changed = current.llm_backend.as_deref() != Some("openai"); + assert!(backend_changed, "switching providers should be detected"); + current.llm_backend = Some("openai".to_string()); + if backend_changed { + current.selected_model = None; + } + + assert_eq!(current.llm_backend.as_deref(), Some("openai")); + assert!( + current.selected_model.is_none(), + "Model must be cleared when switching providers" + ); + } + + /// Simulates incremental save correctness: persist_after_step after + /// Step 3 (provider) should not clobber settings set in Step 2 (security). + /// + /// The wizard persists the full settings object after each step. This + /// test verifies that incremental saves are idempotent for prior steps. + #[test] + fn incremental_persist_does_not_clobber_prior_steps() { + // After steps 1-2, settings has DB + security + let after_step2 = Settings { + database_backend: Some("libsql".to_string()), + secrets_master_key_source: KeySource::Keychain, + ..Default::default() + }; + + // persist_after_step saves to DB + let db_map_after_step2 = after_step2.to_db_map(); + + // Step 3 adds provider + let mut after_step3 = after_step2.clone(); + after_step3.llm_backend = Some("openai".to_string()); + + // persist_after_step saves again — the full settings object + let db_map_after_step3 = after_step3.to_db_map(); + + // Reload from DB after step 3 + let restored = Settings::from_db_map(&db_map_after_step3); + + // Step 2's settings must survive step 3's persist + assert_eq!( + restored.secrets_master_key_source, + KeySource::Keychain, + "Step 2 security setting must survive step 3 persist" + ); + assert_eq!( + restored.database_backend.as_deref(), + Some("libsql"), + "Step 1 DB setting must survive step 3 persist" + ); + assert_eq!( + restored.llm_backend.as_deref(), + Some("openai"), + "Step 3 provider setting must be saved" + ); + + // Also verify that a partial step 2 reload doesn't regress + // (loading the step 2 snapshot and merging with step 3 state) + let from_step2_db = Settings::from_db_map(&db_map_after_step2); + let mut merged = after_step3.clone(); + merged.merge_from(&from_step2_db); + + assert_eq!( + merged.llm_backend.as_deref(), + Some("openai"), + "Step 3 provider must not be clobbered by step 2 snapshot merge" + ); + assert_eq!( + merged.secrets_master_key_source, + KeySource::Keychain, + "Step 2 security must survive merge" + ); + } + + /// Switching database backend should allow fresh connection settings. + /// When user switches from postgres to libsql, the old database_url + /// should not prevent the new libsql_path from being used. + #[test] + fn switching_db_backend_allows_fresh_connection_settings() { + let prior = Settings { + database_backend: Some("postgres".to_string()), + database_url: Some("postgres://host/db".to_string()), + llm_backend: Some("openai".to_string()), + selected_model: Some("gpt-4o".to_string()), + ..Default::default() + }; + let db_map = prior.to_db_map(); + let from_db = Settings::from_db_map(&db_map); + + // User picks libsql this time, wizard clears stale postgres settings + let step1 = Settings { + database_backend: Some("libsql".to_string()), + libsql_path: Some("/home/user/.ironclaw/ironclaw.db".to_string()), + database_url: None, // explicitly not set for libsql + ..Default::default() + }; + + let mut current = step1.clone(); + current.merge_from(&from_db); + current.merge_from(&step1); + + // libsql chosen + assert_eq!(current.database_backend.as_deref(), Some("libsql")); + assert_eq!( + current.libsql_path.as_deref(), + Some("/home/user/.ironclaw/ironclaw.db") + ); + + // Prior provider/model should survive (unrelated to DB switch) + assert_eq!(current.llm_backend.as_deref(), Some("openai")); + assert_eq!(current.selected_model.as_deref(), Some("gpt-4o")); + + // Note: database_url from prior run persists in merge because + // step1.database_url is None (== default), so merge_from doesn't + // override it. This is expected — the .env writer decides which + // vars to emit based on database_backend. The stale URL is + // harmless because the libsql backend ignores it. + assert_eq!( + current.database_url.as_deref(), + Some("postgres://host/db"), + "stale database_url persists (harmless, ignored by libsql backend)" + ); + } + + /// Regression: merge_from must handle boolean fields correctly. + /// A prior run with heartbeat.enabled=true must not be reset to false + /// when merging with a Settings that has heartbeat.enabled=false (default). + #[test] + fn merge_preserves_true_booleans_when_overlay_has_default_false() { + let prior = Settings { + heartbeat: HeartbeatSettings { + enabled: true, + interval_secs: 600, + ..Default::default() + }, + channels: ChannelSettings { + http_enabled: true, + signal_enabled: true, + ..Default::default() + }, + ..Default::default() + }; + let db_map = prior.to_db_map(); + let from_db = Settings::from_db_map(&db_map); + + // New wizard run only sets DB (everything else is default/false) + let step1 = Settings { + database_backend: Some("libsql".to_string()), + ..Default::default() + }; + + let mut current = step1.clone(); + current.merge_from(&from_db); + current.merge_from(&step1); + + // true booleans from prior run must survive + assert!( + current.heartbeat.enabled, + "heartbeat.enabled=true must not be reset to false by default overlay" + ); + assert!( + current.channels.http_enabled, + "http_enabled=true must not be reset to false by default overlay" + ); + assert!( + current.channels.signal_enabled, + "signal_enabled=true must not be reset to false by default overlay" + ); + assert_eq!(current.heartbeat.interval_secs, 600); + } + + /// Regression: embeddings settings (provider, model, enabled) must + /// survive a wizard re-run that doesn't touch step 5. + #[test] + fn embeddings_survive_rerun_that_skips_step5() { + let prior = Settings { + onboard_completed: true, + llm_backend: Some("nearai".to_string()), + selected_model: Some("qwen".to_string()), + embeddings: EmbeddingsSettings { + enabled: true, + provider: "nearai".to_string(), + model: "text-embedding-3-large".to_string(), + }, + ..Default::default() + }; + let db_map = prior.to_db_map(); + let from_db = Settings::from_db_map(&db_map); + + // Full re-run: step 1 only sets DB + let step1 = Settings { + database_backend: Some("libsql".to_string()), + ..Default::default() + }; + let mut current = step1.clone(); + current.merge_from(&from_db); + current.merge_from(&step1); + + // Before step 5 (embeddings) runs, check that prior values are present + assert!(current.embeddings.enabled); + assert_eq!(current.embeddings.provider, "nearai"); + assert_eq!(current.embeddings.model, "text-embedding-3-large"); + } } diff --git a/src/setup/README.md b/src/setup/README.md index a1a1d3aa..196b910d 100644 --- a/src/setup/README.md +++ b/src/setup/README.md @@ -114,6 +114,13 @@ Step 9: Background Tasks (heartbeat) **Goal:** Select backend, establish connection, run migrations. +**Init delegation:** Backend-specific connection logic lives in `src/db/mod.rs` +(`connect_without_migrations()`), not in the wizard. The wizard calls +`test_database_connection()` which delegates to the db module factory. Feature-flag +branching (`#[cfg(feature = ...)]`) is confined to `src/db/mod.rs`. PostgreSQL +validation (version >= 15, pgvector) is handled by `validate_postgres()` in +`src/db/mod.rs`. + **Decision tree:** ``` @@ -121,26 +128,23 @@ Both features compiled? ├─ Yes → DATABASE_BACKEND env var set? │ ├─ Yes → use that backend │ └─ No → interactive selection (PostgreSQL vs libSQL) -├─ Only postgres feature → step_database_postgres() -└─ Only libsql feature → step_database_libsql() +├─ Only postgres feature → prompt for DATABASE_URL, test connection +└─ Only libsql feature → prompt for path, test connection ``` -**PostgreSQL path** (`step_database_postgres`): +**PostgreSQL path:** 1. Check `DATABASE_URL` from env or settings -2. Test connection (creates `deadpool_postgres::Pool`) -3. Optionally run refinery migrations -4. Store pool in `self.db_pool` +2. Test connection via `connect_without_migrations()` (validates version, pgvector) +3. Optionally run migrations -**libSQL path** (`step_database_libsql`): +**libSQL path:** 1. Offer local path (default: `~/.ironclaw/ironclaw.db`) 2. Optional Turso cloud sync (URL + auth token) -3. Test connection (creates `LibSqlBackend`) +3. Test connection via `connect_without_migrations()` 4. Always run migrations (idempotent CREATE IF NOT EXISTS) -5. Store backend in `self.db_backend` -**Invariant:** After Step 1, exactly one of `self.db_pool` or -`self.db_backend` is `Some`. This is required for settings persistence -in `save_and_summarize()`. +**Invariant:** After Step 1, `self.db` is `Some(Arc)`. +This is required for settings persistence in `save_and_summarize()`. --- @@ -338,7 +342,7 @@ key first, then falls back to the standard env var. 1. Check `self.secrets_crypto` (set in Step 2) → use if available 2. Else try `SECRETS_MASTER_KEY` env var 3. Else try `get_master_key()` from keychain (only in `channels_only` mode) -4. Create backend-appropriate secrets store (respects selected database backend) +4. Create secrets store using `self.db` (`Arc`) --- diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index f8c695f1..9437d827 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -14,8 +14,6 @@ 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; @@ -23,8 +21,12 @@ 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, +}; use crate::llm::{SessionConfig, SessionManager}; -use crate::secrets::{SecretsCrypto, SecretsStore}; +use crate::secrets::SecretsCrypto; use crate::settings::{KeySource, Settings}; use crate::setup::channels::{ SecretsContext, setup_http, setup_signal, setup_tunnel, setup_wasm_channel, @@ -85,12 +87,10 @@ pub struct SetupWizard { config: SetupConfig, settings: Settings, 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, + /// Backend-agnostic database trait object (created during setup). + db: Option>, + /// Backend-specific handles for secrets store and other satellite consumers. + db_handles: Option, /// Secrets crypto (created during setup). secrets_crypto: Option>, /// Cached API key from provider setup (used by model fetcher without env mutation). @@ -104,10 +104,8 @@ impl SetupWizard { config: SetupConfig::default(), settings: Settings::default(), session_manager: None, - #[cfg(feature = "postgres")] - db_pool: None, - #[cfg(feature = "libsql")] - db_backend: None, + db: None, + db_handles: None, secrets_crypto: None, llm_api_key: None, } @@ -119,10 +117,8 @@ impl SetupWizard { config, settings: Settings::default(), session_manager: None, - #[cfg(feature = "postgres")] - db_pool: None, - #[cfg(feature = "libsql")] - db_backend: None, + db: None, + db_handles: None, secrets_crypto: None, llm_api_key: None, } @@ -256,115 +252,79 @@ impl SetupWizard { /// 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()); + use crate::config::DatabaseConfig; - // 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(), - ) + let db_config = DatabaseConfig::resolve().map_err(|e| { + SetupError::Database(format!( + "Cannot resolve database config. Run full setup first (ironclaw onboard): {}", + e + )) })?; - self.test_database_connection_postgres(&url).await?; - self.settings.database_backend = Some("postgres".to_string()); - self.settings.database_url = Some(url.clone()); + let backend_name = db_config.backend.to_string(); + let (db, handles) = crate::db::connect_with_handles(&db_config) + .await + .map_err(|e| SetupError::Database(format!("Failed to connect: {}", e)))?; - // 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("default").await { - self.settings = Settings::from_db_map(&map); - self.settings.database_backend = Some("postgres".to_string()); - self.settings.database_url = Some(url); - } + // Load existing settings from DB + if let Ok(map) = db.get_all_settings("default").await { + self.settings = Settings::from_db_map(&map); } - 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()); + // Restore connection fields that may not be persisted in the settings map + self.settings.database_backend = Some(backend_name); + if let Ok(url) = std::env::var("DATABASE_URL") { + self.settings.database_url = Some(url); + } + if let Ok(path) = std::env::var("LIBSQL_PATH") { + self.settings.libsql_path = Some(path); + } else if db_config.libsql_path.is_some() { + self.settings.libsql_path = db_config + .libsql_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()); + } + if let Ok(url) = std::env::var("LIBSQL_URL") { + self.settings.libsql_url = Some(url); } - // 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("default").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); - } - } - } + self.db = Some(db); + self.db_handles = Some(handles); Ok(()) } /// Step 1: Database connection. + /// + /// Determines the backend at runtime (env var, interactive selection, or + /// compile-time default) and runs the appropriate configuration flow. 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(); + use crate::config::{DatabaseBackend, DatabaseConfig}; - 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" { + const POSTGRES_AVAILABLE: bool = cfg!(feature = "postgres"); + const LIBSQL_AVAILABLE: bool = cfg!(feature = "libsql"); + + // Determine backend from env var, interactive selection, or default. + let env_backend = std::env::var("DATABASE_BACKEND").ok(); + + let backend = if let Some(ref raw) = env_backend { + match raw.parse::() { + Ok(b) => b, + Err(_) => { + let fallback = if POSTGRES_AVAILABLE { + DatabaseBackend::Postgres + } else { + DatabaseBackend::LibSql + }; print_info(&format!( - "Unknown DATABASE_BACKEND '{}', defaulting to PostgreSQL", - backend + "Unknown DATABASE_BACKEND '{}', defaulting to {}", + raw, fallback )); + fallback } - return self.step_database_postgres().await; } - - // Interactive selection + } else if POSTGRES_AVAILABLE && LIBSQL_AVAILABLE { + // Both features compiled — offer interactive selection. let pre_selected = self.settings.database_backend.as_deref().map(|b| match b { "libsql" | "turso" | "sqlite" => 1, _ => 0, @@ -390,88 +350,82 @@ impl SetupWizard { self.settings.libsql_url = None; } - match choice { - 1 => return self.step_database_libsql().await, - _ => return self.step_database_postgres().await, + if choice == 1 { + DatabaseBackend::LibSql + } else { + DatabaseBackend::Postgres } - } + } else if LIBSQL_AVAILABLE { + DatabaseBackend::LibSql + } else { + // Only postgres (or neither, but that won't compile anyway). + DatabaseBackend::Postgres + }; - #[cfg(all(feature = "postgres", not(feature = "libsql")))] - { - return self.step_database_postgres().await; - } + // --- Postgres flow --- + if backend == DatabaseBackend::Postgres { + self.settings.database_backend = Some("postgres".to_string()); - #[cfg(all(feature = "libsql", not(feature = "postgres")))] - { - return self.step_database_libsql().await; - } - } + let existing_url = std::env::var("DATABASE_URL") + .ok() + .or_else(|| self.settings.database_url.clone()); - /// 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()); + if let Some(ref url) = existing_url { + let display_url = mask_password_in_url(url); + print_info(&format!("Existing database URL: {}", display_url)); - 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?; + if confirm("Use this database?", true).map_err(SetupError::Io)? { + let config = DatabaseConfig::from_postgres_url(url, 5); + if let Err(e) = self.test_database_connection(&config).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(()); } - - 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(), - )); + } + + 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..."); + let config = DatabaseConfig::from_postgres_url(&url, 5); + match self.test_database_connection(&config).await { + Ok(()) => { + print_success("Database connection successful"); + + if confirm("Run database migrations?", true).map_err(SetupError::Io)? { + self.run_migrations().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> { + // --- libSQL flow --- self.settings.database_backend = Some("libsql".to_string()); let default_path = crate::config::default_libsql_path(); @@ -490,14 +444,12 @@ impl SetupWizard { .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 - { + let config = DatabaseConfig::from_libsql_path( + path, + turso_url.as_deref(), + turso_token.as_deref(), + ); + match self.test_database_connection(&config).await { Ok(()) => { print_success("Database connection successful"); self.settings.libsql_path = Some(path.clone()); @@ -556,15 +508,17 @@ impl SetupWizard { }; print_info("Testing connection..."); - match self - .test_database_connection_libsql(&db_path, turso_url.as_deref(), turso_token.as_deref()) - .await - { + let config = DatabaseConfig::from_libsql_path( + &db_path, + turso_url.as_deref(), + turso_token.as_deref(), + ); + match self.test_database_connection(&config).await { Ok(()) => { print_success("Database connection successful"); // Always run migrations for libsql (they're idempotent) - self.run_migrations_libsql().await?; + self.run_migrations().await?; self.settings.libsql_path = Some(db_path); if let Some(url) = turso_url { @@ -576,155 +530,39 @@ impl SetupWizard { } } - /// Test PostgreSQL connection and store the pool. + /// Test database connection using the db module factory. /// - /// 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( + /// Connects without running migrations and validates PostgreSQL + /// prerequisites (version, pgvector) when using the postgres backend. + async fn test_database_connection( &mut self, - path: &str, - turso_url: Option<&str>, - turso_token: Option<&str>, + config: &crate::config::DatabaseConfig, ) -> Result<(), SetupError> { - use crate::db::libsql::LibSqlBackend; - use std::path::Path; + let (db, handles) = crate::db::connect_without_migrations(config) + .await + .map_err(|e| SetupError::Database(e.to_string()))?; - 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); + self.db = Some(db); + self.db_handles = Some(handles); 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"); - + /// Run database migrations on the current connection. + async fn run_migrations(&self) -> Result<(), SetupError> { + if let Some(ref db) = self.db { if !self.config.quick { print_info("Running migrations..."); } - tracing::debug!("Running PostgreSQL migrations..."); + tracing::debug!("Running database migrations..."); - let mut client = pool - .get() - .await - .map_err(|e| SetupError::Database(format!("Pool error: {}", e)))?; - - migrations::runner() - .run_async(&mut **client) + db.run_migrations() .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"); + tracing::debug!("Database migrations applied"); } Ok(()) } @@ -741,20 +579,19 @@ impl SetupWizard { 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). + // Try to retrieve existing key from keychain via resolve_master_key + // (checks env var first, then keychain). We skip the env var case + // above, so this will only find a keychain key here. 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)) + self.secrets_crypto = Some( + crate::secrets::crypto_from_hex(&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)? { @@ -793,12 +630,11 @@ impl SetupWizard { 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)) + self.secrets_crypto = Some( + crate::secrets::crypto_from_hex(&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"); @@ -809,10 +645,10 @@ impl SetupWizard { // 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())) + self.secrets_crypto = Some( + crate::secrets::crypto_from_hex(&key_hex) .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); @@ -845,16 +681,22 @@ impl SetupWizard { /// 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")] + use crate::config::{DatabaseBackend, DatabaseConfig}; + + const POSTGRES_AVAILABLE: bool = cfg!(feature = "postgres"); + const LIBSQL_AVAILABLE: bool = cfg!(feature = "libsql"); + let env_backend = std::env::var("DATABASE_BACKEND").ok(); - #[cfg(feature = "postgres")] + // If DATABASE_BACKEND=postgres and DATABASE_URL exists: connect+migrate if let Some(ref backend) = env_backend - && (backend == "postgres" || backend == "postgresql") + && let Ok(DatabaseBackend::Postgres) = backend.parse::() { if let Ok(url) = std::env::var("DATABASE_URL") { print_info("Using existing PostgreSQL configuration"); + let config = DatabaseConfig::from_postgres_url(&url, 5); + self.test_database_connection(&config).await?; + self.run_migrations().await?; self.settings.database_backend = Some("postgres".to_string()); self.settings.database_url = Some(url); return Ok(()); @@ -863,17 +705,23 @@ impl SetupWizard { return self.step_database().await; } - #[cfg(feature = "postgres")] - if let Ok(url) = std::env::var("DATABASE_URL") { + // If DATABASE_URL exists (no explicit backend): connect+migrate as postgres, + // but only when the postgres feature is actually compiled in. + if POSTGRES_AVAILABLE + && env_backend.is_none() + && let Ok(url) = std::env::var("DATABASE_URL") + { print_info("Using existing PostgreSQL configuration"); + let config = DatabaseConfig::from_postgres_url(&url, 5); + self.test_database_connection(&config).await?; + self.run_migrations().await?; 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")] - { + // Auto-default to libsql if available + if LIBSQL_AVAILABLE { self.settings.database_backend = Some("libsql".to_string()); let existing_path = std::env::var("LIBSQL_PATH") @@ -889,14 +737,13 @@ impl SetupWizard { let turso_url = std::env::var("LIBSQL_URL").ok(); let turso_token = std::env::var("LIBSQL_AUTH_TOKEN").ok(); - self.test_database_connection_libsql( + let config = DatabaseConfig::from_libsql_path( &db_path, turso_url.as_deref(), turso_token.as_deref(), - ) - .await?; - - self.run_migrations_libsql().await?; + ); + self.test_database_connection(&config).await?; + self.run_migrations().await?; self.settings.libsql_path = Some(db_path.clone()); if let Some(url) = turso_url { @@ -908,10 +755,7 @@ impl SetupWizard { } // Only postgres feature compiled — can't auto-default, use interactive - #[allow(unreachable_code)] - { - self.step_database().await - } + self.step_database().await } /// Auto-setup security with zero prompts (quick mode). @@ -920,26 +764,23 @@ impl SetupWizard { /// 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)) + // Try resolving an existing key from env var or keychain + if let Some(key_hex) = crate::secrets::resolve_master_key().await { + self.secrets_crypto = Some( + crate::secrets::crypto_from_hex(&key_hex) .map_err(|e| SetupError::Config(e.to_string()))?, - )); - self.settings.secrets_master_key_source = KeySource::Keychain; - print_success("Security configured (keychain)"); + ); + // Determine source: env var or keychain (filter empty to match resolve_master_key) + let (source, label) = if std::env::var("SECRETS_MASTER_KEY") + .ok() + .is_some_and(|v| !v.is_empty()) + { + (KeySource::Env, "env var") + } else { + (KeySource::Keychain, "keychain") + }; + self.settings.secrets_master_key_source = source; + print_success(&format!("Security configured ({})", label)); return Ok(()); } @@ -951,10 +792,10 @@ impl SetupWizard { .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)) + self.secrets_crypto = Some( + crate::secrets::crypto_from_hex(&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(()); @@ -962,10 +803,10 @@ impl SetupWizard { // 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())) + self.secrets_crypto = Some( + crate::secrets::crypto_from_hex(&key_hex) .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; @@ -1836,74 +1677,27 @@ impl SetupWizard { /// 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) + // Get crypto (should be set from step 2, or resolve 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( + let key_hex = crate::secrets::resolve_master_key().await.ok_or_else(|| { + 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()))?, - ); + let crypto = crate::secrets::crypto_from_hex(&key_hex) + .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, "default")); - } - // 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, "default")); - } - } - #[cfg(feature = "postgres")] - _ => { - if let Some(store) = self.create_postgres_secrets_store(&crypto).await? { - return Ok(SecretsContext::from_store(store, "default")); - } - // 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, "default")); - } - } - #[cfg(not(feature = "postgres"))] - _ => {} + // Create secrets store from existing database handles + if let Some(ref handles) = self.db_handles + && let Some(store) = crate::secrets::create_secrets_store(Arc::clone(&crypto), handles) + { + return Ok(SecretsContext::from_store(store, "default")); } Err(SetupError::Config( @@ -1911,62 +1705,6 @@ impl SetupWizard { )) } - /// 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) @@ -2484,45 +2222,15 @@ impl SetupWizard { /// 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 - } + if let Some(ref db) = self.db { + db.set_all_settings("default", &db_map).await.map_err(|e| { + SetupError::Database(format!("Failed to save settings to database: {}", e)) + })?; + Ok(true) } 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) + Ok(false) + } } /// Write bootstrap environment variables to `~/.ironclaw/.env`. @@ -2698,28 +2406,12 @@ impl SetupWizard { 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 + if let Some(ref db) = self.db { + if let Err(e) = db .set_setting("default", "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("default", "nearai.session_token", &value) - .await - { - tracing::debug!("Could not persist session token to libsql: {}", e); + tracing::debug!("Could not persist session token to database: {}", e); } else { tracing::debug!("Session token persisted to database"); } @@ -2756,58 +2448,19 @@ impl SetupWizard { /// 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 - } + if let Some(ref db) = self.db { + match db.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()); } - } 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 - } + Ok(_) => {} + Err(e) => { + tracing::debug!("Could not load existing settings: {}", e); } - } 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. @@ -2957,7 +2610,6 @@ impl Default for SetupWizard { } /// 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 @@ -2986,331 +2638,6 @@ fn mask_password_in_url(url: &str) -> String { format!("{}{}:****{}", scheme, username, after_at) } -/// Fetch models from the Anthropic API. -/// -/// Returns `(model_id, display_label)` pairs. Falls back to static defaults on error. -async fn fetch_anthropic_models(cached_key: Option<&str>) -> Vec<(String, String)> { - let static_defaults = vec![ - ( - "claude-opus-4-6".into(), - "Claude Opus 4.6 (latest flagship)".into(), - ), - ("claude-sonnet-4-6".into(), "Claude Sonnet 4.6".into()), - ("claude-opus-4-5".into(), "Claude Opus 4.5".into()), - ("claude-sonnet-4-5".into(), "Claude Sonnet 4.5".into()), - ("claude-haiku-4-5".into(), "Claude Haiku 4.5 (fast)".into()), - ]; - - let api_key = cached_key - .map(String::from) - .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok()) - .filter(|k| !k.is_empty() && k != crate::config::OAUTH_PLACEHOLDER); - - // Fall back to OAuth token if no API key - let oauth_token = if api_key.is_none() { - crate::config::helpers::optional_env("ANTHROPIC_OAUTH_TOKEN") - .ok() - .flatten() - .filter(|t| !t.is_empty()) - } else { - None - }; - - let (key_or_token, is_oauth) = match (api_key, oauth_token) { - (Some(k), _) => (k, false), - (None, Some(t)) => (t, true), - (None, None) => return static_defaults, - }; - - let client = reqwest::Client::new(); - let mut request = client - .get("https://api.anthropic.com/v1/models") - .header("anthropic-version", "2023-06-01") - .timeout(std::time::Duration::from_secs(5)); - - if is_oauth { - request = request - .bearer_auth(&key_or_token) - .header("anthropic-beta", "oauth-2025-04-20"); - } else { - request = request.header("x-api-key", &key_or_token); - } - - let resp = match request.send().await { - Ok(r) if r.status().is_success() => r, - _ => return static_defaults, - }; - - #[derive(serde::Deserialize)] - struct ModelEntry { - id: String, - } - #[derive(serde::Deserialize)] - struct ModelsResponse { - data: Vec, - } - - match resp.json::().await { - Ok(body) => { - let mut models: Vec<(String, String)> = body - .data - .into_iter() - .filter(|m| !m.id.contains("embedding") && !m.id.contains("audio")) - .map(|m| { - let label = m.id.clone(); - (m.id, label) - }) - .collect(); - if models.is_empty() { - return static_defaults; - } - models.sort_by(|a, b| a.0.cmp(&b.0)); - models - } - Err(_) => static_defaults, - } -} - -/// Fetch models from the OpenAI API. -/// -/// Returns `(model_id, display_label)` pairs. Falls back to static defaults on error. -async fn fetch_openai_models(cached_key: Option<&str>) -> Vec<(String, String)> { - let static_defaults = vec![ - ( - "gpt-5.3-codex".into(), - "GPT-5.3 Codex (latest flagship)".into(), - ), - ("gpt-5.2-codex".into(), "GPT-5.2 Codex".into()), - ("gpt-5.2".into(), "GPT-5.2".into()), - ( - "gpt-5.1-codex-mini".into(), - "GPT-5.1 Codex Mini (fast)".into(), - ), - ("gpt-5".into(), "GPT-5".into()), - ("gpt-5-mini".into(), "GPT-5 Mini".into()), - ("gpt-4.1".into(), "GPT-4.1".into()), - ("gpt-4.1-mini".into(), "GPT-4.1 Mini".into()), - ("o4-mini".into(), "o4-mini (fast reasoning)".into()), - ("o3".into(), "o3 (reasoning)".into()), - ]; - - let api_key = cached_key - .map(String::from) - .or_else(|| std::env::var("OPENAI_API_KEY").ok()) - .filter(|k| !k.is_empty()); - - let api_key = match api_key { - Some(k) => k, - None => return static_defaults, - }; - - let client = reqwest::Client::new(); - let resp = match client - .get("https://api.openai.com/v1/models") - .bearer_auth(&api_key) - .timeout(std::time::Duration::from_secs(5)) - .send() - .await - { - Ok(r) if r.status().is_success() => r, - _ => return static_defaults, - }; - - #[derive(serde::Deserialize)] - struct ModelEntry { - id: String, - } - #[derive(serde::Deserialize)] - struct ModelsResponse { - data: Vec, - } - - match resp.json::().await { - Ok(body) => { - let mut models: Vec<(String, String)> = body - .data - .into_iter() - .filter(|m| is_openai_chat_model(&m.id)) - .map(|m| { - let label = m.id.clone(); - (m.id, label) - }) - .collect(); - if models.is_empty() { - return static_defaults; - } - sort_openai_models(&mut models); - models - } - Err(_) => static_defaults, - } -} - -fn is_openai_chat_model(model_id: &str) -> bool { - let id = model_id.to_ascii_lowercase(); - - let is_chat_family = id.starts_with("gpt-") - || id.starts_with("chatgpt-") - || id.starts_with("o1") - || id.starts_with("o3") - || id.starts_with("o4") - || id.starts_with("o5"); - - let is_non_chat_variant = id.contains("realtime") - || id.contains("audio") - || id.contains("transcribe") - || id.contains("tts") - || id.contains("embedding") - || id.contains("moderation") - || id.contains("image"); - - is_chat_family && !is_non_chat_variant -} - -fn openai_model_priority(model_id: &str) -> usize { - let id = model_id.to_ascii_lowercase(); - - const EXACT_PRIORITY: &[&str] = &[ - "gpt-5.3-codex", - "gpt-5.2-codex", - "gpt-5.2", - "gpt-5.1-codex-mini", - "gpt-5", - "gpt-5-mini", - "gpt-5-nano", - "o4-mini", - "o3", - "o1", - "gpt-4.1", - "gpt-4.1-mini", - "gpt-4o", - "gpt-4o-mini", - ]; - if let Some(pos) = EXACT_PRIORITY.iter().position(|m| id == *m) { - return pos; - } - - const PREFIX_PRIORITY: &[&str] = &[ - "gpt-5.", "gpt-5-", "o3-", "o4-", "o1-", "gpt-4.1-", "gpt-4o-", "gpt-3.5-", "chatgpt-", - ]; - if let Some(pos) = PREFIX_PRIORITY - .iter() - .position(|prefix| id.starts_with(prefix)) - { - return EXACT_PRIORITY.len() + pos; - } - - EXACT_PRIORITY.len() + PREFIX_PRIORITY.len() + 1 -} - -fn sort_openai_models(models: &mut [(String, String)]) { - models.sort_by(|a, b| { - openai_model_priority(&a.0) - .cmp(&openai_model_priority(&b.0)) - .then_with(|| a.0.cmp(&b.0)) - }); -} - -/// Fetch installed models from a local Ollama instance. -/// -/// Returns `(model_name, display_label)` pairs. Falls back to static defaults on error. -async fn fetch_ollama_models(base_url: &str) -> Vec<(String, String)> { - let static_defaults = vec![ - ("llama3".into(), "llama3".into()), - ("mistral".into(), "mistral".into()), - ("codellama".into(), "codellama".into()), - ]; - - let url = format!("{}/api/tags", base_url.trim_end_matches('/')); - let client = reqwest::Client::new(); - - let resp = match client - .get(&url) - .timeout(std::time::Duration::from_secs(5)) - .send() - .await - { - Ok(r) if r.status().is_success() => r, - Ok(_) => return static_defaults, - Err(_) => { - print_info("Could not connect to Ollama. Is it running?"); - return static_defaults; - } - }; - - #[derive(serde::Deserialize)] - struct ModelEntry { - name: String, - } - #[derive(serde::Deserialize)] - struct TagsResponse { - models: Vec, - } - - match resp.json::().await { - Ok(body) => { - let models: Vec<(String, String)> = body - .models - .into_iter() - .map(|m| { - let label = m.name.clone(); - (m.name, label) - }) - .collect(); - if models.is_empty() { - return static_defaults; - } - models - } - Err(_) => static_defaults, - } -} - -/// Fetch models from a generic OpenAI-compatible /v1/models endpoint. -/// -/// Used for registry providers like Groq, NVIDIA NIM, etc. -async fn fetch_openai_compatible_models( - base_url: &str, - cached_key: Option<&str>, -) -> Vec<(String, String)> { - if base_url.is_empty() { - return vec![]; - } - - let url = format!("{}/models", base_url.trim_end_matches('/')); - let client = reqwest::Client::new(); - let mut req = client.get(&url).timeout(std::time::Duration::from_secs(5)); - if let Some(key) = cached_key { - req = req.bearer_auth(key); - } - - let resp = match req.send().await { - Ok(r) if r.status().is_success() => r, - _ => return vec![], - }; - - #[derive(serde::Deserialize)] - struct Model { - id: String, - } - #[derive(serde::Deserialize)] - struct ModelsResponse { - data: Vec, - } - - match resp.json::().await { - Ok(body) => body - .data - .into_iter() - .map(|m| { - let label = m.id.clone(); - (m.id, label) - }) - .collect(), - Err(_) => vec![], - } -} - /// Discover WASM channels in a directory. /// /// Returns a list of (channel_name, capabilities_file) pairs. @@ -3380,58 +2707,6 @@ async fn discover_wasm_channels(dir: &std::path::Path) -> Vec<(String, ChannelCa /// Mask an API key for display: show first 6 + last 4 chars. /// /// Uses char-based indexing to avoid panicking on multi-byte UTF-8. -/// Build the `LlmConfig` used by `fetch_nearai_models` to list available models. -/// -/// Reads `NEARAI_API_KEY` from the environment so that users who authenticated -/// via Cloud API key (option 4) don't get re-prompted during model selection. -fn build_nearai_model_fetch_config() -> crate::config::LlmConfig { - // If the user authenticated via API key (option 4), the key is stored - // as an env var. Pass it through so `resolve_bearer_token()` doesn't - // re-trigger the interactive auth prompt. - let api_key = std::env::var("NEARAI_API_KEY") - .ok() - .filter(|k| !k.is_empty()) - .map(secrecy::SecretString::from); - - // Match the same base_url logic as LlmConfig::resolve(): use cloud-api - // when an API key is present, private.near.ai for session-token auth. - let default_base = if api_key.is_some() { - "https://cloud-api.near.ai" - } else { - "https://private.near.ai" - }; - let base_url = std::env::var("NEARAI_BASE_URL").unwrap_or_else(|_| default_base.to_string()); - let auth_base_url = - std::env::var("NEARAI_AUTH_URL").unwrap_or_else(|_| "https://private.near.ai".to_string()); - - crate::config::LlmConfig { - backend: "nearai".to_string(), - session: crate::llm::session::SessionConfig { - auth_base_url, - session_path: crate::config::llm::default_session_path(), - }, - nearai: crate::config::NearAiConfig { - model: "dummy".to_string(), - cheap_model: None, - base_url, - api_key, - fallback_model: None, - max_retries: 3, - circuit_breaker_threshold: None, - circuit_breaker_recovery_secs: 30, - response_cache_enabled: false, - response_cache_ttl_secs: 3600, - response_cache_max_entries: 1000, - failover_cooldown_secs: 300, - failover_cooldown_threshold: 3, - smart_routing_cascade: true, - }, - provider: None, - bedrock: None, - request_timeout_secs: 120, - } -} - fn mask_api_key(key: &str) -> String { let chars: Vec = key.chars().collect(); if chars.len() < 12 { @@ -3641,6 +2916,7 @@ mod tests { use super::*; use crate::config::helpers::ENV_MUTEX; + use crate::llm::models::{is_openai_chat_model, sort_openai_models}; #[test] fn test_wizard_creation() { @@ -3662,7 +2938,6 @@ mod tests { } #[test] - #[cfg(feature = "postgres")] fn test_mask_password_in_url() { assert_eq!( mask_password_in_url("postgres://user:secret@localhost/db"), diff --git a/tests/e2e/scenarios/test_telegram_token_validation.py b/tests/e2e/scenarios/test_telegram_token_validation.py new file mode 100644 index 00000000..69d04e51 --- /dev/null +++ b/tests/e2e/scenarios/test_telegram_token_validation.py @@ -0,0 +1,172 @@ +"""Scenario: Telegram bot token validation - configure modal UI test. + +Tests the Telegram extension configure modal renders and accepts tokens with colons. + +Note: The core URL-building logic (colon preservation, no %3A encoding) is verified +by unit tests in src/extensions/manager.rs. This E2E test verifies the configure modal +UI can accept Telegram tokens with colons and renders correctly. +""" + +import json + +from helpers import SEL + + +# ─── Fixture data ───────────────────────────────────────────────────────────── + +_TELEGRAM_EXTENSION = { + "name": "telegram", + "display_name": "Telegram", + "kind": "wasm_channel", + "description": "Telegram bot channel", + "url": None, + "active": False, + "authenticated": False, + "has_auth": True, + "needs_setup": True, + "tools": [], + "activation_status": "installed", + "activation_error": None, +} + +_TELEGRAM_SECRETS = [ + { + "name": "telegram_bot_token", + "prompt": "Telegram Bot Token", + "provided": False, + "optional": False, + "auto_generate": False, + } +] + + +# ─── Tests ──────────────────────────────────────────────────────────────────── + +async def test_telegram_configure_modal_renders(page): + """ + Telegram extension configure modal renders with correct fields. + + Verifies that the configure modal appears with the Telegram bot token field + and all expected UI elements are present. + """ + ext_body = json.dumps({"extensions": [_TELEGRAM_EXTENSION]}) + + async def handle_ext_list(route): + if route.request.url.endswith("/api/extensions"): + await route.fulfill( + status=200, content_type="application/json", body=ext_body + ) + else: + await route.continue_() + + await page.route("**/api/extensions*", handle_ext_list) + + async def handle_setup(route): + if route.request.method == "GET": + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"secrets": _TELEGRAM_SECRETS}), + ) + else: + await route.continue_() + + await page.route("**/api/extensions/telegram/setup", handle_setup) + await page.evaluate("showConfigureModal('telegram')") + modal = page.locator(SEL["configure_modal"]) + await modal.wait_for(state="visible", timeout=5000) + + # Modal should contain the extension name and token prompt + modal_text = await modal.text_content() + assert "telegram" in modal_text.lower() + assert "bot token" in modal_text.lower() + + # Input field should be present + input_field = page.locator(SEL["configure_input"]) + assert await input_field.is_visible() + + +async def test_telegram_token_input_accepts_colon_format(page): + """ + Telegram bot token input accepts tokens with colon separator. + + Verifies that a token in the format `numeric_id:alphanumeric_string` + can be entered without browser-side validation errors. + """ + ext_body = json.dumps({"extensions": [_TELEGRAM_EXTENSION]}) + + async def handle_ext_list(route): + if route.request.url.endswith("/api/extensions"): + await route.fulfill( + status=200, content_type="application/json", body=ext_body + ) + else: + await route.continue_() + + await page.route("**/api/extensions*", handle_ext_list) + + async def handle_setup(route): + if route.request.method == "GET": + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"secrets": _TELEGRAM_SECRETS}), + ) + + await page.route("**/api/extensions/telegram/setup", handle_setup) + await page.evaluate("showConfigureModal('telegram')") + await page.locator(SEL["configure_modal"]).wait_for(state="visible", timeout=5000) + + # Enter a valid Telegram bot token with colon + token_value = "123456789:AABBccDDeeFFgg_Test-Token" + input_field = page.locator(SEL["configure_input"]) + await input_field.fill(token_value) + + # Verify the value was entered and colon is preserved + entered_value = await input_field.input_value() + assert entered_value == token_value + assert ":" in entered_value, "Colon should be preserved in token" + assert "%3A" not in entered_value, "Colon should not be URL-encoded in input" + + +async def test_telegram_token_with_underscores_and_hyphens(page): + """ + Telegram tokens with hyphens and underscores are accepted. + + Verifies that valid Telegram token characters (hyphens, underscores) are + properly accepted by the input field. + """ + ext_body = json.dumps({"extensions": [_TELEGRAM_EXTENSION]}) + + async def handle_ext_list(route): + if route.request.url.endswith("/api/extensions"): + await route.fulfill( + status=200, content_type="application/json", body=ext_body + ) + else: + await route.continue_() + + await page.route("**/api/extensions*", handle_ext_list) + + async def handle_setup(route): + if route.request.method == "GET": + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"secrets": _TELEGRAM_SECRETS}), + ) + + await page.route("**/api/extensions/telegram/setup", handle_setup) + await page.evaluate("showConfigureModal('telegram')") + await page.locator(SEL["configure_modal"]).wait_for(state="visible", timeout=5000) + + # Token with hyphens and underscores + token_value = "987654321:ABCD-EFgh_ijkl-MNOP_qrst" + input_field = page.locator(SEL["configure_input"]) + await input_field.fill(token_value) + + # Verify the value was entered correctly with all characters preserved + entered_value = await input_field.input_value() + assert entered_value == token_value + assert "-" in entered_value + assert "_" in entered_value