mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
* feat: Move debug log truncation from agent loop to REPL channel Full tool output now flows through StatusUpdate so the web gateway gets untruncated content. The REPL channel truncates at display time (200 chars for tool results, thinking, and status messages). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Flatten WASM tool schemas and fix host HTTP runtime contention LLMs can't reliably follow oneOf + const discriminator patterns in JSON Schema, causing tools like Google Calendar to receive malformed params (e.g., {"operation":"list_events","data":{"calendarId":"primary"}} instead of {"action":"list_events","calendar_id":"primary"}). Replace all 9 WASM tool schemas with flat action enum + top-level properties. The serde #[serde(tag = "action")] deserialization works identically. Also fixes WASM host HTTP requests (channels and tools) stalling during startup by replacing Handle::current().block_on() with a dedicated single-threaded runtime per request, avoiding I/O driver contention. Reduces verbose LLM debug logging (full request/response payloads) and changes tower_http default from debug to warn. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Built-in OAuth credentials and combined Google scopes Add infrastructure for shipping default OAuth credentials with the binary, similar to how gcloud/rclone bake in their client_id. Credentials are set at compile time via IRONCLAW_GOOGLE_CLIENT_ID / IRONCLAW_GOOGLE_CLIENT_SECRET env vars, or can be hardcoded in src/cli/oauth_defaults.rs. The fallback chain is: capabilities file > runtime env var > built-in defaults. Also, when authing any Google tool, scopes from ALL installed Google tools are now combined into a single OAuth request (they all share the same google_oauth_token secret). One login covers Gmail, Calendar, Drive, etc. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Ship default Google OAuth credentials for zero-config auth Google Desktop App credentials are not secret (per Google's own docs). Hardcode them so `ironclaw tool auth <google-tool>` works out of the box without requiring users to register their own OAuth app. Credentials can still be overridden at compile time (IRONCLAW_GOOGLE_CLIENT_ID) or runtime (GOOGLE_OAUTH_CLIENT_ID). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Consistent OAuth callback port and polished landing page - Use fixed port 9876 instead of scanning 9876-9886 (one redirect URI to register in provider OAuth apps, deterministic behavior) - Replace broken unicode checkmark with SVG icons (charset was missing, rendered as mojibake) - Dark themed landing page with proper card layout for both success and error states - Add charset=utf-8 to Content-Type headers Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: Unify OAuth callback server across all auth flows All three OAuth flows (WASM tool auth, MCP server auth, NEAR AI login) now share the same code from cli::oauth_defaults: - Fixed port 9876 (one redirect URI to register per provider) - Shared landing page HTML (dark card with SVG icons, proper charset) - Parameterized wait_for_callback(listener, path, param, display_name) Removes ~120 lines of duplicated callback/HTML code. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Support for oauth token refresh * refactor: Replace bootstrap.json with ~/.ironclaw/.env for DATABASE_URL Kill the 4-field BootstrapConfig JSON file. Only DATABASE_URL actually needs disk persistence (chicken-and-egg before DB connect). The other three fields are now derived: pool_size defaults to 10 via env var, secrets master key is auto-detected (env then keychain probe), and onboard_completed is inferred from DATABASE_URL presence. The new format is a standard .env file loaded via dotenvy early in main, so DATABASE_URL is available as a regular env var everywhere. Handles three upgrade paths: - Clean start: wizard writes .env, reload after wizard completes - Returning user: .env loaded at startup, business as usual - Legacy upgrade: bootstrap.json auto-migrated to .env on first run Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Address PR review findings - Fix UTF-8 panic in truncate_for_preview (byte-slice on char boundary) - Cap WASM guest timeout_ms at 5 minutes to prevent resource exhaustion - Fix localhost detection in requires_auth() to avoid substring matches (e.g. "notlocalhost.com" no longer matches) - Fix query param injection to insert before URL fragment - Fix extract_host_from_url for IPv6 bracket notation - Remove misleading schema defaults: Slack limit, Slides insertion_index, Docs index (per-action defaults documented in descriptions instead) Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: Fix cargo fmt formatting Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: IPv6 loopback support for OAuth listener and localhost detection - bind_callback_listener: try [::1] first, fall back to 127.0.0.1, so OAuth redirects work on systems where localhost resolves to ::1 - is_localhost_url: replace manual string parsing with url::Url for correct handling of IPv6 brackets, ports, userinfo, etc. - Add url crate as direct dependency (already a transitive dep) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Address PR review feedback on runtime reuse, onboard check, and OAuth binding - Remove session file check from check_onboard_needed(); DATABASE_URL is sufficient - Detect AddrInUse on IPv6 bind and fail immediately instead of falling through to IPv4 - Reuse dedicated tokio runtime across HTTP calls in both tool and channel WASM wrappers Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: HTML-escape provider name in OAuth landing page, simplify Slack limit description - Add html_escape() to prevent XSS in landing_html() where provider_name was interpolated directly into HTML (defense-in-depth, source is trusted but escaping costs nothing) - Remove per-action default numbers from Slack limit field description to avoid confusing LLMs with conflicting defaults Addresses review feedback from zmanian on PR #42. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Save all bootstrap fields from wizard, fix config module comment - Wizard now saves secrets_master_key_source and database_pool_size to bootstrap.json (was only saving database_url and onboard_completed, which broke secrets after fresh onboard since SecretsConfig::resolve reads key source from bootstrap) - Update config.rs module doc to reflect bootstrap.json priority chain instead of the removed ~/.ironclaw/.env approach Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: Replace BootstrapConfig with .env-based bootstrap DATABASE_URL is the only setting that needs disk persistence before the database is available. Instead of a custom bootstrap.json with 4 fields, use a standard ~/.ironclaw/.env file loaded via dotenvy. - Remove BootstrapConfig struct entirely - Restore ironclaw_env_path(), load_ironclaw_env(), save_database_url() - SecretsConfig::resolve() now auto-detects (env var then keychain probe) instead of reading a saved source from bootstrap.json - DatabaseConfig::resolve() reads DATABASE_URL from env only (dotenvy loads ~/.ironclaw/.env into the environment early in startup) - check_onboard_needed() is now sync (just checks env vars) - Wizard save_and_summarize() works for both postgres and libsql backends - One-time migration from bootstrap.json to .env preserved Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Ensure load_ironclaw_env() runs in all Config paths, fix .env priority - Config::from_env() and Config::from_db() now call load_ironclaw_env() internally (after dotenvy::dotenv()), so CLI commands like `memory` and `config` correctly load DATABASE_URL from ~/.ironclaw/.env - Fix load order: standard ./.env first (higher priority), then ~/.ironclaw/.env, matching the documented priority chain - Collapse nested if/if-let into let-chains (clippy::collapsible_if) in oauth_defaults.rs, tool.rs, and secrets/store.rs - Fix rename_to_migrated to take &Path instead of &PathBuf Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Address PR review comments (quoting, SSRF, error mapping) - Quote DATABASE_URL in .env writes so `#` in passwords isn't treated as a dotenv comment (e.g., `DATABASE_URL="postgres://..."`) - Add SSRF defenses to refresh_oauth_token(): require HTTPS, reject private/loopback IPs (with DNS resolution), disable redirects. token_url comes from tool capabilities JSON, so a malicious tool could otherwise exfiltrate refresh tokens. - Fix IPv4 bind error mapping: only map AddrInUse to PortInUse, use generic Io variant for other bind failures Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
862 lines
25 KiB
Rust
862 lines
25 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};
|
|
|
|
/// 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: NEAR AI Auth ===
|
|
// Session stored separately in session.json
|
|
|
|
// === 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,
|
|
}
|
|
|
|
/// 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<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>,
|
|
|
|
/// Telegram owner user ID. When set, the bot only responds to this user.
|
|
/// Captured during setup by having the user message the bot.
|
|
#[serde(default)]
|
|
pub telegram_owner_id: Option<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,
|
|
}
|
|
|
|
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<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>,
|
|
}
|
|
|
|
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<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,
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
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 => "null".to_string(),
|
|
other => other.to_string(),
|
|
};
|
|
|
|
if let Err(e) = settings.set(key, &value_str) {
|
|
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 {
|
|
dirs::home_dir()
|
|
.unwrap_or_else(|| std::path::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 (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(),
|
|
}
|
|
}
|
|
|
|
/// 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()));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[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_telegram_owner_id_db_round_trip() {
|
|
let mut settings = Settings::default();
|
|
settings.channels.telegram_owner_id = Some(123456789);
|
|
|
|
let map = settings.to_db_map();
|
|
let restored = Settings::from_db_map(&map);
|
|
assert_eq!(restored.channels.telegram_owner_id, Some(123456789));
|
|
}
|
|
|
|
#[test]
|
|
fn test_telegram_owner_id_default_none() {
|
|
let settings = Settings::default();
|
|
assert_eq!(settings.channels.telegram_owner_id, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_telegram_owner_id_via_set() {
|
|
let mut settings = Settings::default();
|
|
settings
|
|
.set("channels.telegram_owner_id", "987654321")
|
|
.unwrap();
|
|
assert_eq!(settings.channels.telegram_owner_id, Some(987654321));
|
|
}
|
|
}
|