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