zeph-core 0.16.0

Core agent loop, configuration, context builder, metrics, and vault for Zeph
Documentation
// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
// SPDX-License-Identifier: MIT OR Apache-2.0

//! 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.

// 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,
    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")]
pub use zeph_config::{DiagnosticSeverity, DiagnosticsConfig, HoverConfig, LspConfig};

pub use zeph_config::{
    ContentIsolationConfig, CustomPiiPattern, ExfiltrationGuardConfig, MemoryWriteValidationConfig,
    PiiFilterConfig, QuarantineConfig,
};

#[cfg(feature = "guardrail")]
pub use zeph_config::{GuardrailAction, GuardrailConfig, GuardrailFailStrategy};

pub use zeph_config::A2aServerConfig;

pub use zeph_config::{
    DEFAULT_DEBUG_DIR, DEFAULT_LOG_FILE, DEFAULT_SKILLS_DIR, DEFAULT_SQLITE_PATH,
    default_debug_dir, default_log_file_path, default_skills_dir, default_sqlite_path,
    is_legacy_default_debug_dir, is_legacy_default_log_file, is_legacy_default_skills_path,
    is_legacy_default_sqlite_path,
};

pub use zeph_config::providers::{default_stt_language, default_stt_model, default_stt_provider};

pub mod migrate {
    pub use zeph_config::migrate::*;
}

use crate::vault::{Secret, VaultProvider};

/// Extension trait for resolving vault secrets into a [`Config`].
///
/// Implemented for [`Config`] in `zeph-core` because `VaultProvider` lives here.
/// Call with `use zeph_core::config::SecretResolver` in scope.
pub trait SecretResolver {
    /// Populate `secrets` fields from the vault.
    ///
    /// # Errors
    ///
    /// Returns an error if the vault backend fails.
    fn resolve_secrets(
        &mut self,
        vault: &dyn VaultProvider,
    ) -> impl std::future::Future<Output = Result<(), ConfigError>> + Send;
}

impl SecretResolver for Config {
    async fn resolve_secrets(&mut self, vault: &dyn VaultProvider) -> Result<(), ConfigError> {
        if let Some(val) = vault.get_secret("ZEPH_CLAUDE_API_KEY").await? {
            self.secrets.claude_api_key = Some(Secret::new(val));
        }
        if let Some(val) = vault.get_secret("ZEPH_OPENAI_API_KEY").await? {
            self.secrets.openai_api_key = Some(Secret::new(val));
        }
        if let Some(val) = vault.get_secret("ZEPH_GEMINI_API_KEY").await? {
            self.secrets.gemini_api_key = Some(Secret::new(val));
        }
        if let Some(val) = vault.get_secret("ZEPH_TELEGRAM_TOKEN").await? {
            let tg = self.telegram.get_or_insert(TelegramConfig {
                token: None,
                allowed_users: Vec::new(),
            });
            tg.token = Some(val);
        }
        if let Some(val) = vault.get_secret("ZEPH_A2A_AUTH_TOKEN").await? {
            self.a2a.auth_token = Some(val);
        }
        if let Some(ref entries) = self.llm.compatible {
            for entry in entries {
                let env_key = format!("ZEPH_COMPATIBLE_{}_API_KEY", entry.name.to_uppercase());
                if let Some(val) = vault.get_secret(&env_key).await? {
                    self.secrets
                        .compatible_api_keys
                        .insert(entry.name.clone(), Secret::new(val));
                }
            }
        }
        if let Some(val) = vault.get_secret("ZEPH_GATEWAY_TOKEN").await? {
            self.gateway.auth_token = Some(val);
        }
        if let Some(val) = vault.get_secret("ZEPH_DISCORD_TOKEN").await? {
            let dc = self.discord.get_or_insert(DiscordConfig {
                token: None,
                application_id: None,
                allowed_user_ids: Vec::new(),
                allowed_role_ids: Vec::new(),
                allowed_channel_ids: Vec::new(),
            });
            dc.token = Some(val);
        }
        if let Some(val) = vault.get_secret("ZEPH_DISCORD_APP_ID").await?
            && let Some(dc) = self.discord.as_mut()
        {
            dc.application_id = Some(val);
        }
        if let Some(val) = vault.get_secret("ZEPH_SLACK_BOT_TOKEN").await? {
            let sl = self.slack.get_or_insert(SlackConfig {
                bot_token: None,
                signing_secret: None,
                webhook_host: "127.0.0.1".into(),
                port: 3000,
                allowed_user_ids: Vec::new(),
                allowed_channel_ids: Vec::new(),
            });
            sl.bot_token = Some(val);
        }
        if let Some(val) = vault.get_secret("ZEPH_SLACK_SIGNING_SECRET").await?
            && let Some(sl) = self.slack.as_mut()
        {
            sl.signing_secret = Some(val);
        }
        for key in vault.list_keys() {
            if let Some(custom_name) = key.strip_prefix("ZEPH_SECRET_")
                && !custom_name.is_empty()
                && let Some(val) = vault.get_secret(&key).await?
            {
                // Canonical form uses underscores. Both `_` and `-` in vault key names
                // are normalized to `_` so that ZEPH_SECRET_MY-KEY and ZEPH_SECRET_MY_KEY
                // both map to "my_key", matching SKILL.md requires-secrets parsing.
                let normalized = custom_name.to_lowercase().replace('-', "_");
                self.secrets.custom.insert(normalized, Secret::new(val));
            }
        }
        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"));
        }
    }
}