pub mod adaptive_silence;
pub mod chain_paths;
pub mod channels;
pub mod governance;
pub mod nested;
pub mod kernel;
pub mod local_llm;
pub mod overlay;
#[cfg(test)]
mod overlay_tests;
pub mod personality;
pub mod plugins;
pub mod policies;
pub mod skills;
pub mod voice;
pub mod voice_metrics;
pub use adaptive_silence::{AdaptiveSilenceConfig, AdaptiveSilenceTimeout};
pub use channels::*;
pub use governance::*;
pub use kernel::*;
pub use local_llm::*;
pub use personality::*;
pub use plugins::*;
pub use policies::*;
pub use skills::*;
pub use voice::*;
pub use voice_metrics::*;
use std::collections::HashMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::agent_routing::AgentRoutingConfig;
use crate::delegation::DelegationConfig;
use crate::routing::RoutingConfig;
use crate::secret::SecretString;
pub(crate) fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default, skip_serializing_if = "nested::WeaveConfig::is_default")]
pub weave: nested::WeaveConfig,
#[serde(default)]
pub agents: AgentsConfig,
#[serde(default)]
pub channels: ChannelsConfig,
#[serde(default)]
pub providers: ProvidersConfig,
#[serde(default)]
pub gateway: GatewayConfig,
#[serde(default)]
pub tools: ToolsConfig,
#[serde(default)]
pub delegation: DelegationConfig,
#[serde(default)]
pub routing: RoutingConfig,
#[serde(default)]
pub agent_routing: AgentRoutingConfig,
#[serde(default)]
pub voice: VoiceConfig,
#[serde(default)]
pub kernel: KernelConfig,
#[serde(default)]
pub pipeline: PipelineConfig,
#[serde(default)]
pub plugins: PluginsConfig,
#[serde(default)]
pub skills: SkillsConfig,
}
pub const CONFIG_TOP_LEVEL_KEYS: &[&str] = &[
"weave",
"agents",
"channels",
"providers",
"gateway",
"tools",
"delegation",
"routing",
"agent_routing",
"agentRouting",
"voice",
"kernel",
"pipeline",
"plugins",
"skills",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DenyUnknown {
#[default]
No,
Yes,
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigParseError {
#[error("config JSON parse error: {0}")]
Serde(#[from] serde_json::Error),
#[error("unknown top-level config key `{0}` (set WEFT_CONFIG_DENY_UNKNOWN only when you want this check)")]
UnknownField(String),
}
impl Config {
pub fn from_json_str(s: &str) -> Result<Self, ConfigParseError> {
Self::from_json_str_with(s, DenyUnknown::No)
}
pub fn from_json_str_strict(s: &str) -> Result<Self, ConfigParseError> {
Self::from_json_str_with(s, DenyUnknown::Yes)
}
pub fn from_json_str_with(s: &str, deny: DenyUnknown) -> Result<Self, ConfigParseError> {
let value: serde_json::Value = serde_json::from_str(s)?;
if deny == DenyUnknown::Yes
&& let serde_json::Value::Object(map) = &value
{
for key in map.keys() {
if !CONFIG_TOP_LEVEL_KEYS.contains(&key.as_str()) {
return Err(ConfigParseError::UnknownField(key.clone()));
}
}
}
Ok(serde_json::from_value(value)?)
}
pub fn deny_unknown_from_env() -> DenyUnknown {
for var in ["WEFT_CONFIG_DENY_UNKNOWN", "CLAWFT_CONFIG_DENY_UNKNOWN"] {
if let Ok(v) = std::env::var(var) {
let t = v.trim();
if t == "1" || t.eq_ignore_ascii_case("true") || t.eq_ignore_ascii_case("yes") {
return DenyUnknown::Yes;
}
}
}
DenyUnknown::No
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineConfig {
#[serde(default = "default_scorer")]
pub scorer: String,
#[serde(default = "default_learner")]
pub learner: String,
}
fn default_scorer() -> String {
"noop".into()
}
fn default_learner() -> String {
"noop".into()
}
impl Default for PipelineConfig {
fn default() -> Self {
Self {
scorer: default_scorer(),
learner: default_learner(),
}
}
}
impl Config {
pub fn brand(&self) -> &str {
self.kernel.brand()
}
pub fn install_brand(&self) {
install_brand(self.brand());
}
pub fn workspace_path(&self) -> PathBuf {
let raw = &self.agents.defaults.workspace;
#[cfg(feature = "native")]
if let Some(rest) = raw.strip_prefix("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(rest);
}
PathBuf::from(raw)
}
pub fn workspace_path_with_home(&self, home: Option<&std::path::Path>) -> PathBuf {
let raw = &self.agents.defaults.workspace;
if let Some(rest) = raw.strip_prefix("~/")
&& let Some(home) = home
{
return home.join(rest);
}
PathBuf::from(raw)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AgentsConfig {
#[serde(default)]
pub defaults: AgentDefaults,
#[serde(default, alias = "workspaceRoot")]
pub workspace_root: Option<PathBuf>,
#[serde(default, alias = "costBudget")]
pub cost_budget: CostBudgetConfig,
#[serde(default, alias = "cowMemory")]
pub cow_memory: CowMemoryConfig,
#[serde(default, alias = "bindingThreadMode")]
pub binding_thread_mode: BindingThreadMode,
#[serde(default, alias = "memoryRecall")]
pub memory_recall: MemoryRecallConfig,
#[serde(default, alias = "memoryConsolidation")]
pub memory_consolidation: MemoryConsolidationConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MemoryConsolidationConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_consolidate_every_k", alias = "everyKTurns")]
pub every_k_turns: usize,
#[serde(default = "default_true_bool", alias = "topicMerge")]
pub topic_merge: bool,
}
fn default_consolidate_every_k() -> usize {
10
}
fn default_true_bool() -> bool {
true
}
impl Default for MemoryConsolidationConfig {
fn default() -> Self {
Self { enabled: false, every_k_turns: default_consolidate_every_k(), topic_merge: true }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MemoryRecallConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_recall_top_k", alias = "topK")]
pub top_k: usize,
#[serde(default = "default_recall_top_m", alias = "topM")]
pub top_m: usize,
}
fn default_recall_top_k() -> usize {
20
}
fn default_recall_top_m() -> usize {
5
}
impl Default for MemoryRecallConfig {
fn default() -> Self {
Self { enabled: false, top_k: default_recall_top_k(), top_m: default_recall_top_m() }
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BindingThreadMode {
#[default]
Deny,
WarnOnly,
}
impl BindingThreadMode {
pub const fn as_str(self) -> &'static str {
match self {
Self::Deny => "deny",
Self::WarnOnly => "warn_only",
}
}
pub const fn is_deny(self) -> bool {
matches!(self, Self::Deny)
}
}
impl AgentsConfig {
pub fn resolve_workspace_root(&self) -> std::io::Result<PathBuf> {
self.resolve_workspace_root_or(None)
}
pub fn resolve_workspace_root_or(
&self,
fallback: Option<PathBuf>,
) -> std::io::Result<PathBuf> {
if let Some(raw) = self.workspace_root.as_ref() {
return Ok(expand_agent_workspace_root(raw));
}
match fallback {
Some(p) => Ok(p),
None => std::env::current_dir(),
}
}
}
fn expand_agent_workspace_root(raw: &std::path::Path) -> PathBuf {
#[cfg(feature = "native")]
let s = raw.to_string_lossy();
#[cfg(feature = "native")]
if let Some(rest) = s.strip_prefix("~/")
&& let Some(home) = dirs::home_dir()
{
return home.join(rest);
}
#[cfg(feature = "native")]
if s == "~"
&& let Some(home) = dirs::home_dir()
{
return home;
}
raw.to_path_buf()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CowMemoryConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_cow_memory_path")]
pub path: String,
#[serde(default = "default_ingest_turns", alias = "ingestTurns")]
pub ingest_turns: bool,
#[serde(default)]
pub cadence: CowCadence,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CowCadence {
#[default]
Turn,
Tool,
}
fn default_cow_memory_path() -> String {
"~/.clawft/workspace/cow_memory".into()
}
fn default_ingest_turns() -> bool {
true
}
impl Default for CowMemoryConfig {
fn default() -> Self {
Self {
enabled: false,
path: default_cow_memory_path(),
ingest_turns: default_ingest_turns(),
cadence: CowCadence::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostBudgetConfig {
#[serde(default = "default_max_tokens_per_conv", alias = "maxTokensPerConv")]
pub max_tokens_per_conv: u64,
#[serde(default = "default_max_usd_per_conv", alias = "maxUsdPerConv")]
pub max_usd_per_conv: f64,
#[serde(
default = "default_max_iterations_per_conv",
alias = "maxIterationsPerConv"
)]
pub max_iterations_per_conv: u32,
}
fn default_max_tokens_per_conv() -> u64 {
200_000
}
fn default_max_usd_per_conv() -> f64 {
1.00
}
fn default_max_iterations_per_conv() -> u32 {
30
}
impl Default for CostBudgetConfig {
fn default() -> Self {
Self {
max_tokens_per_conv: default_max_tokens_per_conv(),
max_usd_per_conv: default_max_usd_per_conv(),
max_iterations_per_conv: default_max_iterations_per_conv(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentDefaults {
#[serde(default = "default_workspace")]
pub workspace: String,
#[serde(default = "default_model")]
pub model: String,
#[serde(default = "default_max_tokens", alias = "maxTokens")]
pub max_tokens: i32,
#[serde(default = "default_temperature")]
pub temperature: f64,
#[serde(default = "default_max_tool_iterations", alias = "maxToolIterations")]
pub max_tool_iterations: i32,
#[serde(default = "default_memory_window", alias = "memoryWindow")]
pub memory_window: i32,
}
fn default_workspace() -> String {
"~/.nanobot/workspace".into()
}
fn default_model() -> String {
DEFAULT_LOCAL_LLM_MODEL_ROUTED.into()
}
fn default_max_tokens() -> i32 {
8192
}
fn default_temperature() -> f64 {
0.7
}
fn default_max_tool_iterations() -> i32 {
20
}
fn default_memory_window() -> i32 {
50
}
impl Default for AgentDefaults {
fn default() -> Self {
Self {
workspace: default_workspace(),
model: default_model(),
max_tokens: default_max_tokens(),
temperature: default_temperature(),
max_tool_iterations: default_max_tool_iterations(),
memory_window: default_memory_window(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderConfig {
#[serde(default, alias = "apiKey")]
pub api_key: SecretString,
#[serde(default, alias = "apiBase", alias = "baseUrl")]
pub api_base: Option<String>,
#[serde(default, alias = "extraHeaders")]
pub extra_headers: Option<HashMap<String, String>>,
#[serde(default, alias = "browserDirect")]
pub browser_direct: bool,
#[serde(default, alias = "corsProxy")]
pub cors_proxy: Option<String>,
}
pub fn default_provider_fallback_order() -> Vec<String> {
vec![
"openrouter".into(),
"openai".into(),
"anthropic".into(),
"groq".into(),
"deepseek".into(),
"gemini".into(),
"xai".into(),
]
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProvidersConfig {
#[serde(default)]
pub custom: ProviderConfig,
#[serde(default)]
pub anthropic: ProviderConfig,
#[serde(default)]
pub openai: ProviderConfig,
#[serde(default)]
pub openrouter: ProviderConfig,
#[serde(default)]
pub deepseek: ProviderConfig,
#[serde(default)]
pub groq: ProviderConfig,
#[serde(default)]
pub zhipu: ProviderConfig,
#[serde(default)]
pub dashscope: ProviderConfig,
#[serde(default)]
pub vllm: ProviderConfig,
#[serde(default)]
pub gemini: ProviderConfig,
#[serde(default)]
pub moonshot: ProviderConfig,
#[serde(default)]
pub minimax: ProviderConfig,
#[serde(default)]
pub aihubmix: ProviderConfig,
#[serde(default)]
pub openai_codex: ProviderConfig,
#[serde(default)]
pub xai: ProviderConfig,
#[serde(default)]
pub elevenlabs: ProviderConfig,
#[serde(default)]
pub local: ProviderConfig,
#[serde(default)]
pub ollama: ProviderConfig,
#[serde(
default = "default_provider_fallback_order",
alias = "providerFallbackOrder"
)]
pub provider_fallback_order: Vec<String>,
}
impl ProvidersConfig {
pub fn effective_provider_fallback_order(&self) -> Vec<String> {
if self.provider_fallback_order.is_empty() {
default_provider_fallback_order()
} else {
self.provider_fallback_order.clone()
}
}
pub fn provider_config(&self, name: &str) -> &ProviderConfig {
match name {
"anthropic" => &self.anthropic,
"openai" => &self.openai,
"openrouter" => &self.openrouter,
"deepseek" => &self.deepseek,
"groq" => &self.groq,
"zhipu" => &self.zhipu,
"dashscope" => &self.dashscope,
"vllm" => &self.vllm,
"gemini" => &self.gemini,
"moonshot" => &self.moonshot,
"minimax" => &self.minimax,
"aihubmix" => &self.aihubmix,
"openai_codex" | "openai-codex" => &self.openai_codex,
"xai" => &self.xai,
"elevenlabs" => &self.elevenlabs,
"local" => &self.local,
"ollama" => &self.ollama,
_ => &self.custom,
}
}
pub fn first_in_order_with_api_key<'a>(&'a self, order: &'a [String]) -> Option<&'a str> {
order
.iter()
.find(|name| !self.provider_config(name).api_key.is_empty())
.map(|s| s.as_str())
}
pub fn first_fallback_with_api_key(&self) -> Option<String> {
let order = self.effective_provider_fallback_order();
order
.into_iter()
.find(|name| !self.provider_config(name).api_key.is_empty())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GatewayConfig {
#[serde(default = "default_gateway_host")]
pub host: String,
#[serde(default = "default_gateway_port")]
pub port: u16,
#[serde(default, alias = "heartbeatIntervalMinutes")]
pub heartbeat_interval_minutes: u64,
#[serde(default = "default_heartbeat_prompt", alias = "heartbeatPrompt")]
pub heartbeat_prompt: String,
#[serde(default = "default_api_port", alias = "apiPort")]
pub api_port: u16,
#[serde(default = "default_cors_origins", alias = "corsOrigins")]
pub cors_origins: Vec<String>,
#[serde(default, alias = "apiEnabled")]
pub api_enabled: bool,
#[serde(default, alias = "dangerouslyPlainHttp")]
pub dangerously_plain_http: bool,
#[serde(default, alias = "staticDir")]
pub static_dir: Option<String>,
#[serde(default, alias = "consoleConnectSrc")]
pub console_connect_src: Vec<String>,
#[serde(default, alias = "tailnetIdentity")]
pub tailnet_identity: TailnetIdentityConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TailnetIdentityConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default, alias = "allowedLogins")]
pub allowed_logins: Vec<String>,
#[serde(default = "default_tailnet_ttl_secs", alias = "ttlSecs")]
pub ttl_secs: u64,
#[serde(default, alias = "tailscaleBin")]
pub tailscale_bin: Option<String>,
}
fn default_tailnet_ttl_secs() -> u64 {
900
}
impl Default for TailnetIdentityConfig {
fn default() -> Self {
Self {
enabled: false,
allowed_logins: Vec::new(),
ttl_secs: default_tailnet_ttl_secs(),
tailscale_bin: None,
}
}
}
fn default_gateway_host() -> String {
"127.0.0.1".into()
}
fn default_gateway_port() -> u16 {
18790
}
fn default_heartbeat_prompt() -> String {
"heartbeat".into()
}
fn default_api_port() -> u16 {
18789
}
fn default_cors_origins() -> Vec<String> {
vec!["http://localhost:5173".into()]
}
impl Default for GatewayConfig {
fn default() -> Self {
Self {
host: default_gateway_host(),
port: default_gateway_port(),
heartbeat_interval_minutes: 0,
heartbeat_prompt: default_heartbeat_prompt(),
api_port: default_api_port(),
cors_origins: default_cors_origins(),
api_enabled: false,
dangerously_plain_http: false,
static_dir: None,
console_connect_src: Vec::new(),
tailnet_identity: TailnetIdentityConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolsConfig {
#[serde(default)]
pub web: WebToolsConfig,
#[serde(default, rename = "exec")]
pub exec_tool: ExecToolConfig,
#[serde(default, alias = "restrictToWorkspace")]
pub restrict_to_workspace: bool,
#[serde(default, alias = "mcpServers")]
pub mcp_servers: HashMap<String, MCPServerConfig>,
#[serde(default, alias = "commandPolicy")]
pub command_policy: CommandPolicyConfig,
#[serde(default, alias = "urlPolicy")]
pub url_policy: UrlPolicyConfig,
#[serde(default, alias = "allowedTools")]
pub allowed_tools: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WebToolsConfig {
#[serde(default)]
pub search: WebSearchConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebSearchConfig {
#[serde(default, alias = "apiKey")]
pub api_key: SecretString,
#[serde(default = "default_max_results", alias = "maxResults")]
pub max_results: u32,
}
fn default_max_results() -> u32 {
5
}
impl Default for WebSearchConfig {
fn default() -> Self {
Self {
api_key: SecretString::default(),
max_results: default_max_results(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecToolConfig {
#[serde(default = "default_exec_timeout")]
pub timeout: u32,
}
fn default_exec_timeout() -> u32 {
60
}
impl Default for ExecToolConfig {
fn default() -> Self {
Self {
timeout: default_exec_timeout(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MCPServerConfig {
#[serde(default)]
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub url: String,
#[serde(default = "default_true", alias = "internalOnly")]
pub internal_only: bool,
}
impl Default for MCPServerConfig {
fn default() -> Self {
Self {
command: String::new(),
args: Vec::new(),
env: HashMap::new(),
url: String::new(),
internal_only: true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE_PATH: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../tests/fixtures/config.json"
);
fn load_fixture() -> Config {
let content =
std::fs::read_to_string(FIXTURE_PATH).expect("fixture config.json should exist");
serde_json::from_str(&content).expect("fixture should deserialize")
}
#[test]
fn deserialize_fixture() {
let cfg = load_fixture();
assert_eq!(
cfg.agents.defaults.model,
DEFAULT_LOCAL_LLM_MODEL_ROUTED
);
assert_eq!(cfg.agents.defaults.max_tokens, 8192);
assert_eq!(cfg.agents.defaults.temperature, 0.7);
assert_eq!(cfg.agents.defaults.max_tool_iterations, 20);
assert_eq!(cfg.agents.defaults.memory_window, 50);
assert!(cfg.channels.telegram.enabled);
assert_eq!(cfg.channels.telegram.token.expose(), "test-bot-token-123");
assert_eq!(cfg.channels.telegram.allow_from, vec!["user1", "user2"]);
assert!(!cfg.channels.slack.enabled);
assert!(!cfg.channels.discord.enabled);
assert_eq!(cfg.providers.anthropic.api_key.expose(), "sk-ant-test-key");
assert_eq!(cfg.providers.openrouter.api_key.expose(), "sk-or-test-key");
assert_eq!(
cfg.providers.openrouter.api_base.as_deref(),
Some("https://openrouter.ai/api/v1")
);
assert!(cfg.providers.deepseek.api_key.is_empty());
assert_eq!(cfg.gateway.host, "0.0.0.0");
assert_eq!(cfg.gateway.port, 18790);
assert_eq!(cfg.tools.web.search.max_results, 5);
assert_eq!(cfg.tools.exec_tool.timeout, 60);
assert!(!cfg.tools.restrict_to_workspace);
assert!(cfg.tools.mcp_servers.contains_key("test-server"));
let mcp = &cfg.tools.mcp_servers["test-server"];
assert_eq!(mcp.command, "npx");
assert_eq!(mcp.args, vec!["-y", "test-mcp-server"]);
}
#[test]
fn camel_case_aliases() {
let cfg = load_fixture();
assert_eq!(cfg.agents.defaults.max_tokens, 8192); assert_eq!(cfg.agents.defaults.max_tool_iterations, 20); assert_eq!(cfg.agents.defaults.memory_window, 50); assert_eq!(cfg.channels.telegram.allow_from, vec!["user1", "user2"]); }
#[test]
fn default_values_for_missing_fields() {
let json = r#"{}"#;
let cfg: Config = serde_json::from_str(json).unwrap();
assert_eq!(cfg.agents.defaults.workspace, "~/.nanobot/workspace");
assert_eq!(
cfg.agents.defaults.model,
DEFAULT_LOCAL_LLM_MODEL_ROUTED
);
assert_eq!(cfg.agents.defaults.max_tokens, 8192);
assert!((cfg.agents.defaults.temperature - 0.7).abs() < f64::EPSILON);
assert_eq!(cfg.agents.defaults.max_tool_iterations, 20);
assert_eq!(cfg.agents.defaults.memory_window, 50);
assert!(!cfg.channels.telegram.enabled);
assert!(cfg.channels.telegram.token.is_empty());
assert!(!cfg.channels.slack.enabled);
assert_eq!(cfg.channels.slack.mode, "socket");
assert!(!cfg.channels.discord.enabled);
assert_eq!(cfg.channels.discord.intents, 37377);
assert_eq!(cfg.gateway.host, "127.0.0.1");
assert_eq!(cfg.gateway.port, 18790);
assert_eq!(cfg.tools.exec_tool.timeout, 60);
assert_eq!(cfg.tools.web.search.max_results, 5);
assert_eq!(cfg.brand(), DEFAULT_BRAND);
assert_eq!(cfg.kernel.brand(), "WeftOS");
}
#[test]
fn config_brand_installs_process_token() {
let mut cfg = Config::default();
cfg.kernel.brand = "Valtech Agentic Mesh".into();
assert_eq!(cfg.brand(), "Valtech Agentic Mesh");
cfg.install_brand();
assert_eq!(brand(), "Valtech Agentic Mesh");
reset_brand_for_test();
assert_eq!(brand(), DEFAULT_BRAND);
}
#[test]
fn serde_roundtrip() {
let cfg = load_fixture();
let json = serde_json::to_string(&cfg).unwrap();
let restored: Config = serde_json::from_str(&json).unwrap();
assert_eq!(restored.agents.defaults.model, cfg.agents.defaults.model);
assert_eq!(restored.gateway.port, cfg.gateway.port);
assert!(restored.providers.anthropic.api_key.is_empty());
}
#[test]
fn unknown_fields_ignored() {
let json = r#"{
"agents": { "defaults": { "model": "test" } },
"unknown_top_level": true,
"channels": {
"telegram": { "enabled": false, "some_future_field": 42 }
},
"providers": {
"anthropic": { "apiKey": "k", "newField": "x" }
}
}"#;
let cfg: Config = serde_json::from_str(json).unwrap();
assert_eq!(cfg.agents.defaults.model, "test");
assert!(!cfg.channels.telegram.enabled);
assert_eq!(cfg.providers.anthropic.api_key.expose(), "k");
let cfg2 = Config::from_json_str(json).unwrap();
assert_eq!(cfg2.agents.defaults.model, "test");
}
#[test]
fn unknown_fields_rejected_when_deny_unknown() {
let json = r#"{
"agents": { "defaults": { "model": "test" } },
"unknown_top_level": true
}"#;
let err = Config::from_json_str_strict(json).unwrap_err();
match err {
ConfigParseError::UnknownField(k) => assert_eq!(k, "unknown_top_level"),
other => panic!("expected UnknownField, got {other:?}"),
}
let ok = Config::from_json_str_strict(
r#"{ "agents": { "defaults": { "model": "m" } } }"#,
)
.unwrap();
assert_eq!(ok.agents.defaults.model, "m");
}
#[test]
fn unknown_channel_plugins_in_extra() {
let json = r#"{
"channels": {
"telegram": { "enabled": true },
"my_custom_channel": { "url": "wss://custom.io" }
}
}"#;
let cfg: Config = serde_json::from_str(json).unwrap();
assert!(cfg.channels.telegram.enabled);
assert!(cfg.channels.extra.contains_key("my_custom_channel"));
}
#[test]
fn workspace_path_expansion() {
let mut cfg = Config::default();
cfg.agents.defaults.workspace = "~/.clawft/workspace".into();
let path = cfg.workspace_path();
assert!(!path.to_string_lossy().starts_with('~'));
}
#[test]
fn skills_autogen_defaults_disabled() {
let cfg = Config::default();
assert!(!cfg.skills.autogen.enabled);
assert_eq!(cfg.skills.autogen.threshold, 3);
assert_eq!(cfg.skills.autogen.max_pending, 10);
assert!(cfg.skills.promotion.enabled);
assert_eq!(cfg.skills.promotion.threshold, 10);
}
#[test]
fn skills_autogen_deserializes_from_json() {
let json = r#"{
"skills": {
"autogen": { "enabled": true, "threshold": 5, "max_pending": 8 }
}
}"#;
let cfg: Config = serde_json::from_str(json).unwrap();
assert!(cfg.skills.autogen.enabled);
assert_eq!(cfg.skills.autogen.threshold, 5);
assert_eq!(cfg.skills.autogen.max_pending, 8);
assert!(cfg.skills.promotion.enabled);
assert_eq!(cfg.skills.promotion.threshold, 10);
}
#[test]
fn skills_promotion_deserializes_from_json() {
let json = r#"{
"skills": {
"promotion": { "enabled": false, "threshold": 20 }
}
}"#;
let cfg: Config = serde_json::from_str(json).unwrap();
assert!(!cfg.skills.promotion.enabled);
assert_eq!(cfg.skills.promotion.threshold, 20);
}
#[test]
fn workspace_root_defaults_to_none() {
let cfg = Config::default();
assert!(cfg.agents.workspace_root.is_none());
let fallback = PathBuf::from("/tmp/daemon-cwd");
let resolved = cfg
.agents
.resolve_workspace_root_or(Some(fallback.clone()))
.unwrap();
assert_eq!(resolved, fallback);
}
#[test]
fn binding_thread_mode_defaults_to_deny() {
let cfg = Config::default();
assert_eq!(cfg.agents.binding_thread_mode, BindingThreadMode::Deny);
assert!(cfg.agents.binding_thread_mode.is_deny());
let snake = r#"{ "agents": { "binding_thread_mode": "warn_only" } }"#;
let cfg: Config = serde_json::from_str(snake).unwrap();
assert_eq!(cfg.agents.binding_thread_mode, BindingThreadMode::WarnOnly);
let camel = r#"{ "agents": { "bindingThreadMode": "deny" } }"#;
let cfg: Config = serde_json::from_str(camel).unwrap();
assert_eq!(cfg.agents.binding_thread_mode, BindingThreadMode::Deny);
}
#[test]
fn workspace_root_deserialize_snake_and_camel() {
let snake = r#"{ "agents": { "workspace_root": "/home/user/proj-a" } }"#;
let cfg: Config = serde_json::from_str(snake).unwrap();
assert_eq!(
cfg.agents.workspace_root.as_deref(),
Some(std::path::Path::new("/home/user/proj-a"))
);
let camel = r#"{ "agents": { "workspaceRoot": "/home/user/proj-b" } }"#;
let cfg: Config = serde_json::from_str(camel).unwrap();
assert_eq!(
cfg.agents.workspace_root.as_deref(),
Some(std::path::Path::new("/home/user/proj-b"))
);
let resolved = cfg
.agents
.resolve_workspace_root_or(Some(PathBuf::from("/should-not-use")))
.unwrap();
assert_eq!(resolved, PathBuf::from("/home/user/proj-b"));
}
#[test]
fn workspace_root_prefers_config_over_fallback() {
let a = AgentsConfig {
workspace_root: Some(PathBuf::from("/workspaces/alpha")),
..AgentsConfig::default()
};
let b = AgentsConfig {
workspace_root: Some(PathBuf::from("/workspaces/beta")),
..AgentsConfig::default()
};
let cwd = PathBuf::from("/tmp");
assert_eq!(
a.resolve_workspace_root_or(Some(cwd.clone())).unwrap(),
PathBuf::from("/workspaces/alpha")
);
assert_eq!(
b.resolve_workspace_root_or(Some(cwd)).unwrap(),
PathBuf::from("/workspaces/beta")
);
}
#[test]
fn provider_config_with_extra_headers() {
let json = r#"{
"apiKey": "test",
"extraHeaders": { "X-Custom": "value" }
}"#;
let cfg: ProviderConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.api_key.expose(), "test");
let headers = cfg.extra_headers.unwrap();
assert_eq!(headers["X-Custom"], "value");
}
#[test]
fn provider_fallback_order_default_preserves_historical() {
let expected = [
"openrouter",
"openai",
"anthropic",
"groq",
"deepseek",
"gemini",
"xai",
];
assert_eq!(default_provider_fallback_order(), expected);
let cfg = ProvidersConfig::default();
assert!(cfg.provider_fallback_order.is_empty());
assert_eq!(cfg.effective_provider_fallback_order(), expected);
let json = r#"{ "openai": { "apiKey": "sk-test" } }"#;
let cfg: ProvidersConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.provider_fallback_order, expected);
}
#[test]
fn provider_fallback_order_custom_selects_first_with_key() {
let snake = r#"{
"provider_fallback_order": ["gemini", "openai", "anthropic"],
"openai": { "apiKey": "sk-openai" },
"anthropic": { "apiKey": "sk-ant" }
}"#;
let cfg: ProvidersConfig = serde_json::from_str(snake).unwrap();
assert_eq!(
cfg.provider_fallback_order,
vec!["gemini", "openai", "anthropic"]
);
assert_eq!(
cfg.first_fallback_with_api_key().as_deref(),
Some("openai")
);
let camel = r#"{
"providerFallbackOrder": ["xai", "groq", "openrouter"],
"xai": { "apiKey": "xai-key" },
"groq": { "apiKey": "groq-key" }
}"#;
let cfg: ProvidersConfig = serde_json::from_str(camel).unwrap();
assert_eq!(cfg.provider_fallback_order, vec!["xai", "groq", "openrouter"]);
assert_eq!(cfg.first_fallback_with_api_key().as_deref(), Some("xai"));
let root = r#"{
"providers": {
"providerFallbackOrder": ["anthropic", "openai"],
"openai": { "apiKey": "sk-oai" }
}
}"#;
let root_cfg: Config = serde_json::from_str(root).unwrap();
assert_eq!(
root_cfg.providers.effective_provider_fallback_order(),
vec!["anthropic", "openai"]
);
assert_eq!(
root_cfg
.providers
.first_fallback_with_api_key()
.as_deref(),
Some("openai")
);
}
#[test]
fn email_config_defaults() {
let cfg = EmailConfig::default();
assert_eq!(cfg.imap_port, 993);
assert!(cfg.imap_use_ssl);
assert_eq!(cfg.smtp_port, 587);
assert!(cfg.smtp_use_tls);
assert!(!cfg.smtp_use_ssl);
assert!(cfg.auto_reply_enabled);
assert_eq!(cfg.poll_interval_seconds, 30);
assert!(cfg.mark_seen);
assert_eq!(cfg.max_body_chars, 12000);
assert_eq!(cfg.subject_prefix, "Re: ");
}
#[test]
fn mochat_config_defaults() {
let cfg = MochatConfig::default();
assert_eq!(cfg.base_url, "https://mochat.io");
assert_eq!(cfg.socket_path, "/socket.io");
assert_eq!(cfg.socket_reconnect_delay_ms, 1000);
assert_eq!(cfg.socket_max_reconnect_delay_ms, 10000);
assert_eq!(cfg.socket_connect_timeout_ms, 10000);
assert_eq!(cfg.refresh_interval_ms, 30000);
assert_eq!(cfg.watch_timeout_ms, 25000);
assert_eq!(cfg.watch_limit, 100);
assert_eq!(cfg.retry_delay_ms, 500);
assert_eq!(cfg.max_retry_attempts, 0);
assert_eq!(cfg.reply_delay_mode, "non-mention");
assert_eq!(cfg.reply_delay_ms, 120000);
}
#[test]
fn slack_dm_config_defaults() {
let cfg = SlackDMConfig::default();
assert!(cfg.enabled);
assert_eq!(cfg.policy, "open");
}
#[test]
fn gateway_heartbeat_defaults() {
let cfg = GatewayConfig::default();
assert_eq!(cfg.heartbeat_interval_minutes, 0);
assert_eq!(cfg.heartbeat_prompt, "heartbeat");
}
#[test]
fn gateway_plain_http_is_off_by_default_and_parses_both_spellings() {
assert!(!GatewayConfig::default().dangerously_plain_http);
let a: GatewayConfig = serde_json::from_str(r#"{"dangerously_plain_http": true}"#).unwrap();
let b: GatewayConfig = serde_json::from_str(r#"{"dangerouslyPlainHttp": true}"#).unwrap();
assert!(a.dangerously_plain_http && b.dangerously_plain_http);
assert!(!serde_json::from_str::<GatewayConfig>("{}").unwrap().dangerously_plain_http);
}
#[test]
fn gateway_heartbeat_from_json() {
let json = r#"{
"host": "0.0.0.0",
"port": 8080,
"heartbeatIntervalMinutes": 15,
"heartbeatPrompt": "status check"
}"#;
let cfg: GatewayConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.heartbeat_interval_minutes, 15);
assert_eq!(cfg.heartbeat_prompt, "status check");
}
#[test]
fn gateway_heartbeat_disabled_by_default() {
let json = r#"{"host": "0.0.0.0"}"#;
let cfg: GatewayConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.heartbeat_interval_minutes, 0);
assert_eq!(cfg.heartbeat_prompt, "heartbeat");
}
#[test]
fn mcp_server_config_roundtrip() {
let cfg = MCPServerConfig {
command: "npx".into(),
args: vec!["-y".into(), "test-server".into()],
env: {
let mut m = HashMap::new();
m.insert("API_KEY".into(), "secret".into());
m
},
url: String::new(),
internal_only: false,
};
let json = serde_json::to_string(&cfg).unwrap();
let restored: MCPServerConfig = serde_json::from_str(&json).unwrap();
assert_eq!(restored.command, "npx");
assert_eq!(restored.args.len(), 2);
assert_eq!(restored.env["API_KEY"], "secret");
assert!(!restored.internal_only);
}
#[test]
fn command_policy_config_defaults() {
let config: CommandPolicyConfig = serde_json::from_str("{}").unwrap();
assert_eq!(config.mode, "allowlist");
assert!(config.allowlist.is_empty());
assert!(config.denylist.is_empty());
}
#[test]
fn command_policy_config_custom() {
let json = r#"{"mode": "denylist", "allowlist": ["echo", "ls"], "denylist": ["rm -rf /"]}"#;
let config: CommandPolicyConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.mode, "denylist");
assert_eq!(config.allowlist, vec!["echo", "ls"]);
assert_eq!(config.denylist, vec!["rm -rf /"]);
}
#[test]
fn url_policy_config_defaults() {
let config: UrlPolicyConfig = serde_json::from_str("{}").unwrap();
assert!(config.enabled);
assert!(!config.allow_private);
assert!(config.allowed_domains.is_empty());
assert!(config.blocked_domains.is_empty());
}
#[test]
fn url_policy_config_custom() {
let json = r#"{"enabled": false, "allowPrivate": true, "allowedDomains": ["internal.corp"], "blockedDomains": ["evil.com"]}"#;
let config: UrlPolicyConfig = serde_json::from_str(json).unwrap();
assert!(!config.enabled);
assert!(config.allow_private);
assert_eq!(config.allowed_domains, vec!["internal.corp"]);
assert_eq!(config.blocked_domains, vec!["evil.com"]);
}
#[test]
fn tools_config_includes_policies() {
let json = r#"{"commandPolicy": {"mode": "denylist"}, "urlPolicy": {"enabled": false}}"#;
let config: ToolsConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.command_policy.mode, "denylist");
assert!(!config.url_policy.enabled);
}
#[test]
fn tools_config_policies_default_when_absent() {
let config: ToolsConfig = serde_json::from_str("{}").unwrap();
assert_eq!(config.command_policy.mode, "allowlist");
assert!(config.url_policy.enabled);
}
#[test]
fn voice_config_defaults() {
let cfg: VoiceConfig = serde_json::from_str("{}").unwrap();
assert!(!cfg.enabled);
assert_eq!(cfg.mode, VoiceMode::XaiS2s);
assert_eq!(cfg.xai.api_key_env, DEFAULT_XAI_API_KEY_ENV);
assert_eq!(cfg.xai.realtime_model, DEFAULT_XAI_REALTIME_MODEL);
assert_eq!(cfg.xai.endpoint, DEFAULT_XAI_REALTIME_ENDPOINT);
assert_eq!(cfg.audio.sample_rate, 16000);
assert_eq!(cfg.audio.chunk_size, 512);
assert_eq!(cfg.audio.channels, 1);
assert!(cfg.audio.input_device.is_none());
assert!(cfg.audio.output_device.is_none());
assert_eq!(cfg.audio_retention, AudioRetention::None);
assert!(cfg.audio_retention.is_none());
assert!(!cfg.audio_retention.allows_disk_write());
assert!(!cfg.audio_retention.allows_session_hold());
assert!(cfg.stt.enabled);
assert_eq!(cfg.stt.model, "sherpa-onnx-streaming-zipformer-en-20M");
assert!(cfg.stt.language.is_empty());
assert!(cfg.tts.enabled);
assert_eq!(cfg.tts.model, "vits-piper-en_US-amy-medium");
assert!(cfg.tts.voice.is_empty());
assert!((cfg.tts.speed - 1.0).abs() < f32::EPSILON);
assert!((cfg.vad.threshold - 0.5).abs() < f32::EPSILON);
assert_eq!(cfg.vad.silence_timeout_ms, 1500);
assert_eq!(cfg.vad.min_speech_ms, 250);
assert!(cfg.vad.adaptive.enabled);
assert_eq!(cfg.vad.adaptive.min_ms, 500);
assert_eq!(cfg.vad.adaptive.max_ms, 3_000);
assert_eq!(cfg.vad.adaptive.window_size, 8);
assert!(!cfg.wake.enabled);
assert_eq!(cfg.wake.phrase, "hey weft");
assert!((cfg.wake.sensitivity - 0.5).abs() < f32::EPSILON);
assert!(cfg.wake.model_path.is_none());
assert!(!cfg.cloud_fallback.enabled);
assert!(cfg.cloud_fallback.stt_provider.is_empty());
assert!(cfg.cloud_fallback.tts_provider.is_empty());
assert_eq!(cfg.confirmation.timeout_seconds, 10);
assert!(cfg.confirmation.anti_replay_nonce);
assert!(cfg.confirmation.transcription_echo);
assert_eq!(cfg.rate_limit.commands_per_minute, 10);
assert_eq!(cfg.rate_limit.wake_activations_per_minute, 5);
assert_eq!(cfg.rate_limit.fail_threshold, 3);
assert_eq!(cfg.rate_limit.post_fail_cooldown_seconds, 30);
}
#[test]
fn voice_audio_retention_serde() {
let cfg: VoiceConfig = serde_json::from_str(r#"{"enabled":true}"#).unwrap();
assert_eq!(cfg.audio_retention, AudioRetention::None);
let session: VoiceConfig =
serde_json::from_str(r#"{"audio_retention":"session"}"#).unwrap();
assert_eq!(session.audio_retention, AudioRetention::Session);
assert!(session.audio_retention.allows_session_hold());
assert!(!session.audio_retention.allows_disk_write());
let persist: VoiceConfig =
serde_json::from_str(r#"{"audioRetention":"persist"}"#).unwrap();
assert_eq!(persist.audio_retention, AudioRetention::Persist);
assert!(persist.audio_retention.allows_disk_write());
let json = serde_json::to_string(&AudioRetention::Session).unwrap();
assert_eq!(json, "\"session\"");
let back: AudioRetention = serde_json::from_str(&json).unwrap();
assert_eq!(back, AudioRetention::Session);
}
#[test]
fn gateway_api_fields_defaults() {
let cfg = GatewayConfig::default();
assert_eq!(cfg.api_port, 18789);
assert_eq!(cfg.cors_origins, vec!["http://localhost:5173"]);
assert!(!cfg.api_enabled);
}
#[test]
fn provider_browser_fields_defaults() {
let cfg: ProviderConfig = serde_json::from_str("{}").unwrap();
assert!(!cfg.browser_direct);
assert!(cfg.cors_proxy.is_none());
}
#[test]
fn provider_base_url_alias() {
let json = r#"{"baseUrl": "https://example.com"}"#;
let cfg: ProviderConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.api_base.as_deref(), Some("https://example.com"));
}
#[test]
fn config_with_voice_section() {
let json = r#"{"voice": {"enabled": true}}"#;
let cfg: Config = serde_json::from_str(json).unwrap();
assert!(cfg.voice.enabled);
assert_eq!(cfg.voice.audio.sample_rate, 16000);
assert!(cfg.voice.stt.enabled);
}
#[test]
fn config_with_all_new_fields() {
let json = r#"{
"voice": {
"enabled": true,
"audio": { "sampleRate": 48000, "chunkSize": 1024, "channels": 2 },
"stt": { "model": "custom-stt", "language": "zh" },
"tts": { "model": "custom-tts", "voice": "alloy", "speed": 1.5 },
"vad": { "threshold": 0.8, "silenceTimeoutMs": 2000, "minSpeechMs": 500 },
"wake": { "enabled": true, "phrase": "ok clawft", "sensitivity": 0.7 },
"cloudFallback": { "enabled": true, "sttProvider": "whisper", "ttsProvider": "elevenlabs" }
},
"gateway": {
"host": "127.0.0.1",
"port": 9000,
"apiPort": 9001,
"corsOrigins": ["http://localhost:3000", "https://app.example.com"],
"apiEnabled": true
},
"providers": {
"openai": {
"apiKey": "sk-test",
"baseUrl": "https://api.openai.com/v1",
"browserDirect": true,
"corsProxy": "https://proxy.example.com"
}
}
}"#;
let cfg: Config = serde_json::from_str(json).unwrap();
assert!(cfg.voice.enabled);
assert_eq!(cfg.voice.audio.sample_rate, 48000);
assert_eq!(cfg.voice.audio.chunk_size, 1024);
assert_eq!(cfg.voice.audio.channels, 2);
assert_eq!(cfg.voice.stt.model, "custom-stt");
assert_eq!(cfg.voice.stt.language, "zh");
assert_eq!(cfg.voice.tts.model, "custom-tts");
assert_eq!(cfg.voice.tts.voice, "alloy");
assert!((cfg.voice.tts.speed - 1.5).abs() < f32::EPSILON);
assert!((cfg.voice.vad.threshold - 0.8).abs() < f32::EPSILON);
assert_eq!(cfg.voice.vad.silence_timeout_ms, 2000);
assert_eq!(cfg.voice.vad.min_speech_ms, 500);
assert!(cfg.voice.wake.enabled);
assert_eq!(cfg.voice.wake.phrase, "ok clawft");
assert!((cfg.voice.wake.sensitivity - 0.7).abs() < f32::EPSILON);
assert!(cfg.voice.cloud_fallback.enabled);
assert_eq!(cfg.voice.cloud_fallback.stt_provider, "whisper");
assert_eq!(cfg.voice.cloud_fallback.tts_provider, "elevenlabs");
assert_eq!(cfg.gateway.api_port, 9001);
assert_eq!(
cfg.gateway.cors_origins,
vec!["http://localhost:3000", "https://app.example.com"]
);
assert!(cfg.gateway.api_enabled);
assert!(cfg.providers.openai.browser_direct);
assert_eq!(
cfg.providers.openai.cors_proxy.as_deref(),
Some("https://proxy.example.com")
);
assert_eq!(
cfg.providers.openai.api_base.as_deref(),
Some("https://api.openai.com/v1")
);
}
}