merge: plugin tool surface registration

This commit is contained in:
Keisuke Hirata 2026-06-16 01:38:46 +09:00
commit 204d0d022f
No known key found for this signature in database
8 changed files with 841 additions and 59 deletions

View File

@ -127,6 +127,31 @@ impl From<String> for ToolOutput {
// ToolMeta - Immutable Meta Information // 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) /// Tool meta information (fixed at registration, immutable)
/// ///
/// Generated from `ToolDefinition` factory and does not change after registration with Worker. /// Generated from `ToolDefinition` factory and does not change after registration with Worker.
@ -139,6 +164,8 @@ pub struct ToolMeta {
pub description: String, pub description: String,
/// JSON Schema for arguments /// JSON Schema for arguments
pub input_schema: Value, pub input_schema: Value,
/// Optional host-side origin metadata. This is not exposed to the LLM.
pub origin: Option<ToolOrigin>,
} }
impl ToolMeta { impl ToolMeta {
@ -148,6 +175,7 @@ impl ToolMeta {
name: name.into(), name: name.into(),
description: String::new(), description: String::new(),
input_schema: Value::Object(Default::default()), input_schema: Value::Object(Default::default()),
origin: None,
} }
} }
@ -162,6 +190,12 @@ impl ToolMeta {
self.input_schema = schema; self.input_schema = schema;
self self
} }
/// Set host-side origin metadata.
pub fn origin(mut self, origin: ToolOrigin) -> Self {
self.origin = Some(origin);
self
}
} }
// ============================================================================= // =============================================================================

View File

@ -84,6 +84,8 @@ pub struct FeatureConfigPartial {
pub ticket: Option<TicketFeatureConfigPartial>, pub ticket: Option<TicketFeatureConfigPartial>,
#[serde(default)] #[serde(default)]
pub ticket_orchestration: Option<FeatureFlagConfigPartial>, pub ticket_orchestration: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub plugins: Option<FeatureFlagConfigPartial>,
} }
impl FeatureConfigPartial { impl FeatureConfigPartial {
@ -99,6 +101,7 @@ impl FeatureConfigPartial {
other.ticket_orchestration, other.ticket_orchestration,
FeatureFlagConfigPartial::merge, FeatureFlagConfigPartial::merge,
), ),
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
} }
} }
} }
@ -152,6 +155,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
.ticket_orchestration .ticket_orchestration
.map(FeatureFlagConfig::from) .map(FeatureFlagConfig::from)
.unwrap_or_default(), .unwrap_or_default(),
plugins: value
.plugins
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
} }
} }
} }
@ -199,6 +206,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
pods: Some(value.pods.into()), pods: Some(value.pods.into()),
ticket: Some(value.ticket.into()), ticket: Some(value.ticket.into()),
ticket_orchestration: Some(value.ticket_orchestration.into()), ticket_orchestration: Some(value.ticket_orchestration.into()),
plugins: Some(value.plugins.into()),
} }
} }
} }

View File

@ -107,6 +107,8 @@ pub struct FeatureConfig {
pub ticket: TicketFeatureConfig, pub ticket: TicketFeatureConfig,
#[serde(default)] #[serde(default)]
pub ticket_orchestration: FeatureFlagConfig, pub ticket_orchestration: FeatureFlagConfig,
#[serde(default)]
pub plugins: FeatureFlagConfig,
} }
impl Default for FeatureConfig { impl Default for FeatureConfig {
@ -118,6 +120,7 @@ impl Default for FeatureConfig {
pods: FeatureFlagConfig::disabled(), pods: FeatureFlagConfig::disabled(),
ticket: TicketFeatureConfig::default(), ticket: TicketFeatureConfig::default(),
ticket_orchestration: FeatureFlagConfig::disabled(), ticket_orchestration: FeatureFlagConfig::disabled(),
plugins: FeatureFlagConfig::disabled(),
} }
} }
} }

View File

