zeroclaw/src/config/schema.rs
ZeroClaw Contributor c0a80ad656 feat(channel): add mention_only option for Telegram groups
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)
2026-02-18 19:51:42 +08:00

4094 lines
136 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.01.0)
#[serde(default = "default_vector_weight")]
pub vector_weight: f64,
/// Weight for keyword BM25 in hybrid search (0.01.0)
#[serde(default = "default_keyword_weight")]
pub keyword_weight: f64,
/// Minimum hybrid score (0.01.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})"
);
}
}