From 13f8af846e9e7601550c461f5438a17385a2135e Mon Sep 17 00:00:00 2001 From: Srinivas Vaddi <38348871+vaddisrinivas@users.noreply.github.com> Date: Sat, 23 May 2026 11:44:37 -0400 Subject: [PATCH 1/8] Add external capability provider registry --- src/openhuman/config/schema/mod.rs | 12 +- src/openhuman/config/schema/tools.rs | 3 + src/openhuman/config/schema/types.rs | 4 + src/openhuman/external_capabilities/mod.rs | 254 +++++++++++++++++++++ src/openhuman/mod.rs | 1 + 5 files changed, 268 insertions(+), 6 deletions(-) create mode 100644 src/openhuman/external_capabilities/mod.rs diff --git a/src/openhuman/config/schema/mod.rs b/src/openhuman/config/schema/mod.rs index 97f7bac144..86b5128f18 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 c7ae7aa09d..3c2a905e70 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 2617e6bd7b..0b7a482de3 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -175,6 +175,9 @@ pub struct Config { #[serde(default)] pub mcp_client: McpClientConfig, + #[serde(default)] + pub external_capability_providers: ExternalCapabilityProvidersConfig, + #[serde(default)] pub multimodal: MultimodalConfig, @@ -585,6 +588,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 0000000000..f5b8dd5c0c --- /dev/null +++ b/src/openhuman/external_capabilities/mod.rs @@ -0,0 +1,254 @@ +//! 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. + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[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, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)] +#[serde(default)] +pub struct ExternalCapabilityProvidersConfig { + /// Known external capability providers. + pub providers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ExternalCapabilityProvider { + pub id: String, + pub name: String, + pub source_uri: Option, + pub source_digest: Option, + pub trusted: bool, + pub enabled: bool, +} + +impl ExternalCapabilityProvider { + 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, + }) + } + + pub fn can_register_tools(&self) -> bool { + self.enabled && self.trusted + } +} + +#[derive(Debug, Clone, Default)] +pub struct ExternalCapabilityProviderRegistry { + providers: BTreeMap, + errors: Vec, +} + +impl ExternalCapabilityProviderRegistry { + pub fn from_config(config: &ExternalCapabilityProvidersConfig) -> Self { + let mut providers = BTreeMap::new(); + let mut errors = Vec::new(); + + for provider in &config.providers { + match ExternalCapabilityProvider::from_config(provider) { + Ok(provider) => { + if providers.contains_key(&provider.id) { + errors.push(format!( + "duplicate external capability provider id `{}`", + provider.id + )); + } else { + providers.insert(provider.id.clone(), provider); + } + } + Err(err) => errors.push(err), + } + } + + Self { providers, errors } + } + + pub fn is_empty(&self) -> bool { + self.providers.is_empty() + } + + pub fn list(&self) -> Vec<&ExternalCapabilityProvider> { + self.providers.values().collect() + } + + pub fn get(&self, provider_id: &str) -> Option<&ExternalCapabilityProvider> { + normalize_provider_id(provider_id).and_then(|id| self.providers.get(&id)) + } + + pub fn can_register_tools(&self, provider_id: &str) -> bool { + self.get(provider_id) + .map(ExternalCapabilityProvider::can_register_tools) + .unwrap_or(false) + } + + pub fn errors(&self) -> &[String] { + &self.errors + } +} + +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/mod.rs b/src/openhuman/mod.rs index cdafe89ae9..19338d417b 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -38,6 +38,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; From d91b5ef1371e65e81b77ea701e4a6b03e121d6d9 Mon Sep 17 00:00:00 2001 From: Srinivas Vaddi <38348871+vaddisrinivas@users.noreply.github.com> Date: Sat, 23 May 2026 20:14:12 -0400 Subject: [PATCH 2/8] Address provider registry review feedback --- src/openhuman/external_capabilities/mod.rs | 252 +----------------- .../external_capabilities/registry.rs | 216 +++++++++++++++ src/openhuman/external_capabilities/types.rs | 65 +++++ 3 files changed, 287 insertions(+), 246 deletions(-) create mode 100644 src/openhuman/external_capabilities/registry.rs create mode 100644 src/openhuman/external_capabilities/types.rs diff --git a/src/openhuman/external_capabilities/mod.rs b/src/openhuman/external_capabilities/mod.rs index f5b8dd5c0c..b7a99806ee 100644 --- a/src/openhuman/external_capabilities/mod.rs +++ b/src/openhuman/external_capabilities/mod.rs @@ -5,250 +5,10 @@ //! normalizes the provider records OpenHuman can use for admission, policy, and //! diagnostics. -use std::collections::BTreeMap; +mod registry; +mod types; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[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, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)] -#[serde(default)] -pub struct ExternalCapabilityProvidersConfig { - /// Known external capability providers. - pub providers: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct ExternalCapabilityProvider { - pub id: String, - pub name: String, - pub source_uri: Option, - pub source_digest: Option, - pub trusted: bool, - pub enabled: bool, -} - -impl ExternalCapabilityProvider { - 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, - }) - } - - pub fn can_register_tools(&self) -> bool { - self.enabled && self.trusted - } -} - -#[derive(Debug, Clone, Default)] -pub struct ExternalCapabilityProviderRegistry { - providers: BTreeMap, - errors: Vec, -} - -impl ExternalCapabilityProviderRegistry { - pub fn from_config(config: &ExternalCapabilityProvidersConfig) -> Self { - let mut providers = BTreeMap::new(); - let mut errors = Vec::new(); - - for provider in &config.providers { - match ExternalCapabilityProvider::from_config(provider) { - Ok(provider) => { - if providers.contains_key(&provider.id) { - errors.push(format!( - "duplicate external capability provider id `{}`", - provider.id - )); - } else { - providers.insert(provider.id.clone(), provider); - } - } - Err(err) => errors.push(err), - } - } - - Self { providers, errors } - } - - pub fn is_empty(&self) -> bool { - self.providers.is_empty() - } - - pub fn list(&self) -> Vec<&ExternalCapabilityProvider> { - self.providers.values().collect() - } - - pub fn get(&self, provider_id: &str) -> Option<&ExternalCapabilityProvider> { - normalize_provider_id(provider_id).and_then(|id| self.providers.get(&id)) - } - - pub fn can_register_tools(&self, provider_id: &str) -> bool { - self.get(provider_id) - .map(ExternalCapabilityProvider::can_register_tools) - .unwrap_or(false) - } - - pub fn errors(&self) -> &[String] { - &self.errors - } -} - -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")); - } -} +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 0000000000..a69772d1a2 --- /dev/null +++ b/src/openhuman/external_capabilities/registry.rs @@ -0,0 +1,216 @@ +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 mut providers = BTreeMap::new(); + let mut errors = Vec::new(); + + for provider in &config.providers { + match ExternalCapabilityProvider::from_config(provider) { + Ok(provider) => { + if providers.contains_key(&provider.id) { + log::debug!( + "[external_capability] duplicate provider_id={}", + provider.id + ); + errors.push(format!( + "duplicate external capability provider id `{}`", + provider.id + )); + } else { + providers.insert(provider.id.clone(), provider); + } + } + Err(err) => { + log::debug!( + "[external_capability] invalid provider_config_id={} error={}", + provider.id, + err + ); + errors.push(err); + } + } + } + + 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 0000000000..fce5154509 --- /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 + } +} From aa38c0ad95990d431323a016a644e07ff9ceaaf0 Mon Sep 17 00:00:00 2001 From: Srinivas Vaddi <38348871+vaddisrinivas@users.noreply.github.com> Date: Sat, 23 May 2026 23:59:19 -0400 Subject: [PATCH 3/8] Add flow-level diagnostics for provider registry build --- .../external_capabilities/registry.rs | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/openhuman/external_capabilities/registry.rs b/src/openhuman/external_capabilities/registry.rs index a69772d1a2..24802c2a28 100644 --- a/src/openhuman/external_capabilities/registry.rs +++ b/src/openhuman/external_capabilities/registry.rs @@ -36,15 +36,23 @@ pub struct ExternalCapabilityProviderRegistry { impl ExternalCapabilityProviderRegistry { /// Build a registry from config, collecting invalid records as errors. pub fn from_config(config: &ExternalCapabilityProvidersConfig) -> Self { + log::debug!( + "[external_capability][registry] build_start total_providers={}", + config.providers.len() + ); 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; log::debug!( - "[external_capability] duplicate provider_id={}", + "[external_capability][registry] duplicate provider_id={}", provider.id ); errors.push(format!( @@ -52,12 +60,20 @@ impl ExternalCapabilityProviderRegistry { provider.id )); } else { + accepted_count += 1; + log::debug!( + "[external_capability][registry] accepted provider_id={} trusted={} enabled={}", + provider.id, + provider.trusted, + provider.enabled + ); providers.insert(provider.id.clone(), provider); } } Err(err) => { + rejected_count += 1; log::debug!( - "[external_capability] invalid provider_config_id={} error={}", + "[external_capability][registry] rejected provider_config_id={} error={}", provider.id, err ); @@ -66,6 +82,17 @@ impl ExternalCapabilityProviderRegistry { } } + let provider_ids = providers.keys().cloned().collect::>().join(","); + log::debug!( + "[external_capability][registry] build_end total_providers={} accepted_count={} duplicate_count={} rejected_count={} error_count={} provider_ids={}", + config.providers.len(), + accepted_count, + duplicate_count, + rejected_count, + errors.len(), + provider_ids + ); + Self { providers, errors } } From 698a4bd015bbb2cd9d91842cad8b1535e1e1c9b6 Mon Sep 17 00:00:00 2001 From: Srinivas Vaddi <38348871+vaddisrinivas@users.noreply.github.com> Date: Sun, 24 May 2026 10:22:01 -0400 Subject: [PATCH 4/8] Improve external capability registry diagnostics --- .../external_capabilities/registry.rs | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/openhuman/external_capabilities/registry.rs b/src/openhuman/external_capabilities/registry.rs index 24802c2a28..0c8b288a27 100644 --- a/src/openhuman/external_capabilities/registry.rs +++ b/src/openhuman/external_capabilities/registry.rs @@ -36,9 +36,10 @@ pub struct ExternalCapabilityProviderRegistry { 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={}", - config.providers.len() + total_providers ); let mut providers = BTreeMap::new(); let mut errors = Vec::new(); @@ -51,9 +52,14 @@ impl ExternalCapabilityProviderRegistry { Ok(provider) => { if providers.contains_key(&provider.id) { duplicate_count += 1; + rejected_count += 1; log::debug!( - "[external_capability][registry] duplicate provider_id={}", - provider.id + "[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 `{}`", @@ -62,10 +68,14 @@ impl ExternalCapabilityProviderRegistry { } else { accepted_count += 1; log::debug!( - "[external_capability][registry] accepted provider_id={} trusted={} enabled={}", + "[external_capability][registry] provider_accepted provider_id={} trusted={} enabled={} total_providers={} accepted_count={} rejected_count={} duplicate_count={}", provider.id, provider.trusted, - provider.enabled + provider.enabled, + total_providers, + accepted_count, + rejected_count, + duplicate_count ); providers.insert(provider.id.clone(), provider); } @@ -73,24 +83,29 @@ impl ExternalCapabilityProviderRegistry { Err(err) => { rejected_count += 1; log::debug!( - "[external_capability][registry] rejected provider_config_id={} error={}", + "[external_capability][registry] provider_rejected provider_config_id={} error={} total_providers={} accepted_count={} rejected_count={} duplicate_count={}", provider.id, - err + err, + total_providers, + accepted_count, + rejected_count, + duplicate_count ); errors.push(err); } } } - let provider_ids = providers.keys().cloned().collect::>().join(","); + let provider_ids = providers.keys().cloned().collect::>(); log::debug!( - "[external_capability][registry] build_end total_providers={} accepted_count={} duplicate_count={} rejected_count={} error_count={} provider_ids={}", - config.providers.len(), + "[external_capability][registry] build_end total_providers={} accepted_count={} rejected_count={} duplicate_count={} error_count={} provider_ids={:?} errors={:?}", + total_providers, accepted_count, - duplicate_count, rejected_count, + duplicate_count, errors.len(), - provider_ids + provider_ids, + errors ); Self { providers, errors } From cc862fbc9f3443fe130366e6f27e9838b29863a6 Mon Sep 17 00:00:00 2001 From: Srinivas Vaddi <38348871+vaddisrinivas@users.noreply.github.com> Date: Sun, 24 May 2026 10:47:12 -0400 Subject: [PATCH 5/8] Retry CI after runner disk exhaustion From 3f9321990eb67a0f18cc6f5ea34c5a7fa11312c3 Mon Sep 17 00:00:00 2001 From: Srinivas Vaddi <38348871+vaddisrinivas@users.noreply.github.com> Date: Mon, 25 May 2026 01:43:03 -0400 Subject: [PATCH 6/8] test(memory): avoid pii-like tool rule fixtures --- src/openhuman/memory/ops/tool_memory.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/openhuman/memory/ops/tool_memory.rs b/src/openhuman/memory/ops/tool_memory.rs index e3685efc2c..c168e2bdf0 100644 --- a/src/openhuman/memory/ops/tool_memory.rs +++ b/src/openhuman/memory/ops/tool_memory.rs @@ -176,6 +176,7 @@ pub async fn tool_rules_json(params: ToolRuleListParams) -> Result String { - format!("tool-memory-{}", uuid::Uuid::new_v4()) + static NEXT_TOOL_ID: AtomicU64 = AtomicU64::new(1); + let id = NEXT_TOOL_ID.fetch_add(1, Ordering::Relaxed); + format!("toolmemory{id}") } #[tokio::test] From ae2859ed6daa36d28718c7505efd09e988a336a7 Mon Sep 17 00:00:00 2001 From: Srinivas Vaddi <38348871+vaddisrinivas@users.noreply.github.com> Date: Mon, 25 May 2026 09:11:03 -0400 Subject: [PATCH 7/8] Wait for backend session in mega flow --- app/test/e2e/specs/mega-flow.spec.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/app/test/e2e/specs/mega-flow.spec.ts b/app/test/e2e/specs/mega-flow.spec.ts index 00f806e6fb..5fcebfb7b4 100644 --- a/app/test/e2e/specs/mega-flow.spec.ts +++ b/app/test/e2e/specs/mega-flow.spec.ts @@ -70,6 +70,20 @@ async function waitForMockRequest( return undefined; } +async function waitForBackendSession(label: string, timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + let lastProbe: unknown = undefined; + + while (Date.now() < deadline) { + const probe = await callOpenhumanRpc('openhuman.composio_list_triggers', {}); + if (probe.ok) return; + lastProbe = probe; + await browser.pause(500); + } + + throw new Error(`${LOG} ${label}: backend session not ready: ${JSON.stringify(lastProbe)}`); +} + async function resetEverything(label: string): Promise { console.log(`${LOG} reset (${label}) — admin reset only (skip destructive core reset)`); // Mock-side reset is enough to give each scenario a clean slate for the @@ -237,6 +251,7 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => { // Re-login since reset wipes the session. await triggerDeepLink('openhuman://auth?token=mega-composio-token'); await waitForMockRequest('POST', '/telegram/login-tokens/', 15_000); + await waitForBackendSession('Scenario 4 auth'); // Seed connections + available triggers; start with an empty active list. setMockBehaviors({ @@ -248,6 +263,9 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => { }); const before = await callOpenhumanRpc('openhuman.composio_list_triggers', {}); + if (!before.ok) { + console.log(`${LOG} composio: list before enable failed`, before); + } expect(before.ok).toBe(true); // list_triggers always emits a log line → RpcOutcome wraps in {result, logs}. // JSON-RPC result shape: { result: { triggers: [...] }, logs: [...] } @@ -262,9 +280,15 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => { connection_id: 'c1', slug: 'GMAIL_NEW_GMAIL_MESSAGE', }); + if (!enable.ok) { + console.log(`${LOG} composio: enable failed`, enable); + } expect(enable.ok).toBe(true); const after = await callOpenhumanRpc('openhuman.composio_list_triggers', {}); + if (!after.ok) { + console.log(`${LOG} composio: list after enable failed`, after); + } expect(after.ok).toBe(true); const afterList = (after.result?.result?.triggers ?? after.result?.triggers ?? []) as unknown[]; expect(afterList.length).toBeGreaterThan(0); @@ -561,6 +585,7 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => { await triggerDeepLink('openhuman://auth?token=mega-composio-webhook-token'); await waitForMockRequest('POST', '/telegram/login-tokens/', 15_000); + await waitForBackendSession('Scenario 11 auth'); clearRequestLog(); // Seed composio state. @@ -577,6 +602,9 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => { connection_id: 'c2', slug: 'GITHUB_PULL_REQUEST_EVENT', }); + if (!enable.ok) { + console.log(`${LOG} composio+webhook: enable failed`, enable); + } expect(enable.ok).toBe(true); console.log(`${LOG} composio+webhook: trigger enabled`); From f95e727ef6e1272a30f5fd89c0383fd4051c1377 Mon Sep 17 00:00:00 2001 From: Srinivas Vaddi <38348871+vaddisrinivas@users.noreply.github.com> Date: Tue, 26 May 2026 19:23:36 -0400 Subject: [PATCH 8/8] test(e2e): remove unused backend session helper --- app/test/e2e/specs/mega-flow.spec.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/app/test/e2e/specs/mega-flow.spec.ts b/app/test/e2e/specs/mega-flow.spec.ts index 66ff55f1b7..bf6a3c01a1 100644 --- a/app/test/e2e/specs/mega-flow.spec.ts +++ b/app/test/e2e/specs/mega-flow.spec.ts @@ -77,20 +77,6 @@ async function waitForMockRequest( return undefined; } -async function waitForBackendSession(label: string, timeoutMs = 15_000): Promise { - const deadline = Date.now() + timeoutMs; - let lastProbe: unknown = undefined; - - while (Date.now() < deadline) { - const probe = await callOpenhumanRpc('openhuman.composio_list_triggers', {}); - if (probe.ok) return; - lastProbe = probe; - await browser.pause(500); - } - - throw new Error(`${LOG} ${label}: backend session not ready: ${JSON.stringify(lastProbe)}`); -} - async function resetEverything(label: string): Promise { console.log(`${LOG} reset (${label}) — admin reset only (skip destructive core reset)`); // Mock-side reset is enough to give each scenario a clean slate for the