use super::*;
pub fn normalize_model_name(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() || trimmed == "*" {
None
} else {
Some(trimmed.to_string())
}
}
pub(crate) fn normalize_string_list(values: &mut Vec<String>, lowercase: bool) {
*values = values
.drain(..)
.map(|value| {
let value = value.trim();
if lowercase {
value.to_ascii_lowercase()
} else {
value.to_string()
}
})
.filter(|value| !value.is_empty())
.collect();
values.sort();
values.dedup();
}
pub fn resolve_model_name_with_fallback(
provider: &str,
candidate: Option<&str>,
fallback: Option<&str>,
) -> String {
candidate
.and_then(normalize_model_name)
.or_else(|| fallback.and_then(normalize_model_name))
.unwrap_or_else(|| default_model_for_provider_name(provider).to_string())
}
pub(crate) fn expand_path(path: &str) -> PathBuf {
match shellexpand::tilde(path) {
std::borrow::Cow::Borrowed(p) => PathBuf::from(p),
std::borrow::Cow::Owned(p) => PathBuf::from(p),
}
}
pub(crate) fn detect_system_timezone() -> String {
match iana_time_zone::get_timezone() {
Ok(tz_name) => tz_name,
Err(e) => {
warn!("Failed to detect system timezone automatically: {e}. Falling back to UTC.");
"UTC".into()
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum UserMessageLanguage {
#[default]
En,
Zh,
Bilingual,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SystemPromptTimeDetail {
None,
#[default]
Date,
Full,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Config {
#[serde(default = "default_llm_provider")]
pub llm_provider: String,
#[serde(default = "default_api_key")]
pub api_key: String,
#[serde(default)]
pub api_keys: Vec<String>,
#[serde(default = "default_model")]
pub model: String,
#[serde(default)]
pub provider_presets: HashMap<String, LlmProviderProfile>,
#[serde(default)]
pub llm_providers: HashMap<String, LlmProviderProfile>,
#[serde(default)]
pub llm_base_url: Option<String>,
#[serde(default = "default_llm_user_agent")]
pub llm_user_agent: String,
#[serde(default = "default_max_tokens")]
pub max_tokens: u32,
#[serde(default = "default_max_tool_iterations")]
pub max_tool_iterations: usize,
#[serde(default = "default_compaction_timeout_secs")]
pub compaction_timeout_secs: u64,
#[serde(default)]
pub aux_models: AuxModels,
#[serde(default)]
pub fallback_model: Option<String>,
#[serde(default = "default_max_history_messages")]
pub max_history_messages: usize,
#[serde(default = "default_max_document_size_mb")]
pub max_document_size_mb: u64,
#[serde(default = "default_memory_token_budget")]
pub memory_token_budget: usize,
#[serde(default = "default_memory_l0_identity_pct")]
pub memory_l0_identity_pct: usize,
#[serde(default = "default_memory_l1_essential_pct")]
pub memory_l1_essential_pct: usize,
#[serde(default = "default_memory_max_entries_per_chat")]
pub memory_max_entries_per_chat: usize,
#[serde(default = "default_memory_max_global_entries")]
pub memory_max_global_entries: usize,
#[serde(default = "default_kg_max_triples_per_chat")]
pub kg_max_triples_per_chat: usize,
#[serde(default = "default_tool_result_truncation_threshold_chars")]
pub tool_result_truncation_threshold_chars: usize,
#[serde(default = "default_tool_result_truncation_head_chars")]
pub tool_result_truncation_head_chars: usize,
#[serde(default = "default_tool_result_truncation_tail_chars")]
pub tool_result_truncation_tail_chars: usize,
#[serde(default = "default_tool_result_artifact_ttl_hours")]
pub tool_result_artifact_ttl_hours: u64,
#[serde(default = "default_memory_recency_half_life_days")]
pub memory_recency_half_life_days: f64,
#[serde(default = "default_memory_graph_recall_enabled")]
pub memory_graph_recall_enabled: bool,
#[serde(default = "default_memory_graph_max_hops")]
pub memory_graph_max_hops: usize,
#[serde(default = "default_memory_graph_max_triples")]
pub memory_graph_max_triples: usize,
#[serde(default = "default_tool_repeat_window")]
pub tool_repeat_window: usize,
#[serde(default = "default_tool_repeat_limit")]
pub tool_repeat_limit: usize,
#[serde(default = "default_anthropic_prompt_cache_enabled")]
pub anthropic_prompt_cache_enabled: bool,
#[serde(default = "default_anthropic_prompt_cache_ttl")]
pub anthropic_prompt_cache_ttl: String,
#[serde(default = "default_checkpoints_enabled")]
pub checkpoints_enabled: bool,
#[serde(default = "default_skill_archive_after_days")]
pub skill_archive_after_days: u64,
#[serde(default = "default_skills_catalog_top_k")]
pub skills_catalog_top_k: usize,
#[serde(default)]
pub system_prompt_time_detail: SystemPromptTimeDetail,
#[serde(default = "default_max_session_messages")]
pub max_session_messages: usize,
#[serde(default = "default_diff_max_lines")]
pub diff_max_lines: usize,
#[serde(default = "default_file_diffs_in_chat")]
pub file_diffs_in_chat: bool,
#[serde(default)]
pub allow_governance_file_writes: bool,
#[serde(default = "default_model_context_window")]
pub model_context_window: usize,
#[serde(default = "default_context_pressure_compact_pct")]
pub context_pressure_compact_pct: usize,
#[serde(default)]
pub self_recheck: SelfRecheckConfig,
#[serde(default = "default_compact_keep_recent")]
pub compact_keep_recent: usize,
#[serde(default = "default_tool_timeout_secs")]
pub default_tool_timeout_secs: u64,
#[serde(default)]
pub tool_timeout_overrides: HashMap<String, u64>,
#[serde(default = "default_mcp_request_timeout_secs")]
pub default_mcp_request_timeout_secs: u64,
#[serde(default)]
pub show_thinking: bool,
#[serde(default)]
pub subagents: SubagentConfig,
#[serde(default)]
pub idle_checkin: IdleCheckinConfig,
#[serde(default)]
pub heartbeat: HeartbeatConfig,
#[serde(default)]
pub token_budget: TokenBudgetConfig,
#[serde(default)]
pub alerts: AlertsConfig,
#[serde(default)]
pub trust_report: TrustReportConfig,
#[serde(default)]
pub user_message_language: UserMessageLanguage,
#[serde(default)]
pub sleep_time: SleepTimeConfig,
#[serde(default)]
pub interjection: InterjectionConfig,
#[serde(default)]
pub a2a: A2AConfig,
#[serde(default = "default_chat_turn_queue_max_pending")]
pub chat_turn_queue_max_pending: usize,
#[serde(default = "default_true")]
pub enable_mid_turn_injection: bool,
#[serde(default = "default_true")]
pub mid_turn_injection_echo: bool,
#[serde(default = "default_parallel_tool_max_concurrency")]
pub parallel_tool_max_concurrency: usize,
#[serde(default)]
pub tool_concurrency_overrides: HashMap<String, String>,
#[serde(default)]
pub openai_compat_body_overrides: HashMap<String, serde_json::Value>,
#[serde(default)]
pub openai_compat_body_overrides_by_provider:
HashMap<String, HashMap<String, serde_json::Value>>,
#[serde(default)]
pub openai_compat_body_overrides_by_model: HashMap<String, HashMap<String, serde_json::Value>>,
#[serde(default = "default_data_dir")]
pub data_dir: String,
#[serde(default)]
pub skills_dir: Option<String>,
#[serde(default = "default_working_dir")]
pub working_dir: String,
#[serde(default = "default_working_dir_isolation")]
pub working_dir_isolation: WorkingDirIsolation,
#[serde(default = "default_high_risk_tool_user_confirmation_required")]
pub high_risk_tool_user_confirmation_required: bool,
#[serde(default = "default_bash_dangerous_patterns")]
pub bash_dangerous_patterns: Vec<String>,
#[serde(default)]
pub rtk: RtkConfig,
#[serde(default)]
pub sandbox: SandboxConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub override_timezone: Option<String>,
#[serde(default = "default_timezone", skip_serializing)]
pub timezone: String,
#[serde(default = "default_control_chat_ids")]
pub control_chat_ids: Vec<i64>,
#[serde(default)]
pub discord_bot_token: Option<String>,
#[serde(default)]
pub discord_allowed_channels: Vec<u64>,
#[serde(default)]
pub discord_no_mention: bool,
#[serde(default = "default_allow_group_slash_without_mention")]
pub allow_group_slash_without_mention: bool,
#[serde(default = "default_web_enabled")]
pub web_enabled: bool,
#[serde(default = "default_web_host")]
pub web_host: String,
#[serde(default = "default_web_port")]
pub web_port: u16,
#[serde(default = "default_web_max_inflight_per_session")]
pub web_max_inflight_per_session: usize,
#[serde(default = "default_web_max_requests_per_window")]
pub web_max_requests_per_window: usize,
#[serde(default = "default_web_rate_window_seconds")]
pub web_rate_window_seconds: u64,
#[serde(default = "default_web_run_history_limit")]
pub web_run_history_limit: usize,
#[serde(default = "default_web_session_idle_ttl_seconds")]
pub web_session_idle_ttl_seconds: u64,
#[serde(default)]
pub web_fetch_validation: WebContentValidationConfig,
#[serde(default)]
pub web_fetch_url_validation: WebFetchUrlValidationConfig,
#[serde(default)]
pub web_search: SearchProviderConfig,
#[serde(default)]
pub output_guardrail: OutputGuardrailConfig,
#[serde(default)]
pub tool_policy: crate::tool_guardrails::ToolPolicyConfig,
#[serde(default)]
pub egress_policy: EgressPolicyConfig,
#[serde(default)]
pub embedding_provider: Option<String>,
#[serde(default)]
pub embedding_api_key: Option<String>,
#[serde(default)]
pub embedding_base_url: Option<String>,
#[serde(default)]
pub embedding_model: Option<String>,
#[serde(default)]
pub embedding_dim: Option<usize>,
#[serde(default)]
pub openai_api_key: Option<String>,
#[serde(default = "default_model_prices")]
pub model_prices: Vec<ModelPrice>,
#[serde(default = "default_reflector_enabled")]
pub reflector_enabled: bool,
#[serde(default = "default_reflector_interval_mins")]
pub reflector_interval_mins: u64,
#[serde(default = "default_dlq_replay_enabled")]
pub dlq_replay_enabled: bool,
#[serde(default = "default_dlq_replay_interval_secs")]
pub dlq_replay_interval_secs: u64,
#[serde(default = "default_dlq_max_replay_attempts")]
pub dlq_max_replay_attempts: u32,
#[serde(default = "default_skill_review_min_tool_calls")]
pub skill_review_min_tool_calls: usize,
#[serde(default = "default_soul_path")]
pub soul_path: Option<String>,
#[serde(default = "default_souls_dir")]
pub souls_dir: Option<String>,
#[serde(default)]
pub context_dir: Option<String>,
#[serde(default = "default_context_max_chars")]
pub context_max_chars: usize,
#[serde(default)]
pub voice_round_trip: bool,
#[serde(default = "default_user_model_max_chars")]
pub user_model_max_chars: usize,
#[serde(flatten)]
pub clawhub: ClawHubConfig,
#[serde(default)]
pub plugins: PluginsConfig,
#[serde(default)]
pub media: MediaConfig,
#[serde(default)]
pub openai_base_url: Option<String>,
#[serde(default = "default_voice_provider", rename = "voice_provider")]
pub voice_provider: String,
#[serde(default, rename = "voice_transcription_command")]
pub voice_transcription_command: Option<String>,
#[serde(default)]
pub observability: Option<serde_yaml::Value>,
#[serde(default)]
pub channels: HashMap<String, serde_yaml::Value>,
#[serde(default = "default_telegram_bot_token")]
pub telegram_bot_token: String,
#[serde(default = "default_bot_username")]
pub bot_username: String,
#[serde(default)]
pub allowed_groups: Vec<i64>,
}
pub(crate) fn collect_enabled_channel_endpoints(value: &serde_yaml::Value, out: &mut Vec<String>) {
let Some(mapping) = value.as_mapping() else {
return;
};
if mapping
.get(serde_yaml::Value::String("enabled".into()))
.and_then(serde_yaml::Value::as_bool)
== Some(false)
{
return;
}
for (key, value) in mapping {
let Some(key) = key.as_str() else {
continue;
};
let key = key.to_ascii_lowercase();
let is_endpoint_key = key.contains("url")
|| key.contains("endpoint")
|| key == "homeserver"
|| key == "homeserver_url";
if is_endpoint_key {
if let Some(endpoint) = value
.as_str()
.map(str::trim)
.filter(|endpoint| !endpoint.is_empty())
{
out.push(endpoint.to_string());
}
}
match value {
serde_yaml::Value::Mapping(_) => collect_enabled_channel_endpoints(value, out),
serde_yaml::Value::Sequence(values) => {
for value in values {
collect_enabled_channel_endpoints(value, out);
}
}
_ => {}
}
}
}
impl Config {
pub fn is_control_chat(&self, chat_id: i64) -> bool {
self.control_chat_ids.contains(&chat_id)
}
fn ensure_mapping_mut(value: &mut serde_yaml::Value) -> &mut serde_yaml::Mapping {
if !matches!(value, serde_yaml::Value::Mapping(_)) {
*value = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
}
value
.as_mapping_mut()
.expect("value should be a mapping after initialization")
}
fn channel_default_account_id(&self, channel: &str) -> Option<String> {
let channel_cfg = self.channels.get(channel)?;
let mut account_ids: Vec<String> = channel_cfg
.get("accounts")
.and_then(|v| v.as_mapping())
.map(|m| {
m.keys()
.filter_map(|k| k.as_str().map(ToOwned::to_owned))
.collect()
})
.unwrap_or_default();
account_ids.sort();
channel_cfg
.get("default_account")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
if channel_cfg
.get("accounts")
.and_then(|v| v.get("default"))
.is_some()
{
Some("default".to_string())
} else {
account_ids.first().cloned()
}
})
}
fn llm_override_target(&self, channel: &str) -> Option<(String, Option<String>)> {
let channel = channel.trim();
if channel.is_empty() {
return None;
}
if let Some((base_channel, account_id)) = channel.split_once('.') {
let base_channel = base_channel.trim();
let account_id = account_id.trim();
if base_channel.is_empty() || account_id.is_empty() {
return None;
}
return Some((base_channel.to_string(), Some(account_id.to_string())));
}
Some((
channel.to_string(),
self.channel_default_account_id(channel),
))
}
fn llm_override_mapping_mut(&mut self, channel: &str) -> Option<&mut serde_yaml::Mapping> {
let (base_channel, account_id) = self.llm_override_target(channel)?;
let channel_value = self
.channels
.entry(base_channel)
.or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
let channel_map = Self::ensure_mapping_mut(channel_value);
if let Some(account_id) = account_id {
let accounts_key = serde_yaml::Value::String("accounts".to_string());
let accounts_value = channel_map
.entry(accounts_key)
.or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
let accounts_map = Self::ensure_mapping_mut(accounts_value);
let account_value = accounts_map
.entry(serde_yaml::Value::String(account_id))
.or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
return Some(Self::ensure_mapping_mut(account_value));
}
Some(channel_map)
}
pub fn set_provider_override_for_channel(&mut self, channel: &str, provider: Option<&str>) {
let Some(target) = self.llm_override_mapping_mut(channel) else {
return;
};
let provider_preset_key = serde_yaml::Value::String("provider_preset".to_string());
let llm_provider_key = serde_yaml::Value::String("llm_provider".to_string());
target.remove(&provider_preset_key);
target.remove(&llm_provider_key);
if let Some(provider) = provider
.map(str::trim)
.filter(|provider| !provider.is_empty())
.map(|provider| provider.to_ascii_lowercase())
{
target.insert(
provider_preset_key,
serde_yaml::Value::String(provider.to_string()),
);
}
}
pub fn set_model_override_for_channel(&mut self, channel: &str, model: Option<&str>) {
let Some(target) = self.llm_override_mapping_mut(channel) else {
return;
};
let model_key = serde_yaml::Value::String("model".to_string());
target.remove(&model_key);
if let Some(model) = model.and_then(normalize_model_name) {
target.insert(model_key, serde_yaml::Value::String(model.to_string()));
}
}
fn channel_account_bot_username(&self, channel: &str, account_id: &str) -> Option<String> {
self.channels
.get(channel)
.and_then(|v| v.get("accounts"))
.and_then(|v| v.get(account_id))
.and_then(|v| v.get("bot_username"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToOwned::to_owned)
}
fn channel_account_soul_path(&self, channel: &str, account_id: &str) -> Option<String> {
self.channels
.get(channel)
.and_then(|v| v.get("accounts"))
.and_then(|v| v.get(account_id))
.and_then(|v| v.get("soul_path"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToOwned::to_owned)
}
fn provider_override_from_value(value: &serde_yaml::Value) -> Option<String> {
value
.get("provider_preset")
.and_then(|v| v.as_str())
.or_else(|| value.get("llm_provider").and_then(|v| v.as_str()))
.map(str::trim)
.filter(|v| !v.is_empty())
.map(|v| v.to_ascii_lowercase())
}
fn channel_account_provider_override(&self, channel: &str, account_id: &str) -> Option<String> {
self.channels
.get(channel)
.and_then(|v| v.get("accounts"))
.and_then(|v| v.get(account_id))
.and_then(Self::provider_override_from_value)
}
fn model_override_from_value(value: &serde_yaml::Value) -> Option<String> {
value
.get("model")
.and_then(|v| v.as_str())
.and_then(normalize_model_name)
}
fn channel_account_model_override(&self, channel: &str, account_id: &str) -> Option<String> {
self.channels
.get(channel)
.and_then(|v| v.get("accounts"))
.and_then(|v| v.get(account_id))
.and_then(Self::model_override_from_value)
}
pub fn provider_override_for_channel(&self, channel: &str) -> Option<String> {
if let Some((base_channel, account_id)) = channel.split_once('.') {
if let Some(v) = self.channel_account_provider_override(base_channel, account_id) {
return Some(v);
}
return self
.channels
.get(base_channel)
.and_then(Self::provider_override_from_value);
}
if let Some(default_account) = self.channel_default_account_id(channel) {
if let Some(v) = self.channel_account_provider_override(channel, &default_account) {
return Some(v);
}
}
self.channels
.get(channel)
.and_then(Self::provider_override_from_value)
}
pub fn model_override_for_channel(&self, channel: &str) -> Option<String> {
if let Some((base_channel, account_id)) = channel.split_once('.') {
if let Some(v) = self.channel_account_model_override(base_channel, account_id) {
return Some(v);
}
return self
.channels
.get(base_channel)
.and_then(Self::model_override_from_value);
}
if let Some(default_account) = self.channel_default_account_id(channel) {
if let Some(v) = self.channel_account_model_override(channel, &default_account) {
return Some(v);
}
}
self.channels
.get(channel)
.and_then(Self::model_override_from_value)
}
pub fn soul_path_for_channel(&self, channel: &str) -> Option<String> {
let channel_override = self
.channels
.get(channel)
.and_then(|v| v.get("soul_path"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToOwned::to_owned);
if channel_override.is_some() {
return channel_override;
}
if let Some((base_channel, account_id)) = channel.split_once('.') {
if let Some(v) = self.channel_account_soul_path(base_channel, account_id) {
return Some(v);
}
return self
.channels
.get(base_channel)
.and_then(|v| v.get("soul_path"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToOwned::to_owned);
}
if let Some(default_account) = self.channel_default_account_id(channel) {
if let Some(v) = self.channel_account_soul_path(channel, &default_account) {
return Some(v);
}
}
self.channels
.get(channel)
.and_then(|v| v.get("soul_path"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToOwned::to_owned)
}
pub fn bot_username_for_channel(&self, channel: &str) -> String {
let channel_override = self
.channels
.get(channel)
.and_then(|v| v.get("bot_username"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty());
if let Some(v) = channel_override {
return v.to_string();
}
if let Some((base_channel, account_id)) = channel.split_once('.') {
if let Some(v) = self.channel_account_bot_username(base_channel, account_id) {
return v;
}
} else if let Some(default_account) = self.channel_default_account_id(channel) {
if let Some(v) = self.channel_account_bot_username(channel, &default_account) {
return v;
}
}
let global = self.bot_username.trim();
if !global.is_empty() {
global.to_string()
} else {
default_bot_username()
}
}
pub fn bot_username_overrides(&self) -> HashMap<String, String> {
let mut overrides: HashMap<String, String> = self
.channels
.iter()
.filter_map(|(channel, cfg)| {
cfg.get("bot_username")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(|v| (channel.clone(), v.to_string()))
})
.collect();
for (channel, channel_cfg) in &self.channels {
let accounts = channel_cfg.get("accounts").and_then(|v| v.as_mapping());
let Some(accounts) = accounts else {
continue;
};
let default_account = self.channel_default_account_id(channel);
for (key, value) in accounts {
let Some(account_id) = key.as_str() else {
continue;
};
let username = value
.get("bot_username")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty());
let Some(username) = username else {
continue;
};
if default_account
.as_deref()
.map(|v| v == account_id)
.unwrap_or(false)
{
overrides.insert(channel.clone(), username.to_string());
} else {
overrides.insert(format!("{channel}.{account_id}"), username.to_string());
}
}
}
overrides
}
pub fn llm_provider_overrides(&self) -> HashMap<String, String> {
let mut overrides: HashMap<String, String> = self
.channels
.iter()
.filter_map(|(channel, cfg)| {
Self::provider_override_from_value(cfg).map(|provider| (channel.clone(), provider))
})
.collect();
for (channel, channel_cfg) in &self.channels {
let accounts = channel_cfg.get("accounts").and_then(|v| v.as_mapping());
let Some(accounts) = accounts else {
continue;
};
let default_account = self.channel_default_account_id(channel);
for (key, value) in accounts {
let Some(account_id) = key.as_str() else {
continue;
};
let Some(provider) = Self::provider_override_from_value(value) else {
continue;
};
if default_account
.as_deref()
.map(|v| v == account_id)
.unwrap_or(false)
{
overrides.insert(channel.clone(), provider);
} else {
overrides.insert(format!("{channel}.{account_id}"), provider);
}
}
}
overrides
}
#[cfg(any(test, feature = "test-support"))]
pub fn test_defaults() -> Self {
Self {
telegram_bot_token: "tok".into(),
bot_username: "bot".into(),
llm_provider: "anthropic".into(),
api_key: "key".into(),
api_keys: Vec::new(),
model: "claude-sonnet-4-5-20250929".into(),
provider_presets: HashMap::new(),
llm_providers: HashMap::new(),
llm_base_url: None,
llm_user_agent: default_llm_user_agent(),
max_tokens: 8192,
max_tool_iterations: 100,
compaction_timeout_secs: 180,
aux_models: AuxModels::default(),
fallback_model: None,
max_history_messages: 50,
max_document_size_mb: 100,
memory_token_budget: 1500,
memory_l0_identity_pct: 20,
memory_l1_essential_pct: 30,
memory_max_entries_per_chat: 200,
memory_max_global_entries: 500,
kg_max_triples_per_chat: 1000,
tool_result_truncation_threshold_chars: 4000,
tool_result_truncation_head_chars: 1500,
tool_result_truncation_tail_chars: 500,
tool_result_artifact_ttl_hours: 24,
memory_recency_half_life_days: 30.0,
memory_graph_recall_enabled: true,
memory_graph_max_hops: 2,
memory_graph_max_triples: 10,
tool_repeat_window: 10,
tool_repeat_limit: 3,
anthropic_prompt_cache_enabled: true,
anthropic_prompt_cache_ttl: "5m".into(),
checkpoints_enabled: false,
skill_archive_after_days: 30,
skills_catalog_top_k: 3,
system_prompt_time_detail: SystemPromptTimeDetail::default(),
data_dir: default_data_dir(),
skills_dir: None,
working_dir: default_working_dir(),
working_dir_isolation: WorkingDirIsolation::Chat,
high_risk_tool_user_confirmation_required: true,
bash_dangerous_patterns: default_bash_dangerous_patterns(),
rtk: RtkConfig::default(),
sandbox: SandboxConfig::default(),
openai_api_key: None,
override_timezone: None,
timezone: "UTC".into(),
allowed_groups: vec![],
control_chat_ids: vec![],
max_session_messages: 40,
diff_max_lines: default_diff_max_lines(),
file_diffs_in_chat: default_file_diffs_in_chat(),
allow_governance_file_writes: false,
model_context_window: default_model_context_window(),
context_pressure_compact_pct: default_context_pressure_compact_pct(),
self_recheck: SelfRecheckConfig::default(),
compact_keep_recent: 20,
default_tool_timeout_secs: default_tool_timeout_secs(),
tool_timeout_overrides: HashMap::new(),
default_mcp_request_timeout_secs: default_mcp_request_timeout_secs(),
discord_bot_token: None,
discord_allowed_channels: vec![],
discord_no_mention: false,
allow_group_slash_without_mention: false,
show_thinking: false,
subagents: SubagentConfig::default(),
idle_checkin: IdleCheckinConfig::default(),
heartbeat: HeartbeatConfig::default(),
token_budget: TokenBudgetConfig::default(),
alerts: AlertsConfig::default(),
trust_report: TrustReportConfig::default(),
user_message_language: UserMessageLanguage::default(),
sleep_time: SleepTimeConfig::default(),
interjection: InterjectionConfig::default(),
a2a: A2AConfig::default(),
openai_compat_body_overrides: HashMap::new(),
openai_compat_body_overrides_by_provider: HashMap::new(),
openai_compat_body_overrides_by_model: HashMap::new(),
web_enabled: true,
web_host: "127.0.0.1".into(),
web_port: 10961,
web_max_inflight_per_session: 10,
web_max_requests_per_window: 8,
web_rate_window_seconds: 10,
web_run_history_limit: 512,
web_session_idle_ttl_seconds: 300,
web_fetch_validation: WebContentValidationConfig::default(),
web_fetch_url_validation: WebFetchUrlValidationConfig::default(),
web_search: SearchProviderConfig::default(),
output_guardrail: OutputGuardrailConfig::default(),
tool_policy: crate::tool_guardrails::ToolPolicyConfig::default(),
egress_policy: EgressPolicyConfig::default(),
model_prices: vec![],
embedding_provider: None,
embedding_api_key: None,
embedding_base_url: None,
embedding_model: None,
embedding_dim: None,
reflector_enabled: true,
reflector_interval_mins: 15,
dlq_replay_enabled: true,
dlq_replay_interval_secs: 300,
dlq_max_replay_attempts: 3,
skill_review_min_tool_calls: 5,
soul_path: None,
souls_dir: None,
context_dir: None,
context_max_chars: 8000,
user_model_max_chars: 1500,
voice_round_trip: false,
clawhub: ClawHubConfig::default(),
plugins: PluginsConfig::default(),
media: MediaConfig::default(),
openai_base_url: None,
voice_provider: "openai".into(),
voice_transcription_command: None,
observability: None,
channels: HashMap::new(),
chat_turn_queue_max_pending: 20,
enable_mid_turn_injection: true,
mid_turn_injection_echo: true,
parallel_tool_max_concurrency: 8,
tool_concurrency_overrides: HashMap::new(),
}
}
pub fn data_root_dir(&self) -> PathBuf {
expand_path(&self.data_dir)
}
pub fn runtime_data_dir(&self) -> String {
let root = self.data_root_dir();
if root.file_name().and_then(|n| n.to_str()) == Some("runtime") {
return root.to_string_lossy().to_string();
}
root.join("runtime").to_string_lossy().to_string()
}
pub fn skills_data_dir(&self) -> String {
if let Ok(explicit_dir) = std::env::var("MICROCLAW_SKILLS_DIR") {
let trimmed = explicit_dir.trim();
if !trimmed.is_empty() {
return expand_path(trimmed).to_string_lossy().to_string();
}
}
if let Some(configured) = &self.skills_dir {
let trimmed = configured.trim();
if !trimmed.is_empty() {
return expand_path(trimmed).to_string_lossy().to_string();
}
}
self.data_root_dir()
.join("skills")
.to_string_lossy()
.to_string()
}
pub fn souls_data_dir(&self) -> String {
if let Some(configured) = &self.souls_dir {
let trimmed = configured.trim();
if !trimmed.is_empty() {
return expand_path(trimmed).to_string_lossy().to_string();
}
}
self.data_root_dir()
.join("souls")
.to_string_lossy()
.to_string()
}
pub fn clawhub_lockfile_path(&self) -> PathBuf {
self.data_root_dir().join("clawhub.lock.json")
}
pub fn clawhub_load_verification(&self) -> Option<(PathBuf, bool)> {
match self.clawhub.verify_on_load {
ClawHubVerifyMode::Off => None,
ClawHubVerifyMode::Warn => Some((self.clawhub_lockfile_path(), false)),
ClawHubVerifyMode::Block => Some((self.clawhub_lockfile_path(), true)),
}
}
pub fn config_path_for_setup() -> PathBuf {
if let Ok(custom) = std::env::var("MICROCLAW_CONFIG") {
return expand_path(&custom);
}
if std::path::Path::new("./microclaw.config.yaml").exists() {
return PathBuf::from("./microclaw.config.yaml");
}
if std::path::Path::new("./microclaw.config.yml").exists() {
return PathBuf::from("./microclaw.config.yml");
}
if std::env::var("SNAP").is_ok() {
if let Ok(snap_user_common) = std::env::var("SNAP_USER_COMMON") {
return PathBuf::from(snap_user_common).join("config.yaml");
}
}
PathBuf::from("microclaw.config.yaml")
}
pub fn resolve_config_path() -> Result<Option<PathBuf>, MicroClawError> {
if let Ok(custom) = std::env::var("MICROCLAW_CONFIG") {
let expanded = expand_path(&custom);
if expanded.exists() {
return Ok(Some(expanded));
}
return Err(MicroClawError::Config(format!(
"MICROCLAW_CONFIG points to non-existent file: {custom}"
)));
}
if std::path::Path::new("./microclaw.config.yaml").exists() {
return Ok(Some(PathBuf::from("./microclaw.config.yaml")));
}
if std::path::Path::new("./microclaw.config.yml").exists() {
return Ok(Some(PathBuf::from("./microclaw.config.yml")));
}
Ok(None)
}
fn inferred_channel_enabled(&self, channel: &str) -> bool {
match channel {
"telegram" => {
!self.telegram_bot_token.trim().is_empty() || self.channels.contains_key("telegram")
}
"discord" => {
self.discord_bot_token
.as_deref()
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
|| self.channels.contains_key("discord")
}
"web" => self.web_enabled || self.channels.contains_key("web"),
_ => self.channels.contains_key(channel),
}
}
fn explicit_channel_enabled(&self, channel: &str) -> Option<bool> {
self.channels
.get(channel)
.and_then(|v| v.get("enabled"))
.and_then(|v| v.as_bool())
}
pub fn channel_enabled(&self, channel: &str) -> bool {
let needle = channel.trim().to_lowercase();
if let Some(explicit) = self.explicit_channel_enabled(&needle) {
return explicit;
}
self.inferred_channel_enabled(&needle)
}
pub fn channel_status(&self) -> (Vec<&'static str>, Vec<&'static str>) {
let configured = [
(
"telegram",
!self.telegram_bot_token.trim().is_empty()
|| self.channels.contains_key("telegram"),
),
(
"discord",
self.discord_bot_token
.as_deref()
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
|| self.channels.contains_key("discord"),
),
("slack", self.channels.contains_key("slack")),
("feishu", self.channels.contains_key("feishu")),
("matrix", self.channels.contains_key("matrix")),
("irc", self.channels.contains_key("irc")),
("web", self.web_enabled || self.channels.contains_key("web")),
];
let mut enabled = Vec::new();
let mut configured_but_disabled = Vec::new();
for (name, is_configured) in configured {
if !is_configured {
continue;
}
if self.channel_enabled(name) {
enabled.push(name);
} else {
configured_but_disabled.push(name);
}
}
(enabled, configured_but_disabled)
}
pub fn load() -> Result<Self, MicroClawError> {
let yaml_path = Self::resolve_config_path()?;
if let Some(path) = yaml_path {
return Self::load_from_path(&path);
}
Err(MicroClawError::Config(
"No microclaw.config.yaml found. Run `microclaw setup` to create one.".into(),
))
}
pub fn load_from_path(path: &Path) -> Result<Self, MicroClawError> {
Self::load_from_path_inner(path, false)
}
pub fn load_from_path_for_headless(path: &Path) -> Result<Self, MicroClawError> {
Self::load_from_path_inner(path, true)
}
pub fn for_embedded(
provider: impl Into<String>,
model: impl Into<String>,
api_key: impl Into<String>,
base_url: Option<String>,
) -> Result<Self, MicroClawError> {
let mut config: Config = serde_yaml::from_str("{}")
.map_err(|error| MicroClawError::Config(error.to_string()))?;
config.llm_provider = provider.into();
config.model = model.into();
config.api_key = api_key.into();
config.llm_base_url = base_url;
config.web_enabled = true;
config.post_deserialize()?;
config.web_enabled = false;
config.channels.remove("web");
Ok(config)
}
fn load_from_path_inner(path: &Path, allow_no_channels: bool) -> Result<Self, MicroClawError> {
let path_str = path.to_string_lossy().to_string();
let content = std::fs::read_to_string(path)
.map_err(|e| MicroClawError::Config(format!("Failed to read {path_str}: {e}")))?;
if let Ok(raw) = serde_yaml::from_str::<serde_yaml::Value>(&content) {
if let Some(map) = raw.as_mapping() {
let old_top_level = map
.get(serde_yaml::Value::String("web_auth_token".to_string()))
.is_some();
let old_channel_level = map
.get(serde_yaml::Value::String("channels".to_string()))
.and_then(|v| v.as_mapping())
.and_then(|channels| channels.get(serde_yaml::Value::String("web".to_string())))
.and_then(|v| v.as_mapping())
.map(|web| {
web.contains_key(serde_yaml::Value::String("auth_token".to_string()))
})
.unwrap_or(false);
if old_top_level || old_channel_level {
warn!(
"Deprecated web auth token config detected in {}. \
`web_auth_token` / `channels.web.auth_token` are ignored; migrate to operator password + API keys.",
path_str
);
}
for warning in unknown_top_level_key_warnings(map) {
warn!("{warning} (in {path_str})");
}
}
}
let mut config: Config = serde_yaml::from_str(&content)
.map_err(|error| MicroClawError::Config(friendly_yaml_error(&path_str, &error)))?;
let synthesized_channel = allow_no_channels && config.channel_status().0.is_empty();
if synthesized_channel {
config.web_enabled = true;
}
config.post_deserialize()?;
if synthesized_channel {
config.web_enabled = false;
config.channels.remove("web");
}
Ok(config)
}
pub fn post_deserialize(&mut self) -> Result<(), MicroClawError> {
self.llm_provider = self.llm_provider.trim().to_lowercase();
self.model = resolve_model_name_with_fallback(&self.llm_provider, Some(&self.model), None);
self.provider_presets =
normalize_provider_profiles(std::mem::take(&mut self.provider_presets));
self.llm_providers = normalize_provider_profiles(std::mem::take(&mut self.llm_providers));
for (alias, preset) in self.provider_presets.clone() {
self.llm_providers
.entry(alias)
.and_modify(|existing| {
*existing = merge_provider_profile(preset.clone(), existing.clone());
})
.or_insert(preset);
}
for channel_cfg in self.channels.values_mut() {
migrate_model_override_alias_to_provider_preset(channel_cfg, &self.provider_presets);
}
self.override_timezone = self
.override_timezone
.as_ref()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
if self
.override_timezone
.as_deref()
.is_some_and(|v| v.eq_ignore_ascii_case("auto"))
{
self.override_timezone = None;
}
let tz_raw = self
.override_timezone
.clone()
.unwrap_or_else(|| "auto".to_string());
if tz_raw.eq_ignore_ascii_case("auto") {
let detected = detect_system_timezone();
if detected.parse::<chrono_tz::Tz>().is_ok() {
self.timezone = detected;
} else {
warn!(
"Detected system timezone '{}' is not recognized by chrono-tz. Falling back to UTC.",
detected
);
self.timezone = "UTC".into();
}
} else {
tz_raw
.parse::<chrono_tz::Tz>()
.map_err(|_| MicroClawError::Config(format!("Invalid timezone: {tz_raw}")))?;
self.timezone = tz_raw;
}
if let Some(ref url) = self.llm_base_url {
if url.trim().is_empty() {
self.llm_base_url = None;
}
}
self.llm_user_agent = self.llm_user_agent.trim().to_string();
if self.llm_user_agent.is_empty() {
self.llm_user_agent = default_llm_user_agent();
}
if let Some(dir) = &self.skills_dir {
let trimmed = dir.trim().to_string();
self.skills_dir = if trimmed.is_empty() {
None
} else {
Some(expand_path(&trimmed).to_string_lossy().to_string())
};
}
if let Some(dir) = &self.souls_dir {
let trimmed = dir.trim().to_string();
self.souls_dir = if trimmed.is_empty() {
None
} else {
Some(expand_path(&trimmed).to_string_lossy().to_string())
};
}
if let Some(dir) = &self.plugins.dir {
let trimmed = dir.trim().to_string();
self.plugins.dir = if trimmed.is_empty() {
None
} else {
Some(expand_path(&trimmed).to_string_lossy().to_string())
};
}
if self.working_dir.trim().is_empty() {
self.working_dir = default_working_dir();
} else {
self.working_dir = expand_path(&self.working_dir).to_string_lossy().to_string();
}
self.data_dir = expand_path(&self.data_dir).to_string_lossy().to_string();
self.sandbox.image = self.sandbox.image.trim().to_string();
if self.sandbox.image.is_empty() {
self.sandbox.image = default_sandbox_image();
}
self.sandbox.container_prefix = self.sandbox.container_prefix.trim().to_string();
if self.sandbox.container_prefix.is_empty() {
self.sandbox.container_prefix = default_sandbox_container_prefix();
}
normalize_string_list(&mut self.sandbox.credential_env_allowlist, false);
normalize_string_list(&mut self.egress_policy.allow_hosts, true);
normalize_string_list(&mut self.egress_policy.deny_hosts, true);
normalize_string_list(&mut self.tool_policy.allow_tools, false);
normalize_string_list(&mut self.tool_policy.deny_tools, false);
for grant in &mut self.tool_policy.grants {
grant.channel = grant
.channel
.take()
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty());
grant.principal = grant
.principal
.take()
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| !value.is_empty());
normalize_string_list(&mut grant.allow_tools, false);
normalize_string_list(&mut grant.deny_tools, false);
}
if self.web_host.trim().is_empty() {
self.web_host = default_web_host();
}
self.a2a.public_base_url = self
.a2a
.public_base_url
.as_ref()
.map(|v| v.trim().trim_end_matches('/').to_string())
.filter(|v| !v.is_empty());
self.a2a.agent_name = self
.a2a
.agent_name
.as_ref()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
self.a2a.agent_description = self
.a2a
.agent_description
.as_ref()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
self.a2a.shared_tokens = self
.a2a
.shared_tokens
.drain(..)
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.collect();
self.a2a.peers = self
.a2a
.peers
.drain()
.filter_map(|(name, mut peer)| {
let normalized = name.trim().to_ascii_lowercase();
if normalized.is_empty() {
return None;
}
peer.base_url = peer.base_url.trim().trim_end_matches('/').to_string();
if peer.base_url.is_empty() {
return None;
}
peer.bearer_token = peer
.bearer_token
.as_ref()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
peer.description = peer
.description
.as_ref()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
peer.default_session_key = peer
.default_session_key
.as_ref()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
Some((normalized, peer))
})
.collect();
if let Some(provider) = &self.embedding_provider {
let p = provider.trim().to_lowercase();
self.embedding_provider = if p.is_empty() { None } else { Some(p) };
}
if let Some(v) = &self.embedding_api_key {
if v.trim().is_empty() {
self.embedding_api_key = None;
}
}
if let Some(v) = &self.embedding_base_url {
if v.trim().is_empty() {
self.embedding_base_url = None;
}
}
if let Some(v) = &self.embedding_model {
let m = v.trim().to_string();
self.embedding_model = if m.is_empty() { None } else { Some(m) };
}
if let Some(v) = self.embedding_dim {
if v == 0 {
self.embedding_dim = None;
}
}
if let Some(web_cfg) = self
.channels
.get_mut("web")
.and_then(|v| v.as_mapping_mut())
{
if web_cfg
.remove(serde_yaml::Value::String("auth_token".to_string()))
.is_some()
{
warn!(
"Deprecated `channels.web.auth_token` detected and ignored. \
Use operator password + API keys for Web auth."
);
}
}
if self.web_max_inflight_per_session == 0 {
self.web_max_inflight_per_session = default_web_max_inflight_per_session();
}
if self.web_max_requests_per_window == 0 {
self.web_max_requests_per_window = default_web_max_requests_per_window();
}
if self.web_rate_window_seconds == 0 {
self.web_rate_window_seconds = default_web_rate_window_seconds();
}
if self.web_run_history_limit == 0 {
self.web_run_history_limit = default_web_run_history_limit();
}
if self.web_session_idle_ttl_seconds == 0 {
self.web_session_idle_ttl_seconds = default_web_session_idle_ttl_seconds();
}
self.web_fetch_validation.normalize();
self.web_fetch_url_validation.normalize();
self.web_search.normalize();
if self.max_document_size_mb == 0 {
self.max_document_size_mb = default_max_document_size_mb();
}
if self.default_tool_timeout_secs == 0 {
self.default_tool_timeout_secs = default_tool_timeout_secs();
}
if self.default_mcp_request_timeout_secs == 0 {
self.default_mcp_request_timeout_secs = default_mcp_request_timeout_secs();
}
if self.subagents.max_concurrent == 0 {
self.subagents.max_concurrent = default_subagent_max_concurrent();
}
if self.subagents.max_active_per_chat == 0 {
self.subagents.max_active_per_chat = default_subagent_max_active_per_chat();
}
if self.subagents.run_timeout_secs == 0 {
self.subagents.run_timeout_secs = default_subagent_run_timeout_secs();
}
if self.subagents.max_spawn_depth == 0 {
self.subagents.max_spawn_depth = default_subagent_max_spawn_depth();
}
self.subagents.max_spawn_depth = self.subagents.max_spawn_depth.min(5);
if self.subagents.max_children_per_run == 0 {
self.subagents.max_children_per_run = default_subagent_max_children_per_run();
}
if self.subagents.announce_relay_interval_secs == 0 {
self.subagents.announce_relay_interval_secs =
default_subagent_announce_relay_interval_secs();
}
self.subagents.announce_relay_interval_secs =
self.subagents.announce_relay_interval_secs.clamp(1, 300);
if self.subagents.max_tokens_per_run <= 0 {
self.subagents.max_tokens_per_run = default_subagent_max_tokens_per_run();
}
self.subagents.max_tokens_per_run =
self.subagents.max_tokens_per_run.clamp(2_000, 2_000_000);
if self.subagents.orchestrate_max_workers == 0 {
self.subagents.orchestrate_max_workers = default_subagent_orchestrate_max_workers();
}
self.subagents.orchestrate_max_workers =
self.subagents.orchestrate_max_workers.clamp(1, 12);
self.subagents.acp.normalize();
self.tool_timeout_overrides = self
.tool_timeout_overrides
.drain()
.filter_map(|(name, timeout_secs)| {
let normalized = name.trim().to_ascii_lowercase();
if normalized.is_empty() || timeout_secs == 0 {
None
} else {
Some((normalized, timeout_secs))
}
})
.collect();
self.openai_compat_body_overrides =
normalize_body_override_params(std::mem::take(&mut self.openai_compat_body_overrides));
self.openai_compat_body_overrides_by_provider = self
.openai_compat_body_overrides_by_provider
.drain()
.filter_map(|(provider, params)| {
let provider = provider.trim().to_ascii_lowercase();
if provider.is_empty() {
return None;
}
let params = normalize_body_override_params(params);
if params.is_empty() {
None
} else {
Some((provider, params))
}
})
.collect();
self.openai_compat_body_overrides_by_model = self
.openai_compat_body_overrides_by_model
.drain()
.filter_map(|(model, params)| {
let model = model.trim().to_string();
if model.is_empty() {
return None;
}
let params = normalize_body_override_params(params);
if params.is_empty() {
None
} else {
Some((model, params))
}
})
.collect();
if self.memory_token_budget == 0 {
self.memory_token_budget = default_memory_token_budget();
}
for price in &mut self.model_prices {
price.model = price.model.trim().to_string();
if price.model.is_empty() {
return Err(MicroClawError::Config(
"model_prices entries must include non-empty model".into(),
));
}
if !(price.input_per_million_usd.is_finite() && price.input_per_million_usd >= 0.0) {
return Err(MicroClawError::Config(format!(
"model_prices[{}].input_per_million_usd must be >= 0",
price.model
)));
}
if !(price.output_per_million_usd.is_finite() && price.output_per_million_usd >= 0.0) {
return Err(MicroClawError::Config(format!(
"model_prices[{}].output_per_million_usd must be >= 0",
price.model
)));
}
}
if self.channels.is_empty() {
if !self.telegram_bot_token.trim().is_empty() {
self.channels.insert(
"telegram".into(),
serde_yaml::to_value(serde_json::json!({
"enabled": true,
"bot_token": self.telegram_bot_token,
"bot_username": self.bot_username,
"allowed_groups": self.allowed_groups,
}))
.unwrap(),
);
}
if let Some(ref token) = self.discord_bot_token {
if !token.trim().is_empty() {
self.channels.insert(
"discord".into(),
serde_yaml::to_value(serde_json::json!({
"enabled": true,
"bot_token": token,
"allowed_channels": self.discord_allowed_channels,
"no_mention": self.discord_no_mention,
}))
.unwrap(),
);
}
}
if self.web_enabled {
self.channels.insert(
"web".into(),
serde_yaml::to_value(serde_json::json!({
"enabled": true,
"host": self.web_host,
"port": self.web_port,
}))
.unwrap(),
);
}
}
let (enabled_channels, configured_but_disabled) = self.channel_status();
if enabled_channels.is_empty() {
let hint = if configured_but_disabled.is_empty() {
String::new()
} else {
format!(
" These channels are configured but not enabled: {}. \
Set `channels.<name>.enabled: true` (or run `microclaw setup`).",
configured_but_disabled.join(", ")
)
};
return Err(MicroClawError::Config(format!(
"At least one channel must be enabled and configured (via channels.<name>.enabled or legacy channel settings).{hint}"
)));
}
if self.api_key.is_empty() && !provider_allows_empty_api_key(&self.llm_provider) {
return Err(MicroClawError::Config("api_key is required".into()));
}
if is_openai_codex_provider(&self.llm_provider) {
if !self.api_key.trim().is_empty() {
return Err(MicroClawError::Config(
"openai-codex ignores microclaw.config.yaml api_key. Configure ~/.codex/auth.json or run `codex login` instead.".into(),
));
}
if self
.llm_base_url
.as_ref()
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
{
return Err(MicroClawError::Config(
"openai-codex ignores microclaw.config.yaml llm_base_url. Configure ~/.codex/config.toml instead.".into(),
));
}
let has_codex_auth = codex_auth_file_has_access_token()?;
if !has_codex_auth {
return Err(MicroClawError::Config(
"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(),
));
}
}
if is_qwen_portal_provider(&self.llm_provider) && self.api_key.trim().is_empty() {
let has_qwen_auth = qwen_oauth_file_has_access_token()?;
if !has_qwen_auth {
return Err(MicroClawError::Config(
"qwen-portal requires api_key, or ~/.qwen/oauth_creds.json (access_token), or QWEN_PORTAL_ACCESS_TOKEN.".into(),
));
}
}
self.validate_configured_egress()?;
Ok(())
}
fn validate_configured_egress(&self) -> Result<(), MicroClawError> {
if self.egress_policy.mode == EgressPolicyMode::Off {
return Ok(());
}
for url in self.configured_egress_endpoints() {
let decision =
crate::internal::tool_runtime::egress::evaluate_url(&self.egress_policy, &url);
match decision {
crate::internal::tool_runtime::egress::EgressDecision::Warn(reason) => {
warn!(url = %url, reason = %reason, "configured endpoint violates egress policy");
}
crate::internal::tool_runtime::egress::EgressDecision::Block(reason) => {
return Err(MicroClawError::Config(format!(
"configured endpoint `{url}` blocked by egress policy: {reason}"
)));
}
crate::internal::tool_runtime::egress::EgressDecision::Allow => {}
}
}
Ok(())
}
fn configured_egress_endpoints(&self) -> Vec<String> {
let mut urls = Vec::new();
{
let mut add = |value: Option<&str>| {
if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
urls.push(value.to_string());
}
};
add(self.llm_base_url.as_deref());
for profile in self
.provider_presets
.values()
.chain(self.llm_providers.values())
{
add(profile.llm_base_url.as_deref());
}
add(self.embedding_base_url.as_deref());
add(self.openai_base_url.as_deref());
let media_enabled = self.media.image_gen.enabled
|| self.media.vision.enabled
|| self.media.tts.enabled
|| self.media.stt.enabled
|| self.media.podcast.enabled;
if media_enabled {
add(self.media.base_url.as_deref());
}
if self.media.tts.enabled || self.media.podcast.enabled {
add(self.media.tts.base_url.as_deref());
}
if self.media.podcast.enabled {
add(self.media.podcast.base_url.as_deref());
}
if self.web_search.effective_backend()
== crate::internal::tool_runtime::web_search::SearchBackend::Searxng
{
add(Some(&self.web_search.searxng_base_url));
}
for peer in self.a2a.peers.values().filter(|peer| peer.enabled) {
add(Some(&peer.base_url));
}
if self.clawhub.agent_tools_enabled {
add(Some(&self.clawhub.registry));
}
if self.alerts.enabled {
add(Some(&self.alerts.webhook_url));
}
}
for channel in self.channels.values() {
collect_enabled_channel_endpoints(channel, &mut urls);
}
urls.sort();
urls.dedup();
urls
}
fn merged_profile_from_alias(&self, alias: &str) -> Option<ResolvedLlmProviderProfile> {
let alias = alias.trim().to_ascii_lowercase();
if alias.is_empty() {
return None;
}
if alias == self.llm_provider {
let mut provider = self.llm_provider.clone();
let mut api_key = self.api_key.clone();
let mut api_keys = self.api_keys.clone();
let mut llm_base_url = self.llm_base_url.clone();
let mut llm_user_agent = self.llm_user_agent.clone();
let mut default_model = self.model.clone();
let mut models = vec![default_model.clone()];
let mut show_thinking = self.show_thinking;
if let Some(profile) = self.llm_providers.get(&alias) {
if let Some(v) = &profile.provider {
provider = v.clone();
}
if let Some(v) = &profile.api_key {
api_key = v.clone();
}
if !profile.api_keys.is_empty() {
api_keys = profile.api_keys.clone();
}
if let Some(v) = &profile.llm_base_url {
llm_base_url = Some(v.clone());
}
if let Some(v) = &profile.llm_user_agent {
llm_user_agent = v.clone();
}
if let Some(v) = &profile.default_model {
default_model = v.clone();
}
if !profile.models.is_empty() {
models = profile.models.clone();
}
if let Some(v) = profile.show_thinking {
show_thinking = v;
}
}
default_model = resolve_model_name_with_fallback(
&provider,
Some(&default_model),
Some(&self.model),
);
models = models
.into_iter()
.filter_map(|model| normalize_model_name(&model))
.collect();
if !models.iter().any(|m| m == &default_model) {
models.push(default_model.clone());
}
models.sort();
models.dedup();
return Some(ResolvedLlmProviderProfile {
alias,
provider,
api_key,
api_keys,
llm_base_url,
llm_user_agent,
default_model,
models,
show_thinking,
});
}
let profile = self.llm_providers.get(&alias)?;
let provider = profile.provider.clone().unwrap_or_else(|| alias.clone());
let api_key = profile
.api_key
.clone()
.unwrap_or_else(|| self.api_key.clone());
let api_keys = if !profile.api_keys.is_empty() {
profile.api_keys.clone()
} else if profile.api_key.is_none() {
self.api_keys.clone()
} else {
Vec::new()
};
let llm_base_url = profile
.llm_base_url
.clone()
.or_else(|| self.llm_base_url.clone());
let llm_user_agent = profile
.llm_user_agent
.clone()
.unwrap_or_else(|| self.llm_user_agent.clone());
let default_model = resolve_model_name_with_fallback(
&provider,
profile.default_model.as_deref(),
Some(&self.model),
);
let mut models = if profile.models.is_empty() {
vec![default_model.clone()]
} else {
profile
.models
.clone()
.into_iter()
.filter_map(|model| normalize_model_name(&model))
.collect()
};
let show_thinking = profile.show_thinking.unwrap_or(self.show_thinking);
if !models.iter().any(|m| m == &default_model) {
models.push(default_model.clone());
}
models.sort();
models.dedup();
Some(ResolvedLlmProviderProfile {
alias,
provider,
api_key,
api_keys,
llm_base_url,
llm_user_agent,
default_model,
models,
show_thinking,
})
}
pub fn resolve_llm_provider_profile(&self, alias: &str) -> Option<ResolvedLlmProviderProfile> {
self.merged_profile_from_alias(alias)
}
pub fn list_llm_provider_profiles(&self) -> Vec<ResolvedLlmProviderProfile> {
let mut out = Vec::new();
if let Some(default_profile) = self.resolve_llm_provider_profile(&self.llm_provider) {
out.push(default_profile);
}
let mut aliases: Vec<String> = self.llm_providers.keys().cloned().collect();
aliases.sort();
aliases.dedup();
for alias in aliases {
if alias == self.llm_provider {
continue;
}
if let Some(profile) = self.resolve_llm_provider_profile(&alias) {
out.push(profile);
}
}
out
}
pub fn channel_config<T: DeserializeOwned>(&self, name: &str) -> Option<T> {
self.channels
.get(name)
.and_then(|v| serde_yaml::from_value(v.clone()).ok())
}
pub fn model_price(&self, model: &str) -> Option<&ModelPrice> {
let needle = model.trim();
self.model_prices
.iter()
.find(|p| p.model.eq_ignore_ascii_case(needle))
.or_else(|| self.model_prices.iter().find(|p| p.model == "*"))
}
pub fn estimate_cost_usd(
&self,
model: &str,
input_tokens: i64,
output_tokens: i64,
) -> Option<f64> {
let price = self.model_price(model)?;
let in_tok = input_tokens.max(0) as f64;
let out_tok = output_tokens.max(0) as f64;
Some(
(in_tok / 1_000_000.0) * price.input_per_million_usd
+ (out_tok / 1_000_000.0) * price.output_per_million_usd,
)
}
pub fn tool_timeout_secs(&self, tool_name: &str, fallback: u64) -> u64 {
let normalized = tool_name.trim().to_ascii_lowercase();
if let Some(timeout_secs) = self.tool_timeout_overrides.get(&normalized) {
return *timeout_secs;
}
if self.default_tool_timeout_secs == 0 {
fallback
} else {
self.default_tool_timeout_secs
}
}
pub fn mcp_request_timeout_secs(&self) -> u64 {
if self.default_mcp_request_timeout_secs == 0 {
default_mcp_request_timeout_secs()
} else {
self.default_mcp_request_timeout_secs
}
}
#[allow(dead_code)]
pub fn save_yaml(&self, path: &str) -> Result<(), MicroClawError> {
let content = serde_yaml::to_string(self)
.map_err(|e| MicroClawError::Config(format!("Failed to serialize config: {e}")))?;
std::fs::write(path, content)?;
Ok(())
}
}
pub(crate) fn normalize_body_override_params(
params: HashMap<String, serde_json::Value>,
) -> HashMap<String, serde_json::Value> {
params
.into_iter()
.filter_map(|(key, value)| {
let key = key.trim().to_string();
if key.is_empty() {
None
} else {
Some((key, value))
}
})
.collect()
}
pub(crate) fn normalize_provider_profiles(
profiles: HashMap<String, LlmProviderProfile>,
) -> HashMap<String, LlmProviderProfile> {
profiles
.into_iter()
.filter_map(|(alias, mut profile)| {
let alias = alias.trim().to_ascii_lowercase();
if alias.is_empty() {
return None;
}
profile.provider = profile
.provider
.as_ref()
.map(|v| v.trim().to_ascii_lowercase())
.filter(|v| !v.is_empty());
profile.api_key = profile
.api_key
.as_ref()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
profile.llm_base_url = profile
.llm_base_url
.as_ref()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
profile.llm_user_agent = profile
.llm_user_agent
.as_ref()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
profile.default_model = profile
.default_model
.as_ref()
.and_then(|v| normalize_model_name(v));
profile.models = profile
.models
.into_iter()
.filter_map(|m| normalize_model_name(&m))
.collect::<Vec<_>>();
profile.models.sort();
profile.models.dedup();
Some((alias, profile))
})
.collect()
}
pub(crate) fn migrate_model_override_alias_to_provider_preset(
value: &mut serde_yaml::Value,
known_profiles: &HashMap<String, LlmProviderProfile>,
) {
let Some(map) = value.as_mapping_mut() else {
return;
};
let provider_preset_key = serde_yaml::Value::String("provider_preset".to_string());
let llm_provider_key = serde_yaml::Value::String("llm_provider".to_string());
let model_key = serde_yaml::Value::String("model".to_string());
let has_provider_override =
map.contains_key(&provider_preset_key) || map.contains_key(&llm_provider_key);
if !has_provider_override {
let maybe_profile_alias = map
.get(&model_key)
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(|v| v.to_ascii_lowercase());
if let Some(alias) = maybe_profile_alias {
if known_profiles.contains_key(&alias) {
map.insert(
provider_preset_key.clone(),
serde_yaml::Value::String(alias),
);
map.remove(&model_key);
}
}
}
let accounts_key = serde_yaml::Value::String("accounts".to_string());
if let Some(accounts) = map.get_mut(&accounts_key).and_then(|v| v.as_mapping_mut()) {
for (_, account) in accounts.iter_mut() {
migrate_model_override_alias_to_provider_preset(account, known_profiles);
}
}
}
pub(crate) fn merge_provider_profile(
base: LlmProviderProfile,
override_profile: LlmProviderProfile,
) -> LlmProviderProfile {
let mut models = if override_profile.models.is_empty() {
base.models
} else {
override_profile.models
};
models.sort();
models.dedup();
LlmProviderProfile {
provider: override_profile.provider.or(base.provider),
api_key: override_profile.api_key.or(base.api_key),
api_keys: if override_profile.api_keys.is_empty() {
base.api_keys
} else {
override_profile.api_keys
},
llm_base_url: override_profile.llm_base_url.or(base.llm_base_url),
llm_user_agent: override_profile.llm_user_agent.or(base.llm_user_agent),
default_model: override_profile.default_model.or(base.default_model),
models,
show_thinking: override_profile.show_thinking.or(base.show_thinking),
}
}
#[derive(Debug, Default)]
pub struct ConfigCheckReport {
pub errors: Vec<String>,
pub warnings: Vec<String>,
pub summary: Option<(Vec<&'static str>, Vec<&'static str>, String, String)>,
}
impl ConfigCheckReport {
pub fn ok(&self) -> bool {
self.errors.is_empty()
}
}
pub fn check_config_content(path_str: &str, content: &str) -> ConfigCheckReport {
let mut report = ConfigCheckReport::default();
let raw: serde_yaml::Value = match serde_yaml::from_str(content) {
Ok(v) => v,
Err(e) => {
report.errors.push(format!("YAML syntax error: {e}"));
return report;
}
};
if let Some(map) = raw.as_mapping() {
report.warnings.extend(unknown_top_level_key_warnings(map));
} else {
report
.errors
.push("Config root must be a YAML mapping (key: value pairs).".to_string());
return report;
}
match serde_yaml::from_str::<Config>(content) {
Err(e) => {
report.errors.push(friendly_yaml_error(path_str, &e));
}
Ok(mut config) => match config.post_deserialize() {
Err(e) => report.errors.push(e.to_string()),
Ok(()) => {
let (enabled, disabled) = config.channel_status();
report.summary = Some((
enabled,
disabled,
config.llm_provider.clone(),
config.model.clone(),
));
}
},
}
report
}
pub fn run_config_check() -> i32 {
let path = match Config::resolve_config_path() {
Ok(Some(p)) => p,
Ok(None) => {
eprintln!("✗ No microclaw.config.yaml found. Run `microclaw setup` to create one.");
return 2;
}
Err(e) => {
eprintln!("✗ {e}");
return 2;
}
};
let path_str = path.to_string_lossy().to_string();
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
eprintln!("✗ Failed to read {path_str}: {e}");
return 2;
}
};
println!("Checking {path_str}");
let report = check_config_content(&path_str, &content);
for w in &report.warnings {
println!(" âš {w}");
}
for e in &report.errors {
println!(" ✗ {e}");
}
if let Some((enabled, disabled, provider, model)) = &report.summary {
let enabled_str = if enabled.is_empty() {
"none".to_string()
} else {
enabled.join(", ")
};
println!(" provider: {provider} · model: {model}");
println!(" channels enabled: {enabled_str}");
if !disabled.is_empty() {
println!(" configured but disabled: {}", disabled.join(", "));
}
}
if report.ok() {
println!(
"✓ Config OK{}",
if report.warnings.is_empty() {
""
} else {
" (with warnings)"
}
);
0
} else {
println!("✗ Config has errors — fix them and re-run, or run `microclaw setup`.");
1
}
}
pub(crate) fn friendly_yaml_error(path_str: &str, err: &serde_yaml::Error) -> String {
let raw = err.to_string();
let mut hint = String::new();
if let Some((kind, token)) = extract_unknown_token(&raw) {
match suggest_closest(token, &extract_backtick_options(&raw)) {
Some(best) => {
hint = format!("\n\n Unknown {kind} `{token}` — did you mean `{best}`?");
}
None => hint = format!("\n\n Unknown {kind} `{token}`."),
}
}
format!(
"Failed to parse {path_str}: {raw}{hint}\n\n \
Edit the config and re-run, or run `microclaw setup`.\n \
Reference: https://github.com/microclaw/microclaw/blob/main/microclaw.config.example.yaml"
)
}
pub(crate) fn known_top_level_config_keys() -> Option<std::collections::BTreeSet<String>> {
let base: Config = serde_yaml::from_str("{}").ok()?;
let serde_yaml::Value::Mapping(serialized) = serde_yaml::to_value(&base).ok()? else {
return None;
};
let mut known: std::collections::BTreeSet<String> = serialized
.keys()
.filter_map(|k| k.as_str().map(str::to_string))
.collect();
for k in ["timezone", "override_timezone", "web_auth_token"] {
known.insert(k.to_string());
}
if known.len() < 5 {
return None;
}
Some(known)
}
pub(crate) fn unknown_top_level_key_warnings(map: &serde_yaml::Mapping) -> Vec<String> {
let Some(known) = known_top_level_config_keys() else {
return Vec::new();
};
let candidates: Vec<String> = known.iter().cloned().collect();
let mut warnings = Vec::new();
for key in map.keys() {
let Some(name) = key.as_str() else { continue };
if known.contains(name) {
continue;
}
match suggest_closest(name, &candidates) {
Some(best) => warnings.push(format!(
"Unknown config key `{name}` (ignored) — did you mean `{best}`?"
)),
None => warnings.push(format!("Unknown config key `{name}` (ignored).")),
}
}
warnings
}
pub(crate) fn extract_unknown_token(raw: &str) -> Option<(&'static str, &str)> {
for (needle, kind) in [
("unknown variant `", "variant"),
("unknown field `", "field"),
] {
if let Some(start) = raw.find(needle) {
let rest = &raw[start + needle.len()..];
if let Some(end) = rest.find('`') {
return Some((kind, &rest[..end]));
}
}
}
None
}
pub(crate) fn extract_backtick_options(raw: &str) -> Vec<String> {
let Some(exp) = raw.find("expected") else {
return Vec::new();
};
let mut out = Vec::new();
let mut rest = &raw[exp..];
while let Some(open) = rest.find('`') {
rest = &rest[open + 1..];
if let Some(close) = rest.find('`') {
out.push(rest[..close].to_string());
rest = &rest[close + 1..];
} else {
break;
}
}
out
}
pub(crate) fn suggest_closest(token: &str, options: &[String]) -> Option<String> {
let threshold = (token.chars().count() / 3).max(2);
options
.iter()
.map(|o| (levenshtein(token, o), o))
.filter(|(d, _)| *d <= threshold)
.min_by_key(|(d, _)| *d)
.map(|(_, o)| o.clone())
}
pub(crate) fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
cur[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[allow(unused_imports)]
use crate::config::test_prelude::*;
#[test]
fn config_check_reports_syntax_errors_with_location() {
let report = check_config_content("t.yaml", "telegram_bot_token: [unclosed");
assert!(!report.ok());
assert!(report.errors[0].contains("YAML syntax error"));
assert!(report.summary.is_none());
}
#[test]
fn config_check_warns_on_unknown_key_with_suggestion() {
let report = check_config_content(
"t.yaml",
"telegram_bot_token: tok\nbot_username: bot\napi_key: key\nmax_tool_iterationz: 5\n",
);
assert!(report.ok(), "unknown keys warn but do not fail the check");
assert!(
report
.warnings
.iter()
.any(|w| w.contains("max_tool_iterationz") && w.contains("max_tool_iterations")),
"expected did-you-mean warning, got: {:?}",
report.warnings
);
let (enabled, _, provider, _) = report.summary.as_ref().unwrap();
assert!(enabled.contains(&"telegram"));
assert_eq!(provider, "anthropic");
}
#[test]
fn config_check_rejects_bad_enum_value_with_suggestion() {
let report = check_config_content("t.yaml", "api_key: key\ntool_policy:\n mode: blok\n");
assert!(!report.ok());
assert!(
report.errors[0].contains("blok") && report.errors[0].contains("block"),
"expected did-you-mean on enum typo, got: {:?}",
report.errors
);
}
#[test]
fn config_check_flags_no_enabled_channel() {
let report = check_config_content("t.yaml", "api_key: key\n");
assert!(report.ok(), "errors: {:?}", report.errors);
let (enabled, ..) = report.summary.as_ref().unwrap();
assert!(enabled.contains(&"web"));
let report = check_config_content(
"t.yaml",
"api_key: key\nchannels:\n web:\n enabled: false\n",
);
assert!(
!report.ok(),
"disabling all channels should be an error, got warnings: {:?}",
report.warnings
);
}
#[test]
fn config_check_rejects_non_mapping_root() {
let report = check_config_content("t.yaml", "- just\n- a\n- list\n");
assert!(!report.ok());
assert!(report.errors[0].contains("mapping"));
}
#[test]
fn friendly_error_suggests_closest_field() {
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Mini {
#[allow(dead_code)]
discord: bool,
}
let err = serde_yaml::from_str::<Mini>("discrod: true\n").unwrap_err();
let msg = friendly_yaml_error("cfg.yaml", &err);
assert!(msg.contains("did you mean `discord`?"), "got: {msg}");
assert!(msg.contains("microclaw setup"));
}
#[test]
fn extract_and_suggest_helpers() {
let raw = "unknown variant `discrod`, expected one of `telegram`, `discord`, `slack`";
assert_eq!(extract_unknown_token(raw), Some(("variant", "discrod")));
let opts = extract_backtick_options(raw);
assert_eq!(opts, vec!["telegram", "discord", "slack"]);
assert_eq!(
suggest_closest("discrod", &opts).as_deref(),
Some("discord")
);
assert_eq!(suggest_closest("zzzzzz", &opts), None);
}
#[test]
fn levenshtein_basics() {
assert_eq!(levenshtein("kitten", "sitting"), 3);
assert_eq!(levenshtein("discord", "discord"), 0);
assert_eq!(levenshtein("", "abc"), 3);
}
#[test]
fn channel_status_splits_enabled_and_disabled() {
let cfg: Config = serde_yaml::from_str(
"api_key: k\nweb_enabled: false\nchannels:\n telegram:\n enabled: true\n discord:\n enabled: false\n",
)
.unwrap();
let (enabled, disabled) = cfg.channel_status();
assert_eq!(enabled, vec!["telegram"]);
assert_eq!(disabled, vec!["discord"]);
}
#[test]
fn no_enabled_channel_error_names_disabled_channel() {
let mut cfg: Config = serde_yaml::from_str(
"api_key: k\nweb_enabled: false\nchannels:\n telegram:\n enabled: false\n",
)
.unwrap();
let err = cfg.post_deserialize().unwrap_err().to_string();
assert!(err.contains("configured but not enabled"), "got: {err}");
assert!(err.contains("telegram"), "got: {err}");
}
#[test]
fn aux_models_parse_from_yaml() {
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";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
config.aux_models.compaction.as_deref(),
Some("claude-haiku-4-5")
);
assert_eq!(
config.aux_models.reflector_model(),
Some("claude-haiku-4-5")
);
}
#[test]
fn aux_models_absent_yaml_defaults_to_none() {
let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.aux_models.compaction.is_none());
}
#[test]
fn aux_models_title_vision_parse_from_yaml() {
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";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.aux_models.title_model(), Some("claude-haiku-4-5"));
assert_eq!(config.aux_models.vision_model(), Some("claude-haiku-4-5"));
}
#[test]
fn test_clawhub_config_defaults() {
let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.clawhub.registry, "https://clawhub.ai");
assert!(config.clawhub.agent_tools_enabled);
}
#[test]
fn test_voice_config_defaults() {
let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.voice_provider, "openai");
assert!(config.voice_transcription_command.is_none());
}
#[test]
fn test_voice_config_local_provider() {
let yaml = r#"
telegram_bot_token: tok
bot_username: bot
api_key: key
voice_provider: "local"
voice_transcription_command: "whisper-mlx --file {file}"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.voice_provider, "local");
assert_eq!(
config.voice_transcription_command,
Some("whisper-mlx --file {file}".into())
);
}
#[test]
fn test_config_yaml_roundtrip() {
let config = test_config();
let yaml = serde_yaml::to_string(&config).unwrap();
let parsed: Config = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(parsed.telegram_bot_token, "tok");
assert_eq!(parsed.max_tokens, 8192);
assert_eq!(parsed.llm_provider, "anthropic");
}
#[test]
fn test_default_data_dir_uses_microclaw_home() {
let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.data_dir.ends_with(".microclaw"));
}
#[test]
fn test_config_sandbox_defaults_to_off() {
let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(matches!(config.sandbox.mode, SandboxMode::Off));
assert!(matches!(config.sandbox.backend, SandboxBackend::Auto));
assert!(config.sandbox.require_runtime);
assert_eq!(config.sandbox.image, "ubuntu:25.10");
}
#[test]
fn test_post_deserialize_empty_working_dir_uses_default() {
let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nworking_dir: ' '\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
assert!(std::path::PathBuf::from(&config.working_dir)
.ends_with(std::path::Path::new(".microclaw").join("working_dir")));
}
#[test]
fn test_post_deserialize_zero_memory_budget_uses_default() {
let yaml =
"telegram_bot_token: tok\nbot_username: bot\napi_key: key\nmemory_token_budget: 0\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
assert_eq!(config.memory_token_budget, 1500);
}
#[test]
fn test_config_working_dir_isolation_defaults_to_chat() {
let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(matches!(
config.working_dir_isolation,
WorkingDirIsolation::Chat
));
}
#[test]
fn test_config_working_dir_isolation_accepts_chat() {
let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nworking_dir_isolation: chat\n";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(matches!(
config.working_dir_isolation,
WorkingDirIsolation::Chat
));
}
#[test]
fn test_config_working_dir_isolation_accepts_true() {
let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nworking_dir_isolation: true\n";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(matches!(
config.working_dir_isolation,
WorkingDirIsolation::Chat
));
}
#[test]
fn test_config_working_dir_isolation_accepts_false() {
let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nworking_dir_isolation: false\n";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(matches!(
config.working_dir_isolation,
WorkingDirIsolation::Shared
));
}
#[test]
fn test_high_risk_tool_user_confirmation_required_defaults_true() {
let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\n";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.high_risk_tool_user_confirmation_required);
}
#[test]
fn test_high_risk_tool_user_confirmation_required_accepts_false() {
let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nhigh_risk_tool_user_confirmation_required: false\n";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(!config.high_risk_tool_user_confirmation_required);
}
#[test]
fn test_config_post_deserialize() {
let yaml =
"telegram_bot_token: tok\nbot_username: bot\napi_key: key\nllm_provider: ANTHROPIC\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
assert_eq!(config.llm_provider, "anthropic");
assert_eq!(config.model, "claude-sonnet-4-5-20250929");
}
#[test]
fn test_post_deserialize_invalid_timezone() {
let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\noverride_timezone: Mars/Olympus\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
let err = config.post_deserialize().unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Invalid timezone"));
}
#[test]
fn test_post_deserialize_auto_timezone_resolves_to_valid_tz() {
let yaml =
"telegram_bot_token: tok\nbot_username: bot\napi_key: key\noverride_timezone: auto\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
assert!(config.timezone.parse::<chrono_tz::Tz>().is_ok());
}
#[test]
fn test_post_deserialize_missing_api_key() {
let yaml = "telegram_bot_token: tok\nbot_username: bot\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
let err = config.post_deserialize().unwrap_err();
let msg = err.to_string();
assert!(msg.contains("api_key is required"));
}
#[test]
fn test_post_deserialize_openai_codex_allows_empty_api_key() {
let _guard = env_lock();
let prev_codex_home = std::env::var("CODEX_HOME").ok();
let prev_access = std::env::var("OPENAI_CODEX_ACCESS_TOKEN").ok();
std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
let auth_dir = std::env::temp_dir().join(format!(
"microclaw-codex-auth-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&auth_dir).unwrap();
std::fs::write(
auth_dir.join("auth.json"),
r#"{"tokens":{"access_token":"tok"}}"#,
)
.unwrap();
std::env::set_var("CODEX_HOME", &auth_dir);
let yaml =
"telegram_bot_token: tok\nbot_username: bot\nllm_provider: openai-codex\nmodel: gpt-5.3-codex\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
if let Some(prev) = prev_codex_home {
std::env::set_var("CODEX_HOME", prev);
} else {
std::env::remove_var("CODEX_HOME");
}
if let Some(prev) = prev_access {
std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev);
} else {
std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
}
let _ = std::fs::remove_file(auth_dir.join("auth.json"));
let _ = std::fs::remove_dir(auth_dir);
assert_eq!(config.llm_provider, "openai-codex");
}
#[test]
fn test_post_deserialize_missing_bot_tokens() {
let yaml = "bot_username: bot\napi_key: key\nweb_enabled: false\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
let err = config.post_deserialize().unwrap_err();
let msg = err.to_string();
assert!(msg.contains("channel must be enabled"));
}
#[test]
fn test_post_deserialize_discord_only() {
let yaml = "bot_username: bot\napi_key: key\ndiscord_bot_token: discord_tok\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
}
#[test]
fn test_post_deserialize_irc_only() {
let yaml = r##"
api_key: key
channels:
irc:
enabled: true
server: "irc.example.com"
nick: "microclaw"
channels: "#microclaw"
"##;
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
assert!(config.channel_enabled("irc"));
}
#[test]
fn test_post_deserialize_matrix_only() {
let yaml = r##"
api_key: key
channels:
matrix:
enabled: true
homeserver_url: "https://matrix.example.com"
access_token: "syt_xxx"
bot_user_id: "@microclaw:example.com"
"##;
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
assert!(config.channel_enabled("matrix"));
}
#[test]
fn test_post_deserialize_channel_enabled_flag_overrides_legacy_inference() {
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";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
assert!(!config.channel_enabled("telegram"));
assert!(config.channel_enabled("discord"));
assert!(!config.channel_enabled("web"));
}
#[test]
fn test_post_deserialize_channel_enabled_flag_controls_web() {
let yaml =
"api_key: key\ndiscord_bot_token: discord_tok\nchannels:\n web:\n enabled: false\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
assert!(!config.channel_enabled("web"));
}
#[test]
fn test_post_deserialize_openai_default_model() {
let yaml =
"telegram_bot_token: tok\nbot_username: bot\napi_key: key\nllm_provider: openai\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
assert_eq!(config.model, "gpt-5.2");
}
#[test]
fn test_post_deserialize_wildcard_model_falls_back_to_provider_default() {
let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nllm_provider: openai\nmodel: '*'\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
assert_eq!(config.model, "gpt-5.2");
}
#[test]
fn test_post_deserialize_openai_codex_default_model() {
let _guard = env_lock();
let prev_codex_home = std::env::var("CODEX_HOME").ok();
let prev_access = std::env::var("OPENAI_CODEX_ACCESS_TOKEN").ok();
std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
let auth_dir = std::env::temp_dir().join(format!(
"microclaw-codex-auth-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&auth_dir).unwrap();
std::fs::write(
auth_dir.join("auth.json"),
r#"{"tokens":{"access_token":"tok"}}"#,
)
.unwrap();
std::env::set_var("CODEX_HOME", &auth_dir);
let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: openai-codex\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
if let Some(prev) = prev_codex_home {
std::env::set_var("CODEX_HOME", prev);
} else {
std::env::remove_var("CODEX_HOME");
}
if let Some(prev) = prev_access {
std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev);
} else {
std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
}
let _ = std::fs::remove_file(auth_dir.join("auth.json"));
let _ = std::fs::remove_dir(auth_dir);
assert_eq!(config.model, "gpt-5.3-codex");
}
#[test]
fn test_post_deserialize_openai_codex_missing_oauth_token() {
let _guard = env_lock();
let prev_codex_home = std::env::var("CODEX_HOME").ok();
let prev_access = std::env::var("OPENAI_CODEX_ACCESS_TOKEN").ok();
std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
let auth_dir = std::env::temp_dir().join(format!(
"microclaw-codex-auth-missing-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&auth_dir).unwrap();
std::env::set_var("CODEX_HOME", &auth_dir);
let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: openai-codex\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
let err = config.post_deserialize().unwrap_err();
let msg = err.to_string();
if let Some(prev) = prev_codex_home {
std::env::set_var("CODEX_HOME", prev);
} else {
std::env::remove_var("CODEX_HOME");
}
if let Some(prev) = prev_access {
std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev);
} else {
std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
}
let _ = std::fs::remove_dir(auth_dir);
assert!(msg.contains("openai-codex requires ~/.codex/auth.json"));
}
#[test]
fn test_post_deserialize_openai_codex_rejects_plain_api_key_without_oauth() {
let _guard = env_lock();
let prev_codex_home = std::env::var("CODEX_HOME").ok();
let prev_access = std::env::var("OPENAI_CODEX_ACCESS_TOKEN").ok();
std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
let auth_dir = std::env::temp_dir().join(format!(
"microclaw-codex-auth-plain-key-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&auth_dir).unwrap();
std::env::set_var("CODEX_HOME", &auth_dir);
let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: openai-codex\napi_key: sk-user-stale\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
let err = config.post_deserialize().unwrap_err();
let msg = err.to_string();
if let Some(prev) = prev_codex_home {
std::env::set_var("CODEX_HOME", prev);
} else {
std::env::remove_var("CODEX_HOME");
}
if let Some(prev) = prev_access {
std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev);
} else {
std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
}
let _ = std::fs::remove_dir(auth_dir);
assert!(msg.contains("ignores microclaw.config.yaml api_key"));
}
#[test]
fn test_post_deserialize_openai_codex_allows_env_access_token() {
let _guard = env_lock();
let prev_codex_home = std::env::var("CODEX_HOME").ok();
let prev_access = std::env::var("OPENAI_CODEX_ACCESS_TOKEN").ok();
std::env::remove_var("CODEX_HOME");
std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", "env-token");
let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: openai-codex\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
if let Some(prev) = prev_codex_home {
std::env::set_var("CODEX_HOME", prev);
} else {
std::env::remove_var("CODEX_HOME");
}
if let Some(prev) = prev_access {
std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev);
} else {
std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
}
assert_eq!(config.llm_provider, "openai-codex");
}
#[test]
fn test_post_deserialize_qwen_code_allows_oauth_without_api_key() {
let _guard = env_lock();
let prev_qwen_home = std::env::var("QWEN_HOME").ok();
let prev_qwen_access = std::env::var("QWEN_PORTAL_ACCESS_TOKEN").ok();
std::env::remove_var("QWEN_PORTAL_ACCESS_TOKEN");
let qwen_dir = std::env::temp_dir().join(format!(
"microclaw-qwen-auth-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&qwen_dir).unwrap();
std::fs::write(
qwen_dir.join("oauth_creds.json"),
r#"{"access_token":"qwen-oauth-token"}"#,
)
.unwrap();
std::env::set_var("QWEN_HOME", &qwen_dir);
let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: qwen-portal\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
if let Some(prev) = prev_qwen_home {
std::env::set_var("QWEN_HOME", prev);
} else {
std::env::remove_var("QWEN_HOME");
}
if let Some(prev) = prev_qwen_access {
std::env::set_var("QWEN_PORTAL_ACCESS_TOKEN", prev);
} else {
std::env::remove_var("QWEN_PORTAL_ACCESS_TOKEN");
}
let _ = std::fs::remove_file(qwen_dir.join("oauth_creds.json"));
let _ = std::fs::remove_dir(qwen_dir);
assert_eq!(config.llm_provider, "qwen-portal");
}
#[test]
fn test_post_deserialize_qwen_code_missing_oauth_and_api_key() {
let _guard = env_lock();
let prev_qwen_home = std::env::var("QWEN_HOME").ok();
let prev_qwen_access = std::env::var("QWEN_PORTAL_ACCESS_TOKEN").ok();
std::env::remove_var("QWEN_PORTAL_ACCESS_TOKEN");
let qwen_dir = std::env::temp_dir().join(format!(
"microclaw-qwen-auth-missing-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir_all(&qwen_dir).unwrap();
std::env::set_var("QWEN_HOME", &qwen_dir);
let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: qwen-portal\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
let err = config.post_deserialize().unwrap_err();
if let Some(prev) = prev_qwen_home {
std::env::set_var("QWEN_HOME", prev);
} else {
std::env::remove_var("QWEN_HOME");
}
if let Some(prev) = prev_qwen_access {
std::env::set_var("QWEN_PORTAL_ACCESS_TOKEN", prev);
} else {
std::env::remove_var("QWEN_PORTAL_ACCESS_TOKEN");
}
let _ = std::fs::remove_dir(qwen_dir);
assert!(err
.to_string()
.contains("qwen-portal requires api_key, or ~/.qwen/oauth_creds.json"));
}
#[test]
fn test_post_deserialize_ollama_default_model_and_empty_key() {
let yaml = "telegram_bot_token: tok\nbot_username: bot\nllm_provider: ollama\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
assert_eq!(config.model, "llama3.2");
}
#[test]
fn test_post_deserialize_empty_base_url_becomes_none() {
let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nllm_base_url: ' '\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
assert!(config.llm_base_url.is_none());
}
#[test]
fn test_post_deserialize_empty_llm_user_agent_uses_default() {
let yaml =
"telegram_bot_token: tok\nbot_username: bot\napi_key: key\nllm_user_agent: ' '\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
assert_eq!(
config.llm_user_agent,
crate::http_client::default_llm_user_agent()
);
}
#[test]
fn test_post_deserialize_provider_case_insensitive() {
let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nllm_provider: ' ANTHROPIC '\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
assert_eq!(config.llm_provider, "anthropic");
assert_eq!(config.model, "claude-sonnet-4-5-20250929");
}
#[test]
fn test_post_deserialize_normalizes_openai_compat_body_overrides() {
let yaml = r#"
telegram_bot_token: tok
bot_username: bot
api_key: key
openai_compat_body_overrides:
" temperature ": 0.2
" ": 1
openai_compat_body_overrides_by_provider:
" OPENAI ":
" top_p ": 0.95
"": 1
" ":
seed: 7
openai_compat_body_overrides_by_model:
" gpt-5.2 ":
" frequency_penalty ": 0.1
"": 1
" ":
seed: 7
"#;
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
assert_eq!(
config.openai_compat_body_overrides.get("temperature"),
Some(&serde_json::json!(0.2))
);
assert!(!config.openai_compat_body_overrides.contains_key(" "));
let provider_params = config
.openai_compat_body_overrides_by_provider
.get("openai")
.unwrap();
assert_eq!(provider_params.get("top_p"), Some(&serde_json::json!(0.95)));
assert!(!provider_params.contains_key(""));
assert!(!config
.openai_compat_body_overrides_by_provider
.contains_key(" OPENAI "));
let model_params = config
.openai_compat_body_overrides_by_model
.get("gpt-5.2")
.unwrap();
assert_eq!(
model_params.get("frequency_penalty"),
Some(&serde_json::json!(0.1))
);
assert!(!model_params.contains_key(""));
}
#[test]
fn test_post_deserialize_normalizes_subagent_acp_config() {
let yaml = r#"
telegram_bot_token: tok
bot_username: bot
api_key: key
subagents:
acp:
enabled: true
command: " codex "
args: [" --model ", " ", "gpt-5.4"]
env:
" OPENAI_API_KEY ": abc
" ": ignored
default_target: " worker "
targets:
" worker ":
enabled: true
command: " codex-worker "
args: [" --fast "]
env:
" TOKEN ": xyz
"#;
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
assert!(config.subagents.acp.default_target.enabled);
assert_eq!(config.subagents.acp.default_target.command, "codex");
assert_eq!(
config.subagents.acp.default_target.args,
vec!["--model".to_string(), "gpt-5.4".to_string()]
);
assert_eq!(
config
.subagents
.acp
.default_target
.env
.get("OPENAI_API_KEY"),
Some(&"abc".to_string())
);
assert_eq!(
config.subagents.acp.default_target_name.as_deref(),
Some("worker")
);
assert!(!config.subagents.acp.default_target.env.contains_key(" "));
let target = config.subagents.acp.targets.get("worker").unwrap();
assert!(target.enabled);
assert_eq!(target.command, "codex-worker");
assert_eq!(target.args, vec!["--fast".to_string()]);
assert_eq!(target.env.get("TOKEN"), Some(&"xyz".to_string()));
}
#[test]
fn test_post_deserialize_web_non_local_no_token_required() {
let yaml = "telegram_bot_token: tok\nbot_username: bot\napi_key: key\nweb_enabled: true\nweb_host: 0.0.0.0\n";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
}
#[test]
fn test_post_deserialize_web_channel_auth_token_is_removed() {
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";
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
let has_auth_token = config
.channels
.get("web")
.and_then(|v| v.as_mapping())
.map(|map| map.contains_key(serde_yaml::Value::String("auth_token".to_string())))
.unwrap_or(false);
assert!(!has_auth_token);
}
#[test]
fn test_model_prices_parse_and_estimate() {
let yaml = r#"
telegram_bot_token: tok
bot_username: bot
api_key: key
model_prices:
- model: claude-sonnet-4-5-20250929
input_per_million_usd: 3.0
output_per_million_usd: 15.0
"#;
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
let est = config
.estimate_cost_usd("claude-sonnet-4-5-20250929", 1000, 2000)
.unwrap();
assert!((est - 0.033).abs() < 1e-9);
}
#[test]
fn test_model_prices_invalid_rejected() {
let yaml = r#"
telegram_bot_token: tok
bot_username: bot
api_key: key
model_prices:
- model: ""
input_per_million_usd: 1.0
output_per_million_usd: 1.0
"#;
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
let err = config.post_deserialize().unwrap_err();
assert!(err
.to_string()
.contains("model_prices entries must include non-empty model"));
}
#[test]
fn test_config_yaml_with_all_optional_fields() {
let yaml = r#"
telegram_bot_token: tok
bot_username: bot
api_key: key
openai_api_key: sk-test
override_timezone: US/Eastern
allowed_groups: [123, 456]
control_chat_ids: [999]
max_session_messages: 60
compact_keep_recent: 30
discord_bot_token: discord_tok
discord_allowed_channels: [111, 222]
"#;
let mut config: Config = serde_yaml::from_str(yaml).unwrap();
config.post_deserialize().unwrap();
assert_eq!(config.openai_api_key.as_deref(), Some("sk-test"));
assert_eq!(config.timezone, "US/Eastern");
assert_eq!(config.allowed_groups, vec![123, 456]);
assert_eq!(config.control_chat_ids, vec![999]);
assert_eq!(config.max_session_messages, 60);
assert_eq!(config.compact_keep_recent, 30);
assert_eq!(config.discord_allowed_channels, vec![111, 222]);
}
#[test]
fn test_expand_path_with_tilde() {
let home = PathBuf::from(shellexpand::tilde("~").as_ref());
let path = "~/foo/bar";
let expanded = expand_path(path);
assert_eq!(expanded, home.join("foo/bar"));
let path = "~";
let expanded = expand_path(path);
assert_eq!(expanded, home);
let path = "/absolute/path";
let expanded = expand_path(path);
assert_eq!(expanded, std::path::PathBuf::from("/absolute/path"));
}
#[test]
fn unknown_top_level_key_is_flagged() {
let map = mapping_of("definitely_not_a_key: true\napi_key: k\n");
let warnings = unknown_top_level_key_warnings(&map);
assert!(
warnings.iter().any(|w| w.contains("definitely_not_a_key")),
"expected an unknown-key warning, got: {warnings:?}"
);
assert!(
!warnings.iter().any(|w| w.contains("`api_key`")),
"api_key should be recognized, got: {warnings:?}"
);
}
#[test]
fn unknown_key_gets_did_you_mean() {
let map = mapping_of("api_ky: k\n");
let warnings = unknown_top_level_key_warnings(&map);
assert!(
warnings
.iter()
.any(|w| w.contains("api_ky") && w.contains("did you mean `api_key`")),
"expected a did-you-mean suggestion, got: {warnings:?}"
);
}
#[test]
fn known_top_level_keys_produce_no_warnings() {
let yaml = "api_key: k\ntelegram_bot_token: t\nclawhub_registry: https://x\n\
timezone: UTC\noverride_timezone: UTC\nsandbox:\n enabled: false\n";
let warnings = unknown_top_level_key_warnings(&mapping_of(yaml));
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
}