feat(tool-registry): add trusted capability providers (#2551)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
Co-authored-by: sanil-23 <sanil@vezures.xyz>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
YOMXXX
2026-05-28 20:42:06 +05:30
committed by GitHub
co-authored by Steven Enamakel sanil-23 Claude
parent e7bc3c9fd9
commit b748b17cfd
13 changed files with 890 additions and 164 deletions
+23
View File
@@ -142,11 +142,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
+17
View File
@@ -114,9 +114,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
+2 -2
View File
@@ -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`.
+2 -1
View File
@@ -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,
@@ -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<String>,
/// Optional source digest, for example `sha256:<hex>`.
pub source_digest: Option<String>,
/// 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
}
}
+2
View File
@@ -11,6 +11,7 @@ mod accessibility;
mod agent;
mod autocomplete;
mod autonomy;
mod capability_providers;
mod channels;
mod context;
mod defaults;
@@ -45,6 +46,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,
+30
View File
@@ -186,6 +186,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<CapabilityProviderConfig>,
#[serde(default)]
pub multimodal: MultimodalConfig,
@@ -639,6 +644,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(),
@@ -749,6 +755,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();
+9 -2
View File
@@ -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,
};
+43 -158
View File
@@ -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,
@@ -34,7 +36,19 @@ pub fn list_tools() -> RpcOutcome<ToolRegistryList> {
}
/// Return redacted diagnostics for policy/tool visibility reviews.
pub fn diagnostics() -> RpcOutcome<ToolPolicyDiagnostics> {
pub async fn diagnostics() -> Result<RpcOutcome<ToolPolicyDiagnostics>, 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.
pub fn diagnostics_for_config(config: &Config) -> RpcOutcome<ToolPolicyDiagnostics> {
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();
@@ -52,6 +66,17 @@ pub fn diagnostics() -> RpcOutcome<ToolPolicyDiagnostics> {
.map(|entry| entry.tool_id.clone())
.collect::<Vec<_>>();
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,
@@ -60,7 +85,22 @@ pub fn diagnostics() -> RpcOutcome<ToolPolicyDiagnostics> {
json_rpc_tools,
possible_write_surfaces,
policy_surfaces,
capability_providers,
};
log::debug!(
"[tool_registry] diagnostics_for_config completed total_tools={} enabled_tools={} mcp_stdio_tools={} json_rpc_tools={} possible_write_surfaces={} policy_surfaces={} providers_total={} providers_enabled={} providers_trusted={} providers_trusted_enabled={} provider_errors={}",
diagnostics.total_tools,
diagnostics.enabled_tools,
diagnostics.mcp_stdio_tools,
diagnostics.json_rpc_tools,
diagnostics.possible_write_surfaces.len(),
diagnostics.policy_surfaces.len(),
diagnostics.capability_providers.total_providers,
diagnostics.capability_providers.enabled_providers,
diagnostics.capability_providers.trusted_providers,
diagnostics.capability_providers.trusted_enabled_providers,
diagnostics.capability_providers.registry_errors.len()
);
RpcOutcome::new(diagnostics, vec![])
}
@@ -382,160 +422,5 @@ fn title_from_function(function: &str) -> String {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{FieldSchema, TypeSchema};
#[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::<Vec<_>>();
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();
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"));
}
#[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.")
);
}
}
#[path = "ops_test.rs"]
mod tests;
+287
View File
@@ -0,0 +1,287 @@
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::<Vec<_>>();
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_or_else(|e| e.into_inner());
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 config = 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,
),
],
..Config::default()
};
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 config = Config {
capability_providers: vec![
capability_provider("Acme Tools", CapabilityProviderTrustState::Trusted, true),
capability_provider("acme-tools", CapabilityProviderTrustState::Trusted, true),
],
..Config::default()
};
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<std::ffi::OsString>,
}
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),
}
}
}
+408
View File
@@ -0,0 +1,408 @@
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<String>,
pub source_digest: Option<String>,
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<String, CapabilityProviderMetadata>,
}
impl CapabilityProviderRegistry {
/// Build a normalized provider registry from the current config snapshot.
pub fn from_config(config: &Config) -> Result<Self, CapabilityProviderRegistryError> {
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 = 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 = 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,
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);
}
log::debug!(
"[tool_registry] capability_provider_registry completed providers={}",
providers.len()
);
Ok(Self { providers })
}
/// Return all providers sorted by normalized id.
pub fn list(&self) -> Vec<CapabilityProviderMetadata> {
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<String, CapabilityProviderRegistryError> {
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(),
});
}
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 {
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)
}
/// Build the configured provider registry.
pub fn capability_provider_registry(
config: &Config,
) -> Result<CapabilityProviderRegistry, CapabilityProviderRegistryError> {
let registry = CapabilityProviderRegistry::from_config(config);
if let Err(err) = &registry {
log::debug!(
"[tool_registry] capability_provider_registry failed configured_providers={} error={}",
config.capability_providers.len(),
err
);
}
registry
}
/// List configured external capability providers.
pub fn list_capability_providers(
config: &Config,
) -> Result<Vec<CapabilityProviderMetadata>, 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<Option<CapabilityProviderMetadata>, 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();
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(),
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) => {
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()
}
}
}
}
#[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<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn clean_string_opt(value: Option<&str>) -> Option<String> {
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<CapabilityProviderConfig>) -> Config {
Config {
capability_providers: providers,
..Config::default()
}
}
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"));
}
}
+3 -1
View File
@@ -110,7 +110,9 @@ fn handle_diagnostics(params: Map<String, Value>) -> ControllerFuture {
"[tool_registry] rpc diagnostics requested param_count={}",
params.len()
);
let result = to_json(crate::openhuman::tool_registry::ops::diagnostics());
let result = crate::openhuman::tool_registry::ops::diagnostics()
.await
.and_then(to_json);
log::debug!(
"[tool_registry] rpc diagnostics completed success={}",
result.is_ok()
+11
View File
@@ -68,4 +68,15 @@ pub struct ToolPolicyDiagnostics {
pub json_rpc_tools: usize,
pub possible_write_surfaces: Vec<String>,
pub policy_surfaces: Vec<String>,
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<String>,
}