feat: register plugin tool surfaces
This commit is contained in:
parent
fcae886044
commit
05a9c52217
|
|
@ -127,6 +127,31 @@ impl From<String> 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<ToolOrigin>,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ pub struct FeatureConfigPartial {
|
|||
pub ticket: Option<TicketFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub ticket_orchestration: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub plugins: Option<FeatureFlagConfigPartial>,
|
||||
}
|
||||
|
||||
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<FeatureConfigPartial> 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<FeatureConfig> for FeatureConfigPartial {
|
|||
pods: Some(value.pods.into()),
|
||||
ticket: Some(value.ticket.into()),
|
||||
ticket_orchestration: Some(value.ticket_orchestration.into()),
|
||||
plugins: Some(value.plugins.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -188,14 +188,19 @@ pub struct PluginPackageManifest {
|
|||
pub runtime: Option<PluginRuntimeManifest>,
|
||||
#[serde(default)]
|
||||
pub hooks: Vec<PluginHookManifest>,
|
||||
#[serde(default)]
|
||||
pub tools: Vec<PluginToolManifest>,
|
||||
}
|
||||
|
||||
impl PluginPackageManifest {
|
||||
fn declared_surfaces(&self) -> BTreeSet<PluginSurface> {
|
||||
pub fn declared_surfaces(&self) -> BTreeSet<PluginSurface> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ToolDefinition>,
|
||||
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 {
|
||||
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 {
|
||||
|
|
|
|||
490
crates/pod/src/feature/plugin.rs
Normal file
490
crates/pod/src/feature/plugin.rs
Normal file
|
|
@ -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<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(())
|
||||
}
|
||||
|
||||
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<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 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -824,7 +824,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
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<C: LlmClient, St: Store> Pod<C, St> {
|
|||
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(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user