use leviath_mcp::MCPServerConfig;
use leviath_providers::ModelCapabilityOverride;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
mod limits;
pub use limits::*;
mod policy;
pub use policy::*;
mod providers;
pub use providers::*;
mod security;
pub use security::*;
fn collect_dropped_keys(
found: &toml::value::Table,
kept: &toml::value::Table,
prefix: &str,
out: &mut Vec<String>,
) {
for (key, value) in found {
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
match kept.get(key) {
None => out.push(path),
Some(kept_value) => {
if let (Some(found_table), Some(kept_table)) =
(value.as_table(), kept_value.as_table())
{
collect_dropped_keys(found_table, kept_table, &path, out);
}
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default = "default_provider_name")]
pub default_provider: String,
#[serde(default)]
pub providers: ProviderConfig,
#[serde(default)]
pub agent_paths: Vec<PathBuf>,
#[serde(default)]
pub openrouter_api_key: Option<String>,
#[serde(default)]
pub ollama_base_url: Option<String>,
#[serde(default)]
pub mcp_servers: Vec<MCPServerConfig>,
#[serde(default)]
pub default_model: Option<String>,
#[serde(default)]
pub model_capabilities: HashMap<String, ModelCapabilityOverride>,
#[serde(default)]
pub model_providers: HashMap<String, ModelProviderConfig>,
#[serde(default)]
pub tool_permissions: HashMap<String, ToolPolicy>,
#[serde(default)]
pub agent_tool_permissions: HashMap<String, HashMap<String, ToolPolicy>>,
#[serde(default)]
pub safe_commands: crate::approvals::SafeCommands,
#[serde(default)]
pub agent_safe_commands: HashMap<String, crate::approvals::AgentSafeCommands>,
#[serde(default)]
pub title: TitleConfig,
#[serde(default)]
pub request_timeout_secs: Option<u64>,
#[serde(default)]
pub rate_limits: HashMap<String, leviath_providers::RateLimitConfig>,
#[serde(default)]
pub taint_tracking: bool,
#[serde(default)]
pub limits: LimitsConfig,
#[serde(default = "default_true")]
pub batch_tool_hint: bool,
#[serde(default = "default_true")]
pub shell_hint: bool,
#[serde(default)]
pub nudge: leviath_core::NudgeConfig,
#[serde(default)]
pub webhook: WebhookConfig,
#[serde(default)]
pub observability: ObservabilityConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<leviath_core::ToolSandboxConfig>,
#[serde(default)]
pub tool_script_permissions: ScriptToolPermissions,
#[serde(default)]
pub security: SecurityConfig,
#[serde(default)]
pub agent_read_paths: HashMap<String, ReadPathGrants>,
}
impl Default for Config {
fn default() -> Self {
Self {
default_provider: "anthropic".to_string(),
providers: ProviderConfig {
anthropic_api_key: None,
openai_api_key: None,
google_api_key: None,
claude_code_enabled: false,
claude_code_binary: None,
claude_code_effort: None,
anthropic_cache_ttl: None,
fallback_order: Vec::new(),
},
agent_paths: Vec::new(),
openrouter_api_key: None,
ollama_base_url: None,
mcp_servers: Vec::new(),
default_model: None,
model_capabilities: HashMap::new(),
model_providers: HashMap::new(),
tool_permissions: HashMap::new(),
agent_tool_permissions: HashMap::new(),
safe_commands: crate::approvals::SafeCommands::default(),
agent_safe_commands: HashMap::new(),
title: TitleConfig::default(),
request_timeout_secs: None,
rate_limits: HashMap::new(),
taint_tracking: false,
limits: LimitsConfig::default(),
batch_tool_hint: true,
shell_hint: true,
nudge: leviath_core::NudgeConfig::default(),
webhook: WebhookConfig::default(),
observability: ObservabilityConfig::default(),
sandbox: None,
tool_script_permissions: ScriptToolPermissions::default(),
security: SecurityConfig::default(),
agent_read_paths: HashMap::new(),
}
}
}
impl Config {
pub fn permissions_for_agent(&self, agent_name: &str) -> HashMap<String, ToolPolicy> {
let mut merged = self.tool_permissions.clone();
if let Some(per_agent) = self.agent_tool_permissions.get(agent_name) {
merged.extend(per_agent.iter().map(|(k, v)| (k.clone(), *v)));
}
merged
}
pub fn safe_keys_for_agent(
&self,
agent_name: &str,
blueprint: Option<&leviath_core::blueprint::SafeCommandsConfig>,
) -> std::collections::BTreeMap<String, crate::approvals::SafeSource> {
crate::approvals::resolve_safe_keys(
&self.safe_commands,
self.agent_safe_commands.get(agent_name),
blueprint,
self.security.allow_blueprint_safe_commands,
)
}
pub fn read_path_grants_for_agent(&self, agent_name: &str) -> Vec<String> {
let mut grants = self.security.read_paths.clone();
if let Some(per_agent) = self.agent_read_paths.get(agent_name) {
grants.extend(per_agent.allow.iter().cloned());
}
grants
}
pub fn load() -> anyhow::Result<Self> {
#[cfg(test)]
assert!(
std::env::var_os("LEVIATH_CONFIG_PATH").is_some(),
"Config::load() reached from a test that has not isolated the \
environment. Wrap the test in `config::with_isolated_config_path` \
(or `..._async`), which both points this at a scratch config and \
takes the same process-wide lock every other env-touching test \
holds. Without it this test races them and fails intermittently, \
somewhere else."
);
if std::env::var_os("LEVIATH_SKIP_DOTENV").is_none() {
load_dotenv_filtered(".env");
}
let config = Self::load_from_path(&Self::config_path())?;
check_permissions();
Ok(config)
}
fn warn_unknown_config_keys(content: &str) {
let unknown = Self::unknown_config_keys(content);
if !unknown.is_empty() {
let keys = unknown.join(", ");
tracing::warn!(
%keys,
"config.toml has keys nothing reads; they are being ignored. \
`lev doctor` reports them too, if this scrolls past."
);
}
}
pub fn unread_keys_at(path: &std::path::Path) -> Vec<String> {
std::fs::read_to_string(path)
.map(|content| Self::unknown_config_keys(&content))
.unwrap_or_default()
}
fn unknown_config_keys(content: &str) -> Vec<String> {
let Ok(found) = toml::from_str::<toml::value::Table>(content) else {
return Vec::new();
};
let Ok(config) = toml::from_str::<Self>(content) else {
return Vec::new();
};
let kept = toml::Value::try_from(config).expect("a Config is plain data and serializes");
let kept = kept.as_table().expect("a struct serializes to a table");
let mut unknown = Vec::new();
collect_dropped_keys(&found, kept, "", &mut unknown);
unknown
}
fn load_from_path(path: &std::path::Path) -> anyhow::Result<Self> {
let mut config = if !path.exists() {
let path_display = path.display();
tracing::debug!("No config file found at {}, using defaults", path_display);
Self::default()
} else {
let content = std::fs::read_to_string(path).map_err(|e| {
anyhow::anyhow!("Failed to read config from '{}': {}", path.display(), e)
})?;
let c: Self = toml::from_str(&content)
.map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e))?;
Self::warn_unknown_config_keys(&content);
for server in &c.mcp_servers {
server.validate()?;
}
let path_display = path.display();
tracing::debug!("Loaded config from {}", path_display);
c
};
if config.providers.anthropic_api_key.is_none() {
config.providers.anthropic_api_key = std::env::var("ANTHROPIC_API_KEY").ok();
}
if config.providers.openai_api_key.is_none() {
config.providers.openai_api_key = std::env::var("OPENAI_API_KEY").ok();
}
if config.providers.google_api_key.is_none() {
config.providers.google_api_key = std::env::var("GOOGLE_API_KEY").ok();
}
if config.openrouter_api_key.is_none() {
config.openrouter_api_key = std::env::var("OPENROUTER_API_KEY").ok();
}
if config.ollama_base_url.is_none() {
config.ollama_base_url = std::env::var("OLLAMA_HOST").ok();
}
config.fill_from_credential_store();
Ok(config)
}
fn fill_from_credential_store(&mut self) {
let resolved = crate::credentials::store_for(self.security.credential_store);
self.fill_from_credential_store_with(resolved);
}
fn fill_from_credential_store_with(&mut self, resolved: crate::credentials::Resolved) {
match resolved {
Ok(Some(store)) => self.apply_credential_store(store.as_ref()),
Ok(None) => {}
Err(e) => {
tracing::warn!("{e}. Falling back to keys from the config file and environment.");
}
}
}
fn apply_credential_store(&mut self, store: &dyn leviath_core::CredentialStore) {
let accounts: Vec<String> = crate::credentials::PROVIDER_KEYS
.iter()
.map(|p| leviath_core::provider_account(p))
.collect();
let mut found = store.read_all(&accounts);
let mut take = |provider: &str| found.remove(&leviath_core::provider_account(provider));
let anthropic = take("anthropic");
let openai = take("openai");
let google = take("google");
let openrouter = take("openrouter");
self.providers.anthropic_api_key = self.providers.anthropic_api_key.take().or(anthropic);
self.providers.openai_api_key = self.providers.openai_api_key.take().or(openai);
self.providers.google_api_key = self.providers.google_api_key.take().or(google);
self.openrouter_api_key = self.openrouter_api_key.take().or(openrouter);
}
fn without_secrets(&self) -> Self {
let mut copy = self.clone();
copy.providers.anthropic_api_key = None;
copy.providers.openai_api_key = None;
copy.providers.google_api_key = None;
copy.openrouter_api_key = None;
copy
}
pub(crate) fn provider_secrets(&self) -> Vec<(String, String)> {
[
("anthropic", self.providers.anthropic_api_key.as_deref()),
("openai", self.providers.openai_api_key.as_deref()),
("google", self.providers.google_api_key.as_deref()),
("openrouter", self.openrouter_api_key.as_deref()),
]
.into_iter()
.filter_map(|(name, key)| {
key.map(|k| (leviath_core::provider_account(name), k.to_string()))
})
.collect()
}
pub(crate) fn save_to_path(&self, path: &std::path::Path) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
create_config_dir(parent)?;
}
let resolved = crate::credentials::store_for(self.security.credential_store);
self.write_to(path, resolved)
}
fn write_to(
&self,
path: &std::path::Path,
resolved: crate::credentials::Resolved,
) -> anyhow::Result<()> {
let to_write = match resolved.map_err(|e| anyhow::anyhow!("{e}"))? {
Some(store) => {
for (account, secret) in self.provider_secrets() {
store
.set(&account, &secret)
.map_err(|e| anyhow::anyhow!("failed to store {account}: {e}"))?;
}
self.without_secrets()
}
None => self.clone(),
};
let content =
toml::to_string_pretty(&to_write).expect("Config serialization is infallible");
leviath_sys::write_private(path, content.as_bytes()).map_err(|e| {
anyhow::anyhow!("Failed to write config to '{}': {}", path.display(), e)
})?;
let path_display = path.display();
tracing::debug!("Saved config to {}", path_display);
Ok(())
}
pub fn load_from_path_public(path: &std::path::Path) -> anyhow::Result<Self> {
Self::load_from_path(path)
}
pub fn save_to_path_public(&self, path: &std::path::Path) -> anyhow::Result<()> {
self.save_to_path(path)
}
pub fn config_path() -> PathBuf {
if let Ok(override_path) = std::env::var("LEVIATH_CONFIG_PATH") {
return PathBuf::from(override_path);
}
leviath_core::data_dir()
.unwrap_or_default()
.join("config.toml")
}
pub fn validate_keys(&self) -> Vec<String> {
let mut warnings = Vec::new();
if let Some(key) = self.providers.anthropic_api_key.as_deref()
&& !key.trim().is_empty()
&& !key.starts_with("sk-ant-")
{
warnings.push(
"Anthropic API key doesn't start with 'sk-ant-' - verify it's correct".to_string(),
);
}
if let Some(key) = self.providers.openai_api_key.as_deref()
&& !key.trim().is_empty()
&& !key.starts_with("sk-")
{
warnings
.push("OpenAI API key doesn't start with 'sk-' - verify it's correct".to_string());
}
warnings
}
}
pub use leviath_core::paths::home_dir as leviath_home_dir;
pub use leviath_core::paths::providers_dir;
fn create_config_dir(dir: &std::path::Path) -> anyhow::Result<()> {
std::fs::create_dir_all(dir)
.map_err(|e| anyhow::anyhow!("Failed to create config directory: {}", e))?;
set_dir_permissions(dir);
Ok(())
}
fn requote(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for c in value.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'$' => out.push_str("\\$"),
'\n' => out.push_str("\\n"),
other => out.push(other),
}
}
out.push('"');
out
}
fn load_dotenv_filtered(path: &str) {
let Ok(entries) = dotenvy::from_filename_iter(path) else {
return;
};
let (allowed, skipped): (Vec<_>, Vec<_>) = entries
.flatten()
.partition(|(key, _)| leviath_core::dotenv_var_allowed(key));
let doc: String = allowed
.iter()
.map(|(key, value)| format!("{key}={}\n", requote(value)))
.collect();
let _ = dotenvy::from_read(doc.as_bytes());
if skipped.is_empty() {
return;
}
let names = skipped
.iter()
.map(|(key, _)| key.as_str())
.collect::<Vec<_>>()
.join(", ");
tracing::warn!(
"Ignoring {names} from {path}: these decide where configuration is read from or what \
gets executed, so a repository may not set them. Export them yourself if you meant to."
);
}
fn check_permissions() {
check_permissions_at(&Config::config_path());
}
fn check_permissions_at(path: &std::path::Path) {
check_permissions_at_with(path, leviath_sys::ensure_file_private);
}
fn check_permissions_at_with(
path: &std::path::Path,
ensure: fn(&std::path::Path) -> std::io::Result<Option<u32>>,
) {
match ensure(path) {
Ok(Some(old_mode)) => {
let masked_mode = old_mode & 0o777;
tracing::warn!(
"Config file has overly permissive permissions ({:o}), fixing to 600",
masked_mode
);
}
Ok(None) => {}
Err(e) => tracing::warn!("Failed to fix config file permissions: {}", e),
}
}
fn set_dir_permissions(path: &std::path::Path) {
set_dir_permissions_with(path, leviath_sys::secure_dir_perms);
}
fn set_dir_permissions_with(
path: &std::path::Path,
secure: fn(&std::path::Path) -> std::io::Result<()>,
) {
if let Err(e) = secure(path) {
tracing::warn!("Failed to set config directory permissions: {}", e);
}
}
pub(crate) fn default_true() -> bool {
true
}
pub(crate) fn default_provider_name() -> String {
"anthropic".to_string()
}
#[cfg(test)]
pub(crate) static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
pub(crate) struct CwdTestGuard {
original_cwd: std::path::PathBuf,
_lock: std::sync::MutexGuard<'static, ()>,
}
#[cfg(test)]
impl Drop for CwdTestGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.original_cwd);
}
}
#[cfg(test)]
pub(crate) fn isolate_cwd_for_test() -> CwdTestGuard {
let lock = CWD_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let original_cwd = std::env::current_dir().expect("current dir must be readable at test start");
CwdTestGuard {
original_cwd,
_lock: lock,
}
}
#[cfg(test)]
const PROVIDER_KEY_ENV_VARS: &[&str] = &[
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"GOOGLE_API_KEY",
"OPENROUTER_API_KEY",
];
#[cfg(test)]
fn make_fake_config_dir(unique: &str) -> std::path::PathBuf {
let fake_dir = std::env::temp_dir().join(format!("lev-fake-config-{unique}"));
let _ = std::fs::create_dir_all(&fake_dir);
fake_dir
}
#[cfg(test)]
pub(crate) fn config_isolation_vars(
fake_dir: &std::path::Path,
) -> Vec<(&'static str, Option<std::ffi::OsString>)> {
let mut vars: Vec<(&'static str, Option<std::ffi::OsString>)> = vec![
(
"LEVIATH_CONFIG_PATH",
Some(fake_dir.join("config.toml").into_os_string()),
),
("LEVIATH_SKIP_DOTENV", Some(std::ffi::OsString::from("1"))),
];
for &key in PROVIDER_KEY_ENV_VARS {
vars.push((key, None));
}
vars
}
#[cfg(test)]
pub(crate) fn with_isolated_config_path<R>(
unique: &str,
f: impl FnOnce(&std::path::Path) -> R,
) -> R {
let fake_dir = make_fake_config_dir(unique);
let result = temp_env::with_vars(config_isolation_vars(&fake_dir), || f(&fake_dir));
let _ = std::fs::remove_dir_all(&fake_dir);
result
}
#[cfg(test)]
pub(crate) async fn with_isolated_config_path_async<R, Fut>(
unique: &str,
f: impl FnOnce(std::path::PathBuf) -> Fut,
) -> R
where
Fut: std::future::Future<Output = R>,
{
let fake_dir = make_fake_config_dir(unique);
let result =
temp_env::async_with_vars(config_isolation_vars(&fake_dir), f(fake_dir.clone())).await;
let _ = std::fs::remove_dir_all(&fake_dir);
result
}
#[cfg(test)]
mod dotenv_tests {
use super::*;
#[test]
fn a_dot_env_in_the_working_directory_is_read() {
let dir = make_fake_config_dir("dotenv-read");
std::fs::write(dir.join(".env"), "LEV_DOTENV_PROBE=seen\n").unwrap();
{
let _cwd = isolate_cwd_for_test();
std::env::set_current_dir(&dir).unwrap();
temp_env::with_vars(
[
(
"LEVIATH_CONFIG_PATH",
Some(dir.join("config.toml").into_os_string()),
),
("LEVIATH_SKIP_DOTENV", None),
("LEV_DOTENV_PROBE", None),
],
|| {
let loaded = Config::load();
assert!(loaded.is_ok(), "a missing config file is not an error");
assert_eq!(
std::env::var("LEV_DOTENV_PROBE").ok().as_deref(),
Some("seen"),
"the .env beside the working directory was read"
);
},
);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_dot_env_cannot_steer_where_config_comes_from() {
let dir = make_fake_config_dir("dotenv-steer");
std::fs::write(
dir.join(".env"),
"LEVIATH_CONFIG_PATH=/tmp/evil.toml\n\
LEVIATH_API_TOKEN=known\n\
EDITOR=/tmp/evil\n\
PATH=/tmp/evil\n\
LD_PRELOAD=/tmp/evil.so\n\
LEV_DOTENV_KEEPS=kept\n",
)
.unwrap();
{
let _cwd = isolate_cwd_for_test();
std::env::set_current_dir(&dir).unwrap();
temp_env::with_vars(
[
(
"LEVIATH_CONFIG_PATH",
Some(dir.join("config.toml").into_os_string()),
),
("LEVIATH_SKIP_DOTENV", None),
("LEVIATH_API_TOKEN", None),
("EDITOR", None),
("LD_PRELOAD", None),
("LEV_DOTENV_KEEPS", None),
],
|| {
Config::load().expect("a missing config file is not an error");
for steering in ["LEVIATH_API_TOKEN", "EDITOR", "LD_PRELOAD"] {
assert!(
std::env::var(steering).is_err(),
"{steering} must not be settable from a repository's .env"
);
}
assert_ne!(
std::env::var("LEVIATH_CONFIG_PATH").ok(),
Some("/tmp/evil.toml".to_string())
);
assert_eq!(
std::env::var("LEV_DOTENV_KEEPS").ok().as_deref(),
Some("kept")
);
},
);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_missing_dot_env_is_not_an_error() {
let dir = make_fake_config_dir("dotenv-missing");
load_dotenv_filtered(&dir.join("absent.env").to_string_lossy());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_ordinary_dot_env_warns_about_nothing() {
let dir = make_fake_config_dir("dotenv-nothing-skipped");
std::fs::write(dir.join(".env"), "LEV_DOTENV_ORDINARY=fine\n").unwrap();
{
let _cwd = isolate_cwd_for_test();
std::env::set_current_dir(&dir).unwrap();
temp_env::with_vars(
[
(
"LEVIATH_CONFIG_PATH",
Some(dir.join("config.toml").into_os_string()),
),
("LEVIATH_SKIP_DOTENV", None),
("LEV_DOTENV_ORDINARY", None),
],
|| {
Config::load().expect("a missing config file is not an error");
assert_eq!(
std::env::var("LEV_DOTENV_ORDINARY").ok().as_deref(),
Some("fine")
);
},
);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn requote_escapes_what_both_dotenvy_layers_read() {
assert_eq!(requote("plain"), r#""plain""#);
assert_eq!(requote(r"C:\tools\"), r#""C:\\tools\\""#);
assert_eq!(requote(r#"say "hi""#), r#""say \"hi\"""#);
assert_eq!(requote("cost $5 $HOME"), r#""cost \$5 \$HOME""#);
assert_eq!(requote("one\ntwo"), r#""one\ntwo""#);
}
#[test]
fn filtering_survives_a_value_ending_in_a_backslash() {
let dir = make_fake_config_dir("dotenv-backslash");
std::fs::write(
dir.join(".env"),
"PATH=/tmp/anything\n\
LEV_DOTENV_BACKSLASH=\"C:\\\\tools\\\\\"\n\
LEV_DOTENV_AFTER=survived\n",
)
.unwrap();
{
let _cwd = isolate_cwd_for_test();
std::env::set_current_dir(&dir).unwrap();
temp_env::with_vars(
[
(
"LEVIATH_CONFIG_PATH",
Some(dir.join("config.toml").into_os_string()),
),
("LEVIATH_SKIP_DOTENV", None),
("LEV_DOTENV_BACKSLASH", None),
("LEV_DOTENV_AFTER", None),
],
|| {
Config::load().expect("a missing config file is not an error");
assert_eq!(
std::env::var("LEV_DOTENV_BACKSLASH").ok().as_deref(),
Some("C:\\tools\\")
);
assert_eq!(
std::env::var("LEV_DOTENV_AFTER").ok().as_deref(),
Some("survived"),
"a later variable must not be swallowed by an unbalanced quote"
);
},
);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn filtering_preserves_an_awkward_value_verbatim() {
let dir = make_fake_config_dir("dotenv-quoting");
std::fs::write(
dir.join(".env"),
"PATH=/tmp/evil\n\
LEV_DOTENV_AWKWARD=\"it's a #value with 'quotes' and spaces\"\n",
)
.unwrap();
{
let _cwd = isolate_cwd_for_test();
std::env::set_current_dir(&dir).unwrap();
temp_env::with_vars(
[
(
"LEVIATH_CONFIG_PATH",
Some(dir.join("config.toml").into_os_string()),
),
("LEVIATH_SKIP_DOTENV", None),
("LEV_DOTENV_AWKWARD", None),
],
|| {
Config::load().expect("a missing config file is not an error");
assert_eq!(
std::env::var("LEV_DOTENV_AWKWARD").ok().as_deref(),
Some("it's a #value with 'quotes' and spaces")
);
},
);
}
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod tests {
const CONFIG_SCHEMA: &str = include_str!("../../../../docs/schema/config.schema.json");
const CONFIG_EXAMPLE: &str = include_str!("../../../../docs/schema/config.example.toml");
fn schema_problems(
validator: &jsonschema::Validator,
value: &serde_json::Value,
) -> Vec<String> {
validator
.iter_errors(value)
.map(|e| format!("{}: {e}", e.instance_path()))
.collect()
}
#[test]
fn an_unknown_key_is_reported_at_any_depth() {
let content = "\
default_provider = \"anthropic\"
[cache]
ttl = \"banana\"
[limits]
max_concurrent_tool = 3
[providers]
anthropic_api_key = \"x\"
anthropic_cach_ttl = \"1h\"
";
let unknown = Config::unknown_config_keys(content);
assert!(unknown.contains(&"cache".to_string()), "{unknown:?}");
assert!(
unknown.contains(&"limits.max_concurrent_tool".to_string()),
"a key one level down is named by its path: {unknown:?}"
);
assert!(
unknown.contains(&"providers.anthropic_cach_ttl".to_string()),
"{unknown:?}"
);
assert!(
!unknown.iter().any(|k| k == "default_provider"),
"{unknown:?}"
);
assert!(
!unknown.iter().any(|k| k == "providers.anthropic_api_key"),
"{unknown:?}"
);
}
#[test]
fn a_file_that_is_not_a_config_reports_no_unknown_keys() {
assert!(Config::unknown_config_keys("default_provider = 42").is_empty());
}
#[test]
fn unread_keys_of_a_missing_file_is_empty() {
let dir = tempfile::tempdir().unwrap();
assert!(Config::unread_keys_at(&dir.path().join("nope.toml")).is_empty());
}
#[test]
fn unread_keys_at_reads_the_file_it_is_given() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[cache]\nttl = \"banana\"\n").unwrap();
assert_eq!(Config::unread_keys_at(&path), vec!["cache".to_string()]);
}
#[test]
fn keys_a_flatten_field_absorbs_are_not_reported() {
let content = "\
[model_providers.groq]
script = \"groq.rhai\"
some_custom_thing = \"forwarded to the script\"
";
assert!(
Config::unknown_config_keys(content).is_empty(),
"a key serde keeps is a key nothing should complain about"
);
}
#[test]
fn an_unknown_config_key_is_reported_and_the_config_still_loads() {
const CONTENT: &str = "default_provider = \"anthropic\"\n\n[cache]\nttl = \"banana\"\n";
assert_eq!(
Config::unknown_config_keys(CONTENT),
vec!["cache".to_string()],
"the unknown table is named and the real key is not"
);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, CONTENT).unwrap();
let _guard = leviath_testkit::tracing_guard();
let config = Config::load_from_path(&path).expect("an unknown key does not stop the load");
assert_eq!(config.default_provider, "anthropic");
}
#[test]
fn a_config_of_known_keys_reports_nothing() {
assert!(
Config::unknown_config_keys(CONFIG_EXAMPLE).is_empty(),
"the shipped example must be clean"
);
}
#[test]
fn unparseable_content_reports_no_unknown_keys() {
assert!(Config::unknown_config_keys("this is not [[[ toml").is_empty());
}
#[test]
fn the_example_config_satisfies_the_published_schema_and_deserializes() {
let example: toml::Value = toml::from_str(CONFIG_EXAMPLE).expect("the example is TOML");
let schema: serde_json::Value =
serde_json::from_str(CONFIG_SCHEMA).expect("the schema is JSON");
let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
let json = serde_json::to_value(&example).expect("TOML converts to JSON");
assert_eq!(
schema_problems(&validator, &json),
Vec::<String>::new(),
"config.example.toml does not match config.schema.json"
);
let parsed: Config = toml::from_str(CONFIG_EXAMPLE).expect("the example deserializes");
assert_eq!(parsed.default_provider, "anthropic");
assert_eq!(parsed.limits.interaction_timeout_secs, 3600);
assert_eq!(parsed.mcp_servers.len(), 2);
}
#[test]
fn the_config_schema_rejects_a_key_that_is_not_a_setting() {
let schema: serde_json::Value =
serde_json::from_str(CONFIG_SCHEMA).expect("the schema is JSON");
let validator = jsonschema::validator_for(&schema).expect("the schema compiles");
let rejects = |toml_text: &str| {
let value: toml::Value = toml::from_str(toml_text).expect("valid TOML");
let json = serde_json::to_value(&value).expect("converts");
!schema_problems(&validator, &json).is_empty()
};
assert!(
rejects("default_provdier = \"anthropic\"\n"),
"a typo'd key"
);
assert!(
rejects("[limits]\ninteraction_timeout_secs = \"an hour\"\n"),
"a string where a number belongs"
);
assert!(
rejects("[security]\ncredential_store = \"vault\"\n"),
"an unsupported credential store"
);
assert!(
!rejects("default_provider = \"openrouter\"\n"),
"a real key"
);
}
#[test]
fn saving_with_an_unreachable_keychain_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut config = Config::default();
config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
config.providers.anthropic_api_key = Some("sk-ant".to_string());
assert!(
config
.write_to(&path, Err("no keychain".to_string()))
.is_err()
);
assert!(!path.exists(), "no file may be written at all");
}
#[test]
fn saving_to_a_store_that_refuses_the_write_writes_nothing() {
use leviath_core::CredentialStore as _;
struct Refuses;
impl leviath_core::CredentialStore for Refuses {
fn get(&self, _: &str) -> Result<Option<String>, String> {
Ok(None)
}
fn set(&self, _: &str, _: &str) -> Result<(), String> {
Err("read-only keychain".to_string())
}
fn delete(&self, _: &str) -> Result<bool, String> {
Err("read-only keychain".to_string())
}
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut config = Config::default();
config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
config.providers.anthropic_api_key = Some("sk-ant".to_string());
assert_eq!(Refuses.get("provider/anthropic").unwrap(), None);
assert!(Refuses.delete("provider/anthropic").is_err());
let err = config
.write_to(&path, Ok(Some(Box::new(Refuses))))
.expect_err("a refused write is not a save");
assert!(err.to_string().contains("failed to store"), "{err}");
assert!(!path.exists(), "no file may be written at all");
}
#[test]
fn saving_in_keychain_mode_puts_the_secrets_in_the_store_not_the_file() {
use leviath_core::CredentialStore;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut config = Config::default();
config.security.credential_store = leviath_core::CredentialStoreKind::Keychain;
config.providers.anthropic_api_key = Some("sk-ant-secret".to_string());
config.default_model = Some("some-model".to_string());
let store = std::sync::Arc::new(leviath_core::MemoryStore::new());
struct Shared(std::sync::Arc<leviath_core::MemoryStore>);
impl CredentialStore for Shared {
fn get(&self, a: &str) -> Result<Option<String>, String> {
self.0.get(a)
}
fn set(&self, a: &str, s: &str) -> Result<(), String> {
self.0.set(a, s)
}
fn delete(&self, a: &str) -> Result<bool, String> {
self.0.delete(a)
}
}
config
.write_to(&path, Ok(Some(Box::new(Shared(store.clone())))))
.unwrap();
assert!(
Shared(store.clone())
.delete(&leviath_core::provider_account("anthropic"))
.unwrap()
);
store
.set(
&leviath_core::provider_account("anthropic"),
"sk-ant-secret",
)
.unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert!(!written.contains("sk-ant-secret"), "{written}");
assert!(
written.contains("some-model"),
"settings survive: {written}"
);
assert_eq!(
Shared(store.clone())
.get(&leviath_core::provider_account("anthropic"))
.unwrap()
.as_deref(),
Some("sk-ant-secret")
);
}
#[test]
fn the_credential_store_fills_only_the_keys_that_are_unset() {
use leviath_core::{CredentialStore, MemoryStore};
let store = MemoryStore::new();
store
.set(
&leviath_core::provider_account("anthropic"),
"from-keychain",
)
.unwrap();
store
.set(&leviath_core::provider_account("openai"), "openai-keychain")
.unwrap();
store
.set(&leviath_core::provider_account("google"), "google-keychain")
.unwrap();
store
.set(&leviath_core::provider_account("openrouter"), "or-keychain")
.unwrap();
let mut config = Config::default();
config.providers.anthropic_api_key = Some("from-file".to_string());
config.apply_credential_store(&store);
assert_eq!(
config.providers.anthropic_api_key.as_deref(),
Some("from-file"),
"an existing key wins over the keychain"
);
assert_eq!(
config.providers.openai_api_key.as_deref(),
Some("openai-keychain")
);
assert_eq!(
config.providers.google_api_key.as_deref(),
Some("google-keychain")
);
assert_eq!(config.openrouter_api_key.as_deref(), Some("or-keychain"));
}
#[test]
fn an_empty_credential_store_changes_nothing() {
let mut config = Config::default();
config.providers.openai_api_key = Some("keep-me".to_string());
config.apply_credential_store(&leviath_core::MemoryStore::new());
assert_eq!(config.providers.openai_api_key.as_deref(), Some("keep-me"));
assert!(config.providers.anthropic_api_key.is_none());
}
#[test]
fn an_unreachable_credential_store_does_not_stop_the_config_loading() {
use leviath_core::{CredentialStore, MemoryStore};
let mut config = Config::default();
config.fill_from_credential_store_with(Err("no keychain here".to_string()));
assert!(config.providers.anthropic_api_key.is_none());
let mut config = Config::default();
config.providers.openai_api_key = Some("k".to_string());
config.fill_from_credential_store_with(Ok(None));
assert_eq!(config.providers.openai_api_key.as_deref(), Some("k"));
let store = MemoryStore::new();
store
.set(&leviath_core::provider_account("anthropic"), "filled")
.unwrap();
let mut config = Config::default();
config.fill_from_credential_store_with(Ok(Some(Box::new(store))));
assert_eq!(
config.providers.anthropic_api_key.as_deref(),
Some("filled")
);
}
#[test]
fn provider_secrets_lists_every_set_key_and_nothing_else() {
let mut config = Config::default();
assert!(config.provider_secrets().is_empty());
config.providers.anthropic_api_key = Some("a".to_string());
config.openrouter_api_key = Some("o".to_string());
let secrets = config.provider_secrets();
assert_eq!(secrets.len(), 2);
assert!(secrets.contains(&("provider/anthropic".to_string(), "a".to_string())));
assert!(secrets.contains(&("provider/openrouter".to_string(), "o".to_string())));
}
#[test]
fn without_secrets_strips_a_copy_and_leaves_the_original_usable() {
let mut config = Config::default();
config.providers.anthropic_api_key = Some("a".to_string());
config.providers.openai_api_key = Some("b".to_string());
config.providers.google_api_key = Some("c".to_string());
config.openrouter_api_key = Some("d".to_string());
config.default_model = Some("m".to_string());
let stripped = config.without_secrets();
assert!(stripped.provider_secrets().is_empty(), "no keys survive");
assert_eq!(stripped.default_model.as_deref(), Some("m"), "settings do");
assert_eq!(
config.providers.anthropic_api_key.as_deref(),
Some("a"),
"the original is untouched"
);
}
use super::*;
use crate::test_support::with_tracing;
#[test]
fn leviath_home_dir_uses_override_when_set() {
temp_env::with_var(
"LEVIATH_HOME",
Some("/tmp/leviath-home-override-test"),
|| {
assert_eq!(
leviath_home_dir(),
Some(std::path::PathBuf::from("/tmp/leviath-home-override-test"))
);
},
);
}
#[test]
fn leviath_home_dir_falls_back_to_dirs_home_dir_when_unset() {
temp_env::with_var_unset("LEVIATH_HOME", || {
assert_eq!(leviath_home_dir(), dirs::home_dir());
});
}
#[test]
fn load_from_path_missing_file_returns_defaults() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
assert_eq!(config.default_provider, "anthropic");
}
#[test]
fn load_from_path_valid_toml_is_parsed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let original = Config {
default_provider: "openai".to_string(),
..Config::default()
};
std::fs::write(&path, toml::to_string_pretty(&original).unwrap()).unwrap();
let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
assert_eq!(config.default_provider, "openai");
}
#[test]
fn limits_default_to_bounded_values() {
let limits = LimitsConfig::default();
assert_eq!(limits.max_concurrent_inferences, Some(8));
assert_eq!(limits.default_max_iterations, Some(50));
assert!(!limits.exact_token_counting);
assert_eq!(limits.dead_cycles_before_relief, 10);
assert_eq!(limits.finished_retention_secs, 300);
assert_eq!(limits.interaction_timeout_secs, 3600);
assert_eq!(Config::default().limits.max_concurrent_inferences, Some(8));
}
#[test]
fn interaction_timeout_defaults_and_parses() {
let dir = tempfile::tempdir().unwrap();
let load = |body: String| {
let path = dir.path().join(format!("{}.toml", body.len()));
std::fs::write(&path, body).unwrap();
with_tracing(|| Config::load_from_path(&path)).unwrap()
};
let old = load(format!(
"{}\n[limits]\nmax_concurrent_tools = 4\n",
config_toml_without_limits()
));
assert_eq!(old.limits.interaction_timeout_secs, 3600);
let disabled = load(format!(
"{}\n[limits]\ninteraction_timeout_secs = 0\n",
config_toml_without_limits()
));
assert_eq!(disabled.limits.interaction_timeout_secs, 0);
}
#[test]
fn exact_token_counting_parses_when_set() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let body = format!(
"{}\n[limits]\nexact_token_counting = true\n",
config_toml_without_limits()
);
std::fs::write(&path, body).unwrap();
let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
assert!(config.limits.exact_token_counting);
assert_eq!(config.limits.max_concurrent_inferences, Some(8));
}
#[cfg(test)]
fn config_toml_without_limits() -> String {
let full = toml::to_string_pretty(&Config::default()).unwrap();
format!("{}\n", full.split("[limits]").next().unwrap().trim_end())
}
#[test]
fn limits_absent_section_uses_defaults() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, config_toml_without_limits()).unwrap();
let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
assert_eq!(config.limits.max_concurrent_inferences, Some(8));
assert_eq!(config.limits.default_max_iterations, Some(50));
assert_eq!(config.limits.dead_cycles_before_relief, 10);
assert_eq!(config.limits.finished_retention_secs, 300);
assert_eq!(config.limits.wedge_timeout_secs, 0);
}
#[test]
fn the_wedge_watchdog_is_off_until_it_is_configured() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let body = format!(
"{}\n[limits]\nwedge_timeout_secs = 300\n",
config_toml_without_limits()
);
std::fs::write(&path, body).unwrap();
let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
assert_eq!(config.limits.wedge_timeout_secs, 300);
assert_eq!(config.limits.stall_timeout_secs, 60);
}
#[test]
fn limits_partial_section_fills_the_other_default() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let body = format!(
"{}\n[limits]\nmax_concurrent_inferences = 3\n",
config_toml_without_limits()
);
std::fs::write(&path, body).unwrap();
let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
assert_eq!(config.limits.max_concurrent_inferences, Some(3));
assert_eq!(config.limits.default_max_iterations, Some(50));
}
#[test]
fn load_from_path_existing_provider_keys_skip_env_fallback() {
let unset: Vec<(&str, Option<&str>)> = PROVIDER_KEY_ENV_VARS
.iter()
.chain(["OLLAMA_HOST"].iter())
.map(|&key| (key, None))
.collect();
temp_env::with_vars(unset, || {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
default_provider = "anthropic"
openrouter_api_key = "sk-or-existing"
ollama_base_url = "http://existing-ollama:11434"
agent_paths = []
[providers]
anthropic_api_key = "sk-ant-existing"
openai_api_key = "sk-openai-existing"
google_api_key = "AIza-existing"
"#,
)
.unwrap();
let config = with_tracing(|| Config::load_from_path(&path)).unwrap();
assert_eq!(
config.providers.anthropic_api_key.as_deref(),
Some("sk-ant-existing")
);
assert_eq!(
config.providers.openai_api_key.as_deref(),
Some("sk-openai-existing")
);
assert_eq!(
config.providers.google_api_key.as_deref(),
Some("AIza-existing")
);
assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-existing"));
assert_eq!(
config.ollama_base_url.as_deref(),
Some("http://existing-ollama:11434")
);
});
}
#[test]
fn load_from_path_malformed_toml_returns_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "not valid toml [[[").unwrap();
let result = Config::load_from_path(&path);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Failed to parse"));
}
#[test]
fn load_from_path_unreadable_path_returns_error() {
let dir = tempfile::tempdir().unwrap();
let result = Config::load_from_path(dir.path());
assert!(result.is_err());
}
#[test]
fn save_to_path_writes_valid_toml_that_round_trips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("config.toml");
let config = Config {
default_provider: "google".to_string(),
..Config::default()
};
with_tracing(|| config.save_to_path(&path)).unwrap();
let loaded = with_tracing(|| Config::load_from_path(&path)).unwrap();
assert_eq!(loaded.default_provider, "google");
}
#[test]
fn save_to_path_creates_parent_directory() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("a").join("b").join("config.toml");
let config = Config::default();
with_tracing(|| config.save_to_path(&path)).unwrap();
assert!(path.exists());
}
#[test]
fn save_to_path_with_no_parent_skips_create_config_dir() {
let result = Config::default().save_to_path(&std::path::PathBuf::from(""));
assert!(result.is_err());
}
#[cfg(unix)]
#[test]
fn save_to_path_sets_restrictive_file_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
with_tracing(|| Config::default().save_to_path(&path)).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn save_to_path_write_failure_returns_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::create_dir_all(&path).unwrap();
let result = Config::default().save_to_path(&path);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Failed to write config")
);
}
#[test]
fn save_to_path_create_config_dir_failure_returns_error() {
let dir = tempfile::tempdir().unwrap();
let blocking_file = dir.path().join("not-a-dir");
std::fs::write(&blocking_file, "").unwrap();
let path = blocking_file.join("config.toml");
let result = Config::default().save_to_path(&path);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Failed to create config directory")
);
}
#[test]
fn load_propagates_error_when_real_config_file_is_malformed() {
with_isolated_config_path("load-malformed", |fake_dir| {
std::fs::write(fake_dir.join("config.toml"), "not valid toml [[[").unwrap();
let result = Config::load();
assert!(result.is_err());
});
}
#[cfg(unix)]
#[test]
fn check_permissions_at_missing_file_is_noop() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nonexistent.toml");
check_permissions_at(&path); }
#[cfg(unix)]
#[test]
fn check_permissions_at_fixes_overly_permissive_file() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
with_tracing(|| check_permissions_at(&path));
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
#[cfg(unix)]
#[test]
fn check_permissions_at_leaves_already_restrictive_file_alone() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
check_permissions_at(&path);
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn check_permissions_at_with_logs_when_fix_fails() {
fn ensure_fails(_: &std::path::Path) -> std::io::Result<Option<u32>> {
Err(std::io::Error::other("simulated chmod failure"))
}
with_tracing(|| {
check_permissions_at_with(std::path::Path::new("/does/not/matter"), ensure_fails)
});
}
#[test]
fn check_permissions_at_with_logs_when_file_is_permissive() {
fn ensure_permissive(_: &std::path::Path) -> std::io::Result<Option<u32>> {
Ok(Some(0o100644))
}
with_tracing(|| {
check_permissions_at_with(std::path::Path::new("/does/not/matter"), ensure_permissive)
});
}
fn always_failing_secure(_path: &std::path::Path) -> std::io::Result<()> {
Err(std::io::Error::other(
"simulated permission-hardening failure",
))
}
#[test]
fn set_dir_permissions_error_branch_logs_not_panics() {
with_tracing(|| {
set_dir_permissions_with(
std::path::Path::new("/does/not/matter"),
always_failing_secure,
)
}); }
#[test]
fn create_config_dir_creates_nested_dirs() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("a").join("b").join("c");
create_config_dir(&target).unwrap();
assert!(target.is_dir());
}
#[cfg(unix)]
#[test]
fn create_config_dir_sets_restrictive_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("leviath");
create_config_dir(&target).unwrap();
let mode = std::fs::metadata(&target).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700);
}
#[cfg(unix)]
#[test]
fn config_path_honors_leviath_home() {
temp_env::with_vars(
[
("LEVIATH_CONFIG_PATH", None::<&str>),
("LEVIATH_HOME", Some("/tmp/lev-cfg-test")),
],
|| {
assert_eq!(
Config::config_path(),
std::path::PathBuf::from("/tmp/lev-cfg-test/.leviath/config.toml")
);
},
);
}
#[test]
fn config_path_prefers_the_explicit_override() {
temp_env::with_vars(
[
("LEVIATH_CONFIG_PATH", Some("/tmp/exact.toml")),
("LEVIATH_HOME", Some("/tmp/lev-cfg-test")),
],
|| {
assert_eq!(
Config::config_path(),
std::path::PathBuf::from("/tmp/exact.toml")
);
},
);
}
#[test]
fn permissions_for_agent_overlays_the_named_grant_on_the_global() {
let mut config = Config::default();
config
.tool_permissions
.insert("shell".to_string(), ToolPolicy::Ask);
config
.tool_permissions
.insert("write_file".to_string(), ToolPolicy::Deny);
config.agent_tool_permissions.insert(
"coder".to_string(),
HashMap::from([("shell".to_string(), ToolPolicy::Allow)]),
);
let coder = config.permissions_for_agent("coder");
assert_eq!(coder.get("shell"), Some(&ToolPolicy::Allow), "granted");
assert_eq!(
coder.get("write_file"),
Some(&ToolPolicy::Deny),
"the rest of the global ceiling still applies"
);
let other = config.permissions_for_agent("researcher");
assert_eq!(other.get("shell"), Some(&ToolPolicy::Ask));
}
#[test]
fn read_path_grants_merge_global_and_per_agent() {
let mut config = Config::default();
assert!(
!config.security.allow_blueprint_read_paths,
"blueprint read paths must be opt-in"
);
assert!(config.read_path_grants_for_agent("cto").is_empty());
config.security.read_paths = vec!["~/.leviath/runs".to_string()];
config.agent_read_paths.insert(
"cto".to_string(),
ReadPathGrants {
allow: vec!["glob:~/design-docs/**".to_string()],
},
);
assert_eq!(
config.read_path_grants_for_agent("cto"),
vec![
"~/.leviath/runs".to_string(),
"glob:~/design-docs/**".to_string(),
]
);
assert_eq!(
config.read_path_grants_for_agent("researcher"),
vec!["~/.leviath/runs".to_string()]
);
}
#[test]
fn provider_config_debug_never_prints_the_keys() {
let providers = ProviderConfig {
anthropic_api_key: Some("sk-ant-SECRET-VALUE".to_string()),
openai_api_key: Some("sk-openai-SECRET-VALUE".to_string()),
google_api_key: Some("AIza-SECRET-VALUE".to_string()),
claude_code_enabled: true,
claude_code_binary: None,
claude_code_effort: None,
anthropic_cache_ttl: None,
fallback_order: Vec::new(),
};
let rendered = format!("{providers:?}");
assert!(!rendered.contains("SECRET-VALUE"), "key leaked: {rendered}");
assert!(rendered.contains("<set>"), "{rendered}");
assert!(rendered.contains("claude_code_enabled: true"), "{rendered}");
let empty = format!(
"{:?}",
ProviderConfig {
anthropic_api_key: None,
openai_api_key: None,
google_api_key: None,
claude_code_enabled: false,
claude_code_binary: None,
claude_code_effort: None,
anthropic_cache_ttl: None,
fallback_order: Vec::new(),
}
);
assert!(empty.contains("<unset>"), "{empty}");
}
#[cfg(unix)]
#[test]
fn saving_a_config_never_leaves_it_group_or_world_readable() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
Config::default().save_to_path(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "fresh config must be owner-only");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
Config::default().save_to_path(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "re-saving must re-tighten");
}
#[cfg(unix)]
#[test]
fn set_dir_permissions_sets_0700() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
set_dir_permissions(dir.path());
let mode = std::fs::metadata(dir.path()).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700);
}
#[test]
fn test_validate_keys_good_anthropic() {
let config = Config {
providers: ProviderConfig {
anthropic_api_key: Some("sk-ant-test123".to_string()),
openai_api_key: None,
google_api_key: None,
claude_code_enabled: false,
claude_code_binary: None,
claude_code_effort: None,
anthropic_cache_ttl: None,
fallback_order: Vec::new(),
},
..Config::default()
};
assert!(config.validate_keys().is_empty());
}
#[test]
fn test_validate_keys_bad_anthropic() {
let config = Config {
providers: ProviderConfig {
anthropic_api_key: Some("bad-key".to_string()),
openai_api_key: None,
google_api_key: None,
claude_code_enabled: false,
claude_code_binary: None,
claude_code_effort: None,
anthropic_cache_ttl: None,
fallback_order: Vec::new(),
},
..Config::default()
};
let warnings = config.validate_keys();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("Anthropic"));
}
#[test]
fn test_validate_keys_good_openai() {
let config = Config {
providers: ProviderConfig {
anthropic_api_key: None,
openai_api_key: Some("sk-test123".to_string()),
google_api_key: None,
claude_code_enabled: false,
claude_code_binary: None,
claude_code_effort: None,
anthropic_cache_ttl: None,
fallback_order: Vec::new(),
},
..Config::default()
};
assert!(config.validate_keys().is_empty());
}
#[test]
fn test_validate_keys_bad_openai() {
let config = Config {
providers: ProviderConfig {
anthropic_api_key: None,
openai_api_key: Some("bad-key".to_string()),
google_api_key: None,
claude_code_enabled: false,
claude_code_binary: None,
claude_code_effort: None,
anthropic_cache_ttl: None,
fallback_order: Vec::new(),
},
..Config::default()
};
let warnings = config.validate_keys();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("OpenAI"));
}
#[test]
fn test_validate_keys_no_keys() {
let config = Config::default();
assert!(config.validate_keys().is_empty());
}
#[test]
fn config_default_values() {
let config = Config::default();
assert_eq!(config.default_provider, "anthropic");
assert!(config.providers.anthropic_api_key.is_none());
assert!(config.providers.openai_api_key.is_none());
assert!(config.providers.google_api_key.is_none());
assert!(config.openrouter_api_key.is_none());
assert!(config.ollama_base_url.is_none());
assert!(config.mcp_servers.is_empty());
assert!(config.default_model.is_none());
assert!(config.model_capabilities.is_empty());
assert!(config.tool_permissions.is_empty());
}
#[test]
fn title_config_default() {
let tc = TitleConfig::default();
assert!(tc.enabled);
assert!(tc.provider.is_none());
assert!(tc.model.is_none());
}
#[test]
fn title_config_serde_roundtrip() {
let tc = TitleConfig {
enabled: false,
provider: Some("openai".to_string()),
model: Some("gpt-5.4-mini".to_string()),
};
let json = serde_json::to_string(&tc).unwrap();
let back: TitleConfig = serde_json::from_str(&json).unwrap();
assert!(!back.enabled);
assert_eq!(back.provider.as_deref(), Some("openai"));
assert_eq!(back.model.as_deref(), Some("gpt-5.4-mini"));
}
#[test]
fn tool_policy_default_is_ask() {
let policy = ToolPolicy::default();
assert_eq!(policy, ToolPolicy::Ask);
}
#[test]
fn tool_policy_serde_roundtrip() {
for policy in [ToolPolicy::Allow, ToolPolicy::Ask, ToolPolicy::Deny] {
let json = serde_json::to_string(&policy).unwrap();
let back: ToolPolicy = serde_json::from_str(&json).unwrap();
assert_eq!(policy, back);
}
}
#[test]
fn tool_policy_snake_case_serialization() {
assert_eq!(
serde_json::to_string(&ToolPolicy::Allow).unwrap(),
"\"allow\""
);
assert_eq!(serde_json::to_string(&ToolPolicy::Ask).unwrap(), "\"ask\"");
assert_eq!(
serde_json::to_string(&ToolPolicy::Deny).unwrap(),
"\"deny\""
);
}
#[test]
fn config_from_toml_with_all_fields() {
let toml_content = r#"
default_provider = "openai"
openrouter_api_key = "sk-or-test"
ollama_base_url = "http://my-ollama:11434"
default_model = "gpt-5"
agent_paths = []
[providers]
anthropic_api_key = "sk-ant-test"
openai_api_key = "sk-test"
google_api_key = "AIza-test"
[tool_permissions]
bash = "deny"
read_file = "allow"
[title]
enabled = false
provider = "anthropic"
model = "claude-haiku-4-5"
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert_eq!(config.default_provider, "openai");
assert_eq!(
config.providers.anthropic_api_key.as_deref(),
Some("sk-ant-test")
);
assert_eq!(config.providers.openai_api_key.as_deref(), Some("sk-test"));
assert_eq!(
config.providers.google_api_key.as_deref(),
Some("AIza-test")
);
assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-test"));
assert_eq!(
config.ollama_base_url.as_deref(),
Some("http://my-ollama:11434")
);
assert_eq!(config.default_model.as_deref(), Some("gpt-5"));
assert!(!config.title.enabled);
assert_eq!(config.tool_permissions.get("bash"), Some(&ToolPolicy::Deny));
assert_eq!(
config.tool_permissions.get("read_file"),
Some(&ToolPolicy::Allow)
);
}
#[test]
fn config_from_minimal_toml() {
let toml_content = r#"
default_provider = "anthropic"
agent_paths = []
[providers]
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert_eq!(config.default_provider, "anthropic");
assert!(config.providers.anthropic_api_key.is_none());
}
#[test]
fn the_three_lines_that_point_leviath_at_openrouter_are_enough() {
let config: Config = toml::from_str(
r#"
default_provider = "openrouter"
default_model = "openai/gpt-4o-mini"
openrouter_api_key = "sk-or-test"
"#,
)
.expect("a hand-written OpenRouter config parses");
assert_eq!(config.default_provider, "openrouter");
assert_eq!(config.openrouter_api_key.as_deref(), Some("sk-or-test"));
assert_eq!(config.default_model.as_deref(), Some("openai/gpt-4o-mini"));
}
#[test]
fn an_empty_config_file_parses_to_the_defaults() {
let parsed: Config = toml::from_str("").expect("an empty config parses");
let default = Config::default();
assert_eq!(parsed.default_provider, default.default_provider);
assert_eq!(parsed.agent_paths, default.agent_paths);
assert_eq!(parsed.openrouter_api_key, default.openrouter_api_key);
assert_eq!(parsed.ollama_base_url, default.ollama_base_url);
assert_eq!(parsed.default_model, default.default_model);
assert_eq!(parsed.request_timeout_secs, default.request_timeout_secs);
assert_eq!(
parsed.providers.anthropic_api_key,
default.providers.anthropic_api_key
);
assert_eq!(
parsed.providers.claude_code_enabled,
default.providers.claude_code_enabled
);
}
#[test]
fn config_from_toml_with_mcp_servers() {
let toml_content = r#"
default_provider = "anthropic"
agent_paths = []
[providers]
[[mcp_servers]]
name = "test-server"
command = "echo"
args = ["hello"]
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert_eq!(config.mcp_servers.len(), 1);
assert_eq!(config.mcp_servers[0].name, "test-server");
}
#[test]
fn load_rejects_a_malformed_mcp_server_entry() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
default_provider = "anthropic"
agent_paths = []
[providers]
[[mcp_servers]]
name = "broken"
"#,
)
.unwrap();
let err = Config::load_from_path(&path).expect_err("malformed entry must fail load");
let msg = err.to_string();
assert!(msg.contains("broken"), "must name the server: {msg}");
}
#[test]
fn load_accepts_a_well_formed_http_mcp_server() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
default_provider = "anthropic"
agent_paths = []
[providers]
[[mcp_servers]]
name = "remote"
url = "https://mcp.example.com/mcp"
"#,
)
.unwrap();
let config = Config::load_from_path(&path).expect("valid http entry should load");
assert_eq!(
config.mcp_servers[0].url.as_deref(),
Some("https://mcp.example.com/mcp")
);
}
#[test]
fn config_from_toml_with_model_capabilities() {
let toml = r#"
[model_capabilities."my-custom-model"]
max_context_tokens = 1048576
"#;
let config: Config = toml::from_str(toml).expect("a partial entry parses");
let entry = config
.model_capabilities
.get("my-custom-model")
.expect("the entry survives");
assert_eq!(entry.max_context_tokens, Some(1_048_576));
assert_eq!(
entry.max_output_tokens, None,
"an unmentioned field stays unset rather than defaulting"
);
assert_eq!(entry.supports_tools, None);
}
#[test]
fn config_model_capabilities_rejects_an_unknown_key() {
let toml = r#"
[model_capabilities."my-custom-model"]
max_contxt_tokens = 1048576
"#;
assert!(toml::from_str::<Config>(toml).is_err());
}
#[test]
fn validate_keys_is_quiet_about_blank_keys() {
let mut config = Config::default();
config.providers.anthropic_api_key = Some(String::new());
config.providers.openai_api_key = Some(" ".to_string());
assert!(config.validate_keys().is_empty());
config.providers.anthropic_api_key = Some("nope".to_string());
assert_eq!(config.validate_keys().len(), 1);
}
#[test]
fn validate_keys_both_bad() {
let config = Config {
providers: ProviderConfig {
anthropic_api_key: Some("bad".to_string()),
openai_api_key: Some("bad".to_string()),
google_api_key: None,
claude_code_enabled: false,
claude_code_binary: None,
claude_code_effort: None,
anthropic_cache_ttl: None,
fallback_order: Vec::new(),
},
..Config::default()
};
let warnings = config.validate_keys();
assert_eq!(warnings.len(), 2);
}
#[test]
fn config_path_contains_leviath() {
temp_env::with_var_unset("LEVIATH_CONFIG_PATH", || {
let path = Config::config_path();
assert!(path.to_str().unwrap().contains(".leviath"));
assert!(path.to_str().unwrap().ends_with("config.toml"));
});
}
#[test]
fn config_toml_roundtrip() {
let config = Config {
default_provider: "openai".to_string(),
providers: ProviderConfig {
anthropic_api_key: Some("sk-ant-key".to_string()),
openai_api_key: None,
google_api_key: None,
claude_code_enabled: false,
claude_code_binary: None,
claude_code_effort: None,
anthropic_cache_ttl: None,
fallback_order: Vec::new(),
},
tool_permissions: {
let mut m = HashMap::new();
m.insert("bash".to_string(), ToolPolicy::Deny);
m
},
..Config::default()
};
let serialized = toml::to_string_pretty(&config).unwrap();
let deserialized: Config = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.default_provider, "openai");
assert_eq!(
deserialized.providers.anthropic_api_key.as_deref(),
Some("sk-ant-key")
);
assert_eq!(
deserialized.tool_permissions.get("bash"),
Some(&ToolPolicy::Deny)
);
}
#[test]
fn validate_keys_both_valid() {
let config = Config {
providers: ProviderConfig {
anthropic_api_key: Some("sk-ant-good-key".to_string()),
openai_api_key: Some("sk-good-key".to_string()),
google_api_key: None,
claude_code_enabled: false,
claude_code_binary: None,
claude_code_effort: None,
anthropic_cache_ttl: None,
fallback_order: Vec::new(),
},
..Config::default()
};
assert!(config.validate_keys().is_empty());
}
#[test]
fn validate_keys_google_key_not_validated() {
let config = Config {
providers: ProviderConfig {
anthropic_api_key: None,
openai_api_key: None,
google_api_key: Some("anything-goes".to_string()),
claude_code_enabled: false,
claude_code_binary: None,
claude_code_effort: None,
anthropic_cache_ttl: None,
fallback_order: Vec::new(),
},
..Config::default()
};
assert!(config.validate_keys().is_empty());
}
#[test]
fn config_from_toml_custom_registries() {
let toml_content = r#"
default_provider = "anthropic"
agent_paths = ["/my/agents"]
[providers]
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert_eq!(config.agent_paths.len(), 1);
}
#[test]
fn config_save_creates_file() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("subdir").join("config.toml");
let config = Config::default();
let content = toml::to_string_pretty(&config).unwrap();
std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
std::fs::write(&config_path, &content).unwrap();
assert!(config_path.exists());
let loaded_content = std::fs::read_to_string(&config_path).unwrap();
let loaded: Config = toml::from_str(&loaded_content).unwrap();
assert_eq!(loaded.default_provider, "anthropic");
}
#[test]
fn title_config_from_toml_defaults() {
let toml_content = r#"
default_provider = "anthropic"
agent_paths = []
[providers]
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert!(config.title.enabled);
assert!(config.title.provider.is_none());
assert!(config.title.model.is_none());
}
#[test]
fn title_config_from_toml_disabled() {
let toml_content = r#"
default_provider = "anthropic"
agent_paths = []
[providers]
[title]
enabled = false
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert!(!config.title.enabled);
}
#[test]
fn title_config_missing_enabled_key_uses_default_true() {
let toml_content = r#"
default_provider = "anthropic"
agent_paths = []
[providers]
[title]
provider = "openai"
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert!(config.title.enabled);
assert_eq!(config.title.provider.as_deref(), Some("openai"));
}
#[test]
fn config_tool_permissions_allow() {
let toml_content = r#"
default_provider = "anthropic"
agent_paths = []
[providers]
[tool_permissions]
read_file = "allow"
write_file = "ask"
bash = "deny"
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert_eq!(
config.tool_permissions.get("read_file"),
Some(&ToolPolicy::Allow)
);
assert_eq!(
config.tool_permissions.get("write_file"),
Some(&ToolPolicy::Ask)
);
assert_eq!(config.tool_permissions.get("bash"), Some(&ToolPolicy::Deny));
}
#[test]
fn config_with_agent_paths() {
let toml_content = r#"
default_provider = "anthropic"
agent_paths = ["/home/user/agents", "/opt/agents"]
[providers]
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert_eq!(config.agent_paths.len(), 2);
}
#[test]
fn config_load_from_nonexistent_path_returns_default() {
let config = Config::default();
assert_eq!(config.default_provider, "anthropic");
assert!(config.providers.anthropic_api_key.is_none());
}
#[test]
fn config_load_from_toml_string() {
let toml_content = r#"
default_provider = "openai"
agent_paths = []
[providers]
anthropic_api_key = "sk-ant-test-key"
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert_eq!(config.default_provider, "openai");
assert_eq!(
config.providers.anthropic_api_key.as_deref(),
Some("sk-ant-test-key")
);
assert_eq!(config.nudge, leviath_core::NudgeConfig::default());
}
#[test]
fn config_parses_partial_nudge_section() {
let config: Config = toml::from_str(
r#"
default_provider = "openai"
agent_paths = []
[providers]
[nudge]
enabled = false
"#,
)
.unwrap();
assert_eq!(config.nudge.enabled, Some(false));
assert_eq!(config.nudge.max, None);
assert_eq!(config.nudge.text, None);
}
#[test]
fn config_save_and_load_with_file() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let config = Config {
default_provider: "openai".to_string(),
providers: ProviderConfig {
anthropic_api_key: Some("sk-ant-test".to_string()),
openai_api_key: Some("sk-test".to_string()),
google_api_key: None,
claude_code_enabled: false,
claude_code_binary: None,
claude_code_effort: None,
anthropic_cache_ttl: None,
fallback_order: Vec::new(),
},
openrouter_api_key: Some("sk-or-test".to_string()),
default_model: Some("gpt-5".to_string()),
..Config::default()
};
let content = toml::to_string_pretty(&config).unwrap();
std::fs::write(&config_path, &content).unwrap();
let loaded_content = std::fs::read_to_string(&config_path).unwrap();
let loaded: Config = toml::from_str(&loaded_content).unwrap();
assert_eq!(loaded.default_provider, "openai");
assert_eq!(
loaded.providers.anthropic_api_key.as_deref(),
Some("sk-ant-test")
);
assert_eq!(loaded.default_model.as_deref(), Some("gpt-5"));
}
#[test]
fn config_create_config_dir_creates_parent() {
let dir = tempfile::tempdir().unwrap();
let new_dir = dir.path().join("nested").join("config");
std::fs::create_dir_all(&new_dir).unwrap();
assert!(new_dir.exists());
}
#[test]
fn config_default_title_enabled() {
let config = Config::default();
assert!(config.title.enabled);
}
#[test]
fn config_serialize_with_all_options() {
let mut model_caps = HashMap::new();
model_caps.insert(
"my-model".to_string(),
ModelCapabilityOverride {
supports_temperature: Some(true),
supports_streaming: Some(true),
supports_tools: Some(true),
supports_system_prompt: Some(true),
max_context_tokens: Some(8192),
max_output_tokens: Some(4096),
},
);
let mut tool_perms = HashMap::new();
tool_perms.insert("bash".to_string(), ToolPolicy::Allow);
let config = Config {
default_provider: "anthropic".to_string(),
providers: ProviderConfig {
anthropic_api_key: Some("sk-ant-key".to_string()),
openai_api_key: None,
google_api_key: None,
claude_code_enabled: false,
claude_code_binary: None,
claude_code_effort: None,
anthropic_cache_ttl: None,
fallback_order: Vec::new(),
},
agent_paths: vec![std::path::PathBuf::from("/my/agents")],
openrouter_api_key: None,
ollama_base_url: Some("http://custom:11434".to_string()),
mcp_servers: vec![],
default_model: None,
model_capabilities: model_caps,
model_providers: HashMap::new(),
tool_permissions: tool_perms,
agent_tool_permissions: HashMap::new(),
safe_commands: crate::approvals::SafeCommands::default(),
agent_safe_commands: HashMap::new(),
title: TitleConfig {
enabled: false,
provider: Some("openai".to_string()),
model: Some("gpt-5-mini".to_string()),
},
request_timeout_secs: None,
rate_limits: HashMap::new(),
taint_tracking: false,
limits: LimitsConfig {
mcp_idle_disconnect_secs: default_mcp_idle_disconnect_secs(),
max_tool_call_write_bytes: None,
max_run_write_bytes: None,
max_concurrent_inferences: Some(4),
max_concurrent_tools: 3,
default_max_iterations: Some(99),
exact_token_counting: false,
script_shell_timeout_secs: 45,
stall_timeout_secs: 90,
dead_cycles_before_relief: 6,
finished_retention_secs: 120,
wedge_timeout_secs: 420,
provider_failures_before_open: 5,
provider_circuit_cooldown_secs: 120,
interaction_timeout_secs: 120,
},
batch_tool_hint: true,
shell_hint: false,
nudge: leviath_core::NudgeConfig {
enabled: Some(true),
max: Some(2),
text: Some("Use your tools.".to_string()),
},
webhook: WebhookConfig {
max_retries: 5,
base_delay_ms: 250,
max_delay_ms: 10_000,
timeout_secs: 7,
},
observability: ObservabilityConfig {
enabled: true,
exporter: TelemetryExporterKind::Stdout,
endpoint: Some("http://collector:4318".to_string()),
service_name: Some("leviath-prod".to_string()),
},
sandbox: Some(leviath_core::ToolSandboxConfig {
kind: leviath_core::SandboxKind::Container,
image: Some("ubuntu:24.04".to_string()),
network: false,
..Default::default()
}),
tool_script_permissions: ScriptToolPermissions {
http_get: ScriptPermission::Allow,
http_post: ScriptPermission::Deny,
shell: ScriptPermission::Deny,
read_file: ScriptPermission::Inherit,
write_file: ScriptPermission::Deny,
env_var: ScriptPermission::Allow,
},
security: SecurityConfig {
allowed_workdirs: Vec::new(),
allow_seed_commands: false,
allow_local_network: true,
allow_env_vars: vec!["MY_PROVIDER_KEY".to_string()],
allow_blueprint_read_paths: true,
allow_blueprint_safe_commands: true,
read_paths: vec!["~/.leviath/runs".to_string()],
credential_store: leviath_core::CredentialStoreKind::Keychain,
allow_blueprint_permissions: false,
shell_env: leviath_core::ShellEnvMode::default(),
shell_env_withhold: Vec::new(),
},
agent_read_paths: HashMap::from([(
"cto".to_string(),
ReadPathGrants {
allow: vec!["glob:~/design-docs/**".to_string()],
},
)]),
};
let serialized = toml::to_string_pretty(&config).unwrap();
let deserialized: Config = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.default_provider, "anthropic");
assert_eq!(deserialized.limits.max_concurrent_inferences, Some(4));
assert_eq!(deserialized.limits.max_concurrent_tools, 3);
assert_eq!(deserialized.limits.script_shell_timeout_secs, 45);
assert_eq!(deserialized.limits.dead_cycles_before_relief, 6);
assert_eq!(deserialized.limits.finished_retention_secs, 120);
assert_eq!(
deserialized.tool_script_permissions.http_get,
ScriptPermission::Allow
);
assert_eq!(
deserialized.tool_script_permissions.shell,
ScriptPermission::Deny
);
assert_eq!(
deserialized.tool_script_permissions.write_file,
ScriptPermission::Deny
);
assert!(deserialized.batch_tool_hint);
assert!(!deserialized.shell_hint);
assert!(!deserialized.security.allow_seed_commands);
assert!(deserialized.security.allow_blueprint_read_paths);
assert_eq!(deserialized.security.read_paths, vec!["~/.leviath/runs"]);
assert_eq!(
deserialized.agent_read_paths.get("cto"),
Some(&ReadPathGrants {
allow: vec!["glob:~/design-docs/**".to_string()],
})
);
assert_eq!(
deserialized.nudge,
leviath_core::NudgeConfig {
enabled: Some(true),
max: Some(2),
text: Some("Use your tools.".to_string()),
}
);
assert_eq!(deserialized.webhook.max_retries, 5);
assert_eq!(deserialized.webhook.base_delay_ms, 250);
assert_eq!(deserialized.webhook.max_delay_ms, 10_000);
assert_eq!(deserialized.webhook.timeout_secs, 7);
assert!(deserialized.observability.enabled);
assert_eq!(
deserialized.observability.exporter,
TelemetryExporterKind::Stdout
);
assert_eq!(
deserialized.observability.endpoint.as_deref(),
Some("http://collector:4318")
);
assert_eq!(
deserialized.observability.service_name.as_deref(),
Some("leviath-prod")
);
assert_eq!(deserialized.limits.default_max_iterations, Some(99));
assert_eq!(
deserialized.providers.anthropic_api_key.as_deref(),
Some("sk-ant-key")
);
assert_eq!(deserialized.agent_paths.len(), 1);
assert!(deserialized.model_capabilities.contains_key("my-model"));
assert_eq!(
deserialized.tool_permissions.get("bash"),
Some(&ToolPolicy::Allow)
);
assert!(!deserialized.title.enabled);
assert_eq!(deserialized.title.provider.as_deref(), Some("openai"));
let sandbox = deserialized.sandbox.expect("sandbox round-trips");
assert_eq!(sandbox.kind, leviath_core::SandboxKind::Container);
assert_eq!(sandbox.image.as_deref(), Some("ubuntu:24.04"));
assert!(!sandbox.network);
}
#[test]
fn config_multiple_model_capabilities() {
let toml_content = r#"
default_provider = "anthropic"
agent_paths = []
[providers]
[model_capabilities."model-a"]
supports_temperature = true
supports_streaming = true
supports_tools = true
supports_system_prompt = true
max_context_tokens = 8192
max_output_tokens = 4096
[model_capabilities."model-b"]
supports_temperature = false
supports_streaming = false
supports_tools = false
supports_system_prompt = false
max_context_tokens = 2048
max_output_tokens = 1024
"#;
let config: Config = toml::from_str(toml_content).unwrap();
assert_eq!(config.model_capabilities.len(), 2);
let caps_a = config.model_capabilities.get("model-a").unwrap();
assert_eq!(caps_a.supports_temperature, Some(true));
assert_eq!(caps_a.max_context_tokens, Some(8192));
let caps_b = config.model_capabilities.get("model-b").unwrap();
assert_eq!(caps_b.supports_temperature, Some(false));
assert_eq!(caps_b.max_context_tokens, Some(2048));
}
}