1use leviath_mcp::MCPServerConfig;
4use leviath_providers::ModelCapabilityOverride;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::PathBuf;
8
9mod limits;
13pub use limits::*;
14mod policy;
15pub use policy::*;
16mod providers;
17pub use providers::*;
18mod security;
19pub use security::*;
20
21fn collect_dropped_keys(
28 found: &toml::value::Table,
29 kept: &toml::value::Table,
30 prefix: &str,
31 out: &mut Vec<String>,
32) {
33 for (key, value) in found {
34 let path = if prefix.is_empty() {
35 key.clone()
36 } else {
37 format!("{prefix}.{key}")
38 };
39 match kept.get(key) {
40 None => out.push(path),
41 Some(kept_value) => {
42 if let (Some(found_table), Some(kept_table)) =
43 (value.as_table(), kept_value.as_table())
44 {
45 collect_dropped_keys(found_table, kept_table, &path, out);
46 }
47 }
48 }
49 }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct Config {
55 #[serde(default = "default_provider_name")]
57 pub default_provider: String,
58
59 #[serde(default)]
61 pub providers: ProviderConfig,
62
63 #[serde(default)]
65 pub agent_paths: Vec<PathBuf>,
66
67 #[serde(default)]
69 pub openrouter_api_key: Option<String>,
70
71 #[serde(default)]
73 pub ollama_base_url: Option<String>,
74
75 #[serde(default)]
77 pub mcp_servers: Vec<MCPServerConfig>,
78
79 #[serde(default)]
81 pub default_model: Option<String>,
82
83 #[serde(default)]
86 pub model_capabilities: HashMap<String, ModelCapabilityOverride>,
87
88 #[serde(default)]
95 pub model_providers: HashMap<String, ModelProviderConfig>,
96
97 #[serde(default)]
105 pub tool_permissions: HashMap<String, ToolPolicy>,
106
107 #[serde(default)]
122 pub agent_tool_permissions: HashMap<String, HashMap<String, ToolPolicy>>,
123
124 #[serde(default)]
141 pub safe_commands: crate::approvals::SafeCommands,
142
143 #[serde(default)]
154 pub agent_safe_commands: HashMap<String, crate::approvals::AgentSafeCommands>,
155
156 #[serde(default)]
161 pub title: TitleConfig,
162
163 #[serde(default)]
170 pub request_timeout_secs: Option<u64>,
171
172 #[serde(default)]
184 pub rate_limits: HashMap<String, leviath_providers::RateLimitConfig>,
185
186 #[serde(default)]
193 pub taint_tracking: bool,
194
195 #[serde(default)]
197 pub limits: LimitsConfig,
198
199 #[serde(default = "default_true")]
209 pub batch_tool_hint: bool,
210
211 #[serde(default = "default_true")]
222 pub shell_hint: bool,
223
224 #[serde(default)]
231 pub nudge: leviath_core::NudgeConfig,
232
233 #[serde(default)]
235 pub webhook: WebhookConfig,
236
237 #[serde(default)]
243 pub observability: ObservabilityConfig,
244
245 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub sandbox: Option<leviath_core::ToolSandboxConfig>,
251
252 #[serde(default)]
255 pub tool_script_permissions: ScriptToolPermissions,
256
257 #[serde(default)]
261 pub security: SecurityConfig,
262
263 #[serde(default)]
277 pub agent_read_paths: HashMap<String, ReadPathGrants>,
278}
279
280impl Default for Config {
281 fn default() -> Self {
282 Self {
283 default_provider: "anthropic".to_string(),
284 providers: ProviderConfig {
285 anthropic_api_key: None,
286 openai_api_key: None,
287 google_api_key: None,
288 claude_code_enabled: false,
289 claude_code_binary: None,
290 claude_code_effort: None,
291 anthropic_cache_ttl: None,
292 fallback_order: Vec::new(),
293 },
294 agent_paths: Vec::new(),
295 openrouter_api_key: None,
296 ollama_base_url: None,
297 mcp_servers: Vec::new(),
298 default_model: None,
299 model_capabilities: HashMap::new(),
300 model_providers: HashMap::new(),
301 tool_permissions: HashMap::new(),
302 agent_tool_permissions: HashMap::new(),
303 safe_commands: crate::approvals::SafeCommands::default(),
304 agent_safe_commands: HashMap::new(),
305 title: TitleConfig::default(),
306 request_timeout_secs: None,
307 rate_limits: HashMap::new(),
308 taint_tracking: false,
309 limits: LimitsConfig::default(),
310 batch_tool_hint: true,
311 shell_hint: true,
312 nudge: leviath_core::NudgeConfig::default(),
313 webhook: WebhookConfig::default(),
314 observability: ObservabilityConfig::default(),
315 sandbox: None,
316 tool_script_permissions: ScriptToolPermissions::default(),
317 security: SecurityConfig::default(),
318 agent_read_paths: HashMap::new(),
319 }
320 }
321}
322
323impl Config {
324 pub fn permissions_for_agent(&self, agent_name: &str) -> HashMap<String, ToolPolicy> {
332 let mut merged = self.tool_permissions.clone();
333 if let Some(per_agent) = self.agent_tool_permissions.get(agent_name) {
334 merged.extend(per_agent.iter().map(|(k, v)| (k.clone(), *v)));
335 }
336 merged
337 }
338
339 pub fn safe_keys_for_agent(
346 &self,
347 agent_name: &str,
348 blueprint: Option<&leviath_core::blueprint::SafeCommandsConfig>,
349 ) -> std::collections::BTreeMap<String, crate::approvals::SafeSource> {
350 crate::approvals::resolve_safe_keys(
351 &self.safe_commands,
352 self.agent_safe_commands.get(agent_name),
353 blueprint,
354 self.security.allow_blueprint_safe_commands,
355 )
356 }
357
358 pub fn read_path_grants_for_agent(&self, agent_name: &str) -> Vec<String> {
363 let mut grants = self.security.read_paths.clone();
364 if let Some(per_agent) = self.agent_read_paths.get(agent_name) {
365 grants.extend(per_agent.allow.iter().cloned());
366 }
367 grants
368 }
369
370 pub fn load() -> anyhow::Result<Self> {
375 #[cfg(test)]
391 assert!(
392 std::env::var_os("LEVIATH_CONFIG_PATH").is_some(),
393 "Config::load() reached from a test that has not isolated the \
394 environment. Wrap the test in `config::with_isolated_config_path` \
395 (or `..._async`), which both points this at a scratch config and \
396 takes the same process-wide lock every other env-touching test \
397 holds. Without it this test races them and fails intermittently, \
398 somewhere else."
399 );
400
401 if std::env::var_os("LEVIATH_SKIP_DOTENV").is_none() {
430 load_dotenv_filtered(".env");
431 }
432
433 let config = Self::load_from_path(&Self::config_path())?;
434
435 check_permissions();
437
438 Ok(config)
439 }
440
441 fn warn_unknown_config_keys(content: &str) {
454 let unknown = Self::unknown_config_keys(content);
455 if !unknown.is_empty() {
456 let keys = unknown.join(", ");
461 tracing::warn!(
462 %keys,
463 "config.toml has keys nothing reads; they are being ignored. \
464 `lev doctor` reports them too, if this scrolls past."
465 );
466 }
467 }
468
469 pub fn unread_keys_at(path: &std::path::Path) -> Vec<String> {
477 std::fs::read_to_string(path)
478 .map(|content| Self::unknown_config_keys(&content))
479 .unwrap_or_default()
480 }
481
482 fn unknown_config_keys(content: &str) -> Vec<String> {
518 let Ok(found) = toml::from_str::<toml::value::Table>(content) else {
519 return Vec::new();
520 };
521 let Ok(config) = toml::from_str::<Self>(content) else {
524 return Vec::new();
525 };
526 let kept = toml::Value::try_from(config).expect("a Config is plain data and serializes");
530 let kept = kept.as_table().expect("a struct serializes to a table");
531
532 let mut unknown = Vec::new();
533 collect_dropped_keys(&found, kept, "", &mut unknown);
534 unknown
535 }
536
537 fn load_from_path(path: &std::path::Path) -> anyhow::Result<Self> {
540 let mut config = if !path.exists() {
541 let path_display = path.display();
542 tracing::debug!("No config file found at {}, using defaults", path_display);
543 Self::default()
544 } else {
545 let content = std::fs::read_to_string(path).map_err(|e| {
546 anyhow::anyhow!("Failed to read config from '{}': {}", path.display(), e)
547 })?;
548
549 let c: Self = toml::from_str(&content)
550 .map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e))?;
551
552 Self::warn_unknown_config_keys(&content);
553
554 for server in &c.mcp_servers {
558 server.validate()?;
559 }
560
561 let path_display = path.display();
562 tracing::debug!("Loaded config from {}", path_display);
563 c
564 };
565
566 if config.providers.anthropic_api_key.is_none() {
568 config.providers.anthropic_api_key = std::env::var("ANTHROPIC_API_KEY").ok();
569 }
570 if config.providers.openai_api_key.is_none() {
571 config.providers.openai_api_key = std::env::var("OPENAI_API_KEY").ok();
572 }
573 if config.providers.google_api_key.is_none() {
574 config.providers.google_api_key = std::env::var("GOOGLE_API_KEY").ok();
575 }
576 if config.openrouter_api_key.is_none() {
577 config.openrouter_api_key = std::env::var("OPENROUTER_API_KEY").ok();
578 }
579 if config.ollama_base_url.is_none() {
581 config.ollama_base_url = std::env::var("OLLAMA_HOST").ok();
582 }
583
584 config.fill_from_credential_store();
585
586 Ok(config)
587 }
588
589 fn fill_from_credential_store(&mut self) {
591 let resolved = crate::credentials::store_for(self.security.credential_store);
592 self.fill_from_credential_store_with(resolved);
593 }
594
595 fn fill_from_credential_store_with(&mut self, resolved: crate::credentials::Resolved) {
613 match resolved {
614 Ok(Some(store)) => self.apply_credential_store(store.as_ref()),
615 Ok(None) => {}
617 Err(e) => {
618 tracing::warn!("{e}. Falling back to keys from the config file and environment.");
619 }
620 }
621 }
622
623 fn apply_credential_store(&mut self, store: &dyn leviath_core::CredentialStore) {
625 let accounts: Vec<String> = crate::credentials::PROVIDER_KEYS
626 .iter()
627 .map(|p| leviath_core::provider_account(p))
628 .collect();
629 let mut found = store.read_all(&accounts);
630 let mut take = |provider: &str| found.remove(&leviath_core::provider_account(provider));
631
632 let anthropic = take("anthropic");
633 let openai = take("openai");
634 let google = take("google");
635 let openrouter = take("openrouter");
636
637 self.providers.anthropic_api_key = self.providers.anthropic_api_key.take().or(anthropic);
638 self.providers.openai_api_key = self.providers.openai_api_key.take().or(openai);
639 self.providers.google_api_key = self.providers.google_api_key.take().or(google);
640 self.openrouter_api_key = self.openrouter_api_key.take().or(openrouter);
641 }
642
643 fn without_secrets(&self) -> Self {
651 let mut copy = self.clone();
652 copy.providers.anthropic_api_key = None;
653 copy.providers.openai_api_key = None;
654 copy.providers.google_api_key = None;
655 copy.openrouter_api_key = None;
656 copy
657 }
658
659 pub(crate) fn provider_secrets(&self) -> Vec<(String, String)> {
661 [
662 ("anthropic", self.providers.anthropic_api_key.as_deref()),
663 ("openai", self.providers.openai_api_key.as_deref()),
664 ("google", self.providers.google_api_key.as_deref()),
665 ("openrouter", self.openrouter_api_key.as_deref()),
666 ]
667 .into_iter()
668 .filter_map(|(name, key)| {
669 key.map(|k| (leviath_core::provider_account(name), k.to_string()))
670 })
671 .collect()
672 }
673
674 pub(crate) fn save_to_path(&self, path: &std::path::Path) -> anyhow::Result<()> {
679 if let Some(parent) = path.parent() {
681 create_config_dir(parent)?;
682 }
683
684 let resolved = crate::credentials::store_for(self.security.credential_store);
692 self.write_to(path, resolved)
693 }
694
695 fn write_to(
700 &self,
701 path: &std::path::Path,
702 resolved: crate::credentials::Resolved,
703 ) -> anyhow::Result<()> {
704 let to_write = match resolved.map_err(|e| anyhow::anyhow!("{e}"))? {
705 Some(store) => {
706 for (account, secret) in self.provider_secrets() {
707 store
708 .set(&account, &secret)
709 .map_err(|e| anyhow::anyhow!("failed to store {account}: {e}"))?;
710 }
711 self.without_secrets()
712 }
713 None => self.clone(),
714 };
715
716 let content =
718 toml::to_string_pretty(&to_write).expect("Config serialization is infallible");
719
720 leviath_sys::write_private(path, content.as_bytes()).map_err(|e| {
725 anyhow::anyhow!("Failed to write config to '{}': {}", path.display(), e)
726 })?;
727
728 let path_display = path.display();
729 tracing::debug!("Saved config to {}", path_display);
730 Ok(())
731 }
732
733 pub fn load_from_path_public(path: &std::path::Path) -> anyhow::Result<Self> {
736 Self::load_from_path(path)
737 }
738
739 pub fn save_to_path_public(&self, path: &std::path::Path) -> anyhow::Result<()> {
741 self.save_to_path(path)
742 }
743
744 pub fn config_path() -> PathBuf {
758 if let Ok(override_path) = std::env::var("LEVIATH_CONFIG_PATH") {
759 return PathBuf::from(override_path);
760 }
761 leviath_core::data_dir()
762 .unwrap_or_default()
763 .join("config.toml")
764 }
765
766 pub fn validate_keys(&self) -> Vec<String> {
771 let mut warnings = Vec::new();
776 if let Some(key) = self.providers.anthropic_api_key.as_deref()
777 && !key.trim().is_empty()
778 && !key.starts_with("sk-ant-")
779 {
780 warnings.push(
781 "Anthropic API key doesn't start with 'sk-ant-' - verify it's correct".to_string(),
782 );
783 }
784 if let Some(key) = self.providers.openai_api_key.as_deref()
785 && !key.trim().is_empty()
786 && !key.starts_with("sk-")
787 {
788 warnings
789 .push("OpenAI API key doesn't start with 'sk-' - verify it's correct".to_string());
790 }
791 warnings
792 }
793}
794
795pub use leviath_core::paths::home_dir as leviath_home_dir;
802pub use leviath_core::paths::providers_dir;
803
804fn create_config_dir(dir: &std::path::Path) -> anyhow::Result<()> {
806 std::fs::create_dir_all(dir)
807 .map_err(|e| anyhow::anyhow!("Failed to create config directory: {}", e))?;
808 set_dir_permissions(dir);
809 Ok(())
810}
811
812fn requote(value: &str) -> String {
835 let mut out = String::with_capacity(value.len() + 2);
836 out.push('"');
837 for c in value.chars() {
838 match c {
839 '\\' => out.push_str("\\\\"),
840 '"' => out.push_str("\\\""),
841 '$' => out.push_str("\\$"),
842 '\n' => out.push_str("\\n"),
843 other => out.push(other),
844 }
845 }
846 out.push('"');
847 out
848}
849
850fn load_dotenv_filtered(path: &str) {
851 let Ok(entries) = dotenvy::from_filename_iter(path) else {
852 return;
853 };
854 let (allowed, skipped): (Vec<_>, Vec<_>) = entries
857 .flatten()
858 .partition(|(key, _)| leviath_core::dotenv_var_allowed(key));
859
860 let doc: String = allowed
870 .iter()
871 .map(|(key, value)| format!("{key}={}\n", requote(value)))
872 .collect();
873 let _ = dotenvy::from_read(doc.as_bytes());
874
875 if skipped.is_empty() {
878 return;
879 }
880
881 let names = skipped
886 .iter()
887 .map(|(key, _)| key.as_str())
888 .collect::<Vec<_>>()
889 .join(", ");
890 tracing::warn!(
891 "Ignoring {names} from {path}: these decide where configuration is read from or what \
892 gets executed, so a repository may not set them. Export them yourself if you meant to."
893 );
894}
895
896fn check_permissions() {
900 check_permissions_at(&Config::config_path());
901}
902
903fn check_permissions_at(path: &std::path::Path) {
909 check_permissions_at_with(path, leviath_sys::ensure_file_private);
910}
911
912fn check_permissions_at_with(
920 path: &std::path::Path,
921 ensure: fn(&std::path::Path) -> std::io::Result<Option<u32>>,
922) {
923 match ensure(path) {
924 Ok(Some(old_mode)) => {
925 let masked_mode = old_mode & 0o777;
926 tracing::warn!(
927 "Config file has overly permissive permissions ({:o}), fixing to 600",
928 masked_mode
929 );
930 }
931 Ok(None) => {}
932 Err(e) => tracing::warn!("Failed to fix config file permissions: {}", e),
933 }
934}
935
936fn set_dir_permissions(path: &std::path::Path) {
938 set_dir_permissions_with(path, leviath_sys::secure_dir_perms);
939}
940
941fn set_dir_permissions_with(
944 path: &std::path::Path,
945 secure: fn(&std::path::Path) -> std::io::Result<()>,
946) {
947 if let Err(e) = secure(path) {
948 tracing::warn!("Failed to set config directory permissions: {}", e);
949 }
950}
951
952pub(crate) fn default_true() -> bool {
959 true
960}
961
962pub(crate) fn default_provider_name() -> String {
972 "anthropic".to_string()
973}
974
975#[cfg(test)]
984pub(crate) static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
985
986#[cfg(test)]
1006pub(crate) struct CwdTestGuard {
1007 original_cwd: std::path::PathBuf,
1008 _lock: std::sync::MutexGuard<'static, ()>,
1009}
1010
1011#[cfg(test)]
1012impl Drop for CwdTestGuard {
1013 fn drop(&mut self) {
1014 let _ = std::env::set_current_dir(&self.original_cwd);
1015 }
1016}
1017
1018#[cfg(test)]
1021pub(crate) fn isolate_cwd_for_test() -> CwdTestGuard {
1022 let lock = CWD_LOCK
1023 .lock()
1024 .unwrap_or_else(std::sync::PoisonError::into_inner);
1025 let original_cwd = std::env::current_dir().expect("current dir must be readable at test start");
1026 CwdTestGuard {
1027 original_cwd,
1028 _lock: lock,
1029 }
1030}
1031
1032#[cfg(test)]
1037const PROVIDER_KEY_ENV_VARS: &[&str] = &[
1038 "ANTHROPIC_API_KEY",
1039 "OPENAI_API_KEY",
1040 "GOOGLE_API_KEY",
1041 "OPENROUTER_API_KEY",
1042];
1043
1044#[cfg(test)]
1046fn make_fake_config_dir(unique: &str) -> std::path::PathBuf {
1047 let fake_dir = std::env::temp_dir().join(format!("lev-fake-config-{unique}"));
1048 let _ = std::fs::create_dir_all(&fake_dir);
1049 fake_dir
1050}
1051
1052#[cfg(test)]
1064pub(crate) fn config_isolation_vars(
1065 fake_dir: &std::path::Path,
1066) -> Vec<(&'static str, Option<std::ffi::OsString>)> {
1067 let mut vars: Vec<(&'static str, Option<std::ffi::OsString>)> = vec![
1068 (
1069 "LEVIATH_CONFIG_PATH",
1070 Some(fake_dir.join("config.toml").into_os_string()),
1071 ),
1072 ("LEVIATH_SKIP_DOTENV", Some(std::ffi::OsString::from("1"))),
1073 ];
1074 for &key in PROVIDER_KEY_ENV_VARS {
1075 vars.push((key, None));
1076 }
1077 vars
1078}
1079
1080#[cfg(test)]
1088pub(crate) fn with_isolated_config_path<R>(
1089 unique: &str,
1090 f: impl FnOnce(&std::path::Path) -> R,
1091) -> R {
1092 let fake_dir = make_fake_config_dir(unique);
1093 let result = temp_env::with_vars(config_isolation_vars(&fake_dir), || f(&fake_dir));
1094 let _ = std::fs::remove_dir_all(&fake_dir);
1095 result
1096}
1097
1098#[cfg(test)]
1101pub(crate) async fn with_isolated_config_path_async<R, Fut>(
1102 unique: &str,
1103 f: impl FnOnce(std::path::PathBuf) -> Fut,
1104) -> R
1105where
1106 Fut: std::future::Future<Output = R>,
1107{
1108 let fake_dir = make_fake_config_dir(unique);
1109 let result =
1110 temp_env::async_with_vars(config_isolation_vars(&fake_dir), f(fake_dir.clone())).await;
1111 let _ = std::fs::remove_dir_all(&fake_dir);
1112 result
1113}
1114
1115#[cfg(test)]
1116mod dotenv_tests {
1117 use super::*;
1118
1119 #[test]
1130 fn a_dot_env_in_the_working_directory_is_read() {
1131 let dir = make_fake_config_dir("dotenv-read");
1132 std::fs::write(dir.join(".env"), "LEV_DOTENV_PROBE=seen\n").unwrap();
1133
1134 {
1138 let _cwd = isolate_cwd_for_test();
1139 std::env::set_current_dir(&dir).unwrap();
1140
1141 temp_env::with_vars(
1142 [
1143 (
1144 "LEVIATH_CONFIG_PATH",
1145 Some(dir.join("config.toml").into_os_string()),
1146 ),
1147 ("LEVIATH_SKIP_DOTENV", None),
1148 ("LEV_DOTENV_PROBE", None),
1149 ],
1150 || {
1151 let loaded = Config::load();
1152 assert!(loaded.is_ok(), "a missing config file is not an error");
1153 assert_eq!(
1154 std::env::var("LEV_DOTENV_PROBE").ok().as_deref(),
1155 Some("seen"),
1156 "the .env beside the working directory was read"
1157 );
1158 },
1159 );
1160 }
1161 let _ = std::fs::remove_dir_all(&dir);
1162 }
1163
1164 #[test]
1170 fn a_dot_env_cannot_steer_where_config_comes_from() {
1171 let dir = make_fake_config_dir("dotenv-steer");
1172 std::fs::write(
1173 dir.join(".env"),
1174 "LEVIATH_CONFIG_PATH=/tmp/evil.toml\n\
1175 LEVIATH_API_TOKEN=known\n\
1176 EDITOR=/tmp/evil\n\
1177 PATH=/tmp/evil\n\
1178 LD_PRELOAD=/tmp/evil.so\n\
1179 LEV_DOTENV_KEEPS=kept\n",
1180 )
1181 .unwrap();
1182
1183 {
1184 let _cwd = isolate_cwd_for_test();
1185 std::env::set_current_dir(&dir).unwrap();
1186
1187 temp_env::with_vars(
1188 [
1189 (
1190 "LEVIATH_CONFIG_PATH",
1191 Some(dir.join("config.toml").into_os_string()),
1192 ),
1193 ("LEVIATH_SKIP_DOTENV", None),
1194 ("LEVIATH_API_TOKEN", None),
1195 ("EDITOR", None),
1196 ("LD_PRELOAD", None),
1197 ("LEV_DOTENV_KEEPS", None),
1198 ],
1199 || {
1200 Config::load().expect("a missing config file is not an error");
1201 for steering in ["LEVIATH_API_TOKEN", "EDITOR", "LD_PRELOAD"] {
1202 assert!(
1203 std::env::var(steering).is_err(),
1204 "{steering} must not be settable from a repository's .env"
1205 );
1206 }
1207 assert_ne!(
1211 std::env::var("LEVIATH_CONFIG_PATH").ok(),
1212 Some("/tmp/evil.toml".to_string())
1213 );
1214 assert_eq!(
1218 std::env::var("LEV_DOTENV_KEEPS").ok().as_deref(),
1219 Some("kept")
1220 );
1221 },
1222 );
1223 }
1224 let _ = std::fs::remove_dir_all(&dir);
1225 }
1226
1227 #[test]
1231 fn a_missing_dot_env_is_not_an_error() {
1232 let dir = make_fake_config_dir("dotenv-missing");
1233 load_dotenv_filtered(&dir.join("absent.env").to_string_lossy());
1234 let _ = std::fs::remove_dir_all(&dir);
1235 }
1236
1237 #[test]
1242 fn an_ordinary_dot_env_warns_about_nothing() {
1243 let dir = make_fake_config_dir("dotenv-nothing-skipped");
1244 std::fs::write(dir.join(".env"), "LEV_DOTENV_ORDINARY=fine\n").unwrap();
1245
1246 {
1247 let _cwd = isolate_cwd_for_test();
1248 std::env::set_current_dir(&dir).unwrap();
1249 temp_env::with_vars(
1250 [
1251 (
1252 "LEVIATH_CONFIG_PATH",
1253 Some(dir.join("config.toml").into_os_string()),
1254 ),
1255 ("LEVIATH_SKIP_DOTENV", None),
1256 ("LEV_DOTENV_ORDINARY", None),
1257 ],
1258 || {
1259 Config::load().expect("a missing config file is not an error");
1260 assert_eq!(
1263 std::env::var("LEV_DOTENV_ORDINARY").ok().as_deref(),
1264 Some("fine")
1265 );
1266 },
1267 );
1268 }
1269 let _ = std::fs::remove_dir_all(&dir);
1270 }
1271
1272 #[test]
1275 fn requote_escapes_what_both_dotenvy_layers_read() {
1276 assert_eq!(requote("plain"), r#""plain""#);
1277 assert_eq!(requote(r"C:\tools\"), r#""C:\\tools\\""#);
1278 assert_eq!(requote(r#"say "hi""#), r#""say \"hi\"""#);
1279 assert_eq!(requote("cost $5 $HOME"), r#""cost \$5 \$HOME""#);
1282 assert_eq!(requote("one\ntwo"), r#""one\ntwo""#);
1283 }
1284
1285 #[test]
1292 fn filtering_survives_a_value_ending_in_a_backslash() {
1293 let dir = make_fake_config_dir("dotenv-backslash");
1294 std::fs::write(
1298 dir.join(".env"),
1299 "PATH=/tmp/anything\n\
1300 LEV_DOTENV_BACKSLASH=\"C:\\\\tools\\\\\"\n\
1301 LEV_DOTENV_AFTER=survived\n",
1302 )
1303 .unwrap();
1304
1305 {
1306 let _cwd = isolate_cwd_for_test();
1307 std::env::set_current_dir(&dir).unwrap();
1308
1309 temp_env::with_vars(
1310 [
1311 (
1312 "LEVIATH_CONFIG_PATH",
1313 Some(dir.join("config.toml").into_os_string()),
1314 ),
1315 ("LEVIATH_SKIP_DOTENV", None),
1316 ("LEV_DOTENV_BACKSLASH", None),
1317 ("LEV_DOTENV_AFTER", None),
1318 ],
1319 || {
1320 Config::load().expect("a missing config file is not an error");
1321 assert_eq!(
1322 std::env::var("LEV_DOTENV_BACKSLASH").ok().as_deref(),
1323 Some("C:\\tools\\")
1324 );
1325 assert_eq!(
1326 std::env::var("LEV_DOTENV_AFTER").ok().as_deref(),
1327 Some("survived"),
1328 "a later variable must not be swallowed by an unbalanced quote"
1329 );
1330 },
1331 );
1332 }
1333 let _ = std::fs::remove_dir_all(&dir);
1334 }
1335
1336 #[test]
1339 fn filtering_preserves_an_awkward_value_verbatim() {
1340 let dir = make_fake_config_dir("dotenv-quoting");
1341 std::fs::write(
1342 dir.join(".env"),
1343 "PATH=/tmp/evil\n\
1344 LEV_DOTENV_AWKWARD=\"it's a #value with 'quotes' and spaces\"\n",
1345 )
1346 .unwrap();
1347
1348 {
1349 let _cwd = isolate_cwd_for_test();
1350 std::env::set_current_dir(&dir).unwrap();
1351
1352 temp_env::with_vars(
1353 [
1354 (
1355 "LEVIATH_CONFIG_PATH",
1356 Some(dir.join("config.toml").into_os_string()),
1357 ),
1358 ("LEVIATH_SKIP_DOTENV", None),
1359 ("LEV_DOTENV_AWKWARD", None),
1360 ],
1361 || {
1362 Config::load().expect("a missing config file is not an error");
1363 assert_eq!(
1364 std::env::var("LEV_DOTENV_AWKWARD").ok().as_deref(),
1365 Some("it's a #value with 'quotes' and spaces")
1366 );
1367 },
1368 );
1369 }
1370 let _ = std::fs::remove_dir_all(&dir);
1371 }
1372}
1373
1374#[cfg(test)]
1375mod tests {
1376 const CONFIG_SCHEMA: &str = include_str!("../../../../docs/schema/config.schema.json");
1379 const CONFIG_EXAMPLE: &str = include_str!("../../../../docs/schema/config.example.toml");
1380
1381 fn schema_problems(
1383 validator: &jsonschema::Validator,
1384 value: &serde_json::Value,
1385 ) -> Vec<String> {
1386 validator
1387 .iter_errors(value)
1388 .map(|e| format!("{}: {e}", e.instance_path()))
1389 .collect()
1390 }
1391
1392 #[test]
1399 fn an_unknown_key_is_reported_at_any_depth() {
1400 let content = "\
1401default_provider = \"anthropic\"
1402
1403[cache]
1404ttl = \"banana\"
1405
1406[limits]
1407max_concurrent_tool = 3
1408
1409[providers]
1410anthropic_api_key = \"x\"
1411anthropic_cach_ttl = \"1h\"
1412";
1413 let unknown = Config::unknown_config_keys(content);
1414 assert!(unknown.contains(&"cache".to_string()), "{unknown:?}");
1415 assert!(
1416 unknown.contains(&"limits.max_concurrent_tool".to_string()),
1417 "a key one level down is named by its path: {unknown:?}"
1418 );
1419 assert!(
1420 unknown.contains(&"providers.anthropic_cach_ttl".to_string()),
1421 "{unknown:?}"
1422 );
1423 assert!(
1425 !unknown.iter().any(|k| k == "default_provider"),
1426 "{unknown:?}"
1427 );
1428 assert!(
1429 !unknown.iter().any(|k| k == "providers.anthropic_api_key"),
1430 "{unknown:?}"
1431 );
1432 }
1433
1434 #[test]
1438 fn a_file_that_is_not_a_config_reports_no_unknown_keys() {
1439 assert!(Config::unknown_config_keys("default_provider = 42").is_empty());
1441 }
1442
1443 #[test]
1446 fn unread_keys_of_a_missing_file_is_empty() {
1447 let dir = tempfile::tempdir().unwrap();
1448 assert!(Config::unread_keys_at(&dir.path().join("nope.toml")).is_empty());
1449 }
1450
1451 #[test]
1452 fn unread_keys_at_reads_the_file_it_is_given() {
1453 let dir = tempfile::tempdir().unwrap();
1454 let path = dir.path().join("config.toml");
1455 std::fs::write(&path, "[cache]\nttl = \"banana\"\n").unwrap();
1456 assert_eq!(Config::unread_keys_at(&path), vec!["cache".to_string()]);
1457 }
1458
1459 #[test]
1463 fn keys_a_flatten_field_absorbs_are_not_reported() {
1464 let content = "\
1465[model_providers.groq]
1466script = \"groq.rhai\"
1467some_custom_thing = \"forwarded to the script\"
1468";
1469 assert!(
1470 Config::unknown_config_keys(content).is_empty(),
1471 "a key serde keeps is a key nothing should complain about"
1472 );
1473 }
1474
1475 #[test]
1479 fn an_unknown_config_key_is_reported_and_the_config_still_loads() {
1480 const CONTENT: &str = "default_provider = \"anthropic\"\n\n[cache]\nttl = \"banana\"\n";
1481 assert_eq!(
1482 Config::unknown_config_keys(CONTENT),
1483 vec!["cache".to_string()],
1484 "the unknown table is named and the real key is not"
1485 );
1486
1487 let dir = tempfile::tempdir().unwrap();
1488 let path = dir.path().join("config.toml");
1489 std::fs::write(&path, CONTENT).unwrap();
1490 let _guard = leviath_testkit::tracing_guard();
1494 let config = Config::load_from_path(&path).expect("an unknown key does not stop the load");
1495 assert_eq!(config.default_provider, "anthropic");
1496 }
1497
1498 #[test]
1501 fn a_config_of_known_keys_reports_nothing() {
1502 assert!(
1503 Config::unknown_config_keys(CONFIG_EXAMPLE).is_empty(),
1504 "the shipped example must be clean"
1505 );
1506 }
1507
1508 #[test]
1512 fn unparseable_content_reports_no_unknown_keys() {
1513 assert!(Config::unknown_config_keys("this is not [[[ toml").is_empty());
1514 }
1515
1516 #[test]
1517 fn the_example_config_satisfies_the_published_schema_and_deserializes() {
1518 let example: toml::Value = toml::from_str(CONFIG_EXAMPLE).expect("the example is TOML");
1523 let schema: serde_json::Value =
1524 serde_json::from_str(CONFIG_SCHEMA).expect("the schema is JSON");
1525 let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
1526
1527 let json = serde_json::to_value(&example).expect("TOML converts to JSON");
1528 assert_eq!(
1529 schema_problems(&validator, &json),
1530 Vec::<String>::new(),
1531 "config.example.toml does not match config.schema.json"
1532 );
1533
1534 let parsed: Config = toml::from_str(CONFIG_EXAMPLE).expect("the example deserializes");
1535 assert_eq!(parsed.default_provider, "anthropic");
1538 assert_eq!(parsed.limits.interaction_timeout_secs, 3600);
1539 assert_eq!(parsed.mcp_servers.len(), 2);
1540 }
1541
1542 #[test]
1543 fn the_config_schema_rejects_a_key_that_is_not_a_setting() {
1544 let schema: serde_json::Value =
1547 serde_json::from_str(CONFIG_SCHEMA).expect("the schema is JSON");
1548 let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
1549 let rejects = |toml_text: &str| {
1552 let value: toml::Value = toml::from_str(toml_text).expect("valid TOML");
1553 let json = serde_json::to_value(&value).expect("converts");
1554 !schema_problems(&validator, &json).is_empty()
1555 };
1556
1557 assert!(
1558 rejects("default_provdier = \"anthropic\"\n"),
1559 "a typo'd key"
1560 );
1561 assert!(
1562 rejects("[limits]\ninteraction_timeout_secs = \"an hour\"\n"),
1563 "a string where a number belongs"
1564 );
1565 assert!(
1566 rejects("[security]\ncredential_store = \"vault\"\n"),
1567 "an unsupported credential store"
1568 );
1569 assert!(
1570 !rejects("default_provider = \"openrouter\"\n"),
1571 "a real key"
1572 );
1573 }
1574
1575 #[test]
1579 fn saving_with_an_unreachable_keychain_writes_nothing() {
1580 let dir = tempfile::tempdir().unwrap();
1581 let path = dir.path().join("config.toml");
1582 let mut config = Config::default();
1583 config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1584 config.providers.anthropic_api_key = Some("sk-ant".to_string());
1585
1586 assert!(
1587 config
1588 .write_to(&path, Err("no keychain".to_string()))
1589 .is_err()
1590 );
1591 assert!(!path.exists(), "no file may be written at all");
1592 }
1593
1594 #[test]
1596 fn saving_to_a_store_that_refuses_the_write_writes_nothing() {
1597 use leviath_core::CredentialStore as _;
1598
1599 struct Refuses;
1600 impl leviath_core::CredentialStore for Refuses {
1601 fn get(&self, _: &str) -> Result<Option<String>, String> {
1602 Ok(None)
1603 }
1604 fn set(&self, _: &str, _: &str) -> Result<(), String> {
1605 Err("read-only keychain".to_string())
1606 }
1607 fn delete(&self, _: &str) -> Result<bool, String> {
1608 Err("read-only keychain".to_string())
1609 }
1610 }
1611
1612 let dir = tempfile::tempdir().unwrap();
1613 let path = dir.path().join("config.toml");
1614 let mut config = Config::default();
1615 config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1616 config.providers.anthropic_api_key = Some("sk-ant".to_string());
1617
1618 assert_eq!(Refuses.get("provider/anthropic").unwrap(), None);
1621 assert!(Refuses.delete("provider/anthropic").is_err());
1622
1623 let err = config
1624 .write_to(&path, Ok(Some(Box::new(Refuses))))
1625 .expect_err("a refused write is not a save");
1626 assert!(err.to_string().contains("failed to store"), "{err}");
1627 assert!(!path.exists(), "no file may be written at all");
1628 }
1629
1630 #[test]
1633 fn saving_in_keychain_mode_puts_the_secrets_in_the_store_not_the_file() {
1634 use leviath_core::CredentialStore;
1635
1636 let dir = tempfile::tempdir().unwrap();
1637 let path = dir.path().join("config.toml");
1638 let mut config = Config::default();
1639 config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1640 config.providers.anthropic_api_key = Some("sk-ant-secret".to_string());
1641 config.default_model = Some("some-model".to_string());
1642
1643 let store = std::sync::Arc::new(leviath_core::MemoryStore::new());
1644 struct Shared(std::sync::Arc<leviath_core::MemoryStore>);
1645 impl CredentialStore for Shared {
1646 fn get(&self, a: &str) -> Result<Option<String>, String> {
1647 self.0.get(a)
1648 }
1649 fn set(&self, a: &str, s: &str) -> Result<(), String> {
1650 self.0.set(a, s)
1651 }
1652 fn delete(&self, a: &str) -> Result<bool, String> {
1653 self.0.delete(a)
1654 }
1655 }
1656
1657 config
1658 .write_to(&path, Ok(Some(Box::new(Shared(store.clone())))))
1659 .unwrap();
1660
1661 assert!(
1663 Shared(store.clone())
1664 .delete(&leviath_core::provider_account("anthropic"))
1665 .unwrap()
1666 );
1667 store
1668 .set(
1669 &leviath_core::provider_account("anthropic"),
1670 "sk-ant-secret",
1671 )
1672 .unwrap();
1673
1674 let written = std::fs::read_to_string(&path).unwrap();
1675 assert!(!written.contains("sk-ant-secret"), "{written}");
1676 assert!(
1677 written.contains("some-model"),
1678 "settings survive: {written}"
1679 );
1680 assert_eq!(
1683 Shared(store.clone())
1684 .get(&leviath_core::provider_account("anthropic"))
1685 .unwrap()
1686 .as_deref(),
1687 Some("sk-ant-secret")
1688 );
1689 }
1690
1691 #[test]
1694 fn the_credential_store_fills_only_the_keys_that_are_unset() {
1695 use leviath_core::{CredentialStore, MemoryStore};
1696
1697 let store = MemoryStore::new();
1698 store
1699 .set(
1700 &leviath_core::provider_account("anthropic"),
1701 "from-keychain",
1702 )
1703 .unwrap();
1704 store
1705 .set(&leviath_core::provider_account("openai"), "openai-keychain")
1706 .unwrap();
1707 store
1708 .set(&leviath_core::provider_account("google"), "google-keychain")
1709 .unwrap();
1710 store
1711 .set(&leviath_core::provider_account("openrouter"), "or-keychain")
1712 .unwrap();
1713
1714 let mut config = Config::default();
1715 config.providers.anthropic_api_key = Some("from-file".to_string());
1717 config.apply_credential_store(&store);
1718
1719 assert_eq!(
1720 config.providers.anthropic_api_key.as_deref(),
1721 Some("from-file"),
1722 "an existing key wins over the keychain"
1723 );
1724 assert_eq!(
1725 config.providers.openai_api_key.as_deref(),
1726 Some("openai-keychain")
1727 );
1728 assert_eq!(
1729 config.providers.google_api_key.as_deref(),
1730 Some("google-keychain")
1731 );
1732 assert_eq!(config.openrouter_api_key.as_deref(), Some("or-keychain"));
1733 }
1734
1735 #[test]
1737 fn an_empty_credential_store_changes_nothing() {
1738 let mut config = Config::default();
1739 config.providers.openai_api_key = Some("keep-me".to_string());
1740 config.apply_credential_store(&leviath_core::MemoryStore::new());
1741 assert_eq!(config.providers.openai_api_key.as_deref(), Some("keep-me"));
1742 assert!(config.providers.anthropic_api_key.is_none());
1743 }
1744
1745 #[test]
1750 fn an_unreachable_credential_store_does_not_stop_the_config_loading() {
1751 use leviath_core::{CredentialStore, MemoryStore};
1752
1753 let mut config = Config::default();
1754 config.fill_from_credential_store_with(Err("no keychain here".to_string()));
1755 assert!(config.providers.anthropic_api_key.is_none());
1756
1757 let mut config = Config::default();
1759 config.providers.openai_api_key = Some("k".to_string());
1760 config.fill_from_credential_store_with(Ok(None));
1761 assert_eq!(config.providers.openai_api_key.as_deref(), Some("k"));
1762
1763 let store = MemoryStore::new();
1765 store
1766 .set(&leviath_core::provider_account("anthropic"), "filled")
1767 .unwrap();
1768 let mut config = Config::default();
1769 config.fill_from_credential_store_with(Ok(Some(Box::new(store))));
1770 assert_eq!(
1771 config.providers.anthropic_api_key.as_deref(),
1772 Some("filled")
1773 );
1774 }
1775
1776 #[test]
1777 fn provider_secrets_lists_every_set_key_and_nothing_else() {
1778 let mut config = Config::default();
1779 assert!(config.provider_secrets().is_empty());
1780
1781 config.providers.anthropic_api_key = Some("a".to_string());
1782 config.openrouter_api_key = Some("o".to_string());
1783 let secrets = config.provider_secrets();
1784 assert_eq!(secrets.len(), 2);
1785 assert!(secrets.contains(&("provider/anthropic".to_string(), "a".to_string())));
1786 assert!(secrets.contains(&("provider/openrouter".to_string(), "o".to_string())));
1787 }
1788
1789 #[test]
1793 fn without_secrets_strips_a_copy_and_leaves_the_original_usable() {
1794 let mut config = Config::default();
1795 config.providers.anthropic_api_key = Some("a".to_string());
1796 config.providers.openai_api_key = Some("b".to_string());
1797 config.providers.google_api_key = Some("c".to_string());
1798 config.openrouter_api_key = Some("d".to_string());
1799 config.default_model = Some("m".to_string());
1800
1801 let stripped = config.without_secrets();
1802 assert!(stripped.provider_secrets().is_empty(), "no keys survive");
1803 assert_eq!(stripped.default_model.as_deref(), Some("m"), "settings do");
1804 assert_eq!(
1805 config.providers.anthropic_api_key.as_deref(),
1806 Some("a"),
1807 "the original is untouched"
1808 );
1809 }
1810
1811 use super::*;
1812 use crate::test_support::with_tracing;
1813
1814 #[test]
1817 fn leviath_home_dir_uses_override_when_set() {
1818 temp_env::with_var(
1819 "LEVIATH_HOME",
1820 Some("/tmp/leviath-home-override-test"),
1821 || {
1822 assert_eq!(
1823 leviath_home_dir(),
1824 Some(std::path::PathBuf::from("/tmp/leviath-home-override-test"))
1825 );
1826 },
1827 );
1828 }
1829
1830 #[test]
1831 fn leviath_home_dir_falls_back_to_dirs_home_dir_when_unset() {
1832 temp_env::with_var_unset("LEVIATH_HOME", || {
1833 assert_eq!(leviath_home_dir(), dirs::home_dir());
1834 });
1835 }
1836
1837 #[test]
1840 fn load_from_path_missing_file_returns_defaults() {
1841 let dir = tempfile::tempdir().unwrap();
1842 let path = dir.path().join("config.toml");
1843 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1844 assert_eq!(config.default_provider, "anthropic");
1845 }
1846
1847 #[test]
1848 fn load_from_path_valid_toml_is_parsed() {
1849 let dir = tempfile::tempdir().unwrap();
1850 let path = dir.path().join("config.toml");
1851 let original = Config {
1852 default_provider: "openai".to_string(),
1853 ..Config::default()
1854 };
1855 std::fs::write(&path, toml::to_string_pretty(&original).unwrap()).unwrap();
1856 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1857 assert_eq!(config.default_provider, "openai");
1858 }
1859
1860 #[test]
1861 fn limits_default_to_bounded_values() {
1862 let limits = LimitsConfig::default();
1863 assert_eq!(limits.max_concurrent_inferences, Some(8));
1864 assert_eq!(limits.default_max_iterations, Some(50));
1865 assert!(!limits.exact_token_counting);
1867 assert_eq!(limits.dead_cycles_before_relief, 10);
1870 assert_eq!(limits.finished_retention_secs, 300);
1873 assert_eq!(limits.interaction_timeout_secs, 3600);
1876 assert_eq!(Config::default().limits.max_concurrent_inferences, Some(8));
1878 }
1879
1880 #[test]
1883 fn interaction_timeout_defaults_and_parses() {
1884 let dir = tempfile::tempdir().unwrap();
1885 let load = |body: String| {
1886 let path = dir.path().join(format!("{}.toml", body.len()));
1887 std::fs::write(&path, body).unwrap();
1888 with_tracing(|| Config::load_from_path(&path)).unwrap()
1889 };
1890
1891 let old = load(format!(
1892 "{}\n[limits]\nmax_concurrent_tools = 4\n",
1893 config_toml_without_limits()
1894 ));
1895 assert_eq!(old.limits.interaction_timeout_secs, 3600);
1896
1897 let disabled = load(format!(
1898 "{}\n[limits]\ninteraction_timeout_secs = 0\n",
1899 config_toml_without_limits()
1900 ));
1901 assert_eq!(disabled.limits.interaction_timeout_secs, 0);
1902 }
1903
1904 #[test]
1905 fn exact_token_counting_parses_when_set() {
1906 let dir = tempfile::tempdir().unwrap();
1907 let path = dir.path().join("config.toml");
1908 let body = format!(
1909 "{}\n[limits]\nexact_token_counting = true\n",
1910 config_toml_without_limits()
1911 );
1912 std::fs::write(&path, body).unwrap();
1913 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1914 assert!(config.limits.exact_token_counting);
1915 assert_eq!(config.limits.max_concurrent_inferences, Some(8));
1917 }
1918
1919 #[cfg(test)]
1923 fn config_toml_without_limits() -> String {
1924 let full = toml::to_string_pretty(&Config::default()).unwrap();
1925 format!("{}\n", full.split("[limits]").next().unwrap().trim_end())
1926 }
1927
1928 #[test]
1929 fn limits_absent_section_uses_defaults() {
1930 let dir = tempfile::tempdir().unwrap();
1932 let path = dir.path().join("config.toml");
1933 std::fs::write(&path, config_toml_without_limits()).unwrap();
1934 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1935 assert_eq!(config.limits.max_concurrent_inferences, Some(8));
1936 assert_eq!(config.limits.default_max_iterations, Some(50));
1937 assert_eq!(config.limits.dead_cycles_before_relief, 10);
1938 assert_eq!(config.limits.finished_retention_secs, 300);
1939 assert_eq!(config.limits.wedge_timeout_secs, 0);
1942 }
1943
1944 #[test]
1945 fn the_wedge_watchdog_is_off_until_it_is_configured() {
1946 let dir = tempfile::tempdir().unwrap();
1947 let path = dir.path().join("config.toml");
1948 let body = format!(
1949 "{}\n[limits]\nwedge_timeout_secs = 300\n",
1950 config_toml_without_limits()
1951 );
1952 std::fs::write(&path, body).unwrap();
1953 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1954 assert_eq!(config.limits.wedge_timeout_secs, 300);
1955 assert_eq!(config.limits.stall_timeout_secs, 60);
1957 }
1958
1959 #[test]
1960 fn limits_partial_section_fills_the_other_default() {
1961 let dir = tempfile::tempdir().unwrap();
1963 let path = dir.path().join("config.toml");
1964 let body = format!(
1965 "{}\n[limits]\nmax_concurrent_inferences = 3\n",
1966 config_toml_without_limits()
1967 );
1968 std::fs::write(&path, body).unwrap();
1969 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1970 assert_eq!(config.limits.max_concurrent_inferences, Some(3));
1971 assert_eq!(config.limits.default_max_iterations, Some(50));
1972 }
1973
1974 #[test]
1975 fn load_from_path_existing_provider_keys_skip_env_fallback() {
1976 let unset: Vec<(&str, Option<&str>)> = PROVIDER_KEY_ENV_VARS
1984 .iter()
1985 .chain(["OLLAMA_HOST"].iter())
1986 .map(|&key| (key, None))
1987 .collect();
1988 temp_env::with_vars(unset, || {
1989 let dir = tempfile::tempdir().unwrap();
1990 let path = dir.path().join("config.toml");
1991 std::fs::write(
1992 &path,
1993 r#"
1994default_provider = "anthropic"
1995openrouter_api_key = "sk-or-existing"
1996ollama_base_url = "http://existing-ollama:11434"
1997agent_paths = []
1998
1999[providers]
2000anthropic_api_key = "sk-ant-existing"
2001openai_api_key = "sk-openai-existing"
2002google_api_key = "AIza-existing"
2003"#,
2004 )
2005 .unwrap();
2006
2007 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
2008
2009 assert_eq!(
2010 config.providers.anthropic_api_key.as_deref(),
2011 Some("sk-ant-existing")
2012 );
2013 assert_eq!(
2014 config.providers.openai_api_key.as_deref(),
2015 Some("sk-openai-existing")
2016 );
2017 assert_eq!(
2018 config.providers.google_api_key.as_deref(),
2019 Some("AIza-existing")
2020 );
2021 assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-existing"));
2022 assert_eq!(
2023 config.ollama_base_url.as_deref(),
2024 Some("http://existing-ollama:11434")
2025 );
2026 });
2027 }
2028
2029 #[test]
2030 fn load_from_path_malformed_toml_returns_error() {
2031 let dir = tempfile::tempdir().unwrap();
2032 let path = dir.path().join("config.toml");
2033 std::fs::write(&path, "not valid toml [[[").unwrap();
2034 let result = Config::load_from_path(&path);
2035 assert!(result.is_err());
2036 assert!(result.unwrap_err().to_string().contains("Failed to parse"));
2037 }
2038
2039 #[test]
2040 fn load_from_path_unreadable_path_returns_error() {
2041 let dir = tempfile::tempdir().unwrap();
2043 let result = Config::load_from_path(dir.path());
2044 assert!(result.is_err());
2045 }
2046
2047 #[test]
2048 fn save_to_path_writes_valid_toml_that_round_trips() {
2049 let dir = tempfile::tempdir().unwrap();
2050 let path = dir.path().join("nested").join("config.toml");
2051 let config = Config {
2052 default_provider: "google".to_string(),
2053 ..Config::default()
2054 };
2055 with_tracing(|| config.save_to_path(&path)).unwrap();
2056
2057 let loaded = with_tracing(|| Config::load_from_path(&path)).unwrap();
2058 assert_eq!(loaded.default_provider, "google");
2059 }
2060
2061 #[test]
2062 fn save_to_path_creates_parent_directory() {
2063 let dir = tempfile::tempdir().unwrap();
2064 let path = dir.path().join("a").join("b").join("config.toml");
2065 let config = Config::default();
2066 with_tracing(|| config.save_to_path(&path)).unwrap();
2067 assert!(path.exists());
2068 }
2069
2070 #[test]
2071 fn save_to_path_with_no_parent_skips_create_config_dir() {
2072 let result = Config::default().save_to_path(&std::path::PathBuf::from(""));
2079 assert!(result.is_err());
2080 }
2081
2082 #[cfg(unix)]
2083 #[test]
2084 fn save_to_path_sets_restrictive_file_permissions() {
2085 use std::os::unix::fs::PermissionsExt;
2086 let dir = tempfile::tempdir().unwrap();
2087 let path = dir.path().join("config.toml");
2088 with_tracing(|| Config::default().save_to_path(&path)).unwrap();
2089 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2090 assert_eq!(mode & 0o777, 0o600);
2091 }
2092
2093 #[test]
2094 fn save_to_path_write_failure_returns_error() {
2095 let dir = tempfile::tempdir().unwrap();
2100 let path = dir.path().join("config.toml");
2101 std::fs::create_dir_all(&path).unwrap();
2102
2103 let result = Config::default().save_to_path(&path);
2104
2105 assert!(result.is_err());
2106 assert!(
2107 result
2108 .unwrap_err()
2109 .to_string()
2110 .contains("Failed to write config")
2111 );
2112 }
2113
2114 #[test]
2115 fn save_to_path_create_config_dir_failure_returns_error() {
2116 let dir = tempfile::tempdir().unwrap();
2117 let blocking_file = dir.path().join("not-a-dir");
2118 std::fs::write(&blocking_file, "").unwrap();
2119 let path = blocking_file.join("config.toml");
2120 let result = Config::default().save_to_path(&path);
2121 assert!(result.is_err());
2122 assert!(
2123 result
2124 .unwrap_err()
2125 .to_string()
2126 .contains("Failed to create config directory")
2127 );
2128 }
2129
2130 #[test]
2131 fn load_propagates_error_when_real_config_file_is_malformed() {
2132 with_isolated_config_path("load-malformed", |fake_dir| {
2137 std::fs::write(fake_dir.join("config.toml"), "not valid toml [[[").unwrap();
2138
2139 let result = Config::load();
2140
2141 assert!(result.is_err());
2142 });
2143 }
2144
2145 #[cfg(unix)]
2148 #[test]
2149 fn check_permissions_at_missing_file_is_noop() {
2150 let dir = tempfile::tempdir().unwrap();
2151 let path = dir.path().join("nonexistent.toml");
2152 check_permissions_at(&path); }
2154
2155 #[cfg(unix)]
2156 #[test]
2157 fn check_permissions_at_fixes_overly_permissive_file() {
2158 use std::os::unix::fs::PermissionsExt;
2159 let dir = tempfile::tempdir().unwrap();
2160 let path = dir.path().join("config.toml");
2161 std::fs::write(&path, "").unwrap();
2162 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2163
2164 with_tracing(|| check_permissions_at(&path));
2165
2166 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2167 assert_eq!(mode & 0o777, 0o600);
2168 }
2169
2170 #[cfg(unix)]
2171 #[test]
2172 fn check_permissions_at_leaves_already_restrictive_file_alone() {
2173 use std::os::unix::fs::PermissionsExt;
2174 let dir = tempfile::tempdir().unwrap();
2175 let path = dir.path().join("config.toml");
2176 std::fs::write(&path, "").unwrap();
2177 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2178
2179 check_permissions_at(&path);
2180
2181 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2182 assert_eq!(mode & 0o777, 0o600);
2183 }
2184
2185 #[test]
2193 fn check_permissions_at_with_logs_when_fix_fails() {
2194 fn ensure_fails(_: &std::path::Path) -> std::io::Result<Option<u32>> {
2195 Err(std::io::Error::other("simulated chmod failure"))
2196 }
2197 with_tracing(|| {
2199 check_permissions_at_with(std::path::Path::new("/does/not/matter"), ensure_fails)
2200 });
2201 }
2202
2203 #[test]
2204 fn check_permissions_at_with_logs_when_file_is_permissive() {
2205 fn ensure_permissive(_: &std::path::Path) -> std::io::Result<Option<u32>> {
2206 Ok(Some(0o100644))
2207 }
2208 with_tracing(|| {
2209 check_permissions_at_with(std::path::Path::new("/does/not/matter"), ensure_permissive)
2210 });
2211 }
2212
2213 fn always_failing_secure(_path: &std::path::Path) -> std::io::Result<()> {
2219 Err(std::io::Error::other(
2220 "simulated permission-hardening failure",
2221 ))
2222 }
2223
2224 #[test]
2225 fn set_dir_permissions_error_branch_logs_not_panics() {
2226 with_tracing(|| {
2227 set_dir_permissions_with(
2228 std::path::Path::new("/does/not/matter"),
2229 always_failing_secure,
2230 )
2231 }); }
2233
2234 #[test]
2239 fn create_config_dir_creates_nested_dirs() {
2240 let dir = tempfile::tempdir().unwrap();
2241 let target = dir.path().join("a").join("b").join("c");
2242 create_config_dir(&target).unwrap();
2243 assert!(target.is_dir());
2244 }
2245
2246 #[cfg(unix)]
2247 #[test]
2248 fn create_config_dir_sets_restrictive_permissions() {
2249 use std::os::unix::fs::PermissionsExt;
2250 let dir = tempfile::tempdir().unwrap();
2251 let target = dir.path().join("leviath");
2252 create_config_dir(&target).unwrap();
2253 let mode = std::fs::metadata(&target).unwrap().permissions().mode();
2254 assert_eq!(mode & 0o777, 0o700);
2255 }
2256
2257 #[cfg(unix)]
2261 #[test]
2269 fn config_path_honors_leviath_home() {
2270 temp_env::with_vars(
2271 [
2272 ("LEVIATH_CONFIG_PATH", None::<&str>),
2273 ("LEVIATH_HOME", Some("/tmp/lev-cfg-test")),
2274 ],
2275 || {
2276 assert_eq!(
2277 Config::config_path(),
2278 std::path::PathBuf::from("/tmp/lev-cfg-test/.leviath/config.toml")
2279 );
2280 },
2281 );
2282 }
2283
2284 #[test]
2286 fn config_path_prefers_the_explicit_override() {
2287 temp_env::with_vars(
2288 [
2289 ("LEVIATH_CONFIG_PATH", Some("/tmp/exact.toml")),
2290 ("LEVIATH_HOME", Some("/tmp/lev-cfg-test")),
2291 ],
2292 || {
2293 assert_eq!(
2294 Config::config_path(),
2295 std::path::PathBuf::from("/tmp/exact.toml")
2296 );
2297 },
2298 );
2299 }
2300
2301 #[test]
2305 fn permissions_for_agent_overlays_the_named_grant_on_the_global() {
2306 let mut config = Config::default();
2307 config
2308 .tool_permissions
2309 .insert("shell".to_string(), ToolPolicy::Ask);
2310 config
2311 .tool_permissions
2312 .insert("write_file".to_string(), ToolPolicy::Deny);
2313 config.agent_tool_permissions.insert(
2314 "coder".to_string(),
2315 HashMap::from([("shell".to_string(), ToolPolicy::Allow)]),
2316 );
2317
2318 let coder = config.permissions_for_agent("coder");
2319 assert_eq!(coder.get("shell"), Some(&ToolPolicy::Allow), "granted");
2320 assert_eq!(
2321 coder.get("write_file"),
2322 Some(&ToolPolicy::Deny),
2323 "the rest of the global ceiling still applies"
2324 );
2325
2326 let other = config.permissions_for_agent("researcher");
2328 assert_eq!(other.get("shell"), Some(&ToolPolicy::Ask));
2329 }
2330
2331 #[test]
2334 fn read_path_grants_merge_global_and_per_agent() {
2335 let mut config = Config::default();
2336 assert!(
2337 !config.security.allow_blueprint_read_paths,
2338 "blueprint read paths must be opt-in"
2339 );
2340 assert!(config.read_path_grants_for_agent("cto").is_empty());
2341
2342 config.security.read_paths = vec!["~/.leviath/runs".to_string()];
2343 config.agent_read_paths.insert(
2344 "cto".to_string(),
2345 ReadPathGrants {
2346 allow: vec!["glob:~/design-docs/**".to_string()],
2347 },
2348 );
2349
2350 assert_eq!(
2351 config.read_path_grants_for_agent("cto"),
2352 vec![
2353 "~/.leviath/runs".to_string(),
2354 "glob:~/design-docs/**".to_string(),
2355 ]
2356 );
2357 assert_eq!(
2359 config.read_path_grants_for_agent("researcher"),
2360 vec!["~/.leviath/runs".to_string()]
2361 );
2362 }
2363
2364 #[test]
2367 fn provider_config_debug_never_prints_the_keys() {
2368 let providers = ProviderConfig {
2369 anthropic_api_key: Some("sk-ant-SECRET-VALUE".to_string()),
2370 openai_api_key: Some("sk-openai-SECRET-VALUE".to_string()),
2371 google_api_key: Some("AIza-SECRET-VALUE".to_string()),
2372 claude_code_enabled: true,
2373 claude_code_binary: None,
2374 claude_code_effort: None,
2375 anthropic_cache_ttl: None,
2376 fallback_order: Vec::new(),
2377 };
2378 let rendered = format!("{providers:?}");
2379 assert!(!rendered.contains("SECRET-VALUE"), "key leaked: {rendered}");
2380 assert!(rendered.contains("<set>"), "{rendered}");
2382 assert!(rendered.contains("claude_code_enabled: true"), "{rendered}");
2383
2384 let empty = format!(
2385 "{:?}",
2386 ProviderConfig {
2387 anthropic_api_key: None,
2388 openai_api_key: None,
2389 google_api_key: None,
2390 claude_code_enabled: false,
2391 claude_code_binary: None,
2392 claude_code_effort: None,
2393 anthropic_cache_ttl: None,
2394 fallback_order: Vec::new(),
2395 }
2396 );
2397 assert!(empty.contains("<unset>"), "{empty}");
2398 }
2399
2400 #[cfg(unix)]
2404 #[test]
2405 fn saving_a_config_never_leaves_it_group_or_world_readable() {
2406 use std::os::unix::fs::PermissionsExt;
2407 let dir = tempfile::tempdir().unwrap();
2408 let path = dir.path().join("config.toml");
2409
2410 Config::default().save_to_path(&path).unwrap();
2411 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2412 assert_eq!(mode & 0o777, 0o600, "fresh config must be owner-only");
2413
2414 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2416 Config::default().save_to_path(&path).unwrap();
2417 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2418 assert_eq!(mode & 0o777, 0o600, "re-saving must re-tighten");
2419 }
2420
2421 #[cfg(unix)]
2422 #[test]
2423 fn set_dir_permissions_sets_0700() {
2424 use std::os::unix::fs::PermissionsExt;
2425 let dir = tempfile::tempdir().unwrap();
2426 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
2427 set_dir_permissions(dir.path());
2428 let mode = std::fs::metadata(dir.path()).unwrap().permissions().mode();
2429 assert_eq!(mode & 0o777, 0o700);
2430 }
2431
2432 #[test]
2433 fn test_validate_keys_good_anthropic() {
2434 let config = Config {
2435 providers: ProviderConfig {
2436 anthropic_api_key: Some("sk-ant-test123".to_string()),
2437 openai_api_key: None,
2438 google_api_key: None,
2439 claude_code_enabled: false,
2440 claude_code_binary: None,
2441 claude_code_effort: None,
2442 anthropic_cache_ttl: None,
2443 fallback_order: Vec::new(),
2444 },
2445 ..Config::default()
2446 };
2447 assert!(config.validate_keys().is_empty());
2448 }
2449
2450 #[test]
2451 fn test_validate_keys_bad_anthropic() {
2452 let config = Config {
2453 providers: ProviderConfig {
2454 anthropic_api_key: Some("bad-key".to_string()),
2455 openai_api_key: None,
2456 google_api_key: None,
2457 claude_code_enabled: false,
2458 claude_code_binary: None,
2459 claude_code_effort: None,
2460 anthropic_cache_ttl: None,
2461 fallback_order: Vec::new(),
2462 },
2463 ..Config::default()
2464 };
2465 let warnings = config.validate_keys();
2466 assert_eq!(warnings.len(), 1);
2467 assert!(warnings[0].contains("Anthropic"));
2468 }
2469
2470 #[test]
2471 fn test_validate_keys_good_openai() {
2472 let config = Config {
2473 providers: ProviderConfig {
2474 anthropic_api_key: None,
2475 openai_api_key: Some("sk-test123".to_string()),
2476 google_api_key: None,
2477 claude_code_enabled: false,
2478 claude_code_binary: None,
2479 claude_code_effort: None,
2480 anthropic_cache_ttl: None,
2481 fallback_order: Vec::new(),
2482 },
2483 ..Config::default()
2484 };
2485 assert!(config.validate_keys().is_empty());
2486 }
2487
2488 #[test]
2489 fn test_validate_keys_bad_openai() {
2490 let config = Config {
2491 providers: ProviderConfig {
2492 anthropic_api_key: None,
2493 openai_api_key: Some("bad-key".to_string()),
2494 google_api_key: None,
2495 claude_code_enabled: false,
2496 claude_code_binary: None,
2497 claude_code_effort: None,
2498 anthropic_cache_ttl: None,
2499 fallback_order: Vec::new(),
2500 },
2501 ..Config::default()
2502 };
2503 let warnings = config.validate_keys();
2504 assert_eq!(warnings.len(), 1);
2505 assert!(warnings[0].contains("OpenAI"));
2506 }
2507
2508 #[test]
2509 fn test_validate_keys_no_keys() {
2510 let config = Config::default();
2511 assert!(config.validate_keys().is_empty());
2512 }
2513
2514 #[test]
2517 fn config_default_values() {
2518 let config = Config::default();
2519 assert_eq!(config.default_provider, "anthropic");
2520 assert!(config.providers.anthropic_api_key.is_none());
2521 assert!(config.providers.openai_api_key.is_none());
2522 assert!(config.providers.google_api_key.is_none());
2523 assert!(config.openrouter_api_key.is_none());
2524 assert!(config.ollama_base_url.is_none());
2525 assert!(config.mcp_servers.is_empty());
2526 assert!(config.default_model.is_none());
2527 assert!(config.model_capabilities.is_empty());
2528 assert!(config.tool_permissions.is_empty());
2529 }
2530
2531 #[test]
2534 fn title_config_default() {
2535 let tc = TitleConfig::default();
2536 assert!(tc.enabled);
2537 assert!(tc.provider.is_none());
2538 assert!(tc.model.is_none());
2539 }
2540
2541 #[test]
2542 fn title_config_serde_roundtrip() {
2543 let tc = TitleConfig {
2544 enabled: false,
2545 provider: Some("openai".to_string()),
2546 model: Some("gpt-5.4-mini".to_string()),
2547 };
2548 let json = serde_json::to_string(&tc).unwrap();
2549 let back: TitleConfig = serde_json::from_str(&json).unwrap();
2550 assert!(!back.enabled);
2551 assert_eq!(back.provider.as_deref(), Some("openai"));
2552 assert_eq!(back.model.as_deref(), Some("gpt-5.4-mini"));
2553 }
2554
2555 #[test]
2558 fn tool_policy_default_is_ask() {
2559 let policy = ToolPolicy::default();
2560 assert_eq!(policy, ToolPolicy::Ask);
2561 }
2562
2563 #[test]
2564 fn tool_policy_serde_roundtrip() {
2565 for policy in [ToolPolicy::Allow, ToolPolicy::Ask, ToolPolicy::Deny] {
2566 let json = serde_json::to_string(&policy).unwrap();
2567 let back: ToolPolicy = serde_json::from_str(&json).unwrap();
2568 assert_eq!(policy, back);
2569 }
2570 }
2571
2572 #[test]
2573 fn tool_policy_snake_case_serialization() {
2574 assert_eq!(
2575 serde_json::to_string(&ToolPolicy::Allow).unwrap(),
2576 "\"allow\""
2577 );
2578 assert_eq!(serde_json::to_string(&ToolPolicy::Ask).unwrap(), "\"ask\"");
2579 assert_eq!(
2580 serde_json::to_string(&ToolPolicy::Deny).unwrap(),
2581 "\"deny\""
2582 );
2583 }
2584
2585 #[test]
2588 fn config_from_toml_with_all_fields() {
2589 let toml_content = r#"
2590default_provider = "openai"
2591openrouter_api_key = "sk-or-test"
2592ollama_base_url = "http://my-ollama:11434"
2593default_model = "gpt-5"
2594agent_paths = []
2595
2596[providers]
2597anthropic_api_key = "sk-ant-test"
2598openai_api_key = "sk-test"
2599google_api_key = "AIza-test"
2600
2601[tool_permissions]
2602bash = "deny"
2603read_file = "allow"
2604
2605[title]
2606enabled = false
2607provider = "anthropic"
2608model = "claude-haiku-4-5"
2609"#;
2610 let config: Config = toml::from_str(toml_content).unwrap();
2611 assert_eq!(config.default_provider, "openai");
2612 assert_eq!(
2613 config.providers.anthropic_api_key.as_deref(),
2614 Some("sk-ant-test")
2615 );
2616 assert_eq!(config.providers.openai_api_key.as_deref(), Some("sk-test"));
2617 assert_eq!(
2618 config.providers.google_api_key.as_deref(),
2619 Some("AIza-test")
2620 );
2621 assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-test"));
2622 assert_eq!(
2623 config.ollama_base_url.as_deref(),
2624 Some("http://my-ollama:11434")
2625 );
2626 assert_eq!(config.default_model.as_deref(), Some("gpt-5"));
2627 assert!(!config.title.enabled);
2628 assert_eq!(config.tool_permissions.get("bash"), Some(&ToolPolicy::Deny));
2629 assert_eq!(
2630 config.tool_permissions.get("read_file"),
2631 Some(&ToolPolicy::Allow)
2632 );
2633 }
2634
2635 #[test]
2636 fn config_from_minimal_toml() {
2637 let toml_content = r#"
2638default_provider = "anthropic"
2639agent_paths = []
2640
2641[providers]
2642"#;
2643 let config: Config = toml::from_str(toml_content).unwrap();
2644 assert_eq!(config.default_provider, "anthropic");
2645 assert!(config.providers.anthropic_api_key.is_none());
2646 }
2647
2648 #[test]
2649 fn the_three_lines_that_point_leviath_at_openrouter_are_enough() {
2650 let config: Config = toml::from_str(
2655 r#"
2656default_provider = "openrouter"
2657default_model = "openai/gpt-4o-mini"
2658openrouter_api_key = "sk-or-test"
2659"#,
2660 )
2661 .expect("a hand-written OpenRouter config parses");
2662 assert_eq!(config.default_provider, "openrouter");
2663 assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-test"));
2664 assert_eq!(config.default_model.as_deref(), Some("openai/gpt-4o-mini"));
2665 }
2666
2667 #[test]
2668 fn an_empty_config_file_parses_to_the_defaults() {
2669 let parsed: Config = toml::from_str("").expect("an empty config parses");
2673 let default = Config::default();
2674 assert_eq!(parsed.default_provider, default.default_provider);
2675 assert_eq!(parsed.agent_paths, default.agent_paths);
2676 assert_eq!(parsed.openrouter_api_key, default.openrouter_api_key);
2677 assert_eq!(parsed.ollama_base_url, default.ollama_base_url);
2678 assert_eq!(parsed.default_model, default.default_model);
2679 assert_eq!(parsed.request_timeout_secs, default.request_timeout_secs);
2680 assert_eq!(
2681 parsed.providers.anthropic_api_key,
2682 default.providers.anthropic_api_key
2683 );
2684 assert_eq!(
2685 parsed.providers.claude_code_enabled,
2686 default.providers.claude_code_enabled
2687 );
2688 }
2689
2690 #[test]
2691 fn config_from_toml_with_mcp_servers() {
2692 let toml_content = r#"
2693default_provider = "anthropic"
2694agent_paths = []
2695
2696[providers]
2697
2698[[mcp_servers]]
2699name = "test-server"
2700command = "echo"
2701args = ["hello"]
2702"#;
2703 let config: Config = toml::from_str(toml_content).unwrap();
2704 assert_eq!(config.mcp_servers.len(), 1);
2705 assert_eq!(config.mcp_servers[0].name, "test-server");
2706 }
2707
2708 #[test]
2709 fn load_rejects_a_malformed_mcp_server_entry() {
2710 let dir = tempfile::tempdir().unwrap();
2714 let path = dir.path().join("config.toml");
2715 std::fs::write(
2716 &path,
2717 r#"
2718default_provider = "anthropic"
2719agent_paths = []
2720
2721[providers]
2722
2723[[mcp_servers]]
2724name = "broken"
2725"#,
2726 )
2727 .unwrap();
2728
2729 let err = Config::load_from_path(&path).expect_err("malformed entry must fail load");
2730 let msg = err.to_string();
2731 assert!(msg.contains("broken"), "must name the server: {msg}");
2732 }
2733
2734 #[test]
2735 fn load_accepts_a_well_formed_http_mcp_server() {
2736 let dir = tempfile::tempdir().unwrap();
2737 let path = dir.path().join("config.toml");
2738 std::fs::write(
2739 &path,
2740 r#"
2741default_provider = "anthropic"
2742agent_paths = []
2743
2744[providers]
2745
2746[[mcp_servers]]
2747name = "remote"
2748url = "https://mcp.example.com/mcp"
2749"#,
2750 )
2751 .unwrap();
2752
2753 let config = Config::load_from_path(&path).expect("valid http entry should load");
2754 assert_eq!(
2755 config.mcp_servers[0].url.as_deref(),
2756 Some("https://mcp.example.com/mcp")
2757 );
2758 }
2759
2760 #[test]
2761 fn config_from_toml_with_model_capabilities() {
2762 let toml = r#"
2767[model_capabilities."my-custom-model"]
2768max_context_tokens = 1048576
2769"#;
2770 let config: Config = toml::from_str(toml).expect("a partial entry parses");
2771 let entry = config
2772 .model_capabilities
2773 .get("my-custom-model")
2774 .expect("the entry survives");
2775 assert_eq!(entry.max_context_tokens, Some(1_048_576));
2776 assert_eq!(
2777 entry.max_output_tokens, None,
2778 "an unmentioned field stays unset rather than defaulting"
2779 );
2780 assert_eq!(entry.supports_tools, None);
2781 }
2782
2783 #[test]
2786 fn config_model_capabilities_rejects_an_unknown_key() {
2787 let toml = r#"
2788[model_capabilities."my-custom-model"]
2789max_contxt_tokens = 1048576
2790"#;
2791 assert!(toml::from_str::<Config>(toml).is_err());
2792 }
2793
2794 #[test]
2795 fn validate_keys_is_quiet_about_blank_keys() {
2796 let mut config = Config::default();
2797 config.providers.anthropic_api_key = Some(String::new());
2798 config.providers.openai_api_key = Some(" ".to_string());
2799 assert!(config.validate_keys().is_empty());
2800 config.providers.anthropic_api_key = Some("nope".to_string());
2802 assert_eq!(config.validate_keys().len(), 1);
2803 }
2804
2805 #[test]
2806 fn validate_keys_both_bad() {
2807 let config = Config {
2808 providers: ProviderConfig {
2809 anthropic_api_key: Some("bad".to_string()),
2810 openai_api_key: Some("bad".to_string()),
2811 google_api_key: None,
2812 claude_code_enabled: false,
2813 claude_code_binary: None,
2814 claude_code_effort: None,
2815 anthropic_cache_ttl: None,
2816 fallback_order: Vec::new(),
2817 },
2818 ..Config::default()
2819 };
2820 let warnings = config.validate_keys();
2821 assert_eq!(warnings.len(), 2);
2822 }
2823
2824 #[test]
2827 fn config_path_contains_leviath() {
2828 temp_env::with_var_unset("LEVIATH_CONFIG_PATH", || {
2833 let path = Config::config_path();
2834 assert!(path.to_str().unwrap().contains(".leviath"));
2835 assert!(path.to_str().unwrap().ends_with("config.toml"));
2836 });
2837 }
2838
2839 #[test]
2842 fn config_toml_roundtrip() {
2843 let config = Config {
2844 default_provider: "openai".to_string(),
2845 providers: ProviderConfig {
2846 anthropic_api_key: Some("sk-ant-key".to_string()),
2847 openai_api_key: None,
2848 google_api_key: None,
2849 claude_code_enabled: false,
2850 claude_code_binary: None,
2851 claude_code_effort: None,
2852 anthropic_cache_ttl: None,
2853 fallback_order: Vec::new(),
2854 },
2855 tool_permissions: {
2856 let mut m = HashMap::new();
2857 m.insert("bash".to_string(), ToolPolicy::Deny);
2858 m
2859 },
2860 ..Config::default()
2861 };
2862
2863 let serialized = toml::to_string_pretty(&config).unwrap();
2864 let deserialized: Config = toml::from_str(&serialized).unwrap();
2865 assert_eq!(deserialized.default_provider, "openai");
2866 assert_eq!(
2867 deserialized.providers.anthropic_api_key.as_deref(),
2868 Some("sk-ant-key")
2869 );
2870 assert_eq!(
2871 deserialized.tool_permissions.get("bash"),
2872 Some(&ToolPolicy::Deny)
2873 );
2874 }
2875
2876 #[test]
2879 fn validate_keys_both_valid() {
2880 let config = Config {
2881 providers: ProviderConfig {
2882 anthropic_api_key: Some("sk-ant-good-key".to_string()),
2883 openai_api_key: Some("sk-good-key".to_string()),
2884 google_api_key: None,
2885 claude_code_enabled: false,
2886 claude_code_binary: None,
2887 claude_code_effort: None,
2888 anthropic_cache_ttl: None,
2889 fallback_order: Vec::new(),
2890 },
2891 ..Config::default()
2892 };
2893 assert!(config.validate_keys().is_empty());
2894 }
2895
2896 #[test]
2899 fn validate_keys_google_key_not_validated() {
2900 let config = Config {
2901 providers: ProviderConfig {
2902 anthropic_api_key: None,
2903 openai_api_key: None,
2904 google_api_key: Some("anything-goes".to_string()),
2905 claude_code_enabled: false,
2906 claude_code_binary: None,
2907 claude_code_effort: None,
2908 anthropic_cache_ttl: None,
2909 fallback_order: Vec::new(),
2910 },
2911 ..Config::default()
2912 };
2913 assert!(config.validate_keys().is_empty());
2915 }
2916
2917 #[test]
2920 fn config_from_toml_custom_registries() {
2921 let toml_content = r#"
2922default_provider = "anthropic"
2923agent_paths = ["/my/agents"]
2924
2925[providers]
2926"#;
2927 let config: Config = toml::from_str(toml_content).unwrap();
2928 assert_eq!(config.agent_paths.len(), 1);
2929 }
2930
2931 #[test]
2934 fn config_save_creates_file() {
2935 let dir = tempfile::tempdir().unwrap();
2936 let config_path = dir.path().join("subdir").join("config.toml");
2937 let config = Config::default();
2940 let content = toml::to_string_pretty(&config).unwrap();
2941 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
2942 std::fs::write(&config_path, &content).unwrap();
2943 assert!(config_path.exists());
2944 let loaded_content = std::fs::read_to_string(&config_path).unwrap();
2945 let loaded: Config = toml::from_str(&loaded_content).unwrap();
2946 assert_eq!(loaded.default_provider, "anthropic");
2947 }
2948
2949 #[test]
2952 fn title_config_from_toml_defaults() {
2953 let toml_content = r#"
2954default_provider = "anthropic"
2955agent_paths = []
2956
2957[providers]
2958"#;
2959 let config: Config = toml::from_str(toml_content).unwrap();
2960 assert!(config.title.enabled);
2961 assert!(config.title.provider.is_none());
2962 assert!(config.title.model.is_none());
2963 }
2964
2965 #[test]
2966 fn title_config_from_toml_disabled() {
2967 let toml_content = r#"
2968default_provider = "anthropic"
2969agent_paths = []
2970
2971[providers]
2972
2973[title]
2974enabled = false
2975"#;
2976 let config: Config = toml::from_str(toml_content).unwrap();
2977 assert!(!config.title.enabled);
2978 }
2979
2980 #[test]
2981 fn title_config_missing_enabled_key_uses_default_true() {
2982 let toml_content = r#"
2989default_provider = "anthropic"
2990agent_paths = []
2991
2992[providers]
2993
2994[title]
2995provider = "openai"
2996"#;
2997 let config: Config = toml::from_str(toml_content).unwrap();
2998 assert!(config.title.enabled);
2999 assert_eq!(config.title.provider.as_deref(), Some("openai"));
3000 }
3001
3002 #[test]
3005 fn config_tool_permissions_allow() {
3006 let toml_content = r#"
3007default_provider = "anthropic"
3008agent_paths = []
3009
3010[providers]
3011
3012[tool_permissions]
3013read_file = "allow"
3014write_file = "ask"
3015bash = "deny"
3016"#;
3017 let config: Config = toml::from_str(toml_content).unwrap();
3018 assert_eq!(
3019 config.tool_permissions.get("read_file"),
3020 Some(&ToolPolicy::Allow)
3021 );
3022 assert_eq!(
3023 config.tool_permissions.get("write_file"),
3024 Some(&ToolPolicy::Ask)
3025 );
3026 assert_eq!(config.tool_permissions.get("bash"), Some(&ToolPolicy::Deny));
3027 }
3028
3029 #[test]
3032 fn config_with_agent_paths() {
3033 let toml_content = r#"
3034default_provider = "anthropic"
3035agent_paths = ["/home/user/agents", "/opt/agents"]
3036
3037[providers]
3038"#;
3039 let config: Config = toml::from_str(toml_content).unwrap();
3040 assert_eq!(config.agent_paths.len(), 2);
3041 }
3042
3043 #[test]
3046 fn config_load_from_nonexistent_path_returns_default() {
3047 let config = Config::default();
3051 assert_eq!(config.default_provider, "anthropic");
3052 assert!(config.providers.anthropic_api_key.is_none());
3053 }
3054
3055 #[test]
3056 fn config_load_from_toml_string() {
3057 let toml_content = r#"
3059default_provider = "openai"
3060agent_paths = []
3061
3062[providers]
3063anthropic_api_key = "sk-ant-test-key"
3064"#;
3065 let config: Config = toml::from_str(toml_content).unwrap();
3066 assert_eq!(config.default_provider, "openai");
3067 assert_eq!(
3068 config.providers.anthropic_api_key.as_deref(),
3069 Some("sk-ant-test-key")
3070 );
3071 assert_eq!(config.nudge, leviath_core::NudgeConfig::default());
3073 }
3074
3075 #[test]
3076 fn config_parses_partial_nudge_section() {
3077 let config: Config = toml::from_str(
3079 r#"
3080default_provider = "openai"
3081agent_paths = []
3082
3083[providers]
3084
3085[nudge]
3086enabled = false
3087"#,
3088 )
3089 .unwrap();
3090 assert_eq!(config.nudge.enabled, Some(false));
3091 assert_eq!(config.nudge.max, None);
3092 assert_eq!(config.nudge.text, None);
3093 }
3094
3095 #[test]
3096 fn config_save_and_load_with_file() {
3097 let dir = tempfile::tempdir().unwrap();
3099 let config_path = dir.path().join("config.toml");
3100
3101 let config = Config {
3102 default_provider: "openai".to_string(),
3103 providers: ProviderConfig {
3104 anthropic_api_key: Some("sk-ant-test".to_string()),
3105 openai_api_key: Some("sk-test".to_string()),
3106 google_api_key: None,
3107 claude_code_enabled: false,
3108 claude_code_binary: None,
3109 claude_code_effort: None,
3110 anthropic_cache_ttl: None,
3111 fallback_order: Vec::new(),
3112 },
3113 openrouter_api_key: Some("sk-or-test".to_string()),
3114 default_model: Some("gpt-5".to_string()),
3115 ..Config::default()
3116 };
3117
3118 let content = toml::to_string_pretty(&config).unwrap();
3119 std::fs::write(&config_path, &content).unwrap();
3120
3121 let loaded_content = std::fs::read_to_string(&config_path).unwrap();
3122 let loaded: Config = toml::from_str(&loaded_content).unwrap();
3123
3124 assert_eq!(loaded.default_provider, "openai");
3125 assert_eq!(
3126 loaded.providers.anthropic_api_key.as_deref(),
3127 Some("sk-ant-test")
3128 );
3129 assert_eq!(loaded.default_model.as_deref(), Some("gpt-5"));
3130 }
3131
3132 #[test]
3133 fn config_create_config_dir_creates_parent() {
3134 let dir = tempfile::tempdir().unwrap();
3135 let new_dir = dir.path().join("nested").join("config");
3136 std::fs::create_dir_all(&new_dir).unwrap();
3138 assert!(new_dir.exists());
3139 }
3140
3141 #[test]
3142 fn config_default_title_enabled() {
3143 let config = Config::default();
3144 assert!(config.title.enabled);
3145 }
3146
3147 #[test]
3148 fn config_serialize_with_all_options() {
3149 let mut model_caps = HashMap::new();
3150 model_caps.insert(
3151 "my-model".to_string(),
3152 ModelCapabilityOverride {
3153 supports_temperature: Some(true),
3154 supports_streaming: Some(true),
3155 supports_tools: Some(true),
3156 supports_system_prompt: Some(true),
3157 max_context_tokens: Some(8192),
3158 max_output_tokens: Some(4096),
3159 },
3160 );
3161 let mut tool_perms = HashMap::new();
3162 tool_perms.insert("bash".to_string(), ToolPolicy::Allow);
3163
3164 let config = Config {
3165 default_provider: "anthropic".to_string(),
3166 providers: ProviderConfig {
3167 anthropic_api_key: Some("sk-ant-key".to_string()),
3168 openai_api_key: None,
3169 google_api_key: None,
3170 claude_code_enabled: false,
3171 claude_code_binary: None,
3172 claude_code_effort: None,
3173 anthropic_cache_ttl: None,
3174 fallback_order: Vec::new(),
3175 },
3176 agent_paths: vec![std::path::PathBuf::from("/my/agents")],
3177 openrouter_api_key: None,
3178 ollama_base_url: Some("http://custom:11434".to_string()),
3179 mcp_servers: vec![],
3180 default_model: None,
3181 model_capabilities: model_caps,
3182 model_providers: HashMap::new(),
3183 tool_permissions: tool_perms,
3184 agent_tool_permissions: HashMap::new(),
3185 safe_commands: crate::approvals::SafeCommands::default(),
3186 agent_safe_commands: HashMap::new(),
3187 title: TitleConfig {
3188 enabled: false,
3189 provider: Some("openai".to_string()),
3190 model: Some("gpt-5-mini".to_string()),
3191 },
3192 request_timeout_secs: None,
3193 rate_limits: HashMap::new(),
3194 taint_tracking: false,
3195 limits: LimitsConfig {
3196 mcp_idle_disconnect_secs: default_mcp_idle_disconnect_secs(),
3197 max_tool_call_write_bytes: None,
3198 max_run_write_bytes: None,
3199 max_concurrent_inferences: Some(4),
3200 max_concurrent_tools: 3,
3201 default_max_iterations: Some(99),
3202 exact_token_counting: false,
3203 script_shell_timeout_secs: 45,
3204 stall_timeout_secs: 90,
3205 dead_cycles_before_relief: 6,
3206 finished_retention_secs: 120,
3207 wedge_timeout_secs: 420,
3208 provider_failures_before_open: 5,
3209 provider_circuit_cooldown_secs: 120,
3210 interaction_timeout_secs: 120,
3211 },
3212 batch_tool_hint: true,
3213 shell_hint: false,
3214 nudge: leviath_core::NudgeConfig {
3215 enabled: Some(true),
3216 max: Some(2),
3217 text: Some("Use your tools.".to_string()),
3218 },
3219 webhook: WebhookConfig {
3220 max_retries: 5,
3221 base_delay_ms: 250,
3222 max_delay_ms: 10_000,
3223 timeout_secs: 7,
3224 },
3225 observability: ObservabilityConfig {
3226 enabled: true,
3227 exporter: TelemetryExporterKind::Stdout,
3228 endpoint: Some("http://collector:4318".to_string()),
3229 service_name: Some("leviath-prod".to_string()),
3230 },
3231 sandbox: Some(leviath_core::ToolSandboxConfig {
3232 kind: leviath_core::SandboxKind::Container,
3233 image: Some("ubuntu:24.04".to_string()),
3234 network: false,
3235 ..Default::default()
3236 }),
3237 tool_script_permissions: ScriptToolPermissions {
3238 http_get: ScriptPermission::Allow,
3239 http_post: ScriptPermission::Deny,
3240 shell: ScriptPermission::Deny,
3241 read_file: ScriptPermission::Inherit,
3242 write_file: ScriptPermission::Deny,
3243 env_var: ScriptPermission::Allow,
3244 },
3245 security: SecurityConfig {
3246 allowed_workdirs: Vec::new(),
3247 allow_seed_commands: false,
3248 allow_local_network: true,
3249 allow_env_vars: vec!["MY_PROVIDER_KEY".to_string()],
3250 allow_blueprint_read_paths: true,
3251 allow_blueprint_safe_commands: true,
3252 read_paths: vec!["~/.leviath/runs".to_string()],
3253 credential_store: leviath_core::CredentialStoreKind::Keychain,
3254 allow_blueprint_permissions: false,
3255 shell_env: leviath_core::ShellEnvMode::default(),
3256 shell_env_withhold: Vec::new(),
3257 },
3258 agent_read_paths: HashMap::from([(
3259 "cto".to_string(),
3260 ReadPathGrants {
3261 allow: vec!["glob:~/design-docs/**".to_string()],
3262 },
3263 )]),
3264 };
3265
3266 let serialized = toml::to_string_pretty(&config).unwrap();
3267 let deserialized: Config = toml::from_str(&serialized).unwrap();
3268
3269 assert_eq!(deserialized.default_provider, "anthropic");
3270 assert_eq!(deserialized.limits.max_concurrent_inferences, Some(4));
3271 assert_eq!(deserialized.limits.max_concurrent_tools, 3);
3272 assert_eq!(deserialized.limits.script_shell_timeout_secs, 45);
3273 assert_eq!(deserialized.limits.dead_cycles_before_relief, 6);
3274 assert_eq!(deserialized.limits.finished_retention_secs, 120);
3275 assert_eq!(
3276 deserialized.tool_script_permissions.http_get,
3277 ScriptPermission::Allow
3278 );
3279 assert_eq!(
3280 deserialized.tool_script_permissions.shell,
3281 ScriptPermission::Deny
3282 );
3283 assert_eq!(
3284 deserialized.tool_script_permissions.write_file,
3285 ScriptPermission::Deny
3286 );
3287 assert!(deserialized.batch_tool_hint);
3290 assert!(!deserialized.shell_hint);
3291 assert!(!deserialized.security.allow_seed_commands);
3292 assert!(deserialized.security.allow_blueprint_read_paths);
3293 assert_eq!(deserialized.security.read_paths, vec!["~/.leviath/runs"]);
3294 assert_eq!(
3295 deserialized.agent_read_paths.get("cto"),
3296 Some(&ReadPathGrants {
3297 allow: vec!["glob:~/design-docs/**".to_string()],
3298 })
3299 );
3300 assert_eq!(
3301 deserialized.nudge,
3302 leviath_core::NudgeConfig {
3303 enabled: Some(true),
3304 max: Some(2),
3305 text: Some("Use your tools.".to_string()),
3306 }
3307 );
3308 assert_eq!(deserialized.webhook.max_retries, 5);
3309 assert_eq!(deserialized.webhook.base_delay_ms, 250);
3310 assert_eq!(deserialized.webhook.max_delay_ms, 10_000);
3311 assert_eq!(deserialized.webhook.timeout_secs, 7);
3312 assert!(deserialized.observability.enabled);
3313 assert_eq!(
3314 deserialized.observability.exporter,
3315 TelemetryExporterKind::Stdout
3316 );
3317 assert_eq!(
3318 deserialized.observability.endpoint.as_deref(),
3319 Some("http://collector:4318")
3320 );
3321 assert_eq!(
3322 deserialized.observability.service_name.as_deref(),
3323 Some("leviath-prod")
3324 );
3325 assert_eq!(deserialized.limits.default_max_iterations, Some(99));
3326 assert_eq!(
3327 deserialized.providers.anthropic_api_key.as_deref(),
3328 Some("sk-ant-key")
3329 );
3330 assert_eq!(deserialized.agent_paths.len(), 1);
3331 assert!(deserialized.model_capabilities.contains_key("my-model"));
3332 assert_eq!(
3333 deserialized.tool_permissions.get("bash"),
3334 Some(&ToolPolicy::Allow)
3335 );
3336 assert!(!deserialized.title.enabled);
3337 assert_eq!(deserialized.title.provider.as_deref(), Some("openai"));
3338 let sandbox = deserialized.sandbox.expect("sandbox round-trips");
3339 assert_eq!(sandbox.kind, leviath_core::SandboxKind::Container);
3340 assert_eq!(sandbox.image.as_deref(), Some("ubuntu:24.04"));
3341 assert!(!sandbox.network);
3342 }
3343
3344 #[test]
3347 fn config_multiple_model_capabilities() {
3348 let toml_content = r#"
3349default_provider = "anthropic"
3350agent_paths = []
3351
3352[providers]
3353
3354[model_capabilities."model-a"]
3355supports_temperature = true
3356supports_streaming = true
3357supports_tools = true
3358supports_system_prompt = true
3359max_context_tokens = 8192
3360max_output_tokens = 4096
3361
3362[model_capabilities."model-b"]
3363supports_temperature = false
3364supports_streaming = false
3365supports_tools = false
3366supports_system_prompt = false
3367max_context_tokens = 2048
3368max_output_tokens = 1024
3369"#;
3370 let config: Config = toml::from_str(toml_content).unwrap();
3371 assert_eq!(config.model_capabilities.len(), 2);
3372 let caps_a = config.model_capabilities.get("model-a").unwrap();
3373 assert_eq!(caps_a.supports_temperature, Some(true));
3374 assert_eq!(caps_a.max_context_tokens, Some(8192));
3375 let caps_b = config.model_capabilities.get("model-b").unwrap();
3376 assert_eq!(caps_b.supports_temperature, Some(false));
3377 assert_eq!(caps_b.max_context_tokens, Some(2048));
3378 }
3379}