Skip to main content

clawft_types/config/
mod.rs

1//! Configuration schema types.
2//!
3//! A faithful port of `nanobot/config/schema.py`. All structs support
4//! both `snake_case` and `camelCase` field names in JSON via `#[serde(alias)]`.
5//! Unknown fields are silently ignored for forward compatibility.
6//!
7//! # Module Structure
8//!
9//! - [`channels`] -- Chat channel configurations (Telegram, Slack, Discord, etc.)
10//! - [`policies`] -- Security policy configurations (command execution, URL safety)
11
12pub mod channels;
13pub mod kernel;
14pub mod personality;
15pub mod policies;
16pub mod voice;
17
18// Re-export channel types at the config level for backward compatibility.
19pub use channels::*;
20pub use kernel::*;
21pub use personality::*;
22pub use policies::*;
23pub use voice::*;
24
25use std::collections::HashMap;
26use std::path::PathBuf;
27
28use serde::{Deserialize, Serialize};
29
30use crate::delegation::DelegationConfig;
31use crate::routing::RoutingConfig;
32use crate::secret::SecretString;
33
34/// Shared default function: returns `true`.
35pub(crate) fn default_true() -> bool {
36    true
37}
38
39// ── Root config ──────────────────────────────────────────────────────────
40
41/// Root configuration for the clawft framework.
42///
43/// Mirrors the Python `Config(BaseSettings)` class.
44#[derive(Debug, Clone, Serialize, Deserialize, Default)]
45pub struct Config {
46    /// Agent defaults and per-agent overrides.
47    #[serde(default)]
48    pub agents: AgentsConfig,
49
50    /// Chat channel configurations (Telegram, Slack, Discord, etc.).
51    #[serde(default)]
52    pub channels: ChannelsConfig,
53
54    /// LLM provider credentials and settings.
55    #[serde(default)]
56    pub providers: ProvidersConfig,
57
58    /// Gateway / HTTP server settings.
59    #[serde(default)]
60    pub gateway: GatewayConfig,
61
62    /// Tool configurations (web search, exec, MCP servers).
63    #[serde(default)]
64    pub tools: ToolsConfig,
65
66    /// Task delegation routing configuration.
67    #[serde(default)]
68    pub delegation: DelegationConfig,
69
70    /// Tiered routing and permission configuration.
71    #[serde(default)]
72    pub routing: RoutingConfig,
73
74    /// Voice pipeline configuration (STT, TTS, VAD, wake word).
75    #[serde(default)]
76    pub voice: VoiceConfig,
77
78    /// Kernel subsystem configuration (WeftOS).
79    #[serde(default)]
80    pub kernel: KernelConfig,
81
82    /// Pipeline stage selection (scorer, learner backends).
83    #[serde(default)]
84    pub pipeline: PipelineConfig,
85}
86
87// ── Pipeline ────────────────────────────────────────────────────────────
88
89/// Pipeline stage backend selection.
90///
91/// Allows selecting which scorer and learner implementations to use.
92/// Defaults to `"noop"` for backward compatibility.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct PipelineConfig {
95    /// Quality scorer backend: `"noop"` (default) or `"fitness"`.
96    #[serde(default = "default_scorer")]
97    pub scorer: String,
98
99    /// Learning backend: `"noop"` (default) or `"trajectory"`.
100    #[serde(default = "default_learner")]
101    pub learner: String,
102}
103
104fn default_scorer() -> String {
105    "noop".into()
106}
107
108fn default_learner() -> String {
109    "noop".into()
110}
111
112impl Default for PipelineConfig {
113    fn default() -> Self {
114        Self {
115            scorer: default_scorer(),
116            learner: default_learner(),
117        }
118    }
119}
120
121impl Config {
122    /// Get the expanded workspace path.
123    ///
124    /// On native targets (with the `native` feature), this expands `~/` prefixes
125    /// using `dirs::home_dir()`. On WASM or when `native` is disabled, `~/`
126    /// prefixes are left unexpanded.
127    pub fn workspace_path(&self) -> PathBuf {
128        let raw = &self.agents.defaults.workspace;
129        #[cfg(feature = "native")]
130        if let Some(rest) = raw.strip_prefix("~/")
131            && let Some(home) = dirs::home_dir()
132        {
133            return home.join(rest);
134        }
135        PathBuf::from(raw)
136    }
137
138    /// Get the expanded workspace path with an explicit home directory.
139    ///
140    /// This is the browser-friendly variant that does not depend on `dirs`.
141    /// Pass `None` for `home` to skip `~/` expansion.
142    pub fn workspace_path_with_home(&self, home: Option<&std::path::Path>) -> PathBuf {
143        let raw = &self.agents.defaults.workspace;
144        if let Some(rest) = raw.strip_prefix("~/")
145            && let Some(home) = home
146        {
147            return home.join(rest);
148        }
149        PathBuf::from(raw)
150    }
151}
152
153// ── Agents ───────────────────────────────────────────────────────────────
154
155/// Agent configuration container.
156#[derive(Debug, Clone, Serialize, Deserialize, Default)]
157pub struct AgentsConfig {
158    /// Default settings applied to all agents.
159    #[serde(default)]
160    pub defaults: AgentDefaults,
161}
162
163/// Default agent settings.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct AgentDefaults {
166    /// Working directory for agent file operations.
167    #[serde(default = "default_workspace")]
168    pub workspace: String,
169
170    /// Default LLM model identifier.
171    #[serde(default = "default_model")]
172    pub model: String,
173
174    /// Maximum tokens in a single LLM response.
175    #[serde(default = "default_max_tokens", alias = "maxTokens")]
176    pub max_tokens: i32,
177
178    /// Sampling temperature.
179    #[serde(default = "default_temperature")]
180    pub temperature: f64,
181
182    /// Maximum tool-use iterations per turn.
183    #[serde(default = "default_max_tool_iterations", alias = "maxToolIterations")]
184    pub max_tool_iterations: i32,
185
186    /// Number of recent messages to include in context.
187    #[serde(default = "default_memory_window", alias = "memoryWindow")]
188    pub memory_window: i32,
189}
190
191fn default_workspace() -> String {
192    "~/.nanobot/workspace".into()
193}
194fn default_model() -> String {
195    "deepseek/deepseek-chat".into()
196}
197fn default_max_tokens() -> i32 {
198    8192
199}
200fn default_temperature() -> f64 {
201    0.7
202}
203fn default_max_tool_iterations() -> i32 {
204    20
205}
206fn default_memory_window() -> i32 {
207    50
208}
209
210impl Default for AgentDefaults {
211    fn default() -> Self {
212        Self {
213            workspace: default_workspace(),
214            model: default_model(),
215            max_tokens: default_max_tokens(),
216            temperature: default_temperature(),
217            max_tool_iterations: default_max_tool_iterations(),
218            memory_window: default_memory_window(),
219        }
220    }
221}
222
223// ── Providers ────────────────────────────────────────────────────────────
224
225/// LLM provider credentials.
226#[derive(Debug, Clone, Serialize, Deserialize, Default)]
227pub struct ProviderConfig {
228    /// API key for authentication.
229    #[serde(default, alias = "apiKey")]
230    pub api_key: SecretString,
231
232    /// Base URL override (e.g. for proxies).
233    #[serde(default, alias = "apiBase", alias = "baseUrl")]
234    pub api_base: Option<String>,
235
236    /// Custom HTTP headers (e.g. `APP-Code` for AiHubMix).
237    #[serde(default, alias = "extraHeaders")]
238    pub extra_headers: Option<HashMap<String, String>>,
239
240    /// Whether this provider supports direct browser access (no CORS proxy needed).
241    #[serde(default, alias = "browserDirect")]
242    pub browser_direct: bool,
243
244    /// CORS proxy URL for browser-mode API calls (e.g. "https://proxy.example.com").
245    #[serde(default, alias = "corsProxy")]
246    pub cors_proxy: Option<String>,
247}
248
249/// Configuration for all LLM providers.
250#[derive(Debug, Clone, Serialize, Deserialize, Default)]
251pub struct ProvidersConfig {
252    /// Custom OpenAI-compatible endpoint.
253    #[serde(default)]
254    pub custom: ProviderConfig,
255
256    /// Anthropic.
257    #[serde(default)]
258    pub anthropic: ProviderConfig,
259
260    /// OpenAI.
261    #[serde(default)]
262    pub openai: ProviderConfig,
263
264    /// OpenRouter gateway.
265    #[serde(default)]
266    pub openrouter: ProviderConfig,
267
268    /// DeepSeek.
269    #[serde(default)]
270    pub deepseek: ProviderConfig,
271
272    /// Groq.
273    #[serde(default)]
274    pub groq: ProviderConfig,
275
276    /// Zhipu AI.
277    #[serde(default)]
278    pub zhipu: ProviderConfig,
279
280    /// DashScope (Alibaba Cloud Qwen).
281    #[serde(default)]
282    pub dashscope: ProviderConfig,
283
284    /// vLLM / local server.
285    #[serde(default)]
286    pub vllm: ProviderConfig,
287
288    /// Google Gemini.
289    #[serde(default)]
290    pub gemini: ProviderConfig,
291
292    /// Moonshot (Kimi).
293    #[serde(default)]
294    pub moonshot: ProviderConfig,
295
296    /// MiniMax.
297    #[serde(default)]
298    pub minimax: ProviderConfig,
299
300    /// AiHubMix gateway.
301    #[serde(default)]
302    pub aihubmix: ProviderConfig,
303
304    /// OpenAI Codex (OAuth-based).
305    #[serde(default)]
306    pub openai_codex: ProviderConfig,
307
308    /// xAI (Grok).
309    #[serde(default)]
310    pub xai: ProviderConfig,
311
312    /// ElevenLabs (TTS).
313    #[serde(default)]
314    pub elevenlabs: ProviderConfig,
315}
316
317// ── Gateway ──────────────────────────────────────────────────────────────
318
319/// Gateway / HTTP server configuration.
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct GatewayConfig {
322    /// Bind address.
323    #[serde(default = "default_gateway_host")]
324    pub host: String,
325
326    /// Listen port.
327    #[serde(default = "default_gateway_port")]
328    pub port: u16,
329
330    /// Heartbeat interval in minutes (0 = disabled).
331    #[serde(default, alias = "heartbeatIntervalMinutes")]
332    pub heartbeat_interval_minutes: u64,
333
334    /// Heartbeat prompt text.
335    #[serde(default = "default_heartbeat_prompt", alias = "heartbeatPrompt")]
336    pub heartbeat_prompt: String,
337
338    /// Port for the UI REST API (separate from gateway port).
339    #[serde(default = "default_api_port", alias = "apiPort")]
340    pub api_port: u16,
341
342    /// Allowed CORS origins for the UI API.
343    #[serde(default = "default_cors_origins", alias = "corsOrigins")]
344    pub cors_origins: Vec<String>,
345
346    /// Whether the REST/WS API is enabled.
347    #[serde(default, alias = "apiEnabled")]
348    pub api_enabled: bool,
349}
350
351fn default_gateway_host() -> String {
352    "0.0.0.0".into()
353}
354fn default_gateway_port() -> u16 {
355    18790
356}
357fn default_heartbeat_prompt() -> String {
358    "heartbeat".into()
359}
360fn default_api_port() -> u16 {
361    18789
362}
363fn default_cors_origins() -> Vec<String> {
364    vec!["http://localhost:5173".into()]
365}
366
367impl Default for GatewayConfig {
368    fn default() -> Self {
369        Self {
370            host: default_gateway_host(),
371            port: default_gateway_port(),
372            heartbeat_interval_minutes: 0,
373            heartbeat_prompt: default_heartbeat_prompt(),
374            api_port: default_api_port(),
375            cors_origins: default_cors_origins(),
376            api_enabled: false,
377        }
378    }
379}
380
381// ── Tools ────────────────────────────────────────────────────────────────
382
383/// Tools configuration.
384#[derive(Debug, Clone, Serialize, Deserialize, Default)]
385pub struct ToolsConfig {
386    /// Web tools (search, etc.).
387    #[serde(default)]
388    pub web: WebToolsConfig,
389
390    /// Shell exec tool settings.
391    #[serde(default, rename = "exec")]
392    pub exec_tool: ExecToolConfig,
393
394    /// Whether to restrict all tool access to the workspace directory.
395    #[serde(default, alias = "restrictToWorkspace")]
396    pub restrict_to_workspace: bool,
397
398    /// MCP server connections.
399    #[serde(default, alias = "mcpServers")]
400    pub mcp_servers: HashMap<String, MCPServerConfig>,
401
402    /// Command execution policy (allowlist/denylist).
403    #[serde(default, alias = "commandPolicy")]
404    pub command_policy: CommandPolicyConfig,
405
406    /// URL safety policy (SSRF protection).
407    #[serde(default, alias = "urlPolicy")]
408    pub url_policy: UrlPolicyConfig,
409}
410
411/// Web tools configuration.
412#[derive(Debug, Clone, Serialize, Deserialize, Default)]
413pub struct WebToolsConfig {
414    /// Search engine settings.
415    #[serde(default)]
416    pub search: WebSearchConfig,
417}
418
419/// Web search tool configuration.
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct WebSearchConfig {
422    /// Search API key (e.g. Brave Search).
423    #[serde(default, alias = "apiKey")]
424    pub api_key: SecretString,
425
426    /// Maximum number of search results.
427    #[serde(default = "default_max_results", alias = "maxResults")]
428    pub max_results: u32,
429}
430
431fn default_max_results() -> u32 {
432    5
433}
434
435impl Default for WebSearchConfig {
436    fn default() -> Self {
437        Self {
438            api_key: SecretString::default(),
439            max_results: default_max_results(),
440        }
441    }
442}
443
444/// Shell exec tool configuration.
445#[derive(Debug, Clone, Serialize, Deserialize)]
446pub struct ExecToolConfig {
447    /// Command timeout in seconds.
448    #[serde(default = "default_exec_timeout")]
449    pub timeout: u32,
450}
451
452fn default_exec_timeout() -> u32 {
453    60
454}
455
456impl Default for ExecToolConfig {
457    fn default() -> Self {
458        Self {
459            timeout: default_exec_timeout(),
460        }
461    }
462}
463
464/// MCP server connection configuration (stdio or HTTP).
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct MCPServerConfig {
467    /// Command to run (for stdio transport, e.g. `"npx"`).
468    #[serde(default)]
469    pub command: String,
470
471    /// Command arguments (for stdio transport).
472    #[serde(default)]
473    pub args: Vec<String>,
474
475    /// Extra environment variables (for stdio transport).
476    #[serde(default)]
477    pub env: HashMap<String, String>,
478
479    /// Streamable HTTP endpoint URL (for HTTP transport).
480    #[serde(default)]
481    pub url: String,
482
483    /// If true, MCP session is created but tools are NOT registered in ToolRegistry.
484    /// Infrastructure servers (claude-flow, claude-code) should be internal.
485    #[serde(default = "default_true", alias = "internalOnly")]
486    pub internal_only: bool,
487}
488
489impl Default for MCPServerConfig {
490    fn default() -> Self {
491        Self {
492            command: String::new(),
493            args: Vec::new(),
494            env: HashMap::new(),
495            url: String::new(),
496            internal_only: true,
497        }
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    /// Path to the test fixture config.
506    const FIXTURE_PATH: &str = concat!(
507        env!("CARGO_MANIFEST_DIR"),
508        "/../../tests/fixtures/config.json"
509    );
510
511    fn load_fixture() -> Config {
512        let content =
513            std::fs::read_to_string(FIXTURE_PATH).expect("fixture config.json should exist");
514        serde_json::from_str(&content).expect("fixture should deserialize")
515    }
516
517    #[test]
518    fn deserialize_fixture() {
519        let cfg = load_fixture();
520
521        // Agents
522        assert_eq!(cfg.agents.defaults.model, "deepseek/deepseek-chat");
523        assert_eq!(cfg.agents.defaults.max_tokens, 8192);
524        assert_eq!(cfg.agents.defaults.temperature, 0.7);
525        assert_eq!(cfg.agents.defaults.max_tool_iterations, 20);
526        assert_eq!(cfg.agents.defaults.memory_window, 50);
527
528        // Channels
529        assert!(cfg.channels.telegram.enabled);
530        assert_eq!(cfg.channels.telegram.token.expose(), "test-bot-token-123");
531        assert_eq!(cfg.channels.telegram.allow_from, vec!["user1", "user2"]);
532        assert!(!cfg.channels.slack.enabled);
533        assert!(!cfg.channels.discord.enabled);
534
535        // Providers
536        assert_eq!(cfg.providers.anthropic.api_key.expose(), "sk-ant-test-key");
537        assert_eq!(cfg.providers.openrouter.api_key.expose(), "sk-or-test-key");
538        assert_eq!(
539            cfg.providers.openrouter.api_base.as_deref(),
540            Some("https://openrouter.ai/api/v1")
541        );
542        assert!(cfg.providers.deepseek.api_key.is_empty());
543
544        // Gateway
545        assert_eq!(cfg.gateway.host, "0.0.0.0");
546        assert_eq!(cfg.gateway.port, 18790);
547
548        // Tools
549        assert_eq!(cfg.tools.web.search.max_results, 5);
550        assert_eq!(cfg.tools.exec_tool.timeout, 60);
551        assert!(!cfg.tools.restrict_to_workspace);
552        assert!(cfg.tools.mcp_servers.contains_key("test-server"));
553        let mcp = &cfg.tools.mcp_servers["test-server"];
554        assert_eq!(mcp.command, "npx");
555        assert_eq!(mcp.args, vec!["-y", "test-mcp-server"]);
556    }
557
558    #[test]
559    fn camel_case_aliases() {
560        // The fixture uses camelCase (maxTokens, allowFrom, etc.)
561        // This test is essentially the same as deserialize_fixture
562        // but focuses on alias correctness.
563        let cfg = load_fixture();
564        assert_eq!(cfg.agents.defaults.max_tokens, 8192); // maxTokens
565        assert_eq!(cfg.agents.defaults.max_tool_iterations, 20); // maxToolIterations
566        assert_eq!(cfg.agents.defaults.memory_window, 50); // memoryWindow
567        assert_eq!(cfg.channels.telegram.allow_from, vec!["user1", "user2"]); // allowFrom
568    }
569
570    #[test]
571    fn default_values_for_missing_fields() {
572        let json = r#"{}"#;
573        let cfg: Config = serde_json::from_str(json).unwrap();
574
575        // Agent defaults
576        assert_eq!(cfg.agents.defaults.workspace, "~/.nanobot/workspace");
577        assert_eq!(cfg.agents.defaults.model, "deepseek/deepseek-chat");
578        assert_eq!(cfg.agents.defaults.max_tokens, 8192);
579        assert!((cfg.agents.defaults.temperature - 0.7).abs() < f64::EPSILON);
580        assert_eq!(cfg.agents.defaults.max_tool_iterations, 20);
581        assert_eq!(cfg.agents.defaults.memory_window, 50);
582
583        // Channel defaults
584        assert!(!cfg.channels.telegram.enabled);
585        assert!(cfg.channels.telegram.token.is_empty());
586        assert!(!cfg.channels.slack.enabled);
587        assert_eq!(cfg.channels.slack.mode, "socket");
588        assert!(!cfg.channels.discord.enabled);
589        assert_eq!(cfg.channels.discord.intents, 37377);
590
591        // Gateway defaults
592        assert_eq!(cfg.gateway.host, "0.0.0.0");
593        assert_eq!(cfg.gateway.port, 18790);
594
595        // Tool defaults
596        assert_eq!(cfg.tools.exec_tool.timeout, 60);
597        assert_eq!(cfg.tools.web.search.max_results, 5);
598    }
599
600    #[test]
601    fn serde_roundtrip() {
602        let cfg = load_fixture();
603        let json = serde_json::to_string(&cfg).unwrap();
604        let restored: Config = serde_json::from_str(&json).unwrap();
605        assert_eq!(restored.agents.defaults.model, cfg.agents.defaults.model);
606        assert_eq!(restored.gateway.port, cfg.gateway.port);
607        // SecretString serializes to "" for security, so after round-trip
608        // the restored api_key is empty (by design).
609        assert!(restored.providers.anthropic.api_key.is_empty());
610    }
611
612    #[test]
613    fn unknown_fields_ignored() {
614        let json = r#"{
615            "agents": { "defaults": { "model": "test" } },
616            "unknown_top_level": true,
617            "channels": {
618                "telegram": { "enabled": false, "some_future_field": 42 }
619            },
620            "providers": {
621                "anthropic": { "apiKey": "k", "newField": "x" }
622            }
623        }"#;
624        let cfg: Config = serde_json::from_str(json).unwrap();
625        assert_eq!(cfg.agents.defaults.model, "test");
626        assert!(!cfg.channels.telegram.enabled);
627        assert_eq!(cfg.providers.anthropic.api_key.expose(), "k");
628    }
629
630    #[test]
631    fn unknown_channel_plugins_in_extra() {
632        let json = r#"{
633            "channels": {
634                "telegram": { "enabled": true },
635                "my_custom_channel": { "url": "wss://custom.io" }
636            }
637        }"#;
638        let cfg: Config = serde_json::from_str(json).unwrap();
639        assert!(cfg.channels.telegram.enabled);
640        assert!(cfg.channels.extra.contains_key("my_custom_channel"));
641    }
642
643    #[test]
644    fn workspace_path_expansion() {
645        let mut cfg = Config::default();
646        cfg.agents.defaults.workspace = "~/.clawft/workspace".into();
647        let path = cfg.workspace_path();
648        // Should not start with "~" after expansion
649        assert!(!path.to_string_lossy().starts_with('~'));
650    }
651
652    #[test]
653    fn provider_config_with_extra_headers() {
654        let json = r#"{
655            "apiKey": "test",
656            "extraHeaders": { "X-Custom": "value" }
657        }"#;
658        let cfg: ProviderConfig = serde_json::from_str(json).unwrap();
659        assert_eq!(cfg.api_key.expose(), "test");
660        let headers = cfg.extra_headers.unwrap();
661        assert_eq!(headers["X-Custom"], "value");
662    }
663
664    #[test]
665    fn email_config_defaults() {
666        let cfg = EmailConfig::default();
667        assert_eq!(cfg.imap_port, 993);
668        assert!(cfg.imap_use_ssl);
669        assert_eq!(cfg.smtp_port, 587);
670        assert!(cfg.smtp_use_tls);
671        assert!(!cfg.smtp_use_ssl);
672        assert!(cfg.auto_reply_enabled);
673        assert_eq!(cfg.poll_interval_seconds, 30);
674        assert!(cfg.mark_seen);
675        assert_eq!(cfg.max_body_chars, 12000);
676        assert_eq!(cfg.subject_prefix, "Re: ");
677    }
678
679    #[test]
680    fn mochat_config_defaults() {
681        let cfg = MochatConfig::default();
682        assert_eq!(cfg.base_url, "https://mochat.io");
683        assert_eq!(cfg.socket_path, "/socket.io");
684        assert_eq!(cfg.socket_reconnect_delay_ms, 1000);
685        assert_eq!(cfg.socket_max_reconnect_delay_ms, 10000);
686        assert_eq!(cfg.socket_connect_timeout_ms, 10000);
687        assert_eq!(cfg.refresh_interval_ms, 30000);
688        assert_eq!(cfg.watch_timeout_ms, 25000);
689        assert_eq!(cfg.watch_limit, 100);
690        assert_eq!(cfg.retry_delay_ms, 500);
691        assert_eq!(cfg.max_retry_attempts, 0);
692        assert_eq!(cfg.reply_delay_mode, "non-mention");
693        assert_eq!(cfg.reply_delay_ms, 120000);
694    }
695
696    #[test]
697    fn slack_dm_config_defaults() {
698        let cfg = SlackDMConfig::default();
699        assert!(cfg.enabled);
700        assert_eq!(cfg.policy, "open");
701    }
702
703    #[test]
704    fn gateway_heartbeat_defaults() {
705        let cfg = GatewayConfig::default();
706        assert_eq!(cfg.heartbeat_interval_minutes, 0);
707        assert_eq!(cfg.heartbeat_prompt, "heartbeat");
708    }
709
710    #[test]
711    fn gateway_heartbeat_from_json() {
712        let json = r#"{
713            "host": "0.0.0.0",
714            "port": 8080,
715            "heartbeatIntervalMinutes": 15,
716            "heartbeatPrompt": "status check"
717        }"#;
718        let cfg: GatewayConfig = serde_json::from_str(json).unwrap();
719        assert_eq!(cfg.heartbeat_interval_minutes, 15);
720        assert_eq!(cfg.heartbeat_prompt, "status check");
721    }
722
723    #[test]
724    fn gateway_heartbeat_disabled_by_default() {
725        let json = r#"{"host": "0.0.0.0"}"#;
726        let cfg: GatewayConfig = serde_json::from_str(json).unwrap();
727        assert_eq!(cfg.heartbeat_interval_minutes, 0);
728        assert_eq!(cfg.heartbeat_prompt, "heartbeat");
729    }
730
731    #[test]
732    fn mcp_server_config_roundtrip() {
733        let cfg = MCPServerConfig {
734            command: "npx".into(),
735            args: vec!["-y".into(), "test-server".into()],
736            env: {
737                let mut m = HashMap::new();
738                m.insert("API_KEY".into(), "secret".into());
739                m
740            },
741            url: String::new(),
742            internal_only: false,
743        };
744        let json = serde_json::to_string(&cfg).unwrap();
745        let restored: MCPServerConfig = serde_json::from_str(&json).unwrap();
746        assert_eq!(restored.command, "npx");
747        assert_eq!(restored.args.len(), 2);
748        assert_eq!(restored.env["API_KEY"], "secret");
749        assert!(!restored.internal_only);
750    }
751
752    #[test]
753    fn command_policy_config_defaults() {
754        let config: CommandPolicyConfig = serde_json::from_str("{}").unwrap();
755        assert_eq!(config.mode, "allowlist");
756        assert!(config.allowlist.is_empty());
757        assert!(config.denylist.is_empty());
758    }
759
760    #[test]
761    fn command_policy_config_custom() {
762        let json = r#"{"mode": "denylist", "allowlist": ["echo", "ls"], "denylist": ["rm -rf /"]}"#;
763        let config: CommandPolicyConfig = serde_json::from_str(json).unwrap();
764        assert_eq!(config.mode, "denylist");
765        assert_eq!(config.allowlist, vec!["echo", "ls"]);
766        assert_eq!(config.denylist, vec!["rm -rf /"]);
767    }
768
769    #[test]
770    fn url_policy_config_defaults() {
771        let config: UrlPolicyConfig = serde_json::from_str("{}").unwrap();
772        assert!(config.enabled);
773        assert!(!config.allow_private);
774        assert!(config.allowed_domains.is_empty());
775        assert!(config.blocked_domains.is_empty());
776    }
777
778    #[test]
779    fn url_policy_config_custom() {
780        let json = r#"{"enabled": false, "allowPrivate": true, "allowedDomains": ["internal.corp"], "blockedDomains": ["evil.com"]}"#;
781        let config: UrlPolicyConfig = serde_json::from_str(json).unwrap();
782        assert!(!config.enabled);
783        assert!(config.allow_private);
784        assert_eq!(config.allowed_domains, vec!["internal.corp"]);
785        assert_eq!(config.blocked_domains, vec!["evil.com"]);
786    }
787
788    #[test]
789    fn tools_config_includes_policies() {
790        let json = r#"{"commandPolicy": {"mode": "denylist"}, "urlPolicy": {"enabled": false}}"#;
791        let config: ToolsConfig = serde_json::from_str(json).unwrap();
792        assert_eq!(config.command_policy.mode, "denylist");
793        assert!(!config.url_policy.enabled);
794    }
795
796    #[test]
797    fn tools_config_policies_default_when_absent() {
798        let config: ToolsConfig = serde_json::from_str("{}").unwrap();
799        assert_eq!(config.command_policy.mode, "allowlist");
800        assert!(config.url_policy.enabled);
801    }
802
803    // ── Step 0: Three-workstream config field tests ──────────────────────
804
805    #[test]
806    fn voice_config_defaults() {
807        let cfg: VoiceConfig = serde_json::from_str("{}").unwrap();
808        assert!(!cfg.enabled);
809        assert_eq!(cfg.audio.sample_rate, 16000);
810        assert_eq!(cfg.audio.chunk_size, 512);
811        assert_eq!(cfg.audio.channels, 1);
812        assert!(cfg.audio.input_device.is_none());
813        assert!(cfg.audio.output_device.is_none());
814        assert!(cfg.stt.enabled);
815        assert_eq!(cfg.stt.model, "sherpa-onnx-streaming-zipformer-en-20M");
816        assert!(cfg.stt.language.is_empty());
817        assert!(cfg.tts.enabled);
818        assert_eq!(cfg.tts.model, "vits-piper-en_US-amy-medium");
819        assert!(cfg.tts.voice.is_empty());
820        assert!((cfg.tts.speed - 1.0).abs() < f32::EPSILON);
821        assert!((cfg.vad.threshold - 0.5).abs() < f32::EPSILON);
822        assert_eq!(cfg.vad.silence_timeout_ms, 1500);
823        assert_eq!(cfg.vad.min_speech_ms, 250);
824        assert!(!cfg.wake.enabled);
825        assert_eq!(cfg.wake.phrase, "hey weft");
826        assert!((cfg.wake.sensitivity - 0.5).abs() < f32::EPSILON);
827        assert!(cfg.wake.model_path.is_none());
828        assert!(!cfg.cloud_fallback.enabled);
829        assert!(cfg.cloud_fallback.stt_provider.is_empty());
830        assert!(cfg.cloud_fallback.tts_provider.is_empty());
831    }
832
833    #[test]
834    fn gateway_api_fields_defaults() {
835        let cfg = GatewayConfig::default();
836        assert_eq!(cfg.api_port, 18789);
837        assert_eq!(cfg.cors_origins, vec!["http://localhost:5173"]);
838        assert!(!cfg.api_enabled);
839    }
840
841    #[test]
842    fn provider_browser_fields_defaults() {
843        let cfg: ProviderConfig = serde_json::from_str("{}").unwrap();
844        assert!(!cfg.browser_direct);
845        assert!(cfg.cors_proxy.is_none());
846    }
847
848    #[test]
849    fn provider_base_url_alias() {
850        let json = r#"{"baseUrl": "https://example.com"}"#;
851        let cfg: ProviderConfig = serde_json::from_str(json).unwrap();
852        assert_eq!(cfg.api_base.as_deref(), Some("https://example.com"));
853    }
854
855    #[test]
856    fn config_with_voice_section() {
857        let json = r#"{"voice": {"enabled": true}}"#;
858        let cfg: Config = serde_json::from_str(json).unwrap();
859        assert!(cfg.voice.enabled);
860        // Sub-structs should still be default
861        assert_eq!(cfg.voice.audio.sample_rate, 16000);
862        assert!(cfg.voice.stt.enabled);
863    }
864
865    #[test]
866    fn config_with_all_new_fields() {
867        let json = r#"{
868            "voice": {
869                "enabled": true,
870                "audio": { "sampleRate": 48000, "chunkSize": 1024, "channels": 2 },
871                "stt": { "model": "custom-stt", "language": "zh" },
872                "tts": { "model": "custom-tts", "voice": "alloy", "speed": 1.5 },
873                "vad": { "threshold": 0.8, "silenceTimeoutMs": 2000, "minSpeechMs": 500 },
874                "wake": { "enabled": true, "phrase": "ok clawft", "sensitivity": 0.7 },
875                "cloudFallback": { "enabled": true, "sttProvider": "whisper", "ttsProvider": "elevenlabs" }
876            },
877            "gateway": {
878                "host": "127.0.0.1",
879                "port": 9000,
880                "apiPort": 9001,
881                "corsOrigins": ["http://localhost:3000", "https://app.example.com"],
882                "apiEnabled": true
883            },
884            "providers": {
885                "openai": {
886                    "apiKey": "sk-test",
887                    "baseUrl": "https://api.openai.com/v1",
888                    "browserDirect": true,
889                    "corsProxy": "https://proxy.example.com"
890                }
891            }
892        }"#;
893        let cfg: Config = serde_json::from_str(json).unwrap();
894
895        // Voice
896        assert!(cfg.voice.enabled);
897        assert_eq!(cfg.voice.audio.sample_rate, 48000);
898        assert_eq!(cfg.voice.audio.chunk_size, 1024);
899        assert_eq!(cfg.voice.audio.channels, 2);
900        assert_eq!(cfg.voice.stt.model, "custom-stt");
901        assert_eq!(cfg.voice.stt.language, "zh");
902        assert_eq!(cfg.voice.tts.model, "custom-tts");
903        assert_eq!(cfg.voice.tts.voice, "alloy");
904        assert!((cfg.voice.tts.speed - 1.5).abs() < f32::EPSILON);
905        assert!((cfg.voice.vad.threshold - 0.8).abs() < f32::EPSILON);
906        assert_eq!(cfg.voice.vad.silence_timeout_ms, 2000);
907        assert_eq!(cfg.voice.vad.min_speech_ms, 500);
908        assert!(cfg.voice.wake.enabled);
909        assert_eq!(cfg.voice.wake.phrase, "ok clawft");
910        assert!((cfg.voice.wake.sensitivity - 0.7).abs() < f32::EPSILON);
911        assert!(cfg.voice.cloud_fallback.enabled);
912        assert_eq!(cfg.voice.cloud_fallback.stt_provider, "whisper");
913        assert_eq!(cfg.voice.cloud_fallback.tts_provider, "elevenlabs");
914
915        // Gateway new fields
916        assert_eq!(cfg.gateway.api_port, 9001);
917        assert_eq!(
918            cfg.gateway.cors_origins,
919            vec!["http://localhost:3000", "https://app.example.com"]
920        );
921        assert!(cfg.gateway.api_enabled);
922
923        // Provider browser fields
924        assert!(cfg.providers.openai.browser_direct);
925        assert_eq!(
926            cfg.providers.openai.cors_proxy.as_deref(),
927            Some("https://proxy.example.com")
928        );
929        assert_eq!(
930            cfg.providers.openai.api_base.as_deref(),
931            Some("https://api.openai.com/v1")
932        );
933    }
934}