1use super::*;
2
3pub fn normalize_model_name(value: &str) -> Option<String> {
4 let trimmed = value.trim();
5 if trimmed.is_empty() || trimmed == "*" {
6 None
7 } else {
8 Some(trimmed.to_string())
9 }
10}
11
12pub(crate) fn normalize_string_list(values: &mut Vec<String>, lowercase: bool) {
13 *values = values
14 .drain(..)
15 .map(|value| {
16 let value = value.trim();
17 if lowercase {
18 value.to_ascii_lowercase()
19 } else {
20 value.to_string()
21 }
22 })
23 .filter(|value| !value.is_empty())
24 .collect();
25 values.sort();
26 values.dedup();
27}
28
29pub fn resolve_model_name_with_fallback(
30 provider: &str,
31 candidate: Option<&str>,
32 fallback: Option<&str>,
33) -> String {
34 candidate
35 .and_then(normalize_model_name)
36 .or_else(|| fallback.and_then(normalize_model_name))
37 .unwrap_or_else(|| default_model_for_provider_name(provider).to_string())
38}
39
40pub(crate) fn expand_path(path: &str) -> PathBuf {
42 match shellexpand::tilde(path) {
43 std::borrow::Cow::Borrowed(p) => PathBuf::from(p),
44 std::borrow::Cow::Owned(p) => PathBuf::from(p),
45 }
46}
47
48pub(crate) fn detect_system_timezone() -> String {
49 match iana_time_zone::get_timezone() {
50 Ok(tz_name) => tz_name,
51 Err(e) => {
52 warn!("Failed to detect system timezone automatically: {e}. Falling back to UTC.");
53 "UTC".into()
54 }
55 }
56}
57
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "lowercase")]
63pub enum UserMessageLanguage {
64 #[default]
65 En,
66 Zh,
67 Bilingual,
68}
69
70#[derive(Clone, Debug, Serialize, Deserialize)]
71pub struct Config {
72 #[serde(default = "default_llm_provider")]
74 pub llm_provider: String,
75 #[serde(default = "default_api_key")]
76 pub api_key: String,
77 #[serde(default)]
81 pub api_keys: Vec<String>,
82 #[serde(default = "default_model")]
83 pub model: String,
84 #[serde(default)]
85 pub provider_presets: HashMap<String, LlmProviderProfile>,
86 #[serde(default)]
87 pub llm_providers: HashMap<String, LlmProviderProfile>,
88 #[serde(default)]
89 pub llm_base_url: Option<String>,
90 #[serde(default = "default_llm_user_agent")]
91 pub llm_user_agent: String,
92 #[serde(default = "default_max_tokens")]
93 pub max_tokens: u32,
94 #[serde(default = "default_max_tool_iterations")]
95 pub max_tool_iterations: usize,
96 #[serde(default = "default_compaction_timeout_secs")]
97 pub compaction_timeout_secs: u64,
98 #[serde(default)]
101 pub aux_models: AuxModels,
102 #[serde(default)]
107 pub fallback_model: Option<String>,
108 #[serde(default = "default_max_history_messages")]
109 pub max_history_messages: usize,
110 #[serde(default = "default_max_document_size_mb")]
111 pub max_document_size_mb: u64,
112 #[serde(default = "default_memory_token_budget")]
113 pub memory_token_budget: usize,
114 #[serde(default = "default_memory_l0_identity_pct")]
116 pub memory_l0_identity_pct: usize,
117 #[serde(default = "default_memory_l1_essential_pct")]
119 pub memory_l1_essential_pct: usize,
120 #[serde(default = "default_memory_max_entries_per_chat")]
121 pub memory_max_entries_per_chat: usize,
122 #[serde(default = "default_memory_max_global_entries")]
123 pub memory_max_global_entries: usize,
124 #[serde(default = "default_kg_max_triples_per_chat")]
126 pub kg_max_triples_per_chat: usize,
127 #[serde(default = "default_tool_result_truncation_threshold_chars")]
131 pub tool_result_truncation_threshold_chars: usize,
132 #[serde(default = "default_tool_result_truncation_head_chars")]
135 pub tool_result_truncation_head_chars: usize,
136 #[serde(default = "default_tool_result_truncation_tail_chars")]
139 pub tool_result_truncation_tail_chars: usize,
140 #[serde(default = "default_tool_result_artifact_ttl_hours")]
143 pub tool_result_artifact_ttl_hours: u64,
144 #[serde(default = "default_memory_recency_half_life_days")]
149 pub memory_recency_half_life_days: f64,
150 #[serde(default = "default_memory_graph_recall_enabled")]
156 pub memory_graph_recall_enabled: bool,
157 #[serde(default = "default_memory_graph_max_hops")]
160 pub memory_graph_max_hops: usize,
161 #[serde(default = "default_memory_graph_max_triples")]
165 pub memory_graph_max_triples: usize,
166 #[serde(default = "default_tool_repeat_window")]
172 pub tool_repeat_window: usize,
173 #[serde(default = "default_tool_repeat_limit")]
175 pub tool_repeat_limit: usize,
176 #[serde(default = "default_anthropic_prompt_cache_enabled")]
180 pub anthropic_prompt_cache_enabled: bool,
181 #[serde(default = "default_anthropic_prompt_cache_ttl")]
184 pub anthropic_prompt_cache_ttl: String,
185 #[serde(default = "default_checkpoints_enabled")]
191 pub checkpoints_enabled: bool,
192 #[serde(default = "default_skill_archive_after_days")]
197 pub skill_archive_after_days: u64,
198 #[serde(default = "default_skills_catalog_top_k")]
204 pub skills_catalog_top_k: usize,
205 #[serde(default = "default_max_session_messages")]
206 pub max_session_messages: usize,
207 #[serde(default = "default_diff_max_lines")]
210 pub diff_max_lines: usize,
211 #[serde(default = "default_file_diffs_in_chat")]
214 pub file_diffs_in_chat: bool,
215 #[serde(default = "default_model_context_window")]
218 pub model_context_window: usize,
219 #[serde(default = "default_context_pressure_compact_pct")]
223 pub context_pressure_compact_pct: usize,
224 #[serde(default)]
228 pub self_recheck: SelfRecheckConfig,
229 #[serde(default = "default_compact_keep_recent")]
230 pub compact_keep_recent: usize,
231 #[serde(default = "default_tool_timeout_secs")]
232 pub default_tool_timeout_secs: u64,
233 #[serde(default)]
234 pub tool_timeout_overrides: HashMap<String, u64>,
235 #[serde(default = "default_mcp_request_timeout_secs")]
236 pub default_mcp_request_timeout_secs: u64,
237 #[serde(default)]
238 pub show_thinking: bool,
239 #[serde(default)]
240 pub subagents: SubagentConfig,
241 #[serde(default)]
242 pub idle_checkin: IdleCheckinConfig,
243 #[serde(default)]
244 pub heartbeat: HeartbeatConfig,
245 #[serde(default)]
246 pub token_budget: TokenBudgetConfig,
247 #[serde(default)]
248 pub alerts: AlertsConfig,
249 #[serde(default)]
250 pub trust_report: TrustReportConfig,
251 #[serde(default)]
253 pub user_message_language: UserMessageLanguage,
254 #[serde(default)]
255 pub sleep_time: SleepTimeConfig,
256 #[serde(default)]
257 pub interjection: InterjectionConfig,
258 #[serde(default)]
259 pub a2a: A2AConfig,
260
261 #[serde(default = "default_chat_turn_queue_max_pending")]
264 pub chat_turn_queue_max_pending: usize,
265 #[serde(default = "default_true")]
268 pub enable_mid_turn_injection: bool,
269 #[serde(default = "default_true")]
273 pub mid_turn_injection_echo: bool,
274 #[serde(default = "default_parallel_tool_max_concurrency")]
276 pub parallel_tool_max_concurrency: usize,
277 #[serde(default)]
280 pub tool_concurrency_overrides: HashMap<String, String>,
281 #[serde(default)]
284 pub openai_compat_body_overrides: HashMap<String, serde_json::Value>,
285 #[serde(default)]
288 pub openai_compat_body_overrides_by_provider:
289 HashMap<String, HashMap<String, serde_json::Value>>,
290 #[serde(default)]
292 pub openai_compat_body_overrides_by_model: HashMap<String, HashMap<String, serde_json::Value>>,
293
294 #[serde(default = "default_data_dir")]
296 pub data_dir: String,
297 #[serde(default)]
298 pub skills_dir: Option<String>,
299 #[serde(default = "default_working_dir")]
300 pub working_dir: String,
301 #[serde(default = "default_working_dir_isolation")]
302 pub working_dir_isolation: WorkingDirIsolation,
303 #[serde(default = "default_high_risk_tool_user_confirmation_required")]
304 pub high_risk_tool_user_confirmation_required: bool,
305 #[serde(default = "default_bash_dangerous_patterns")]
312 pub bash_dangerous_patterns: Vec<String>,
313 #[serde(default)]
315 pub rtk: RtkConfig,
316 #[serde(default)]
317 pub sandbox: SandboxConfig,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub override_timezone: Option<String>,
320 #[serde(default = "default_timezone", skip_serializing)]
321 pub timezone: String,
322 #[serde(default = "default_control_chat_ids")]
323 pub control_chat_ids: Vec<i64>,
324 #[serde(default)]
325 pub discord_bot_token: Option<String>,
326 #[serde(default)]
327 pub discord_allowed_channels: Vec<u64>,
328 #[serde(default)]
329 pub discord_no_mention: bool,
330 #[serde(default = "default_allow_group_slash_without_mention")]
331 pub allow_group_slash_without_mention: bool,
332
333 #[serde(default = "default_web_enabled")]
335 pub web_enabled: bool,
336 #[serde(default = "default_web_host")]
337 pub web_host: String,
338 #[serde(default = "default_web_port")]
339 pub web_port: u16,
340 #[serde(default = "default_web_max_inflight_per_session")]
341 pub web_max_inflight_per_session: usize,
342 #[serde(default = "default_web_max_requests_per_window")]
343 pub web_max_requests_per_window: usize,
344 #[serde(default = "default_web_rate_window_seconds")]
345 pub web_rate_window_seconds: u64,
346 #[serde(default = "default_web_run_history_limit")]
347 pub web_run_history_limit: usize,
348 #[serde(default = "default_web_session_idle_ttl_seconds")]
349 pub web_session_idle_ttl_seconds: u64,
350 #[serde(default)]
351 pub web_fetch_validation: WebContentValidationConfig,
352 #[serde(default)]
353 pub web_fetch_url_validation: WebFetchUrlValidationConfig,
354 #[serde(default)]
357 pub web_search: SearchProviderConfig,
358 #[serde(default)]
361 pub output_guardrail: OutputGuardrailConfig,
362 #[serde(default)]
365 pub tool_policy: crate::tool_guardrails::ToolPolicyConfig,
366 #[serde(default)]
369 pub egress_policy: EgressPolicyConfig,
370
371 #[serde(default)]
373 pub embedding_provider: Option<String>,
374 #[serde(default)]
375 pub embedding_api_key: Option<String>,
376 #[serde(default)]
377 pub embedding_base_url: Option<String>,
378 #[serde(default)]
379 pub embedding_model: Option<String>,
380 #[serde(default)]
381 pub embedding_dim: Option<usize>,
382 #[serde(default)]
383 pub openai_api_key: Option<String>,
384
385 #[serde(default = "default_model_prices")]
387 pub model_prices: Vec<ModelPrice>,
388
389 #[serde(default = "default_reflector_enabled")]
391 pub reflector_enabled: bool,
392 #[serde(default = "default_reflector_interval_mins")]
393 pub reflector_interval_mins: u64,
394 #[serde(default = "default_dlq_replay_enabled")]
399 pub dlq_replay_enabled: bool,
400 #[serde(default = "default_dlq_replay_interval_secs")]
402 pub dlq_replay_interval_secs: u64,
403 #[serde(default = "default_dlq_max_replay_attempts")]
406 pub dlq_max_replay_attempts: u32,
407 #[serde(default = "default_skill_review_min_tool_calls")]
411 pub skill_review_min_tool_calls: usize,
412
413 #[serde(default = "default_soul_path")]
417 pub soul_path: Option<String>,
418 #[serde(default = "default_souls_dir")]
420 pub souls_dir: Option<String>,
421
422 #[serde(default)]
429 pub context_dir: Option<String>,
430 #[serde(default = "default_context_max_chars")]
434 pub context_max_chars: usize,
435
436 #[serde(default)]
443 pub voice_round_trip: bool,
444
445 #[serde(default = "default_user_model_max_chars")]
453 pub user_model_max_chars: usize,
454
455 #[serde(flatten)]
457 pub clawhub: ClawHubConfig,
458
459 #[serde(default)]
461 pub plugins: PluginsConfig,
462
463 #[serde(default)]
469 pub media: MediaConfig,
470
471 #[serde(default)]
474 pub openai_base_url: Option<String>,
475
476 #[serde(default = "default_voice_provider", rename = "voice_provider")]
479 pub voice_provider: String,
480 #[serde(default, rename = "voice_transcription_command")]
483 pub voice_transcription_command: Option<String>,
484
485 #[serde(default)]
487 pub observability: Option<serde_yaml::Value>,
488
489 #[serde(default)]
494 pub channels: HashMap<String, serde_yaml::Value>,
495
496 #[serde(default = "default_telegram_bot_token")]
498 pub telegram_bot_token: String,
499 #[serde(default = "default_bot_username")]
500 pub bot_username: String,
501 #[serde(default)]
502 pub allowed_groups: Vec<i64>,
503}
504
505pub(crate) fn collect_enabled_channel_endpoints(value: &serde_yaml::Value, out: &mut Vec<String>) {
506 let Some(mapping) = value.as_mapping() else {
507 return;
508 };
509 if mapping
510 .get(serde_yaml::Value::String("enabled".into()))
511 .and_then(serde_yaml::Value::as_bool)
512 == Some(false)
513 {
514 return;
515 }
516
517 for (key, value) in mapping {
518 let Some(key) = key.as_str() else {
519 continue;
520 };
521 let key = key.to_ascii_lowercase();
522 let is_endpoint_key = key.contains("url")
523 || key.contains("endpoint")
524 || key == "homeserver"
525 || key == "homeserver_url";
526 if is_endpoint_key {
527 if let Some(endpoint) = value
528 .as_str()
529 .map(str::trim)
530 .filter(|endpoint| !endpoint.is_empty())
531 {
532 out.push(endpoint.to_string());
533 }
534 }
535 match value {
536 serde_yaml::Value::Mapping(_) => collect_enabled_channel_endpoints(value, out),
537 serde_yaml::Value::Sequence(values) => {
538 for value in values {
539 collect_enabled_channel_endpoints(value, out);
540 }
541 }
542 _ => {}
543 }
544 }
545}
546
547impl Config {
548 pub fn is_control_chat(&self, chat_id: i64) -> bool {
553 self.control_chat_ids.contains(&chat_id)
554 }
555
556 fn ensure_mapping_mut(value: &mut serde_yaml::Value) -> &mut serde_yaml::Mapping {
557 if !matches!(value, serde_yaml::Value::Mapping(_)) {
558 *value = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
559 }
560 value
561 .as_mapping_mut()
562 .expect("value should be a mapping after initialization")
563 }
564
565 fn channel_default_account_id(&self, channel: &str) -> Option<String> {
566 let channel_cfg = self.channels.get(channel)?;
567 let mut account_ids: Vec<String> = channel_cfg
568 .get("accounts")
569 .and_then(|v| v.as_mapping())
570 .map(|m| {
571 m.keys()
572 .filter_map(|k| k.as_str().map(ToOwned::to_owned))
573 .collect()
574 })
575 .unwrap_or_default();
576 account_ids.sort();
577 channel_cfg
578 .get("default_account")
579 .and_then(|v| v.as_str())
580 .map(str::trim)
581 .filter(|v| !v.is_empty())
582 .map(ToOwned::to_owned)
583 .or_else(|| {
584 if channel_cfg
585 .get("accounts")
586 .and_then(|v| v.get("default"))
587 .is_some()
588 {
589 Some("default".to_string())
590 } else {
591 account_ids.first().cloned()
592 }
593 })
594 }
595
596 fn llm_override_target(&self, channel: &str) -> Option<(String, Option<String>)> {
597 let channel = channel.trim();
598 if channel.is_empty() {
599 return None;
600 }
601 if let Some((base_channel, account_id)) = channel.split_once('.') {
602 let base_channel = base_channel.trim();
603 let account_id = account_id.trim();
604 if base_channel.is_empty() || account_id.is_empty() {
605 return None;
606 }
607 return Some((base_channel.to_string(), Some(account_id.to_string())));
608 }
609
610 Some((
611 channel.to_string(),
612 self.channel_default_account_id(channel),
613 ))
614 }
615
616 fn llm_override_mapping_mut(&mut self, channel: &str) -> Option<&mut serde_yaml::Mapping> {
617 let (base_channel, account_id) = self.llm_override_target(channel)?;
618 let channel_value = self
619 .channels
620 .entry(base_channel)
621 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
622 let channel_map = Self::ensure_mapping_mut(channel_value);
623 if let Some(account_id) = account_id {
624 let accounts_key = serde_yaml::Value::String("accounts".to_string());
625 let accounts_value = channel_map
626 .entry(accounts_key)
627 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
628 let accounts_map = Self::ensure_mapping_mut(accounts_value);
629 let account_value = accounts_map
630 .entry(serde_yaml::Value::String(account_id))
631 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
632 return Some(Self::ensure_mapping_mut(account_value));
633 }
634 Some(channel_map)
635 }
636
637 pub fn set_provider_override_for_channel(&mut self, channel: &str, provider: Option<&str>) {
638 let Some(target) = self.llm_override_mapping_mut(channel) else {
639 return;
640 };
641 let provider_preset_key = serde_yaml::Value::String("provider_preset".to_string());
642 let llm_provider_key = serde_yaml::Value::String("llm_provider".to_string());
643 target.remove(&provider_preset_key);
644 target.remove(&llm_provider_key);
645 if let Some(provider) = provider
646 .map(str::trim)
647 .filter(|provider| !provider.is_empty())
648 .map(|provider| provider.to_ascii_lowercase())
649 {
650 target.insert(
651 provider_preset_key,
652 serde_yaml::Value::String(provider.to_string()),
653 );
654 }
655 }
656
657 pub fn set_model_override_for_channel(&mut self, channel: &str, model: Option<&str>) {
658 let Some(target) = self.llm_override_mapping_mut(channel) else {
659 return;
660 };
661 let model_key = serde_yaml::Value::String("model".to_string());
662 target.remove(&model_key);
663 if let Some(model) = model.and_then(normalize_model_name) {
664 target.insert(model_key, serde_yaml::Value::String(model.to_string()));
665 }
666 }
667
668 fn channel_account_bot_username(&self, channel: &str, account_id: &str) -> Option<String> {
669 self.channels
670 .get(channel)
671 .and_then(|v| v.get("accounts"))
672 .and_then(|v| v.get(account_id))
673 .and_then(|v| v.get("bot_username"))
674 .and_then(|v| v.as_str())
675 .map(str::trim)
676 .filter(|v| !v.is_empty())
677 .map(ToOwned::to_owned)
678 }
679
680 fn channel_account_soul_path(&self, channel: &str, account_id: &str) -> Option<String> {
681 self.channels
682 .get(channel)
683 .and_then(|v| v.get("accounts"))
684 .and_then(|v| v.get(account_id))
685 .and_then(|v| v.get("soul_path"))
686 .and_then(|v| v.as_str())
687 .map(str::trim)
688 .filter(|v| !v.is_empty())
689 .map(ToOwned::to_owned)
690 }
691
692 fn provider_override_from_value(value: &serde_yaml::Value) -> Option<String> {
693 value
694 .get("provider_preset")
695 .and_then(|v| v.as_str())
696 .or_else(|| value.get("llm_provider").and_then(|v| v.as_str()))
697 .map(str::trim)
698 .filter(|v| !v.is_empty())
699 .map(|v| v.to_ascii_lowercase())
700 }
701
702 fn channel_account_provider_override(&self, channel: &str, account_id: &str) -> Option<String> {
703 self.channels
704 .get(channel)
705 .and_then(|v| v.get("accounts"))
706 .and_then(|v| v.get(account_id))
707 .and_then(Self::provider_override_from_value)
708 }
709
710 fn model_override_from_value(value: &serde_yaml::Value) -> Option<String> {
711 value
712 .get("model")
713 .and_then(|v| v.as_str())
714 .and_then(normalize_model_name)
715 }
716
717 fn channel_account_model_override(&self, channel: &str, account_id: &str) -> Option<String> {
718 self.channels
719 .get(channel)
720 .and_then(|v| v.get("accounts"))
721 .and_then(|v| v.get(account_id))
722 .and_then(Self::model_override_from_value)
723 }
724
725 pub fn provider_override_for_channel(&self, channel: &str) -> Option<String> {
726 if let Some((base_channel, account_id)) = channel.split_once('.') {
727 if let Some(v) = self.channel_account_provider_override(base_channel, account_id) {
728 return Some(v);
729 }
730 return self
731 .channels
732 .get(base_channel)
733 .and_then(Self::provider_override_from_value);
734 }
735
736 if let Some(default_account) = self.channel_default_account_id(channel) {
737 if let Some(v) = self.channel_account_provider_override(channel, &default_account) {
738 return Some(v);
739 }
740 }
741
742 self.channels
743 .get(channel)
744 .and_then(Self::provider_override_from_value)
745 }
746
747 pub fn model_override_for_channel(&self, channel: &str) -> Option<String> {
748 if let Some((base_channel, account_id)) = channel.split_once('.') {
749 if let Some(v) = self.channel_account_model_override(base_channel, account_id) {
750 return Some(v);
751 }
752 return self
753 .channels
754 .get(base_channel)
755 .and_then(Self::model_override_from_value);
756 }
757
758 if let Some(default_account) = self.channel_default_account_id(channel) {
759 if let Some(v) = self.channel_account_model_override(channel, &default_account) {
760 return Some(v);
761 }
762 }
763
764 self.channels
765 .get(channel)
766 .and_then(Self::model_override_from_value)
767 }
768
769 pub fn soul_path_for_channel(&self, channel: &str) -> Option<String> {
770 let channel_override = self
771 .channels
772 .get(channel)
773 .and_then(|v| v.get("soul_path"))
774 .and_then(|v| v.as_str())
775 .map(str::trim)
776 .filter(|v| !v.is_empty())
777 .map(ToOwned::to_owned);
778 if channel_override.is_some() {
779 return channel_override;
780 }
781
782 if let Some((base_channel, account_id)) = channel.split_once('.') {
783 if let Some(v) = self.channel_account_soul_path(base_channel, account_id) {
784 return Some(v);
785 }
786 return self
787 .channels
788 .get(base_channel)
789 .and_then(|v| v.get("soul_path"))
790 .and_then(|v| v.as_str())
791 .map(str::trim)
792 .filter(|v| !v.is_empty())
793 .map(ToOwned::to_owned);
794 }
795
796 if let Some(default_account) = self.channel_default_account_id(channel) {
797 if let Some(v) = self.channel_account_soul_path(channel, &default_account) {
798 return Some(v);
799 }
800 }
801
802 self.channels
803 .get(channel)
804 .and_then(|v| v.get("soul_path"))
805 .and_then(|v| v.as_str())
806 .map(str::trim)
807 .filter(|v| !v.is_empty())
808 .map(ToOwned::to_owned)
809 }
810
811 pub fn bot_username_for_channel(&self, channel: &str) -> String {
812 let channel_override = self
813 .channels
814 .get(channel)
815 .and_then(|v| v.get("bot_username"))
816 .and_then(|v| v.as_str())
817 .map(str::trim)
818 .filter(|v| !v.is_empty());
819 if let Some(v) = channel_override {
820 return v.to_string();
821 }
822
823 if let Some((base_channel, account_id)) = channel.split_once('.') {
824 if let Some(v) = self.channel_account_bot_username(base_channel, account_id) {
825 return v;
826 }
827 } else if let Some(default_account) = self.channel_default_account_id(channel) {
828 if let Some(v) = self.channel_account_bot_username(channel, &default_account) {
829 return v;
830 }
831 }
832
833 let global = self.bot_username.trim();
834 if !global.is_empty() {
835 global.to_string()
836 } else {
837 default_bot_username()
838 }
839 }
840
841 pub fn bot_username_overrides(&self) -> HashMap<String, String> {
842 let mut overrides: HashMap<String, String> = self
843 .channels
844 .iter()
845 .filter_map(|(channel, cfg)| {
846 cfg.get("bot_username")
847 .and_then(|v| v.as_str())
848 .map(str::trim)
849 .filter(|v| !v.is_empty())
850 .map(|v| (channel.clone(), v.to_string()))
851 })
852 .collect();
853
854 for (channel, channel_cfg) in &self.channels {
855 let accounts = channel_cfg.get("accounts").and_then(|v| v.as_mapping());
856 let Some(accounts) = accounts else {
857 continue;
858 };
859 let default_account = self.channel_default_account_id(channel);
860 for (key, value) in accounts {
861 let Some(account_id) = key.as_str() else {
862 continue;
863 };
864 let username = value
865 .get("bot_username")
866 .and_then(|v| v.as_str())
867 .map(str::trim)
868 .filter(|v| !v.is_empty());
869 let Some(username) = username else {
870 continue;
871 };
872 if default_account
873 .as_deref()
874 .map(|v| v == account_id)
875 .unwrap_or(false)
876 {
877 overrides.insert(channel.clone(), username.to_string());
878 } else {
879 overrides.insert(format!("{channel}.{account_id}"), username.to_string());
880 }
881 }
882 }
883
884 overrides
885 }
886
887 pub fn llm_provider_overrides(&self) -> HashMap<String, String> {
888 let mut overrides: HashMap<String, String> = self
889 .channels
890 .iter()
891 .filter_map(|(channel, cfg)| {
892 Self::provider_override_from_value(cfg).map(|provider| (channel.clone(), provider))
893 })
894 .collect();
895
896 for (channel, channel_cfg) in &self.channels {
897 let accounts = channel_cfg.get("accounts").and_then(|v| v.as_mapping());
898 let Some(accounts) = accounts else {
899 continue;
900 };
901 let default_account = self.channel_default_account_id(channel);
902 for (key, value) in accounts {
903 let Some(account_id) = key.as_str() else {
904 continue;
905 };
906 let Some(provider) = Self::provider_override_from_value(value) else {
907 continue;
908 };
909 if default_account
910 .as_deref()
911 .map(|v| v == account_id)
912 .unwrap_or(false)
913 {
914 overrides.insert(channel.clone(), provider);
915 } else {
916 overrides.insert(format!("{channel}.{account_id}"), provider);
917 }
918 }
919 }
920
921 overrides
922 }
923
924 #[cfg(any(test, feature = "test-support"))]
925 pub fn test_defaults() -> Self {
926 Self {
927 telegram_bot_token: "tok".into(),
928 bot_username: "bot".into(),
929 llm_provider: "anthropic".into(),
930 api_key: "key".into(),
931 api_keys: Vec::new(),
932 model: "claude-sonnet-4-5-20250929".into(),
933 provider_presets: HashMap::new(),
934 llm_providers: HashMap::new(),
935 llm_base_url: None,
936 llm_user_agent: default_llm_user_agent(),
937 max_tokens: 8192,
938 max_tool_iterations: 100,
939 compaction_timeout_secs: 180,
940 aux_models: AuxModels::default(),
941 fallback_model: None,
942 max_history_messages: 50,
943 max_document_size_mb: 100,
944 memory_token_budget: 1500,
945 memory_l0_identity_pct: 20,
946 memory_l1_essential_pct: 30,
947 memory_max_entries_per_chat: 200,
948 memory_max_global_entries: 500,
949 kg_max_triples_per_chat: 1000,
950 tool_result_truncation_threshold_chars: 4000,
951 tool_result_truncation_head_chars: 1500,
952 tool_result_truncation_tail_chars: 500,
953 tool_result_artifact_ttl_hours: 24,
954 memory_recency_half_life_days: 30.0,
955 memory_graph_recall_enabled: true,
956 memory_graph_max_hops: 2,
957 memory_graph_max_triples: 10,
958 tool_repeat_window: 10,
959 tool_repeat_limit: 3,
960 anthropic_prompt_cache_enabled: true,
961 anthropic_prompt_cache_ttl: "5m".into(),
962 checkpoints_enabled: false,
963 skill_archive_after_days: 30,
964 skills_catalog_top_k: 3,
965 data_dir: default_data_dir(),
966 skills_dir: None,
967 working_dir: default_working_dir(),
968 working_dir_isolation: WorkingDirIsolation::Chat,
969 high_risk_tool_user_confirmation_required: true,
970 bash_dangerous_patterns: default_bash_dangerous_patterns(),
971 rtk: RtkConfig::default(),
972 sandbox: SandboxConfig::default(),
973 openai_api_key: None,
974 override_timezone: None,
975 timezone: "UTC".into(),
976 allowed_groups: vec![],
977 control_chat_ids: vec![],
978 max_session_messages: 40,
979 diff_max_lines: default_diff_max_lines(),
980 file_diffs_in_chat: default_file_diffs_in_chat(),
981 model_context_window: default_model_context_window(),
982 context_pressure_compact_pct: default_context_pressure_compact_pct(),
983 self_recheck: SelfRecheckConfig::default(),
984 compact_keep_recent: 20,
985 default_tool_timeout_secs: default_tool_timeout_secs(),
986 tool_timeout_overrides: HashMap::new(),
987 default_mcp_request_timeout_secs: default_mcp_request_timeout_secs(),
988 discord_bot_token: None,
989 discord_allowed_channels: vec![],
990 discord_no_mention: false,
991 allow_group_slash_without_mention: false,
992 show_thinking: false,
993 subagents: SubagentConfig::default(),
994 idle_checkin: IdleCheckinConfig::default(),
995 heartbeat: HeartbeatConfig::default(),
996 token_budget: TokenBudgetConfig::default(),
997 alerts: AlertsConfig::default(),
998 trust_report: TrustReportConfig::default(),
999 user_message_language: UserMessageLanguage::default(),
1000 sleep_time: SleepTimeConfig::default(),
1001 interjection: InterjectionConfig::default(),
1002 a2a: A2AConfig::default(),
1003 openai_compat_body_overrides: HashMap::new(),
1004 openai_compat_body_overrides_by_provider: HashMap::new(),
1005 openai_compat_body_overrides_by_model: HashMap::new(),
1006 web_enabled: true,
1007 web_host: "127.0.0.1".into(),
1008 web_port: 10961,
1009 web_max_inflight_per_session: 10,
1010 web_max_requests_per_window: 8,
1011 web_rate_window_seconds: 10,
1012 web_run_history_limit: 512,
1013 web_session_idle_ttl_seconds: 300,
1014 web_fetch_validation: WebContentValidationConfig::default(),
1015 web_fetch_url_validation: WebFetchUrlValidationConfig::default(),
1016 web_search: SearchProviderConfig::default(),
1017 output_guardrail: OutputGuardrailConfig::default(),
1018 tool_policy: crate::tool_guardrails::ToolPolicyConfig::default(),
1019 egress_policy: EgressPolicyConfig::default(),
1020 model_prices: vec![],
1021 embedding_provider: None,
1022 embedding_api_key: None,
1023 embedding_base_url: None,
1024 embedding_model: None,
1025 embedding_dim: None,
1026 reflector_enabled: true,
1027 reflector_interval_mins: 15,
1028 dlq_replay_enabled: true,
1029 dlq_replay_interval_secs: 300,
1030 dlq_max_replay_attempts: 3,
1031 skill_review_min_tool_calls: 5,
1032 soul_path: None,
1033 souls_dir: None,
1034 context_dir: None,
1035 context_max_chars: 8000,
1036 user_model_max_chars: 1500,
1037 voice_round_trip: false,
1038 clawhub: ClawHubConfig::default(),
1039 plugins: PluginsConfig::default(),
1040 media: MediaConfig::default(),
1041 openai_base_url: None,
1042 voice_provider: "openai".into(),
1043 voice_transcription_command: None,
1044 observability: None,
1045 channels: HashMap::new(),
1046 chat_turn_queue_max_pending: 20,
1047 enable_mid_turn_injection: true,
1048 mid_turn_injection_echo: true,
1049 parallel_tool_max_concurrency: 8,
1050 tool_concurrency_overrides: HashMap::new(),
1051 }
1052 }
1053
1054 pub fn data_root_dir(&self) -> PathBuf {
1056 expand_path(&self.data_dir)
1057 }
1058
1059 pub fn runtime_data_dir(&self) -> String {
1061 let root = self.data_root_dir();
1062 if root.file_name().and_then(|n| n.to_str()) == Some("runtime") {
1064 return root.to_string_lossy().to_string();
1065 }
1066 root.join("runtime").to_string_lossy().to_string()
1067 }
1068
1069 pub fn skills_data_dir(&self) -> String {
1071 if let Ok(explicit_dir) = std::env::var("MICROCLAW_SKILLS_DIR") {
1073 let trimmed = explicit_dir.trim();
1074 if !trimmed.is_empty() {
1075 return expand_path(trimmed).to_string_lossy().to_string();
1076 }
1077 }
1078 if let Some(configured) = &self.skills_dir {
1080 let trimmed = configured.trim();
1081 if !trimmed.is_empty() {
1082 return expand_path(trimmed).to_string_lossy().to_string();
1083 }
1084 }
1085 self.data_root_dir()
1087 .join("skills")
1088 .to_string_lossy()
1089 .to_string()
1090 }
1091
1092 pub fn souls_data_dir(&self) -> String {
1094 if let Some(configured) = &self.souls_dir {
1095 let trimmed = configured.trim();
1096 if !trimmed.is_empty() {
1097 return expand_path(trimmed).to_string_lossy().to_string();
1098 }
1099 }
1100 self.data_root_dir()
1101 .join("souls")
1102 .to_string_lossy()
1103 .to_string()
1104 }
1105
1106 pub fn clawhub_lockfile_path(&self) -> PathBuf {
1107 self.data_root_dir().join("clawhub.lock.json")
1108 }
1109
1110 pub fn clawhub_load_verification(&self) -> Option<(PathBuf, bool)> {
1113 match self.clawhub.verify_on_load {
1114 ClawHubVerifyMode::Off => None,
1115 ClawHubVerifyMode::Warn => Some((self.clawhub_lockfile_path(), false)),
1116 ClawHubVerifyMode::Block => Some((self.clawhub_lockfile_path(), true)),
1117 }
1118 }
1119
1120 pub fn config_path_for_setup() -> PathBuf {
1121 if let Ok(custom) = std::env::var("MICROCLAW_CONFIG") {
1122 return expand_path(&custom);
1123 }
1124 if std::path::Path::new("./microclaw.config.yaml").exists() {
1125 return PathBuf::from("./microclaw.config.yaml");
1126 }
1127 if std::path::Path::new("./microclaw.config.yml").exists() {
1128 return PathBuf::from("./microclaw.config.yml");
1129 }
1130 if std::env::var("SNAP").is_ok() {
1131 if let Ok(snap_user_common) = std::env::var("SNAP_USER_COMMON") {
1132 return PathBuf::from(snap_user_common).join("config.yaml");
1133 }
1134 }
1135 PathBuf::from("microclaw.config.yaml")
1136 }
1137
1138 pub fn resolve_config_path() -> Result<Option<PathBuf>, MicroClawError> {
1139 if let Ok(custom) = std::env::var("MICROCLAW_CONFIG") {
1141 let expanded = expand_path(&custom);
1142 if expanded.exists() {
1143 return Ok(Some(expanded));
1144 }
1145 return Err(MicroClawError::Config(format!(
1146 "MICROCLAW_CONFIG points to non-existent file: {custom}"
1147 )));
1148 }
1149
1150 if std::path::Path::new("./microclaw.config.yaml").exists() {
1151 return Ok(Some(PathBuf::from("./microclaw.config.yaml")));
1152 }
1153 if std::path::Path::new("./microclaw.config.yml").exists() {
1154 return Ok(Some(PathBuf::from("./microclaw.config.yml")));
1155 }
1156 Ok(None)
1157 }
1158
1159 fn inferred_channel_enabled(&self, channel: &str) -> bool {
1160 match channel {
1161 "telegram" => {
1162 !self.telegram_bot_token.trim().is_empty() || self.channels.contains_key("telegram")
1163 }
1164 "discord" => {
1165 self.discord_bot_token
1166 .as_deref()
1167 .map(|v| !v.trim().is_empty())
1168 .unwrap_or(false)
1169 || self.channels.contains_key("discord")
1170 }
1171 "web" => self.web_enabled || self.channels.contains_key("web"),
1172 _ => self.channels.contains_key(channel),
1173 }
1174 }
1175
1176 fn explicit_channel_enabled(&self, channel: &str) -> Option<bool> {
1177 self.channels
1178 .get(channel)
1179 .and_then(|v| v.get("enabled"))
1180 .and_then(|v| v.as_bool())
1181 }
1182
1183 pub fn channel_enabled(&self, channel: &str) -> bool {
1184 let needle = channel.trim().to_lowercase();
1185 if let Some(explicit) = self.explicit_channel_enabled(&needle) {
1186 return explicit;
1187 }
1188 self.inferred_channel_enabled(&needle)
1189 }
1190
1191 pub fn channel_status(&self) -> (Vec<&'static str>, Vec<&'static str>) {
1197 let configured = [
1198 (
1199 "telegram",
1200 !self.telegram_bot_token.trim().is_empty()
1201 || self.channels.contains_key("telegram"),
1202 ),
1203 (
1204 "discord",
1205 self.discord_bot_token
1206 .as_deref()
1207 .map(|v| !v.trim().is_empty())
1208 .unwrap_or(false)
1209 || self.channels.contains_key("discord"),
1210 ),
1211 ("slack", self.channels.contains_key("slack")),
1212 ("feishu", self.channels.contains_key("feishu")),
1213 ("matrix", self.channels.contains_key("matrix")),
1214 ("irc", self.channels.contains_key("irc")),
1215 ("web", self.web_enabled || self.channels.contains_key("web")),
1216 ];
1217 let mut enabled = Vec::new();
1218 let mut configured_but_disabled = Vec::new();
1219 for (name, is_configured) in configured {
1220 if !is_configured {
1221 continue;
1222 }
1223 if self.channel_enabled(name) {
1224 enabled.push(name);
1225 } else {
1226 configured_but_disabled.push(name);
1227 }
1228 }
1229 (enabled, configured_but_disabled)
1230 }
1231
1232 pub fn load() -> Result<Self, MicroClawError> {
1234 let yaml_path = Self::resolve_config_path()?;
1235
1236 if let Some(path) = yaml_path {
1237 return Self::load_from_path(&path);
1238 }
1239
1240 Err(MicroClawError::Config(
1242 "No microclaw.config.yaml found. Run `microclaw setup` to create one.".into(),
1243 ))
1244 }
1245
1246 pub fn load_from_path(path: &Path) -> Result<Self, MicroClawError> {
1248 Self::load_from_path_inner(path, false)
1249 }
1250
1251 pub fn load_from_path_for_headless(path: &Path) -> Result<Self, MicroClawError> {
1258 Self::load_from_path_inner(path, true)
1259 }
1260
1261 pub fn for_embedded(
1263 provider: impl Into<String>,
1264 model: impl Into<String>,
1265 api_key: impl Into<String>,
1266 base_url: Option<String>,
1267 ) -> Result<Self, MicroClawError> {
1268 let mut config: Config = serde_yaml::from_str("{}")
1269 .map_err(|error| MicroClawError::Config(error.to_string()))?;
1270 config.llm_provider = provider.into();
1271 config.model = model.into();
1272 config.api_key = api_key.into();
1273 config.llm_base_url = base_url;
1274 config.web_enabled = true;
1275 config.post_deserialize()?;
1276 config.web_enabled = false;
1277 config.channels.remove("web");
1278 Ok(config)
1279 }
1280
1281 fn load_from_path_inner(path: &Path, allow_no_channels: bool) -> Result<Self, MicroClawError> {
1282 let path_str = path.to_string_lossy().to_string();
1283 let content = std::fs::read_to_string(path)
1284 .map_err(|e| MicroClawError::Config(format!("Failed to read {path_str}: {e}")))?;
1285 if let Ok(raw) = serde_yaml::from_str::<serde_yaml::Value>(&content) {
1286 if let Some(map) = raw.as_mapping() {
1287 let old_top_level = map
1288 .get(serde_yaml::Value::String("web_auth_token".to_string()))
1289 .is_some();
1290 let old_channel_level = map
1291 .get(serde_yaml::Value::String("channels".to_string()))
1292 .and_then(|v| v.as_mapping())
1293 .and_then(|channels| channels.get(serde_yaml::Value::String("web".to_string())))
1294 .and_then(|v| v.as_mapping())
1295 .map(|web| {
1296 web.contains_key(serde_yaml::Value::String("auth_token".to_string()))
1297 })
1298 .unwrap_or(false);
1299 if old_top_level || old_channel_level {
1300 warn!(
1301 "Deprecated web auth token config detected in {}. \
1302`web_auth_token` / `channels.web.auth_token` are ignored; migrate to operator password + API keys.",
1303 path_str
1304 );
1305 }
1306 for warning in unknown_top_level_key_warnings(map) {
1307 warn!("{warning} (in {path_str})");
1308 }
1309 }
1310 }
1311 let mut config: Config = serde_yaml::from_str(&content)
1312 .map_err(|error| MicroClawError::Config(friendly_yaml_error(&path_str, &error)))?;
1313 let synthesized_channel = allow_no_channels && config.channel_status().0.is_empty();
1314 if synthesized_channel {
1315 config.web_enabled = true;
1316 }
1317 config.post_deserialize()?;
1318 if synthesized_channel {
1319 config.web_enabled = false;
1320 config.channels.remove("web");
1321 }
1322 Ok(config)
1323 }
1324
1325 pub fn post_deserialize(&mut self) -> Result<(), MicroClawError> {
1327 self.llm_provider = self.llm_provider.trim().to_lowercase();
1328
1329 self.model = resolve_model_name_with_fallback(&self.llm_provider, Some(&self.model), None);
1330 self.provider_presets =
1331 normalize_provider_profiles(std::mem::take(&mut self.provider_presets));
1332 self.llm_providers = normalize_provider_profiles(std::mem::take(&mut self.llm_providers));
1333 for (alias, preset) in self.provider_presets.clone() {
1334 self.llm_providers
1335 .entry(alias)
1336 .and_modify(|existing| {
1337 *existing = merge_provider_profile(preset.clone(), existing.clone());
1338 })
1339 .or_insert(preset);
1340 }
1341 for channel_cfg in self.channels.values_mut() {
1342 migrate_model_override_alias_to_provider_preset(channel_cfg, &self.provider_presets);
1343 }
1344
1345 self.override_timezone = self
1346 .override_timezone
1347 .as_ref()
1348 .map(|v| v.trim().to_string())
1349 .filter(|v| !v.is_empty());
1350 if self
1351 .override_timezone
1352 .as_deref()
1353 .is_some_and(|v| v.eq_ignore_ascii_case("auto"))
1354 {
1355 self.override_timezone = None;
1356 }
1357
1358 let tz_raw = self
1361 .override_timezone
1362 .clone()
1363 .unwrap_or_else(|| "auto".to_string());
1364
1365 if tz_raw.eq_ignore_ascii_case("auto") {
1366 let detected = detect_system_timezone();
1367 if detected.parse::<chrono_tz::Tz>().is_ok() {
1368 self.timezone = detected;
1369 } else {
1370 warn!(
1371 "Detected system timezone '{}' is not recognized by chrono-tz. Falling back to UTC.",
1372 detected
1373 );
1374 self.timezone = "UTC".into();
1375 }
1376 } else {
1377 tz_raw
1378 .parse::<chrono_tz::Tz>()
1379 .map_err(|_| MicroClawError::Config(format!("Invalid timezone: {tz_raw}")))?;
1380 self.timezone = tz_raw;
1381 }
1382
1383 if let Some(ref url) = self.llm_base_url {
1385 if url.trim().is_empty() {
1386 self.llm_base_url = None;
1387 }
1388 }
1389 self.llm_user_agent = self.llm_user_agent.trim().to_string();
1390 if self.llm_user_agent.is_empty() {
1391 self.llm_user_agent = default_llm_user_agent();
1392 }
1393 if let Some(dir) = &self.skills_dir {
1394 let trimmed = dir.trim().to_string();
1395 self.skills_dir = if trimmed.is_empty() {
1396 None
1397 } else {
1398 Some(expand_path(&trimmed).to_string_lossy().to_string())
1399 };
1400 }
1401 if let Some(dir) = &self.souls_dir {
1402 let trimmed = dir.trim().to_string();
1403 self.souls_dir = if trimmed.is_empty() {
1404 None
1405 } else {
1406 Some(expand_path(&trimmed).to_string_lossy().to_string())
1407 };
1408 }
1409 if let Some(dir) = &self.plugins.dir {
1410 let trimmed = dir.trim().to_string();
1411 self.plugins.dir = if trimmed.is_empty() {
1412 None
1413 } else {
1414 Some(expand_path(&trimmed).to_string_lossy().to_string())
1415 };
1416 }
1417 if self.working_dir.trim().is_empty() {
1418 self.working_dir = default_working_dir();
1419 } else {
1420 self.working_dir = expand_path(&self.working_dir).to_string_lossy().to_string();
1421 }
1422 self.data_dir = expand_path(&self.data_dir).to_string_lossy().to_string();
1423 self.sandbox.image = self.sandbox.image.trim().to_string();
1424 if self.sandbox.image.is_empty() {
1425 self.sandbox.image = default_sandbox_image();
1426 }
1427 self.sandbox.container_prefix = self.sandbox.container_prefix.trim().to_string();
1428 if self.sandbox.container_prefix.is_empty() {
1429 self.sandbox.container_prefix = default_sandbox_container_prefix();
1430 }
1431 normalize_string_list(&mut self.sandbox.credential_env_allowlist, false);
1432 normalize_string_list(&mut self.egress_policy.allow_hosts, true);
1433 normalize_string_list(&mut self.egress_policy.deny_hosts, true);
1434 normalize_string_list(&mut self.tool_policy.allow_tools, false);
1435 normalize_string_list(&mut self.tool_policy.deny_tools, false);
1436 for grant in &mut self.tool_policy.grants {
1437 grant.channel = grant
1438 .channel
1439 .take()
1440 .map(|value| value.trim().to_ascii_lowercase())
1441 .filter(|value| !value.is_empty());
1442 grant.principal = grant
1443 .principal
1444 .take()
1445 .map(|value| value.trim().to_ascii_lowercase())
1446 .filter(|value| !value.is_empty());
1447 normalize_string_list(&mut grant.allow_tools, false);
1448 normalize_string_list(&mut grant.deny_tools, false);
1449 }
1450 if self.web_host.trim().is_empty() {
1451 self.web_host = default_web_host();
1452 }
1453 self.a2a.public_base_url = self
1454 .a2a
1455 .public_base_url
1456 .as_ref()
1457 .map(|v| v.trim().trim_end_matches('/').to_string())
1458 .filter(|v| !v.is_empty());
1459 self.a2a.agent_name = self
1460 .a2a
1461 .agent_name
1462 .as_ref()
1463 .map(|v| v.trim().to_string())
1464 .filter(|v| !v.is_empty());
1465 self.a2a.agent_description = self
1466 .a2a
1467 .agent_description
1468 .as_ref()
1469 .map(|v| v.trim().to_string())
1470 .filter(|v| !v.is_empty());
1471 self.a2a.shared_tokens = self
1472 .a2a
1473 .shared_tokens
1474 .drain(..)
1475 .map(|v| v.trim().to_string())
1476 .filter(|v| !v.is_empty())
1477 .collect();
1478 self.a2a.peers = self
1479 .a2a
1480 .peers
1481 .drain()
1482 .filter_map(|(name, mut peer)| {
1483 let normalized = name.trim().to_ascii_lowercase();
1484 if normalized.is_empty() {
1485 return None;
1486 }
1487 peer.base_url = peer.base_url.trim().trim_end_matches('/').to_string();
1488 if peer.base_url.is_empty() {
1489 return None;
1490 }
1491 peer.bearer_token = peer
1492 .bearer_token
1493 .as_ref()
1494 .map(|v| v.trim().to_string())
1495 .filter(|v| !v.is_empty());
1496 peer.description = peer
1497 .description
1498 .as_ref()
1499 .map(|v| v.trim().to_string())
1500 .filter(|v| !v.is_empty());
1501 peer.default_session_key = peer
1502 .default_session_key
1503 .as_ref()
1504 .map(|v| v.trim().to_string())
1505 .filter(|v| !v.is_empty());
1506 Some((normalized, peer))
1507 })
1508 .collect();
1509 if let Some(provider) = &self.embedding_provider {
1510 let p = provider.trim().to_lowercase();
1511 self.embedding_provider = if p.is_empty() { None } else { Some(p) };
1512 }
1513 if let Some(v) = &self.embedding_api_key {
1514 if v.trim().is_empty() {
1515 self.embedding_api_key = None;
1516 }
1517 }
1518 if let Some(v) = &self.embedding_base_url {
1519 if v.trim().is_empty() {
1520 self.embedding_base_url = None;
1521 }
1522 }
1523 if let Some(v) = &self.embedding_model {
1524 let m = v.trim().to_string();
1525 self.embedding_model = if m.is_empty() { None } else { Some(m) };
1526 }
1527 if let Some(v) = self.embedding_dim {
1528 if v == 0 {
1529 self.embedding_dim = None;
1530 }
1531 }
1532 if let Some(web_cfg) = self
1533 .channels
1534 .get_mut("web")
1535 .and_then(|v| v.as_mapping_mut())
1536 {
1537 if web_cfg
1538 .remove(serde_yaml::Value::String("auth_token".to_string()))
1539 .is_some()
1540 {
1541 warn!(
1542 "Deprecated `channels.web.auth_token` detected and ignored. \
1543Use operator password + API keys for Web auth."
1544 );
1545 }
1546 }
1547 if self.web_max_inflight_per_session == 0 {
1548 self.web_max_inflight_per_session = default_web_max_inflight_per_session();
1549 }
1550 if self.web_max_requests_per_window == 0 {
1551 self.web_max_requests_per_window = default_web_max_requests_per_window();
1552 }
1553 if self.web_rate_window_seconds == 0 {
1554 self.web_rate_window_seconds = default_web_rate_window_seconds();
1555 }
1556 if self.web_run_history_limit == 0 {
1557 self.web_run_history_limit = default_web_run_history_limit();
1558 }
1559 if self.web_session_idle_ttl_seconds == 0 {
1560 self.web_session_idle_ttl_seconds = default_web_session_idle_ttl_seconds();
1561 }
1562 self.web_fetch_validation.normalize();
1563 self.web_fetch_url_validation.normalize();
1564 self.web_search.normalize();
1565 if self.max_document_size_mb == 0 {
1566 self.max_document_size_mb = default_max_document_size_mb();
1567 }
1568 if self.default_tool_timeout_secs == 0 {
1569 self.default_tool_timeout_secs = default_tool_timeout_secs();
1570 }
1571 if self.default_mcp_request_timeout_secs == 0 {
1572 self.default_mcp_request_timeout_secs = default_mcp_request_timeout_secs();
1573 }
1574 if self.subagents.max_concurrent == 0 {
1575 self.subagents.max_concurrent = default_subagent_max_concurrent();
1576 }
1577 if self.subagents.max_active_per_chat == 0 {
1578 self.subagents.max_active_per_chat = default_subagent_max_active_per_chat();
1579 }
1580 if self.subagents.run_timeout_secs == 0 {
1581 self.subagents.run_timeout_secs = default_subagent_run_timeout_secs();
1582 }
1583 if self.subagents.max_spawn_depth == 0 {
1584 self.subagents.max_spawn_depth = default_subagent_max_spawn_depth();
1585 }
1586 self.subagents.max_spawn_depth = self.subagents.max_spawn_depth.min(5);
1587 if self.subagents.max_children_per_run == 0 {
1588 self.subagents.max_children_per_run = default_subagent_max_children_per_run();
1589 }
1590 if self.subagents.announce_relay_interval_secs == 0 {
1591 self.subagents.announce_relay_interval_secs =
1592 default_subagent_announce_relay_interval_secs();
1593 }
1594 self.subagents.announce_relay_interval_secs =
1595 self.subagents.announce_relay_interval_secs.clamp(1, 300);
1596 if self.subagents.max_tokens_per_run <= 0 {
1597 self.subagents.max_tokens_per_run = default_subagent_max_tokens_per_run();
1598 }
1599 self.subagents.max_tokens_per_run =
1600 self.subagents.max_tokens_per_run.clamp(2_000, 2_000_000);
1601 if self.subagents.orchestrate_max_workers == 0 {
1602 self.subagents.orchestrate_max_workers = default_subagent_orchestrate_max_workers();
1603 }
1604 self.subagents.orchestrate_max_workers =
1605 self.subagents.orchestrate_max_workers.clamp(1, 12);
1606 self.subagents.acp.normalize();
1607 self.tool_timeout_overrides = self
1608 .tool_timeout_overrides
1609 .drain()
1610 .filter_map(|(name, timeout_secs)| {
1611 let normalized = name.trim().to_ascii_lowercase();
1612 if normalized.is_empty() || timeout_secs == 0 {
1613 None
1614 } else {
1615 Some((normalized, timeout_secs))
1616 }
1617 })
1618 .collect();
1619 self.openai_compat_body_overrides =
1620 normalize_body_override_params(std::mem::take(&mut self.openai_compat_body_overrides));
1621 self.openai_compat_body_overrides_by_provider = self
1622 .openai_compat_body_overrides_by_provider
1623 .drain()
1624 .filter_map(|(provider, params)| {
1625 let provider = provider.trim().to_ascii_lowercase();
1626 if provider.is_empty() {
1627 return None;
1628 }
1629 let params = normalize_body_override_params(params);
1630 if params.is_empty() {
1631 None
1632 } else {
1633 Some((provider, params))
1634 }
1635 })
1636 .collect();
1637 self.openai_compat_body_overrides_by_model = self
1638 .openai_compat_body_overrides_by_model
1639 .drain()
1640 .filter_map(|(model, params)| {
1641 let model = model.trim().to_string();
1642 if model.is_empty() {
1643 return None;
1644 }
1645 let params = normalize_body_override_params(params);
1646 if params.is_empty() {
1647 None
1648 } else {
1649 Some((model, params))
1650 }
1651 })
1652 .collect();
1653 if self.memory_token_budget == 0 {
1654 self.memory_token_budget = default_memory_token_budget();
1655 }
1656 for price in &mut self.model_prices {
1657 price.model = price.model.trim().to_string();
1658 if price.model.is_empty() {
1659 return Err(MicroClawError::Config(
1660 "model_prices entries must include non-empty model".into(),
1661 ));
1662 }
1663 if !(price.input_per_million_usd.is_finite() && price.input_per_million_usd >= 0.0) {
1664 return Err(MicroClawError::Config(format!(
1665 "model_prices[{}].input_per_million_usd must be >= 0",
1666 price.model
1667 )));
1668 }
1669 if !(price.output_per_million_usd.is_finite() && price.output_per_million_usd >= 0.0) {
1670 return Err(MicroClawError::Config(format!(
1671 "model_prices[{}].output_per_million_usd must be >= 0",
1672 price.model
1673 )));
1674 }
1675 }
1676
1677 if self.channels.is_empty() {
1679 if !self.telegram_bot_token.trim().is_empty() {
1680 self.channels.insert(
1681 "telegram".into(),
1682 serde_yaml::to_value(serde_json::json!({
1683 "enabled": true,
1684 "bot_token": self.telegram_bot_token,
1685 "bot_username": self.bot_username,
1686 "allowed_groups": self.allowed_groups,
1687 }))
1688 .unwrap(),
1689 );
1690 }
1691 if let Some(ref token) = self.discord_bot_token {
1692 if !token.trim().is_empty() {
1693 self.channels.insert(
1694 "discord".into(),
1695 serde_yaml::to_value(serde_json::json!({
1696 "enabled": true,
1697 "bot_token": token,
1698 "allowed_channels": self.discord_allowed_channels,
1699 "no_mention": self.discord_no_mention,
1700 }))
1701 .unwrap(),
1702 );
1703 }
1704 }
1705 if self.web_enabled {
1706 self.channels.insert(
1707 "web".into(),
1708 serde_yaml::to_value(serde_json::json!({
1709 "enabled": true,
1710 "host": self.web_host,
1711 "port": self.web_port,
1712 }))
1713 .unwrap(),
1714 );
1715 }
1716 }
1717
1718 let (enabled_channels, configured_but_disabled) = self.channel_status();
1720 if enabled_channels.is_empty() {
1721 let hint = if configured_but_disabled.is_empty() {
1722 String::new()
1723 } else {
1724 format!(
1725 " These channels are configured but not enabled: {}. \
1726 Set `channels.<name>.enabled: true` (or run `microclaw setup`).",
1727 configured_but_disabled.join(", ")
1728 )
1729 };
1730 return Err(MicroClawError::Config(format!(
1731 "At least one channel must be enabled and configured (via channels.<name>.enabled or legacy channel settings).{hint}"
1732 )));
1733 }
1734 if self.api_key.is_empty() && !provider_allows_empty_api_key(&self.llm_provider) {
1735 return Err(MicroClawError::Config("api_key is required".into()));
1736 }
1737 if is_openai_codex_provider(&self.llm_provider) {
1738 if !self.api_key.trim().is_empty() {
1739 return Err(MicroClawError::Config(
1740 "openai-codex ignores microclaw.config.yaml api_key. Configure ~/.codex/auth.json or run `codex login` instead.".into(),
1741 ));
1742 }
1743 if self
1744 .llm_base_url
1745 .as_ref()
1746 .map(|v| !v.trim().is_empty())
1747 .unwrap_or(false)
1748 {
1749 return Err(MicroClawError::Config(
1750 "openai-codex ignores microclaw.config.yaml llm_base_url. Configure ~/.codex/config.toml instead.".into(),
1751 ));
1752 }
1753 let has_codex_auth = codex_auth_file_has_access_token()?;
1754 if !has_codex_auth {
1755 return Err(MicroClawError::Config(
1756 "openai-codex requires ~/.codex/auth.json (access token or OPENAI_API_KEY), or OPENAI_CODEX_ACCESS_TOKEN. Run `codex login` or update Codex config files.".into(),
1757 ));
1758 }
1759 }
1760 if is_qwen_portal_provider(&self.llm_provider) && self.api_key.trim().is_empty() {
1761 let has_qwen_auth = qwen_oauth_file_has_access_token()?;
1762 if !has_qwen_auth {
1763 return Err(MicroClawError::Config(
1764 "qwen-portal requires api_key, or ~/.qwen/oauth_creds.json (access_token), or QWEN_PORTAL_ACCESS_TOKEN.".into(),
1765 ));
1766 }
1767 }
1768
1769 self.validate_configured_egress()?;
1770
1771 Ok(())
1772 }
1773
1774 fn validate_configured_egress(&self) -> Result<(), MicroClawError> {
1775 if self.egress_policy.mode == EgressPolicyMode::Off {
1776 return Ok(());
1777 }
1778 for url in self.configured_egress_endpoints() {
1779 let decision =
1780 crate::internal::tool_runtime::egress::evaluate_url(&self.egress_policy, &url);
1781 match decision {
1782 crate::internal::tool_runtime::egress::EgressDecision::Warn(reason) => {
1783 warn!(url = %url, reason = %reason, "configured endpoint violates egress policy");
1784 }
1785 crate::internal::tool_runtime::egress::EgressDecision::Block(reason) => {
1786 return Err(MicroClawError::Config(format!(
1787 "configured endpoint `{url}` blocked by egress policy: {reason}"
1788 )));
1789 }
1790 crate::internal::tool_runtime::egress::EgressDecision::Allow => {}
1791 }
1792 }
1793 Ok(())
1794 }
1795
1796 fn configured_egress_endpoints(&self) -> Vec<String> {
1797 let mut urls = Vec::new();
1798 {
1799 let mut add = |value: Option<&str>| {
1800 if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
1801 urls.push(value.to_string());
1802 }
1803 };
1804
1805 add(self.llm_base_url.as_deref());
1806 for profile in self
1807 .provider_presets
1808 .values()
1809 .chain(self.llm_providers.values())
1810 {
1811 add(profile.llm_base_url.as_deref());
1812 }
1813 add(self.embedding_base_url.as_deref());
1814 add(self.openai_base_url.as_deref());
1815
1816 let media_enabled = self.media.image_gen.enabled
1817 || self.media.vision.enabled
1818 || self.media.tts.enabled
1819 || self.media.stt.enabled
1820 || self.media.podcast.enabled;
1821 if media_enabled {
1822 add(self.media.base_url.as_deref());
1823 }
1824 if self.media.tts.enabled || self.media.podcast.enabled {
1825 add(self.media.tts.base_url.as_deref());
1826 }
1827 if self.media.podcast.enabled {
1828 add(self.media.podcast.base_url.as_deref());
1829 }
1830
1831 if self.web_search.effective_backend()
1832 == crate::internal::tool_runtime::web_search::SearchBackend::Searxng
1833 {
1834 add(Some(&self.web_search.searxng_base_url));
1835 }
1836 for peer in self.a2a.peers.values().filter(|peer| peer.enabled) {
1837 add(Some(&peer.base_url));
1838 }
1839 if self.clawhub.agent_tools_enabled {
1840 add(Some(&self.clawhub.registry));
1841 }
1842 if self.alerts.enabled {
1843 add(Some(&self.alerts.webhook_url));
1844 }
1845 }
1846
1847 for channel in self.channels.values() {
1848 collect_enabled_channel_endpoints(channel, &mut urls);
1849 }
1850
1851 urls.sort();
1852 urls.dedup();
1853 urls
1854 }
1855
1856 fn merged_profile_from_alias(&self, alias: &str) -> Option<ResolvedLlmProviderProfile> {
1857 let alias = alias.trim().to_ascii_lowercase();
1858 if alias.is_empty() {
1859 return None;
1860 }
1861 if alias == self.llm_provider {
1862 let mut provider = self.llm_provider.clone();
1863 let mut api_key = self.api_key.clone();
1864 let mut api_keys = self.api_keys.clone();
1865 let mut llm_base_url = self.llm_base_url.clone();
1866 let mut llm_user_agent = self.llm_user_agent.clone();
1867 let mut default_model = self.model.clone();
1868 let mut models = vec![default_model.clone()];
1869 let mut show_thinking = self.show_thinking;
1870 if let Some(profile) = self.llm_providers.get(&alias) {
1871 if let Some(v) = &profile.provider {
1872 provider = v.clone();
1873 }
1874 if let Some(v) = &profile.api_key {
1875 api_key = v.clone();
1876 }
1877 if !profile.api_keys.is_empty() {
1878 api_keys = profile.api_keys.clone();
1879 }
1880 if let Some(v) = &profile.llm_base_url {
1881 llm_base_url = Some(v.clone());
1882 }
1883 if let Some(v) = &profile.llm_user_agent {
1884 llm_user_agent = v.clone();
1885 }
1886 if let Some(v) = &profile.default_model {
1887 default_model = v.clone();
1888 }
1889 if !profile.models.is_empty() {
1890 models = profile.models.clone();
1891 }
1892 if let Some(v) = profile.show_thinking {
1893 show_thinking = v;
1894 }
1895 }
1896 default_model = resolve_model_name_with_fallback(
1897 &provider,
1898 Some(&default_model),
1899 Some(&self.model),
1900 );
1901 models = models
1902 .into_iter()
1903 .filter_map(|model| normalize_model_name(&model))
1904 .collect();
1905 if !models.iter().any(|m| m == &default_model) {
1906 models.push(default_model.clone());
1907 }
1908 models.sort();
1909 models.dedup();
1910 return Some(ResolvedLlmProviderProfile {
1911 alias,
1912 provider,
1913 api_key,
1914 api_keys,
1915 llm_base_url,
1916 llm_user_agent,
1917 default_model,
1918 models,
1919 show_thinking,
1920 });
1921 }
1922
1923 let profile = self.llm_providers.get(&alias)?;
1924 let provider = profile.provider.clone().unwrap_or_else(|| alias.clone());
1925 let api_key = profile
1926 .api_key
1927 .clone()
1928 .unwrap_or_else(|| self.api_key.clone());
1929 let api_keys = if !profile.api_keys.is_empty() {
1931 profile.api_keys.clone()
1932 } else if profile.api_key.is_none() {
1933 self.api_keys.clone()
1934 } else {
1935 Vec::new()
1936 };
1937 let llm_base_url = profile
1938 .llm_base_url
1939 .clone()
1940 .or_else(|| self.llm_base_url.clone());
1941 let llm_user_agent = profile
1942 .llm_user_agent
1943 .clone()
1944 .unwrap_or_else(|| self.llm_user_agent.clone());
1945 let default_model = resolve_model_name_with_fallback(
1946 &provider,
1947 profile.default_model.as_deref(),
1948 Some(&self.model),
1949 );
1950 let mut models = if profile.models.is_empty() {
1951 vec![default_model.clone()]
1952 } else {
1953 profile
1954 .models
1955 .clone()
1956 .into_iter()
1957 .filter_map(|model| normalize_model_name(&model))
1958 .collect()
1959 };
1960 let show_thinking = profile.show_thinking.unwrap_or(self.show_thinking);
1961 if !models.iter().any(|m| m == &default_model) {
1962 models.push(default_model.clone());
1963 }
1964 models.sort();
1965 models.dedup();
1966 Some(ResolvedLlmProviderProfile {
1967 alias,
1968 provider,
1969 api_key,
1970 api_keys,
1971 llm_base_url,
1972 llm_user_agent,
1973 default_model,
1974 models,
1975 show_thinking,
1976 })
1977 }
1978
1979 pub fn resolve_llm_provider_profile(&self, alias: &str) -> Option<ResolvedLlmProviderProfile> {
1980 self.merged_profile_from_alias(alias)
1981 }
1982
1983 pub fn list_llm_provider_profiles(&self) -> Vec<ResolvedLlmProviderProfile> {
1984 let mut out = Vec::new();
1985 if let Some(default_profile) = self.resolve_llm_provider_profile(&self.llm_provider) {
1986 out.push(default_profile);
1987 }
1988 let mut aliases: Vec<String> = self.llm_providers.keys().cloned().collect();
1989 aliases.sort();
1990 aliases.dedup();
1991 for alias in aliases {
1992 if alias == self.llm_provider {
1993 continue;
1994 }
1995 if let Some(profile) = self.resolve_llm_provider_profile(&alias) {
1996 out.push(profile);
1997 }
1998 }
1999 out
2000 }
2001
2002 pub fn channel_config<T: DeserializeOwned>(&self, name: &str) -> Option<T> {
2004 self.channels
2005 .get(name)
2006 .and_then(|v| serde_yaml::from_value(v.clone()).ok())
2007 }
2008
2009 pub fn model_price(&self, model: &str) -> Option<&ModelPrice> {
2010 let needle = model.trim();
2011 self.model_prices
2012 .iter()
2013 .find(|p| p.model.eq_ignore_ascii_case(needle))
2014 .or_else(|| self.model_prices.iter().find(|p| p.model == "*"))
2015 }
2016
2017 pub fn estimate_cost_usd(
2018 &self,
2019 model: &str,
2020 input_tokens: i64,
2021 output_tokens: i64,
2022 ) -> Option<f64> {
2023 let price = self.model_price(model)?;
2024 let in_tok = input_tokens.max(0) as f64;
2025 let out_tok = output_tokens.max(0) as f64;
2026 Some(
2027 (in_tok / 1_000_000.0) * price.input_per_million_usd
2028 + (out_tok / 1_000_000.0) * price.output_per_million_usd,
2029 )
2030 }
2031
2032 pub fn tool_timeout_secs(&self, tool_name: &str, fallback: u64) -> u64 {
2033 let normalized = tool_name.trim().to_ascii_lowercase();
2034 if let Some(timeout_secs) = self.tool_timeout_overrides.get(&normalized) {
2035 return *timeout_secs;
2036 }
2037 if self.default_tool_timeout_secs == 0 {
2038 fallback
2039 } else {
2040 self.default_tool_timeout_secs
2041 }
2042 }
2043
2044 pub fn mcp_request_timeout_secs(&self) -> u64 {
2045 if self.default_mcp_request_timeout_secs == 0 {
2046 default_mcp_request_timeout_secs()
2047 } else {
2048 self.default_mcp_request_timeout_secs
2049 }
2050 }
2051
2052 #[allow(dead_code)]
2054 pub fn save_yaml(&self, path: &str) -> Result<(), MicroClawError> {
2055 let content = serde_yaml::to_string(self)
2056 .map_err(|e| MicroClawError::Config(format!("Failed to serialize config: {e}")))?;
2057 std::fs::write(path, content)?;
2058 Ok(())
2059 }
2060}
2061
2062pub(crate) fn normalize_body_override_params(
2063 params: HashMap<String, serde_json::Value>,
2064) -> HashMap<String, serde_json::Value> {
2065 params
2066 .into_iter()
2067 .filter_map(|(key, value)| {
2068 let key = key.trim().to_string();
2069 if key.is_empty() {
2070 None
2071 } else {
2072 Some((key, value))
2073 }
2074 })
2075 .collect()
2076}
2077
2078pub(crate) fn normalize_provider_profiles(
2079 profiles: HashMap<String, LlmProviderProfile>,
2080) -> HashMap<String, LlmProviderProfile> {
2081 profiles
2082 .into_iter()
2083 .filter_map(|(alias, mut profile)| {
2084 let alias = alias.trim().to_ascii_lowercase();
2085 if alias.is_empty() {
2086 return None;
2087 }
2088 profile.provider = profile
2089 .provider
2090 .as_ref()
2091 .map(|v| v.trim().to_ascii_lowercase())
2092 .filter(|v| !v.is_empty());
2093 profile.api_key = profile
2094 .api_key
2095 .as_ref()
2096 .map(|v| v.trim().to_string())
2097 .filter(|v| !v.is_empty());
2098 profile.llm_base_url = profile
2099 .llm_base_url
2100 .as_ref()
2101 .map(|v| v.trim().to_string())
2102 .filter(|v| !v.is_empty());
2103 profile.llm_user_agent = profile
2104 .llm_user_agent
2105 .as_ref()
2106 .map(|v| v.trim().to_string())
2107 .filter(|v| !v.is_empty());
2108 profile.default_model = profile
2109 .default_model
2110 .as_ref()
2111 .and_then(|v| normalize_model_name(v));
2112 profile.models = profile
2113 .models
2114 .into_iter()
2115 .filter_map(|m| normalize_model_name(&m))
2116 .collect::<Vec<_>>();
2117 profile.models.sort();
2118 profile.models.dedup();
2119 Some((alias, profile))
2120 })
2121 .collect()
2122}
2123
2124pub(crate) fn migrate_model_override_alias_to_provider_preset(
2125 value: &mut serde_yaml::Value,
2126 known_profiles: &HashMap<String, LlmProviderProfile>,
2127) {
2128 let Some(map) = value.as_mapping_mut() else {
2129 return;
2130 };
2131
2132 let provider_preset_key = serde_yaml::Value::String("provider_preset".to_string());
2133 let llm_provider_key = serde_yaml::Value::String("llm_provider".to_string());
2134 let model_key = serde_yaml::Value::String("model".to_string());
2135
2136 let has_provider_override =
2137 map.contains_key(&provider_preset_key) || map.contains_key(&llm_provider_key);
2138 if !has_provider_override {
2139 let maybe_profile_alias = map
2140 .get(&model_key)
2141 .and_then(|v| v.as_str())
2142 .map(str::trim)
2143 .filter(|v| !v.is_empty())
2144 .map(|v| v.to_ascii_lowercase());
2145 if let Some(alias) = maybe_profile_alias {
2146 if known_profiles.contains_key(&alias) {
2147 map.insert(
2148 provider_preset_key.clone(),
2149 serde_yaml::Value::String(alias),
2150 );
2151 map.remove(&model_key);
2152 }
2153 }
2154 }
2155
2156 let accounts_key = serde_yaml::Value::String("accounts".to_string());
2157 if let Some(accounts) = map.get_mut(&accounts_key).and_then(|v| v.as_mapping_mut()) {
2158 for (_, account) in accounts.iter_mut() {
2159 migrate_model_override_alias_to_provider_preset(account, known_profiles);
2160 }
2161 }
2162}
2163
2164pub(crate) fn merge_provider_profile(
2165 base: LlmProviderProfile,
2166 override_profile: LlmProviderProfile,
2167) -> LlmProviderProfile {
2168 let mut models = if override_profile.models.is_empty() {
2169 base.models
2170 } else {
2171 override_profile.models
2172 };
2173 models.sort();
2174 models.dedup();
2175
2176 LlmProviderProfile {
2177 provider: override_profile.provider.or(base.provider),
2178 api_key: override_profile.api_key.or(base.api_key),
2179 api_keys: if override_profile.api_keys.is_empty() {
2180 base.api_keys
2181 } else {
2182 override_profile.api_keys
2183 },
2184 llm_base_url: override_profile.llm_base_url.or(base.llm_base_url),
2185 llm_user_agent: override_profile.llm_user_agent.or(base.llm_user_agent),
2186 default_model: override_profile.default_model.or(base.default_model),
2187 models,
2188 show_thinking: override_profile.show_thinking.or(base.show_thinking),
2189 }
2190}
2191
2192#[derive(Debug, Default)]
2195pub struct ConfigCheckReport {
2196 pub errors: Vec<String>,
2197 pub warnings: Vec<String>,
2198 pub summary: Option<(Vec<&'static str>, Vec<&'static str>, String, String)>,
2201}
2202
2203impl ConfigCheckReport {
2204 pub fn ok(&self) -> bool {
2205 self.errors.is_empty()
2206 }
2207}
2208
2209pub fn check_config_content(path_str: &str, content: &str) -> ConfigCheckReport {
2214 let mut report = ConfigCheckReport::default();
2215
2216 let raw: serde_yaml::Value = match serde_yaml::from_str(content) {
2218 Ok(v) => v,
2219 Err(e) => {
2220 report.errors.push(format!("YAML syntax error: {e}"));
2221 return report;
2222 }
2223 };
2224
2225 if let Some(map) = raw.as_mapping() {
2227 report.warnings.extend(unknown_top_level_key_warnings(map));
2228 } else {
2229 report
2230 .errors
2231 .push("Config root must be a YAML mapping (key: value pairs).".to_string());
2232 return report;
2233 }
2234
2235 match serde_yaml::from_str::<Config>(content) {
2238 Err(e) => {
2239 report.errors.push(friendly_yaml_error(path_str, &e));
2240 }
2241 Ok(mut config) => match config.post_deserialize() {
2242 Err(e) => report.errors.push(e.to_string()),
2244 Ok(()) => {
2245 let (enabled, disabled) = config.channel_status();
2246 report.summary = Some((
2247 enabled,
2248 disabled,
2249 config.llm_provider.clone(),
2250 config.model.clone(),
2251 ));
2252 }
2253 },
2254 }
2255 report
2256}
2257
2258pub fn run_config_check() -> i32 {
2262 let path = match Config::resolve_config_path() {
2263 Ok(Some(p)) => p,
2264 Ok(None) => {
2265 eprintln!("✗ No microclaw.config.yaml found. Run `microclaw setup` to create one.");
2266 return 2;
2267 }
2268 Err(e) => {
2269 eprintln!("✗ {e}");
2270 return 2;
2271 }
2272 };
2273 let path_str = path.to_string_lossy().to_string();
2274 let content = match std::fs::read_to_string(&path) {
2275 Ok(c) => c,
2276 Err(e) => {
2277 eprintln!("✗ Failed to read {path_str}: {e}");
2278 return 2;
2279 }
2280 };
2281
2282 println!("Checking {path_str}");
2283 let report = check_config_content(&path_str, &content);
2284 for w in &report.warnings {
2285 println!(" ⚠ {w}");
2286 }
2287 for e in &report.errors {
2288 println!(" ✗ {e}");
2289 }
2290 if let Some((enabled, disabled, provider, model)) = &report.summary {
2291 let enabled_str = if enabled.is_empty() {
2292 "none".to_string()
2293 } else {
2294 enabled.join(", ")
2295 };
2296 println!(" provider: {provider} · model: {model}");
2297 println!(" channels enabled: {enabled_str}");
2298 if !disabled.is_empty() {
2299 println!(" configured but disabled: {}", disabled.join(", "));
2300 }
2301 }
2302 if report.ok() {
2303 println!(
2304 "✓ Config OK{}",
2305 if report.warnings.is_empty() {
2306 ""
2307 } else {
2308 " (with warnings)"
2309 }
2310 );
2311 0
2312 } else {
2313 println!("✗ Config has errors — fix them and re-run, or run `microclaw setup`.");
2314 1
2315 }
2316}
2317
2318pub(crate) fn friendly_yaml_error(path_str: &str, err: &serde_yaml::Error) -> String {
2323 let raw = err.to_string();
2324 let mut hint = String::new();
2325 if let Some((kind, token)) = extract_unknown_token(&raw) {
2326 match suggest_closest(token, &extract_backtick_options(&raw)) {
2327 Some(best) => {
2328 hint = format!("\n\n Unknown {kind} `{token}` — did you mean `{best}`?");
2329 }
2330 None => hint = format!("\n\n Unknown {kind} `{token}`."),
2331 }
2332 }
2333 format!(
2334 "Failed to parse {path_str}: {raw}{hint}\n\n \
2335 Edit the config and re-run, or run `microclaw setup`.\n \
2336 Reference: https://github.com/microclaw/microclaw/blob/main/microclaw.config.example.yaml"
2337 )
2338}
2339
2340pub(crate) fn known_top_level_config_keys() -> Option<std::collections::BTreeSet<String>> {
2346 let base: Config = serde_yaml::from_str("{}").ok()?;
2347 let serde_yaml::Value::Mapping(serialized) = serde_yaml::to_value(&base).ok()? else {
2348 return None;
2349 };
2350 let mut known: std::collections::BTreeSet<String> = serialized
2351 .keys()
2352 .filter_map(|k| k.as_str().map(str::to_string))
2353 .collect();
2354 for k in ["timezone", "override_timezone", "web_auth_token"] {
2357 known.insert(k.to_string());
2358 }
2359 if known.len() < 5 {
2362 return None;
2363 }
2364 Some(known)
2365}
2366
2367pub(crate) fn unknown_top_level_key_warnings(map: &serde_yaml::Mapping) -> Vec<String> {
2372 let Some(known) = known_top_level_config_keys() else {
2373 return Vec::new();
2374 };
2375 let candidates: Vec<String> = known.iter().cloned().collect();
2376 let mut warnings = Vec::new();
2377 for key in map.keys() {
2378 let Some(name) = key.as_str() else { continue };
2379 if known.contains(name) {
2380 continue;
2381 }
2382 match suggest_closest(name, &candidates) {
2383 Some(best) => warnings.push(format!(
2384 "Unknown config key `{name}` (ignored) — did you mean `{best}`?"
2385 )),
2386 None => warnings.push(format!("Unknown config key `{name}` (ignored).")),
2387 }
2388 }
2389 warnings
2390}
2391
2392pub(crate) fn extract_unknown_token(raw: &str) -> Option<(&'static str, &str)> {
2394 for (needle, kind) in [
2395 ("unknown variant `", "variant"),
2396 ("unknown field `", "field"),
2397 ] {
2398 if let Some(start) = raw.find(needle) {
2399 let rest = &raw[start + needle.len()..];
2400 if let Some(end) = rest.find('`') {
2401 return Some((kind, &rest[..end]));
2402 }
2403 }
2404 }
2405 None
2406}
2407
2408pub(crate) fn extract_backtick_options(raw: &str) -> Vec<String> {
2410 let Some(exp) = raw.find("expected") else {
2411 return Vec::new();
2412 };
2413 let mut out = Vec::new();
2414 let mut rest = &raw[exp..];
2415 while let Some(open) = rest.find('`') {
2416 rest = &rest[open + 1..];
2417 if let Some(close) = rest.find('`') {
2418 out.push(rest[..close].to_string());
2419 rest = &rest[close + 1..];
2420 } else {
2421 break;
2422 }
2423 }
2424 out
2425}
2426
2427pub(crate) fn suggest_closest(token: &str, options: &[String]) -> Option<String> {
2430 let threshold = (token.chars().count() / 3).max(2);
2431 options
2432 .iter()
2433 .map(|o| (levenshtein(token, o), o))
2434 .filter(|(d, _)| *d <= threshold)
2435 .min_by_key(|(d, _)| *d)
2436 .map(|(_, o)| o.clone())
2437}
2438
2439pub(crate) fn levenshtein(a: &str, b: &str) -> usize {
2441 let a: Vec<char> = a.chars().collect();
2442 let b: Vec<char> = b.chars().collect();
2443 let mut prev: Vec<usize> = (0..=b.len()).collect();
2444 let mut cur = vec![0usize; b.len() + 1];
2445 for (i, ca) in a.iter().enumerate() {
2446 cur[0] = i + 1;
2447 for (j, cb) in b.iter().enumerate() {
2448 let cost = if ca == cb { 0 } else { 1 };
2449 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
2450 }
2451 std::mem::swap(&mut prev, &mut cur);
2452 }
2453 prev[b.len()]
2454}
2455
2456#[cfg(test)]
2457mod tests {
2458 use super::*;
2459 #[allow(unused_imports)]
2460 use crate::config::test_prelude::*;
2461
2462 #[test]
2463 fn config_check_reports_syntax_errors_with_location() {
2464 let report = check_config_content("t.yaml", "telegram_bot_token: [unclosed");
2465 assert!(!report.ok());
2466 assert!(report.errors[0].contains("YAML syntax error"));
2467 assert!(report.summary.is_none());
2468 }
2469
2470 #[test]
2471 fn config_check_warns_on_unknown_key_with_suggestion() {
2472 let report = check_config_content(
2473 "t.yaml",
2474 "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nmax_tool_iterationz: 5\n",
2475 );
2476 assert!(report.ok(), "unknown keys warn but do not fail the check");
2477 assert!(
2478 report
2479 .warnings
2480 .iter()
2481 .any(|w| w.contains("max_tool_iterationz") && w.contains("max_tool_iterations")),
2482 "expected did-you-mean warning, got: {:?}",
2483 report.warnings
2484 );
2485 let (enabled, _, provider, _) = report.summary.as_ref().unwrap();
2486 assert!(enabled.contains(&"telegram"));
2487 assert_eq!(provider, "anthropic");
2488 }
2489
2490 #[test]
2491 fn config_check_rejects_bad_enum_value_with_suggestion() {
2492 let report = check_config_content("t.yaml", "api_key: key\ntool_policy:\n mode: blok\n");
2493 assert!(!report.ok());
2494 assert!(
2495 report.errors[0].contains("blok") && report.errors[0].contains("block"),
2496 "expected did-you-mean on enum typo, got: {:?}",
2497 report.errors
2498 );
2499 }
2500
2501 #[test]
2502 fn config_check_flags_no_enabled_channel() {
2503 let report = check_config_content("t.yaml", "api_key: key\n");
2505 assert!(report.ok(), "errors: {:?}", report.errors);
2506 let (enabled, ..) = report.summary.as_ref().unwrap();
2507 assert!(enabled.contains(&"web"));
2508
2509 let report = check_config_content(
2512 "t.yaml",
2513 "api_key: key\nchannels:\n web:\n enabled: false\n",
2514 );
2515 assert!(
2516 !report.ok(),
2517 "disabling all channels should be an error, got warnings: {:?}",
2518 report.warnings
2519 );
2520 }
2521
2522 #[test]
2523 fn config_check_rejects_non_mapping_root() {
2524 let report = check_config_content("t.yaml", "- just\n- a\n- list\n");
2525 assert!(!report.ok());
2526 assert!(report.errors[0].contains("mapping"));
2527 }
2528
2529 #[test]
2530 fn friendly_error_suggests_closest_field() {
2531 #[derive(Debug, serde::Deserialize)]
2533 #[serde(deny_unknown_fields)]
2534 struct Mini {
2535 #[allow(dead_code)]
2536 discord: bool,
2537 }
2538 let err = serde_yaml::from_str::<Mini>("discrod: true\n").unwrap_err();
2539 let msg = friendly_yaml_error("cfg.yaml", &err);
2540 assert!(msg.contains("did you mean `discord`?"), "got: {msg}");
2541 assert!(msg.contains("microclaw setup"));
2542 }
2543
2544 #[test]
2545 fn extract_and_suggest_helpers() {
2546 let raw = "unknown variant `discrod`, expected one of `telegram`, `discord`, `slack`";
2547 assert_eq!(extract_unknown_token(raw), Some(("variant", "discrod")));
2548 let opts = extract_backtick_options(raw);
2549 assert_eq!(opts, vec!["telegram", "discord", "slack"]);
2550 assert_eq!(
2551 suggest_closest("discrod", &opts).as_deref(),
2552 Some("discord")
2553 );
2554 assert_eq!(suggest_closest("zzzzzz", &opts), None);
2556 }
2557
2558 #[test]
2559 fn levenshtein_basics() {
2560 assert_eq!(levenshtein("kitten", "sitting"), 3);
2561 assert_eq!(levenshtein("discord", "discord"), 0);
2562 assert_eq!(levenshtein("", "abc"), 3);
2563 }
2564
2565 #[test]
2566 fn channel_status_splits_enabled_and_disabled() {
2567 let cfg: Config = serde_yaml::from_str(
2568 "api_key: k\nweb_enabled: false\nchannels:\n telegram:\n enabled: true\n discord:\n enabled: false\n",
2569 )
2570 .unwrap();
2571 let (enabled, disabled) = cfg.channel_status();
2572 assert_eq!(enabled, vec!["telegram"]);
2573 assert_eq!(disabled, vec!["discord"]);
2574 }
2575
2576 #[test]
2577 fn no_enabled_channel_error_names_disabled_channel() {
2578 let mut cfg: Config = serde_yaml::from_str(
2579 "api_key: k\nweb_enabled: false\nchannels:\n telegram:\n enabled: false\n",
2580 )
2581 .unwrap();
2582 let err = cfg.post_deserialize().unwrap_err().to_string();
2583 assert!(err.contains("configured but not enabled"), "got: {err}");
2584 assert!(err.contains("telegram"), "got: {err}");
2585 }
2586
2587 #[test]
2588 fn aux_models_parse_from_yaml() {
2589 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\naux_models:\n compaction: claude-haiku-4-5\n reflector: claude-haiku-4-5\n";
2590 let config: Config = serde_yaml::from_str(yaml).unwrap();
2591 assert_eq!(
2592 config.aux_models.compaction.as_deref(),
2593 Some("claude-haiku-4-5")
2594 );
2595 assert_eq!(
2596 config.aux_models.reflector_model(),
2597 Some("claude-haiku-4-5")
2598 );
2599 }
2600
2601 #[test]
2602 fn aux_models_absent_yaml_defaults_to_none() {
2603 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
2604 let config: Config = serde_yaml::from_str(yaml).unwrap();
2605 assert!(config.aux_models.compaction.is_none());
2606 }
2607
2608 #[test]
2609 fn aux_models_title_vision_parse_from_yaml() {
2610 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\naux_models:\n title: claude-haiku-4-5\n vision: claude-haiku-4-5\n";
2611 let config: Config = serde_yaml::from_str(yaml).unwrap();
2612 assert_eq!(config.aux_models.title_model(), Some("claude-haiku-4-5"));
2613 assert_eq!(config.aux_models.vision_model(), Some("claude-haiku-4-5"));
2614 }
2615
2616 #[test]
2617 fn test_clawhub_config_defaults() {
2618 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
2619 let config: Config = serde_yaml::from_str(yaml).unwrap();
2620 assert_eq!(config.clawhub.registry, "https://clawhub.ai");
2621 assert!(config.clawhub.agent_tools_enabled);
2622 }
2623
2624 #[test]
2625 fn test_voice_config_defaults() {
2626 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
2627 let config: Config = serde_yaml::from_str(yaml).unwrap();
2628 assert_eq!(config.voice_provider, "openai");
2629 assert!(config.voice_transcription_command.is_none());
2630 }
2631
2632 #[test]
2633 fn test_voice_config_local_provider() {
2634 let yaml = r#"
2635telegram_bot_token: tok
2636bot_username: bot
2637api_key: key
2638voice_provider: "local"
2639voice_transcription_command: "whisper-mlx --file {file}"
2640"#;
2641 let config: Config = serde_yaml::from_str(yaml).unwrap();
2642 assert_eq!(config.voice_provider, "local");
2643 assert_eq!(
2644 config.voice_transcription_command,
2645 Some("whisper-mlx --file {file}".into())
2646 );
2647 }
2648
2649 #[test]
2650 fn test_config_yaml_roundtrip() {
2651 let config = test_config();
2652 let yaml = serde_yaml::to_string(&config).unwrap();
2653 let parsed: Config = serde_yaml::from_str(&yaml).unwrap();
2654 assert_eq!(parsed.telegram_bot_token, "tok");
2655 assert_eq!(parsed.max_tokens, 8192);
2656 assert_eq!(parsed.llm_provider, "anthropic");
2657 }
2658
2659 #[test]
2660 fn test_default_data_dir_uses_microclaw_home() {
2661 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
2662 let config: Config = serde_yaml::from_str(yaml).unwrap();
2663 assert!(config.data_dir.ends_with(".microclaw"));
2664 }
2665
2666 #[test]
2667 fn test_config_sandbox_defaults_to_off() {
2668 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
2669 let config: Config = serde_yaml::from_str(yaml).unwrap();
2670 assert!(matches!(config.sandbox.mode, SandboxMode::Off));
2671 assert!(matches!(config.sandbox.backend, SandboxBackend::Auto));
2672 assert!(config.sandbox.require_runtime);
2673 assert_eq!(config.sandbox.image, "ubuntu:25.10");
2674 }
2675
2676 #[test]
2677 fn test_post_deserialize_empty_working_dir_uses_default() {
2678 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nworking_dir: ' '\n";
2679 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2680 config.post_deserialize().unwrap();
2681 assert!(std::path::PathBuf::from(&config.working_dir)
2682 .ends_with(std::path::Path::new(".microclaw").join("working_dir")));
2683 }
2684
2685 #[test]
2686 fn test_post_deserialize_zero_memory_budget_uses_default() {
2687 let yaml =
2688 "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nmemory_token_budget: 0\n";
2689 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2690 config.post_deserialize().unwrap();
2691 assert_eq!(config.memory_token_budget, 1500);
2692 }
2693
2694 #[test]
2695 fn test_config_working_dir_isolation_defaults_to_chat() {
2696 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
2697 let config: Config = serde_yaml::from_str(yaml).unwrap();
2698 assert!(matches!(
2699 config.working_dir_isolation,
2700 WorkingDirIsolation::Chat
2701 ));
2702 }
2703
2704 #[test]
2705 fn test_config_working_dir_isolation_accepts_chat() {
2706 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nworking_dir_isolation: chat\n";
2707 let config: Config = serde_yaml::from_str(yaml).unwrap();
2708 assert!(matches!(
2709 config.working_dir_isolation,
2710 WorkingDirIsolation::Chat
2711 ));
2712 }
2713
2714 #[test]
2715 fn test_config_working_dir_isolation_accepts_true() {
2716 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nworking_dir_isolation: true\n";
2717 let config: Config = serde_yaml::from_str(yaml).unwrap();
2718 assert!(matches!(
2719 config.working_dir_isolation,
2720 WorkingDirIsolation::Chat
2721 ));
2722 }
2723
2724 #[test]
2725 fn test_config_working_dir_isolation_accepts_false() {
2726 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nworking_dir_isolation: false\n";
2727 let config: Config = serde_yaml::from_str(yaml).unwrap();
2728 assert!(matches!(
2729 config.working_dir_isolation,
2730 WorkingDirIsolation::Shared
2731 ));
2732 }
2733
2734 #[test]
2735 fn test_high_risk_tool_user_confirmation_required_defaults_true() {
2736 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
2737 let config: Config = serde_yaml::from_str(yaml).unwrap();
2738 assert!(config.high_risk_tool_user_confirmation_required);
2739 }
2740
2741 #[test]
2742 fn test_high_risk_tool_user_confirmation_required_accepts_false() {
2743 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nhigh_risk_tool_user_confirmation_required: false\n";
2744 let config: Config = serde_yaml::from_str(yaml).unwrap();
2745 assert!(!config.high_risk_tool_user_confirmation_required);
2746 }
2747
2748 #[test]
2749 fn test_config_post_deserialize() {
2750 let yaml =
2751 "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nllm_provider: ANTHROPIC\n";
2752 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2753 config.post_deserialize().unwrap();
2754 assert_eq!(config.llm_provider, "anthropic");
2755 assert_eq!(config.model, "claude-sonnet-4-5-20250929");
2756 }
2757
2758 #[test]
2759 fn test_post_deserialize_invalid_timezone() {
2760 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\noverride_timezone: Mars/Olympus\n";
2761 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2762 let err = config.post_deserialize().unwrap_err();
2763 let msg = err.to_string();
2764 assert!(msg.contains("Invalid timezone"));
2765 }
2766
2767 #[test]
2768 fn test_post_deserialize_auto_timezone_resolves_to_valid_tz() {
2769 let yaml =
2770 "telegram_bot_token: tok\nbot_username: bot\napi_key: key\noverride_timezone: auto\n";
2771 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2772 config.post_deserialize().unwrap();
2773 assert!(config.timezone.parse::<chrono_tz::Tz>().is_ok());
2774 }
2775
2776 #[test]
2777 fn test_post_deserialize_missing_api_key() {
2778 let yaml = "telegram_bot_token: tok\nbot_username: bot\n";
2779 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2780 let err = config.post_deserialize().unwrap_err();
2781 let msg = err.to_string();
2782 assert!(msg.contains("api_key is required"));
2783 }
2784
2785 #[test]
2786 fn test_post_deserialize_openai_codex_allows_empty_api_key() {
2787 let _guard = env_lock();
2788 let prev_codex_home = std::env::var("CODEX_HOME").ok();
2789 let prev_access = std::env::var("OPENAI_CODEX_ACCESS_TOKEN").ok();
2790 std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
2791
2792 let auth_dir = std::env::temp_dir().join(format!(
2793 "microclaw-codex-auth-{}",
2794 std::time::SystemTime::now()
2795 .duration_since(std::time::UNIX_EPOCH)
2796 .unwrap_or_default()
2797 .as_nanos()
2798 ));
2799 std::fs::create_dir_all(&auth_dir).unwrap();
2800 std::fs::write(
2801 auth_dir.join("auth.json"),
2802 r#"{"tokens":{"access_token":"tok"}}"#,
2803 )
2804 .unwrap();
2805 std::env::set_var("CODEX_HOME", &auth_dir);
2806
2807 let yaml =
2808 "telegram_bot_token: tok\nbot_username: bot\nllm_provider: openai-codex\nmodel: gpt-5.3-codex\n";
2809 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2810 config.post_deserialize().unwrap();
2811
2812 if let Some(prev) = prev_codex_home {
2813 std::env::set_var("CODEX_HOME", prev);
2814 } else {
2815 std::env::remove_var("CODEX_HOME");
2816 }
2817 if let Some(prev) = prev_access {
2818 std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev);
2819 } else {
2820 std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
2821 }
2822 let _ = std::fs::remove_file(auth_dir.join("auth.json"));
2823 let _ = std::fs::remove_dir(auth_dir);
2824 assert_eq!(config.llm_provider, "openai-codex");
2825 }
2826
2827 #[test]
2828 fn test_post_deserialize_missing_bot_tokens() {
2829 let yaml = "bot_username: bot\napi_key: key\nweb_enabled: false\n";
2830 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2831 let err = config.post_deserialize().unwrap_err();
2832 let msg = err.to_string();
2833 assert!(msg.contains("channel must be enabled"));
2834 }
2835
2836 #[test]
2837 fn test_post_deserialize_discord_only() {
2838 let yaml = "bot_username: bot\napi_key: key\ndiscord_bot_token: discord_tok\n";
2839 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2840 config.post_deserialize().unwrap();
2842 }
2843
2844 #[test]
2845 fn test_post_deserialize_irc_only() {
2846 let yaml = r##"
2847api_key: key
2848channels:
2849 irc:
2850 enabled: true
2851 server: "irc.example.com"
2852 nick: "microclaw"
2853 channels: "#microclaw"
2854"##;
2855 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2856 config.post_deserialize().unwrap();
2857 assert!(config.channel_enabled("irc"));
2858 }
2859
2860 #[test]
2861 fn test_post_deserialize_matrix_only() {
2862 let yaml = r##"
2863api_key: key
2864channels:
2865 matrix:
2866 enabled: true
2867 homeserver_url: "https://matrix.example.com"
2868 access_token: "syt_xxx"
2869 bot_user_id: "@microclaw:example.com"
2870"##;
2871 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2872 config.post_deserialize().unwrap();
2873 assert!(config.channel_enabled("matrix"));
2874 }
2875
2876 #[test]
2877 fn test_post_deserialize_channel_enabled_flag_overrides_legacy_inference() {
2878 let yaml = "telegram_bot_token: tok\nbot_username: bot\ndiscord_bot_token: discord_tok\napi_key: key\nchannels:\n telegram:\n enabled: false\n discord:\n enabled: true\n web:\n enabled: false\n";
2879 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2880 config.post_deserialize().unwrap();
2881
2882 assert!(!config.channel_enabled("telegram"));
2883 assert!(config.channel_enabled("discord"));
2884 assert!(!config.channel_enabled("web"));
2885 }
2886
2887 #[test]
2888 fn test_post_deserialize_channel_enabled_flag_controls_web() {
2889 let yaml =
2890 "api_key: key\ndiscord_bot_token: discord_tok\nchannels:\n web:\n enabled: false\n";
2891 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2892 config.post_deserialize().unwrap();
2893
2894 assert!(!config.channel_enabled("web"));
2895 }
2896
2897 #[test]
2898 fn test_post_deserialize_openai_default_model() {
2899 let yaml =
2900 "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nllm_provider: openai\n";
2901 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2902 config.post_deserialize().unwrap();
2903 assert_eq!(config.model, "gpt-5.2");
2904 }
2905
2906 #[test]
2907 fn test_post_deserialize_wildcard_model_falls_back_to_provider_default() {
2908 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nllm_provider: openai\nmodel: '*'\n";
2909 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2910 config.post_deserialize().unwrap();
2911 assert_eq!(config.model, "gpt-5.2");
2912 }
2913
2914 #[test]
2915 fn test_post_deserialize_openai_codex_default_model() {
2916 let _guard = env_lock();
2917 let prev_codex_home = std::env::var("CODEX_HOME").ok();
2918 let prev_access = std::env::var("OPENAI_CODEX_ACCESS_TOKEN").ok();
2919 std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
2920
2921 let auth_dir = std::env::temp_dir().join(format!(
2922 "microclaw-codex-auth-{}",
2923 std::time::SystemTime::now()
2924 .duration_since(std::time::UNIX_EPOCH)
2925 .unwrap_or_default()
2926 .as_nanos()
2927 ));
2928 std::fs::create_dir_all(&auth_dir).unwrap();
2929 std::fs::write(
2930 auth_dir.join("auth.json"),
2931 r#"{"tokens":{"access_token":"tok"}}"#,
2932 )
2933 .unwrap();
2934 std::env::set_var("CODEX_HOME", &auth_dir);
2935
2936 let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: openai-codex\n";
2937 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2938 config.post_deserialize().unwrap();
2939
2940 if let Some(prev) = prev_codex_home {
2941 std::env::set_var("CODEX_HOME", prev);
2942 } else {
2943 std::env::remove_var("CODEX_HOME");
2944 }
2945 if let Some(prev) = prev_access {
2946 std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev);
2947 } else {
2948 std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
2949 }
2950 let _ = std::fs::remove_file(auth_dir.join("auth.json"));
2951 let _ = std::fs::remove_dir(auth_dir);
2952 assert_eq!(config.model, "gpt-5.3-codex");
2953 }
2954
2955 #[test]
2956 fn test_post_deserialize_openai_codex_missing_oauth_token() {
2957 let _guard = env_lock();
2958 let prev_codex_home = std::env::var("CODEX_HOME").ok();
2959 let prev_access = std::env::var("OPENAI_CODEX_ACCESS_TOKEN").ok();
2960 std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
2961
2962 let auth_dir = std::env::temp_dir().join(format!(
2963 "microclaw-codex-auth-missing-{}",
2964 std::time::SystemTime::now()
2965 .duration_since(std::time::UNIX_EPOCH)
2966 .unwrap_or_default()
2967 .as_nanos()
2968 ));
2969 std::fs::create_dir_all(&auth_dir).unwrap();
2970 std::env::set_var("CODEX_HOME", &auth_dir);
2971
2972 let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: openai-codex\n";
2973 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
2974 let err = config.post_deserialize().unwrap_err();
2975 let msg = err.to_string();
2976
2977 if let Some(prev) = prev_codex_home {
2978 std::env::set_var("CODEX_HOME", prev);
2979 } else {
2980 std::env::remove_var("CODEX_HOME");
2981 }
2982 if let Some(prev) = prev_access {
2983 std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev);
2984 } else {
2985 std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
2986 }
2987 let _ = std::fs::remove_dir(auth_dir);
2988
2989 assert!(msg.contains("openai-codex requires ~/.codex/auth.json"));
2990 }
2991
2992 #[test]
2993 fn test_post_deserialize_openai_codex_rejects_plain_api_key_without_oauth() {
2994 let _guard = env_lock();
2995 let prev_codex_home = std::env::var("CODEX_HOME").ok();
2996 let prev_access = std::env::var("OPENAI_CODEX_ACCESS_TOKEN").ok();
2997 std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
2998
2999 let auth_dir = std::env::temp_dir().join(format!(
3000 "microclaw-codex-auth-plain-key-{}",
3001 std::time::SystemTime::now()
3002 .duration_since(std::time::UNIX_EPOCH)
3003 .unwrap_or_default()
3004 .as_nanos()
3005 ));
3006 std::fs::create_dir_all(&auth_dir).unwrap();
3007 std::env::set_var("CODEX_HOME", &auth_dir);
3008
3009 let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: openai-codex\napi_key: sk-user-stale\n";
3010 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3011 let err = config.post_deserialize().unwrap_err();
3012 let msg = err.to_string();
3013
3014 if let Some(prev) = prev_codex_home {
3015 std::env::set_var("CODEX_HOME", prev);
3016 } else {
3017 std::env::remove_var("CODEX_HOME");
3018 }
3019 if let Some(prev) = prev_access {
3020 std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev);
3021 } else {
3022 std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
3023 }
3024 let _ = std::fs::remove_dir(auth_dir);
3025
3026 assert!(msg.contains("ignores microclaw.config.yaml api_key"));
3027 }
3028
3029 #[test]
3030 fn test_post_deserialize_openai_codex_allows_env_access_token() {
3031 let _guard = env_lock();
3032 let prev_codex_home = std::env::var("CODEX_HOME").ok();
3033 let prev_access = std::env::var("OPENAI_CODEX_ACCESS_TOKEN").ok();
3034 std::env::remove_var("CODEX_HOME");
3035 std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", "env-token");
3036
3037 let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: openai-codex\n";
3038 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3039 config.post_deserialize().unwrap();
3040
3041 if let Some(prev) = prev_codex_home {
3042 std::env::set_var("CODEX_HOME", prev);
3043 } else {
3044 std::env::remove_var("CODEX_HOME");
3045 }
3046 if let Some(prev) = prev_access {
3047 std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev);
3048 } else {
3049 std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
3050 }
3051
3052 assert_eq!(config.llm_provider, "openai-codex");
3053 }
3054
3055 #[test]
3056 fn test_post_deserialize_qwen_code_allows_oauth_without_api_key() {
3057 let _guard = env_lock();
3058 let prev_qwen_home = std::env::var("QWEN_HOME").ok();
3059 let prev_qwen_access = std::env::var("QWEN_PORTAL_ACCESS_TOKEN").ok();
3060 std::env::remove_var("QWEN_PORTAL_ACCESS_TOKEN");
3061
3062 let qwen_dir = std::env::temp_dir().join(format!(
3063 "microclaw-qwen-auth-{}",
3064 std::time::SystemTime::now()
3065 .duration_since(std::time::UNIX_EPOCH)
3066 .unwrap_or_default()
3067 .as_nanos()
3068 ));
3069 std::fs::create_dir_all(&qwen_dir).unwrap();
3070 std::fs::write(
3071 qwen_dir.join("oauth_creds.json"),
3072 r#"{"access_token":"qwen-oauth-token"}"#,
3073 )
3074 .unwrap();
3075 std::env::set_var("QWEN_HOME", &qwen_dir);
3076
3077 let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: qwen-portal\n";
3078 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3079 config.post_deserialize().unwrap();
3080
3081 if let Some(prev) = prev_qwen_home {
3082 std::env::set_var("QWEN_HOME", prev);
3083 } else {
3084 std::env::remove_var("QWEN_HOME");
3085 }
3086 if let Some(prev) = prev_qwen_access {
3087 std::env::set_var("QWEN_PORTAL_ACCESS_TOKEN", prev);
3088 } else {
3089 std::env::remove_var("QWEN_PORTAL_ACCESS_TOKEN");
3090 }
3091 let _ = std::fs::remove_file(qwen_dir.join("oauth_creds.json"));
3092 let _ = std::fs::remove_dir(qwen_dir);
3093
3094 assert_eq!(config.llm_provider, "qwen-portal");
3095 }
3096
3097 #[test]
3098 fn test_post_deserialize_qwen_code_missing_oauth_and_api_key() {
3099 let _guard = env_lock();
3100 let prev_qwen_home = std::env::var("QWEN_HOME").ok();
3101 let prev_qwen_access = std::env::var("QWEN_PORTAL_ACCESS_TOKEN").ok();
3102 std::env::remove_var("QWEN_PORTAL_ACCESS_TOKEN");
3103
3104 let qwen_dir = std::env::temp_dir().join(format!(
3105 "microclaw-qwen-auth-missing-{}",
3106 std::time::SystemTime::now()
3107 .duration_since(std::time::UNIX_EPOCH)
3108 .unwrap_or_default()
3109 .as_nanos()
3110 ));
3111 std::fs::create_dir_all(&qwen_dir).unwrap();
3112 std::env::set_var("QWEN_HOME", &qwen_dir);
3113
3114 let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: qwen-portal\n";
3115 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3116 let err = config.post_deserialize().unwrap_err();
3117
3118 if let Some(prev) = prev_qwen_home {
3119 std::env::set_var("QWEN_HOME", prev);
3120 } else {
3121 std::env::remove_var("QWEN_HOME");
3122 }
3123 if let Some(prev) = prev_qwen_access {
3124 std::env::set_var("QWEN_PORTAL_ACCESS_TOKEN", prev);
3125 } else {
3126 std::env::remove_var("QWEN_PORTAL_ACCESS_TOKEN");
3127 }
3128 let _ = std::fs::remove_dir(qwen_dir);
3129
3130 assert!(err
3131 .to_string()
3132 .contains("qwen-portal requires api_key, or ~/.qwen/oauth_creds.json"));
3133 }
3134
3135 #[test]
3136 fn test_post_deserialize_ollama_default_model_and_empty_key() {
3137 let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: ollama\n";
3138 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3139 config.post_deserialize().unwrap();
3140 assert_eq!(config.model, "llama3.2");
3141 }
3142
3143 #[test]
3144 fn test_post_deserialize_empty_base_url_becomes_none() {
3145 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nllm_base_url: ' '\n";
3146 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3147 config.post_deserialize().unwrap();
3148 assert!(config.llm_base_url.is_none());
3149 }
3150
3151 #[test]
3152 fn test_post_deserialize_empty_llm_user_agent_uses_default() {
3153 let yaml =
3154 "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nllm_user_agent: ' '\n";
3155 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3156 config.post_deserialize().unwrap();
3157 assert_eq!(
3158 config.llm_user_agent,
3159 crate::http_client::default_llm_user_agent()
3160 );
3161 }
3162
3163 #[test]
3164 fn test_post_deserialize_provider_case_insensitive() {
3165 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nllm_provider: ' ANTHROPIC '\n";
3166 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3167 config.post_deserialize().unwrap();
3168 assert_eq!(config.llm_provider, "anthropic");
3169 assert_eq!(config.model, "claude-sonnet-4-5-20250929");
3170 }
3171
3172 #[test]
3173 fn test_post_deserialize_normalizes_openai_compat_body_overrides() {
3174 let yaml = r#"
3175telegram_bot_token: tok
3176bot_username: bot
3177api_key: key
3178openai_compat_body_overrides:
3179 " temperature ": 0.2
3180 " ": 1
3181openai_compat_body_overrides_by_provider:
3182 " OPENAI ":
3183 " top_p ": 0.95
3184 "": 1
3185 " ":
3186 seed: 7
3187openai_compat_body_overrides_by_model:
3188 " gpt-5.2 ":
3189 " frequency_penalty ": 0.1
3190 "": 1
3191 " ":
3192 seed: 7
3193"#;
3194 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3195 config.post_deserialize().unwrap();
3196
3197 assert_eq!(
3198 config.openai_compat_body_overrides.get("temperature"),
3199 Some(&serde_json::json!(0.2))
3200 );
3201 assert!(!config.openai_compat_body_overrides.contains_key(" "));
3202
3203 let provider_params = config
3204 .openai_compat_body_overrides_by_provider
3205 .get("openai")
3206 .unwrap();
3207 assert_eq!(provider_params.get("top_p"), Some(&serde_json::json!(0.95)));
3208 assert!(!provider_params.contains_key(""));
3209 assert!(!config
3210 .openai_compat_body_overrides_by_provider
3211 .contains_key(" OPENAI "));
3212
3213 let model_params = config
3214 .openai_compat_body_overrides_by_model
3215 .get("gpt-5.2")
3216 .unwrap();
3217 assert_eq!(
3218 model_params.get("frequency_penalty"),
3219 Some(&serde_json::json!(0.1))
3220 );
3221 assert!(!model_params.contains_key(""));
3222 }
3223
3224 #[test]
3225 fn test_post_deserialize_normalizes_subagent_acp_config() {
3226 let yaml = r#"
3227telegram_bot_token: tok
3228bot_username: bot
3229api_key: key
3230subagents:
3231 acp:
3232 enabled: true
3233 command: " codex "
3234 args: [" --model ", " ", "gpt-5.4"]
3235 env:
3236 " OPENAI_API_KEY ": abc
3237 " ": ignored
3238 default_target: " worker "
3239 targets:
3240 " worker ":
3241 enabled: true
3242 command: " codex-worker "
3243 args: [" --fast "]
3244 env:
3245 " TOKEN ": xyz
3246"#;
3247 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3248 config.post_deserialize().unwrap();
3249
3250 assert!(config.subagents.acp.default_target.enabled);
3251 assert_eq!(config.subagents.acp.default_target.command, "codex");
3252 assert_eq!(
3253 config.subagents.acp.default_target.args,
3254 vec!["--model".to_string(), "gpt-5.4".to_string()]
3255 );
3256 assert_eq!(
3257 config
3258 .subagents
3259 .acp
3260 .default_target
3261 .env
3262 .get("OPENAI_API_KEY"),
3263 Some(&"abc".to_string())
3264 );
3265 assert_eq!(
3266 config.subagents.acp.default_target_name.as_deref(),
3267 Some("worker")
3268 );
3269 assert!(!config.subagents.acp.default_target.env.contains_key(" "));
3270 let target = config.subagents.acp.targets.get("worker").unwrap();
3271 assert!(target.enabled);
3272 assert_eq!(target.command, "codex-worker");
3273 assert_eq!(target.args, vec!["--fast".to_string()]);
3274 assert_eq!(target.env.get("TOKEN"), Some(&"xyz".to_string()));
3275 }
3276
3277 #[test]
3278 fn test_post_deserialize_web_non_local_no_token_required() {
3279 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nweb_enabled: true\nweb_host: 0.0.0.0\n";
3280 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3281 config.post_deserialize().unwrap();
3282 }
3283
3284 #[test]
3285 fn test_post_deserialize_web_channel_auth_token_is_removed() {
3286 let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nweb_enabled: true\nweb_host: 0.0.0.0\nchannels:\n web:\n enabled: true\n auth_token: token123\n";
3287 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3288 config.post_deserialize().unwrap();
3289 let has_auth_token = config
3290 .channels
3291 .get("web")
3292 .and_then(|v| v.as_mapping())
3293 .map(|map| map.contains_key(serde_yaml::Value::String("auth_token".to_string())))
3294 .unwrap_or(false);
3295 assert!(!has_auth_token);
3296 }
3297
3298 #[test]
3299 fn test_model_prices_parse_and_estimate() {
3300 let yaml = r#"
3301telegram_bot_token: tok
3302bot_username: bot
3303api_key: key
3304model_prices:
3305 - model: claude-sonnet-4-5-20250929
3306 input_per_million_usd: 3.0
3307 output_per_million_usd: 15.0
3308"#;
3309 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3310 config.post_deserialize().unwrap();
3311 let est = config
3312 .estimate_cost_usd("claude-sonnet-4-5-20250929", 1000, 2000)
3313 .unwrap();
3314 assert!((est - 0.033).abs() < 1e-9);
3315 }
3316
3317 #[test]
3318 fn test_model_prices_invalid_rejected() {
3319 let yaml = r#"
3320telegram_bot_token: tok
3321bot_username: bot
3322api_key: key
3323model_prices:
3324 - model: ""
3325 input_per_million_usd: 1.0
3326 output_per_million_usd: 1.0
3327"#;
3328 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3329 let err = config.post_deserialize().unwrap_err();
3330 assert!(err
3331 .to_string()
3332 .contains("model_prices entries must include non-empty model"));
3333 }
3334
3335 #[test]
3336 fn test_config_yaml_with_all_optional_fields() {
3337 let yaml = r#"
3338telegram_bot_token: tok
3339bot_username: bot
3340api_key: key
3341openai_api_key: sk-test
3342override_timezone: US/Eastern
3343allowed_groups: [123, 456]
3344control_chat_ids: [999]
3345max_session_messages: 60
3346compact_keep_recent: 30
3347discord_bot_token: discord_tok
3348discord_allowed_channels: [111, 222]
3349"#;
3350 let mut config: Config = serde_yaml::from_str(yaml).unwrap();
3351 config.post_deserialize().unwrap();
3352 assert_eq!(config.openai_api_key.as_deref(), Some("sk-test"));
3353 assert_eq!(config.timezone, "US/Eastern");
3354 assert_eq!(config.allowed_groups, vec![123, 456]);
3355 assert_eq!(config.control_chat_ids, vec![999]);
3356 assert_eq!(config.max_session_messages, 60);
3357 assert_eq!(config.compact_keep_recent, 30);
3358 assert_eq!(config.discord_allowed_channels, vec![111, 222]);
3359 }
3360
3361 #[test]
3362 fn test_expand_path_with_tilde() {
3363 let home = PathBuf::from(shellexpand::tilde("~").as_ref());
3364 let path = "~/foo/bar";
3365 let expanded = expand_path(path);
3366 assert_eq!(expanded, home.join("foo/bar"));
3367
3368 let path = "~";
3369 let expanded = expand_path(path);
3370 assert_eq!(expanded, home);
3371
3372 let path = "/absolute/path";
3373 let expanded = expand_path(path);
3374 assert_eq!(expanded, std::path::PathBuf::from("/absolute/path"));
3375 }
3376
3377 #[test]
3378 fn unknown_top_level_key_is_flagged() {
3379 let map = mapping_of("definitely_not_a_key: true\napi_key: k\n");
3380 let warnings = unknown_top_level_key_warnings(&map);
3381 assert!(
3382 warnings.iter().any(|w| w.contains("definitely_not_a_key")),
3383 "expected an unknown-key warning, got: {warnings:?}"
3384 );
3385 assert!(
3387 !warnings.iter().any(|w| w.contains("`api_key`")),
3388 "api_key should be recognized, got: {warnings:?}"
3389 );
3390 }
3391
3392 #[test]
3393 fn unknown_key_gets_did_you_mean() {
3394 let map = mapping_of("api_ky: k\n");
3396 let warnings = unknown_top_level_key_warnings(&map);
3397 assert!(
3398 warnings
3399 .iter()
3400 .any(|w| w.contains("api_ky") && w.contains("did you mean `api_key`")),
3401 "expected a did-you-mean suggestion, got: {warnings:?}"
3402 );
3403 }
3404
3405 #[test]
3406 fn known_top_level_keys_produce_no_warnings() {
3407 let yaml = "api_key: k\ntelegram_bot_token: t\nclawhub_registry: https://x\n\
3410timezone: UTC\noverride_timezone: UTC\nsandbox:\n enabled: false\n";
3411 let warnings = unknown_top_level_key_warnings(&mapping_of(yaml));
3412 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
3413 }
3414}