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 #[serde(default = "default_provider_name")]
90 pub default_provider: String,
91
92 #[serde(default)]
94 pub providers: ProviderConfig,
95
96 #[serde(default)]
98 pub agent_paths: Vec<PathBuf>,
99
100 #[serde(default)]
102 pub openrouter_api_key: Option<String>,
103
104 #[serde(default)]
106 pub ollama_base_url: Option<String>,
107
108 #[serde(default)]
110 pub mcp_servers: Vec<MCPServerConfig>,
111
112 #[serde(default)]
114 pub default_model: Option<String>,
115
116 #[serde(default)]
119 pub model_capabilities: HashMap<String, ModelCapabilities>,
120
121 #[serde(default)]
128 pub model_providers: HashMap<String, ModelProviderConfig>,
129
130 #[serde(default)]
138 pub tool_permissions: HashMap<String, ToolPolicy>,
139
140 #[serde(default)]
155 pub agent_tool_permissions: HashMap<String, HashMap<String, ToolPolicy>>,
156
157 #[serde(default)]
162 pub title: TitleConfig,
163
164 #[serde(default)]
171 pub request_timeout_secs: Option<u64>,
172
173 #[serde(default)]
185 pub rate_limits: HashMap<String, leviath_providers::RateLimitConfig>,
186
187 #[serde(default)]
194 pub taint_tracking: bool,
195
196 #[serde(default)]
198 pub limits: LimitsConfig,
199
200 #[serde(default = "default_true")]
210 pub batch_tool_hint: bool,
211
212 #[serde(default = "default_true")]
223 pub shell_hint: bool,
224
225 #[serde(default)]
232 pub nudge: leviath_core::NudgeConfig,
233
234 #[serde(default)]
236 pub webhook: WebhookConfig,
237
238 #[serde(default)]
244 pub observability: ObservabilityConfig,
245
246 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub sandbox: Option<leviath_core::ToolSandboxConfig>,
252
253 #[serde(default)]
256 pub tool_script_permissions: ScriptToolPermissions,
257
258 #[serde(default)]
262 pub security: SecurityConfig,
263
264 #[serde(default)]
278 pub agent_read_paths: HashMap<String, ReadPathGrants>,
279}
280
281#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
283pub struct ReadPathGrants {
284 #[serde(default)]
286 pub allow: Vec<String>,
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
294pub struct SecurityConfig {
295 #[serde(default = "default_true")]
306 pub allow_seed_commands: bool,
307
308 #[serde(default)]
324 pub allow_local_network: bool,
325
326 #[serde(default)]
346 pub allow_env_vars: Vec<String>,
347
348 #[serde(default)]
361 pub allow_blueprint_read_paths: bool,
362
363 #[serde(default)]
380 pub read_paths: Vec<String>,
381
382 #[serde(default)]
406 pub credential_store: leviath_core::CredentialStoreKind,
407}
408
409impl Default for SecurityConfig {
410 fn default() -> Self {
411 Self {
412 allow_seed_commands: true,
413 allow_local_network: false,
414 allow_env_vars: Vec::new(),
415 allow_blueprint_read_paths: false,
416 read_paths: Vec::new(),
417 credential_store: leviath_core::CredentialStoreKind::File,
418 }
419 }
420}
421
422fn default_true() -> bool {
423 true
424}
425
426fn default_max_concurrent_inferences() -> Option<usize> {
427 Some(8)
428}
429
430fn default_default_max_iterations() -> Option<usize> {
431 Some(50)
432}
433
434fn default_max_concurrent_tools() -> usize {
435 8
436}
437
438fn default_script_shell_timeout_secs() -> u64 {
439 60
440}
441
442fn default_stall_timeout_secs() -> u64 {
443 leviath_runtime::pipeline::DEFAULT_STALL_TIMEOUT_SECS
444}
445
446fn default_dead_cycles_before_relief() -> u32 {
447 leviath_runtime::host::DEFAULT_DEAD_CYCLES_BEFORE_RELIEF
448}
449
450fn default_finished_retention_secs() -> u64 {
451 leviath_runtime::host::DEFAULT_FINISHED_RETENTION_SECS
452}
453
454fn default_wedge_timeout_secs() -> u64 {
455 leviath_runtime::pipeline::DEFAULT_WEDGE_TIMEOUT_SECS
456}
457
458fn default_provider_name() -> String {
468 "anthropic".to_string()
469}
470
471fn default_provider_failures_before_open() -> u32 {
472 leviath_runtime::pipeline::DEFAULT_FAILURES_BEFORE_OPEN
473}
474
475fn default_provider_circuit_cooldown_secs() -> u64 {
476 leviath_runtime::pipeline::DEFAULT_CIRCUIT_COOLDOWN_SECS
477}
478
479fn default_interaction_timeout_secs() -> u64 {
480 leviath_runtime::interaction_hub::DEFAULT_INTERACTION_TIMEOUT_SECS
481}
482
483#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct LimitsConfig {
490 #[serde(default = "default_max_concurrent_inferences")]
494 pub max_concurrent_inferences: Option<usize>,
495
496 #[serde(default = "default_max_concurrent_tools")]
501 pub max_concurrent_tools: usize,
502
503 #[serde(default = "default_default_max_iterations")]
507 pub default_max_iterations: Option<usize>,
508
509 #[serde(default)]
517 pub exact_token_counting: bool,
518
519 #[serde(default = "default_script_shell_timeout_secs")]
523 pub script_shell_timeout_secs: u64,
524
525 #[serde(default = "default_stall_timeout_secs")]
536 pub stall_timeout_secs: u64,
537
538 #[serde(default = "default_dead_cycles_before_relief")]
551 pub dead_cycles_before_relief: u32,
552
553 #[serde(default = "default_finished_retention_secs")]
568 pub finished_retention_secs: u64,
569 #[serde(default = "default_wedge_timeout_secs")]
589 pub wedge_timeout_secs: u64,
590 #[serde(default = "default_provider_failures_before_open")]
602 pub provider_failures_before_open: u32,
603
604 #[serde(default = "default_provider_circuit_cooldown_secs")]
613 pub provider_circuit_cooldown_secs: u64,
614 #[serde(default = "default_interaction_timeout_secs")]
632 pub interaction_timeout_secs: u64,
633}
634
635impl Default for LimitsConfig {
636 fn default() -> Self {
637 Self {
638 max_concurrent_inferences: default_max_concurrent_inferences(),
639 max_concurrent_tools: default_max_concurrent_tools(),
640 default_max_iterations: default_default_max_iterations(),
641 exact_token_counting: false,
642 script_shell_timeout_secs: default_script_shell_timeout_secs(),
643 stall_timeout_secs: default_stall_timeout_secs(),
644 dead_cycles_before_relief: default_dead_cycles_before_relief(),
645 finished_retention_secs: default_finished_retention_secs(),
646 wedge_timeout_secs: default_wedge_timeout_secs(),
647 provider_failures_before_open: default_provider_failures_before_open(),
648 provider_circuit_cooldown_secs: default_provider_circuit_cooldown_secs(),
649 interaction_timeout_secs: default_interaction_timeout_secs(),
650 }
651 }
652}
653
654fn default_webhook_max_retries() -> u32 {
655 3
656}
657
658fn default_webhook_base_delay_ms() -> u64 {
659 500
660}
661
662fn default_webhook_max_delay_ms() -> u64 {
663 30_000
664}
665
666fn default_webhook_timeout_secs() -> u64 {
667 10
668}
669
670#[derive(Debug, Clone, Serialize, Deserialize)]
677pub struct WebhookConfig {
678 #[serde(default = "default_webhook_max_retries")]
681 pub max_retries: u32,
682
683 #[serde(default = "default_webhook_base_delay_ms")]
686 pub base_delay_ms: u64,
687
688 #[serde(default = "default_webhook_max_delay_ms")]
691 pub max_delay_ms: u64,
692
693 #[serde(default = "default_webhook_timeout_secs")]
695 pub timeout_secs: u64,
696}
697
698impl Default for WebhookConfig {
699 fn default() -> Self {
700 Self {
701 max_retries: default_webhook_max_retries(),
702 base_delay_ms: default_webhook_base_delay_ms(),
703 max_delay_ms: default_webhook_max_delay_ms(),
704 timeout_secs: default_webhook_timeout_secs(),
705 }
706 }
707}
708
709#[derive(Clone, Default, Serialize, Deserialize)]
713pub struct ProviderConfig {
714 #[serde(default)]
716 pub anthropic_api_key: Option<String>,
717
718 #[serde(default)]
720 pub openai_api_key: Option<String>,
721
722 #[serde(default)]
724 pub google_api_key: Option<String>,
725
726 #[serde(default)]
734 pub claude_code_enabled: bool,
735
736 #[serde(default)]
738 pub claude_code_binary: Option<String>,
739
740 #[serde(default)]
747 pub claude_code_effort: Option<String>,
748
749 #[serde(default)]
761 pub fallback_order: Vec<String>,
762}
763
764impl std::fmt::Debug for ProviderConfig {
775 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
776 f.debug_struct("ProviderConfig")
777 .field("anthropic_api_key", &redacted(&self.anthropic_api_key))
778 .field("openai_api_key", &redacted(&self.openai_api_key))
779 .field("google_api_key", &redacted(&self.google_api_key))
780 .field("claude_code_enabled", &self.claude_code_enabled)
781 .field("claude_code_binary", &self.claude_code_binary)
782 .field("claude_code_effort", &self.claude_code_effort)
783 .field("fallback_order", &self.fallback_order)
784 .finish()
785 }
786}
787
788fn redacted(value: &Option<String>) -> &'static str {
790 match value {
791 Some(_) => "<set>",
792 None => "<unset>",
793 }
794}
795
796#[derive(Debug, Clone, Serialize, Deserialize, Default)]
802pub struct ModelProviderConfig {
803 #[serde(default)]
806 pub script: Option<String>,
807
808 #[serde(default)]
811 pub api_key: Option<String>,
812
813 #[serde(default)]
815 pub base_url: Option<String>,
816
817 #[serde(default)]
819 pub rate_limit: Option<leviath_providers::RateLimitConfig>,
820
821 #[serde(flatten)]
823 pub extra: HashMap<String, toml::Value>,
824}
825
826impl Default for Config {
827 fn default() -> Self {
828 Self {
829 default_provider: "anthropic".to_string(),
830 providers: ProviderConfig {
831 anthropic_api_key: None,
832 openai_api_key: None,
833 google_api_key: None,
834 claude_code_enabled: false,
835 claude_code_binary: None,
836 claude_code_effort: None,
837 fallback_order: Vec::new(),
838 },
839 agent_paths: Vec::new(),
840 openrouter_api_key: None,
841 ollama_base_url: None,
842 mcp_servers: Vec::new(),
843 default_model: None,
844 model_capabilities: HashMap::new(),
845 model_providers: HashMap::new(),
846 tool_permissions: HashMap::new(),
847 agent_tool_permissions: HashMap::new(),
848 title: TitleConfig::default(),
849 request_timeout_secs: None,
850 rate_limits: HashMap::new(),
851 taint_tracking: false,
852 limits: LimitsConfig::default(),
853 batch_tool_hint: true,
854 shell_hint: true,
855 nudge: leviath_core::NudgeConfig::default(),
856 webhook: WebhookConfig::default(),
857 observability: ObservabilityConfig::default(),
858 sandbox: None,
859 tool_script_permissions: ScriptToolPermissions::default(),
860 security: SecurityConfig::default(),
861 agent_read_paths: HashMap::new(),
862 }
863 }
864}
865
866impl Config {
867 pub fn permissions_for_agent(&self, agent_name: &str) -> HashMap<String, ToolPolicy> {
875 let mut merged = self.tool_permissions.clone();
876 if let Some(per_agent) = self.agent_tool_permissions.get(agent_name) {
877 merged.extend(per_agent.iter().map(|(k, v)| (k.clone(), *v)));
878 }
879 merged
880 }
881
882 pub fn read_path_grants_for_agent(&self, agent_name: &str) -> Vec<String> {
887 let mut grants = self.security.read_paths.clone();
888 if let Some(per_agent) = self.agent_read_paths.get(agent_name) {
889 grants.extend(per_agent.allow.iter().cloned());
890 }
891 grants
892 }
893
894 pub fn load() -> anyhow::Result<Self> {
899 #[cfg(test)]
915 assert!(
916 std::env::var_os("LEVIATH_CONFIG_PATH").is_some(),
917 "Config::load() reached from a test that has not isolated the \
918 environment. Wrap the test in `config::with_isolated_config_path` \
919 (or `..._async`), which both points this at a scratch config and \
920 takes the same process-wide lock every other env-touching test \
921 holds. Without it this test races them and fails intermittently, \
922 somewhere else."
923 );
924
925 if std::env::var_os("LEVIATH_SKIP_DOTENV").is_none() {
943 let _ = dotenvy::from_filename(".env");
944 }
945
946 let config = Self::load_from_path(&Self::config_path())?;
947
948 check_permissions();
950
951 Ok(config)
952 }
953
954 fn load_from_path(path: &std::path::Path) -> anyhow::Result<Self> {
957 let mut config = if !path.exists() {
958 let path_display = path.display();
959 tracing::debug!("No config file found at {}, using defaults", path_display);
960 Self::default()
961 } else {
962 let content = std::fs::read_to_string(path).map_err(|e| {
963 anyhow::anyhow!("Failed to read config from '{}': {}", path.display(), e)
964 })?;
965
966 let c: Self = toml::from_str(&content)
967 .map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e))?;
968
969 for server in &c.mcp_servers {
973 server.validate()?;
974 }
975
976 let path_display = path.display();
977 tracing::debug!("Loaded config from {}", path_display);
978 c
979 };
980
981 if config.providers.anthropic_api_key.is_none() {
983 config.providers.anthropic_api_key = std::env::var("ANTHROPIC_API_KEY").ok();
984 }
985 if config.providers.openai_api_key.is_none() {
986 config.providers.openai_api_key = std::env::var("OPENAI_API_KEY").ok();
987 }
988 if config.providers.google_api_key.is_none() {
989 config.providers.google_api_key = std::env::var("GOOGLE_API_KEY").ok();
990 }
991 if config.openrouter_api_key.is_none() {
992 config.openrouter_api_key = std::env::var("OPENROUTER_API_KEY").ok();
993 }
994 if config.ollama_base_url.is_none() {
996 config.ollama_base_url = std::env::var("OLLAMA_HOST").ok();
997 }
998
999 config.fill_from_credential_store();
1000
1001 Ok(config)
1002 }
1003
1004 fn fill_from_credential_store(&mut self) {
1006 let resolved = crate::credentials::store_for(self.security.credential_store);
1007 self.fill_from_credential_store_with(resolved);
1008 }
1009
1010 fn fill_from_credential_store_with(&mut self, resolved: crate::credentials::Resolved) {
1028 match resolved {
1029 Ok(Some(store)) => self.apply_credential_store(store.as_ref()),
1030 Ok(None) => {}
1032 Err(e) => {
1033 tracing::warn!("{e}. Falling back to keys from the config file and environment.");
1034 }
1035 }
1036 }
1037
1038 fn apply_credential_store(&mut self, store: &dyn leviath_core::CredentialStore) {
1040 let accounts: Vec<String> = crate::credentials::PROVIDER_KEYS
1041 .iter()
1042 .map(|p| leviath_core::provider_account(p))
1043 .collect();
1044 let mut found = store.read_all(&accounts);
1045 let mut take = |provider: &str| found.remove(&leviath_core::provider_account(provider));
1046
1047 let anthropic = take("anthropic");
1048 let openai = take("openai");
1049 let google = take("google");
1050 let openrouter = take("openrouter");
1051
1052 self.providers.anthropic_api_key = self.providers.anthropic_api_key.take().or(anthropic);
1053 self.providers.openai_api_key = self.providers.openai_api_key.take().or(openai);
1054 self.providers.google_api_key = self.providers.google_api_key.take().or(google);
1055 self.openrouter_api_key = self.openrouter_api_key.take().or(openrouter);
1056 }
1057
1058 fn without_secrets(&self) -> Self {
1066 let mut copy = self.clone();
1067 copy.providers.anthropic_api_key = None;
1068 copy.providers.openai_api_key = None;
1069 copy.providers.google_api_key = None;
1070 copy.openrouter_api_key = None;
1071 copy
1072 }
1073
1074 pub(crate) fn provider_secrets(&self) -> Vec<(String, String)> {
1076 [
1077 ("anthropic", self.providers.anthropic_api_key.as_deref()),
1078 ("openai", self.providers.openai_api_key.as_deref()),
1079 ("google", self.providers.google_api_key.as_deref()),
1080 ("openrouter", self.openrouter_api_key.as_deref()),
1081 ]
1082 .into_iter()
1083 .filter_map(|(name, key)| {
1084 key.map(|k| (leviath_core::provider_account(name), k.to_string()))
1085 })
1086 .collect()
1087 }
1088
1089 pub(crate) fn save_to_path(&self, path: &std::path::Path) -> anyhow::Result<()> {
1094 if let Some(parent) = path.parent() {
1096 create_config_dir(parent)?;
1097 }
1098
1099 let resolved = crate::credentials::store_for(self.security.credential_store);
1107 self.write_to(path, resolved)
1108 }
1109
1110 fn write_to(
1115 &self,
1116 path: &std::path::Path,
1117 resolved: crate::credentials::Resolved,
1118 ) -> anyhow::Result<()> {
1119 let to_write = match resolved.map_err(|e| anyhow::anyhow!("{e}"))? {
1120 Some(store) => {
1121 for (account, secret) in self.provider_secrets() {
1122 store
1123 .set(&account, &secret)
1124 .map_err(|e| anyhow::anyhow!("failed to store {account}: {e}"))?;
1125 }
1126 self.without_secrets()
1127 }
1128 None => self.clone(),
1129 };
1130
1131 let content =
1133 toml::to_string_pretty(&to_write).expect("Config serialization is infallible");
1134
1135 leviath_sys::write_private(path, content.as_bytes()).map_err(|e| {
1140 anyhow::anyhow!("Failed to write config to '{}': {}", path.display(), e)
1141 })?;
1142
1143 let path_display = path.display();
1144 tracing::debug!("Saved config to {}", path_display);
1145 Ok(())
1146 }
1147
1148 pub fn load_from_path_public(path: &std::path::Path) -> anyhow::Result<Self> {
1151 Self::load_from_path(path)
1152 }
1153
1154 pub fn save_to_path_public(&self, path: &std::path::Path) -> anyhow::Result<()> {
1156 self.save_to_path(path)
1157 }
1158
1159 pub fn config_path() -> PathBuf {
1173 if let Ok(override_path) = std::env::var("LEVIATH_CONFIG_PATH") {
1174 return PathBuf::from(override_path);
1175 }
1176 leviath_core::data_dir()
1177 .unwrap_or_default()
1178 .join("config.toml")
1179 }
1180
1181 pub fn validate_keys(&self) -> Vec<String> {
1186 let mut warnings = Vec::new();
1191 if let Some(key) = self.providers.anthropic_api_key.as_deref()
1192 && !key.trim().is_empty()
1193 && !key.starts_with("sk-ant-")
1194 {
1195 warnings.push(
1196 "Anthropic API key doesn't start with 'sk-ant-' - verify it's correct".to_string(),
1197 );
1198 }
1199 if let Some(key) = self.providers.openai_api_key.as_deref()
1200 && !key.trim().is_empty()
1201 && !key.starts_with("sk-")
1202 {
1203 warnings
1204 .push("OpenAI API key doesn't start with 'sk-' - verify it's correct".to_string());
1205 }
1206 warnings
1207 }
1208}
1209
1210pub use leviath_core::paths::home_dir as leviath_home_dir;
1217pub use leviath_core::paths::providers_dir;
1218
1219fn create_config_dir(dir: &std::path::Path) -> anyhow::Result<()> {
1221 std::fs::create_dir_all(dir)
1222 .map_err(|e| anyhow::anyhow!("Failed to create config directory: {}", e))?;
1223 set_dir_permissions(dir);
1224 Ok(())
1225}
1226
1227fn check_permissions() {
1231 check_permissions_at(&Config::config_path());
1232}
1233
1234fn check_permissions_at(path: &std::path::Path) {
1240 check_permissions_at_with(path, leviath_sys::ensure_file_private);
1241}
1242
1243fn check_permissions_at_with(
1251 path: &std::path::Path,
1252 ensure: fn(&std::path::Path) -> std::io::Result<Option<u32>>,
1253) {
1254 match ensure(path) {
1255 Ok(Some(old_mode)) => {
1256 let masked_mode = old_mode & 0o777;
1257 tracing::warn!(
1258 "Config file has overly permissive permissions ({:o}), fixing to 600",
1259 masked_mode
1260 );
1261 }
1262 Ok(None) => {}
1263 Err(e) => tracing::warn!("Failed to fix config file permissions: {}", e),
1264 }
1265}
1266
1267fn set_dir_permissions(path: &std::path::Path) {
1269 set_dir_permissions_with(path, leviath_sys::secure_dir_perms);
1270}
1271
1272fn set_dir_permissions_with(
1275 path: &std::path::Path,
1276 secure: fn(&std::path::Path) -> std::io::Result<()>,
1277) {
1278 if let Err(e) = secure(path) {
1279 tracing::warn!("Failed to set config directory permissions: {}", e);
1280 }
1281}
1282
1283#[cfg(test)]
1292pub(crate) static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1293
1294#[cfg(test)]
1314pub(crate) struct CwdTestGuard {
1315 original_cwd: std::path::PathBuf,
1316 _lock: std::sync::MutexGuard<'static, ()>,
1317}
1318
1319#[cfg(test)]
1320impl Drop for CwdTestGuard {
1321 fn drop(&mut self) {
1322 let _ = std::env::set_current_dir(&self.original_cwd);
1323 }
1324}
1325
1326#[cfg(test)]
1329pub(crate) fn isolate_cwd_for_test() -> CwdTestGuard {
1330 let lock = CWD_LOCK
1331 .lock()
1332 .unwrap_or_else(std::sync::PoisonError::into_inner);
1333 let original_cwd = std::env::current_dir().expect("current dir must be readable at test start");
1334 CwdTestGuard {
1335 original_cwd,
1336 _lock: lock,
1337 }
1338}
1339
1340#[cfg(test)]
1345const PROVIDER_KEY_ENV_VARS: &[&str] = &[
1346 "ANTHROPIC_API_KEY",
1347 "OPENAI_API_KEY",
1348 "GOOGLE_API_KEY",
1349 "OPENROUTER_API_KEY",
1350];
1351
1352#[cfg(test)]
1354fn make_fake_config_dir(unique: &str) -> std::path::PathBuf {
1355 let fake_dir = std::env::temp_dir().join(format!("lev-fake-config-{unique}"));
1356 let _ = std::fs::create_dir_all(&fake_dir);
1357 fake_dir
1358}
1359
1360#[cfg(test)]
1372pub(crate) fn config_isolation_vars(
1373 fake_dir: &std::path::Path,
1374) -> Vec<(&'static str, Option<std::ffi::OsString>)> {
1375 let mut vars: Vec<(&'static str, Option<std::ffi::OsString>)> = vec![
1376 (
1377 "LEVIATH_CONFIG_PATH",
1378 Some(fake_dir.join("config.toml").into_os_string()),
1379 ),
1380 ("LEVIATH_SKIP_DOTENV", Some(std::ffi::OsString::from("1"))),
1381 ];
1382 for &key in PROVIDER_KEY_ENV_VARS {
1383 vars.push((key, None));
1384 }
1385 vars
1386}
1387
1388#[cfg(test)]
1396pub(crate) fn with_isolated_config_path<R>(
1397 unique: &str,
1398 f: impl FnOnce(&std::path::Path) -> R,
1399) -> R {
1400 let fake_dir = make_fake_config_dir(unique);
1401 let result = temp_env::with_vars(config_isolation_vars(&fake_dir), || f(&fake_dir));
1402 let _ = std::fs::remove_dir_all(&fake_dir);
1403 result
1404}
1405
1406#[cfg(test)]
1409pub(crate) async fn with_isolated_config_path_async<R, Fut>(
1410 unique: &str,
1411 f: impl FnOnce(std::path::PathBuf) -> Fut,
1412) -> R
1413where
1414 Fut: std::future::Future<Output = R>,
1415{
1416 let fake_dir = make_fake_config_dir(unique);
1417 let result =
1418 temp_env::async_with_vars(config_isolation_vars(&fake_dir), f(fake_dir.clone())).await;
1419 let _ = std::fs::remove_dir_all(&fake_dir);
1420 result
1421}
1422
1423#[cfg(test)]
1424mod dotenv_tests {
1425 use super::*;
1426
1427 #[test]
1438 fn a_dot_env_in_the_working_directory_is_read() {
1439 let dir = make_fake_config_dir("dotenv-read");
1440 std::fs::write(dir.join(".env"), "LEV_DOTENV_PROBE=seen\n").unwrap();
1441
1442 {
1446 let _cwd = isolate_cwd_for_test();
1447 std::env::set_current_dir(&dir).unwrap();
1448
1449 temp_env::with_vars(
1450 [
1451 (
1452 "LEVIATH_CONFIG_PATH",
1453 Some(dir.join("config.toml").into_os_string()),
1454 ),
1455 ("LEVIATH_SKIP_DOTENV", None),
1456 ("LEV_DOTENV_PROBE", None),
1457 ],
1458 || {
1459 let loaded = Config::load();
1460 assert!(loaded.is_ok(), "a missing config file is not an error");
1461 assert_eq!(
1462 std::env::var("LEV_DOTENV_PROBE").ok().as_deref(),
1463 Some("seen"),
1464 "the .env beside the working directory was read"
1465 );
1466 },
1467 );
1468 }
1469 let _ = std::fs::remove_dir_all(&dir);
1470 }
1471}
1472
1473#[cfg(test)]
1474mod tests {
1475 const CONFIG_SCHEMA: &str = include_str!("../../../docs/schema/config.schema.json");
1478 const CONFIG_EXAMPLE: &str = include_str!("../../../docs/schema/config.example.toml");
1479
1480 fn schema_problems(
1482 validator: &jsonschema::Validator,
1483 value: &serde_json::Value,
1484 ) -> Vec<String> {
1485 validator
1486 .iter_errors(value)
1487 .map(|e| format!("{}: {e}", e.instance_path()))
1488 .collect()
1489 }
1490
1491 #[test]
1492 fn the_example_config_satisfies_the_published_schema_and_deserializes() {
1493 let example: toml::Value = toml::from_str(CONFIG_EXAMPLE).expect("the example is TOML");
1498 let schema: serde_json::Value =
1499 serde_json::from_str(CONFIG_SCHEMA).expect("the schema is JSON");
1500 let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
1501
1502 let json = serde_json::to_value(&example).expect("TOML converts to JSON");
1503 assert_eq!(
1504 schema_problems(&validator, &json),
1505 Vec::<String>::new(),
1506 "config.example.toml does not match config.schema.json"
1507 );
1508
1509 let parsed: Config = toml::from_str(CONFIG_EXAMPLE).expect("the example deserializes");
1510 assert_eq!(parsed.default_provider, "anthropic");
1513 assert_eq!(parsed.limits.interaction_timeout_secs, 3600);
1514 assert_eq!(parsed.mcp_servers.len(), 2);
1515 }
1516
1517 #[test]
1518 fn the_config_schema_rejects_a_key_that_is_not_a_setting() {
1519 let schema: serde_json::Value =
1522 serde_json::from_str(CONFIG_SCHEMA).expect("the schema is JSON");
1523 let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
1524 let rejects = |toml_text: &str| {
1527 let value: toml::Value = toml::from_str(toml_text).expect("valid TOML");
1528 let json = serde_json::to_value(&value).expect("converts");
1529 !schema_problems(&validator, &json).is_empty()
1530 };
1531
1532 assert!(
1533 rejects("default_provdier = \"anthropic\"\n"),
1534 "a typo'd key"
1535 );
1536 assert!(
1537 rejects("[limits]\ninteraction_timeout_secs = \"an hour\"\n"),
1538 "a string where a number belongs"
1539 );
1540 assert!(
1541 rejects("[security]\ncredential_store = \"vault\"\n"),
1542 "an unsupported credential store"
1543 );
1544 assert!(
1545 !rejects("default_provider = \"openrouter\"\n"),
1546 "a real key"
1547 );
1548 }
1549
1550 #[test]
1554 fn saving_with_an_unreachable_keychain_writes_nothing() {
1555 let dir = tempfile::tempdir().unwrap();
1556 let path = dir.path().join("config.toml");
1557 let mut config = Config::default();
1558 config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1559 config.providers.anthropic_api_key = Some("sk-ant".to_string());
1560
1561 assert!(
1562 config
1563 .write_to(&path, Err("no keychain".to_string()))
1564 .is_err()
1565 );
1566 assert!(!path.exists(), "no file may be written at all");
1567 }
1568
1569 #[test]
1571 fn saving_to_a_store_that_refuses_the_write_writes_nothing() {
1572 use leviath_core::CredentialStore as _;
1573
1574 struct Refuses;
1575 impl leviath_core::CredentialStore for Refuses {
1576 fn get(&self, _: &str) -> Result<Option<String>, String> {
1577 Ok(None)
1578 }
1579 fn set(&self, _: &str, _: &str) -> Result<(), String> {
1580 Err("read-only keychain".to_string())
1581 }
1582 fn delete(&self, _: &str) -> Result<bool, String> {
1583 Err("read-only keychain".to_string())
1584 }
1585 }
1586
1587 let dir = tempfile::tempdir().unwrap();
1588 let path = dir.path().join("config.toml");
1589 let mut config = Config::default();
1590 config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1591 config.providers.anthropic_api_key = Some("sk-ant".to_string());
1592
1593 assert_eq!(Refuses.get("provider/anthropic").unwrap(), None);
1596 assert!(Refuses.delete("provider/anthropic").is_err());
1597
1598 let err = config
1599 .write_to(&path, Ok(Some(Box::new(Refuses))))
1600 .expect_err("a refused write is not a save");
1601 assert!(err.to_string().contains("failed to store"), "{err}");
1602 assert!(!path.exists(), "no file may be written at all");
1603 }
1604
1605 #[test]
1608 fn saving_in_keychain_mode_puts_the_secrets_in_the_store_not_the_file() {
1609 use leviath_core::CredentialStore;
1610
1611 let dir = tempfile::tempdir().unwrap();
1612 let path = dir.path().join("config.toml");
1613 let mut config = Config::default();
1614 config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
1615 config.providers.anthropic_api_key = Some("sk-ant-secret".to_string());
1616 config.default_model = Some("some-model".to_string());
1617
1618 let store = std::sync::Arc::new(leviath_core::MemoryStore::new());
1619 struct Shared(std::sync::Arc<leviath_core::MemoryStore>);
1620 impl CredentialStore for Shared {
1621 fn get(&self, a: &str) -> Result<Option<String>, String> {
1622 self.0.get(a)
1623 }
1624 fn set(&self, a: &str, s: &str) -> Result<(), String> {
1625 self.0.set(a, s)
1626 }
1627 fn delete(&self, a: &str) -> Result<bool, String> {
1628 self.0.delete(a)
1629 }
1630 }
1631
1632 config
1633 .write_to(&path, Ok(Some(Box::new(Shared(store.clone())))))
1634 .unwrap();
1635
1636 assert!(
1638 Shared(store.clone())
1639 .delete(&leviath_core::provider_account("anthropic"))
1640 .unwrap()
1641 );
1642 store
1643 .set(
1644 &leviath_core::provider_account("anthropic"),
1645 "sk-ant-secret",
1646 )
1647 .unwrap();
1648
1649 let written = std::fs::read_to_string(&path).unwrap();
1650 assert!(!written.contains("sk-ant-secret"), "{written}");
1651 assert!(
1652 written.contains("some-model"),
1653 "settings survive: {written}"
1654 );
1655 assert_eq!(
1658 Shared(store.clone())
1659 .get(&leviath_core::provider_account("anthropic"))
1660 .unwrap()
1661 .as_deref(),
1662 Some("sk-ant-secret")
1663 );
1664 }
1665
1666 #[test]
1669 fn the_credential_store_fills_only_the_keys_that_are_unset() {
1670 use leviath_core::{CredentialStore, MemoryStore};
1671
1672 let store = MemoryStore::new();
1673 store
1674 .set(
1675 &leviath_core::provider_account("anthropic"),
1676 "from-keychain",
1677 )
1678 .unwrap();
1679 store
1680 .set(&leviath_core::provider_account("openai"), "openai-keychain")
1681 .unwrap();
1682 store
1683 .set(&leviath_core::provider_account("google"), "google-keychain")
1684 .unwrap();
1685 store
1686 .set(&leviath_core::provider_account("openrouter"), "or-keychain")
1687 .unwrap();
1688
1689 let mut config = Config::default();
1690 config.providers.anthropic_api_key = Some("from-file".to_string());
1692 config.apply_credential_store(&store);
1693
1694 assert_eq!(
1695 config.providers.anthropic_api_key.as_deref(),
1696 Some("from-file"),
1697 "an existing key wins over the keychain"
1698 );
1699 assert_eq!(
1700 config.providers.openai_api_key.as_deref(),
1701 Some("openai-keychain")
1702 );
1703 assert_eq!(
1704 config.providers.google_api_key.as_deref(),
1705 Some("google-keychain")
1706 );
1707 assert_eq!(config.openrouter_api_key.as_deref(), Some("or-keychain"));
1708 }
1709
1710 #[test]
1712 fn an_empty_credential_store_changes_nothing() {
1713 let mut config = Config::default();
1714 config.providers.openai_api_key = Some("keep-me".to_string());
1715 config.apply_credential_store(&leviath_core::MemoryStore::new());
1716 assert_eq!(config.providers.openai_api_key.as_deref(), Some("keep-me"));
1717 assert!(config.providers.anthropic_api_key.is_none());
1718 }
1719
1720 #[test]
1725 fn an_unreachable_credential_store_does_not_stop_the_config_loading() {
1726 use leviath_core::{CredentialStore, MemoryStore};
1727
1728 let mut config = Config::default();
1729 config.fill_from_credential_store_with(Err("no keychain here".to_string()));
1730 assert!(config.providers.anthropic_api_key.is_none());
1731
1732 let mut config = Config::default();
1734 config.providers.openai_api_key = Some("k".to_string());
1735 config.fill_from_credential_store_with(Ok(None));
1736 assert_eq!(config.providers.openai_api_key.as_deref(), Some("k"));
1737
1738 let store = MemoryStore::new();
1740 store
1741 .set(&leviath_core::provider_account("anthropic"), "filled")
1742 .unwrap();
1743 let mut config = Config::default();
1744 config.fill_from_credential_store_with(Ok(Some(Box::new(store))));
1745 assert_eq!(
1746 config.providers.anthropic_api_key.as_deref(),
1747 Some("filled")
1748 );
1749 }
1750
1751 #[test]
1752 fn provider_secrets_lists_every_set_key_and_nothing_else() {
1753 let mut config = Config::default();
1754 assert!(config.provider_secrets().is_empty());
1755
1756 config.providers.anthropic_api_key = Some("a".to_string());
1757 config.openrouter_api_key = Some("o".to_string());
1758 let secrets = config.provider_secrets();
1759 assert_eq!(secrets.len(), 2);
1760 assert!(secrets.contains(&("provider/anthropic".to_string(), "a".to_string())));
1761 assert!(secrets.contains(&("provider/openrouter".to_string(), "o".to_string())));
1762 }
1763
1764 #[test]
1768 fn without_secrets_strips_a_copy_and_leaves_the_original_usable() {
1769 let mut config = Config::default();
1770 config.providers.anthropic_api_key = Some("a".to_string());
1771 config.providers.openai_api_key = Some("b".to_string());
1772 config.providers.google_api_key = Some("c".to_string());
1773 config.openrouter_api_key = Some("d".to_string());
1774 config.default_model = Some("m".to_string());
1775
1776 let stripped = config.without_secrets();
1777 assert!(stripped.provider_secrets().is_empty(), "no keys survive");
1778 assert_eq!(stripped.default_model.as_deref(), Some("m"), "settings do");
1779 assert_eq!(
1780 config.providers.anthropic_api_key.as_deref(),
1781 Some("a"),
1782 "the original is untouched"
1783 );
1784 }
1785
1786 use super::*;
1787 use crate::test_support::with_tracing;
1788
1789 #[test]
1792 fn leviath_home_dir_uses_override_when_set() {
1793 temp_env::with_var(
1794 "LEVIATH_HOME",
1795 Some("/tmp/leviath-home-override-test"),
1796 || {
1797 assert_eq!(
1798 leviath_home_dir(),
1799 Some(std::path::PathBuf::from("/tmp/leviath-home-override-test"))
1800 );
1801 },
1802 );
1803 }
1804
1805 #[test]
1806 fn leviath_home_dir_falls_back_to_dirs_home_dir_when_unset() {
1807 temp_env::with_var_unset("LEVIATH_HOME", || {
1808 assert_eq!(leviath_home_dir(), dirs::home_dir());
1809 });
1810 }
1811
1812 #[test]
1815 fn load_from_path_missing_file_returns_defaults() {
1816 let dir = tempfile::tempdir().unwrap();
1817 let path = dir.path().join("config.toml");
1818 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1819 assert_eq!(config.default_provider, "anthropic");
1820 }
1821
1822 #[test]
1823 fn load_from_path_valid_toml_is_parsed() {
1824 let dir = tempfile::tempdir().unwrap();
1825 let path = dir.path().join("config.toml");
1826 let original = Config {
1827 default_provider: "openai".to_string(),
1828 ..Config::default()
1829 };
1830 std::fs::write(&path, toml::to_string_pretty(&original).unwrap()).unwrap();
1831 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1832 assert_eq!(config.default_provider, "openai");
1833 }
1834
1835 #[test]
1836 fn limits_default_to_bounded_values() {
1837 let limits = LimitsConfig::default();
1838 assert_eq!(limits.max_concurrent_inferences, Some(8));
1839 assert_eq!(limits.default_max_iterations, Some(50));
1840 assert!(!limits.exact_token_counting);
1842 assert_eq!(limits.dead_cycles_before_relief, 10);
1845 assert_eq!(limits.finished_retention_secs, 300);
1848 assert_eq!(limits.interaction_timeout_secs, 3600);
1851 assert_eq!(Config::default().limits.max_concurrent_inferences, Some(8));
1853 }
1854
1855 #[test]
1858 fn interaction_timeout_defaults_and_parses() {
1859 let dir = tempfile::tempdir().unwrap();
1860 let load = |body: String| {
1861 let path = dir.path().join(format!("{}.toml", body.len()));
1862 std::fs::write(&path, body).unwrap();
1863 with_tracing(|| Config::load_from_path(&path)).unwrap()
1864 };
1865
1866 let old = load(format!(
1867 "{}\n[limits]\nmax_concurrent_tools = 4\n",
1868 config_toml_without_limits()
1869 ));
1870 assert_eq!(old.limits.interaction_timeout_secs, 3600);
1871
1872 let disabled = load(format!(
1873 "{}\n[limits]\ninteraction_timeout_secs = 0\n",
1874 config_toml_without_limits()
1875 ));
1876 assert_eq!(disabled.limits.interaction_timeout_secs, 0);
1877 }
1878
1879 #[test]
1880 fn exact_token_counting_parses_when_set() {
1881 let dir = tempfile::tempdir().unwrap();
1882 let path = dir.path().join("config.toml");
1883 let body = format!(
1884 "{}\n[limits]\nexact_token_counting = true\n",
1885 config_toml_without_limits()
1886 );
1887 std::fs::write(&path, body).unwrap();
1888 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1889 assert!(config.limits.exact_token_counting);
1890 assert_eq!(config.limits.max_concurrent_inferences, Some(8));
1892 }
1893
1894 #[cfg(test)]
1898 fn config_toml_without_limits() -> String {
1899 let full = toml::to_string_pretty(&Config::default()).unwrap();
1900 format!("{}\n", full.split("[limits]").next().unwrap().trim_end())
1901 }
1902
1903 #[test]
1904 fn limits_absent_section_uses_defaults() {
1905 let dir = tempfile::tempdir().unwrap();
1907 let path = dir.path().join("config.toml");
1908 std::fs::write(&path, config_toml_without_limits()).unwrap();
1909 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1910 assert_eq!(config.limits.max_concurrent_inferences, Some(8));
1911 assert_eq!(config.limits.default_max_iterations, Some(50));
1912 assert_eq!(config.limits.dead_cycles_before_relief, 10);
1913 assert_eq!(config.limits.finished_retention_secs, 300);
1914 assert_eq!(config.limits.wedge_timeout_secs, 0);
1917 }
1918
1919 #[test]
1920 fn the_wedge_watchdog_is_off_until_it_is_configured() {
1921 let dir = tempfile::tempdir().unwrap();
1922 let path = dir.path().join("config.toml");
1923 let body = format!(
1924 "{}\n[limits]\nwedge_timeout_secs = 300\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.wedge_timeout_secs, 300);
1930 assert_eq!(config.limits.stall_timeout_secs, 60);
1932 }
1933
1934 #[test]
1935 fn limits_partial_section_fills_the_other_default() {
1936 let dir = tempfile::tempdir().unwrap();
1938 let path = dir.path().join("config.toml");
1939 let body = format!(
1940 "{}\n[limits]\nmax_concurrent_inferences = 3\n",
1941 config_toml_without_limits()
1942 );
1943 std::fs::write(&path, body).unwrap();
1944 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1945 assert_eq!(config.limits.max_concurrent_inferences, Some(3));
1946 assert_eq!(config.limits.default_max_iterations, Some(50));
1947 }
1948
1949 #[test]
1950 fn load_from_path_existing_provider_keys_skip_env_fallback() {
1951 let unset: Vec<(&str, Option<&str>)> = PROVIDER_KEY_ENV_VARS
1959 .iter()
1960 .chain(["OLLAMA_HOST"].iter())
1961 .map(|&key| (key, None))
1962 .collect();
1963 temp_env::with_vars(unset, || {
1964 let dir = tempfile::tempdir().unwrap();
1965 let path = dir.path().join("config.toml");
1966 std::fs::write(
1967 &path,
1968 r#"
1969default_provider = "anthropic"
1970openrouter_api_key = "sk-or-existing"
1971ollama_base_url = "http://existing-ollama:11434"
1972agent_paths = []
1973
1974[providers]
1975anthropic_api_key = "sk-ant-existing"
1976openai_api_key = "sk-openai-existing"
1977google_api_key = "AIza-existing"
1978"#,
1979 )
1980 .unwrap();
1981
1982 let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
1983
1984 assert_eq!(
1985 config.providers.anthropic_api_key.as_deref(),
1986 Some("sk-ant-existing")
1987 );
1988 assert_eq!(
1989 config.providers.openai_api_key.as_deref(),
1990 Some("sk-openai-existing")
1991 );
1992 assert_eq!(
1993 config.providers.google_api_key.as_deref(),
1994 Some("AIza-existing")
1995 );
1996 assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-existing"));
1997 assert_eq!(
1998 config.ollama_base_url.as_deref(),
1999 Some("http://existing-ollama:11434")
2000 );
2001 });
2002 }
2003
2004 #[test]
2005 fn load_from_path_malformed_toml_returns_error() {
2006 let dir = tempfile::tempdir().unwrap();
2007 let path = dir.path().join("config.toml");
2008 std::fs::write(&path, "not valid toml [[[").unwrap();
2009 let result = Config::load_from_path(&path);
2010 assert!(result.is_err());
2011 assert!(result.unwrap_err().to_string().contains("Failed to parse"));
2012 }
2013
2014 #[test]
2015 fn load_from_path_unreadable_path_returns_error() {
2016 let dir = tempfile::tempdir().unwrap();
2018 let result = Config::load_from_path(dir.path());
2019 assert!(result.is_err());
2020 }
2021
2022 #[test]
2023 fn save_to_path_writes_valid_toml_that_round_trips() {
2024 let dir = tempfile::tempdir().unwrap();
2025 let path = dir.path().join("nested").join("config.toml");
2026 let config = Config {
2027 default_provider: "google".to_string(),
2028 ..Config::default()
2029 };
2030 with_tracing(|| config.save_to_path(&path)).unwrap();
2031
2032 let loaded = with_tracing(|| Config::load_from_path(&path)).unwrap();
2033 assert_eq!(loaded.default_provider, "google");
2034 }
2035
2036 #[test]
2037 fn save_to_path_creates_parent_directory() {
2038 let dir = tempfile::tempdir().unwrap();
2039 let path = dir.path().join("a").join("b").join("config.toml");
2040 let config = Config::default();
2041 with_tracing(|| config.save_to_path(&path)).unwrap();
2042 assert!(path.exists());
2043 }
2044
2045 #[test]
2046 fn save_to_path_with_no_parent_skips_create_config_dir() {
2047 let result = Config::default().save_to_path(&std::path::PathBuf::from(""));
2054 assert!(result.is_err());
2055 }
2056
2057 #[cfg(unix)]
2058 #[test]
2059 fn save_to_path_sets_restrictive_file_permissions() {
2060 use std::os::unix::fs::PermissionsExt;
2061 let dir = tempfile::tempdir().unwrap();
2062 let path = dir.path().join("config.toml");
2063 with_tracing(|| Config::default().save_to_path(&path)).unwrap();
2064 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2065 assert_eq!(mode & 0o777, 0o600);
2066 }
2067
2068 #[test]
2069 fn save_to_path_write_failure_returns_error() {
2070 let dir = tempfile::tempdir().unwrap();
2075 let path = dir.path().join("config.toml");
2076 std::fs::create_dir_all(&path).unwrap();
2077
2078 let result = Config::default().save_to_path(&path);
2079
2080 assert!(result.is_err());
2081 assert!(
2082 result
2083 .unwrap_err()
2084 .to_string()
2085 .contains("Failed to write config")
2086 );
2087 }
2088
2089 #[test]
2090 fn save_to_path_create_config_dir_failure_returns_error() {
2091 let dir = tempfile::tempdir().unwrap();
2092 let blocking_file = dir.path().join("not-a-dir");
2093 std::fs::write(&blocking_file, "").unwrap();
2094 let path = blocking_file.join("config.toml");
2095 let result = Config::default().save_to_path(&path);
2096 assert!(result.is_err());
2097 assert!(
2098 result
2099 .unwrap_err()
2100 .to_string()
2101 .contains("Failed to create config directory")
2102 );
2103 }
2104
2105 #[test]
2106 fn load_propagates_error_when_real_config_file_is_malformed() {
2107 with_isolated_config_path("load-malformed", |fake_dir| {
2112 std::fs::write(fake_dir.join("config.toml"), "not valid toml [[[").unwrap();
2113
2114 let result = Config::load();
2115
2116 assert!(result.is_err());
2117 });
2118 }
2119
2120 #[cfg(unix)]
2123 #[test]
2124 fn check_permissions_at_missing_file_is_noop() {
2125 let dir = tempfile::tempdir().unwrap();
2126 let path = dir.path().join("nonexistent.toml");
2127 check_permissions_at(&path); }
2129
2130 #[cfg(unix)]
2131 #[test]
2132 fn check_permissions_at_fixes_overly_permissive_file() {
2133 use std::os::unix::fs::PermissionsExt;
2134 let dir = tempfile::tempdir().unwrap();
2135 let path = dir.path().join("config.toml");
2136 std::fs::write(&path, "").unwrap();
2137 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2138
2139 with_tracing(|| check_permissions_at(&path));
2140
2141 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2142 assert_eq!(mode & 0o777, 0o600);
2143 }
2144
2145 #[cfg(unix)]
2146 #[test]
2147 fn check_permissions_at_leaves_already_restrictive_file_alone() {
2148 use std::os::unix::fs::PermissionsExt;
2149 let dir = tempfile::tempdir().unwrap();
2150 let path = dir.path().join("config.toml");
2151 std::fs::write(&path, "").unwrap();
2152 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2153
2154 check_permissions_at(&path);
2155
2156 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2157 assert_eq!(mode & 0o777, 0o600);
2158 }
2159
2160 #[test]
2168 fn check_permissions_at_with_logs_when_fix_fails() {
2169 fn ensure_fails(_: &std::path::Path) -> std::io::Result<Option<u32>> {
2170 Err(std::io::Error::other("simulated chmod failure"))
2171 }
2172 with_tracing(|| {
2174 check_permissions_at_with(std::path::Path::new("/does/not/matter"), ensure_fails)
2175 });
2176 }
2177
2178 #[test]
2179 fn check_permissions_at_with_logs_when_file_is_permissive() {
2180 fn ensure_permissive(_: &std::path::Path) -> std::io::Result<Option<u32>> {
2181 Ok(Some(0o100644))
2182 }
2183 with_tracing(|| {
2184 check_permissions_at_with(std::path::Path::new("/does/not/matter"), ensure_permissive)
2185 });
2186 }
2187
2188 fn always_failing_secure(_path: &std::path::Path) -> std::io::Result<()> {
2194 Err(std::io::Error::other(
2195 "simulated permission-hardening failure",
2196 ))
2197 }
2198
2199 #[test]
2200 fn set_dir_permissions_error_branch_logs_not_panics() {
2201 with_tracing(|| {
2202 set_dir_permissions_with(
2203 std::path::Path::new("/does/not/matter"),
2204 always_failing_secure,
2205 )
2206 }); }
2208
2209 #[test]
2214 fn create_config_dir_creates_nested_dirs() {
2215 let dir = tempfile::tempdir().unwrap();
2216 let target = dir.path().join("a").join("b").join("c");
2217 create_config_dir(&target).unwrap();
2218 assert!(target.is_dir());
2219 }
2220
2221 #[cfg(unix)]
2222 #[test]
2223 fn create_config_dir_sets_restrictive_permissions() {
2224 use std::os::unix::fs::PermissionsExt;
2225 let dir = tempfile::tempdir().unwrap();
2226 let target = dir.path().join("leviath");
2227 create_config_dir(&target).unwrap();
2228 let mode = std::fs::metadata(&target).unwrap().permissions().mode();
2229 assert_eq!(mode & 0o777, 0o700);
2230 }
2231
2232 #[cfg(unix)]
2236 #[test]
2244 fn config_path_honors_leviath_home() {
2245 temp_env::with_vars(
2246 [
2247 ("LEVIATH_CONFIG_PATH", None::<&str>),
2248 ("LEVIATH_HOME", Some("/tmp/lev-cfg-test")),
2249 ],
2250 || {
2251 assert_eq!(
2252 Config::config_path(),
2253 std::path::PathBuf::from("/tmp/lev-cfg-test/.leviath/config.toml")
2254 );
2255 },
2256 );
2257 }
2258
2259 #[test]
2261 fn config_path_prefers_the_explicit_override() {
2262 temp_env::with_vars(
2263 [
2264 ("LEVIATH_CONFIG_PATH", Some("/tmp/exact.toml")),
2265 ("LEVIATH_HOME", Some("/tmp/lev-cfg-test")),
2266 ],
2267 || {
2268 assert_eq!(
2269 Config::config_path(),
2270 std::path::PathBuf::from("/tmp/exact.toml")
2271 );
2272 },
2273 );
2274 }
2275
2276 #[test]
2280 fn permissions_for_agent_overlays_the_named_grant_on_the_global() {
2281 let mut config = Config::default();
2282 config
2283 .tool_permissions
2284 .insert("shell".to_string(), ToolPolicy::Ask);
2285 config
2286 .tool_permissions
2287 .insert("write_file".to_string(), ToolPolicy::Deny);
2288 config.agent_tool_permissions.insert(
2289 "coder".to_string(),
2290 HashMap::from([("shell".to_string(), ToolPolicy::Allow)]),
2291 );
2292
2293 let coder = config.permissions_for_agent("coder");
2294 assert_eq!(coder.get("shell"), Some(&ToolPolicy::Allow), "granted");
2295 assert_eq!(
2296 coder.get("write_file"),
2297 Some(&ToolPolicy::Deny),
2298 "the rest of the global ceiling still applies"
2299 );
2300
2301 let other = config.permissions_for_agent("researcher");
2303 assert_eq!(other.get("shell"), Some(&ToolPolicy::Ask));
2304 }
2305
2306 #[test]
2309 fn read_path_grants_merge_global_and_per_agent() {
2310 let mut config = Config::default();
2311 assert!(
2312 !config.security.allow_blueprint_read_paths,
2313 "blueprint read paths must be opt-in"
2314 );
2315 assert!(config.read_path_grants_for_agent("cto").is_empty());
2316
2317 config.security.read_paths = vec!["~/.leviath/runs".to_string()];
2318 config.agent_read_paths.insert(
2319 "cto".to_string(),
2320 ReadPathGrants {
2321 allow: vec!["glob:~/design-docs/**".to_string()],
2322 },
2323 );
2324
2325 assert_eq!(
2326 config.read_path_grants_for_agent("cto"),
2327 vec![
2328 "~/.leviath/runs".to_string(),
2329 "glob:~/design-docs/**".to_string(),
2330 ]
2331 );
2332 assert_eq!(
2334 config.read_path_grants_for_agent("researcher"),
2335 vec!["~/.leviath/runs".to_string()]
2336 );
2337 }
2338
2339 #[test]
2342 fn provider_config_debug_never_prints_the_keys() {
2343 let providers = ProviderConfig {
2344 anthropic_api_key: Some("sk-ant-SECRET-VALUE".to_string()),
2345 openai_api_key: Some("sk-openai-SECRET-VALUE".to_string()),
2346 google_api_key: Some("AIza-SECRET-VALUE".to_string()),
2347 claude_code_enabled: true,
2348 claude_code_binary: None,
2349 claude_code_effort: None,
2350 fallback_order: Vec::new(),
2351 };
2352 let rendered = format!("{providers:?}");
2353 assert!(!rendered.contains("SECRET-VALUE"), "key leaked: {rendered}");
2354 assert!(rendered.contains("<set>"), "{rendered}");
2356 assert!(rendered.contains("claude_code_enabled: true"), "{rendered}");
2357
2358 let empty = format!(
2359 "{:?}",
2360 ProviderConfig {
2361 anthropic_api_key: None,
2362 openai_api_key: None,
2363 google_api_key: None,
2364 claude_code_enabled: false,
2365 claude_code_binary: None,
2366 claude_code_effort: None,
2367 fallback_order: Vec::new(),
2368 }
2369 );
2370 assert!(empty.contains("<unset>"), "{empty}");
2371 }
2372
2373 #[cfg(unix)]
2377 #[test]
2378 fn saving_a_config_never_leaves_it_group_or_world_readable() {
2379 use std::os::unix::fs::PermissionsExt;
2380 let dir = tempfile::tempdir().unwrap();
2381 let path = dir.path().join("config.toml");
2382
2383 Config::default().save_to_path(&path).unwrap();
2384 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2385 assert_eq!(mode & 0o777, 0o600, "fresh config must be owner-only");
2386
2387 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2389 Config::default().save_to_path(&path).unwrap();
2390 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2391 assert_eq!(mode & 0o777, 0o600, "re-saving must re-tighten");
2392 }
2393
2394 #[cfg(unix)]
2395 #[test]
2396 fn set_dir_permissions_sets_0700() {
2397 use std::os::unix::fs::PermissionsExt;
2398 let dir = tempfile::tempdir().unwrap();
2399 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
2400 set_dir_permissions(dir.path());
2401 let mode = std::fs::metadata(dir.path()).unwrap().permissions().mode();
2402 assert_eq!(mode & 0o777, 0o700);
2403 }
2404
2405 #[test]
2406 fn test_validate_keys_good_anthropic() {
2407 let config = Config {
2408 providers: ProviderConfig {
2409 anthropic_api_key: Some("sk-ant-test123".to_string()),
2410 openai_api_key: None,
2411 google_api_key: None,
2412 claude_code_enabled: false,
2413 claude_code_binary: None,
2414 claude_code_effort: None,
2415 fallback_order: Vec::new(),
2416 },
2417 ..Config::default()
2418 };
2419 assert!(config.validate_keys().is_empty());
2420 }
2421
2422 #[test]
2423 fn test_validate_keys_bad_anthropic() {
2424 let config = Config {
2425 providers: ProviderConfig {
2426 anthropic_api_key: Some("bad-key".to_string()),
2427 openai_api_key: None,
2428 google_api_key: None,
2429 claude_code_enabled: false,
2430 claude_code_binary: None,
2431 claude_code_effort: None,
2432 fallback_order: Vec::new(),
2433 },
2434 ..Config::default()
2435 };
2436 let warnings = config.validate_keys();
2437 assert_eq!(warnings.len(), 1);
2438 assert!(warnings[0].contains("Anthropic"));
2439 }
2440
2441 #[test]
2442 fn test_validate_keys_good_openai() {
2443 let config = Config {
2444 providers: ProviderConfig {
2445 anthropic_api_key: None,
2446 openai_api_key: Some("sk-test123".to_string()),
2447 google_api_key: None,
2448 claude_code_enabled: false,
2449 claude_code_binary: None,
2450 claude_code_effort: None,
2451 fallback_order: Vec::new(),
2452 },
2453 ..Config::default()
2454 };
2455 assert!(config.validate_keys().is_empty());
2456 }
2457
2458 #[test]
2459 fn test_validate_keys_bad_openai() {
2460 let config = Config {
2461 providers: ProviderConfig {
2462 anthropic_api_key: None,
2463 openai_api_key: Some("bad-key".to_string()),
2464 google_api_key: None,
2465 claude_code_enabled: false,
2466 claude_code_binary: None,
2467 claude_code_effort: None,
2468 fallback_order: Vec::new(),
2469 },
2470 ..Config::default()
2471 };
2472 let warnings = config.validate_keys();
2473 assert_eq!(warnings.len(), 1);
2474 assert!(warnings[0].contains("OpenAI"));
2475 }
2476
2477 #[test]
2478 fn test_validate_keys_no_keys() {
2479 let config = Config::default();
2480 assert!(config.validate_keys().is_empty());
2481 }
2482
2483 #[test]
2486 fn config_default_values() {
2487 let config = Config::default();
2488 assert_eq!(config.default_provider, "anthropic");
2489 assert!(config.providers.anthropic_api_key.is_none());
2490 assert!(config.providers.openai_api_key.is_none());
2491 assert!(config.providers.google_api_key.is_none());
2492 assert!(config.openrouter_api_key.is_none());
2493 assert!(config.ollama_base_url.is_none());
2494 assert!(config.mcp_servers.is_empty());
2495 assert!(config.default_model.is_none());
2496 assert!(config.model_capabilities.is_empty());
2497 assert!(config.tool_permissions.is_empty());
2498 }
2499
2500 #[test]
2503 fn title_config_default() {
2504 let tc = TitleConfig::default();
2505 assert!(tc.enabled);
2506 assert!(tc.provider.is_none());
2507 assert!(tc.model.is_none());
2508 }
2509
2510 #[test]
2511 fn title_config_serde_roundtrip() {
2512 let tc = TitleConfig {
2513 enabled: false,
2514 provider: Some("openai".to_string()),
2515 model: Some("gpt-5.4-mini".to_string()),
2516 };
2517 let json = serde_json::to_string(&tc).unwrap();
2518 let back: TitleConfig = serde_json::from_str(&json).unwrap();
2519 assert!(!back.enabled);
2520 assert_eq!(back.provider.as_deref(), Some("openai"));
2521 assert_eq!(back.model.as_deref(), Some("gpt-5.4-mini"));
2522 }
2523
2524 #[test]
2527 fn tool_policy_default_is_ask() {
2528 let policy = ToolPolicy::default();
2529 assert_eq!(policy, ToolPolicy::Ask);
2530 }
2531
2532 #[test]
2533 fn tool_policy_serde_roundtrip() {
2534 for policy in [ToolPolicy::Allow, ToolPolicy::Ask, ToolPolicy::Deny] {
2535 let json = serde_json::to_string(&policy).unwrap();
2536 let back: ToolPolicy = serde_json::from_str(&json).unwrap();
2537 assert_eq!(policy, back);
2538 }
2539 }
2540
2541 #[test]
2542 fn tool_policy_snake_case_serialization() {
2543 assert_eq!(
2544 serde_json::to_string(&ToolPolicy::Allow).unwrap(),
2545 "\"allow\""
2546 );
2547 assert_eq!(serde_json::to_string(&ToolPolicy::Ask).unwrap(), "\"ask\"");
2548 assert_eq!(
2549 serde_json::to_string(&ToolPolicy::Deny).unwrap(),
2550 "\"deny\""
2551 );
2552 }
2553
2554 #[test]
2557 fn config_from_toml_with_all_fields() {
2558 let toml_content = r#"
2559default_provider = "openai"
2560openrouter_api_key = "sk-or-test"
2561ollama_base_url = "http://my-ollama:11434"
2562default_model = "gpt-5"
2563agent_paths = []
2564
2565[providers]
2566anthropic_api_key = "sk-ant-test"
2567openai_api_key = "sk-test"
2568google_api_key = "AIza-test"
2569
2570[tool_permissions]
2571bash = "deny"
2572read_file = "allow"
2573
2574[title]
2575enabled = false
2576provider = "anthropic"
2577model = "claude-haiku-4-5"
2578"#;
2579 let config: Config = toml::from_str(toml_content).unwrap();
2580 assert_eq!(config.default_provider, "openai");
2581 assert_eq!(
2582 config.providers.anthropic_api_key.as_deref(),
2583 Some("sk-ant-test")
2584 );
2585 assert_eq!(config.providers.openai_api_key.as_deref(), Some("sk-test"));
2586 assert_eq!(
2587 config.providers.google_api_key.as_deref(),
2588 Some("AIza-test")
2589 );
2590 assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-test"));
2591 assert_eq!(
2592 config.ollama_base_url.as_deref(),
2593 Some("http://my-ollama:11434")
2594 );
2595 assert_eq!(config.default_model.as_deref(), Some("gpt-5"));
2596 assert!(!config.title.enabled);
2597 assert_eq!(config.tool_permissions.get("bash"), Some(&ToolPolicy::Deny));
2598 assert_eq!(
2599 config.tool_permissions.get("read_file"),
2600 Some(&ToolPolicy::Allow)
2601 );
2602 }
2603
2604 #[test]
2605 fn config_from_minimal_toml() {
2606 let toml_content = r#"
2607default_provider = "anthropic"
2608agent_paths = []
2609
2610[providers]
2611"#;
2612 let config: Config = toml::from_str(toml_content).unwrap();
2613 assert_eq!(config.default_provider, "anthropic");
2614 assert!(config.providers.anthropic_api_key.is_none());
2615 }
2616
2617 #[test]
2618 fn the_three_lines_that_point_leviath_at_openrouter_are_enough() {
2619 let config: Config = toml::from_str(
2624 r#"
2625default_provider = "openrouter"
2626default_model = "openai/gpt-4o-mini"
2627openrouter_api_key = "sk-or-test"
2628"#,
2629 )
2630 .expect("a hand-written OpenRouter config parses");
2631 assert_eq!(config.default_provider, "openrouter");
2632 assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-test"));
2633 assert_eq!(config.default_model.as_deref(), Some("openai/gpt-4o-mini"));
2634 }
2635
2636 #[test]
2637 fn an_empty_config_file_parses_to_the_defaults() {
2638 let parsed: Config = toml::from_str("").expect("an empty config parses");
2642 let default = Config::default();
2643 assert_eq!(parsed.default_provider, default.default_provider);
2644 assert_eq!(parsed.agent_paths, default.agent_paths);
2645 assert_eq!(parsed.openrouter_api_key, default.openrouter_api_key);
2646 assert_eq!(parsed.ollama_base_url, default.ollama_base_url);
2647 assert_eq!(parsed.default_model, default.default_model);
2648 assert_eq!(parsed.request_timeout_secs, default.request_timeout_secs);
2649 assert_eq!(
2650 parsed.providers.anthropic_api_key,
2651 default.providers.anthropic_api_key
2652 );
2653 assert_eq!(
2654 parsed.providers.claude_code_enabled,
2655 default.providers.claude_code_enabled
2656 );
2657 }
2658
2659 #[test]
2660 fn config_from_toml_with_mcp_servers() {
2661 let toml_content = r#"
2662default_provider = "anthropic"
2663agent_paths = []
2664
2665[providers]
2666
2667[[mcp_servers]]
2668name = "test-server"
2669command = "echo"
2670args = ["hello"]
2671"#;
2672 let config: Config = toml::from_str(toml_content).unwrap();
2673 assert_eq!(config.mcp_servers.len(), 1);
2674 assert_eq!(config.mcp_servers[0].name, "test-server");
2675 }
2676
2677 #[test]
2678 fn load_rejects_a_malformed_mcp_server_entry() {
2679 let dir = tempfile::tempdir().unwrap();
2683 let path = dir.path().join("config.toml");
2684 std::fs::write(
2685 &path,
2686 r#"
2687default_provider = "anthropic"
2688agent_paths = []
2689
2690[providers]
2691
2692[[mcp_servers]]
2693name = "broken"
2694"#,
2695 )
2696 .unwrap();
2697
2698 let err = Config::load_from_path(&path).expect_err("malformed entry must fail load");
2699 let msg = err.to_string();
2700 assert!(msg.contains("broken"), "must name the server: {msg}");
2701 }
2702
2703 #[test]
2704 fn load_accepts_a_well_formed_http_mcp_server() {
2705 let dir = tempfile::tempdir().unwrap();
2706 let path = dir.path().join("config.toml");
2707 std::fs::write(
2708 &path,
2709 r#"
2710default_provider = "anthropic"
2711agent_paths = []
2712
2713[providers]
2714
2715[[mcp_servers]]
2716name = "remote"
2717url = "https://mcp.example.com/mcp"
2718"#,
2719 )
2720 .unwrap();
2721
2722 let config = Config::load_from_path(&path).expect("valid http entry should load");
2723 assert_eq!(
2724 config.mcp_servers[0].url.as_deref(),
2725 Some("https://mcp.example.com/mcp")
2726 );
2727 }
2728
2729 #[test]
2730 fn config_from_toml_with_model_capabilities() {
2731 let toml_content = r#"
2732default_provider = "anthropic"
2733agent_paths = []
2734
2735[providers]
2736
2737[model_capabilities."my-custom-model"]
2738supports_temperature = true
2739supports_streaming = false
2740supports_tools = true
2741supports_system_prompt = true
2742max_context_tokens = 4096
2743max_output_tokens = 2048
2744"#;
2745 let config: Config = toml::from_str(toml_content).unwrap();
2746 let caps = config.model_capabilities.get("my-custom-model").unwrap();
2747 assert!(caps.supports_temperature);
2748 assert!(!caps.supports_streaming);
2749 assert_eq!(caps.max_context_tokens, 4096);
2750 assert_eq!(caps.max_output_tokens, 2048);
2751 }
2752
2753 #[test]
2759 fn validate_keys_is_quiet_about_blank_keys() {
2760 let mut config = Config::default();
2761 config.providers.anthropic_api_key = Some(String::new());
2762 config.providers.openai_api_key = Some(" ".to_string());
2763 assert!(config.validate_keys().is_empty());
2764 config.providers.anthropic_api_key = Some("nope".to_string());
2766 assert_eq!(config.validate_keys().len(), 1);
2767 }
2768
2769 #[test]
2770 fn validate_keys_both_bad() {
2771 let config = Config {
2772 providers: ProviderConfig {
2773 anthropic_api_key: Some("bad".to_string()),
2774 openai_api_key: Some("bad".to_string()),
2775 google_api_key: None,
2776 claude_code_enabled: false,
2777 claude_code_binary: None,
2778 claude_code_effort: None,
2779 fallback_order: Vec::new(),
2780 },
2781 ..Config::default()
2782 };
2783 let warnings = config.validate_keys();
2784 assert_eq!(warnings.len(), 2);
2785 }
2786
2787 #[test]
2790 fn config_path_contains_leviath() {
2791 temp_env::with_var_unset("LEVIATH_CONFIG_PATH", || {
2796 let path = Config::config_path();
2797 assert!(path.to_str().unwrap().contains(".leviath"));
2798 assert!(path.to_str().unwrap().ends_with("config.toml"));
2799 });
2800 }
2801
2802 #[test]
2805 fn config_toml_roundtrip() {
2806 let config = Config {
2807 default_provider: "openai".to_string(),
2808 providers: ProviderConfig {
2809 anthropic_api_key: Some("sk-ant-key".to_string()),
2810 openai_api_key: None,
2811 google_api_key: None,
2812 claude_code_enabled: false,
2813 claude_code_binary: None,
2814 claude_code_effort: None,
2815 fallback_order: Vec::new(),
2816 },
2817 tool_permissions: {
2818 let mut m = HashMap::new();
2819 m.insert("bash".to_string(), ToolPolicy::Deny);
2820 m
2821 },
2822 ..Config::default()
2823 };
2824
2825 let serialized = toml::to_string_pretty(&config).unwrap();
2826 let deserialized: Config = toml::from_str(&serialized).unwrap();
2827 assert_eq!(deserialized.default_provider, "openai");
2828 assert_eq!(
2829 deserialized.providers.anthropic_api_key.as_deref(),
2830 Some("sk-ant-key")
2831 );
2832 assert_eq!(
2833 deserialized.tool_permissions.get("bash"),
2834 Some(&ToolPolicy::Deny)
2835 );
2836 }
2837
2838 #[test]
2841 fn validate_keys_both_valid() {
2842 let config = Config {
2843 providers: ProviderConfig {
2844 anthropic_api_key: Some("sk-ant-good-key".to_string()),
2845 openai_api_key: Some("sk-good-key".to_string()),
2846 google_api_key: None,
2847 claude_code_enabled: false,
2848 claude_code_binary: None,
2849 claude_code_effort: None,
2850 fallback_order: Vec::new(),
2851 },
2852 ..Config::default()
2853 };
2854 assert!(config.validate_keys().is_empty());
2855 }
2856
2857 #[test]
2860 fn validate_keys_google_key_not_validated() {
2861 let config = Config {
2862 providers: ProviderConfig {
2863 anthropic_api_key: None,
2864 openai_api_key: None,
2865 google_api_key: Some("anything-goes".to_string()),
2866 claude_code_enabled: false,
2867 claude_code_binary: None,
2868 claude_code_effort: None,
2869 fallback_order: Vec::new(),
2870 },
2871 ..Config::default()
2872 };
2873 assert!(config.validate_keys().is_empty());
2875 }
2876
2877 #[test]
2880 fn config_from_toml_custom_registries() {
2881 let toml_content = r#"
2882default_provider = "anthropic"
2883agent_paths = ["/my/agents"]
2884
2885[providers]
2886"#;
2887 let config: Config = toml::from_str(toml_content).unwrap();
2888 assert_eq!(config.agent_paths.len(), 1);
2889 }
2890
2891 #[test]
2894 fn config_save_creates_file() {
2895 let dir = tempfile::tempdir().unwrap();
2896 let config_path = dir.path().join("subdir").join("config.toml");
2897 let config = Config::default();
2900 let content = toml::to_string_pretty(&config).unwrap();
2901 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
2902 std::fs::write(&config_path, &content).unwrap();
2903 assert!(config_path.exists());
2904 let loaded_content = std::fs::read_to_string(&config_path).unwrap();
2905 let loaded: Config = toml::from_str(&loaded_content).unwrap();
2906 assert_eq!(loaded.default_provider, "anthropic");
2907 }
2908
2909 #[test]
2912 fn title_config_from_toml_defaults() {
2913 let toml_content = r#"
2914default_provider = "anthropic"
2915agent_paths = []
2916
2917[providers]
2918"#;
2919 let config: Config = toml::from_str(toml_content).unwrap();
2920 assert!(config.title.enabled);
2921 assert!(config.title.provider.is_none());
2922 assert!(config.title.model.is_none());
2923 }
2924
2925 #[test]
2926 fn title_config_from_toml_disabled() {
2927 let toml_content = r#"
2928default_provider = "anthropic"
2929agent_paths = []
2930
2931[providers]
2932
2933[title]
2934enabled = false
2935"#;
2936 let config: Config = toml::from_str(toml_content).unwrap();
2937 assert!(!config.title.enabled);
2938 }
2939
2940 #[test]
2941 fn title_config_missing_enabled_key_uses_default_true() {
2942 let toml_content = r#"
2949default_provider = "anthropic"
2950agent_paths = []
2951
2952[providers]
2953
2954[title]
2955provider = "openai"
2956"#;
2957 let config: Config = toml::from_str(toml_content).unwrap();
2958 assert!(config.title.enabled);
2959 assert_eq!(config.title.provider.as_deref(), Some("openai"));
2960 }
2961
2962 #[test]
2965 fn config_tool_permissions_allow() {
2966 let toml_content = r#"
2967default_provider = "anthropic"
2968agent_paths = []
2969
2970[providers]
2971
2972[tool_permissions]
2973read_file = "allow"
2974write_file = "ask"
2975bash = "deny"
2976"#;
2977 let config: Config = toml::from_str(toml_content).unwrap();
2978 assert_eq!(
2979 config.tool_permissions.get("read_file"),
2980 Some(&ToolPolicy::Allow)
2981 );
2982 assert_eq!(
2983 config.tool_permissions.get("write_file"),
2984 Some(&ToolPolicy::Ask)
2985 );
2986 assert_eq!(config.tool_permissions.get("bash"), Some(&ToolPolicy::Deny));
2987 }
2988
2989 #[test]
2992 fn config_with_agent_paths() {
2993 let toml_content = r#"
2994default_provider = "anthropic"
2995agent_paths = ["/home/user/agents", "/opt/agents"]
2996
2997[providers]
2998"#;
2999 let config: Config = toml::from_str(toml_content).unwrap();
3000 assert_eq!(config.agent_paths.len(), 2);
3001 }
3002
3003 #[test]
3006 fn config_load_from_nonexistent_path_returns_default() {
3007 let config = Config::default();
3011 assert_eq!(config.default_provider, "anthropic");
3012 assert!(config.providers.anthropic_api_key.is_none());
3013 }
3014
3015 #[test]
3016 fn config_load_from_toml_string() {
3017 let toml_content = r#"
3019default_provider = "openai"
3020agent_paths = []
3021
3022[providers]
3023anthropic_api_key = "sk-ant-test-key"
3024"#;
3025 let config: Config = toml::from_str(toml_content).unwrap();
3026 assert_eq!(config.default_provider, "openai");
3027 assert_eq!(
3028 config.providers.anthropic_api_key.as_deref(),
3029 Some("sk-ant-test-key")
3030 );
3031 assert_eq!(config.nudge, leviath_core::NudgeConfig::default());
3033 }
3034
3035 #[test]
3036 fn config_parses_partial_nudge_section() {
3037 let config: Config = toml::from_str(
3039 r#"
3040default_provider = "openai"
3041agent_paths = []
3042
3043[providers]
3044
3045[nudge]
3046enabled = false
3047"#,
3048 )
3049 .unwrap();
3050 assert_eq!(config.nudge.enabled, Some(false));
3051 assert_eq!(config.nudge.max, None);
3052 assert_eq!(config.nudge.text, None);
3053 }
3054
3055 #[test]
3056 fn config_save_and_load_with_file() {
3057 let dir = tempfile::tempdir().unwrap();
3059 let config_path = dir.path().join("config.toml");
3060
3061 let config = Config {
3062 default_provider: "openai".to_string(),
3063 providers: ProviderConfig {
3064 anthropic_api_key: Some("sk-ant-test".to_string()),
3065 openai_api_key: Some("sk-test".to_string()),
3066 google_api_key: None,
3067 claude_code_enabled: false,
3068 claude_code_binary: None,
3069 claude_code_effort: 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 ModelCapabilities {
3112 supports_temperature: true,
3113 supports_streaming: true,
3114 supports_tools: true,
3115 supports_system_prompt: true,
3116 max_context_tokens: 8192,
3117 max_output_tokens: 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 fallback_order: Vec::new(),
3133 },
3134 agent_paths: vec![std::path::PathBuf::from("/my/agents")],
3135 openrouter_api_key: None,
3136 ollama_base_url: Some("http://custom:11434".to_string()),
3137 mcp_servers: vec![],
3138 default_model: None,
3139 model_capabilities: model_caps,
3140 model_providers: HashMap::new(),
3141 tool_permissions: tool_perms,
3142 agent_tool_permissions: HashMap::new(),
3143 title: TitleConfig {
3144 enabled: false,
3145 provider: Some("openai".to_string()),
3146 model: Some("gpt-5-mini".to_string()),
3147 },
3148 request_timeout_secs: None,
3149 rate_limits: HashMap::new(),
3150 taint_tracking: false,
3151 limits: LimitsConfig {
3152 max_concurrent_inferences: Some(4),
3153 max_concurrent_tools: 3,
3154 default_max_iterations: Some(99),
3155 exact_token_counting: false,
3156 script_shell_timeout_secs: 45,
3157 stall_timeout_secs: 90,
3158 dead_cycles_before_relief: 6,
3159 finished_retention_secs: 120,
3160 wedge_timeout_secs: 420,
3161 provider_failures_before_open: 5,
3162 provider_circuit_cooldown_secs: 120,
3163 interaction_timeout_secs: 120,
3164 },
3165 batch_tool_hint: true,
3166 shell_hint: false,
3167 nudge: leviath_core::NudgeConfig {
3168 enabled: Some(true),
3169 max: Some(2),
3170 text: Some("Use your tools.".to_string()),
3171 },
3172 webhook: WebhookConfig {
3173 max_retries: 5,
3174 base_delay_ms: 250,
3175 max_delay_ms: 10_000,
3176 timeout_secs: 7,
3177 },
3178 observability: ObservabilityConfig {
3179 enabled: true,
3180 exporter: TelemetryExporterKind::Stdout,
3181 endpoint: Some("http://collector:4318".to_string()),
3182 service_name: Some("leviath-prod".to_string()),
3183 },
3184 sandbox: Some(leviath_core::ToolSandboxConfig {
3185 kind: leviath_core::SandboxKind::Container,
3186 image: Some("ubuntu:24.04".to_string()),
3187 network: false,
3188 ..Default::default()
3189 }),
3190 tool_script_permissions: ScriptToolPermissions {
3191 http_get: ScriptPermission::Allow,
3192 http_post: ScriptPermission::Deny,
3193 shell: ScriptPermission::Deny,
3194 read_file: ScriptPermission::Inherit,
3195 write_file: ScriptPermission::Deny,
3196 env_var: ScriptPermission::Allow,
3197 },
3198 security: SecurityConfig {
3199 allow_seed_commands: false,
3200 allow_local_network: true,
3201 allow_env_vars: vec!["MY_PROVIDER_KEY".to_string()],
3202 allow_blueprint_read_paths: true,
3203 read_paths: vec!["~/.leviath/runs".to_string()],
3204 credential_store: leviath_core::CredentialStoreKind::Keychain,
3205 },
3206 agent_read_paths: HashMap::from([(
3207 "cto".to_string(),
3208 ReadPathGrants {
3209 allow: vec!["glob:~/design-docs/**".to_string()],
3210 },
3211 )]),
3212 };
3213
3214 let serialized = toml::to_string_pretty(&config).unwrap();
3215 let deserialized: Config = toml::from_str(&serialized).unwrap();
3216
3217 assert_eq!(deserialized.default_provider, "anthropic");
3218 assert_eq!(deserialized.limits.max_concurrent_inferences, Some(4));
3219 assert_eq!(deserialized.limits.max_concurrent_tools, 3);
3220 assert_eq!(deserialized.limits.script_shell_timeout_secs, 45);
3221 assert_eq!(deserialized.limits.dead_cycles_before_relief, 6);
3222 assert_eq!(deserialized.limits.finished_retention_secs, 120);
3223 assert_eq!(
3224 deserialized.tool_script_permissions.http_get,
3225 ScriptPermission::Allow
3226 );
3227 assert_eq!(
3228 deserialized.tool_script_permissions.shell,
3229 ScriptPermission::Deny
3230 );
3231 assert_eq!(
3232 deserialized.tool_script_permissions.write_file,
3233 ScriptPermission::Deny
3234 );
3235 assert!(deserialized.batch_tool_hint);
3238 assert!(!deserialized.shell_hint);
3239 assert!(!deserialized.security.allow_seed_commands);
3240 assert!(deserialized.security.allow_blueprint_read_paths);
3241 assert_eq!(deserialized.security.read_paths, vec!["~/.leviath/runs"]);
3242 assert_eq!(
3243 deserialized.agent_read_paths.get("cto"),
3244 Some(&ReadPathGrants {
3245 allow: vec!["glob:~/design-docs/**".to_string()],
3246 })
3247 );
3248 assert_eq!(
3249 deserialized.nudge,
3250 leviath_core::NudgeConfig {
3251 enabled: Some(true),
3252 max: Some(2),
3253 text: Some("Use your tools.".to_string()),
3254 }
3255 );
3256 assert_eq!(deserialized.webhook.max_retries, 5);
3257 assert_eq!(deserialized.webhook.base_delay_ms, 250);
3258 assert_eq!(deserialized.webhook.max_delay_ms, 10_000);
3259 assert_eq!(deserialized.webhook.timeout_secs, 7);
3260 assert!(deserialized.observability.enabled);
3261 assert_eq!(
3262 deserialized.observability.exporter,
3263 TelemetryExporterKind::Stdout
3264 );
3265 assert_eq!(
3266 deserialized.observability.endpoint.as_deref(),
3267 Some("http://collector:4318")
3268 );
3269 assert_eq!(
3270 deserialized.observability.service_name.as_deref(),
3271 Some("leviath-prod")
3272 );
3273 assert_eq!(deserialized.limits.default_max_iterations, Some(99));
3274 assert_eq!(
3275 deserialized.providers.anthropic_api_key.as_deref(),
3276 Some("sk-ant-key")
3277 );
3278 assert_eq!(deserialized.agent_paths.len(), 1);
3279 assert!(deserialized.model_capabilities.contains_key("my-model"));
3280 assert_eq!(
3281 deserialized.tool_permissions.get("bash"),
3282 Some(&ToolPolicy::Allow)
3283 );
3284 assert!(!deserialized.title.enabled);
3285 assert_eq!(deserialized.title.provider.as_deref(), Some("openai"));
3286 let sandbox = deserialized.sandbox.expect("sandbox round-trips");
3287 assert_eq!(sandbox.kind, leviath_core::SandboxKind::Container);
3288 assert_eq!(sandbox.image.as_deref(), Some("ubuntu:24.04"));
3289 assert!(!sandbox.network);
3290 }
3291
3292 #[test]
3295 fn config_multiple_model_capabilities() {
3296 let toml_content = r#"
3297default_provider = "anthropic"
3298agent_paths = []
3299
3300[providers]
3301
3302[model_capabilities."model-a"]
3303supports_temperature = true
3304supports_streaming = true
3305supports_tools = true
3306supports_system_prompt = true
3307max_context_tokens = 8192
3308max_output_tokens = 4096
3309
3310[model_capabilities."model-b"]
3311supports_temperature = false
3312supports_streaming = false
3313supports_tools = false
3314supports_system_prompt = false
3315max_context_tokens = 2048
3316max_output_tokens = 1024
3317"#;
3318 let config: Config = toml::from_str(toml_content).unwrap();
3319 assert_eq!(config.model_capabilities.len(), 2);
3320 let caps_a = config.model_capabilities.get("model-a").unwrap();
3321 assert!(caps_a.supports_temperature);
3322 assert_eq!(caps_a.max_context_tokens, 8192);
3323 let caps_b = config.model_capabilities.get("model-b").unwrap();
3324 assert!(!caps_b.supports_temperature);
3325 assert_eq!(caps_b.max_context_tokens, 2048);
3326 }
3327}