@ -188,14 +188,19 @@ pub struct PluginPackageManifest {
pub runtime: Option<PluginRuntimeManifest>, pub runtime: Option<PluginRuntimeManifest>,
#[serde(default)] #[serde(default)]
pub hooks: Vec<PluginHookManifest>, pub hooks: Vec<PluginHookManifest>,
#[serde(default)]
pub tools: Vec<PluginToolManifest>,
} }
impl PluginPackageManifest { impl PluginPackageManifest {
fn declared_surfaces(&self) -> BTreeSet<PluginSurface> { pub fn declared_surfaces(&self) -> BTreeSet<PluginSurface> {
let mut surfaces: BTreeSet<_> = self.surfaces.iter().copied().collect(); let mut surfaces: BTreeSet<_> = self.surfaces.iter().copied().collect();
if !self.hooks.is_empty() { if !self.hooks.is_empty() {
surfaces.insert(PluginSurface::Hook); surfaces.insert(PluginSurface::Hook);
} }
if !self.tools.is_empty() {
surfaces.insert(PluginSurface::Tool);
}
if self.runtime.is_some() { if self.runtime.is_some() {
surfaces.insert(PluginSurface::Wasm); surfaces.insert(PluginSurface::Wasm);
} }
@ -218,6 +223,14 @@ pub struct PluginHookManifest {
pub file: String, 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)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct PluginDiscoveryLimits { pub struct PluginDiscoveryLimits {
pub max_packages_per_store: usize, 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] #[test]
fn malformed_manifest_multibyte_diagnostic_is_bounded_and_redacted() { fn malformed_manifest_multibyte_diagnostic_is_bounded_and_redacted() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();

View File

@ -634,8 +634,14 @@ where
), ),
); );
} }
let _feature_install_report = pod.install_features(feature_registry); 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);
}
{
let worker = pod.worker_mut(); let worker = pod.worker_mut();
// Memory tools require both explicit feature exposure and memory storage // Memory tools require both explicit feature exposure and memory storage
@ -694,6 +700,8 @@ where
worker.register_tool(restore_pod_tool(discovery.clone())); worker.register_tool(restore_pod_tool(discovery.clone()));
worker.register_tool(send_to_peer_pod_tool(discovery)); worker.register_tool(send_to_peer_pod_tool(discovery));
} }
}
let _feature_install_report = pod.install_features(feature_registry);
pod.attach_tracker(tracker); pod.attach_tracker(tracker);
Ok(fs_for_view) Ok(fs_for_view)
} }

View File

