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 let names = skipped
880 .iter()
881 .map(|(key, _)| key.as_str())
882 .collect::<Vec<_>>()
883 .join(", ");
884 tracing::warn!(
885 "Ignoring {names} from {path}: these decide where configuration is read from or what \
886 gets executed, so a repository may not set them. Export them yourself if you meant to."
887 );
888}
889
890fn check_permissions() {
894 check_permissions_at(&Config::config_path());
895}
896
897fn check_permissions_at(path: &std::path::Path) {
903 check_permissions_at_with(path, leviath_sys::ensure_file_private);
904}
905
906fn check_permissions_at_with(
914 path: &std::path::Path,
915 ensure: fn(&std::path::Path) -> std::io::Result<Option<u32>>,
916) {
917 match ensure(path) {
918 Ok(Some(old_mode)) => {
919 let masked_mode = old_mode & 0o777;
920 tracing::warn!(
921 "Config file has overly permissive permissions ({:o}), fixing to 600",
922 masked_mode
923 );
924 }
925 Ok(None) => {}
926 Err(e) => tracing::warn!("Failed to fix config file permissions: {}", e),
927 }
928}
929
930fn set_dir_permissions(path: &std::path::Path) {
932 set_dir_permissions_with(path, leviath_sys::secure_dir_perms);
933}
934
935fn set_dir_permissions_with(
938 path: &std::path::Path,
939 secure: fn(&std::path::Path) -> std::io::Result<()>,
940) {
941 if let Err(e) = secure(path) {
942 tracing::warn!("Failed to set config directory permissions: {}", e);
943 }
944}
945
946pub(crate) fn default_true() -> bool {
953 true
954}
955
956pub(crate) fn default_provider_name() -> String {
966 "anthropic".to_string()
967}
968
969#[cfg(test)]
978pub(crate) static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
979
980#[cfg(test)]
1000pub(crate) struct CwdTestGuard {
1001 original_cwd: std::path::PathBuf,
1002 _lock: std::sync::MutexGuard<'static, ()>,
1003}
1004
1005#[cfg(test)]
1006impl Drop for CwdTestGuard {
1007 fn drop(&mut self) {
1008 let _ = std::env::set_current_dir(&self.original_cwd);
1009 }
1010}
1011
1012#[cfg(test)]
1015pub(crate) fn isolate_cwd_for_test() -> CwdTestGuard {
1016 let lock = CWD_LOCK
1017 .lock()
1018 .unwrap_or_else(std::sync::PoisonError::into_inner);
1019 let original_cwd = std::env::current_dir().expect("current dir must be readable at test start");
1020 CwdTestGuard {
1021 original_cwd,
1022 _lock: lock,
1023 }
1024}
1025
1026#[cfg(test)]
1031const PROVIDER_KEY_ENV_VARS: &[&str] = &[
1032 "ANTHROPIC_API_KEY",
1033 "OPENAI_API_KEY",
1034 "GOOGLE_API_KEY",
1035 "OPENROUTER_API_KEY",
1036];
1037
1038#[cfg(test)]
1040fn make_fake_config_dir(unique: &str) -> std::path::PathBuf {
1041 let fake_dir = std::env::temp_dir().join(format!("lev-fake-config-{unique}"));
1042 let _ = std::fs::create_dir_all(&fake_dir);
1043 fake_dir
1044}
1045
1046#[cfg(test)]
1058pub(crate) fn config_isolation_vars(
1059 fake_dir: &std::path::Path,
1060) -> Vec<(&'static str, Option<std::ffi::OsString>)> {
1061 let mut vars: Vec<(&'static str, Option<std::ffi::OsString>)> = vec![
1062 (
1063 "LEVIATH_CONFIG_PATH",
1064 Some(fake_dir.join("config.toml").into_os_string()),
1065 ),
1066 ("LEVIATH_SKIP_DOTENV", Some(std::ffi::OsString::from("1"))),
1067 ];
1068 for &key in PROVIDER_KEY_ENV_VARS {
1069 vars.push((key, None));
1070 }
1071 vars
1072}
1073
1074#[cfg(test)]
1082pub(crate) fn with_isolated_config_path<R>(
1083 unique: &str,
1084 f: impl FnOnce(&std::path::Path) -> R,
1085) -> R {
1086 let fake_dir = make_fake_config_dir(unique);
1087 let result = temp_env::with_vars(config_isolation_vars(&fake_dir), || f(&fake_dir));
1088 let _ = std::fs::remove_dir_all(&fake_dir);
1089 result
1090}
1091
1092#[cfg(test)]
1095pub(crate) async fn with_isolated_config_path_async<R, Fut>(
1096 unique: &str,
1097 f: impl FnOnce(std::path::PathBuf) -> Fut,
1098) -> R
1099where
1100 Fut: std::future::Future<Output = R>,
1101{
1102 let fake_dir = make_fake_config_dir(unique);
1103 let result =
1104 temp_env::async_with_vars(config_isolation_vars(&fake_dir), f(fake_dir.clone())).await;
1105 let _ = std::fs::remove_dir_all(&fake_dir);
1106 result
1107}
1108
1109#[cfg(test)]
1110mod dotenv_tests {
1111 use super::*;
1112
1113 #[test]
1124 fn a_dot_env_in_the_working_directory_is_read() {
1125 let dir = make_fake_config_dir("dotenv-read");
1126 std::fs::write(dir.join(".env"), "LEV_DOTENV_PROBE=seen\n").unwrap();
1127
1128 {
1132 let _cwd = isolate_cwd_for_test();
1133 std::env::set_current_dir(&dir).unwrap();
1134
1135 temp_env::with_vars(
1136 [
1137 (
1138 "LEVIATH_CONFIG_PATH",
1139 Some(dir.join("config.toml").into_os_string()),
1140 ),
1141 ("LEVIATH_SKIP_DOTENV", None),
1142 ("LEV_DOTENV_PROBE", None),
1143 ],
1144 || {
1145 let loaded = Config::load();
1146 assert!(loaded.is_ok(), "a missing config file is not an error");
1147 assert_eq!(
1148 std::env::var("LEV_DOTENV_PROBE").ok().as_deref(),
1149 Some("seen"),
1150 "the .env beside the working directory was read"
1151 );
1152 },
1153 );
1154 }
1155 let _ = std::fs::remove_dir_all(&dir);
1156 }
1157
1158 #[test]
1164 fn a_dot_env_cannot_steer_where_config_comes_from() {
1165 let dir = make_fake_config_dir("dotenv-steer");
1166 std::fs::write(
1167 dir.join(".env"),
1168 "LEVIATH_CONFIG_PATH=/tmp/evil.toml\n\
1169 LEVIATH_API_TOKEN=known\n\
1170 EDITOR=/tmp/evil\n\
1171 PATH=/tmp/evil\n\
1172 LD_PRELOAD=/tmp/evil.so\n\
1173 LEV_DOTENV_KEEPS=kept\n",
1174 )
1175 .unwrap();
1176
1177 {
1178 let _cwd = isolate_cwd_for_test();
1179 std::env::set_current_dir(&dir).unwrap();
1180
1181 temp_env::with_vars(
1182 [
1183 (
1184 "LEVIATH_CONFIG_PATH",
1185 Some(dir.join("config.toml").into_os_string()),
1186 ),
1187 ("LEVIATH_SKIP_DOTENV", None),
1188 ("LEVIATH_API_TOKEN", None),
1189 ("EDITOR", None),
1190 ("LD_PRELOAD", None),
1191 ("LEV_DOTENV_KEEPS", None),
1192 ],
1193 || {
1194 Config::load().expect("a missing config file is not an error");
1195 for steering in ["LEVIATH_API_TOKEN", "EDITOR", "LD_PRELOAD"] {
1196 assert!(
1197 std::env::var(steering).is_err(),
1198 "{steering} must not be settable from a repository's .env"
1199 );
1200 }
1201 assert_ne!(
1205 std::env::var("LEVIATH_CONFIG_PATH").ok(),
1206 Some("/tmp/evil.toml".to_string())
1207 );
1208 assert_eq!(
1212 std::env::var("LEV_DOTENV_KEEPS").ok().as_deref(),
1213 Some("kept")
1214 );
1215 },
1216 );
1217 }
1218 let _ = std::fs::remove_dir_all(&dir);
1219 }
1220
1221 #[test]
1225 fn a_missing_dot_env_is_not_an_error() {
1226 let dir = make_fake_config_dir("dotenv-missing");
1227 load_dotenv_filtered(&dir.join("absent.env").to_string_lossy());
1228 let _ = std::fs::remove_dir_all(&dir);
1229 }
1230
1231 #[test]
1234 fn requote_escapes_what_both_dotenvy_layers_read() {
1235 assert_eq!(requote("plain"), r#""plain""#);
1236 assert_eq!(requote(r"C:\tools\"), r#""C:\\tools\\""#);
1237 assert_eq!(requote(r#"say "hi""#), r#""say \"hi\"""#);
1238 assert_eq!(requote("cost $5 $HOME"), r#""cost \$5 \$HOME""#);
1241 assert_eq!(requote("one\ntwo"), r#""one\ntwo""#);
1242 }
1243
1244 #[test]
1251 fn filtering_survives_a_value_ending_in_a_backslash() {
1252 let dir = make_fake_config_dir("dotenv-backslash");
1253 std::fs::write(
1257 dir.join(".env"),
1258 "PATH=/tmp/anything\n\
1259 LEV_DOTENV_BACKSLASH=\"C:\\\\tools\\\\\"\n\
1260 LEV_DOTENV_AFTER=survived\n",
1261 )
1262 .unwrap();
1263
1264 {
1265 let _cwd = isolate_cwd_for_test();
1266 std::env::set_current_dir(&dir).unwrap();
1267
1268 temp_env::with_vars(
1269 [
1270 (
1271 "LEVIATH_CONFIG_PATH",
1272 Some(dir.join("config.toml").into_os_string()),
1273 ),
1274 ("LEVIATH_SKIP_DOTENV", None),
1275 ("LEV_DOTENV_BACKSLASH", None),
1276 ("LEV_DOTENV_AFTER", None),
1277 ],
1278 || {
1279 Config::load().expect("a missing config file is not an error");
1280 assert_eq!(
1281 std::env::var("LEV_DOTENV_BACKSLASH").ok().as_deref(),
1282 Some("C:\\tools\\")
1283 );
1284 assert_eq!(
1285 std::env::var("LEV_DOTENV_AFTER").ok().as_deref(),
1286 Some("survived"),
1287 "a later variable must not be swallowed by an unbalanced quote"
1288 );
1289 },
1290 );
1291 }
1292 let _ = std::fs::remove_dir_all(&dir);
1293 }
1294
1295 #[test]
1298 fn filtering_preserves_an_awkward_value_verbatim() {
1299 let dir = make_fake_config_dir("dotenv-quoting");
1300 std::fs::write(
1301 dir.join(".env"),
1302 "PATH=/tmp/evil\n\
1303 LEV_DOTENV_AWKWARD=\"it's a #value with 'quotes' and spaces\"\n",
1304 )
1305 .unwrap();
1306
1307 {
1308 let _cwd = isolate_cwd_for_test();
1309 std::env::set_current_dir(&dir).unwrap();
1310
1311 temp_env::with_vars(
1312 [
1313 (
1314 "LEVIATH_CONFIG_PATH",
1315 Some(dir.join("config.toml").into_os_string()),
1316 ),
1317 ("LEVIATH_SKIP_DOTENV", None),
1318 ("LEV_DOTENV_AWKWARD", None),
1319 ],
1320 || {
1321 Config::load().expect("a missing config file is not an error");
1322 assert_eq!(
1323 std::env::var("LEV_DOTENV_AWKWARD").ok().as_deref(),
1324 Some("it's a #value with 'quotes' and spaces")
1325 );
1326 },
1327 );
1328 }
1329 let _ = std::fs::remove_dir_all(&dir);
1330 }
1331}
1332
1333#[cfg(test)]
1334mod tests {
1335 const CONFIG_SCHEMA: &str = include_str!("../../../../docs/schema/config.schema.json");
1338 const CONFIG_EXAMPLE: &str = include_str!("../../../../docs/schema/config.example.toml");
1339
1340 fn schema_problems(
1342 validator: &jsonschema::Validator,
1343 value: &serde_json::Value,
1344 ) -> Vec<String> {
1345 validator
1346 .iter_errors(value)
1347 .map(|e| format!("{}: {e}", e.instance_path()))
1348 .collect()
1349 }
1350
1351 #[test]
1358 fn an_unknown_key_is_reported_at_any_depth() {
1359 let content = "\
1360default_provider = \"anthropic\"
1361
1362[cache]
1363ttl = \"banana\"
1364
1365[limits]
1366max_concurrent_tool = 3
1367
1368[providers]
1369anthropic_api_key = \"x\"
1370anthropic_cach_ttl = \"1h\"
1371";
1372 let unknown = Config::unknown_config_keys(content);
1373 assert!(unknown.contains(&"cache".to_string()), "{unknown:?}");
1374 assert!(
1375 unknown.contains(&"limits.max_concurrent_tool".to_string()),
1376 "a key one level down is named by its path: {unknown:?}"
1377 );
1378 assert!(
1379 unknown.contains(&"providers.anthropic_cach_ttl".to_string()),
1380 "{unknown:?}"
1381 );
1382 assert!(
1384 !unknown.iter().any(|k| k == "default_provider"),
1385 "{unknown:?}"
1386 );
1387 assert!(
1388 !unknown.iter().any(|k| k == "providers.anthropic_api_key"),
1389 "{unknown:?}"
1390 );
1391 }
1392
1393 #[test]
1397 fn a_file_that_is_not_a_config_reports_no_unknown_keys() {
1398 assert!(Config::unknown_config_keys("default_provider = 42").is_empty());
1400 }
1401
1402 #[test]
1405 fn unread_keys_of_a_missing_file_is_empty() {
1406 let dir = tempfile::tempdir().unwrap();
1407 assert!(Config::unread_keys_at(&dir.path().join("nope.toml")).is_empty());
1408 }
1409
1410 #[test]
1411 fn unread_keys_at_reads_the_file_it_is_given() {
1412 let dir = tempfile::tempdir().unwrap();
1413 let path = dir.path().join("config.toml");
1414 std::fs::write(&path, "[cache]\nttl = \"banana\"\n").unwrap();
1415 assert_eq!(Config::unread_keys_at(&path), vec!["cache".to_string()]);
1416 }
1417
1418 #[test]
1422 fn keys_a_flatten_field_absorbs_are_not_reported() {
1423 let content = "\
1424[model_providers.groq]
1425script = \"groq.rhai\"
1426some_custom_thing = \"forwarded to the script\"
1427";
1428 assert!(
1429 Config::unknown_config_keys(content).is_empty(),
1430 "a key serde keeps is a key nothing should complain about"
1431 );
1432 }
1433
1434 #[test]
1438 fn an_unknown_config_key_is_reported_and_the_config_still_loads() {
1439 const CONTENT: &str = "default_provider = \"anthropic\"\n\n[cache]\nttl = \"banana\"\n";
1440 assert_eq!(
1441 Config::unknown_config_keys(CONTENT),
1442 vec!["cache".to_string()],
1443 "the unknown table is named and the real key is not"
1444 );
1445
1446 let dir = tempfile::tempdir().unwrap();
1447 let path = dir.path().join("config.toml");
1448 std::fs::write(&path, CONTENT).unwrap();
1449 let _guard = leviath_testkit::tracing_guard();
1453 let config = Config::load_from_path(&path).expect("an unknown key does not stop the load");
1454 assert_eq!(config.default_provider, "anthropic");
1455 }
1456
1457 #[test]
1460 fn a_config_of_known_keys_reports_nothing() {
1461 assert!(
1462 Config::unknown_config_keys(CONFIG_EXAMPLE).is_empty(),
1463 "the shipped example must be clean"
1464 );
1465 }
1466
1467 #[test]
1471 fn unparseable_content_reports_no_unknown_keys() {
1472 assert!(Config::unknown_config_keys("this is not [[[ toml").is_empty());
1473 }
1474
1475 #[test]
1476 fn the_example_config_satisfies_the_published_schema_and_deserializes() {
1477 let example: toml::Value = toml::from_str(CONFIG_EXAMPLE).expect("the example is TOML");
1482 let schema: serde_json::Value =
1483 serde_json::from_str(CONFIG_SCHEMA).expect("the schema is JSON");
1484 let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
1485
1486 let json = serde_json::to_value(&example).expect("TOML converts to JSON");
1487 assert_eq!(
1488 schema_problems(&validator, &json),
1489 Vec::<String>::new(),
1490 "config.example.toml does not match config.schema.json"
1491 );
1492
1493 let parsed: Config = toml::from_str(CONFIG_EXAMPLE).expect("the example deserializes");
1494 assert_eq!(parsed.default_provider, "anthropic");
1497 assert_eq!(parsed.limits.interaction_timeout_secs, 3600);
1498 assert_eq!(parsed.mcp_servers.len(), 2);
1499 }
1500
1501 #[test]
1502 fn the_config_schema_rejects_a_key_that_is_not_a_setting() {
1503 let schema: serde_json::Value =
1506 serde_json::from_str(CONFIG_SCHEMA).expect("the schema is JSON");
1507 let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
1508 let rejects = |toml_text: &str| {
1511 let value: toml::Value = toml::from_str(toml_text).expect("valid TOML");
1512 let json = serde_json::to_value(&value).expect("converts");
1513 !schema_problems(&validator, &json).is_empty()
1514 };
1515
1516 assert!(
1517 rejects("default_provdier = \"anthropic\"\n"),
1518 "a typo'd key"
1519 );
1520 assert!(
1521 rejects("[limits]\ninteraction_timeout_secs = \"an hour\"\n"),
1522 "a string where a number belongs"
1523 );
1524 assert!(
1525 rejects("[security]\ncredential_store = \"vault\"\n"),
1526 "an unsupported credential store"
1527 );
1528 assert!(
1529 !rejects("default_provider = \"openrouter\"\n"),
1530 "a real key"
1531 );
1532 }
1533
1534 #[test]
1538 fn saving_with_an_unreachable_keychain_writes_nothing() {
1539 let dir = tempfile::tempdir().unwrap();
1540 let path = dir.path().join("config.toml");
1541 let mut config = Config::default();
1542 config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1543 config.providers.anthropic_api_key = Some("sk-ant".to_string());
1544
1545 assert!(
1546 config
1547 .write_to(&path, Err("no keychain".to_string()))
1548 .is_err()
1549 );
1550 assert!(!path.exists(), "no file may be written at all");
1551 }
1552
1553 #[test]
1555 fn saving_to_a_store_that_refuses_the_write_writes_nothing() {
1556 use leviath_core::CredentialStore as _;
1557
1558 struct Refuses;
1559 impl leviath_core::CredentialStore for Refuses {
1560 fn get(&self, _: &str) -> Result<Option<String>, String> {
1561 Ok(None)
1562 }
1563 fn set(&self, _: &str, _: &str) -> Result<(), String> {
1564 Err("read-only keychain".to_string())
1565 }
1566 fn delete(&self, _: &str) -> Result<bool, String> {
1567 Err("read-only keychain".to_string())
1568 }
1569 }
1570
1571 let dir = tempfile::tempdir().unwrap();
1572 let path = dir.path().join("config.toml");
1573 let mut config = Config::default();
1574 config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1575 config.providers.anthropic_api_key = Some("sk-ant".to_string());
1576
1577 assert_eq!(Refuses.get("provider/anthropic").unwrap(), None);
1580 assert!(Refuses.delete("provider/anthropic").is_err());
1581
1582 let err = config
1583 .write_to(&path, Ok(Some(Box::new(Refuses))))
1584 .expect_err("a refused write is not a save");
1585 assert!(err.to_string().contains("failed to store"), "{err}");
1586 assert!(!path.exists(), "no file may be written at all");
1587 }
1588
1589 #[test]
1592 fn saving_in_keychain_mode_puts_the_secrets_in_the_store_not_the_file() {
1593 use leviath_core::CredentialStore;
1594
1595 let dir = tempfile::tempdir().unwrap();
1596 let path = dir.path().join("config.toml");
1597 let mut config = Config::default();
1598 config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1599 config.providers.anthropic_api_key = Some("sk-ant-secret".to_string());
1600 config.default_model = Some("some-model".to_string());
1601
1602 let store = std::sync::Arc::new(leviath_core::MemoryStore::new());
1603 struct Shared(std::sync::Arc<leviath_core::MemoryStore>);
1604 impl CredentialStore for Shared {
1605 fn get(&self, a: &str) -> Result<Option<String>, String> {
1606 self.0.get(a)
1607 }
1608 fn set(&self, a: &str, s: &str) -> Result<(), String> {
1609 self.0.set(a, s)
1610 }
1611 fn delete(&self, a: &str) -> Result<bool, String> {
1612 self.0.delete(a)
1613 }
1614 }
1615
1616 config
1617 .write_to(&path, Ok(Some(Box::new(Shared(store.clone())))))
1618 .unwrap();
1619
1620 assert!(
1622 Shared(store.clone())
1623 .delete(&leviath_core::provider_account("anthropic"))
1624 .unwrap()
1625 );
1626 store
1627 .set(
1628 &leviath_core::provider_account("anthropic"),
1629 "sk-ant-secret",
1630 )
1631 .unwrap();
1632
1633 let written = std::fs::read_to_string(&path).unwrap();
1634 assert!(!written.contains("sk-ant-secret"), "{written}");
1635 assert!(
1636 written.contains("some-model"),
1637 "settings survive: {written}"
1638 );
1639 assert_eq!(
1642 Shared(store.clone())
1643 .get(&leviath_core::provider_account("anthropic"))
1644 .unwrap()
1645 .as_deref(),
1646 Some("sk-ant-secret")
1647 );
1648 }
1649
1650 #[test]
1653 fn the_credential_store_fills_only_the_keys_that_are_unset() {
1654 use leviath_core::{CredentialStore, MemoryStore};
1655
1656 let store = MemoryStore::new();
1657 store
1658 .set(
1659 &leviath_core::provider_account("anthropic"),
1660 "from-keychain",
1661 )
1662 .unwrap();
1663 store
1664 .set(&leviath_core::provider_account("openai"), "openai-keychain")
1665 .unwrap();
1666 store
1667 .set(&leviath_core::provider_account("google"), "google-keychain")
1668 .unwrap();
1669 store
1670 .set(&leviath_core::provider_account("openrouter"), "or-keychain")
1671 .unwrap();
1672
1673 let mut config = Config::default();
1674 config.providers.anthropic_api_key = Some("from-file".to_string());
1676 config.apply_credential_store(&store);
1677
1678 assert_eq!(
1679 config.providers.anthropic_api_key.as_deref(),
1680 Some("from-file"),
1681 "an existing key wins over the keychain"
1682 );
1683 assert_eq!(
1684 config.providers.openai_api_key.as_deref(),
1685 Some("openai-keychain")
1686 );
1687 assert_eq!(
1688 config.providers.google_api_key.as_deref(),
1689 Some("google-keychain")
1690 );
1691 assert_eq!(config.openrouter_api_key.as_deref(), Some("or-keychain"));
1692 }
1693
1694 #[test]
1696 fn an_empty_credential_store_changes_nothing() {
1697 let mut config = Config::default();
1698 config.providers.openai_api_key = Some("keep-me".to_string());
1699 config.apply_credential_store(&leviath_core::MemoryStore::new());
1700 assert_eq!(config.providers.openai_api_key.as_deref(), Some("keep-me"));
1701 assert!(config.providers.anthropic_api_key.is_none());
1702 }
1703
1704 #[test]
1709 fn an_unreachable_credential_store_does_not_stop_the_config_loading() {
1710 use leviath_core::{CredentialStore, MemoryStore};
1711
1712 let mut config = Config::default();
1713 config.fill_from_credential_store_with(Err("no keychain here".to_string()));
1714 assert!(config.providers.anthropic_api_key.is_none());
1715
1716 let mut config = Config::default();
1718 config.providers.openai_api_key = Some("k".to_string());
1719 config.fill_from_credential_store_with(Ok(None));
1720 assert_eq!(config.providers.openai_api_key.as_deref(), Some("k"));
1721
1722 let store = MemoryStore::new();
1724 store
1725 .set(&leviath_core::provider_account("anthropic"), "filled")
1726 .unwrap();
1727 let mut config = Config::default();
1728 config.fill_from_credential_store_with(Ok(Some(Box::new(store))));
1729 assert_eq!(
1730 config.providers.anthropic_api_key.as_deref(),
1731 Some("filled")
1732 );
1733 }
1734
1735 #[test]
1736 fn provider_secrets_lists_every_set_key_and_nothing_else() {
1737 let mut config = Config::default();
1738 assert!(config.provider_secrets().is_empty());
1739
1740 config.providers.anthropic_api_key = Some("a".to_string());
1741 config.openrouter_api_key = Some("o".to_string());
1742 let secrets = config.provider_secrets();
1743 assert_eq!(secrets.len(), 2);
1744 assert!(secrets.contains(&("provider/anthropic".to_string(), "a".to_string())));
1745 assert!(secrets.contains(&("provider/openrouter".to_string(), "o".to_string())));
1746 }
1747
1748 #[test]
1752 fn without_secrets_strips_a_copy_and_leaves_the_original_usable() {
1753 let mut config = Config::default();
1754 config.providers.anthropic_api_key = Some("a".to_string());
1755 config.providers.openai_api_key = Some("b".to_string());
1756 config.providers.google_api_key = Some("c".to_string());
1757 config.openrouter_api_key = Some("d".to_string());
1758 config.default_model = Some("m".to_string());
1759
1760 let stripped = config.without_secrets();
1761 assert!(stripped.provider_secrets().is_empty(), "no keys survive");
1762 assert_eq!(stripped.default_model.as_deref(), Some("m"), "settings do");
1763 assert_eq!(
1764 config.providers.anthropic_api_key.as_deref(),
1765 Some("a"),
1766 "the original is untouched"
1767 );
1768 }
1769
1770 use super::*;
1771 use crate::test_support::with_tracing;
1772
1773 #[test]
1776 fn leviath_home_dir_uses_override_when_set() {
1777 temp_env::with_var(
1778 "LEVIATH_HOME",
1779 Some("/tmp/leviath-home-override-test"),
1780 || {
1781 assert_eq!(
1782 leviath_home_dir(),
1783 Some(std::path::PathBuf::from("/tmp/leviath-home-override-test"))
1784 );
1785 },
1786 );
1787 }
1788
1789 #[test]
1790 fn leviath_home_dir_falls_back_to_dirs_home_dir_when_unset() {
1791 temp_env::with_var_unset("LEVIATH_HOME", || {
1792 assert_eq!(leviath_home_dir(), dirs::home_dir());
1793 });
1794 }
1795
1796 #[test]
1799 fn load_from_path_missing_file_returns_defaults() {
1800 let dir = tempfile::tempdir().unwrap();
1801 let path = dir.path().join("config.toml");
1802 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1803 assert_eq!(config.default_provider, "anthropic");
1804 }
1805
1806 #[test]
1807 fn load_from_path_valid_toml_is_parsed() {
1808 let dir = tempfile::tempdir().unwrap();
1809 let path = dir.path().join("config.toml");
1810 let original = Config {
1811 default_provider: "openai".to_string(),
1812 ..Config::default()
1813 };
1814 std::fs::write(&path, toml::to_string_pretty(&original).unwrap()).unwrap();
1815 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1816 assert_eq!(config.default_provider, "openai");
1817 }
1818
1819 #[test]
1820 fn limits_default_to_bounded_values() {
1821 let limits = LimitsConfig::default();
1822 assert_eq!(limits.max_concurrent_inferences, Some(8));
1823 assert_eq!(limits.default_max_iterations, Some(50));
1824 assert!(!limits.exact_token_counting);
1826 assert_eq!(limits.dead_cycles_before_relief, 10);
1829 assert_eq!(limits.finished_retention_secs, 300);
1832 assert_eq!(limits.interaction_timeout_secs, 3600);
1835 assert_eq!(Config::default().limits.max_concurrent_inferences, Some(8));
1837 }
1838
1839 #[test]
1842 fn interaction_timeout_defaults_and_parses() {
1843 let dir = tempfile::tempdir().unwrap();
1844 let load = |body: String| {
1845 let path = dir.path().join(format!("{}.toml", body.len()));
1846 std::fs::write(&path, body).unwrap();
1847 with_tracing(|| Config::load_from_path(&path)).unwrap()
1848 };
1849
1850 let old = load(format!(
1851 "{}\n[limits]\nmax_concurrent_tools = 4\n",
1852 config_toml_without_limits()
1853 ));
1854 assert_eq!(old.limits.interaction_timeout_secs, 3600);
1855
1856 let disabled = load(format!(
1857 "{}\n[limits]\ninteraction_timeout_secs = 0\n",
1858 config_toml_without_limits()
1859 ));
1860 assert_eq!(disabled.limits.interaction_timeout_secs, 0);
1861 }
1862
1863 #[test]
1864 fn exact_token_counting_parses_when_set() {
1865 let dir = tempfile::tempdir().unwrap();
1866 let path = dir.path().join("config.toml");
1867 let body = format!(
1868 "{}\n[limits]\nexact_token_counting = true\n",
1869 config_toml_without_limits()
1870 );
1871 std::fs::write(&path, body).unwrap();
1872 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1873 assert!(config.limits.exact_token_counting);
1874 assert_eq!(config.limits.max_concurrent_inferences, Some(8));
1876 }
1877
1878 #[cfg(test)]
1882 fn config_toml_without_limits() -> String {
1883 let full = toml::to_string_pretty(&Config::default()).unwrap();
1884 format!("{}\n", full.split("[limits]").next().unwrap().trim_end())
1885 }
1886
1887 #[test]
1888 fn limits_absent_section_uses_defaults() {
1889 let dir = tempfile::tempdir().unwrap();
1891 let path = dir.path().join("config.toml");
1892 std::fs::write(&path, config_toml_without_limits()).unwrap();
1893 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1894 assert_eq!(config.limits.max_concurrent_inferences, Some(8));
1895 assert_eq!(config.limits.default_max_iterations, Some(50));
1896 assert_eq!(config.limits.dead_cycles_before_relief, 10);
1897 assert_eq!(config.limits.finished_retention_secs, 300);
1898 assert_eq!(config.limits.wedge_timeout_secs, 0);
1901 }
1902
1903 #[test]
1904 fn the_wedge_watchdog_is_off_until_it_is_configured() {
1905 let dir = tempfile::tempdir().unwrap();
1906 let path = dir.path().join("config.toml");
1907 let body = format!(
1908 "{}\n[limits]\nwedge_timeout_secs = 300\n",
1909 config_toml_without_limits()
1910 );
1911 std::fs::write(&path, body).unwrap();
1912 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1913 assert_eq!(config.limits.wedge_timeout_secs, 300);
1914 assert_eq!(config.limits.stall_timeout_secs, 60);
1916 }
1917
1918 #[test]
1919 fn limits_partial_section_fills_the_other_default() {
1920 let dir = tempfile::tempdir().unwrap();
1922 let path = dir.path().join("config.toml");
1923 let body = format!(
1924 "{}\n[limits]\nmax_concurrent_inferences = 3\n",
1925 config_toml_without_limits()
1926 );
1927 std::fs::write(&path, body).unwrap();
1928 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1929 assert_eq!(config.limits.max_concurrent_inferences, Some(3));
1930 assert_eq!(config.limits.default_max_iterations, Some(50));
1931 }
1932
1933 #[test]
1934 fn load_from_path_existing_provider_keys_skip_env_fallback() {
1935 let unset: Vec<(&str, Option<&str>)> = PROVIDER_KEY_ENV_VARS
1943 .iter()
1944 .chain(["OLLAMA_HOST"].iter())
1945 .map(|&key| (key, None))
1946 .collect();
1947 temp_env::with_vars(unset, || {
1948 let dir = tempfile::tempdir().unwrap();
1949 let path = dir.path().join("config.toml");
1950 std::fs::write(
1951 &path,
1952 r#"
1953default_provider = "anthropic"
1954openrouter_api_key = "sk-or-existing"
1955ollama_base_url = "http://existing-ollama:11434"
1956agent_paths = []
1957
1958[providers]
1959anthropic_api_key = "sk-ant-existing"
1960openai_api_key = "sk-openai-existing"
1961google_api_key = "AIza-existing"
1962"#,
1963 )
1964 .unwrap();
1965
1966 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1967
1968 assert_eq!(
1969 config.providers.anthropic_api_key.as_deref(),
1970 Some("sk-ant-existing")
1971 );
1972 assert_eq!(
1973 config.providers.openai_api_key.as_deref(),
1974 Some("sk-openai-existing")
1975 );
1976 assert_eq!(
1977 config.providers.google_api_key.as_deref(),
1978 Some("AIza-existing")
1979 );
1980 assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-existing"));
1981 assert_eq!(
1982 config.ollama_base_url.as_deref(),
1983 Some("http://existing-ollama:11434")
1984 );
1985 });
1986 }
1987
1988 #[test]
1989 fn load_from_path_malformed_toml_returns_error() {
1990 let dir = tempfile::tempdir().unwrap();
1991 let path = dir.path().join("config.toml");
1992 std::fs::write(&path, "not valid toml [[[").unwrap();
1993 let result = Config::load_from_path(&path);
1994 assert!(result.is_err());
1995 assert!(result.unwrap_err().to_string().contains("Failed to parse"));
1996 }
1997
1998 #[test]
1999 fn load_from_path_unreadable_path_returns_error() {
2000 let dir = tempfile::tempdir().unwrap();
2002 let result = Config::load_from_path(dir.path());
2003 assert!(result.is_err());
2004 }
2005
2006 #[test]
2007 fn save_to_path_writes_valid_toml_that_round_trips() {
2008 let dir = tempfile::tempdir().unwrap();
2009 let path = dir.path().join("nested").join("config.toml");
2010 let config = Config {
2011 default_provider: "google".to_string(),
2012 ..Config::default()
2013 };
2014 with_tracing(|| config.save_to_path(&path)).unwrap();
2015
2016 let loaded = with_tracing(|| Config::load_from_path(&path)).unwrap();
2017 assert_eq!(loaded.default_provider, "google");
2018 }
2019
2020 #[test]
2021 fn save_to_path_creates_parent_directory() {
2022 let dir = tempfile::tempdir().unwrap();
2023 let path = dir.path().join("a").join("b").join("config.toml");
2024 let config = Config::default();
2025 with_tracing(|| config.save_to_path(&path)).unwrap();
2026 assert!(path.exists());
2027 }
2028
2029 #[test]
2030 fn save_to_path_with_no_parent_skips_create_config_dir() {
2031 let result = Config::default().save_to_path(&std::path::PathBuf::from(""));
2038 assert!(result.is_err());
2039 }
2040
2041 #[cfg(unix)]
2042 #[test]
2043 fn save_to_path_sets_restrictive_file_permissions() {
2044 use std::os::unix::fs::PermissionsExt;
2045 let dir = tempfile::tempdir().unwrap();
2046 let path = dir.path().join("config.toml");
2047 with_tracing(|| Config::default().save_to_path(&path)).unwrap();
2048 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2049 assert_eq!(mode & 0o777, 0o600);
2050 }
2051
2052 #[test]
2053 fn save_to_path_write_failure_returns_error() {
2054 let dir = tempfile::tempdir().unwrap();
2059 let path = dir.path().join("config.toml");
2060 std::fs::create_dir_all(&path).unwrap();
2061
2062 let result = Config::default().save_to_path(&path);
2063
2064 assert!(result.is_err());
2065 assert!(
2066 result
2067 .unwrap_err()
2068 .to_string()
2069 .contains("Failed to write config")
2070 );
2071 }
2072
2073 #[test]
2074 fn save_to_path_create_config_dir_failure_returns_error() {
2075 let dir = tempfile::tempdir().unwrap();
2076 let blocking_file = dir.path().join("not-a-dir");
2077 std::fs::write(&blocking_file, "").unwrap();
2078 let path = blocking_file.join("config.toml");
2079 let result = Config::default().save_to_path(&path);
2080 assert!(result.is_err());
2081 assert!(
2082 result
2083 .unwrap_err()
2084 .to_string()
2085 .contains("Failed to create config directory")
2086 );
2087 }
2088
2089 #[test]
2090 fn load_propagates_error_when_real_config_file_is_malformed() {
2091 with_isolated_config_path("load-malformed", |fake_dir| {
2096 std::fs::write(fake_dir.join("config.toml"), "not valid toml [[[").unwrap();
2097
2098 let result = Config::load();
2099
2100 assert!(result.is_err());
2101 });
2102 }
2103
2104 #[cfg(unix)]
2107 #[test]
2108 fn check_permissions_at_missing_file_is_noop() {
2109 let dir = tempfile::tempdir().unwrap();
2110 let path = dir.path().join("nonexistent.toml");
2111 check_permissions_at(&path); }
2113
2114 #[cfg(unix)]
2115 #[test]
2116 fn check_permissions_at_fixes_overly_permissive_file() {
2117 use std::os::unix::fs::PermissionsExt;
2118 let dir = tempfile::tempdir().unwrap();
2119 let path = dir.path().join("config.toml");
2120 std::fs::write(&path, "").unwrap();
2121 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2122
2123 with_tracing(|| check_permissions_at(&path));
2124
2125 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2126 assert_eq!(mode & 0o777, 0o600);
2127 }
2128
2129 #[cfg(unix)]
2130 #[test]
2131 fn check_permissions_at_leaves_already_restrictive_file_alone() {
2132 use std::os::unix::fs::PermissionsExt;
2133 let dir = tempfile::tempdir().unwrap();
2134 let path = dir.path().join("config.toml");
2135 std::fs::write(&path, "").unwrap();
2136 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2137
2138 check_permissions_at(&path);
2139
2140 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2141 assert_eq!(mode & 0o777, 0o600);
2142 }
2143
2144 #[test]
2152 fn check_permissions_at_with_logs_when_fix_fails() {
2153 fn ensure_fails(_: &std::path::Path) -> std::io::Result<Option<u32>> {
2154 Err(std::io::Error::other("simulated chmod failure"))
2155 }
2156 with_tracing(|| {
2158 check_permissions_at_with(std::path::Path::new("/does/not/matter"), ensure_fails)
2159 });
2160 }
2161
2162 #[test]
2163 fn check_permissions_at_with_logs_when_file_is_permissive() {
2164 fn ensure_permissive(_: &std::path::Path) -> std::io::Result<Option<u32>> {
2165 Ok(Some(0o100644))
2166 }
2167 with_tracing(|| {
2168 check_permissions_at_with(std::path::Path::new("/does/not/matter"), ensure_permissive)
2169 });
2170 }
2171
2172 fn always_failing_secure(_path: &std::path::Path) -> std::io::Result<()> {
2178 Err(std::io::Error::other(
2179 "simulated permission-hardening failure",
2180 ))
2181 }
2182
2183 #[test]
2184 fn set_dir_permissions_error_branch_logs_not_panics() {
2185 with_tracing(|| {
2186 set_dir_permissions_with(
2187 std::path::Path::new("/does/not/matter"),
2188 always_failing_secure,
2189 )
2190 }); }
2192
2193 #[test]
2198 fn create_config_dir_creates_nested_dirs() {
2199 let dir = tempfile::tempdir().unwrap();
2200 let target = dir.path().join("a").join("b").join("c");
2201 create_config_dir(&target).unwrap();
2202 assert!(target.is_dir());
2203 }
2204
2205 #[cfg(unix)]
2206 #[test]
2207 fn create_config_dir_sets_restrictive_permissions() {
2208 use std::os::unix::fs::PermissionsExt;
2209 let dir = tempfile::tempdir().unwrap();
2210 let target = dir.path().join("leviath");
2211 create_config_dir(&target).unwrap();
2212 let mode = std::fs::metadata(&target).unwrap().permissions().mode();
2213 assert_eq!(mode & 0o777, 0o700);
2214 }
2215
2216 #[cfg(unix)]
2220 #[test]
2228 fn config_path_honors_leviath_home() {
2229 temp_env::with_vars(
2230 [
2231 ("LEVIATH_CONFIG_PATH", None::<&str>),
2232 ("LEVIATH_HOME", Some("/tmp/lev-cfg-test")),
2233 ],
2234 || {
2235 assert_eq!(
2236 Config::config_path(),
2237 std::path::PathBuf::from("/tmp/lev-cfg-test/.leviath/config.toml")
2238 );
2239 },
2240 );
2241 }
2242
2243 #[test]
2245 fn config_path_prefers_the_explicit_override() {
2246 temp_env::with_vars(
2247 [
2248 ("LEVIATH_CONFIG_PATH", Some("/tmp/exact.toml")),
2249 ("LEVIATH_HOME", Some("/tmp/lev-cfg-test")),
2250 ],
2251 || {
2252 assert_eq!(
2253 Config::config_path(),
2254 std::path::PathBuf::from("/tmp/exact.toml")
2255 );
2256 },
2257 );
2258 }
2259
2260 #[test]
2264 fn permissions_for_agent_overlays_the_named_grant_on_the_global() {
2265 let mut config = Config::default();
2266 config
2267 .tool_permissions
2268 .insert("shell".to_string(), ToolPolicy::Ask);
2269 config
2270 .tool_permissions
2271 .insert("write_file".to_string(), ToolPolicy::Deny);
2272 config.agent_tool_permissions.insert(
2273 "coder".to_string(),
2274 HashMap::from([("shell".to_string(), ToolPolicy::Allow)]),
2275 );
2276
2277 let coder = config.permissions_for_agent("coder");
2278 assert_eq!(coder.get("shell"), Some(&ToolPolicy::Allow), "granted");
2279 assert_eq!(
2280 coder.get("write_file"),
2281 Some(&ToolPolicy::Deny),
2282 "the rest of the global ceiling still applies"
2283 );
2284
2285 let other = config.permissions_for_agent("researcher");
2287 assert_eq!(other.get("shell"), Some(&ToolPolicy::Ask));
2288 }
2289
2290 #[test]
2293 fn read_path_grants_merge_global_and_per_agent() {
2294 let mut config = Config::default();
2295 assert!(
2296 !config.security.allow_blueprint_read_paths,
2297 "blueprint read paths must be opt-in"
2298 );
2299 assert!(config.read_path_grants_for_agent("cto").is_empty());
2300
2301 config.security.read_paths = vec!["~/.leviath/runs".to_string()];
2302 config.agent_read_paths.insert(
2303 "cto".to_string(),
2304 ReadPathGrants {
2305 allow: vec!["glob:~/design-docs/**".to_string()],
2306 },
2307 );
2308
2309 assert_eq!(
2310 config.read_path_grants_for_agent("cto"),
2311 vec![
2312 "~/.leviath/runs".to_string(),
2313 "glob:~/design-docs/**".to_string(),
2314 ]
2315 );
2316 assert_eq!(
2318 config.read_path_grants_for_agent("researcher"),
2319 vec!["~/.leviath/runs".to_string()]
2320 );
2321 }
2322
2323 #[test]
2326 fn provider_config_debug_never_prints_the_keys() {
2327 let providers = ProviderConfig {
2328 anthropic_api_key: Some("sk-ant-SECRET-VALUE".to_string()),
2329 openai_api_key: Some("sk-openai-SECRET-VALUE".to_string()),
2330 google_api_key: Some("AIza-SECRET-VALUE".to_string()),
2331 claude_code_enabled: true,
2332 claude_code_binary: None,
2333 claude_code_effort: None,
2334 anthropic_cache_ttl: None,
2335 fallback_order: Vec::new(),
2336 };
2337 let rendered = format!("{providers:?}");
2338 assert!(!rendered.contains("SECRET-VALUE"), "key leaked: {rendered}");
2339 assert!(rendered.contains("<set>"), "{rendered}");
2341 assert!(rendered.contains("claude_code_enabled: true"), "{rendered}");
2342
2343 let empty = format!(
2344 "{:?}",
2345 ProviderConfig {
2346 anthropic_api_key: None,
2347 openai_api_key: None,
2348 google_api_key: None,
2349 claude_code_enabled: false,
2350 claude_code_binary: None,
2351 claude_code_effort: None,
2352 anthropic_cache_ttl: None,
2353 fallback_order: Vec::new(),
2354 }
2355 );
2356 assert!(empty.contains("<unset>"), "{empty}");
2357 }
2358
2359 #[cfg(unix)]
2363 #[test]
2364 fn saving_a_config_never_leaves_it_group_or_world_readable() {
2365 use std::os::unix::fs::PermissionsExt;
2366 let dir = tempfile::tempdir().unwrap();
2367 let path = dir.path().join("config.toml");
2368
2369 Config::default().save_to_path(&path).unwrap();
2370 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2371 assert_eq!(mode & 0o777, 0o600, "fresh config must be owner-only");
2372
2373 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2375 Config::default().save_to_path(&path).unwrap();
2376 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2377 assert_eq!(mode & 0o777, 0o600, "re-saving must re-tighten");
2378 }
2379
2380 #[cfg(unix)]
2381 #[test]
2382 fn set_dir_permissions_sets_0700() {
2383 use std::os::unix::fs::PermissionsExt;
2384 let dir = tempfile::tempdir().unwrap();
2385 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
2386 set_dir_permissions(dir.path());
2387 let mode = std::fs::metadata(dir.path()).unwrap().permissions().mode();
2388 assert_eq!(mode & 0o777, 0o700);
2389 }
2390
2391 #[test]
2392 fn test_validate_keys_good_anthropic() {
2393 let config = Config {
2394 providers: ProviderConfig {
2395 anthropic_api_key: Some("sk-ant-test123".to_string()),
2396 openai_api_key: None,
2397 google_api_key: None,
2398 claude_code_enabled: false,
2399 claude_code_binary: None,
2400 claude_code_effort: None,
2401 anthropic_cache_ttl: None,
2402 fallback_order: Vec::new(),
2403 },
2404 ..Config::default()
2405 };
2406 assert!(config.validate_keys().is_empty());
2407 }
2408
2409 #[test]
2410 fn test_validate_keys_bad_anthropic() {
2411 let config = Config {
2412 providers: ProviderConfig {
2413 anthropic_api_key: Some("bad-key".to_string()),
2414 openai_api_key: None,
2415 google_api_key: None,
2416 claude_code_enabled: false,
2417 claude_code_binary: None,
2418 claude_code_effort: None,
2419 anthropic_cache_ttl: None,
2420 fallback_order: Vec::new(),
2421 },
2422 ..Config::default()
2423 };
2424 let warnings = config.validate_keys();
2425 assert_eq!(warnings.len(), 1);
2426 assert!(warnings[0].contains("Anthropic"));
2427 }
2428
2429 #[test]
2430 fn test_validate_keys_good_openai() {
2431 let config = Config {
2432 providers: ProviderConfig {
2433 anthropic_api_key: None,
2434 openai_api_key: Some("sk-test123".to_string()),
2435 google_api_key: None,
2436 claude_code_enabled: false,
2437 claude_code_binary: None,
2438 claude_code_effort: None,
2439 anthropic_cache_ttl: None,
2440 fallback_order: Vec::new(),
2441 },
2442 ..Config::default()
2443 };
2444 assert!(config.validate_keys().is_empty());
2445 }
2446
2447 #[test]
2448 fn test_validate_keys_bad_openai() {
2449 let config = Config {
2450 providers: ProviderConfig {
2451 anthropic_api_key: None,
2452 openai_api_key: Some("bad-key".to_string()),
2453 google_api_key: None,
2454 claude_code_enabled: false,
2455 claude_code_binary: None,
2456 claude_code_effort: None,
2457 anthropic_cache_ttl: None,
2458 fallback_order: Vec::new(),
2459 },
2460 ..Config::default()
2461 };
2462 let warnings = config.validate_keys();
2463 assert_eq!(warnings.len(), 1);
2464 assert!(warnings[0].contains("OpenAI"));
2465 }
2466
2467 #[test]
2468 fn test_validate_keys_no_keys() {
2469 let config = Config::default();
2470 assert!(config.validate_keys().is_empty());
2471 }
2472
2473 #[test]
2476 fn config_default_values() {
2477 let config = Config::default();
2478 assert_eq!(config.default_provider, "anthropic");
2479 assert!(config.providers.anthropic_api_key.is_none());
2480 assert!(config.providers.openai_api_key.is_none());
2481 assert!(config.providers.google_api_key.is_none());
2482 assert!(config.openrouter_api_key.is_none());
2483 assert!(config.ollama_base_url.is_none());
2484 assert!(config.mcp_servers.is_empty());
2485 assert!(config.default_model.is_none());
2486 assert!(config.model_capabilities.is_empty());
2487 assert!(config.tool_permissions.is_empty());
2488 }
2489
2490 #[test]
2493 fn title_config_default() {
2494 let tc = TitleConfig::default();
2495 assert!(tc.enabled);
2496 assert!(tc.provider.is_none());
2497 assert!(tc.model.is_none());
2498 }
2499
2500 #[test]
2501 fn title_config_serde_roundtrip() {
2502 let tc = TitleConfig {
2503 enabled: false,
2504 provider: Some("openai".to_string()),
2505 model: Some("gpt-5.4-mini".to_string()),
2506 };
2507 let json = serde_json::to_string(&tc).unwrap();
2508 let back: TitleConfig = serde_json::from_str(&json).unwrap();
2509 assert!(!back.enabled);
2510 assert_eq!(back.provider.as_deref(), Some("openai"));
2511 assert_eq!(back.model.as_deref(), Some("gpt-5.4-mini"));
2512 }
2513
2514 #[test]
2517 fn tool_policy_default_is_ask() {
2518 let policy = ToolPolicy::default();
2519 assert_eq!(policy, ToolPolicy::Ask);
2520 }
2521
2522 #[test]
2523 fn tool_policy_serde_roundtrip() {
2524 for policy in [ToolPolicy::Allow, ToolPolicy::Ask, ToolPolicy::Deny] {
2525 let json = serde_json::to_string(&policy).unwrap();
2526 let back: ToolPolicy = serde_json::from_str(&json).unwrap();
2527 assert_eq!(policy, back);
2528 }
2529 }
2530
2531 #[test]
2532 fn tool_policy_snake_case_serialization() {
2533 assert_eq!(
2534 serde_json::to_string(&ToolPolicy::Allow).unwrap(),
2535 "\"allow\""
2536 );
2537 assert_eq!(serde_json::to_string(&ToolPolicy::Ask).unwrap(), "\"ask\"");
2538 assert_eq!(
2539 serde_json::to_string(&ToolPolicy::Deny).unwrap(),
2540 "\"deny\""
2541 );
2542 }
2543
2544 #[test]
2547 fn config_from_toml_with_all_fields() {
2548 let toml_content = r#"
2549default_provider = "openai"
2550openrouter_api_key = "sk-or-test"
2551ollama_base_url = "http://my-ollama:11434"
2552default_model = "gpt-5"
2553agent_paths = []
2554
2555[providers]
2556anthropic_api_key = "sk-ant-test"
2557openai_api_key = "sk-test"
2558google_api_key = "AIza-test"
2559
2560[tool_permissions]
2561bash = "deny"
2562read_file = "allow"
2563
2564[title]
2565enabled = false
2566provider = "anthropic"
2567model = "claude-haiku-4-5"
2568"#;
2569 let config: Config = toml::from_str(toml_content).unwrap();
2570 assert_eq!(config.default_provider, "openai");
2571 assert_eq!(
2572 config.providers.anthropic_api_key.as_deref(),
2573 Some("sk-ant-test")
2574 );
2575 assert_eq!(config.providers.openai_api_key.as_deref(), Some("sk-test"));
2576 assert_eq!(
2577 config.providers.google_api_key.as_deref(),
2578 Some("AIza-test")
2579 );
2580 assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-test"));
2581 assert_eq!(
2582 config.ollama_base_url.as_deref(),
2583 Some("http://my-ollama:11434")
2584 );
2585 assert_eq!(config.default_model.as_deref(), Some("gpt-5"));
2586 assert!(!config.title.enabled);
2587 assert_eq!(config.tool_permissions.get("bash"), Some(&ToolPolicy::Deny));
2588 assert_eq!(
2589 config.tool_permissions.get("read_file"),
2590 Some(&ToolPolicy::Allow)
2591 );
2592 }
2593
2594 #[test]
2595 fn config_from_minimal_toml() {
2596 let toml_content = r#"
2597default_provider = "anthropic"
2598agent_paths = []
2599
2600[providers]
2601"#;
2602 let config: Config = toml::from_str(toml_content).unwrap();
2603 assert_eq!(config.default_provider, "anthropic");
2604 assert!(config.providers.anthropic_api_key.is_none());
2605 }
2606
2607 #[test]
2608 fn the_three_lines_that_point_leviath_at_openrouter_are_enough() {
2609 let config: Config = toml::from_str(
2614 r#"
2615default_provider = "openrouter"
2616default_model = "openai/gpt-4o-mini"
2617openrouter_api_key = "sk-or-test"
2618"#,
2619 )
2620 .expect("a hand-written OpenRouter config parses");
2621 assert_eq!(config.default_provider, "openrouter");
2622 assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-test"));
2623 assert_eq!(config.default_model.as_deref(), Some("openai/gpt-4o-mini"));
2624 }
2625
2626 #[test]
2627 fn an_empty_config_file_parses_to_the_defaults() {
2628 let parsed: Config = toml::from_str("").expect("an empty config parses");
2632 let default = Config::default();
2633 assert_eq!(parsed.default_provider, default.default_provider);
2634 assert_eq!(parsed.agent_paths, default.agent_paths);
2635 assert_eq!(parsed.openrouter_api_key, default.openrouter_api_key);
2636 assert_eq!(parsed.ollama_base_url, default.ollama_base_url);
2637 assert_eq!(parsed.default_model, default.default_model);
2638 assert_eq!(parsed.request_timeout_secs, default.request_timeout_secs);
2639 assert_eq!(
2640 parsed.providers.anthropic_api_key,
2641 default.providers.anthropic_api_key
2642 );
2643 assert_eq!(
2644 parsed.providers.claude_code_enabled,
2645 default.providers.claude_code_enabled
2646 );
2647 }
2648
2649 #[test]
2650 fn config_from_toml_with_mcp_servers() {
2651 let toml_content = r#"
2652default_provider = "anthropic"
2653agent_paths = []
2654
2655[providers]
2656
2657[[mcp_servers]]
2658name = "test-server"
2659command = "echo"
2660args = ["hello"]
2661"#;
2662 let config: Config = toml::from_str(toml_content).unwrap();
2663 assert_eq!(config.mcp_servers.len(), 1);
2664 assert_eq!(config.mcp_servers[0].name, "test-server");
2665 }
2666
2667 #[test]
2668 fn load_rejects_a_malformed_mcp_server_entry() {
2669 let dir = tempfile::tempdir().unwrap();
2673 let path = dir.path().join("config.toml");
2674 std::fs::write(
2675 &path,
2676 r#"
2677default_provider = "anthropic"
2678agent_paths = []
2679
2680[providers]
2681
2682[[mcp_servers]]
2683name = "broken"
2684"#,
2685 )
2686 .unwrap();
2687
2688 let err = Config::load_from_path(&path).expect_err("malformed entry must fail load");
2689 let msg = err.to_string();
2690 assert!(msg.contains("broken"), "must name the server: {msg}");
2691 }
2692
2693 #[test]
2694 fn load_accepts_a_well_formed_http_mcp_server() {
2695 let dir = tempfile::tempdir().unwrap();
2696 let path = dir.path().join("config.toml");
2697 std::fs::write(
2698 &path,
2699 r#"
2700default_provider = "anthropic"
2701agent_paths = []
2702
2703[providers]
2704
2705[[mcp_servers]]
2706name = "remote"
2707url = "https://mcp.example.com/mcp"
2708"#,
2709 )
2710 .unwrap();
2711
2712 let config = Config::load_from_path(&path).expect("valid http entry should load");
2713 assert_eq!(
2714 config.mcp_servers[0].url.as_deref(),
2715 Some("https://mcp.example.com/mcp")
2716 );
2717 }
2718
2719 #[test]
2720 fn config_from_toml_with_model_capabilities() {
2721 let toml = r#"
2726[model_capabilities."my-custom-model"]
2727max_context_tokens = 1048576
2728"#;
2729 let config: Config = toml::from_str(toml).expect("a partial entry parses");
2730 let entry = config
2731 .model_capabilities
2732 .get("my-custom-model")
2733 .expect("the entry survives");
2734 assert_eq!(entry.max_context_tokens, Some(1_048_576));
2735 assert_eq!(
2736 entry.max_output_tokens, None,
2737 "an unmentioned field stays unset rather than defaulting"
2738 );
2739 assert_eq!(entry.supports_tools, None);
2740 }
2741
2742 #[test]
2745 fn config_model_capabilities_rejects_an_unknown_key() {
2746 let toml = r#"
2747[model_capabilities."my-custom-model"]
2748max_contxt_tokens = 1048576
2749"#;
2750 assert!(toml::from_str::<Config>(toml).is_err());
2751 }
2752
2753 #[test]
2754 fn validate_keys_is_quiet_about_blank_keys() {
2755 let mut config = Config::default();
2756 config.providers.anthropic_api_key = Some(String::new());
2757 config.providers.openai_api_key = Some(" ".to_string());
2758 assert!(config.validate_keys().is_empty());
2759 config.providers.anthropic_api_key = Some("nope".to_string());
2761 assert_eq!(config.validate_keys().len(), 1);
2762 }
2763
2764 #[test]
2765 fn validate_keys_both_bad() {
2766 let config = Config {
2767 providers: ProviderConfig {
2768 anthropic_api_key: Some("bad".to_string()),
2769 openai_api_key: Some("bad".to_string()),
2770 google_api_key: None,
2771 claude_code_enabled: false,
2772 claude_code_binary: None,
2773 claude_code_effort: None,
2774 anthropic_cache_ttl: None,
2775 fallback_order: Vec::new(),
2776 },
2777 ..Config::default()
2778 };
2779 let warnings = config.validate_keys();
2780 assert_eq!(warnings.len(), 2);
2781 }
2782
2783 #[test]
2786 fn config_path_contains_leviath() {
2787 temp_env::with_var_unset("LEVIATH_CONFIG_PATH", || {
2792 let path = Config::config_path();
2793 assert!(path.to_str().unwrap().contains(".leviath"));
2794 assert!(path.to_str().unwrap().ends_with("config.toml"));
2795 });
2796 }
2797
2798 #[test]
2801 fn config_toml_roundtrip() {
2802 let config = Config {
2803 default_provider: "openai".to_string(),
2804 providers: ProviderConfig {
2805 anthropic_api_key: Some("sk-ant-key".to_string()),
2806 openai_api_key: None,
2807 google_api_key: None,
2808 claude_code_enabled: false,
2809 claude_code_binary: None,
2810 claude_code_effort: None,
2811 anthropic_cache_ttl: None,
2812 fallback_order: Vec::new(),
2813 },
2814 tool_permissions: {
2815 let mut m = HashMap::new();
2816 m.insert("bash".to_string(), ToolPolicy::Deny);
2817 m
2818 },
2819 ..Config::default()
2820 };
2821
2822 let serialized = toml::to_string_pretty(&config).unwrap();
2823 let deserialized: Config = toml::from_str(&serialized).unwrap();
2824 assert_eq!(deserialized.default_provider, "openai");
2825 assert_eq!(
2826 deserialized.providers.anthropic_api_key.as_deref(),
2827 Some("sk-ant-key")
2828 );
2829 assert_eq!(
2830 deserialized.tool_permissions.get("bash"),
2831 Some(&ToolPolicy::Deny)
2832 );
2833 }
2834
2835 #[test]
2838 fn validate_keys_both_valid() {
2839 let config = Config {
2840 providers: ProviderConfig {
2841 anthropic_api_key: Some("sk-ant-good-key".to_string()),
2842 openai_api_key: Some("sk-good-key".to_string()),
2843 google_api_key: None,
2844 claude_code_enabled: false,
2845 claude_code_binary: None,
2846 claude_code_effort: None,
2847 anthropic_cache_ttl: None,
2848 fallback_order: Vec::new(),
2849 },
2850 ..Config::default()
2851 };
2852 assert!(config.validate_keys().is_empty());
2853 }
2854
2855 #[test]
2858 fn validate_keys_google_key_not_validated() {
2859 let config = Config {
2860 providers: ProviderConfig {
2861 anthropic_api_key: None,
2862 openai_api_key: None,
2863 google_api_key: Some("anything-goes".to_string()),
2864 claude_code_enabled: false,
2865 claude_code_binary: None,
2866 claude_code_effort: None,
2867 anthropic_cache_ttl: None,
2868 fallback_order: Vec::new(),
2869 },
2870 ..Config::default()
2871 };
2872 assert!(config.validate_keys().is_empty());
2874 }
2875
2876 #[test]
2879 fn config_from_toml_custom_registries() {
2880 let toml_content = r#"
2881default_provider = "anthropic"
2882agent_paths = ["/my/agents"]
2883
2884[providers]
2885"#;
2886 let config: Config = toml::from_str(toml_content).unwrap();
2887 assert_eq!(config.agent_paths.len(), 1);
2888 }
2889
2890 #[test]
2893 fn config_save_creates_file() {
2894 let dir = tempfile::tempdir().unwrap();
2895 let config_path = dir.path().join("subdir").join("config.toml");
2896 let config = Config::default();
2899 let content = toml::to_string_pretty(&config).unwrap();
2900 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
2901 std::fs::write(&config_path, &content).unwrap();
2902 assert!(config_path.exists());
2903 let loaded_content = std::fs::read_to_string(&config_path).unwrap();
2904 let loaded: Config = toml::from_str(&loaded_content).unwrap();
2905 assert_eq!(loaded.default_provider, "anthropic");
2906 }
2907
2908 #[test]
2911 fn title_config_from_toml_defaults() {
2912 let toml_content = r#"
2913default_provider = "anthropic"
2914agent_paths = []
2915
2916[providers]
2917"#;
2918 let config: Config = toml::from_str(toml_content).unwrap();
2919 assert!(config.title.enabled);
2920 assert!(config.title.provider.is_none());
2921 assert!(config.title.model.is_none());
2922 }
2923
2924 #[test]
2925 fn title_config_from_toml_disabled() {
2926 let toml_content = r#"
2927default_provider = "anthropic"
2928agent_paths = []
2929
2930[providers]
2931
2932[title]
2933enabled = false
2934"#;
2935 let config: Config = toml::from_str(toml_content).unwrap();
2936 assert!(!config.title.enabled);
2937 }
2938
2939 #[test]
2940 fn title_config_missing_enabled_key_uses_default_true() {
2941 let toml_content = r#"
2948default_provider = "anthropic"
2949agent_paths = []
2950
2951[providers]
2952
2953[title]
2954provider = "openai"
2955"#;
2956 let config: Config = toml::from_str(toml_content).unwrap();
2957 assert!(config.title.enabled);
2958 assert_eq!(config.title.provider.as_deref(), Some("openai"));
2959 }
2960
2961 #[test]
2964 fn config_tool_permissions_allow() {
2965 let toml_content = r#"
2966default_provider = "anthropic"
2967agent_paths = []
2968
2969[providers]
2970
2971[tool_permissions]
2972read_file = "allow"
2973write_file = "ask"
2974bash = "deny"
2975"#;
2976 let config: Config = toml::from_str(toml_content).unwrap();
2977 assert_eq!(
2978 config.tool_permissions.get("read_file"),
2979 Some(&ToolPolicy::Allow)
2980 );
2981 assert_eq!(
2982 config.tool_permissions.get("write_file"),
2983 Some(&ToolPolicy::Ask)
2984 );
2985 assert_eq!(config.tool_permissions.get("bash"), Some(&ToolPolicy::Deny));
2986 }
2987
2988 #[test]
2991 fn config_with_agent_paths() {
2992 let toml_content = r#"
2993default_provider = "anthropic"
2994agent_paths = ["/home/user/agents", "/opt/agents"]
2995
2996[providers]
2997"#;
2998 let config: Config = toml::from_str(toml_content).unwrap();
2999 assert_eq!(config.agent_paths.len(), 2);
3000 }
3001
3002 #[test]
3005 fn config_load_from_nonexistent_path_returns_default() {
3006 let config = Config::default();
3010 assert_eq!(config.default_provider, "anthropic");
3011 assert!(config.providers.anthropic_api_key.is_none());
3012 }
3013
3014 #[test]
3015 fn config_load_from_toml_string() {
3016 let toml_content = r#"
3018default_provider = "openai"
3019agent_paths = []
3020
3021[providers]
3022anthropic_api_key = "sk-ant-test-key"
3023"#;
3024 let config: Config = toml::from_str(toml_content).unwrap();
3025 assert_eq!(config.default_provider, "openai");
3026 assert_eq!(
3027 config.providers.anthropic_api_key.as_deref(),
3028 Some("sk-ant-test-key")
3029 );
3030 assert_eq!(config.nudge, leviath_core::NudgeConfig::default());
3032 }
3033
3034 #[test]
3035 fn config_parses_partial_nudge_section() {
3036 let config: Config = toml::from_str(
3038 r#"
3039default_provider = "openai"
3040agent_paths = []
3041
3042[providers]
3043
3044[nudge]
3045enabled = false
3046"#,
3047 )
3048 .unwrap();
3049 assert_eq!(config.nudge.enabled, Some(false));
3050 assert_eq!(config.nudge.max, None);
3051 assert_eq!(config.nudge.text, None);
3052 }
3053
3054 #[test]
3055 fn config_save_and_load_with_file() {
3056 let dir = tempfile::tempdir().unwrap();
3058 let config_path = dir.path().join("config.toml");
3059
3060 let config = Config {
3061 default_provider: "openai".to_string(),
3062 providers: ProviderConfig {
3063 anthropic_api_key: Some("sk-ant-test".to_string()),
3064 openai_api_key: Some("sk-test".to_string()),
3065 google_api_key: None,
3066 claude_code_enabled: false,
3067 claude_code_binary: None,
3068 claude_code_effort: None,
3069 anthropic_cache_ttl: None,
3070 fallback_order: Vec::new(),
3071 },
3072 openrouter_api_key: Some("sk-or-test".to_string()),
3073 default_model: Some("gpt-5".to_string()),
3074 ..Config::default()
3075 };
3076
3077 let content = toml::to_string_pretty(&config).unwrap();
3078 std::fs::write(&config_path, &content).unwrap();
3079
3080 let loaded_content = std::fs::read_to_string(&config_path).unwrap();
3081 let loaded: Config = toml::from_str(&loaded_content).unwrap();
3082
3083 assert_eq!(loaded.default_provider, "openai");
3084 assert_eq!(
3085 loaded.providers.anthropic_api_key.as_deref(),
3086 Some("sk-ant-test")
3087 );
3088 assert_eq!(loaded.default_model.as_deref(), Some("gpt-5"));
3089 }
3090
3091 #[test]
3092 fn config_create_config_dir_creates_parent() {
3093 let dir = tempfile::tempdir().unwrap();
3094 let new_dir = dir.path().join("nested").join("config");
3095 std::fs::create_dir_all(&new_dir).unwrap();
3097 assert!(new_dir.exists());
3098 }
3099
3100 #[test]
3101 fn config_default_title_enabled() {
3102 let config = Config::default();
3103 assert!(config.title.enabled);
3104 }
3105
3106 #[test]
3107 fn config_serialize_with_all_options() {
3108 let mut model_caps = HashMap::new();
3109 model_caps.insert(
3110 "my-model".to_string(),
3111 ModelCapabilityOverride {
3112 supports_temperature: Some(true),
3113 supports_streaming: Some(true),
3114 supports_tools: Some(true),
3115 supports_system_prompt: Some(true),
3116 max_context_tokens: Some(8192),
3117 max_output_tokens: Some(4096),
3118 },
3119 );
3120 let mut tool_perms = HashMap::new();
3121 tool_perms.insert("bash".to_string(), ToolPolicy::Allow);
3122
3123 let config = Config {
3124 default_provider: "anthropic".to_string(),
3125 providers: ProviderConfig {
3126 anthropic_api_key: Some("sk-ant-key".to_string()),
3127 openai_api_key: None,
3128 google_api_key: None,
3129 claude_code_enabled: false,
3130 claude_code_binary: None,
3131 claude_code_effort: None,
3132 anthropic_cache_ttl: None,
3133 fallback_order: Vec::new(),
3134 },
3135 agent_paths: vec![std::path::PathBuf::from("/my/agents")],
3136 openrouter_api_key: None,
3137 ollama_base_url: Some("http://custom:11434".to_string()),
3138 mcp_servers: vec![],
3139 default_model: None,
3140 model_capabilities: model_caps,
3141 model_providers: HashMap::new(),
3142 tool_permissions: tool_perms,
3143 agent_tool_permissions: HashMap::new(),
3144 safe_commands: crate::approvals::SafeCommands::default(),
3145 agent_safe_commands: HashMap::new(),
3146 title: TitleConfig {
3147 enabled: false,
3148 provider: Some("openai".to_string()),
3149 model: Some("gpt-5-mini".to_string()),
3150 },
3151 request_timeout_secs: None,
3152 rate_limits: HashMap::new(),
3153 taint_tracking: false,
3154 limits: LimitsConfig {
3155 mcp_idle_disconnect_secs: default_mcp_idle_disconnect_secs(),
3156 max_tool_call_write_bytes: None,
3157 max_run_write_bytes: None,
3158 max_concurrent_inferences: Some(4),
3159 max_concurrent_tools: 3,
3160 default_max_iterations: Some(99),
3161 exact_token_counting: false,
3162 script_shell_timeout_secs: 45,
3163 stall_timeout_secs: 90,
3164 dead_cycles_before_relief: 6,
3165 finished_retention_secs: 120,
3166 wedge_timeout_secs: 420,
3167 provider_failures_before_open: 5,
3168 provider_circuit_cooldown_secs: 120,
3169 interaction_timeout_secs: 120,
3170 },
3171 batch_tool_hint: true,
3172 shell_hint: false,
3173 nudge: leviath_core::NudgeConfig {
3174 enabled: Some(true),
3175 max: Some(2),
3176 text: Some("Use your tools.".to_string()),
3177 },
3178 webhook: WebhookConfig {
3179 max_retries: 5,
3180 base_delay_ms: 250,
3181 max_delay_ms: 10_000,
3182 timeout_secs: 7,
3183 },
3184 observability: ObservabilityConfig {
3185 enabled: true,
3186 exporter: TelemetryExporterKind::Stdout,
3187 endpoint: Some("http://collector:4318".to_string()),
3188 service_name: Some("leviath-prod".to_string()),
3189 },
3190 sandbox: Some(leviath_core::ToolSandboxConfig {
3191 kind: leviath_core::SandboxKind::Container,
3192 image: Some("ubuntu:24.04".to_string()),
3193 network: false,
3194 ..Default::default()
3195 }),
3196 tool_script_permissions: ScriptToolPermissions {
3197 http_get: ScriptPermission::Allow,
3198 http_post: ScriptPermission::Deny,
3199 shell: ScriptPermission::Deny,
3200 read_file: ScriptPermission::Inherit,
3201 write_file: ScriptPermission::Deny,
3202 env_var: ScriptPermission::Allow,
3203 },
3204 security: SecurityConfig {
3205 allowed_workdirs: Vec::new(),
3206 allow_seed_commands: false,
3207 allow_local_network: true,
3208 allow_env_vars: vec!["MY_PROVIDER_KEY".to_string()],
3209 allow_blueprint_read_paths: true,
3210 allow_blueprint_safe_commands: true,
3211 read_paths: vec!["~/.leviath/runs".to_string()],
3212 credential_store: leviath_core::CredentialStoreKind::Keychain,
3213 allow_blueprint_permissions: false,
3214 shell_env: leviath_core::ShellEnvMode::default(),
3215 shell_env_withhold: Vec::new(),
3216 },
3217 agent_read_paths: HashMap::from([(
3218 "cto".to_string(),
3219 ReadPathGrants {
3220 allow: vec!["glob:~/design-docs/**".to_string()],
3221 },
3222 )]),
3223 };
3224
3225 let serialized = toml::to_string_pretty(&config).unwrap();
3226 let deserialized: Config = toml::from_str(&serialized).unwrap();
3227
3228 assert_eq!(deserialized.default_provider, "anthropic");
3229 assert_eq!(deserialized.limits.max_concurrent_inferences, Some(4));
3230 assert_eq!(deserialized.limits.max_concurrent_tools, 3);
3231 assert_eq!(deserialized.limits.script_shell_timeout_secs, 45);
3232 assert_eq!(deserialized.limits.dead_cycles_before_relief, 6);
3233 assert_eq!(deserialized.limits.finished_retention_secs, 120);
3234 assert_eq!(
3235 deserialized.tool_script_permissions.http_get,
3236 ScriptPermission::Allow
3237 );
3238 assert_eq!(
3239 deserialized.tool_script_permissions.shell,
3240 ScriptPermission::Deny
3241 );
3242 assert_eq!(
3243 deserialized.tool_script_permissions.write_file,
3244 ScriptPermission::Deny
3245 );
3246 assert!(deserialized.batch_tool_hint);
3249 assert!(!deserialized.shell_hint);
3250 assert!(!deserialized.security.allow_seed_commands);
3251 assert!(deserialized.security.allow_blueprint_read_paths);
3252 assert_eq!(deserialized.security.read_paths, vec!["~/.leviath/runs"]);
3253 assert_eq!(
3254 deserialized.agent_read_paths.get("cto"),
3255 Some(&ReadPathGrants {
3256 allow: vec!["glob:~/design-docs/**".to_string()],
3257 })
3258 );
3259 assert_eq!(
3260 deserialized.nudge,
3261 leviath_core::NudgeConfig {
3262 enabled: Some(true),
3263 max: Some(2),
3264 text: Some("Use your tools.".to_string()),
3265 }
3266 );
3267 assert_eq!(deserialized.webhook.max_retries, 5);
3268 assert_eq!(deserialized.webhook.base_delay_ms, 250);
3269 assert_eq!(deserialized.webhook.max_delay_ms, 10_000);
3270 assert_eq!(deserialized.webhook.timeout_secs, 7);
3271 assert!(deserialized.observability.enabled);
3272 assert_eq!(
3273 deserialized.observability.exporter,
3274 TelemetryExporterKind::Stdout
3275 );
3276 assert_eq!(
3277 deserialized.observability.endpoint.as_deref(),
3278 Some("http://collector:4318")
3279 );
3280 assert_eq!(
3281 deserialized.observability.service_name.as_deref(),
3282 Some("leviath-prod")
3283 );
3284 assert_eq!(deserialized.limits.default_max_iterations, Some(99));
3285 assert_eq!(
3286 deserialized.providers.anthropic_api_key.as_deref(),
3287 Some("sk-ant-key")
3288 );
3289 assert_eq!(deserialized.agent_paths.len(), 1);
3290 assert!(deserialized.model_capabilities.contains_key("my-model"));
3291 assert_eq!(
3292 deserialized.tool_permissions.get("bash"),
3293 Some(&ToolPolicy::Allow)
3294 );
3295 assert!(!deserialized.title.enabled);
3296 assert_eq!(deserialized.title.provider.as_deref(), Some("openai"));
3297 let sandbox = deserialized.sandbox.expect("sandbox round-trips");
3298 assert_eq!(sandbox.kind, leviath_core::SandboxKind::Container);
3299 assert_eq!(sandbox.image.as_deref(), Some("ubuntu:24.04"));
3300 assert!(!sandbox.network);
3301 }
3302
3303 #[test]
3306 fn config_multiple_model_capabilities() {
3307 let toml_content = r#"
3308default_provider = "anthropic"
3309agent_paths = []
3310
3311[providers]
3312
3313[model_capabilities."model-a"]
3314supports_temperature = true
3315supports_streaming = true
3316supports_tools = true
3317supports_system_prompt = true
3318max_context_tokens = 8192
3319max_output_tokens = 4096
3320
3321[model_capabilities."model-b"]
3322supports_temperature = false
3323supports_streaming = false
3324supports_tools = false
3325supports_system_prompt = false
3326max_context_tokens = 2048
3327max_output_tokens = 1024
3328"#;
3329 let config: Config = toml::from_str(toml_content).unwrap();
3330 assert_eq!(config.model_capabilities.len(), 2);
3331 let caps_a = config.model_capabilities.get("model-a").unwrap();
3332 assert_eq!(caps_a.supports_temperature, Some(true));
3333 assert_eq!(caps_a.max_context_tokens, Some(8192));
3334 let caps_b = config.model_capabilities.get("model-b").unwrap();
3335 assert_eq!(caps_b.supports_temperature, Some(false));
3336 assert_eq!(caps_b.max_context_tokens, Some(2048));
3337 }
3338}