mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 23:10:11 +00:00
* feat: complete multi-tenant isolation — per-user budgets, model selection, heartbeat cycling Finishes the remaining isolation work from phases 2–4 of #59: Phase 2 (DB scoping): Fix /status and /list commands to use _for_user DB variants instead of global queries that leaked cross-user job data. Phase 3 (Runtime isolation): Per-user workspace in routine engine's spawn_fire so lightweight routines run in the correct user context. Per-user daily cost tracking in CostGuard with configurable budget via MAX_COST_PER_USER_PER_DAY_CENTS. Multi-user heartbeat that cycles through all users with routines, auto-detected from GATEWAY_USER_TOKENS. Phase 4 (Provider/tools): Per-user model selection via preferred_model setting — looked up from SettingsStore on first iteration, threaded through ReasoningContext.model_override to CompletionRequest. Works with providers that support per-request model overrides (NearAI). Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: use selected_model setting key to match /model command persistence The dispatcher was reading "preferred_model" but the /model command (merged from staging) persists to "selected_model". Since set_setting is already per-user scoped, using the same key makes /model work as the per-user model override in multi-tenant mode. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: heartbeat hygiene, /model multi-tenant guard, RigAdapter model override Three follow-up fixes for multi-tenant isolation: 1. Multi-user heartbeat now runs memory hygiene per user before each heartbeat check, matching single-user heartbeat behavior. 2. /model command in multi-tenant mode only persists to per-user settings (selected_model) without calling set_model() on the shared LlmProvider. The per-request model_override in the dispatcher reads from the same setting. Added multi_tenant flag to AgentConfig (auto-detected from GATEWAY_USER_TOKENS). 3. RigAdapter now supports per-request model overrides by injecting the model name into rig-core's additional_params. OpenAI/Anthropic/Ollama API servers use last-key-wins for duplicate JSON keys, so the override takes effect via serde's flatten serialization order. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR review — cost model attribution, heartbeat concurrency, pruning Fixes from review comments on #1614: - Cost tracking now uses the override model name (not active_model_name) when a per-user model override is active, for accurate attribution. - Multi-user heartbeat runs per-user checks concurrently via JoinSet instead of sequentially, preventing one slow user from blocking others. - Per-user failure counts tracked independently; users exceeding max_failures are skipped (matching single-user semantics). - per_user_daily_cost HashMap pruned on day rollover to prevent unbounded growth in long-lived deployments. - Doc comment fixed: says "routines" not "active routines". Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: /status ownership, model persistence scoping, heartbeat robustness Addresses second round of PR review on #1614: - /status <job_id> DB path now validates job.user_id == requesting user before returning data (was missing ownership check, security fix). - persist_selected_model takes user_id param instead of owner_id, and skips .env/TOML writes in multi-tenant mode (these are shared global files). handle_system_command now receives user_id from caller. - JoinSet collection handles Err(JoinError) explicitly instead of silently dropping panicked tasks. - Notification forwarder extracts owner_id from response metadata in multi-tenant mode for per-user routing instead of broadcasting to the agent owner. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: cost pricing, fire_manual workspace, heartbeat concurrency cap Round 3 review fixes: - Cost tracking passes None for cost_per_token when model override is active, letting CostGuard look up pricing by model name instead of using the default provider's rates (serrrfirat). - fire_manual() now uses per-user workspace, matching spawn_fire() pattern (serrrfirat). - Removed MULTI_TENANT env var — multi-tenant mode is auto-detected solely from GATEWAY_USER_TOKENS presence (serrrfirat + Copilot). - Multi-user heartbeat capped at 8 concurrent tasks to avoid flooding the LLM provider (serrrfirat + Copilot). - Fixed inject_model_override doc comment accuracy (Copilot). - Added comment explaining multi-tenant notification routing priority (Copilot). Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: user-scoped webhook endpoint for multi-tenant isolation Adds POST /api/webhooks/u/{user_id}/{path} — a user-scoped webhook endpoint that filters the routine lookup by user_id, preventing cross-user webhook triggering when paths collide. The existing /api/webhooks/{path} endpoint remains unchanged for backward compatibility in single-user deployments. Changes: - get_webhook_routine_by_path gains user_id: Option<&str> param - Both postgres and libsql implementations add AND user_id = ? filter when user_id is provided - New webhook_trigger_user_scoped_handler extracts (user_id, path) from URL and passes to shared fire_webhook_inner logic - Route registered on public router (webhooks are called by external services that can't send bearer tokens) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(db): add UserStore trait with users, api_tokens, invitations tables Foundation for DB-backed user management (#1605): - UserRecord, ApiTokenRecord, InvitationRecord types in db/mod.rs - UserStore sub-trait (17 methods) added to Database supertrait - PostgreSQL migration V14__users.sql (users, api_tokens, invitations) - libSQL schema + incremental migration V14 - Full implementations for both PgBackend (via Store delegation) and LibSqlBackend (direct SQL in libsql/users.rs) - authenticate_token JOINs api_tokens+users with active/non-revoked checks; has_any_users for bootstrap detection Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(web): DB-backed auth, user/token/invitation API handlers Adds the web gateway layer for DB-backed user management (#1605): Auth refactor: - CombinedAuthState wraps env-var tokens (MultiAuthState) + optional DbAuthenticator for DB-backed token lookup with LRU cache (60s TTL, 1024 max entries) - auth_middleware tries env-var tokens first, then DB fallback - From<MultiAuthState> impl for backward compatibility - main.rs wires with_db_auth when database is available API handlers (12 new endpoints): - /api/admin/users — CRUD: create, list, detail, update, suspend, activate - /api/tokens — create (returns plaintext once), list, revoke - /api/invitations — create, list, accept (creates user + first token) Token creation: 32 random bytes → hex plaintext, SHA-256 hash stored. Invitation accept: validates hash + pending + not expired, creates user record and first API token atomically. All test files updated for CombinedAuthState type change. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: startup env-var user migration + UserStore integration tests Completes the DB-backed user management feature (#1605): - Startup migration: when GATEWAY_USER_TOKENS is set and the users table is empty, inserts env-var users + hashed tokens into DB. Logs deprecation notice when DB already has users. - hash_token made pub for reuse in migration code. - 10 integration tests for UserStore (libsql file-backed): - has_any_users bootstrap detection - create/get/get_by_email/list/update user lifecycle - token create → authenticate → revoke → reject cycle - suspended user tokens rejected - wrong-user token revoke returns false - invitation create → accept → user created - record_login and record_token_usage timestamps - libSQL migration: removed FK constraints from V14 (incompatible with execute_batch inside transactions). Tables in both base SCHEMA and incremental migration for fresh and existing databases. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor: remove GATEWAY_USER_TOKENS, fix review feedback GATEWAY_USER_TOKENS never went to production — replaced entirely by DB-backed user management via /api/admin/users and /api/tokens. Removed: - UserTokenConfig struct and GATEWAY_USER_TOKENS env var parsing - user_tokens field from GatewayConfig - GatewayChannel::new_multi_auth() constructor - Env-var user migration block in main.rs (~90 lines) - multi_tenant auto-detection from GATEWAY_USER_TOKENS (now runtime via db.has_any_users() in app.rs) Review fixes (zmanian): - User ID generation: UUID instead of display-name derivation (#1) - Invitation accept moved to public router (no auth needed) (#3) - libSQL get_invitation_by_hash aligned with postgres: filters status='pending' AND expires_at > now (#4) - UUID parse: returns DatabaseError::Serialization instead of unwrap_or_default (#7) - PostgreSQL SELECT * replaced with explicit column lists (#8) - Sort order aligned (both backends use DESC) (#6) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: add role-based access control (admin/member) Adds a `role` field (admin|member) to user management: Schema: - `role TEXT NOT NULL DEFAULT 'member'` added to users table in both PostgreSQL V14 migration and libSQL schema/incremental migration - UserRecord gains `role: String` field - UserIdentity gains `role: String` field, populated from DB in DbAuthenticator and defaulting to "admin" for single-user mode Access control: - AdminUser extractor: returns 403 Forbidden if role != "admin" - /api/admin/users/* handlers: require AdminUser (create, list, detail, update, suspend, activate) - POST /api/invitations: requires AdminUser (only admins can invite) - User creation accepts optional "role" param (defaults to "member") - Invitation acceptance creates users with "member" role Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(web): add Users admin tab to web UI Adds a Users tab to the web gateway UI for managing users, tokens, and roles without needing direct API calls. Features: - User list table with ID, name, email, role, status, created date - Create user form with display name, email, role selector - Suspend/activate actions per user - Create API token for any user (shows plaintext once with copy button) - Role badges (admin highlighted, member muted) - Non-admin users see "Admin access required" message - Keyboard shortcut: Cmd/Ctrl+5 switches to Users tab CSS: - Reuses routines-table styles for the user list - Badge, token-display, btn-small, btn-danger, btn-primary components Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: move Users to Settings subtab, bootstrap admin user on first run - Moved Users from top-level tab to Settings sidebar subtab (under Skills, before Theme toggle) - On first startup with empty users table, automatically creates an admin user from GATEWAY_USER_ID config with a corresponding API token from GATEWAY_AUTH_TOKEN. This ensures the owner appears in the Users panel immediately. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: user creation shows token, + Token works, no password save popup Three UI/UX fixes: 1. Create user now generates an initial API token and shows it in a copy-able banner instead of triggering the browser's password save dialog. Uses autocomplete="off" and type="text" for email field. 2. "+ Token" button works: exposed createTokenForUser/suspendUser/ activateUser on window for inline onclick handlers in dynamically generated table rows. Token creation uses showTokenBanner helper. 3. Admin token creation: POST /api/tokens now accepts optional "user_id" field when the requesting user is admin, allowing token creation for other users from the Users panel. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: use event delegation for user action buttons (CSP compliance) Inline onclick handlers are blocked by the Content-Security-Policy (script-src 'self' without 'unsafe-inline'). Switched to data-action attributes with a delegated click listener on the users table. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: add i18n for Users subtab, show login link on user creation - Added 'settings.users' i18n key for English and Chinese - Token banner now shows a full login link (domain/?token=xxx) with a Copy Link button, plus the raw token below - Login link works automatically via existing ?token= auto-auth Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: token hash mismatch — hash hex string, not raw bytes Critical auth bug: token creation hashed the raw 32 bytes (hasher.update(token_bytes)) but authentication hashed the hex-encoded string (hash_token(candidate) where candidate is the hex string the user sends). This meant newly created tokens could never authenticate. Fixed all 4 token creation sites (users, tokens, invitations create, invitations accept) to use hash_token(&plaintext_token) which hashes the hex string consistently with the auth lookup path. Removed now-unused sha2::Digest imports from handlers. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor: remove invitation system The invitation flow is redundant — admin create user already generates a token and shows a login link. Invitations add complexity without value until email integration exists. Removed: - InvitationRecord struct and 4 UserStore trait methods - invitations table from V14 migration (postgres + both libsql schemas) - PostgreSQL Store methods (create/get/accept/list invitations) - libSQL UserStore invitation methods + row_to_invitation helper - invitations.rs handler file (212 lines) - /api/invitations routes (create, list, accept) - test_invitation_lifecycle test Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: user deletion, self-service profile, per-user job limits, usage API Four multi-tenancy improvements: 1. User deletion cascade (DELETE /api/admin/users/{id}): Deletes user and all data across 11 user-scoped tables (settings, secrets, routines, memory, jobs, conversations, etc.). Admin only. 2. Self-service profile (GET/PATCH /api/profile): Users can read and update their own display_name and metadata without admin privileges. 3. Per-user job concurrency (MAX_JOBS_PER_USER env var): Scheduler checks active_jobs_for(user_id) before dispatch. Prevents one user from exhausting all job slots. 4. Usage reporting (GET /api/admin/usage?user_id=X&period=day|week|month): Aggregates LLM costs from llm_calls via agent_jobs.user_id. Returns per-user, per-model breakdown of calls, tokens, and cost. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: add TenantCtx for compile-time tenant isolation Implements zmanian's architectural proposal from #1614 review: two-tier scoped database access (TenantScope/AdminScope) so handler code cannot accidentally bypass tenant scoping. TenantScope (default): wraps user_id + Arc<dyn Database>, auto-binds user_id on every operation. ID-based lookups return None for cross- tenant resources. No escape hatch — forgetting to scope is a compile error. AdminScope (explicit opt-in): cross-tenant access for system-level components (heartbeat, routine engine, self-repair, scheduler, worker). TenantCtx bundles TenantScope + workspace + cost guard + per-user rate limiting. Constructed once per request in handle_message, threaded through all command handlers and ChatDelegate. Key changes: - New src/tenant.rs (~920 lines): TenantScope, AdminScope, TenantCtx, TenantRateState, TenantRateRegistry - All command handlers: user_id: &str → ctx: &TenantCtx - ChatDelegate: cost check/record/settings via self.tenant - System components: store field changed to AdminScope - Config: TENANT_MAX_LLM_CONCURRENT, TENANT_MAX_JOBS_CONCURRENT env vars - Fixes bug: /status <job_id> cross-tenant leak (now auto-filtered) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR #1626 review feedback — bounded LRU cache, admin auth, FK cleanup - Replace HashMap with lru::LruCache in DbAuthenticator so the token cache is hard-bounded at 1024 entries (evicts LRU, not just expired) - Gate admin user endpoints (list/detail/update/suspend/activate) with AdminUser extractor so members get 403 instead of full access - Add api_tokens to libSQL delete_user cleanup list to prevent orphaned tokens (libSQL has no FK cascade) - Add regression tests for all three fixes Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: update CA certificates in runtime Docker image Ensures the root certificate bundle is current so TLS handshakes to services like Supabase succeed on Railway. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: resolve CI failures — formatting, no-panics check - Run cargo fmt on test code - Replace .expect() with const NonZeroUsize in DbAuthenticator - Add // safety: comments for test-only code in multi_tenant.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: switch PostgreSQL TLS from rustls to native-tls rustls with rustls-native-certs fails TLS handshake on Railway's slim container (empty or stale root cert store). native-tls delegates to OpenSSL on Linux which handles system certs more reliably. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * Adding user management api * feat: admin secrets provisioning API + API documentation - Add PUT/GET/DELETE /api/admin/users/{id}/secrets/{name} endpoints for application backends to provision per-user secrets (AES-256-GCM encrypted) - Add secrets_store field to GatewayState with builder wiring - Create docs/USER_MANAGEMENT_API.md with full API spec covering users, secrets, tokens, profile, and usage endpoints - Update web gateway CLAUDE.md route table Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: add CatchPanicLayer to capture handler panics Without this, panics in async handlers silently drop the connection and the edge proxy returns a generic 503. Now panics are caught, logged, and returned as 500 with the panic message. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address second-round review — transactional delete, overflow, error logging - C1: Wrap PostgreSQL delete_user() in a transaction so partial cleanup can't leave users in a half-deleted state - M2: Add job_events to delete cleanup (both backends) — FK to agent_jobs without CASCADE would cause FK violation - H1/M4: Cap expires_in_days to 36500 before i64 cast (tokens + secrets) - H2: Validate target user exists before creating admin token to prevent orphan tokens on libSQL - H3: Log DB errors in DbAuthenticator::authenticate() instead of silently swallowing them as 401 Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: revert to rustls with webpki-roots fallback for PostgreSQL TLS native-tls/OpenSSL caused silent crashes (segfaults in C code) during DB writes on Railway containers. Switch back to rustls but add webpki-roots as a fallback when system certs are missing, which was the original TLS handshake failure on slim container images. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: update Cargo.lock for rustls + webpki-roots Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * debug: add /api/debug/db-write endpoint to diagnose user insert failure Temporary diagnostic endpoint that tests DB INSERT to users table with full error logging. No auth required. Will be removed after debugging. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * perf: use cargo-chef in Dockerfile for dependency caching Splits the build into planner/deps/builder stages. Dependencies are only recompiled when Cargo.toml or Cargo.lock change. Source-only changes skip straight to the final build stage. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * debug: add tracing to users_create_handler Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: guard created_by FK in user creation handler The auth identity user_id (from owner_id scope) may not match any user row in the DB, causing a FK violation on the created_by column. Check that the referenced user exists before setting created_by. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor: collapse GATEWAY_USER_ID into IRONCLAW_OWNER_ID Remove the separate GATEWAY_USER_ID config. The gateway now uses IRONCLAW_OWNER_ID (config.owner_id) directly for auth identity, bootstrap user creation, and workspace scoping. Previously, with_owner_scope() rebinds the auth identity to owner_id while keeping default_sender_id as the gateway user_id. This caused a FK constraint violation when creating users because the auth identity ("default") didn't match any user in the DB ("nearai"). Changes: - Remove GATEWAY_USER_ID env var and gateway_user_id from settings - Remove user_id field from GatewayConfig - Add owner_id parameter to GatewayChannel::new() - Remove with_owner_scope() method - Remove default_sender_id from GatewayState - Remove sender override logic in chat/approval handlers - Remove debug endpoint and tracing from prior debugging - Update all tests and E2E fixtures Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: hide Users tab for non-admins, remove auth hint text - Fetch /api/profile after login and hide the Users settings tab when the user's role is not admin - Remove the "Enter the GATEWAY_AUTH_TOKEN" hint from the login page since tokens are now managed via the admin panel, not .env files Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review feedback (auth 503, token expiry, CORS PATCH) - DB auth errors now return 503 instead of 401 so outages are distinguishable from invalid tokens (serrrfirat H3) - Cap expires_in_days to 36500 before i64 cast to prevent negative duration from u64 overflow (serrrfirat H1) - Add PATCH to CORS allowed methods for profile/user update endpoints (Copilot) - Stop leaking panic details in CatchPanicLayer response body Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: harden multi-tenant isolation — review fixes from #1614 - Add conversation ownership checks in TenantScope: add_conversation_message, touch_conversation, list_conversation_messages (+ paginated), update_conversation_metadata_field, get_conversation_metadata now return NotFound for conversations not owned by the tenant (cross-tenant data leak) - Fix multi-user heartbeat: clear notify_user_id per runner so notifications persist to the correct user, not the shared config target - Move hygiene tasks into bounded JoinSet instead of unbounded tokio::spawn - Revert send_notification to private visibility (only used within module) - Use effective_model_name() for cost attribution in dispatcher so providers that ignore per-request model overrides report the actual model used - Fix inject_model_override doc comment; add 3 unit tests - Fix heartbeat doc comment ("routines" not "active routines") Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: add Jobs, Cost, Last Active columns to admin Users table Add UserSummaryStats struct and user_summary_stats() batch query to the UserStore trait (both PostgreSQL and libSQL backends). The admin users list endpoint now fetches per-user aggregates (job count, total LLM spend, most recent activity) in a single query and includes them inline in the response. The frontend Users table displays three new columns. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review comments and CI formatting failures CI fixes: - cargo fmt fixes in cli/mod.rs and db/tls.rs Security/correctness (from Copilot + serrrfirat + pranavraja99 reviews): - Token create: reject expires_in_days > 36500 with 400 instead of silent clamp - Token create: return 404 when admin targets non-existent user - User create: map duplicate email constraint violations to 409 Conflict - User create: remove unnecessary DB roundtrip for created_by (use AdminUser directly) - DB auth: log warn on DB lookup failures instead of silently swallowing errors - libSQL: add FK constraints on users.created_by and api_tokens.user_id Config fixes: - agent.multi_tenant: resolve from AGENT_MULTI_TENANT env var instead of hardcoding false - heartbeat.multi_tenant: fix doc comment to match actual env-var-based behavior UI fix: - showTokenBanner: pass correct title ("Token created!" vs "User created!") Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address remaining review comments (round 2) - Secrets handlers: normalize name to lowercase before store operations, validate target user_id exists (returns 404 if not found) - libSQL: propagate cost parsing errors instead of unwrap_or_default() in both user_usage_stats and user_summary_stats - users_list_handler: propagate user_summary_stats DB errors (was silently swallowed with unwrap_or_default) - loadUsers: distinguish 401/403 (admin required) from other errors - Docs: fix users.id type (TEXT not UUID), remove "invitation flow" from V14 migration comment Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: i18n for Users tab, atomic user+token creation, transactional delete_user i18n: - Add 31 translation keys for all Users tab strings (en + zh-CN) - Wire data-i18n attributes on HTML elements (headings, buttons, inputs, table headers, empty state) - Replace all hard-coded strings in app.js with I18n.t() calls Atomic user+token creation: - Add create_user_with_token() to UserStore trait - PostgreSQL: wraps both INSERTs in conn.transaction() with auto-rollback - libSQL: wraps in explicit BEGIN/COMMIT with ROLLBACK on error - Handler uses single atomic call instead of two separate operations Transactional delete_user for libSQL: - Wrap multi-table DELETE cascade in BEGIN/COMMIT transaction - ROLLBACK on any error to prevent partial cleanup / inconsistent state - Matches the PostgreSQL implementation which already used transactions Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: revert V14 migration to match deployed checksum [skip-regression-check] Refinery checksums applied migrations — editing V14__users.sql after it was already applied causes deployment failures. Revert the cosmetic comment changes (added in df40b22f) to restore the original checksum. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: bootstrap onboarding flow for multi-tenant users The bootstrap greeting and workspace seeding only ran for the owner workspace at startup, so new users created via the admin API never received the welcome message or identity files (BOOTSTRAP.md, SOUL.md, AGENTS.md, USER.md, etc.). Three fixes: - tenant_ctx(): seed per-user workspace on first creation via seed_if_empty(), which writes identity files and sets bootstrap_pending when the workspace is truly fresh - handle_message(): check take_bootstrap_pending() on the tenant workspace (not the owner workspace) and persist the greeting to the user's own assistant conversation + broadcast via SSE - WorkspacePool: seed new per-user workspaces in the web gateway so memory tools also see identity files immediately The existing single-user bootstrap in Agent::run() is preserved for non-multi-tenant deployments. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address remaining PR review comments (round 3) - Docs: fix metadata description from "merge patch" to "full replacement" - Secrets: reject expires_in_days > 36500 with 400 (was silently clamped) - libSQL: CAST(SUM(cost) AS TEXT) in user_usage_stats and user_summary_stats to prevent SQLite numeric coercion from crashing get_text() — this was the root cause of the Copilot "SUM returns numeric type" comments - Add 3 regression tests: user_summary_stats (empty + with data) and user_usage_stats (multi-model aggregation) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: add role change support for users (admin/member toggle) - Add update_user_role() to UserStore trait + both backends (PostgreSQL and libSQL) - Extend PATCH /api/admin/users/{id} to accept optional "role" field with validation (must be "admin" or "member") - Add "Make Admin" / "Make Member" toggle button in Users table actions - Add i18n keys for role change (en + zh-CN) - Update API docs to document the role field on PATCH - Fix test helpers to use fmt_ts() for timestamps (was using SQLite datetime('now') which produces incompatible format for string comparison) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: show live LLM spend in Users table instead of only DB-recorded costs [skip-regression-check] Chat turns record LLM cost in CostGuard (in-memory) but don't create agent_jobs/llm_calls DB rows — those are only written for background jobs. The Users table was querying only from DB, so it showed $0.00 for users who only chatted. Now supplements DB stats with CostGuard.daily_spend_for_user() — the same source displayed in the status bar token counter. Shows whichever is larger (DB historical total vs live daily spend). Also falls back to last_login_at for "Last Active" when no DB job activity exists. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: persist chat LLM calls to DB and fix usage stats query Two root causes for zero usage stats: 1. ChatDelegate only recorded LLM costs to CostGuard (in-memory) — never to the llm_calls DB table. Added DB persistence via TenantScope.record_llm_call() after each chat LLM call, with job_id=NULL and conversation_id=thread_id. 2. user_summary_stats query only joined agent_jobs→llm_calls, missing chat calls (which have job_id=NULL). Redesigned query to start from llm_calls and resolve user_id via COALESCE(agent_jobs.user_id, conversations.user_id) — covers both job and chat LLM calls. Both PostgreSQL and libSQL queries updated. TenantScope gets record_llm_call() method. Tests updated for new query semantics. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review comments — input validation, cost semantics, panic safety [skip-regression-check] - Validate display_name: trim whitespace, reject empty strings (create + update) - Validate metadata: must be a JSON object, return 400 if not (admin + profile) - secrets_list_handler: verify target user_id exists before listing - Cost display: use DB total directly (chat calls now persist to DB), remove confusing max(db,live) CostGuard fallback - CatchPanicLayer: truncate panic payload to 200 chars in log to limit potential sensitive data exposure Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address Copilot round 5 — docs, secrets consistency, token name, provider field [skip-regression-check] - Docs: users.id note updated to "typically UUID v4 strings (bootstrap admin may use a custom ID)" - secrets_list_handler: return 503 when DB store is None (was falling through to list secrets without user validation) - tokens_create: trim + reject empty token name (matching display_name pattern) - LlmCallRecord.provider: use llm_backend ("nearai","openai") instead of model_name() which returns the model identifier - user_summary_stats zero-LLM users: acceptable — handler already falls back to 0 cost and last_login_at for missing entries Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: DB auth returns 503 on outage, scheduler counts only blocking jobs From serrrfirat review: - DB auth: return Err(()) on database errors so middleware returns 503 instead of silently returning Ok(None) → 401 (auth miss) - Scheduler: add parallel_blocking_count_for() that uses is_parallel_blocking() (Pending/InProgress/Stuck) instead of is_active() for per-user concurrency — Completed/Submitted jobs no longer count against MAX_JOBS_PER_USER From Copilot: - CLAUDE.md: fix secrets route paths from {id} to {user_id} - token_hash: use .as_slice() instead of .to_vec() to avoid heap allocation on every token auth/creation call Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: immediate auth cache invalidation on security-critical actions (zmanian review #6) Add DbAuthenticator::invalidate_user() that evicts all cached entries for a user. Called after: - Suspend user (immediate lockout, was 60s delay) - Activate user (immediate access restoration) - Role change (admin↔member takes effect immediately) - Token revocation (revoked token can't be reused from cache) The DbAuthenticator is shared (via Clone, which Arc-clones the cache) between the auth middleware and GatewayState, so handlers can evict entries from the same cache the middleware reads. Also from zmanian's review: - Items 1-5, 7-11 were already resolved in prior commits - Item 12 (String→enum for status/role) is deferred as a broader refactor Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: last-admin protection, usage stats for chat calls, UTF-8 safe panic truncation Last-admin protection: - Suspend, delete, and role-demotion of the last active admin now return 409 Conflict instead of succeeding and locking out the admin API - Helper is_last_admin() checks active admin count before destructive ops Usage stats: - user_usage_stats() now includes chat LLM calls (job_id=NULL) by joining via conversations.user_id, matching user_summary_stats() - Both PostgreSQL and libSQL queries updated Panic handler: - Use floor_char_boundary(200) instead of byte-index [..200] to prevent panic on multi-byte UTF-8 characters in panic messages Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: workspace seed race, bootstrap atomicity, email trim, secrets upsert response [skip-regression-check] - WorkspacePool: await seed_if_empty() synchronously after inserting into cache (drop lock first to avoid blocking), so callers see identity files immediately instead of racing a background task - Bootstrap admin: use create_user_with_token() for atomic user+token creation, matching the admin create endpoint - Email: trim whitespace, treat empty as None to prevent " " being stored and breaking uniqueness - Secrets PUT: report "updated" vs "created" based on prior existence - Last token_hash.to_vec() → .as_slice() in authenticate_token Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: disable unscoped webhook endpoint in multi-tenant mode [skip-regression-check] The original /api/webhooks/{path} endpoint looks up routines across all users. In multi-tenant mode, anyone who knows the webhook path + secret could trigger another user's routine. Now returns 410 Gone with a message pointing to the scoped endpoint /api/webhooks/u/{user_id}/{path}. Detection uses state.db_auth.is_some() — present only when DB-backed auth is enabled (multi-tenant). Single-user deployments are unaffected. From: standardtoaster review comment Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: webhook multi-tenant check, secrets error propagation, stale doc comment [skip-regression-check] - Webhook: use workspace_pool.is_some() instead of db_auth.is_some() for multi-tenant detection — db_auth is set for any DB deployment, workspace_pool is only set when has_any_users() was true at startup - Secrets: propagate exists() errors instead of unwrap_or(false) so backend outages surface as 500 rather than incorrect "created" status - Config: fix stale workspace_read_scopes comment referencing user_id Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2382 lines
81 KiB
Rust
2382 lines
81 KiB
Rust
//! User settings persistence.
|
|
//!
|
|
//! Stores user preferences in ~/.ironclaw/settings.json.
|
|
//! Settings are loaded with env var > settings.json > default priority.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::bootstrap::ironclaw_base_dir;
|
|
|
|
/// User settings persisted to disk.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct Settings {
|
|
/// Whether onboarding wizard has been completed.
|
|
#[serde(default, alias = "setup_completed")]
|
|
pub onboard_completed: bool,
|
|
|
|
/// Stable owner scope for this IronClaw instance.
|
|
///
|
|
/// This is bootstrap configuration loaded from env / disk / TOML. We do
|
|
/// not persist it in the per-user DB settings table because the DB lookup
|
|
/// itself already requires the owner scope to be known.
|
|
#[serde(default)]
|
|
pub owner_id: Option<String>,
|
|
|
|
// === Step 1: Database ===
|
|
/// Database backend: "postgres" or "libsql".
|
|
#[serde(default)]
|
|
pub database_backend: Option<String>,
|
|
|
|
/// Database connection URL (postgres://...).
|
|
#[serde(default)]
|
|
pub database_url: Option<String>,
|
|
|
|
/// Database pool size.
|
|
#[serde(default)]
|
|
pub database_pool_size: Option<usize>,
|
|
|
|
/// Path to local libSQL database file.
|
|
#[serde(default)]
|
|
pub libsql_path: Option<String>,
|
|
|
|
/// Turso cloud URL for remote replica sync.
|
|
#[serde(default)]
|
|
pub libsql_url: Option<String>,
|
|
|
|
// === Step 2: Security ===
|
|
/// Source for the secrets master key.
|
|
#[serde(default)]
|
|
pub secrets_master_key_source: KeySource,
|
|
|
|
/// Generated master key hex (env var mode only, written to .env by wizard).
|
|
#[serde(default, skip_serializing)]
|
|
pub secrets_master_key_hex: Option<String>,
|
|
|
|
// === Step 3: Inference Provider ===
|
|
/// LLM backend: "nearai", "anthropic", "openai", "github_copilot", "ollama", "openai_compatible", "tinfoil", "bedrock".
|
|
#[serde(default)]
|
|
pub llm_backend: Option<String>,
|
|
|
|
/// Ollama base URL (when llm_backend = "ollama").
|
|
#[serde(default)]
|
|
pub ollama_base_url: Option<String>,
|
|
|
|
/// OpenAI-compatible endpoint base URL (when llm_backend = "openai_compatible").
|
|
#[serde(default)]
|
|
pub openai_compatible_base_url: Option<String>,
|
|
|
|
/// Bedrock region (when llm_backend = "bedrock").
|
|
#[serde(default)]
|
|
pub bedrock_region: Option<String>,
|
|
|
|
/// Bedrock cross-region inference prefix (when llm_backend = "bedrock").
|
|
#[serde(default)]
|
|
pub bedrock_cross_region: Option<String>,
|
|
|
|
/// AWS profile name for Bedrock (when llm_backend = "bedrock").
|
|
#[serde(default)]
|
|
pub bedrock_profile: Option<String>,
|
|
|
|
// === Step 4: Model Selection ===
|
|
/// Currently selected model.
|
|
#[serde(default)]
|
|
pub selected_model: Option<String>,
|
|
|
|
// === Step 5: Embeddings ===
|
|
/// Embeddings configuration.
|
|
#[serde(default)]
|
|
pub embeddings: EmbeddingsSettings,
|
|
|
|
// === Step 6: Channels ===
|
|
/// Tunnel configuration for public webhook endpoints.
|
|
#[serde(default)]
|
|
pub tunnel: TunnelSettings,
|
|
|
|
/// Channel configuration.
|
|
#[serde(default)]
|
|
pub channels: ChannelSettings,
|
|
|
|
// === Step 7: Heartbeat ===
|
|
/// Heartbeat configuration.
|
|
#[serde(default)]
|
|
pub heartbeat: HeartbeatSettings,
|
|
|
|
// === Conversational Profile Onboarding ===
|
|
/// Whether the conversational profile onboarding has been completed.
|
|
///
|
|
/// Set during the user's first interaction with the running assistant
|
|
/// (not during the setup wizard), after the agent builds a psychographic
|
|
/// profile via `memory_write`. Used by the agent loop (via workspace
|
|
/// system-prompt wiring) to suppress BOOTSTRAP.md injection once
|
|
/// onboarding is complete.
|
|
#[serde(default, alias = "personal_onboarding_completed")]
|
|
pub profile_onboarding_completed: bool,
|
|
|
|
// === Advanced Settings (not asked during setup, editable via CLI) ===
|
|
/// Agent behavior configuration.
|
|
#[serde(default)]
|
|
pub agent: AgentSettings,
|
|
|
|
/// WASM sandbox configuration.
|
|
#[serde(default)]
|
|
pub wasm: WasmSettings,
|
|
|
|
/// Docker sandbox configuration.
|
|
#[serde(default)]
|
|
pub sandbox: SandboxSettings,
|
|
|
|
/// Safety configuration.
|
|
#[serde(default)]
|
|
pub safety: SafetySettings,
|
|
|
|
/// Builder configuration.
|
|
#[serde(default)]
|
|
pub builder: BuilderSettings,
|
|
|
|
/// Transcription configuration.
|
|
#[serde(default)]
|
|
pub transcription: Option<TranscriptionSettings>,
|
|
}
|
|
|
|
/// Source for the secrets master key.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum KeySource {
|
|
/// Auto-generated key stored in OS keychain.
|
|
Keychain,
|
|
/// User provides via SECRETS_MASTER_KEY env var.
|
|
Env,
|
|
/// Not configured (secrets features disabled).
|
|
#[default]
|
|
None,
|
|
}
|
|
|
|
/// Embeddings configuration.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct EmbeddingsSettings {
|
|
/// Whether embeddings are enabled.
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
|
|
/// Provider to use: "openai" or "nearai".
|
|
#[serde(default = "default_embeddings_provider")]
|
|
pub provider: String,
|
|
|
|
/// Model to use for embeddings.
|
|
#[serde(default = "default_embeddings_model")]
|
|
pub model: String,
|
|
}
|
|
|
|
fn default_embeddings_provider() -> String {
|
|
"nearai".to_string()
|
|
}
|
|
|
|
fn default_embeddings_model() -> String {
|
|
"text-embedding-3-small".to_string()
|
|
}
|
|
|
|
impl Default for EmbeddingsSettings {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
provider: default_embeddings_provider(),
|
|
model: default_embeddings_model(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Tunnel settings for public webhook endpoints.
|
|
///
|
|
/// The tunnel URL is shared across all channels that need webhooks.
|
|
/// Two modes:
|
|
/// - **Static URL**: `public_url` set directly (manual tunnel management).
|
|
/// - **Managed provider**: `provider` is set and the agent starts/stops the
|
|
/// tunnel process automatically at boot/shutdown.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct TunnelSettings {
|
|
/// Public URL from tunnel provider (e.g., "https://abc123.ngrok.io").
|
|
/// When set without a provider, treated as a static (externally managed) URL.
|
|
#[serde(default)]
|
|
pub public_url: Option<String>,
|
|
|
|
/// Managed tunnel provider: "ngrok", "cloudflare", "tailscale", "custom".
|
|
#[serde(default)]
|
|
pub provider: Option<String>,
|
|
|
|
/// Cloudflare tunnel token.
|
|
#[serde(default)]
|
|
pub cf_token: Option<String>,
|
|
|
|
/// ngrok auth token.
|
|
#[serde(default)]
|
|
pub ngrok_token: Option<String>,
|
|
|
|
/// ngrok custom domain (paid plans).
|
|
#[serde(default)]
|
|
pub ngrok_domain: Option<String>,
|
|
|
|
/// Use Tailscale Funnel (public) instead of Serve (tailnet-only).
|
|
#[serde(default)]
|
|
pub ts_funnel: bool,
|
|
|
|
/// Tailscale hostname override.
|
|
#[serde(default)]
|
|
pub ts_hostname: Option<String>,
|
|
|
|
/// Shell command for custom tunnel (with `{port}` / `{host}` placeholders).
|
|
#[serde(default)]
|
|
pub custom_command: Option<String>,
|
|
|
|
/// Health check URL for custom tunnel.
|
|
#[serde(default)]
|
|
pub custom_health_url: Option<String>,
|
|
|
|
/// Substring pattern to extract URL from custom tunnel stdout.
|
|
#[serde(default)]
|
|
pub custom_url_pattern: Option<String>,
|
|
}
|
|
|
|
/// Channel-specific settings.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ChannelSettings {
|
|
/// Whether HTTP webhook channel is enabled.
|
|
#[serde(default)]
|
|
pub http_enabled: bool,
|
|
|
|
/// HTTP webhook port (if enabled).
|
|
#[serde(default)]
|
|
pub http_port: Option<u16>,
|
|
|
|
/// HTTP webhook host.
|
|
#[serde(default)]
|
|
pub http_host: Option<String>,
|
|
|
|
/// Whether the web gateway is enabled.
|
|
#[serde(default = "default_true")]
|
|
pub gateway_enabled: bool,
|
|
|
|
/// Web gateway listen host.
|
|
#[serde(default)]
|
|
pub gateway_host: Option<String>,
|
|
|
|
/// Web gateway listen port.
|
|
#[serde(default)]
|
|
pub gateway_port: Option<u16>,
|
|
|
|
/// Web gateway bearer auth token. Auto-generated at gateway startup if unset.
|
|
#[serde(default)]
|
|
pub gateway_auth_token: Option<String>,
|
|
|
|
/// Whether the CLI channel is enabled.
|
|
#[serde(default = "default_true")]
|
|
pub cli_enabled: bool,
|
|
|
|
/// Whether Signal channel is enabled.
|
|
#[serde(default)]
|
|
pub signal_enabled: bool,
|
|
|
|
/// Signal HTTP URL (signal-cli daemon endpoint).
|
|
#[serde(default)]
|
|
pub signal_http_url: Option<String>,
|
|
|
|
/// Signal account (E.164 phone number).
|
|
#[serde(default)]
|
|
pub signal_account: Option<String>,
|
|
|
|
/// Signal allow from list for DMs (comma-separated E.164 phone numbers).
|
|
/// Comma-separated identifiers: E.164 phone numbers, `*`, bare UUIDs, or `uuid:<id>` entries.
|
|
/// Defaults to the configured account.
|
|
#[serde(default)]
|
|
pub signal_allow_from: Option<String>,
|
|
|
|
/// Signal allow from groups (comma-separated group IDs).
|
|
#[serde(default)]
|
|
pub signal_allow_from_groups: Option<String>,
|
|
|
|
/// Signal DM policy: "open", "allowlist", or "pairing". Default: "pairing".
|
|
#[serde(default)]
|
|
pub signal_dm_policy: Option<String>,
|
|
|
|
/// Signal group policy: "allowlist", "open", or "disabled". Default: "allowlist".
|
|
#[serde(default)]
|
|
pub signal_group_policy: Option<String>,
|
|
|
|
/// Signal group allow from (comma-separated group member IDs).
|
|
/// If empty, inherits from signal_allow_from.
|
|
#[serde(default)]
|
|
pub signal_group_allow_from: Option<String>,
|
|
|
|
/// Per-channel owner user IDs. When set, the channel only responds to this user.
|
|
/// Key: channel name (e.g., "telegram"), Value: owner user ID.
|
|
#[serde(default)]
|
|
pub wasm_channel_owner_ids: std::collections::HashMap<String, i64>,
|
|
|
|
/// Enabled WASM channels by name.
|
|
/// Channels not in this list but present in the channels directory will still load.
|
|
/// This is primarily used by the setup wizard to track which channels were configured.
|
|
#[serde(default)]
|
|
pub wasm_channels: Vec<String>,
|
|
|
|
/// Whether WASM channels are enabled.
|
|
#[serde(default = "default_true")]
|
|
pub wasm_channels_enabled: bool,
|
|
|
|
/// Directory containing WASM channel modules.
|
|
#[serde(default)]
|
|
pub wasm_channels_dir: Option<PathBuf>,
|
|
}
|
|
|
|
impl Default for ChannelSettings {
|
|
fn default() -> Self {
|
|
Self {
|
|
http_enabled: false,
|
|
http_port: None,
|
|
http_host: None,
|
|
gateway_enabled: true,
|
|
gateway_host: None,
|
|
gateway_port: None,
|
|
gateway_auth_token: None,
|
|
cli_enabled: true,
|
|
signal_enabled: false,
|
|
signal_http_url: None,
|
|
signal_account: None,
|
|
signal_allow_from: None,
|
|
signal_allow_from_groups: None,
|
|
signal_dm_policy: None,
|
|
signal_group_policy: None,
|
|
signal_group_allow_from: None,
|
|
wasm_channel_owner_ids: std::collections::HashMap::new(),
|
|
wasm_channels: Vec::new(),
|
|
wasm_channels_enabled: true,
|
|
wasm_channels_dir: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Heartbeat configuration.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct HeartbeatSettings {
|
|
/// Whether heartbeat is enabled.
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
|
|
/// Interval between heartbeat checks in seconds.
|
|
#[serde(default = "default_heartbeat_interval")]
|
|
pub interval_secs: u64,
|
|
|
|
/// Channel to notify on heartbeat findings.
|
|
#[serde(default)]
|
|
pub notify_channel: Option<String>,
|
|
|
|
/// User ID to notify on heartbeat findings.
|
|
#[serde(default)]
|
|
pub notify_user: Option<String>,
|
|
|
|
/// Fixed time-of-day to fire (HH:MM, 24h). When set, interval_secs is ignored.
|
|
#[serde(default)]
|
|
pub fire_at: Option<String>,
|
|
|
|
/// Hour (0-23) when quiet hours start (heartbeat skipped).
|
|
#[serde(default)]
|
|
pub quiet_hours_start: Option<u32>,
|
|
|
|
/// Hour (0-23) when quiet hours end (heartbeat resumes).
|
|
#[serde(default)]
|
|
pub quiet_hours_end: Option<u32>,
|
|
|
|
/// Timezone for fire_at and quiet hours (IANA name, e.g. "Pacific/Auckland").
|
|
#[serde(default)]
|
|
pub timezone: Option<String>,
|
|
}
|
|
|
|
fn default_heartbeat_interval() -> u64 {
|
|
1800 // 30 minutes
|
|
}
|
|
|
|
impl Default for HeartbeatSettings {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
interval_secs: default_heartbeat_interval(),
|
|
notify_channel: None,
|
|
notify_user: None,
|
|
fire_at: None,
|
|
quiet_hours_start: None,
|
|
quiet_hours_end: None,
|
|
timezone: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Agent behavior configuration.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AgentSettings {
|
|
/// Agent name.
|
|
#[serde(default = "default_agent_name")]
|
|
pub name: String,
|
|
|
|
/// Maximum parallel jobs.
|
|
#[serde(default = "default_max_parallel_jobs")]
|
|
pub max_parallel_jobs: u32,
|
|
|
|
/// Job timeout in seconds.
|
|
#[serde(default = "default_job_timeout")]
|
|
pub job_timeout_secs: u64,
|
|
|
|
/// Stuck job threshold in seconds.
|
|
#[serde(default = "default_stuck_threshold")]
|
|
pub stuck_threshold_secs: u64,
|
|
|
|
/// Whether to use planning before tool execution.
|
|
#[serde(default = "default_true")]
|
|
pub use_planning: bool,
|
|
|
|
/// Self-repair check interval in seconds.
|
|
#[serde(default = "default_repair_interval")]
|
|
pub repair_check_interval_secs: u64,
|
|
|
|
/// Maximum repair attempts.
|
|
#[serde(default = "default_max_repair_attempts")]
|
|
pub max_repair_attempts: u32,
|
|
|
|
/// Session idle timeout in seconds (default: 7 days). Sessions inactive
|
|
/// longer than this are pruned from memory.
|
|
#[serde(default = "default_session_idle_timeout")]
|
|
pub session_idle_timeout_secs: u64,
|
|
|
|
/// Maximum tool-call iterations per agentic loop invocation (default: 50).
|
|
#[serde(default = "default_max_tool_iterations")]
|
|
pub max_tool_iterations: usize,
|
|
|
|
/// When true, skip tool approval checks entirely. For benchmarks/CI.
|
|
#[serde(default)]
|
|
pub auto_approve_tools: bool,
|
|
|
|
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
|
|
#[serde(default = "default_timezone")]
|
|
pub default_timezone: String,
|
|
|
|
/// Maximum tokens per job (0 = unlimited).
|
|
#[serde(default)]
|
|
pub max_tokens_per_job: u64,
|
|
}
|
|
|
|
fn default_agent_name() -> String {
|
|
"ironclaw".to_string()
|
|
}
|
|
|
|
fn default_max_parallel_jobs() -> u32 {
|
|
5
|
|
}
|
|
|
|
fn default_job_timeout() -> u64 {
|
|
3600 // 1 hour
|
|
}
|
|
|
|
fn default_stuck_threshold() -> u64 {
|
|
300 // 5 minutes
|
|
}
|
|
|
|
fn default_repair_interval() -> u64 {
|
|
60 // 1 minute
|
|
}
|
|
|
|
fn default_session_idle_timeout() -> u64 {
|
|
7 * 24 * 3600 // 7 days
|
|
}
|
|
|
|
fn default_max_repair_attempts() -> u32 {
|
|
3
|
|
}
|
|
|
|
fn default_max_tool_iterations() -> usize {
|
|
50
|
|
}
|
|
|
|
fn default_timezone() -> String {
|
|
"UTC".to_string()
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
impl Default for AgentSettings {
|
|
fn default() -> Self {
|
|
Self {
|
|
name: default_agent_name(),
|
|
max_parallel_jobs: default_max_parallel_jobs(),
|
|
job_timeout_secs: default_job_timeout(),
|
|
stuck_threshold_secs: default_stuck_threshold(),
|
|
use_planning: true,
|
|
repair_check_interval_secs: default_repair_interval(),
|
|
max_repair_attempts: default_max_repair_attempts(),
|
|
session_idle_timeout_secs: default_session_idle_timeout(),
|
|
max_tool_iterations: default_max_tool_iterations(),
|
|
auto_approve_tools: false,
|
|
default_timezone: default_timezone(),
|
|
max_tokens_per_job: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// WASM sandbox configuration.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct WasmSettings {
|
|
/// Whether WASM tool execution is enabled.
|
|
#[serde(default = "default_true")]
|
|
pub enabled: bool,
|
|
|
|
/// Directory containing installed WASM tools.
|
|
#[serde(default)]
|
|
pub tools_dir: Option<PathBuf>,
|
|
|
|
/// Default memory limit in bytes.
|
|
#[serde(default = "default_wasm_memory_limit")]
|
|
pub default_memory_limit: u64,
|
|
|
|
/// Default execution timeout in seconds.
|
|
#[serde(default = "default_wasm_timeout")]
|
|
pub default_timeout_secs: u64,
|
|
|
|
/// Default fuel limit for CPU metering.
|
|
#[serde(default = "default_wasm_fuel_limit")]
|
|
pub default_fuel_limit: u64,
|
|
|
|
/// Whether to cache compiled modules.
|
|
#[serde(default = "default_true")]
|
|
pub cache_compiled: bool,
|
|
|
|
/// Directory for compiled module cache.
|
|
#[serde(default)]
|
|
pub cache_dir: Option<PathBuf>,
|
|
}
|
|
|
|
fn default_wasm_memory_limit() -> u64 {
|
|
10 * 1024 * 1024 // 10 MB
|
|
}
|
|
|
|
fn default_wasm_timeout() -> u64 {
|
|
60
|
|
}
|
|
|
|
fn default_wasm_fuel_limit() -> u64 {
|
|
10_000_000
|
|
}
|
|
|
|
impl Default for WasmSettings {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
tools_dir: None,
|
|
default_memory_limit: default_wasm_memory_limit(),
|
|
default_timeout_secs: default_wasm_timeout(),
|
|
default_fuel_limit: default_wasm_fuel_limit(),
|
|
cache_compiled: true,
|
|
cache_dir: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Docker sandbox configuration.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SandboxSettings {
|
|
/// Whether the Docker sandbox is enabled.
|
|
#[serde(default = "default_true")]
|
|
pub enabled: bool,
|
|
|
|
/// Sandbox policy: "readonly", "workspace_write", or "full_access".
|
|
#[serde(default = "default_sandbox_policy")]
|
|
pub policy: String,
|
|
|
|
/// Command timeout in seconds.
|
|
#[serde(default = "default_sandbox_timeout")]
|
|
pub timeout_secs: u64,
|
|
|
|
/// Memory limit in megabytes.
|
|
#[serde(default = "default_sandbox_memory")]
|
|
pub memory_limit_mb: u64,
|
|
|
|
/// CPU shares (relative weight).
|
|
#[serde(default = "default_sandbox_cpu_shares")]
|
|
pub cpu_shares: u32,
|
|
|
|
/// Docker image for the sandbox.
|
|
#[serde(default = "default_sandbox_image")]
|
|
pub image: String,
|
|
|
|
/// Whether to auto-pull the image if not found.
|
|
#[serde(default = "default_true")]
|
|
pub auto_pull_image: bool,
|
|
|
|
/// Additional domains to allow through the network proxy.
|
|
#[serde(default)]
|
|
pub extra_allowed_domains: Vec<String>,
|
|
|
|
/// Whether Claude Code sandbox mode is enabled.
|
|
#[serde(default)]
|
|
pub claude_code_enabled: bool,
|
|
}
|
|
|
|
fn default_sandbox_policy() -> String {
|
|
"readonly".to_string()
|
|
}
|
|
|
|
fn default_sandbox_timeout() -> u64 {
|
|
120
|
|
}
|
|
|
|
fn default_sandbox_memory() -> u64 {
|
|
2048
|
|
}
|
|
|
|
fn default_sandbox_cpu_shares() -> u32 {
|
|
1024
|
|
}
|
|
|
|
fn default_sandbox_image() -> String {
|
|
"ironclaw-worker:latest".to_string()
|
|
}
|
|
|
|
impl Default for SandboxSettings {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
policy: default_sandbox_policy(),
|
|
timeout_secs: default_sandbox_timeout(),
|
|
memory_limit_mb: default_sandbox_memory(),
|
|
cpu_shares: default_sandbox_cpu_shares(),
|
|
image: default_sandbox_image(),
|
|
auto_pull_image: true,
|
|
extra_allowed_domains: Vec::new(),
|
|
claude_code_enabled: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Safety configuration.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SafetySettings {
|
|
/// Maximum output length in bytes.
|
|
#[serde(default = "default_max_output_length")]
|
|
pub max_output_length: usize,
|
|
|
|
/// Whether injection check is enabled.
|
|
#[serde(default = "default_true")]
|
|
pub injection_check_enabled: bool,
|
|
}
|
|
|
|
fn default_max_output_length() -> usize {
|
|
100_000
|
|
}
|
|
|
|
impl Default for SafetySettings {
|
|
fn default() -> Self {
|
|
Self {
|
|
max_output_length: default_max_output_length(),
|
|
injection_check_enabled: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Builder configuration.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct BuilderSettings {
|
|
/// Whether the software builder tool is enabled.
|
|
#[serde(default = "default_true")]
|
|
pub enabled: bool,
|
|
|
|
/// Directory for build artifacts.
|
|
#[serde(default)]
|
|
pub build_dir: Option<PathBuf>,
|
|
|
|
/// Maximum iterations for the build loop.
|
|
#[serde(default = "default_builder_max_iterations")]
|
|
pub max_iterations: u32,
|
|
|
|
/// Build timeout in seconds.
|
|
#[serde(default = "default_builder_timeout")]
|
|
pub timeout_secs: u64,
|
|
|
|
/// Whether to automatically register built WASM tools.
|
|
#[serde(default = "default_true")]
|
|
pub auto_register: bool,
|
|
}
|
|
|
|
fn default_builder_max_iterations() -> u32 {
|
|
20
|
|
}
|
|
|
|
fn default_builder_timeout() -> u64 {
|
|
600
|
|
}
|
|
|
|
impl Default for BuilderSettings {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
build_dir: None,
|
|
max_iterations: default_builder_max_iterations(),
|
|
timeout_secs: default_builder_timeout(),
|
|
auto_register: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Transcription pipeline settings.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TranscriptionSettings {
|
|
/// Whether audio transcription is enabled.
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
}
|
|
|
|
impl Settings {
|
|
/// Reconstruct Settings from a flat key-value map (as stored in the DB).
|
|
///
|
|
/// Each key is a dotted path (e.g., "agent.name"), value is a JSONB value.
|
|
/// Missing keys get their default value.
|
|
pub fn from_db_map(map: &std::collections::HashMap<String, serde_json::Value>) -> Self {
|
|
// Start with defaults, then overlay each DB setting.
|
|
//
|
|
// The settings table stores both Settings struct fields and app-specific
|
|
// data (e.g. nearai.session_token). Skip keys that don't correspond to
|
|
// a known Settings path.
|
|
let mut settings = Self::default();
|
|
|
|
for (key, value) in map {
|
|
if key == "owner_id" {
|
|
continue;
|
|
}
|
|
|
|
// Convert the JSONB value to a string for the existing set() method
|
|
let value_str = match value {
|
|
serde_json::Value::String(s) => s.clone(),
|
|
serde_json::Value::Bool(b) => b.to_string(),
|
|
serde_json::Value::Number(n) => n.to_string(),
|
|
serde_json::Value::Null => continue, // null means default, skip
|
|
other => other.to_string(),
|
|
};
|
|
|
|
match settings.set(key, &value_str) {
|
|
Ok(()) => {}
|
|
// The settings table stores both Settings fields and app-specific
|
|
// data (e.g. nearai.session_token). Silently skip unknown paths.
|
|
Err(e) if e.starts_with("Path not found") => {}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"Failed to apply DB setting '{}' = '{}': {}",
|
|
key,
|
|
value_str,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
settings
|
|
}
|
|
|
|
/// Flatten Settings into a key-value map suitable for DB storage.
|
|
///
|
|
/// Each entry is a (dotted_path, JSONB value) pair.
|
|
pub fn to_db_map(&self) -> std::collections::HashMap<String, serde_json::Value> {
|
|
let json = match serde_json::to_value(self) {
|
|
Ok(v) => v,
|
|
Err(_) => return std::collections::HashMap::new(),
|
|
};
|
|
|
|
let mut map = std::collections::HashMap::new();
|
|
collect_settings_json(&json, String::new(), &mut map);
|
|
map.remove("owner_id");
|
|
map
|
|
}
|
|
|
|
/// Get the default settings file path (~/.ironclaw/settings.json).
|
|
pub fn default_path() -> std::path::PathBuf {
|
|
ironclaw_base_dir().join("settings.json")
|
|
}
|
|
|
|
/// Load settings from disk, returning default if not found.
|
|
pub fn load() -> Self {
|
|
Self::load_from(&Self::default_path())
|
|
}
|
|
|
|
/// Load settings from a specific path (used by bootstrap legacy migration).
|
|
pub fn load_from(path: &std::path::Path) -> Self {
|
|
match std::fs::read_to_string(path) {
|
|
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
|
|
Err(_) => Self::default(),
|
|
}
|
|
}
|
|
|
|
/// Default TOML config file path (~/.ironclaw/config.toml).
|
|
pub fn default_toml_path() -> PathBuf {
|
|
ironclaw_base_dir().join("config.toml")
|
|
}
|
|
|
|
/// Load settings from a TOML file.
|
|
///
|
|
/// Returns `None` if the file doesn't exist. Returns an error only
|
|
/// if the file exists but can't be parsed.
|
|
pub fn load_toml(path: &std::path::Path) -> Result<Option<Self>, String> {
|
|
let data = match std::fs::read_to_string(path) {
|
|
Ok(d) => d,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
Err(e) => return Err(format!("failed to read {}: {}", path.display(), e)),
|
|
};
|
|
|
|
let settings: Self = toml::from_str(&data)
|
|
.map_err(|e| format!("invalid TOML in {}: {}", path.display(), e))?;
|
|
Ok(Some(settings))
|
|
}
|
|
|
|
/// Write a well-commented TOML config file with current settings.
|
|
pub fn save_toml(&self, path: &std::path::Path) -> Result<(), String> {
|
|
let raw = toml::to_string_pretty(self)
|
|
.map_err(|e| format!("failed to serialize settings: {}", e))?;
|
|
|
|
let content = format!(
|
|
"# IronClaw configuration file.\n\
|
|
#\n\
|
|
# Priority: env var > this file > database settings > defaults.\n\
|
|
# Uncomment and edit values to override defaults.\n\
|
|
# Run `ironclaw config init` to regenerate this file.\n\
|
|
#\n\
|
|
# Documentation: https://github.com/nearai/ironclaw\n\
|
|
\n\
|
|
{raw}"
|
|
);
|
|
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
|
|
}
|
|
|
|
std::fs::write(path, content)
|
|
.map_err(|e| format!("failed to write {}: {}", path.display(), e))
|
|
}
|
|
|
|
/// Merge values from `other` into `self`, preferring `other` for
|
|
/// fields that differ from the default.
|
|
///
|
|
/// This enables layering: load DB/JSON settings as the base, then
|
|
/// overlay TOML values on top. Only fields that the TOML file
|
|
/// explicitly changed (i.e. differ from Default) are applied.
|
|
pub fn merge_from(&mut self, other: &Self) {
|
|
let default_json = match serde_json::to_value(Self::default()) {
|
|
Ok(v) => v,
|
|
Err(_) => return,
|
|
};
|
|
let other_json = match serde_json::to_value(other) {
|
|
Ok(v) => v,
|
|
Err(_) => return,
|
|
};
|
|
let mut self_json = match serde_json::to_value(&*self) {
|
|
Ok(v) => v,
|
|
Err(_) => return,
|
|
};
|
|
|
|
merge_non_default(&mut self_json, &other_json, &default_json);
|
|
|
|
if let Ok(merged) = serde_json::from_value(self_json) {
|
|
*self = merged;
|
|
}
|
|
}
|
|
|
|
/// Get a setting value by dotted path (e.g., "agent.max_parallel_jobs").
|
|
pub fn get(&self, path: &str) -> Option<String> {
|
|
let json = serde_json::to_value(self).ok()?;
|
|
let mut current = &json;
|
|
|
|
for part in path.split('.') {
|
|
current = current.get(part)?;
|
|
}
|
|
|
|
match current {
|
|
serde_json::Value::String(s) => Some(s.clone()),
|
|
serde_json::Value::Number(n) => Some(n.to_string()),
|
|
serde_json::Value::Bool(b) => Some(b.to_string()),
|
|
serde_json::Value::Null => Some("null".to_string()),
|
|
serde_json::Value::Array(arr) => Some(serde_json::to_string(arr).unwrap_or_default()),
|
|
serde_json::Value::Object(obj) => Some(serde_json::to_string(obj).unwrap_or_default()),
|
|
}
|
|
}
|
|
|
|
/// Set a setting value by dotted path.
|
|
///
|
|
/// Returns error if path is invalid or value cannot be parsed.
|
|
pub fn set(&mut self, path: &str, value: &str) -> Result<(), String> {
|
|
let mut json = serde_json::to_value(&self)
|
|
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
|
|
|
|
let parts: Vec<&str> = path.split('.').collect();
|
|
let (final_key, parent_parts) =
|
|
parts.split_last().ok_or_else(|| "Empty path".to_string())?;
|
|
|
|
// Navigate to parent and set the final key
|
|
let mut current = &mut json;
|
|
for part in parent_parts {
|
|
current = current
|
|
.get_mut(*part)
|
|
.ok_or_else(|| format!("Path not found: {}", path))?;
|
|
}
|
|
let obj = current
|
|
.as_object_mut()
|
|
.ok_or_else(|| format!("Parent is not an object: {}", path))?;
|
|
|
|
// Try to infer the type from the existing value
|
|
let new_value = if let Some(existing) = obj.get(*final_key) {
|
|
match existing {
|
|
serde_json::Value::Bool(_) => {
|
|
let b = value
|
|
.parse::<bool>()
|
|
.map_err(|_| format!("Expected boolean for {}, got '{}'", path, value))?;
|
|
serde_json::Value::Bool(b)
|
|
}
|
|
serde_json::Value::Number(n) => {
|
|
if n.is_u64() {
|
|
let n = value.parse::<u64>().map_err(|_| {
|
|
format!("Expected integer for {}, got '{}'", path, value)
|
|
})?;
|
|
serde_json::Value::Number(n.into())
|
|
} else if n.is_i64() {
|
|
let n = value.parse::<i64>().map_err(|_| {
|
|
format!("Expected integer for {}, got '{}'", path, value)
|
|
})?;
|
|
serde_json::Value::Number(n.into())
|
|
} else {
|
|
let n = value.parse::<f64>().map_err(|_| {
|
|
format!("Expected number for {}, got '{}'", path, value)
|
|
})?;
|
|
serde_json::Number::from_f64(n)
|
|
.map(serde_json::Value::Number)
|
|
.unwrap_or(serde_json::Value::String(value.to_string()))
|
|
}
|
|
}
|
|
serde_json::Value::Null => {
|
|
// Could be Option<T>, try to parse as JSON or use string
|
|
serde_json::from_str(value)
|
|
.unwrap_or(serde_json::Value::String(value.to_string()))
|
|
}
|
|
serde_json::Value::Array(_) => serde_json::from_str(value)
|
|
.map_err(|e| format!("Invalid JSON array for {}: {}", path, e))?,
|
|
serde_json::Value::Object(_) => serde_json::from_str(value)
|
|
.map_err(|e| format!("Invalid JSON object for {}: {}", path, e))?,
|
|
serde_json::Value::String(_) => serde_json::Value::String(value.to_string()),
|
|
}
|
|
} else {
|
|
// Key doesn't exist, try to parse as JSON or use string
|
|
serde_json::from_str(value).unwrap_or(serde_json::Value::String(value.to_string()))
|
|
};
|
|
|
|
obj.insert((*final_key).to_string(), new_value);
|
|
|
|
// Deserialize back to Settings
|
|
*self =
|
|
serde_json::from_value(json).map_err(|e| format!("Failed to apply setting: {}", e))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Reset a setting to its default value.
|
|
pub fn reset(&mut self, path: &str) -> Result<(), String> {
|
|
let default = Self::default();
|
|
let default_value = default
|
|
.get(path)
|
|
.ok_or_else(|| format!("Unknown setting: {}", path))?;
|
|
|
|
self.set(path, &default_value)
|
|
}
|
|
|
|
/// List all settings as (path, value) pairs.
|
|
pub fn list(&self) -> Vec<(String, String)> {
|
|
let json = match serde_json::to_value(self) {
|
|
Ok(v) => v,
|
|
Err(_) => return Vec::new(),
|
|
};
|
|
|
|
let mut results = Vec::new();
|
|
collect_settings(&json, String::new(), &mut results);
|
|
results.sort_by(|a, b| a.0.cmp(&b.0));
|
|
results
|
|
}
|
|
}
|
|
|
|
/// Recursively collect settings paths with their JSON values (for DB storage).
|
|
fn collect_settings_json(
|
|
value: &serde_json::Value,
|
|
prefix: String,
|
|
results: &mut std::collections::HashMap<String, serde_json::Value>,
|
|
) {
|
|
match value {
|
|
serde_json::Value::Object(obj) => {
|
|
for (key, val) in obj {
|
|
let path = if prefix.is_empty() {
|
|
key.clone()
|
|
} else {
|
|
format!("{}.{}", prefix, key)
|
|
};
|
|
collect_settings_json(val, path, results);
|
|
}
|
|
}
|
|
other => {
|
|
results.insert(prefix, other.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Recursively collect settings paths and values.
|
|
fn collect_settings(
|
|
value: &serde_json::Value,
|
|
prefix: String,
|
|
results: &mut Vec<(String, String)>,
|
|
) {
|
|
match value {
|
|
serde_json::Value::Object(obj) => {
|
|
for (key, val) in obj {
|
|
let path = if prefix.is_empty() {
|
|
key.clone()
|
|
} else {
|
|
format!("{}.{}", prefix, key)
|
|
};
|
|
collect_settings(val, path, results);
|
|
}
|
|
}
|
|
serde_json::Value::Array(arr) => {
|
|
let display = serde_json::to_string(arr).unwrap_or_default();
|
|
results.push((prefix, display));
|
|
}
|
|
serde_json::Value::String(s) => {
|
|
results.push((prefix, s.clone()));
|
|
}
|
|
serde_json::Value::Number(n) => {
|
|
results.push((prefix, n.to_string()));
|
|
}
|
|
serde_json::Value::Bool(b) => {
|
|
results.push((prefix, b.to_string()));
|
|
}
|
|
serde_json::Value::Null => {
|
|
results.push((prefix, "null".to_string()));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Recursively merge `other` into `target`, but only for fields where
|
|
/// `other` differs from `defaults`. This means only explicitly-set values
|
|
/// in the TOML file override the base settings.
|
|
fn merge_non_default(
|
|
target: &mut serde_json::Value,
|
|
other: &serde_json::Value,
|
|
defaults: &serde_json::Value,
|
|
) {
|
|
match (target, other, defaults) {
|
|
(
|
|
serde_json::Value::Object(t),
|
|
serde_json::Value::Object(o),
|
|
serde_json::Value::Object(d),
|
|
) => {
|
|
for (key, other_val) in o {
|
|
let default_val = d.get(key).cloned().unwrap_or(serde_json::Value::Null);
|
|
if let Some(target_val) = t.get_mut(key) {
|
|
merge_non_default(target_val, other_val, &default_val);
|
|
} else if other_val != &default_val {
|
|
t.insert(key.clone(), other_val.clone());
|
|
}
|
|
}
|
|
}
|
|
(target, other, defaults) => {
|
|
if other != defaults {
|
|
*target = other.clone();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::settings::*;
|
|
|
|
#[test]
|
|
fn test_db_map_round_trip() {
|
|
let settings = Settings {
|
|
selected_model: Some("claude-3-5-sonnet-20241022".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let map = settings.to_db_map();
|
|
let restored = Settings::from_db_map(&map);
|
|
assert_eq!(
|
|
restored.selected_model,
|
|
Some("claude-3-5-sonnet-20241022".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_setting() {
|
|
let settings = Settings::default();
|
|
|
|
assert_eq!(settings.get("agent.name"), Some("ironclaw".to_string()));
|
|
assert_eq!(
|
|
settings.get("agent.max_parallel_jobs"),
|
|
Some("5".to_string())
|
|
);
|
|
assert_eq!(settings.get("heartbeat.enabled"), Some("false".to_string()));
|
|
assert_eq!(settings.get("nonexistent"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_setting() {
|
|
let mut settings = Settings::default();
|
|
|
|
settings.set("agent.name", "mybot").unwrap();
|
|
assert_eq!(settings.agent.name, "mybot");
|
|
|
|
settings.set("agent.max_parallel_jobs", "10").unwrap();
|
|
assert_eq!(settings.agent.max_parallel_jobs, 10);
|
|
|
|
settings.set("heartbeat.enabled", "true").unwrap();
|
|
assert!(settings.heartbeat.enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn test_reset_setting() {
|
|
let mut settings = Settings::default();
|
|
|
|
settings.agent.name = "custom".to_string();
|
|
settings.reset("agent.name").unwrap();
|
|
assert_eq!(settings.agent.name, "ironclaw");
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_settings() {
|
|
let settings = Settings::default();
|
|
let list = settings.list();
|
|
|
|
// Check some expected entries
|
|
assert!(list.iter().any(|(k, _)| k == "agent.name"));
|
|
assert!(list.iter().any(|(k, _)| k == "heartbeat.enabled"));
|
|
assert!(list.iter().any(|(k, _)| k == "onboard_completed"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_key_source_serialization() {
|
|
let settings = Settings {
|
|
secrets_master_key_source: KeySource::Keychain,
|
|
..Default::default()
|
|
};
|
|
|
|
let json = serde_json::to_string(&settings).unwrap();
|
|
assert!(json.contains("\"keychain\""));
|
|
|
|
let loaded: Settings = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(loaded.secrets_master_key_source, KeySource::Keychain);
|
|
}
|
|
|
|
#[test]
|
|
fn test_embeddings_defaults() {
|
|
let settings = Settings::default();
|
|
assert!(!settings.embeddings.enabled);
|
|
assert_eq!(settings.embeddings.provider, "nearai");
|
|
assert_eq!(settings.embeddings.model, "text-embedding-3-small");
|
|
}
|
|
|
|
#[test]
|
|
fn test_wasm_channel_owner_ids_db_round_trip() {
|
|
let mut settings = Settings::default();
|
|
settings
|
|
.channels
|
|
.wasm_channel_owner_ids
|
|
.insert("telegram".to_string(), 123456789);
|
|
|
|
let map = settings.to_db_map();
|
|
let restored = Settings::from_db_map(&map);
|
|
assert_eq!(
|
|
restored.channels.wasm_channel_owner_ids.get("telegram"),
|
|
Some(&123456789)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_wasm_channel_owner_ids_default_empty() {
|
|
let settings = Settings::default();
|
|
assert!(settings.channels.wasm_channel_owner_ids.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_wasm_channel_owner_ids_via_set() {
|
|
let mut settings = Settings::default();
|
|
settings
|
|
.set("channels.wasm_channel_owner_ids.telegram", "987654321")
|
|
.unwrap();
|
|
assert_eq!(
|
|
settings.channels.wasm_channel_owner_ids.get("telegram"),
|
|
Some(&987654321)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_llm_backend_round_trip() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("settings.json");
|
|
|
|
let settings = Settings {
|
|
llm_backend: Some("anthropic".to_string()),
|
|
ollama_base_url: Some("http://localhost:11434".to_string()),
|
|
openai_compatible_base_url: Some("http://my-vllm:8000/v1".to_string()),
|
|
..Default::default()
|
|
};
|
|
let json = serde_json::to_string_pretty(&settings).unwrap();
|
|
std::fs::write(&path, json).unwrap();
|
|
|
|
let loaded = Settings::load_from(&path);
|
|
assert_eq!(loaded.llm_backend, Some("anthropic".to_string()));
|
|
assert_eq!(
|
|
loaded.ollama_base_url,
|
|
Some("http://localhost:11434".to_string())
|
|
);
|
|
assert_eq!(
|
|
loaded.openai_compatible_base_url,
|
|
Some("http://my-vllm:8000/v1".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_openai_compatible_db_map_round_trip() {
|
|
let settings = Settings {
|
|
llm_backend: Some("openai_compatible".to_string()),
|
|
openai_compatible_base_url: Some("http://my-vllm:8000/v1".to_string()),
|
|
embeddings: EmbeddingsSettings {
|
|
enabled: false,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
|
|
let map = settings.to_db_map();
|
|
let restored = Settings::from_db_map(&map);
|
|
|
|
assert_eq!(
|
|
restored.llm_backend,
|
|
Some("openai_compatible".to_string()),
|
|
"llm_backend must survive DB round-trip"
|
|
);
|
|
assert_eq!(
|
|
restored.openai_compatible_base_url,
|
|
Some("http://my-vllm:8000/v1".to_string()),
|
|
"openai_compatible_base_url must survive DB round-trip"
|
|
);
|
|
assert!(
|
|
!restored.embeddings.enabled,
|
|
"embeddings.enabled=false must survive DB round-trip"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn toml_round_trip() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
|
|
let mut settings = Settings::default();
|
|
settings.agent.name = "toml-bot".to_string();
|
|
settings.heartbeat.enabled = true;
|
|
settings.heartbeat.interval_secs = 900;
|
|
|
|
settings.save_toml(&path).unwrap();
|
|
let loaded = Settings::load_toml(&path).unwrap().unwrap();
|
|
|
|
assert_eq!(loaded.agent.name, "toml-bot");
|
|
assert!(loaded.heartbeat.enabled);
|
|
assert_eq!(loaded.heartbeat.interval_secs, 900);
|
|
}
|
|
|
|
/// Regression: /model writes a single key ("selected_model") to the DB via
|
|
/// set_setting(). On restart, get_all_settings() returns ALL keys including
|
|
/// wizard-written defaults. The single-key update must survive the full
|
|
/// from_db_map() round trip.
|
|
#[test]
|
|
fn db_single_key_model_update_survives_roundtrip() {
|
|
// Step 1: Wizard writes full settings to DB (including selected_model
|
|
// from initial setup).
|
|
let wizard_settings = Settings {
|
|
llm_backend: Some("nearai".to_string()),
|
|
selected_model: Some("old-wizard-model".to_string()),
|
|
..Default::default()
|
|
};
|
|
let mut db: std::collections::HashMap<String, serde_json::Value> =
|
|
wizard_settings.to_db_map();
|
|
|
|
// Step 2: User runs /model new-model — persist_selected_model writes
|
|
// a single key, overwriting the wizard value.
|
|
db.insert(
|
|
"selected_model".to_string(),
|
|
serde_json::Value::String("new-model".to_string()),
|
|
);
|
|
|
|
// Step 3: On restart, from_db_map() rebuilds Settings from the full
|
|
// DB map.
|
|
let restored = Settings::from_db_map(&db);
|
|
assert_eq!(
|
|
restored.selected_model,
|
|
Some("new-model".to_string()),
|
|
"/model change must survive DB round trip"
|
|
);
|
|
}
|
|
|
|
/// Regression: TOML overlay must not clobber a DB-persisted selected_model
|
|
/// when the TOML file matches the DB. This is the normal case after /model
|
|
/// successfully writes to both DB and TOML.
|
|
#[test]
|
|
fn toml_overlay_preserves_matching_model() {
|
|
// DB settings with new model from /model command.
|
|
let mut db_settings = Settings {
|
|
llm_backend: Some("nearai".to_string()),
|
|
selected_model: Some("new-model".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
// TOML also updated by /model command to the same value.
|
|
let toml_settings = Settings {
|
|
selected_model: Some("new-model".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
db_settings.merge_from(&toml_settings);
|
|
assert_eq!(
|
|
db_settings.selected_model,
|
|
Some("new-model".to_string()),
|
|
"TOML overlay must not clobber matching model"
|
|
);
|
|
}
|
|
|
|
/// Regression: when /model updates DB but TOML write fails, a stale TOML
|
|
/// file would overwrite the DB value. This test documents the priority:
|
|
/// TOML > DB (by design). persist_selected_model MUST update the TOML.
|
|
#[test]
|
|
fn stale_toml_overwrites_db_model() {
|
|
// DB has the new model from /model.
|
|
let mut db_settings = Settings {
|
|
selected_model: Some("new-model".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
// TOML still has the old model (write failed or was not attempted).
|
|
let stale_toml = Settings {
|
|
selected_model: Some("old-model".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
db_settings.merge_from(&stale_toml);
|
|
// This documents the current priority: TOML wins over DB.
|
|
// The fix in persist_selected_model ensures TOML is always updated.
|
|
assert_eq!(
|
|
db_settings.selected_model,
|
|
Some("old-model".to_string()),
|
|
"TOML overlay has higher priority than DB (by design)"
|
|
);
|
|
}
|
|
|
|
/// Regression test: /model command must persist selected_model to TOML config.
|
|
/// Prior to the fix, `set_model()` only changed the in-memory provider and the
|
|
/// choice was lost on restart.
|
|
#[test]
|
|
fn toml_selected_model_update_persists() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
|
|
// Start with a config that has a different model.
|
|
let settings = Settings {
|
|
selected_model: Some("old-model".to_string()),
|
|
..Default::default()
|
|
};
|
|
settings.save_toml(&path).unwrap();
|
|
|
|
// Simulate what persist_selected_model does: load, update, save.
|
|
let mut loaded = Settings::load_toml(&path).unwrap().unwrap();
|
|
loaded.selected_model = Some("new-model".to_string());
|
|
loaded.save_toml(&path).unwrap();
|
|
|
|
// Verify the change survived a reload.
|
|
let reloaded = Settings::load_toml(&path).unwrap().unwrap();
|
|
assert_eq!(reloaded.selected_model, Some("new-model".to_string()));
|
|
}
|
|
|
|
/// Regression: /model must create config.toml when it doesn't exist, so the
|
|
/// model survives restarts. Previously the Ok(None) case was a no-op.
|
|
#[test]
|
|
fn toml_created_when_missing_for_model_persist() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
|
|
// No config.toml yet (fresh install, no wizard).
|
|
assert!(Settings::load_toml(&path).unwrap().is_none());
|
|
|
|
// Simulate what persist_selected_model now does for the Ok(None) case.
|
|
let settings = Settings {
|
|
selected_model: Some("new-model".to_string()),
|
|
..Default::default()
|
|
};
|
|
settings.save_toml(&path).unwrap();
|
|
|
|
// Verify the model survived.
|
|
let loaded = Settings::load_toml(&path).unwrap().unwrap();
|
|
assert_eq!(loaded.selected_model, Some("new-model".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn toml_missing_file_returns_none() {
|
|
let result = Settings::load_toml(std::path::Path::new("/tmp/nonexistent_config.toml"));
|
|
assert!(result.unwrap().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn toml_invalid_content_returns_error() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("bad.toml");
|
|
std::fs::write(&path, "this is not valid toml [[[").unwrap();
|
|
|
|
let result = Settings::load_toml(&path);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn toml_partial_config_uses_defaults() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("partial.toml");
|
|
|
|
// Only set agent name, everything else should be default
|
|
std::fs::write(&path, "[agent]\nname = \"partial-bot\"\n").unwrap();
|
|
|
|
let loaded = Settings::load_toml(&path).unwrap().unwrap();
|
|
assert_eq!(loaded.agent.name, "partial-bot");
|
|
// Defaults preserved
|
|
assert_eq!(loaded.agent.max_parallel_jobs, 5);
|
|
assert!(!loaded.heartbeat.enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn toml_header_comment_present() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
|
|
Settings::default().save_toml(&path).unwrap();
|
|
let content = std::fs::read_to_string(&path).unwrap();
|
|
|
|
assert!(content.starts_with("# IronClaw configuration file."));
|
|
assert!(content.contains("[agent]"));
|
|
assert!(content.contains("[heartbeat]"));
|
|
}
|
|
|
|
#[test]
|
|
fn merge_only_overrides_non_default_values() {
|
|
let mut base = Settings::default();
|
|
base.agent.name = "from-db".to_string();
|
|
base.heartbeat.interval_secs = 600;
|
|
|
|
let mut toml_overlay = Settings::default();
|
|
toml_overlay.agent.name = "from-toml".to_string();
|
|
|
|
base.merge_from(&toml_overlay);
|
|
|
|
assert_eq!(base.agent.name, "from-toml");
|
|
assert_eq!(base.heartbeat.interval_secs, 600);
|
|
}
|
|
|
|
#[test]
|
|
fn merge_preserves_base_when_overlay_is_default() {
|
|
let mut base = Settings::default();
|
|
base.agent.name = "custom-name".to_string();
|
|
base.heartbeat.enabled = true;
|
|
|
|
let overlay = Settings::default();
|
|
base.merge_from(&overlay);
|
|
|
|
assert_eq!(base.agent.name, "custom-name");
|
|
assert!(base.heartbeat.enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn toml_creates_parent_dirs() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("nested").join("deep").join("config.toml");
|
|
|
|
Settings::default().save_toml(&path).unwrap();
|
|
assert!(path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn default_toml_path_under_ironclaw() {
|
|
let path = Settings::default_toml_path();
|
|
assert!(path.to_string_lossy().contains(".ironclaw"));
|
|
assert!(path.to_string_lossy().ends_with("config.toml"));
|
|
}
|
|
|
|
#[test]
|
|
fn tunnel_settings_round_trip() {
|
|
let settings = Settings {
|
|
tunnel: TunnelSettings {
|
|
provider: Some("ngrok".to_string()),
|
|
ngrok_token: Some("tok_abc123".to_string()),
|
|
ngrok_domain: Some("my.ngrok.dev".to_string()),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
|
|
// JSON round-trip
|
|
let json = serde_json::to_string(&settings).unwrap();
|
|
let restored: Settings = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(restored.tunnel.provider, Some("ngrok".to_string()));
|
|
assert_eq!(restored.tunnel.ngrok_token, Some("tok_abc123".to_string()));
|
|
assert_eq!(
|
|
restored.tunnel.ngrok_domain,
|
|
Some("my.ngrok.dev".to_string())
|
|
);
|
|
assert!(restored.tunnel.public_url.is_none());
|
|
|
|
// DB map round-trip
|
|
let map = settings.to_db_map();
|
|
let from_db = Settings::from_db_map(&map);
|
|
assert_eq!(from_db.tunnel.provider, Some("ngrok".to_string()));
|
|
assert_eq!(from_db.tunnel.ngrok_token, Some("tok_abc123".to_string()));
|
|
|
|
// get/set round-trip
|
|
let mut s = Settings::default();
|
|
s.set("tunnel.provider", "cloudflare").unwrap();
|
|
s.set("tunnel.cf_token", "cf_tok_xyz").unwrap();
|
|
s.set("tunnel.ts_funnel", "true").unwrap();
|
|
assert_eq!(s.tunnel.provider, Some("cloudflare".to_string()));
|
|
assert_eq!(s.tunnel.cf_token, Some("cf_tok_xyz".to_string()));
|
|
assert!(s.tunnel.ts_funnel);
|
|
}
|
|
|
|
/// Simulates the wizard recovery scenario:
|
|
///
|
|
/// 1. A prior partial run saved steps 1-4 to the DB
|
|
/// 2. User re-runs the wizard, Step 1 sets a new database_url
|
|
/// 3. Prior settings are loaded from the DB
|
|
/// 4. Step 1's fresh choices must win over stale DB values
|
|
///
|
|
/// This tests the ordering: load DB → merge_from(step1_overrides).
|
|
#[test]
|
|
fn wizard_recovery_step1_overrides_stale_db() {
|
|
// Simulate prior partial run (steps 1-4 completed):
|
|
let prior_run = Settings {
|
|
database_backend: Some("postgres".to_string()),
|
|
database_url: Some("postgres://old-host/ironclaw".to_string()),
|
|
llm_backend: Some("anthropic".to_string()),
|
|
selected_model: Some("claude-sonnet-4-5".to_string()),
|
|
embeddings: EmbeddingsSettings {
|
|
enabled: true,
|
|
provider: "openai".to_string(),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
|
|
// Save to DB and reload (simulates persistence round-trip)
|
|
let db_map = prior_run.to_db_map();
|
|
let from_db = Settings::from_db_map(&db_map);
|
|
|
|
// Step 1 of the new wizard run: user enters a NEW database_url
|
|
let step1_settings = Settings {
|
|
database_backend: Some("postgres".to_string()),
|
|
database_url: Some("postgres://new-host/ironclaw".to_string()),
|
|
..Settings::default()
|
|
};
|
|
|
|
// Wizard flow: load DB → merge_from(step1_overrides)
|
|
let mut current = step1_settings.clone();
|
|
// try_load_existing_settings: merge DB into current
|
|
current.merge_from(&from_db);
|
|
// Re-apply Step 1 choices on top
|
|
current.merge_from(&step1_settings);
|
|
|
|
// Step 1's fresh database_url wins over stale DB value
|
|
assert_eq!(
|
|
current.database_url,
|
|
Some("postgres://new-host/ironclaw".to_string()),
|
|
"Step 1 fresh choice must override stale DB value"
|
|
);
|
|
|
|
// Prior run's steps 2-4 settings are preserved
|
|
assert_eq!(
|
|
current.llm_backend,
|
|
Some("anthropic".to_string()),
|
|
"Prior run's LLM backend must be recovered"
|
|
);
|
|
assert_eq!(
|
|
current.selected_model,
|
|
Some("claude-sonnet-4-5".to_string()),
|
|
"Prior run's model must be recovered"
|
|
);
|
|
assert!(
|
|
current.embeddings.enabled,
|
|
"Prior run's embeddings setting must be recovered"
|
|
);
|
|
}
|
|
|
|
/// Verifies that persisting defaults doesn't clobber prior settings
|
|
/// when the merge ordering is correct.
|
|
#[test]
|
|
fn wizard_recovery_defaults_dont_clobber_prior() {
|
|
// Prior run saved non-default settings
|
|
let prior_run = Settings {
|
|
llm_backend: Some("openai".to_string()),
|
|
selected_model: Some("gpt-4o".to_string()),
|
|
heartbeat: HeartbeatSettings {
|
|
enabled: true,
|
|
interval_secs: 900,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
let db_map = prior_run.to_db_map();
|
|
let from_db = Settings::from_db_map(&db_map);
|
|
|
|
// New wizard run: Step 1 only sets DB fields (rest is default)
|
|
let step1 = Settings {
|
|
database_backend: Some("libsql".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
// Correct merge ordering
|
|
let mut current = step1.clone();
|
|
current.merge_from(&from_db);
|
|
current.merge_from(&step1);
|
|
|
|
// Prior settings preserved (Step 1 doesn't touch these)
|
|
assert_eq!(current.llm_backend, Some("openai".to_string()));
|
|
assert_eq!(current.selected_model, Some("gpt-4o".to_string()));
|
|
assert!(current.heartbeat.enabled);
|
|
assert_eq!(current.heartbeat.interval_secs, 900);
|
|
|
|
// Step 1's choice applied
|
|
assert_eq!(current.database_backend, Some("libsql".to_string()));
|
|
}
|
|
|
|
// === QA Plan P1 - 1.2: Config round-trip tests ===
|
|
|
|
#[test]
|
|
fn comprehensive_db_map_round_trip() {
|
|
// Set a representative value in EVERY section and verify survival
|
|
let settings = Settings {
|
|
onboard_completed: true,
|
|
database_backend: Some("libsql".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()),
|
|
openai_compatible_base_url: Some("http://vllm:8000/v1".to_string()),
|
|
secrets_master_key_source: KeySource::Keychain,
|
|
embeddings: EmbeddingsSettings {
|
|
enabled: true,
|
|
provider: "nearai".to_string(),
|
|
model: "text-embedding-3-large".to_string(),
|
|
},
|
|
tunnel: TunnelSettings {
|
|
provider: Some("ngrok".to_string()),
|
|
ngrok_token: Some("tok_xxx".to_string()),
|
|
..Default::default()
|
|
},
|
|
channels: ChannelSettings {
|
|
http_enabled: true,
|
|
http_port: Some(9090),
|
|
wasm_channel_owner_ids: {
|
|
let mut m = std::collections::HashMap::new();
|
|
m.insert("telegram".to_string(), 12345);
|
|
m
|
|
},
|
|
..Default::default()
|
|
},
|
|
heartbeat: HeartbeatSettings {
|
|
enabled: true,
|
|
interval_secs: 900,
|
|
..Default::default()
|
|
},
|
|
agent: AgentSettings {
|
|
name: "my-bot".to_string(),
|
|
max_parallel_jobs: 10,
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
|
|
let map = settings.to_db_map();
|
|
let restored = Settings::from_db_map(&map);
|
|
|
|
assert!(restored.onboard_completed, "onboard_completed lost");
|
|
assert_eq!(
|
|
restored.database_backend,
|
|
Some("libsql".to_string()),
|
|
"database_backend lost"
|
|
);
|
|
assert_eq!(
|
|
restored.database_url,
|
|
Some("postgres://host/db".to_string()),
|
|
"database_url lost"
|
|
);
|
|
assert_eq!(
|
|
restored.llm_backend,
|
|
Some("anthropic".to_string()),
|
|
"llm_backend lost"
|
|
);
|
|
assert_eq!(
|
|
restored.selected_model,
|
|
Some("claude-sonnet-4-5".to_string()),
|
|
"selected_model lost"
|
|
);
|
|
assert_eq!(
|
|
restored.openai_compatible_base_url,
|
|
Some("http://vllm:8000/v1".to_string()),
|
|
"openai_compatible_base_url lost"
|
|
);
|
|
assert_eq!(
|
|
restored.secrets_master_key_source,
|
|
KeySource::Keychain,
|
|
"key_source lost"
|
|
);
|
|
assert!(restored.embeddings.enabled, "embeddings.enabled lost");
|
|
assert_eq!(
|
|
restored.embeddings.provider, "nearai",
|
|
"embeddings.provider lost"
|
|
);
|
|
assert_eq!(
|
|
restored.embeddings.model, "text-embedding-3-large",
|
|
"embeddings.model lost"
|
|
);
|
|
assert_eq!(
|
|
restored.tunnel.provider,
|
|
Some("ngrok".to_string()),
|
|
"tunnel.provider lost"
|
|
);
|
|
assert!(restored.channels.http_enabled, "http_enabled lost");
|
|
assert_eq!(restored.channels.http_port, Some(9090), "http_port lost");
|
|
assert_eq!(
|
|
restored.channels.wasm_channel_owner_ids.get("telegram"),
|
|
Some(&12345),
|
|
"wasm_channel_owner_ids lost"
|
|
);
|
|
assert!(restored.heartbeat.enabled, "heartbeat.enabled lost");
|
|
assert_eq!(
|
|
restored.heartbeat.interval_secs, 900,
|
|
"heartbeat.interval_secs lost"
|
|
);
|
|
assert_eq!(restored.agent.name, "my-bot", "agent.name lost");
|
|
assert_eq!(
|
|
restored.agent.max_parallel_jobs, 10,
|
|
"agent.max_parallel_jobs lost"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn toml_json_db_all_agree() {
|
|
// A config that goes through all three formats should produce the same values
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let toml_path = dir.path().join("config.toml");
|
|
let json_path = dir.path().join("settings.json");
|
|
|
|
let original = Settings {
|
|
llm_backend: Some("ollama".to_string()),
|
|
selected_model: Some("llama3".to_string()),
|
|
heartbeat: HeartbeatSettings {
|
|
enabled: true,
|
|
interval_secs: 600,
|
|
..Default::default()
|
|
},
|
|
agent: AgentSettings {
|
|
name: "round-trip-bot".to_string(),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
};
|
|
|
|
// TOML round-trip
|
|
original.save_toml(&toml_path).unwrap();
|
|
let from_toml = Settings::load_toml(&toml_path).unwrap().unwrap();
|
|
|
|
// JSON round-trip
|
|
let json = serde_json::to_string_pretty(&original).unwrap();
|
|
std::fs::write(&json_path, &json).unwrap();
|
|
let from_json = Settings::load_from(&json_path);
|
|
|
|
// DB map round-trip
|
|
let db_map = original.to_db_map();
|
|
let from_db = Settings::from_db_map(&db_map);
|
|
|
|
// All three should agree on key values
|
|
for (label, loaded) in [("TOML", &from_toml), ("JSON", &from_json), ("DB", &from_db)] {
|
|
assert_eq!(
|
|
loaded.llm_backend,
|
|
Some("ollama".to_string()),
|
|
"{label}: llm_backend"
|
|
);
|
|
assert_eq!(
|
|
loaded.selected_model,
|
|
Some("llama3".to_string()),
|
|
"{label}: selected_model"
|
|
);
|
|
assert!(loaded.heartbeat.enabled, "{label}: heartbeat.enabled");
|
|
assert_eq!(
|
|
loaded.heartbeat.interval_secs, 600,
|
|
"{label}: heartbeat.interval_secs"
|
|
);
|
|
assert_eq!(loaded.agent.name, "round-trip-bot", "{label}: agent.name");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn set_get_round_trip_all_documented_paths() {
|
|
let mut settings = Settings::default();
|
|
|
|
// Test set + get for each documented settings path
|
|
let test_cases: Vec<(&str, &str)> = vec![
|
|
("agent.name", "test-agent"),
|
|
("agent.max_parallel_jobs", "8"),
|
|
("heartbeat.enabled", "true"),
|
|
("heartbeat.interval_secs", "300"),
|
|
("channels.http_enabled", "true"),
|
|
("channels.http_port", "8081"),
|
|
];
|
|
|
|
for (path, value) in &test_cases {
|
|
settings
|
|
.set(path, value)
|
|
.unwrap_or_else(|e| panic!("set({path}, {value}) failed: {e}"));
|
|
let got = settings
|
|
.get(path)
|
|
.unwrap_or_else(|| panic!("get({path}) returned None after set"));
|
|
assert_eq!(&got, value, "set/get round-trip failed for path '{path}'");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn option_string_fields_survive_db_round_trip_as_null() {
|
|
// When an Option<String> field is None, it should be stored as null
|
|
// and come back as None, not silently become Some("")
|
|
let settings = Settings {
|
|
database_url: None,
|
|
llm_backend: None,
|
|
selected_model: None,
|
|
openai_compatible_base_url: None,
|
|
..Default::default()
|
|
};
|
|
|
|
let map = settings.to_db_map();
|
|
let restored = Settings::from_db_map(&map);
|
|
|
|
assert_eq!(
|
|
restored.database_url, None,
|
|
"None database_url should stay None"
|
|
);
|
|
assert_eq!(
|
|
restored.llm_backend, None,
|
|
"None llm_backend should stay None"
|
|
);
|
|
assert_eq!(
|
|
restored.selected_model, None,
|
|
"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");
|
|
}
|
|
}
|