diff --git a/crates/manifest/src/plugin.rs b/crates/manifest/src/plugin.rs index 1ca3f2e7..6f062d4d 100644 --- a/crates/manifest/src/plugin.rs +++ b/crates/manifest/src/plugin.rs @@ -57,6 +57,40 @@ pub const RUST_COMPONENT_TOOL_TEMPLATE: &[PluginTemplateResource] = &[ }, ]; +/// Embedded starter template for Rust Component Model instance Plugins. +pub const RUST_COMPONENT_INSTANCE_TEMPLATE: &[PluginTemplateResource] = &[ + PluginTemplateResource { + path: "Cargo.toml", + contents: include_str!( + "../../../resources/plugin/templates/rust-component-instance/Cargo.toml" + ), + }, + PluginTemplateResource { + path: "src/lib.rs", + contents: include_str!( + "../../../resources/plugin/templates/rust-component-instance/src/lib.rs" + ), + }, + PluginTemplateResource { + path: "plugin.toml", + contents: include_str!( + "../../../resources/plugin/templates/rust-component-instance/plugin.toml" + ), + }, + PluginTemplateResource { + path: "plugin.component.wasm", + contents: include_str!( + "../../../resources/plugin/templates/rust-component-instance/plugin.component.wasm" + ), + }, + PluginTemplateResource { + path: "README.md", + contents: include_str!( + "../../../resources/plugin/templates/rust-component-instance/README.md" + ), + }, +]; + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct PluginConfig { @@ -170,6 +204,8 @@ pub enum PluginPermission { Surface { surface: PluginSurface }, Tool { name: String }, ToolNamespace { namespace: String }, + Service { name: String }, + Ingress { name: String }, ExternalWrite, HostApi { api: PluginHostApi }, } @@ -249,6 +285,8 @@ impl PluginPermission { Self::Surface { surface } => format!("surfaces.{surface}"), Self::Tool { name } => format!("tool.{name}"), Self::ToolNamespace { namespace } => format!("tool_namespace.{namespace}"), + Self::Service { name } => format!("service.{name}"), + Self::Ingress { name } => format!("ingress.{name}"), Self::ExternalWrite => "external_write".to_string(), Self::HostApi { api } => format!("host_api.{api}"), } @@ -268,6 +306,14 @@ impl PluginPermission { } } + pub fn service(name: impl Into) -> Self { + Self::Service { name: name.into() } + } + + pub fn ingress(name: impl Into) -> Self { + Self::Ingress { name: name.into() } + } + pub fn host_api(api: PluginHostApi) -> Self { Self::HostApi { api } } @@ -382,7 +428,7 @@ pub enum PluginIdParseError { InvalidLocalId, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PluginPackageManifest { pub schema_version: u32, @@ -398,6 +444,10 @@ pub struct PluginPackageManifest { pub hooks: Vec, #[serde(default)] pub tools: Vec, + #[serde(default)] + pub services: Vec, + #[serde(default)] + pub ingresses: Vec, /// Permission requests declared by the package. These are requests only; /// enablement grants must match them before runtime surfaces are exposed. #[serde(default)] @@ -413,6 +463,12 @@ impl PluginPackageManifest { if !self.tools.is_empty() { surfaces.insert(PluginSurface::Tool); } + if !self.services.is_empty() { + surfaces.insert(PluginSurface::Service); + } + if !self.ingresses.is_empty() { + surfaces.insert(PluginSurface::Ingress); + } if self.runtime.is_some() { surfaces.insert(PluginSurface::Wasm); } @@ -429,6 +485,7 @@ pub const PLUGIN_RUNTIME_WASM_ABI: &str = "yoi-plugin-wasm-1"; /// packages remain explicit `kind = "wasm"` plus `abi = "yoi-plugin-wasm-1"`. pub const PLUGIN_RUNTIME_COMPONENT_KIND: &str = "wasm-component"; pub const PLUGIN_COMPONENT_TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0"; +pub const PLUGIN_COMPONENT_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0"; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -464,6 +521,34 @@ pub struct PluginToolManifest { pub external_write: bool, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginServiceManifest { + pub name: String, + pub description: String, + #[serde(default)] + pub lifecycle: String, + #[serde(default)] + pub status_schema: Option, + #[serde(default)] + pub side_effects: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginIngressManifest { + pub name: String, + pub description: String, + #[serde(default)] + pub event_kinds: Vec, + #[serde(default)] + pub input_schema: Option, + #[serde(default)] + pub sources: Vec, + #[serde(default)] + pub side_effects: Vec, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PluginDiscoveryLimits { pub max_packages_per_store: usize, @@ -514,7 +599,7 @@ impl PluginDiscoveryOptions { } } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub struct DiscoveredPluginPackage { pub identity: SourceQualifiedPluginId, pub package_path: PathBuf, @@ -529,19 +614,19 @@ pub struct DiscoveredPluginPackage { /// This is data-only metadata and bytes. Constructing it parses manifests and /// validates package/archive shape, but it does not load, instantiate, or /// execute Plugin code. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub struct MaterializedPluginPackage { pub package: DiscoveredPluginPackage, pub files: BTreeMap>, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub struct PackedPluginPackage { pub output_path: PathBuf, pub package: DiscoveredPluginPackage, } -#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct PluginDiscoveryReport { pub packages: Vec, pub diagnostics: Vec, @@ -1072,7 +1157,10 @@ pub fn read_resolved_plugin_runtime_component( .with_package(&record.package_label) .with_digest(&record.digest)); } - if runtime.world.as_deref() != Some(PLUGIN_COMPONENT_TOOL_WORLD) { + if !matches!( + runtime.world.as_deref(), + Some(PLUGIN_COMPONENT_TOOL_WORLD) | Some(PLUGIN_COMPONENT_INSTANCE_WORLD) + ) { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Api, PluginDiagnosticPhase::Manifest, @@ -1918,7 +2006,10 @@ fn validate_manifest( .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) .with_package(label)); } - if runtime.world.as_deref() != Some(PLUGIN_COMPONENT_TOOL_WORLD) { + if !matches!( + runtime.world.as_deref(), + Some(PLUGIN_COMPONENT_TOOL_WORLD) | Some(PLUGIN_COMPONENT_INSTANCE_WORLD) + ) { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Api, PluginDiagnosticPhase::Manifest, @@ -1952,6 +2043,44 @@ fn validate_manifest( } } } + let instance_capable = manifest.runtime.as_ref().is_some_and(|runtime| { + runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND + && runtime.world.as_deref() == Some(PLUGIN_COMPONENT_INSTANCE_WORLD) + }); + if (!manifest.services.is_empty() || !manifest.ingresses.is_empty()) && !instance_capable { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Surface, + PluginDiagnosticPhase::Manifest, + "plugin service/ingress declarations require the yoi:plugin/instance@1.0.0 component world", + ) + .with_source(source) + .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) + .with_package(label)); + } + for service in &manifest.services { + if !is_safe_id(&service.name) { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Malformed, + PluginDiagnosticPhase::Manifest, + "plugin service name is not safe", + ) + .with_source(source) + .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) + .with_package(label)); + } + } + for ingress in &manifest.ingresses { + if !is_safe_id(&ingress.name) { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Malformed, + PluginDiagnosticPhase::Manifest, + "plugin ingress name is not safe", + ) + .with_source(source) + .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) + .with_package(label)); + } + } for hook in &manifest.hooks { if !is_safe_id(&hook.id) { return Err(PluginDiagnostic::new( @@ -2425,7 +2554,13 @@ mod tests { .collect(); assert_eq!( paths, - BTreeSet::from(["Cargo.toml", "src/lib.rs", "plugin.toml", "README.md"]) + BTreeSet::from([ + "Cargo.toml", + "src/lib.rs", + "plugin.toml", + "plugin.component.wasm", + "README.md", + ]) ); assert!( RUST_COMPONENT_TOOL_TEMPLATE @@ -2451,6 +2586,86 @@ mod tests { assert_eq!(manifest.tools.len(), 1); } + #[test] + fn embedded_rust_component_instance_template_is_valid_package_shape() { + let paths: BTreeSet<_> = RUST_COMPONENT_INSTANCE_TEMPLATE + .iter() + .map(|file| file.path) + .collect(); + assert_eq!( + paths, + BTreeSet::from([ + "Cargo.toml", + "src/lib.rs", + "plugin.toml", + "plugin.component.wasm", + "README.md" + ]) + ); + assert!( + RUST_COMPONENT_INSTANCE_TEMPLATE + .iter() + .all(|file| !file.path.starts_with('/') && !file.path.contains("..")) + ); + let manifest_text = RUST_COMPONENT_INSTANCE_TEMPLATE + .iter() + .find(|file| file.path == "plugin.toml") + .unwrap() + .contents; + let manifest: PluginPackageManifest = toml::from_str(manifest_text).unwrap(); + assert_eq!( + manifest.runtime.as_ref().unwrap().world.as_deref(), + Some(PLUGIN_COMPONENT_INSTANCE_WORLD) + ); + assert_eq!(manifest.services.len(), 1); + assert_eq!(manifest.ingresses.len(), 1); + assert!( + manifest + .declared_surfaces() + .contains(&PluginSurface::Service) + ); + assert!( + manifest + .declared_surfaces() + .contains(&PluginSurface::Ingress) + ); + } + + #[test] + fn service_ingress_require_instance_component_world() { + let manifest: PluginPackageManifest = toml::from_str( + r#" +schema_version = 1 +id = "bad.service" +name = "Bad Service" +version = "0.1.0" +surfaces = ["service"] +permissions = [{ kind = "surface", surface = "service" }, { kind = "service", name = "svc" }] + +[runtime] +kind = "wasm-component" +world = "yoi:plugin/tool@1.0.0" +component = "plugin.component.wasm" + +[[services]] +name = "svc" +description = "bad" +"#, + ) + .unwrap(); + let archive = StoredArchive { + files: BTreeMap::from([("plugin.component.wasm".to_string(), b"placeholder".to_vec())]), + }; + let err = validate_manifest( + &manifest, + &archive, + "bad.service", + PluginSourceKind::Project, + ) + .unwrap_err(); + assert!(err.message.contains("service/ingress")); + } + #[test] fn discovers_valid_user_and_workspace_packages() { let temp = TempDir::new().unwrap(); diff --git a/crates/plugin-pdk/src/lib.rs b/crates/plugin-pdk/src/lib.rs index 69f9edd7..00008722 100644 --- a/crates/plugin-pdk/src/lib.rs +++ b/crates/plugin-pdk/src/lib.rs @@ -38,6 +38,8 @@ use serde_json::Value; pub use wit_bindgen; +pub type Result = std::result::Result; + /// Current Yoi Component Model Tool world targeted by this PDK. pub const TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0"; @@ -98,7 +100,10 @@ impl ToolOutput { } /// Create a Tool output whose content is typed JSON. - pub fn json(summary: impl Into, value: impl Serialize) -> Result { + pub fn json( + summary: impl Into, + value: impl Serialize, + ) -> std::result::Result { let content = serde_json::to_string(&value).map_err(ToolError::serialization)?; let output = Self { summary: normalize_summary(summary.into()), @@ -292,7 +297,7 @@ impl ToolError { } /// Parse the WIT `input-json` string into a typed input value. -pub fn parse_json_input(input_json: &str) -> Result +pub fn parse_json_input(input_json: &str) -> std::result::Result where T: DeserializeOwned, { @@ -311,7 +316,7 @@ where pub fn run_json_tool(tool_name: &str, input_json: &str, handler: F) -> String where I: DeserializeOwned, - F: FnOnce(ToolContext, I) -> Result, + F: FnOnce(ToolContext, I) -> std::result::Result, { let result = parse_json_input::(input_json).and_then(|input| { let context = ToolContext::new(tool_name); @@ -474,3 +479,169 @@ mod tests { assert!(HOST_WIT.contains("%list: func")); } } + +/// Versioned Component Model instance world handled by the host-managed +/// PluginInstanceRegistry. +pub const PLUGIN_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0"; + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct PluginIngressEvent { + pub kind: String, + pub source: String, + #[serde(default)] + pub payload: Value, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct PluginStatus { + pub state: String, + #[serde(default)] + pub data: Value, +} + +impl PluginStatus { + pub fn ready(data: Value) -> Self { + Self { + state: "ready".to_string(), + data, + } + } + + pub fn stopped() -> Self { + Self { + state: "stopped".to_string(), + data: Value::Null, + } + } +} + +/// Rust-facing instance Plugin contract. Hosts call `start` once, then route +/// Tool/Ingress surfaces through the same mutable instance. +pub trait Plugin: Sized + 'static { + fn start(config: Value) -> Result; + fn handle_tool(&mut self, name: &str, input: Value) -> Result; + fn handle_ingress(&mut self, name: &str, event: PluginIngressEvent) -> Result; + fn status(&self) -> Result { + Ok(PluginStatus::ready(Value::Null)) + } + fn stop(&mut self) -> Result { + Ok(PluginStatus::stopped()) + } +} + +#[doc(hidden)] +pub fn plugin_instance_error(message: impl Into) -> String { + serde_json::json!({ "error": message.into() }).to_string() +} + +/// Export the simple string-json instance ABI used by +/// `yoi:plugin/instance@1.0.0`. +#[macro_export] +macro_rules! export_plugin_instance { + ($plugin:ty) => { + mod __yoi_plugin_instance_export { + use super::*; + use std::cell::RefCell; + + thread_local! { + static INSTANCE: RefCell> = const { RefCell::new(None) }; + } + + #[unsafe(export_name = "start")] + pub extern "C" fn __yoi_start( + _config_json_ptr: *const u8, + _config_json_len: usize, + ) -> usize { + // This low-level symbol is a placeholder for non-component raw builds. + // Component builds should bind this macro through generated WIT glue. + 0 + } + + pub struct InstanceGuest; + + impl InstanceGuest { + pub fn start(config_json: String) -> String { + let config = + serde_json::from_str(&config_json).unwrap_or(serde_json::Value::Null); + match <$plugin as $crate::Plugin>::start(config) { + Ok(plugin) => { + INSTANCE.with(|slot| *slot.borrow_mut() = Some(plugin)); + serde_json::to_string(&$crate::PluginStatus::ready( + serde_json::Value::Null, + )) + .unwrap() + } + Err(error) => $crate::plugin_instance_error(error.to_string()), + } + } + + pub fn handle_tool(name: String, input_json: String) -> String { + let input = + serde_json::from_str(&input_json).unwrap_or(serde_json::Value::Null); + INSTANCE.with(|slot| { + let mut slot = slot.borrow_mut(); + let Some(plugin) = slot.as_mut() else { + return $crate::plugin_instance_error( + "plugin instance has not been started", + ); + }; + match plugin.handle_tool(&name, input) { + Ok(output) => serde_json::to_string(&output).unwrap(), + Err(error) => $crate::plugin_instance_error(error.to_string()), + } + }) + } + + pub fn handle_ingress(name: String, event_json: String) -> String { + let event = + match serde_json::from_str::<$crate::PluginIngressEvent>(&event_json) { + Ok(event) => event, + Err(error) => return $crate::plugin_instance_error(error.to_string()), + }; + INSTANCE.with(|slot| { + let mut slot = slot.borrow_mut(); + let Some(plugin) = slot.as_mut() else { + return $crate::plugin_instance_error( + "plugin instance has not been started", + ); + }; + match plugin.handle_ingress(&name, event) { + Ok(output) => serde_json::to_string(&output).unwrap(), + Err(error) => $crate::plugin_instance_error(error.to_string()), + } + }) + } + + pub fn status() -> String { + INSTANCE.with(|slot| { + let slot = slot.borrow(); + let Some(plugin) = slot.as_ref() else { + return $crate::plugin_instance_error( + "plugin instance has not been started", + ); + }; + match plugin.status() { + Ok(status) => serde_json::to_string(&status).unwrap(), + Err(error) => $crate::plugin_instance_error(error.to_string()), + } + }) + } + + pub fn stop() -> String { + INSTANCE.with(|slot| { + let mut slot = slot.borrow_mut(); + let Some(plugin) = slot.as_mut() else { + return $crate::plugin_instance_error( + "plugin instance has not been started", + ); + }; + match plugin.stop() { + Ok(status) => serde_json::to_string(&status).unwrap(), + Err(error) => $crate::plugin_instance_error(error.to_string()), + } + }) + } + } + } + }; +} diff --git a/crates/pod/src/feature/plugin.rs b/crates/pod/src/feature/plugin.rs index 3ea94c52..8318cc91 100644 --- a/crates/pod/src/feature/plugin.rs +++ b/crates/pod/src/feature/plugin.rs @@ -21,10 +21,10 @@ use llm_worker::tool::{ Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput, }; use manifest::plugin::{ - PLUGIN_COMPONENT_TOOL_WORLD, PLUGIN_RUNTIME_COMPONENT_KIND, PLUGIN_RUNTIME_WASM_ABI, - PLUGIN_RUNTIME_WASM_KIND, PluginConfig, PluginDiscoveryLimits, PluginFsGrant, - PluginFsOperation, PluginHostApi, PluginPermission, PluginSurface, PluginToolManifest, - ResolvedPluginRecord, read_resolved_plugin_runtime_component, + PLUGIN_COMPONENT_INSTANCE_WORLD, PLUGIN_COMPONENT_TOOL_WORLD, PLUGIN_RUNTIME_COMPONENT_KIND, + PLUGIN_RUNTIME_WASM_ABI, PLUGIN_RUNTIME_WASM_KIND, PluginConfig, PluginDiscoveryLimits, + PluginFsGrant, PluginFsOperation, PluginHostApi, PluginPermission, PluginSurface, + PluginToolManifest, ResolvedPluginRecord, read_resolved_plugin_runtime_component, read_resolved_plugin_runtime_module, }; use serde::{Deserialize, Serialize}; @@ -32,7 +32,7 @@ use serde_json::Value; use super::{ FeatureDescriptor, FeatureId, FeatureInstallContext, FeatureInstallError, FeatureModule, - FeatureRuntimeKind, ToolContribution, ToolDeclaration, + FeatureRuntimeKind, ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration, }; /// Build Feature modules for enabled plugin packages when the profile exposes @@ -47,13 +47,21 @@ pub fn plugin_tool_features_if_enabled( plugin_tool_features(config) } -/// Build Feature modules for enabled plugin packages that declare Tool surfaces. +/// Build Feature modules for enabled plugin packages that declare Tool/Service/Ingress 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()) + .filter(|record| { + record.enabled_surfaces.contains(&PluginSurface::Tool) + || record.enabled_surfaces.contains(&PluginSurface::Service) + || record.enabled_surfaces.contains(&PluginSurface::Ingress) + }) + .filter(|record| { + !record.manifest.tools.is_empty() + || !record.manifest.services.is_empty() + || !record.manifest.ingresses.is_empty() + }) .cloned() .map(PluginToolFeature::new) .collect() @@ -86,6 +94,24 @@ impl PluginToolFeature { } } +fn plugin_tool_origin(record: &ResolvedPluginRecord) -> ToolOrigin { + ToolOrigin { + kind: "plugin".into(), + plugin_id: record.manifest.id.clone(), + plugin_ref: record.identity.to_string(), + source: record.identity.source.to_string(), + digest: record.digest.clone(), + package_version: record.version.clone(), + package_api_version: record.manifest.schema_version, + surface: "tool".into(), + } +} + +fn plugin_service_id(record: &ResolvedPluginRecord, name: &str) -> ServiceId { + ServiceId::new(format!("plugin:{}:{name}", record.identity.to_string())) + .expect("plugin service id is generated from safe plugin identity/name") +} + /// Static, read-only eligibility information for a resolved plugin package. /// /// This inspection mirrors the registration-time permission checks without @@ -95,6 +121,8 @@ pub struct PluginStaticInspection { pub runtime: PluginRuntimeEligibility, pub host_apis: Vec, pub tools: Vec, + pub services: Vec, + pub ingresses: Vec, } impl PluginStaticInspection { @@ -102,6 +130,8 @@ impl PluginStaticInspection { self.runtime.eligible && self.host_apis.iter().all(|api| api.eligible) && self.tools.iter().all(|tool| tool.eligible) + && self.services.iter().all(|service| service.eligible) + && self.ingresses.iter().all(|ingress| ingress.eligible) } } @@ -132,6 +162,16 @@ pub struct PluginToolEligibility { pub diagnostic: Option, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct PluginSurfaceEligibility { + pub name: String, + pub permission: String, + pub requested: bool, + pub granted: bool, + pub eligible: bool, + pub diagnostic: Option, +} + /// Inspect static plugin runtime/tool eligibility without executing plugin code. pub fn inspect_resolved_plugin_static(record: &ResolvedPluginRecord) -> PluginStaticInspection { let runtime = match &record.manifest.runtime { @@ -160,12 +200,22 @@ pub fn inspect_resolved_plugin_static(record: &ResolvedPluginRecord) -> PluginSt } Some(runtime) if runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND - && runtime.world.as_deref() == Some(PLUGIN_COMPONENT_TOOL_WORLD) + && matches!( + runtime.world.as_deref(), + Some(PLUGIN_COMPONENT_TOOL_WORLD) | Some(PLUGIN_COMPONENT_INSTANCE_WORLD) + ) && runtime.component.is_some() => { PluginRuntimeEligibility { eligible: true, - status: format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/{PLUGIN_COMPONENT_TOOL_WORLD}"), + status: format!( + "{}/{}", + PLUGIN_RUNTIME_COMPONENT_KIND, + runtime + .world + .as_deref() + .unwrap_or(PLUGIN_COMPONENT_TOOL_WORLD) + ), diagnostic: None, } } @@ -244,10 +294,69 @@ pub fn inspect_resolved_plugin_static(record: &ResolvedPluginRecord) -> PluginSt }) .collect(); + let instance_world = record.manifest.runtime.as_ref().is_some_and(|runtime| { + runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND + && runtime.world.as_deref() == Some(PLUGIN_COMPONENT_INSTANCE_WORLD) + }); + let services = record + .manifest + .services + .iter() + .map(|service| { + let permission = PluginPermission::service(&service.name); + let requested = permission_requested(record, &permission); + let granted = grant_allows(record, &permission); + let mut diagnostics = Vec::new(); + if !instance_world { + diagnostics.push("service requires instance-capable component world".to_string()); + } + if let Err(error) = authorize_plugin_service(record, &service.name) { + diagnostics.push(error.bounded_message()); + } + let diagnostic = join_tool_diagnostics(diagnostics); + PluginSurfaceEligibility { + name: service.name.clone(), + permission: permission.label(), + requested, + granted, + eligible: diagnostic.is_none(), + diagnostic, + } + }) + .collect(); + let ingresses = record + .manifest + .ingresses + .iter() + .map(|ingress| { + let permission = PluginPermission::ingress(&ingress.name); + let requested = permission_requested(record, &permission); + let granted = grant_allows(record, &permission); + let mut diagnostics = Vec::new(); + if !instance_world { + diagnostics.push("ingress requires instance-capable component world".to_string()); + } + if let Err(error) = authorize_plugin_ingress(record, &ingress.name) { + diagnostics.push(error.bounded_message()); + } + let diagnostic = join_tool_diagnostics(diagnostics); + PluginSurfaceEligibility { + name: ingress.name.clone(), + permission: permission.label(), + requested, + granted, + eligible: diagnostic.is_none(), + diagnostic, + } + }) + .collect(); + PluginStaticInspection { runtime, host_apis, tools, + services, + ingresses, } } @@ -327,6 +436,13 @@ impl FeatureModule for PluginToolFeature { requires_services: Vec::new(), protocol_providers: Vec::new(), }; + for service in &self.record.manifest.services { + descriptor.provides_services.push(ServiceDeclaration::new( + plugin_service_id(&self.record, &service.name), + self.record.manifest.version.clone(), + service.description.clone(), + )); + } for tool in &self.record.manifest.tools { descriptor = descriptor.with_tool(ToolDeclaration::new( tool.name.clone(), @@ -338,25 +454,68 @@ impl FeatureModule for PluginToolFeature { fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { validate_declared_tool_names(&self.record)?; - let origin = self.origin(); + let registry = PluginInstanceRegistry::default(); + let mut instance: Option = None; let mut registered = 0usize; let mut denied = Vec::new(); + for service in &self.record.manifest.services { + validate_tool_name(&service.name).map_err(|reason| { + FeatureInstallError::Install(format!( + "plugin {} service {} has invalid name: {reason}", + self.record.identity, service.name + )) + })?; + if let Err(error) = authorize_plugin_service(&self.record, &service.name) { + let message = format!( + "plugin {} service {} registration denied: {}", + self.record.identity, + service.name, + error.bounded_message() + ); + context.diagnostics().warning(message.clone()); + denied.push(message); + continue; + } + context.services().provide(ServiceDeclaration::new( + plugin_service_id(&self.record, &service.name), + self.record.manifest.version.clone(), + service.description.clone(), + ))?; + } + for ingress in &self.record.manifest.ingresses { + validate_tool_name(&ingress.name).map_err(|reason| { + FeatureInstallError::Install(format!( + "plugin {} ingress {} has invalid name: {reason}", + self.record.identity, ingress.name + )) + })?; + if let Err(error) = authorize_plugin_ingress(&self.record, &ingress.name) { + let message = format!( + "plugin {} ingress {} registration denied: {}", + self.record.identity, + ingress.name, + error.bounded_message() + ); + context.diagnostics().warning(message.clone()); + denied.push(message); + } + } for tool in &self.record.manifest.tools { validate_tool_name(&tool.name).map_err(|reason| { FeatureInstallError::Install(format!( - "plugin `{}` tool `{}` has invalid name: {reason}", + "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}", + "plugin {} tool {} has invalid input_schema: {reason}", self.record.identity, tool.name )) })?; if let Err(error) = authorize_plugin_tool(&self.record, tool) { let message = format!( - "plugin `{}` tool `{}` registration denied: {}", + "plugin {} tool {} registration denied: {}", self.record.identity, tool.name, error.bounded_message() @@ -365,14 +524,21 @@ impl FeatureModule for PluginToolFeature { denied.push(message); continue; } + let tool_instance = match &instance { + Some(instance) => instance.clone(), + None => { + let created = registry.register(self.record.clone())?; + instance = Some(created.clone()); + created + } + }; context.tools().register(ToolContribution::new( tool.name.clone(), - plugin_wasm_tool_definition( - self.record.clone(), + plugin_instance_tool_definition( + tool_instance, tool.name.clone(), tool.description.clone(), tool.input_schema.clone(), - origin.clone(), ), ))?; registered += 1; @@ -1416,6 +1582,64 @@ fn authorize_plugin_tool( Ok(()) } +fn authorize_plugin_service( + record: &ResolvedPluginRecord, + service_name: &str, +) -> Result<(), PluginPermissionError> { + validate_grant_binding(record)?; + require_permission( + &record.manifest.permissions, + &PluginPermission::surface(PluginSurface::Service), + "requested surfaces.service permission is missing", + )?; + require_permission( + &record.grants.permissions, + &PluginPermission::surface(PluginSurface::Service), + "granted surfaces.service permission is missing", + )?; + let permission = PluginPermission::service(service_name); + require_permission( + &record.manifest.permissions, + &permission, + &format!("requested service permission for `{service_name}` is missing"), + )?; + require_permission( + &record.grants.permissions, + &permission, + &format!("granted service permission for `{service_name}` is missing"), + )?; + Ok(()) +} + +fn authorize_plugin_ingress( + record: &ResolvedPluginRecord, + ingress_name: &str, +) -> Result<(), PluginPermissionError> { + validate_grant_binding(record)?; + require_permission( + &record.manifest.permissions, + &PluginPermission::surface(PluginSurface::Ingress), + "requested surfaces.ingress permission is missing", + )?; + require_permission( + &record.grants.permissions, + &PluginPermission::surface(PluginSurface::Ingress), + "granted surfaces.ingress permission is missing", + )?; + let permission = PluginPermission::ingress(ingress_name); + require_permission( + &record.manifest.permissions, + &permission, + &format!("requested ingress permission for `{ingress_name}` is missing"), + )?; + require_permission( + &record.grants.permissions, + &permission, + &format!("granted ingress permission for `{ingress_name}` is missing"), + )?; + Ok(()) +} + fn authorize_plugin_host_api( record: &ResolvedPluginRecord, api: PluginHostApi, @@ -1692,21 +1916,502 @@ impl PluginHttpsError { } } -fn plugin_wasm_tool_definition( +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub enum PluginInstanceLifecycleState { + Ready, + Started, + Stopped, + Failed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct PluginInstanceDiagnostic { + pub state: PluginInstanceLifecycleState, + pub message: String, +} + +impl PluginInstanceDiagnostic { + pub fn new(state: PluginInstanceLifecycleState, message: impl Into) -> Self { + Self { + state, + message: bounded_message(message.into()), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct PluginInstanceStatus { + pub plugin_ref: String, + pub lifecycle: PluginInstanceLifecycleState, + pub diagnostics: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct PluginIngressEvent { + pub kind: String, + pub source: String, + pub payload: Value, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct PluginIngressDispatchReport { + pub plugin_ref: String, + pub ingress: String, + pub accepted: bool, + pub output: Value, + pub diagnostics: Vec, +} + +#[derive(Clone, Default)] +pub struct PluginInstanceRegistry { + instances: Arc>>, +} + +impl PluginInstanceRegistry { + pub fn register( + &self, + record: ResolvedPluginRecord, + ) -> Result { + let key = record.identity.to_string(); + let mut instances = self + .instances + .lock() + .expect("plugin instance registry poisoned"); + if let Some(existing) = instances.get(&key) { + return Ok(existing.clone()); + } + let handle = PluginInstanceHandle::new(record).map_err(|error| { + FeatureInstallError::Install(format!( + "plugin instance startup failed closed: {}", + error.bounded_message() + )) + })?; + instances.insert(key, handle.clone()); + Ok(handle) + } + + pub fn status(&self, plugin_ref: &str) -> Option { + self.instances + .lock() + .expect("plugin instance registry poisoned") + .get(plugin_ref) + .map(PluginInstanceHandle::status) + } + + pub fn stop(&self, plugin_ref: &str) -> Result, PluginWasmError> { + let handle = self + .instances + .lock() + .expect("plugin instance registry poisoned") + .get(plugin_ref) + .cloned(); + handle.map(|handle| handle.stop()).transpose() + } +} + +#[derive(Clone)] +pub struct PluginInstanceHandle(Arc>); + +impl PluginInstanceHandle { + fn new(record: ResolvedPluginRecord) -> Result { + let runtime = PluginInstanceRuntime::new(&record)?; + let mut instance = PluginInstance { + record, + runtime, + lifecycle: PluginInstanceLifecycleState::Ready, + diagnostics: Vec::new(), + }; + instance.start()?; + Ok(Self(Arc::new(Mutex::new(instance)))) + } + + fn handle_tool(&self, tool_name: &str, input: Vec) -> Result { + self.0 + .lock() + .expect("plugin instance poisoned") + .handle_tool(tool_name, input) + } + + pub fn deliver_ingress( + &self, + ingress_name: &str, + event: PluginIngressEvent, + ) -> Result { + self.0 + .lock() + .expect("plugin instance poisoned") + .deliver_ingress(ingress_name, event) + } + + pub fn status(&self) -> PluginInstanceStatus { + self.0.lock().expect("plugin instance poisoned").status() + } + + pub fn stop(&self) -> Result { + let mut instance = self.0.lock().expect("plugin instance poisoned"); + instance.stop()?; + Ok(instance.status()) + } + + fn record_diagnostic(&self, diagnostic: PluginInstanceDiagnostic) { + if let Ok(mut instance) = self.0.lock() { + instance.lifecycle = diagnostic.state.clone(); + instance.diagnostics.push(diagnostic); + } + } +} + +struct PluginInstance { record: ResolvedPluginRecord, + runtime: PluginInstanceRuntime, + lifecycle: PluginInstanceLifecycleState, + diagnostics: Vec, +} + +impl PluginInstance { + fn start(&mut self) -> Result<(), PluginWasmError> { + match &mut self.runtime { + PluginInstanceRuntime::LegacyToolAdapter => { + self.lifecycle = PluginInstanceLifecycleState::Ready; + self.diagnostics.push(PluginInstanceDiagnostic::new( + PluginInstanceLifecycleState::Ready, + "legacy tool runtime adapted behind PluginInstanceRegistry", + )); + } + #[cfg(test)] + PluginInstanceRuntime::TestIngress { .. } => { + self.lifecycle = PluginInstanceLifecycleState::Started; + } + PluginInstanceRuntime::ComponentInstance(runtime) => { + runtime.start(&self.record)?; + self.lifecycle = PluginInstanceLifecycleState::Started; + } + } + Ok(()) + } + + fn handle_tool( + &mut self, + tool_name: &str, + input: Vec, + ) -> Result { + let tool = self + .record + .manifest + .tools + .iter() + .find(|tool| tool.name == tool_name) + .ok_or_else(|| { + PluginWasmError::Module( + "requested tool is not declared by plugin manifest".to_string(), + ) + })?; + authorize_plugin_tool(&self.record, tool).map_err(|error| { + PluginWasmError::Module(format!( + "plugin permission denied: {}", + error.bounded_message() + )) + })?; + match &mut self.runtime { + PluginInstanceRuntime::LegacyToolAdapter => { + run_plugin_tool(self.record.clone(), tool_name.to_string(), input) + } + #[cfg(test)] + PluginInstanceRuntime::TestIngress { calls } => { + *calls += 1; + Ok(ToolOutput { + summary: format!("{tool_name}: {calls}"), + content: Some(String::from_utf8_lossy(&input).to_string()), + }) + } + PluginInstanceRuntime::ComponentInstance(runtime) => { + runtime.handle_tool(tool_name, input) + } + } + } + + fn deliver_ingress( + &mut self, + ingress_name: &str, + event: PluginIngressEvent, + ) -> Result { + if serde_json::to_vec(&event) + .map(|bytes| bytes.len()) + .unwrap_or(usize::MAX) + > PLUGIN_WASM_MAX_INPUT_BYTES + { + return Err(PluginWasmError::Module(format!( + "plugin ingress event exceeds {} bytes", + PLUGIN_WASM_MAX_INPUT_BYTES + ))); + } + self.record + .manifest + .ingresses + .iter() + .find(|ingress| ingress.name == ingress_name) + .ok_or_else(|| { + PluginWasmError::Module( + "requested ingress is not declared by plugin manifest".to_string(), + ) + })?; + authorize_plugin_ingress(&self.record, ingress_name).map_err(|error| { + PluginWasmError::Module(format!( + "plugin ingress permission denied: {}", + error.bounded_message() + )) + })?; + match &mut self.runtime { + PluginInstanceRuntime::LegacyToolAdapter => Err(PluginWasmError::Module( + "legacy tool runtime does not expose ingress dispatch".to_string(), + )), + #[cfg(test)] + PluginInstanceRuntime::TestIngress { calls } => { + let output = serde_json::json!({ + "ingress": ingress_name, + "kind": event.kind, + "source": event.source, + "calls": *calls, + "payload": event.payload, + }); + Ok(PluginIngressDispatchReport { + plugin_ref: self.record.identity.to_string(), + ingress: ingress_name.to_string(), + accepted: true, + output, + diagnostics: self.diagnostics.clone(), + }) + } + PluginInstanceRuntime::ComponentInstance(runtime) => { + let output = runtime.handle_ingress(ingress_name, &event)?; + Ok(PluginIngressDispatchReport { + plugin_ref: self.record.identity.to_string(), + ingress: ingress_name.to_string(), + accepted: true, + output, + diagnostics: self.diagnostics.clone(), + }) + } + } + } + + fn stop(&mut self) -> Result<(), PluginWasmError> { + match &mut self.runtime { + PluginInstanceRuntime::LegacyToolAdapter => {} + #[cfg(test)] + PluginInstanceRuntime::TestIngress { .. } => {} + PluginInstanceRuntime::ComponentInstance(runtime) => runtime.stop()?, + } + self.lifecycle = PluginInstanceLifecycleState::Stopped; + Ok(()) + } + + fn status(&self) -> PluginInstanceStatus { + PluginInstanceStatus { + plugin_ref: self.record.identity.to_string(), + lifecycle: self.lifecycle.clone(), + diagnostics: self.diagnostics.clone(), + } + } +} + +enum PluginInstanceRuntime { + LegacyToolAdapter, + #[cfg(test)] + TestIngress { + calls: u64, + }, + ComponentInstance(PluginComponentInstanceRuntime), +} + +impl PluginInstanceRuntime { + fn new(record: &ResolvedPluginRecord) -> Result { + let Some(runtime) = record.manifest.runtime.as_ref() else { + return Ok(Self::LegacyToolAdapter); + }; + match runtime.kind.as_str() { + PLUGIN_RUNTIME_WASM_KIND => Ok(Self::LegacyToolAdapter), + PLUGIN_RUNTIME_COMPONENT_KIND + if runtime.world.as_deref() == Some(PLUGIN_COMPONENT_INSTANCE_WORLD) => + { + Ok(Self::ComponentInstance( + PluginComponentInstanceRuntime::instantiate(record)?, + )) + } + PLUGIN_RUNTIME_COMPONENT_KIND => Ok(Self::LegacyToolAdapter), + other => Err(PluginWasmError::Module(format!( + "unsupported plugin runtime kind `{other}`" + ))), + } + } +} + +struct PluginComponentInstanceRuntime { + store: wasmtime::Store, + instance: wasmtime::component::Instance, +} + +impl PluginComponentInstanceRuntime { + fn instantiate(record: &ResolvedPluginRecord) -> Result { + let limits = PluginDiscoveryLimits::default(); + let component_bytes = read_resolved_plugin_runtime_component(record, &limits) + .map_err(|diagnostic| PluginWasmError::Package(diagnostic.message))?; + if component_bytes.len() > limits.max_file_size_bytes as usize { + return Err(PluginWasmError::Package(format!( + "WASM component runtime artifact exceeds {} bytes", + limits.max_file_size_bytes + ))); + } + let mut config = wasmtime::Config::new(); + config.wasm_component_model(true); + config.consume_fuel(true); + config.max_wasm_stack(8 * 1024 * 1024); + let engine = wasmtime::Engine::new(&config) + .map_err(|error| PluginWasmError::Module(error.to_string()))?; + let component = + wasmtime::component::Component::new(&engine, &component_bytes).map_err(|error| { + PluginWasmError::Module(format!("component is incompatible: {error:?}")) + })?; + validate_component_imports(record, &engine, &component)?; + let mut linker = wasmtime::component::Linker::::new(&engine); + define_plugin_component_host_imports(&mut linker)?; + let mut store = wasmtime::Store::new( + &engine, + PluginComponentHostState { + record: record.clone(), + https_client: Arc::new(ReqwestPluginHttpsClient), + store_limits: wasm_component_store_limits(), + }, + ); + store.limiter(|state| &mut state.store_limits); + store + .set_fuel(PLUGIN_WASM_FUEL) + .map_err(|error| PluginWasmError::Execution(error.to_string()))?; + let instance = linker + .instantiate(&mut store, &component) + .map_err(|error| PluginWasmError::Execution(error.to_string()))?; + Ok(Self { store, instance }) + } + + fn reset_fuel(&mut self) -> Result<(), PluginWasmError> { + self.store + .set_fuel(PLUGIN_WASM_FUEL) + .map_err(|error| PluginWasmError::Execution(error.to_string())) + } + + fn start(&mut self, record: &ResolvedPluginRecord) -> Result<(), PluginWasmError> { + self.reset_fuel()?; + let start = self + .instance + .get_typed_func::<(&str,), (String,)>(&mut self.store, "start") + .map_err(|error| { + PluginWasmError::Module(format!( + "component does not export expected `{}` start function: {error}", + PLUGIN_COMPONENT_INSTANCE_WORLD + )) + })?; + let config_json = plugin_config_json(record); + let (_status,) = start + .call(&mut self.store, (&config_json,)) + .map_err(|error| PluginWasmError::Execution(error.to_string()))?; + Ok(()) + } + + fn handle_tool( + &mut self, + tool_name: &str, + input: Vec, + ) -> Result { + self.reset_fuel()?; + let call = self + .instance + .get_typed_func::<(&str, &str), (String,)>(&mut self.store, "handle-tool") + .map_err(|error| { + PluginWasmError::Module(format!( + "component does not export expected `{}` handle-tool function: {error}", + PLUGIN_COMPONENT_INSTANCE_WORLD + )) + })?; + let input_json = std::str::from_utf8(&input).map_err(|error| { + PluginWasmError::Output(format!("plugin component input is not UTF-8: {error}")) + })?; + let (output,) = call + .call(&mut self.store, (tool_name, input_json)) + .map_err(|error| PluginWasmError::Execution(error.to_string()))?; + decode_plugin_wasm_output(output.as_bytes()) + } + + fn handle_ingress( + &mut self, + ingress_name: &str, + event: &PluginIngressEvent, + ) -> Result { + self.reset_fuel()?; + let call = self + .instance + .get_typed_func::<(&str, &str), (String,)>(&mut self.store, "handle-ingress") + .map_err(|error| { + PluginWasmError::Module(format!( + "component does not export expected `{}` handle-ingress function: {error}", + PLUGIN_COMPONENT_INSTANCE_WORLD + )) + })?; + let event_json = serde_json::to_string(event) + .map_err(|error| PluginWasmError::Output(error.to_string()))?; + let (output,) = call + .call(&mut self.store, (ingress_name, event_json.as_str())) + .map_err(|error| PluginWasmError::Execution(error.to_string()))?; + if output.len() > PLUGIN_WASM_MAX_OUTPUT_BYTES { + return Err(PluginWasmError::Output(format!( + "plugin ingress output exceeds {} bytes", + PLUGIN_WASM_MAX_OUTPUT_BYTES + ))); + } + serde_json::from_str(&output).map_err(|error| { + PluginWasmError::Output(format!("plugin ingress output is not JSON: {error}")) + }) + } + + fn stop(&mut self) -> Result<(), PluginWasmError> { + self.reset_fuel()?; + let stop = self + .instance + .get_typed_func::<(), (String,)>(&mut self.store, "stop") + .map_err(|error| { + PluginWasmError::Module(format!( + "component does not export expected `{}` stop function: {error}", + PLUGIN_COMPONENT_INSTANCE_WORLD + )) + })?; + let (_status,) = stop + .call(&mut self.store, ()) + .map_err(|error| PluginWasmError::Execution(error.to_string()))?; + Ok(()) + } +} + +fn plugin_config_json(record: &ResolvedPluginRecord) -> String { + serde_json::to_string(&record.config).unwrap_or_else(|_| "{}".to_string()) +} + +fn plugin_instance_tool_definition( + instance: PluginInstanceHandle, name: String, description: String, input_schema: Value, - origin: ToolOrigin, ) -> ToolDefinition { + let origin = { + let guard = instance.0.lock().expect("plugin instance poisoned"); + plugin_tool_origin(&guard.record) + }; Arc::new(move || { ( ToolMeta::new(name.clone()) .description(description.clone()) .input_schema(input_schema.clone()) .origin(origin.clone()), - Arc::new(PluginWasmTool { - record: record.clone(), + Arc::new(PluginInstanceTool { + instance: instance.clone(), name: name.clone(), origin: origin.clone(), }) as Arc, @@ -1714,12 +2419,77 @@ fn plugin_wasm_tool_definition( }) } +struct PluginInstanceTool { + instance: PluginInstanceHandle, + name: String, + origin: ToolOrigin, +} + +#[async_trait] +impl Tool for PluginInstanceTool { + async fn execute( + &self, + input_json: &str, + _ctx: ToolExecutionContext, + ) -> Result { + if input_json.len() > PLUGIN_WASM_MAX_INPUT_BYTES { + return Err(ToolError::InvalidArgument(format!( + "plugin tool `{}` input exceeds {} bytes", + self.name, PLUGIN_WASM_MAX_INPUT_BYTES + ))); + } + serde_json::from_str::(input_json).map_err(|error| { + ToolError::InvalidArgument(format!( + "plugin tool `{}` input is not valid JSON: {}", + self.name, + bounded_message(error.to_string()) + )) + })?; + let instance = self.instance.clone(); + let name = self.name.clone(); + let plugin_ref = self.origin.plugin_ref.clone(); + let digest = self.origin.digest.clone(); + let input = input_json.as_bytes().to_vec(); + let execution = tokio::task::spawn_blocking(move || instance.handle_tool(&name, input)); + match tokio::time::timeout(PLUGIN_WASM_TIMEOUT, execution).await { + Ok(Ok(Ok(output))) => Ok(output), + Ok(Ok(Err(error))) => Err(ToolError::ExecutionFailed(format!( + "plugin tool `{}` from `{}` (digest {}) failed closed: {}", + self.name, + plugin_ref, + digest, + error.bounded_message() + ))), + Ok(Err(error)) => Err(ToolError::ExecutionFailed(format!( + "plugin tool `{}` from `{}` (digest {}) cancelled/failed to join: {}", + self.name, + plugin_ref, + digest, + bounded_message(error.to_string()) + ))), + Err(_) => { + self.instance + .record_diagnostic(PluginInstanceDiagnostic::new( + PluginInstanceLifecycleState::Failed, + format!("plugin tool timed out after {:?}", PLUGIN_WASM_TIMEOUT), + )); + Err(ToolError::ExecutionFailed(format!( + "plugin tool `{}` from `{}` (digest {}) timed out after {:?}", + self.name, plugin_ref, digest, PLUGIN_WASM_TIMEOUT + ))) + } + } + } +} + +#[cfg(test)] struct PluginWasmTool { record: ResolvedPluginRecord, name: String, origin: ToolOrigin, } +#[cfg(test)] #[async_trait] impl Tool for PluginWasmTool { async fn execute( @@ -1740,7 +2510,6 @@ impl Tool for PluginWasmTool { bounded_message(error.to_string()) )) })?; - let record = self.record.clone(); let name = self.name.clone(); let plugin_ref = self.origin.plugin_ref.clone(); @@ -1772,7 +2541,7 @@ impl Tool for PluginWasmTool { } #[derive(Debug)] -enum PluginWasmError { +pub enum PluginWasmError { Package(String), Module(String), Execution(String), @@ -2812,6 +3581,8 @@ mod tests { runtime: None, hooks: Vec::new(), tools, + services: Vec::new(), + ingresses: Vec::new(), permissions: permissions.clone(), }, enabled_surfaces: vec![PluginSurface::Tool], @@ -2837,6 +3608,63 @@ mod tests { permissions } + #[test] + fn instance_ingress_dispatch_uses_shared_in_process_instance() { + let mut record = record(vec![tool("shared_tool")]); + record.manifest.surfaces.push(PluginSurface::Ingress); + record.enabled_surfaces.push(PluginSurface::Ingress); + record + .manifest + .ingresses + .push(manifest::plugin::PluginIngressManifest { + name: "shared_ingress".into(), + description: "test ingress".into(), + event_kinds: vec!["test".into()], + input_schema: None, + sources: Vec::new(), + side_effects: Vec::new(), + }); + record + .manifest + .permissions + .push(PluginPermission::surface(PluginSurface::Ingress)); + record + .manifest + .permissions + .push(PluginPermission::ingress("shared_ingress")); + record + .grants + .permissions + .push(PluginPermission::surface(PluginSurface::Ingress)); + record + .grants + .permissions + .push(PluginPermission::ingress("shared_ingress")); + let handle = PluginInstanceHandle(Arc::new(Mutex::new(PluginInstance { + record, + runtime: PluginInstanceRuntime::TestIngress { calls: 0 }, + lifecycle: PluginInstanceLifecycleState::Started, + diagnostics: Vec::new(), + }))); + + let _tool = handle + .handle_tool("shared_tool", br#"{"first":true}"#.to_vec()) + .unwrap(); + let report = handle + .deliver_ingress( + "shared_ingress", + PluginIngressEvent { + kind: "test".into(), + source: "unit".into(), + payload: serde_json::json!({ "hello": "world" }), + }, + ) + .unwrap(); + assert!(report.accepted); + assert_eq!(report.output["calls"], 1); + assert_eq!(report.output["ingress"], "shared_ingress"); + } + fn skipped_count(report: &super::super::FeatureRegistryInstallReport) -> usize { report .reports diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index 9c4da1a8..882ee11e 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -5375,6 +5375,8 @@ permission = "read" runtime: None, hooks: vec![], tools: vec![], + services: vec![], + ingresses: vec![], permissions: vec![], }, enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook], diff --git a/crates/yoi/src/plugin_cli.rs b/crates/yoi/src/plugin_cli.rs index c680126b..f93b4d98 100644 --- a/crates/yoi/src/plugin_cli.rs +++ b/crates/yoi/src/plugin_cli.rs @@ -329,6 +329,24 @@ fn static_inspection_diagnostics( }); } } + for service in &inspection.services { + if let Some(message) = &service.diagnostic { + diagnostics.push(PluginDiagnosticReport { + kind: "grant".to_string(), + phase: "resolution".to_string(), + message: bound_text(format!("service `{}`: {message}", service.name)), + }); + } + } + for ingress in &inspection.ingresses { + if let Some(message) = &ingress.diagnostic { + diagnostics.push(PluginDiagnosticReport { + kind: "grant".to_string(), + phase: "resolution".to_string(), + message: bound_text(format!("ingress `{}`: {message}", ingress.name)), + }); + } + } diagnostics } diff --git a/docs/design/plugin-component-model.md b/docs/design/plugin-component-model.md index f3ab5ee5..bf9aa9ff 100644 --- a/docs/design/plugin-component-model.md +++ b/docs/design/plugin-component-model.md @@ -179,3 +179,26 @@ semantics while moving package authors onto WIT/canonical ABI bindings. Structured WIT records for Tool requests/responses/errors and host HTTPS/FS payloads are deferred to a follow-up API-design step rather than accidentally omitted. + +## Instance lifecycle surface + +The first instance-capable world is `yoi:plugin/instance@1.0.0`. It moves +runtime ownership from per-Tool artifact execution to a host-managed +`PluginInstance`. The same instance handles Tool, Service, and Ingress surfaces, +so Plugin state/config/diagnostics can be shared without bypassing Yoi's normal +authority model. + +Important boundaries: + +- Tool calls still enter through `ToolRegistry` and return ordinary `ToolOutput` + that is visible in the Worker history path. +- Service and Ingress grants are separate from Tool grants. Sharing an instance + does not authorize a surface that lacks its own `surface.*` and per-surface + permission/grant. +- Ingress delivery accepts bounded typed untrusted events and returns explicit + JSON to the host. It does not call model Tools or mutate LLM context/history. +- Legacy raw-wasm and `yoi:plugin/tool@1.0.0` component packages are adapted + behind `PluginInstanceRegistry` for compatibility rather than executed through + a separate authority path. +- Host APIs such as `https` and `fs` remain independently grant-gated and still + reject ambient filesystem/network authority. diff --git a/resources/plugin/templates/rust-component-instance/Cargo.toml b/resources/plugin/templates/rust-component-instance/Cargo.toml new file mode 100644 index 00000000..afd6d830 --- /dev/null +++ b/resources/plugin/templates/rust-component-instance/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "example-yoi-instance-plugin" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/resources/plugin/templates/rust-component-instance/README.md b/resources/plugin/templates/rust-component-instance/README.md new file mode 100644 index 00000000..aad35af3 --- /dev/null +++ b/resources/plugin/templates/rust-component-instance/README.md @@ -0,0 +1,9 @@ +# Yoi instance Plugin template + +This template targets `yoi:plugin/instance@1.0.0`. The host creates one +`PluginInstance` for the package; Tool, Service, and Ingress surfaces share that +instance state while each surface keeps separate permissions/grants. + +Tools still run only through ordinary model/user-initiated Tool calls. Ingress +handlers receive bounded typed untrusted events and must return explicit JSON +for host-mediated visible/durable paths. diff --git a/resources/plugin/templates/rust-component-instance/plugin.component.wasm b/resources/plugin/templates/rust-component-instance/plugin.component.wasm new file mode 100644 index 00000000..66d02db0 --- /dev/null +++ b/resources/plugin/templates/rust-component-instance/plugin.component.wasm @@ -0,0 +1,3 @@ +# Build with: +# cargo component build --release +# cp target/wasm32-wasip1/release/example_yoi_instance_plugin.wasm plugin.component.wasm diff --git a/resources/plugin/templates/rust-component-instance/plugin.toml b/resources/plugin/templates/rust-component-instance/plugin.toml new file mode 100644 index 00000000..71bbbaec --- /dev/null +++ b/resources/plugin/templates/rust-component-instance/plugin.toml @@ -0,0 +1,35 @@ +schema_version = 1 +id = "example.rust_instance_plugin" +name = "Rust Instance Plugin Template" +version = "0.1.0" +description = "Example instance-oriented Yoi Plugin with shared Tool/Ingress state." +surfaces = ["tool", "service", "ingress"] +permissions = [ + { kind = "surface", surface = "tool" }, + { kind = "tool", name = "example_instance_tool" }, + { kind = "surface", surface = "service" }, + { kind = "service", name = "example_instance_service" }, + { kind = "surface", surface = "ingress" }, + { kind = "ingress", name = "example_instance_ingress" }, +] + +[runtime] +kind = "wasm-component" +world = "yoi:plugin/instance@1.0.0" +component = "plugin.component.wasm" + +[[tools]] +name = "example_instance_tool" +description = "Return the input and increment shared instance state." +input_schema = { type = "object" } + +[[services]] +name = "example_instance_service" +description = "Reports shared plugin instance lifecycle status." +lifecycle = "host-managed" + +[[ingresses]] +name = "example_instance_ingress" +description = "Accepts bounded in-process ingress events." +event_kinds = ["example"] +input_schema = { type = "object" } diff --git a/resources/plugin/templates/rust-component-instance/src/lib.rs b/resources/plugin/templates/rust-component-instance/src/lib.rs new file mode 100644 index 00000000..a8da3ba5 --- /dev/null +++ b/resources/plugin/templates/rust-component-instance/src/lib.rs @@ -0,0 +1,37 @@ +use serde_json::{json, Value}; +use yoi_plugin_pdk::{export_plugin_instance, Plugin, PluginIngressEvent, PluginStatus, ToolOutput}; + +struct ExamplePlugin { + calls: u64, +} + +impl Plugin for ExamplePlugin { + fn start(_config: Value) -> yoi_plugin_pdk::Result { + Ok(Self { calls: 0 }) + } + + fn handle_tool(&mut self, name: &str, input: Value) -> yoi_plugin_pdk::Result { + self.calls += 1; + Ok(ToolOutput::text(json!({ + "tool": name, + "calls": self.calls, + "input": input + }).to_string())) + } + + fn handle_ingress(&mut self, name: &str, event: PluginIngressEvent) -> yoi_plugin_pdk::Result { + Ok(json!({ + "ingress": name, + "kind": event.kind, + "source": event.source, + "calls": self.calls, + "accepted": true + })) + } + + fn status(&self) -> yoi_plugin_pdk::Result { + Ok(PluginStatus::ready(json!({ "calls": self.calls }))) + } +} + +export_plugin_instance!(ExamplePlugin); diff --git a/resources/plugin/wit/yoi-plugin-instance-v1.wit b/resources/plugin/wit/yoi-plugin-instance-v1.wit new file mode 100644 index 00000000..e811ecd7 --- /dev/null +++ b/resources/plugin/wit/yoi-plugin-instance-v1.wit @@ -0,0 +1,12 @@ +package yoi:plugin@1.0.0; + +world instance { + import yoi:host/https@1.0.0; + import yoi:host/fs@1.0.0; + + export start: func(config-json: string) -> string; + export handle-tool: func(name: string, input-json: string) -> string; + export handle-ingress: func(name: string, event-json: string) -> string; + export status: func() -> string; + export stop: func() -> string; +}