mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat(setup): add Anthropic OAuth and Codex OAuth onboarding flows Add OAuth token authentication as an alternative to API keys during onboarding for both Anthropic (via `claude login`) and OpenAI/Codex (via `~/.codex/auth.json`). Key changes: - New `AnthropicOAuthProvider` using `Authorization: Bearer` header (rig-core hardcodes `x-api-key` which rejects OAuth tokens) - Wizard auth method selector: "Direct API Key" vs "OAuth Token" for both Anthropic and OpenAI providers - Codex token extraction from `$CODEX_HOME/auth.json` / `~/.codex/auth.json` - Claude Code sandbox sub-step in Docker setup (checks for credentials) - Secret injection mappings for `ANTHROPIC_OAUTH_TOKEN` and `CODEX_OAUTH_TOKEN` - `CODEX_OAUTH_TOKEN` falls back to `OPENAI_API_KEY` (same Bearer auth) Supersedes #143 which had a broken auth flow (OAuth token sent as x-api-key → 401). Credit to @bigguybobby for the original approach. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: persist OAuth tokens in bootstrap .env and re-extract at startup OAuth tokens stored only in the secrets DB were invisible to Config::from_env() which runs before the DB connects (chicken-and-egg). Two fixes: 1. write_bootstrap_env() now persists ANTHROPIC_OAUTH_TOKEN and CODEX_OAUTH_TOKEN to ~/.ironclaw/.env (same pattern as NEARAI_API_KEY) 2. main.rs re-extracts a fresh token from the OS credential store (macOS Keychain / ~/.claude/.credentials.json) before config resolution, handling token expiry (8-12h) gracefully Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: persist all LLM credentials in bootstrap .env, not just NEAR AI All providers had the same chicken-and-egg issue: API keys stored in the secrets DB were invisible to Config::from_env() which runs before DB connects. Only NEARAI_API_KEY was written to bootstrap .env. Now write_bootstrap_env() persists all credential env vars: NEARAI_API_KEY, ANTHROPIC_API_KEY, ANTHROPIC_OAUTH_TOKEN, OPENAI_API_KEY, CODEX_OAUTH_TOKEN, LLM_API_KEY, TINFOIL_API_KEY. Also: setup_api_key_provider() now sets the env var during the wizard session so write_bootstrap_env() can pick it up. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address security review findings for OAuth onboarding - Extract "oauth-placeholder" to named OAUTH_PLACEHOLDER constant shared across config and wizard to prevent silent drift - Document plaintext credential tradeoff in write_bootstrap_env (API keys stored with 0o600 permissions, recommend full-disk encryption) - Add blocking "Press Enter" wait in Anthropic OAuth retry flow so user has time to run `claude login` in another terminal - Add escape hatch from manual OAuth paste back to API key flow (empty input switches to setup_api_key_provider) - Fix Retry-After header: parse u64 seconds into Duration before passing to LlmError::RateLimited - Make config::llm module pub(crate) for constant visibility - Use .bearer_auth() instead of manual format!("Bearer {}") - Remove response body from debug log (may contain PII) - Update Anthropic API version to 2024-10-22 Co-Authored-By: Claude Opus 4.6 <[email protected]> * security: remove plaintext credentials from bootstrap .env Credentials (API keys, OAuth tokens) were being written in plaintext to ~/.ironclaw/.env to work around a chicken-and-egg problem: Config::from_env() runs before the encrypted secrets DB is connected. Instead of storing secrets on disk, LlmConfig::resolve() now defers gracefully when credentials are missing — it returns None for the provider config instead of hard-erroring with MissingRequired. After the DB connects, AppBuilder::build_all() loads secrets from encrypted storage via inject_llm_keys_from_secrets() and re-resolves the config. For Anthropic OAuth tokens (which expire in 8-12h), the secret injection step also tries the OS credential store (macOS Keychain / Linux credentials.json) for a fresh token, overriding the potentially stale copy in the DB. Changes: - LlmConfig::resolve(): OpenAI, Anthropic, OpenAI-compatible, and Tinfoil all return None instead of MissingRequired when credentials are absent - write_bootstrap_env(): no longer writes any credential env vars - inject_llm_keys_from_secrets(): refreshes Anthropic OAuth from OS credential store before overlay is finalized - main.rs: removed OAuth re-extraction hack (no longer needed) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: load OS credential store tokens even without secrets DB The OAuth token extraction from macOS Keychain / Linux credentials files was only running inside inject_llm_keys_from_secrets(), which requires the encrypted secrets DB. When no master key is configured, init_secrets() returned early — skipping both DB secret loading AND OS credential store extraction, leaving the Anthropic OAuth token unavailable. Split into two paths: - inject_llm_keys_from_secrets(): loads from encrypted DB + OS stores - inject_os_credentials(): loads from OS stores only (no DB needed) init_secrets() now calls inject_os_credentials() and re-resolves config even in the no-master-key early-return path, so `claude login` tokens are always available regardless of secrets DB state. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: add anthropic-beta header required for OAuth authentication Anthropic's api.anthropic.com requires the `anthropic-beta: oauth-2025-04-20` header to accept OAuth Bearer tokens. Without it, the API returns 401 "OAuth authentication is currently not supported." Also reverts API version to 2023-06-01 since the OAuth beta flag does not support the 2024-10-22 version (returns 400 "not a valid version"). This was the same bug that caused PR #143's 401 errors — the beta header was missing entirely. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Anthropic and OpenAI model resolution respects selected_model The Anthropic and OpenAI config resolution ignored settings.selected_model entirely, only checking the provider-specific env var (ANTHROPIC_MODEL, OPENAI_MODEL) and falling back to a hardcoded default. This meant the model chosen during onboarding wizard was silently overridden. Now follows the same pattern as NearAI and OpenAI-compatible: env var > settings.selected_model > hardcoded default. Also deduplicated the Anthropic config construction (two identical branches for API key vs OAuth now share model/base_url resolution). Co-Authored-By: Claude Opus 4.6 <[email protected]> * test: add provider resolution tests for all LLM backends Covers deferred resolution (no credentials → None instead of error), credential presence, model selection fallback chain, and OAuth token routing for Anthropic, OpenAI, Tinfoil, Ollama, and NearAI. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: handle nested tokens.access_token format in Codex auth.json Codex CLI stores OAuth tokens in a nested format under tokens.access_token (ChatGPT OAuth flow), not at the top level. Also adds ENV_MUTEX to Codex token tests for thread safety. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: remove Codex OAuth onboarding (incompatible with OpenAI API) Codex CLI OAuth tokens use a different endpoint (chatgpt.com/backend-api/codex) and the Responses API wire format, not api.openai.com with Chat Completions. The tokens lack the model.request scope needed for the platform API, so they can't be used as drop-in OPENAI_API_KEY replacements. Removes: extract_codex_oauth_token(), wizard Codex OAuth flow, CODEX_OAUTH_TOKEN env var support, and related tests. OpenAI onboarding now uses direct API key only. Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: fix formatting for CI (cargo fmt) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address Gemini review feedback - Use ? operator for ANTHROPIC_MODEL/BASE_URL env resolution instead of .ok().flatten() to propagate ConfigErrors consistently - Skip Tool messages without tool_call_id with a warning instead of using unwrap_or_default() which would send empty string to Anthropic - Extract credential check into closure to reduce duplication in Claude Code sandbox setup Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor(review): address PR review feedback for OAuth onboarding - Gate ANTHROPIC_OAUTH_TOKEN resolution to Anthropic provider only (was needlessly checked for all registry providers) - Add 3 regression tests for OAuth config resolution: - oauth_token sets placeholder api_key - real api_key takes priority over oauth - non-Anthropic providers don't pick up oauth_token - Validate OAuth token prefix (sk-ant-oat) in wizard to catch accidentally pasted API keys - Improve error body read handling in AnthropicOAuthProvider (was silently swallowing read errors with unwrap_or_default) - Remove extra blank line in write_bootstrap_env - Remove stale blank line in RegistryProviderConfig doc comment [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR #384 review comments Blocker: - Replace OnceLock<HashMap> with LazyLock<Mutex<HashMap>> for INJECTED_VARS so both inject_os_credentials() and inject_llm_keys_from_secrets() merge data instead of the second caller silently dropping its entries. High: - Add 401 retry with OS credential store re-extraction in AnthropicOAuthProvider, recovering from expired OAuth tokens (~8-12h) without manual intervention. - Fix comment in app.rs: ~/.codex/auth.json → ~/.claude/.credentials.json. Medium: - Remove unsafe { std::env::set_var } from wizard; use thread-safe inject_single_var() overlay instead (safe on multi-threaded Tokio). - Add post-init validation in AppBuilder: fail early with clear error when LLM_BACKEND is set but no credentials were resolved after secret injection. - Add sk-ant-oat prefix validation in parse_oauth_access_token(). - Only route to AnthropicOAuthProvider when api_key is missing or equals OAUTH_PLACEHOLDER (API key takes priority over OAuth token). - Teach fetch_anthropic_models() to use Bearer auth when only OAuth token is available (model listing no longer fails for OAuth-only users). Low: - Use optional_env() in wizard credential checks to read from injected overlay, not just raw env vars. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: cargo fmt Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]> Co-authored-by: [email protected] <[email protected]>
1632 lines
52 KiB
Rust
1632 lines
52 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,
|
|
|
|
// === 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,
|
|
|
|
// === Step 3: Inference Provider ===
|
|
/// LLM backend: "nearai", "anthropic", "openai", "ollama", "openai_compatible".
|
|
#[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>,
|
|
|
|
// === 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,
|
|
|
|
// === 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, Default)]
|
|
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 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>,
|
|
}
|
|
|
|
/// 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>,
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
|
|
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_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,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
// 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
|
|
}
|
|
|
|
/// 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();
|
|
if parts.is_empty() {
|
|
return Err("Empty path".to_string());
|
|
}
|
|
|
|
// Navigate to parent and set the final key
|
|
let mut current = &mut json;
|
|
for part in &parts[..parts.len() - 1] {
|
|
current = current
|
|
.get_mut(*part)
|
|
.ok_or_else(|| format!("Path not found: {}", path))?;
|
|
}
|
|
|
|
let final_key = parts.last().unwrap();
|
|
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);
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
}
|