Adds mention_only config option to Telegram channel, allowing the bot to only respond to messages that @-mention the bot in group chats. Direct messages are always processed regardless of this setting. Behavior: - When mention_only = true: Bot only responds to group messages containing @botname - When mention_only = false (default): Bot responds to all allowed messages - DM/private chats always work regardless of mention_only setting Implementation: - Fetch and cache bot username from Telegram API on startup - Check for @botname mention in group messages - Strip mention from message content before processing Config example: [channels.telegram] bot_token = "your_token" mention_only = true Changes: - src/config/schema.rs: Add mention_only to TelegramConfig - src/channels/telegram.rs: Implement mention_only logic + 6 new tests - src/channels/mod.rs: Update factory calls - src/cron/scheduler.rs: Update constructor call - src/onboard/wizard.rs: Update wizard config - src/daemon/mod.rs: Update test config - src/integrations/registry.rs: Update test config - TESTING_TELEGRAM.md: Add mention_only test section - CHANGELOG.md: Document feature Risk: medium Backward compatible: Yes (default: false)
4094 lines
136 KiB
Rust
4094 lines
136 KiB
Rust
use crate::providers::{is_glm_alias, is_zai_alias};
|
||
use crate::security::AutonomyLevel;
|
||
use anyhow::{Context, Result};
|
||
use directories::UserDirs;
|
||
use serde::{Deserialize, Serialize};
|
||
use std::collections::HashMap;
|
||
use std::fs::{self, File, OpenOptions};
|
||
use std::io::Write;
|
||
use std::path::{Path, PathBuf};
|
||
|
||
// ── Top-level config ──────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct Config {
|
||
/// Workspace directory - computed from home, not serialized
|
||
#[serde(skip)]
|
||
pub workspace_dir: PathBuf,
|
||
/// Path to config.toml - computed from home, not serialized
|
||
#[serde(skip)]
|
||
pub config_path: PathBuf,
|
||
pub api_key: Option<String>,
|
||
/// Base URL override for provider API (e.g. "http://10.0.0.1:11434" for remote Ollama)
|
||
pub api_url: Option<String>,
|
||
pub default_provider: Option<String>,
|
||
pub default_model: Option<String>,
|
||
pub default_temperature: f64,
|
||
|
||
#[serde(default)]
|
||
pub observability: ObservabilityConfig,
|
||
|
||
#[serde(default)]
|
||
pub autonomy: AutonomyConfig,
|
||
|
||
#[serde(default)]
|
||
pub runtime: RuntimeConfig,
|
||
|
||
#[serde(default)]
|
||
pub reliability: ReliabilityConfig,
|
||
|
||
#[serde(default)]
|
||
pub scheduler: SchedulerConfig,
|
||
|
||
#[serde(default)]
|
||
pub agent: AgentConfig,
|
||
|
||
/// Model routing rules — route `hint:<name>` to specific provider+model combos.
|
||
#[serde(default)]
|
||
pub model_routes: Vec<ModelRouteConfig>,
|
||
|
||
/// Automatic query classification — maps user messages to model hints.
|
||
#[serde(default)]
|
||
pub query_classification: QueryClassificationConfig,
|
||
|
||
#[serde(default)]
|
||
pub heartbeat: HeartbeatConfig,
|
||
|
||
#[serde(default)]
|
||
pub cron: CronConfig,
|
||
|
||
#[serde(default)]
|
||
pub channels_config: ChannelsConfig,
|
||
|
||
#[serde(default)]
|
||
pub memory: MemoryConfig,
|
||
|
||
#[serde(default)]
|
||
pub tunnel: TunnelConfig,
|
||
|
||
#[serde(default)]
|
||
pub gateway: GatewayConfig,
|
||
|
||
#[serde(default)]
|
||
pub composio: ComposioConfig,
|
||
|
||
#[serde(default)]
|
||
pub secrets: SecretsConfig,
|
||
|
||
#[serde(default)]
|
||
pub browser: BrowserConfig,
|
||
|
||
#[serde(default)]
|
||
pub http_request: HttpRequestConfig,
|
||
|
||
#[serde(default)]
|
||
pub web_search: WebSearchConfig,
|
||
|
||
#[serde(default)]
|
||
pub identity: IdentityConfig,
|
||
|
||
#[serde(default)]
|
||
pub cost: CostConfig,
|
||
|
||
#[serde(default)]
|
||
pub peripherals: PeripheralsConfig,
|
||
|
||
/// Delegate agent configurations for multi-agent workflows.
|
||
#[serde(default)]
|
||
pub agents: HashMap<String, DelegateAgentConfig>,
|
||
|
||
/// Hardware configuration (wizard-driven physical world setup).
|
||
#[serde(default)]
|
||
pub hardware: HardwareConfig,
|
||
}
|
||
|
||
// ── Delegate Agents ──────────────────────────────────────────────
|
||
|
||
/// Configuration for a delegate sub-agent used by the `delegate` tool.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct DelegateAgentConfig {
|
||
/// Provider name (e.g. "ollama", "openrouter", "anthropic")
|
||
pub provider: String,
|
||
/// Model name
|
||
pub model: String,
|
||
/// Optional system prompt for the sub-agent
|
||
#[serde(default)]
|
||
pub system_prompt: Option<String>,
|
||
/// Optional API key override
|
||
#[serde(default)]
|
||
pub api_key: Option<String>,
|
||
/// Temperature override
|
||
#[serde(default)]
|
||
pub temperature: Option<f64>,
|
||
/// Max recursion depth for nested delegation
|
||
#[serde(default = "default_max_depth")]
|
||
pub max_depth: u32,
|
||
}
|
||
|
||
fn default_max_depth() -> u32 {
|
||
3
|
||
}
|
||
|
||
// ── Hardware Config (wizard-driven) ─────────────────────────────
|
||
|
||
/// Hardware transport mode.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||
pub enum HardwareTransport {
|
||
#[default]
|
||
None,
|
||
Native,
|
||
Serial,
|
||
Probe,
|
||
}
|
||
|
||
impl std::fmt::Display for HardwareTransport {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
Self::None => write!(f, "none"),
|
||
Self::Native => write!(f, "native"),
|
||
Self::Serial => write!(f, "serial"),
|
||
Self::Probe => write!(f, "probe"),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Wizard-driven hardware configuration for physical world interaction.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct HardwareConfig {
|
||
/// Whether hardware access is enabled
|
||
#[serde(default)]
|
||
pub enabled: bool,
|
||
/// Transport mode
|
||
#[serde(default)]
|
||
pub transport: HardwareTransport,
|
||
/// Serial port path (e.g. "/dev/ttyACM0")
|
||
#[serde(default)]
|
||
pub serial_port: Option<String>,
|
||
/// Serial baud rate
|
||
#[serde(default = "default_baud_rate")]
|
||
pub baud_rate: u32,
|
||
/// Probe target chip (e.g. "STM32F401RE")
|
||
#[serde(default)]
|
||
pub probe_target: Option<String>,
|
||
/// Enable workspace datasheet RAG (index PDF schematics for AI pin lookups)
|
||
#[serde(default)]
|
||
pub workspace_datasheets: bool,
|
||
}
|
||
|
||
fn default_baud_rate() -> u32 {
|
||
115_200
|
||
}
|
||
|
||
impl HardwareConfig {
|
||
/// Return the active transport mode.
|
||
pub fn transport_mode(&self) -> HardwareTransport {
|
||
self.transport.clone()
|
||
}
|
||
}
|
||
|
||
impl Default for HardwareConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
enabled: false,
|
||
transport: HardwareTransport::None,
|
||
serial_port: None,
|
||
baud_rate: default_baud_rate(),
|
||
probe_target: None,
|
||
workspace_datasheets: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct AgentConfig {
|
||
/// When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models.
|
||
#[serde(default)]
|
||
pub compact_context: bool,
|
||
#[serde(default = "default_agent_max_tool_iterations")]
|
||
pub max_tool_iterations: usize,
|
||
#[serde(default = "default_agent_max_history_messages")]
|
||
pub max_history_messages: usize,
|
||
#[serde(default)]
|
||
pub parallel_tools: bool,
|
||
#[serde(default = "default_agent_tool_dispatcher")]
|
||
pub tool_dispatcher: String,
|
||
}
|
||
|
||
fn default_agent_max_tool_iterations() -> usize {
|
||
10
|
||
}
|
||
|
||
fn default_agent_max_history_messages() -> usize {
|
||
50
|
||
}
|
||
|
||
fn default_agent_tool_dispatcher() -> String {
|
||
"auto".into()
|
||
}
|
||
|
||
impl Default for AgentConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
compact_context: false,
|
||
max_tool_iterations: default_agent_max_tool_iterations(),
|
||
max_history_messages: default_agent_max_history_messages(),
|
||
parallel_tools: false,
|
||
tool_dispatcher: default_agent_tool_dispatcher(),
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Identity (AIEOS / OpenClaw format) ──────────────────────────
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct IdentityConfig {
|
||
/// Identity format: "openclaw" (default) or "aieos"
|
||
#[serde(default = "default_identity_format")]
|
||
pub format: String,
|
||
/// Path to AIEOS JSON file (relative to workspace)
|
||
#[serde(default)]
|
||
pub aieos_path: Option<String>,
|
||
/// Inline AIEOS JSON (alternative to file path)
|
||
#[serde(default)]
|
||
pub aieos_inline: Option<String>,
|
||
}
|
||
|
||
fn default_identity_format() -> String {
|
||
"openclaw".into()
|
||
}
|
||
|
||
impl Default for IdentityConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
format: default_identity_format(),
|
||
aieos_path: None,
|
||
aieos_inline: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Cost tracking and budget enforcement ───────────────────────────
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct CostConfig {
|
||
/// Enable cost tracking (default: false)
|
||
#[serde(default)]
|
||
pub enabled: bool,
|
||
|
||
/// Daily spending limit in USD (default: 10.00)
|
||
#[serde(default = "default_daily_limit")]
|
||
pub daily_limit_usd: f64,
|
||
|
||
/// Monthly spending limit in USD (default: 100.00)
|
||
#[serde(default = "default_monthly_limit")]
|
||
pub monthly_limit_usd: f64,
|
||
|
||
/// Warn when spending reaches this percentage of limit (default: 80)
|
||
#[serde(default = "default_warn_percent")]
|
||
pub warn_at_percent: u8,
|
||
|
||
/// Allow requests to exceed budget with --override flag (default: false)
|
||
#[serde(default)]
|
||
pub allow_override: bool,
|
||
|
||
/// Per-model pricing (USD per 1M tokens)
|
||
#[serde(default)]
|
||
pub prices: std::collections::HashMap<String, ModelPricing>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ModelPricing {
|
||
/// Input price per 1M tokens
|
||
#[serde(default)]
|
||
pub input: f64,
|
||
|
||
/// Output price per 1M tokens
|
||
#[serde(default)]
|
||
pub output: f64,
|
||
}
|
||
|
||
fn default_daily_limit() -> f64 {
|
||
10.0
|
||
}
|
||
|
||
fn default_monthly_limit() -> f64 {
|
||
100.0
|
||
}
|
||
|
||
fn default_warn_percent() -> u8 {
|
||
80
|
||
}
|
||
|
||
impl Default for CostConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
enabled: false,
|
||
daily_limit_usd: default_daily_limit(),
|
||
monthly_limit_usd: default_monthly_limit(),
|
||
warn_at_percent: default_warn_percent(),
|
||
allow_override: false,
|
||
prices: get_default_pricing(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Default pricing for popular models (USD per 1M tokens)
|
||
fn get_default_pricing() -> std::collections::HashMap<String, ModelPricing> {
|
||
let mut prices = std::collections::HashMap::new();
|
||
|
||
// Anthropic models
|
||
prices.insert(
|
||
"anthropic/claude-sonnet-4-20250514".into(),
|
||
ModelPricing {
|
||
input: 3.0,
|
||
output: 15.0,
|
||
},
|
||
);
|
||
prices.insert(
|
||
"anthropic/claude-opus-4-20250514".into(),
|
||
ModelPricing {
|
||
input: 15.0,
|
||
output: 75.0,
|
||
},
|
||
);
|
||
prices.insert(
|
||
"anthropic/claude-3.5-sonnet".into(),
|
||
ModelPricing {
|
||
input: 3.0,
|
||
output: 15.0,
|
||
},
|
||
);
|
||
prices.insert(
|
||
"anthropic/claude-3-haiku".into(),
|
||
ModelPricing {
|
||
input: 0.25,
|
||
output: 1.25,
|
||
},
|
||
);
|
||
|
||
// OpenAI models
|
||
prices.insert(
|
||
"openai/gpt-4o".into(),
|
||
ModelPricing {
|
||
input: 5.0,
|
||
output: 15.0,
|
||
},
|
||
);
|
||
prices.insert(
|
||
"openai/gpt-4o-mini".into(),
|
||
ModelPricing {
|
||
input: 0.15,
|
||
output: 0.60,
|
||
},
|
||
);
|
||
prices.insert(
|
||
"openai/o1-preview".into(),
|
||
ModelPricing {
|
||
input: 15.0,
|
||
output: 60.0,
|
||
},
|
||
);
|
||
|
||
// Google models
|
||
prices.insert(
|
||
"google/gemini-2.0-flash".into(),
|
||
ModelPricing {
|
||
input: 0.10,
|
||
output: 0.40,
|
||
},
|
||
);
|
||
prices.insert(
|
||
"google/gemini-1.5-pro".into(),
|
||
ModelPricing {
|
||
input: 1.25,
|
||
output: 5.0,
|
||
},
|
||
);
|
||
|
||
prices
|
||
}
|
||
|
||
// ── Peripherals (hardware: STM32, RPi GPIO, etc.) ────────────────────────
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||
pub struct PeripheralsConfig {
|
||
/// Enable peripheral support (boards become agent tools)
|
||
#[serde(default)]
|
||
pub enabled: bool,
|
||
/// Board configurations (nucleo-f401re, rpi-gpio, etc.)
|
||
#[serde(default)]
|
||
pub boards: Vec<PeripheralBoardConfig>,
|
||
/// Path to datasheet docs (relative to workspace) for RAG retrieval.
|
||
/// Place .md/.txt files named by board (e.g. nucleo-f401re.md, rpi-gpio.md).
|
||
#[serde(default)]
|
||
pub datasheet_dir: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct PeripheralBoardConfig {
|
||
/// Board type: "nucleo-f401re", "rpi-gpio", "esp32", etc.
|
||
pub board: String,
|
||
/// Transport: "serial", "native", "websocket"
|
||
#[serde(default = "default_peripheral_transport")]
|
||
pub transport: String,
|
||
/// Path for serial: "/dev/ttyACM0", "/dev/ttyUSB0"
|
||
#[serde(default)]
|
||
pub path: Option<String>,
|
||
/// Baud rate for serial (default: 115200)
|
||
#[serde(default = "default_peripheral_baud")]
|
||
pub baud: u32,
|
||
}
|
||
|
||
fn default_peripheral_transport() -> String {
|
||
"serial".into()
|
||
}
|
||
|
||
fn default_peripheral_baud() -> u32 {
|
||
115_200
|
||
}
|
||
|
||
impl Default for PeripheralBoardConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
board: String::new(),
|
||
transport: default_peripheral_transport(),
|
||
path: None,
|
||
baud: default_peripheral_baud(),
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Gateway security ─────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct GatewayConfig {
|
||
/// Gateway port (default: 3000)
|
||
#[serde(default = "default_gateway_port")]
|
||
pub port: u16,
|
||
/// Gateway host (default: 127.0.0.1)
|
||
#[serde(default = "default_gateway_host")]
|
||
pub host: String,
|
||
/// Require pairing before accepting requests (default: true)
|
||
#[serde(default = "default_true")]
|
||
pub require_pairing: bool,
|
||
/// Allow binding to non-localhost without a tunnel (default: false)
|
||
#[serde(default)]
|
||
pub allow_public_bind: bool,
|
||
/// Paired bearer tokens (managed automatically, not user-edited)
|
||
#[serde(default)]
|
||
pub paired_tokens: Vec<String>,
|
||
|
||
/// Max `/pair` requests per minute per client key.
|
||
#[serde(default = "default_pair_rate_limit")]
|
||
pub pair_rate_limit_per_minute: u32,
|
||
|
||
/// Max `/webhook` requests per minute per client key.
|
||
#[serde(default = "default_webhook_rate_limit")]
|
||
pub webhook_rate_limit_per_minute: u32,
|
||
|
||
/// Trust proxy-forwarded client IP headers (`X-Forwarded-For`, `X-Real-IP`).
|
||
/// Disabled by default; enable only behind a trusted reverse proxy.
|
||
#[serde(default)]
|
||
pub trust_forwarded_headers: bool,
|
||
|
||
/// Maximum distinct client keys tracked by gateway rate limiter maps.
|
||
#[serde(default = "default_gateway_rate_limit_max_keys")]
|
||
pub rate_limit_max_keys: usize,
|
||
|
||
/// TTL for webhook idempotency keys.
|
||
#[serde(default = "default_idempotency_ttl_secs")]
|
||
pub idempotency_ttl_secs: u64,
|
||
|
||
/// Maximum distinct idempotency keys retained in memory.
|
||
#[serde(default = "default_gateway_idempotency_max_keys")]
|
||
pub idempotency_max_keys: usize,
|
||
}
|
||
|
||
fn default_gateway_port() -> u16 {
|
||
3000
|
||
}
|
||
|
||
fn default_gateway_host() -> String {
|
||
"127.0.0.1".into()
|
||
}
|
||
|
||
fn default_pair_rate_limit() -> u32 {
|
||
10
|
||
}
|
||
|
||
fn default_webhook_rate_limit() -> u32 {
|
||
60
|
||
}
|
||
|
||
fn default_idempotency_ttl_secs() -> u64 {
|
||
300
|
||
}
|
||
|
||
fn default_gateway_rate_limit_max_keys() -> usize {
|
||
10_000
|
||
}
|
||
|
||
fn default_gateway_idempotency_max_keys() -> usize {
|
||
10_000
|
||
}
|
||
|
||
fn default_true() -> bool {
|
||
true
|
||
}
|
||
|
||
impl Default for GatewayConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
port: default_gateway_port(),
|
||
host: default_gateway_host(),
|
||
require_pairing: true,
|
||
allow_public_bind: false,
|
||
paired_tokens: Vec::new(),
|
||
pair_rate_limit_per_minute: default_pair_rate_limit(),
|
||
webhook_rate_limit_per_minute: default_webhook_rate_limit(),
|
||
trust_forwarded_headers: false,
|
||
rate_limit_max_keys: default_gateway_rate_limit_max_keys(),
|
||
idempotency_ttl_secs: default_idempotency_ttl_secs(),
|
||
idempotency_max_keys: default_gateway_idempotency_max_keys(),
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Composio (managed tool surface) ─────────────────────────────
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ComposioConfig {
|
||
/// Enable Composio integration for 1000+ OAuth tools
|
||
#[serde(default)]
|
||
pub enabled: bool,
|
||
/// Composio API key (stored encrypted when secrets.encrypt = true)
|
||
#[serde(default)]
|
||
pub api_key: Option<String>,
|
||
/// Default entity ID for multi-user setups
|
||
#[serde(default = "default_entity_id")]
|
||
pub entity_id: String,
|
||
}
|
||
|
||
fn default_entity_id() -> String {
|
||
"default".into()
|
||
}
|
||
|
||
impl Default for ComposioConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
enabled: false,
|
||
api_key: None,
|
||
entity_id: default_entity_id(),
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Secrets (encrypted credential store) ────────────────────────
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct SecretsConfig {
|
||
/// Enable encryption for API keys and tokens in config.toml
|
||
#[serde(default = "default_true")]
|
||
pub encrypt: bool,
|
||
}
|
||
|
||
impl Default for SecretsConfig {
|
||
fn default() -> Self {
|
||
Self { encrypt: true }
|
||
}
|
||
}
|
||
|
||
// ── Browser (friendly-service browsing only) ───────────────────
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct BrowserComputerUseConfig {
|
||
/// Sidecar endpoint for computer-use actions (OS-level mouse/keyboard/screenshot)
|
||
#[serde(default = "default_browser_computer_use_endpoint")]
|
||
pub endpoint: String,
|
||
/// Optional bearer token for computer-use sidecar
|
||
#[serde(default)]
|
||
pub api_key: Option<String>,
|
||
/// Per-action request timeout in milliseconds
|
||
#[serde(default = "default_browser_computer_use_timeout_ms")]
|
||
pub timeout_ms: u64,
|
||
/// Allow remote/public endpoint for computer-use sidecar (default: false)
|
||
#[serde(default)]
|
||
pub allow_remote_endpoint: bool,
|
||
/// Optional window title/process allowlist forwarded to sidecar policy
|
||
#[serde(default)]
|
||
pub window_allowlist: Vec<String>,
|
||
/// Optional X-axis boundary for coordinate-based actions
|
||
#[serde(default)]
|
||
pub max_coordinate_x: Option<i64>,
|
||
/// Optional Y-axis boundary for coordinate-based actions
|
||
#[serde(default)]
|
||
pub max_coordinate_y: Option<i64>,
|
||
}
|
||
|
||
fn default_browser_computer_use_endpoint() -> String {
|
||
"http://127.0.0.1:8787/v1/actions".into()
|
||
}
|
||
|
||
fn default_browser_computer_use_timeout_ms() -> u64 {
|
||
15_000
|
||
}
|
||
|
||
impl Default for BrowserComputerUseConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
endpoint: default_browser_computer_use_endpoint(),
|
||
api_key: None,
|
||
timeout_ms: default_browser_computer_use_timeout_ms(),
|
||
allow_remote_endpoint: false,
|
||
window_allowlist: Vec::new(),
|
||
max_coordinate_x: None,
|
||
max_coordinate_y: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct BrowserConfig {
|
||
/// Enable `browser_open` tool (opens URLs in Brave without scraping)
|
||
#[serde(default)]
|
||
pub enabled: bool,
|
||
/// Allowed domains for `browser_open` (exact or subdomain match)
|
||
#[serde(default)]
|
||
pub allowed_domains: Vec<String>,
|
||
/// Browser session name (for agent-browser automation)
|
||
#[serde(default)]
|
||
pub session_name: Option<String>,
|
||
/// Browser automation backend: "agent_browser" | "rust_native" | "computer_use" | "auto"
|
||
#[serde(default = "default_browser_backend")]
|
||
pub backend: String,
|
||
/// Headless mode for rust-native backend
|
||
#[serde(default = "default_true")]
|
||
pub native_headless: bool,
|
||
/// WebDriver endpoint URL for rust-native backend (e.g. http://127.0.0.1:9515)
|
||
#[serde(default = "default_browser_webdriver_url")]
|
||
pub native_webdriver_url: String,
|
||
/// Optional Chrome/Chromium executable path for rust-native backend
|
||
#[serde(default)]
|
||
pub native_chrome_path: Option<String>,
|
||
/// Computer-use sidecar configuration
|
||
#[serde(default)]
|
||
pub computer_use: BrowserComputerUseConfig,
|
||
}
|
||
|
||
fn default_browser_backend() -> String {
|
||
"agent_browser".into()
|
||
}
|
||
|
||
fn default_browser_webdriver_url() -> String {
|
||
"http://127.0.0.1:9515".into()
|
||
}
|
||
|
||
impl Default for BrowserConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
enabled: false,
|
||
allowed_domains: Vec::new(),
|
||
session_name: None,
|
||
backend: default_browser_backend(),
|
||
native_headless: default_true(),
|
||
native_webdriver_url: default_browser_webdriver_url(),
|
||
native_chrome_path: None,
|
||
computer_use: BrowserComputerUseConfig::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── HTTP request tool ───────────────────────────────────────────
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||
pub struct HttpRequestConfig {
|
||
/// Enable `http_request` tool for API interactions
|
||
#[serde(default)]
|
||
pub enabled: bool,
|
||
/// Allowed domains for HTTP requests (exact or subdomain match)
|
||
#[serde(default)]
|
||
pub allowed_domains: Vec<String>,
|
||
/// Maximum response size in bytes (default: 1MB)
|
||
#[serde(default = "default_http_max_response_size")]
|
||
pub max_response_size: usize,
|
||
/// Request timeout in seconds (default: 30)
|
||
#[serde(default = "default_http_timeout_secs")]
|
||
pub timeout_secs: u64,
|
||
}
|
||
|
||
fn default_http_max_response_size() -> usize {
|
||
1_000_000 // 1MB
|
||
}
|
||
|
||
fn default_http_timeout_secs() -> u64 {
|
||
30
|
||
}
|
||
|
||
// ── Web search ───────────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct WebSearchConfig {
|
||
/// Enable `web_search_tool` for web searches
|
||
#[serde(default = "default_true")]
|
||
pub enabled: bool,
|
||
/// Search provider: "duckduckgo" (free, no API key) or "brave" (requires API key)
|
||
#[serde(default = "default_web_search_provider")]
|
||
pub provider: String,
|
||
/// Brave Search API key (required if provider is "brave")
|
||
#[serde(default)]
|
||
pub brave_api_key: Option<String>,
|
||
/// Maximum results per search (1-10)
|
||
#[serde(default = "default_web_search_max_results")]
|
||
pub max_results: usize,
|
||
/// Request timeout in seconds
|
||
#[serde(default = "default_web_search_timeout_secs")]
|
||
pub timeout_secs: u64,
|
||
}
|
||
|
||
fn default_web_search_provider() -> String {
|
||
"duckduckgo".into()
|
||
}
|
||
|
||
fn default_web_search_max_results() -> usize {
|
||
5
|
||
}
|
||
|
||
fn default_web_search_timeout_secs() -> u64 {
|
||
15
|
||
}
|
||
|
||
impl Default for WebSearchConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
enabled: true,
|
||
provider: default_web_search_provider(),
|
||
brave_api_key: None,
|
||
max_results: default_web_search_max_results(),
|
||
timeout_secs: default_web_search_timeout_secs(),
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Memory ───────────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[allow(clippy::struct_excessive_bools)]
|
||
pub struct MemoryConfig {
|
||
/// "sqlite" | "lucid" | "markdown" | "none" (`none` = explicit no-op memory)
|
||
pub backend: String,
|
||
/// Auto-save conversation context to memory
|
||
pub auto_save: bool,
|
||
/// Run memory/session hygiene (archiving + retention cleanup)
|
||
#[serde(default = "default_hygiene_enabled")]
|
||
pub hygiene_enabled: bool,
|
||
/// Archive daily/session files older than this many days
|
||
#[serde(default = "default_archive_after_days")]
|
||
pub archive_after_days: u32,
|
||
/// Purge archived files older than this many days
|
||
#[serde(default = "default_purge_after_days")]
|
||
pub purge_after_days: u32,
|
||
/// For sqlite backend: prune conversation rows older than this many days
|
||
#[serde(default = "default_conversation_retention_days")]
|
||
pub conversation_retention_days: u32,
|
||
/// Embedding provider: "none" | "openai" | "custom:URL"
|
||
#[serde(default = "default_embedding_provider")]
|
||
pub embedding_provider: String,
|
||
/// Embedding model name (e.g. "text-embedding-3-small")
|
||
#[serde(default = "default_embedding_model")]
|
||
pub embedding_model: String,
|
||
/// Embedding vector dimensions
|
||
#[serde(default = "default_embedding_dims")]
|
||
pub embedding_dimensions: usize,
|
||
/// Weight for vector similarity in hybrid search (0.0–1.0)
|
||
#[serde(default = "default_vector_weight")]
|
||
pub vector_weight: f64,
|
||
/// Weight for keyword BM25 in hybrid search (0.0–1.0)
|
||
#[serde(default = "default_keyword_weight")]
|
||
pub keyword_weight: f64,
|
||
/// Minimum hybrid score (0.0–1.0) for a memory to be included in context.
|
||
/// Memories scoring below this threshold are dropped to prevent irrelevant
|
||
/// context from bleeding into conversations. Default: 0.4
|
||
#[serde(default = "default_min_relevance_score")]
|
||
pub min_relevance_score: f64,
|
||
/// Max embedding cache entries before LRU eviction
|
||
#[serde(default = "default_cache_size")]
|
||
pub embedding_cache_size: usize,
|
||
/// Max tokens per chunk for document splitting
|
||
#[serde(default = "default_chunk_size")]
|
||
pub chunk_max_tokens: usize,
|
||
|
||
// ── Response Cache (saves tokens on repeated prompts) ──────
|
||
/// Enable LLM response caching to avoid paying for duplicate prompts
|
||
#[serde(default)]
|
||
pub response_cache_enabled: bool,
|
||
/// TTL in minutes for cached responses (default: 60)
|
||
#[serde(default = "default_response_cache_ttl")]
|
||
pub response_cache_ttl_minutes: u32,
|
||
/// Max number of cached responses before LRU eviction (default: 5000)
|
||
#[serde(default = "default_response_cache_max")]
|
||
pub response_cache_max_entries: usize,
|
||
|
||
// ── Memory Snapshot (soul backup to Markdown) ─────────────
|
||
/// Enable periodic export of core memories to MEMORY_SNAPSHOT.md
|
||
#[serde(default)]
|
||
pub snapshot_enabled: bool,
|
||
/// Run snapshot during hygiene passes (heartbeat-driven)
|
||
#[serde(default)]
|
||
pub snapshot_on_hygiene: bool,
|
||
/// Auto-hydrate from MEMORY_SNAPSHOT.md when brain.db is missing
|
||
#[serde(default = "default_true")]
|
||
pub auto_hydrate: bool,
|
||
|
||
// ── SQLite backend options ─────────────────────────────────
|
||
/// For sqlite backend: max seconds to wait when opening the DB (e.g. file locked).
|
||
/// None = wait indefinitely (default). Recommended max: 300.
|
||
#[serde(default)]
|
||
pub sqlite_open_timeout_secs: Option<u64>,
|
||
}
|
||
|
||
fn default_embedding_provider() -> String {
|
||
"none".into()
|
||
}
|
||
fn default_hygiene_enabled() -> bool {
|
||
true
|
||
}
|
||
fn default_archive_after_days() -> u32 {
|
||
7
|
||
}
|
||
fn default_purge_after_days() -> u32 {
|
||
30
|
||
}
|
||
fn default_conversation_retention_days() -> u32 {
|
||
30
|
||
}
|
||
fn default_embedding_model() -> String {
|
||
"text-embedding-3-small".into()
|
||
}
|
||
fn default_embedding_dims() -> usize {
|
||
1536
|
||
}
|
||
fn default_vector_weight() -> f64 {
|
||
0.7
|
||
}
|
||
fn default_keyword_weight() -> f64 {
|
||
0.3
|
||
}
|
||
fn default_min_relevance_score() -> f64 {
|
||
0.4
|
||
}
|
||
fn default_cache_size() -> usize {
|
||
10_000
|
||
}
|
||
fn default_chunk_size() -> usize {
|
||
512
|
||
}
|
||
fn default_response_cache_ttl() -> u32 {
|
||
60
|
||
}
|
||
fn default_response_cache_max() -> usize {
|
||
5_000
|
||
}
|
||
|
||
impl Default for MemoryConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
backend: "sqlite".into(),
|
||
auto_save: true,
|
||
hygiene_enabled: default_hygiene_enabled(),
|
||
archive_after_days: default_archive_after_days(),
|
||
purge_after_days: default_purge_after_days(),
|
||
conversation_retention_days: default_conversation_retention_days(),
|
||
embedding_provider: default_embedding_provider(),
|
||
embedding_model: default_embedding_model(),
|
||
embedding_dimensions: default_embedding_dims(),
|
||
vector_weight: default_vector_weight(),
|
||
keyword_weight: default_keyword_weight(),
|
||
min_relevance_score: default_min_relevance_score(),
|
||
embedding_cache_size: default_cache_size(),
|
||
chunk_max_tokens: default_chunk_size(),
|
||
response_cache_enabled: false,
|
||
response_cache_ttl_minutes: default_response_cache_ttl(),
|
||
response_cache_max_entries: default_response_cache_max(),
|
||
snapshot_enabled: false,
|
||
snapshot_on_hygiene: false,
|
||
auto_hydrate: true,
|
||
sqlite_open_timeout_secs: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Observability ─────────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ObservabilityConfig {
|
||
/// "none" | "log" | "prometheus" | "otel"
|
||
pub backend: String,
|
||
|
||
/// OTLP endpoint (e.g. "http://localhost:4318"). Only used when backend = "otel".
|
||
#[serde(default)]
|
||
pub otel_endpoint: Option<String>,
|
||
|
||
/// Service name reported to the OTel collector. Defaults to "zeroclaw".
|
||
#[serde(default)]
|
||
pub otel_service_name: Option<String>,
|
||
}
|
||
|
||
impl Default for ObservabilityConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
backend: "none".into(),
|
||
otel_endpoint: None,
|
||
otel_service_name: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Autonomy / Security ──────────────────────────────────────────
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct AutonomyConfig {
|
||
pub level: AutonomyLevel,
|
||
pub workspace_only: bool,
|
||
pub allowed_commands: Vec<String>,
|
||
pub forbidden_paths: Vec<String>,
|
||
pub max_actions_per_hour: u32,
|
||
pub max_cost_per_day_cents: u32,
|
||
|
||
/// Require explicit approval for medium-risk shell commands.
|
||
#[serde(default = "default_true")]
|
||
pub require_approval_for_medium_risk: bool,
|
||
|
||
/// Block high-risk shell commands even if allowlisted.
|
||
#[serde(default = "default_true")]
|
||
pub block_high_risk_commands: bool,
|
||
|
||
/// Tools that never require approval (e.g. read-only tools).
|
||
#[serde(default = "default_auto_approve")]
|
||
pub auto_approve: Vec<String>,
|
||
|
||
/// Tools that always require interactive approval, even after "Always".
|
||
#[serde(default = "default_always_ask")]
|
||
pub always_ask: Vec<String>,
|
||
}
|
||
|
||
fn default_auto_approve() -> Vec<String> {
|
||
vec!["file_read".into(), "memory_recall".into()]
|
||
}
|
||
|
||
fn default_always_ask() -> Vec<String> {
|
||
vec![]
|
||
}
|
||
|
||
impl Default for AutonomyConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
level: AutonomyLevel::Supervised,
|
||
workspace_only: true,
|
||
allowed_commands: vec![
|
||
"git".into(),
|
||
"npm".into(),
|
||
"cargo".into(),
|
||
"ls".into(),
|
||
"cat".into(),
|
||
"grep".into(),
|
||
"find".into(),
|
||
"echo".into(),
|
||
"pwd".into(),
|
||
"wc".into(),
|
||
"head".into(),
|
||
"tail".into(),
|
||
],
|
||
forbidden_paths: vec![
|
||
"/etc".into(),
|
||
"/root".into(),
|
||
"/home".into(),
|
||
"/usr".into(),
|
||
"/bin".into(),
|
||
"/sbin".into(),
|
||
"/lib".into(),
|
||
"/opt".into(),
|
||
"/boot".into(),
|
||
"/dev".into(),
|
||
"/proc".into(),
|
||
"/sys".into(),
|
||
"/var".into(),
|
||
"/tmp".into(),
|
||
"~/.ssh".into(),
|
||
"~/.gnupg".into(),
|
||
"~/.aws".into(),
|
||
"~/.config".into(),
|
||
],
|
||
max_actions_per_hour: 20,
|
||
max_cost_per_day_cents: 500,
|
||
require_approval_for_medium_risk: true,
|
||
block_high_risk_commands: true,
|
||
auto_approve: default_auto_approve(),
|
||
always_ask: default_always_ask(),
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Runtime ──────────────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct RuntimeConfig {
|
||
/// Runtime kind (`native` | `docker`).
|
||
#[serde(default = "default_runtime_kind")]
|
||
pub kind: String,
|
||
|
||
/// Docker runtime settings (used when `kind = "docker"`).
|
||
#[serde(default)]
|
||
pub docker: DockerRuntimeConfig,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct DockerRuntimeConfig {
|
||
/// Runtime image used to execute shell commands.
|
||
#[serde(default = "default_docker_image")]
|
||
pub image: String,
|
||
|
||
/// Docker network mode (`none`, `bridge`, etc.).
|
||
#[serde(default = "default_docker_network")]
|
||
pub network: String,
|
||
|
||
/// Optional memory limit in MB (`None` = no explicit limit).
|
||
#[serde(default = "default_docker_memory_limit_mb")]
|
||
pub memory_limit_mb: Option<u64>,
|
||
|
||
/// Optional CPU limit (`None` = no explicit limit).
|
||
#[serde(default = "default_docker_cpu_limit")]
|
||
pub cpu_limit: Option<f64>,
|
||
|
||
/// Mount root filesystem as read-only.
|
||
#[serde(default = "default_true")]
|
||
pub read_only_rootfs: bool,
|
||
|
||
/// Mount configured workspace into `/workspace`.
|
||
#[serde(default = "default_true")]
|
||
pub mount_workspace: bool,
|
||
|
||
/// Optional workspace root allowlist for Docker mount validation.
|
||
#[serde(default)]
|
||
pub allowed_workspace_roots: Vec<String>,
|
||
}
|
||
|
||
fn default_runtime_kind() -> String {
|
||
"native".into()
|
||
}
|
||
|
||
fn default_docker_image() -> String {
|
||
"alpine:3.20".into()
|
||
}
|
||
|
||
fn default_docker_network() -> String {
|
||
"none".into()
|
||
}
|
||
|
||
fn default_docker_memory_limit_mb() -> Option<u64> {
|
||
Some(512)
|
||
}
|
||
|
||
fn default_docker_cpu_limit() -> Option<f64> {
|
||
Some(1.0)
|
||
}
|
||
|
||
impl Default for DockerRuntimeConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
image: default_docker_image(),
|
||
network: default_docker_network(),
|
||
memory_limit_mb: default_docker_memory_limit_mb(),
|
||
cpu_limit: default_docker_cpu_limit(),
|
||
read_only_rootfs: true,
|
||
mount_workspace: true,
|
||
allowed_workspace_roots: Vec::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for RuntimeConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
kind: default_runtime_kind(),
|
||
docker: DockerRuntimeConfig::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Reliability / supervision ────────────────────────────────────
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ReliabilityConfig {
|
||
/// Retries per provider before failing over.
|
||
#[serde(default = "default_provider_retries")]
|
||
pub provider_retries: u32,
|
||
/// Base backoff (ms) for provider retry delay.
|
||
#[serde(default = "default_provider_backoff_ms")]
|
||
pub provider_backoff_ms: u64,
|
||
/// Fallback provider chain (e.g. `["anthropic", "openai"]`).
|
||
#[serde(default)]
|
||
pub fallback_providers: Vec<String>,
|
||
/// Additional API keys for round-robin rotation on rate-limit (429) errors.
|
||
/// The primary `api_key` is always tried first; these are extras.
|
||
#[serde(default)]
|
||
pub api_keys: Vec<String>,
|
||
/// Per-model fallback chains. When a model fails, try these alternatives in order.
|
||
/// Example: `{ "claude-opus-4-20250514" = ["claude-sonnet-4-20250514", "gpt-4o"] }`
|
||
#[serde(default)]
|
||
pub model_fallbacks: std::collections::HashMap<String, Vec<String>>,
|
||
/// Initial backoff for channel/daemon restarts.
|
||
#[serde(default = "default_channel_backoff_secs")]
|
||
pub channel_initial_backoff_secs: u64,
|
||
/// Max backoff for channel/daemon restarts.
|
||
#[serde(default = "default_channel_backoff_max_secs")]
|
||
pub channel_max_backoff_secs: u64,
|
||
/// Scheduler polling cadence in seconds.
|
||
#[serde(default = "default_scheduler_poll_secs")]
|
||
pub scheduler_poll_secs: u64,
|
||
/// Max retries for cron job execution attempts.
|
||
#[serde(default = "default_scheduler_retries")]
|
||
pub scheduler_retries: u32,
|
||
}
|
||
|
||
fn default_provider_retries() -> u32 {
|
||
2
|
||
}
|
||
|
||
fn default_provider_backoff_ms() -> u64 {
|
||
500
|
||
}
|
||
|
||
fn default_channel_backoff_secs() -> u64 {
|
||
2
|
||
}
|
||
|
||
fn default_channel_backoff_max_secs() -> u64 {
|
||
60
|
||
}
|
||
|
||
fn default_scheduler_poll_secs() -> u64 {
|
||
15
|
||
}
|
||
|
||
fn default_scheduler_retries() -> u32 {
|
||
2
|
||
}
|
||
|
||
impl Default for ReliabilityConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
provider_retries: default_provider_retries(),
|
||
provider_backoff_ms: default_provider_backoff_ms(),
|
||
fallback_providers: Vec::new(),
|
||
api_keys: Vec::new(),
|
||
model_fallbacks: std::collections::HashMap::new(),
|
||
channel_initial_backoff_secs: default_channel_backoff_secs(),
|
||
channel_max_backoff_secs: default_channel_backoff_max_secs(),
|
||
scheduler_poll_secs: default_scheduler_poll_secs(),
|
||
scheduler_retries: default_scheduler_retries(),
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Scheduler ────────────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct SchedulerConfig {
|
||
/// Enable the built-in scheduler loop.
|
||
#[serde(default = "default_scheduler_enabled")]
|
||
pub enabled: bool,
|
||
/// Maximum number of persisted scheduled tasks.
|
||
#[serde(default = "default_scheduler_max_tasks")]
|
||
pub max_tasks: usize,
|
||
/// Maximum tasks executed per scheduler polling cycle.
|
||
#[serde(default = "default_scheduler_max_concurrent")]
|
||
pub max_concurrent: usize,
|
||
}
|
||
|
||
fn default_scheduler_enabled() -> bool {
|
||
true
|
||
}
|
||
|
||
fn default_scheduler_max_tasks() -> usize {
|
||
64
|
||
}
|
||
|
||
fn default_scheduler_max_concurrent() -> usize {
|
||
4
|
||
}
|
||
|
||
impl Default for SchedulerConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
enabled: default_scheduler_enabled(),
|
||
max_tasks: default_scheduler_max_tasks(),
|
||
max_concurrent: default_scheduler_max_concurrent(),
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Model routing ────────────────────────────────────────────────
|
||
|
||
/// Route a task hint to a specific provider + model.
|
||
///
|
||
/// ```toml
|
||
/// [[model_routes]]
|
||
/// hint = "reasoning"
|
||
/// provider = "openrouter"
|
||
/// model = "anthropic/claude-opus-4-20250514"
|
||
///
|
||
/// [[model_routes]]
|
||
/// hint = "fast"
|
||
/// provider = "groq"
|
||
/// model = "llama-3.3-70b-versatile"
|
||
/// ```
|
||
///
|
||
/// Usage: pass `hint:reasoning` as the model parameter to route the request.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ModelRouteConfig {
|
||
/// Task hint name (e.g. "reasoning", "fast", "code", "summarize")
|
||
pub hint: String,
|
||
/// Provider to route to (must match a known provider name)
|
||
pub provider: String,
|
||
/// Model to use with that provider
|
||
pub model: String,
|
||
/// Optional API key override for this route's provider
|
||
#[serde(default)]
|
||
pub api_key: Option<String>,
|
||
}
|
||
|
||
// ── Query Classification ─────────────────────────────────────────
|
||
|
||
/// Automatic query classification — classifies user messages by keyword/pattern
|
||
/// and routes to the appropriate model hint. Disabled by default.
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||
pub struct QueryClassificationConfig {
|
||
#[serde(default)]
|
||
pub enabled: bool,
|
||
#[serde(default)]
|
||
pub rules: Vec<ClassificationRule>,
|
||
}
|
||
|
||
/// A single classification rule mapping message patterns to a model hint.
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||
pub struct ClassificationRule {
|
||
/// Must match a `[[model_routes]]` hint value.
|
||
pub hint: String,
|
||
/// Case-insensitive substring matches.
|
||
#[serde(default)]
|
||
pub keywords: Vec<String>,
|
||
/// Case-sensitive literal matches (for "```", "fn ", etc.).
|
||
#[serde(default)]
|
||
pub patterns: Vec<String>,
|
||
/// Only match if message length >= N chars.
|
||
#[serde(default)]
|
||
pub min_length: Option<usize>,
|
||
/// Only match if message length <= N chars.
|
||
#[serde(default)]
|
||
pub max_length: Option<usize>,
|
||
/// Higher priority rules are checked first.
|
||
#[serde(default)]
|
||
pub priority: i32,
|
||
}
|
||
|
||
// ── Heartbeat ────────────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct HeartbeatConfig {
|
||
pub enabled: bool,
|
||
pub interval_minutes: u32,
|
||
}
|
||
|
||
impl Default for HeartbeatConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
enabled: false,
|
||
interval_minutes: 30,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Cron ────────────────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct CronConfig {
|
||
#[serde(default = "default_true")]
|
||
pub enabled: bool,
|
||
#[serde(default = "default_max_run_history")]
|
||
pub max_run_history: u32,
|
||
}
|
||
|
||
fn default_max_run_history() -> u32 {
|
||
50
|
||
}
|
||
|
||
impl Default for CronConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
enabled: true,
|
||
max_run_history: default_max_run_history(),
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Tunnel ──────────────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct TunnelConfig {
|
||
/// "none", "cloudflare", "tailscale", "ngrok", "custom"
|
||
pub provider: String,
|
||
|
||
#[serde(default)]
|
||
pub cloudflare: Option<CloudflareTunnelConfig>,
|
||
|
||
#[serde(default)]
|
||
pub tailscale: Option<TailscaleTunnelConfig>,
|
||
|
||
#[serde(default)]
|
||
pub ngrok: Option<NgrokTunnelConfig>,
|
||
|
||
#[serde(default)]
|
||
pub custom: Option<CustomTunnelConfig>,
|
||
}
|
||
|
||
impl Default for TunnelConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
provider: "none".into(),
|
||
cloudflare: None,
|
||
tailscale: None,
|
||
ngrok: None,
|
||
custom: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct CloudflareTunnelConfig {
|
||
/// Cloudflare Tunnel token (from Zero Trust dashboard)
|
||
pub token: String,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct TailscaleTunnelConfig {
|
||
/// Use Tailscale Funnel (public internet) vs Serve (tailnet only)
|
||
#[serde(default)]
|
||
pub funnel: bool,
|
||
/// Optional hostname override
|
||
pub hostname: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct NgrokTunnelConfig {
|
||
/// ngrok auth token
|
||
pub auth_token: String,
|
||
/// Optional custom domain
|
||
pub domain: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct CustomTunnelConfig {
|
||
/// Command template to start the tunnel. Use {port} and {host} placeholders.
|
||
/// Example: "bore local {port} --to bore.pub"
|
||
pub start_command: String,
|
||
/// Optional URL to check tunnel health
|
||
pub health_url: Option<String>,
|
||
/// Optional regex to extract public URL from command stdout
|
||
pub url_pattern: Option<String>,
|
||
}
|
||
|
||
// ── Channels ─────────────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ChannelsConfig {
|
||
pub cli: bool,
|
||
pub telegram: Option<TelegramConfig>,
|
||
pub discord: Option<DiscordConfig>,
|
||
pub slack: Option<SlackConfig>,
|
||
pub mattermost: Option<MattermostConfig>,
|
||
pub webhook: Option<WebhookConfig>,
|
||
pub imessage: Option<IMessageConfig>,
|
||
pub matrix: Option<MatrixConfig>,
|
||
pub signal: Option<SignalConfig>,
|
||
pub whatsapp: Option<WhatsAppConfig>,
|
||
pub email: Option<crate::channels::email_channel::EmailConfig>,
|
||
pub irc: Option<IrcConfig>,
|
||
pub lark: Option<LarkConfig>,
|
||
pub dingtalk: Option<DingTalkConfig>,
|
||
pub qq: Option<QQConfig>,
|
||
}
|
||
|
||
impl Default for ChannelsConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
cli: true,
|
||
telegram: None,
|
||
discord: None,
|
||
slack: None,
|
||
mattermost: None,
|
||
webhook: None,
|
||
imessage: None,
|
||
matrix: None,
|
||
signal: None,
|
||
whatsapp: None,
|
||
email: None,
|
||
irc: None,
|
||
lark: None,
|
||
dingtalk: None,
|
||
qq: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Streaming mode for channels that support progressive message updates.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum StreamMode {
|
||
/// No streaming -- send the complete response as a single message (default).
|
||
#[default]
|
||
Off,
|
||
/// Update a draft message with every flush interval.
|
||
Partial,
|
||
}
|
||
|
||
fn default_draft_update_interval_ms() -> u64 {
|
||
1000
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct TelegramConfig {
|
||
pub bot_token: String,
|
||
pub allowed_users: Vec<String>,
|
||
/// Streaming mode for progressive response delivery via message edits.
|
||
#[serde(default)]
|
||
pub stream_mode: StreamMode,
|
||
/// Minimum interval (ms) between draft message edits to avoid rate limits.
|
||
#[serde(default = "default_draft_update_interval_ms")]
|
||
pub draft_update_interval_ms: u64,
|
||
/// When true, only respond to messages that @-mention the bot in groups.
|
||
/// Direct messages are always processed.
|
||
#[serde(default)]
|
||
pub mention_only: bool,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct DiscordConfig {
|
||
pub bot_token: String,
|
||
pub guild_id: Option<String>,
|
||
#[serde(default)]
|
||
pub allowed_users: Vec<String>,
|
||
/// When true, process messages from other bots (not just humans).
|
||
/// The bot still ignores its own messages to prevent feedback loops.
|
||
#[serde(default)]
|
||
pub listen_to_bots: bool,
|
||
/// When true, only respond to messages that @-mention the bot.
|
||
/// Other messages in the guild are silently ignored.
|
||
#[serde(default)]
|
||
pub mention_only: bool,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct SlackConfig {
|
||
pub bot_token: String,
|
||
pub app_token: Option<String>,
|
||
pub channel_id: Option<String>,
|
||
#[serde(default)]
|
||
pub allowed_users: Vec<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct MattermostConfig {
|
||
pub url: String,
|
||
pub bot_token: String,
|
||
pub channel_id: Option<String>,
|
||
#[serde(default)]
|
||
pub allowed_users: Vec<String>,
|
||
/// When true (default), replies thread on the original post.
|
||
/// When false, replies go to the channel root.
|
||
#[serde(default)]
|
||
pub thread_replies: Option<bool>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct WebhookConfig {
|
||
pub port: u16,
|
||
pub secret: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct IMessageConfig {
|
||
pub allowed_contacts: Vec<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct MatrixConfig {
|
||
pub homeserver: String,
|
||
pub access_token: String,
|
||
pub room_id: String,
|
||
pub allowed_users: Vec<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct SignalConfig {
|
||
/// Base URL for the signal-cli HTTP daemon (e.g. "http://127.0.0.1:8686").
|
||
pub http_url: String,
|
||
/// E.164 phone number of the signal-cli account (e.g. "+1234567890").
|
||
pub account: String,
|
||
/// Optional group ID to filter messages.
|
||
/// - `None` or omitted: accept all messages (DMs and groups)
|
||
/// - `"dm"`: only accept direct messages
|
||
/// - Specific group ID: only accept messages from that group
|
||
#[serde(default)]
|
||
pub group_id: Option<String>,
|
||
/// Allowed sender phone numbers (E.164) or "*" for all.
|
||
#[serde(default)]
|
||
pub allowed_from: Vec<String>,
|
||
/// Skip messages that are attachment-only (no text body).
|
||
#[serde(default)]
|
||
pub ignore_attachments: bool,
|
||
/// Skip incoming story messages.
|
||
#[serde(default)]
|
||
pub ignore_stories: bool,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct WhatsAppConfig {
|
||
/// Access token from Meta Business Suite
|
||
pub access_token: String,
|
||
/// Phone number ID from Meta Business API
|
||
pub phone_number_id: String,
|
||
/// Webhook verify token (you define this, Meta sends it back for verification)
|
||
pub verify_token: String,
|
||
/// App secret from Meta Business Suite (for webhook signature verification)
|
||
/// Can also be set via `ZEROCLAW_WHATSAPP_APP_SECRET` environment variable
|
||
#[serde(default)]
|
||
pub app_secret: Option<String>,
|
||
/// Allowed phone numbers (E.164 format: +1234567890) or "*" for all
|
||
#[serde(default)]
|
||
pub allowed_numbers: Vec<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct IrcConfig {
|
||
/// IRC server hostname
|
||
pub server: String,
|
||
/// IRC server port (default: 6697 for TLS)
|
||
#[serde(default = "default_irc_port")]
|
||
pub port: u16,
|
||
/// Bot nickname
|
||
pub nickname: String,
|
||
/// Username (defaults to nickname if not set)
|
||
pub username: Option<String>,
|
||
/// Channels to join on connect
|
||
#[serde(default)]
|
||
pub channels: Vec<String>,
|
||
/// Allowed nicknames (case-insensitive) or "*" for all
|
||
#[serde(default)]
|
||
pub allowed_users: Vec<String>,
|
||
/// Server password (for bouncers like ZNC)
|
||
pub server_password: Option<String>,
|
||
/// NickServ IDENTIFY password
|
||
pub nickserv_password: Option<String>,
|
||
/// SASL PLAIN password (IRCv3)
|
||
pub sasl_password: Option<String>,
|
||
/// Verify TLS certificate (default: true)
|
||
pub verify_tls: Option<bool>,
|
||
}
|
||
|
||
fn default_irc_port() -> u16 {
|
||
6697
|
||
}
|
||
|
||
/// How ZeroClaw receives events from Feishu / Lark.
|
||
///
|
||
/// - `websocket` (default) — persistent WSS long-connection; no public URL required.
|
||
/// - `webhook` — HTTP callback server; requires a public HTTPS endpoint.
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum LarkReceiveMode {
|
||
#[default]
|
||
Websocket,
|
||
Webhook,
|
||
}
|
||
|
||
/// Lark/Feishu configuration for messaging integration.
|
||
/// Lark is the international version; Feishu is the Chinese version.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct LarkConfig {
|
||
/// App ID from Lark/Feishu developer console
|
||
pub app_id: String,
|
||
/// App Secret from Lark/Feishu developer console
|
||
pub app_secret: String,
|
||
/// Encrypt key for webhook message decryption (optional)
|
||
#[serde(default)]
|
||
pub encrypt_key: Option<String>,
|
||
/// Verification token for webhook validation (optional)
|
||
#[serde(default)]
|
||
pub verification_token: Option<String>,
|
||
/// Allowed user IDs or union IDs (empty = deny all, "*" = allow all)
|
||
#[serde(default)]
|
||
pub allowed_users: Vec<String>,
|
||
/// Whether to use the Feishu (Chinese) endpoint instead of Lark (International)
|
||
#[serde(default)]
|
||
pub use_feishu: bool,
|
||
/// Event receive mode: "websocket" (default) or "webhook"
|
||
#[serde(default)]
|
||
pub receive_mode: LarkReceiveMode,
|
||
/// HTTP port for webhook mode only. Must be set when receive_mode = "webhook".
|
||
/// Not required (and ignored) for websocket mode.
|
||
#[serde(default)]
|
||
pub port: Option<u16>,
|
||
}
|
||
|
||
// ── Security Config ─────────────────────────────────────────────────
|
||
|
||
/// Security configuration for sandboxing, resource limits, and audit logging
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||
pub struct SecurityConfig {
|
||
/// Sandbox configuration
|
||
#[serde(default)]
|
||
pub sandbox: SandboxConfig,
|
||
|
||
/// Resource limits
|
||
#[serde(default)]
|
||
pub resources: ResourceLimitsConfig,
|
||
|
||
/// Audit logging configuration
|
||
#[serde(default)]
|
||
pub audit: AuditConfig,
|
||
}
|
||
|
||
/// Sandbox configuration for OS-level isolation
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct SandboxConfig {
|
||
/// Enable sandboxing (None = auto-detect, Some = explicit)
|
||
#[serde(default)]
|
||
pub enabled: Option<bool>,
|
||
|
||
/// Sandbox backend to use
|
||
#[serde(default)]
|
||
pub backend: SandboxBackend,
|
||
|
||
/// Custom Firejail arguments (when backend = firejail)
|
||
#[serde(default)]
|
||
pub firejail_args: Vec<String>,
|
||
}
|
||
|
||
impl Default for SandboxConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
enabled: None, // Auto-detect
|
||
backend: SandboxBackend::Auto,
|
||
firejail_args: Vec::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Sandbox backend selection
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum SandboxBackend {
|
||
/// Auto-detect best available (default)
|
||
#[default]
|
||
Auto,
|
||
/// Landlock (Linux kernel LSM, native)
|
||
Landlock,
|
||
/// Firejail (user-space sandbox)
|
||
Firejail,
|
||
/// Bubblewrap (user namespaces)
|
||
Bubblewrap,
|
||
/// Docker container isolation
|
||
Docker,
|
||
/// No sandboxing (application-layer only)
|
||
None,
|
||
}
|
||
|
||
/// Resource limits for command execution
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ResourceLimitsConfig {
|
||
/// Maximum memory in MB per command
|
||
#[serde(default = "default_max_memory_mb")]
|
||
pub max_memory_mb: u32,
|
||
|
||
/// Maximum CPU time in seconds per command
|
||
#[serde(default = "default_max_cpu_time_seconds")]
|
||
pub max_cpu_time_seconds: u64,
|
||
|
||
/// Maximum number of subprocesses
|
||
#[serde(default = "default_max_subprocesses")]
|
||
pub max_subprocesses: u32,
|
||
|
||
/// Enable memory monitoring
|
||
#[serde(default = "default_memory_monitoring_enabled")]
|
||
pub memory_monitoring: bool,
|
||
}
|
||
|
||
fn default_max_memory_mb() -> u32 {
|
||
512
|
||
}
|
||
|
||
fn default_max_cpu_time_seconds() -> u64 {
|
||
60
|
||
}
|
||
|
||
fn default_max_subprocesses() -> u32 {
|
||
10
|
||
}
|
||
|
||
fn default_memory_monitoring_enabled() -> bool {
|
||
true
|
||
}
|
||
|
||
impl Default for ResourceLimitsConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
max_memory_mb: default_max_memory_mb(),
|
||
max_cpu_time_seconds: default_max_cpu_time_seconds(),
|
||
max_subprocesses: default_max_subprocesses(),
|
||
memory_monitoring: default_memory_monitoring_enabled(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Audit logging configuration
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct AuditConfig {
|
||
/// Enable audit logging
|
||
#[serde(default = "default_audit_enabled")]
|
||
pub enabled: bool,
|
||
|
||
/// Path to audit log file (relative to zeroclaw dir)
|
||
#[serde(default = "default_audit_log_path")]
|
||
pub log_path: String,
|
||
|
||
/// Maximum log size in MB before rotation
|
||
#[serde(default = "default_audit_max_size_mb")]
|
||
pub max_size_mb: u32,
|
||
|
||
/// Sign events with HMAC for tamper evidence
|
||
#[serde(default)]
|
||
pub sign_events: bool,
|
||
}
|
||
|
||
fn default_audit_enabled() -> bool {
|
||
true
|
||
}
|
||
|
||
fn default_audit_log_path() -> String {
|
||
"audit.log".to_string()
|
||
}
|
||
|
||
fn default_audit_max_size_mb() -> u32 {
|
||
100
|
||
}
|
||
|
||
impl Default for AuditConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
enabled: default_audit_enabled(),
|
||
log_path: default_audit_log_path(),
|
||
max_size_mb: default_audit_max_size_mb(),
|
||
sign_events: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// DingTalk configuration for Stream Mode messaging
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct DingTalkConfig {
|
||
/// Client ID (AppKey) from DingTalk developer console
|
||
pub client_id: String,
|
||
/// Client Secret (AppSecret) from DingTalk developer console
|
||
pub client_secret: String,
|
||
/// Allowed user IDs (staff IDs). Empty = deny all, "*" = allow all
|
||
#[serde(default)]
|
||
pub allowed_users: Vec<String>,
|
||
}
|
||
|
||
/// QQ Official Bot configuration (Tencent QQ Bot SDK)
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct QQConfig {
|
||
/// App ID from QQ Bot developer console
|
||
pub app_id: String,
|
||
/// App Secret from QQ Bot developer console
|
||
pub app_secret: String,
|
||
/// Allowed user IDs. Empty = deny all, "*" = allow all
|
||
#[serde(default)]
|
||
pub allowed_users: Vec<String>,
|
||
}
|
||
|
||
// ── Config impl ──────────────────────────────────────────────────
|
||
|
||
impl Default for Config {
|
||
fn default() -> Self {
|
||
let home =
|
||
UserDirs::new().map_or_else(|| PathBuf::from("."), |u| u.home_dir().to_path_buf());
|
||
let zeroclaw_dir = home.join(".zeroclaw");
|
||
|
||
Self {
|
||
workspace_dir: zeroclaw_dir.join("workspace"),
|
||
config_path: zeroclaw_dir.join("config.toml"),
|
||
api_key: None,
|
||
api_url: None,
|
||
default_provider: Some("openrouter".to_string()),
|
||
default_model: Some("anthropic/claude-sonnet-4".to_string()),
|
||
default_temperature: 0.7,
|
||
observability: ObservabilityConfig::default(),
|
||
autonomy: AutonomyConfig::default(),
|
||
runtime: RuntimeConfig::default(),
|
||
reliability: ReliabilityConfig::default(),
|
||
scheduler: SchedulerConfig::default(),
|
||
agent: AgentConfig::default(),
|
||
model_routes: Vec::new(),
|
||
heartbeat: HeartbeatConfig::default(),
|
||
cron: CronConfig::default(),
|
||
channels_config: ChannelsConfig::default(),
|
||
memory: MemoryConfig::default(),
|
||
tunnel: TunnelConfig::default(),
|
||
gateway: GatewayConfig::default(),
|
||
composio: ComposioConfig::default(),
|
||
secrets: SecretsConfig::default(),
|
||
browser: BrowserConfig::default(),
|
||
http_request: HttpRequestConfig::default(),
|
||
web_search: WebSearchConfig::default(),
|
||
identity: IdentityConfig::default(),
|
||
cost: CostConfig::default(),
|
||
peripherals: PeripheralsConfig::default(),
|
||
agents: HashMap::new(),
|
||
hardware: HardwareConfig::default(),
|
||
query_classification: QueryClassificationConfig::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn default_config_and_workspace_dirs() -> Result<(PathBuf, PathBuf)> {
|
||
let config_dir = default_config_dir()?;
|
||
Ok((config_dir.clone(), config_dir.join("workspace")))
|
||
}
|
||
|
||
const ACTIVE_WORKSPACE_STATE_FILE: &str = "active_workspace.toml";
|
||
|
||
#[derive(Debug, Serialize, Deserialize)]
|
||
struct ActiveWorkspaceState {
|
||
config_dir: String,
|
||
}
|
||
|
||
fn default_config_dir() -> Result<PathBuf> {
|
||
let home = UserDirs::new()
|
||
.map(|u| u.home_dir().to_path_buf())
|
||
.context("Could not find home directory")?;
|
||
Ok(home.join(".zeroclaw"))
|
||
}
|
||
|
||
fn active_workspace_state_path(default_dir: &Path) -> PathBuf {
|
||
default_dir.join(ACTIVE_WORKSPACE_STATE_FILE)
|
||
}
|
||
|
||
fn load_persisted_workspace_dirs(default_config_dir: &Path) -> Result<Option<(PathBuf, PathBuf)>> {
|
||
let state_path = active_workspace_state_path(default_config_dir);
|
||
if !state_path.exists() {
|
||
return Ok(None);
|
||
}
|
||
|
||
let contents = match fs::read_to_string(&state_path) {
|
||
Ok(contents) => contents,
|
||
Err(error) => {
|
||
tracing::warn!(
|
||
"Failed to read active workspace marker {}: {error}",
|
||
state_path.display()
|
||
);
|
||
return Ok(None);
|
||
}
|
||
};
|
||
|
||
let state: ActiveWorkspaceState = match toml::from_str(&contents) {
|
||
Ok(state) => state,
|
||
Err(error) => {
|
||
tracing::warn!(
|
||
"Failed to parse active workspace marker {}: {error}",
|
||
state_path.display()
|
||
);
|
||
return Ok(None);
|
||
}
|
||
};
|
||
|
||
let raw_config_dir = state.config_dir.trim();
|
||
if raw_config_dir.is_empty() {
|
||
tracing::warn!(
|
||
"Ignoring active workspace marker {} because config_dir is empty",
|
||
state_path.display()
|
||
);
|
||
return Ok(None);
|
||
}
|
||
|
||
let parsed_dir = PathBuf::from(raw_config_dir);
|
||
let config_dir = if parsed_dir.is_absolute() {
|
||
parsed_dir
|
||
} else {
|
||
default_config_dir.join(parsed_dir)
|
||
};
|
||
Ok(Some((config_dir.clone(), config_dir.join("workspace"))))
|
||
}
|
||
|
||
pub(crate) fn persist_active_workspace_config_dir(config_dir: &Path) -> Result<()> {
|
||
let default_config_dir = default_config_dir()?;
|
||
let state_path = active_workspace_state_path(&default_config_dir);
|
||
|
||
if config_dir == default_config_dir {
|
||
if state_path.exists() {
|
||
fs::remove_file(&state_path).with_context(|| {
|
||
format!(
|
||
"Failed to clear active workspace marker: {}",
|
||
state_path.display()
|
||
)
|
||
})?;
|
||
}
|
||
return Ok(());
|
||
}
|
||
|
||
fs::create_dir_all(&default_config_dir).with_context(|| {
|
||
format!(
|
||
"Failed to create default config directory: {}",
|
||
default_config_dir.display()
|
||
)
|
||
})?;
|
||
|
||
let state = ActiveWorkspaceState {
|
||
config_dir: config_dir.to_string_lossy().into_owned(),
|
||
};
|
||
let serialized =
|
||
toml::to_string_pretty(&state).context("Failed to serialize active workspace marker")?;
|
||
|
||
let temp_path = default_config_dir.join(format!(
|
||
".{ACTIVE_WORKSPACE_STATE_FILE}.tmp-{}",
|
||
uuid::Uuid::new_v4()
|
||
));
|
||
fs::write(&temp_path, serialized).with_context(|| {
|
||
format!(
|
||
"Failed to write temporary active workspace marker: {}",
|
||
temp_path.display()
|
||
)
|
||
})?;
|
||
|
||
if let Err(error) = fs::rename(&temp_path, &state_path) {
|
||
let _ = fs::remove_file(&temp_path);
|
||
anyhow::bail!(
|
||
"Failed to atomically persist active workspace marker {}: {error}",
|
||
state_path.display()
|
||
);
|
||
}
|
||
|
||
sync_directory(&default_config_dir)?;
|
||
Ok(())
|
||
}
|
||
|
||
fn resolve_config_dir_for_workspace(workspace_dir: &Path) -> (PathBuf, PathBuf) {
|
||
let workspace_config_dir = workspace_dir.to_path_buf();
|
||
if workspace_config_dir.join("config.toml").exists() {
|
||
return (
|
||
workspace_config_dir.clone(),
|
||
workspace_config_dir.join("workspace"),
|
||
);
|
||
}
|
||
|
||
let legacy_config_dir = workspace_dir
|
||
.parent()
|
||
.map(|parent| parent.join(".zeroclaw"));
|
||
if let Some(legacy_dir) = legacy_config_dir {
|
||
if legacy_dir.join("config.toml").exists() {
|
||
return (legacy_dir, workspace_config_dir);
|
||
}
|
||
|
||
if workspace_dir
|
||
.file_name()
|
||
.is_some_and(|name| name == std::ffi::OsStr::new("workspace"))
|
||
{
|
||
return (legacy_dir, workspace_config_dir);
|
||
}
|
||
}
|
||
|
||
(
|
||
workspace_config_dir.clone(),
|
||
workspace_config_dir.join("workspace"),
|
||
)
|
||
}
|
||
|
||
fn decrypt_optional_secret(
|
||
store: &crate::security::SecretStore,
|
||
value: &mut Option<String>,
|
||
field_name: &str,
|
||
) -> Result<()> {
|
||
if let Some(raw) = value.clone() {
|
||
if crate::security::SecretStore::is_encrypted(&raw) {
|
||
*value = Some(
|
||
store
|
||
.decrypt(&raw)
|
||
.with_context(|| format!("Failed to decrypt {field_name}"))?,
|
||
);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn encrypt_optional_secret(
|
||
store: &crate::security::SecretStore,
|
||
value: &mut Option<String>,
|
||
field_name: &str,
|
||
) -> Result<()> {
|
||
if let Some(raw) = value.clone() {
|
||
if !crate::security::SecretStore::is_encrypted(&raw) {
|
||
*value = Some(
|
||
store
|
||
.encrypt(&raw)
|
||
.with_context(|| format!("Failed to encrypt {field_name}"))?,
|
||
);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
impl Config {
|
||
pub fn load_or_init() -> Result<Self> {
|
||
let (default_zeroclaw_dir, default_workspace_dir) = default_config_and_workspace_dirs()?;
|
||
|
||
// Resolution priority:
|
||
// 1. ZEROCLAW_WORKSPACE env override
|
||
// 2. Persisted active workspace marker from onboarding/custom profile
|
||
// 3. Default ~/.zeroclaw layout
|
||
let (zeroclaw_dir, workspace_dir) = match std::env::var("ZEROCLAW_WORKSPACE") {
|
||
Ok(custom_workspace) if !custom_workspace.is_empty() => {
|
||
resolve_config_dir_for_workspace(&PathBuf::from(custom_workspace))
|
||
}
|
||
_ => load_persisted_workspace_dirs(&default_zeroclaw_dir)?
|
||
.unwrap_or((default_zeroclaw_dir, default_workspace_dir)),
|
||
};
|
||
|
||
let config_path = zeroclaw_dir.join("config.toml");
|
||
|
||
fs::create_dir_all(&zeroclaw_dir).context("Failed to create config directory")?;
|
||
fs::create_dir_all(&workspace_dir).context("Failed to create workspace directory")?;
|
||
|
||
if config_path.exists() {
|
||
// Warn if config file is world-readable (may contain API keys)
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
if let Ok(meta) = fs::metadata(&config_path) {
|
||
if meta.permissions().mode() & 0o004 != 0 {
|
||
tracing::warn!(
|
||
"Config file {:?} is world-readable (mode {:o}). \
|
||
Consider restricting with: chmod 600 {:?}",
|
||
config_path,
|
||
meta.permissions().mode() & 0o777,
|
||
config_path,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
let contents =
|
||
fs::read_to_string(&config_path).context("Failed to read config file")?;
|
||
let mut config: Config =
|
||
toml::from_str(&contents).context("Failed to parse config file")?;
|
||
// Set computed paths that are skipped during serialization
|
||
config.config_path = config_path.clone();
|
||
config.workspace_dir = workspace_dir;
|
||
let store = crate::security::SecretStore::new(&zeroclaw_dir, config.secrets.encrypt);
|
||
decrypt_optional_secret(&store, &mut config.api_key, "config.api_key")?;
|
||
decrypt_optional_secret(
|
||
&store,
|
||
&mut config.composio.api_key,
|
||
"config.composio.api_key",
|
||
)?;
|
||
|
||
decrypt_optional_secret(
|
||
&store,
|
||
&mut config.browser.computer_use.api_key,
|
||
"config.browser.computer_use.api_key",
|
||
)?;
|
||
|
||
decrypt_optional_secret(
|
||
&store,
|
||
&mut config.web_search.brave_api_key,
|
||
"config.web_search.brave_api_key",
|
||
)?;
|
||
|
||
for agent in config.agents.values_mut() {
|
||
decrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?;
|
||
}
|
||
config.apply_env_overrides();
|
||
Ok(config)
|
||
} else {
|
||
let mut config = Config::default();
|
||
config.config_path = config_path.clone();
|
||
config.workspace_dir = workspace_dir;
|
||
config.save()?;
|
||
|
||
// Restrict permissions on newly created config file (may contain API keys)
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let _ = fs::set_permissions(&config_path, fs::Permissions::from_mode(0o600));
|
||
}
|
||
|
||
config.apply_env_overrides();
|
||
Ok(config)
|
||
}
|
||
}
|
||
|
||
/// Apply environment variable overrides to config
|
||
pub fn apply_env_overrides(&mut self) {
|
||
// API Key: ZEROCLAW_API_KEY or API_KEY (generic)
|
||
if let Ok(key) = std::env::var("ZEROCLAW_API_KEY").or_else(|_| std::env::var("API_KEY")) {
|
||
if !key.is_empty() {
|
||
self.api_key = Some(key);
|
||
}
|
||
}
|
||
// API Key: GLM_API_KEY overrides when provider is a GLM/Zhipu variant.
|
||
if self.default_provider.as_deref().is_some_and(is_glm_alias) {
|
||
if let Ok(key) = std::env::var("GLM_API_KEY") {
|
||
if !key.is_empty() {
|
||
self.api_key = Some(key);
|
||
}
|
||
}
|
||
}
|
||
|
||
// API Key: ZAI_API_KEY overrides when provider is a Z.AI variant.
|
||
if self.default_provider.as_deref().is_some_and(is_zai_alias) {
|
||
if let Ok(key) = std::env::var("ZAI_API_KEY") {
|
||
if !key.is_empty() {
|
||
self.api_key = Some(key);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Provider: ZEROCLAW_PROVIDER or PROVIDER
|
||
if let Ok(provider) =
|
||
std::env::var("ZEROCLAW_PROVIDER").or_else(|_| std::env::var("PROVIDER"))
|
||
{
|
||
if !provider.is_empty() {
|
||
self.default_provider = Some(provider);
|
||
}
|
||
}
|
||
|
||
// Model: ZEROCLAW_MODEL or MODEL
|
||
if let Ok(model) = std::env::var("ZEROCLAW_MODEL").or_else(|_| std::env::var("MODEL")) {
|
||
if !model.is_empty() {
|
||
self.default_model = Some(model);
|
||
}
|
||
}
|
||
|
||
// Workspace directory: ZEROCLAW_WORKSPACE
|
||
if let Ok(workspace) = std::env::var("ZEROCLAW_WORKSPACE") {
|
||
if !workspace.is_empty() {
|
||
let (_, workspace_dir) =
|
||
resolve_config_dir_for_workspace(&PathBuf::from(workspace));
|
||
self.workspace_dir = workspace_dir;
|
||
}
|
||
}
|
||
|
||
// Gateway port: ZEROCLAW_GATEWAY_PORT or PORT
|
||
if let Ok(port_str) =
|
||
std::env::var("ZEROCLAW_GATEWAY_PORT").or_else(|_| std::env::var("PORT"))
|
||
{
|
||
if let Ok(port) = port_str.parse::<u16>() {
|
||
self.gateway.port = port;
|
||
}
|
||
}
|
||
|
||
// Gateway host: ZEROCLAW_GATEWAY_HOST or HOST
|
||
if let Ok(host) = std::env::var("ZEROCLAW_GATEWAY_HOST").or_else(|_| std::env::var("HOST"))
|
||
{
|
||
if !host.is_empty() {
|
||
self.gateway.host = host;
|
||
}
|
||
}
|
||
|
||
// Allow public bind: ZEROCLAW_ALLOW_PUBLIC_BIND
|
||
if let Ok(val) = std::env::var("ZEROCLAW_ALLOW_PUBLIC_BIND") {
|
||
self.gateway.allow_public_bind = val == "1" || val.eq_ignore_ascii_case("true");
|
||
}
|
||
|
||
// Temperature: ZEROCLAW_TEMPERATURE
|
||
if let Ok(temp_str) = std::env::var("ZEROCLAW_TEMPERATURE") {
|
||
if let Ok(temp) = temp_str.parse::<f64>() {
|
||
if (0.0..=2.0).contains(&temp) {
|
||
self.default_temperature = temp;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Web search enabled: ZEROCLAW_WEB_SEARCH_ENABLED or WEB_SEARCH_ENABLED
|
||
if let Ok(enabled) = std::env::var("ZEROCLAW_WEB_SEARCH_ENABLED")
|
||
.or_else(|_| std::env::var("WEB_SEARCH_ENABLED"))
|
||
{
|
||
self.web_search.enabled = enabled == "1" || enabled.eq_ignore_ascii_case("true");
|
||
}
|
||
|
||
// Web search provider: ZEROCLAW_WEB_SEARCH_PROVIDER or WEB_SEARCH_PROVIDER
|
||
if let Ok(provider) = std::env::var("ZEROCLAW_WEB_SEARCH_PROVIDER")
|
||
.or_else(|_| std::env::var("WEB_SEARCH_PROVIDER"))
|
||
{
|
||
let provider = provider.trim();
|
||
if !provider.is_empty() {
|
||
self.web_search.provider = provider.to_string();
|
||
}
|
||
}
|
||
|
||
// Brave API key: ZEROCLAW_BRAVE_API_KEY or BRAVE_API_KEY
|
||
if let Ok(api_key) =
|
||
std::env::var("ZEROCLAW_BRAVE_API_KEY").or_else(|_| std::env::var("BRAVE_API_KEY"))
|
||
{
|
||
let api_key = api_key.trim();
|
||
if !api_key.is_empty() {
|
||
self.web_search.brave_api_key = Some(api_key.to_string());
|
||
}
|
||
}
|
||
|
||
// Web search max results: ZEROCLAW_WEB_SEARCH_MAX_RESULTS or WEB_SEARCH_MAX_RESULTS
|
||
if let Ok(max_results) = std::env::var("ZEROCLAW_WEB_SEARCH_MAX_RESULTS")
|
||
.or_else(|_| std::env::var("WEB_SEARCH_MAX_RESULTS"))
|
||
{
|
||
if let Ok(max_results) = max_results.parse::<usize>() {
|
||
if (1..=10).contains(&max_results) {
|
||
self.web_search.max_results = max_results;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Web search timeout: ZEROCLAW_WEB_SEARCH_TIMEOUT_SECS or WEB_SEARCH_TIMEOUT_SECS
|
||
if let Ok(timeout_secs) = std::env::var("ZEROCLAW_WEB_SEARCH_TIMEOUT_SECS")
|
||
.or_else(|_| std::env::var("WEB_SEARCH_TIMEOUT_SECS"))
|
||
{
|
||
if let Ok(timeout_secs) = timeout_secs.parse::<u64>() {
|
||
if timeout_secs > 0 {
|
||
self.web_search.timeout_secs = timeout_secs;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn save(&self) -> Result<()> {
|
||
// Encrypt secrets before serialization
|
||
let mut config_to_save = self.clone();
|
||
let zeroclaw_dir = self
|
||
.config_path
|
||
.parent()
|
||
.context("Config path must have a parent directory")?;
|
||
let store = crate::security::SecretStore::new(zeroclaw_dir, self.secrets.encrypt);
|
||
|
||
encrypt_optional_secret(&store, &mut config_to_save.api_key, "config.api_key")?;
|
||
encrypt_optional_secret(
|
||
&store,
|
||
&mut config_to_save.composio.api_key,
|
||
"config.composio.api_key",
|
||
)?;
|
||
|
||
encrypt_optional_secret(
|
||
&store,
|
||
&mut config_to_save.browser.computer_use.api_key,
|
||
"config.browser.computer_use.api_key",
|
||
)?;
|
||
|
||
encrypt_optional_secret(
|
||
&store,
|
||
&mut config_to_save.web_search.brave_api_key,
|
||
"config.web_search.brave_api_key",
|
||
)?;
|
||
|
||
for agent in config_to_save.agents.values_mut() {
|
||
encrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?;
|
||
}
|
||
|
||
let toml_str =
|
||
toml::to_string_pretty(&config_to_save).context("Failed to serialize config")?;
|
||
|
||
let parent_dir = self
|
||
.config_path
|
||
.parent()
|
||
.context("Config path must have a parent directory")?;
|
||
fs::create_dir_all(parent_dir).with_context(|| {
|
||
format!(
|
||
"Failed to create config directory: {}",
|
||
parent_dir.display()
|
||
)
|
||
})?;
|
||
|
||
let file_name = self
|
||
.config_path
|
||
.file_name()
|
||
.and_then(|v| v.to_str())
|
||
.unwrap_or("config.toml");
|
||
let temp_path = parent_dir.join(format!(".{file_name}.tmp-{}", uuid::Uuid::new_v4()));
|
||
let backup_path = parent_dir.join(format!("{file_name}.bak"));
|
||
|
||
let mut temp_file = OpenOptions::new()
|
||
.create_new(true)
|
||
.write(true)
|
||
.open(&temp_path)
|
||
.with_context(|| {
|
||
format!(
|
||
"Failed to create temporary config file: {}",
|
||
temp_path.display()
|
||
)
|
||
})?;
|
||
temp_file
|
||
.write_all(toml_str.as_bytes())
|
||
.context("Failed to write temporary config contents")?;
|
||
temp_file
|
||
.sync_all()
|
||
.context("Failed to fsync temporary config file")?;
|
||
drop(temp_file);
|
||
|
||
let had_existing_config = self.config_path.exists();
|
||
if had_existing_config {
|
||
fs::copy(&self.config_path, &backup_path).with_context(|| {
|
||
format!(
|
||
"Failed to create config backup before atomic replace: {}",
|
||
backup_path.display()
|
||
)
|
||
})?;
|
||
}
|
||
|
||
if let Err(e) = fs::rename(&temp_path, &self.config_path) {
|
||
let _ = fs::remove_file(&temp_path);
|
||
if had_existing_config && backup_path.exists() {
|
||
let _ = fs::copy(&backup_path, &self.config_path);
|
||
}
|
||
anyhow::bail!("Failed to atomically replace config file: {e}");
|
||
}
|
||
|
||
sync_directory(parent_dir)?;
|
||
|
||
if had_existing_config {
|
||
let _ = fs::remove_file(&backup_path);
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn sync_directory(path: &Path) -> Result<()> {
|
||
let dir = File::open(path)
|
||
.with_context(|| format!("Failed to open directory for fsync: {}", path.display()))?;
|
||
dir.sync_all()
|
||
.with_context(|| format!("Failed to fsync directory metadata: {}", path.display()))?;
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(not(unix))]
|
||
fn sync_directory(_path: &Path) -> Result<()> {
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::path::PathBuf;
|
||
|
||
// ── Defaults ─────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn config_default_has_sane_values() {
|
||
let c = Config::default();
|
||
assert_eq!(c.default_provider.as_deref(), Some("openrouter"));
|
||
assert!(c.default_model.as_deref().unwrap().contains("claude"));
|
||
assert!((c.default_temperature - 0.7).abs() < f64::EPSILON);
|
||
assert!(c.api_key.is_none());
|
||
assert!(c.workspace_dir.to_string_lossy().contains("workspace"));
|
||
assert!(c.config_path.to_string_lossy().contains("config.toml"));
|
||
}
|
||
|
||
#[test]
|
||
fn observability_config_default() {
|
||
let o = ObservabilityConfig::default();
|
||
assert_eq!(o.backend, "none");
|
||
}
|
||
|
||
#[test]
|
||
fn autonomy_config_default() {
|
||
let a = AutonomyConfig::default();
|
||
assert_eq!(a.level, AutonomyLevel::Supervised);
|
||
assert!(a.workspace_only);
|
||
assert!(a.allowed_commands.contains(&"git".to_string()));
|
||
assert!(a.allowed_commands.contains(&"cargo".to_string()));
|
||
assert!(a.forbidden_paths.contains(&"/etc".to_string()));
|
||
assert_eq!(a.max_actions_per_hour, 20);
|
||
assert_eq!(a.max_cost_per_day_cents, 500);
|
||
assert!(a.require_approval_for_medium_risk);
|
||
assert!(a.block_high_risk_commands);
|
||
}
|
||
|
||
#[test]
|
||
fn runtime_config_default() {
|
||
let r = RuntimeConfig::default();
|
||
assert_eq!(r.kind, "native");
|
||
assert_eq!(r.docker.image, "alpine:3.20");
|
||
assert_eq!(r.docker.network, "none");
|
||
assert_eq!(r.docker.memory_limit_mb, Some(512));
|
||
assert_eq!(r.docker.cpu_limit, Some(1.0));
|
||
assert!(r.docker.read_only_rootfs);
|
||
assert!(r.docker.mount_workspace);
|
||
}
|
||
|
||
#[test]
|
||
fn heartbeat_config_default() {
|
||
let h = HeartbeatConfig::default();
|
||
assert!(!h.enabled);
|
||
assert_eq!(h.interval_minutes, 30);
|
||
}
|
||
|
||
#[test]
|
||
fn cron_config_default() {
|
||
let c = CronConfig::default();
|
||
assert!(c.enabled);
|
||
assert_eq!(c.max_run_history, 50);
|
||
}
|
||
|
||
#[test]
|
||
fn cron_config_serde_roundtrip() {
|
||
let c = CronConfig {
|
||
enabled: false,
|
||
max_run_history: 100,
|
||
};
|
||
let json = serde_json::to_string(&c).unwrap();
|
||
let parsed: CronConfig = serde_json::from_str(&json).unwrap();
|
||
assert!(!parsed.enabled);
|
||
assert_eq!(parsed.max_run_history, 100);
|
||
}
|
||
|
||
#[test]
|
||
fn config_defaults_cron_when_section_missing() {
|
||
let toml_str = r#"
|
||
workspace_dir = "/tmp/workspace"
|
||
config_path = "/tmp/config.toml"
|
||
default_temperature = 0.7
|
||
"#;
|
||
|
||
let parsed: Config = toml::from_str(toml_str).unwrap();
|
||
assert!(parsed.cron.enabled);
|
||
assert_eq!(parsed.cron.max_run_history, 50);
|
||
}
|
||
|
||
#[test]
|
||
fn memory_config_default_hygiene_settings() {
|
||
let m = MemoryConfig::default();
|
||
assert_eq!(m.backend, "sqlite");
|
||
assert!(m.auto_save);
|
||
assert!(m.hygiene_enabled);
|
||
assert_eq!(m.archive_after_days, 7);
|
||
assert_eq!(m.purge_after_days, 30);
|
||
assert_eq!(m.conversation_retention_days, 30);
|
||
assert!(m.sqlite_open_timeout_secs.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn channels_config_default() {
|
||
let c = ChannelsConfig::default();
|
||
assert!(c.cli);
|
||
assert!(c.telegram.is_none());
|
||
assert!(c.discord.is_none());
|
||
}
|
||
|
||
// ── Serde round-trip ─────────────────────────────────────
|
||
|
||
#[test]
|
||
fn config_toml_roundtrip() {
|
||
let config = Config {
|
||
workspace_dir: PathBuf::from("/tmp/test/workspace"),
|
||
config_path: PathBuf::from("/tmp/test/config.toml"),
|
||
api_key: Some("sk-test-key".into()),
|
||
api_url: None,
|
||
default_provider: Some("openrouter".into()),
|
||
default_model: Some("gpt-4o".into()),
|
||
default_temperature: 0.5,
|
||
observability: ObservabilityConfig {
|
||
backend: "log".into(),
|
||
..ObservabilityConfig::default()
|
||
},
|
||
autonomy: AutonomyConfig {
|
||
level: AutonomyLevel::Full,
|
||
workspace_only: false,
|
||
allowed_commands: vec!["docker".into()],
|
||
forbidden_paths: vec!["/secret".into()],
|
||
max_actions_per_hour: 50,
|
||
max_cost_per_day_cents: 1000,
|
||
require_approval_for_medium_risk: false,
|
||
block_high_risk_commands: true,
|
||
auto_approve: vec!["file_read".into()],
|
||
always_ask: vec![],
|
||
},
|
||
runtime: RuntimeConfig {
|
||
kind: "docker".into(),
|
||
..RuntimeConfig::default()
|
||
},
|
||
reliability: ReliabilityConfig::default(),
|
||
scheduler: SchedulerConfig::default(),
|
||
model_routes: Vec::new(),
|
||
query_classification: QueryClassificationConfig::default(),
|
||
heartbeat: HeartbeatConfig {
|
||
enabled: true,
|
||
interval_minutes: 15,
|
||
},
|
||
cron: CronConfig::default(),
|
||
channels_config: ChannelsConfig {
|
||
cli: true,
|
||
telegram: Some(TelegramConfig {
|
||
bot_token: "123:ABC".into(),
|
||
allowed_users: vec!["user1".into()],
|
||
stream_mode: StreamMode::default(),
|
||
draft_update_interval_ms: default_draft_update_interval_ms(),
|
||
mention_only: false,
|
||
}),
|
||
discord: None,
|
||
slack: None,
|
||
mattermost: None,
|
||
webhook: None,
|
||
imessage: None,
|
||
matrix: None,
|
||
signal: None,
|
||
whatsapp: None,
|
||
email: None,
|
||
irc: None,
|
||
lark: None,
|
||
dingtalk: None,
|
||
qq: None,
|
||
},
|
||
memory: MemoryConfig::default(),
|
||
tunnel: TunnelConfig::default(),
|
||
gateway: GatewayConfig::default(),
|
||
composio: ComposioConfig::default(),
|
||
secrets: SecretsConfig::default(),
|
||
browser: BrowserConfig::default(),
|
||
http_request: HttpRequestConfig::default(),
|
||
web_search: WebSearchConfig::default(),
|
||
agent: AgentConfig::default(),
|
||
identity: IdentityConfig::default(),
|
||
cost: CostConfig::default(),
|
||
peripherals: PeripheralsConfig::default(),
|
||
agents: HashMap::new(),
|
||
hardware: HardwareConfig::default(),
|
||
};
|
||
|
||
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||
let parsed: Config = toml::from_str(&toml_str).unwrap();
|
||
|
||
assert_eq!(parsed.api_key, config.api_key);
|
||
assert_eq!(parsed.default_provider, config.default_provider);
|
||
assert_eq!(parsed.default_model, config.default_model);
|
||
assert!((parsed.default_temperature - config.default_temperature).abs() < f64::EPSILON);
|
||
assert_eq!(parsed.observability.backend, "log");
|
||
assert_eq!(parsed.autonomy.level, AutonomyLevel::Full);
|
||
assert!(!parsed.autonomy.workspace_only);
|
||
assert_eq!(parsed.runtime.kind, "docker");
|
||
assert!(parsed.heartbeat.enabled);
|
||
assert_eq!(parsed.heartbeat.interval_minutes, 15);
|
||
assert!(parsed.channels_config.telegram.is_some());
|
||
assert_eq!(
|
||
parsed.channels_config.telegram.unwrap().bot_token,
|
||
"123:ABC"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn config_minimal_toml_uses_defaults() {
|
||
let minimal = r#"
|
||
workspace_dir = "/tmp/ws"
|
||
config_path = "/tmp/config.toml"
|
||
default_temperature = 0.7
|
||
"#;
|
||
let parsed: Config = toml::from_str(minimal).unwrap();
|
||
assert!(parsed.api_key.is_none());
|
||
assert!(parsed.default_provider.is_none());
|
||
assert_eq!(parsed.observability.backend, "none");
|
||
assert_eq!(parsed.autonomy.level, AutonomyLevel::Supervised);
|
||
assert_eq!(parsed.runtime.kind, "native");
|
||
assert!(!parsed.heartbeat.enabled);
|
||
assert!(parsed.channels_config.cli);
|
||
assert!(parsed.memory.hygiene_enabled);
|
||
assert_eq!(parsed.memory.archive_after_days, 7);
|
||
assert_eq!(parsed.memory.purge_after_days, 30);
|
||
assert_eq!(parsed.memory.conversation_retention_days, 30);
|
||
}
|
||
|
||
#[test]
|
||
fn agent_config_defaults() {
|
||
let cfg = AgentConfig::default();
|
||
assert!(!cfg.compact_context);
|
||
assert_eq!(cfg.max_tool_iterations, 10);
|
||
assert_eq!(cfg.max_history_messages, 50);
|
||
assert!(!cfg.parallel_tools);
|
||
assert_eq!(cfg.tool_dispatcher, "auto");
|
||
}
|
||
|
||
#[test]
|
||
fn agent_config_deserializes() {
|
||
let raw = r#"
|
||
default_temperature = 0.7
|
||
[agent]
|
||
compact_context = true
|
||
max_tool_iterations = 20
|
||
max_history_messages = 80
|
||
parallel_tools = true
|
||
tool_dispatcher = "xml"
|
||
"#;
|
||
let parsed: Config = toml::from_str(raw).unwrap();
|
||
assert!(parsed.agent.compact_context);
|
||
assert_eq!(parsed.agent.max_tool_iterations, 20);
|
||
assert_eq!(parsed.agent.max_history_messages, 80);
|
||
assert!(parsed.agent.parallel_tools);
|
||
assert_eq!(parsed.agent.tool_dispatcher, "xml");
|
||
}
|
||
|
||
#[test]
|
||
fn config_save_and_load_tmpdir() {
|
||
let dir = std::env::temp_dir().join("zeroclaw_test_config");
|
||
let _ = fs::remove_dir_all(&dir);
|
||
fs::create_dir_all(&dir).unwrap();
|
||
|
||
let config_path = dir.join("config.toml");
|
||
let config = Config {
|
||
workspace_dir: dir.join("workspace"),
|
||
config_path: config_path.clone(),
|
||
api_key: Some("sk-roundtrip".into()),
|
||
api_url: None,
|
||
default_provider: Some("openrouter".into()),
|
||
default_model: Some("test-model".into()),
|
||
default_temperature: 0.9,
|
||
observability: ObservabilityConfig::default(),
|
||
autonomy: AutonomyConfig::default(),
|
||
runtime: RuntimeConfig::default(),
|
||
reliability: ReliabilityConfig::default(),
|
||
scheduler: SchedulerConfig::default(),
|
||
model_routes: Vec::new(),
|
||
query_classification: QueryClassificationConfig::default(),
|
||
heartbeat: HeartbeatConfig::default(),
|
||
cron: CronConfig::default(),
|
||
channels_config: ChannelsConfig::default(),
|
||
memory: MemoryConfig::default(),
|
||
tunnel: TunnelConfig::default(),
|
||
gateway: GatewayConfig::default(),
|
||
composio: ComposioConfig::default(),
|
||
secrets: SecretsConfig::default(),
|
||
browser: BrowserConfig::default(),
|
||
http_request: HttpRequestConfig::default(),
|
||
web_search: WebSearchConfig::default(),
|
||
agent: AgentConfig::default(),
|
||
identity: IdentityConfig::default(),
|
||
cost: CostConfig::default(),
|
||
peripherals: PeripheralsConfig::default(),
|
||
agents: HashMap::new(),
|
||
hardware: HardwareConfig::default(),
|
||
};
|
||
|
||
config.save().unwrap();
|
||
assert!(config_path.exists());
|
||
|
||
let contents = fs::read_to_string(&config_path).unwrap();
|
||
let loaded: Config = toml::from_str(&contents).unwrap();
|
||
assert!(loaded
|
||
.api_key
|
||
.as_deref()
|
||
.is_some_and(crate::security::SecretStore::is_encrypted));
|
||
let store = crate::security::SecretStore::new(&dir, true);
|
||
let decrypted = store.decrypt(loaded.api_key.as_deref().unwrap()).unwrap();
|
||
assert_eq!(decrypted, "sk-roundtrip");
|
||
assert_eq!(loaded.default_model.as_deref(), Some("test-model"));
|
||
assert!((loaded.default_temperature - 0.9).abs() < f64::EPSILON);
|
||
|
||
let _ = fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn config_save_encrypts_nested_credentials() {
|
||
let dir = std::env::temp_dir().join(format!(
|
||
"zeroclaw_test_nested_credentials_{}",
|
||
uuid::Uuid::new_v4()
|
||
));
|
||
fs::create_dir_all(&dir).unwrap();
|
||
|
||
let mut config = Config::default();
|
||
config.workspace_dir = dir.join("workspace");
|
||
config.config_path = dir.join("config.toml");
|
||
config.api_key = Some("root-credential".into());
|
||
config.composio.api_key = Some("composio-credential".into());
|
||
config.browser.computer_use.api_key = Some("browser-credential".into());
|
||
config.web_search.brave_api_key = Some("brave-credential".into());
|
||
|
||
config.agents.insert(
|
||
"worker".into(),
|
||
DelegateAgentConfig {
|
||
provider: "openrouter".into(),
|
||
model: "model-test".into(),
|
||
system_prompt: None,
|
||
api_key: Some("agent-credential".into()),
|
||
temperature: None,
|
||
max_depth: 3,
|
||
},
|
||
);
|
||
|
||
config.save().unwrap();
|
||
|
||
let contents = fs::read_to_string(config.config_path.clone()).unwrap();
|
||
let stored: Config = toml::from_str(&contents).unwrap();
|
||
let store = crate::security::SecretStore::new(&dir, true);
|
||
|
||
let root_encrypted = stored.api_key.as_deref().unwrap();
|
||
assert!(crate::security::SecretStore::is_encrypted(root_encrypted));
|
||
assert_eq!(store.decrypt(root_encrypted).unwrap(), "root-credential");
|
||
|
||
let composio_encrypted = stored.composio.api_key.as_deref().unwrap();
|
||
assert!(crate::security::SecretStore::is_encrypted(
|
||
composio_encrypted
|
||
));
|
||
assert_eq!(
|
||
store.decrypt(composio_encrypted).unwrap(),
|
||
"composio-credential"
|
||
);
|
||
|
||
let browser_encrypted = stored.browser.computer_use.api_key.as_deref().unwrap();
|
||
assert!(crate::security::SecretStore::is_encrypted(
|
||
browser_encrypted
|
||
));
|
||
assert_eq!(
|
||
store.decrypt(browser_encrypted).unwrap(),
|
||
"browser-credential"
|
||
);
|
||
|
||
let web_search_encrypted = stored.web_search.brave_api_key.as_deref().unwrap();
|
||
assert!(crate::security::SecretStore::is_encrypted(
|
||
web_search_encrypted
|
||
));
|
||
assert_eq!(
|
||
store.decrypt(web_search_encrypted).unwrap(),
|
||
"brave-credential"
|
||
);
|
||
|
||
let worker = stored.agents.get("worker").unwrap();
|
||
let worker_encrypted = worker.api_key.as_deref().unwrap();
|
||
assert!(crate::security::SecretStore::is_encrypted(worker_encrypted));
|
||
assert_eq!(store.decrypt(worker_encrypted).unwrap(), "agent-credential");
|
||
|
||
let _ = fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[test]
|
||
fn config_save_atomic_cleanup() {
|
||
let dir =
|
||
std::env::temp_dir().join(format!("zeroclaw_test_config_{}", uuid::Uuid::new_v4()));
|
||
fs::create_dir_all(&dir).unwrap();
|
||
|
||
let config_path = dir.join("config.toml");
|
||
let mut config = Config::default();
|
||
config.workspace_dir = dir.join("workspace");
|
||
config.config_path = config_path.clone();
|
||
config.default_model = Some("model-a".into());
|
||
|
||
config.save().unwrap();
|
||
assert!(config_path.exists());
|
||
|
||
config.default_model = Some("model-b".into());
|
||
config.save().unwrap();
|
||
|
||
let contents = fs::read_to_string(&config_path).unwrap();
|
||
assert!(contents.contains("model-b"));
|
||
|
||
let names: Vec<String> = fs::read_dir(&dir)
|
||
.unwrap()
|
||
.map(|entry| entry.unwrap().file_name().to_string_lossy().to_string())
|
||
.collect();
|
||
assert!(!names.iter().any(|name| name.contains(".tmp-")));
|
||
assert!(!names.iter().any(|name| name.ends_with(".bak")));
|
||
|
||
let _ = fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
// ── Telegram / Discord config ────────────────────────────
|
||
|
||
#[test]
|
||
fn telegram_config_serde() {
|
||
let tc = TelegramConfig {
|
||
bot_token: "123:XYZ".into(),
|
||
allowed_users: vec!["alice".into(), "bob".into()],
|
||
stream_mode: StreamMode::Partial,
|
||
draft_update_interval_ms: 500,
|
||
mention_only: false,
|
||
};
|
||
let json = serde_json::to_string(&tc).unwrap();
|
||
let parsed: TelegramConfig = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(parsed.bot_token, "123:XYZ");
|
||
assert_eq!(parsed.allowed_users.len(), 2);
|
||
assert_eq!(parsed.stream_mode, StreamMode::Partial);
|
||
assert_eq!(parsed.draft_update_interval_ms, 500);
|
||
}
|
||
|
||
#[test]
|
||
fn telegram_config_defaults_stream_off() {
|
||
let json = r#"{"bot_token":"tok","allowed_users":[]}"#;
|
||
let parsed: TelegramConfig = serde_json::from_str(json).unwrap();
|
||
assert_eq!(parsed.stream_mode, StreamMode::Off);
|
||
assert_eq!(parsed.draft_update_interval_ms, 1000);
|
||
}
|
||
|
||
#[test]
|
||
fn discord_config_serde() {
|
||
let dc = DiscordConfig {
|
||
bot_token: "discord-token".into(),
|
||
guild_id: Some("12345".into()),
|
||
allowed_users: vec![],
|
||
listen_to_bots: false,
|
||
mention_only: false,
|
||
};
|
||
let json = serde_json::to_string(&dc).unwrap();
|
||
let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(parsed.bot_token, "discord-token");
|
||
assert_eq!(parsed.guild_id.as_deref(), Some("12345"));
|
||
}
|
||
|
||
#[test]
|
||
fn discord_config_optional_guild() {
|
||
let dc = DiscordConfig {
|
||
bot_token: "tok".into(),
|
||
guild_id: None,
|
||
allowed_users: vec![],
|
||
listen_to_bots: false,
|
||
mention_only: false,
|
||
};
|
||
let json = serde_json::to_string(&dc).unwrap();
|
||
let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
|
||
assert!(parsed.guild_id.is_none());
|
||
}
|
||
|
||
// ── iMessage / Matrix config ────────────────────────────
|
||
|
||
#[test]
|
||
fn imessage_config_serde() {
|
||
let ic = IMessageConfig {
|
||
allowed_contacts: vec!["+1234567890".into(), "user@icloud.com".into()],
|
||
};
|
||
let json = serde_json::to_string(&ic).unwrap();
|
||
let parsed: IMessageConfig = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(parsed.allowed_contacts.len(), 2);
|
||
assert_eq!(parsed.allowed_contacts[0], "+1234567890");
|
||
}
|
||
|
||
#[test]
|
||
fn imessage_config_empty_contacts() {
|
||
let ic = IMessageConfig {
|
||
allowed_contacts: vec![],
|
||
};
|
||
let json = serde_json::to_string(&ic).unwrap();
|
||
let parsed: IMessageConfig = serde_json::from_str(&json).unwrap();
|
||
assert!(parsed.allowed_contacts.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn imessage_config_wildcard() {
|
||
let ic = IMessageConfig {
|
||
allowed_contacts: vec!["*".into()],
|
||
};
|
||
let toml_str = toml::to_string(&ic).unwrap();
|
||
let parsed: IMessageConfig = toml::from_str(&toml_str).unwrap();
|
||
assert_eq!(parsed.allowed_contacts, vec!["*"]);
|
||
}
|
||
|
||
#[test]
|
||
fn matrix_config_serde() {
|
||
let mc = MatrixConfig {
|
||
homeserver: "https://matrix.org".into(),
|
||
access_token: "syt_token_abc".into(),
|
||
room_id: "!room123:matrix.org".into(),
|
||
allowed_users: vec!["@user:matrix.org".into()],
|
||
};
|
||
let json = serde_json::to_string(&mc).unwrap();
|
||
let parsed: MatrixConfig = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(parsed.homeserver, "https://matrix.org");
|
||
assert_eq!(parsed.access_token, "syt_token_abc");
|
||
assert_eq!(parsed.room_id, "!room123:matrix.org");
|
||
assert_eq!(parsed.allowed_users.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn matrix_config_toml_roundtrip() {
|
||
let mc = MatrixConfig {
|
||
homeserver: "https://synapse.local:8448".into(),
|
||
access_token: "tok".into(),
|
||
room_id: "!abc:synapse.local".into(),
|
||
allowed_users: vec!["@admin:synapse.local".into(), "*".into()],
|
||
};
|
||
let toml_str = toml::to_string(&mc).unwrap();
|
||
let parsed: MatrixConfig = toml::from_str(&toml_str).unwrap();
|
||
assert_eq!(parsed.homeserver, "https://synapse.local:8448");
|
||
assert_eq!(parsed.allowed_users.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn signal_config_serde() {
|
||
let sc = SignalConfig {
|
||
http_url: "http://127.0.0.1:8686".into(),
|
||
account: "+1234567890".into(),
|
||
group_id: Some("group123".into()),
|
||
allowed_from: vec!["+1111111111".into()],
|
||
ignore_attachments: true,
|
||
ignore_stories: false,
|
||
};
|
||
let json = serde_json::to_string(&sc).unwrap();
|
||
let parsed: SignalConfig = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(parsed.http_url, "http://127.0.0.1:8686");
|
||
assert_eq!(parsed.account, "+1234567890");
|
||
assert_eq!(parsed.group_id.as_deref(), Some("group123"));
|
||
assert_eq!(parsed.allowed_from.len(), 1);
|
||
assert!(parsed.ignore_attachments);
|
||
assert!(!parsed.ignore_stories);
|
||
}
|
||
|
||
#[test]
|
||
fn signal_config_toml_roundtrip() {
|
||
let sc = SignalConfig {
|
||
http_url: "http://localhost:8080".into(),
|
||
account: "+9876543210".into(),
|
||
group_id: None,
|
||
allowed_from: vec!["*".into()],
|
||
ignore_attachments: false,
|
||
ignore_stories: true,
|
||
};
|
||
let toml_str = toml::to_string(&sc).unwrap();
|
||
let parsed: SignalConfig = toml::from_str(&toml_str).unwrap();
|
||
assert_eq!(parsed.http_url, "http://localhost:8080");
|
||
assert_eq!(parsed.account, "+9876543210");
|
||
assert!(parsed.group_id.is_none());
|
||
assert!(parsed.ignore_stories);
|
||
}
|
||
|
||
#[test]
|
||
fn signal_config_defaults() {
|
||
let json = r#"{"http_url":"http://127.0.0.1:8686","account":"+1234567890"}"#;
|
||
let parsed: SignalConfig = serde_json::from_str(json).unwrap();
|
||
assert!(parsed.group_id.is_none());
|
||
assert!(parsed.allowed_from.is_empty());
|
||
assert!(!parsed.ignore_attachments);
|
||
assert!(!parsed.ignore_stories);
|
||
}
|
||
|
||
#[test]
|
||
fn channels_config_with_imessage_and_matrix() {
|
||
let c = ChannelsConfig {
|
||
cli: true,
|
||
telegram: None,
|
||
discord: None,
|
||
slack: None,
|
||
mattermost: None,
|
||
webhook: None,
|
||
imessage: Some(IMessageConfig {
|
||
allowed_contacts: vec!["+1".into()],
|
||
}),
|
||
matrix: Some(MatrixConfig {
|
||
homeserver: "https://m.org".into(),
|
||
access_token: "tok".into(),
|
||
room_id: "!r:m".into(),
|
||
allowed_users: vec!["@u:m".into()],
|
||
}),
|
||
signal: None,
|
||
whatsapp: None,
|
||
email: None,
|
||
irc: None,
|
||
lark: None,
|
||
dingtalk: None,
|
||
qq: None,
|
||
};
|
||
let toml_str = toml::to_string_pretty(&c).unwrap();
|
||
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
|
||
assert!(parsed.imessage.is_some());
|
||
assert!(parsed.matrix.is_some());
|
||
assert_eq!(parsed.imessage.unwrap().allowed_contacts, vec!["+1"]);
|
||
assert_eq!(parsed.matrix.unwrap().homeserver, "https://m.org");
|
||
}
|
||
|
||
#[test]
|
||
fn channels_config_default_has_no_imessage_matrix() {
|
||
let c = ChannelsConfig::default();
|
||
assert!(c.imessage.is_none());
|
||
assert!(c.matrix.is_none());
|
||
}
|
||
|
||
// ── Edge cases: serde(default) for allowed_users ─────────
|
||
|
||
#[test]
|
||
fn discord_config_deserializes_without_allowed_users() {
|
||
// Old configs won't have allowed_users — serde(default) should fill vec![]
|
||
let json = r#"{"bot_token":"tok","guild_id":"123"}"#;
|
||
let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
|
||
assert!(parsed.allowed_users.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn discord_config_deserializes_with_allowed_users() {
|
||
let json = r#"{"bot_token":"tok","guild_id":"123","allowed_users":["111","222"]}"#;
|
||
let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
|
||
assert_eq!(parsed.allowed_users, vec!["111", "222"]);
|
||
}
|
||
|
||
#[test]
|
||
fn slack_config_deserializes_without_allowed_users() {
|
||
let json = r#"{"bot_token":"xoxb-tok"}"#;
|
||
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
|
||
assert!(parsed.allowed_users.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn slack_config_deserializes_with_allowed_users() {
|
||
let json = r#"{"bot_token":"xoxb-tok","allowed_users":["U111"]}"#;
|
||
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
|
||
assert_eq!(parsed.allowed_users, vec!["U111"]);
|
||
}
|
||
|
||
#[test]
|
||
fn discord_config_toml_backward_compat() {
|
||
let toml_str = r#"
|
||
bot_token = "tok"
|
||
guild_id = "123"
|
||
"#;
|
||
let parsed: DiscordConfig = toml::from_str(toml_str).unwrap();
|
||
assert!(parsed.allowed_users.is_empty());
|
||
assert_eq!(parsed.bot_token, "tok");
|
||
}
|
||
|
||
#[test]
|
||
fn slack_config_toml_backward_compat() {
|
||
let toml_str = r#"
|
||
bot_token = "xoxb-tok"
|
||
channel_id = "C123"
|
||
"#;
|
||
let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
|
||
assert!(parsed.allowed_users.is_empty());
|
||
assert_eq!(parsed.channel_id.as_deref(), Some("C123"));
|
||
}
|
||
|
||
#[test]
|
||
fn webhook_config_with_secret() {
|
||
let json = r#"{"port":8080,"secret":"my-secret-key"}"#;
|
||
let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
|
||
assert_eq!(parsed.secret.as_deref(), Some("my-secret-key"));
|
||
}
|
||
|
||
#[test]
|
||
fn webhook_config_without_secret() {
|
||
let json = r#"{"port":8080}"#;
|
||
let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
|
||
assert!(parsed.secret.is_none());
|
||
assert_eq!(parsed.port, 8080);
|
||
}
|
||
|
||
// ── WhatsApp config ──────────────────────────────────────
|
||
|
||
#[test]
|
||
fn whatsapp_config_serde() {
|
||
let wc = WhatsAppConfig {
|
||
access_token: "EAABx...".into(),
|
||
phone_number_id: "123456789".into(),
|
||
verify_token: "my-verify-token".into(),
|
||
app_secret: None,
|
||
allowed_numbers: vec!["+1234567890".into(), "+9876543210".into()],
|
||
};
|
||
let json = serde_json::to_string(&wc).unwrap();
|
||
let parsed: WhatsAppConfig = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(parsed.access_token, "EAABx...");
|
||
assert_eq!(parsed.phone_number_id, "123456789");
|
||
assert_eq!(parsed.verify_token, "my-verify-token");
|
||
assert_eq!(parsed.allowed_numbers.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn whatsapp_config_toml_roundtrip() {
|
||
let wc = WhatsAppConfig {
|
||
access_token: "tok".into(),
|
||
phone_number_id: "12345".into(),
|
||
verify_token: "verify".into(),
|
||
app_secret: Some("secret123".into()),
|
||
allowed_numbers: vec!["+1".into()],
|
||
};
|
||
let toml_str = toml::to_string(&wc).unwrap();
|
||
let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
|
||
assert_eq!(parsed.phone_number_id, "12345");
|
||
assert_eq!(parsed.allowed_numbers, vec!["+1"]);
|
||
}
|
||
|
||
#[test]
|
||
fn whatsapp_config_deserializes_without_allowed_numbers() {
|
||
let json = r#"{"access_token":"tok","phone_number_id":"123","verify_token":"ver"}"#;
|
||
let parsed: WhatsAppConfig = serde_json::from_str(json).unwrap();
|
||
assert!(parsed.allowed_numbers.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn whatsapp_config_wildcard_allowed() {
|
||
let wc = WhatsAppConfig {
|
||
access_token: "tok".into(),
|
||
phone_number_id: "123".into(),
|
||
verify_token: "ver".into(),
|
||
app_secret: None,
|
||
allowed_numbers: vec!["*".into()],
|
||
};
|
||
let toml_str = toml::to_string(&wc).unwrap();
|
||
let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
|
||
assert_eq!(parsed.allowed_numbers, vec!["*"]);
|
||
}
|
||
|
||
#[test]
|
||
fn channels_config_with_whatsapp() {
|
||
let c = ChannelsConfig {
|
||
cli: true,
|
||
telegram: None,
|
||
discord: None,
|
||
slack: None,
|
||
mattermost: None,
|
||
webhook: None,
|
||
imessage: None,
|
||
matrix: None,
|
||
signal: None,
|
||
whatsapp: Some(WhatsAppConfig {
|
||
access_token: "tok".into(),
|
||
phone_number_id: "123".into(),
|
||
verify_token: "ver".into(),
|
||
app_secret: None,
|
||
allowed_numbers: vec!["+1".into()],
|
||
}),
|
||
email: None,
|
||
irc: None,
|
||
lark: None,
|
||
dingtalk: None,
|
||
qq: None,
|
||
};
|
||
let toml_str = toml::to_string_pretty(&c).unwrap();
|
||
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
|
||
assert!(parsed.whatsapp.is_some());
|
||
let wa = parsed.whatsapp.unwrap();
|
||
assert_eq!(wa.phone_number_id, "123");
|
||
assert_eq!(wa.allowed_numbers, vec!["+1"]);
|
||
}
|
||
|
||
#[test]
|
||
fn channels_config_default_has_no_whatsapp() {
|
||
let c = ChannelsConfig::default();
|
||
assert!(c.whatsapp.is_none());
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════
|
||
// SECURITY CHECKLIST TESTS — Gateway config
|
||
// ══════════════════════════════════════════════════════════
|
||
|
||
#[test]
|
||
fn checklist_gateway_default_requires_pairing() {
|
||
let g = GatewayConfig::default();
|
||
assert!(g.require_pairing, "Pairing must be required by default");
|
||
}
|
||
|
||
#[test]
|
||
fn checklist_gateway_default_blocks_public_bind() {
|
||
let g = GatewayConfig::default();
|
||
assert!(
|
||
!g.allow_public_bind,
|
||
"Public bind must be blocked by default"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn checklist_gateway_default_no_tokens() {
|
||
let g = GatewayConfig::default();
|
||
assert!(
|
||
g.paired_tokens.is_empty(),
|
||
"No pre-paired tokens by default"
|
||
);
|
||
assert_eq!(g.pair_rate_limit_per_minute, 10);
|
||
assert_eq!(g.webhook_rate_limit_per_minute, 60);
|
||
assert!(!g.trust_forwarded_headers);
|
||
assert_eq!(g.rate_limit_max_keys, 10_000);
|
||
assert_eq!(g.idempotency_ttl_secs, 300);
|
||
assert_eq!(g.idempotency_max_keys, 10_000);
|
||
}
|
||
|
||
#[test]
|
||
fn checklist_gateway_cli_default_host_is_localhost() {
|
||
// The CLI default for --host is 127.0.0.1 (checked in main.rs)
|
||
// Here we verify the config default matches
|
||
let c = Config::default();
|
||
assert!(
|
||
c.gateway.require_pairing,
|
||
"Config default must require pairing"
|
||
);
|
||
assert!(
|
||
!c.gateway.allow_public_bind,
|
||
"Config default must block public bind"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn checklist_gateway_serde_roundtrip() {
|
||
let g = GatewayConfig {
|
||
port: 3000,
|
||
host: "127.0.0.1".into(),
|
||
require_pairing: true,
|
||
allow_public_bind: false,
|
||
paired_tokens: vec!["zc_test_token".into()],
|
||
pair_rate_limit_per_minute: 12,
|
||
webhook_rate_limit_per_minute: 80,
|
||
trust_forwarded_headers: true,
|
||
rate_limit_max_keys: 2048,
|
||
idempotency_ttl_secs: 600,
|
||
idempotency_max_keys: 4096,
|
||
};
|
||
let toml_str = toml::to_string(&g).unwrap();
|
||
let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
|
||
assert!(parsed.require_pairing);
|
||
assert!(!parsed.allow_public_bind);
|
||
assert_eq!(parsed.paired_tokens, vec!["zc_test_token"]);
|
||
assert_eq!(parsed.pair_rate_limit_per_minute, 12);
|
||
assert_eq!(parsed.webhook_rate_limit_per_minute, 80);
|
||
assert!(parsed.trust_forwarded_headers);
|
||
assert_eq!(parsed.rate_limit_max_keys, 2048);
|
||
assert_eq!(parsed.idempotency_ttl_secs, 600);
|
||
assert_eq!(parsed.idempotency_max_keys, 4096);
|
||
}
|
||
|
||
#[test]
|
||
fn checklist_gateway_backward_compat_no_gateway_section() {
|
||
// Old configs without [gateway] should get secure defaults
|
||
let minimal = r#"
|
||
workspace_dir = "/tmp/ws"
|
||
config_path = "/tmp/config.toml"
|
||
default_temperature = 0.7
|
||
"#;
|
||
let parsed: Config = toml::from_str(minimal).unwrap();
|
||
assert!(
|
||
parsed.gateway.require_pairing,
|
||
"Missing [gateway] must default to require_pairing=true"
|
||
);
|
||
assert!(
|
||
!parsed.gateway.allow_public_bind,
|
||
"Missing [gateway] must default to allow_public_bind=false"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn checklist_autonomy_default_is_workspace_scoped() {
|
||
let a = AutonomyConfig::default();
|
||
assert!(a.workspace_only, "Default autonomy must be workspace_only");
|
||
assert!(
|
||
a.forbidden_paths.contains(&"/etc".to_string()),
|
||
"Must block /etc"
|
||
);
|
||
assert!(
|
||
a.forbidden_paths.contains(&"/proc".to_string()),
|
||
"Must block /proc"
|
||
);
|
||
assert!(
|
||
a.forbidden_paths.contains(&"~/.ssh".to_string()),
|
||
"Must block ~/.ssh"
|
||
);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════
|
||
// COMPOSIO CONFIG TESTS
|
||
// ══════════════════════════════════════════════════════════
|
||
|
||
#[test]
|
||
fn composio_config_default_disabled() {
|
||
let c = ComposioConfig::default();
|
||
assert!(!c.enabled, "Composio must be disabled by default");
|
||
assert!(c.api_key.is_none(), "No API key by default");
|
||
assert_eq!(c.entity_id, "default");
|
||
}
|
||
|
||
#[test]
|
||
fn composio_config_serde_roundtrip() {
|
||
let c = ComposioConfig {
|
||
enabled: true,
|
||
api_key: Some("comp-key-123".into()),
|
||
entity_id: "user42".into(),
|
||
};
|
||
let toml_str = toml::to_string(&c).unwrap();
|
||
let parsed: ComposioConfig = toml::from_str(&toml_str).unwrap();
|
||
assert!(parsed.enabled);
|
||
assert_eq!(parsed.api_key.as_deref(), Some("comp-key-123"));
|
||
assert_eq!(parsed.entity_id, "user42");
|
||
}
|
||
|
||
#[test]
|
||
fn composio_config_backward_compat_missing_section() {
|
||
let minimal = r#"
|
||
workspace_dir = "/tmp/ws"
|
||
config_path = "/tmp/config.toml"
|
||
default_temperature = 0.7
|
||
"#;
|
||
let parsed: Config = toml::from_str(minimal).unwrap();
|
||
assert!(
|
||
!parsed.composio.enabled,
|
||
"Missing [composio] must default to disabled"
|
||
);
|
||
assert!(parsed.composio.api_key.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn composio_config_partial_toml() {
|
||
let toml_str = r"
|
||
enabled = true
|
||
";
|
||
let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
|
||
assert!(parsed.enabled);
|
||
assert!(parsed.api_key.is_none());
|
||
assert_eq!(parsed.entity_id, "default");
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════
|
||
// SECRETS CONFIG TESTS
|
||
// ══════════════════════════════════════════════════════════
|
||
|
||
#[test]
|
||
fn secrets_config_default_encrypts() {
|
||
let s = SecretsConfig::default();
|
||
assert!(s.encrypt, "Encryption must be enabled by default");
|
||
}
|
||
|
||
#[test]
|
||
fn secrets_config_serde_roundtrip() {
|
||
let s = SecretsConfig { encrypt: false };
|
||
let toml_str = toml::to_string(&s).unwrap();
|
||
let parsed: SecretsConfig = toml::from_str(&toml_str).unwrap();
|
||
assert!(!parsed.encrypt);
|
||
}
|
||
|
||
#[test]
|
||
fn secrets_config_backward_compat_missing_section() {
|
||
let minimal = r#"
|
||
workspace_dir = "/tmp/ws"
|
||
config_path = "/tmp/config.toml"
|
||
default_temperature = 0.7
|
||
"#;
|
||
let parsed: Config = toml::from_str(minimal).unwrap();
|
||
assert!(
|
||
parsed.secrets.encrypt,
|
||
"Missing [secrets] must default to encrypt=true"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn config_default_has_composio_and_secrets() {
|
||
let c = Config::default();
|
||
assert!(!c.composio.enabled);
|
||
assert!(c.composio.api_key.is_none());
|
||
assert!(c.secrets.encrypt);
|
||
assert!(!c.browser.enabled);
|
||
assert!(c.browser.allowed_domains.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn browser_config_default_disabled() {
|
||
let b = BrowserConfig::default();
|
||
assert!(!b.enabled);
|
||
assert!(b.allowed_domains.is_empty());
|
||
assert_eq!(b.backend, "agent_browser");
|
||
assert!(b.native_headless);
|
||
assert_eq!(b.native_webdriver_url, "http://127.0.0.1:9515");
|
||
assert!(b.native_chrome_path.is_none());
|
||
assert_eq!(b.computer_use.endpoint, "http://127.0.0.1:8787/v1/actions");
|
||
assert_eq!(b.computer_use.timeout_ms, 15_000);
|
||
assert!(!b.computer_use.allow_remote_endpoint);
|
||
assert!(b.computer_use.window_allowlist.is_empty());
|
||
assert!(b.computer_use.max_coordinate_x.is_none());
|
||
assert!(b.computer_use.max_coordinate_y.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn browser_config_serde_roundtrip() {
|
||
let b = BrowserConfig {
|
||
enabled: true,
|
||
allowed_domains: vec!["example.com".into(), "docs.example.com".into()],
|
||
session_name: None,
|
||
backend: "auto".into(),
|
||
native_headless: false,
|
||
native_webdriver_url: "http://localhost:4444".into(),
|
||
native_chrome_path: Some("/usr/bin/chromium".into()),
|
||
computer_use: BrowserComputerUseConfig {
|
||
endpoint: "https://computer-use.example.com/v1/actions".into(),
|
||
api_key: Some("test-token".into()),
|
||
timeout_ms: 8_000,
|
||
allow_remote_endpoint: true,
|
||
window_allowlist: vec!["Chrome".into(), "Visual Studio Code".into()],
|
||
max_coordinate_x: Some(3840),
|
||
max_coordinate_y: Some(2160),
|
||
},
|
||
};
|
||
let toml_str = toml::to_string(&b).unwrap();
|
||
let parsed: BrowserConfig = toml::from_str(&toml_str).unwrap();
|
||
assert!(parsed.enabled);
|
||
assert_eq!(parsed.allowed_domains.len(), 2);
|
||
assert_eq!(parsed.allowed_domains[0], "example.com");
|
||
assert_eq!(parsed.backend, "auto");
|
||
assert!(!parsed.native_headless);
|
||
assert_eq!(parsed.native_webdriver_url, "http://localhost:4444");
|
||
assert_eq!(
|
||
parsed.native_chrome_path.as_deref(),
|
||
Some("/usr/bin/chromium")
|
||
);
|
||
assert_eq!(
|
||
parsed.computer_use.endpoint,
|
||
"https://computer-use.example.com/v1/actions"
|
||
);
|
||
assert_eq!(parsed.computer_use.api_key.as_deref(), Some("test-token"));
|
||
assert_eq!(parsed.computer_use.timeout_ms, 8_000);
|
||
assert!(parsed.computer_use.allow_remote_endpoint);
|
||
assert_eq!(parsed.computer_use.window_allowlist.len(), 2);
|
||
assert_eq!(parsed.computer_use.max_coordinate_x, Some(3840));
|
||
assert_eq!(parsed.computer_use.max_coordinate_y, Some(2160));
|
||
}
|
||
|
||
#[test]
|
||
fn browser_config_backward_compat_missing_section() {
|
||
let minimal = r#"
|
||
workspace_dir = "/tmp/ws"
|
||
config_path = "/tmp/config.toml"
|
||
default_temperature = 0.7
|
||
"#;
|
||
let parsed: Config = toml::from_str(minimal).unwrap();
|
||
assert!(!parsed.browser.enabled);
|
||
assert!(parsed.browser.allowed_domains.is_empty());
|
||
}
|
||
|
||
// ── Environment variable overrides (Docker support) ─────────
|
||
|
||
fn env_override_test_guard() -> std::sync::MutexGuard<'static, ()> {
|
||
static ENV_OVERRIDE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||
ENV_OVERRIDE_TEST_LOCK
|
||
.lock()
|
||
.expect("env override test lock poisoned")
|
||
}
|
||
|
||
#[test]
|
||
fn env_override_api_key() {
|
||
let _env_guard = env_override_test_guard();
|
||
let mut config = Config::default();
|
||
assert!(config.api_key.is_none());
|
||
|
||
std::env::set_var("ZEROCLAW_API_KEY", "sk-test-env-key");
|
||
config.apply_env_overrides();
|
||
assert_eq!(config.api_key.as_deref(), Some("sk-test-env-key"));
|
||
|
||
std::env::remove_var("ZEROCLAW_API_KEY");
|
||
}
|
||
|
||
#[test]
|
||
fn env_override_api_key_fallback() {
|
||
let _env_guard = env_override_test_guard();
|
||
let mut config = Config::default();
|
||
|
||
std::env::remove_var("ZEROCLAW_API_KEY");
|
||
std::env::set_var("API_KEY", "sk-fallback-key");
|
||
config.apply_env_overrides();
|
||
assert_eq!(config.api_key.as_deref(), Some("sk-fallback-key"));
|
||
|
||
std::env::remove_var("API_KEY");
|
||
}
|
||
|
||
#[test]
|
||
fn env_override_provider() {
|
||
let _env_guard = env_override_test_guard();
|
||
let mut config = Config::default();
|
||
|
||
std::env::set_var("ZEROCLAW_PROVIDER", "anthropic");
|
||
config.apply_env_overrides();
|
||
assert_eq!(config.default_provider.as_deref(), Some("anthropic"));
|
||
|
||
std::env::remove_var("ZEROCLAW_PROVIDER");
|
||
}
|
||
|
||
#[test]
|
||
fn env_override_provider_fallback() {
|
||
let _env_guard = env_override_test_guard();
|
||
let mut config = Config::default();
|
||
|
||
std::env::remove_var("ZEROCLAW_PROVIDER");
|
||
std::env::set_var("PROVIDER", "openai");
|
||
config.apply_env_overrides();
|
||
assert_eq!(config.default_provider.as_deref(), Some("openai"));
|
||
|
||
std::env::remove_var("PROVIDER");
|
||
}
|
||
|
||
#[test]
|
||
fn env_override_glm_api_key_for_regional_aliases() {
|
||
let _env_guard = env_override_test_guard();
|
||
let mut config = Config {
|
||
default_provider: Some("glm-cn".to_string()),
|
||
..Config::default()
|
||
};
|
||
|
||
std::env::set_var("GLM_API_KEY", "glm-regional-key");
|
||
config.apply_env_overrides();
|
||
assert_eq!(config.api_key.as_deref(), Some("glm-regional-key"));
|
||
|
||
std::env::remove_var("GLM_API_KEY");
|
||
}
|
||
|
||
#[test]
|
||
fn env_override_zai_api_key_for_regional_aliases() {
|
||
let _env_guard = env_override_test_guard();
|
||
let mut config = Config {
|
||
default_provider: Some("zai-cn".to_string()),
|
||
..Config::default()
|
||
};
|
||
|
||
std::env::set_var("ZAI_API_KEY", "zai-regional-key");
|
||
config.apply_env_overrides();
|
||
assert_eq!(config.api_key.as_deref(), Some("zai-regional-key"));
|
||
|
||
std::env::remove_var("ZAI_API_KEY");
|
||
}
|
||
|
||
#[test]
|
||
fn env_override_model() {
|
||
let _env_guard = env_override_test_guard();
|
||
let mut config = Config::default();
|
||
|
||
std::env::set_var("ZEROCLAW_MODEL", "gpt-4o");
|
||
config.apply_env_overrides();
|
||
assert_eq!(config.default_model.as_deref(), Some("gpt-4o"));
|
||
|
||
std::env::remove_var("ZEROCLAW_MODEL");
|
||
}
|
||
|
||
#[test]
|
||
fn env_override_model_fallback() {
|
||
let _env_guard = env_override_test_guard();
|
||
let mut config = Config::default();
|
||
|
||
std::env::remove_var("ZEROCLAW_MODEL");
|
||
std::env::set_var("MODEL", "anthropic/claude-3.5-sonnet");
|
||
config.apply_env_overrides();
|
||
assert_eq!(
|
||
config.default_model.as_deref(),
|
||
Some("anthropic/claude-3.5-sonnet")
|
||
);
|
||
|
||
std::env::remove_var("MODEL");
|
||
}
|
||
|
||
#[test]
|
||
fn env_override_workspace() {
|
||
let _env_guard = env_override_test_guard();
|
||
let mut config = Config::default();
|
||
|
||
std::env::set_var("ZEROCLAW_WORKSPACE", "/custom/workspace");
|
||
config.apply_env_overrides();
|
||
assert_eq!(config.workspace_dir, PathBuf::from("/custom/workspace"));
|
||
|
||
std::env::remove_var("ZEROCLAW_WORKSPACE");
|
||
}
|
||
|
||
#[test]
|
||
fn load_or_init_workspace_override_uses_workspace_root_for_config() {
|
||
let _env_guard = env_override_test_guard();
|
||
let temp_home =
|
||
std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
|
||
let workspace_dir = temp_home.join("profile-a");
|
||
|
||
let original_home = std::env::var("HOME").ok();
|
||
std::env::set_var("HOME", &temp_home);
|
||
std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir);
|
||
|
||
let config = Config::load_or_init().unwrap();
|
||
|
||
assert_eq!(config.workspace_dir, workspace_dir.join("workspace"));
|
||
assert_eq!(config.config_path, workspace_dir.join("config.toml"));
|
||
assert!(workspace_dir.join("config.toml").exists());
|
||
|
||
std::env::remove_var("ZEROCLAW_WORKSPACE");
|
||
if let Some(home) = original_home {
|
||
std::env::set_var("HOME", home);
|
||
} else {
|
||
std::env::remove_var("HOME");
|
||
}
|
||
let _ = fs::remove_dir_all(temp_home);
|
||
}
|
||
|
||
#[test]
|
||
fn load_or_init_workspace_suffix_uses_legacy_config_layout() {
|
||
let _env_guard = env_override_test_guard();
|
||
let temp_home =
|
||
std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
|
||
let workspace_dir = temp_home.join("workspace");
|
||
let legacy_config_path = temp_home.join(".zeroclaw").join("config.toml");
|
||
|
||
let original_home = std::env::var("HOME").ok();
|
||
std::env::set_var("HOME", &temp_home);
|
||
std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir);
|
||
|
||
let config = Config::load_or_init().unwrap();
|
||
|
||
assert_eq!(config.workspace_dir, workspace_dir);
|
||
assert_eq!(config.config_path, legacy_config_path);
|
||
assert!(config.config_path.exists());
|
||
|
||
std::env::remove_var("ZEROCLAW_WORKSPACE");
|
||
if let Some(home) = original_home {
|
||
std::env::set_var("HOME", home);
|
||
} else {
|
||
std::env::remove_var("HOME");
|
||
}
|
||
let _ = fs::remove_dir_all(temp_home);
|
||
}
|
||
|
||
#[test]
|
||
fn load_or_init_workspace_override_keeps_existing_legacy_config() {
|
||
let _env_guard = env_override_test_guard();
|
||
let temp_home =
|
||
std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
|
||
let workspace_dir = temp_home.join("custom-workspace");
|
||
let legacy_config_dir = temp_home.join(".zeroclaw");
|
||
let legacy_config_path = legacy_config_dir.join("config.toml");
|
||
|
||
fs::create_dir_all(&legacy_config_dir).unwrap();
|
||
fs::write(
|
||
&legacy_config_path,
|
||
r#"default_temperature = 0.7
|
||
default_model = "legacy-model"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
|
||
let original_home = std::env::var("HOME").ok();
|
||
std::env::set_var("HOME", &temp_home);
|
||
std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir);
|
||
|
||
let config = Config::load_or_init().unwrap();
|
||
|
||
assert_eq!(config.workspace_dir, workspace_dir);
|
||
assert_eq!(config.config_path, legacy_config_path);
|
||
assert_eq!(config.default_model.as_deref(), Some("legacy-model"));
|
||
|
||
std::env::remove_var("ZEROCLAW_WORKSPACE");
|
||
if let Some(home) = original_home {
|
||
std::env::set_var("HOME", home);
|
||
} else {
|
||
std::env::remove_var("HOME");
|
||
}
|
||
let _ = fs::remove_dir_all(temp_home);
|
||
}
|
||
|
||
#[test]
|
||
fn load_or_init_uses_persisted_active_workspace_marker() {
|
||
let _env_guard = env_override_test_guard();
|
||
let temp_home =
|
||
std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
|
||
let custom_config_dir = temp_home.join("profiles").join("agent-alpha");
|
||
|
||
fs::create_dir_all(&custom_config_dir).unwrap();
|
||
fs::write(
|
||
custom_config_dir.join("config.toml"),
|
||
"default_temperature = 0.7\ndefault_model = \"persisted-profile\"\n",
|
||
)
|
||
.unwrap();
|
||
|
||
let original_home = std::env::var("HOME").ok();
|
||
std::env::set_var("HOME", &temp_home);
|
||
std::env::remove_var("ZEROCLAW_WORKSPACE");
|
||
|
||
persist_active_workspace_config_dir(&custom_config_dir).unwrap();
|
||
|
||
let config = Config::load_or_init().unwrap();
|
||
|
||
assert_eq!(config.config_path, custom_config_dir.join("config.toml"));
|
||
assert_eq!(config.workspace_dir, custom_config_dir.join("workspace"));
|
||
assert_eq!(config.default_model.as_deref(), Some("persisted-profile"));
|
||
|
||
if let Some(home) = original_home {
|
||
std::env::set_var("HOME", home);
|
||
} else {
|
||
std::env::remove_var("HOME");
|
||
}
|
||
let _ = fs::remove_dir_all(temp_home);
|
||
}
|
||
|
||
#[test]
|
||
fn load_or_init_env_workspace_override_takes_priority_over_marker() {
|
||
let _env_guard = env_override_test_guard();
|
||
let temp_home =
|
||
std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
|
||
let marker_config_dir = temp_home.join("profiles").join("persisted-profile");
|
||
let env_workspace_dir = temp_home.join("env-workspace");
|
||
|
||
fs::create_dir_all(&marker_config_dir).unwrap();
|
||
fs::write(
|
||
marker_config_dir.join("config.toml"),
|
||
"default_temperature = 0.7\ndefault_model = \"marker-model\"\n",
|
||
)
|
||
.unwrap();
|
||
|
||
let original_home = std::env::var("HOME").ok();
|
||
std::env::set_var("HOME", &temp_home);
|
||
persist_active_workspace_config_dir(&marker_config_dir).unwrap();
|
||
std::env::set_var("ZEROCLAW_WORKSPACE", &env_workspace_dir);
|
||
|
||
let config = Config::load_or_init().unwrap();
|
||
|
||
assert_eq!(config.workspace_dir, env_workspace_dir.join("workspace"));
|
||
assert_eq!(config.config_path, env_workspace_dir.join("config.toml"));
|
||
|
||
std::env::remove_var("ZEROCLAW_WORKSPACE");
|
||
if let Some(home) = original_home {
|
||
std::env::set_var("HOME", home);
|
||
} else {
|
||
std::env::remove_var("HOME");
|
||
}
|
||
let _ = fs::remove_dir_all(temp_home);
|
||
}
|
||
|
||
#[test]
|
||
fn persist_active_workspace_marker_is_cleared_for_default_config_dir() {
|
||
let _env_guard = env_override_test_guard();
|
||
let temp_home =
|
||
std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
|
||
let default_config_dir = temp_home.join(".zeroclaw");
|
||
let custom_config_dir = temp_home.join("profiles").join("custom-profile");
|
||
let marker_path = default_config_dir.join(ACTIVE_WORKSPACE_STATE_FILE);
|
||
|
||
let original_home = std::env::var("HOME").ok();
|
||
std::env::set_var("HOME", &temp_home);
|
||
|
||
persist_active_workspace_config_dir(&custom_config_dir).unwrap();
|
||
assert!(marker_path.exists());
|
||
|
||
persist_active_workspace_config_dir(&default_config_dir).unwrap();
|
||
assert!(!marker_path.exists());
|
||
|
||
if let Some(home) = original_home {
|
||
std::env::set_var("HOME", home);
|
||
} else {
|
||
std::env::remove_var("HOME");
|
||
}
|
||
let _ = fs::remove_dir_all(temp_home);
|
||
}
|
||
|
||
#[test]
|
||
fn env_override_empty_values_ignored() {
|
||
let _env_guard = env_override_test_guard();
|
||
let mut config = Config::default();
|
||
let original_provider = config.default_provider.clone();
|
||
|
||
std::env::set_var("ZEROCLAW_PROVIDER", "");
|
||
config.apply_env_overrides();
|
||
assert_eq!(config.default_provider, original_provider);
|
||
|
||
std::env::remove_var("ZEROCLAW_PROVIDER");
|
||
}
|
||
|
||
#[test]
|
||
fn env_override_gateway_port() {
|
||
let _env_guard = env_override_test_guard();
|
||
let mut config = Config::default();
|
||
assert_eq!(config.gateway.port, 3000);
|
||
|
||
std::env::set_var("ZEROCLAW_GATEWAY_PORT", "8080");
|
||
config.apply_env_overrides();
|
||
assert_eq!(config.gateway.port, 8080);
|
||
|
||
std::env::remove_var("ZEROCLAW_GATEWAY_PORT");
|
||
}
|
||
|
||
#[test]
|
||
fn env_override_port_fallback() {
|
||
let _env_guard = env_override_test_guard();
|
||
let mut config = Config::default();
|
||
|
||
std::env::remove_var("ZEROCLAW_GATEWAY_PORT");
|
||
std::env::set_var("PORT", "9000");
|
||
config.apply_env_overrides();
|
||
assert_eq!(config.gateway.port, 9000);
|
||
|
||
std::env::remove_var("PORT");
|
||
}
|
||
|
||
#[test]
|
||
fn env_override_gateway_host() {
|
||
let _env_guard = env_override_test_guard();
|
||
let mut config = Config::default();
|
||
assert_eq!(config.gateway.host, "127.0.0.1");
|
||
|
||
std::env::set_var("ZEROCLAW_GATEWAY_HOST", "0.0.0.0");
|
||
config.apply_env_overrides();
|
||
assert_eq!(config.gateway.host, "0.0.0.0");
|
||
|
||
std::env::remove_var("ZEROCLAW_GATEWAY_HOST");
|
||
}
|
||
|
||
#[test]
|
||
fn env_override_host_fallback() {
|
||
let _env_guard = env_override_test_guard();
|
||
let mut config = Config::default();
|
||
|
||
std::env::remove_var("ZEROCLAW_GATEWAY_HOST");
|
||
std::env::set_var("HOST", "0.0.0.0");
|
||
config.apply_env_overrides();
|
||
assert_eq!(config.gateway.host, "0.0.0.0");
|
||
|
||
std::env::remove_var("HOST");
|
||
}
|
||
|
||
#[test]
|
||
fn env_override_temperature() {
|
||
let _env_guard = env_override_test_guard();
|
||
let mut config = Config::default();
|
||
|
||
std::env::set_var("ZEROCLAW_TEMPERATURE", "0.5");
|
||
config.apply_env_overrides();
|
||
assert!((config.default_temperature - 0.5).abs() < f64::EPSILON);
|
||
|
||
std::env::remove_var("ZEROCLAW_TEMPERATURE");
|
||
}
|
||
|
||
#[test]
|
||
fn env_override_temperature_out_of_range_ignored() {
|
||
let _env_guard = env_override_test_guard();
|
||
// Clean up any leftover env vars from other tests
|
||
std::env::remove_var("ZEROCLAW_TEMPERATURE");
|
||
|
||
let mut config = Config::default();
|
||
let original_temp = config.default_temperature;
|
||
|
||
// Temperature > 2.0 should be ignored
|
||
std::env::set_var("ZEROCLAW_TEMPERATURE", "3.0");
|
||
config.apply_env_overrides();
|
||
assert!(
|
||
(config.default_temperature - original_temp).abs() < f64::EPSILON,
|
||
"Temperature 3.0 should be ignored (out of range)"
|
||
);
|
||
|
||
std::env::remove_var("ZEROCLAW_TEMPERATURE");
|
||
}
|
||
|
||
#[test]
|
||
fn env_override_invalid_port_ignored() {
|
||
let _env_guard = env_override_test_guard();
|
||
let mut config = Config::default();
|
||
let original_port = config.gateway.port;
|
||
|
||
std::env::set_var("PORT", "not_a_number");
|
||
config.apply_env_overrides();
|
||
assert_eq!(config.gateway.port, original_port);
|
||
|
||
std::env::remove_var("PORT");
|
||
}
|
||
|
||
#[test]
|
||
fn env_override_web_search_config() {
|
||
let _env_guard = env_override_test_guard();
|
||
let mut config = Config::default();
|
||
|
||
std::env::set_var("WEB_SEARCH_ENABLED", "false");
|
||
std::env::set_var("WEB_SEARCH_PROVIDER", "brave");
|
||
std::env::set_var("WEB_SEARCH_MAX_RESULTS", "7");
|
||
std::env::set_var("WEB_SEARCH_TIMEOUT_SECS", "20");
|
||
std::env::set_var("BRAVE_API_KEY", "brave-test-key");
|
||
|
||
config.apply_env_overrides();
|
||
|
||
assert!(!config.web_search.enabled);
|
||
assert_eq!(config.web_search.provider, "brave");
|
||
assert_eq!(config.web_search.max_results, 7);
|
||
assert_eq!(config.web_search.timeout_secs, 20);
|
||
assert_eq!(
|
||
config.web_search.brave_api_key.as_deref(),
|
||
Some("brave-test-key")
|
||
);
|
||
|
||
std::env::remove_var("WEB_SEARCH_ENABLED");
|
||
std::env::remove_var("WEB_SEARCH_PROVIDER");
|
||
std::env::remove_var("WEB_SEARCH_MAX_RESULTS");
|
||
std::env::remove_var("WEB_SEARCH_TIMEOUT_SECS");
|
||
std::env::remove_var("BRAVE_API_KEY");
|
||
}
|
||
|
||
#[test]
|
||
fn env_override_web_search_invalid_values_ignored() {
|
||
let _env_guard = env_override_test_guard();
|
||
let mut config = Config::default();
|
||
let original_max_results = config.web_search.max_results;
|
||
let original_timeout = config.web_search.timeout_secs;
|
||
|
||
std::env::set_var("WEB_SEARCH_MAX_RESULTS", "99");
|
||
std::env::set_var("WEB_SEARCH_TIMEOUT_SECS", "0");
|
||
|
||
config.apply_env_overrides();
|
||
|
||
assert_eq!(config.web_search.max_results, original_max_results);
|
||
assert_eq!(config.web_search.timeout_secs, original_timeout);
|
||
|
||
std::env::remove_var("WEB_SEARCH_MAX_RESULTS");
|
||
std::env::remove_var("WEB_SEARCH_TIMEOUT_SECS");
|
||
}
|
||
|
||
#[test]
|
||
fn gateway_config_default_values() {
|
||
let g = GatewayConfig::default();
|
||
assert_eq!(g.port, 3000);
|
||
assert_eq!(g.host, "127.0.0.1");
|
||
assert!(g.require_pairing);
|
||
assert!(!g.allow_public_bind);
|
||
assert!(g.paired_tokens.is_empty());
|
||
assert!(!g.trust_forwarded_headers);
|
||
assert_eq!(g.rate_limit_max_keys, 10_000);
|
||
assert_eq!(g.idempotency_max_keys, 10_000);
|
||
}
|
||
|
||
// ── Peripherals config ───────────────────────────────────────
|
||
|
||
#[test]
|
||
fn peripherals_config_default_disabled() {
|
||
let p = PeripheralsConfig::default();
|
||
assert!(!p.enabled);
|
||
assert!(p.boards.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn peripheral_board_config_defaults() {
|
||
let b = PeripheralBoardConfig::default();
|
||
assert!(b.board.is_empty());
|
||
assert_eq!(b.transport, "serial");
|
||
assert!(b.path.is_none());
|
||
assert_eq!(b.baud, 115_200);
|
||
}
|
||
|
||
#[test]
|
||
fn peripherals_config_toml_roundtrip() {
|
||
let p = PeripheralsConfig {
|
||
enabled: true,
|
||
boards: vec![PeripheralBoardConfig {
|
||
board: "nucleo-f401re".into(),
|
||
transport: "serial".into(),
|
||
path: Some("/dev/ttyACM0".into()),
|
||
baud: 115_200,
|
||
}],
|
||
datasheet_dir: None,
|
||
};
|
||
let toml_str = toml::to_string(&p).unwrap();
|
||
let parsed: PeripheralsConfig = toml::from_str(&toml_str).unwrap();
|
||
assert!(parsed.enabled);
|
||
assert_eq!(parsed.boards.len(), 1);
|
||
assert_eq!(parsed.boards[0].board, "nucleo-f401re");
|
||
assert_eq!(parsed.boards[0].path.as_deref(), Some("/dev/ttyACM0"));
|
||
}
|
||
|
||
#[test]
|
||
fn lark_config_serde() {
|
||
let lc = LarkConfig {
|
||
app_id: "cli_123456".into(),
|
||
app_secret: "secret_abc".into(),
|
||
encrypt_key: Some("encrypt_key".into()),
|
||
verification_token: Some("verify_token".into()),
|
||
allowed_users: vec!["user_123".into(), "user_456".into()],
|
||
use_feishu: true,
|
||
receive_mode: LarkReceiveMode::Websocket,
|
||
port: None,
|
||
};
|
||
let json = serde_json::to_string(&lc).unwrap();
|
||
let parsed: LarkConfig = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(parsed.app_id, "cli_123456");
|
||
assert_eq!(parsed.app_secret, "secret_abc");
|
||
assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key"));
|
||
assert_eq!(parsed.verification_token.as_deref(), Some("verify_token"));
|
||
assert_eq!(parsed.allowed_users.len(), 2);
|
||
assert!(parsed.use_feishu);
|
||
}
|
||
|
||
#[test]
|
||
fn lark_config_toml_roundtrip() {
|
||
let lc = LarkConfig {
|
||
app_id: "cli_123456".into(),
|
||
app_secret: "secret_abc".into(),
|
||
encrypt_key: Some("encrypt_key".into()),
|
||
verification_token: Some("verify_token".into()),
|
||
allowed_users: vec!["*".into()],
|
||
use_feishu: false,
|
||
receive_mode: LarkReceiveMode::Webhook,
|
||
port: Some(9898),
|
||
};
|
||
let toml_str = toml::to_string(&lc).unwrap();
|
||
let parsed: LarkConfig = toml::from_str(&toml_str).unwrap();
|
||
assert_eq!(parsed.app_id, "cli_123456");
|
||
assert_eq!(parsed.app_secret, "secret_abc");
|
||
assert!(!parsed.use_feishu);
|
||
}
|
||
|
||
#[test]
|
||
fn lark_config_deserializes_without_optional_fields() {
|
||
let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
|
||
let parsed: LarkConfig = serde_json::from_str(json).unwrap();
|
||
assert!(parsed.encrypt_key.is_none());
|
||
assert!(parsed.verification_token.is_none());
|
||
assert!(parsed.allowed_users.is_empty());
|
||
assert!(!parsed.use_feishu);
|
||
}
|
||
|
||
#[test]
|
||
fn lark_config_defaults_to_lark_endpoint() {
|
||
let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
|
||
let parsed: LarkConfig = serde_json::from_str(json).unwrap();
|
||
assert!(
|
||
!parsed.use_feishu,
|
||
"use_feishu should default to false (Lark)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn lark_config_with_wildcard_allowed_users() {
|
||
let json = r#"{"app_id":"cli_123","app_secret":"secret","allowed_users":["*"]}"#;
|
||
let parsed: LarkConfig = serde_json::from_str(json).unwrap();
|
||
assert_eq!(parsed.allowed_users, vec!["*"]);
|
||
}
|
||
|
||
// ── Config file permission hardening (Unix only) ───────────────
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn new_config_file_has_restricted_permissions() {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let config_path = tmp.path().join("config.toml");
|
||
|
||
// Create a config and save it
|
||
let mut config = Config::default();
|
||
config.config_path = config_path.clone();
|
||
config.save().unwrap();
|
||
|
||
// Apply the same permission logic as load_or_init
|
||
let _ = std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600));
|
||
|
||
let meta = std::fs::metadata(&config_path).unwrap();
|
||
let mode = meta.permissions().mode() & 0o777;
|
||
assert_eq!(
|
||
mode, 0o600,
|
||
"New config file should be owner-only (0600), got {mode:o}"
|
||
);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn world_readable_config_is_detectable() {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
|
||
let tmp = tempfile::TempDir::new().unwrap();
|
||
let config_path = tmp.path().join("config.toml");
|
||
|
||
// Create a config file with intentionally loose permissions
|
||
std::fs::write(&config_path, "# test config").unwrap();
|
||
std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||
|
||
let meta = std::fs::metadata(&config_path).unwrap();
|
||
let mode = meta.permissions().mode();
|
||
assert!(
|
||
mode & 0o004 != 0,
|
||
"Test setup: file should be world-readable (mode {mode:o})"
|
||
);
|
||
}
|
||
}
|