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