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]
1907 fn the_inference_retry_schedule_defaults_and_parses() {
1908 let dir = tempfile::tempdir().unwrap();
1909 let load = |name: &str, body: String| {
1910 let path = dir.path().join(name);
1911 std::fs::write(&path, body).unwrap();
1912 with_tracing(|| Config::load_from_path(&path)).unwrap()
1913 };
1914
1915 let old = load(
1916 "old.toml",
1917 format!(
1918 "{}\n[limits]\nmax_concurrent_tools = 4\n",
1919 config_toml_without_limits()
1920 ),
1921 );
1922 assert_eq!(
1923 old.limits.inference_retry_attempts,
1924 leviath_runtime::DEFAULT_RETRY_ATTEMPTS
1925 );
1926 assert_eq!(
1927 old.limits.inference_retry_base_ms,
1928 leviath_runtime::DEFAULT_RETRY_BASE_DELAY_MS
1929 );
1930
1931 let tuned = load(
1934 "tuned.toml",
1935 format!(
1936 "{}\n[limits]\ninference_retry_attempts = 8\ninference_retry_base_ms = 2000\n",
1937 config_toml_without_limits()
1938 ),
1939 );
1940 assert_eq!(tuned.limits.inference_retry_attempts, 8);
1941 assert_eq!(tuned.limits.inference_retry_base_ms, 2000);
1942 }
1943
1944 #[test]
1945 fn exact_token_counting_parses_when_set() {
1946 let dir = tempfile::tempdir().unwrap();
1947 let path = dir.path().join("config.toml");
1948 let body = format!(
1949 "{}\n[limits]\nexact_token_counting = true\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!(config.limits.exact_token_counting);
1955 assert_eq!(config.limits.max_concurrent_inferences, Some(8));
1957 }
1958
1959 #[cfg(test)]
1963 fn config_toml_without_limits() -> String {
1964 let full = toml::to_string_pretty(&Config::default()).unwrap();
1965 format!("{}\n", full.split("[limits]").next().unwrap().trim_end())
1966 }
1967
1968 #[test]
1969 fn limits_absent_section_uses_defaults() {
1970 let dir = tempfile::tempdir().unwrap();
1972 let path = dir.path().join("config.toml");
1973 std::fs::write(&path, config_toml_without_limits()).unwrap();
1974 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1975 assert_eq!(config.limits.max_concurrent_inferences, Some(8));
1976 assert_eq!(config.limits.default_max_iterations, Some(50));
1977 assert_eq!(config.limits.dead_cycles_before_relief, 10);
1978 assert_eq!(config.limits.finished_retention_secs, 300);
1979 assert_eq!(config.limits.wedge_timeout_secs, 0);
1982 }
1983
1984 #[test]
1985 fn the_wedge_watchdog_is_off_until_it_is_configured() {
1986 let dir = tempfile::tempdir().unwrap();
1987 let path = dir.path().join("config.toml");
1988 let body = format!(
1989 "{}\n[limits]\nwedge_timeout_secs = 300\n",
1990 config_toml_without_limits()
1991 );
1992 std::fs::write(&path, body).unwrap();
1993 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1994 assert_eq!(config.limits.wedge_timeout_secs, 300);
1995 assert_eq!(config.limits.stall_timeout_secs, 60);
1997 }
1998
1999 #[test]
2000 fn limits_partial_section_fills_the_other_default() {
2001 let dir = tempfile::tempdir().unwrap();
2003 let path = dir.path().join("config.toml");
2004 let body = format!(
2005 "{}\n[limits]\nmax_concurrent_inferences = 3\n",
2006 config_toml_without_limits()
2007 );
2008 std::fs::write(&path, body).unwrap();
2009 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
2010 assert_eq!(config.limits.max_concurrent_inferences, Some(3));
2011 assert_eq!(config.limits.default_max_iterations, Some(50));
2012 }
2013
2014 #[test]
2015 fn load_from_path_existing_provider_keys_skip_env_fallback() {
2016 let unset: Vec<(&str, Option<&str>)> = PROVIDER_KEY_ENV_VARS
2024 .iter()
2025 .chain(["OLLAMA_HOST"].iter())
2026 .map(|&key| (key, None))
2027 .collect();
2028 temp_env::with_vars(unset, || {
2029 let dir = tempfile::tempdir().unwrap();
2030 let path = dir.path().join("config.toml");
2031 std::fs::write(
2032 &path,
2033 r#"
2034default_provider = "anthropic"
2035openrouter_api_key = "sk-or-existing"
2036ollama_base_url = "http://existing-ollama:11434"
2037agent_paths = []
2038
2039[providers]
2040anthropic_api_key = "sk-ant-existing"
2041openai_api_key = "sk-openai-existing"
2042google_api_key = "AIza-existing"
2043"#,
2044 )
2045 .unwrap();
2046
2047 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
2048
2049 assert_eq!(
2050 config.providers.anthropic_api_key.as_deref(),
2051 Some("sk-ant-existing")
2052 );
2053 assert_eq!(
2054 config.providers.openai_api_key.as_deref(),
2055 Some("sk-openai-existing")
2056 );
2057 assert_eq!(
2058 config.providers.google_api_key.as_deref(),
2059 Some("AIza-existing")
2060 );
2061 assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-existing"));
2062 assert_eq!(
2063 config.ollama_base_url.as_deref(),
2064 Some("http://existing-ollama:11434")
2065 );
2066 });
2067 }
2068
2069 #[test]
2070 fn load_from_path_malformed_toml_returns_error() {
2071 let dir = tempfile::tempdir().unwrap();
2072 let path = dir.path().join("config.toml");
2073 std::fs::write(&path, "not valid toml [[[").unwrap();
2074 let result = Config::load_from_path(&path);
2075 assert!(result.is_err());
2076 assert!(result.unwrap_err().to_string().contains("Failed to parse"));
2077 }
2078
2079 #[test]
2080 fn load_from_path_unreadable_path_returns_error() {
2081 let dir = tempfile::tempdir().unwrap();
2083 let result = Config::load_from_path(dir.path());
2084 assert!(result.is_err());
2085 }
2086
2087 #[test]
2088 fn save_to_path_writes_valid_toml_that_round_trips() {
2089 let dir = tempfile::tempdir().unwrap();
2090 let path = dir.path().join("nested").join("config.toml");
2091 let config = Config {
2092 default_provider: "google".to_string(),
2093 ..Config::default()
2094 };
2095 with_tracing(|| config.save_to_path(&path)).unwrap();
2096
2097 let loaded = with_tracing(|| Config::load_from_path(&path)).unwrap();
2098 assert_eq!(loaded.default_provider, "google");
2099 }
2100
2101 #[test]
2102 fn save_to_path_creates_parent_directory() {
2103 let dir = tempfile::tempdir().unwrap();
2104 let path = dir.path().join("a").join("b").join("config.toml");
2105 let config = Config::default();
2106 with_tracing(|| config.save_to_path(&path)).unwrap();
2107 assert!(path.exists());
2108 }
2109
2110 #[test]
2111 fn save_to_path_with_no_parent_skips_create_config_dir() {
2112 let result = Config::default().save_to_path(&std::path::PathBuf::from(""));
2119 assert!(result.is_err());
2120 }
2121
2122 #[cfg(unix)]
2123 #[test]
2124 fn save_to_path_sets_restrictive_file_permissions() {
2125 use std::os::unix::fs::PermissionsExt;
2126 let dir = tempfile::tempdir().unwrap();
2127 let path = dir.path().join("config.toml");
2128 with_tracing(|| Config::default().save_to_path(&path)).unwrap();
2129 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2130 assert_eq!(mode & 0o777, 0o600);
2131 }
2132
2133 #[test]
2134 fn save_to_path_write_failure_returns_error() {
2135 let dir = tempfile::tempdir().unwrap();
2140 let path = dir.path().join("config.toml");
2141 std::fs::create_dir_all(&path).unwrap();
2142
2143 let result = Config::default().save_to_path(&path);
2144
2145 assert!(result.is_err());
2146 assert!(
2147 result
2148 .unwrap_err()
2149 .to_string()
2150 .contains("Failed to write config")
2151 );
2152 }
2153
2154 #[test]
2155 fn save_to_path_create_config_dir_failure_returns_error() {
2156 let dir = tempfile::tempdir().unwrap();
2157 let blocking_file = dir.path().join("not-a-dir");
2158 std::fs::write(&blocking_file, "").unwrap();
2159 let path = blocking_file.join("config.toml");
2160 let result = Config::default().save_to_path(&path);
2161 assert!(result.is_err());
2162 assert!(
2163 result
2164 .unwrap_err()
2165 .to_string()
2166 .contains("Failed to create config directory")
2167 );
2168 }
2169
2170 #[test]
2171 fn load_propagates_error_when_real_config_file_is_malformed() {
2172 with_isolated_config_path("load-malformed", |fake_dir| {
2177 std::fs::write(fake_dir.join("config.toml"), "not valid toml [[[").unwrap();
2178
2179 let result = Config::load();
2180
2181 assert!(result.is_err());
2182 });
2183 }
2184
2185 #[cfg(unix)]
2188 #[test]
2189 fn check_permissions_at_missing_file_is_noop() {
2190 let dir = tempfile::tempdir().unwrap();
2191 let path = dir.path().join("nonexistent.toml");
2192 check_permissions_at(&path); }
2194
2195 #[cfg(unix)]
2196 #[test]
2197 fn check_permissions_at_fixes_overly_permissive_file() {
2198 use std::os::unix::fs::PermissionsExt;
2199 let dir = tempfile::tempdir().unwrap();
2200 let path = dir.path().join("config.toml");
2201 std::fs::write(&path, "").unwrap();
2202 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2203
2204 with_tracing(|| check_permissions_at(&path));
2205
2206 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2207 assert_eq!(mode & 0o777, 0o600);
2208 }
2209
2210 #[cfg(unix)]
2211 #[test]
2212 fn check_permissions_at_leaves_already_restrictive_file_alone() {
2213 use std::os::unix::fs::PermissionsExt;
2214 let dir = tempfile::tempdir().unwrap();
2215 let path = dir.path().join("config.toml");
2216 std::fs::write(&path, "").unwrap();
2217 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2218
2219 check_permissions_at(&path);
2220
2221 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2222 assert_eq!(mode & 0o777, 0o600);
2223 }
2224
2225 #[test]
2233 fn check_permissions_at_with_logs_when_fix_fails() {
2234 fn ensure_fails(_: &std::path::Path) -> std::io::Result<Option<u32>> {
2235 Err(std::io::Error::other("simulated chmod failure"))
2236 }
2237 with_tracing(|| {
2239 check_permissions_at_with(std::path::Path::new("/does/not/matter"), ensure_fails)
2240 });
2241 }
2242
2243 #[test]
2244 fn check_permissions_at_with_logs_when_file_is_permissive() {
2245 fn ensure_permissive(_: &std::path::Path) -> std::io::Result<Option<u32>> {
2246 Ok(Some(0o100644))
2247 }
2248 with_tracing(|| {
2249 check_permissions_at_with(std::path::Path::new("/does/not/matter"), ensure_permissive)
2250 });
2251 }
2252
2253 fn always_failing_secure(_path: &std::path::Path) -> std::io::Result<()> {
2259 Err(std::io::Error::other(
2260 "simulated permission-hardening failure",
2261 ))
2262 }
2263
2264 #[test]
2265 fn set_dir_permissions_error_branch_logs_not_panics() {
2266 with_tracing(|| {
2267 set_dir_permissions_with(
2268 std::path::Path::new("/does/not/matter"),
2269 always_failing_secure,
2270 )
2271 }); }
2273
2274 #[test]
2279 fn create_config_dir_creates_nested_dirs() {
2280 let dir = tempfile::tempdir().unwrap();
2281 let target = dir.path().join("a").join("b").join("c");
2282 create_config_dir(&target).unwrap();
2283 assert!(target.is_dir());
2284 }
2285
2286 #[cfg(unix)]
2287 #[test]
2288 fn create_config_dir_sets_restrictive_permissions() {
2289 use std::os::unix::fs::PermissionsExt;
2290 let dir = tempfile::tempdir().unwrap();
2291 let target = dir.path().join("leviath");
2292 create_config_dir(&target).unwrap();
2293 let mode = std::fs::metadata(&target).unwrap().permissions().mode();
2294 assert_eq!(mode & 0o777, 0o700);
2295 }
2296
2297 #[cfg(unix)]
2301 #[test]
2309 fn config_path_honors_leviath_home() {
2310 temp_env::with_vars(
2311 [
2312 ("LEVIATH_CONFIG_PATH", None::<&str>),
2313 ("LEVIATH_HOME", Some("/tmp/lev-cfg-test")),
2314 ],
2315 || {
2316 assert_eq!(
2317 Config::config_path(),
2318 std::path::PathBuf::from("/tmp/lev-cfg-test/.leviath/config.toml")
2319 );
2320 },
2321 );
2322 }
2323
2324 #[test]
2326 fn config_path_prefers_the_explicit_override() {
2327 temp_env::with_vars(
2328 [
2329 ("LEVIATH_CONFIG_PATH", Some("/tmp/exact.toml")),
2330 ("LEVIATH_HOME", Some("/tmp/lev-cfg-test")),
2331 ],
2332 || {
2333 assert_eq!(
2334 Config::config_path(),
2335 std::path::PathBuf::from("/tmp/exact.toml")
2336 );
2337 },
2338 );
2339 }
2340
2341 #[test]
2345 fn permissions_for_agent_overlays_the_named_grant_on_the_global() {
2346 let mut config = Config::default();
2347 config
2348 .tool_permissions
2349 .insert("shell".to_string(), ToolPolicy::Ask);
2350 config
2351 .tool_permissions
2352 .insert("write_file".to_string(), ToolPolicy::Deny);
2353 config.agent_tool_permissions.insert(
2354 "coder".to_string(),
2355 HashMap::from([("shell".to_string(), ToolPolicy::Allow)]),
2356 );
2357
2358 let coder = config.permissions_for_agent("coder");
2359 assert_eq!(coder.get("shell"), Some(&ToolPolicy::Allow), "granted");
2360 assert_eq!(
2361 coder.get("write_file"),
2362 Some(&ToolPolicy::Deny),
2363 "the rest of the global ceiling still applies"
2364 );
2365
2366 let other = config.permissions_for_agent("researcher");
2368 assert_eq!(other.get("shell"), Some(&ToolPolicy::Ask));
2369 }
2370
2371 #[test]
2374 fn read_path_grants_merge_global_and_per_agent() {
2375 let mut config = Config::default();
2376 assert!(
2377 !config.security.allow_blueprint_read_paths,
2378 "blueprint read paths must be opt-in"
2379 );
2380 assert!(config.read_path_grants_for_agent("cto").is_empty());
2381
2382 config.security.read_paths = vec!["~/.leviath/runs".to_string()];
2383 config.agent_read_paths.insert(
2384 "cto".to_string(),
2385 ReadPathGrants {
2386 allow: vec!["glob:~/design-docs/**".to_string()],
2387 },
2388 );
2389
2390 assert_eq!(
2391 config.read_path_grants_for_agent("cto"),
2392 vec![
2393 "~/.leviath/runs".to_string(),
2394 "glob:~/design-docs/**".to_string(),
2395 ]
2396 );
2397 assert_eq!(
2399 config.read_path_grants_for_agent("researcher"),
2400 vec!["~/.leviath/runs".to_string()]
2401 );
2402 }
2403
2404 #[test]
2407 fn provider_config_debug_never_prints_the_keys() {
2408 let providers = ProviderConfig {
2409 anthropic_api_key: Some("sk-ant-SECRET-VALUE".to_string()),
2410 openai_api_key: Some("sk-openai-SECRET-VALUE".to_string()),
2411 google_api_key: Some("AIza-SECRET-VALUE".to_string()),
2412 claude_code_enabled: true,
2413 claude_code_binary: None,
2414 claude_code_effort: None,
2415 anthropic_cache_ttl: None,
2416 fallback_order: Vec::new(),
2417 };
2418 let rendered = format!("{providers:?}");
2419 assert!(!rendered.contains("SECRET-VALUE"), "key leaked: {rendered}");
2420 assert!(rendered.contains("<set>"), "{rendered}");
2422 assert!(rendered.contains("claude_code_enabled: true"), "{rendered}");
2423
2424 let empty = format!(
2425 "{:?}",
2426 ProviderConfig {
2427 anthropic_api_key: None,
2428 openai_api_key: None,
2429 google_api_key: None,
2430 claude_code_enabled: false,
2431 claude_code_binary: None,
2432 claude_code_effort: None,
2433 anthropic_cache_ttl: None,
2434 fallback_order: Vec::new(),
2435 }
2436 );
2437 assert!(empty.contains("<unset>"), "{empty}");
2438 }
2439
2440 #[cfg(unix)]
2444 #[test]
2445 fn saving_a_config_never_leaves_it_group_or_world_readable() {
2446 use std::os::unix::fs::PermissionsExt;
2447 let dir = tempfile::tempdir().unwrap();
2448 let path = dir.path().join("config.toml");
2449
2450 Config::default().save_to_path(&path).unwrap();
2451 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2452 assert_eq!(mode & 0o777, 0o600, "fresh config must be owner-only");
2453
2454 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2456 Config::default().save_to_path(&path).unwrap();
2457 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2458 assert_eq!(mode & 0o777, 0o600, "re-saving must re-tighten");
2459 }
2460
2461 #[cfg(unix)]
2462 #[test]
2463 fn set_dir_permissions_sets_0700() {
2464 use std::os::unix::fs::PermissionsExt;
2465 let dir = tempfile::tempdir().unwrap();
2466 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
2467 set_dir_permissions(dir.path());
2468 let mode = std::fs::metadata(dir.path()).unwrap().permissions().mode();
2469 assert_eq!(mode & 0o777, 0o700);
2470 }
2471
2472 #[test]
2473 fn test_validate_keys_good_anthropic() {
2474 let config = Config {
2475 providers: ProviderConfig {
2476 anthropic_api_key: Some("sk-ant-test123".to_string()),
2477 openai_api_key: None,
2478 google_api_key: None,
2479 claude_code_enabled: false,
2480 claude_code_binary: None,
2481 claude_code_effort: None,
2482 anthropic_cache_ttl: None,
2483 fallback_order: Vec::new(),
2484 },
2485 ..Config::default()
2486 };
2487 assert!(config.validate_keys().is_empty());
2488 }
2489
2490 #[test]
2491 fn test_validate_keys_bad_anthropic() {
2492 let config = Config {
2493 providers: ProviderConfig {
2494 anthropic_api_key: Some("bad-key".to_string()),
2495 openai_api_key: None,
2496 google_api_key: None,
2497 claude_code_enabled: false,
2498 claude_code_binary: None,
2499 claude_code_effort: None,
2500 anthropic_cache_ttl: None,
2501 fallback_order: Vec::new(),
2502 },
2503 ..Config::default()
2504 };
2505 let warnings = config.validate_keys();
2506 assert_eq!(warnings.len(), 1);
2507 assert!(warnings[0].contains("Anthropic"));
2508 }
2509
2510 #[test]
2511 fn test_validate_keys_good_openai() {
2512 let config = Config {
2513 providers: ProviderConfig {
2514 anthropic_api_key: None,
2515 openai_api_key: Some("sk-test123".to_string()),
2516 google_api_key: None,
2517 claude_code_enabled: false,
2518 claude_code_binary: None,
2519 claude_code_effort: None,
2520 anthropic_cache_ttl: None,
2521 fallback_order: Vec::new(),
2522 },
2523 ..Config::default()
2524 };
2525 assert!(config.validate_keys().is_empty());
2526 }
2527
2528 #[test]
2529 fn test_validate_keys_bad_openai() {
2530 let config = Config {
2531 providers: ProviderConfig {
2532 anthropic_api_key: None,
2533 openai_api_key: Some("bad-key".to_string()),
2534 google_api_key: None,
2535 claude_code_enabled: false,
2536 claude_code_binary: None,
2537 claude_code_effort: None,
2538 anthropic_cache_ttl: None,
2539 fallback_order: Vec::new(),
2540 },
2541 ..Config::default()
2542 };
2543 let warnings = config.validate_keys();
2544 assert_eq!(warnings.len(), 1);
2545 assert!(warnings[0].contains("OpenAI"));
2546 }
2547
2548 #[test]
2549 fn test_validate_keys_no_keys() {
2550 let config = Config::default();
2551 assert!(config.validate_keys().is_empty());
2552 }
2553
2554 #[test]
2557 fn config_default_values() {
2558 let config = Config::default();
2559 assert_eq!(config.default_provider, "anthropic");
2560 assert!(config.providers.anthropic_api_key.is_none());
2561 assert!(config.providers.openai_api_key.is_none());
2562 assert!(config.providers.google_api_key.is_none());
2563 assert!(config.openrouter_api_key.is_none());
2564 assert!(config.ollama_base_url.is_none());
2565 assert!(config.mcp_servers.is_empty());
2566 assert!(config.default_model.is_none());
2567 assert!(config.model_capabilities.is_empty());
2568 assert!(config.tool_permissions.is_empty());
2569 }
2570
2571 #[test]
2574 fn title_config_default() {
2575 let tc = TitleConfig::default();
2576 assert!(tc.enabled);
2577 assert!(tc.provider.is_none());
2578 assert!(tc.model.is_none());
2579 }
2580
2581 #[test]
2582 fn title_config_serde_roundtrip() {
2583 let tc = TitleConfig {
2584 enabled: false,
2585 provider: Some("openai".to_string()),
2586 model: Some("gpt-5.4-mini".to_string()),
2587 };
2588 let json = serde_json::to_string(&tc).unwrap();
2589 let back: TitleConfig = serde_json::from_str(&json).unwrap();
2590 assert!(!back.enabled);
2591 assert_eq!(back.provider.as_deref(), Some("openai"));
2592 assert_eq!(back.model.as_deref(), Some("gpt-5.4-mini"));
2593 }
2594
2595 #[test]
2598 fn tool_policy_default_is_ask() {
2599 let policy = ToolPolicy::default();
2600 assert_eq!(policy, ToolPolicy::Ask);
2601 }
2602
2603 #[test]
2604 fn tool_policy_serde_roundtrip() {
2605 for policy in [ToolPolicy::Allow, ToolPolicy::Ask, ToolPolicy::Deny] {
2606 let json = serde_json::to_string(&policy).unwrap();
2607 let back: ToolPolicy = serde_json::from_str(&json).unwrap();
2608 assert_eq!(policy, back);
2609 }
2610 }
2611
2612 #[test]
2613 fn tool_policy_snake_case_serialization() {
2614 assert_eq!(
2615 serde_json::to_string(&ToolPolicy::Allow).unwrap(),
2616 "\"allow\""
2617 );
2618 assert_eq!(serde_json::to_string(&ToolPolicy::Ask).unwrap(), "\"ask\"");
2619 assert_eq!(
2620 serde_json::to_string(&ToolPolicy::Deny).unwrap(),
2621 "\"deny\""
2622 );
2623 }
2624
2625 #[test]
2628 fn config_from_toml_with_all_fields() {
2629 let toml_content = r#"
2630default_provider = "openai"
2631openrouter_api_key = "sk-or-test"
2632ollama_base_url = "http://my-ollama:11434"
2633default_model = "gpt-5"
2634agent_paths = []
2635
2636[providers]
2637anthropic_api_key = "sk-ant-test"
2638openai_api_key = "sk-test"
2639google_api_key = "AIza-test"
2640
2641[tool_permissions]
2642bash = "deny"
2643read_file = "allow"
2644
2645[title]
2646enabled = false
2647provider = "anthropic"
2648model = "claude-haiku-4-5"
2649"#;
2650 let config: Config = toml::from_str(toml_content).unwrap();
2651 assert_eq!(config.default_provider, "openai");
2652 assert_eq!(
2653 config.providers.anthropic_api_key.as_deref(),
2654 Some("sk-ant-test")
2655 );
2656 assert_eq!(config.providers.openai_api_key.as_deref(), Some("sk-test"));
2657 assert_eq!(
2658 config.providers.google_api_key.as_deref(),
2659 Some("AIza-test")
2660 );
2661 assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-test"));
2662 assert_eq!(
2663 config.ollama_base_url.as_deref(),
2664 Some("http://my-ollama:11434")
2665 );
2666 assert_eq!(config.default_model.as_deref(), Some("gpt-5"));
2667 assert!(!config.title.enabled);
2668 assert_eq!(config.tool_permissions.get("bash"), Some(&ToolPolicy::Deny));
2669 assert_eq!(
2670 config.tool_permissions.get("read_file"),
2671 Some(&ToolPolicy::Allow)
2672 );
2673 }
2674
2675 #[test]
2676 fn config_from_minimal_toml() {
2677 let toml_content = r#"
2678default_provider = "anthropic"
2679agent_paths = []
2680
2681[providers]
2682"#;
2683 let config: Config = toml::from_str(toml_content).unwrap();
2684 assert_eq!(config.default_provider, "anthropic");
2685 assert!(config.providers.anthropic_api_key.is_none());
2686 }
2687
2688 #[test]
2689 fn the_three_lines_that_point_leviath_at_openrouter_are_enough() {
2690 let config: Config = toml::from_str(
2695 r#"
2696default_provider = "openrouter"
2697default_model = "openai/gpt-4o-mini"
2698openrouter_api_key = "sk-or-test"
2699"#,
2700 )
2701 .expect("a hand-written OpenRouter config parses");
2702 assert_eq!(config.default_provider, "openrouter");
2703 assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-test"));
2704 assert_eq!(config.default_model.as_deref(), Some("openai/gpt-4o-mini"));
2705 }
2706
2707 #[test]
2708 fn an_empty_config_file_parses_to_the_defaults() {
2709 let parsed: Config = toml::from_str("").expect("an empty config parses");
2713 let default = Config::default();
2714 assert_eq!(parsed.default_provider, default.default_provider);
2715 assert_eq!(parsed.agent_paths, default.agent_paths);
2716 assert_eq!(parsed.openrouter_api_key, default.openrouter_api_key);
2717 assert_eq!(parsed.ollama_base_url, default.ollama_base_url);
2718 assert_eq!(parsed.default_model, default.default_model);
2719 assert_eq!(parsed.request_timeout_secs, default.request_timeout_secs);
2720 assert_eq!(
2721 parsed.providers.anthropic_api_key,
2722 default.providers.anthropic_api_key
2723 );
2724 assert_eq!(
2725 parsed.providers.claude_code_enabled,
2726 default.providers.claude_code_enabled
2727 );
2728 }
2729
2730 #[test]
2731 fn config_from_toml_with_mcp_servers() {
2732 let toml_content = r#"
2733default_provider = "anthropic"
2734agent_paths = []
2735
2736[providers]
2737
2738[[mcp_servers]]
2739name = "test-server"
2740command = "echo"
2741args = ["hello"]
2742"#;
2743 let config: Config = toml::from_str(toml_content).unwrap();
2744 assert_eq!(config.mcp_servers.len(), 1);
2745 assert_eq!(config.mcp_servers[0].name, "test-server");
2746 }
2747
2748 #[test]
2749 fn load_rejects_a_malformed_mcp_server_entry() {
2750 let dir = tempfile::tempdir().unwrap();
2754 let path = dir.path().join("config.toml");
2755 std::fs::write(
2756 &path,
2757 r#"
2758default_provider = "anthropic"
2759agent_paths = []
2760
2761[providers]
2762
2763[[mcp_servers]]
2764name = "broken"
2765"#,
2766 )
2767 .unwrap();
2768
2769 let err = Config::load_from_path(&path).expect_err("malformed entry must fail load");
2770 let msg = err.to_string();
2771 assert!(msg.contains("broken"), "must name the server: {msg}");
2772 }
2773
2774 #[test]
2775 fn load_accepts_a_well_formed_http_mcp_server() {
2776 let dir = tempfile::tempdir().unwrap();
2777 let path = dir.path().join("config.toml");
2778 std::fs::write(
2779 &path,
2780 r#"
2781default_provider = "anthropic"
2782agent_paths = []
2783
2784[providers]
2785
2786[[mcp_servers]]
2787name = "remote"
2788url = "https://mcp.example.com/mcp"
2789"#,
2790 )
2791 .unwrap();
2792
2793 let config = Config::load_from_path(&path).expect("valid http entry should load");
2794 assert_eq!(
2795 config.mcp_servers[0].url.as_deref(),
2796 Some("https://mcp.example.com/mcp")
2797 );
2798 }
2799
2800 #[test]
2801 fn config_from_toml_with_model_capabilities() {
2802 let toml = r#"
2807[model_capabilities."my-custom-model"]
2808max_context_tokens = 1048576
2809"#;
2810 let config: Config = toml::from_str(toml).expect("a partial entry parses");
2811 let entry = config
2812 .model_capabilities
2813 .get("my-custom-model")
2814 .expect("the entry survives");
2815 assert_eq!(entry.max_context_tokens, Some(1_048_576));
2816 assert_eq!(
2817 entry.max_output_tokens, None,
2818 "an unmentioned field stays unset rather than defaulting"
2819 );
2820 assert_eq!(entry.supports_tools, None);
2821 }
2822
2823 #[test]
2826 fn config_model_capabilities_rejects_an_unknown_key() {
2827 let toml = r#"
2828[model_capabilities."my-custom-model"]
2829max_contxt_tokens = 1048576
2830"#;
2831 assert!(toml::from_str::<Config>(toml).is_err());
2832 }
2833
2834 #[test]
2835 fn validate_keys_is_quiet_about_blank_keys() {
2836 let mut config = Config::default();
2837 config.providers.anthropic_api_key = Some(String::new());
2838 config.providers.openai_api_key = Some(" ".to_string());
2839 assert!(config.validate_keys().is_empty());
2840 config.providers.anthropic_api_key = Some("nope".to_string());
2842 assert_eq!(config.validate_keys().len(), 1);
2843 }
2844
2845 #[test]
2846 fn validate_keys_both_bad() {
2847 let config = Config {
2848 providers: ProviderConfig {
2849 anthropic_api_key: Some("bad".to_string()),
2850 openai_api_key: Some("bad".to_string()),
2851 google_api_key: None,
2852 claude_code_enabled: false,
2853 claude_code_binary: None,
2854 claude_code_effort: None,
2855 anthropic_cache_ttl: None,
2856 fallback_order: Vec::new(),
2857 },
2858 ..Config::default()
2859 };
2860 let warnings = config.validate_keys();
2861 assert_eq!(warnings.len(), 2);
2862 }
2863
2864 #[test]
2867 fn config_path_contains_leviath() {
2868 temp_env::with_var_unset("LEVIATH_CONFIG_PATH", || {
2873 let path = Config::config_path();
2874 assert!(path.to_str().unwrap().contains(".leviath"));
2875 assert!(path.to_str().unwrap().ends_with("config.toml"));
2876 });
2877 }
2878
2879 #[test]
2882 fn config_toml_roundtrip() {
2883 let config = Config {
2884 default_provider: "openai".to_string(),
2885 providers: ProviderConfig {
2886 anthropic_api_key: Some("sk-ant-key".to_string()),
2887 openai_api_key: None,
2888 google_api_key: None,
2889 claude_code_enabled: false,
2890 claude_code_binary: None,
2891 claude_code_effort: None,
2892 anthropic_cache_ttl: None,
2893 fallback_order: Vec::new(),
2894 },
2895 tool_permissions: {
2896 let mut m = HashMap::new();
2897 m.insert("bash".to_string(), ToolPolicy::Deny);
2898 m
2899 },
2900 ..Config::default()
2901 };
2902
2903 let serialized = toml::to_string_pretty(&config).unwrap();
2904 let deserialized: Config = toml::from_str(&serialized).unwrap();
2905 assert_eq!(deserialized.default_provider, "openai");
2906 assert_eq!(
2907 deserialized.providers.anthropic_api_key.as_deref(),
2908 Some("sk-ant-key")
2909 );
2910 assert_eq!(
2911 deserialized.tool_permissions.get("bash"),
2912 Some(&ToolPolicy::Deny)
2913 );
2914 }
2915
2916 #[test]
2919 fn validate_keys_both_valid() {
2920 let config = Config {
2921 providers: ProviderConfig {
2922 anthropic_api_key: Some("sk-ant-good-key".to_string()),
2923 openai_api_key: Some("sk-good-key".to_string()),
2924 google_api_key: None,
2925 claude_code_enabled: false,
2926 claude_code_binary: None,
2927 claude_code_effort: None,
2928 anthropic_cache_ttl: None,
2929 fallback_order: Vec::new(),
2930 },
2931 ..Config::default()
2932 };
2933 assert!(config.validate_keys().is_empty());
2934 }
2935
2936 #[test]
2939 fn validate_keys_google_key_not_validated() {
2940 let config = Config {
2941 providers: ProviderConfig {
2942 anthropic_api_key: None,
2943 openai_api_key: None,
2944 google_api_key: Some("anything-goes".to_string()),
2945 claude_code_enabled: false,
2946 claude_code_binary: None,
2947 claude_code_effort: None,
2948 anthropic_cache_ttl: None,
2949 fallback_order: Vec::new(),
2950 },
2951 ..Config::default()
2952 };
2953 assert!(config.validate_keys().is_empty());
2955 }
2956
2957 #[test]
2960 fn config_from_toml_custom_registries() {
2961 let toml_content = r#"
2962default_provider = "anthropic"
2963agent_paths = ["/my/agents"]
2964
2965[providers]
2966"#;
2967 let config: Config = toml::from_str(toml_content).unwrap();
2968 assert_eq!(config.agent_paths.len(), 1);
2969 }
2970
2971 #[test]
2974 fn config_save_creates_file() {
2975 let dir = tempfile::tempdir().unwrap();
2976 let config_path = dir.path().join("subdir").join("config.toml");
2977 let config = Config::default();
2980 let content = toml::to_string_pretty(&config).unwrap();
2981 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
2982 std::fs::write(&config_path, &content).unwrap();
2983 assert!(config_path.exists());
2984 let loaded_content = std::fs::read_to_string(&config_path).unwrap();
2985 let loaded: Config = toml::from_str(&loaded_content).unwrap();
2986 assert_eq!(loaded.default_provider, "anthropic");
2987 }
2988
2989 #[test]
2992 fn title_config_from_toml_defaults() {
2993 let toml_content = r#"
2994default_provider = "anthropic"
2995agent_paths = []
2996
2997[providers]
2998"#;
2999 let config: Config = toml::from_str(toml_content).unwrap();
3000 assert!(config.title.enabled);
3001 assert!(config.title.provider.is_none());
3002 assert!(config.title.model.is_none());
3003 }
3004
3005 #[test]
3006 fn title_config_from_toml_disabled() {
3007 let toml_content = r#"
3008default_provider = "anthropic"
3009agent_paths = []
3010
3011[providers]
3012
3013[title]
3014enabled = false
3015"#;
3016 let config: Config = toml::from_str(toml_content).unwrap();
3017 assert!(!config.title.enabled);
3018 }
3019
3020 #[test]
3021 fn title_config_missing_enabled_key_uses_default_true() {
3022 let toml_content = r#"
3029default_provider = "anthropic"
3030agent_paths = []
3031
3032[providers]
3033
3034[title]
3035provider = "openai"
3036"#;
3037 let config: Config = toml::from_str(toml_content).unwrap();
3038 assert!(config.title.enabled);
3039 assert_eq!(config.title.provider.as_deref(), Some("openai"));
3040 }
3041
3042 #[test]
3045 fn config_tool_permissions_allow() {
3046 let toml_content = r#"
3047default_provider = "anthropic"
3048agent_paths = []
3049
3050[providers]
3051
3052[tool_permissions]
3053read_file = "allow"
3054write_file = "ask"
3055bash = "deny"
3056"#;
3057 let config: Config = toml::from_str(toml_content).unwrap();
3058 assert_eq!(
3059 config.tool_permissions.get("read_file"),
3060 Some(&ToolPolicy::Allow)
3061 );
3062 assert_eq!(
3063 config.tool_permissions.get("write_file"),
3064 Some(&ToolPolicy::Ask)
3065 );
3066 assert_eq!(config.tool_permissions.get("bash"), Some(&ToolPolicy::Deny));
3067 }
3068
3069 #[test]
3072 fn config_with_agent_paths() {
3073 let toml_content = r#"
3074default_provider = "anthropic"
3075agent_paths = ["/home/user/agents", "/opt/agents"]
3076
3077[providers]
3078"#;
3079 let config: Config = toml::from_str(toml_content).unwrap();
3080 assert_eq!(config.agent_paths.len(), 2);
3081 }
3082
3083 #[test]
3086 fn config_load_from_nonexistent_path_returns_default() {
3087 let config = Config::default();
3091 assert_eq!(config.default_provider, "anthropic");
3092 assert!(config.providers.anthropic_api_key.is_none());
3093 }
3094
3095 #[test]
3096 fn config_load_from_toml_string() {
3097 let toml_content = r#"
3099default_provider = "openai"
3100agent_paths = []
3101
3102[providers]
3103anthropic_api_key = "sk-ant-test-key"
3104"#;
3105 let config: Config = toml::from_str(toml_content).unwrap();
3106 assert_eq!(config.default_provider, "openai");
3107 assert_eq!(
3108 config.providers.anthropic_api_key.as_deref(),
3109 Some("sk-ant-test-key")
3110 );
3111 assert_eq!(config.nudge, leviath_core::NudgeConfig::default());
3113 }
3114
3115 #[test]
3116 fn config_parses_partial_nudge_section() {
3117 let config: Config = toml::from_str(
3119 r#"
3120default_provider = "openai"
3121agent_paths = []
3122
3123[providers]
3124
3125[nudge]
3126enabled = false
3127"#,
3128 )
3129 .unwrap();
3130 assert_eq!(config.nudge.enabled, Some(false));
3131 assert_eq!(config.nudge.max, None);
3132 assert_eq!(config.nudge.text, None);
3133 }
3134
3135 #[test]
3136 fn config_save_and_load_with_file() {
3137 let dir = tempfile::tempdir().unwrap();
3139 let config_path = dir.path().join("config.toml");
3140
3141 let config = Config {
3142 default_provider: "openai".to_string(),
3143 providers: ProviderConfig {
3144 anthropic_api_key: Some("sk-ant-test".to_string()),
3145 openai_api_key: Some("sk-test".to_string()),
3146 google_api_key: None,
3147 claude_code_enabled: false,
3148 claude_code_binary: None,
3149 claude_code_effort: None,
3150 anthropic_cache_ttl: None,
3151 fallback_order: Vec::new(),
3152 },
3153 openrouter_api_key: Some("sk-or-test".to_string()),
3154 default_model: Some("gpt-5".to_string()),
3155 ..Config::default()
3156 };
3157
3158 let content = toml::to_string_pretty(&config).unwrap();
3159 std::fs::write(&config_path, &content).unwrap();
3160
3161 let loaded_content = std::fs::read_to_string(&config_path).unwrap();
3162 let loaded: Config = toml::from_str(&loaded_content).unwrap();
3163
3164 assert_eq!(loaded.default_provider, "openai");
3165 assert_eq!(
3166 loaded.providers.anthropic_api_key.as_deref(),
3167 Some("sk-ant-test")
3168 );
3169 assert_eq!(loaded.default_model.as_deref(), Some("gpt-5"));
3170 }
3171
3172 #[test]
3173 fn config_create_config_dir_creates_parent() {
3174 let dir = tempfile::tempdir().unwrap();
3175 let new_dir = dir.path().join("nested").join("config");
3176 std::fs::create_dir_all(&new_dir).unwrap();
3178 assert!(new_dir.exists());
3179 }
3180
3181 #[test]
3182 fn config_default_title_enabled() {
3183 let config = Config::default();
3184 assert!(config.title.enabled);
3185 }
3186
3187 #[test]
3188 fn config_serialize_with_all_options() {
3189 let mut model_caps = HashMap::new();
3190 model_caps.insert(
3191 "my-model".to_string(),
3192 ModelCapabilityOverride {
3193 supports_temperature: Some(true),
3194 supports_streaming: Some(true),
3195 supports_tools: Some(true),
3196 supports_system_prompt: Some(true),
3197 max_context_tokens: Some(8192),
3198 max_output_tokens: Some(4096),
3199 },
3200 );
3201 let mut tool_perms = HashMap::new();
3202 tool_perms.insert("bash".to_string(), ToolPolicy::Allow);
3203
3204 let config = Config {
3205 default_provider: "anthropic".to_string(),
3206 providers: ProviderConfig {
3207 anthropic_api_key: Some("sk-ant-key".to_string()),
3208 openai_api_key: None,
3209 google_api_key: None,
3210 claude_code_enabled: false,
3211 claude_code_binary: None,
3212 claude_code_effort: None,
3213 anthropic_cache_ttl: None,
3214 fallback_order: Vec::new(),
3215 },
3216 agent_paths: vec![std::path::PathBuf::from("/my/agents")],
3217 openrouter_api_key: None,
3218 ollama_base_url: Some("http://custom:11434".to_string()),
3219 mcp_servers: vec![],
3220 default_model: None,
3221 model_capabilities: model_caps,
3222 model_providers: HashMap::new(),
3223 tool_permissions: tool_perms,
3224 agent_tool_permissions: HashMap::new(),
3225 safe_commands: crate::approvals::SafeCommands::default(),
3226 agent_safe_commands: HashMap::new(),
3227 title: TitleConfig {
3228 enabled: false,
3229 provider: Some("openai".to_string()),
3230 model: Some("gpt-5-mini".to_string()),
3231 },
3232 request_timeout_secs: None,
3233 rate_limits: HashMap::new(),
3234 taint_tracking: false,
3235 limits: LimitsConfig {
3236 mcp_idle_disconnect_secs: default_mcp_idle_disconnect_secs(),
3237 max_tool_call_write_bytes: None,
3238 max_run_write_bytes: None,
3239 max_concurrent_inferences: Some(4),
3240 max_concurrent_tools: 3,
3241 default_max_iterations: Some(99),
3242 exact_token_counting: false,
3243 script_shell_timeout_secs: 45,
3244 stall_timeout_secs: 90,
3245 dead_cycles_before_relief: 6,
3246 finished_retention_secs: 120,
3247 wedge_timeout_secs: 420,
3248 provider_failures_before_open: 5,
3249 provider_circuit_cooldown_secs: 120,
3250 interaction_timeout_secs: 120,
3251 inference_retry_attempts: 6,
3252 inference_retry_base_ms: 250,
3253 },
3254 batch_tool_hint: true,
3255 shell_hint: false,
3256 nudge: leviath_core::NudgeConfig {
3257 enabled: Some(true),
3258 max: Some(2),
3259 text: Some("Use your tools.".to_string()),
3260 },
3261 webhook: WebhookConfig {
3262 max_retries: 5,
3263 base_delay_ms: 250,
3264 max_delay_ms: 10_000,
3265 timeout_secs: 7,
3266 },
3267 observability: ObservabilityConfig {
3268 enabled: true,
3269 exporter: TelemetryExporterKind::Stdout,
3270 endpoint: Some("http://collector:4318".to_string()),
3271 service_name: Some("leviath-prod".to_string()),
3272 },
3273 sandbox: Some(leviath_core::ToolSandboxConfig {
3274 kind: leviath_core::SandboxKind::Container,
3275 image: Some("ubuntu:24.04".to_string()),
3276 network: false,
3277 ..Default::default()
3278 }),
3279 tool_script_permissions: ScriptToolPermissions {
3280 http_get: ScriptPermission::Allow,
3281 http_post: ScriptPermission::Deny,
3282 shell: ScriptPermission::Deny,
3283 read_file: ScriptPermission::Inherit,
3284 write_file: ScriptPermission::Deny,
3285 env_var: ScriptPermission::Allow,
3286 },
3287 security: SecurityConfig {
3288 allowed_workdirs: Vec::new(),
3289 allow_seed_commands: false,
3290 allow_local_network: true,
3291 allow_env_vars: vec!["MY_PROVIDER_KEY".to_string()],
3292 allow_blueprint_read_paths: true,
3293 allow_blueprint_safe_commands: true,
3294 read_paths: vec!["~/.leviath/runs".to_string()],
3295 credential_store: leviath_core::CredentialStoreKind::Keychain,
3296 allow_blueprint_permissions: false,
3297 shell_env: leviath_core::ShellEnvMode::default(),
3298 shell_env_withhold: Vec::new(),
3299 },
3300 agent_read_paths: HashMap::from([(
3301 "cto".to_string(),
3302 ReadPathGrants {
3303 allow: vec!["glob:~/design-docs/**".to_string()],
3304 },
3305 )]),
3306 };
3307
3308 let serialized = toml::to_string_pretty(&config).unwrap();
3309 let deserialized: Config = toml::from_str(&serialized).unwrap();
3310
3311 assert_eq!(deserialized.default_provider, "anthropic");
3312 assert_eq!(deserialized.limits.max_concurrent_inferences, Some(4));
3313 assert_eq!(deserialized.limits.max_concurrent_tools, 3);
3314 assert_eq!(deserialized.limits.script_shell_timeout_secs, 45);
3315 assert_eq!(deserialized.limits.dead_cycles_before_relief, 6);
3316 assert_eq!(deserialized.limits.finished_retention_secs, 120);
3317 assert_eq!(
3318 deserialized.tool_script_permissions.http_get,
3319 ScriptPermission::Allow
3320 );
3321 assert_eq!(
3322 deserialized.tool_script_permissions.shell,
3323 ScriptPermission::Deny
3324 );
3325 assert_eq!(
3326 deserialized.tool_script_permissions.write_file,
3327 ScriptPermission::Deny
3328 );
3329 assert!(deserialized.batch_tool_hint);
3332 assert!(!deserialized.shell_hint);
3333 assert!(!deserialized.security.allow_seed_commands);
3334 assert!(deserialized.security.allow_blueprint_read_paths);
3335 assert_eq!(deserialized.security.read_paths, vec!["~/.leviath/runs"]);
3336 assert_eq!(
3337 deserialized.agent_read_paths.get("cto"),
3338 Some(&ReadPathGrants {
3339 allow: vec!["glob:~/design-docs/**".to_string()],
3340 })
3341 );
3342 assert_eq!(
3343 deserialized.nudge,
3344 leviath_core::NudgeConfig {
3345 enabled: Some(true),
3346 max: Some(2),
3347 text: Some("Use your tools.".to_string()),
3348 }
3349 );
3350 assert_eq!(deserialized.webhook.max_retries, 5);
3351 assert_eq!(deserialized.webhook.base_delay_ms, 250);
3352 assert_eq!(deserialized.webhook.max_delay_ms, 10_000);
3353 assert_eq!(deserialized.webhook.timeout_secs, 7);
3354 assert!(deserialized.observability.enabled);
3355 assert_eq!(
3356 deserialized.observability.exporter,
3357 TelemetryExporterKind::Stdout
3358 );
3359 assert_eq!(
3360 deserialized.observability.endpoint.as_deref(),
3361 Some("http://collector:4318")
3362 );
3363 assert_eq!(
3364 deserialized.observability.service_name.as_deref(),
3365 Some("leviath-prod")
3366 );
3367 assert_eq!(deserialized.limits.default_max_iterations, Some(99));
3368 assert_eq!(deserialized.limits.inference_retry_attempts, 6);
3369 assert_eq!(deserialized.limits.inference_retry_base_ms, 250);
3370 assert_eq!(
3371 deserialized.providers.anthropic_api_key.as_deref(),
3372 Some("sk-ant-key")
3373 );
3374 assert_eq!(deserialized.agent_paths.len(), 1);
3375 assert!(deserialized.model_capabilities.contains_key("my-model"));
3376 assert_eq!(
3377 deserialized.tool_permissions.get("bash"),
3378 Some(&ToolPolicy::Allow)
3379 );
3380 assert!(!deserialized.title.enabled);
3381 assert_eq!(deserialized.title.provider.as_deref(), Some("openai"));
3382 let sandbox = deserialized.sandbox.expect("sandbox round-trips");
3383 assert_eq!(sandbox.kind, leviath_core::SandboxKind::Container);
3384 assert_eq!(sandbox.image.as_deref(), Some("ubuntu:24.04"));
3385 assert!(!sandbox.network);
3386 }
3387
3388 #[test]
3391 fn config_multiple_model_capabilities() {
3392 let toml_content = r#"
3393default_provider = "anthropic"
3394agent_paths = []
3395
3396[providers]
3397
3398[model_capabilities."model-a"]
3399supports_temperature = true
3400supports_streaming = true
3401supports_tools = true
3402supports_system_prompt = true
3403max_context_tokens = 8192
3404max_output_tokens = 4096
3405
3406[model_capabilities."model-b"]
3407supports_temperature = false
3408supports_streaming = false
3409supports_tools = false
3410supports_system_prompt = false
3411max_context_tokens = 2048
3412max_output_tokens = 1024
3413"#;
3414 let config: Config = toml::from_str(toml_content).unwrap();
3415 assert_eq!(config.model_capabilities.len(), 2);
3416 let caps_a = config.model_capabilities.get("model-a").unwrap();
3417 assert_eq!(caps_a.supports_temperature, Some(true));
3418 assert_eq!(caps_a.max_context_tokens, Some(8192));
3419 let caps_b = config.model_capabilities.get("model-b").unwrap();
3420 assert_eq!(caps_b.supports_temperature, Some(false));
3421 assert_eq!(caps_b.max_context_tokens, Some(2048));
3422 }
3423}