Skip to main content

zeph_core/
config.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Extension trait for resolving vault secrets into a Config.
5//!
6//! This trait is defined in zeph-core (not in zeph-config) due to Rust's orphan rule:
7//! implementing a foreign trait on a foreign type requires the trait to be defined locally.
8
9// Re-export Config types from zeph-config for internal use.
10pub use zeph_config::{
11    AcpAuthMethod, AcpConfig, AcpLspConfig, AcpTransport, AdditionalDir, AdditionalDirError,
12    AgentConfig, CandleConfig, CandleInlineConfig, CascadeClassifierMode, CascadeConfig,
13    ClassifiersConfig, CompressionConfig, CompressionStrategy, Config, ConfigError, ContextFormat,
14    CostConfig, DaemonConfig, DebugConfig, DetectorMode, DiscordConfig, DocumentConfig, DumpFormat,
15    ExperimentConfig, ExperimentSchedule, FocusConfig, GatewayConfig, GenerationParams, GonkaNode,
16    GraphConfig, HookAction, HookDef, HookMatcher, IndexConfig, LearningConfig, LlmConfig,
17    LlmRoutingStrategy, LogRotation, LoggingConfig, MAX_TOKENS_CAP, McpConfig, McpOAuthConfig,
18    McpServerConfig, McpTrustLevel, MemoryConfig, MemoryScope, NoteLinkingConfig,
19    OAuthTokenStorage, OrchestrationConfig, PermissionMode, ProviderEntry, ProviderKind,
20    ProviderName, PruningStrategy, RateLimitConfig, ResolvedSecrets, RetrievalConfig, RouterConfig,
21    RouterStrategyConfig, ScheduledTaskConfig, ScheduledTaskKind, SchedulerConfig,
22    SchedulerDaemonConfig, SecurityConfig, SemanticConfig, SessionsConfig, SidequestConfig,
23    SkillFilter, SkillPromptMode, SkillsConfig, SlackConfig, StoreRoutingConfig,
24    StoreRoutingStrategy, SttConfig, SubAgentConfig, SubAgentLifecycleHooks, SubagentHooks,
25    TaskSupervisorConfig, TelegramConfig, TimeoutConfig, ToolDiscoveryConfig,
26    ToolDiscoveryStrategyConfig, ToolFilterConfig, ToolPolicy, TraceConfig, TrustConfig, TuiConfig,
27    VaultConfig, VectorBackend,
28};
29
30pub use zeph_config::{
31    AutoDreamConfig, CategoryConfig, ContextStrategy, DigestConfig, MagicDocsConfig,
32    MicrocompactConfig, PersonaConfig, TrajectoryConfig, TreeConfig,
33};
34pub use zeph_config::{DiagnosticSeverity, DiagnosticsConfig, HoverConfig, LspConfig};
35pub use zeph_config::{QualityConfig, TriggerPolicy};
36pub use zeph_config::{TelemetryBackend, TelemetryConfig};
37
38pub use zeph_config::{
39    ContentIsolationConfig, CustomPiiPattern, ExfiltrationGuardConfig, MemoryWriteValidationConfig,
40    PiiFilterConfig, QuarantineConfig,
41};
42pub use zeph_config::{GuardrailAction, GuardrailConfig, GuardrailFailStrategy};
43
44pub use zeph_config::A2aServerConfig;
45pub use zeph_config::ChannelSkillsConfig;
46pub use zeph_config::{FileChangedConfig, HooksConfig};
47
48pub use zeph_config::{
49    DEFAULT_DEBUG_DIR, DEFAULT_LOG_FILE, DEFAULT_SKILLS_DIR, DEFAULT_SQLITE_PATH,
50    default_debug_dir, default_log_file_path, default_skills_dir, default_sqlite_path,
51    is_legacy_default_debug_dir, is_legacy_default_log_file, is_legacy_default_skills_path,
52    is_legacy_default_sqlite_path,
53};
54
55pub use zeph_config::providers::{default_stt_language, default_stt_provider, validate_pool};
56
57pub mod migrate {
58    pub use zeph_config::migrate::*;
59}
60
61use crate::vault::{Secret, VaultProvider};
62
63/// Extension trait for resolving vault secrets into a [`Config`].
64///
65/// Implemented for [`Config`] in `zeph-core` because `VaultProvider` lives here.
66/// Call with `use zeph_core::config::SecretResolver` in scope.
67pub trait SecretResolver {
68    /// Populate `secrets` fields from the vault.
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if the vault backend fails.
73    fn resolve_secrets(
74        &mut self,
75        vault: &dyn VaultProvider,
76    ) -> impl std::future::Future<Output = Result<(), ConfigError>> + Send;
77}
78
79fn log_gonka_credential_status(has_key: bool, has_address: bool) {
80    match (has_key, has_address) {
81        (true, true) => tracing::info!("gonka wallet credentials resolved from vault"),
82        (true, false) => tracing::warn!(
83            "ZEPH_GONKA_PRIVATE_KEY is set but ZEPH_GONKA_ADDRESS is missing from vault"
84        ),
85        (false, true) => tracing::warn!(
86            "ZEPH_GONKA_ADDRESS is set but ZEPH_GONKA_PRIVATE_KEY is missing from vault"
87        ),
88        (false, false) => {}
89    }
90}
91
92// TODO(critic): vault values are inserted verbatim into typed config fields.
93// Consider centralizing whitespace trimming and empty-string normalization
94// here so every consumer (qdrant client, claude, openai, etc.) sees a clean value.
95impl SecretResolver for Config {
96    async fn resolve_secrets(&mut self, vault: &dyn VaultProvider) -> Result<(), ConfigError> {
97        if let Some(val) = vault.get_secret("ZEPH_CLAUDE_API_KEY").await? {
98            self.secrets.claude_api_key = Some(Secret::new(val));
99        }
100        if let Some(val) = vault.get_secret("ZEPH_OPENAI_API_KEY").await? {
101            self.secrets.openai_api_key = Some(Secret::new(val));
102        }
103        if let Some(val) = vault.get_secret("ZEPH_GEMINI_API_KEY").await? {
104            self.secrets.gemini_api_key = Some(Secret::new(val));
105        }
106        if let Some(val) = vault.get_secret("ZEPH_GONKA_PRIVATE_KEY").await? {
107            self.secrets.gonka_private_key = Some(Secret::new(val));
108        }
109        if let Some(val) = vault.get_secret("ZEPH_GONKA_ADDRESS").await? {
110            self.secrets.gonka_address = Some(Secret::new(val));
111        }
112        if let Some(val) = vault.get_secret("ZEPH_COCOON_ACCESS_HASH").await? {
113            self.secrets.cocoon_access_hash = Some(Secret::new(val));
114        }
115        log_gonka_credential_status(
116            self.secrets.gonka_private_key.is_some(),
117            self.secrets.gonka_address.is_some(),
118        );
119        if let Some(val) = vault.get_secret("ZEPH_TELEGRAM_TOKEN").await?
120            && let Some(tg) = self.telegram.as_mut()
121        {
122            tg.token = Some(val);
123        }
124        if let Some(val) = vault.get_secret("ZEPH_A2A_AUTH_TOKEN").await? {
125            self.a2a.auth_token = Some(val);
126        }
127        for entry in &self.llm.providers {
128            if entry.provider_type == crate::config::ProviderKind::Compatible
129                && let Some(ref name) = entry.name
130            {
131                let env_key = format!("ZEPH_COMPATIBLE_{}_API_KEY", name.to_uppercase());
132                if let Some(val) = vault.get_secret(&env_key).await? {
133                    self.secrets
134                        .compatible_api_keys
135                        .insert(name.clone(), Secret::new(val));
136                }
137            }
138        }
139        if let Some(val) = vault.get_secret("ZEPH_HF_TOKEN").await? {
140            self.classifiers.hf_token = Some(val.clone());
141            if let Some(candle) = self.llm.candle.as_mut() {
142                candle.hf_token = Some(val);
143            }
144        }
145        if let Some(val) = vault.get_secret("ZEPH_GATEWAY_TOKEN").await? {
146            self.gateway.auth_token = Some(val);
147        }
148        if let Some(val) = vault.get_secret("ZEPH_DATABASE_URL").await? {
149            self.memory.database_url = Some(val);
150        }
151        if let Some(val) = vault.get_secret("ZEPH_QDRANT_API_KEY").await? {
152            self.memory.qdrant_api_key = Some(Secret::new(val));
153        }
154        if let Some(val) = vault.get_secret("ZEPH_DISCORD_TOKEN").await?
155            && let Some(dc) = self.discord.as_mut()
156        {
157            dc.token = Some(val);
158        }
159        if let Some(val) = vault.get_secret("ZEPH_DISCORD_APP_ID").await?
160            && let Some(dc) = self.discord.as_mut()
161        {
162            dc.application_id = Some(val);
163        }
164        if let Some(val) = vault.get_secret("ZEPH_SLACK_BOT_TOKEN").await?
165            && let Some(sl) = self.slack.as_mut()
166        {
167            sl.bot_token = Some(val);
168        }
169        if let Some(val) = vault.get_secret("ZEPH_SLACK_SIGNING_SECRET").await?
170            && let Some(sl) = self.slack.as_mut()
171        {
172            sl.signing_secret = Some(val);
173        }
174        for key in vault.list_keys() {
175            if let Some(custom_name) = key.strip_prefix("ZEPH_SECRET_")
176                && !custom_name.is_empty()
177                && let Some(val) = vault.get_secret(&key).await?
178            {
179                // Canonical form uses underscores. Both `_` and `-` in vault key names
180                // are normalized to `_` so that ZEPH_SECRET_MY-KEY and ZEPH_SECRET_MY_KEY
181                // both map to "my_key", matching SKILL.md requires-secrets parsing.
182                let normalized = custom_name.to_lowercase().replace('-', "_");
183                self.secrets.custom.insert(normalized, Secret::new(val));
184            }
185        }
186        Ok(())
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[tokio::test]
195    #[cfg(any(test, feature = "mock"))]
196    async fn resolve_secrets_with_mock_vault() {
197        use crate::vault::MockVaultProvider;
198
199        let vault = MockVaultProvider::new()
200            .with_secret("ZEPH_CLAUDE_API_KEY", "sk-test-123")
201            .with_secret("ZEPH_TELEGRAM_TOKEN", "tg-token-456");
202
203        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
204        config.resolve_secrets(&vault).await.unwrap();
205
206        assert_eq!(
207            config.secrets.claude_api_key.as_ref().unwrap().expose(),
208            "sk-test-123"
209        );
210        if let Some(tg) = config.telegram {
211            assert_eq!(tg.token.as_deref(), Some("tg-token-456"));
212        }
213    }
214
215    #[tokio::test]
216    #[cfg(any(test, feature = "mock"))]
217    async fn resolve_gonka_secrets_both_present() {
218        use crate::vault::MockVaultProvider;
219
220        let vault = MockVaultProvider::new()
221            .with_secret("ZEPH_GONKA_PRIVATE_KEY", "gonka-priv-key-abc")
222            .with_secret("ZEPH_GONKA_ADDRESS", "gonka1xyzaddress");
223
224        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
225        config.resolve_secrets(&vault).await.unwrap();
226
227        assert_eq!(
228            config.secrets.gonka_private_key.as_ref().unwrap().expose(),
229            "gonka-priv-key-abc"
230        );
231        assert_eq!(
232            config.secrets.gonka_address.as_ref().unwrap().expose(),
233            "gonka1xyzaddress"
234        );
235    }
236
237    #[tokio::test]
238    #[cfg(any(test, feature = "mock"))]
239    async fn resolve_gonka_partial_only_private_key() {
240        use crate::vault::MockVaultProvider;
241
242        let vault =
243            MockVaultProvider::new().with_secret("ZEPH_GONKA_PRIVATE_KEY", "gonka-priv-key-only");
244
245        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
246        config.resolve_secrets(&vault).await.unwrap();
247
248        assert!(config.secrets.gonka_private_key.is_some());
249        assert!(config.secrets.gonka_address.is_none());
250    }
251}