diff --git a/src/openhuman/config/schema/mod.rs b/src/openhuman/config/schema/mod.rs index 7722e1f8b..2539d24ba 100644 --- a/src/openhuman/config/schema/mod.rs +++ b/src/openhuman/config/schema/mod.rs @@ -73,12 +73,12 @@ pub use storage_memory::{ }; pub use tools::{ BrowserComputerUseConfig, BrowserConfig, ComposioConfig, ComputerControlConfig, CurlConfig, - GitbooksConfig, HttpRequestConfig, IntegrationToggle, IntegrationsConfig, McpAuthConfig, - McpClientConfig, McpClientIdentityConfig, McpServerConfig, MultimodalConfig, - PolymarketClobCredentials, PolymarketConfig, SearchConfig, SearchEngine, - SearchEngineCredentials, SearxngConfig, SecretsConfig, SeltzConfig, WebSearchConfig, - COMPOSIO_MODE_BACKEND, COMPOSIO_MODE_DIRECT, SEARCH_ENGINE_BRAVE, SEARCH_ENGINE_MANAGED, - SEARCH_ENGINE_PARALLEL, + ExternalCapabilityProviderConfig, ExternalCapabilityProvidersConfig, GitbooksConfig, + HttpRequestConfig, IntegrationToggle, IntegrationsConfig, McpAuthConfig, McpClientConfig, + McpClientIdentityConfig, McpServerConfig, MultimodalConfig, PolymarketClobCredentials, + PolymarketConfig, SearchConfig, SearchEngine, SearchEngineCredentials, SearxngConfig, + SecretsConfig, SeltzConfig, WebSearchConfig, COMPOSIO_MODE_BACKEND, COMPOSIO_MODE_DIRECT, + SEARCH_ENGINE_BRAVE, SEARCH_ENGINE_MANAGED, SEARCH_ENGINE_PARALLEL, }; pub use update::{UpdateConfig, UpdateRestartStrategy}; mod voice_server; diff --git a/src/openhuman/config/schema/tools.rs b/src/openhuman/config/schema/tools.rs index 64e573b7e..a2801ff5a 100644 --- a/src/openhuman/config/schema/tools.rs +++ b/src/openhuman/config/schema/tools.rs @@ -1,6 +1,9 @@ //! Tool-related config: browser, HTTP, web search, composio, secrets, multimodal. use super::defaults; +pub use crate::openhuman::external_capabilities::{ + ExternalCapabilityProviderConfig, ExternalCapabilityProvidersConfig, +}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::HashMap; diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index 81999c11d..83f30febd 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -176,6 +176,9 @@ pub struct Config { #[serde(default)] pub mcp_client: McpClientConfig, + #[serde(default)] + pub external_capability_providers: ExternalCapabilityProvidersConfig, + #[serde(default)] pub multimodal: MultimodalConfig, @@ -613,6 +616,7 @@ impl Default for Config { storage: StorageConfig::default(), composio: ComposioConfig::default(), secrets: SecretsConfig::default(), + external_capability_providers: ExternalCapabilityProvidersConfig::default(), browser: BrowserConfig::default(), http_request: HttpRequestConfig::default(), curl: CurlConfig::default(), diff --git a/src/openhuman/external_capabilities/mod.rs b/src/openhuman/external_capabilities/mod.rs new file mode 100644 index 000000000..b7a99806e --- /dev/null +++ b/src/openhuman/external_capabilities/mod.rs @@ -0,0 +1,14 @@ +//! Registry for external capability providers. +//! +//! This module keeps provider identity and trust metadata generic. It does not +//! know how any provider packages, loads, or executes capabilities; it only +//! normalizes the provider records OpenHuman can use for admission, policy, and +//! diagnostics. + +mod registry; +mod types; + +pub use registry::{normalize_provider_id, ExternalCapabilityProviderRegistry}; +pub use types::{ + ExternalCapabilityProvider, ExternalCapabilityProviderConfig, ExternalCapabilityProvidersConfig, +}; diff --git a/src/openhuman/external_capabilities/registry.rs b/src/openhuman/external_capabilities/registry.rs new file mode 100644 index 000000000..0c8b288a2 --- /dev/null +++ b/src/openhuman/external_capabilities/registry.rs @@ -0,0 +1,258 @@ +use std::collections::BTreeMap; + +use super::types::{ + ExternalCapabilityProvider, ExternalCapabilityProviderConfig, ExternalCapabilityProvidersConfig, +}; + +impl ExternalCapabilityProvider { + pub(crate) fn from_config(config: &ExternalCapabilityProviderConfig) -> Result { + let id = normalize_provider_id(&config.id) + .ok_or_else(|| format!("invalid external capability provider id `{}`", config.id))?; + let name = config.name.trim(); + if name.is_empty() { + return Err(format!( + "external capability provider `{id}` name must be non-empty" + )); + } + + Ok(Self { + id, + name: name.to_string(), + source_uri: trim_optional(&config.source_uri), + source_digest: trim_optional(&config.source_digest), + trusted: config.trusted, + enabled: config.enabled, + }) + } +} + +/// Lookup table for normalized external capability providers. +#[derive(Debug, Clone, Default)] +pub struct ExternalCapabilityProviderRegistry { + providers: BTreeMap, + errors: Vec, +} + +impl ExternalCapabilityProviderRegistry { + /// Build a registry from config, collecting invalid records as errors. + pub fn from_config(config: &ExternalCapabilityProvidersConfig) -> Self { + let total_providers = config.providers.len(); + log::debug!( + "[external_capability][registry] build_start total_providers={}", + total_providers + ); + let mut providers = BTreeMap::new(); + let mut errors = Vec::new(); + let mut accepted_count = 0usize; + let mut rejected_count = 0usize; + let mut duplicate_count = 0usize; + + for provider in &config.providers { + match ExternalCapabilityProvider::from_config(provider) { + Ok(provider) => { + if providers.contains_key(&provider.id) { + duplicate_count += 1; + rejected_count += 1; + log::debug!( + "[external_capability][registry] provider_duplicate provider_id={} total_providers={} accepted_count={} rejected_count={} duplicate_count={}", + provider.id, + total_providers, + accepted_count, + rejected_count, + duplicate_count + ); + errors.push(format!( + "duplicate external capability provider id `{}`", + provider.id + )); + } else { + accepted_count += 1; + log::debug!( + "[external_capability][registry] provider_accepted provider_id={} trusted={} enabled={} total_providers={} accepted_count={} rejected_count={} duplicate_count={}", + provider.id, + provider.trusted, + provider.enabled, + total_providers, + accepted_count, + rejected_count, + duplicate_count + ); + providers.insert(provider.id.clone(), provider); + } + } + Err(err) => { + rejected_count += 1; + log::debug!( + "[external_capability][registry] provider_rejected provider_config_id={} error={} total_providers={} accepted_count={} rejected_count={} duplicate_count={}", + provider.id, + err, + total_providers, + accepted_count, + rejected_count, + duplicate_count + ); + errors.push(err); + } + } + } + + let provider_ids = providers.keys().cloned().collect::>(); + log::debug!( + "[external_capability][registry] build_end total_providers={} accepted_count={} rejected_count={} duplicate_count={} error_count={} provider_ids={:?} errors={:?}", + total_providers, + accepted_count, + rejected_count, + duplicate_count, + errors.len(), + provider_ids, + errors + ); + + Self { providers, errors } + } + + /// Whether the registry has no valid providers. + pub fn is_empty(&self) -> bool { + self.providers.is_empty() + } + + /// List valid providers in normalized id order. + pub fn list(&self) -> Vec<&ExternalCapabilityProvider> { + self.providers.values().collect() + } + + /// Get a provider by raw or normalized id. + pub fn get(&self, provider_id: &str) -> Option<&ExternalCapabilityProvider> { + normalize_provider_id(provider_id).and_then(|id| self.providers.get(&id)) + } + + /// Whether a provider is known, enabled, and trusted. + pub fn can_register_tools(&self, provider_id: &str) -> bool { + self.get(provider_id) + .map(ExternalCapabilityProvider::can_register_tools) + .unwrap_or(false) + } + + /// Config load errors for invalid or duplicate provider records. + pub fn errors(&self) -> &[String] { + &self.errors + } +} + +/// Normalize and validate an external capability provider id. +pub fn normalize_provider_id(value: &str) -> Option { + let normalized = value.trim().to_ascii_lowercase(); + if normalized.is_empty() { + return None; + } + let valid = normalized + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_' | '.')); + if !valid { + return None; + } + let starts_or_ends_with_sep = normalized + .chars() + .next() + .zip(normalized.chars().last()) + .map(|(first, last)| is_separator(first) || is_separator(last)) + .unwrap_or(true); + if starts_or_ends_with_sep { + return None; + } + Some(normalized) +} + +fn is_separator(ch: char) -> bool { + matches!(ch, '-' | '_' | '.') +} + +fn trim_optional(value: &Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(id: &str) -> ExternalCapabilityProviderConfig { + ExternalCapabilityProviderConfig { + id: id.to_string(), + name: "Local Runtime".to_string(), + source_uri: Some(" file:///runtime ".to_string()), + source_digest: Some(" sha256:abc ".to_string()), + trusted: true, + enabled: true, + } + } + + #[test] + fn normalizes_valid_provider_ids() { + assert_eq!( + normalize_provider_id(" Local.Runtime_1 "), + Some("local.runtime_1".to_string()) + ); + assert_eq!( + normalize_provider_id("provider-1"), + Some("provider-1".to_string()) + ); + } + + #[test] + fn rejects_invalid_provider_ids() { + assert_eq!(normalize_provider_id(""), None); + assert_eq!(normalize_provider_id(".provider"), None); + assert_eq!(normalize_provider_id("provider."), None); + assert_eq!(normalize_provider_id("provider id"), None); + assert_eq!(normalize_provider_id("provider/id"), None); + } + + #[test] + fn registry_loads_trusted_enabled_provider() { + let registry = + ExternalCapabilityProviderRegistry::from_config(&ExternalCapabilityProvidersConfig { + providers: vec![config("runtime.local")], + }); + + assert!(registry.errors().is_empty()); + assert_eq!(registry.list().len(), 1); + assert!(registry.can_register_tools("RUNTIME.LOCAL")); + let provider = registry.get("runtime.local").unwrap(); + assert_eq!(provider.source_uri.as_deref(), Some("file:///runtime")); + assert_eq!(provider.source_digest.as_deref(), Some("sha256:abc")); + } + + #[test] + fn disabled_or_untrusted_provider_cannot_register_tools() { + let mut disabled = config("disabled.runtime"); + disabled.enabled = false; + let mut untrusted = config("untrusted.runtime"); + untrusted.trusted = false; + let registry = + ExternalCapabilityProviderRegistry::from_config(&ExternalCapabilityProvidersConfig { + providers: vec![disabled, untrusted], + }); + + assert!(!registry.can_register_tools("disabled.runtime")); + assert!(!registry.can_register_tools("untrusted.runtime")); + } + + #[test] + fn registry_reports_duplicates_and_invalid_records() { + let mut unnamed = config("unnamed.runtime"); + unnamed.name = " ".to_string(); + let registry = + ExternalCapabilityProviderRegistry::from_config(&ExternalCapabilityProvidersConfig { + providers: vec![config("runtime.local"), config("RUNTIME.LOCAL"), unnamed], + }); + + assert_eq!(registry.list().len(), 1); + assert_eq!(registry.errors().len(), 2); + assert!(registry.errors()[0].contains("duplicate")); + assert!(registry.errors()[1].contains("name must be non-empty")); + } +} diff --git a/src/openhuman/external_capabilities/types.rs b/src/openhuman/external_capabilities/types.rs new file mode 100644 index 000000000..fce515450 --- /dev/null +++ b/src/openhuman/external_capabilities/types.rs @@ -0,0 +1,65 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Config entry for one external capability provider. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct ExternalCapabilityProviderConfig { + /// Stable provider id used in generated tool provenance. + pub id: String, + /// Human-readable display name for diagnostics. + pub name: String, + /// Optional source URI for trust/debugging. + pub source_uri: Option, + /// Optional source digest, e.g. `sha256:`. + pub source_digest: Option, + /// Whether this provider is trusted to register generated tools. + pub trusted: bool, + /// Whether this provider is currently enabled. + pub enabled: bool, +} + +impl Default for ExternalCapabilityProviderConfig { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + source_uri: None, + source_digest: None, + trusted: false, + enabled: true, + } + } +} + +/// Top-level config section for external capability providers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)] +#[serde(default)] +pub struct ExternalCapabilityProvidersConfig { + /// Known external capability providers. + pub providers: Vec, +} + +/// Normalized runtime provider record used by registries and diagnostics. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ExternalCapabilityProvider { + /// Normalized provider id. + pub id: String, + /// Human-readable display name. + pub name: String, + /// Optional source URI for trust/debugging. + pub source_uri: Option, + /// Optional source digest, e.g. `sha256:`. + pub source_digest: Option, + /// Whether this provider is trusted to register generated tools. + pub trusted: bool, + /// Whether this provider is currently enabled. + pub enabled: bool, +} + +impl ExternalCapabilityProvider { + /// Whether this provider can currently register generated tools. + pub fn can_register_tools(&self) -> bool { + self.enabled && self.trusted + } +} diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 8a4f8d658..6309a400b 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -39,6 +39,7 @@ pub mod devices; pub mod doctor; pub mod embeddings; pub mod encryption; +pub mod external_capabilities; pub mod health; pub mod heartbeat; pub mod http_host;