From c7d7b078b137e1b7dc1e94fec193ce3d8d2381ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Sun, 24 May 2026 00:56:28 +0800 Subject: [PATCH 1/5] feat(tool-registry): add trusted capability providers --- gitbooks/developing/mcp-server.md | 23 ++ gitbooks/developing/mcp-server.zh-CN.md | 17 + src/openhuman/config/README.md | 4 +- src/openhuman/config/mod.rs | 3 +- .../config/schema/capability_providers.rs | 53 +++ src/openhuman/config/schema/mod.rs | 2 + src/openhuman/config/schema/types.rs | 30 ++ src/openhuman/tool_registry/mod.rs | 11 +- src/openhuman/tool_registry/ops.rs | 79 ++++ src/openhuman/tool_registry/providers.rs | 336 ++++++++++++++++++ src/openhuman/tool_registry/types.rs | 11 + 11 files changed, 564 insertions(+), 5 deletions(-) create mode 100644 src/openhuman/config/schema/capability_providers.rs create mode 100644 src/openhuman/tool_registry/providers.rs diff --git a/gitbooks/developing/mcp-server.md b/gitbooks/developing/mcp-server.md index 5d4bebb21c..7944cb3e90 100644 --- a/gitbooks/developing/mcp-server.md +++ b/gitbooks/developing/mcp-server.md @@ -86,11 +86,34 @@ session: | --- | --- | | `openhuman.tool_registry_list` | List MCP stdio tools and controller-backed tools with stable `tool_id`, route, version, input/output schemas, allowed agents, tags, enabled state, and health. | | `openhuman.tool_registry_get` | Return one registry entry by `tool_id`, for example `memory.search` or `tools.web_search`. | +| `openhuman.tool_registry_diagnostics` | Return redacted inventory counts, write-surface candidates, policy surfaces, and external capability-provider diagnostics. | The registry is discovery-only. It does not change tool dispatch or permission checks; MCP calls still go through `tools/call`, and controller-backed tools still route through their existing JSON-RPC methods. +### External Capability Providers + +OpenHuman can record trusted external capability providers in `config.toml`. +This is governance metadata only: it does not install packages, execute remote +code, or bypass the existing MCP/controller dispatch paths. + +```toml +[[capability_providers]] +id = "Acme Tools" +display_name = "Acme Tools" +source_uri = "https://example.com/openhuman/acme-tools" +source_digest = "sha256:abc123" +trust_state = "trusted" +enabled = true +``` + +Provider ids are normalized before policy checks. For example, `Acme Tools` +becomes `acme-tools`; duplicates after normalization are rejected. A provider is +eligible for future admission checks only when it is both `enabled = true` and +`trust_state = "trusted"`. Missing provider config preserves the previous +behavior: the provider registry is empty and no existing tools are hidden. + ## Smoke Test ```bash diff --git a/gitbooks/developing/mcp-server.zh-CN.md b/gitbooks/developing/mcp-server.zh-CN.md index eafec726af..cde6348b85 100644 --- a/gitbooks/developing/mcp-server.zh-CN.md +++ b/gitbooks/developing/mcp-server.zh-CN.md @@ -66,9 +66,26 @@ HTTP JSON-RPC 服务器还暴露一个只读的全局工具注册表,供需要 | --- | --- | | `openhuman.tool_registry_list` | 列出 MCP stdio 工具和控制器支持的工具,包含稳定的 `tool_id`、路由、版本、输入/输出 schema、允许的智能体、标签、启用状态和健康状况。 | | `openhuman.tool_registry_get` | 通过 `tool_id` 返回一个注册表条目,例如 `memory.search` 或 `tools.web_search`。 | +| `openhuman.tool_registry_diagnostics` | 返回脱敏的清单统计、疑似写入面、策略面以及外部能力提供方诊断信息。 | 注册表仅用于发现。它不改变工具分派或权限检查;MCP 调用仍通过 `tools/call`,控制器支持的工具仍通过其现有的 JSON-RPC 方法路由。 +### 外部能力提供方 + +OpenHuman 可以在 `config.toml` 中记录可信外部能力提供方。这只是治理元数据:不会安装包、执行远程代码,也不会绕过现有 MCP/控制器分派路径。 + +```toml +[[capability_providers]] +id = "Acme Tools" +display_name = "Acme Tools" +source_uri = "https://example.com/openhuman/acme-tools" +source_digest = "sha256:abc123" +trust_state = "trusted" +enabled = true +``` + +Provider id 会在策略检查前规范化。例如 `Acme Tools` 会变成 `acme-tools`;规范化后重复的 id 会被拒绝。只有同时满足 `enabled = true` 和 `trust_state = "trusted"` 的提供方,才会被后续准入检查视为可用。没有 provider 配置时保持旧行为:provider 注册表为空,现有工具不会被隐藏。 + ## 冒烟测试 ```bash diff --git a/src/openhuman/config/README.md b/src/openhuman/config/README.md index f65bea6062..1eb3b6a64d 100644 --- a/src/openhuman/config/README.md +++ b/src/openhuman/config/README.md @@ -5,8 +5,8 @@ Authoritative TOML-backed configuration layer. Owns the `Config` schema (every d ## Public surface - `pub struct Config` — `schema/types.rs` (re-exported `mod.rs:28`) — top-level user settings. -- Per-domain config structs (re-exported `mod.rs:28-39`): `AgentConfig`, `AuditConfig`, `AutocompleteConfig`, `AutonomyConfig`, `BrowserComputerUseConfig`, `BrowserConfig`, `ChannelsConfig`, `ComposioConfig`, `ContextConfig`, `CostConfig`, `CronConfig`, `CurlConfig`, `DelegateAgentConfig`, `DictationConfig`, `DiscordConfig`, `DockerRuntimeConfig`, `EmbeddingRouteConfig`, `GitbooksConfig`, `HeartbeatConfig`, `HttpRequestConfig`, `IMessageConfig`, `IntegrationsConfig`, `LarkConfig`, `LearningConfig`, `LocalAiConfig`, `MatrixConfig`, `MemoryConfig`, `ModelRouteConfig`, `MultimodalConfig`, `ObservabilityConfig`, `ProxyConfig`, `ReliabilityConfig`, `ResourceLimitsConfig`, `RuntimeConfig`, `SandboxConfig`, `SchedulerConfig`, `ScreenIntelligenceConfig`, `SecretsConfig`, `SecurityConfig`, `SlackConfig`, `StorageConfig`, `TelegramConfig`, `UpdateConfig`, `VoiceServerConfig`, `WebSearchConfig`, `WebhookConfig`. -- Enums: `DictationActivationMode`, `IntegrationToggle`, `ProxyScope`, `ReflectionSource`, `SandboxBackend`, `StorageProviderConfig`, `StorageProviderSection`, `StreamMode`, `VoiceActivationMode`. +- Per-domain config structs (re-exported `mod.rs:28-39`): `AgentConfig`, `AuditConfig`, `AutocompleteConfig`, `AutonomyConfig`, `BrowserComputerUseConfig`, `BrowserConfig`, `CapabilityProviderConfig`, `ChannelsConfig`, `ComposioConfig`, `ContextConfig`, `CostConfig`, `CronConfig`, `CurlConfig`, `DelegateAgentConfig`, `DictationConfig`, `DiscordConfig`, `DockerRuntimeConfig`, `EmbeddingRouteConfig`, `GitbooksConfig`, `HeartbeatConfig`, `HttpRequestConfig`, `IMessageConfig`, `IntegrationsConfig`, `LarkConfig`, `LearningConfig`, `LocalAiConfig`, `MatrixConfig`, `MemoryConfig`, `ModelRouteConfig`, `MultimodalConfig`, `ObservabilityConfig`, `ProxyConfig`, `ReliabilityConfig`, `ResourceLimitsConfig`, `RuntimeConfig`, `SandboxConfig`, `SchedulerConfig`, `ScreenIntelligenceConfig`, `SecretsConfig`, `SecurityConfig`, `SlackConfig`, `StorageConfig`, `TelegramConfig`, `UpdateConfig`, `VoiceServerConfig`, `WebSearchConfig`, `WebhookConfig`. +- Enums: `CapabilityProviderTrustState`, `DictationActivationMode`, `IntegrationToggle`, `ProxyScope`, `ReflectionSource`, `SandboxBackend`, `StorageProviderConfig`, `StorageProviderSection`, `StreamMode`, `VoiceActivationMode`. - Model constants: `DEFAULT_MODEL`, `MODEL_AGENTIC_V1`, `MODEL_CODING_V1`, `MODEL_REASONING_V1`. - `pub struct DaemonConfig` — `daemon.rs` — sidecar lifecycle / port descriptor. - `pub fn apply_runtime_proxy_to_builder` / `pub fn build_runtime_proxy_client` / `pub fn build_runtime_proxy_client_with_timeouts` / `pub fn runtime_proxy_config` / `pub fn set_runtime_proxy_config` — `schema/proxy.rs`. diff --git a/src/openhuman/config/mod.rs b/src/openhuman/config/mod.rs index bfb28d6ea8..15129cc780 100644 --- a/src/openhuman/config/mod.rs +++ b/src/openhuman/config/mod.rs @@ -25,7 +25,8 @@ pub use schema::{ apply_runtime_proxy_to_builder, build_runtime_proxy_client, build_runtime_proxy_client_with_timeouts, output_language_directive, runtime_proxy_config, set_runtime_proxy_config, AgentConfig, AuditConfig, AutocompleteConfig, AutonomyConfig, - BrowserComputerUseConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, ContextConfig, + BrowserComputerUseConfig, BrowserConfig, CapabilityProviderConfig, + CapabilityProviderTrustState, ChannelsConfig, ComposioConfig, Config, ContextConfig, CostConfig, CronConfig, CurlConfig, DelegateAgentConfig, DictationActivationMode, DictationConfig, DiscordConfig, DockerRuntimeConfig, EmbeddingRouteConfig, GitbooksConfig, HeartbeatConfig, HttpRequestConfig, IMessageConfig, IntegrationToggle, IntegrationsConfig, diff --git a/src/openhuman/config/schema/capability_providers.rs b/src/openhuman/config/schema/capability_providers.rs new file mode 100644 index 0000000000..b5d4feac98 --- /dev/null +++ b/src/openhuman/config/schema/capability_providers.rs @@ -0,0 +1,53 @@ +//! External capability-provider trust metadata. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Configured external capability provider metadata. +/// +/// The registry layer normalizes and validates `id` before policy code uses it. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(default)] +pub struct CapabilityProviderConfig { + /// Human-configured provider id. Registry normalization makes this stable. + pub id: String, + /// Display name for diagnostics and future UI surfaces. + pub display_name: String, + /// Optional source URI for provenance, such as a GitHub repo or MCP catalog URL. + pub source_uri: Option, + /// Optional source digest, for example `sha256:`. + pub source_digest: Option, + /// Explicit trust state. Defaults to `untrusted` for fail-closed behavior. + pub trust_state: CapabilityProviderTrustState, + /// Whether this provider is enabled for discovery/admission. + pub enabled: bool, +} + +impl Default for CapabilityProviderConfig { + fn default() -> Self { + Self { + id: String::new(), + display_name: String::new(), + source_uri: None, + source_digest: None, + trust_state: CapabilityProviderTrustState::Untrusted, + enabled: false, + } + } +} + +/// Trust state for an external capability provider. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CapabilityProviderTrustState { + /// Provider metadata is accepted, but capabilities from it are not trusted. + Untrusted, + /// Provider is explicitly trusted by local config. + Trusted, +} + +impl Default for CapabilityProviderTrustState { + fn default() -> Self { + Self::Untrusted + } +} diff --git a/src/openhuman/config/schema/mod.rs b/src/openhuman/config/schema/mod.rs index 97f7bac144..586d965101 100644 --- a/src/openhuman/config/schema/mod.rs +++ b/src/openhuman/config/schema/mod.rs @@ -11,6 +11,7 @@ mod accessibility; mod agent; mod autocomplete; mod autonomy; +mod capability_providers; mod channels; mod context; mod defaults; @@ -43,6 +44,7 @@ pub use agent::{ }; pub use autocomplete::AutocompleteConfig; pub use autonomy::AutonomyConfig; +pub use capability_providers::{CapabilityProviderConfig, CapabilityProviderTrustState}; pub use channels::{ AuditConfig, ChannelsConfig, DingTalkConfig, DiscordConfig, IMessageConfig, IrcConfig, LarkConfig, LarkReceiveMode, MatrixConfig, MattermostConfig, QQConfig, ResourceLimitsConfig, diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index 2617e6bd7b..1b2047d4ba 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -175,6 +175,11 @@ pub struct Config { #[serde(default)] pub mcp_client: McpClientConfig, + /// Trust metadata for external capability providers. Empty by default so + /// existing installations keep the same tool-discovery behavior. + #[serde(default)] + pub capability_providers: Vec, + #[serde(default)] pub multimodal: MultimodalConfig, @@ -590,6 +595,7 @@ impl Default for Config { curl: CurlConfig::default(), gitbooks: GitbooksConfig::default(), mcp_client: McpClientConfig::default(), + capability_providers: Vec::new(), multimodal: MultimodalConfig::default(), seltz: SeltzConfig::default(), searxng: SearxngConfig::default(), @@ -694,6 +700,30 @@ mod model_pin_tests { ); } + #[test] + fn config_parses_capability_provider_entries() { + let config: Config = toml::from_str( + r#" + [[capability_providers]] + id = "Acme Tools" + display_name = "Acme Tools" + source_uri = "https://example.com/openhuman/acme-tools" + source_digest = "sha256:abc123" + trust_state = "trusted" + enabled = true + "#, + ) + .expect("config should parse capability providers"); + + assert_eq!(config.capability_providers.len(), 1); + assert_eq!(config.capability_providers[0].id, "Acme Tools"); + assert_eq!( + config.capability_providers[0].trust_state, + CapabilityProviderTrustState::Trusted + ); + assert!(config.capability_providers[0].enabled); + } + #[test] fn empty_model_pin_values_fall_back_to_auto_routing() { let mut config = Config::default(); diff --git a/src/openhuman/tool_registry/mod.rs b/src/openhuman/tool_registry/mod.rs index 208c603898..f7c2054882 100644 --- a/src/openhuman/tool_registry/mod.rs +++ b/src/openhuman/tool_registry/mod.rs @@ -1,15 +1,22 @@ //! Unified read-only tool registry for discovery across OpenHuman tool surfaces. pub mod ops; +mod providers; mod schemas; mod types; pub use ops::{get_tool, list_tools, registry_entries}; +pub use providers::{ + capability_provider_by_id, capability_provider_diagnostics, capability_provider_registry, + is_capability_provider_trusted_enabled, list_capability_providers, + normalize_capability_provider_id, CapabilityProviderMetadata, CapabilityProviderRegistry, + CapabilityProviderRegistryError, +}; pub use schemas::{ all_controller_schemas as all_tool_registry_controller_schemas, all_registered_controllers as all_tool_registry_registered_controllers, }; pub use types::{ - ToolPolicyDiagnostics, ToolRegistryEntry, ToolRegistryHealth, ToolRegistryList, - ToolRegistryTransport, + CapabilityProviderDiagnostics, ToolPolicyDiagnostics, ToolRegistryEntry, ToolRegistryHealth, + ToolRegistryList, ToolRegistryTransport, }; diff --git a/src/openhuman/tool_registry/ops.rs b/src/openhuman/tool_registry/ops.rs index a94b030e9d..3a30300913 100644 --- a/src/openhuman/tool_registry/ops.rs +++ b/src/openhuman/tool_registry/ops.rs @@ -4,9 +4,11 @@ use serde_json::{json, Map, Value}; use crate::core::all; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; +use crate::openhuman::config::Config; use crate::openhuman::mcp_server::McpToolSpec; use crate::rpc::RpcOutcome; +use super::providers::capability_provider_diagnostics; use super::types::{ ToolPolicyDiagnostics, ToolRegistryEntry, ToolRegistryHealth, ToolRegistryList, ToolRegistryTransport, @@ -35,6 +37,11 @@ pub fn list_tools() -> RpcOutcome { /// Return redacted diagnostics for policy/tool visibility reviews. pub fn diagnostics() -> RpcOutcome { + diagnostics_for_config(&Config::default()) +} + +/// Return redacted diagnostics using a specific config snapshot. +pub fn diagnostics_for_config(config: &Config) -> RpcOutcome { let tools = registry_entries(); let total_tools = tools.len(); let enabled_tools = tools.iter().filter(|entry| entry.enabled).count(); @@ -60,6 +67,7 @@ pub fn diagnostics() -> RpcOutcome { json_rpc_tools, possible_write_surfaces, policy_surfaces, + capability_providers: capability_provider_diagnostics(config), }; RpcOutcome::new(diagnostics, vec![]) } @@ -385,6 +393,9 @@ fn title_from_function(function: &str) -> String { mod tests { use super::*; use crate::core::{FieldSchema, TypeSchema}; + use crate::openhuman::config::schema::{ + CapabilityProviderConfig, CapabilityProviderTrustState, Config, + }; #[test] fn registry_entries_include_mcp_and_controller_tools() { @@ -444,6 +455,59 @@ mod tests { .any(|tool_id| tool_id == "tools.composio_execute")); } + #[test] + fn diagnostics_for_config_reports_capability_provider_summary() { + let mut config = Config::default(); + config.capability_providers = vec![ + capability_provider( + "trusted-enabled", + CapabilityProviderTrustState::Trusted, + true, + ), + capability_provider( + "trusted-disabled", + CapabilityProviderTrustState::Trusted, + false, + ), + capability_provider( + "untrusted-enabled", + CapabilityProviderTrustState::Untrusted, + true, + ), + ]; + + let outcome = diagnostics_for_config(&config); + + assert_eq!(outcome.value.capability_providers.total_providers, 3); + assert_eq!(outcome.value.capability_providers.enabled_providers, 2); + assert_eq!(outcome.value.capability_providers.trusted_providers, 2); + assert_eq!( + outcome.value.capability_providers.trusted_enabled_providers, + 1 + ); + assert!(outcome + .value + .capability_providers + .registry_errors + .is_empty()); + } + + #[test] + fn diagnostics_for_config_reports_capability_provider_errors() { + let mut config = Config::default(); + config.capability_providers = vec![ + capability_provider("Acme Tools", CapabilityProviderTrustState::Trusted, true), + capability_provider("acme-tools", CapabilityProviderTrustState::Trusted, true), + ]; + + let outcome = diagnostics_for_config(&config); + + assert_eq!(outcome.value.capability_providers.total_providers, 2); + assert_eq!(outcome.value.capability_providers.enabled_providers, 0); + assert!(outcome.value.capability_providers.registry_errors[0].contains("duplicate")); + assert!(outcome.value.capability_providers.registry_errors[0].contains("acme-tools")); + } + #[test] fn looks_write_capable_detects_action_prefixes_and_suffixes() { assert!(looks_write_capable("user.create")); @@ -538,4 +602,19 @@ mod tests { json!("Optional cap.") ); } + + fn capability_provider( + id: &str, + trust_state: CapabilityProviderTrustState, + enabled: bool, + ) -> CapabilityProviderConfig { + CapabilityProviderConfig { + id: id.to_string(), + display_name: id.to_string(), + source_uri: None, + source_digest: None, + trust_state, + enabled, + } + } } diff --git a/src/openhuman/tool_registry/providers.rs b/src/openhuman/tool_registry/providers.rs new file mode 100644 index 0000000000..e9cc226723 --- /dev/null +++ b/src/openhuman/tool_registry/providers.rs @@ -0,0 +1,336 @@ +use std::collections::BTreeMap; +use std::fmt; + +use serde::Serialize; + +use crate::openhuman::config::schema::{CapabilityProviderTrustState, Config}; + +use super::types::CapabilityProviderDiagnostics; + +const MAX_PROVIDER_ID_LEN: usize = 96; + +/// Normalized provider metadata used by policy and diagnostics callers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CapabilityProviderMetadata { + pub id: String, + pub display_name: String, + pub source_uri: Option, + pub source_digest: Option, + pub trust_state: CapabilityProviderTrustState, + pub enabled: bool, +} + +/// In-memory view of configured external capability providers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapabilityProviderRegistry { + providers: BTreeMap, +} + +impl CapabilityProviderRegistry { + /// Build a normalized provider registry from the current config snapshot. + pub fn from_config(config: &Config) -> Result { + let mut providers = BTreeMap::new(); + + for provider in &config.capability_providers { + let id = normalize_capability_provider_id(&provider.id)?; + if providers.contains_key(&id) { + return Err(CapabilityProviderRegistryError::DuplicateId { id }); + } + + let display_name = clean_string(&provider.display_name).unwrap_or_else(|| id.clone()); + let metadata = CapabilityProviderMetadata { + id: id.clone(), + display_name, + source_uri: clean_string_opt(provider.source_uri.as_deref()), + source_digest: clean_string_opt(provider.source_digest.as_deref()), + trust_state: provider.trust_state.clone(), + enabled: provider.enabled, + }; + providers.insert(id, metadata); + } + + Ok(Self { providers }) + } + + /// Return all providers sorted by normalized id. + pub fn list(&self) -> Vec { + self.providers.values().cloned().collect() + } + + /// Look up a provider by raw or normalized id. + pub fn get(&self, id: &str) -> Option<&CapabilityProviderMetadata> { + let normalized = normalize_capability_provider_id(id).ok()?; + self.providers.get(&normalized) + } + + /// True only when the provider is both enabled and explicitly trusted. + pub fn is_trusted_enabled(&self, id: &str) -> bool { + self.get(id).is_some_and(|provider| { + provider.enabled && provider.trust_state == CapabilityProviderTrustState::Trusted + }) + } +} + +/// Normalize provider ids to a stable, policy-safe slug. +pub fn normalize_capability_provider_id( + raw: &str, +) -> Result { + let raw = raw.trim(); + if raw.is_empty() { + return Err(CapabilityProviderRegistryError::InvalidId { + raw: raw.to_string(), + }); + } + + let mut normalized = String::new(); + let mut previous_separator = false; + for ch in raw.chars() { + if ch.is_ascii_alphanumeric() { + normalized.push(ch.to_ascii_lowercase()); + previous_separator = false; + } else if ch == '-' || ch == '_' || ch == '.' || ch.is_ascii_whitespace() { + push_separator(&mut normalized, &mut previous_separator, ch); + } else { + push_separator(&mut normalized, &mut previous_separator, '-'); + } + } + + let normalized = normalized + .trim_matches(|ch| ch == '-' || ch == '_' || ch == '.') + .to_string(); + + if normalized.is_empty() || normalized.len() > MAX_PROVIDER_ID_LEN { + return Err(CapabilityProviderRegistryError::InvalidId { + raw: raw.to_string(), + }); + } + + Ok(normalized) +} + +/// Build the configured provider registry. +pub fn capability_provider_registry( + config: &Config, +) -> Result { + CapabilityProviderRegistry::from_config(config) +} + +/// List configured external capability providers. +pub fn list_capability_providers( + config: &Config, +) -> Result, CapabilityProviderRegistryError> { + Ok(capability_provider_registry(config)?.list()) +} + +/// Look up one configured external capability provider. +pub fn capability_provider_by_id( + config: &Config, + id: &str, +) -> Result, CapabilityProviderRegistryError> { + Ok(capability_provider_registry(config)?.get(id).cloned()) +} + +/// Return whether a configured provider may be treated as trusted and enabled. +pub fn is_capability_provider_trusted_enabled(config: &Config, id: &str) -> bool { + capability_provider_registry(config) + .map(|registry| registry.is_trusted_enabled(id)) + .unwrap_or(false) +} + +/// Return a redacted provider summary for tool-policy diagnostics. +pub fn capability_provider_diagnostics(config: &Config) -> CapabilityProviderDiagnostics { + match capability_provider_registry(config) { + Ok(registry) => { + let providers = registry.list(); + CapabilityProviderDiagnostics { + total_providers: providers.len(), + enabled_providers: providers.iter().filter(|provider| provider.enabled).count(), + trusted_providers: providers + .iter() + .filter(|provider| { + provider.trust_state == CapabilityProviderTrustState::Trusted + }) + .count(), + trusted_enabled_providers: providers + .iter() + .filter(|provider| { + provider.enabled + && provider.trust_state == CapabilityProviderTrustState::Trusted + }) + .count(), + registry_errors: Vec::new(), + } + } + Err(err) => CapabilityProviderDiagnostics { + total_providers: config.capability_providers.len(), + registry_errors: vec![err.to_string()], + ..CapabilityProviderDiagnostics::default() + }, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CapabilityProviderRegistryError { + InvalidId { raw: String }, + DuplicateId { id: String }, +} + +impl fmt::Display for CapabilityProviderRegistryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CapabilityProviderRegistryError::InvalidId { raw } => { + write!(formatter, "invalid provider id: {raw:?}") + } + CapabilityProviderRegistryError::DuplicateId { id } => { + write!(formatter, "duplicate provider id after normalization: {id}") + } + } + } +} + +impl std::error::Error for CapabilityProviderRegistryError {} + +fn push_separator(normalized: &mut String, previous_separator: &mut bool, separator: char) { + if normalized.is_empty() || *previous_separator { + return; + } + normalized.push(match separator { + '_' => '_', + '.' => '.', + _ => '-', + }); + *previous_separator = true; +} + +fn clean_string(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn clean_string_opt(value: Option<&str>) -> Option { + value.and_then(clean_string) +} + +#[cfg(test)] +mod tests { + use crate::openhuman::config::schema::{ + CapabilityProviderConfig, CapabilityProviderTrustState, Config, + }; + + use super::CapabilityProviderRegistry; + + fn config_with(providers: Vec) -> Config { + let mut config = Config::default(); + config.capability_providers = providers; + config + } + + fn provider( + id: &str, + trust_state: CapabilityProviderTrustState, + enabled: bool, + ) -> CapabilityProviderConfig { + CapabilityProviderConfig { + id: id.to_string(), + display_name: format!("{id} Provider"), + source_uri: Some(format!("https://example.com/{id}")), + source_digest: Some("sha256:abc123".to_string()), + trust_state, + enabled, + } + } + + #[test] + fn default_config_has_no_capability_providers() { + let registry = + CapabilityProviderRegistry::from_config(&Config::default()).expect("empty registry"); + + assert!(registry.list().is_empty()); + assert!(registry.get("anything").is_none()); + } + + #[test] + fn valid_provider_registration_normalizes_metadata() { + let config = config_with(vec![CapabilityProviderConfig { + id: "Acme Tools".to_string(), + display_name: "Acme Tools".to_string(), + source_uri: Some("https://example.com/openhuman/acme-tools".to_string()), + source_digest: Some("sha256:abc123".to_string()), + trust_state: CapabilityProviderTrustState::Trusted, + enabled: true, + }]); + + let registry = CapabilityProviderRegistry::from_config(&config).expect("valid provider"); + let providers = registry.list(); + + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].id, "acme-tools"); + assert_eq!(providers[0].display_name, "Acme Tools"); + assert_eq!( + providers[0].source_uri.as_deref(), + Some("https://example.com/openhuman/acme-tools") + ); + assert_eq!(providers[0].source_digest.as_deref(), Some("sha256:abc123")); + assert_eq!( + providers[0].trust_state, + CapabilityProviderTrustState::Trusted + ); + assert!(providers[0].enabled); + assert!(registry.is_trusted_enabled("ACME Tools")); + } + + #[test] + fn disabled_or_untrusted_providers_are_not_trusted_enabled() { + let config = config_with(vec![ + provider( + "trusted-disabled", + CapabilityProviderTrustState::Trusted, + false, + ), + provider( + "untrusted-enabled", + CapabilityProviderTrustState::Untrusted, + true, + ), + ]); + + let registry = + CapabilityProviderRegistry::from_config(&config).expect("providers should parse"); + + assert_eq!(registry.list().len(), 2); + assert!(!registry.is_trusted_enabled("trusted-disabled")); + assert!(!registry.is_trusted_enabled("untrusted-enabled")); + } + + #[test] + fn duplicate_provider_ids_are_rejected_after_normalization() { + let config = config_with(vec![ + provider("Acme Tools", CapabilityProviderTrustState::Trusted, true), + provider("acme-tools", CapabilityProviderTrustState::Trusted, true), + ]); + + let err = + CapabilityProviderRegistry::from_config(&config).expect_err("duplicate should fail"); + + assert!(err.to_string().contains("duplicate")); + assert!(err.to_string().contains("acme-tools")); + } + + #[test] + fn invalid_provider_ids_are_rejected() { + let config = config_with(vec![provider( + "!!!", + CapabilityProviderTrustState::Trusted, + true, + )]); + + let err = + CapabilityProviderRegistry::from_config(&config).expect_err("invalid id should fail"); + + assert!(err.to_string().contains("invalid provider id")); + } +} diff --git a/src/openhuman/tool_registry/types.rs b/src/openhuman/tool_registry/types.rs index 200e68ac62..e5c200b6f3 100644 --- a/src/openhuman/tool_registry/types.rs +++ b/src/openhuman/tool_registry/types.rs @@ -68,4 +68,15 @@ pub struct ToolPolicyDiagnostics { pub json_rpc_tools: usize, pub possible_write_surfaces: Vec, pub policy_surfaces: Vec, + pub capability_providers: CapabilityProviderDiagnostics, +} + +/// Redacted diagnostics for configured external capability providers. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct CapabilityProviderDiagnostics { + pub total_providers: usize, + pub enabled_providers: usize, + pub trusted_providers: usize, + pub trusted_enabled_providers: usize, + pub registry_errors: Vec, } From 33a827fa645b29a4af721f674ad782cbafa76a5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Sun, 24 May 2026 01:23:13 +0800 Subject: [PATCH 2/5] chore(tool-registry): add provider diagnostics logging --- src/openhuman/tool_registry/ops.rs | 29 +++++++- src/openhuman/tool_registry/providers.rs | 87 +++++++++++++++++++++--- 2 files changed, 107 insertions(+), 9 deletions(-) diff --git a/src/openhuman/tool_registry/ops.rs b/src/openhuman/tool_registry/ops.rs index 3a30300913..17e7853ffd 100644 --- a/src/openhuman/tool_registry/ops.rs +++ b/src/openhuman/tool_registry/ops.rs @@ -42,6 +42,8 @@ pub fn diagnostics() -> RpcOutcome { /// Return redacted diagnostics using a specific config snapshot. pub fn diagnostics_for_config(config: &Config) -> RpcOutcome { + log::debug!("[tool_registry] diagnostics_for_config start"); + let tools = registry_entries(); let total_tools = tools.len(); let enabled_tools = tools.iter().filter(|entry| entry.enabled).count(); @@ -59,6 +61,17 @@ pub fn diagnostics_for_config(config: &Config) -> RpcOutcome>(); let policy_surfaces = policy_surface_ids(); + let capability_providers = capability_provider_diagnostics(config); + + log::trace!( + "[tool_registry] diagnostics_for_config counted total_tools={} enabled_tools={} mcp_stdio_tools={} json_rpc_tools={} possible_write_surfaces={} policy_surfaces={}", + total_tools, + enabled_tools, + mcp_stdio_tools, + json_rpc_tools, + possible_write_surfaces.len(), + policy_surfaces.len() + ); let diagnostics = ToolPolicyDiagnostics { total_tools, @@ -67,8 +80,22 @@ pub fn diagnostics_for_config(config: &Config) -> RpcOutcome Result { + log::trace!( + "[tool_registry] capability_provider_registry start configured_providers={}", + config.capability_providers.len() + ); + let mut providers = BTreeMap::new(); for provider in &config.capability_providers { - let id = normalize_capability_provider_id(&provider.id)?; + let id = match normalize_capability_provider_id(&provider.id) { + Ok(id) => id, + Err(err) => { + log::debug!( + "[tool_registry] capability_provider_registry invalid_provider_id raw_id={:?} error={}", + provider.id, + err + ); + return Err(err); + } + }; + log::debug!( + "[tool_registry] capability_provider_registry normalized_provider raw_id={:?} provider_id={} enabled={} trust_state={:?}", + provider.id, + id, + provider.enabled, + provider.trust_state + ); + if providers.contains_key(&id) { + log::debug!( + "[tool_registry] capability_provider_registry duplicate_provider_id provider_id={}", + id + ); return Err(CapabilityProviderRegistryError::DuplicateId { id }); } - let display_name = clean_string(&provider.display_name).unwrap_or_else(|| id.clone()); + let display_name = match clean_string(&provider.display_name) { + Some(display_name) => display_name, + None => { + log::debug!( + "[tool_registry] capability_provider_registry display_name_empty provider_id={} fallback=provider_id", + id + ); + id.clone() + } + }; let metadata = CapabilityProviderMetadata { id: id.clone(), display_name, @@ -49,6 +85,10 @@ impl CapabilityProviderRegistry { providers.insert(id, metadata); } + log::debug!( + "[tool_registry] capability_provider_registry completed providers={}", + providers.len() + ); Ok(Self { providers }) } @@ -77,6 +117,7 @@ pub fn normalize_capability_provider_id( ) -> Result { let raw = raw.trim(); if raw.is_empty() { + log::trace!("[tool_registry] normalize_capability_provider_id invalid_empty"); return Err(CapabilityProviderRegistryError::InvalidId { raw: raw.to_string(), }); @@ -100,11 +141,22 @@ pub fn normalize_capability_provider_id( .to_string(); if normalized.is_empty() || normalized.len() > MAX_PROVIDER_ID_LEN { + log::trace!( + "[tool_registry] normalize_capability_provider_id invalid raw_id={:?} normalized_id={:?} normalized_len={}", + raw, + normalized, + normalized.len() + ); return Err(CapabilityProviderRegistryError::InvalidId { raw: raw.to_string(), }); } + log::trace!( + "[tool_registry] normalize_capability_provider_id completed raw_id={:?} normalized_id={}", + raw, + normalized + ); Ok(normalized) } @@ -112,7 +164,15 @@ pub fn normalize_capability_provider_id( pub fn capability_provider_registry( config: &Config, ) -> Result { - CapabilityProviderRegistry::from_config(config) + let registry = CapabilityProviderRegistry::from_config(config); + if let Err(err) = ®istry { + log::debug!( + "[tool_registry] capability_provider_registry failed configured_providers={} error={}", + config.capability_providers.len(), + err + ); + } + registry } /// List configured external capability providers. @@ -142,6 +202,10 @@ pub fn capability_provider_diagnostics(config: &Config) -> CapabilityProviderDia match capability_provider_registry(config) { Ok(registry) => { let providers = registry.list(); + log::debug!( + "[tool_registry] capability_provider_diagnostics completed providers={}", + providers.len() + ); CapabilityProviderDiagnostics { total_providers: providers.len(), enabled_providers: providers.iter().filter(|provider| provider.enabled).count(), @@ -161,11 +225,18 @@ pub fn capability_provider_diagnostics(config: &Config) -> CapabilityProviderDia registry_errors: Vec::new(), } } - Err(err) => CapabilityProviderDiagnostics { - total_providers: config.capability_providers.len(), - registry_errors: vec![err.to_string()], - ..CapabilityProviderDiagnostics::default() - }, + Err(err) => { + log::debug!( + "[tool_registry] capability_provider_diagnostics registry_error configured_providers={} error={}", + config.capability_providers.len(), + err + ); + CapabilityProviderDiagnostics { + total_providers: config.capability_providers.len(), + registry_errors: vec![err.to_string()], + ..CapabilityProviderDiagnostics::default() + } + } } } From 57ac91bb09e61ecc33aa474e6ca7e222b8b85bdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Sun, 24 May 2026 01:34:13 +0800 Subject: [PATCH 3/5] fix(tool-registry): load config for diagnostics --- src/openhuman/tool_registry/ops.rs | 66 ++++++++++++++++++++++++-- src/openhuman/tool_registry/schemas.rs | 5 +- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/openhuman/tool_registry/ops.rs b/src/openhuman/tool_registry/ops.rs index 17e7853ffd..fb5b555f9e 100644 --- a/src/openhuman/tool_registry/ops.rs +++ b/src/openhuman/tool_registry/ops.rs @@ -36,8 +36,13 @@ pub fn list_tools() -> RpcOutcome { } /// Return redacted diagnostics for policy/tool visibility reviews. -pub fn diagnostics() -> RpcOutcome { - diagnostics_for_config(&Config::default()) +pub async fn diagnostics() -> Result, String> { + log::debug!("[tool_registry] diagnostics loading_config"); + let config = Config::load_or_init().await.map_err(|err| { + log::warn!("[tool_registry] diagnostics config_load_failed error={err}"); + format!("failed to load config for tool registry diagnostics: {err}") + })?; + Ok(diagnostics_for_config(&config)) } /// Return redacted diagnostics using a specific config snapshot. @@ -464,7 +469,7 @@ mod tests { #[test] fn diagnostics_reports_inventory_and_policy_surfaces() { - let outcome = diagnostics(); + let outcome = diagnostics_for_config(&Config::default()); assert!(outcome.value.total_tools > 0); assert_eq!(outcome.value.total_tools, outcome.value.enabled_tools); @@ -482,6 +487,39 @@ mod tests { .any(|tool_id| tool_id == "tools.composio_execute")); } + #[tokio::test] + async fn diagnostics_loads_active_capability_provider_config() { + let _lock = crate::openhuman::config::TEST_ENV_LOCK.lock().unwrap(); + let tmp = tempfile::tempdir().expect("tempdir"); + let _env = EnvRestore::set_path("OPENHUMAN_WORKSPACE", tmp.path()); + std::fs::write( + tmp.path().join("config.toml"), + r#" +[[capability_providers]] +id = "Runtime Provider" +display_name = "Runtime Provider" +trust_state = "trusted" +enabled = true +"#, + ) + .expect("write config"); + + let outcome = diagnostics().await.expect("diagnostics"); + + assert_eq!(outcome.value.capability_providers.total_providers, 1); + assert_eq!(outcome.value.capability_providers.enabled_providers, 1); + assert_eq!(outcome.value.capability_providers.trusted_providers, 1); + assert_eq!( + outcome.value.capability_providers.trusted_enabled_providers, + 1 + ); + assert!(outcome + .value + .capability_providers + .registry_errors + .is_empty()); + } + #[test] fn diagnostics_for_config_reports_capability_provider_summary() { let mut config = Config::default(); @@ -644,4 +682,26 @@ mod tests { enabled, } } + + struct EnvRestore { + key: &'static str, + previous: Option, + } + + impl EnvRestore { + fn set_path(key: &'static str, value: &std::path::Path) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } + } + + impl Drop for EnvRestore { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } } diff --git a/src/openhuman/tool_registry/schemas.rs b/src/openhuman/tool_registry/schemas.rs index f3b1750a8f..b4dd693e77 100644 --- a/src/openhuman/tool_registry/schemas.rs +++ b/src/openhuman/tool_registry/schemas.rs @@ -110,7 +110,10 @@ fn handle_diagnostics(params: Map) -> ControllerFuture { "[tool_registry] rpc diagnostics requested param_count={}", params.len() ); - let result = to_json(crate::openhuman::tool_registry::ops::diagnostics()); + let result = match crate::openhuman::tool_registry::ops::diagnostics().await { + Ok(outcome) => to_json(outcome), + Err(err) => Err(err), + }; log::debug!( "[tool_registry] rpc diagnostics completed success={}", result.is_ok() From 1de8a71d57ca133130e868bfe360125601654cd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Sun, 24 May 2026 01:45:57 +0800 Subject: [PATCH 4/5] test(tool-registry): extract ops tests --- src/openhuman/tool_registry/ops.rs | 285 +----------------------- src/openhuman/tool_registry/ops_test.rs | 281 +++++++++++++++++++++++ 2 files changed, 283 insertions(+), 283 deletions(-) create mode 100644 src/openhuman/tool_registry/ops_test.rs diff --git a/src/openhuman/tool_registry/ops.rs b/src/openhuman/tool_registry/ops.rs index fb5b555f9e..9e38ed214d 100644 --- a/src/openhuman/tool_registry/ops.rs +++ b/src/openhuman/tool_registry/ops.rs @@ -422,286 +422,5 @@ fn title_from_function(function: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - use crate::core::{FieldSchema, TypeSchema}; - use crate::openhuman::config::schema::{ - CapabilityProviderConfig, CapabilityProviderTrustState, Config, - }; - - #[test] - fn registry_entries_include_mcp_and_controller_tools() { - let entries = registry_entries(); - - let memory_search = entries - .iter() - .find(|entry| entry.tool_id == "memory.search") - .expect("memory.search mcp tool"); - assert_eq!(memory_search.transport, ToolRegistryTransport::McpStdio); - assert_eq!(memory_search.route["method"], json!("tools/call")); - assert_eq!(memory_search.health, ToolRegistryHealth::Available); - - let web_search = entries - .iter() - .find(|entry| entry.tool_id == "tools.web_search") - .expect("tools.web_search controller tool"); - assert_eq!(web_search.transport, ToolRegistryTransport::JsonRpc); - assert_eq!( - web_search.route["method"], - json!("openhuman.tools_web_search") - ); - assert_eq!(web_search.input_schema["type"], json!("object")); - } - - #[test] - fn registry_entries_are_unique_and_sorted_by_tool_id() { - let entries = registry_entries(); - let ids = entries - .iter() - .map(|entry| entry.tool_id.as_str()) - .collect::>(); - let mut sorted = ids.clone(); - sorted.sort_unstable(); - sorted.dedup(); - - assert_eq!(ids, sorted); - } - - #[test] - fn diagnostics_reports_inventory_and_policy_surfaces() { - let outcome = diagnostics_for_config(&Config::default()); - - assert!(outcome.value.total_tools > 0); - assert_eq!(outcome.value.total_tools, outcome.value.enabled_tools); - assert!(outcome.value.mcp_stdio_tools > 0); - assert!(outcome.value.json_rpc_tools > 0); - assert!(outcome - .value - .policy_surfaces - .iter() - .any(|tool_id| tool_id == "security.policy_info")); - assert!(outcome - .value - .possible_write_surfaces - .iter() - .any(|tool_id| tool_id == "tools.composio_execute")); - } - - #[tokio::test] - async fn diagnostics_loads_active_capability_provider_config() { - let _lock = crate::openhuman::config::TEST_ENV_LOCK.lock().unwrap(); - let tmp = tempfile::tempdir().expect("tempdir"); - let _env = EnvRestore::set_path("OPENHUMAN_WORKSPACE", tmp.path()); - std::fs::write( - tmp.path().join("config.toml"), - r#" -[[capability_providers]] -id = "Runtime Provider" -display_name = "Runtime Provider" -trust_state = "trusted" -enabled = true -"#, - ) - .expect("write config"); - - let outcome = diagnostics().await.expect("diagnostics"); - - assert_eq!(outcome.value.capability_providers.total_providers, 1); - assert_eq!(outcome.value.capability_providers.enabled_providers, 1); - assert_eq!(outcome.value.capability_providers.trusted_providers, 1); - assert_eq!( - outcome.value.capability_providers.trusted_enabled_providers, - 1 - ); - assert!(outcome - .value - .capability_providers - .registry_errors - .is_empty()); - } - - #[test] - fn diagnostics_for_config_reports_capability_provider_summary() { - let mut config = Config::default(); - config.capability_providers = vec![ - capability_provider( - "trusted-enabled", - CapabilityProviderTrustState::Trusted, - true, - ), - capability_provider( - "trusted-disabled", - CapabilityProviderTrustState::Trusted, - false, - ), - capability_provider( - "untrusted-enabled", - CapabilityProviderTrustState::Untrusted, - true, - ), - ]; - - let outcome = diagnostics_for_config(&config); - - assert_eq!(outcome.value.capability_providers.total_providers, 3); - assert_eq!(outcome.value.capability_providers.enabled_providers, 2); - assert_eq!(outcome.value.capability_providers.trusted_providers, 2); - assert_eq!( - outcome.value.capability_providers.trusted_enabled_providers, - 1 - ); - assert!(outcome - .value - .capability_providers - .registry_errors - .is_empty()); - } - - #[test] - fn diagnostics_for_config_reports_capability_provider_errors() { - let mut config = Config::default(); - config.capability_providers = vec![ - capability_provider("Acme Tools", CapabilityProviderTrustState::Trusted, true), - capability_provider("acme-tools", CapabilityProviderTrustState::Trusted, true), - ]; - - let outcome = diagnostics_for_config(&config); - - assert_eq!(outcome.value.capability_providers.total_providers, 2); - assert_eq!(outcome.value.capability_providers.enabled_providers, 0); - assert!(outcome.value.capability_providers.registry_errors[0].contains("duplicate")); - assert!(outcome.value.capability_providers.registry_errors[0].contains("acme-tools")); - } - - #[test] - fn looks_write_capable_detects_action_prefixes_and_suffixes() { - assert!(looks_write_capable("user.create")); - assert!(looks_write_capable("create.user")); - assert!(looks_write_capable("tools.composio_execute")); - assert!(!looks_write_capable("tools.search")); - } - - #[test] - fn is_policy_surface_includes_policy_namespaces() { - assert!(is_policy_surface("security.audit_status")); - assert!(is_policy_surface("approval.request")); - assert!(is_policy_surface("tool_registry.diagnostics")); - assert!(!is_policy_surface("tools.web_search")); - } - - #[test] - fn insert_registry_entry_skips_duplicate_tool_id() { - let mut entries = BTreeMap::new(); - let first_entry = ToolRegistryEntry { - tool_id: "duplicate.tool".to_string(), - name: "duplicate.tool".to_string(), - title: "First Entry".to_string(), - description: "First description.".to_string(), - version: REGISTRY_ENTRY_VERSION.to_string(), - transport: ToolRegistryTransport::JsonRpc, - route: json!({}), - input_schema: json!({}), - output_schema: json!({}), - allowed_agents: vec!["*".to_string()], - tags: vec!["test".to_string()], - enabled: true, - health: ToolRegistryHealth::Available, - }; - let second_entry = ToolRegistryEntry { - title: "Second Entry".to_string(), - description: "Second description.".to_string(), - ..first_entry.clone() - }; - - insert_registry_entry(&mut entries, first_entry, "first"); - // Should not panic; first entry is kept, second is silently dropped. - insert_registry_entry(&mut entries, second_entry, "second"); - - assert_eq!(entries.len(), 1); - assert_eq!(entries["duplicate.tool"].title, "First Entry"); - } - - #[test] - fn get_tool_trims_and_returns_exact_entry() { - let outcome = get_tool(" memory.search ").expect("registry lookup"); - assert_eq!(outcome.value.tool_id, "memory.search"); - } - - #[test] - fn get_tool_rejects_blank_id() { - let err = get_tool(" ").expect_err("blank id should fail"); - assert!(err.contains("non-empty")); - } - - #[test] - fn get_tool_reports_unknown_id() { - let err = get_tool("missing.tool").expect_err("unknown id should fail"); - assert!(err.contains("missing.tool")); - } - - #[test] - fn controller_json_schema_marks_required_and_optional_fields() { - let schema = schema_fields_to_json_schema(&[ - FieldSchema { - name: "query", - ty: TypeSchema::String, - comment: "Query text.", - required: true, - }, - FieldSchema { - name: "max_results", - ty: TypeSchema::Option(Box::new(TypeSchema::U64)), - comment: "Optional cap.", - required: false, - }, - ]); - - assert_eq!(schema["required"], json!(["query"])); - assert_eq!(schema["properties"]["query"]["type"], json!("string")); - assert_eq!( - schema["properties"]["max_results"]["anyOf"][0]["type"], - json!("integer") - ); - assert_eq!( - schema["properties"]["max_results"]["description"], - json!("Optional cap.") - ); - } - - fn capability_provider( - id: &str, - trust_state: CapabilityProviderTrustState, - enabled: bool, - ) -> CapabilityProviderConfig { - CapabilityProviderConfig { - id: id.to_string(), - display_name: id.to_string(), - source_uri: None, - source_digest: None, - trust_state, - enabled, - } - } - - struct EnvRestore { - key: &'static str, - previous: Option, - } - - impl EnvRestore { - fn set_path(key: &'static str, value: &std::path::Path) -> Self { - let previous = std::env::var_os(key); - std::env::set_var(key, value); - Self { key, previous } - } - } - - impl Drop for EnvRestore { - fn drop(&mut self) { - match &self.previous { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), - } - } - } -} +#[path = "ops_test.rs"] +mod tests; diff --git a/src/openhuman/tool_registry/ops_test.rs b/src/openhuman/tool_registry/ops_test.rs new file mode 100644 index 0000000000..a94bcb2b7d --- /dev/null +++ b/src/openhuman/tool_registry/ops_test.rs @@ -0,0 +1,281 @@ +use super::*; +use crate::core::{FieldSchema, TypeSchema}; +use crate::openhuman::config::schema::{ + CapabilityProviderConfig, CapabilityProviderTrustState, Config, +}; + +#[test] +fn registry_entries_include_mcp_and_controller_tools() { + let entries = registry_entries(); + + let memory_search = entries + .iter() + .find(|entry| entry.tool_id == "memory.search") + .expect("memory.search mcp tool"); + assert_eq!(memory_search.transport, ToolRegistryTransport::McpStdio); + assert_eq!(memory_search.route["method"], json!("tools/call")); + assert_eq!(memory_search.health, ToolRegistryHealth::Available); + + let web_search = entries + .iter() + .find(|entry| entry.tool_id == "tools.web_search") + .expect("tools.web_search controller tool"); + assert_eq!(web_search.transport, ToolRegistryTransport::JsonRpc); + assert_eq!( + web_search.route["method"], + json!("openhuman.tools_web_search") + ); + assert_eq!(web_search.input_schema["type"], json!("object")); +} + +#[test] +fn registry_entries_are_unique_and_sorted_by_tool_id() { + let entries = registry_entries(); + let ids = entries + .iter() + .map(|entry| entry.tool_id.as_str()) + .collect::>(); + let mut sorted = ids.clone(); + sorted.sort_unstable(); + sorted.dedup(); + + assert_eq!(ids, sorted); +} + +#[test] +fn diagnostics_reports_inventory_and_policy_surfaces() { + let outcome = diagnostics_for_config(&Config::default()); + + assert!(outcome.value.total_tools > 0); + assert_eq!(outcome.value.total_tools, outcome.value.enabled_tools); + assert!(outcome.value.mcp_stdio_tools > 0); + assert!(outcome.value.json_rpc_tools > 0); + assert!(outcome + .value + .policy_surfaces + .iter() + .any(|tool_id| tool_id == "security.policy_info")); + assert!(outcome + .value + .possible_write_surfaces + .iter() + .any(|tool_id| tool_id == "tools.composio_execute")); +} + +#[tokio::test] +async fn diagnostics_loads_active_capability_provider_config() { + let _lock = crate::openhuman::config::TEST_ENV_LOCK.lock().unwrap(); + let tmp = tempfile::tempdir().expect("tempdir"); + let _env = EnvRestore::set_path("OPENHUMAN_WORKSPACE", tmp.path()); + std::fs::write( + tmp.path().join("config.toml"), + r#" +[[capability_providers]] +id = "Runtime Provider" +display_name = "Runtime Provider" +trust_state = "trusted" +enabled = true +"#, + ) + .expect("write config"); + + let outcome = diagnostics().await.expect("diagnostics"); + + assert_eq!(outcome.value.capability_providers.total_providers, 1); + assert_eq!(outcome.value.capability_providers.enabled_providers, 1); + assert_eq!(outcome.value.capability_providers.trusted_providers, 1); + assert_eq!( + outcome.value.capability_providers.trusted_enabled_providers, + 1 + ); + assert!(outcome + .value + .capability_providers + .registry_errors + .is_empty()); +} + +#[test] +fn diagnostics_for_config_reports_capability_provider_summary() { + let mut config = Config::default(); + config.capability_providers = vec![ + capability_provider( + "trusted-enabled", + CapabilityProviderTrustState::Trusted, + true, + ), + capability_provider( + "trusted-disabled", + CapabilityProviderTrustState::Trusted, + false, + ), + capability_provider( + "untrusted-enabled", + CapabilityProviderTrustState::Untrusted, + true, + ), + ]; + + let outcome = diagnostics_for_config(&config); + + assert_eq!(outcome.value.capability_providers.total_providers, 3); + assert_eq!(outcome.value.capability_providers.enabled_providers, 2); + assert_eq!(outcome.value.capability_providers.trusted_providers, 2); + assert_eq!( + outcome.value.capability_providers.trusted_enabled_providers, + 1 + ); + assert!(outcome + .value + .capability_providers + .registry_errors + .is_empty()); +} + +#[test] +fn diagnostics_for_config_reports_capability_provider_errors() { + let mut config = Config::default(); + config.capability_providers = vec![ + capability_provider("Acme Tools", CapabilityProviderTrustState::Trusted, true), + capability_provider("acme-tools", CapabilityProviderTrustState::Trusted, true), + ]; + + let outcome = diagnostics_for_config(&config); + + assert_eq!(outcome.value.capability_providers.total_providers, 2); + assert_eq!(outcome.value.capability_providers.enabled_providers, 0); + assert!(outcome.value.capability_providers.registry_errors[0].contains("duplicate")); + assert!(outcome.value.capability_providers.registry_errors[0].contains("acme-tools")); +} + +#[test] +fn looks_write_capable_detects_action_prefixes_and_suffixes() { + assert!(looks_write_capable("user.create")); + assert!(looks_write_capable("create.user")); + assert!(looks_write_capable("tools.composio_execute")); + assert!(!looks_write_capable("tools.search")); +} + +#[test] +fn is_policy_surface_includes_policy_namespaces() { + assert!(is_policy_surface("security.audit_status")); + assert!(is_policy_surface("approval.request")); + assert!(is_policy_surface("tool_registry.diagnostics")); + assert!(!is_policy_surface("tools.web_search")); +} + +#[test] +fn insert_registry_entry_skips_duplicate_tool_id() { + let mut entries = BTreeMap::new(); + let first_entry = ToolRegistryEntry { + tool_id: "duplicate.tool".to_string(), + name: "duplicate.tool".to_string(), + title: "First Entry".to_string(), + description: "First description.".to_string(), + version: REGISTRY_ENTRY_VERSION.to_string(), + transport: ToolRegistryTransport::JsonRpc, + route: json!({}), + input_schema: json!({}), + output_schema: json!({}), + allowed_agents: vec!["*".to_string()], + tags: vec!["test".to_string()], + enabled: true, + health: ToolRegistryHealth::Available, + }; + let second_entry = ToolRegistryEntry { + title: "Second Entry".to_string(), + description: "Second description.".to_string(), + ..first_entry.clone() + }; + + insert_registry_entry(&mut entries, first_entry, "first"); + // Should not panic; first entry is kept, second is silently dropped. + insert_registry_entry(&mut entries, second_entry, "second"); + + assert_eq!(entries.len(), 1); + assert_eq!(entries["duplicate.tool"].title, "First Entry"); +} + +#[test] +fn get_tool_trims_and_returns_exact_entry() { + let outcome = get_tool(" memory.search ").expect("registry lookup"); + assert_eq!(outcome.value.tool_id, "memory.search"); +} + +#[test] +fn get_tool_rejects_blank_id() { + let err = get_tool(" ").expect_err("blank id should fail"); + assert!(err.contains("non-empty")); +} + +#[test] +fn get_tool_reports_unknown_id() { + let err = get_tool("missing.tool").expect_err("unknown id should fail"); + assert!(err.contains("missing.tool")); +} + +#[test] +fn controller_json_schema_marks_required_and_optional_fields() { + let schema = schema_fields_to_json_schema(&[ + FieldSchema { + name: "query", + ty: TypeSchema::String, + comment: "Query text.", + required: true, + }, + FieldSchema { + name: "max_results", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Optional cap.", + required: false, + }, + ]); + + assert_eq!(schema["required"], json!(["query"])); + assert_eq!(schema["properties"]["query"]["type"], json!("string")); + assert_eq!( + schema["properties"]["max_results"]["anyOf"][0]["type"], + json!("integer") + ); + assert_eq!( + schema["properties"]["max_results"]["description"], + json!("Optional cap.") + ); +} + +fn capability_provider( + id: &str, + trust_state: CapabilityProviderTrustState, + enabled: bool, +) -> CapabilityProviderConfig { + CapabilityProviderConfig { + id: id.to_string(), + display_name: id.to_string(), + source_uri: None, + source_digest: None, + trust_state, + enabled, + } +} + +struct EnvRestore { + key: &'static str, + previous: Option, +} + +impl EnvRestore { + fn set_path(key: &'static str, value: &std::path::Path) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } +} + +impl Drop for EnvRestore { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } +} From 964bb8efa68797e8e01b0d04134412867978866a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Tue, 26 May 2026 07:45:39 +0800 Subject: [PATCH 5/5] chore: rerun cancelled windows ci