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