Skip to main content

microclaw_engine/config/
mod.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::de::DeserializeOwned;
5use serde::{Deserialize, Serialize};
6use tracing::warn;
7
8use crate::codex_auth::{
9    codex_auth_file_has_access_token, is_openai_codex_provider, is_qwen_portal_provider,
10    provider_allows_empty_api_key, qwen_oauth_file_has_access_token,
11};
12pub use crate::internal::tool_runtime::egress::{EgressPolicyConfig, EgressPolicyMode};
13pub use crate::internal::tool_runtime::sandbox::{
14    SandboxBackend, SandboxConfig, SandboxMode, SecurityProfile,
15};
16pub use crate::internal::tool_runtime::types::WorkingDirIsolation;
17use crate::internal::tool_runtime::web_content_validation::WebContentValidationConfig;
18use crate::internal::tool_runtime::web_fetch::WebFetchUrlValidationConfig;
19use crate::internal::tool_runtime::web_search::SearchProviderConfig;
20use crate::plugins::PluginsConfig;
21use microclaw_core::error::MicroClawError;
22use microclaw_core::redact::OutputGuardrailConfig;
23
24pub mod autonomy;
25pub mod core;
26pub mod defaults;
27pub mod governance;
28pub mod integrations;
29pub mod llm_profiles;
30pub mod media;
31pub mod subagents;
32#[cfg(test)]
33pub(crate) mod test_prelude;
34
35pub use self::autonomy::*;
36pub use self::core::*;
37pub use self::defaults::*;
38pub use self::governance::*;
39pub use self::integrations::*;
40pub use self::llm_profiles::*;
41pub use self::media::*;
42pub use self::subagents::*;
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47    #[allow(unused_imports)]
48    use crate::config::test_prelude::*;
49
50    #[test]
51    fn test_config_default_values() {
52        let mut config = test_config();
53        config.openai_api_key = Some("sk-test".into());
54        config.timezone = "US/Eastern".into();
55        config.allowed_groups = vec![123, 456];
56        config.control_chat_ids = vec![999];
57        assert_eq!(config.model, "claude-sonnet-4-5-20250929");
58        assert!(config.data_dir.ends_with(".microclaw"));
59        assert!(std::path::PathBuf::from(&config.working_dir)
60            .ends_with(std::path::Path::new(".microclaw").join("working_dir")));
61        assert_eq!(config.openai_api_key.as_deref(), Some("sk-test"));
62        assert_eq!(config.timezone, "US/Eastern");
63        assert_eq!(config.allowed_groups, vec![123, 456]);
64        assert_eq!(config.control_chat_ids, vec![999]);
65    }
66
67    #[test]
68    fn embedded_config_does_not_require_a_delivery_channel() {
69        let config = Config::for_embedded("anthropic", "claude-test", "test-key", None).unwrap();
70        assert_eq!(config.llm_provider, "anthropic");
71        assert_eq!(config.model, "claude-test");
72        assert_eq!(config.api_key, "test-key");
73        assert!(!config.web_enabled);
74        assert!(config.channel_status().0.is_empty());
75    }
76
77    #[test]
78    fn test_llm_provider_overrides_support_provider_preset_and_legacy_llm_provider_keys() {
79        let mut config = test_config();
80        config.channels = serde_yaml::from_str(
81            r#"
82telegram:
83  enabled: true
84  provider_preset: channel-default
85  default_account: sales
86  accounts:
87    sales:
88      enabled: true
89      bot_token: tg_sales
90      provider_preset: sales-preset
91    ops:
92      enabled: true
93      bot_token: tg_ops
94      llm_provider: ops-legacy
95discord:
96  enabled: true
97  llm_provider: discord-legacy
98"#,
99        )
100        .unwrap();
101
102        assert_eq!(
103            config.provider_override_for_channel("telegram").as_deref(),
104            Some("sales-preset")
105        );
106        assert_eq!(
107            config
108                .provider_override_for_channel("telegram.ops")
109                .as_deref(),
110            Some("ops-legacy")
111        );
112        assert_eq!(
113            config.provider_override_for_channel("discord").as_deref(),
114            Some("discord-legacy")
115        );
116
117        let overrides = config.llm_provider_overrides();
118        assert_eq!(
119            overrides.get("telegram").map(String::as_str),
120            Some("sales-preset")
121        );
122        assert_eq!(
123            overrides.get("telegram.ops").map(String::as_str),
124            Some("ops-legacy")
125        );
126        assert_eq!(
127            overrides.get("discord").map(String::as_str),
128            Some("discord-legacy")
129        );
130    }
131
132    #[test]
133    fn test_runtime_and_skills_dirs_from_root_data_dir() {
134        let mut config = test_config();
135        config.data_dir = "./microclaw.data".into();
136
137        let runtime = std::path::PathBuf::from(config.runtime_data_dir());
138        let skills = std::path::PathBuf::from(config.skills_data_dir());
139
140        assert!(runtime.ends_with(std::path::Path::new("microclaw.data").join("runtime")));
141        assert!(skills.ends_with(std::path::Path::new("microclaw.data").join("skills")));
142    }
143
144    #[test]
145    fn test_runtime_and_skills_dirs_from_runtime_data_dir() {
146        let mut config = test_config();
147        config.data_dir = "./microclaw.data".into();
148
149        let runtime = std::path::PathBuf::from(config.runtime_data_dir());
150        let skills = std::path::PathBuf::from(config.skills_data_dir());
151
152        assert!(runtime.ends_with(std::path::Path::new("microclaw.data").join("runtime")));
153        assert!(skills.ends_with(std::path::Path::new("microclaw.data").join("skills")));
154    }
155
156    #[test]
157    fn test_skills_dir_uses_config_override() {
158        let mut config = test_config();
159        config.skills_dir = Some("./microclaw.data/skills".to_string());
160        let skills = std::path::PathBuf::from(config.skills_data_dir());
161        assert!(skills.ends_with(std::path::Path::new("microclaw.data").join("skills")));
162    }
163
164    #[test]
165    fn test_post_deserialize_egress_policy_blocks_private_endpoint() {
166        let mut config = test_config();
167        config.llm_base_url = Some("http://127.0.0.1:11434/v1".into());
168        config.egress_policy.mode = EgressPolicyMode::Block;
169
170        let err = config.post_deserialize().unwrap_err().to_string();
171
172        assert!(err.contains("blocked by egress policy"), "{err}");
173        assert!(err.contains("127.0.0.1"), "{err}");
174    }
175
176    #[test]
177    fn test_post_deserialize_egress_allowlist_applies_to_configured_endpoint() {
178        let mut config = test_config();
179        config.llm_base_url = Some("https://api.example.test/v1".into());
180        config.clawhub.agent_tools_enabled = false;
181        config.egress_policy = EgressPolicyConfig {
182            mode: EgressPolicyMode::Block,
183            allow_hosts: vec!["api.example.test".into()],
184            block_private_ips: false,
185            ..Default::default()
186        };
187
188        config.post_deserialize().unwrap();
189    }
190
191    #[test]
192    fn test_post_deserialize_egress_ignores_non_endpoint_urls() {
193        let mut config = test_config();
194        config.clawhub.agent_tools_enabled = false;
195        config.soul_path = Some("https://docs.example.test/SOUL.md".into());
196        config.egress_policy = EgressPolicyConfig {
197            mode: EgressPolicyMode::Block,
198            allow_hosts: vec!["api.example.test".into()],
199            block_private_ips: false,
200            ..Default::default()
201        };
202
203        config.post_deserialize().unwrap();
204    }
205
206    #[test]
207    fn test_post_deserialize_egress_checks_enabled_channel_endpoint() {
208        let mut config = test_config();
209        config.clawhub.agent_tools_enabled = false;
210        config.channels.insert(
211            "matrix".into(),
212            serde_yaml::from_str(
213                "enabled: true\nhomeserver_url: http://127.0.0.1:8008\naccess_token: tok\nbot_user_id: '@bot:localhost'\n",
214            )
215            .unwrap(),
216        );
217        config.egress_policy.mode = EgressPolicyMode::Block;
218
219        let err = config.post_deserialize().unwrap_err().to_string();
220
221        assert!(err.contains("127.0.0.1"), "{err}");
222    }
223
224    #[test]
225    fn test_config_save_yaml() {
226        let config = test_config();
227        let dir = std::env::temp_dir();
228        let path = dir.join("microclaw_test_config.yaml");
229        config.save_yaml(path.to_str().unwrap()).unwrap();
230        let content = std::fs::read_to_string(&path).unwrap();
231        assert!(content.contains("telegram_bot_token"));
232        std::fs::remove_file(path).ok();
233    }
234
235    #[test]
236    fn test_post_deserialize_expands_paths() {
237        let mut config = test_config();
238        config.data_dir = "~/.microclaw".into();
239        config.working_dir = "~/workspace".into();
240        config.skills_dir = Some("~/skills".into());
241
242        config.post_deserialize().unwrap();
243
244        let home = PathBuf::from(shellexpand::tilde("~").as_ref());
245        // Use PathBuf comparison to handle separator differences on Windows
246        assert_eq!(PathBuf::from(&config.data_dir), home.join(".microclaw"));
247        assert_eq!(PathBuf::from(&config.working_dir), home.join("workspace"));
248        assert_eq!(
249            PathBuf::from(config.skills_dir.unwrap()),
250            home.join("skills")
251        );
252    }
253}