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