Skip to main content

microclaw_engine/config/
core.rs

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