1use leviath_mcp::MCPServerConfig;
4use leviath_providers::ModelCapabilities;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
14#[serde(rename_all = "snake_case")]
15pub enum ToolPolicy {
16 Allow,
18 #[default]
20 Ask,
21 Deny,
23}
24
25pub use leviath_core::config::TitleConfig;
29
30pub use leviath_core::config::{ObservabilityConfig, TelemetryExporterKind};
33
34#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(rename_all = "snake_case")]
43pub enum ScriptPermission {
44 Allow,
46 Deny,
48 #[default]
55 Inherit,
56}
57
58#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
64pub struct ScriptToolPermissions {
65 #[serde(default)]
67 pub http_get: ScriptPermission,
68 #[serde(default)]
70 pub http_post: ScriptPermission,
71 #[serde(default)]
73 pub shell: ScriptPermission,
74 #[serde(default)]
76 pub read_file: ScriptPermission,
77 #[serde(default)]
79 pub write_file: ScriptPermission,
80 #[serde(default)]
82 pub env_var: ScriptPermission,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct Config {
88 pub default_provider: String,
90
91 pub providers: ProviderConfig,
93
94 pub agent_paths: Vec<PathBuf>,
96
97 pub openrouter_api_key: Option<String>,
99
100 pub ollama_base_url: Option<String>,
102
103 #[serde(default)]
105 pub mcp_servers: Vec<MCPServerConfig>,
106
107 pub default_model: Option<String>,
109
110 #[serde(default)]
113 pub model_capabilities: HashMap<String, ModelCapabilities>,
114
115 #[serde(default)]
122 pub model_providers: HashMap<String, ModelProviderConfig>,
123
124 #[serde(default)]
132 pub tool_permissions: HashMap<String, ToolPolicy>,
133
134 #[serde(default)]
149 pub agent_tool_permissions: HashMap<String, HashMap<String, ToolPolicy>>,
150
151 #[serde(default)]
156 pub title: TitleConfig,
157
158 pub request_timeout_secs: Option<u64>,
165
166 #[serde(default)]
178 pub rate_limits: HashMap<String, leviath_providers::RateLimitConfig>,
179
180 #[serde(default)]
187 pub taint_tracking: bool,
188
189 #[serde(default)]
191 pub limits: LimitsConfig,
192
193 #[serde(default = "default_true")]
203 pub batch_tool_hint: bool,
204
205 #[serde(default = "default_true")]
216 pub shell_hint: bool,
217
218 #[serde(default)]
225 pub nudge: leviath_core::NudgeConfig,
226
227 #[serde(default)]
229 pub webhook: WebhookConfig,
230
231 #[serde(default)]
237 pub observability: ObservabilityConfig,
238
239 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub sandbox: Option<leviath_core::ToolSandboxConfig>,
245
246 #[serde(default)]
249 pub tool_script_permissions: ScriptToolPermissions,
250
251 #[serde(default)]
255 pub security: SecurityConfig,
256
257 #[serde(default)]
271 pub agent_read_paths: HashMap<String, ReadPathGrants>,
272}
273
274#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
276pub struct ReadPathGrants {
277 #[serde(default)]
279 pub allow: Vec<String>,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
287pub struct SecurityConfig {
288 #[serde(default = "default_true")]
299 pub allow_seed_commands: bool,
300
301 #[serde(default)]
317 pub allow_local_network: bool,
318
319 #[serde(default)]
339 pub allow_env_vars: Vec<String>,
340
341 #[serde(default)]
354 pub allow_blueprint_read_paths: bool,
355
356 #[serde(default)]
373 pub read_paths: Vec<String>,
374
375 #[serde(default)]
399 pub credential_store: leviath_core::CredentialStoreKind,
400}
401
402impl Default for SecurityConfig {
403 fn default() -> Self {
404 Self {
405 allow_seed_commands: true,
406 allow_local_network: false,
407 allow_env_vars: Vec::new(),
408 allow_blueprint_read_paths: false,
409 read_paths: Vec::new(),
410 credential_store: leviath_core::CredentialStoreKind::File,
411 }
412 }
413}
414
415fn default_true() -> bool {
416 true
417}
418
419fn default_max_concurrent_inferences() -> Option<usize> {
420 Some(8)
421}
422
423fn default_default_max_iterations() -> Option<usize> {
424 Some(50)
425}
426
427fn default_max_concurrent_tools() -> usize {
428 8
429}
430
431fn default_script_shell_timeout_secs() -> u64 {
432 60
433}
434
435fn default_stall_timeout_secs() -> u64 {
436 leviath_runtime::pipeline::DEFAULT_STALL_TIMEOUT_SECS
437}
438
439fn default_dead_cycles_before_relief() -> u32 {
440 leviath_runtime::host::DEFAULT_DEAD_CYCLES_BEFORE_RELIEF
441}
442
443fn default_finished_retention_secs() -> u64 {
444 leviath_runtime::host::DEFAULT_FINISHED_RETENTION_SECS
445}
446
447fn default_wedge_timeout_secs() -> u64 {
448 leviath_runtime::pipeline::DEFAULT_WEDGE_TIMEOUT_SECS
449}
450
451fn default_provider_failures_before_open() -> u32 {
452 leviath_runtime::pipeline::DEFAULT_FAILURES_BEFORE_OPEN
453}
454
455fn default_provider_circuit_cooldown_secs() -> u64 {
456 leviath_runtime::pipeline::DEFAULT_CIRCUIT_COOLDOWN_SECS
457}
458
459fn default_interaction_timeout_secs() -> u64 {
460 leviath_runtime::interaction_hub::DEFAULT_INTERACTION_TIMEOUT_SECS
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize)]
469pub struct LimitsConfig {
470 #[serde(default = "default_max_concurrent_inferences")]
474 pub max_concurrent_inferences: Option<usize>,
475
476 #[serde(default = "default_max_concurrent_tools")]
481 pub max_concurrent_tools: usize,
482
483 #[serde(default = "default_default_max_iterations")]
487 pub default_max_iterations: Option<usize>,
488
489 #[serde(default)]
497 pub exact_token_counting: bool,
498
499 #[serde(default = "default_script_shell_timeout_secs")]
503 pub script_shell_timeout_secs: u64,
504
505 #[serde(default = "default_stall_timeout_secs")]
516 pub stall_timeout_secs: u64,
517
518 #[serde(default = "default_dead_cycles_before_relief")]
531 pub dead_cycles_before_relief: u32,
532
533 #[serde(default = "default_finished_retention_secs")]
548 pub finished_retention_secs: u64,
549 #[serde(default = "default_wedge_timeout_secs")]
569 pub wedge_timeout_secs: u64,
570 #[serde(default = "default_provider_failures_before_open")]
582 pub provider_failures_before_open: u32,
583
584 #[serde(default = "default_provider_circuit_cooldown_secs")]
593 pub provider_circuit_cooldown_secs: u64,
594 #[serde(default = "default_interaction_timeout_secs")]
612 pub interaction_timeout_secs: u64,
613}
614
615impl Default for LimitsConfig {
616 fn default() -> Self {
617 Self {
618 max_concurrent_inferences: default_max_concurrent_inferences(),
619 max_concurrent_tools: default_max_concurrent_tools(),
620 default_max_iterations: default_default_max_iterations(),
621 exact_token_counting: false,
622 script_shell_timeout_secs: default_script_shell_timeout_secs(),
623 stall_timeout_secs: default_stall_timeout_secs(),
624 dead_cycles_before_relief: default_dead_cycles_before_relief(),
625 finished_retention_secs: default_finished_retention_secs(),
626 wedge_timeout_secs: default_wedge_timeout_secs(),
627 provider_failures_before_open: default_provider_failures_before_open(),
628 provider_circuit_cooldown_secs: default_provider_circuit_cooldown_secs(),
629 interaction_timeout_secs: default_interaction_timeout_secs(),
630 }
631 }
632}
633
634fn default_webhook_max_retries() -> u32 {
635 3
636}
637
638fn default_webhook_base_delay_ms() -> u64 {
639 500
640}
641
642fn default_webhook_max_delay_ms() -> u64 {
643 30_000
644}
645
646fn default_webhook_timeout_secs() -> u64 {
647 10
648}
649
650#[derive(Debug, Clone, Serialize, Deserialize)]
657pub struct WebhookConfig {
658 #[serde(default = "default_webhook_max_retries")]
661 pub max_retries: u32,
662
663 #[serde(default = "default_webhook_base_delay_ms")]
666 pub base_delay_ms: u64,
667
668 #[serde(default = "default_webhook_max_delay_ms")]
671 pub max_delay_ms: u64,
672
673 #[serde(default = "default_webhook_timeout_secs")]
675 pub timeout_secs: u64,
676}
677
678impl Default for WebhookConfig {
679 fn default() -> Self {
680 Self {
681 max_retries: default_webhook_max_retries(),
682 base_delay_ms: default_webhook_base_delay_ms(),
683 max_delay_ms: default_webhook_max_delay_ms(),
684 timeout_secs: default_webhook_timeout_secs(),
685 }
686 }
687}
688
689#[derive(Clone, Serialize, Deserialize)]
693pub struct ProviderConfig {
694 pub anthropic_api_key: Option<String>,
696
697 pub openai_api_key: Option<String>,
699
700 pub google_api_key: Option<String>,
702
703 #[serde(default)]
711 pub claude_code_enabled: bool,
712
713 #[serde(default)]
715 pub claude_code_binary: Option<String>,
716
717 #[serde(default)]
724 pub claude_code_effort: Option<String>,
725
726 #[serde(default)]
738 pub fallback_order: Vec<String>,
739}
740
741impl std::fmt::Debug for ProviderConfig {
752 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
753 f.debug_struct("ProviderConfig")
754 .field("anthropic_api_key", &redacted(&self.anthropic_api_key))
755 .field("openai_api_key", &redacted(&self.openai_api_key))
756 .field("google_api_key", &redacted(&self.google_api_key))
757 .field("claude_code_enabled", &self.claude_code_enabled)
758 .field("claude_code_binary", &self.claude_code_binary)
759 .field("claude_code_effort", &self.claude_code_effort)
760 .field("fallback_order", &self.fallback_order)
761 .finish()
762 }
763}
764
765fn redacted(value: &Option<String>) -> &'static str {
767 match value {
768 Some(_) => "<set>",
769 None => "<unset>",
770 }
771}
772
773#[derive(Debug, Clone, Serialize, Deserialize, Default)]
779pub struct ModelProviderConfig {
780 #[serde(default)]
783 pub script: Option<String>,
784
785 #[serde(default)]
788 pub api_key: Option<String>,
789
790 #[serde(default)]
792 pub base_url: Option<String>,
793
794 #[serde(default)]
796 pub rate_limit: Option<leviath_providers::RateLimitConfig>,
797
798 #[serde(flatten)]
800 pub extra: HashMap<String, toml::Value>,
801}
802
803impl Default for Config {
804 fn default() -> Self {
805 Self {
806 default_provider: "anthropic".to_string(),
807 providers: ProviderConfig {
808 anthropic_api_key: None,
809 openai_api_key: None,
810 google_api_key: None,
811 claude_code_enabled: false,
812 claude_code_binary: None,
813 claude_code_effort: None,
814 fallback_order: Vec::new(),
815 },
816 agent_paths: Vec::new(),
817 openrouter_api_key: None,
818 ollama_base_url: None,
819 mcp_servers: Vec::new(),
820 default_model: None,
821 model_capabilities: HashMap::new(),
822 model_providers: HashMap::new(),
823 tool_permissions: HashMap::new(),
824 agent_tool_permissions: HashMap::new(),
825 title: TitleConfig::default(),
826 request_timeout_secs: None,
827 rate_limits: HashMap::new(),
828 taint_tracking: false,
829 limits: LimitsConfig::default(),
830 batch_tool_hint: true,
831 shell_hint: true,
832 nudge: leviath_core::NudgeConfig::default(),
833 webhook: WebhookConfig::default(),
834 observability: ObservabilityConfig::default(),
835 sandbox: None,
836 tool_script_permissions: ScriptToolPermissions::default(),
837 security: SecurityConfig::default(),
838 agent_read_paths: HashMap::new(),
839 }
840 }
841}
842
843impl Config {
844 pub fn permissions_for_agent(&self, agent_name: &str) -> HashMap<String, ToolPolicy> {
852 let mut merged = self.tool_permissions.clone();
853 if let Some(per_agent) = self.agent_tool_permissions.get(agent_name) {
854 merged.extend(per_agent.iter().map(|(k, v)| (k.clone(), *v)));
855 }
856 merged
857 }
858
859 pub fn read_path_grants_for_agent(&self, agent_name: &str) -> Vec<String> {
864 let mut grants = self.security.read_paths.clone();
865 if let Some(per_agent) = self.agent_read_paths.get(agent_name) {
866 grants.extend(per_agent.allow.iter().cloned());
867 }
868 grants
869 }
870
871 pub fn load() -> anyhow::Result<Self> {
876 #[cfg(test)]
892 assert!(
893 std::env::var_os("LEVIATH_CONFIG_PATH").is_some(),
894 "Config::load() reached from a test that has not isolated the \
895 environment. Wrap the test in `config::with_isolated_config_path` \
896 (or `..._async`), which both points this at a scratch config and \
897 takes the same process-wide lock every other env-touching test \
898 holds. Without it this test races them and fails intermittently, \
899 somewhere else."
900 );
901
902 if std::env::var_os("LEVIATH_SKIP_DOTENV").is_none() {
920 let _ = dotenvy::from_filename(".env");
921 }
922
923 let config = Self::load_from_path(&Self::config_path())?;
924
925 check_permissions();
927
928 Ok(config)
929 }
930
931 fn load_from_path(path: &std::path::Path) -> anyhow::Result<Self> {
934 let mut config = if !path.exists() {
935 let path_display = path.display();
936 tracing::debug!("No config file found at {}, using defaults", path_display);
937 Self::default()
938 } else {
939 let content = std::fs::read_to_string(path).map_err(|e| {
940 anyhow::anyhow!("Failed to read config from '{}': {}", path.display(), e)
941 })?;
942
943 let c: Self = toml::from_str(&content)
944 .map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e))?;
945
946 for server in &c.mcp_servers {
950 server.validate()?;
951 }
952
953 let path_display = path.display();
954 tracing::debug!("Loaded config from {}", path_display);
955 c
956 };
957
958 if config.providers.anthropic_api_key.is_none() {
960 config.providers.anthropic_api_key = std::env::var("ANTHROPIC_API_KEY").ok();
961 }
962 if config.providers.openai_api_key.is_none() {
963 config.providers.openai_api_key = std::env::var("OPENAI_API_KEY").ok();
964 }
965 if config.providers.google_api_key.is_none() {
966 config.providers.google_api_key = std::env::var("GOOGLE_API_KEY").ok();
967 }
968 if config.openrouter_api_key.is_none() {
969 config.openrouter_api_key = std::env::var("OPENROUTER_API_KEY").ok();
970 }
971 if config.ollama_base_url.is_none() {
973 config.ollama_base_url = std::env::var("OLLAMA_HOST").ok();
974 }
975
976 config.fill_from_credential_store();
977
978 Ok(config)
979 }
980
981 fn fill_from_credential_store(&mut self) {
983 let resolved = crate::credentials::store_for(self.security.credential_store);
984 self.fill_from_credential_store_with(resolved);
985 }
986
987 fn fill_from_credential_store_with(&mut self, resolved: crate::credentials::Resolved) {
1005 match resolved {
1006 Ok(Some(store)) => self.apply_credential_store(store.as_ref()),
1007 Ok(None) => {}
1009 Err(e) => {
1010 tracing::warn!("{e}. Falling back to keys from the config file and environment.");
1011 }
1012 }
1013 }
1014
1015 fn apply_credential_store(&mut self, store: &dyn leviath_core::CredentialStore) {
1017 let accounts: Vec<String> = crate::credentials::PROVIDER_KEYS
1018 .iter()
1019 .map(|p| leviath_core::provider_account(p))
1020 .collect();
1021 let mut found = store.read_all(&accounts);
1022 let mut take = |provider: &str| found.remove(&leviath_core::provider_account(provider));
1023
1024 let anthropic = take("anthropic");
1025 let openai = take("openai");
1026 let google = take("google");
1027 let openrouter = take("openrouter");
1028
1029 self.providers.anthropic_api_key = self.providers.anthropic_api_key.take().or(anthropic);
1030 self.providers.openai_api_key = self.providers.openai_api_key.take().or(openai);
1031 self.providers.google_api_key = self.providers.google_api_key.take().or(google);
1032 self.openrouter_api_key = self.openrouter_api_key.take().or(openrouter);
1033 }
1034
1035 fn without_secrets(&self) -> Self {
1043 let mut copy = self.clone();
1044 copy.providers.anthropic_api_key = None;
1045 copy.providers.openai_api_key = None;
1046 copy.providers.google_api_key = None;
1047 copy.openrouter_api_key = None;
1048 copy
1049 }
1050
1051 pub(crate) fn provider_secrets(&self) -> Vec<(String, String)> {
1053 [
1054 ("anthropic", self.providers.anthropic_api_key.as_deref()),
1055 ("openai", self.providers.openai_api_key.as_deref()),
1056 ("google", self.providers.google_api_key.as_deref()),
1057 ("openrouter", self.openrouter_api_key.as_deref()),
1058 ]
1059 .into_iter()
1060 .filter_map(|(name, key)| {
1061 key.map(|k| (leviath_core::provider_account(name), k.to_string()))
1062 })
1063 .collect()
1064 }
1065
1066 pub(crate) fn save_to_path(&self, path: &std::path::Path) -> anyhow::Result<()> {
1071 if let Some(parent) = path.parent() {
1073 create_config_dir(parent)?;
1074 }
1075
1076 let resolved = crate::credentials::store_for(self.security.credential_store);
1084 self.write_to(path, resolved)
1085 }
1086
1087 fn write_to(
1092 &self,
1093 path: &std::path::Path,
1094 resolved: crate::credentials::Resolved,
1095 ) -> anyhow::Result<()> {
1096 let to_write = match resolved.map_err(|e| anyhow::anyhow!("{e}"))? {
1097 Some(store) => {
1098 for (account, secret) in self.provider_secrets() {
1099 store
1100 .set(&account, &secret)
1101 .map_err(|e| anyhow::anyhow!("failed to store {account}: {e}"))?;
1102 }
1103 self.without_secrets()
1104 }
1105 None => self.clone(),
1106 };
1107
1108 let content =
1110 toml::to_string_pretty(&to_write).expect("Config serialization is infallible");
1111
1112 leviath_sys::write_private(path, content.as_bytes()).map_err(|e| {
1117 anyhow::anyhow!("Failed to write config to '{}': {}", path.display(), e)
1118 })?;
1119
1120 let path_display = path.display();
1121 tracing::debug!("Saved config to {}", path_display);
1122 Ok(())
1123 }
1124
1125 pub fn load_from_path_public(path: &std::path::Path) -> anyhow::Result<Self> {
1128 Self::load_from_path(path)
1129 }
1130
1131 pub fn save_to_path_public(&self, path: &std::path::Path) -> anyhow::Result<()> {
1133 self.save_to_path(path)
1134 }
1135
1136 pub fn config_path() -> PathBuf {
1150 if let Ok(override_path) = std::env::var("LEVIATH_CONFIG_PATH") {
1151 return PathBuf::from(override_path);
1152 }
1153 leviath_core::data_dir()
1154 .unwrap_or_default()
1155 .join("config.toml")
1156 }
1157
1158 pub fn validate_keys(&self) -> Vec<String> {
1163 let mut warnings = Vec::new();
1168 if let Some(key) = self.providers.anthropic_api_key.as_deref()
1169 && !key.trim().is_empty()
1170 && !key.starts_with("sk-ant-")
1171 {
1172 warnings.push(
1173 "Anthropic API key doesn't start with 'sk-ant-' - verify it's correct".to_string(),
1174 );
1175 }
1176 if let Some(key) = self.providers.openai_api_key.as_deref()
1177 && !key.trim().is_empty()
1178 && !key.starts_with("sk-")
1179 {
1180 warnings
1181 .push("OpenAI API key doesn't start with 'sk-' - verify it's correct".to_string());
1182 }
1183 warnings
1184 }
1185}
1186
1187pub use leviath_core::paths::home_dir as leviath_home_dir;
1194pub use leviath_core::paths::providers_dir;
1195
1196fn create_config_dir(dir: &std::path::Path) -> anyhow::Result<()> {
1198 std::fs::create_dir_all(dir)
1199 .map_err(|e| anyhow::anyhow!("Failed to create config directory: {}", e))?;
1200 set_dir_permissions(dir);
1201 Ok(())
1202}
1203
1204fn check_permissions() {
1208 check_permissions_at(&Config::config_path());
1209}
1210
1211fn check_permissions_at(path: &std::path::Path) {
1217 check_permissions_at_with(path, leviath_sys::ensure_file_private);
1218}
1219
1220fn check_permissions_at_with(
1228 path: &std::path::Path,
1229 ensure: fn(&std::path::Path) -> std::io::Result<Option<u32>>,
1230) {
1231 match ensure(path) {
1232 Ok(Some(old_mode)) => {
1233 let masked_mode = old_mode & 0o777;
1234 tracing::warn!(
1235 "Config file has overly permissive permissions ({:o}), fixing to 600",
1236 masked_mode
1237 );
1238 }
1239 Ok(None) => {}
1240 Err(e) => tracing::warn!("Failed to fix config file permissions: {}", e),
1241 }
1242}
1243
1244fn set_dir_permissions(path: &std::path::Path) {
1246 set_dir_permissions_with(path, leviath_sys::secure_dir_perms);
1247}
1248
1249fn set_dir_permissions_with(
1252 path: &std::path::Path,
1253 secure: fn(&std::path::Path) -> std::io::Result<()>,
1254) {
1255 if let Err(e) = secure(path) {
1256 tracing::warn!("Failed to set config directory permissions: {}", e);
1257 }
1258}
1259
1260#[cfg(test)]
1269pub(crate) static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1270
1271#[cfg(test)]
1291pub(crate) struct CwdTestGuard {
1292 original_cwd: std::path::PathBuf,
1293 _lock: std::sync::MutexGuard<'static, ()>,
1294}
1295
1296#[cfg(test)]
1297impl Drop for CwdTestGuard {
1298 fn drop(&mut self) {
1299 let _ = std::env::set_current_dir(&self.original_cwd);
1300 }
1301}
1302
1303#[cfg(test)]
1306pub(crate) fn isolate_cwd_for_test() -> CwdTestGuard {
1307 let lock = CWD_LOCK
1308 .lock()
1309 .unwrap_or_else(std::sync::PoisonError::into_inner);
1310 let original_cwd = std::env::current_dir().expect("current dir must be readable at test start");
1311 CwdTestGuard {
1312 original_cwd,
1313 _lock: lock,
1314 }
1315}
1316
1317#[cfg(test)]
1322const PROVIDER_KEY_ENV_VARS: &[&str] = &[
1323 "ANTHROPIC_API_KEY",
1324 "OPENAI_API_KEY",
1325 "GOOGLE_API_KEY",
1326 "OPENROUTER_API_KEY",
1327];
1328
1329#[cfg(test)]
1331fn make_fake_config_dir(unique: &str) -> std::path::PathBuf {
1332 let fake_dir = std::env::temp_dir().join(format!("lev-fake-config-{unique}"));
1333 let _ = std::fs::create_dir_all(&fake_dir);
1334 fake_dir
1335}
1336
1337#[cfg(test)]
1349pub(crate) fn config_isolation_vars(
1350 fake_dir: &std::path::Path,
1351) -> Vec<(&'static str, Option<std::ffi::OsString>)> {
1352 let mut vars: Vec<(&'static str, Option<std::ffi::OsString>)> = vec![
1353 (
1354 "LEVIATH_CONFIG_PATH",
1355 Some(fake_dir.join("config.toml").into_os_string()),
1356 ),
1357 ("LEVIATH_SKIP_DOTENV", Some(std::ffi::OsString::from("1"))),
1358 ];
1359 for &key in PROVIDER_KEY_ENV_VARS {
1360 vars.push((key, None));
1361 }
1362 vars
1363}
1364
1365#[cfg(test)]
1373pub(crate) fn with_isolated_config_path<R>(
1374 unique: &str,
1375 f: impl FnOnce(&std::path::Path) -> R,
1376) -> R {
1377 let fake_dir = make_fake_config_dir(unique);
1378 let result = temp_env::with_vars(config_isolation_vars(&fake_dir), || f(&fake_dir));
1379 let _ = std::fs::remove_dir_all(&fake_dir);
1380 result
1381}
1382
1383#[cfg(test)]
1386pub(crate) async fn with_isolated_config_path_async<R, Fut>(
1387 unique: &str,
1388 f: impl FnOnce(std::path::PathBuf) -> Fut,
1389) -> R
1390where
1391 Fut: std::future::Future<Output = R>,
1392{
1393 let fake_dir = make_fake_config_dir(unique);
1394 let result =
1395 temp_env::async_with_vars(config_isolation_vars(&fake_dir), f(fake_dir.clone())).await;
1396 let _ = std::fs::remove_dir_all(&fake_dir);
1397 result
1398}
1399
1400#[cfg(test)]
1401mod dotenv_tests {
1402 use super::*;
1403
1404 #[test]
1415 fn a_dot_env_in_the_working_directory_is_read() {
1416 let dir = make_fake_config_dir("dotenv-read");
1417 std::fs::write(dir.join(".env"), "LEV_DOTENV_PROBE=seen\n").unwrap();
1418
1419 {
1423 let _cwd = isolate_cwd_for_test();
1424 std::env::set_current_dir(&dir).unwrap();
1425
1426 temp_env::with_vars(
1427 [
1428 (
1429 "LEVIATH_CONFIG_PATH",
1430 Some(dir.join("config.toml").into_os_string()),
1431 ),
1432 ("LEVIATH_SKIP_DOTENV", None),
1433 ("LEV_DOTENV_PROBE", None),
1434 ],
1435 || {
1436 let loaded = Config::load();
1437 assert!(loaded.is_ok(), "a missing config file is not an error");
1438 assert_eq!(
1439 std::env::var("LEV_DOTENV_PROBE").ok().as_deref(),
1440 Some("seen"),
1441 "the .env beside the working directory was read"
1442 );
1443 },
1444 );
1445 }
1446 let _ = std::fs::remove_dir_all(&dir);
1447 }
1448}
1449
1450#[cfg(test)]
1451mod tests {
1452 #[test]
1456 fn saving_with_an_unreachable_keychain_writes_nothing() {
1457 let dir = tempfile::tempdir().unwrap();
1458 let path = dir.path().join("config.toml");
1459 let mut config = Config::default();
1460 config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1461 config.providers.anthropic_api_key = Some("sk-ant".to_string());
1462
1463 assert!(
1464 config
1465 .write_to(&path, Err("no keychain".to_string()))
1466 .is_err()
1467 );
1468 assert!(!path.exists(), "no file may be written at all");
1469 }
1470
1471 #[test]
1473 fn saving_to_a_store_that_refuses_the_write_writes_nothing() {
1474 use leviath_core::CredentialStore as _;
1475
1476 struct Refuses;
1477 impl leviath_core::CredentialStore for Refuses {
1478 fn get(&self, _: &str) -> Result<Option<String>, String> {
1479 Ok(None)
1480 }
1481 fn set(&self, _: &str, _: &str) -> Result<(), String> {
1482 Err("read-only keychain".to_string())
1483 }
1484 fn delete(&self, _: &str) -> Result<bool, String> {
1485 Err("read-only keychain".to_string())
1486 }
1487 }
1488
1489 let dir = tempfile::tempdir().unwrap();
1490 let path = dir.path().join("config.toml");
1491 let mut config = Config::default();
1492 config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1493 config.providers.anthropic_api_key = Some("sk-ant".to_string());
1494
1495 assert_eq!(Refuses.get("provider/anthropic").unwrap(), None);
1498 assert!(Refuses.delete("provider/anthropic").is_err());
1499
1500 let err = config
1501 .write_to(&path, Ok(Some(Box::new(Refuses))))
1502 .expect_err("a refused write is not a save");
1503 assert!(err.to_string().contains("failed to store"), "{err}");
1504 assert!(!path.exists(), "no file may be written at all");
1505 }
1506
1507 #[test]
1510 fn saving_in_keychain_mode_puts_the_secrets_in_the_store_not_the_file() {
1511 use leviath_core::CredentialStore;
1512
1513 let dir = tempfile::tempdir().unwrap();
1514 let path = dir.path().join("config.toml");
1515 let mut config = Config::default();
1516 config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1517 config.providers.anthropic_api_key = Some("sk-ant-secret".to_string());
1518 config.default_model = Some("some-model".to_string());
1519
1520 let store = std::sync::Arc::new(leviath_core::MemoryStore::new());
1521 struct Shared(std::sync::Arc<leviath_core::MemoryStore>);
1522 impl CredentialStore for Shared {
1523 fn get(&self, a: &str) -> Result<Option<String>, String> {
1524 self.0.get(a)
1525 }
1526 fn set(&self, a: &str, s: &str) -> Result<(), String> {
1527 self.0.set(a, s)
1528 }
1529 fn delete(&self, a: &str) -> Result<bool, String> {
1530 self.0.delete(a)
1531 }
1532 }
1533
1534 config
1535 .write_to(&path, Ok(Some(Box::new(Shared(store.clone())))))
1536 .unwrap();
1537
1538 assert!(
1540 Shared(store.clone())
1541 .delete(&leviath_core::provider_account("anthropic"))
1542 .unwrap()
1543 );
1544 store
1545 .set(
1546 &leviath_core::provider_account("anthropic"),
1547 "sk-ant-secret",
1548 )
1549 .unwrap();
1550
1551 let written = std::fs::read_to_string(&path).unwrap();
1552 assert!(!written.contains("sk-ant-secret"), "{written}");
1553 assert!(
1554 written.contains("some-model"),
1555 "settings survive: {written}"
1556 );
1557 assert_eq!(
1560 Shared(store.clone())
1561 .get(&leviath_core::provider_account("anthropic"))
1562 .unwrap()
1563 .as_deref(),
1564 Some("sk-ant-secret")
1565 );
1566 }
1567
1568 #[test]
1571 fn the_credential_store_fills_only_the_keys_that_are_unset() {
1572 use leviath_core::{CredentialStore, MemoryStore};
1573
1574 let store = MemoryStore::new();
1575 store
1576 .set(
1577 &leviath_core::provider_account("anthropic"),
1578 "from-keychain",
1579 )
1580 .unwrap();
1581 store
1582 .set(&leviath_core::provider_account("openai"), "openai-keychain")
1583 .unwrap();
1584 store
1585 .set(&leviath_core::provider_account("google"), "google-keychain")
1586 .unwrap();
1587 store
1588 .set(&leviath_core::provider_account("openrouter"), "or-keychain")
1589 .unwrap();
1590
1591 let mut config = Config::default();
1592 config.providers.anthropic_api_key = Some("from-file".to_string());
1594 config.apply_credential_store(&store);
1595
1596 assert_eq!(
1597 config.providers.anthropic_api_key.as_deref(),
1598 Some("from-file"),
1599 "an existing key wins over the keychain"
1600 );
1601 assert_eq!(
1602 config.providers.openai_api_key.as_deref(),
1603 Some("openai-keychain")
1604 );
1605 assert_eq!(
1606 config.providers.google_api_key.as_deref(),
1607 Some("google-keychain")
1608 );
1609 assert_eq!(config.openrouter_api_key.as_deref(), Some("or-keychain"));
1610 }
1611
1612 #[test]
1614 fn an_empty_credential_store_changes_nothing() {
1615 let mut config = Config::default();
1616 config.providers.openai_api_key = Some("keep-me".to_string());
1617 config.apply_credential_store(&leviath_core::MemoryStore::new());
1618 assert_eq!(config.providers.openai_api_key.as_deref(), Some("keep-me"));
1619 assert!(config.providers.anthropic_api_key.is_none());
1620 }
1621
1622 #[test]
1627 fn an_unreachable_credential_store_does_not_stop_the_config_loading() {
1628 use leviath_core::{CredentialStore, MemoryStore};
1629
1630 let mut config = Config::default();
1631 config.fill_from_credential_store_with(Err("no keychain here".to_string()));
1632 assert!(config.providers.anthropic_api_key.is_none());
1633
1634 let mut config = Config::default();
1636 config.providers.openai_api_key = Some("k".to_string());
1637 config.fill_from_credential_store_with(Ok(None));
1638 assert_eq!(config.providers.openai_api_key.as_deref(), Some("k"));
1639
1640 let store = MemoryStore::new();
1642 store
1643 .set(&leviath_core::provider_account("anthropic"), "filled")
1644 .unwrap();
1645 let mut config = Config::default();
1646 config.fill_from_credential_store_with(Ok(Some(Box::new(store))));
1647 assert_eq!(
1648 config.providers.anthropic_api_key.as_deref(),
1649 Some("filled")
1650 );
1651 }
1652
1653 #[test]
1654 fn provider_secrets_lists_every_set_key_and_nothing_else() {
1655 let mut config = Config::default();
1656 assert!(config.provider_secrets().is_empty());
1657
1658 config.providers.anthropic_api_key = Some("a".to_string());
1659 config.openrouter_api_key = Some("o".to_string());
1660 let secrets = config.provider_secrets();
1661 assert_eq!(secrets.len(), 2);
1662 assert!(secrets.contains(&("provider/anthropic".to_string(), "a".to_string())));
1663 assert!(secrets.contains(&("provider/openrouter".to_string(), "o".to_string())));
1664 }
1665
1666 #[test]
1670 fn without_secrets_strips_a_copy_and_leaves_the_original_usable() {
1671 let mut config = Config::default();
1672 config.providers.anthropic_api_key = Some("a".to_string());
1673 config.providers.openai_api_key = Some("b".to_string());
1674 config.providers.google_api_key = Some("c".to_string());
1675 config.openrouter_api_key = Some("d".to_string());
1676 config.default_model = Some("m".to_string());
1677
1678 let stripped = config.without_secrets();
1679 assert!(stripped.provider_secrets().is_empty(), "no keys survive");
1680 assert_eq!(stripped.default_model.as_deref(), Some("m"), "settings do");
1681 assert_eq!(
1682 config.providers.anthropic_api_key.as_deref(),
1683 Some("a"),
1684 "the original is untouched"
1685 );
1686 }
1687
1688 use super::*;
1689 use crate::test_support::with_tracing;
1690
1691 #[test]
1694 fn leviath_home_dir_uses_override_when_set() {
1695 temp_env::with_var(
1696 "LEVIATH_HOME",
1697 Some("/tmp/leviath-home-override-test"),
1698 || {
1699 assert_eq!(
1700 leviath_home_dir(),
1701 Some(std::path::PathBuf::from("/tmp/leviath-home-override-test"))
1702 );
1703 },
1704 );
1705 }
1706
1707 #[test]
1708 fn leviath_home_dir_falls_back_to_dirs_home_dir_when_unset() {
1709 temp_env::with_var_unset("LEVIATH_HOME", || {
1710 assert_eq!(leviath_home_dir(), dirs::home_dir());
1711 });
1712 }
1713
1714 #[test]
1717 fn load_from_path_missing_file_returns_defaults() {
1718 let dir = tempfile::tempdir().unwrap();
1719 let path = dir.path().join("config.toml");
1720 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1721 assert_eq!(config.default_provider, "anthropic");
1722 }
1723
1724 #[test]
1725 fn load_from_path_valid_toml_is_parsed() {
1726 let dir = tempfile::tempdir().unwrap();
1727 let path = dir.path().join("config.toml");
1728 let original = Config {
1729 default_provider: "openai".to_string(),
1730 ..Config::default()
1731 };
1732 std::fs::write(&path, toml::to_string_pretty(&original).unwrap()).unwrap();
1733 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1734 assert_eq!(config.default_provider, "openai");
1735 }
1736
1737 #[test]
1738 fn limits_default_to_bounded_values() {
1739 let limits = LimitsConfig::default();
1740 assert_eq!(limits.max_concurrent_inferences, Some(8));
1741 assert_eq!(limits.default_max_iterations, Some(50));
1742 assert!(!limits.exact_token_counting);
1744 assert_eq!(limits.dead_cycles_before_relief, 10);
1747 assert_eq!(limits.finished_retention_secs, 300);
1750 assert_eq!(limits.interaction_timeout_secs, 3600);
1753 assert_eq!(Config::default().limits.max_concurrent_inferences, Some(8));
1755 }
1756
1757 #[test]
1760 fn interaction_timeout_defaults_and_parses() {
1761 let dir = tempfile::tempdir().unwrap();
1762 let load = |body: String| {
1763 let path = dir.path().join(format!("{}.toml", body.len()));
1764 std::fs::write(&path, body).unwrap();
1765 with_tracing(|| Config::load_from_path(&path)).unwrap()
1766 };
1767
1768 let old = load(format!(
1769 "{}\n[limits]\nmax_concurrent_tools = 4\n",
1770 config_toml_without_limits()
1771 ));
1772 assert_eq!(old.limits.interaction_timeout_secs, 3600);
1773
1774 let disabled = load(format!(
1775 "{}\n[limits]\ninteraction_timeout_secs = 0\n",
1776 config_toml_without_limits()
1777 ));
1778 assert_eq!(disabled.limits.interaction_timeout_secs, 0);
1779 }
1780
1781 #[test]
1782 fn exact_token_counting_parses_when_set() {
1783 let dir = tempfile::tempdir().unwrap();
1784 let path = dir.path().join("config.toml");
1785 let body = format!(
1786 "{}\n[limits]\nexact_token_counting = true\n",
1787 config_toml_without_limits()
1788 );
1789 std::fs::write(&path, body).unwrap();
1790 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1791 assert!(config.limits.exact_token_counting);
1792 assert_eq!(config.limits.max_concurrent_inferences, Some(8));
1794 }
1795
1796 #[cfg(test)]
1800 fn config_toml_without_limits() -> String {
1801 let full = toml::to_string_pretty(&Config::default()).unwrap();
1802 format!("{}\n", full.split("[limits]").next().unwrap().trim_end())
1803 }
1804
1805 #[test]
1806 fn limits_absent_section_uses_defaults() {
1807 let dir = tempfile::tempdir().unwrap();
1809 let path = dir.path().join("config.toml");
1810 std::fs::write(&path, config_toml_without_limits()).unwrap();
1811 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1812 assert_eq!(config.limits.max_concurrent_inferences, Some(8));
1813 assert_eq!(config.limits.default_max_iterations, Some(50));
1814 assert_eq!(config.limits.dead_cycles_before_relief, 10);
1815 assert_eq!(config.limits.finished_retention_secs, 300);
1816 assert_eq!(config.limits.wedge_timeout_secs, 0);
1819 }
1820
1821 #[test]
1822 fn the_wedge_watchdog_is_off_until_it_is_configured() {
1823 let dir = tempfile::tempdir().unwrap();
1824 let path = dir.path().join("config.toml");
1825 let body = format!(
1826 "{}\n[limits]\nwedge_timeout_secs = 300\n",
1827 config_toml_without_limits()
1828 );
1829 std::fs::write(&path, body).unwrap();
1830 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1831 assert_eq!(config.limits.wedge_timeout_secs, 300);
1832 assert_eq!(config.limits.stall_timeout_secs, 60);
1834 }
1835
1836 #[test]
1837 fn limits_partial_section_fills_the_other_default() {
1838 let dir = tempfile::tempdir().unwrap();
1840 let path = dir.path().join("config.toml");
1841 let body = format!(
1842 "{}\n[limits]\nmax_concurrent_inferences = 3\n",
1843 config_toml_without_limits()
1844 );
1845 std::fs::write(&path, body).unwrap();
1846 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1847 assert_eq!(config.limits.max_concurrent_inferences, Some(3));
1848 assert_eq!(config.limits.default_max_iterations, Some(50));
1849 }
1850
1851 #[test]
1852 fn load_from_path_existing_provider_keys_skip_env_fallback() {
1853 let unset: Vec<(&str, Option<&str>)> = PROVIDER_KEY_ENV_VARS
1861 .iter()
1862 .chain(["OLLAMA_HOST"].iter())
1863 .map(|&key| (key, None))
1864 .collect();
1865 temp_env::with_vars(unset, || {
1866 let dir = tempfile::tempdir().unwrap();
1867 let path = dir.path().join("config.toml");
1868 std::fs::write(
1869 &path,
1870 r#"
1871default_provider = "anthropic"
1872openrouter_api_key = "sk-or-existing"
1873ollama_base_url = "http://existing-ollama:11434"
1874agent_paths = []
1875
1876[providers]
1877anthropic_api_key = "sk-ant-existing"
1878openai_api_key = "sk-openai-existing"
1879google_api_key = "AIza-existing"
1880"#,
1881 )
1882 .unwrap();
1883
1884 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1885
1886 assert_eq!(
1887 config.providers.anthropic_api_key.as_deref(),
1888 Some("sk-ant-existing")
1889 );
1890 assert_eq!(
1891 config.providers.openai_api_key.as_deref(),
1892 Some("sk-openai-existing")
1893 );
1894 assert_eq!(
1895 config.providers.google_api_key.as_deref(),
1896 Some("AIza-existing")
1897 );
1898 assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-existing"));
1899 assert_eq!(
1900 config.ollama_base_url.as_deref(),
1901 Some("http://existing-ollama:11434")
1902 );
1903 });
1904 }
1905
1906 #[test]
1907 fn load_from_path_malformed_toml_returns_error() {
1908 let dir = tempfile::tempdir().unwrap();
1909 let path = dir.path().join("config.toml");
1910 std::fs::write(&path, "not valid toml [[[").unwrap();
1911 let result = Config::load_from_path(&path);
1912 assert!(result.is_err());
1913 assert!(result.unwrap_err().to_string().contains("Failed to parse"));
1914 }
1915
1916 #[test]
1917 fn load_from_path_unreadable_path_returns_error() {
1918 let dir = tempfile::tempdir().unwrap();
1920 let result = Config::load_from_path(dir.path());
1921 assert!(result.is_err());
1922 }
1923
1924 #[test]
1925 fn save_to_path_writes_valid_toml_that_round_trips() {
1926 let dir = tempfile::tempdir().unwrap();
1927 let path = dir.path().join("nested").join("config.toml");
1928 let config = Config {
1929 default_provider: "google".to_string(),
1930 ..Config::default()
1931 };
1932 with_tracing(|| config.save_to_path(&path)).unwrap();
1933
1934 let loaded = with_tracing(|| Config::load_from_path(&path)).unwrap();
1935 assert_eq!(loaded.default_provider, "google");
1936 }
1937
1938 #[test]
1939 fn save_to_path_creates_parent_directory() {
1940 let dir = tempfile::tempdir().unwrap();
1941 let path = dir.path().join("a").join("b").join("config.toml");
1942 let config = Config::default();
1943 with_tracing(|| config.save_to_path(&path)).unwrap();
1944 assert!(path.exists());
1945 }
1946
1947 #[test]
1948 fn save_to_path_with_no_parent_skips_create_config_dir() {
1949 let result = Config::default().save_to_path(&std::path::PathBuf::from(""));
1956 assert!(result.is_err());
1957 }
1958
1959 #[cfg(unix)]
1960 #[test]
1961 fn save_to_path_sets_restrictive_file_permissions() {
1962 use std::os::unix::fs::PermissionsExt;
1963 let dir = tempfile::tempdir().unwrap();
1964 let path = dir.path().join("config.toml");
1965 with_tracing(|| Config::default().save_to_path(&path)).unwrap();
1966 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1967 assert_eq!(mode & 0o777, 0o600);
1968 }
1969
1970 #[test]
1971 fn save_to_path_write_failure_returns_error() {
1972 let dir = tempfile::tempdir().unwrap();
1977 let path = dir.path().join("config.toml");
1978 std::fs::create_dir_all(&path).unwrap();
1979
1980 let result = Config::default().save_to_path(&path);
1981
1982 assert!(result.is_err());
1983 assert!(
1984 result
1985 .unwrap_err()
1986 .to_string()
1987 .contains("Failed to write config")
1988 );
1989 }
1990
1991 #[test]
1992 fn save_to_path_create_config_dir_failure_returns_error() {
1993 let dir = tempfile::tempdir().unwrap();
1994 let blocking_file = dir.path().join("not-a-dir");
1995 std::fs::write(&blocking_file, "").unwrap();
1996 let path = blocking_file.join("config.toml");
1997 let result = Config::default().save_to_path(&path);
1998 assert!(result.is_err());
1999 assert!(
2000 result
2001 .unwrap_err()
2002 .to_string()
2003 .contains("Failed to create config directory")
2004 );
2005 }
2006
2007 #[test]
2008 fn load_propagates_error_when_real_config_file_is_malformed() {
2009 with_isolated_config_path("load-malformed", |fake_dir| {
2014 std::fs::write(fake_dir.join("config.toml"), "not valid toml [[[").unwrap();
2015
2016 let result = Config::load();
2017
2018 assert!(result.is_err());
2019 });
2020 }
2021
2022 #[cfg(unix)]
2025 #[test]
2026 fn check_permissions_at_missing_file_is_noop() {
2027 let dir = tempfile::tempdir().unwrap();
2028 let path = dir.path().join("nonexistent.toml");
2029 check_permissions_at(&path); }
2031
2032 #[cfg(unix)]
2033 #[test]
2034 fn check_permissions_at_fixes_overly_permissive_file() {
2035 use std::os::unix::fs::PermissionsExt;
2036 let dir = tempfile::tempdir().unwrap();
2037 let path = dir.path().join("config.toml");
2038 std::fs::write(&path, "").unwrap();
2039 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2040
2041 with_tracing(|| check_permissions_at(&path));
2042
2043 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2044 assert_eq!(mode & 0o777, 0o600);
2045 }
2046
2047 #[cfg(unix)]
2048 #[test]
2049 fn check_permissions_at_leaves_already_restrictive_file_alone() {
2050 use std::os::unix::fs::PermissionsExt;
2051 let dir = tempfile::tempdir().unwrap();
2052 let path = dir.path().join("config.toml");
2053 std::fs::write(&path, "").unwrap();
2054 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2055
2056 check_permissions_at(&path);
2057
2058 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2059 assert_eq!(mode & 0o777, 0o600);
2060 }
2061
2062 #[test]
2070 fn check_permissions_at_with_logs_when_fix_fails() {
2071 fn ensure_fails(_: &std::path::Path) -> std::io::Result<Option<u32>> {
2072 Err(std::io::Error::other("simulated chmod failure"))
2073 }
2074 with_tracing(|| {
2076 check_permissions_at_with(std::path::Path::new("/does/not/matter"), ensure_fails)
2077 });
2078 }
2079
2080 #[test]
2081 fn check_permissions_at_with_logs_when_file_is_permissive() {
2082 fn ensure_permissive(_: &std::path::Path) -> std::io::Result<Option<u32>> {
2083 Ok(Some(0o100644))
2084 }
2085 with_tracing(|| {
2086 check_permissions_at_with(std::path::Path::new("/does/not/matter"), ensure_permissive)
2087 });
2088 }
2089
2090 fn always_failing_secure(_path: &std::path::Path) -> std::io::Result<()> {
2096 Err(std::io::Error::other(
2097 "simulated permission-hardening failure",
2098 ))
2099 }
2100
2101 #[test]
2102 fn set_dir_permissions_error_branch_logs_not_panics() {
2103 with_tracing(|| {
2104 set_dir_permissions_with(
2105 std::path::Path::new("/does/not/matter"),
2106 always_failing_secure,
2107 )
2108 }); }
2110
2111 #[test]
2116 fn create_config_dir_creates_nested_dirs() {
2117 let dir = tempfile::tempdir().unwrap();
2118 let target = dir.path().join("a").join("b").join("c");
2119 create_config_dir(&target).unwrap();
2120 assert!(target.is_dir());
2121 }
2122
2123 #[cfg(unix)]
2124 #[test]
2125 fn create_config_dir_sets_restrictive_permissions() {
2126 use std::os::unix::fs::PermissionsExt;
2127 let dir = tempfile::tempdir().unwrap();
2128 let target = dir.path().join("leviath");
2129 create_config_dir(&target).unwrap();
2130 let mode = std::fs::metadata(&target).unwrap().permissions().mode();
2131 assert_eq!(mode & 0o777, 0o700);
2132 }
2133
2134 #[cfg(unix)]
2138 #[test]
2146 fn config_path_honors_leviath_home() {
2147 temp_env::with_vars(
2148 [
2149 ("LEVIATH_CONFIG_PATH", None::<&str>),
2150 ("LEVIATH_HOME", Some("/tmp/lev-cfg-test")),
2151 ],
2152 || {
2153 assert_eq!(
2154 Config::config_path(),
2155 std::path::PathBuf::from("/tmp/lev-cfg-test/.leviath/config.toml")
2156 );
2157 },
2158 );
2159 }
2160
2161 #[test]
2163 fn config_path_prefers_the_explicit_override() {
2164 temp_env::with_vars(
2165 [
2166 ("LEVIATH_CONFIG_PATH", Some("/tmp/exact.toml")),
2167 ("LEVIATH_HOME", Some("/tmp/lev-cfg-test")),
2168 ],
2169 || {
2170 assert_eq!(
2171 Config::config_path(),
2172 std::path::PathBuf::from("/tmp/exact.toml")
2173 );
2174 },
2175 );
2176 }
2177
2178 #[test]
2182 fn permissions_for_agent_overlays_the_named_grant_on_the_global() {
2183 let mut config = Config::default();
2184 config
2185 .tool_permissions
2186 .insert("shell".to_string(), ToolPolicy::Ask);
2187 config
2188 .tool_permissions
2189 .insert("write_file".to_string(), ToolPolicy::Deny);
2190 config.agent_tool_permissions.insert(
2191 "coder".to_string(),
2192 HashMap::from([("shell".to_string(), ToolPolicy::Allow)]),
2193 );
2194
2195 let coder = config.permissions_for_agent("coder");
2196 assert_eq!(coder.get("shell"), Some(&ToolPolicy::Allow), "granted");
2197 assert_eq!(
2198 coder.get("write_file"),
2199 Some(&ToolPolicy::Deny),
2200 "the rest of the global ceiling still applies"
2201 );
2202
2203 let other = config.permissions_for_agent("researcher");
2205 assert_eq!(other.get("shell"), Some(&ToolPolicy::Ask));
2206 }
2207
2208 #[test]
2211 fn read_path_grants_merge_global_and_per_agent() {
2212 let mut config = Config::default();
2213 assert!(
2214 !config.security.allow_blueprint_read_paths,
2215 "blueprint read paths must be opt-in"
2216 );
2217 assert!(config.read_path_grants_for_agent("cto").is_empty());
2218
2219 config.security.read_paths = vec!["~/.leviath/runs".to_string()];
2220 config.agent_read_paths.insert(
2221 "cto".to_string(),
2222 ReadPathGrants {
2223 allow: vec!["glob:~/design-docs/**".to_string()],
2224 },
2225 );
2226
2227 assert_eq!(
2228 config.read_path_grants_for_agent("cto"),
2229 vec![
2230 "~/.leviath/runs".to_string(),
2231 "glob:~/design-docs/**".to_string(),
2232 ]
2233 );
2234 assert_eq!(
2236 config.read_path_grants_for_agent("researcher"),
2237 vec!["~/.leviath/runs".to_string()]
2238 );
2239 }
2240
2241 #[test]
2244 fn provider_config_debug_never_prints_the_keys() {
2245 let providers = ProviderConfig {
2246 anthropic_api_key: Some("sk-ant-SECRET-VALUE".to_string()),
2247 openai_api_key: Some("sk-openai-SECRET-VALUE".to_string()),
2248 google_api_key: Some("AIza-SECRET-VALUE".to_string()),
2249 claude_code_enabled: true,
2250 claude_code_binary: None,
2251 claude_code_effort: None,
2252 fallback_order: Vec::new(),
2253 };
2254 let rendered = format!("{providers:?}");
2255 assert!(!rendered.contains("SECRET-VALUE"), "key leaked: {rendered}");
2256 assert!(rendered.contains("<set>"), "{rendered}");
2258 assert!(rendered.contains("claude_code_enabled: true"), "{rendered}");
2259
2260 let empty = format!(
2261 "{:?}",
2262 ProviderConfig {
2263 anthropic_api_key: None,
2264 openai_api_key: None,
2265 google_api_key: None,
2266 claude_code_enabled: false,
2267 claude_code_binary: None,
2268 claude_code_effort: None,
2269 fallback_order: Vec::new(),
2270 }
2271 );
2272 assert!(empty.contains("<unset>"), "{empty}");
2273 }
2274
2275 #[cfg(unix)]
2279 #[test]
2280 fn saving_a_config_never_leaves_it_group_or_world_readable() {
2281 use std::os::unix::fs::PermissionsExt;
2282 let dir = tempfile::tempdir().unwrap();
2283 let path = dir.path().join("config.toml");
2284
2285 Config::default().save_to_path(&path).unwrap();
2286 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2287 assert_eq!(mode & 0o777, 0o600, "fresh config must be owner-only");
2288
2289 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2291 Config::default().save_to_path(&path).unwrap();
2292 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2293 assert_eq!(mode & 0o777, 0o600, "re-saving must re-tighten");
2294 }
2295
2296 #[cfg(unix)]
2297 #[test]
2298 fn set_dir_permissions_sets_0700() {
2299 use std::os::unix::fs::PermissionsExt;
2300 let dir = tempfile::tempdir().unwrap();
2301 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
2302 set_dir_permissions(dir.path());
2303 let mode = std::fs::metadata(dir.path()).unwrap().permissions().mode();
2304 assert_eq!(mode & 0o777, 0o700);
2305 }
2306
2307 #[test]
2308 fn test_validate_keys_good_anthropic() {
2309 let config = Config {
2310 providers: ProviderConfig {
2311 anthropic_api_key: Some("sk-ant-test123".to_string()),
2312 openai_api_key: None,
2313 google_api_key: None,
2314 claude_code_enabled: false,
2315 claude_code_binary: None,
2316 claude_code_effort: None,
2317 fallback_order: Vec::new(),
2318 },
2319 ..Config::default()
2320 };
2321 assert!(config.validate_keys().is_empty());
2322 }
2323
2324 #[test]
2325 fn test_validate_keys_bad_anthropic() {
2326 let config = Config {
2327 providers: ProviderConfig {
2328 anthropic_api_key: Some("bad-key".to_string()),
2329 openai_api_key: None,
2330 google_api_key: None,
2331 claude_code_enabled: false,
2332 claude_code_binary: None,
2333 claude_code_effort: None,
2334 fallback_order: Vec::new(),
2335 },
2336 ..Config::default()
2337 };
2338 let warnings = config.validate_keys();
2339 assert_eq!(warnings.len(), 1);
2340 assert!(warnings[0].contains("Anthropic"));
2341 }
2342
2343 #[test]
2344 fn test_validate_keys_good_openai() {
2345 let config = Config {
2346 providers: ProviderConfig {
2347 anthropic_api_key: None,
2348 openai_api_key: Some("sk-test123".to_string()),
2349 google_api_key: None,
2350 claude_code_enabled: false,
2351 claude_code_binary: None,
2352 claude_code_effort: None,
2353 fallback_order: Vec::new(),
2354 },
2355 ..Config::default()
2356 };
2357 assert!(config.validate_keys().is_empty());
2358 }
2359
2360 #[test]
2361 fn test_validate_keys_bad_openai() {
2362 let config = Config {
2363 providers: ProviderConfig {
2364 anthropic_api_key: None,
2365 openai_api_key: Some("bad-key".to_string()),
2366 google_api_key: None,
2367 claude_code_enabled: false,
2368 claude_code_binary: None,
2369 claude_code_effort: None,
2370 fallback_order: Vec::new(),
2371 },
2372 ..Config::default()
2373 };
2374 let warnings = config.validate_keys();
2375 assert_eq!(warnings.len(), 1);
2376 assert!(warnings[0].contains("OpenAI"));
2377 }
2378
2379 #[test]
2380 fn test_validate_keys_no_keys() {
2381 let config = Config::default();
2382 assert!(config.validate_keys().is_empty());
2383 }
2384
2385 #[test]
2388 fn config_default_values() {
2389 let config = Config::default();
2390 assert_eq!(config.default_provider, "anthropic");
2391 assert!(config.providers.anthropic_api_key.is_none());
2392 assert!(config.providers.openai_api_key.is_none());
2393 assert!(config.providers.google_api_key.is_none());
2394 assert!(config.openrouter_api_key.is_none());
2395 assert!(config.ollama_base_url.is_none());
2396 assert!(config.mcp_servers.is_empty());
2397 assert!(config.default_model.is_none());
2398 assert!(config.model_capabilities.is_empty());
2399 assert!(config.tool_permissions.is_empty());
2400 }
2401
2402 #[test]
2405 fn title_config_default() {
2406 let tc = TitleConfig::default();
2407 assert!(tc.enabled);
2408 assert!(tc.provider.is_none());
2409 assert!(tc.model.is_none());
2410 }
2411
2412 #[test]
2413 fn title_config_serde_roundtrip() {
2414 let tc = TitleConfig {
2415 enabled: false,
2416 provider: Some("openai".to_string()),
2417 model: Some("gpt-5.4-mini".to_string()),
2418 };
2419 let json = serde_json::to_string(&tc).unwrap();
2420 let back: TitleConfig = serde_json::from_str(&json).unwrap();
2421 assert!(!back.enabled);
2422 assert_eq!(back.provider.as_deref(), Some("openai"));
2423 assert_eq!(back.model.as_deref(), Some("gpt-5.4-mini"));
2424 }
2425
2426 #[test]
2429 fn tool_policy_default_is_ask() {
2430 let policy = ToolPolicy::default();
2431 assert_eq!(policy, ToolPolicy::Ask);
2432 }
2433
2434 #[test]
2435 fn tool_policy_serde_roundtrip() {
2436 for policy in [ToolPolicy::Allow, ToolPolicy::Ask, ToolPolicy::Deny] {
2437 let json = serde_json::to_string(&policy).unwrap();
2438 let back: ToolPolicy = serde_json::from_str(&json).unwrap();
2439 assert_eq!(policy, back);
2440 }
2441 }
2442
2443 #[test]
2444 fn tool_policy_snake_case_serialization() {
2445 assert_eq!(
2446 serde_json::to_string(&ToolPolicy::Allow).unwrap(),
2447 "\"allow\""
2448 );
2449 assert_eq!(serde_json::to_string(&ToolPolicy::Ask).unwrap(), "\"ask\"");
2450 assert_eq!(
2451 serde_json::to_string(&ToolPolicy::Deny).unwrap(),
2452 "\"deny\""
2453 );
2454 }
2455
2456 #[test]
2459 fn config_from_toml_with_all_fields() {
2460 let toml_content = r#"
2461default_provider = "openai"
2462openrouter_api_key = "sk-or-test"
2463ollama_base_url = "http://my-ollama:11434"
2464default_model = "gpt-5"
2465agent_paths = []
2466
2467[providers]
2468anthropic_api_key = "sk-ant-test"
2469openai_api_key = "sk-test"
2470google_api_key = "AIza-test"
2471
2472[tool_permissions]
2473bash = "deny"
2474read_file = "allow"
2475
2476[title]
2477enabled = false
2478provider = "anthropic"
2479model = "claude-haiku-4-5"
2480"#;
2481 let config: Config = toml::from_str(toml_content).unwrap();
2482 assert_eq!(config.default_provider, "openai");
2483 assert_eq!(
2484 config.providers.anthropic_api_key.as_deref(),
2485 Some("sk-ant-test")
2486 );
2487 assert_eq!(config.providers.openai_api_key.as_deref(), Some("sk-test"));
2488 assert_eq!(
2489 config.providers.google_api_key.as_deref(),
2490 Some("AIza-test")
2491 );
2492 assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-test"));
2493 assert_eq!(
2494 config.ollama_base_url.as_deref(),
2495 Some("http://my-ollama:11434")
2496 );
2497 assert_eq!(config.default_model.as_deref(), Some("gpt-5"));
2498 assert!(!config.title.enabled);
2499 assert_eq!(config.tool_permissions.get("bash"), Some(&ToolPolicy::Deny));
2500 assert_eq!(
2501 config.tool_permissions.get("read_file"),
2502 Some(&ToolPolicy::Allow)
2503 );
2504 }
2505
2506 #[test]
2507 fn config_from_minimal_toml() {
2508 let toml_content = r#"
2509default_provider = "anthropic"
2510agent_paths = []
2511
2512[providers]
2513"#;
2514 let config: Config = toml::from_str(toml_content).unwrap();
2515 assert_eq!(config.default_provider, "anthropic");
2516 assert!(config.providers.anthropic_api_key.is_none());
2517 }
2518
2519 #[test]
2520 fn config_from_toml_with_mcp_servers() {
2521 let toml_content = r#"
2522default_provider = "anthropic"
2523agent_paths = []
2524
2525[providers]
2526
2527[[mcp_servers]]
2528name = "test-server"
2529command = "echo"
2530args = ["hello"]
2531"#;
2532 let config: Config = toml::from_str(toml_content).unwrap();
2533 assert_eq!(config.mcp_servers.len(), 1);
2534 assert_eq!(config.mcp_servers[0].name, "test-server");
2535 }
2536
2537 #[test]
2538 fn load_rejects_a_malformed_mcp_server_entry() {
2539 let dir = tempfile::tempdir().unwrap();
2543 let path = dir.path().join("config.toml");
2544 std::fs::write(
2545 &path,
2546 r#"
2547default_provider = "anthropic"
2548agent_paths = []
2549
2550[providers]
2551
2552[[mcp_servers]]
2553name = "broken"
2554"#,
2555 )
2556 .unwrap();
2557
2558 let err = Config::load_from_path(&path).expect_err("malformed entry must fail load");
2559 let msg = err.to_string();
2560 assert!(msg.contains("broken"), "must name the server: {msg}");
2561 }
2562
2563 #[test]
2564 fn load_accepts_a_well_formed_http_mcp_server() {
2565 let dir = tempfile::tempdir().unwrap();
2566 let path = dir.path().join("config.toml");
2567 std::fs::write(
2568 &path,
2569 r#"
2570default_provider = "anthropic"
2571agent_paths = []
2572
2573[providers]
2574
2575[[mcp_servers]]
2576name = "remote"
2577url = "https://mcp.example.com/mcp"
2578"#,
2579 )
2580 .unwrap();
2581
2582 let config = Config::load_from_path(&path).expect("valid http entry should load");
2583 assert_eq!(
2584 config.mcp_servers[0].url.as_deref(),
2585 Some("https://mcp.example.com/mcp")
2586 );
2587 }
2588
2589 #[test]
2590 fn config_from_toml_with_model_capabilities() {
2591 let toml_content = r#"
2592default_provider = "anthropic"
2593agent_paths = []
2594
2595[providers]
2596
2597[model_capabilities."my-custom-model"]
2598supports_temperature = true
2599supports_streaming = false
2600supports_tools = true
2601supports_system_prompt = true
2602max_context_tokens = 4096
2603max_output_tokens = 2048
2604"#;
2605 let config: Config = toml::from_str(toml_content).unwrap();
2606 let caps = config.model_capabilities.get("my-custom-model").unwrap();
2607 assert!(caps.supports_temperature);
2608 assert!(!caps.supports_streaming);
2609 assert_eq!(caps.max_context_tokens, 4096);
2610 assert_eq!(caps.max_output_tokens, 2048);
2611 }
2612
2613 #[test]
2619 fn validate_keys_is_quiet_about_blank_keys() {
2620 let mut config = Config::default();
2621 config.providers.anthropic_api_key = Some(String::new());
2622 config.providers.openai_api_key = Some(" ".to_string());
2623 assert!(config.validate_keys().is_empty());
2624 config.providers.anthropic_api_key = Some("nope".to_string());
2626 assert_eq!(config.validate_keys().len(), 1);
2627 }
2628
2629 #[test]
2630 fn validate_keys_both_bad() {
2631 let config = Config {
2632 providers: ProviderConfig {
2633 anthropic_api_key: Some("bad".to_string()),
2634 openai_api_key: Some("bad".to_string()),
2635 google_api_key: None,
2636 claude_code_enabled: false,
2637 claude_code_binary: None,
2638 claude_code_effort: None,
2639 fallback_order: Vec::new(),
2640 },
2641 ..Config::default()
2642 };
2643 let warnings = config.validate_keys();
2644 assert_eq!(warnings.len(), 2);
2645 }
2646
2647 #[test]
2650 fn config_path_contains_leviath() {
2651 temp_env::with_var_unset("LEVIATH_CONFIG_PATH", || {
2656 let path = Config::config_path();
2657 assert!(path.to_str().unwrap().contains(".leviath"));
2658 assert!(path.to_str().unwrap().ends_with("config.toml"));
2659 });
2660 }
2661
2662 #[test]
2665 fn config_toml_roundtrip() {
2666 let config = Config {
2667 default_provider: "openai".to_string(),
2668 providers: ProviderConfig {
2669 anthropic_api_key: Some("sk-ant-key".to_string()),
2670 openai_api_key: None,
2671 google_api_key: None,
2672 claude_code_enabled: false,
2673 claude_code_binary: None,
2674 claude_code_effort: None,
2675 fallback_order: Vec::new(),
2676 },
2677 tool_permissions: {
2678 let mut m = HashMap::new();
2679 m.insert("bash".to_string(), ToolPolicy::Deny);
2680 m
2681 },
2682 ..Config::default()
2683 };
2684
2685 let serialized = toml::to_string_pretty(&config).unwrap();
2686 let deserialized: Config = toml::from_str(&serialized).unwrap();
2687 assert_eq!(deserialized.default_provider, "openai");
2688 assert_eq!(
2689 deserialized.providers.anthropic_api_key.as_deref(),
2690 Some("sk-ant-key")
2691 );
2692 assert_eq!(
2693 deserialized.tool_permissions.get("bash"),
2694 Some(&ToolPolicy::Deny)
2695 );
2696 }
2697
2698 #[test]
2701 fn validate_keys_both_valid() {
2702 let config = Config {
2703 providers: ProviderConfig {
2704 anthropic_api_key: Some("sk-ant-good-key".to_string()),
2705 openai_api_key: Some("sk-good-key".to_string()),
2706 google_api_key: None,
2707 claude_code_enabled: false,
2708 claude_code_binary: None,
2709 claude_code_effort: None,
2710 fallback_order: Vec::new(),
2711 },
2712 ..Config::default()
2713 };
2714 assert!(config.validate_keys().is_empty());
2715 }
2716
2717 #[test]
2720 fn validate_keys_google_key_not_validated() {
2721 let config = Config {
2722 providers: ProviderConfig {
2723 anthropic_api_key: None,
2724 openai_api_key: None,
2725 google_api_key: Some("anything-goes".to_string()),
2726 claude_code_enabled: false,
2727 claude_code_binary: None,
2728 claude_code_effort: None,
2729 fallback_order: Vec::new(),
2730 },
2731 ..Config::default()
2732 };
2733 assert!(config.validate_keys().is_empty());
2735 }
2736
2737 #[test]
2740 fn config_from_toml_custom_registries() {
2741 let toml_content = r#"
2742default_provider = "anthropic"
2743agent_paths = ["/my/agents"]
2744
2745[providers]
2746"#;
2747 let config: Config = toml::from_str(toml_content).unwrap();
2748 assert_eq!(config.agent_paths.len(), 1);
2749 }
2750
2751 #[test]
2754 fn config_save_creates_file() {
2755 let dir = tempfile::tempdir().unwrap();
2756 let config_path = dir.path().join("subdir").join("config.toml");
2757 let config = Config::default();
2760 let content = toml::to_string_pretty(&config).unwrap();
2761 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
2762 std::fs::write(&config_path, &content).unwrap();
2763 assert!(config_path.exists());
2764 let loaded_content = std::fs::read_to_string(&config_path).unwrap();
2765 let loaded: Config = toml::from_str(&loaded_content).unwrap();
2766 assert_eq!(loaded.default_provider, "anthropic");
2767 }
2768
2769 #[test]
2772 fn title_config_from_toml_defaults() {
2773 let toml_content = r#"
2774default_provider = "anthropic"
2775agent_paths = []
2776
2777[providers]
2778"#;
2779 let config: Config = toml::from_str(toml_content).unwrap();
2780 assert!(config.title.enabled);
2781 assert!(config.title.provider.is_none());
2782 assert!(config.title.model.is_none());
2783 }
2784
2785 #[test]
2786 fn title_config_from_toml_disabled() {
2787 let toml_content = r#"
2788default_provider = "anthropic"
2789agent_paths = []
2790
2791[providers]
2792
2793[title]
2794enabled = false
2795"#;
2796 let config: Config = toml::from_str(toml_content).unwrap();
2797 assert!(!config.title.enabled);
2798 }
2799
2800 #[test]
2801 fn title_config_missing_enabled_key_uses_default_true() {
2802 let toml_content = r#"
2809default_provider = "anthropic"
2810agent_paths = []
2811
2812[providers]
2813
2814[title]
2815provider = "openai"
2816"#;
2817 let config: Config = toml::from_str(toml_content).unwrap();
2818 assert!(config.title.enabled);
2819 assert_eq!(config.title.provider.as_deref(), Some("openai"));
2820 }
2821
2822 #[test]
2825 fn config_tool_permissions_allow() {
2826 let toml_content = r#"
2827default_provider = "anthropic"
2828agent_paths = []
2829
2830[providers]
2831
2832[tool_permissions]
2833read_file = "allow"
2834write_file = "ask"
2835bash = "deny"
2836"#;
2837 let config: Config = toml::from_str(toml_content).unwrap();
2838 assert_eq!(
2839 config.tool_permissions.get("read_file"),
2840 Some(&ToolPolicy::Allow)
2841 );
2842 assert_eq!(
2843 config.tool_permissions.get("write_file"),
2844 Some(&ToolPolicy::Ask)
2845 );
2846 assert_eq!(config.tool_permissions.get("bash"), Some(&ToolPolicy::Deny));
2847 }
2848
2849 #[test]
2852 fn config_with_agent_paths() {
2853 let toml_content = r#"
2854default_provider = "anthropic"
2855agent_paths = ["/home/user/agents", "/opt/agents"]
2856
2857[providers]
2858"#;
2859 let config: Config = toml::from_str(toml_content).unwrap();
2860 assert_eq!(config.agent_paths.len(), 2);
2861 }
2862
2863 #[test]
2866 fn config_load_from_nonexistent_path_returns_default() {
2867 let config = Config::default();
2871 assert_eq!(config.default_provider, "anthropic");
2872 assert!(config.providers.anthropic_api_key.is_none());
2873 }
2874
2875 #[test]
2876 fn config_load_from_toml_string() {
2877 let toml_content = r#"
2879default_provider = "openai"
2880agent_paths = []
2881
2882[providers]
2883anthropic_api_key = "sk-ant-test-key"
2884"#;
2885 let config: Config = toml::from_str(toml_content).unwrap();
2886 assert_eq!(config.default_provider, "openai");
2887 assert_eq!(
2888 config.providers.anthropic_api_key.as_deref(),
2889 Some("sk-ant-test-key")
2890 );
2891 assert_eq!(config.nudge, leviath_core::NudgeConfig::default());
2893 }
2894
2895 #[test]
2896 fn config_parses_partial_nudge_section() {
2897 let config: Config = toml::from_str(
2899 r#"
2900default_provider = "openai"
2901agent_paths = []
2902
2903[providers]
2904
2905[nudge]
2906enabled = false
2907"#,
2908 )
2909 .unwrap();
2910 assert_eq!(config.nudge.enabled, Some(false));
2911 assert_eq!(config.nudge.max, None);
2912 assert_eq!(config.nudge.text, None);
2913 }
2914
2915 #[test]
2916 fn config_save_and_load_with_file() {
2917 let dir = tempfile::tempdir().unwrap();
2919 let config_path = dir.path().join("config.toml");
2920
2921 let config = Config {
2922 default_provider: "openai".to_string(),
2923 providers: ProviderConfig {
2924 anthropic_api_key: Some("sk-ant-test".to_string()),
2925 openai_api_key: Some("sk-test".to_string()),
2926 google_api_key: None,
2927 claude_code_enabled: false,
2928 claude_code_binary: None,
2929 claude_code_effort: None,
2930 fallback_order: Vec::new(),
2931 },
2932 openrouter_api_key: Some("sk-or-test".to_string()),
2933 default_model: Some("gpt-5".to_string()),
2934 ..Config::default()
2935 };
2936
2937 let content = toml::to_string_pretty(&config).unwrap();
2938 std::fs::write(&config_path, &content).unwrap();
2939
2940 let loaded_content = std::fs::read_to_string(&config_path).unwrap();
2941 let loaded: Config = toml::from_str(&loaded_content).unwrap();
2942
2943 assert_eq!(loaded.default_provider, "openai");
2944 assert_eq!(
2945 loaded.providers.anthropic_api_key.as_deref(),
2946 Some("sk-ant-test")
2947 );
2948 assert_eq!(loaded.default_model.as_deref(), Some("gpt-5"));
2949 }
2950
2951 #[test]
2952 fn config_create_config_dir_creates_parent() {
2953 let dir = tempfile::tempdir().unwrap();
2954 let new_dir = dir.path().join("nested").join("config");
2955 std::fs::create_dir_all(&new_dir).unwrap();
2957 assert!(new_dir.exists());
2958 }
2959
2960 #[test]
2961 fn config_default_title_enabled() {
2962 let config = Config::default();
2963 assert!(config.title.enabled);
2964 }
2965
2966 #[test]
2967 fn config_serialize_with_all_options() {
2968 let mut model_caps = HashMap::new();
2969 model_caps.insert(
2970 "my-model".to_string(),
2971 ModelCapabilities {
2972 supports_temperature: true,
2973 supports_streaming: true,
2974 supports_tools: true,
2975 supports_system_prompt: true,
2976 max_context_tokens: 8192,
2977 max_output_tokens: 4096,
2978 },
2979 );
2980 let mut tool_perms = HashMap::new();
2981 tool_perms.insert("bash".to_string(), ToolPolicy::Allow);
2982
2983 let config = Config {
2984 default_provider: "anthropic".to_string(),
2985 providers: ProviderConfig {
2986 anthropic_api_key: Some("sk-ant-key".to_string()),
2987 openai_api_key: None,
2988 google_api_key: None,
2989 claude_code_enabled: false,
2990 claude_code_binary: None,
2991 claude_code_effort: None,
2992 fallback_order: Vec::new(),
2993 },
2994 agent_paths: vec![std::path::PathBuf::from("/my/agents")],
2995 openrouter_api_key: None,
2996 ollama_base_url: Some("http://custom:11434".to_string()),
2997 mcp_servers: vec![],
2998 default_model: None,
2999 model_capabilities: model_caps,
3000 model_providers: HashMap::new(),
3001 tool_permissions: tool_perms,
3002 agent_tool_permissions: HashMap::new(),
3003 title: TitleConfig {
3004 enabled: false,
3005 provider: Some("openai".to_string()),
3006 model: Some("gpt-5-mini".to_string()),
3007 },
3008 request_timeout_secs: None,
3009 rate_limits: HashMap::new(),
3010 taint_tracking: false,
3011 limits: LimitsConfig {
3012 max_concurrent_inferences: Some(4),
3013 max_concurrent_tools: 3,
3014 default_max_iterations: Some(99),
3015 exact_token_counting: false,
3016 script_shell_timeout_secs: 45,
3017 stall_timeout_secs: 90,
3018 dead_cycles_before_relief: 6,
3019 finished_retention_secs: 120,
3020 wedge_timeout_secs: 420,
3021 provider_failures_before_open: 5,
3022 provider_circuit_cooldown_secs: 120,
3023 interaction_timeout_secs: 120,
3024 },
3025 batch_tool_hint: true,
3026 shell_hint: false,
3027 nudge: leviath_core::NudgeConfig {
3028 enabled: Some(true),
3029 max: Some(2),
3030 text: Some("Use your tools.".to_string()),
3031 },
3032 webhook: WebhookConfig {
3033 max_retries: 5,
3034 base_delay_ms: 250,
3035 max_delay_ms: 10_000,
3036 timeout_secs: 7,
3037 },
3038 observability: ObservabilityConfig {
3039 enabled: true,
3040 exporter: TelemetryExporterKind::Stdout,
3041 endpoint: Some("http://collector:4318".to_string()),
3042 service_name: Some("leviath-prod".to_string()),
3043 },
3044 sandbox: Some(leviath_core::ToolSandboxConfig {
3045 kind: leviath_core::SandboxKind::Container,
3046 image: Some("ubuntu:24.04".to_string()),
3047 network: false,
3048 ..Default::default()
3049 }),
3050 tool_script_permissions: ScriptToolPermissions {
3051 http_get: ScriptPermission::Allow,
3052 http_post: ScriptPermission::Deny,
3053 shell: ScriptPermission::Deny,
3054 read_file: ScriptPermission::Inherit,
3055 write_file: ScriptPermission::Deny,
3056 env_var: ScriptPermission::Allow,
3057 },
3058 security: SecurityConfig {
3059 allow_seed_commands: false,
3060 allow_local_network: true,
3061 allow_env_vars: vec!["MY_PROVIDER_KEY".to_string()],
3062 allow_blueprint_read_paths: true,
3063 read_paths: vec!["~/.leviath/runs".to_string()],
3064 credential_store: leviath_core::CredentialStoreKind::Keychain,
3065 },
3066 agent_read_paths: HashMap::from([(
3067 "cto".to_string(),
3068 ReadPathGrants {
3069 allow: vec!["glob:~/design-docs/**".to_string()],
3070 },
3071 )]),
3072 };
3073
3074 let serialized = toml::to_string_pretty(&config).unwrap();
3075 let deserialized: Config = toml::from_str(&serialized).unwrap();
3076
3077 assert_eq!(deserialized.default_provider, "anthropic");
3078 assert_eq!(deserialized.limits.max_concurrent_inferences, Some(4));
3079 assert_eq!(deserialized.limits.max_concurrent_tools, 3);
3080 assert_eq!(deserialized.limits.script_shell_timeout_secs, 45);
3081 assert_eq!(deserialized.limits.dead_cycles_before_relief, 6);
3082 assert_eq!(deserialized.limits.finished_retention_secs, 120);
3083 assert_eq!(
3084 deserialized.tool_script_permissions.http_get,
3085 ScriptPermission::Allow
3086 );
3087 assert_eq!(
3088 deserialized.tool_script_permissions.shell,
3089 ScriptPermission::Deny
3090 );
3091 assert_eq!(
3092 deserialized.tool_script_permissions.write_file,
3093 ScriptPermission::Deny
3094 );
3095 assert!(deserialized.batch_tool_hint);
3098 assert!(!deserialized.shell_hint);
3099 assert!(!deserialized.security.allow_seed_commands);
3100 assert!(deserialized.security.allow_blueprint_read_paths);
3101 assert_eq!(deserialized.security.read_paths, vec!["~/.leviath/runs"]);
3102 assert_eq!(
3103 deserialized.agent_read_paths.get("cto"),
3104 Some(&ReadPathGrants {
3105 allow: vec!["glob:~/design-docs/**".to_string()],
3106 })
3107 );
3108 assert_eq!(
3109 deserialized.nudge,
3110 leviath_core::NudgeConfig {
3111 enabled: Some(true),
3112 max: Some(2),
3113 text: Some("Use your tools.".to_string()),
3114 }
3115 );
3116 assert_eq!(deserialized.webhook.max_retries, 5);
3117 assert_eq!(deserialized.webhook.base_delay_ms, 250);
3118 assert_eq!(deserialized.webhook.max_delay_ms, 10_000);
3119 assert_eq!(deserialized.webhook.timeout_secs, 7);
3120 assert!(deserialized.observability.enabled);
3121 assert_eq!(
3122 deserialized.observability.exporter,
3123 TelemetryExporterKind::Stdout
3124 );
3125 assert_eq!(
3126 deserialized.observability.endpoint.as_deref(),
3127 Some("http://collector:4318")
3128 );
3129 assert_eq!(
3130 deserialized.observability.service_name.as_deref(),
3131 Some("leviath-prod")
3132 );
3133 assert_eq!(deserialized.limits.default_max_iterations, Some(99));
3134 assert_eq!(
3135 deserialized.providers.anthropic_api_key.as_deref(),
3136 Some("sk-ant-key")
3137 );
3138 assert_eq!(deserialized.agent_paths.len(), 1);
3139 assert!(deserialized.model_capabilities.contains_key("my-model"));
3140 assert_eq!(
3141 deserialized.tool_permissions.get("bash"),
3142 Some(&ToolPolicy::Allow)
3143 );
3144 assert!(!deserialized.title.enabled);
3145 assert_eq!(deserialized.title.provider.as_deref(), Some("openai"));
3146 let sandbox = deserialized.sandbox.expect("sandbox round-trips");
3147 assert_eq!(sandbox.kind, leviath_core::SandboxKind::Container);
3148 assert_eq!(sandbox.image.as_deref(), Some("ubuntu:24.04"));
3149 assert!(!sandbox.network);
3150 }
3151
3152 #[test]
3155 fn config_multiple_model_capabilities() {
3156 let toml_content = r#"
3157default_provider = "anthropic"
3158agent_paths = []
3159
3160[providers]
3161
3162[model_capabilities."model-a"]
3163supports_temperature = true
3164supports_streaming = true
3165supports_tools = true
3166supports_system_prompt = true
3167max_context_tokens = 8192
3168max_output_tokens = 4096
3169
3170[model_capabilities."model-b"]
3171supports_temperature = false
3172supports_streaming = false
3173supports_tools = false
3174supports_system_prompt = false
3175max_context_tokens = 2048
3176max_output_tokens = 1024
3177"#;
3178 let config: Config = toml::from_str(toml_content).unwrap();
3179 assert_eq!(config.model_capabilities.len(), 2);
3180 let caps_a = config.model_capabilities.get("model-a").unwrap();
3181 assert!(caps_a.supports_temperature);
3182 assert_eq!(caps_a.max_context_tokens, 8192);
3183 let caps_b = config.model_capabilities.get("model-b").unwrap();
3184 assert!(!caps_b.supports_temperature);
3185 assert_eq!(caps_b.max_context_tokens, 2048);
3186 }
3187}