mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
co-authored by
Steven Enamakel
sanil-23
Claude
parent
e7bc3c9fd9
commit
b748b17cfd
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) = ®istry {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user