@ -1290,15 +1290,36 @@ impl FeatureRegistryBuilder {
hook_builder: &mut HookRegistryBuilder, hook_builder: &mut HookRegistryBuilder,
) -> FeatureRegistryInstallReport { ) -> FeatureRegistryInstallReport {
let mut pending_tools = Vec::new(); 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); worker.register_tools(pending_tools);
report report
} }
#[allow(dead_code)]
pub(crate) fn install_into_pending( pub(crate) fn install_into_pending(
self, self,
pending_tools: &mut Vec<ToolDefinition>, pending_tools: &mut Vec<ToolDefinition>,
hook_builder: &mut HookRegistryBuilder, 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<ToolDefinition>,
hook_builder: &mut HookRegistryBuilder,
mut installed_tool_names: HashMap<String, FeatureId>,
) -> FeatureRegistryInstallReport { ) -> FeatureRegistryInstallReport {
let descriptors: Vec<_> = self let descriptors: Vec<_> = self
.modules .modules
@ -1307,7 +1328,6 @@ impl FeatureRegistryBuilder {
.collect(); .collect();
let mut service_registry = FeatureServiceRegistry::default(); let mut service_registry = FeatureServiceRegistry::default();
let mut reports = Vec::with_capacity(self.modules.len()); let mut reports = Vec::with_capacity(self.modules.len());
let mut installed_tool_names = HashMap::new();
let mut seen_features = HashSet::new(); let mut seen_features = HashSet::new();
for (module, descriptor) in self.modules.into_iter().zip(descriptors.into_iter()) { for (module, descriptor) in self.modules.into_iter().zip(descriptors.into_iter()) {
@ -1455,6 +1475,7 @@ pub enum FeatureInstallError {
} }
pub mod builtin; pub mod builtin;
pub mod plugin;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {

View File

@ -0,0 +1,671 @@
//! 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<PluginToolFeature> {
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<PluginToolFeature> {
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<dyn Tool>,
)
})
}
struct PluginRuntimeMissingTool {
name: String,
origin: ToolOrigin,
}
#[async_trait]
impl Tool for PluginRuntimeMissingTool {
async fn execute(
&self,
_input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
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(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SupportedSchemaType {
Object,
Array,
String,
Number,
Integer,
Boolean,
Null,
}
impl SupportedSchemaType {
fn parse(value: &str) -> Option<Self> {
match value {
"object" => Some(Self::Object),
"array" => Some(Self::Array),
"string" => Some(Self::String),
"number" => Some(Self::Number),
"integer" => Some(Self::Integer),
"boolean" => Some(Self::Boolean),
"null" => Some(Self::Null),
_ => None,
}
}
}
fn validate_input_schema(schema: &Value) -> Result<(), String> {
let ty = validate_schema_node(schema, "$", true)?;
if ty != SupportedSchemaType::Object {
return Err("root schema type must be `object`".into());
}
Ok(())
}
fn validate_schema_node(
schema: &Value,
path: &str,
root: bool,
) -> Result<SupportedSchemaType, String> {
let Value::Object(map) = schema else {
return Err(format!("{path}: schema node must be a JSON object"));
};
for key in map.keys() {
if !is_supported_schema_keyword(key) {
return Err(format!("{path}: unsupported schema keyword `{key}`"));
}
}
let ty = match map.get("type") {
Some(Value::String(value)) => SupportedSchemaType::parse(value)
.ok_or_else(|| format!("{path}: unsupported schema type `{value}`"))?,
Some(_) => return Err(format!("{path}: type must be a string")),
None if root => return Err("root schema must declare type = `object`".into()),
None => return Err(format!("{path}: schema node must declare type")),
};
if let Some(title) = map.get("title") {
if !title.is_string() {
return Err(format!("{path}: title must be a string"));
}
}
if let Some(description) = map.get("description") {
if !description.is_string() {
return Err(format!("{path}: description must be a string"));
}
}
let properties = map.get("properties");
if let Some(properties) = properties {
if ty != SupportedSchemaType::Object {
return Err(format!(
"{path}: properties is only supported for object schemas"
));
}
let Some(properties) = properties.as_object() else {
return Err(format!("{path}: properties must be a JSON object"));
};
for (name, child_schema) in properties {
validate_schema_node(child_schema, &format!("{path}.properties.{name}"), false)?;
}
}
if let Some(required) = map.get("required") {
if ty != SupportedSchemaType::Object {
return Err(format!(
"{path}: required is only supported for object schemas"
));
}
let Some(required) = required.as_array() else {
return Err(format!("{path}: required must be an array"));
};
let mut seen = HashSet::new();
for entry in required {
let Some(name) = entry.as_str() else {
return Err(format!("{path}: required entries must be strings"));
};
if !seen.insert(name) {
return Err(format!("{path}: required entries must be unique"));
}
if let Some(properties) = properties.and_then(Value::as_object) {
if !properties.contains_key(name) {
return Err(format!(
"{path}: required entry `{name}` is not declared in properties"
));
}
}
}
}
if let Some(additional) = map.get("additionalProperties") {
if ty != SupportedSchemaType::Object {
return Err(format!(
"{path}: additionalProperties is only supported for object schemas"
));
}
match additional {
Value::Bool(_) => {}
Value::Object(_) => {
validate_schema_node(additional, &format!("{path}.additionalProperties"), false)?;
}
_ => {
return Err(format!(
"{path}: additionalProperties must be boolean or schema object"
));
}
}
}
if let Some(items) = map.get("items") {
if ty != SupportedSchemaType::Array {
return Err(format!("{path}: items is only supported for array schemas"));
}
validate_schema_node(items, &format!("{path}.items"), false)?;
}
if let Some(enum_values) = map.get("enum") {
let Some(enum_values) = enum_values.as_array() else {
return Err(format!("{path}: enum must be an array"));
};
if enum_values.is_empty() {
return Err(format!("{path}: enum must not be empty"));
}
for (index, value) in enum_values.iter().enumerate() {
if enum_values
.iter()
.skip(index + 1)
.any(|other| other == value)
{
return Err(format!("{path}: enum entries must be unique"));
}
}
}
Ok(ty)
}
fn is_supported_schema_keyword(key: &str) -> bool {
matches!(
key,
"type"
| "title"
| "description"
| "properties"
| "required"
| "additionalProperties"
| "items"
| "enum"
)
}
#[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<manifest::plugin::PluginToolManifest>) -> ResolvedPluginRecord {
record_with_identity("project:example", tools)
}
fn record_with_identity(
identity: &str,
tools: Vec<manifest::plugin::PluginToolManifest>,
) -> 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 rejects_invalid_nested_property_schema_node() {
let schema = json!({
"type":"object",
"properties":{"query":"not-a-schema"},
"required":["query"],
"additionalProperties":false
});
let error = validate_input_schema(&schema).unwrap_err();
assert!(error.contains("$.properties.query"));
assert!(error.contains("schema node must be a JSON object"));
}
#[test]
fn rejects_invalid_recursive_schema_members() {
let duplicate_required = json!({
"type":"object",
"properties":{"query":{"type":"string"}},
"required":["query", "query"]
});
assert!(
validate_input_schema(&duplicate_required)
.unwrap_err()
.contains("required entries must be unique")
);
let invalid_items = json!({
"type":"object",
"properties":{"values":{"type":"array", "items":"not-a-schema"}}
});
assert!(
validate_input_schema(&invalid_items)
.unwrap_err()
.contains("$.properties.values.items")
);
let invalid_additional = json!({
"type":"object",
"additionalProperties":{"type":"unsupported"}
});
assert!(
validate_input_schema(&invalid_additional)
.unwrap_err()
.contains("unsupported schema type")
);
}
#[test]
fn accepts_object_tool_schema() {
validate_input_schema(&json!({
"type":"object",
"properties":{
"query":{"type":"string", "description":"Search text"},
"limit":{"type":"integer", "enum":[1, 5, 10]},
"tags":{"type":"array", "items":{"type":"string"}}
},
"required":["query"],
"additionalProperties":{"type":"string"}
}))
.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"));
}
#[test]
fn nested_invalid_input_schema_does_not_register_plugin_tool() {
let mut invalid = tool("BadNestedSchema");
invalid.input_schema = json!({
"type":"object",
"properties":{"query":"not-a-schema"},
"required":["query"],
"additionalProperties":false
});
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"));
assert!(has_diagnostic(&report, "$.properties.query"));
}
#[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"));
}
}

View File

@ -824,7 +824,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
registry: FeatureRegistryBuilder, registry: FeatureRegistryBuilder,
) -> FeatureRegistryInstallReport { ) -> FeatureRegistryInstallReport {
let worker = self.worker.as_mut().expect("worker taken during run"); 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| { let active_workflow_committer = self.log_writer.clone().map(|writer| {
Arc::new(move |entry| writer.commit_log_entry(entry)) Arc::new(move |entry| writer.commit_log_entry(entry))
as active_workflow::LogEntryCommitter as active_workflow::LogEntryCommitter
@ -833,6 +832,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.active_workflows.clone(), self.active_workflows.clone(),
active_workflow_committer, active_workflow_committer,
)); ));
let report = registry.install_into_worker(worker, &mut self.hook_builder);
report report
} }
@ -5353,6 +5353,7 @@ permission = "read"
surfaces: vec![manifest::plugin::PluginSurface::Hook], surfaces: vec![manifest::plugin::PluginSurface::Hook],
runtime: None, runtime: None,
hooks: vec![], hooks: vec![],
tools: vec![],
}, },
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook], enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],
grants: manifest::plugin::PluginGrantConfig::default(), grants: manifest::plugin::PluginGrantConfig::default(),