From 05a9c522178243337d0b299280065c2b6dd9b9b2 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 16 Jun 2026 01:18:54 +0900 Subject: [PATCH] feat: register plugin tool surfaces --- crates/llm-worker/src/tool.rs | 34 +++ crates/manifest/src/config.rs | 8 + crates/manifest/src/lib.rs | 3 + crates/manifest/src/plugin.rs | 38 ++- crates/pod/src/controller.rs | 118 ++++---- crates/pod/src/feature.rs | 25 +- crates/pod/src/feature/plugin.rs | 490 +++++++++++++++++++++++++++++++ crates/pod/src/pod.rs | 3 +- 8 files changed, 660 insertions(+), 59 deletions(-) create mode 100644 crates/pod/src/feature/plugin.rs diff --git a/crates/llm-worker/src/tool.rs b/crates/llm-worker/src/tool.rs index 231c645d..e90ffb12 100644 --- a/crates/llm-worker/src/tool.rs +++ b/crates/llm-worker/src/tool.rs @@ -127,6 +127,31 @@ impl From for ToolOutput { // ToolMeta - Immutable Meta Information // ============================================================================= +/// Origin metadata for a registered tool. +/// +/// This metadata is intentionally not part of the provider-facing tool schema. +/// It lets host layers audit where a model-visible tool definition came from +/// while keeping execution and permission semantics in the normal Worker path. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolOrigin { + /// Origin kind, for example `plugin` or `builtin`. + pub kind: String, + /// Package-local plugin id. + pub plugin_id: String, + /// Source-qualified plugin/package reference when `kind == "plugin"`. + pub plugin_ref: String, + /// Plugin source such as `user`, `project`, or `builtin`. + pub source: String, + /// Resolved package digest. + pub digest: String, + /// Resolved package version. + pub package_version: String, + /// Plugin API/schema version declared by the package. + pub package_api_version: u32, + /// Surface that contributed this tool. Plugin tools use `tool`. + pub surface: String, +} + /// Tool meta information (fixed at registration, immutable) /// /// Generated from `ToolDefinition` factory and does not change after registration with Worker. @@ -139,6 +164,8 @@ pub struct ToolMeta { pub description: String, /// JSON Schema for arguments pub input_schema: Value, + /// Optional host-side origin metadata. This is not exposed to the LLM. + pub origin: Option, } impl ToolMeta { @@ -148,6 +175,7 @@ impl ToolMeta { name: name.into(), description: String::new(), input_schema: Value::Object(Default::default()), + origin: None, } } @@ -162,6 +190,12 @@ impl ToolMeta { self.input_schema = schema; self } + + /// Set host-side origin metadata. + pub fn origin(mut self, origin: ToolOrigin) -> Self { + self.origin = Some(origin); + self + } } // ============================================================================= diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index bea5ae8c..1137e71c 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -84,6 +84,8 @@ pub struct FeatureConfigPartial { pub ticket: Option, #[serde(default)] pub ticket_orchestration: Option, + #[serde(default)] + pub plugins: Option, } impl FeatureConfigPartial { @@ -99,6 +101,7 @@ impl FeatureConfigPartial { other.ticket_orchestration, FeatureFlagConfigPartial::merge, ), + plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge), } } } @@ -152,6 +155,10 @@ impl From for FeatureConfig { .ticket_orchestration .map(FeatureFlagConfig::from) .unwrap_or_default(), + plugins: value + .plugins + .map(FeatureFlagConfig::from) + .unwrap_or_default(), } } } @@ -199,6 +206,7 @@ impl From for FeatureConfigPartial { pods: Some(value.pods.into()), ticket: Some(value.ticket.into()), ticket_orchestration: Some(value.ticket_orchestration.into()), + plugins: Some(value.plugins.into()), } } } diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 707253dd..19319f95 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -107,6 +107,8 @@ pub struct FeatureConfig { pub ticket: TicketFeatureConfig, #[serde(default)] pub ticket_orchestration: FeatureFlagConfig, + #[serde(default)] + pub plugins: FeatureFlagConfig, } impl Default for FeatureConfig { @@ -118,6 +120,7 @@ impl Default for FeatureConfig { pods: FeatureFlagConfig::disabled(), ticket: TicketFeatureConfig::default(), ticket_orchestration: FeatureFlagConfig::disabled(), + plugins: FeatureFlagConfig::disabled(), } } } diff --git a/crates/manifest/src/plugin.rs b/crates/manifest/src/plugin.rs index 431d919a..d4460e8e 100644 --- a/crates/manifest/src/plugin.rs +++ b/crates/manifest/src/plugin.rs @@ -188,14 +188,19 @@ pub struct PluginPackageManifest { pub runtime: Option, #[serde(default)] pub hooks: Vec, + #[serde(default)] + pub tools: Vec, } impl PluginPackageManifest { - fn declared_surfaces(&self) -> BTreeSet { + pub fn declared_surfaces(&self) -> BTreeSet { let mut surfaces: BTreeSet<_> = self.surfaces.iter().copied().collect(); if !self.hooks.is_empty() { surfaces.insert(PluginSurface::Hook); } + if !self.tools.is_empty() { + surfaces.insert(PluginSurface::Tool); + } if self.runtime.is_some() { surfaces.insert(PluginSurface::Wasm); } @@ -218,6 +223,14 @@ pub struct PluginHookManifest { pub file: String, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginToolManifest { + pub name: String, + pub description: String, + pub input_schema: serde_json::Value, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PluginDiscoveryLimits { pub max_packages_per_store: usize, @@ -1653,6 +1666,29 @@ file = "hooks/summary.md" ); } + #[test] + fn package_manifest_tool_surface_shape_is_accepted() { + let manifest: PluginPackageManifest = toml::from_str( + r#" +schema_version = 1 +id = "example.tool" +name = "Example Tool" +version = "0.1.0" + +[[tools]] +name = "ExampleTool" +description = "Runs a package-defined tool." +input_schema = { type = "object", properties = { query = { type = "string" } }, required = ["query"], additionalProperties = false } +"#, + ) + .unwrap(); + + assert_eq!(manifest.tools.len(), 1); + assert!(manifest.declared_surfaces().contains(&PluginSurface::Tool)); + assert_eq!(manifest.tools[0].name, "ExampleTool"); + assert_eq!(manifest.tools[0].input_schema["type"], "object"); + } + #[test] fn malformed_manifest_multibyte_diagnostic_is_bounded_and_redacted() { let temp = TempDir::new().unwrap(); diff --git a/crates/pod/src/controller.rs b/crates/pod/src/controller.rs index fbb09bf2..cc8b5b06 100644 --- a/crates/pod/src/controller.rs +++ b/crates/pod/src/controller.rs @@ -634,66 +634,74 @@ where ), ); } - let _feature_install_report = pod.install_features(feature_registry); - - let worker = pod.worker_mut(); - - // Memory tools require both explicit feature exposure and memory storage - // configuration. This keeps resident-memory config separate from the - // model-visible Memory*/Knowledge* tool surface. - if feature_config.memory.enabled { - let mem = memory_config.as_ref().ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "[feature.memory].enabled = true requires a [memory] configuration section", - ) - })?; - let layout = memory::WorkspaceLayout::resolve(mem, &workspace_root); - let query_cfg = memory::tool::QueryConfig::from(mem); - worker.register_tool(memory::tool::read_tool_with_usage( - layout.clone(), - session_id_for_usage, - )); - worker.register_tool(memory::tool::write_tool(layout.clone())); - worker.register_tool(memory::tool::edit_tool(layout.clone())); - worker.register_tool(memory::tool::delete_tool(layout.clone())); - worker.register_tool(memory::tool::memory_query_tool(layout.clone(), query_cfg)); - worker.register_tool(memory::tool::knowledge_query_tool(layout, query_cfg)); + for module in crate::feature::plugin::plugin_tool_features_if_enabled( + feature_config.plugins.enabled, + &pod.manifest().plugins, + ) { + feature_registry = feature_registry.with_module(module); } - // Pod-orchestration tools (SpawnPod + the four comm tools) share - // the Pod-scoped `SpawnedPodRegistry` (also consumed by the main - // loop's `PodEvent` handler). Expose them only behind the explicit - // profile feature and require delegation authority up front so enabling - // the surface cannot imply broad child scope by accident. - if feature_config.pods.enabled { - if spawner_manifest.delegation_scope.allow.is_empty() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "[feature.pods].enabled = true requires non-empty [[delegation_scope.allow]]", + { + let worker = pod.worker_mut(); + + // Memory tools require both explicit feature exposure and memory storage + // configuration. This keeps resident-memory config separate from the + // model-visible Memory*/Knowledge* tool surface. + if feature_config.memory.enabled { + let mem = memory_config.as_ref().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "[feature.memory].enabled = true requires a [memory] configuration section", + ) + })?; + let layout = memory::WorkspaceLayout::resolve(mem, &workspace_root); + let query_cfg = memory::tool::QueryConfig::from(mem); + worker.register_tool(memory::tool::read_tool_with_usage( + layout.clone(), + session_id_for_usage, )); + worker.register_tool(memory::tool::write_tool(layout.clone())); + worker.register_tool(memory::tool::edit_tool(layout.clone())); + worker.register_tool(memory::tool::delete_tool(layout.clone())); + worker.register_tool(memory::tool::memory_query_tool(layout.clone(), query_cfg)); + worker.register_tool(memory::tool::knowledge_query_tool(layout, query_cfg)); + } + + // Pod-orchestration tools (SpawnPod + the four comm tools) share + // the Pod-scoped `SpawnedPodRegistry` (also consumed by the main + // loop's `PodEvent` handler). Expose them only behind the explicit + // profile feature and require delegation authority up front so enabling + // the surface cannot imply broad child scope by accident. + if feature_config.pods.enabled { + if spawner_manifest.delegation_scope.allow.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "[feature.pods].enabled = true requires non-empty [[delegation_scope.allow]]", + )); + } + worker.register_tool(spawn_pod_tool( + spawner_name.clone(), + spawner_socket, + runtime_base.clone(), + workspace_root.clone(), + cwd.clone(), + spawned_registry.clone(), + self_parent_socket, + spawner_manifest, + scope_handle, + prompts, + )); + worker.register_tool(send_to_pod_tool(spawned_registry.clone())); + worker.register_tool(read_pod_output_tool(spawned_registry.clone())); + worker.register_tool(stop_pod_tool(spawned_registry.clone())); + let discovery = + PodDiscovery::new(pod_store, spawner_name, runtime_base, cwd, spawned_registry); + worker.register_tool(list_pods_tool(discovery.clone())); + worker.register_tool(restore_pod_tool(discovery.clone())); + worker.register_tool(send_to_peer_pod_tool(discovery)); } - worker.register_tool(spawn_pod_tool( - spawner_name.clone(), - spawner_socket, - runtime_base.clone(), - workspace_root.clone(), - cwd.clone(), - spawned_registry.clone(), - self_parent_socket, - spawner_manifest, - scope_handle, - prompts, - )); - worker.register_tool(send_to_pod_tool(spawned_registry.clone())); - worker.register_tool(read_pod_output_tool(spawned_registry.clone())); - worker.register_tool(stop_pod_tool(spawned_registry.clone())); - let discovery = - PodDiscovery::new(pod_store, spawner_name, runtime_base, cwd, spawned_registry); - worker.register_tool(list_pods_tool(discovery.clone())); - worker.register_tool(restore_pod_tool(discovery.clone())); - worker.register_tool(send_to_peer_pod_tool(discovery)); } + let _feature_install_report = pod.install_features(feature_registry); pod.attach_tracker(tracker); Ok(fs_for_view) } diff --git a/crates/pod/src/feature.rs b/crates/pod/src/feature.rs index b6bbf8fb..924065c5 100644 --- a/crates/pod/src/feature.rs +++ b/crates/pod/src/feature.rs @@ -1290,15 +1290,36 @@ impl FeatureRegistryBuilder { hook_builder: &mut HookRegistryBuilder, ) -> FeatureRegistryInstallReport { let mut pending_tools = Vec::new(); - let report = self.install_into_pending(&mut pending_tools, hook_builder); + worker.tool_server_handle().flush_pending(); + let registered_tool_names = worker + .tool_server_handle() + .tool_definitions_sorted() + .into_iter() + .map(|definition| (definition.name, FeatureId::builtin("preexisting-tool"))) + .collect(); + let report = self.install_into_pending_with_registered( + &mut pending_tools, + hook_builder, + registered_tool_names, + ); worker.register_tools(pending_tools); report } + #[allow(dead_code)] pub(crate) fn install_into_pending( self, pending_tools: &mut Vec, hook_builder: &mut HookRegistryBuilder, + ) -> FeatureRegistryInstallReport { + self.install_into_pending_with_registered(pending_tools, hook_builder, HashMap::new()) + } + + fn install_into_pending_with_registered( + self, + pending_tools: &mut Vec, + hook_builder: &mut HookRegistryBuilder, + mut installed_tool_names: HashMap, ) -> FeatureRegistryInstallReport { let descriptors: Vec<_> = self .modules @@ -1307,7 +1328,6 @@ impl FeatureRegistryBuilder { .collect(); let mut service_registry = FeatureServiceRegistry::default(); let mut reports = Vec::with_capacity(self.modules.len()); - let mut installed_tool_names = HashMap::new(); let mut seen_features = HashSet::new(); for (module, descriptor) in self.modules.into_iter().zip(descriptors.into_iter()) { @@ -1455,6 +1475,7 @@ pub enum FeatureInstallError { } pub mod builtin; +pub mod plugin; #[cfg(test)] mod tests { diff --git a/crates/pod/src/feature/plugin.rs b/crates/pod/src/feature/plugin.rs new file mode 100644 index 00000000..6133289c --- /dev/null +++ b/crates/pod/src/feature/plugin.rs @@ -0,0 +1,490 @@ +//! Plugin package contributions for model-visible Tool schemas. +//! +//! This module registers *enabled* plugin package tool surface definitions as +//! unavailable Tool stubs. It deliberately does not execute plugin code or grant +//! plugin permissions; the runtime/WASM executor belongs to a later boundary. + +use std::collections::HashSet; +use std::sync::Arc; + +use async_trait::async_trait; +use llm_worker::tool::{ + Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput, +}; +use manifest::plugin::{PluginConfig, PluginSurface, ResolvedPluginRecord}; +use serde_json::Value; + +use super::{ + FeatureDescriptor, FeatureId, FeatureInstallContext, FeatureInstallError, FeatureModule, + FeatureRuntimeKind, ToolContribution, ToolDeclaration, +}; + +/// Build Feature modules for enabled plugin packages when the profile exposes +/// the plugin Tool surface feature. +pub fn plugin_tool_features_if_enabled( + feature_enabled: bool, + config: &PluginConfig, +) -> Vec { + if !feature_enabled { + return Vec::new(); + } + plugin_tool_features(config) +} + +/// Build Feature modules for enabled plugin packages that declare Tool surfaces. +pub fn plugin_tool_features(config: &PluginConfig) -> Vec { + config + .resolved + .iter() + .filter(|record| record.enabled_surfaces.contains(&PluginSurface::Tool)) + .filter(|record| !record.manifest.tools.is_empty()) + .cloned() + .map(PluginToolFeature::new) + .collect() +} + +#[derive(Clone, Debug)] +pub struct PluginToolFeature { + record: ResolvedPluginRecord, + feature_id: FeatureId, +} + +impl PluginToolFeature { + pub fn new(record: ResolvedPluginRecord) -> Self { + let feature_id = FeatureId::new(format!("plugin:{}:tool", record.identity)) + .expect("source-qualified plugin identity yields non-empty feature id"); + Self { record, feature_id } + } + + pub fn origin(&self) -> ToolOrigin { + ToolOrigin { + kind: "plugin".into(), + plugin_id: self.record.manifest.id.clone(), + plugin_ref: self.record.identity.to_string(), + source: self.record.identity.source.to_string(), + digest: self.record.digest.clone(), + package_version: self.record.version.clone(), + package_api_version: self.record.manifest.schema_version, + surface: "tool".into(), + } + } +} + +impl FeatureModule for PluginToolFeature { + fn descriptor(&self) -> FeatureDescriptor { + let mut descriptor = + FeatureDescriptor { + id: self.feature_id.clone(), + runtime: FeatureRuntimeKind::ExternalPlugin, + display_name: self.record.manifest.name.clone(), + version: self.record.manifest.version.clone(), + description: self.record.manifest.description.clone().unwrap_or_else(|| { + format!("Plugin tool surface from {}", self.record.identity) + }), + tools: Vec::new(), + hooks: Vec::new(), + background_tasks: Vec::new(), + provides_services: Vec::new(), + requires_services: Vec::new(), + protocol_providers: Vec::new(), + }; + for tool in &self.record.manifest.tools { + descriptor = descriptor.with_tool(ToolDeclaration::new( + tool.name.clone(), + tool.description.clone(), + )); + } + descriptor + } + + fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + validate_declared_tool_names(&self.record)?; + let origin = self.origin(); + for tool in &self.record.manifest.tools { + validate_tool_name(&tool.name).map_err(|reason| { + FeatureInstallError::Install(format!( + "plugin `{}` tool `{}` has invalid name: {reason}", + self.record.identity, tool.name + )) + })?; + validate_input_schema(&tool.input_schema).map_err(|reason| { + FeatureInstallError::Install(format!( + "plugin `{}` tool `{}` has invalid input_schema: {reason}", + self.record.identity, tool.name + )) + })?; + context.tools().register(ToolContribution::new( + tool.name.clone(), + plugin_runtime_missing_definition( + tool.name.clone(), + tool.description.clone(), + tool.input_schema.clone(), + origin.clone(), + ), + ))?; + } + Ok(()) + } +} + +fn plugin_runtime_missing_definition( + name: String, + description: String, + input_schema: Value, + origin: ToolOrigin, +) -> ToolDefinition { + Arc::new(move || { + ( + ToolMeta::new(name.clone()) + .description(description.clone()) + .input_schema(input_schema.clone()) + .origin(origin.clone()), + Arc::new(PluginRuntimeMissingTool { + name: name.clone(), + origin: origin.clone(), + }) as Arc, + ) + }) +} + +struct PluginRuntimeMissingTool { + name: String, + origin: ToolOrigin, +} + +#[async_trait] +impl Tool for PluginRuntimeMissingTool { + async fn execute( + &self, + _input_json: &str, + _ctx: ToolExecutionContext, + ) -> Result { + Err(ToolError::ExecutionFailed(format!( + "plugin tool runtime missing/unavailable for `{}` from `{}` (digest {}, package {} api {})", + self.name, + self.origin.plugin_ref, + self.origin.digest, + self.origin.package_version, + self.origin.package_api_version + ))) + } +} + +fn validate_declared_tool_names(record: &ResolvedPluginRecord) -> Result<(), FeatureInstallError> { + let mut seen = HashSet::new(); + for tool in &record.manifest.tools { + if !seen.insert(tool.name.as_str()) { + return Err(FeatureInstallError::DuplicateToolName { + tool: tool.name.clone(), + first_feature: format!("{} (same plugin package)", record.identity), + duplicate_feature: record.identity.to_string(), + }); + } + } + Ok(()) +} + +fn validate_tool_name(name: &str) -> Result<(), &'static str> { + if name.is_empty() { + return Err("name must not be empty"); + } + if name.len() > 128 { + return Err("name is longer than 128 bytes"); + } + if name.chars().any(|c| c.is_control() || c.is_whitespace()) { + return Err("name must not contain whitespace or control characters"); + } + Ok(()) +} + +fn validate_input_schema(schema: &Value) -> Result<(), String> { + let Value::Object(root) = schema else { + return Err("root schema must be a JSON object".into()); + }; + match root.get("type") { + Some(Value::String(value)) if value == "object" => {} + Some(_) => return Err("root schema type must be `object`".into()), + None => return Err("root schema must declare type = `object`".into()), + } + if let Some(properties) = root.get("properties") { + if !properties.is_object() { + return Err("properties must be a JSON object".into()); + } + } + if let Some(required) = root.get("required") { + let Some(required) = required.as_array() else { + return Err("required must be an array".into()); + }; + if !required.iter().all(Value::is_string) { + return Err("required entries must be strings".into()); + } + } + if let Some(additional) = root.get("additionalProperties") { + if !(additional.is_boolean() || additional.is_object()) { + return Err("additionalProperties must be boolean or object".into()); + } + } + reject_unsupported_keywords(schema) +} + +fn reject_unsupported_keywords(schema: &Value) -> Result<(), String> { + match schema { + Value::Object(map) => { + for (key, value) in map { + if matches!( + key.as_str(), + "$ref" + | "$dynamicRef" + | "oneOf" + | "anyOf" + | "allOf" + | "not" + | "patternProperties" + | "dependentSchemas" + | "dependencies" + ) { + return Err(format!("unsupported schema keyword `{key}`")); + } + reject_unsupported_keywords(value)?; + } + Ok(()) + } + Value::Array(values) => { + for value in values { + reject_unsupported_keywords(value)?; + } + Ok(()) + } + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use manifest::plugin::{PluginPackageManifest, SourceQualifiedPluginId}; + use serde_json::json; + + fn tool(name: &str) -> manifest::plugin::PluginToolManifest { + manifest::plugin::PluginToolManifest { + name: name.into(), + description: format!("{name} tool"), + input_schema: json!({"type":"object","properties":{},"additionalProperties":false}), + } + } + + fn record(tools: Vec) -> ResolvedPluginRecord { + record_with_identity("project:example", tools) + } + + fn record_with_identity( + identity: &str, + tools: Vec, + ) -> ResolvedPluginRecord { + let parsed_identity = SourceQualifiedPluginId::parse(identity).unwrap(); + ResolvedPluginRecord { + identity: parsed_identity.clone(), + source: parsed_identity.source, + package_path: std::path::PathBuf::from("/tmp/example.zip"), + package_label: "example.zip".into(), + digest: "sha256:abc".into(), + version: "0.1.0".into(), + manifest: PluginPackageManifest { + schema_version: 1, + id: "example".into(), + name: "Example".into(), + version: "0.1.0".into(), + description: None, + surfaces: vec![PluginSurface::Tool], + runtime: None, + hooks: Vec::new(), + tools, + }, + enabled_surfaces: vec![PluginSurface::Tool], + grants: manifest::plugin::PluginGrantConfig::default(), + config: None, + } + } + + fn skipped_count(report: &super::super::FeatureRegistryInstallReport) -> usize { + report + .reports + .iter() + .map(|feature_report| feature_report.skipped.len()) + .sum() + } + + fn has_diagnostic(report: &super::super::FeatureRegistryInstallReport, needle: &str) -> bool { + report.reports.iter().any(|feature_report| { + feature_report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains(needle)) + }) + } + + #[test] + fn rejects_invalid_root_schema() { + let schema = json!({"type":"string"}); + assert!( + validate_input_schema(&schema) + .unwrap_err() + .contains("type must be `object`") + ); + } + + #[test] + fn rejects_unsupported_schema_keyword() { + let schema = json!({"type":"object","oneOf":[]}); + assert!( + validate_input_schema(&schema) + .unwrap_err() + .contains("unsupported schema keyword") + ); + } + + #[test] + fn accepts_object_tool_schema() { + validate_input_schema(&json!({ + "type":"object", + "properties":{"query":{"type":"string"}}, + "required":["query"], + "additionalProperties":false + })) + .unwrap(); + } + + #[test] + fn origin_retains_plugin_metadata() { + let feature = PluginToolFeature::new(record(Vec::new())); + let origin = feature.origin(); + assert_eq!(origin.kind, "plugin"); + assert_eq!(origin.plugin_id, "example"); + assert_eq!(origin.plugin_ref, "project:example"); + assert_eq!(origin.source, "project"); + assert_eq!(origin.digest, "sha256:abc"); + assert_eq!(origin.package_version, "0.1.0"); + assert_eq!(origin.package_api_version, 1); + assert_eq!(origin.surface, "tool"); + } + + #[test] + fn enabled_plugin_tool_registers_model_visible_schema_and_origin() { + let mut pending = Vec::new(); + let mut hooks = crate::hook::HookRegistryBuilder::new(); + let report = super::super::FeatureRegistryBuilder::default() + .with_module(PluginToolFeature::new(record(vec![tool("PluginSearch")]))) + .install_into_pending(&mut pending, &mut hooks); + + assert!( + report + .reports + .iter() + .all(|feature_report| feature_report.diagnostics.is_empty()), + "{:#?}", + report.reports + ); + assert_eq!(report.installed_tool_names(), vec!["PluginSearch"]); + assert_eq!(pending.len(), 1); + let (meta, _) = pending[0](); + assert_eq!(meta.name, "PluginSearch"); + assert_eq!(meta.input_schema["type"], "object"); + let origin = meta.origin.expect("plugin origin metadata"); + assert_eq!(origin.plugin_ref, "project:example"); + assert_eq!(origin.digest, "sha256:abc"); + assert_eq!(origin.source, "project"); + assert_eq!(origin.surface, "tool"); + } + + #[test] + fn package_without_enabled_tool_surface_registers_no_schema() { + let mut config = PluginConfig::default(); + let mut disabled = record(vec![tool("PluginSearch")]); + disabled.enabled_surfaces.clear(); + config.resolved.push(disabled); + + assert!(plugin_tool_features(&config).is_empty()); + } + + #[test] + fn disabled_profile_feature_registers_no_schema() { + let mut config = PluginConfig::default(); + config.resolved.push(record(vec![tool("PluginSearch")])); + + assert!(plugin_tool_features_if_enabled(false, &config).is_empty()); + assert_eq!(plugin_tool_features_if_enabled(true, &config).len(), 1); + } + + #[test] + fn duplicate_plugin_tool_names_are_rejected_with_diagnostic() { + let mut pending = Vec::new(); + let mut hooks = crate::hook::HookRegistryBuilder::new(); + let report = super::super::FeatureRegistryBuilder::default() + .with_module(PluginToolFeature::new(record(vec![tool("PluginSearch")]))) + .with_module(PluginToolFeature::new(record_with_identity( + "project:other", + vec![tool("PluginSearch")], + ))) + .install_into_pending(&mut pending, &mut hooks); + + assert_eq!(pending.len(), 1); + assert_eq!(skipped_count(&report), 1); + assert!(has_diagnostic(&report, "duplicate tool contribution")); + } + + #[test] + fn builtin_tool_name_collision_is_rejected_with_diagnostic() { + let mut pending = Vec::new(); + let mut hooks = crate::hook::HookRegistryBuilder::new(); + let mut registered = std::collections::HashMap::new(); + registered.insert("Read".to_string(), FeatureId::builtin("preexisting-tool")); + + let report = super::super::FeatureRegistryBuilder::default() + .with_module(PluginToolFeature::new(record(vec![tool("Read")]))) + .install_into_pending_with_registered(&mut pending, &mut hooks, registered); + + assert!(pending.is_empty()); + assert_eq!(skipped_count(&report), 1); + assert!(has_diagnostic(&report, "duplicate tool contribution")); + } + + #[test] + fn invalid_input_schema_is_rejected_with_diagnostic() { + let mut invalid = tool("BadSchema"); + invalid.input_schema = json!({"type":"object","$ref":"#/defs/input"}); + let mut pending = Vec::new(); + let mut hooks = crate::hook::HookRegistryBuilder::new(); + + let report = super::super::FeatureRegistryBuilder::default() + .with_module(PluginToolFeature::new(record(vec![invalid]))) + .install_into_pending(&mut pending, &mut hooks); + + assert!(pending.is_empty()); + assert!(has_diagnostic(&report, "invalid input_schema")); + } + + #[tokio::test] + async fn registered_tool_executes_as_runtime_missing_error() { + let mut pending = Vec::new(); + let mut hooks = crate::hook::HookRegistryBuilder::new(); + let report = super::super::FeatureRegistryBuilder::default() + .with_module(PluginToolFeature::new(record(vec![tool("PluginSearch")]))) + .install_into_pending(&mut pending, &mut hooks); + assert!( + report + .reports + .iter() + .all(|feature_report| feature_report.diagnostics.is_empty()), + "{:#?}", + report.reports + ); + + let (_, tool) = pending[0](); + let error = tool + .execute("{}", ToolExecutionContext::default()) + .await + .unwrap_err(); + assert!(error.to_string().contains("runtime missing/unavailable")); + assert!(error.to_string().contains("project:example")); + } +} diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index fe155b63..b34845b7 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -824,7 +824,6 @@ impl Pod { registry: FeatureRegistryBuilder, ) -> FeatureRegistryInstallReport { let worker = self.worker.as_mut().expect("worker taken during run"); - let report = registry.install_into_worker(worker, &mut self.hook_builder); let active_workflow_committer = self.log_writer.clone().map(|writer| { Arc::new(move |entry| writer.commit_log_entry(entry)) as active_workflow::LogEntryCommitter @@ -833,6 +832,7 @@ impl Pod { self.active_workflows.clone(), active_workflow_committer, )); + let report = registry.install_into_worker(worker, &mut self.hook_builder); report } @@ -5353,6 +5353,7 @@ permission = "read" surfaces: vec![manifest::plugin::PluginSurface::Hook], runtime: None, hooks: vec![], + tools: vec![], }, enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook], grants: manifest::plugin::PluginGrantConfig::default(),