diff --git a/CHANGELOG.md b/CHANGELOG.md index 85986eb4..2f495e78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- refactor(vault): extract vault logic into new `zeph-vault` crate (Epic #1973 Phase 1c) + - New `zeph-vault` crate at Layer 1 with `VaultProvider` trait, `EnvVaultProvider`, `AgeVaultProvider`, `ArcAgeVaultProvider`, `AgeVaultError`, `default_vault_dir()` + - `MockVaultProvider` gated behind `#[cfg(any(test, feature = "mock"))]` — accessible from downstream test code via `zeph-vault/mock` feature + - `pub use zeph_common::secret::{Secret, VaultError}` re-exported from `zeph-vault` preserving `crate::vault::Secret` paths + - `zeph-core/src/vault.rs` replaced with thin re-export shim `pub use zeph_vault::*;` — zero import path changes in consumers + - `age_encrypt_decrypt_resolve_secrets_roundtrip` integration test kept in `zeph-core` (depends on `SecretResolver` trait) + - `age` and `zeroize` direct dependencies removed from `zeph-core` (now provided transitively via `zeph-vault`) + - refactor(config): extract pure-data configuration types into new `zeph-config` crate (Epic #1973 Phase 1a) - New `zeph-config` crate at Layer 1 (no `zeph-core` dependency) with all pure-data config structs - Moved: `AgentConfig`, `FocusConfig`, `LlmConfig`, `MemoryConfig`, `SecurityConfig`, `TrustConfig`, `TimeoutConfig`, `RateLimitConfig`, `ContentIsolationConfig`, `QuarantineConfig`, `ExfiltrationGuardConfig`, `PiiFilterConfig`, `CustomPiiPattern`, `MemoryWriteValidationConfig`, `GuardrailConfig`, `GuardrailAction`, `GuardrailFailStrategy`, `PermissionMode`, `MemoryScope`, `ToolPolicy`, `SkillFilter`, `HookDef`, `HookType`, `HookMatcher`, `SubagentHooks`, `DumpFormat`, and all other pure-data config types diff --git a/Cargo.lock b/Cargo.lock index 72967a47..dca2ae59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9493,7 +9493,7 @@ dependencies = [ "zeph-memory", "zeph-skills", "zeph-tools", - "zeroize", + "zeph-vault", ] [[package]] @@ -9748,6 +9748,22 @@ dependencies = [ "zeph-core", ] +[[package]] +name = "zeph-vault" +version = "0.15.3" +dependencies = [ + "age", + "proptest", + "serde", + "serde_json", + "serial_test", + "tempfile", + "thiserror 2.0.18", + "tokio", + "zeph-common", + "zeroize", +] + [[package]] name = "zerocopy" version = "0.8.42" diff --git a/Cargo.toml b/Cargo.toml index 41ac86fe..1e14060b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -127,6 +127,7 @@ zeph-scheduler = { path = "crates/zeph-scheduler", version = "0.15.3" } zeph-skills = { path = "crates/zeph-skills", version = "0.15.3" } zeph-tools = { path = "crates/zeph-tools", version = "0.15.3" } zeph-tui = { path = "crates/zeph-tui", version = "0.15.3" } +zeph-vault = { path = "crates/zeph-vault", version = "0.15.3" } [workspace.lints.rust] unsafe_code = "deny" diff --git a/crates/zeph-core/Cargo.toml b/crates/zeph-core/Cargo.toml index 47e0e337..39b1f238 100644 --- a/crates/zeph-core/Cargo.toml +++ b/crates/zeph-core/Cargo.toml @@ -19,14 +19,14 @@ compression-guidelines = ["zeph-memory/compression-guidelines", "zeph-config/com cuda = ["zeph-llm/cuda"] experiments = ["dep:ordered-float", "zeph-memory/experiments", "zeph-config/experiments"] guardrail = ["zeph-config/guardrail"] -metal = ["zeph-llm/metal"] lsp-context = ["zeph-config/lsp-context"] +metal = ["zeph-llm/metal"] +mock = ["zeph-vault/mock"] policy-enforcer = ["zeph-tools/policy-enforcer", "zeph-config/policy-enforcer"] scheduler = [] context-compression = [] [dependencies] -age.workspace = true async-trait.workspace = true base64.workspace = true blake3.workspace = true @@ -56,13 +56,13 @@ tree-sitter.workspace = true uuid = { workspace = true, features = ["v4", "serde"] } zeph-common.workspace = true zeph-config.workspace = true +zeph-vault.workspace = true zeph-index.workspace = true zeph-llm.workspace = true zeph-memory.workspace = true zeph-mcp.workspace = true zeph-skills.workspace = true zeph-tools.workspace = true -zeroize = { workspace = true, features = ["derive", "serde"] } # See https://github.com/bug-ops/zeph (workspace dependencies only contain versions) [[bench]] @@ -70,6 +70,7 @@ name = "context_building" harness = false [dev-dependencies] +age.workspace = true criterion.workspace = true rmcp.workspace = true indoc.workspace = true @@ -81,6 +82,7 @@ sqlx.workspace = true tempfile.workspace = true zeph-llm.workspace = true zeph-memory.workspace = true +zeph-vault = { workspace = true, features = ["mock"] } [lints] workspace = true diff --git a/crates/zeph-core/src/config/mod.rs b/crates/zeph-core/src/config.rs similarity index 70% rename from crates/zeph-core/src/config/mod.rs rename to crates/zeph-core/src/config.rs index ccb47d86..b61c6a46 100644 --- a/crates/zeph-core/src/config/mod.rs +++ b/crates/zeph-core/src/config.rs @@ -1,35 +1,27 @@ // SPDX-FileCopyrightText: 2026 Andrei G // SPDX-License-Identifier: MIT OR Apache-2.0 -// Config is defined in zeph-config. Inherent impls (load, validate, env overrides, -// normalize_legacy_runtime_defaults) live there. Only trait impls (SecretResolver) -// can be added here due to Rust orphan rules. -pub mod migrate { - pub use zeph_config::migrate::*; -} - -#[cfg(test)] -mod tests; +//! Extension trait for resolving vault secrets into a Config. +//! +//! This trait is defined in zeph-core (not in zeph-config) due to Rust's orphan rule: +//! implementing a foreign trait on a foreign type requires the trait to be defined locally. -pub use zeph_config::{Config, ConfigError, ResolvedSecrets}; -pub use zeph_tools::AutonomyLevel; - -// Re-export all previously available types so downstream users see no change. +// Re-export Config types from zeph-config for internal use. pub use zeph_config::{ AcpConfig, AcpLspConfig, AcpTransport, AgentConfig, CandleConfig, CascadeClassifierMode, CascadeConfig, CloudLlmConfig, CompatibleConfig, CompressionConfig, CompressionStrategy, - CostConfig, DaemonConfig, DebugConfig, DetectorMode, DiscordConfig, DocumentConfig, DumpFormat, - ExperimentConfig, ExperimentSchedule, FocusConfig, GatewayConfig, GeminiConfig, - GenerationParams, GraphConfig, HookDef, HookMatcher, HookType, IndexConfig, LearningConfig, - LlmConfig, LogRotation, LoggingConfig, MAX_TOKENS_CAP, McpConfig, McpOAuthConfig, - McpServerConfig, MemoryConfig, MemoryScope, NoteLinkingConfig, OAuthTokenStorage, - ObservabilityConfig, OllamaConfig, OpenAiConfig, OrchestrationConfig, OrchestratorConfig, - OrchestratorProviderConfig, PermissionMode, ProviderKind, PruningStrategy, RateLimitConfig, - RouterConfig, RouterStrategyConfig, RoutingConfig, RoutingStrategy, ScheduledTaskConfig, - ScheduledTaskKind, SchedulerConfig, SecurityConfig, SemanticConfig, SessionsConfig, - SidequestConfig, SkillFilter, SkillPromptMode, SkillsConfig, SlackConfig, SttConfig, - SubAgentConfig, SubAgentLifecycleHooks, SubagentHooks, TelegramConfig, TimeoutConfig, - ToolPolicy, TraceConfig, TrustConfig, TuiConfig, VaultConfig, VectorBackend, + Config, ConfigError, CostConfig, DaemonConfig, DebugConfig, DetectorMode, DiscordConfig, + DocumentConfig, DumpFormat, ExperimentConfig, ExperimentSchedule, FocusConfig, GatewayConfig, + GeminiConfig, GenerationParams, GraphConfig, HookDef, HookMatcher, HookType, IndexConfig, + LearningConfig, LlmConfig, LogRotation, LoggingConfig, MAX_TOKENS_CAP, McpConfig, + McpOAuthConfig, McpServerConfig, MemoryConfig, MemoryScope, NoteLinkingConfig, + OAuthTokenStorage, ObservabilityConfig, OllamaConfig, OpenAiConfig, OrchestrationConfig, + OrchestratorConfig, OrchestratorProviderConfig, PermissionMode, ProviderKind, PruningStrategy, + RateLimitConfig, ResolvedSecrets, RouterConfig, RouterStrategyConfig, RoutingConfig, + RoutingStrategy, ScheduledTaskConfig, ScheduledTaskKind, SchedulerConfig, SecurityConfig, + SemanticConfig, SessionsConfig, SidequestConfig, SkillFilter, SkillPromptMode, SkillsConfig, + SlackConfig, SttConfig, SubAgentConfig, SubAgentLifecycleHooks, SubagentHooks, TelegramConfig, + TimeoutConfig, ToolPolicy, TraceConfig, TrustConfig, TuiConfig, VaultConfig, VectorBackend, }; #[cfg(feature = "lsp-context")] @@ -54,7 +46,11 @@ pub use zeph_config::{ pub use zeph_config::providers::{default_stt_language, default_stt_model, default_stt_provider}; -use crate::vault::VaultProvider; +pub mod migrate { + pub use zeph_config::migrate::*; +} + +use crate::vault::{Secret, VaultProvider}; /// Extension trait for resolving vault secrets into a [`Config`]. /// @@ -74,8 +70,6 @@ pub trait SecretResolver { impl SecretResolver for Config { async fn resolve_secrets(&mut self, vault: &dyn VaultProvider) -> Result<(), ConfigError> { - use crate::vault::Secret; - if let Some(val) = vault.get_secret("ZEPH_CLAUDE_API_KEY").await? { self.secrets.claude_api_key = Some(Secret::new(val)); } @@ -154,3 +148,29 @@ impl SecretResolver for Config { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + #[cfg(any(test, feature = "mock"))] + async fn resolve_secrets_with_mock_vault() { + use crate::vault::MockVaultProvider; + + let vault = MockVaultProvider::new() + .with_secret("ZEPH_CLAUDE_API_KEY", "sk-test-123") + .with_secret("ZEPH_TELEGRAM_TOKEN", "tg-token-456"); + + let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap(); + config.resolve_secrets(&vault).await.unwrap(); + + assert_eq!( + config.secrets.claude_api_key.as_ref().unwrap().expose(), + "sk-test-123" + ); + if let Some(tg) = config.telegram { + assert_eq!(tg.token.as_deref(), Some("tg-token-456")); + } + } +} diff --git a/crates/zeph-core/src/config/tests.rs b/crates/zeph-core/src/config/tests.rs deleted file mode 100644 index 9a2a8ad9..00000000 --- a/crates/zeph-core/src/config/tests.rs +++ /dev/null @@ -1,3243 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Andrei G -// SPDX-License-Identifier: MIT OR Apache-2.0 - -// std::env::set_var / remove_var are unsafe in Rust 2024 edition; all callers are #[serial]. -#![allow(unsafe_code)] -#![allow( - clippy::default_trait_access, - clippy::doc_markdown, - clippy::field_reassign_with_default, - clippy::unnecessary_get_then_check -)] -use std::collections::HashMap; -use std::io::Write; - -use serial_test::serial; - -/// Test helper: verify a Secret in a `HashMap` matches the expected plaintext. -/// Separated to avoid `CodeQL` cleartext-logging false positives on `.get().expose()`. -fn assert_custom_secret(custom: &HashMap, key: &str, expected: &str) { - let actual = custom - .get(key) - .unwrap_or_else(|| panic!("missing key: {key}")); - assert_eq!(actual.expose(), expected, "secret mismatch for key: {key}"); -} - -use super::*; - -const ENV_KEYS: [&str; 53] = [ - "ZEPH_LLM_PROVIDER", - "ZEPH_LLM_BASE_URL", - "ZEPH_LLM_MODEL", - "ZEPH_LLM_EMBEDDING_MODEL", - "ZEPH_CLAUDE_API_KEY", - "ZEPH_OPENAI_API_KEY", - "ZEPH_SQLITE_PATH", - "ZEPH_QDRANT_URL", - "ZEPH_MEMORY_SUMMARIZATION_THRESHOLD", - "ZEPH_MEMORY_CONTEXT_BUDGET_TOKENS", - "ZEPH_MEMORY_COMPACTION_THRESHOLD", - "ZEPH_MEMORY_SOFT_COMPACTION_THRESHOLD", - "ZEPH_MEMORY_COMPACTION_PRESERVE_TAIL", - "ZEPH_MEMORY_PRUNE_PROTECT_TOKENS", - "ZEPH_MEMORY_SEMANTIC_ENABLED", - "ZEPH_MEMORY_RECALL_LIMIT", - "ZEPH_SKILLS_MAX_ACTIVE", - "ZEPH_TELEGRAM_TOKEN", - "ZEPH_A2A_AUTH_TOKEN", - "ZEPH_A2A_ENABLED", - "ZEPH_A2A_HOST", - "ZEPH_A2A_PORT", - "ZEPH_A2A_PUBLIC_URL", - "ZEPH_A2A_RATE_LIMIT", - "ZEPH_A2A_REQUIRE_TLS", - "ZEPH_A2A_SSRF_PROTECTION", - "ZEPH_A2A_MAX_BODY_SIZE", - "ZEPH_SECURITY_REDACT_SECRETS", - "ZEPH_TIMEOUT_LLM", - "ZEPH_TIMEOUT_EMBEDDING", - "ZEPH_TIMEOUT_A2A", - "ZEPH_TOOLS_TIMEOUT", - "ZEPH_TOOLS_SHELL_ALLOWED_COMMANDS", - "ZEPH_TOOLS_SHELL_ALLOWED_PATHS", - "ZEPH_TOOLS_SHELL_ALLOW_NETWORK", - "ZEPH_TOOLS_SCRAPE_TIMEOUT", - "ZEPH_TOOLS_SCRAPE_MAX_BODY", - "ZEPH_TOOLS_AUDIT_ENABLED", - "ZEPH_TOOLS_AUDIT_DESTINATION", - "ZEPH_SKILLS_LEARNING_ENABLED", - "ZEPH_SKILLS_LEARNING_AUTO_ACTIVATE", - "ZEPH_TOOLS_SUMMARIZE_OUTPUT", - "ZEPH_MEMORY_AUTO_BUDGET", - "ZEPH_INDEX_ENABLED", - "ZEPH_INDEX_MAX_CHUNKS", - "ZEPH_INDEX_SCORE_THRESHOLD", - "ZEPH_INDEX_BUDGET_RATIO", - "ZEPH_INDEX_REPO_MAP_TOKENS", - "ZEPH_STT_PROVIDER", - "ZEPH_STT_MODEL", - "ZEPH_AUTO_UPDATE_CHECK", - "ZEPH_LOG_FILE", - "ZEPH_LOG_LEVEL", -]; - -fn clear_env() { - for key in ENV_KEYS { - unsafe { std::env::remove_var(key) }; - } -} - -#[test] -fn defaults_when_file_missing() { - let config = Config::default(); - assert_eq!(config.llm.provider, super::ProviderKind::Ollama); - assert_eq!(config.llm.base_url, "http://localhost:11434"); - assert_eq!(config.llm.model, "qwen3:8b"); - assert_eq!(config.llm.embedding_model, "qwen3-embedding"); - assert_eq!(config.agent.name, "Zeph"); - assert_eq!(config.memory.history_limit, 50); - assert_eq!(config.memory.qdrant_url, "http://localhost:6334"); - assert!(config.llm.cloud.is_none()); - assert!(config.llm.openai.is_none()); - assert!(config.telegram.is_none()); - assert!(config.tools.enabled); - assert_eq!(config.tools.shell.timeout, 30); - assert!(config.tools.shell.blocked_commands.is_empty()); - assert_eq!(config.skills.paths, vec![default_skills_dir()]); - assert_eq!(config.memory.sqlite_path, default_sqlite_path()); - assert_eq!(config.debug.output_dir, default_debug_dir()); - assert_eq!(config.logging.file, default_log_file_path()); -} - -#[test] -#[serial] -fn legacy_runtime_defaults_are_rewritten_but_custom_relative_paths_are_preserved() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("legacy-defaults.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "TestBot" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" -embedding_model = "qwen3-embedding" - -[skills] -paths = [".zeph/skills", "./extra-skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[debug] -output_dir = ".zeph/debug" - -[logging] -file = ".zeph/logs/zeph.log" -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.skills.paths[0], default_skills_dir()); - assert_eq!(config.skills.paths[1], "./extra-skills"); - assert_eq!(config.memory.sqlite_path, default_sqlite_path()); - assert_eq!(config.debug.output_dir, default_debug_dir()); - assert_eq!(config.logging.file, default_log_file_path()); -} - -#[test] -#[serial] -fn parse_valid_toml() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("test.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "TestBot" - -[llm] -provider = "ollama" -base_url = "http://custom:1234" -model = "llama3:8b" - -[skills] -paths = ["./s"] - -[memory] -sqlite_path = "./test.db" -history_limit = 10 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.agent.name, "TestBot"); - assert_eq!(config.llm.base_url, "http://custom:1234"); - assert_eq!(config.llm.model, "llama3:8b"); - assert_eq!(config.memory.history_limit, 10); -} - -#[test] -#[serial] -fn parse_toml_with_cloud() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("cloud.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "claude" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[llm.cloud] -model = "claude-sonnet-4-5-20250929" -max_tokens = 4096 - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.llm.provider, super::ProviderKind::Claude); - let cloud = config.llm.cloud.unwrap(); - assert_eq!(cloud.model, "claude-sonnet-4-5-20250929"); - assert_eq!(cloud.max_tokens, 4096); -} - -#[test] -#[serial] -fn env_overrides() { - clear_env(); - let mut config = Config::default(); - assert_eq!(config.llm.model, "qwen3:8b"); - - unsafe { std::env::set_var("ZEPH_LLM_MODEL", "phi3:mini") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_LLM_MODEL") }; - - assert_eq!(config.llm.model, "phi3:mini"); -} - -#[test] -#[serial] -fn telegram_config_from_toml() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("tg.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[telegram] -token = "123:ABC" -allowed_users = ["alice", "bob"] -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let tg = config.telegram.unwrap(); - assert_eq!(tg.token.as_deref(), Some("123:ABC")); - assert_eq!(tg.allowed_users, vec!["alice", "bob"]); -} - -#[tokio::test] -async fn resolve_secrets_populates_telegram_token() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new().with_secret("ZEPH_TELEGRAM_TOKEN", "vault-token"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - let tg = config.telegram.unwrap(); - assert_eq!(tg.token.as_deref(), Some("vault-token")); -} - -#[test] -#[serial] -fn config_with_tools_section() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("tools.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[tools] -enabled = true - -[tools.shell] -timeout = 60 -blocked_commands = ["custom-danger"] -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert!(config.tools.enabled); - assert_eq!(config.tools.shell.timeout, 60); - assert_eq!(config.tools.shell.blocked_commands, vec!["custom-danger"]); -} - -#[test] -#[serial] -fn config_without_tools_section() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("no_tools.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert!(config.tools.enabled); - assert_eq!(config.tools.shell.timeout, 30); - assert!(config.tools.shell.blocked_commands.is_empty()); -} - -#[test] -#[serial] -fn env_override_tools_timeout() { - clear_env(); - let mut config = Config::default(); - assert_eq!(config.tools.shell.timeout, 30); - - unsafe { std::env::set_var("ZEPH_TOOLS_TIMEOUT", "120") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TOOLS_TIMEOUT") }; - - assert_eq!(config.tools.shell.timeout, 120); -} - -#[test] -#[serial] -fn env_override_tools_timeout_invalid_ignored() { - clear_env(); - let mut config = Config::default(); - assert_eq!(config.tools.shell.timeout, 30); - - unsafe { std::env::set_var("ZEPH_TOOLS_TIMEOUT", "not-a-number") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TOOLS_TIMEOUT") }; - - assert_eq!(config.tools.shell.timeout, 30); -} - -#[test] -#[serial] -fn env_override_allowed_commands() { - clear_env(); - let mut config = Config::default(); - assert!(config.tools.shell.allowed_commands.is_empty()); - - unsafe { std::env::set_var("ZEPH_TOOLS_SHELL_ALLOWED_COMMANDS", "curl, wget , ") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TOOLS_SHELL_ALLOWED_COMMANDS") }; - - assert_eq!(config.tools.shell.allowed_commands, vec!["curl", "wget"]); -} - -#[test] -fn config_default_embedding_model() { - let config = Config::default(); - assert_eq!(config.llm.embedding_model, "qwen3-embedding"); -} - -#[test] -#[serial] -fn config_parse_embedding_model() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("embed.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" -embedding_model = "nomic-embed-text" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.llm.embedding_model, "nomic-embed-text"); -} - -#[test] -#[serial] -fn config_env_override_embedding_model() { - let mut config = Config::default(); - assert_eq!(config.llm.embedding_model, "qwen3-embedding"); - - unsafe { std::env::set_var("ZEPH_LLM_EMBEDDING_MODEL", "mxbai-embed-large") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_LLM_EMBEDDING_MODEL") }; - - assert_eq!(config.llm.embedding_model, "mxbai-embed-large"); -} - -#[test] -#[serial] -fn config_missing_embedding_model_uses_default() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("no_embed.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.llm.embedding_model, "qwen3-embedding"); -} - -#[test] -fn config_default_qdrant_url() { - let config = Config::default(); - assert_eq!(config.memory.qdrant_url, "http://localhost:6334"); -} - -#[test] -#[serial] -fn config_parse_qdrant_url() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("qdrant.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -qdrant_url = "http://qdrant:6334" -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.memory.qdrant_url, "http://qdrant:6334"); -} - -#[test] -#[serial] -fn config_env_override_qdrant_url() { - let mut config = Config::default(); - assert_eq!(config.memory.qdrant_url, "http://localhost:6334"); - - unsafe { std::env::set_var("ZEPH_QDRANT_URL", "http://remote:6334") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_QDRANT_URL") }; - - assert_eq!(config.memory.qdrant_url, "http://remote:6334"); -} - -#[test] -fn config_default_summarization_threshold() { - let config = Config::default(); - assert_eq!(config.memory.summarization_threshold, 50); -} - -#[test] -#[serial] -fn config_parse_summarization_threshold() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("sum.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -summarization_threshold = 200 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.memory.summarization_threshold, 200); -} - -#[test] -#[serial] -fn config_env_override_summarization_threshold() { - let mut config = Config::default(); - assert_eq!(config.memory.summarization_threshold, 50); - - unsafe { std::env::set_var("ZEPH_MEMORY_SUMMARIZATION_THRESHOLD", "150") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_MEMORY_SUMMARIZATION_THRESHOLD") }; - - assert_eq!(config.memory.summarization_threshold, 150); -} - -#[test] -fn config_default_context_budget_tokens() { - let config = Config::default(); - assert_eq!(config.memory.context_budget_tokens, 0); -} - -#[test] -fn config_parse_context_budget_tokens() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("budget.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -context_budget_tokens = 4096 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.memory.context_budget_tokens, 4096); -} - -#[test] -#[serial] -fn config_env_override_context_budget_tokens() { - let mut config = Config::default(); - assert_eq!(config.memory.context_budget_tokens, 0); - - unsafe { std::env::set_var("ZEPH_MEMORY_CONTEXT_BUDGET_TOKENS", "8192") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_MEMORY_CONTEXT_BUDGET_TOKENS") }; - - assert_eq!(config.memory.context_budget_tokens, 8192); -} - -#[test] -fn learning_config_defaults() { - let config = Config::default(); - let lc = &config.skills.learning; - assert!(!lc.enabled); - assert!(!lc.auto_activate); - assert_eq!(lc.min_failures, 3); - assert!((lc.improve_threshold - 0.7).abs() < f64::EPSILON); - assert!((lc.rollback_threshold - 0.5).abs() < f64::EPSILON); - assert_eq!(lc.min_evaluations, 5); - assert_eq!(lc.max_versions, 10); - assert_eq!(lc.cooldown_minutes, 60); -} - -#[test] -fn parse_toml_with_learning_section() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("learn.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[skills.learning] -enabled = true -auto_activate = true -min_failures = 5 -improve_threshold = 0.6 -rollback_threshold = 0.4 -min_evaluations = 10 -max_versions = 20 -cooldown_minutes = 120 - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let lc = &config.skills.learning; - assert!(lc.enabled); - assert!(lc.auto_activate); - assert_eq!(lc.min_failures, 5); - assert!((lc.improve_threshold - 0.6).abs() < f64::EPSILON); - assert!((lc.rollback_threshold - 0.4).abs() < f64::EPSILON); - assert_eq!(lc.min_evaluations, 10); - assert_eq!(lc.max_versions, 20); - assert_eq!(lc.cooldown_minutes, 120); -} - -#[test] -#[serial] -fn parse_toml_without_learning_uses_defaults() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("no_learn.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert!(!config.skills.learning.enabled); - assert_eq!(config.skills.learning.min_failures, 3); -} - -#[test] -#[serial] -fn env_override_learning_enabled() { - clear_env(); - let mut config = Config::default(); - assert!(!config.skills.learning.enabled); - - unsafe { std::env::set_var("ZEPH_SKILLS_LEARNING_ENABLED", "true") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_SKILLS_LEARNING_ENABLED") }; - - assert!(config.skills.learning.enabled); -} - -#[test] -#[serial] -fn env_override_learning_auto_activate() { - clear_env(); - let mut config = Config::default(); - assert!(!config.skills.learning.auto_activate); - - unsafe { std::env::set_var("ZEPH_SKILLS_LEARNING_AUTO_ACTIVATE", "true") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_SKILLS_LEARNING_AUTO_ACTIVATE") }; - - assert!(config.skills.learning.auto_activate); -} - -#[test] -#[serial] -fn env_override_learning_invalid_ignored() { - clear_env(); - let mut config = Config::default(); - assert!(!config.skills.learning.enabled); - - unsafe { std::env::set_var("ZEPH_SKILLS_LEARNING_ENABLED", "not-a-bool") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_SKILLS_LEARNING_ENABLED") }; - - assert!(!config.skills.learning.enabled); -} - -#[tokio::test] -async fn resolve_secrets_populates_claude_api_key() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new().with_secret("ZEPH_CLAUDE_API_KEY", "sk-test-123"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - assert_eq!( - config.secrets.claude_api_key.as_ref().unwrap().expose(), - "sk-test-123" - ); -} - -#[tokio::test] -async fn resolve_secrets_populates_a2a_auth_token() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new().with_secret("ZEPH_A2A_AUTH_TOKEN", "a2a-secret"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - assert_eq!(config.a2a.auth_token.as_deref(), Some("a2a-secret")); -} - -#[tokio::test] -async fn resolve_secrets_empty_vault_leaves_defaults() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new(); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - assert!(config.secrets.claude_api_key.is_none()); - assert!(config.secrets.openai_api_key.is_none()); - assert!(config.telegram.is_none()); - assert!(config.a2a.auth_token.is_none()); -} - -#[tokio::test] -async fn resolve_secrets_overrides_toml_values() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new().with_secret("ZEPH_TELEGRAM_TOKEN", "vault-token"); - let mut config = Config::default(); - config.telegram = Some(TelegramConfig { - token: Some("toml-token".into()), - allowed_users: Vec::new(), - }); - config.resolve_secrets(&vault).await.unwrap(); - let tg = config.telegram.unwrap(); - assert_eq!(tg.token.as_deref(), Some("vault-token")); -} - -#[test] -fn telegram_debug_redacts_token() { - let tg = TelegramConfig { - token: Some("secret-token".into()), - allowed_users: vec!["alice".into()], - }; - let debug = format!("{tg:?}"); - assert!(!debug.contains("secret-token")); - assert!(debug.contains("[REDACTED]")); -} - -#[test] -fn a2a_debug_redacts_auth_token() { - let a2a = A2aServerConfig { - auth_token: Some("secret-auth".into()), - ..A2aServerConfig::default() - }; - let debug = format!("{a2a:?}"); - assert!(!debug.contains("secret-auth")); - assert!(debug.contains("[REDACTED]")); -} - -#[test] -fn acp_debug_redacts_auth_token() { - let cfg = AcpConfig { - auth_token: Some("secret".to_string()), - ..AcpConfig::default() - }; - let debug = format!("{cfg:?}"); - assert!(!debug.contains("secret")); - assert!(debug.contains("[REDACTED]")); -} - -#[test] -fn vault_config_default_backend() { - let config = Config::default(); - assert_eq!(config.vault.backend, "env"); -} - -#[test] -fn mcp_config_defaults() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("mcp-defaults.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Test" -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "m" -[skills] -paths = [".zeph/skills"] -[memory] -sqlite_path = ":memory:" -history_limit = 50 -qdrant_url = "http://localhost:6334" -[mcp] -"# - ) - .unwrap(); - let config = Config::load(&path).unwrap(); - assert!(config.mcp.servers.is_empty()); - assert!(config.mcp.allowed_commands.is_empty()); - assert_eq!(config.mcp.max_dynamic_servers, 10); -} - -#[test] -#[serial] -fn parse_toml_with_mcp() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("mcp.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[mcp] -allowed_commands = ["npx"] -max_dynamic_servers = 5 - -[[mcp.servers]] -id = "github" -command = "npx" -args = ["-y", "mcp-github"] -timeout = 60 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.mcp.allowed_commands, vec!["npx"]); - assert_eq!(config.mcp.max_dynamic_servers, 5); - assert_eq!(config.mcp.servers.len(), 1); - assert_eq!(config.mcp.servers[0].id, "github"); - assert_eq!(config.mcp.servers[0].command.as_deref(), Some("npx")); - assert_eq!(config.mcp.servers[0].timeout, 60); -} - -#[test] -#[serial] -fn parse_toml_mcp_http_server() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("mcp_http.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[[mcp.servers]] -id = "remote" -url = "http://remote-mcp:8080" -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.mcp.servers.len(), 1); - assert_eq!(config.mcp.servers[0].id, "remote"); - assert_eq!( - config.mcp.servers[0].url.as_deref(), - Some("http://remote-mcp:8080") - ); - assert!(config.mcp.servers[0].command.is_none()); - assert_eq!(config.mcp.servers[0].timeout, 30); -} - -#[test] -fn a2a_config_defaults() { - let config = Config::default(); - assert!(!config.a2a.enabled); - assert_eq!(config.a2a.host, "0.0.0.0"); - assert_eq!(config.a2a.port, 8080); - assert!(config.a2a.public_url.is_empty()); - assert!(config.a2a.auth_token.is_none()); - assert_eq!(config.a2a.rate_limit, 60); - assert!(config.a2a.require_tls); - assert!(config.a2a.ssrf_protection); - assert_eq!(config.a2a.max_body_size, 1_048_576); -} - -#[test] -#[serial] -fn parse_toml_with_a2a() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("a2a.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[a2a] -enabled = true -host = "127.0.0.1" -port = 9090 -public_url = "https://agent.example.com" -auth_token = "secret" -rate_limit = 120 -require_tls = false -ssrf_protection = false -max_body_size = 2097152 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert!(config.a2a.enabled); - assert_eq!(config.a2a.host, "127.0.0.1"); - assert_eq!(config.a2a.port, 9090); - assert_eq!(config.a2a.public_url, "https://agent.example.com"); - assert_eq!(config.a2a.auth_token.as_deref(), Some("secret")); - assert_eq!(config.a2a.rate_limit, 120); - assert!(!config.a2a.require_tls); - assert!(!config.a2a.ssrf_protection); - assert_eq!(config.a2a.max_body_size, 2_097_152); -} - -#[test] -fn security_config_defaults() { - let config = Config::default(); - assert!(config.security.redact_secrets); -} - -#[test] -#[serial] -fn parse_toml_with_security() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("sec.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[security] -redact_secrets = false -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert!(!config.security.redact_secrets); -} - -#[test] -fn timeout_config_defaults() { - let config = Config::default(); - assert_eq!(config.timeouts.llm_seconds, 120); - assert_eq!(config.timeouts.embedding_seconds, 30); - assert_eq!(config.timeouts.a2a_seconds, 30); -} - -#[test] -#[serial] -fn parse_toml_with_timeouts() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("timeouts.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[timeouts] -llm_seconds = 60 -embedding_seconds = 15 -a2a_seconds = 10 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.timeouts.llm_seconds, 60); - assert_eq!(config.timeouts.embedding_seconds, 15); - assert_eq!(config.timeouts.a2a_seconds, 10); -} - -#[test] -#[serial] -fn parse_toml_with_vault() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("vault.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[vault] -backend = "age" -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.vault.backend, "age"); -} - -#[test] -#[serial] -fn env_override_a2a_enabled() { - clear_env(); - let mut config = Config::default(); - assert!(!config.a2a.enabled); - - unsafe { std::env::set_var("ZEPH_A2A_ENABLED", "true") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_A2A_ENABLED") }; - - assert!(config.a2a.enabled); -} - -#[test] -#[serial] -fn env_override_a2a_host_port() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_A2A_HOST", "127.0.0.1") }; - unsafe { std::env::set_var("ZEPH_A2A_PORT", "3000") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_A2A_HOST") }; - unsafe { std::env::remove_var("ZEPH_A2A_PORT") }; - - assert_eq!(config.a2a.host, "127.0.0.1"); - assert_eq!(config.a2a.port, 3000); -} - -#[test] -#[serial] -fn env_override_a2a_rate_limit() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_A2A_RATE_LIMIT", "200") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_A2A_RATE_LIMIT") }; - - assert_eq!(config.a2a.rate_limit, 200); -} - -#[test] -#[serial] -fn env_override_security_redact() { - clear_env(); - let mut config = Config::default(); - assert!(config.security.redact_secrets); - - unsafe { std::env::set_var("ZEPH_SECURITY_REDACT_SECRETS", "false") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_SECURITY_REDACT_SECRETS") }; - - assert!(!config.security.redact_secrets); -} - -#[test] -#[serial] -fn env_override_timeout_llm() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_TIMEOUT_LLM", "300") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TIMEOUT_LLM") }; - - assert_eq!(config.timeouts.llm_seconds, 300); -} - -#[test] -#[serial] -fn env_override_timeout_embedding() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_TIMEOUT_EMBEDDING", "45") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TIMEOUT_EMBEDDING") }; - - assert_eq!(config.timeouts.embedding_seconds, 45); -} - -#[test] -#[serial] -fn env_override_timeout_a2a() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_TIMEOUT_A2A", "90") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TIMEOUT_A2A") }; - - assert_eq!(config.timeouts.a2a_seconds, 90); -} - -#[test] -#[serial] -fn env_override_a2a_require_tls() { - clear_env(); - let mut config = Config::default(); - assert!(config.a2a.require_tls); - - unsafe { std::env::set_var("ZEPH_A2A_REQUIRE_TLS", "false") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_A2A_REQUIRE_TLS") }; - - assert!(!config.a2a.require_tls); -} - -#[test] -#[serial] -fn env_override_a2a_ssrf_protection() { - clear_env(); - let mut config = Config::default(); - assert!(config.a2a.ssrf_protection); - - unsafe { std::env::set_var("ZEPH_A2A_SSRF_PROTECTION", "false") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_A2A_SSRF_PROTECTION") }; - - assert!(!config.a2a.ssrf_protection); -} - -#[test] -#[serial] -fn env_override_a2a_max_body_size() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_A2A_MAX_BODY_SIZE", "524288") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_A2A_MAX_BODY_SIZE") }; - - assert_eq!(config.a2a.max_body_size, 524_288); -} - -#[test] -#[serial] -fn env_override_scrape_timeout() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_TOOLS_SCRAPE_TIMEOUT", "60") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TOOLS_SCRAPE_TIMEOUT") }; - - assert_eq!(config.tools.scrape.timeout, 60); -} - -#[test] -#[serial] -fn env_override_scrape_max_body() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_TOOLS_SCRAPE_MAX_BODY", "2097152") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TOOLS_SCRAPE_MAX_BODY") }; - - assert_eq!(config.tools.scrape.max_body_bytes, 2_097_152); -} - -#[test] -#[serial] -fn env_override_shell_allowed_paths() { - clear_env(); - let mut config = Config::default(); - assert!(config.tools.shell.allowed_paths.is_empty()); - - unsafe { std::env::set_var("ZEPH_TOOLS_SHELL_ALLOWED_PATHS", "/tmp, /home") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TOOLS_SHELL_ALLOWED_PATHS") }; - - assert_eq!(config.tools.shell.allowed_paths, vec!["/tmp", "/home"]); -} - -#[test] -#[serial] -fn env_override_shell_allow_network() { - clear_env(); - let mut config = Config::default(); - assert!(config.tools.shell.allow_network); - - unsafe { std::env::set_var("ZEPH_TOOLS_SHELL_ALLOW_NETWORK", "false") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TOOLS_SHELL_ALLOW_NETWORK") }; - - assert!(!config.tools.shell.allow_network); -} - -#[test] -#[serial] -fn env_override_audit_enabled() { - clear_env(); - let mut config = Config::default(); - assert!(!config.tools.audit.enabled); - - unsafe { std::env::set_var("ZEPH_TOOLS_AUDIT_ENABLED", "true") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TOOLS_AUDIT_ENABLED") }; - - assert!(config.tools.audit.enabled); -} - -#[test] -#[serial] -fn env_override_audit_destination() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_TOOLS_AUDIT_DESTINATION", "/var/log/audit.log") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TOOLS_AUDIT_DESTINATION") }; - - assert_eq!(config.tools.audit.destination, "/var/log/audit.log"); -} - -#[test] -#[serial] -fn env_override_semantic_enabled() { - clear_env(); - let mut config = Config::default(); - assert!(config.memory.semantic.enabled); - - unsafe { std::env::set_var("ZEPH_MEMORY_SEMANTIC_ENABLED", "false") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_MEMORY_SEMANTIC_ENABLED") }; - - assert!(!config.memory.semantic.enabled); -} - -#[test] -#[serial] -fn env_override_recall_limit() { - clear_env(); - let mut config = Config::default(); - assert_eq!(config.memory.semantic.recall_limit, 5); - - unsafe { std::env::set_var("ZEPH_MEMORY_RECALL_LIMIT", "20") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_MEMORY_RECALL_LIMIT") }; - - assert_eq!(config.memory.semantic.recall_limit, 20); -} - -#[test] -#[serial] -fn env_override_skills_max_active() { - clear_env(); - let mut config = Config::default(); - assert_eq!(config.skills.max_active_skills, 5); - - unsafe { std::env::set_var("ZEPH_SKILLS_MAX_ACTIVE", "10") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_SKILLS_MAX_ACTIVE") }; - - assert_eq!(config.skills.max_active_skills, 10); -} - -#[test] -#[serial] -fn env_override_a2a_public_url() { - clear_env(); - let mut config = Config::default(); - assert!(config.a2a.public_url.is_empty()); - - unsafe { std::env::set_var("ZEPH_A2A_PUBLIC_URL", "https://my-agent.dev") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_A2A_PUBLIC_URL") }; - - assert_eq!(config.a2a.public_url, "https://my-agent.dev"); -} - -#[test] -fn mcp_server_config_debug_redacts_env() { - let mcp = McpServerConfig { - id: "test".into(), - command: Some("npx".into()), - args: vec![], - env: HashMap::from([("SECRET".into(), "super-secret".into())]), - url: None, - headers: HashMap::new(), - oauth: None, - timeout: 30, - policy: Default::default(), - }; - let debug = format!("{mcp:?}"); - assert!(!debug.contains("super-secret")); - assert!(debug.contains("[REDACTED]")); -} - -#[test] -#[serial] -fn mcp_server_config_default_timeout() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("mcp_default_timeout.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[[mcp.servers]] -id = "test" -command = "cmd" -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.mcp.servers[0].timeout, 30); -} - -#[test] -fn config_load_nonexistent_file_uses_defaults() { - let path = std::path::Path::new("/nonexistent/config.toml"); - let config = Config::load(path).unwrap(); - assert_eq!(config.agent.name, "Zeph"); - assert_eq!(config.llm.provider, super::ProviderKind::Ollama); -} - -#[test] -fn generation_params_defaults() { - let params = GenerationParams::default(); - assert!((params.temperature - 0.7).abs() < f64::EPSILON); - assert!(params.top_p.is_none()); - assert!(params.top_k.is_none()); - assert_eq!(params.max_tokens, 2048); - assert_eq!(params.seed, 42); - assert!((params.repeat_penalty - 1.1).abs() < f32::EPSILON); - assert_eq!(params.repeat_last_n, 64); -} - -#[test] -fn generation_params_capped_max_tokens() { - let mut params = GenerationParams::default(); - params.max_tokens = 100_000; - assert_eq!(params.capped_max_tokens(), 32_768); -} - -#[test] -fn generation_params_capped_below_cap() { - let params = GenerationParams::default(); - assert_eq!(params.capped_max_tokens(), 2048); -} - -#[test] -fn semantic_config_defaults() { - let config = SemanticConfig::default(); - assert!(config.enabled); - assert_eq!(config.recall_limit, 5); -} - -#[test] -fn resolved_secrets_default() { - let secrets = ResolvedSecrets::default(); - assert!(secrets.claude_api_key.is_none()); - assert!(secrets.openai_api_key.is_none()); -} - -#[test] -#[serial] -fn parse_toml_with_all_sections() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("full.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "FullBot" - -[llm] -provider = "claude" -base_url = "http://localhost:11434" -model = "qwen3:8b" -embedding_model = "nomic" - -[llm.cloud] -model = "claude-sonnet-4-5-20250929" -max_tokens = 8192 - -[skills] -paths = [".zeph/skills", "./extra-skills"] -max_active_skills = 3 - -[skills.learning] -enabled = true -min_failures = 5 - -[memory] -sqlite_path = "./data/test.db" -history_limit = 100 -qdrant_url = "http://qdrant:6334" -summarization_threshold = 50 -context_budget_tokens = 4096 - -[memory.semantic] -enabled = true -recall_limit = 10 - -[telegram] -token = "123:TOKEN" -allowed_users = ["admin"] - -[tools] -enabled = true - -[tools.shell] -timeout = 90 -blocked_commands = ["rm"] -allowed_commands = ["curl"] -allowed_paths = ["/tmp"] -allow_network = false - -[tools.scrape] -timeout = 30 -max_body_bytes = 2097152 - -[tools.audit] -enabled = true -destination = "/var/log/zeph.log" - -[a2a] -enabled = true -host = "127.0.0.1" -port = 9090 -rate_limit = 100 - -[mcp] -max_dynamic_servers = 3 - -[vault] -backend = "age" - -[security] -redact_secrets = false - -[timeouts] -llm_seconds = 60 -embedding_seconds = 10 -a2a_seconds = 15 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.agent.name, "FullBot"); - assert_eq!(config.llm.provider, super::ProviderKind::Claude); - assert_eq!(config.llm.embedding_model, "nomic"); - assert!(config.llm.cloud.is_some()); - assert_eq!(config.skills.paths.len(), 2); - assert_eq!(config.skills.max_active_skills, 3); - assert!(config.skills.learning.enabled); - assert_eq!(config.memory.history_limit, 100); - assert!(config.memory.semantic.enabled); - assert_eq!(config.memory.semantic.recall_limit, 10); - assert!(config.telegram.is_some()); - assert!(!config.tools.shell.allow_network); - assert!(config.tools.audit.enabled); - assert!(config.a2a.enabled); - assert_eq!(config.mcp.max_dynamic_servers, 3); - assert_eq!(config.vault.backend, "age"); - assert!(!config.security.redact_secrets); - assert_eq!(config.timeouts.llm_seconds, 60); -} - -#[test] -#[serial] -fn parse_toml_with_openai() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("openai.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "openai" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[llm.openai] -base_url = "https://api.openai.com/v1" -model = "gpt-4o" -max_tokens = 4096 -embedding_model = "text-embedding-3-small" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.llm.provider, super::ProviderKind::OpenAi); - let openai = config.llm.openai.unwrap(); - assert_eq!(openai.base_url, "https://api.openai.com/v1"); - assert_eq!(openai.model, "gpt-4o"); - assert_eq!(openai.max_tokens, 4096); - assert_eq!( - openai.embedding_model.as_deref(), - Some("text-embedding-3-small") - ); -} - -#[test] -#[serial] -fn parse_toml_openai_without_embedding_model() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("openai_no_embed.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "openai" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[llm.openai] -base_url = "https://api.openai.com/v1" -model = "gpt-4o" -max_tokens = 4096 - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let openai = config.llm.openai.unwrap(); - assert!(openai.embedding_model.is_none()); -} - -#[tokio::test] -async fn resolve_secrets_populates_openai_api_key() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new().with_secret("ZEPH_OPENAI_API_KEY", "sk-openai-123"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - assert_eq!( - config.secrets.openai_api_key.as_ref().unwrap().expose(), - "sk-openai-123" - ); -} - -#[test] -#[serial] -fn parse_toml_openai_with_reasoning_effort() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("openai_reasoning.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "openai" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[llm.openai] -base_url = "https://api.openai.com/v1" -model = "gpt-5.2" -max_tokens = 4096 -reasoning_effort = "high" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let openai = config.llm.openai.unwrap(); - assert_eq!(openai.reasoning_effort.as_deref(), Some("high")); -} - -#[test] -#[serial] -fn parse_toml_openai_without_reasoning_effort() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("openai_no_reasoning.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "openai" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[llm.openai] -base_url = "https://api.openai.com/v1" -model = "gpt-5.2" -max_tokens = 4096 - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let openai = config.llm.openai.unwrap(); - assert!(openai.reasoning_effort.is_none()); -} - -#[test] -fn compaction_config_defaults() { - let config = Config::default(); - assert!((config.memory.soft_compaction_threshold - 0.60).abs() < f32::EPSILON); - assert!((config.memory.hard_compaction_threshold - 0.90).abs() < f32::EPSILON); - assert_eq!(config.memory.compaction_preserve_tail, 6); -} - -#[test] -#[serial] -fn compaction_config_parsing() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("compact.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -soft_compaction_threshold = 0.75 -hard_compaction_threshold = 0.95 -compaction_preserve_tail = 6 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert!((config.memory.soft_compaction_threshold - 0.75).abs() < f32::EPSILON); - assert!((config.memory.hard_compaction_threshold - 0.95).abs() < f32::EPSILON); - assert_eq!(config.memory.compaction_preserve_tail, 6); -} - -#[test] -#[serial] -fn compaction_env_overrides() { - clear_env(); - let mut config = Config::default(); - assert!((config.memory.soft_compaction_threshold - 0.60).abs() < f32::EPSILON); - assert!((config.memory.hard_compaction_threshold - 0.90).abs() < f32::EPSILON); - assert_eq!(config.memory.compaction_preserve_tail, 6); - - // ZEPH_MEMORY_COMPACTION_THRESHOLD maps to hard_compaction_threshold (backward compat). - unsafe { std::env::set_var("ZEPH_MEMORY_COMPACTION_THRESHOLD", "0.85") }; - unsafe { std::env::set_var("ZEPH_MEMORY_SOFT_COMPACTION_THRESHOLD", "0.60") }; - unsafe { std::env::set_var("ZEPH_MEMORY_COMPACTION_PRESERVE_TAIL", "8") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_MEMORY_COMPACTION_THRESHOLD") }; - unsafe { std::env::remove_var("ZEPH_MEMORY_SOFT_COMPACTION_THRESHOLD") }; - unsafe { std::env::remove_var("ZEPH_MEMORY_COMPACTION_PRESERVE_TAIL") }; - - assert!((config.memory.hard_compaction_threshold - 0.85).abs() < f32::EPSILON); - assert!((config.memory.soft_compaction_threshold - 0.60).abs() < f32::EPSILON); - assert_eq!(config.memory.compaction_preserve_tail, 8); -} - -#[test] -fn tools_summarize_output_default_true() { - let config = Config::default(); - assert!(config.tools.summarize_output); -} - -#[test] -#[serial] -fn env_override_tools_summarize_output() { - clear_env(); - let mut config = Config::default(); - assert!(config.tools.summarize_output); - - unsafe { std::env::set_var("ZEPH_TOOLS_SUMMARIZE_OUTPUT", "false") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_TOOLS_SUMMARIZE_OUTPUT") }; - - assert!(!config.tools.summarize_output); -} - -#[test] -#[serial] -fn auto_budget_default_true() { - clear_env(); - let config = Config::default(); - assert!(config.memory.auto_budget); -} - -#[test] -#[serial] -fn env_override_auto_budget() { - clear_env(); - let mut config = Config::default(); - assert!(config.memory.auto_budget); - - unsafe { std::env::set_var("ZEPH_MEMORY_AUTO_BUDGET", "false") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_MEMORY_AUTO_BUDGET") }; - - assert!(!config.memory.auto_budget); -} - -#[test] -fn index_config_defaults() { - let config = Config::default(); - assert!(!config.index.enabled); - assert_eq!(config.index.max_chunks, 12); - assert!((config.index.score_threshold - 0.25).abs() < f32::EPSILON); - assert!((config.index.budget_ratio - 0.40).abs() < f32::EPSILON); - assert_eq!(config.index.repo_map_tokens, 500); -} - -#[test] -#[serial] -fn index_config_from_toml() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("index.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[index] -enabled = true -max_chunks = 20 -score_threshold = 0.30 -budget_ratio = 0.50 -repo_map_tokens = 1000 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert!(config.index.enabled); - assert_eq!(config.index.max_chunks, 20); - assert!((config.index.score_threshold - 0.30).abs() < f32::EPSILON); - assert!((config.index.budget_ratio - 0.50).abs() < f32::EPSILON); - assert_eq!(config.index.repo_map_tokens, 1000); -} - -#[test] -#[serial] -fn index_config_missing_uses_defaults() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("no_index.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert!(!config.index.enabled); - assert_eq!(config.index.max_chunks, 12); -} - -#[test] -#[serial] -fn index_config_env_overrides() { - clear_env(); - let mut config = Config::default(); - assert!(!config.index.enabled); - assert_eq!(config.index.max_chunks, 12); - - unsafe { std::env::set_var("ZEPH_INDEX_ENABLED", "true") }; - unsafe { std::env::set_var("ZEPH_INDEX_MAX_CHUNKS", "24") }; - unsafe { std::env::set_var("ZEPH_INDEX_SCORE_THRESHOLD", "0.35") }; - unsafe { std::env::set_var("ZEPH_INDEX_BUDGET_RATIO", "0.60") }; - unsafe { std::env::set_var("ZEPH_INDEX_REPO_MAP_TOKENS", "750") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_INDEX_ENABLED") }; - unsafe { std::env::remove_var("ZEPH_INDEX_MAX_CHUNKS") }; - unsafe { std::env::remove_var("ZEPH_INDEX_SCORE_THRESHOLD") }; - unsafe { std::env::remove_var("ZEPH_INDEX_BUDGET_RATIO") }; - unsafe { std::env::remove_var("ZEPH_INDEX_REPO_MAP_TOKENS") }; - - assert!(config.index.enabled); - assert_eq!(config.index.max_chunks, 24); - assert!((config.index.score_threshold - 0.35).abs() < f32::EPSILON); - assert!((config.index.budget_ratio - 0.60).abs() < f32::EPSILON); - assert_eq!(config.index.repo_map_tokens, 750); -} - -#[test] -#[serial] -fn index_config_env_overrides_clamped() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_INDEX_SCORE_THRESHOLD", "-0.5") }; - unsafe { std::env::set_var("ZEPH_INDEX_BUDGET_RATIO", "2.0") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_INDEX_SCORE_THRESHOLD") }; - unsafe { std::env::remove_var("ZEPH_INDEX_BUDGET_RATIO") }; - - assert!((config.index.score_threshold - 0.0).abs() < f32::EPSILON); - assert!((config.index.budget_ratio - 1.0).abs() < f32::EPSILON); -} - -#[test] -#[serial] -fn index_config_env_override_invalid_ignored() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_INDEX_ENABLED", "not-a-bool") }; - unsafe { std::env::set_var("ZEPH_INDEX_MAX_CHUNKS", "abc") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_INDEX_ENABLED") }; - unsafe { std::env::remove_var("ZEPH_INDEX_MAX_CHUNKS") }; - - assert!(!config.index.enabled); - assert_eq!(config.index.max_chunks, 12); -} - -#[test] -fn security_config_default_autonomy_supervised() { - let config = Config::default(); - assert_eq!(config.security.autonomy_level, AutonomyLevel::Supervised); -} - -#[test] -fn discord_config_defaults() { - let config = Config::default(); - assert!(config.discord.is_none()); -} - -#[test] -#[serial] -fn parse_toml_with_autonomy_readonly() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("autonomy_readonly.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[security] -autonomy_level = "readonly" -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.security.autonomy_level, AutonomyLevel::ReadOnly); -} - -#[test] -#[serial] -fn discord_config_from_toml() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("discord.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[discord] -token = "discord-bot-token" -application_id = "12345" -allowed_user_ids = ["u1", "u2"] -allowed_role_ids = ["admin"] -allowed_channel_ids = ["ch1"] -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let dc = config.discord.unwrap(); - assert_eq!(dc.token.as_deref(), Some("discord-bot-token")); - assert_eq!(dc.application_id.as_deref(), Some("12345")); - assert_eq!(dc.allowed_user_ids, vec!["u1", "u2"]); - assert_eq!(dc.allowed_role_ids, vec!["admin"]); - assert_eq!(dc.allowed_channel_ids, vec!["ch1"]); -} - -#[test] -fn discord_debug_redacts_token() { - let dc = DiscordConfig { - token: Some("secret-discord-token".into()), - application_id: Some("app123".into()), - allowed_user_ids: vec![], - allowed_role_ids: vec![], - allowed_channel_ids: vec![], - }; - let debug = format!("{dc:?}"); - assert!(!debug.contains("secret-discord-token")); - assert!(debug.contains("[REDACTED]")); - assert!(debug.contains("app123")); -} - -#[test] -#[serial] -fn parse_toml_with_autonomy_full() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("autonomy_full.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[security] -autonomy_level = "full" -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.security.autonomy_level, AutonomyLevel::Full); -} - -#[test] -#[serial] -fn discord_config_empty_allowlists() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("discord_empty.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[discord] -token = "tok" -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let dc = config.discord.unwrap(); - assert!(dc.allowed_user_ids.is_empty()); - assert!(dc.allowed_role_ids.is_empty()); - assert!(dc.allowed_channel_ids.is_empty()); -} - -#[test] -fn slack_config_defaults() { - let config = Config::default(); - assert!(config.slack.is_none()); -} - -#[test] -#[serial] -fn slack_config_from_toml() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("slack.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 - -[slack] -bot_token = "xoxb-slack-token" -signing_secret = "slack-sign-secret" -port = 4000 -allowed_user_ids = ["U1"] -allowed_channel_ids = ["C1"] -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let sl = config.slack.unwrap(); - assert_eq!(sl.bot_token.as_deref(), Some("xoxb-slack-token")); - assert_eq!(sl.signing_secret.as_deref(), Some("slack-sign-secret")); - assert_eq!(sl.port, 4000); - assert_eq!(sl.allowed_user_ids, vec!["U1"]); - assert_eq!(sl.allowed_channel_ids, vec!["C1"]); -} - -#[test] -fn slack_config_default_port() { - let sl = SlackConfig { - bot_token: None, - signing_secret: None, - webhook_host: "127.0.0.1".into(), - port: 3000, - allowed_user_ids: vec![], - allowed_channel_ids: vec![], - }; - assert_eq!(sl.port, 3000); -} - -#[test] -fn slack_debug_redacts_tokens() { - let sl = SlackConfig { - bot_token: Some("xoxb-secret".into()), - signing_secret: Some("sign-secret".into()), - webhook_host: "127.0.0.1".into(), - port: 3000, - allowed_user_ids: vec![], - allowed_channel_ids: vec![], - }; - let debug = format!("{sl:?}"); - assert!(!debug.contains("xoxb-secret")); - assert!(!debug.contains("sign-secret")); - assert!(debug.contains("[REDACTED]")); - assert!(debug.contains("3000")); -} - -#[tokio::test] -async fn resolve_secrets_populates_discord_token() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new().with_secret("ZEPH_DISCORD_TOKEN", "dc-vault-token"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - let dc = config.discord.unwrap(); - assert_eq!(dc.token.as_deref(), Some("dc-vault-token")); -} - -#[tokio::test] -async fn resolve_secrets_populates_slack_tokens() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new() - .with_secret("ZEPH_SLACK_BOT_TOKEN", "xoxb-vault") - .with_secret("ZEPH_SLACK_SIGNING_SECRET", "sign-vault"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - let sl = config.slack.unwrap(); - assert_eq!(sl.bot_token.as_deref(), Some("xoxb-vault")); - assert_eq!(sl.signing_secret.as_deref(), Some("sign-vault")); -} - -#[tokio::test] -async fn resolve_secrets_populates_custom_map() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new() - .with_secret("ZEPH_SECRET_GITHUB_TOKEN", "gh-token-123") - .with_secret("ZEPH_SECRET_SOME_API_KEY", "api-val"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - assert_custom_secret(&config.secrets.custom, "github_token", "gh-token-123"); - assert_custom_secret(&config.secrets.custom, "some_api_key", "api-val"); -} - -#[tokio::test] -async fn resolve_secrets_custom_ignores_non_prefix_keys() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new() - .with_secret("ZEPH_CLAUDE_API_KEY", "claude-key") - .with_secret("OTHER_KEY", "other"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - assert!(config.secrets.custom.is_empty()); -} - -#[tokio::test] -async fn resolve_secrets_hyphen_in_vault_key_normalized_to_underscore() { - use crate::vault::MockVaultProvider; - let vault = MockVaultProvider::new().with_secret("ZEPH_SECRET_MY-KEY", "val"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - assert_custom_secret(&config.secrets.custom, "my_key", "val"); - assert!( - config.secrets.custom.get("my-key").is_none(), - "hyphenated key must not be stored" - ); -} - -#[tokio::test] -async fn resolve_secrets_bare_prefix_rejected() { - use crate::vault::MockVaultProvider; - // "ZEPH_SECRET_" with nothing after it — empty custom_name must be skipped - let vault = MockVaultProvider::new().with_secret("ZEPH_SECRET_", "val"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - assert!( - config.secrets.custom.is_empty(), - "bare ZEPH_SECRET_ prefix must not produce a custom entry" - ); -} - -#[tokio::test] -async fn resolve_secrets_get_secret_returns_none_skips_entry() { - use crate::vault::MockVaultProvider; - // Key is present in list_keys() but get_secret() returns None — entry must be skipped. - let vault = MockVaultProvider::new() - .with_listed_key("ZEPH_SECRET_GHOST") - .with_secret("ZEPH_SECRET_REAL", "val"); - let mut config = Config::default(); - config.resolve_secrets(&vault).await.unwrap(); - assert!( - config.secrets.custom.get("ghost").is_none(), - "key with None get_secret must not appear in custom map" - ); - assert_custom_secret(&config.secrets.custom, "real", "val"); -} - -#[test] -fn stt_config_defaults() { - let toml_str = r" -[llm.stt] -"; - let stt: SttConfig = toml::from_str(toml_str).unwrap(); - assert_eq!(stt.provider, "whisper"); - assert_eq!(stt.model, "whisper-1"); -} - -#[test] -fn stt_config_custom_values() { - let toml_str = r#" -provider = "custom" -model = "whisper-large-v3" -"#; - let stt: SttConfig = toml::from_str(toml_str).unwrap(); - assert_eq!(stt.provider, "custom"); - assert_eq!(stt.model, "whisper-large-v3"); -} - -#[test] -fn llm_config_stt_none_by_default() { - let config = Config::default(); - assert!(config.llm.stt.is_none()); -} - -#[test] -#[serial] -fn env_override_stt_provider_and_model() { - clear_env(); - let mut config = Config::default(); - assert!(config.llm.stt.is_none()); - - unsafe { std::env::set_var("ZEPH_STT_PROVIDER", "candle-whisper") }; - unsafe { std::env::set_var("ZEPH_STT_MODEL", "openai/whisper-tiny") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_STT_PROVIDER") }; - unsafe { std::env::remove_var("ZEPH_STT_MODEL") }; - - let stt = config.llm.stt.unwrap(); - assert_eq!(stt.provider, "candle-whisper"); - assert_eq!(stt.model, "openai/whisper-tiny"); -} - -#[test] -#[serial] -fn env_override_stt_provider_only() { - clear_env(); - let mut config = Config::default(); - - unsafe { std::env::set_var("ZEPH_STT_PROVIDER", "whisper") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_STT_PROVIDER") }; - - let stt = config.llm.stt.unwrap(); - assert_eq!(stt.provider, "whisper"); - assert_eq!(stt.model, "whisper-1"); -} - -#[test] -fn config_default_auto_update_check_is_true() { - let config = Config::default(); - assert!(config.agent.auto_update_check); -} - -#[test] -#[serial] -fn env_override_auto_update_check_false() { - clear_env(); - let mut config = Config::default(); - assert!(config.agent.auto_update_check); - - unsafe { std::env::set_var("ZEPH_AUTO_UPDATE_CHECK", "false") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_AUTO_UPDATE_CHECK") }; - - assert!(!config.agent.auto_update_check); -} - -#[test] -#[serial] -fn env_override_auto_update_check_true() { - clear_env(); - let mut config = Config::default(); - config.agent.auto_update_check = false; - - unsafe { std::env::set_var("ZEPH_AUTO_UPDATE_CHECK", "true") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_AUTO_UPDATE_CHECK") }; - - assert!(config.agent.auto_update_check); -} - -#[test] -#[serial] -fn env_override_auto_update_check_invalid_ignored() { - clear_env(); - let mut config = Config::default(); - assert!(config.agent.auto_update_check); - - unsafe { std::env::set_var("ZEPH_AUTO_UPDATE_CHECK", "not-a-bool") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_AUTO_UPDATE_CHECK") }; - - assert!(config.agent.auto_update_check); -} - -#[test] -fn vector_backend_sqlite_roundtrip() { - let toml_str = r#" -sqlite_path = "zeph.db" -history_limit = 100 -vector_backend = "sqlite" -"#; - let memory: MemoryConfig = toml::from_str(toml_str).unwrap(); - assert_eq!(memory.vector_backend, VectorBackend::Sqlite); - - let serialized = toml::to_string(&memory).unwrap(); - let reparsed: MemoryConfig = toml::from_str(&serialized).unwrap(); - assert_eq!(reparsed.vector_backend, VectorBackend::Sqlite); -} - -#[test] -fn redact_credentials_false_parse() { - let toml_str = r#" -sqlite_path = "zeph.db" -history_limit = 100 -redact_credentials = false -"#; - let memory: MemoryConfig = toml::from_str(toml_str).unwrap(); - assert!(!memory.redact_credentials); -} - -#[test] -fn token_safety_margin_custom_parse() { - let toml_str = r#" -sqlite_path = "zeph.db" -history_limit = 100 -token_safety_margin = 1.15 -"#; - let memory: MemoryConfig = toml::from_str(toml_str).unwrap(); - assert!( - (memory.token_safety_margin - 1.15).abs() < 1e-5, - "token_safety_margin must parse to 1.15, got {}", - memory.token_safety_margin - ); -} - -#[test] -fn tool_call_cutoff_zero_rejected_by_validate() { - let mut config = Config::default(); - config.memory.tool_call_cutoff = 0; - let result = config.validate(); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("tool_call_cutoff must be >= 1"), - "unexpected error: {err}" - ); -} - -#[test] -fn tool_call_cutoff_one_accepted_by_validate() { - let mut config = Config::default(); - config.memory.tool_call_cutoff = 1; - assert!(config.validate().is_ok()); -} - -#[test] -fn gateway_max_body_size_over_limit_rejected() { - let mut config = Config::default(); - config.gateway.max_body_size = 20_000_000; - let result = config.validate(); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("gateway.max_body_size must be <= 10485760"), - "unexpected error: {err}" - ); -} - -#[test] -fn gateway_max_body_size_at_limit_accepted() { - let mut config = Config::default(); - config.gateway.max_body_size = 10_485_760; - assert!(config.validate().is_ok()); -} - -// --- SEC-01: CompressionConfig validation tests --- - -#[test] -fn compression_threshold_zero_rejected_by_validate() { - let mut config = Config::default(); - config.memory.compression.strategy = CompressionStrategy::Proactive { - threshold_tokens: 0, - max_summary_tokens: 4_000, - }; - let result = config.validate(); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("compression.threshold_tokens must be >= 1000"), - "unexpected error: {err}" - ); -} - -#[test] -fn compression_threshold_below_minimum_rejected_by_validate() { - let mut config = Config::default(); - config.memory.compression.strategy = CompressionStrategy::Proactive { - threshold_tokens: 999, - max_summary_tokens: 4_000, - }; - let result = config.validate(); - assert!(result.is_err()); -} - -#[test] -fn compression_threshold_at_minimum_accepted_by_validate() { - let mut config = Config::default(); - config.memory.compression.strategy = CompressionStrategy::Proactive { - threshold_tokens: 1_000, - max_summary_tokens: 128, - }; - assert!(config.validate().is_ok()); -} - -#[test] -fn compression_max_summary_tokens_zero_rejected_by_validate() { - let mut config = Config::default(); - config.memory.compression.strategy = CompressionStrategy::Proactive { - threshold_tokens: 80_000, - max_summary_tokens: 0, - }; - let result = config.validate(); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("compression.max_summary_tokens must be >= 128"), - "unexpected error: {err}" - ); -} - -#[test] -fn compression_reactive_strategy_always_passes_validate() { - // Reactive strategy has no numeric fields, so validate should always pass. - let config = Config::default(); // Reactive by default - assert!( - matches!( - config.memory.compression.strategy, - CompressionStrategy::Reactive - ), - "default strategy should be Reactive" - ); - assert!(config.validate().is_ok()); -} - -#[test] -fn logging_config_defaults() { - let config = Config::default(); - assert_eq!(config.logging.file, default_log_file_path()); - assert_eq!(config.logging.level, "info"); - assert_eq!(config.logging.rotation, LogRotation::Daily); - assert_eq!(config.logging.max_files, 7); -} - -#[test] -#[serial] -fn logging_config_toml_deserialization() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("test.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - std::io::Write::write_all( - &mut f, - br#" -[agent] -name = "Test" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/test.db" -history_limit = 50 - -[logging] -file = "/tmp/zeph-test.log" -level = "debug" -rotation = "never" -max_files = 3 -"#, - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert_eq!(config.logging.file, "/tmp/zeph-test.log"); - assert_eq!(config.logging.level, "debug"); - assert_eq!(config.logging.rotation, LogRotation::Never); - assert_eq!(config.logging.max_files, 3); -} - -#[test] -#[serial] -fn logging_config_empty_file_disables_file_logging() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("test.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - std::io::Write::write_all( - &mut f, - br#" -[agent] -name = "Test" - -[llm] -provider = "ollama" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/test.db" -history_limit = 50 - -[logging] -file = "" -"#, - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - assert!(config.logging.file.is_empty()); -} - -#[test] -#[serial] -fn env_override_zeph_log_file() { - clear_env(); - let mut config = Config::default(); - unsafe { std::env::set_var("ZEPH_LOG_FILE", "/var/log/zeph.log") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_LOG_FILE") }; - assert_eq!(config.logging.file, "/var/log/zeph.log"); -} - -#[test] -#[serial] -fn env_override_zeph_log_level() { - clear_env(); - let mut config = Config::default(); - unsafe { std::env::set_var("ZEPH_LOG_LEVEL", "warn") }; - config.apply_env_overrides(); - unsafe { std::env::remove_var("ZEPH_LOG_LEVEL") }; - assert_eq!(config.logging.level, "warn"); -} - -#[test] -fn logging_rotation_serde_roundtrip() { - let daily: LogRotation = toml::from_str("rotation = \"daily\"") - .map(|t: toml::Table| { - t["rotation"] - .clone() - .try_into::() - .expect("deserialize daily") - }) - .expect("parse toml"); - assert_eq!(daily, LogRotation::Daily); - - let hourly: LogRotation = toml::from_str("rotation = \"hourly\"") - .map(|t: toml::Table| { - t["rotation"] - .clone() - .try_into::() - .expect("deserialize hourly") - }) - .expect("parse toml"); - assert_eq!(hourly, LogRotation::Hourly); - - let never: LogRotation = toml::from_str("rotation = \"never\"") - .map(|t: toml::Table| { - t["rotation"] - .clone() - .try_into::() - .expect("deserialize never") - }) - .expect("parse toml"); - assert_eq!(never, LogRotation::Never); -} - -// --- Threshold ordering validation tests --- - -#[test] -fn soft_above_hard_rejected_by_validate() { - let mut config = Config::default(); - config.memory.soft_compaction_threshold = 0.95; - config.memory.hard_compaction_threshold = 0.90; - let err = config.validate().unwrap_err().to_string(); - assert!( - err.contains("soft_compaction_threshold") && err.contains("hard_compaction_threshold"), - "unexpected error: {err}" - ); -} - -#[test] -fn soft_equal_hard_rejected_by_validate() { - let mut config = Config::default(); - config.memory.soft_compaction_threshold = 0.90; - config.memory.hard_compaction_threshold = 0.90; - let err = config.validate().unwrap_err().to_string(); - assert!( - err.contains("soft_compaction_threshold") && err.contains("hard_compaction_threshold"), - "unexpected error: {err}" - ); -} - -#[test] -fn soft_below_hard_accepted_by_validate() { - let mut config = Config::default(); - config.memory.soft_compaction_threshold = 0.70; - config.memory.hard_compaction_threshold = 0.90; - assert!(config.validate().is_ok()); -} - -#[test] -fn soft_compaction_threshold_zero_rejected_by_validate() { - let mut config = Config::default(); - config.memory.soft_compaction_threshold = 0.0; - let err = config.validate().unwrap_err().to_string(); - assert!( - err.contains("soft_compaction_threshold"), - "unexpected error: {err}" - ); -} - -#[test] -fn soft_compaction_threshold_one_rejected_by_validate() { - let mut config = Config::default(); - config.memory.soft_compaction_threshold = 1.0; - let err = config.validate().unwrap_err().to_string(); - assert!( - err.contains("soft_compaction_threshold"), - "unexpected error: {err}" - ); -} - -#[test] -fn soft_compaction_threshold_negative_rejected_by_validate() { - let mut config = Config::default(); - config.memory.soft_compaction_threshold = -0.1; - let err = config.validate().unwrap_err().to_string(); - assert!( - err.contains("soft_compaction_threshold"), - "unexpected error: {err}" - ); -} - -#[test] -fn hard_compaction_threshold_zero_rejected_by_validate() { - let mut config = Config::default(); - config.memory.hard_compaction_threshold = 0.0; - let err = config.validate().unwrap_err().to_string(); - assert!( - err.contains("hard_compaction_threshold"), - "unexpected error: {err}" - ); -} - -#[test] -fn hard_compaction_threshold_one_rejected_by_validate() { - let mut config = Config::default(); - config.memory.hard_compaction_threshold = 1.0; - let err = config.validate().unwrap_err().to_string(); - assert!( - err.contains("hard_compaction_threshold"), - "unexpected error: {err}" - ); -} - -#[test] -fn hard_compaction_threshold_infinity_rejected_by_validate() { - let mut config = Config::default(); - config.memory.hard_compaction_threshold = f32::INFINITY; - let err = config.validate().unwrap_err().to_string(); - assert!( - err.contains("hard_compaction_threshold"), - "unexpected error: {err}" - ); -} - -#[test] -fn soft_compaction_threshold_nan_rejected_by_validate() { - let mut config = Config::default(); - config.memory.soft_compaction_threshold = f32::NAN; - let err = config.validate().unwrap_err().to_string(); - assert!( - err.contains("soft_compaction_threshold"), - "unexpected error: {err}" - ); -} - -fn minimal_cloud_toml(extra_cloud: &str) -> String { - format!( - r#" -[agent] -name = "Zeph" - -[llm] -provider = "claude" -base_url = "http://localhost:11434" -model = "qwen3:8b" - -[llm.cloud] -model = "claude-sonnet-4-6" -max_tokens = 8192 -{extra_cloud} -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) -} - -#[test] -#[serial] -fn server_compaction_defaults_to_false() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("no_sc.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!(f, "{}", minimal_cloud_toml("")).unwrap(); - clear_env(); - let config = Config::load(&path).unwrap(); - let cloud = config.llm.cloud.unwrap(); - assert!( - !cloud.server_compaction, - "server_compaction must default to false" - ); -} - -#[test] -#[serial] -fn server_compaction_parsed_from_toml() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("sc.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!(f, "{}", minimal_cloud_toml("server_compaction = true\n")).unwrap(); - clear_env(); - let config = Config::load(&path).unwrap(); - let cloud = config.llm.cloud.unwrap(); - assert!( - cloud.server_compaction, - "server_compaction must be true when set in TOML" - ); -} - -#[test] -#[serial] -fn parse_toml_gemini_with_thinking_level() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("gemini_thinking.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "gemini" -base_url = "http://localhost:11434" -model = "gemini-3.0-flash" - -[llm.gemini] -model = "gemini-3.0-flash" -thinking_level = "medium" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let gemini = config.llm.gemini.unwrap(); - assert_eq!( - gemini.thinking_level, - Some(zeph_llm::GeminiThinkingLevel::Medium), - "thinking_level must parse from TOML lowercase value" - ); - assert!(gemini.thinking_budget.is_none()); - assert!(gemini.include_thoughts.is_none()); -} - -#[test] -#[serial] -fn parse_toml_gemini_with_thinking_budget() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("gemini_budget.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "gemini" -base_url = "http://localhost:11434" -model = "gemini-2.5-flash" - -[llm.gemini] -model = "gemini-2.5-flash" -thinking_budget = 2048 -include_thoughts = true - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let gemini = config.llm.gemini.unwrap(); - assert_eq!(gemini.thinking_budget, Some(2048)); - assert_eq!(gemini.include_thoughts, Some(true)); - assert!(gemini.thinking_level.is_none()); -} - -#[test] -#[serial] -fn parse_toml_gemini_without_thinking_fields() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("gemini_no_thinking.toml"); - let mut f = std::fs::File::create(&path).unwrap(); - write!( - f, - r#" -[agent] -name = "Zeph" - -[llm] -provider = "gemini" -base_url = "http://localhost:11434" -model = "gemini-2.0-flash" - -[llm.gemini] -model = "gemini-2.0-flash" - -[skills] -paths = [".zeph/skills"] - -[memory] -sqlite_path = ".zeph/data/zeph.db" -history_limit = 50 -"# - ) - .unwrap(); - - clear_env(); - - let config = Config::load(&path).unwrap(); - let gemini = config.llm.gemini.unwrap(); - assert!(gemini.thinking_level.is_none()); - assert!(gemini.thinking_budget.is_none()); - assert!(gemini.include_thoughts.is_none()); -} - -// TC-01: McpOAuthConfig TOML deserialization with defaults. -#[test] -fn mcp_oauth_config_defaults() { - let toml_str = r#"enabled = true"#; - let cfg: crate::config::McpOAuthConfig = toml::from_str(toml_str).unwrap(); - assert!(cfg.enabled); - assert_eq!(cfg.callback_port, 18766); - assert_eq!(cfg.client_name, "Zeph"); - assert!(cfg.scopes.is_empty()); - assert!(matches!( - cfg.token_storage, - crate::config::OAuthTokenStorage::Vault - )); -} - -// TC-02: OAuthTokenStorage serde variants round-trip via JSON. -#[test] -fn oauth_token_storage_serde_vault() { - let s: crate::config::OAuthTokenStorage = serde_json::from_str(r#""vault""#).unwrap(); - assert!(matches!(s, crate::config::OAuthTokenStorage::Vault)); - let back = serde_json::to_string(&s).unwrap(); - assert_eq!(back, r#""vault""#); -} - -#[test] -fn oauth_token_storage_serde_memory() { - let s: crate::config::OAuthTokenStorage = serde_json::from_str(r#""memory""#).unwrap(); - assert!(matches!(s, crate::config::OAuthTokenStorage::Memory)); - let back = serde_json::to_string(&s).unwrap(); - assert_eq!(back, r#""memory""#); -} - -// TC-03: Config::validate() rejects headers + oauth simultaneously. -#[test] -fn validate_rejects_headers_and_oauth_together() { - use std::collections::HashMap; - let mut config = Config::default(); - let mut headers = HashMap::new(); - headers.insert("Authorization".to_owned(), "Bearer tok".to_owned()); - config.mcp.servers.push(crate::config::McpServerConfig { - id: "srv".into(), - command: None, - args: vec![], - env: HashMap::new(), - url: Some("https://mcp.example.com".into()), - timeout: 30, - policy: zeph_mcp::McpPolicy::default(), - headers, - oauth: Some(crate::config::McpOAuthConfig { - enabled: true, - ..Default::default() - }), - }); - let err = config.validate().unwrap_err(); - assert!(err.to_string().contains("cannot use both")); -} - -// TC-04: Config::validate() detects vault key collision. -#[test] -fn validate_detects_vault_key_collision() { - use std::collections::HashMap; - let mut config = Config::default(); - // Two servers with IDs that normalize to the same vault key. - for id in &["my-server", "my_server"] { - config.mcp.servers.push(crate::config::McpServerConfig { - id: (*id).to_owned(), - command: None, - args: vec![], - env: HashMap::new(), - url: Some("https://mcp.example.com".into()), - timeout: 30, - policy: zeph_mcp::McpPolicy::default(), - headers: HashMap::new(), - oauth: Some(crate::config::McpOAuthConfig { - enabled: true, - ..Default::default() - }), - }); - } - let err = config.validate().unwrap_err(); - assert!(err.to_string().contains("vault key collision")); -} diff --git a/crates/zeph-core/src/lib.rs b/crates/zeph-core/src/lib.rs index 41889633..fbbf11f7 100644 --- a/crates/zeph-core/src/lib.rs +++ b/crates/zeph-core/src/lib.rs @@ -18,7 +18,6 @@ pub mod metrics; pub mod pipeline; pub mod project; pub mod redact; -pub mod vault; #[cfg(feature = "experiments")] pub mod experiments; @@ -59,3 +58,14 @@ pub use sanitizer::{ }; pub use skill_loader::SkillLoaderExecutor; pub use zeph_tools::executor::DiffData; + +// Re-export vault module to preserve internal import paths (e.g., `crate::vault::VaultProvider`). +pub mod vault { + pub use zeph_vault::{ + AgeVaultError, AgeVaultProvider, ArcAgeVaultProvider, EnvVaultProvider, Secret, VaultError, + VaultProvider, default_vault_dir, + }; + + #[cfg(any(test, feature = "mock"))] + pub use zeph_vault::MockVaultProvider; +} diff --git a/crates/zeph-core/tests/vault_integration.rs b/crates/zeph-core/tests/vault_integration.rs new file mode 100644 index 00000000..b66bfa2c --- /dev/null +++ b/crates/zeph-core/tests/vault_integration.rs @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2026 Andrei G +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Integration tests for vault + config resolution. + +use std::io::Write as _; +use std::path::Path; + +use age::secrecy::ExposeSecret; + +use zeph_core::config::SecretResolver; +use zeph_vault::{AgeVaultError, AgeVaultProvider}; + +fn encrypt_json(identity: &age::x25519::Identity, json: &serde_json::Value) -> Vec { + let recipient = identity.to_public(); + let encryptor = + age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient)) + .expect("encryptor creation"); + let mut encrypted = vec![]; + let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap_output"); + writer + .write_all(json.to_string().as_bytes()) + .expect("write plaintext"); + writer.finish().expect("finish encryption"); + encrypted +} + +fn write_temp_files( + identity: &age::x25519::Identity, + ciphertext: &[u8], +) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { + let dir = tempfile::tempdir().expect("tempdir"); + let key_path = dir.path().join("key.txt"); + let vault_path = dir.path().join("secrets.age"); + std::fs::write(&key_path, identity.to_string().expose_secret()).expect("write key"); + std::fs::write(&vault_path, ciphertext).expect("write vault"); + (dir, key_path, vault_path) +} + +#[tokio::test] +async fn age_encrypt_decrypt_resolve_secrets_roundtrip() { + let identity = age::x25519::Identity::generate(); + let json = serde_json::json!({ + "ZEPH_CLAUDE_API_KEY": "sk-ant-test-123", + "ZEPH_TELEGRAM_TOKEN": "tg-token-456" + }); + let encrypted = encrypt_json(&identity, &json); + let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); + + let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap(); + let mut config = + zeph_core::config::Config::load(Path::new("/nonexistent/config.toml")).unwrap(); + config.resolve_secrets(&vault).await.unwrap(); + + assert_eq!( + config.secrets.claude_api_key.as_ref().unwrap().expose(), + "sk-ant-test-123" + ); + let tg = config.telegram.unwrap(); + assert_eq!(tg.token.as_deref(), Some("tg-token-456")); +} + +// Suppress unused import warning when age is not in scope (satisfies clippy) +#[allow(dead_code)] +fn _use_age_vault_error(_: AgeVaultError) {} diff --git a/crates/zeph-vault/Cargo.toml b/crates/zeph-vault/Cargo.toml new file mode 100644 index 00000000..dbaa24ec --- /dev/null +++ b/crates/zeph-vault/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "zeph-vault" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/zeph-vault" +keywords.workspace = true +categories.workspace = true +description = "VaultProvider trait and backends (env, age) for Zeph secret management" +readme = "README.md" + +[features] +default = [] +mock = [] + +[dependencies] +age.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] } +zeph-common.workspace = true +zeroize = { workspace = true, features = ["derive", "serde"] } + +[dev-dependencies] +proptest.workspace = true +serial_test.workspace = true +tempfile.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/crates/zeph-vault/README.md b/crates/zeph-vault/README.md new file mode 100644 index 00000000..c5976635 --- /dev/null +++ b/crates/zeph-vault/README.md @@ -0,0 +1,7 @@ +# zeph-vault + +VaultProvider trait and backends (env, age) for Zeph secret management. + +## Features + +- `mock` — enables `MockVaultProvider` for use in downstream test code diff --git a/crates/zeph-core/src/vault.rs b/crates/zeph-vault/src/lib.rs similarity index 96% rename from crates/zeph-core/src/vault.rs rename to crates/zeph-vault/src/lib.rs index ae3004cb..03d391cf 100644 --- a/crates/zeph-core/src/vault.rs +++ b/crates/zeph-vault/src/lib.rs @@ -1,16 +1,13 @@ // SPDX-FileCopyrightText: 2026 Andrei G // SPDX-License-Identifier: MIT OR Apache-2.0 +use std::collections::BTreeMap; use std::fmt; use std::future::Future; -use std::io::Write as _; -use std::pin::Pin; - -use std::collections::BTreeMap; - -use std::io::Read as _; - +use std::io::{Read as _, Write as _}; use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::Arc; use zeroize::Zeroizing; @@ -323,8 +320,6 @@ impl VaultProvider for EnvVaultProvider { /// persistence via `VaultCredentialStore`. pub struct ArcAgeVaultProvider(pub Arc>); -use std::sync::Arc; - impl VaultProvider for ArcAgeVaultProvider { fn get_secret( &self, @@ -350,7 +345,7 @@ impl VaultProvider for ArcAgeVaultProvider { } /// Test helper with BTreeMap-based secret storage. -#[cfg(test)] +#[cfg(any(test, feature = "mock"))] #[derive(Default)] pub struct MockVaultProvider { secrets: std::collections::BTreeMap, @@ -359,7 +354,7 @@ pub struct MockVaultProvider { listed_only: Vec, } -#[cfg(test)] +#[cfg(any(test, feature = "mock"))] impl MockVaultProvider { #[must_use] pub fn new() -> Self { @@ -380,7 +375,7 @@ impl MockVaultProvider { } } -#[cfg(test)] +#[cfg(any(test, feature = "mock"))] impl VaultProvider for MockVaultProvider { fn get_secret( &self, @@ -517,7 +512,6 @@ mod tests { mod age_tests { use std::io::Write as _; - use crate::config::SecretResolver; use age::secrecy::ExposeSecret; use super::*; @@ -640,29 +634,6 @@ mod age_tests { assert!(matches!(err, AgeVaultError::Json(_))); } - #[tokio::test] - async fn age_encrypt_decrypt_resolve_secrets_roundtrip() { - let identity = age::x25519::Identity::generate(); - let json = serde_json::json!({ - "ZEPH_CLAUDE_API_KEY": "sk-ant-test-123", - "ZEPH_TELEGRAM_TOKEN": "tg-token-456" - }); - let encrypted = encrypt_json(&identity, &json); - let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted); - - let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap(); - let mut config = - crate::config::Config::load(Path::new("/nonexistent/config.toml")).unwrap(); - config.resolve_secrets(&vault).await.unwrap(); - - assert_eq!( - config.secrets.claude_api_key.as_ref().unwrap().expose(), - "sk-ant-test-123" - ); - let tg = config.telegram.unwrap(); - assert_eq!(tg.token.as_deref(), Some("tg-token-456")); - } - #[test] fn age_vault_debug_impl() { let identity = age::x25519::Identity::generate(); diff --git a/tests/integration.rs b/tests/integration.rs index d52d8693..8f88f2da 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -8,13 +8,14 @@ use std::sync::{Arc, Mutex}; use zeph_core::agent::Agent; use zeph_core::channel::{Channel, ChannelError, ChannelMessage}; -use zeph_core::config::{AutonomyLevel, Config, ProviderKind, SecurityConfig, TimeoutConfig}; +use zeph_core::config::{Config, ProviderKind, SecurityConfig, TimeoutConfig}; use zeph_llm::any::AnyProvider; use zeph_llm::mock::MockProvider; use zeph_memory::semantic::SemanticMemory; use zeph_memory::sqlite::SqliteStore; use zeph_skills::loader::load_skill; use zeph_skills::registry::SkillRegistry; +use zeph_tools::AutonomyLevel; use zeph_tools::executor::{ToolError, ToolExecutor, ToolOutput}; // -- Provider helpers --