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 1/6] 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 | 1221 ++++------------- .../test_telegram_token_validation.py | 172 +++ 13 files changed, 1602 insertions(+), 1001 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 92f203b36a..ee16c0f8df 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 ed64c2423b..2577b4a278 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 44abc09b26..55d8baea7f 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 a306c14bc1..6d2eb2960c 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 e057e2acc1..680c4dfc90 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 1902f128b3..a3e76ef77e 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 b49e4974a1..3c9de369a8 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 0000000000..7022d3cf6a --- /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 9ebad71598..9154b78b49 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 29bfbae169..1c0b737e7e 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 a1a1d3aa2a..196b910d4f 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 f8c695f156..9437d8279b 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()); - - // 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; - } + use crate::config::DatabaseConfig; - #[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()); - } - - // 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); - } - } + // 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); + } + + 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 } - } - - #[cfg(all(feature = "postgres", not(feature = "libsql")))] - { - return self.step_database_postgres().await; - } - - #[cfg(all(feature = "libsql", not(feature = "postgres")))] - { - return self.step_database_libsql().await; - } - } + } else if LIBSQL_AVAILABLE { + DatabaseBackend::LibSql + } else { + // Only postgres (or neither, but that won't compile anyway). + DatabaseBackend::Postgres + }; - /// 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()); + // --- Postgres flow --- + if backend == DatabaseBackend::Postgres { + self.settings.database_backend = Some("postgres".to_string()); - let existing_url = std::env::var("DATABASE_URL") - .ok() - .or_else(|| self.settings.database_url.clone()); + 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 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(()); + 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(()); + } } } - } - println!(); - print_info("Enter your PostgreSQL connection URL."); - print_info("Format: postgres://user:password@host:port/database"); - println!(); + 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)?; + loop { + let url = input("Database URL").map_err(SetupError::Io)?; - if url.is_empty() { - print_error("Database URL is required."); - continue; - } + 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"); + 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_postgres().await?; - } + 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(), - )); + 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_path = Path::new(path); - - let backend = if let (Some(url), Some(token)) = (turso_url, turso_token) { - LibSqlBackend::new_remote_replica(db_path, url, token) - .await - .map_err(|e| SetupError::Database(format!("Failed to connect: {}", e)))? - } else { - LibSqlBackend::new_local(db_path) - .await - .map_err(|e| SetupError::Database(format!("Failed to open database: {}", e)))? - }; - - self.db_backend = Some(backend); - Ok(()) - } - - /// Run PostgreSQL migrations. - #[cfg(feature = "postgres")] - async fn run_migrations_postgres(&self) -> Result<(), SetupError> { - if let Some(ref pool) = self.db_pool { - use refinery::embed_migrations; - embed_migrations!("migrations"); - - if !self.config.quick { - print_info("Running migrations..."); - } - tracing::debug!("Running PostgreSQL migrations..."); - - let mut client = pool - .get() - .await - .map_err(|e| SetupError::Database(format!("Pool error: {}", e)))?; - - migrations::runner() - .run_async(&mut **client) - .await - .map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?; + let (db, handles) = crate::db::connect_without_migrations(config) + .await + .map_err(|e| SetupError::Database(e.to_string()))?; - if !self.config.quick { - print_success("Migrations applied"); - } - tracing::debug!("PostgreSQL migrations applied"); - } + self.db = Some(db); + self.db_handles = Some(handles); 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; - + /// 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 libSQL migrations..."); + tracing::debug!("Running database migrations..."); - backend - .run_migrations() + db.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 - } - } 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 - } + 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 - }; - - 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 - .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 + 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 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 0000000000..69d04e51f4 --- /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 From 1ad1335fea49927e91c2b13e18b60d96b0b86861 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:39:47 -0700 Subject: [PATCH 2/6] chore: release v0.19.0 (#973) Co-authored-by: ironclaw-ci[bot] <266877842+ironclaw-ci[bot]@users.noreply.github.com> --- CHANGELOG.md | 147 +++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36c4d103d0..6aad499357 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,153 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.0](https://github.com/nearai/ironclaw/compare/v0.18.0...v0.19.0) - 2026-03-17 + +### Added + +- verify telegram owner during hot activation ([#1157](https://github.com/nearai/ironclaw/pull/1157)) +- *(config)* unify config resolution with Settings fallback (Phase 2, #1119) ([#1203](https://github.com/nearai/ironclaw/pull/1203)) +- *(sandbox)* add retry logic for transient container failures ([#1232](https://github.com/nearai/ironclaw/pull/1232)) +- *(heartbeat)* fire_at time-of-day scheduling with IANA timezone ([#1029](https://github.com/nearai/ironclaw/pull/1029)) +- Reuse Codex CLI OAuth tokens for ChatGPT backend LLM calls ([#693](https://github.com/nearai/ironclaw/pull/693)) +- add pre-push git hook with delta lint mode ([#833](https://github.com/nearai/ironclaw/pull/833)) +- *(cli)* add `logs` command for gateway log access ([#1105](https://github.com/nearai/ironclaw/pull/1105)) +- add Feishu/Lark WASM channel plugin ([#1110](https://github.com/nearai/ironclaw/pull/1110)) +- add Criterion benchmarks for safety layer hot paths ([#836](https://github.com/nearai/ironclaw/pull/836)) +- *(routines)* human-readable cron schedule summaries in web UI ([#1154](https://github.com/nearai/ironclaw/pull/1154)) +- *(web)* add follow-up suggestion chips and ghost text ([#1156](https://github.com/nearai/ironclaw/pull/1156)) +- *(ci)* include commit history in staging promotion PRs ([#952](https://github.com/nearai/ironclaw/pull/952)) +- *(tools)* add reusable sensitive JSON redaction helper ([#457](https://github.com/nearai/ironclaw/pull/457)) +- configurable hybrid search fusion strategy ([#234](https://github.com/nearai/ironclaw/pull/234)) +- *(cli)* add cron subcommand for managing scheduled routines ([#1017](https://github.com/nearai/ironclaw/pull/1017)) +- adds context-llm tool support ([#616](https://github.com/nearai/ironclaw/pull/616)) +- *(web-chat)* add hover copy button for user/assistant messages ([#948](https://github.com/nearai/ironclaw/pull/948)) +- add Slack approval buttons for tool execution in DMs ([#796](https://github.com/nearai/ironclaw/pull/796)) +- enhance HTTP tool parameter parsing ([#911](https://github.com/nearai/ironclaw/pull/911)) +- *(routines)* enable tool access in lightweight routine execution ([#257](https://github.com/nearai/ironclaw/pull/257)) ([#730](https://github.com/nearai/ironclaw/pull/730)) +- add MiniMax as a built-in LLM provider ([#940](https://github.com/nearai/ironclaw/pull/940)) +- *(cli)* add `ironclaw channels list` subcommand ([#933](https://github.com/nearai/ironclaw/pull/933)) +- *(cli)* add `ironclaw skills list/search/info` subcommands ([#918](https://github.com/nearai/ironclaw/pull/918)) +- add cargo-deny for supply chain safety ([#834](https://github.com/nearai/ironclaw/pull/834)) +- *(setup)* display ASCII art banner during onboarding ([#851](https://github.com/nearai/ironclaw/pull/851)) +- *(extensions)* unify auth and configure into single entrypoint ([#677](https://github.com/nearai/ironclaw/pull/677)) +- *(i18n)* Add internationalization support with Chinese and English translations ([#929](https://github.com/nearai/ironclaw/pull/929)) +- Import OpenClaw memory, history and settings ([#903](https://github.com/nearai/ironclaw/pull/903)) + +### Fixed + +- jobs limit ([#1274](https://github.com/nearai/ironclaw/pull/1274)) +- misleading UI message ([#1265](https://github.com/nearai/ironclaw/pull/1265)) +- bump channel registry versions for promotion ([#1264](https://github.com/nearai/ironclaw/pull/1264)) +- cover staging CI all-features and routine batch regressions ([#1256](https://github.com/nearai/ironclaw/pull/1256)) +- resolve merge conflict fallout and missing config fields +- web/CLI routine mutations do not refresh live event trigger cache ([#1255](https://github.com/nearai/ironclaw/pull/1255)) +- *(jobs)* make completed->completed transition idempotent to prevent race errors ([#1068](https://github.com/nearai/ironclaw/pull/1068)) +- *(llm)* persist refreshed Anthropic OAuth token after Keychain re-read ([#1213](https://github.com/nearai/ironclaw/pull/1213)) +- *(worker)* prevent orphaned tool_results and fix parallel merging ([#1069](https://github.com/nearai/ironclaw/pull/1069)) +- Telegram bot token validation fails intermittently (HTTP 404) ([#1166](https://github.com/nearai/ironclaw/pull/1166)) +- *(security)* prevent metadata spoofing of internal job monitor flag ([#1195](https://github.com/nearai/ironclaw/pull/1195)) +- *(security)* default webhook server to loopback when tunnel is configured ([#1194](https://github.com/nearai/ironclaw/pull/1194)) +- *(auth)* avoid false success and block chat during pending auth ([#1111](https://github.com/nearai/ironclaw/pull/1111)) +- *(config)* unify ChannelsConfig resolution to env > settings > default ([#1124](https://github.com/nearai/ironclaw/pull/1124)) +- *(web-chat)* normalize chat copy to plain text ([#1114](https://github.com/nearai/ironclaw/pull/1114)) +- *(skill)* treat empty url param as absent when installing skills ([#1128](https://github.com/nearai/ironclaw/pull/1128)) +- preserve AuthError type in oauth_http_client cache ([#1152](https://github.com/nearai/ironclaw/pull/1152)) +- *(web)* prevent Safari IME composition Enter from sending message ([#1140](https://github.com/nearai/ironclaw/pull/1140)) +- *(mcp)* handle 400 auth errors, clear auth mode after OAuth, trim tokens ([#1158](https://github.com/nearai/ironclaw/pull/1158)) +- eliminate panic paths in production code ([#1184](https://github.com/nearai/ironclaw/pull/1184)) +- N+1 query pattern in event trigger loop (routine_engine) ([#1163](https://github.com/nearai/ironclaw/pull/1163)) +- *(llm)* add stop_sequences parity for tool completions ([#1170](https://github.com/nearai/ironclaw/pull/1170)) +- *(channels)* use live owner binding during wasm hot activation ([#1171](https://github.com/nearai/ironclaw/pull/1171)) +- Non-transactional multi-step context updates between metadata/to… ([#1161](https://github.com/nearai/ironclaw/pull/1161)) +- *(webhook)* avoid lock-held awaits in server lifecycle paths ([#1168](https://github.com/nearai/ironclaw/pull/1168)) +- Google Sheets returns 403 PERMISSION_DENIED after completing OAuth ([#1164](https://github.com/nearai/ironclaw/pull/1164)) +- HTTP webhook secret transmitted in request body rather than via header, docs inconsistency and security concern ([#1162](https://github.com/nearai/ironclaw/pull/1162)) +- *(ci)* exclude ironclaw_safety from release automation ([#1146](https://github.com/nearai/ironclaw/pull/1146)) +- *(registry)* bump versions for github, web-search, and discord extensions ([#1106](https://github.com/nearai/ironclaw/pull/1106)) +- *(mcp)* address 14 audit findings across MCP module ([#1094](https://github.com/nearai/ironclaw/pull/1094)) +- *(http)* replace .expect() with match in webhook handler ([#1133](https://github.com/nearai/ironclaw/pull/1133)) +- *(time)* treat empty timezone string as absent ([#1127](https://github.com/nearai/ironclaw/pull/1127)) +- 5 critical/high-priority bugs (auth bypass, relay failures, unbounded recursion, context growth) ([#1083](https://github.com/nearai/ironclaw/pull/1083)) +- *(ci)* checkout promotion PR head for metadata refresh ([#1097](https://github.com/nearai/ironclaw/pull/1097)) +- *(ci)* add missing attachments field and crates/ dir to Dockerfiles ([#1100](https://github.com/nearai/ironclaw/pull/1100)) +- *(registry)* bump telegram channel version for capabilities change ([#1064](https://github.com/nearai/ironclaw/pull/1064)) +- *(ci)* repair staging promotion workflow behavior ([#1091](https://github.com/nearai/ironclaw/pull/1091)) +- *(wasm)* address #1086 review followups -- description hint and coercion safety ([#1092](https://github.com/nearai/ironclaw/pull/1092)) +- *(ci)* repair staging-ci workflow parsing ([#1090](https://github.com/nearai/ironclaw/pull/1090)) +- *(extensions)* fix lifecycle bugs + comprehensive E2E tests ([#1070](https://github.com/nearai/ironclaw/pull/1070)) +- add tool_info schema discovery for WASM tools ([#1086](https://github.com/nearai/ironclaw/pull/1086)) +- resolve bug_bash UX/logging issues (#1054 #1055 #1058) ([#1072](https://github.com/nearai/ironclaw/pull/1072)) +- *(http)* fail closed when webhook secret is missing at runtime ([#1075](https://github.com/nearai/ironclaw/pull/1075)) +- *(service)* set CLI_ENABLED=false in macOS launchd plist ([#1079](https://github.com/nearai/ironclaw/pull/1079)) +- relax approval requirements for low-risk tools ([#922](https://github.com/nearai/ironclaw/pull/922)) +- *(web)* make approval requests appear without page reload ([#996](https://github.com/nearai/ironclaw/pull/996)) ([#1073](https://github.com/nearai/ironclaw/pull/1073)) +- *(routines)* run cron checks immediately on ticker startup ([#1066](https://github.com/nearai/ironclaw/pull/1066)) +- *(web)* recompute cron next_fire_at when re-enabling routines ([#1080](https://github.com/nearai/ironclaw/pull/1080)) +- *(memory)* reject absolute filesystem paths with corrective routing ([#934](https://github.com/nearai/ironclaw/pull/934)) +- remove all inline event handlers for CSP script-src compliance ([#1063](https://github.com/nearai/ironclaw/pull/1063)) +- *(mcp)* include OAuth state parameter in authorization URLs ([#1049](https://github.com/nearai/ironclaw/pull/1049)) +- *(mcp)* open MCP OAuth in same browser as gateway ([#951](https://github.com/nearai/ironclaw/pull/951)) +- *(deploy)* harden production container and bootstrap security ([#1014](https://github.com/nearai/ironclaw/pull/1014)) +- release lock guards before awaiting channel send ([#869](https://github.com/nearai/ironclaw/pull/869)) ([#1003](https://github.com/nearai/ironclaw/pull/1003)) +- *(registry)* use versioned artifact URLs and checksums for all WASM manifests ([#1007](https://github.com/nearai/ironclaw/pull/1007)) +- *(setup)* preserve model selection on provider re-run ([#679](https://github.com/nearai/ironclaw/pull/679)) ([#987](https://github.com/nearai/ironclaw/pull/987)) +- *(mcp)* attach session manager for non-OAuth HTTP clients ([#793](https://github.com/nearai/ironclaw/pull/793)) ([#986](https://github.com/nearai/ironclaw/pull/986)) +- *(security)* migrate webhook auth to HMAC-SHA256 signature header ([#970](https://github.com/nearai/ironclaw/pull/970)) +- *(security)* make unsafe env::set_var calls safe with explicit invariants ([#968](https://github.com/nearai/ironclaw/pull/968)) +- *(security)* require explicit SANDBOX_ALLOW_FULL_ACCESS to enable FullAccess policy ([#967](https://github.com/nearai/ironclaw/pull/967)) +- *(security)* add Content-Security-Policy header to web gateway ([#966](https://github.com/nearai/ironclaw/pull/966)) +- *(test)* stabilize openai compat oversized-body regression ([#839](https://github.com/nearai/ironclaw/pull/839)) +- *(ci)* disambiguate WASM bundle filenames to prevent tool/channel collision ([#964](https://github.com/nearai/ironclaw/pull/964)) +- *(setup)* validate channel credentials during setup ([#684](https://github.com/nearai/ironclaw/pull/684)) +- drain tunnel pipes to prevent zombie process ([#735](https://github.com/nearai/ironclaw/pull/735)) +- *(mcp)* header safety validation and Authorization conflict bug from #704 ([#752](https://github.com/nearai/ironclaw/pull/752)) +- *(agent)* block thread_id-based context pollution across users ([#760](https://github.com/nearai/ironclaw/pull/760)) +- *(mcp)* stdio/unix transports skip initialize handshake ([#890](https://github.com/nearai/ironclaw/pull/890)) ([#935](https://github.com/nearai/ironclaw/pull/935)) +- *(setup)* drain residual events and filter key kind in onboard prompts ([#937](https://github.com/nearai/ironclaw/pull/937)) ([#949](https://github.com/nearai/ironclaw/pull/949)) +- *(security)* load WASM tool description and schema from capabilities.json ([#520](https://github.com/nearai/ironclaw/pull/520)) +- *(security)* resolve DNS once and reuse for SSRF validation to prevent rebinding ([#518](https://github.com/nearai/ironclaw/pull/518)) +- *(security)* replace regex HTML sanitizer with DOMPurify to prevent XSS ([#510](https://github.com/nearai/ironclaw/pull/510)) +- *(ci)* improve Claude Code review reliability ([#955](https://github.com/nearai/ironclaw/pull/955)) +- *(ci)* run gated test jobs during staging CI ([#956](https://github.com/nearai/ironclaw/pull/956)) +- *(ci)* prevent staging-ci tag failure and chained PR auto-close ([#900](https://github.com/nearai/ironclaw/pull/900)) +- *(ci)* WASM WIT compat sqlite3 duplicate symbol conflict ([#953](https://github.com/nearai/ironclaw/pull/953)) +- resolve deferred review items from PRs #883, #848, #788 ([#915](https://github.com/nearai/ironclaw/pull/915)) +- *(web)* improve UX readability and accessibility in chat UI ([#910](https://github.com/nearai/ironclaw/pull/910)) + +### Other + +- Fix Telegram auto-verify flow and routing ([#1273](https://github.com/nearai/ironclaw/pull/1273)) +- *(e2e)* fix approval waiting regression coverage ([#1270](https://github.com/nearai/ironclaw/pull/1270)) +- isolate heavy integration tests ([#1266](https://github.com/nearai/ironclaw/pull/1266)) +- Merge branch 'main' into fix/resolve-conflicts +- Refactor owner scope across channels and fix default routing fallback ([#1151](https://github.com/nearai/ironclaw/pull/1151)) +- *(extensions)* document relay manager init order ([#928](https://github.com/nearai/ironclaw/pull/928)) +- *(setup)* extract init logic from wizard into owning modules ([#1210](https://github.com/nearai/ironclaw/pull/1210)) +- mention MiniMax as built-in provider in all READMEs ([#1209](https://github.com/nearai/ironclaw/pull/1209)) +- Fix schema-guided tool parameter coercion ([#1143](https://github.com/nearai/ironclaw/pull/1143)) +- Make no-panics CI check test-aware ([#1160](https://github.com/nearai/ironclaw/pull/1160)) +- *(mcp)* avoid reallocating SSE buffer on each chunk ([#1153](https://github.com/nearai/ironclaw/pull/1153)) +- *(routines)* avoid full message history clone each tool iteration ([#1172](https://github.com/nearai/ironclaw/pull/1172)) +- *(registry)* align manifest versions with published artifacts ([#1169](https://github.com/nearai/ironclaw/pull/1169)) +- remove __pycache__ from repo and add to .gitignore ([#1177](https://github.com/nearai/ironclaw/pull/1177)) +- *(registry)* move MCP servers from code to JSON manifests ([#1144](https://github.com/nearai/ironclaw/pull/1144)) +- improve routine schema guidance ([#1089](https://github.com/nearai/ironclaw/pull/1089)) +- add event-trigger routine e2e coverage ([#1088](https://github.com/nearai/ironclaw/pull/1088)) +- enforce no .unwrap(), .expect(), or assert!() in production code ([#1087](https://github.com/nearai/ironclaw/pull/1087)) +- periodic sync main into staging (resolved conflicts) ([#1098](https://github.com/nearai/ironclaw/pull/1098)) +- fix formatting in cli/mod.rs and mcp/auth.rs ([#1071](https://github.com/nearai/ironclaw/pull/1071)) +- Expose the shared agent session manager via AppComponents ([#532](https://github.com/nearai/ironclaw/pull/532)) +- *(agent)* remove unnecessary Worker re-export ([#923](https://github.com/nearai/ironclaw/pull/923)) +- Fix UTF-8 unsafe truncation in WASM emit_message ([#1015](https://github.com/nearai/ironclaw/pull/1015)) +- extract safety module into ironclaw_safety crate ([#1024](https://github.com/nearai/ironclaw/pull/1024)) +- Add Z.AI provider support for GLM-5 ([#938](https://github.com/nearai/ironclaw/pull/938)) +- *(html_to_markdown)* refresh golden files after renderer bump ([#1016](https://github.com/nearai/ironclaw/pull/1016)) +- Migrate GitHub webhook normalization into github tool ([#758](https://github.com/nearai/ironclaw/pull/758)) +- Fix systemctl unit ([#472](https://github.com/nearai/ironclaw/pull/472)) +- add Russian localization (README.ru.md) ([#850](https://github.com/nearai/ironclaw/pull/850)) +- Add generic host-verified /webhook/tools/{tool} ingress ([#757](https://github.com/nearai/ironclaw/pull/757)) + ## [0.18.0](https://github.com/nearai/ironclaw/compare/v0.17.0...v0.18.0) - 2026-03-11 ### Other diff --git a/Cargo.lock b/Cargo.lock index 854d103abf..2c5547e0b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3436,7 +3436,7 @@ dependencies = [ [[package]] name = "ironclaw" -version = "0.18.0" +version = "0.19.0" dependencies = [ "aes-gcm", "aho-corasick", diff --git a/Cargo.toml b/Cargo.toml index b396b18d86..5b452651f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ exclude = [ [package] name = "ironclaw" -version = "0.18.0" +version = "0.19.0" edition = "2024" rust-version = "1.92" description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" From ef5715cb9675a01654faa498efe78857cfaaded4 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 16 Mar 2026 21:55:49 -0700 Subject: [PATCH 3/6] fix: mark ironclaw_safety unpublished in release-plz (#1286) --- release-plz.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/release-plz.toml b/release-plz.toml index ee7037dfc1..b003952dd3 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -3,4 +3,5 @@ git_release_enable = false [[package]] name = "ironclaw_safety" +publish = false release = false From 7a4673c11eaa4833223d8a41bf84d965cc83ddbe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:13:13 -0700 Subject: [PATCH 4/6] chore: update WASM artifact SHA256 checksums [skip ci] (#1297) Co-authored-by: github-actions[bot] --- registry/channels/discord.json | 4 ++-- registry/channels/feishu.json | 7 ++++++- registry/channels/telegram.json | 4 ++-- registry/tools/github.json | 4 ++-- registry/tools/llm-context.json | 4 ++-- registry/tools/slack.json | 4 ++-- registry/tools/telegram.json | 4 ++-- registry/tools/web-search.json | 4 ++-- 8 files changed, 20 insertions(+), 15 deletions(-) diff --git a/registry/channels/discord.json b/registry/channels/discord.json index 50ef85ee0a..dc545d75d7 100644 --- a/registry/channels/discord.json +++ b/registry/channels/discord.json @@ -18,8 +18,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/discord-0.2.0-wasm32-wasip2.tar.gz", - "sha256": "efa1b9019fa33e243f8db1e1fcc732731d45836336bdd26ca19b6fe227ca8b69" + "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-discord-0.2.1-wasm32-wasip2.tar.gz", + "sha256": "6159cb54aa44a9d8219e29bf0aea9404213b20ff567506fe75f23d4698d6ec18" } }, "auth_summary": { diff --git a/registry/channels/feishu.json b/registry/channels/feishu.json index 0446a4423f..66cecf1dd2 100644 --- a/registry/channels/feishu.json +++ b/registry/channels/feishu.json @@ -17,7 +17,12 @@ "capabilities": "feishu.capabilities.json", "crate_name": "feishu-channel" }, - "artifacts": {}, + "artifacts": { + "wasm32-wasip2": { + "sha256": "5fca74022264d1c8e78a0853766276f7ffa3cf0d8065b2f51ca10985acad4714", + "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-feishu-0.1.1-wasm32-wasip2.tar.gz" + } + }, "auth_summary": { "method": "manual", "provider": "Feishu / Lark", diff --git a/registry/channels/telegram.json b/registry/channels/telegram.json index e44061e536..bd07208f7d 100644 --- a/registry/channels/telegram.json +++ b/registry/channels/telegram.json @@ -18,8 +18,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.3-wasm32-wasip2.tar.gz", - "sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed" + "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-telegram-0.2.4-wasm32-wasip2.tar.gz", + "sha256": "a7cb300ec1c946831cfceaa95c1dc8f30d0f42a3924f3cb5de8098821573f4b8" } }, "auth_summary": { diff --git a/registry/tools/github.json b/registry/tools/github.json index e775ac8216..e760c4df0a 100644 --- a/registry/tools/github.json +++ b/registry/tools/github.json @@ -19,8 +19,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/github-0.2.0-wasm32-wasip2.tar.gz", - "sha256": "da9fac56b6f20197a415489bbaec9fefb085a5cf6324cab79ea48a47eb19c13b" + "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-github-0.2.1-wasm32-wasip2.tar.gz", + "sha256": "92c530b3ad172e2372d819744b5233f1d8f65768e26eb5a6c213eba3ce1de758" } }, "auth_summary": { diff --git a/registry/tools/llm-context.json b/registry/tools/llm-context.json index a647a1532c..e4e9808c5f 100644 --- a/registry/tools/llm-context.json +++ b/registry/tools/llm-context.json @@ -21,8 +21,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/latest/download/llm-context-wasm32-wasip2.tar.gz", - "sha256": "581cc5867ef3b75116b7ddc8161e63dd92befe2b53e6ad8213c007639aa243c3" + "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-llm-context-0.1.0-wasm32-wasip2.tar.gz", + "sha256": "d9ced2b1226b879135891e0ee40e072c7c95412e1b2462925a23853e1f92497e" } }, "auth_summary": { diff --git a/registry/tools/slack.json b/registry/tools/slack.json index 11bd7fffc3..8e1df98968 100644 --- a/registry/tools/slack.json +++ b/registry/tools/slack.json @@ -17,8 +17,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/slack-0.2.1-wasm32-wasip2.tar.gz", - "sha256": "d4667e35126986509d862bc3a0088777305d8f41c75de83c1e223b42312ede48" + "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-slack-0.2.0-wasm32-wasip2.tar.gz", + "sha256": "ccfb0415d7a04f9497726c712d15216de36e86f498b849101283c017f5ab4efb" } }, "auth_summary": { diff --git a/registry/tools/telegram.json b/registry/tools/telegram.json index 680d6fdb9c..12e58c684d 100644 --- a/registry/tools/telegram.json +++ b/registry/tools/telegram.json @@ -18,8 +18,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.2-wasm32-wasip2.tar.gz", - "sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed" + "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-telegram-0.2.0-wasm32-wasip2.tar.gz", + "sha256": "c17065ca41fae5f2a7c43b36144686718cd310a2f22442313bb1aa82bbad0ae4" } }, "auth_summary": { diff --git a/registry/tools/web-search.json b/registry/tools/web-search.json index 1722c39187..5c1dedefde 100644 --- a/registry/tools/web-search.json +++ b/registry/tools/web-search.json @@ -18,8 +18,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/web-search-0.2.0-wasm32-wasip2.tar.gz", - "sha256": "56834573c54ea2a33cea1eb0f04bbdf59f1ef8d8702995cf431b0921302eeccc" + "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-web-search-0.2.1-wasm32-wasip2.tar.gz", + "sha256": "bad275ca4ec314adea5241d6b92c44ccf9cebcbca8e30ba2493cc0bcb4b57218" } }, "auth_summary": { From 4566181f40d1bdf7546d101758d22187f6ab7fb8 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Wed, 18 Mar 2026 16:18:29 -0700 Subject: [PATCH 5/6] feat(gateway): unified settings page with subtabs (#1191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gateway): full settings page polish with all tiers - Backend: add ActiveConfigSnapshot to expose resolved LLM backend, model, and enabled channels via /api/gateway/status - Add missing Agent settings (daily cost cap, actions/hour, local tools) - Add Sandbox, Routines, Safety, Skills, and Search setting groups - Settings import/export (JSON download + file upload) - Active env defaults shown as placeholders in Inference settings - Styled confirmation modals replace window.confirm() for remove actions - Global restart banner persists across settings subtab switches - Client-side validation with min/max constraints on number inputs - Accessibility: aria-label on inputs, role=status on save indicators - Settings search filters rows across current subtab - Smooth CSS transitions for conditional field visibility (showWhen) - Tunnel settings in Channels subtab - Mobile responsive settings layout at 768px breakpoint - i18n keys for toolbar, search, and import/export in en + zh-CN Co-Authored-By: Claude Opus 4.6 * feat(gateway): polish settings page and remove registered tools debug section Remove the "Registered Tools" table from the extensions tab (debug info not useful to end users), clean up associated CSS/i18n/JS. Additional settings page UI polish: extension card state styling, layout refinements. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(gateway): address PR review feedback [skip-regression-check] - Use refreshCurrentSettingsTab() in SSE event handlers to reduce duplication - Remove unused formatGroupName/formatSettingLabel helpers - Use i18n keys for MCP Configure/Reconfigure buttons - Add data-i18n-placeholder to settings search input - Remove data-i18n from confirm modal button (set dynamically by showConfirmModal) - Fix cargo fmt in main.rs Co-Authored-By: Claude Opus 4.6 (1M context) * fix(e2e): update tests for unified settings tab layout [skip-regression-check] - Update TABS list: replace extensions/skills with settings - Add settings_subtab/settings_subpanel selectors to helpers - Update test_connection, test_skills, test_extensions, test_wasm_lifecycle to navigate via Settings > subtab instead of top-level tabs - Move MCP card tests to use go_to_mcp() helper (MCP is now a separate subtab) - Remove tools table tests and mock_ext_apis tools= parameter - Fix CSP violation: replace inline onclick on confirm modal cancel button Co-Authored-By: Claude Opus 4.6 (1M context) * fix(gateway): address second round of PR review feedback [skip-regression-check] - Use I18n.t() for MCP empty state, export/import toasts, confirm modal - Fix CLI channel card using wrong channel key ('repl' -> 'cli') - Fix settings search counting hidden rows as visible - Add aria-label i18n for settings search input - Add common.loadFailed i18n key (en + zh-CN) - Update E2E tests: WASM channel tests use Channels subtab, remove tests use custom confirm modal instead of window.confirm Co-Authored-By: Claude Opus 4.6 (1M context) * fix(e2e): fix WASM channel card selector and skills remove confirm [skip-regression-check] - WASM channel tests: filter by display name to avoid matching built-in channel cards in the Channels subtab - Skills remove test: click confirm modal button instead of using window.confirm (skill removal now uses custom confirm modal) Co-Authored-By: Claude Opus 4.6 (1M context) * fix(gateway): address third round of PR review feedback [skip-regression-check] - approval_needed SSE: refresh any active settings subtab, not just Extensions — approvals can surface from Channels/MCP setup flows too - renderCardsSkeleton: remove nested .extensions-list wrapper that caused skeleton cards to render constrained inside grid cells Co-Authored-By: Claude Opus 4.6 (1M context) * fix(e2e): fix auth_completed reload test race condition [skip-regression-check] Use expect_response to deterministically wait for the /api/extensions reload triggered by handleAuthCompleted → refreshCurrentSettingsTab, instead of a fixed 600ms sleep that was too short under CI load. Also remove stale /api/extensions/tools route handler. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(e2e): debug auth_completed reload test with function counter [skip-regression-check] Inject a counter wrapper around refreshCurrentSettingsTab to verify it's actually called, and wait for the async fetch to complete before asserting the reload count. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(gateway): localize all settings labels, descriptions, and channel cards [skip-regression-check] Move 120+ hardcoded strings in settings definitions (INFERENCE_SETTINGS, AGENT_SETTINGS, NETWORKING_SETTINGS) and channel card labels to i18n keys. Render functions now resolve labels via I18n.t() so the settings page translates when switching locales. Covers: group titles, setting labels/descriptions, built-in channel names/descriptions, and the "No settings found" empty state. Both en.js and zh-CN.js updated with all new cfg.* and channels.* keys. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(gateway): localize remaining hardcoded UI strings [skip-regression-check] - Fix export error toast using wrong i18n key (importFailed → exportFailed) - Replace "Failed to load settings:" with I18n.t('common.loadFailed') - Localize renderBuiltinChannelCard: "Built-in", "Active", "Inactive" - Localize settings placeholders: "env: ", "env default", "use env default" - Localize "✓ Saved" indicator - Add new i18n keys to both en.js and zh-CN.js Co-Authored-By: Claude Opus 4.6 (1M context) * fix(gateway): confirm modal a11y, Esc/click-outside, search guard [skip-regression-check] - Add role="dialog", aria-modal="true", aria-labelledby to confirm modal - Focus confirm button when modal opens - Close modal on Escape key or overlay click - Skip settings search on non-settings panels (Extensions/MCP/Skills/Channels) Co-Authored-By: Claude Opus 4.6 (1M context) * fix(gateway): boolean tri-state, search reset on subtab switch, stale model suggestions [skip-regression-check] Address PR review feedback: - Boolean settings now use a tri-state select (env default / On / Off) instead of a checkbox, matching the pattern used by other select settings and allowing users to revert to the env default - Clear search input when switching settings subtabs so stale filters don't carry over to the new panel - Always assign model suggestions (even empty array) so stale IDs from a previous successful /v1/models fetch don't persist when the endpoint later returns empty Co-Authored-By: Claude Opus 4.6 (1M context) * fix(gateway): auth_completed handler, bedrock_cross_region select, integer-only number inputs [skip-regression-check] Address PR review feedback: - auth_completed SSE listener now delegates to handleAuthCompleted(data) instead of inlining logic with a bare closeConfigureModal() call, so only the matching extension's modal is dismissed - bedrock_cross_region changed from free text to select with the four valid values (us/eu/apac/global), matching backend validation - Number settings now use step=1 and parseInt() instead of parseFloat(), preventing fractional values that the backend (u32/u64) would reject Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 --- src/channels/web/mod.rs | 8 + src/channels/web/server.rs | 17 + src/channels/web/static/app.js | 977 +++++++++++++++++++-- src/channels/web/static/i18n/en.js | 180 +++- src/channels/web/static/i18n/zh-CN.js | 180 +++- src/channels/web/static/index.html | 175 ++-- src/channels/web/static/style.css | 606 +++++++++++-- src/channels/web/test_helpers.rs | 1 + src/channels/web/ws.rs | 1 + src/main.rs | 21 + tests/e2e/helpers.py | 15 +- tests/e2e/scenarios/test_extensions.py | 204 ++--- tests/e2e/scenarios/test_skills.py | 25 +- tests/e2e/scenarios/test_wasm_lifecycle.py | 8 +- tests/openai_compat_integration.rs | 2 + tests/support/gateway_workflow_harness.rs | 1 + tests/ws_gateway_integration.rs | 1 + 17 files changed, 2045 insertions(+), 377 deletions(-) diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs index 0d970569a9..a96f7c7b2d 100644 --- a/src/channels/web/mod.rs +++ b/src/channels/web/mod.rs @@ -102,6 +102,7 @@ impl GatewayChannel { cost_guard: None, routine_engine: Arc::new(tokio::sync::RwLock::new(None)), startup_time: std::time::Instant::now(), + active_config: server::ActiveConfigSnapshot::default(), }); Self { @@ -139,6 +140,7 @@ impl GatewayChannel { cost_guard: self.state.cost_guard.clone(), routine_engine: Arc::clone(&self.state.routine_engine), startup_time: self.state.startup_time, + active_config: self.state.active_config.clone(), }; mutate(&mut new_state); self.state = Arc::new(new_state); @@ -250,6 +252,12 @@ impl GatewayChannel { self } + /// Inject the active (resolved) configuration snapshot for the status endpoint. + pub fn with_active_config(mut self, config: server::ActiveConfigSnapshot) -> Self { + self.rebuild_state(|s| s.active_config = config); + self + } + /// Get the auth token (for printing to console on startup). pub fn auth_token(&self) -> &str { &self.auth_token diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 27ef7cdce9..9a182c6cdf 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -126,6 +126,14 @@ impl RateLimiter { } } +/// Snapshot of the active (resolved) configuration exposed to the frontend. +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct ActiveConfigSnapshot { + pub llm_backend: String, + pub llm_model: String, + pub enabled_channels: Vec, +} + /// Shared state for all gateway handlers. pub struct GatewayState { /// Channel to send messages to the agent loop. @@ -177,6 +185,8 @@ pub struct GatewayState { pub routine_engine: RoutineEngineSlot, /// Server startup time for uptime calculation. pub startup_time: std::time::Instant, + /// Snapshot of active (resolved) configuration for the frontend. + pub active_config: ActiveConfigSnapshot, } /// Start the gateway HTTP server. @@ -2669,6 +2679,9 @@ async fn gateway_status_handler( daily_cost, actions_this_hour, model_usage, + llm_backend: state.active_config.llm_backend.clone(), + llm_model: state.active_config.llm_model.clone(), + enabled_channels: state.active_config.enabled_channels.clone(), }) } @@ -2694,6 +2707,9 @@ struct GatewayStatusResponse { actions_this_hour: Option, #[serde(skip_serializing_if = "Option::is_none")] model_usage: Option>, + llm_backend: String, + llm_model: String, + enabled_channels: Vec, } #[cfg(test)] @@ -2890,6 +2906,7 @@ mod tests { cost_guard: None, routine_engine: Arc::new(tokio::sync::RwLock::new(None)), startup_time: std::time::Instant::now(), + active_config: ActiveConfigSnapshot::default(), }) } diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 9d931500cd..82b033b2d4 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -21,6 +21,7 @@ const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100; let stagedImages = []; let authFlowPending = false; let _ghostSuggestion = ''; +let currentSettingsSubtab = 'inference'; // --- Slash Commands --- @@ -135,6 +136,7 @@ function apiFetch(path, options) { throw new Error(body || (res.status + ' ' + res.statusText)); }); } + if (res.status === 204) return null; return res.json(); }); } @@ -364,8 +366,8 @@ function connectSSE() { debouncedLoadThreads(); } - // Extension setup flows can surface approvals while user is on Extensions tab. - if (currentTab === 'extensions') loadExtensions(); + // Extension setup flows can surface approvals from any settings subtab. + if (currentTab === 'settings') refreshCurrentSettingsTab(); }); eventSource.addEventListener('auth_required', (e) => { @@ -373,11 +375,12 @@ function connectSSE() { }); eventSource.addEventListener('auth_completed', (e) => { - handleAuthCompleted(JSON.parse(e.data)); + const data = JSON.parse(e.data); + handleAuthCompleted(data); }); eventSource.addEventListener('extension_status', (e) => { - if (currentTab === 'extensions') loadExtensions(); + if (currentTab === 'settings') refreshCurrentSettingsTab(); }); eventSource.addEventListener('image_generated', (e) => { @@ -1232,7 +1235,7 @@ function handleAuthCompleted(data) { if (shouldShowChannelConnectedMessage(data.extension_name, data.success)) { addMessage('system', 'Telegram is now connected. You can message me there and I can send you notifications.'); } - if (currentTab === 'extensions') loadExtensions(); + if (currentTab === 'settings') refreshCurrentSettingsTab(); enableChatInput(); } @@ -1877,13 +1880,11 @@ function switchTab(tab) { if (tab === 'jobs') loadJobs(); if (tab === 'routines') loadRoutines(); if (tab === 'logs') applyLogFilters(); - if (tab === 'extensions') { - loadExtensions(); - startPairingPoll(); + if (tab === 'settings') { + loadSettingsSubtab(currentSettingsSubtab); } else { stopPairingPoll(); } - if (tab === 'skills') loadSkills(); } // --- Memory (filesystem tree) --- @@ -2270,61 +2271,42 @@ var kindLabels = { 'wasm_channel': 'Channel', 'wasm_tool': 'Tool', 'mcp_server': function loadExtensions() { const extList = document.getElementById('extensions-list'); const wasmList = document.getElementById('available-wasm-list'); - const mcpList = document.getElementById('mcp-servers-list'); - const toolsTbody = document.getElementById('tools-tbody'); - const toolsEmpty = document.getElementById('tools-empty'); + extList.innerHTML = renderCardsSkeleton(3); - // Fetch all three in parallel + // Fetch extensions and registry in parallel Promise.all([ apiFetch('/api/extensions').catch(() => ({ extensions: [] })), - apiFetch('/api/extensions/tools').catch(() => ({ tools: [] })), apiFetch('/api/extensions/registry').catch(function(err) { console.warn('registry fetch failed:', err); return { entries: [] }; }), - ]).then(([extData, toolData, registryData]) => { - // Render installed extensions - if (extData.extensions.length === 0) { + ]).then(([extData, registryData]) => { + // Render installed extensions (exclude wasm_channel and mcp_server — shown in their own tabs) + var nonChannelExts = extData.extensions.filter(function(e) { + return e.kind !== 'wasm_channel' && e.kind !== 'mcp_server'; + }); + if (nonChannelExts.length === 0) { extList.innerHTML = '
' + I18n.t('extensions.noInstalled') + '
'; } else { extList.innerHTML = ''; - for (const ext of extData.extensions) { + for (const ext of nonChannelExts) { extList.appendChild(renderExtensionCard(ext)); } } - // Split registry entries by kind - var wasmEntries = registryData.entries.filter(function(e) { return e.kind !== 'mcp_server' && !e.installed; }); - var mcpEntries = registryData.entries.filter(function(e) { return e.kind === 'mcp_server'; }); + // Available extensions (exclude MCP servers and channels — they have their own tabs) + var wasmEntries = registryData.entries.filter(function(e) { + return e.kind !== 'mcp_server' && e.kind !== 'wasm_channel' && e.kind !== 'channel' && !e.installed; + }); - // Available WASM extensions + var wasmSection = document.getElementById('available-wasm-section'); if (wasmEntries.length === 0) { - wasmList.innerHTML = '
' + I18n.t('extensions.noAvailable') + '
'; + if (wasmSection) wasmSection.style.display = 'none'; } else { + if (wasmSection) wasmSection.style.display = ''; wasmList.innerHTML = ''; for (const entry of wasmEntries) { wasmList.appendChild(renderAvailableExtensionCard(entry)); } } - // MCP servers (show both installed and uninstalled) - if (mcpEntries.length === 0) { - mcpList.innerHTML = '
' + I18n.t('mcp.noServers') + '
'; - } else { - mcpList.innerHTML = ''; - for (const entry of mcpEntries) { - var installedExt = extData.extensions.find(function(e) { return e.name === entry.name; }); - mcpList.appendChild(renderMcpServerCard(entry, installedExt)); - } - } - - // Render tools - if (toolData.tools.length === 0) { - toolsTbody.innerHTML = ''; - toolsEmpty.style.display = 'block'; - } else { - toolsEmpty.style.display = 'none'; - toolsTbody.innerHTML = toolData.tools.map((t) => - '' + escapeHtml(t.name) + '' + escapeHtml(t.description) + '' - ).join(''); - } }); } @@ -2390,18 +2372,18 @@ function renderAvailableExtensionCard(entry) { showToast('Opening authentication for ' + entry.display_name, 'info'); openOAuthUrl(res.auth_url); } - loadExtensions(); + refreshCurrentSettingsTab(); // Auto-open configure for WASM channels if (entry.kind === 'wasm_channel') { showConfigureModal(entry.name); } } else { showToast('Install: ' + (res.message || 'unknown error'), 'error'); - loadExtensions(); + refreshCurrentSettingsTab(); } }).catch(function(err) { showToast('Install failed: ' + err.message, 'error'); - loadExtensions(); + refreshCurrentSettingsTab(); }); }); actions.appendChild(installBtn); @@ -2457,6 +2439,13 @@ function renderMcpServerCard(entry, installedExt) { activeLabel.textContent = I18n.t('ext.active'); actions.appendChild(activeLabel); } + if (installedExt.needs_setup || (installedExt.has_auth && installedExt.authenticated)) { + var configBtn = document.createElement('button'); + configBtn.className = 'btn-ext configure'; + configBtn.textContent = installedExt.authenticated ? I18n.t('ext.reconfigure') : I18n.t('ext.configure'); + configBtn.addEventListener('click', function() { showConfigureModal(installedExt.name); }); + actions.appendChild(configBtn); + } var removeBtn = document.createElement('button'); removeBtn.className = 'btn-ext remove'; removeBtn.textContent = I18n.t('ext.remove'); @@ -2478,10 +2467,10 @@ function renderMcpServerCard(entry, installedExt) { } else { showToast(I18n.t('ext.install') + ': ' + (res.message || 'unknown error'), 'error'); } - loadExtensions(); + loadMcpServers(); }).catch(function(err) { showToast(I18n.t('ext.installFailed', { message: err.message }), 'error'); - loadExtensions(); + loadMcpServers(); }); }); actions.appendChild(installBtn); @@ -2501,7 +2490,16 @@ function createReconfigureButton(extName) { function renderExtensionCard(ext) { const card = document.createElement('div'); - card.className = 'ext-card'; + var stateClass = 'state-inactive'; + if (ext.kind === 'wasm_channel') { + var s = ext.activation_status || 'installed'; + if (s === 'active') stateClass = 'state-active'; + else if (s === 'failed') stateClass = 'state-error'; + else if (s === 'pairing') stateClass = 'state-pairing'; + } else if (ext.active) { + stateClass = 'state-active'; + } + card.className = 'ext-card ' + stateClass; const header = document.createElement('div'); header.className = 'ext-header'; @@ -2646,6 +2644,12 @@ function renderExtensionCard(ext) { return card; } +function refreshCurrentSettingsTab() { + if (currentSettingsSubtab === 'extensions') loadExtensions(); + if (currentSettingsSubtab === 'channels') loadChannelsStatus(); + if (currentSettingsSubtab === 'mcp') loadMcpServers(); +} + function activateExtension(name) { apiFetch('/api/extensions/' + encodeURIComponent(name) + '/activate', { method: 'POST' }) .then((res) => { @@ -2659,7 +2663,7 @@ function activateExtension(name) { showToast('Opening authentication for ' + name, 'info'); openOAuthUrl(res.auth_url); } - loadExtensions(); + refreshCurrentSettingsTab(); return; } @@ -2675,23 +2679,24 @@ function activateExtension(name) { } else { showToast('Activate failed: ' + res.message, 'error'); } - loadExtensions(); + refreshCurrentSettingsTab(); }) .catch((err) => showToast('Activate failed: ' + err.message, 'error')); } function removeExtension(name) { - if (!confirm(I18n.t('ext.confirmRemove', { name: name }))) return; - apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' }) - .then((res) => { - if (!res.success) { - showToast(I18n.t('ext.removeFailed', { message: res.message }), 'error'); - } else { - showToast(I18n.t('ext.removed', { name: name }), 'success'); - } - loadExtensions(); - }) - .catch((err) => showToast(I18n.t('ext.removeFailed', { message: err.message }), 'error')); + showConfirmModal(I18n.t('ext.confirmRemove', { name: name }), '', function() { + apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' }) + .then((res) => { + if (!res.success) { + showToast(I18n.t('ext.removeFailed', { message: res.message }), 'error'); + } else { + showToast(I18n.t('ext.removed', { name: name }), 'success'); + } + refreshCurrentSettingsTab(); + }) + .catch((err) => showToast(I18n.t('ext.removeFailed', { message: err.message }), 'error')); + }, I18n.t('common.remove'), 'btn-danger'); } function showConfigureModal(name) { @@ -2969,7 +2974,7 @@ function submitConfigureModal(name, fields, options) { }); showToast('Opening OAuth authorization for ' + name, 'info'); openOAuthUrl(res.auth_url); - loadExtensions(); + refreshCurrentSettingsTab(); } // For non-OAuth success: the server always broadcasts auth_completed SSE, // which will show the toast and refresh extensions — no need to do it here too. @@ -3078,7 +3083,7 @@ function approvePairing(channel, code, container) { }).then(res => { if (res.success) { showToast('Pairing approved', 'success'); - loadExtensions(); + refreshCurrentSettingsTab(); } else { showToast(res.message || 'Approve failed', 'error'); } @@ -4184,7 +4189,7 @@ function addMcpServer() { showToast('Added MCP server ' + name, 'success'); document.getElementById('mcp-install-name').value = ''; document.getElementById('mcp-install-url').value = ''; - loadExtensions(); + loadMcpServers(); } else { showToast('Failed to add MCP server: ' + (res.message || 'unknown error'), 'error'); } @@ -4197,6 +4202,7 @@ function addMcpServer() { function loadSkills() { var skillsList = document.getElementById('skills-list'); + skillsList.innerHTML = renderCardsSkeleton(3); apiFetch('/api/skills').then(function(data) { if (!data.skills || data.skills.length === 0) { skillsList.innerHTML = '
' + I18n.t('skills.noInstalled') + '
'; @@ -4213,7 +4219,7 @@ function loadSkills() { function renderSkillCard(skill) { var card = document.createElement('div'); - card.className = 'ext-card'; + card.className = 'ext-card state-active'; var header = document.createElement('div'); header.className = 'ext-header'; @@ -4480,20 +4486,21 @@ function installSkill(nameOrSlug, url, btn) { } function removeSkill(name) { - if (!confirm(I18n.t('skills.confirmRemove', { name: name }))) return; - apiFetch('/api/skills/' + encodeURIComponent(name), { - method: 'DELETE', - headers: { 'X-Confirm-Action': 'true' }, - }).then(function(res) { - if (res.success) { - showToast(I18n.t('skills.removed', { name: name }), 'success'); - } else { - showToast(I18n.t('skills.removeFailed', { message: res.message || 'unknown error' }), 'error'); - } - loadSkills(); - }).catch(function(err) { - showToast(I18n.t('skills.removeFailed', { message: err.message }), 'error'); - }); + showConfirmModal(I18n.t('skills.confirmRemove', { name: name }), '', function() { + apiFetch('/api/skills/' + encodeURIComponent(name), { + method: 'DELETE', + headers: { 'X-Confirm-Action': 'true' }, + }).then(function(res) { + if (res.success) { + showToast(I18n.t('skills.removed', { name: name }), 'success'); + } else { + showToast(I18n.t('skills.removeFailed', { message: res.message || 'unknown error' }), 'error'); + } + loadSkills(); + }).catch(function(err) { + showToast(I18n.t('skills.removeFailed', { message: err.message }), 'error'); + }); + }, I18n.t('common.remove'), 'btn-danger'); } function installSkillFromForm() { @@ -4522,10 +4529,10 @@ document.addEventListener('keydown', (e) => { const tag = (e.target.tagName || '').toLowerCase(); const inInput = tag === 'input' || tag === 'textarea'; - // Mod+1-6: switch tabs - if (mod && e.key >= '1' && e.key <= '6') { + // Mod+1-5: switch tabs + if (mod && e.key >= '1' && e.key <= '5') { e.preventDefault(); - const tabs = ['chat', 'memory', 'jobs', 'routines', 'extensions', 'skills']; + const tabs = ['chat', 'memory', 'jobs', 'routines', 'settings']; const idx = parseInt(e.key) - 1; if (tabs[idx]) switchTab(tabs[idx]); return; @@ -4565,6 +4572,684 @@ document.addEventListener('keydown', (e) => { } }); +// --- Settings Tab --- + +document.querySelectorAll('.settings-subtab').forEach(function(btn) { + btn.addEventListener('click', function() { + switchSettingsSubtab(btn.getAttribute('data-settings-subtab')); + }); +}); + +function switchSettingsSubtab(subtab) { + currentSettingsSubtab = subtab; + document.querySelectorAll('.settings-subtab').forEach(function(b) { + b.classList.toggle('active', b.getAttribute('data-settings-subtab') === subtab); + }); + document.querySelectorAll('.settings-subpanel').forEach(function(p) { + p.classList.toggle('active', p.id === 'settings-' + subtab); + }); + // Clear search when switching subtabs so stale filters don't apply + var searchInput = document.getElementById('settings-search-input'); + if (searchInput && searchInput.value) { + searchInput.value = ''; + searchInput.dispatchEvent(new Event('input')); + } + loadSettingsSubtab(subtab); +} + +function loadSettingsSubtab(subtab) { + if (subtab === 'inference') loadInferenceSettings(); + else if (subtab === 'agent') loadAgentSettings(); + else if (subtab === 'channels') { loadChannelsStatus(); startPairingPoll(); } + else if (subtab === 'networking') loadNetworkingSettings(); + else if (subtab === 'extensions') { loadExtensions(); startPairingPoll(); } + else if (subtab === 'mcp') loadMcpServers(); + else if (subtab === 'skills') loadSkills(); + if (subtab !== 'extensions' && subtab !== 'channels') stopPairingPoll(); +} + +// --- Structured Settings Definitions --- + +var INFERENCE_SETTINGS = [ + { + group: 'cfg.group.llm', + settings: [ + { key: 'llm_backend', label: 'cfg.llm_backend.label', description: 'cfg.llm_backend.desc', + type: 'select', options: ['nearai', 'anthropic', 'openai', 'ollama', 'openai_compatible', 'tinfoil', 'bedrock'] }, + { key: 'selected_model', label: 'cfg.selected_model.label', description: 'cfg.selected_model.desc', type: 'text' }, + { key: 'ollama_base_url', label: 'cfg.ollama_base_url.label', description: 'cfg.ollama_base_url.desc', type: 'text', + showWhen: { key: 'llm_backend', value: 'ollama' } }, + { key: 'openai_compatible_base_url', label: 'cfg.openai_compatible_base_url.label', description: 'cfg.openai_compatible_base_url.desc', type: 'text', + showWhen: { key: 'llm_backend', value: 'openai_compatible' } }, + { key: 'bedrock_region', label: 'cfg.bedrock_region.label', description: 'cfg.bedrock_region.desc', type: 'text', + showWhen: { key: 'llm_backend', value: 'bedrock' } }, + { key: 'bedrock_cross_region', label: 'cfg.bedrock_cross_region.label', description: 'cfg.bedrock_cross_region.desc', + type: 'select', options: ['us', 'eu', 'apac', 'global'], + showWhen: { key: 'llm_backend', value: 'bedrock' } }, + { key: 'bedrock_profile', label: 'cfg.bedrock_profile.label', description: 'cfg.bedrock_profile.desc', type: 'text', + showWhen: { key: 'llm_backend', value: 'bedrock' } }, + ] + }, + { + group: 'cfg.group.embeddings', + settings: [ + { key: 'embeddings.enabled', label: 'cfg.embeddings_enabled.label', description: 'cfg.embeddings_enabled.desc', type: 'boolean' }, + { key: 'embeddings.provider', label: 'cfg.embeddings_provider.label', description: 'cfg.embeddings_provider.desc', + type: 'select', options: ['openai', 'nearai'] }, + { key: 'embeddings.model', label: 'cfg.embeddings_model.label', description: 'cfg.embeddings_model.desc', type: 'text' }, + ] + }, +]; + +var AGENT_SETTINGS = [ + { + group: 'cfg.group.agent', + settings: [ + { key: 'agent.name', label: 'cfg.agent_name.label', description: 'cfg.agent_name.desc', type: 'text' }, + { key: 'agent.max_parallel_jobs', label: 'cfg.agent_max_parallel_jobs.label', description: 'cfg.agent_max_parallel_jobs.desc', type: 'number' }, + { key: 'agent.job_timeout_secs', label: 'cfg.agent_job_timeout.label', description: 'cfg.agent_job_timeout.desc', type: 'number' }, + { key: 'agent.max_tool_iterations', label: 'cfg.agent_max_tool_iterations.label', description: 'cfg.agent_max_tool_iterations.desc', type: 'number' }, + { key: 'agent.use_planning', label: 'cfg.agent_use_planning.label', description: 'cfg.agent_use_planning.desc', type: 'boolean' }, + { key: 'agent.auto_approve_tools', label: 'cfg.agent_auto_approve.label', description: 'cfg.agent_auto_approve.desc', type: 'boolean' }, + { key: 'agent.default_timezone', label: 'cfg.agent_timezone.label', description: 'cfg.agent_timezone.desc', type: 'text' }, + { key: 'agent.session_idle_timeout_secs', label: 'cfg.agent_session_idle.label', description: 'cfg.agent_session_idle.desc', type: 'number' }, + { key: 'agent.stuck_threshold_secs', label: 'cfg.agent_stuck_threshold.label', description: 'cfg.agent_stuck_threshold.desc', type: 'number' }, + { key: 'agent.max_repair_attempts', label: 'cfg.agent_max_repair.label', description: 'cfg.agent_max_repair.desc', type: 'number' }, + { key: 'agent.max_cost_per_day_cents', label: 'cfg.agent_max_cost.label', description: 'cfg.agent_max_cost.desc', type: 'number', min: 0 }, + { key: 'agent.max_actions_per_hour', label: 'cfg.agent_max_actions.label', description: 'cfg.agent_max_actions.desc', type: 'number', min: 0 }, + { key: 'agent.allow_local_tools', label: 'cfg.agent_allow_local.label', description: 'cfg.agent_allow_local.desc', type: 'boolean' }, + ] + }, + { + group: 'cfg.group.heartbeat', + settings: [ + { key: 'heartbeat.enabled', label: 'cfg.heartbeat_enabled.label', description: 'cfg.heartbeat_enabled.desc', type: 'boolean' }, + { key: 'heartbeat.interval_secs', label: 'cfg.heartbeat_interval.label', description: 'cfg.heartbeat_interval.desc', type: 'number' }, + { key: 'heartbeat.notify_channel', label: 'cfg.heartbeat_notify_channel.label', description: 'cfg.heartbeat_notify_channel.desc', type: 'text' }, + { key: 'heartbeat.notify_user', label: 'cfg.heartbeat_notify_user.label', description: 'cfg.heartbeat_notify_user.desc', type: 'text' }, + { key: 'heartbeat.quiet_hours_start', label: 'cfg.heartbeat_quiet_start.label', description: 'cfg.heartbeat_quiet_start.desc', type: 'number', min: 0, max: 23 }, + { key: 'heartbeat.quiet_hours_end', label: 'cfg.heartbeat_quiet_end.label', description: 'cfg.heartbeat_quiet_end.desc', type: 'number', min: 0, max: 23 }, + { key: 'heartbeat.timezone', label: 'cfg.heartbeat_timezone.label', description: 'cfg.heartbeat_timezone.desc', type: 'text' }, + ] + }, + { + group: 'cfg.group.sandbox', + settings: [ + { key: 'sandbox.enabled', label: 'cfg.sandbox_enabled.label', description: 'cfg.sandbox_enabled.desc', type: 'boolean' }, + { key: 'sandbox.policy', label: 'cfg.sandbox_policy.label', description: 'cfg.sandbox_policy.desc', + type: 'select', options: ['readonly', 'workspace_write', 'full_access'] }, + { key: 'sandbox.timeout_secs', label: 'cfg.sandbox_timeout.label', description: 'cfg.sandbox_timeout.desc', type: 'number', min: 0 }, + { key: 'sandbox.memory_limit_mb', label: 'cfg.sandbox_memory.label', description: 'cfg.sandbox_memory.desc', type: 'number', min: 0 }, + { key: 'sandbox.image', label: 'cfg.sandbox_image.label', description: 'cfg.sandbox_image.desc', type: 'text' }, + ] + }, + { + group: 'cfg.group.routines', + settings: [ + { key: 'routines.max_concurrent', label: 'cfg.routines_max_concurrent.label', description: 'cfg.routines_max_concurrent.desc', type: 'number', min: 0 }, + { key: 'routines.default_cooldown_secs', label: 'cfg.routines_cooldown.label', description: 'cfg.routines_cooldown.desc', type: 'number', min: 0 }, + ] + }, + { + group: 'cfg.group.safety', + settings: [ + { key: 'safety.max_output_length', label: 'cfg.safety_max_output.label', description: 'cfg.safety_max_output.desc', type: 'number', min: 0 }, + { key: 'safety.injection_check_enabled', label: 'cfg.safety_injection_check.label', description: 'cfg.safety_injection_check.desc', type: 'boolean' }, + ] + }, + { + group: 'cfg.group.skills', + settings: [ + { key: 'skills.max_active', label: 'cfg.skills_max_active.label', description: 'cfg.skills_max_active.desc', type: 'number', min: 0 }, + { key: 'skills.max_context_tokens', label: 'cfg.skills_max_tokens.label', description: 'cfg.skills_max_tokens.desc', type: 'number', min: 0 }, + ] + }, + { + group: 'cfg.group.search', + settings: [ + { key: 'search.fusion_strategy', label: 'cfg.search_fusion.label', description: 'cfg.search_fusion.desc', + type: 'select', options: ['rrf', 'weighted'] }, + ] + }, +]; + +function renderSettingsSkeleton(rows) { + var html = '
'; + for (var i = 0; i < (rows || 5); i++) { + var w1 = 100 + Math.floor(Math.random() * 60); + var w2 = 140 + Math.floor(Math.random() * 60); + html += '
'; + } + html += '
'; + return html; +} + +function renderCardsSkeleton(count) { + var html = ''; + for (var i = 0; i < (count || 3); i++) { + html += '
'; + } + return html; +} + +function loadInferenceSettings() { + var container = document.getElementById('settings-inference-content'); + container.innerHTML = renderSettingsSkeleton(6); + + Promise.all([ + apiFetch('/api/settings/export'), + apiFetch('/api/gateway/status').catch(function() { return {}; }), + apiFetch('/v1/models').catch(function() { return { data: [] }; }) + ]).then(function(results) { + var settings = results[0].settings || {}; + var status = results[1]; + var modelsData = results[2]; + var activeValues = { + 'llm_backend': status.llm_backend, + 'selected_model': status.llm_model + }; + // Inject available model IDs as suggestions for the selected_model field + var modelIds = (modelsData.data || []).map(function(m) { return m.id; }).filter(Boolean); + var llmGroup = INFERENCE_SETTINGS[0]; + for (var i = 0; i < llmGroup.settings.length; i++) { + if (llmGroup.settings[i].key === 'selected_model') { + llmGroup.settings[i].suggestions = modelIds; + break; + } + } + container.innerHTML = ''; + renderStructuredSettingsInto(container, INFERENCE_SETTINGS, settings, activeValues); + }).catch(function(err) { + container.innerHTML = '
' + I18n.t('common.loadFailed') + ': ' + + escapeHtml(err.message) + '
'; + }); +} + +function loadAgentSettings() { + loadStructuredSettings('settings-agent-content', AGENT_SETTINGS); +} + +function loadStructuredSettings(containerId, settingsDefs) { + var container = document.getElementById(containerId); + container.innerHTML = renderSettingsSkeleton(8); + + apiFetch('/api/settings/export').then(function(data) { + var settings = data.settings || {}; + container.innerHTML = ''; + renderStructuredSettingsInto(container, settingsDefs, settings, {}); + }).catch(function(err) { + container.innerHTML = '
' + I18n.t('common.loadFailed') + ': ' + + escapeHtml(err.message) + '
'; + }); +} + +function renderStructuredSettingsInto(container, settingsDefs, settings, activeValues) { + for (var gi = 0; gi < settingsDefs.length; gi++) { + var groupDef = settingsDefs[gi]; + var group = document.createElement('div'); + group.className = 'settings-group'; + + var title = document.createElement('div'); + title.className = 'settings-group-title'; + title.textContent = I18n.t(groupDef.group); + group.appendChild(title); + + var rows = []; + for (var si = 0; si < groupDef.settings.length; si++) { + var def = groupDef.settings[si]; + var activeVal = activeValues ? activeValues[def.key] : undefined; + var row = renderStructuredSettingsRow(def, settings[def.key], activeVal); + if (def.showWhen) { + row.setAttribute('data-show-when-key', def.showWhen.key); + row.setAttribute('data-show-when-value', def.showWhen.value); + var currentVal = settings[def.showWhen.key]; + if (currentVal === def.showWhen.value) { + row.classList.remove('hidden'); + } else { + row.classList.add('hidden'); + } + } + rows.push(row); + group.appendChild(row); + } + + container.appendChild(group); + + // Wire up showWhen reactivity for select fields in this group + (function(groupRows, allSettings) { + for (var ri = 0; ri < groupRows.length; ri++) { + var sel = groupRows[ri].querySelector('.settings-select'); + if (sel) { + sel.addEventListener('change', function() { + var changedKey = this.getAttribute('data-setting-key'); + var changedVal = this.value; + for (var rj = 0; rj < groupRows.length; rj++) { + var whenKey = groupRows[rj].getAttribute('data-show-when-key'); + var whenVal = groupRows[rj].getAttribute('data-show-when-value'); + if (whenKey === changedKey) { + if (changedVal === whenVal) { + groupRows[rj].classList.remove('hidden'); + } else { + groupRows[rj].classList.add('hidden'); + } + } + } + }); + } + } + })(rows, settings); + } + + if (container.children.length === 0) { + container.innerHTML = '
' + I18n.t('settings.noSettings') + '
'; + } +} + +function renderStructuredSettingsRow(def, value, activeValue) { + var row = document.createElement('div'); + row.className = 'settings-row'; + + var labelWrap = document.createElement('div'); + labelWrap.className = 'settings-label-wrap'; + + var label = document.createElement('div'); + label.className = 'settings-label'; + label.textContent = I18n.t(def.label); + labelWrap.appendChild(label); + + if (def.description) { + var desc = document.createElement('div'); + desc.className = 'settings-description'; + desc.textContent = I18n.t(def.description); + labelWrap.appendChild(desc); + } + + row.appendChild(labelWrap); + + var inputWrap = document.createElement('div'); + inputWrap.style.display = 'flex'; + inputWrap.style.alignItems = 'center'; + inputWrap.style.gap = '8px'; + + var ariaLabel = I18n.t(def.label) + (def.description ? '. ' + I18n.t(def.description) : ''); + var placeholderText = activeValue ? I18n.t('settings.envValue', { value: activeValue }) : (def.placeholder || I18n.t('settings.envDefault')); + + if (def.type === 'boolean') { + var boolSel = document.createElement('select'); + boolSel.className = 'settings-select'; + boolSel.setAttribute('data-setting-key', def.key); + boolSel.setAttribute('aria-label', ariaLabel); + var boolDefault = document.createElement('option'); + boolDefault.value = ''; + boolDefault.textContent = activeValue !== undefined && activeValue !== null + ? '\u2014 ' + I18n.t('settings.envValue', { value: String(activeValue) }) + ' \u2014' + : '\u2014 ' + I18n.t('settings.useEnvDefault') + ' \u2014'; + if (value === null || value === undefined) boolDefault.selected = true; + boolSel.appendChild(boolDefault); + var boolOn = document.createElement('option'); + boolOn.value = 'true'; + boolOn.textContent = I18n.t('settings.on'); + if (value === true) boolOn.selected = true; + boolSel.appendChild(boolOn); + var boolOff = document.createElement('option'); + boolOff.value = 'false'; + boolOff.textContent = I18n.t('settings.off'); + if (value === false) boolOff.selected = true; + boolSel.appendChild(boolOff); + boolSel.addEventListener('change', (function(k, el) { + return function() { + if (el.value === '') saveSetting(k, null); + else saveSetting(k, el.value === 'true'); + }; + })(def.key, boolSel)); + inputWrap.appendChild(boolSel); + } else if (def.type === 'select' && def.options) { + var sel = document.createElement('select'); + sel.className = 'settings-select'; + sel.setAttribute('data-setting-key', def.key); + sel.setAttribute('aria-label', ariaLabel); + var emptyOpt = document.createElement('option'); + emptyOpt.value = ''; + emptyOpt.textContent = activeValue ? '\u2014 ' + I18n.t('settings.envValue', { value: activeValue }) + ' \u2014' : '\u2014 ' + I18n.t('settings.useEnvDefault') + ' \u2014'; + if (!value && value !== false && value !== 0) emptyOpt.selected = true; + sel.appendChild(emptyOpt); + for (var oi = 0; oi < def.options.length; oi++) { + var opt = document.createElement('option'); + opt.value = def.options[oi]; + opt.textContent = def.options[oi]; + if (String(value) === def.options[oi]) opt.selected = true; + sel.appendChild(opt); + } + sel.addEventListener('change', (function(k, el) { + return function() { saveSetting(k, el.value === '' ? null : el.value); }; + })(def.key, sel)); + inputWrap.appendChild(sel); + } else if (def.type === 'number') { + var numInp = document.createElement('input'); + numInp.type = 'number'; + numInp.step = '1'; + numInp.className = 'settings-input'; + numInp.setAttribute('aria-label', ariaLabel); + numInp.value = (value === null || value === undefined) ? '' : value; + if (!value && value !== 0) numInp.placeholder = placeholderText; + if (def.min !== undefined) numInp.min = def.min; + if (def.max !== undefined) numInp.max = def.max; + numInp.addEventListener('change', (function(k, el) { + return function() { + if (el.value === '') return saveSetting(k, null); + var parsed = parseInt(el.value, 10); + if (isNaN(parsed)) return; + el.value = parsed; + saveSetting(k, parsed); + }; + })(def.key, numInp)); + inputWrap.appendChild(numInp); + } else { + var textInp = document.createElement('input'); + textInp.type = 'text'; + textInp.className = 'settings-input'; + textInp.setAttribute('aria-label', ariaLabel); + textInp.value = (value === null || value === undefined) ? '' : String(value); + if (!value) textInp.placeholder = placeholderText; + // Attach datalist for autocomplete suggestions (e.g., model list) + if (def.suggestions && def.suggestions.length > 0) { + var dlId = 'dl-' + def.key.replace(/\./g, '-'); + var dl = document.createElement('datalist'); + dl.id = dlId; + for (var di = 0; di < def.suggestions.length; di++) { + var dlOpt = document.createElement('option'); + dlOpt.value = def.suggestions[di]; + dl.appendChild(dlOpt); + } + textInp.setAttribute('list', dlId); + inputWrap.appendChild(dl); + } + textInp.addEventListener('change', (function(k, el) { + return function() { saveSetting(k, el.value === '' ? null : el.value); }; + })(def.key, textInp)); + inputWrap.appendChild(textInp); + } + + var saved = document.createElement('span'); + saved.className = 'settings-saved-indicator'; + saved.textContent = '\u2713 ' + I18n.t('settings.saved'); + saved.setAttribute('data-key', def.key); + saved.setAttribute('role', 'status'); + saved.setAttribute('aria-live', 'polite'); + inputWrap.appendChild(saved); + + row.appendChild(inputWrap); + return row; +} + +var RESTART_REQUIRED_KEYS = ['llm_backend', 'selected_model', 'ollama_base_url', 'openai_compatible_base_url', + 'bedrock_region', 'bedrock_cross_region', 'bedrock_profile', 'embeddings.enabled', 'embeddings.provider', 'embeddings.model', + 'agent.auto_approve_tools', 'tunnel.provider', 'tunnel.public_url', 'gateway.rate_limit', 'gateway.max_connections']; + +var _settingsSavedTimers = {}; + +function saveSetting(key, value) { + var method = (value === null || value === undefined) ? 'DELETE' : 'PUT'; + var opts = { method: method }; + if (method === 'PUT') opts.body = { value: value }; + apiFetch('/api/settings/' + encodeURIComponent(key), opts).then(function() { + var indicator = document.querySelector('.settings-saved-indicator[data-key="' + key + '"]'); + if (indicator) { + if (_settingsSavedTimers[key]) clearTimeout(_settingsSavedTimers[key]); + indicator.classList.add('visible'); + _settingsSavedTimers[key] = setTimeout(function() { indicator.classList.remove('visible'); }, 2000); + } + // Show restart banner for inference settings + if (RESTART_REQUIRED_KEYS.indexOf(key) !== -1) { + showRestartBanner(); + } + }).catch(function(err) { + showToast('Failed to save ' + key + ': ' + err.message, 'error'); + }); +} + +function showRestartBanner() { + var container = document.querySelector('.settings-content'); + if (!container || container.querySelector('.restart-banner')) return; + var banner = document.createElement('div'); + banner.className = 'restart-banner'; + banner.setAttribute('role', 'alert'); + var textSpan = document.createElement('span'); + textSpan.className = 'restart-banner-text'; + textSpan.textContent = '\u26A0\uFE0F ' + I18n.t('settings.restartRequired'); + banner.appendChild(textSpan); + var restartBtn = document.createElement('button'); + restartBtn.className = 'restart-banner-btn'; + restartBtn.textContent = I18n.t('settings.restartNow'); + restartBtn.addEventListener('click', function() { triggerRestart(); }); + banner.appendChild(restartBtn); + container.insertBefore(banner, container.firstChild); +} + +function loadMcpServers() { + var mcpList = document.getElementById('mcp-servers-list'); + mcpList.innerHTML = renderCardsSkeleton(2); + + Promise.all([ + apiFetch('/api/extensions').catch(function() { return { extensions: [] }; }), + apiFetch('/api/extensions/registry').catch(function() { return { entries: [] }; }), + ]).then(function(results) { + var extData = results[0]; + var registryData = results[1]; + var mcpEntries = (registryData.entries || []).filter(function(e) { return e.kind === 'mcp_server'; }); + var installedMcp = (extData.extensions || []).filter(function(e) { return e.kind === 'mcp_server'; }); + + mcpList.innerHTML = ''; + var renderedNames = {}; + + // Registry entries (cross-referenced with installed) + for (var i = 0; i < mcpEntries.length; i++) { + renderedNames[mcpEntries[i].name] = true; + var installedExt = installedMcp.find(function(e) { return e.name === mcpEntries[i].name; }); + mcpList.appendChild(renderMcpServerCard(mcpEntries[i], installedExt)); + } + + // Custom installed MCP servers not in registry + for (var j = 0; j < installedMcp.length; j++) { + if (!renderedNames[installedMcp[j].name]) { + mcpList.appendChild(renderExtensionCard(installedMcp[j])); + } + } + + if (mcpList.children.length === 0) { + mcpList.innerHTML = '
' + I18n.t('mcp.noServers') + '
'; + } + }).catch(function(err) { + mcpList.innerHTML = '
' + I18n.t('common.loadFailed') + ': ' + + escapeHtml(err.message) + '
'; + }); +} + +function loadChannelsStatus() { + var container = document.getElementById('settings-channels-content'); + container.innerHTML = renderCardsSkeleton(4); + + Promise.all([ + apiFetch('/api/gateway/status').catch(function() { return {}; }), + apiFetch('/api/extensions').catch(function() { return { extensions: [] }; }), + apiFetch('/api/extensions/registry').catch(function() { return { entries: [] }; }), + ]).then(function(results) { + var status = results[0]; + var extensions = results[1].extensions || []; + var registry = results[2].entries || []; + + container.innerHTML = ''; + + // Built-in Channels section + var builtinSection = document.createElement('div'); + builtinSection.className = 'extensions-section'; + var builtinTitle = document.createElement('h3'); + builtinTitle.textContent = I18n.t('channels.builtin'); + builtinSection.appendChild(builtinTitle); + var builtinList = document.createElement('div'); + builtinList.className = 'extensions-list'; + + builtinList.appendChild(renderBuiltinChannelCard( + I18n.t('channels.webGateway'), + I18n.t('channels.webGatewayDesc'), + true, + 'SSE: ' + (status.sse_connections || 0) + ' \u00B7 WS: ' + (status.ws_connections || 0) + )); + + var enabledChannels = status.enabled_channels || []; + + builtinList.appendChild(renderBuiltinChannelCard( + I18n.t('channels.httpWebhook'), + I18n.t('channels.httpWebhookDesc'), + enabledChannels.indexOf('http') !== -1, + I18n.t('channels.configureVia', { env: 'ENABLE_HTTP=true' }) + )); + + builtinList.appendChild(renderBuiltinChannelCard( + I18n.t('channels.cli'), + I18n.t('channels.cliDesc'), + enabledChannels.indexOf('cli') !== -1, + I18n.t('channels.runWith', { cmd: 'ironclaw run --cli' }) + )); + + builtinList.appendChild(renderBuiltinChannelCard( + I18n.t('channels.repl'), + I18n.t('channels.replDesc'), + enabledChannels.indexOf('repl') !== -1, + I18n.t('channels.runWith', { cmd: 'ironclaw run --repl' }) + )); + + builtinSection.appendChild(builtinList); + container.appendChild(builtinSection); + + // Messaging Channels section — use extension cards with full stepper/pairing UI + var channelEntries = registry.filter(function(e) { + return e.kind === 'wasm_channel' || e.kind === 'channel'; + }); + var installedChannels = extensions.filter(function(e) { + return e.kind === 'wasm_channel'; + }); + + if (channelEntries.length > 0 || installedChannels.length > 0) { + var messagingSection = document.createElement('div'); + messagingSection.className = 'extensions-section'; + var messagingTitle = document.createElement('h3'); + messagingTitle.textContent = I18n.t('channels.messaging'); + messagingSection.appendChild(messagingTitle); + var messagingList = document.createElement('div'); + messagingList.className = 'extensions-list'; + + var renderedNames = {}; + + // Registry entries: show full ext card if installed, available card if not + for (var i = 0; i < channelEntries.length; i++) { + var entry = channelEntries[i]; + renderedNames[entry.name] = true; + var installed = null; + for (var k = 0; k < installedChannels.length; k++) { + if (installedChannels[k].name === entry.name) { installed = installedChannels[k]; break; } + } + if (installed) { + messagingList.appendChild(renderExtensionCard(installed)); + } else { + messagingList.appendChild(renderAvailableExtensionCard(entry)); + } + } + + // Installed channels not in registry (custom installs) + for (var j = 0; j < installedChannels.length; j++) { + if (!renderedNames[installedChannels[j].name]) { + messagingList.appendChild(renderExtensionCard(installedChannels[j])); + } + } + + messagingSection.appendChild(messagingList); + container.appendChild(messagingSection); + } + }); +} + +function renderBuiltinChannelCard(name, description, active, detail) { + var card = document.createElement('div'); + card.className = 'ext-card ' + (active ? 'state-active' : 'state-inactive'); + + var header = document.createElement('div'); + header.className = 'ext-header'; + + var nameEl = document.createElement('span'); + nameEl.className = 'ext-name'; + nameEl.textContent = name; + header.appendChild(nameEl); + + var kindEl = document.createElement('span'); + kindEl.className = 'ext-kind kind-builtin'; + kindEl.textContent = I18n.t('ext.builtin'); + header.appendChild(kindEl); + + var statusDot = document.createElement('span'); + statusDot.className = 'ext-auth-dot ' + (active ? 'authed' : 'unauthed'); + statusDot.title = active ? I18n.t('ext.active') : I18n.t('ext.inactive'); + header.appendChild(statusDot); + + card.appendChild(header); + + var desc = document.createElement('div'); + desc.className = 'ext-desc'; + desc.textContent = description; + card.appendChild(desc); + + if (detail) { + var detailEl = document.createElement('div'); + detailEl.className = 'ext-url'; + detailEl.textContent = detail; + card.appendChild(detailEl); + } + + var actions = document.createElement('div'); + actions.className = 'ext-actions'; + var label = document.createElement('span'); + label.className = 'ext-active-label'; + label.textContent = active ? I18n.t('ext.active') : I18n.t('ext.inactive'); + actions.appendChild(label); + card.appendChild(actions); + + return card; +} + +// --- Networking Settings --- + +var NETWORKING_SETTINGS = [ + { + group: 'cfg.group.tunnel', + settings: [ + { key: 'tunnel.provider', label: 'cfg.tunnel_provider.label', description: 'cfg.tunnel_provider.desc', + type: 'select', options: ['none', 'cloudflare', 'ngrok', 'tailscale', 'custom'] }, + { key: 'tunnel.public_url', label: 'cfg.tunnel_public_url.label', description: 'cfg.tunnel_public_url.desc', type: 'text' }, + ] + }, + { + group: 'cfg.group.gateway', + settings: [ + { key: 'gateway.rate_limit', label: 'cfg.gateway_rate_limit.label', description: 'cfg.gateway_rate_limit.desc', type: 'number', min: 0 }, + { key: 'gateway.max_connections', label: 'cfg.gateway_max_connections.label', description: 'cfg.gateway_max_connections.desc', type: 'number', min: 0 }, + ] + }, +]; + +function loadNetworkingSettings() { + var container = document.getElementById('settings-networking-content'); + container.innerHTML = renderSettingsSkeleton(4); + + apiFetch('/api/settings/export').then(function(data) { + var settings = data.settings || {}; + container.innerHTML = ''; + renderStructuredSettingsInto(container, NETWORKING_SETTINGS, settings, {}); + }).catch(function(err) { + container.innerHTML = '
' + I18n.t('common.loadFailed') + ': ' + + escapeHtml(err.message) + '
'; + }); +} + // --- Toasts --- function showToast(message, type) { @@ -4617,6 +5302,8 @@ document.getElementById('wasm-install-btn').addEventListener('click', () => inst document.getElementById('mcp-add-btn').addEventListener('click', () => addMcpServer()); document.getElementById('skill-search-btn').addEventListener('click', () => searchClawHub()); document.getElementById('skill-install-btn').addEventListener('click', () => installSkillFromForm()); +document.getElementById('settings-export-btn').addEventListener('click', () => exportSettings()); +document.getElementById('settings-import-btn').addEventListener('click', () => importSettings()); // --- Delegated Event Handlers (for dynamically generated HTML) --- @@ -4685,3 +5372,125 @@ document.addEventListener('click', function(e) { document.getElementById('language-btn').addEventListener('click', function() { if (typeof toggleLanguageMenu === 'function') toggleLanguageMenu(); }); + +// --- Confirmation Modal --- + +var _confirmModalCallback = null; + +function showConfirmModal(title, message, onConfirm, confirmLabel, confirmClass) { + var modal = document.getElementById('confirm-modal'); + document.getElementById('confirm-modal-title').textContent = title; + document.getElementById('confirm-modal-message').textContent = message || ''; + document.getElementById('confirm-modal-message').style.display = message ? '' : 'none'; + var btn = document.getElementById('confirm-modal-btn'); + btn.textContent = confirmLabel || I18n.t('btn.confirm'); + btn.className = confirmClass || 'btn-danger'; + _confirmModalCallback = onConfirm; + modal.style.display = 'flex'; + btn.focus(); +} + +function closeConfirmModal() { + document.getElementById('confirm-modal').style.display = 'none'; + _confirmModalCallback = null; +} + +document.getElementById('confirm-modal-btn').addEventListener('click', function() { + if (_confirmModalCallback) _confirmModalCallback(); + closeConfirmModal(); +}); +document.getElementById('confirm-modal-cancel-btn').addEventListener('click', closeConfirmModal); +document.getElementById('confirm-modal').addEventListener('click', function(e) { + if (e.target === this) closeConfirmModal(); +}); +document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && document.getElementById('confirm-modal').style.display === 'flex') { + closeConfirmModal(); + } +}); + +// --- Settings Import/Export --- + +function exportSettings() { + apiFetch('/api/settings/export').then(function(data) { + var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'ironclaw-settings.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + showToast(I18n.t('settings.exportSuccess'), 'success'); + }).catch(function(err) { + showToast(I18n.t('settings.exportFailed', { message: err.message }), 'error'); + }); +} + +function importSettings() { + var input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json,application/json'; + input.addEventListener('change', function() { + if (!input.files || !input.files[0]) return; + var reader = new FileReader(); + reader.onload = function() { + try { + var data = JSON.parse(reader.result); + apiFetch('/api/settings/import', { + method: 'POST', + body: data, + }).then(function() { + showToast(I18n.t('settings.importSuccess'), 'success'); + loadSettingsSubtab(currentSettingsSubtab); + }).catch(function(err) { + showToast(I18n.t('settings.importFailed', { message: err.message }), 'error'); + }); + } catch (e) { + showToast(I18n.t('settings.importFailed', { message: e.message }), 'error'); + } + }; + reader.readAsText(input.files[0]); + }); + input.click(); +} + +// --- Settings Search --- + +document.getElementById('settings-search-input').addEventListener('input', function() { + var query = this.value.toLowerCase(); + var activePanel = document.querySelector('.settings-subpanel.active'); + if (!activePanel) return; + var rows = activePanel.querySelectorAll('.settings-row'); + if (rows.length === 0) return; + var visibleCount = 0; + rows.forEach(function(row) { + var text = row.textContent.toLowerCase(); + if (query === '' || text.indexOf(query) !== -1) { + row.classList.remove('search-hidden'); + if (!row.classList.contains('hidden')) visibleCount++; + } else { + row.classList.add('search-hidden'); + } + }); + // Show/hide group titles based on visible children + var groups = activePanel.querySelectorAll('.settings-group'); + groups.forEach(function(group) { + var visibleRows = group.querySelectorAll('.settings-row:not(.search-hidden):not(.hidden)'); + if (visibleRows.length === 0 && query !== '') { + group.style.display = 'none'; + } else { + group.style.display = ''; + } + }); + // Show/hide empty state + var existingEmpty = activePanel.querySelector('.settings-search-empty'); + if (existingEmpty) existingEmpty.remove(); + if (query !== '' && visibleCount === 0) { + var empty = document.createElement('div'); + empty.className = 'settings-search-empty'; + empty.textContent = I18n.t('settings.noMatchingSettings', { query: this.value }); + activePanel.appendChild(empty); + } +}); diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js index 49bec76204..1369b48531 100644 --- a/src/channels/web/static/i18n/en.js +++ b/src/channels/web/static/i18n/en.js @@ -29,9 +29,15 @@ I18n.register('en', { 'tab.memory': 'Memory', 'tab.jobs': 'Jobs', 'tab.routines': 'Routines', + 'tab.settings': 'Settings', 'tab.extensions': 'Extensions', 'tab.skills': 'Skills', 'tab.logs': 'Logs', + 'settings.inference': 'Inference', + 'settings.agent': 'Agent', + 'settings.channels': 'Channels', + 'settings.networking': 'Networking', + 'settings.mcp': 'MCP', // Status 'status.connected': 'Connected', @@ -131,10 +137,10 @@ I18n.register('en', { // Extensions Tab 'extensions.installed': 'Installed Extensions', - 'extensions.available': 'Available WASM Extensions', - 'extensions.installWasm': 'Install WASM Extension', + 'extensions.available': 'Available Extensions', + 'extensions.installWasm': 'Install Extension', 'extensions.noInstalled': 'No extensions installed', - 'extensions.noAvailable': 'No additional WASM extensions available', + 'extensions.noAvailable': 'No additional extensions available', 'extensions.loading': 'Loading...', 'extensions.install': 'Install', 'extensions.installing': 'Installing...', @@ -156,13 +162,8 @@ I18n.register('en', { 'mcp.addCustom': 'Add Custom MCP Server', 'mcp.add': 'Add', 'mcp.addedSuccess': 'Added MCP server {name}', - - // Registered Tools - 'tools.registered': 'Registered Tools', - 'tools.name': 'Name', - 'tools.description': 'Description', - 'tools.empty': 'No tools registered', - + + // Skills Tab 'skills.installed': 'Installed Skills', 'skills.noInstalled': 'No skills installed', @@ -302,6 +303,7 @@ I18n.register('en', { // Common 'common.loading': 'Loading...', + 'common.loadFailed': 'Failed to load', 'common.noData': 'No data', 'common.search': 'Search', 'common.add': 'Add', @@ -328,6 +330,8 @@ I18n.register('en', { // Extensions 'ext.active': 'Active', + 'ext.inactive': 'Inactive', + 'ext.builtin': 'Built-in', 'ext.remove': 'Remove', 'ext.install': 'Install', 'ext.installing': 'Installing...', @@ -355,4 +359,160 @@ I18n.register('en', { 'config.autoGenerate': 'Auto-generated if empty', 'config.save': 'Save', 'config.cancel': 'Cancel', + + // Settings toolbar + 'settings.export': 'Export', + 'settings.import': 'Import', + 'settings.searchPlaceholder': 'Search settings...', + 'settings.exportSuccess': 'Settings exported', + 'settings.exportFailed': 'Export failed: {message}', + 'settings.importSuccess': 'Settings imported successfully', + 'settings.importFailed': 'Import failed: {message}', + 'settings.restartRequired': 'Restart required for changes to take effect.', + 'settings.restartNow': 'Restart Now', + 'settings.noMatchingSettings': 'No settings matching "{query}"', + 'settings.noSettings': 'No settings found', + 'settings.saved': 'Saved', + 'settings.on': 'On', + 'settings.off': 'Off', + 'settings.envValue': 'env: {value}', + 'settings.envDefault': 'env default', + 'settings.useEnvDefault': 'use env default', + + // Settings groups + 'cfg.group.llm': 'LLM Provider', + 'cfg.group.embeddings': 'Embeddings', + 'cfg.group.agent': 'Agent', + 'cfg.group.heartbeat': 'Heartbeat', + 'cfg.group.sandbox': 'Sandbox', + 'cfg.group.routines': 'Routines', + 'cfg.group.safety': 'Safety', + 'cfg.group.skills': 'Skills', + 'cfg.group.search': 'Search', + 'cfg.group.tunnel': 'Tunnel', + 'cfg.group.gateway': 'Gateway', + + // Inference settings + 'cfg.llm_backend.label': 'Backend', + 'cfg.llm_backend.desc': 'LLM inference provider', + 'cfg.selected_model.label': 'Model', + 'cfg.selected_model.desc': 'Model name or ID for the selected backend', + 'cfg.ollama_base_url.label': 'Ollama URL', + 'cfg.ollama_base_url.desc': 'Base URL for Ollama API', + 'cfg.openai_compatible_base_url.label': 'OpenAI-compatible URL', + 'cfg.openai_compatible_base_url.desc': 'Base URL for OpenAI-compatible API', + 'cfg.bedrock_region.label': 'Bedrock Region', + 'cfg.bedrock_region.desc': 'AWS region for Bedrock', + 'cfg.bedrock_cross_region.label': 'Cross-Region', + 'cfg.bedrock_cross_region.desc': 'Enable cross-region inference', + 'cfg.bedrock_profile.label': 'AWS Profile', + 'cfg.bedrock_profile.desc': 'AWS profile for Bedrock auth', + 'cfg.embeddings_enabled.label': 'Enabled', + 'cfg.embeddings_enabled.desc': 'Enable vector embeddings for memory search', + 'cfg.embeddings_provider.label': 'Provider', + 'cfg.embeddings_provider.desc': 'Embeddings API provider', + 'cfg.embeddings_model.label': 'Model', + 'cfg.embeddings_model.desc': 'Embedding model name', + + // Agent settings + 'cfg.agent_name.label': 'Name', + 'cfg.agent_name.desc': 'Agent display name', + 'cfg.agent_max_parallel_jobs.label': 'Max Parallel Jobs', + 'cfg.agent_max_parallel_jobs.desc': 'Maximum concurrent background jobs', + 'cfg.agent_job_timeout.label': 'Job Timeout', + 'cfg.agent_job_timeout.desc': 'Max duration per job in seconds', + 'cfg.agent_max_tool_iterations.label': 'Max Tool Iterations', + 'cfg.agent_max_tool_iterations.desc': 'Max tool calls per turn', + 'cfg.agent_use_planning.label': 'Planning', + 'cfg.agent_use_planning.desc': 'Enable multi-step planning before execution', + 'cfg.agent_auto_approve.label': 'Auto-approve Tools', + 'cfg.agent_auto_approve.desc': 'Skip manual approval for tool calls', + 'cfg.agent_timezone.label': 'Timezone', + 'cfg.agent_timezone.desc': 'Default timezone (IANA)', + 'cfg.agent_session_idle.label': 'Session Idle Timeout', + 'cfg.agent_session_idle.desc': 'Seconds before idle session expires', + 'cfg.agent_stuck_threshold.label': 'Stuck Threshold', + 'cfg.agent_stuck_threshold.desc': 'Seconds before a job is considered stuck', + 'cfg.agent_max_repair.label': 'Max Repair Attempts', + 'cfg.agent_max_repair.desc': 'Auto-recovery attempts for stuck jobs', + 'cfg.agent_max_cost.label': 'Max Daily Cost', + 'cfg.agent_max_cost.desc': 'Daily LLM spend cap in cents (0 = unlimited)', + 'cfg.agent_max_actions.label': 'Max Actions/Hour', + 'cfg.agent_max_actions.desc': 'Hourly tool call rate limit (0 = unlimited)', + 'cfg.agent_allow_local.label': 'Allow Local Tools', + 'cfg.agent_allow_local.desc': 'Enable local filesystem tool execution', + + // Heartbeat settings + 'cfg.heartbeat_enabled.label': 'Enabled', + 'cfg.heartbeat_enabled.desc': 'Run periodic background checks', + 'cfg.heartbeat_interval.label': 'Interval', + 'cfg.heartbeat_interval.desc': 'Seconds between heartbeats (default: 1800)', + 'cfg.heartbeat_notify_channel.label': 'Notify Channel', + 'cfg.heartbeat_notify_channel.desc': 'Channel to send heartbeat findings to', + 'cfg.heartbeat_notify_user.label': 'Notify User', + 'cfg.heartbeat_notify_user.desc': 'User ID to notify', + 'cfg.heartbeat_quiet_start.label': 'Quiet Hours Start', + 'cfg.heartbeat_quiet_start.desc': 'Hour (0-23) to stop heartbeats', + 'cfg.heartbeat_quiet_end.label': 'Quiet Hours End', + 'cfg.heartbeat_quiet_end.desc': 'Hour (0-23) to resume heartbeats', + 'cfg.heartbeat_timezone.label': 'Timezone', + 'cfg.heartbeat_timezone.desc': 'Timezone for quiet hours (IANA)', + + // Sandbox settings + 'cfg.sandbox_enabled.label': 'Enabled', + 'cfg.sandbox_enabled.desc': 'Enable Docker sandbox for background jobs', + 'cfg.sandbox_policy.label': 'Policy', + 'cfg.sandbox_policy.desc': 'Sandbox security policy', + 'cfg.sandbox_timeout.label': 'Timeout', + 'cfg.sandbox_timeout.desc': 'Max job duration in seconds', + 'cfg.sandbox_memory.label': 'Memory Limit', + 'cfg.sandbox_memory.desc': 'Container memory limit (MB)', + 'cfg.sandbox_image.label': 'Docker Image', + 'cfg.sandbox_image.desc': 'Container image for sandbox jobs', + + // Routines settings + 'cfg.routines_max_concurrent.label': 'Max Concurrent', + 'cfg.routines_max_concurrent.desc': 'Maximum routines running simultaneously', + 'cfg.routines_cooldown.label': 'Default Cooldown', + 'cfg.routines_cooldown.desc': 'Minimum seconds between routine fires', + + // Safety settings + 'cfg.safety_max_output.label': 'Max Output Length', + 'cfg.safety_max_output.desc': 'Maximum output tokens per response', + 'cfg.safety_injection_check.label': 'Injection Check', + 'cfg.safety_injection_check.desc': 'Enable prompt injection detection', + + // Skills settings + 'cfg.skills_max_active.label': 'Max Active Skills', + 'cfg.skills_max_active.desc': 'Maximum skills active simultaneously', + 'cfg.skills_max_tokens.label': 'Max Context Tokens', + 'cfg.skills_max_tokens.desc': 'Token budget for skill prompts', + + // Search settings + 'cfg.search_fusion.label': 'Fusion Strategy', + 'cfg.search_fusion.desc': 'Hybrid search ranking method', + + // Networking settings + 'cfg.tunnel_provider.label': 'Provider', + 'cfg.tunnel_provider.desc': 'Public URL tunnel provider', + 'cfg.tunnel_public_url.label': 'Public URL', + 'cfg.tunnel_public_url.desc': 'Static public URL (if not using tunnel provider)', + 'cfg.gateway_rate_limit.label': 'Rate Limit', + 'cfg.gateway_rate_limit.desc': 'Max chat messages per minute', + 'cfg.gateway_max_connections.label': 'Max Connections', + 'cfg.gateway_max_connections.desc': 'Max simultaneous SSE/WS connections', + + // Channels subtab + 'channels.builtin': 'Built-in Channels', + 'channels.messaging': 'Messaging Channels', + 'channels.webGateway': 'Web Gateway', + 'channels.webGatewayDesc': 'Browser-based chat interface', + 'channels.httpWebhook': 'HTTP Webhook', + 'channels.httpWebhookDesc': 'Incoming webhook endpoint for external integrations', + 'channels.cli': 'CLI', + 'channels.cliDesc': 'Terminal UI with Ratatui', + 'channels.repl': 'REPL', + 'channels.replDesc': 'Simple read-eval-print loop for testing', + 'channels.configureVia': 'Configure via {env}', + 'channels.runWith': 'Run with: {cmd}', }); diff --git a/src/channels/web/static/i18n/zh-CN.js b/src/channels/web/static/i18n/zh-CN.js index d31cc0df91..6262b562b8 100644 --- a/src/channels/web/static/i18n/zh-CN.js +++ b/src/channels/web/static/i18n/zh-CN.js @@ -29,9 +29,15 @@ I18n.register('zh-CN', { 'tab.memory': '记忆', 'tab.jobs': '任务', 'tab.routines': '定时任务', + 'tab.settings': '设置', 'tab.extensions': '扩展', 'tab.skills': '技能', 'tab.logs': '日志', + 'settings.inference': '推理', + 'settings.agent': '代理', + 'settings.channels': '频道', + 'settings.networking': '网络', + 'settings.mcp': 'MCP', // 状态 'status.connected': '已连接', @@ -131,10 +137,10 @@ I18n.register('zh-CN', { // 扩展标签页 'extensions.installed': '已安装扩展', - 'extensions.available': '可用 WASM 扩展', - 'extensions.installWasm': '安装 WASM 扩展', + 'extensions.available': '可用扩展', + 'extensions.installWasm': '安装扩展', 'extensions.noInstalled': '没有安装扩展', - 'extensions.noAvailable': '没有其他可用的 WASM 扩展', + 'extensions.noAvailable': '没有其他可用扩展', 'extensions.loading': '加载中...', 'extensions.install': '安装', 'extensions.installing': '安装中...', @@ -156,13 +162,8 @@ I18n.register('zh-CN', { 'mcp.addCustom': '添加自定义 MCP 服务器', 'mcp.add': '添加', 'mcp.addedSuccess': '已添加 MCP 服务器 {name}', - - // 注册工具 - 'tools.registered': '注册工具', - 'tools.name': '名称', - 'tools.description': '描述', - 'tools.empty': '没有注册工具', - + + // 技能标签页 'skills.installed': '已安装技能', 'skills.noInstalled': '没有安装技能', @@ -302,6 +303,7 @@ I18n.register('zh-CN', { // 通用 'common.loading': '加载中...', + 'common.loadFailed': '加载失败', 'common.noData': '暂无数据', 'common.search': '搜索', 'common.add': '添加', @@ -328,6 +330,8 @@ I18n.register('zh-CN', { // 扩展 'ext.active': '已激活', + 'ext.inactive': '未激活', + 'ext.builtin': '内置', 'ext.remove': '移除', 'ext.install': '安装', 'ext.installing': '安装中...', @@ -354,4 +358,160 @@ I18n.register('zh-CN', { 'config.autoGenerate': '如果为空则自动生成', 'config.save': '保存', 'config.cancel': '取消', + + // 设置工具栏 + 'settings.export': '导出', + 'settings.import': '导入', + 'settings.searchPlaceholder': '搜索设置...', + 'settings.exportSuccess': '设置已导出', + 'settings.exportFailed': '导出失败: {message}', + 'settings.importSuccess': '设置导入成功', + 'settings.importFailed': '导入失败: {message}', + 'settings.restartRequired': '需要重启才能使更改生效。', + 'settings.restartNow': '立即重启', + 'settings.noMatchingSettings': '没有匹配 "{query}" 的设置', + 'settings.noSettings': '未找到设置', + 'settings.saved': '已保存', + 'settings.on': '开启', + 'settings.off': '关闭', + 'settings.envValue': '环境变量: {value}', + 'settings.envDefault': '使用环境变量默认值', + 'settings.useEnvDefault': '使用环境变量默认值', + + // 设置分组 + 'cfg.group.llm': 'LLM 提供商', + 'cfg.group.embeddings': '嵌入向量', + 'cfg.group.agent': '代理', + 'cfg.group.heartbeat': '心跳', + 'cfg.group.sandbox': '沙箱', + 'cfg.group.routines': '定时任务', + 'cfg.group.safety': '安全', + 'cfg.group.skills': '技能', + 'cfg.group.search': '搜索', + 'cfg.group.tunnel': '隧道', + 'cfg.group.gateway': '网关', + + // 推理设置 + 'cfg.llm_backend.label': '后端', + 'cfg.llm_backend.desc': 'LLM 推理提供商', + 'cfg.selected_model.label': '模型', + 'cfg.selected_model.desc': '所选后端的模型名称或 ID', + 'cfg.ollama_base_url.label': 'Ollama URL', + 'cfg.ollama_base_url.desc': 'Ollama API 基础 URL', + 'cfg.openai_compatible_base_url.label': 'OpenAI 兼容 URL', + 'cfg.openai_compatible_base_url.desc': 'OpenAI 兼容 API 基础 URL', + 'cfg.bedrock_region.label': 'Bedrock 区域', + 'cfg.bedrock_region.desc': 'Bedrock 的 AWS 区域', + 'cfg.bedrock_cross_region.label': '跨区域', + 'cfg.bedrock_cross_region.desc': '启用跨区域推理', + 'cfg.bedrock_profile.label': 'AWS 配置文件', + 'cfg.bedrock_profile.desc': 'Bedrock 认证的 AWS 配置文件', + 'cfg.embeddings_enabled.label': '启用', + 'cfg.embeddings_enabled.desc': '启用向量嵌入以支持记忆搜索', + 'cfg.embeddings_provider.label': '提供商', + 'cfg.embeddings_provider.desc': '嵌入向量 API 提供商', + 'cfg.embeddings_model.label': '模型', + 'cfg.embeddings_model.desc': '嵌入向量模型名称', + + // 代理设置 + 'cfg.agent_name.label': '名称', + 'cfg.agent_name.desc': '代理显示名称', + 'cfg.agent_max_parallel_jobs.label': '最大并行任务数', + 'cfg.agent_max_parallel_jobs.desc': '最大并发后台任务数', + 'cfg.agent_job_timeout.label': '任务超时', + 'cfg.agent_job_timeout.desc': '每个任务的最大持续时间(秒)', + 'cfg.agent_max_tool_iterations.label': '最大工具迭代次数', + 'cfg.agent_max_tool_iterations.desc': '每轮最大工具调用次数', + 'cfg.agent_use_planning.label': '规划', + 'cfg.agent_use_planning.desc': '执行前启用多步规划', + 'cfg.agent_auto_approve.label': '自动批准工具', + 'cfg.agent_auto_approve.desc': '跳过工具调用的手动审批', + 'cfg.agent_timezone.label': '时区', + 'cfg.agent_timezone.desc': '默认时区(IANA)', + 'cfg.agent_session_idle.label': '会话空闲超时', + 'cfg.agent_session_idle.desc': '空闲会话过期前的秒数', + 'cfg.agent_stuck_threshold.label': '卡住阈值', + 'cfg.agent_stuck_threshold.desc': '任务被认为卡住前的秒数', + 'cfg.agent_max_repair.label': '最大修复尝试次数', + 'cfg.agent_max_repair.desc': '卡住任务的自动恢复尝试次数', + 'cfg.agent_max_cost.label': '每日最大费用', + 'cfg.agent_max_cost.desc': '每日 LLM 支出上限(美分,0 = 无限制)', + 'cfg.agent_max_actions.label': '每小时最大操作数', + 'cfg.agent_max_actions.desc': '每小时工具调用速率限制(0 = 无限制)', + 'cfg.agent_allow_local.label': '允许本地工具', + 'cfg.agent_allow_local.desc': '启用本地文件系统工具执行', + + // 心跳设置 + 'cfg.heartbeat_enabled.label': '启用', + 'cfg.heartbeat_enabled.desc': '运行定期后台检查', + 'cfg.heartbeat_interval.label': '间隔', + 'cfg.heartbeat_interval.desc': '心跳间隔秒数(默认:1800)', + 'cfg.heartbeat_notify_channel.label': '通知频道', + 'cfg.heartbeat_notify_channel.desc': '发送心跳发现的频道', + 'cfg.heartbeat_notify_user.label': '通知用户', + 'cfg.heartbeat_notify_user.desc': '要通知的用户 ID', + 'cfg.heartbeat_quiet_start.label': '静默时段开始', + 'cfg.heartbeat_quiet_start.desc': '停止心跳的小时(0-23)', + 'cfg.heartbeat_quiet_end.label': '静默时段结束', + 'cfg.heartbeat_quiet_end.desc': '恢复心跳的小时(0-23)', + 'cfg.heartbeat_timezone.label': '时区', + 'cfg.heartbeat_timezone.desc': '静默时段的时区(IANA)', + + // 沙箱设置 + 'cfg.sandbox_enabled.label': '启用', + 'cfg.sandbox_enabled.desc': '启用 Docker 沙箱以运行后台任务', + 'cfg.sandbox_policy.label': '策略', + 'cfg.sandbox_policy.desc': '沙箱安全策略', + 'cfg.sandbox_timeout.label': '超时', + 'cfg.sandbox_timeout.desc': '最大任务持续时间(秒)', + 'cfg.sandbox_memory.label': '内存限制', + 'cfg.sandbox_memory.desc': '容器内存限制(MB)', + 'cfg.sandbox_image.label': 'Docker 镜像', + 'cfg.sandbox_image.desc': '沙箱任务的容器镜像', + + // 定时任务设置 + 'cfg.routines_max_concurrent.label': '最大并发数', + 'cfg.routines_max_concurrent.desc': '同时运行的最大定时任务数', + 'cfg.routines_cooldown.label': '默认冷却时间', + 'cfg.routines_cooldown.desc': '定时任务触发间的最小秒数', + + // 安全设置 + 'cfg.safety_max_output.label': '最大输出长度', + 'cfg.safety_max_output.desc': '每次响应的最大输出令牌数', + 'cfg.safety_injection_check.label': '注入检查', + 'cfg.safety_injection_check.desc': '启用提示注入检测', + + // 技能设置 + 'cfg.skills_max_active.label': '最大活跃技能数', + 'cfg.skills_max_active.desc': '同时活跃的最大技能数', + 'cfg.skills_max_tokens.label': '最大上下文令牌数', + 'cfg.skills_max_tokens.desc': '技能提示的令牌预算', + + // 搜索设置 + 'cfg.search_fusion.label': '融合策略', + 'cfg.search_fusion.desc': '混合搜索排名方法', + + // 网络设置 + 'cfg.tunnel_provider.label': '提供商', + 'cfg.tunnel_provider.desc': '公网 URL 隧道提供商', + 'cfg.tunnel_public_url.label': '公网 URL', + 'cfg.tunnel_public_url.desc': '静态公网 URL(不使用隧道提供商时)', + 'cfg.gateway_rate_limit.label': '速率限制', + 'cfg.gateway_rate_limit.desc': '每分钟最大聊天消息数', + 'cfg.gateway_max_connections.label': '最大连接数', + 'cfg.gateway_max_connections.desc': '最大同时 SSE/WS 连接数', + + // 频道子标签 + 'channels.builtin': '内置频道', + 'channels.messaging': '消息频道', + 'channels.webGateway': 'Web 网关', + 'channels.webGatewayDesc': '基于浏览器的聊天界面', + 'channels.httpWebhook': 'HTTP Webhook', + 'channels.httpWebhookDesc': '用于外部集成的传入 webhook 端点', + 'channels.cli': 'CLI', + 'channels.cliDesc': '使用 Ratatui 的终端 UI', + 'channels.repl': 'REPL', + 'channels.replDesc': '用于测试的简单读取-求值-打印循环', + 'channels.configureVia': '通过 {env} 配置', + 'channels.runWith': '运行命令: {cmd}', }); diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index 4e1074d08e..b342cb535e 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -95,8 +95,7 @@

Restart IronClaw Instance

- - +
@@ -271,81 +270,129 @@

Restart IronClaw Instance

- -
-
-
-

Installed Extensions

-
-
Loading...
-
+ +
+
+
+ + + + + + +
-
-

Available WASM Extensions

-
-
Loading...
+
+
+ + +
-
-
-

Install WASM Extension

-
- - - +
+
+
Loading settings...
+
-
-
-

MCP Servers

-
-
Loading...
+
+
+
Loading settings...
+
-

Add Custom MCP Server

-
- - - +
+
+
Loading channels...
+
-
-
-

Registered Tools

- - - -
NameDescription
- -
-
-
- - -
-
-
-

Search ClawHub

- -
-

Installed Skills

-
-
Loading skills...
+
+
+
+

Installed Extensions

+
+
Loading...
+
+
+
+

Available Extensions

+
+
Loading...
+
+
+
+

Install Extension

+
+ + + +
+
+
-
-
-

Install Skill by URL

-
- - - +
+
+
+

MCP Servers

+
+
Loading...
+
+

Add Custom MCP Server

+
+ + + +
+
+
+
+
+
+
+

Search ClawHub

+ +
+
+
+

Installed Skills

+
+
Loading skills...
+
+
+
+

Install Skill by URL

+
+ + + +
+
+
+ + +
diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index 06d9665a20..626d3539d7 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -18,6 +18,12 @@ --radius-lg: 12px; --shadow: 0 2px 8px rgba(0, 0, 0, 0.4); --font-mono: 'IBM Plex Mono', 'SF Mono', 'Fira Code', Consolas, monospace; + --text-muted: #71717a; + --bg-hover: rgba(255, 255, 255, 0.03); + --danger-soft: rgba(230, 76, 76, 0.15); + --warning-soft: rgba(245, 166, 35, 0.15); + --transition-fast: 150ms ease; + --transition-base: 0.2s ease; } * { @@ -332,10 +338,10 @@ body { .restart-loader-content { position: relative; z-index: 10000; - background-color: #1a1a1a; - border: 1px solid #333; + background-color: var(--bg-secondary); + border: 1px solid var(--border); border-radius: 0.75rem; - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); width: 100%; max-width: 28rem; margin: 0 1rem; @@ -352,7 +358,7 @@ body { } .restart-title { - color: #e0e0e0; + color: var(--text); font-size: 0.85rem; margin-bottom: 1rem; margin-top: 0; @@ -388,10 +394,10 @@ body { .restart-modal-content { position: relative; z-index: 10000; - background-color: #1a1a1a; - border: 1px solid #333; + background-color: var(--bg-secondary); + border: 1px solid var(--border); border-radius: 0.75rem; - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); width: 100%; max-width: 28rem; margin: 0 1rem; @@ -403,11 +409,11 @@ body { align-items: center; justify-content: space-between; padding: 1rem 1.25rem; - border-bottom: 1px solid #2a2a2a; + border-bottom: 1px solid var(--border); } .restart-modal-header h2 { - color: #e0e0e0; + color: var(--text); font-size: 0.95rem; margin: 0; } @@ -426,8 +432,8 @@ body { } .restart-modal-close:hover { - color: #ccc; - background-color: #2a2a2a; + color: var(--text-secondary); + background-color: var(--bg-tertiary); } .restart-modal-body { @@ -435,21 +441,21 @@ body { } .restart-modal-description { - color: #aaa; + color: var(--text-secondary); font-size: 0.85rem; margin: 0; } .restart-modal-warning { margin-top: 1rem; - background-color: #1e1400; - border: 1px solid #3a2a00; + background-color: var(--warning-soft); + border: 1px solid rgba(245, 166, 35, 0.25); border-radius: 0.5rem; padding: 0.75rem 1rem; } .restart-modal-warning p { - color: #facc15; + color: var(--warning); font-size: 0.8rem; margin: 0; } @@ -460,7 +466,7 @@ body { justify-content: flex-end; gap: 0.75rem; padding: 1rem 1.25rem; - border-top: 1px solid #2a2a2a; + border-top: 1px solid var(--border); } .restart-modal-btn { @@ -473,28 +479,28 @@ body { } .restart-modal-btn.cancel { - color: #ccc; + color: var(--text-secondary); background-color: transparent; } .restart-modal-btn.cancel:hover { - background-color: #2a2a2a; + background-color: var(--bg-tertiary); } .restart-modal-btn.confirm { - background-color: #00D894; - color: #111; + background-color: var(--accent); + color: #09090b; } .restart-modal-btn.confirm:hover { - background-color: #00be82; + background-color: var(--accent-hover); } /* Progress Bar for Restart */ .restart-progress-bar { width: 100%; height: 0.375rem; - background-color: #2a2a2a; + background-color: var(--bg-tertiary); border-radius: 9999px; overflow: hidden; } @@ -502,7 +508,7 @@ body { .restart-progress-fill { height: 100%; border-radius: 9999px; - background-color: #00D894; + background-color: var(--accent); width: 40%; animation: indeterminate 1.5s ease-in-out infinite; } @@ -523,14 +529,14 @@ body { } .restart-modal-info { - color: #666; + color: var(--text-secondary); font-size: 0.8rem; margin-top: 1.25rem; margin-bottom: 0; } .restart-modal-info a { - color: #00D894; + color: var(--accent); text-decoration: none; } @@ -2522,17 +2528,21 @@ body { } .extensions-section h3 { - font-size: 15px; + font-size: 11px; font-weight: 600; margin-bottom: 12px; - color: var(--text); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; } .extensions-section h4 { - font-size: 13px; + font-size: 11px; font-weight: 600; margin: 16px 0 8px; - color: var(--text-secondary); + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; } .extensions-list { @@ -2544,12 +2554,29 @@ body { .ext-card { background: var(--bg-secondary); border: 1px solid var(--border); + border-left: 3px solid transparent; border-radius: var(--radius-lg); padding: 14px; display: flex; flex-direction: column; gap: 8px; - transition: border-color 0.2s, transform 0.2s; + transition: border-color var(--transition-base), box-shadow var(--transition-base), transform 0.2s; +} + +.ext-card.state-active { + border-left-color: var(--success); +} + +.ext-card.state-inactive { + border-left-color: var(--text-muted); +} + +.ext-card.state-error { + border-left-color: var(--danger); +} + +.ext-card.state-pairing { + border-left-color: var(--warning); } .ext-card:hover { @@ -2592,6 +2619,11 @@ body { color: var(--warning); } +.ext-kind.kind-builtin { + background: rgba(161, 161, 170, 0.15); + color: var(--text-secondary); +} + .ext-version { font-size: 11px; color: var(--text-muted); @@ -2767,13 +2799,20 @@ body { border-radius: var(--radius); cursor: pointer; font-size: 12px; + font-weight: 500; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text); + transition: all var(--transition-fast); } .btn-ext:hover { background: var(--border); + transform: translateY(-1px); +} + +.btn-ext:active { + transform: scale(0.97); } .btn-ext.activate { @@ -2873,6 +2912,7 @@ body { width: 100%; height: 100%; background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); z-index: 1000; display: flex; align-items: center; @@ -2893,7 +2933,7 @@ body { .configure-modal h3 { margin: 0 0 16px 0; font-size: 16px; - color: var(--text-primary); + color: var(--text); } .configure-hint { @@ -3036,31 +3076,6 @@ body { justify-content: flex-end; } -.tools-table { - width: 100%; - border-collapse: collapse; -} - -.tools-table th, -.tools-table td { - padding: 8px 12px; - text-align: left; - border-bottom: 1px solid var(--border); - font-size: 13px; -} - -.tools-table th { - color: var(--text-secondary); - font-weight: 500; - text-transform: uppercase; - font-size: 11px; - letter-spacing: 0.5px; -} - -.tools-table tr:hover td { - background: rgba(255, 255, 255, 0.03); -} - /* --- Activity tab (unified sandbox job events) --- */ .activity-terminal { @@ -3714,10 +3729,14 @@ mark { gap: 8px; align-items: center; flex-wrap: wrap; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 14px; } .ext-install-form input { - padding: 6px 10px; + padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); @@ -3759,6 +3778,10 @@ mark { gap: 8px; align-items: center; margin-bottom: 12px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 14px; } .skill-search-box input { @@ -3795,10 +3818,10 @@ mark { } .skill-trust { - font-size: 10px; - padding: 2px 6px; - border-radius: 8px; - font-weight: 500; + font-size: 11px; + padding: 3px 8px; + border-radius: 9999px; + font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; } @@ -3942,6 +3965,27 @@ mark { border-bottom: 1px solid var(--border); } + /* Settings layout: horizontal subtabs on mobile */ + .settings-layout { flex-direction: column; } + .settings-sidebar { + width: 100%; + flex-direction: row; + overflow-x: auto; + border-right: none; + border-bottom: 1px solid var(--border); + padding: 0; + } + .settings-subtab { + border-left: none; + border-bottom: 2px solid transparent; + white-space: nowrap; + padding: 8px 16px; + } + .settings-subtab.active { + border-left-color: transparent; + border-bottom-color: var(--accent); + } + /* Extension install form */ .ext-install-form { flex-direction: column; @@ -3968,6 +4012,238 @@ mark { } } +/* --- Settings Tab Layout --- */ +.settings-layout { + flex: 1; + display: flex; + overflow: hidden; +} + +.settings-sidebar { + width: 180px; + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + background: var(--bg-secondary); + padding: 12px 0; + flex-shrink: 0; +} + +.settings-subtab { + display: block; + width: 100%; + padding: 10px 20px; + background: none; + border: none; + border-left: 2px solid transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 14px; + font-weight: 500; + text-align: left; + transition: color 0.2s, background 0.2s, border-color 0.2s; +} + +.settings-subtab:hover { + color: var(--text); + background: var(--bg-tertiary); +} + +.settings-subtab.active { + color: var(--accent); + border-left-color: var(--accent); + background: var(--bg-tertiary); +} + +.settings-content { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.settings-subpanel { + display: none; + flex: 1; + overflow: hidden; + flex-direction: column; + opacity: 0; +} + +.settings-subpanel.active { + display: flex; + animation: settingsFadeIn 0.2s ease forwards; +} + +@keyframes settingsFadeIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Settings form styles (General subtab) */ +.settings-group { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + margin-bottom: 16px; +} + +.settings-group-title { + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + margin: 0 -12px; + border-bottom: 1px solid rgba(255,255,255,0.04); + border-radius: 6px; + gap: 16px; + max-height: 80px; + overflow: hidden; + transition: max-height 0.2s ease, opacity 0.2s ease, margin 0.2s ease, padding 0.2s ease, background var(--transition-fast); + opacity: 1; +} + +.settings-row:hover { + background: var(--bg-hover); +} + +.settings-row.hidden { + max-height: 0; + opacity: 0; + margin: 0; + padding: 0; + border-bottom: none; +} + +.settings-row.search-hidden { + display: none; +} + +.settings-row:last-child { border-bottom: none; } + +.settings-label { + font-size: 13px; + color: var(--text); + font-weight: 500; + flex-shrink: 0; + min-width: 180px; +} + +.settings-input { + padding: 6px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 13px; + font-family: 'IBM Plex Mono', monospace; + width: 240px; + max-width: 100%; +} + +.settings-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15); +} + +.settings-saved-indicator { + font-size: 11px; + color: var(--success); + opacity: 0; + transform: translateY(4px); + transition: opacity 0.3s ease, transform 0.3s ease; +} + +.settings-saved-indicator.visible { + opacity: 1; + transform: translateY(0); +} + +.settings-description { + font-size: 11px; + color: var(--text-secondary); + margin-top: 2px; +} + +.restart-banner { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: var(--warning-soft); + border: 1px solid rgba(245, 166, 35, 0.25); + border-radius: var(--radius); + color: var(--text); + font-size: 12px; + margin: 8px 16px; + animation: settingsFadeIn 0.25s ease forwards; +} + +.restart-banner-text { + flex: 1; +} + +.restart-banner-btn { + padding: 4px 12px; + background: var(--warning); + color: #09090b; + border: none; + border-radius: var(--radius); + cursor: pointer; + font-size: 11px; + font-weight: 600; + white-space: nowrap; + transition: opacity var(--transition-fast); +} + +.restart-banner-btn:hover { + opacity: 0.85; +} + +.settings-label-wrap { + display: flex; + flex-direction: column; + flex-shrink: 0; + min-width: 180px; +} + +.settings-select { + padding: 6px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 13px; + font-family: 'IBM Plex Mono', monospace; + width: 240px; + max-width: 100%; + cursor: pointer; +} + +.settings-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15); +} + +input[type="checkbox"]:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + /* Slash command autocomplete dropdown */ .slash-autocomplete { position: relative; @@ -4156,3 +4432,211 @@ mark { padding: 4px 8px; background: var(--bg-secondary); } + +/* Settings toolbar (search + import/export) */ +.settings-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-bottom: 1px solid var(--border); + background: var(--bg-secondary); + flex-shrink: 0; +} + +.settings-search { + flex: 1; +} + +.settings-search input { + width: 100%; + padding: 6px 10px 6px 32px; + background: var(--bg); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='M21 21l-4.35-4.35'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: 10px center; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 13px; + font-family: 'IBM Plex Mono', monospace; +} + +.settings-search input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15); +} + +.settings-toolbar-btn { + padding: 6px 12px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); + white-space: nowrap; +} + +.settings-toolbar-btn:hover { + background: var(--bg-secondary); + color: var(--text); + border-color: rgba(255, 255, 255, 0.15); + transform: translateY(-1px); +} + +.settings-toolbar-btn:active { + transform: scale(0.98); +} + +/* Confirmation modal */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: modalFadeIn 0.15s ease; +} + +@keyframes modalFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes modalSlideIn { + from { opacity: 0; transform: translateY(10px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.modal { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 0; + max-width: 420px; + width: 90%; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); + animation: modalSlideIn 0.2s ease; +} + +.modal h3 { + margin: 0; + padding: 16px 20px; + font-size: 16px; + color: var(--text); + border-bottom: 1px solid var(--border); +} + +.modal p { + margin: 0; + padding: 16px 20px; + font-size: 13px; + color: var(--text-secondary); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 20px; + border-top: 1px solid var(--border); +} + +.btn-secondary { + padding: 8px 16px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + cursor: pointer; + font-size: 13px; +} + +.btn-secondary:hover { + background: var(--bg); +} + +.btn-danger { + padding: 8px 16px; + background: var(--danger); + border: 1px solid var(--danger); + border-radius: var(--radius); + color: white; + cursor: pointer; + font-size: 13px; +} + +.btn-danger:hover { + opacity: 0.9; +} + +/* Mobile settings responsiveness */ +@media (max-width: 768px) { + .settings-row { + flex-direction: column; + align-items: stretch; + max-height: 140px; + } + .settings-label-wrap { + min-width: unset; + } + .settings-input, .settings-select { + width: 100%; + } + .settings-toolbar { + flex-wrap: wrap; + } + .settings-search { + min-width: 150px; + } +} + +/* Loading skeletons */ +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +.skeleton-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + gap: 16px; +} + +.skeleton-bar { + height: 12px; + border-radius: 6px; + background: linear-gradient(90deg, var(--bg-tertiary) 25%, rgba(255,255,255,0.06) 50%, var(--bg-tertiary) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; +} + +.skeleton-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +/* Settings search empty state */ +.settings-search-empty { + padding: 32px 16px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} diff --git a/src/channels/web/test_helpers.rs b/src/channels/web/test_helpers.rs index 981eacdd6d..76b2a76043 100644 --- a/src/channels/web/test_helpers.rs +++ b/src/channels/web/test_helpers.rs @@ -87,6 +87,7 @@ impl TestGatewayBuilder { cost_guard: None, routine_engine: Arc::new(tokio::sync::RwLock::new(None)), startup_time: std::time::Instant::now(), + active_config: crate::channels::web::server::ActiveConfigSnapshot::default(), }) } diff --git a/src/channels/web/ws.rs b/src/channels/web/ws.rs index 7bf50e52a9..8efc69f603 100644 --- a/src/channels/web/ws.rs +++ b/src/channels/web/ws.rs @@ -521,6 +521,7 @@ mod tests { cost_guard: None, routine_engine: Arc::new(tokio::sync::RwLock::new(None)), startup_time: std::time::Instant::now(), + active_config: crate::channels::web::server::ActiveConfigSnapshot::default(), } } } diff --git a/src/main.rs b/src/main.rs index 745cae09b4..65c04dda8d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -323,6 +323,17 @@ async fn async_main() -> anyhow::Result<()> { })); // Load WASM channels and register their webhook routes. + // Ensure the channels directory exists so the WASM runtime initializes even when + // no channels are installed yet — hot-activation needs the runtime to be available. + if config.channels.wasm_channels_enabled + && let Err(e) = std::fs::create_dir_all(&config.channels.wasm_channels_dir) + { + tracing::warn!( + path = %config.channels.wasm_channels_dir.display(), + error = %e, + "Failed to create WASM channels directory" + ); + } if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() { let wasm_result = ironclaw::channels::wasm::setup_wasm_channels( &config, @@ -511,6 +522,16 @@ async fn async_main() -> anyhow::Result<()> { gw = gw.with_skill_catalog(Arc::clone(sc)); } gw = gw.with_cost_guard(Arc::clone(&components.cost_guard)); + { + let active_model = components.llm.model_name().to_string(); + let mut enabled = channel_names.clone(); + enabled.push("gateway".into()); + gw = gw.with_active_config(ironclaw::channels::web::server::ActiveConfigSnapshot { + llm_backend: config.llm.backend.to_string(), + llm_model: active_model, + enabled_channels: enabled, + }); + } if config.sandbox.enabled { gw = gw.with_prompt_queue(Arc::clone(&prompt_queue)); diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index a0c498e575..4cb7afebc9 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -45,12 +45,13 @@ "approval_always_btn": ".approval-actions button.always", "approval_deny_btn": ".approval-actions button.deny", "approval_resolved": ".approval-resolved", - # Extensions tab – sections + # Settings subtabs + "settings_subtab": '.settings-subtab[data-settings-subtab="{subtab}"]', + "settings_subpanel": "#settings-{subtab}", + # Extensions section "extensions_list": "#extensions-list", "available_wasm_list": "#available-wasm-list", "mcp_servers_list": "#mcp-servers-list", - "tools_tbody": "#tools-tbody", - "tools_empty": "#tools-empty", # Extensions tab – cards "ext_card_installed": "#extensions-list .ext-card", "ext_card_available": "#available-wasm-list .ext-card.ext-available", @@ -92,6 +93,12 @@ "ext_stepper": ".ext-stepper", "stepper_step": ".stepper-step", "stepper_circle": ".stepper-circle", + # Confirm modal (custom, replaces window.confirm) + "confirm_modal": "#confirm-modal", + "confirm_modal_btn": "#confirm-modal-btn", + "confirm_modal_cancel": "#confirm-modal-cancel-btn", + # Channels subtab – cards + "channels_ext_card": "#settings-channels-content .ext-card", # Toast notifications "toast": ".toast", "toast_success": ".toast.toast-success", @@ -106,7 +113,7 @@ "routines_empty": "#routines-empty", } -TABS = ["chat", "memory", "jobs", "routines", "extensions", "skills"] +TABS = ["chat", "memory", "jobs", "routines", "settings"] # Auth token used across all tests AUTH_TOKEN = "e2e-test-token" diff --git a/tests/e2e/scenarios/test_extensions.py b/tests/e2e/scenarios/test_extensions.py index a728a9944f..03ae98077a 100644 --- a/tests/e2e/scenarios/test_extensions.py +++ b/tests/e2e/scenarios/test_extensions.py @@ -87,23 +87,21 @@ "installed": False, } -_SAMPLE_TOOL = {"name": "echo", "description": "Echo a message"} -_SAMPLE_TOOL_2 = {"name": "time", "description": "Get current time"} - # ─── Navigation helpers ──────────────────────────────────────────────────────── async def go_to_extensions(page): - """Click the Extensions tab and wait for the panel to appear. + """Navigate to Settings > Extensions subtab and wait for content. Waits for loadExtensions() to finish rendering by polling for the first content signal (empty-state div or an installed card) rather than sleeping. """ - await page.locator(SEL["tab_button"].format(tab="extensions")).click() - await page.locator(SEL["tab_panel"].format(tab="extensions")).wait_for( + await page.locator(SEL["tab_button"].format(tab="settings")).click() + await page.locator(SEL["settings_subtab"].format(subtab="extensions")).click() + await page.locator(SEL["settings_subpanel"].format(subtab="extensions")).wait_for( state="visible", timeout=5000 ) - # loadExtensions() fires three parallel fetches then renders. Wait for the + # loadExtensions() fires parallel fetches then renders. Wait for the # first concrete DOM signal instead of a hard sleep so the test is # deterministic even under CI load. await page.locator( @@ -111,19 +109,39 @@ async def go_to_extensions(page): ).first.wait_for(state="visible", timeout=8000) -async def mock_ext_apis(page, *, installed=None, tools=None, registry=None): - """Intercept the three extension list APIs with fixture data. +async def go_to_channels(page): + """Navigate to Settings > Channels subtab and wait for content.""" + await page.locator(SEL["tab_button"].format(tab="settings")).click() + await page.locator(SEL["settings_subtab"].format(subtab="channels")).click() + await page.locator(SEL["settings_subpanel"].format(subtab="channels")).wait_for( + state="visible", timeout=5000 + ) + + +async def go_to_mcp(page): + """Navigate to Settings > MCP subtab and wait for content.""" + await page.locator(SEL["tab_button"].format(tab="settings")).click() + await page.locator(SEL["settings_subtab"].format(subtab="mcp")).click() + await page.locator(SEL["settings_subpanel"].format(subtab="mcp")).wait_for( + state="visible", timeout=5000 + ) + await page.locator( + f"{SEL['mcp_servers_list']} .empty-state, {SEL['ext_card_mcp']}" + ).first.wait_for(state="visible", timeout=8000) + + +async def mock_ext_apis(page, *, installed=None, registry=None): + """Intercept the extension list APIs with fixture data. - Must be called BEFORE navigating to the extensions tab. + Must be called BEFORE navigating to the extensions subtab. """ ext_body = json.dumps({"extensions": installed or []}) - tools_body = json.dumps({"tools": tools or []}) registry_body = json.dumps({"entries": registry or []}) # Playwright evaluates route handlers in LIFO order (last-registered fires # first). Register the broad handler first so it is checked last; the - # specific /tools and /registry handlers are registered after and therefore - # checked first — no continue_() fallthrough needed. + # specific /registry handler is registered after and therefore checked + # first — no continue_() fallthrough needed. async def handle_ext_list(route): path = route.request.url.split("?")[0] if path.endswith("/api/extensions"): @@ -133,13 +151,9 @@ async def handle_ext_list(route): await page.route("**/api/extensions*", handle_ext_list) - async def handle_tools(route): - await route.fulfill(status=200, content_type="application/json", body=tools_body) - async def handle_registry(route): await route.fulfill(status=200, content_type="application/json", body=registry_body) - await page.route("**/api/extensions/tools", handle_tools) await page.route("**/api/extensions/registry", handle_registry) @@ -151,46 +165,17 @@ async def wait_for_toast(page, text: str, *, timeout: int = 5000): # ─── Group A: Structural / empty state ──────────────────────────────────────── async def test_extensions_empty_tab_layout(page): - """Extensions tab with no data shows all three sections with correct empty-state messages.""" - await mock_ext_apis(page, tools=[]) + """Extensions subtab with no data shows sections with correct empty-state messages.""" + await mock_ext_apis(page) await go_to_extensions(page) - panel = page.locator(SEL["tab_panel"].format(tab="extensions")) + panel = page.locator(SEL["settings_subpanel"].format(subtab="extensions")) assert await panel.is_visible() ext_list = page.locator(SEL["extensions_list"]) assert await ext_list.is_visible() assert "No extensions installed" in await ext_list.text_content() - wasm_list = page.locator(SEL["available_wasm_list"]) - assert await wasm_list.is_visible() - assert "No additional WASM extensions available" in await wasm_list.text_content() - - mcp_list = page.locator(SEL["mcp_servers_list"]) - assert await mcp_list.is_visible() - assert "No MCP servers available" in await mcp_list.text_content() - - # Tools table should be empty - tbody = page.locator(SEL["tools_tbody"]) - rows = await tbody.locator("tr").count() - empty_visible = await page.locator(SEL["tools_empty"]).is_visible() - assert empty_visible or rows == 0, "Expected tools table to be empty" - - -async def test_extensions_tools_table_populated(page): - """Two mock tools produce two rows in the tools table.""" - await mock_ext_apis(page, tools=[_SAMPLE_TOOL, _SAMPLE_TOOL_2]) - await go_to_extensions(page) - - tbody = page.locator(SEL["tools_tbody"]) - rows = tbody.locator("tr") - await rows.first.wait_for(state="visible", timeout=5000) - assert await rows.count() == 2 - - text = await tbody.text_content() - assert "echo" in text - assert "time" in text - # ─── Group B: Installed WASM tool cards ─────────────────────────────────────── @@ -248,9 +233,9 @@ async def test_installed_wasm_tool_authed_shows_reconfigure_btn(page): async def test_installed_mcp_server_active(page): """Active MCP server shows 'Active' label and no Activate button.""" await mock_ext_apis(page, installed=[_MCP_ACTIVE]) - await go_to_extensions(page) + await go_to_mcp(page) - card = page.locator(SEL["ext_card_installed"]).first + card = page.locator(SEL["ext_card_mcp"]).first await card.wait_for(state="visible", timeout=5000) assert await card.locator(SEL["ext_active_label"]).count() == 1 assert await card.locator(SEL["ext_activate_btn"]).count() == 0 @@ -260,9 +245,9 @@ async def test_installed_mcp_server_active(page): async def test_installed_mcp_server_inactive_shows_activate(page): """Inactive MCP server shows Activate button.""" await mock_ext_apis(page, installed=[_MCP_INACTIVE]) - await go_to_extensions(page) + await go_to_mcp(page) - card = page.locator(SEL["ext_card_installed"]).first + card = page.locator(SEL["ext_card_mcp"]).first await card.wait_for(state="visible", timeout=5000) assert await card.locator(SEL["ext_activate_btn"]).count() == 1 @@ -270,7 +255,7 @@ async def test_installed_mcp_server_inactive_shows_activate(page): async def test_mcp_server_in_registry_not_installed(page): """Registry MCP entry (not installed) appears in the MCP section with Install button.""" await mock_ext_apis(page, registry=[_REGISTRY_MCP]) - await go_to_extensions(page) + await go_to_mcp(page) mcp_list = page.locator(SEL["mcp_servers_list"]) card = mcp_list.locator(".ext-card").first @@ -285,7 +270,7 @@ async def test_mcp_server_installed_auth_dot(page): installed_mcp = {**_MCP_ACTIVE, "name": "registry-mcp", "authenticated": False} registry_mcp = {**_REGISTRY_MCP, "name": "registry-mcp"} await mock_ext_apis(page, installed=[installed_mcp], registry=[registry_mcp]) - await go_to_extensions(page) + await go_to_mcp(page) mcp_list = page.locator(SEL["mcp_servers_list"]) card = mcp_list.locator(".ext-card").first @@ -299,8 +284,9 @@ async def test_mcp_server_installed_auth_dot(page): async def _load_wasm_channel(page, activation_status, activation_error=None): ext = {**_WASM_CHANNEL, "activation_status": activation_status, "activation_error": activation_error} await mock_ext_apis(page, installed=[ext]) - await go_to_extensions(page) - card = page.locator(SEL["ext_card_installed"]).first + await go_to_channels(page) + # Find the WASM channel card specifically (not built-in channel cards) + card = page.locator(SEL["channels_ext_card"], has_text="Test Channel").first await card.wait_for(state="visible", timeout=5000) return card @@ -446,9 +432,9 @@ async def handle_channel_install(route): await page.route("**/api/extensions/test-channel/setup", handle_channel_setup) await page.route("**/api/extensions/install", handle_channel_install) - await go_to_extensions(page) + await go_to_channels(page) - install_btn = page.locator(SEL["available_wasm_list"]).locator(SEL["ext_install_btn"]).first + install_btn = page.locator(SEL["channels_ext_card"]).locator(SEL["ext_install_btn"]).first await install_btn.wait_for(state="visible", timeout=5000) await install_btn.click() @@ -523,13 +509,14 @@ async def handle_ext_empty(route): # Override for subsequent calls await page.route("**/api/extensions*", handle_ext_empty) - # Auto-accept confirm dialog - await page.evaluate("window.confirm = () => true") - card = page.locator(SEL["ext_card_installed"]).first await card.wait_for(state="visible", timeout=5000) await card.locator(SEL["ext_remove_btn"]).click() + # Confirm via custom modal + await page.locator(SEL["confirm_modal"]).wait_for(state="visible", timeout=5000) + await page.locator(SEL["confirm_modal_btn"]).click() + # Card should disappear await page.wait_for_function( "() => document.querySelectorAll('#extensions-list .ext-card').length === 0", @@ -543,13 +530,14 @@ async def test_remove_cancelled_keeps_card(page): await mock_ext_apis(page, installed=[_WASM_TOOL]) await go_to_extensions(page) - # Reject the confirm dialog - await page.evaluate("window.confirm = () => false") - card = page.locator(SEL["ext_card_installed"]).first await card.wait_for(state="visible", timeout=5000) await card.locator(SEL["ext_remove_btn"]).click() + # Cancel via custom modal + await page.locator(SEL["confirm_modal"]).wait_for(state="visible", timeout=5000) + await page.locator(SEL["confirm_modal_cancel"]).click() + assert await page.locator(SEL["ext_card_installed"]).count() >= 1, "Card should remain after cancel" @@ -973,14 +961,10 @@ async def counting_handler(route): else: await route.continue_() - async def handle_tools(route): - await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}') - async def handle_registry(route): await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}') await page.route("**/api/extensions*", counting_handler) - await page.route("**/api/extensions/tools", handle_tools) await page.route("**/api/extensions/registry", handle_registry) await go_to_extensions(page) @@ -989,6 +973,9 @@ async def handle_registry(route): await _show_auth_card(page, extension_name="gmail", auth_url="https://example.com/oauth") assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 1 + # Inject a counter to confirm refreshCurrentSettingsTab is called + await page.evaluate("window.__refreshCount = 0; var _origRefresh = refreshCurrentSettingsTab; refreshCurrentSettingsTab = function() { window.__refreshCount++; _origRefresh(); };") + await page.evaluate(""" handleAuthCompleted({ extension_name: 'gmail', @@ -999,14 +986,11 @@ async def handle_registry(route): await wait_for_toast(page, "OAuth flow expired. Please try again.") assert await page.locator(SEL["auth_card"] + '[data-extension-name="gmail"]').count() == 0 - assert ( - await page.locator( - SEL["toast_error"], has_text="OAuth flow expired. Please try again." - ).count() - >= 1 - ) - await page.wait_for_timeout(600) + # Wait for the refresh to complete + await page.wait_for_function("() => window.__refreshCount > 0", timeout=5000) + # Give the async fetch time to complete + await page.wait_for_timeout(1000) assert len(reload_count) > count_before, "Extensions list did not reload after auth failure" @@ -1026,9 +1010,9 @@ async def handle_activate(route): await mock_ext_apis(page, installed=[_MCP_INACTIVE]) await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate) - await go_to_extensions(page) + await go_to_mcp(page) - activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"]) + activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"]) await activate_btn.wait_for(state="visible", timeout=5000) async with page.expect_response("**/api/extensions/test-mcp-inactive/activate", timeout=5000): @@ -1051,9 +1035,9 @@ async def handle_setup(route): await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate) await page.route("**/api/extensions/test-mcp-inactive/setup", handle_setup) - await go_to_extensions(page) + await go_to_mcp(page) - activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"]) + activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"]) await activate_btn.wait_for(state="visible", timeout=5000) await activate_btn.click() @@ -1070,9 +1054,9 @@ async def handle_activate(route): await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": False, "message": "Config missing"})) await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate) - await go_to_extensions(page) + await go_to_mcp(page) - activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"]) + activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"]) await activate_btn.wait_for(state="visible", timeout=5000) await activate_btn.click() @@ -1088,9 +1072,9 @@ async def handle_activate(route): await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": True, "auth_url": "https://example.com/oauth"})) await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate) - await go_to_extensions(page) + await go_to_mcp(page) - activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"]) + activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"]) await activate_btn.wait_for(state="visible", timeout=5000) await activate_btn.click() @@ -1106,7 +1090,7 @@ async def handle_activate(route): # ─── Group J: Tab reload behaviour ──────────────────────────────────────────── async def test_extensions_tab_reloads_on_revisit(page): - """loadExtensions() is called again when re-navigating to the extensions tab.""" + """loadExtensions() is called again when re-navigating to the extensions subtab.""" call_count = [] async def counting_handler(route): @@ -1121,14 +1105,10 @@ async def counting_handler(route): else: await route.continue_() - async def handle_tools(route): - await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}') - async def handle_registry(route): await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}') await page.route("**/api/extensions*", counting_handler) - await page.route("**/api/extensions/tools", handle_tools) await page.route("**/api/extensions/registry", handle_registry) # First visit @@ -1148,48 +1128,6 @@ async def handle_registry(route): assert count_after_second > count_after_first, "loadExtensions not called on return visit" -async def test_auth_completed_sse_triggers_extensions_reload(page): - """auth_completed SSE event while on the extensions tab triggers a reload.""" - reload_count = [] - - async def counting_handler(route): - path = route.request.url.split("?")[0] - if path.endswith("/api/extensions"): - reload_count.append(1) - await route.fulfill( - status=200, - content_type="application/json", - body=json.dumps({"extensions": []}), - ) - else: - await route.continue_() - - async def handle_tools(route): - await route.fulfill(status=200, content_type="application/json", body='{"tools":[]}') - - async def handle_registry(route): - await route.fulfill(status=200, content_type="application/json", body='{"entries":[]}') - - await page.route("**/api/extensions*", counting_handler) - await page.route("**/api/extensions/tools", handle_tools) - await page.route("**/api/extensions/registry", handle_registry) - - await go_to_extensions(page) - count_before = len(reload_count) - - # Simulate auth_completed via the shared handler. - await page.evaluate(""" - handleAuthCompleted({ - extension_name: 'reload-ext', - success: true, - message: 'Reloaded.', - }); - """) - - await page.wait_for_timeout(600) - assert len(reload_count) > count_before, "loadExtensions was not called after auth_completed" - - # ─── Regression tests ───────────────────────────────────────────────────────── # Each test below is a regression for a specific bug found after the initial # test suite was written. The bug description is in the docstring. @@ -1267,9 +1205,9 @@ async def handle_activate(route): ) await page.route("**/api/extensions/test-mcp-inactive/activate", handle_activate) - await go_to_extensions(page) + await go_to_mcp(page) - activate_btn = page.locator(SEL["ext_card_installed"]).first.locator(SEL["ext_activate_btn"]) + activate_btn = page.locator(SEL["ext_card_mcp"]).first.locator(SEL["ext_activate_btn"]) await activate_btn.wait_for(state="visible", timeout=5000) await activate_btn.click() diff --git a/tests/e2e/scenarios/test_skills.py b/tests/e2e/scenarios/test_skills.py index 4d92331b6b..50f5b6be81 100644 --- a/tests/e2e/scenarios/test_skills.py +++ b/tests/e2e/scenarios/test_skills.py @@ -4,11 +4,18 @@ from helpers import SEL +async def go_to_skills(page): + """Navigate to Settings > Skills subtab.""" + await page.locator(SEL["tab_button"].format(tab="settings")).click() + await page.locator(SEL["settings_subtab"].format(subtab="skills")).click() + await page.locator(SEL["settings_subpanel"].format(subtab="skills")).wait_for( + state="visible", timeout=5000 + ) + + async def test_skills_tab_visible(page): - """Skills tab shows the search interface.""" - await page.locator(SEL["tab_button"].format(tab="skills")).click() - panel = page.locator(SEL["tab_panel"].format(tab="skills")) - await panel.wait_for(state="visible", timeout=5000) + """Skills subtab shows the search interface.""" + await go_to_skills(page) search_input = page.locator(SEL["skill_search_input"]) assert await search_input.is_visible(), "Skills search input not visible" @@ -16,7 +23,7 @@ async def test_skills_tab_visible(page): async def test_skills_search(page): """Search ClawHub for skills and verify results appear.""" - await page.locator(SEL["tab_button"].format(tab="skills")).click() + await go_to_skills(page) search_input = page.locator(SEL["skill_search_input"]) await search_input.fill("markdown") @@ -35,7 +42,7 @@ async def test_skills_search(page): async def test_skills_install_and_remove(page): """Install a skill from search results, then remove it.""" - await page.locator(SEL["tab_button"].format(tab="skills")).click() + await go_to_skills(page) # Search search_input = page.locator(SEL["skill_search_input"]) @@ -68,10 +75,14 @@ async def test_skills_install_and_remove(page): installed_count = await installed.count() assert installed_count >= 1, "Skill should appear in installed list after install" - # Remove the skill (confirm is already overridden) + # Remove the skill via confirm modal remove_btn = installed.first.locator("button", has_text="Remove") if await remove_btn.count() > 0: await remove_btn.click() + # Confirm in the modal + confirm_btn = page.locator(SEL["confirm_modal_btn"]) + await confirm_btn.wait_for(state="visible", timeout=5000) + await confirm_btn.click() # Wait for the card to disappear or list to shrink await page.wait_for_timeout(3000) new_count = await page.locator(SEL["skill_installed"]).count() diff --git a/tests/e2e/scenarios/test_wasm_lifecycle.py b/tests/e2e/scenarios/test_wasm_lifecycle.py index 961e7ad0c6..212cc3ce05 100644 --- a/tests/e2e/scenarios/test_wasm_lifecycle.py +++ b/tests/e2e/scenarios/test_wasm_lifecycle.py @@ -507,10 +507,10 @@ async def test_configure_noninstalled(ironclaw_server): async def test_extensions_tab_shows_registry(page): - """Extensions tab loads and shows available extensions from registry.""" - tab_btn = page.locator(SEL["tab_button"].format(tab="extensions")) - await tab_btn.click() - panel = page.locator(SEL["tab_panel"].format(tab="extensions")) + """Extensions subtab loads and shows available extensions from registry.""" + await page.locator(SEL["tab_button"].format(tab="settings")).click() + await page.locator(SEL["settings_subtab"].format(subtab="extensions")).click() + panel = page.locator(SEL["settings_subpanel"].format(subtab="extensions")) await panel.wait_for(state="visible", timeout=5000) available_section = page.locator(SEL["available_wasm_list"]) diff --git a/tests/openai_compat_integration.rs b/tests/openai_compat_integration.rs index 939f39eb53..a1bc6a6452 100644 --- a/tests/openai_compat_integration.rs +++ b/tests/openai_compat_integration.rs @@ -214,6 +214,7 @@ async fn start_test_server_with_provider( cost_guard: None, routine_engine: Arc::new(tokio::sync::RwLock::new(None)), startup_time: std::time::Instant::now(), + active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(), }); let addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); @@ -705,6 +706,7 @@ async fn test_no_llm_provider_returns_503() { cost_guard: None, routine_engine: Arc::new(tokio::sync::RwLock::new(None)), startup_time: std::time::Instant::now(), + active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(), }); let addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); diff --git a/tests/support/gateway_workflow_harness.rs b/tests/support/gateway_workflow_harness.rs index a4d737b52a..13a8a54cfe 100644 --- a/tests/support/gateway_workflow_harness.rs +++ b/tests/support/gateway_workflow_harness.rs @@ -234,6 +234,7 @@ impl GatewayWorkflowHarness { cost_guard: Some(Arc::clone(&components.cost_guard)), routine_engine: Arc::clone(&routine_slot), startup_time: Instant::now(), + active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(), }); let mut agent = Agent::new( diff --git a/tests/ws_gateway_integration.rs b/tests/ws_gateway_integration.rs index 51e39d8d9d..6702d4ffde 100644 --- a/tests/ws_gateway_integration.rs +++ b/tests/ws_gateway_integration.rs @@ -62,6 +62,7 @@ async fn start_test_server() -> ( cost_guard: None, routine_engine: Arc::new(tokio::sync::RwLock::new(None)), startup_time: std::time::Instant::now(), + active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(), }); let addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); From b7a1edf346e352590fa1c07d1807ac7c98c53a8c Mon Sep 17 00:00:00 2001 From: Henry Park Date: Wed, 18 Mar 2026 17:02:09 -0700 Subject: [PATCH 6/6] fix: remove debug_assert guards that panic on valid error paths (#1385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: remove debug_assert guards that panic on valid error paths (#1312) Two debug_assert! calls added in #1312 fire on expected runtime error paths (not programmer bugs), turning graceful error returns into panics in debug/test builds: - state.rs: Completed→Cancelled is a user-facing error handled by transition_to() returning Err — not a bug - execute.rs: empty tool_name from malformed LLM output is handled by ToolError::NotFound — not a bug Removes both asserts; keeps the circuit-breaker assert (genuinely guards a caller invariant). Co-Authored-By: Claude Opus 4.6 (1M context) * fix: tighten empty tool name test to assert ToolError::NotFound variant Address review feedback: assert the specific error variant instead of just is_err() so the regression test actually enforces the expected error path. Co-Authored-By: Claude Opus 4.6 (1M context) * style: cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/context/state.rs | 7 ------- src/tools/execute.rs | 18 +++++++++++------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/context/state.rs b/src/context/state.rs index bae5bdf1bf..f5307947c3 100644 --- a/src/context/state.rs +++ b/src/context/state.rs @@ -258,13 +258,6 @@ impl JobContext { new_state: JobState, reason: Option, ) -> Result<(), String> { - debug_assert!( - self.state.can_transition_to(new_state), - "BUG: invalid job state transition {} -> {} for job {}", - self.state, - new_state, - self.job_id - ); if !self.state.can_transition_to(new_state) { return Err(format!( "Cannot transition from {} to {}", diff --git a/src/tools/execute.rs b/src/tools/execute.rs index fa52c59c35..bb8a7b9d71 100644 --- a/src/tools/execute.rs +++ b/src/tools/execute.rs @@ -22,10 +22,6 @@ pub async fn execute_tool_with_safety( params: &serde_json::Value, job_ctx: &JobContext, ) -> Result { - debug_assert!( - !tool_name.is_empty(), - "BUG: execute_tool_with_safety called with empty tool_name" - ); let tool = tools .get(tool_name) .await @@ -297,8 +293,8 @@ mod tests { #[tokio::test] async fn test_execute_empty_tool_name_returns_not_found() { - // Regression: execute_tool_with_safety must reject empty tool names before - // even attempting a registry lookup (the debug_assert guards this invariant). + // Regression: execute_tool_with_safety must reject empty tool names + // gracefully via ToolError::NotFound (not a panic). let registry = registry_with(vec![]).await; let safety = test_safety(); @@ -311,7 +307,15 @@ mod tests { ) .await; - assert!(result.is_err(), "Empty tool name should return an error"); // safety: test-only assertion + assert!( + matches!( + result, + Err(crate::error::Error::Tool( + crate::error::ToolError::NotFound { .. } + )) + ), + "Empty tool name should return ToolError::NotFound, got: {result:?}" + ); } #[tokio::test]