microclaw_engine/config/
mod.rs1use 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 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}