//! 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}; /// 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 connection URL (postgres://...). #[serde(default)] pub database_url: Option, /// Database pool size. #[serde(default)] pub database_pool_size: Option, // === Step 2: Security === /// Source for the secrets master key. #[serde(default)] pub secrets_master_key_source: KeySource, // === Step 3: NEAR AI Auth === // Session stored separately in session.json // === Step 4: Model Selection === /// Currently selected model. #[serde(default)] pub selected_model: Option, // === 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, } /// 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. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct TunnelSettings { /// Public URL from tunnel provider (e.g., "https://abc123.ngrok.io"). #[serde(default)] pub public_url: Option, } /// 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, /// HTTP webhook host. #[serde(default)] pub http_host: Option, /// 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, /// 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, } /// 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, /// User ID to notify on heartbeat findings. #[serde(default)] pub notify_user: Option, } 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, } 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_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(), } } } /// 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, /// 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, } 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, } 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 { "ghcr.io/nearai/sandbox: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(), } } } /// 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, /// 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, } } } impl Settings { /// Get the default settings file path (~/.ironclaw/settings.json). pub fn default_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".ironclaw") .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. pub fn load_from(path: &PathBuf) -> Self { match std::fs::read_to_string(path) { Ok(data) => serde_json::from_str(&data).unwrap_or_default(), Err(_) => Self::default(), } } /// Save settings to disk. pub fn save(&self) -> std::io::Result<()> { self.save_to(&Self::default_path()) } /// Save settings to a specific path. pub fn save_to(&self, path: &PathBuf) -> std::io::Result<()> { // Ensure parent directory exists if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let json = serde_json::to_string_pretty(self) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?; std::fs::write(path, json) } /// Get the selected model, falling back to the provided default. pub fn model_or(&self, default: &str) -> String { self.selected_model .clone() .unwrap_or_else(|| default.to_string()) } /// Set the selected model and save. pub fn set_model(&mut self, model: &str) -> std::io::Result<()> { self.selected_model = Some(model.to_string()); self.save() } /// Get a setting value by dotted path (e.g., "agent.max_parallel_jobs"). pub fn get(&self, path: &str) -> Option { 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::() .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::().map_err(|_| { format!("Expected integer for {}, got '{}'", path, value) })?; serde_json::Value::Number(n.into()) } else if n.is_i64() { let n = value.parse::().map_err(|_| { format!("Expected integer for {}, got '{}'", path, value) })?; serde_json::Value::Number(n.into()) } else { let n = value.parse::().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, 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 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())); } } } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn test_settings_save_load() { let dir = tempdir().unwrap(); let path = dir.path().join("settings.json"); let settings = Settings { selected_model: Some("claude-3-5-sonnet-20241022".to_string()), ..Default::default() }; settings.save_to(&path).unwrap(); let loaded = Settings::load_from(&path); assert_eq!( loaded.selected_model, Some("claude-3-5-sonnet-20241022".to_string()) ); } #[test] fn test_model_or_default() { let settings = Settings::default(); assert_eq!( settings.model_or("default-model"), "default-model".to_string() ); let settings = Settings { selected_model: Some("my-model".to_string()), ..Default::default() }; assert_eq!(settings.model_or("default-model"), "my-model".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"); } }