diff --git a/crates/plugin-pdk/src/lib.rs b/crates/plugin-pdk/src/lib.rs index 8e2fd4fc..f1aa137d 100644 --- a/crates/plugin-pdk/src/lib.rs +++ b/crates/plugin-pdk/src/lib.rs @@ -468,15 +468,48 @@ mod tests { assert!(error.message().len() <= MAX_ERROR_MESSAGE_BYTES + "…".len()); } + #[test] + fn service_output_helper_builds_runtime_command_envelope() { + let event = PluginIngressEvent { + kind: "websocket_text".to_string(), + source: "websocket:wss://example.test/socket".to_string(), + ingress_name: "example_ws".to_string(), + payload: json!({"text":"ping"}), + created_at: "2026-06-25T00:00:00Z".to_string(), + attempt: 1, + correlation_id: "event-1".to_string(), + }; + + assert_eq!(event.websocket_text(), Some("ping")); + let output = + ServiceOutput::websocket_send(&event, "reply-1", "wss://example.test/socket", "pong") + .unwrap(); + let value = serde_json::to_value(output).unwrap(); + + assert_eq!(value["accepted"], true); + assert_eq!(value["output_commands"][0]["source_event_id"], "event-1"); + assert_eq!(value["output_commands"][0]["command_id"], "reply-1"); + assert_eq!(value["output_commands"][0]["kind"], "websocket_send"); + assert_eq!(value["output_commands"][0]["payload"]["text"], "pong"); + assert_eq!( + value["output_commands"][0]["requested_at"], + "2026-06-25T00:00:00Z" + ); + } + #[test] fn wit_constants_match_current_world() { assert!(TOOL_WIT.contains("package yoi:plugin@1.0.0")); assert!(TOOL_WIT.contains("world tool")); assert!(TOOL_WIT.contains("export call")); assert_eq!(TOOL_WORLD, "yoi:plugin/tool@1.0.0"); - assert!(HOST_WIT.contains("interface https")); + assert!(HOST_WIT.contains("interface request")); + assert!(HOST_WIT.contains("interface websocket")); assert!(HOST_WIT.contains("interface fs")); assert!(HOST_WIT.contains("%list: func")); + assert!(INSTANCE_WIT.contains("world instance")); + assert!(INSTANCE_WIT.contains("export handle-ingress")); + assert!(INSTANCE_WIT.contains("websocket_send")); } } @@ -488,12 +521,48 @@ pub const PLUGIN_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0"; pub const INSTANCE_WIT: &str = include_str!("../../../resources/plugin/wit/yoi-plugin-instance-v1.wit"); -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct PluginIngressEvent { pub kind: String, pub source: String, #[serde(default)] + pub ingress_name: String, + #[serde(default)] pub payload: Value, + #[serde(default)] + pub created_at: String, + #[serde(default = "default_attempt")] + pub attempt: u32, + #[serde(default)] + pub correlation_id: String, +} + +impl PluginIngressEvent { + /// Return the text payload carried by a host-owned WebSocket ingress event. + pub fn websocket_text(&self) -> Option<&str> { + self.payload.get("text").and_then(Value::as_str) + } + + /// Build a `websocket_send` output command that replies through the + /// host-owned Service WebSocket driver. The host still validates the target + /// URL and matching grants before sending. + pub fn websocket_send( + &self, + command_id: impl Into, + url: impl Into, + text: impl Into, + ) -> Result { + ServiceOutputCommand::new( + self, + command_id, + ServiceOutputCommandKind::WebSocketSend, + serde_json::json!({ "url": url.into(), "text": text.into() }), + ) + } +} + +fn default_attempt() -> u32 { + 1 } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] @@ -519,12 +588,118 @@ impl PluginStatus { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ServiceOutputCommandKind { + DiagnosticStatusUpdate, + HostRequestDispatch, + #[serde(rename = "websocket_send")] + WebSocketSend, +} + +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ServiceOutputCommand { + pub correlation_id: String, + pub source_event_id: String, + pub command_id: String, + pub kind: ServiceOutputCommandKind, + pub payload: Value, + pub requested_at: String, +} + +impl ServiceOutputCommand { + pub fn new( + event: &PluginIngressEvent, + command_id: impl Into, + kind: ServiceOutputCommandKind, + payload: impl Serialize, + ) -> Result { + let command_id = sanitize_command_id(command_id.into()); + if command_id.is_empty() { + return Err(ToolError::invalid_output( + "service output command_id must not be empty", + )); + } + let source_event_id = event.correlation_id.clone(); + if source_event_id.is_empty() { + return Err(ToolError::invalid_output( + "service output command requires ingress event correlation_id", + )); + } + let requested_at = if event.created_at.is_empty() { + "1970-01-01T00:00:00Z".to_string() + } else { + event.created_at.clone() + }; + Ok(Self { + correlation_id: sanitize_command_id(format!("{source_event_id}:{command_id}")), + source_event_id, + command_id, + kind, + payload: serde_json::to_value(payload).map_err(ToolError::serialization)?, + requested_at, + }) + } +} + +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ServiceOutput { + #[serde(default)] + pub accepted: bool, + #[serde(default, skip_serializing_if = "Value::is_null")] + pub data: Value, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub output_commands: Vec, +} + +impl ServiceOutput { + pub fn accepted(data: impl Serialize) -> Result { + Ok(Self { + accepted: true, + data: serde_json::to_value(data).map_err(ToolError::serialization)?, + output_commands: Vec::new(), + }) + } + + pub fn empty() -> Self { + Self { + accepted: true, + data: Value::Null, + output_commands: Vec::new(), + } + } + + pub fn with_command(mut self, command: ServiceOutputCommand) -> Self { + self.output_commands.push(command); + self + } + + pub fn websocket_send( + event: &PluginIngressEvent, + command_id: impl Into, + url: impl Into, + text: impl Into, + ) -> Result { + Ok(Self::empty().with_command(event.websocket_send(command_id, url, text)?)) + } +} + +fn sanitize_command_id(value: String) -> String { + bounded_text( + value + .chars() + .map(|ch| if ch.is_control() { '-' } else { ch }) + .collect(), + 128, + ) +} + /// 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 handle_ingress(&mut self, name: &str, event: PluginIngressEvent) -> Result; fn status(&self) -> Result { Ok(PluginStatus::ready(Value::Null)) } diff --git a/crates/plugin-pdk/tests/template.rs b/crates/plugin-pdk/tests/template.rs index a5e2239d..82b8ff13 100644 --- a/crates/plugin-pdk/tests/template.rs +++ b/crates/plugin-pdk/tests/template.rs @@ -12,6 +12,14 @@ const TEMPLATE_PLUGIN: &str = include_str!("../../../resources/plugin/templates/rust-component-tool/plugin.toml"); const TEMPLATE_README: &str = include_str!("../../../resources/plugin/templates/rust-component-tool/README.md"); +const SERVICE_TEMPLATE_CARGO: &str = + include_str!("../../../resources/plugin/templates/rust-component-instance/Cargo.toml"); +const SERVICE_TEMPLATE_LIB: &str = + include_str!("../../../resources/plugin/templates/rust-component-instance/src/lib.rs"); +const SERVICE_TEMPLATE_PLUGIN: &str = + include_str!("../../../resources/plugin/templates/rust-component-instance/plugin.toml"); +const SERVICE_TEMPLATE_README: &str = + include_str!("../../../resources/plugin/templates/rust-component-instance/README.md"); const SAMPLE_LIB: &str = include_str!("../../../docs/examples/plugin-component-tool/lib.rs"); const PDK_CARGO: &str = include_str!("../Cargo.toml"); @@ -25,7 +33,7 @@ fn rust_component_tool_template_has_expected_files() { cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(), Some("../../../../crates/plugin-pdk") ); - assert!(TEMPLATE_CARGO.contains("rev = \"\"")); + assert!(TEMPLATE_CARGO.contains("rev = \"\"")); let plugin: Value = toml::from_str(TEMPLATE_PLUGIN).expect("template plugin.toml parses"); assert_eq!(plugin["schema_version"].as_integer(), Some(1)); @@ -45,6 +53,71 @@ fn rust_component_tool_template_has_expected_files() { assert!(TEMPLATE_README.contains("Component Model Tool Plugin")); } +#[test] +fn rust_component_service_template_has_event_output_pattern() { + let cargo: Value = toml::from_str(SERVICE_TEMPLATE_CARGO).expect("service Cargo.toml parses"); + assert_eq!(cargo["package"]["edition"].as_str(), Some("2024")); + assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib")); + assert_eq!( + cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(), + Some("../../../../crates/plugin-pdk") + ); + assert!(SERVICE_TEMPLATE_CARGO.contains("rev = \"\"")); + + let plugin: Value = + toml::from_str(SERVICE_TEMPLATE_PLUGIN).expect("service plugin.toml parses"); + assert_eq!(plugin["schema_version"].as_integer(), Some(1)); + assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component")); + assert_eq!( + plugin["runtime"]["world"].as_str(), + Some("yoi:plugin/instance@1.0.0") + ); + assert!( + plugin["permissions"] + .as_array() + .expect("permissions array") + .iter() + .any(|permission| permission["kind"].as_str() == Some("host_api") + && permission["api"].as_str() == Some("websocket")) + ); + assert_eq!( + plugin["services"].as_array().expect("services array").len(), + 1 + ); + let ingress = &plugin["ingresses"].as_array().expect("ingresses array")[0]; + assert!( + ingress["event_kinds"] + .as_array() + .expect("event kinds") + .iter() + .any(|kind| kind.as_str() == Some("websocket_text")) + ); + assert!( + ingress["sources"] + .as_array() + .expect("sources") + .iter() + .any(|source| source.as_str() == Some("websocket:wss://example.com/socket")) + ); + let websocket = &plugin["websocket"].as_array().expect("websocket targets")[0]; + assert_eq!(websocket["scheme"].as_str(), Some("wss")); + assert_eq!(websocket["host"].as_str(), Some("example.com")); + assert!( + websocket["path_prefixes"] + .as_array() + .expect("websocket path prefixes") + .iter() + .any(|prefix| prefix.as_str() == Some("/socket")) + ); + + assert!(SERVICE_TEMPLATE_LIB.contains("world: \"instance\"")); + assert!(SERVICE_TEMPLATE_LIB.contains("PluginIngressEvent")); + assert!(SERVICE_TEMPLATE_LIB.contains("ServiceOutput::websocket_send")); + assert!(!SERVICE_TEMPLATE_LIB.contains("recv(timeout")); + assert!(SERVICE_TEMPLATE_README.contains("output command")); + assert!(!SERVICE_TEMPLATE_PLUGIN.contains("kind = \"wasm\"")); +} + #[test] fn documented_sample_uses_pdk_component_path() { assert!(SAMPLE_LIB.contains("use yoi_plugin_pdk::wit_bindgen")); @@ -57,8 +130,17 @@ fn documented_sample_uses_pdk_component_path() { #[test] fn embedded_template_cargo_checks_for_wasm_target() { + cargo_check_template("rust-component-tool"); +} + +#[test] +fn embedded_service_template_cargo_checks_for_wasm_target() { + cargo_check_template("rust-component-instance"); +} + +fn cargo_check_template(template: &str) { let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let template_dir = crate_dir.join("../../resources/plugin/templates/rust-component-tool"); + let template_dir = crate_dir.join(format!("../../resources/plugin/templates/{template}")); let manifest_path = template_dir.join("Cargo.toml"); let lock_path = template_dir.join("Cargo.lock"); let _ = fs::remove_file(&lock_path); diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 3625681a..7d0e7263 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -727,7 +727,7 @@ fn parse_plugin_pack_args( } fn plugin_usage() -> &'static str { - "usage: yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show [--workspace PATH] [--profile REF] [--json]" + "usage: yoi plugin new [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show [--workspace PATH] [--profile REF] [--json]" } fn parse_mcp_args(args: &[String]) -> Result { @@ -901,7 +901,7 @@ fn parse_session_id(value: &str) -> Result { fn print_help() { println!( - "yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace ] [--all]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi pod delete [--force] [--dry-run]\n yoi pod prune --older-than [--force] [--dry-run]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi session prune --unreferenced [--older-than ] [--force] [--dry-run]\n yoi ticket [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi mcp list [--workspace ] [--profile ] [--json]\n yoi mcp show [--workspace ] [--profile ] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod Open the Pod Console by name (attach/restore/create)\n --socket Attach a Pod Console to a specific socket with --pod\n --session Resume a specific session segment in the Pod Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" + "yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace ] [--all]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi pod delete [--force] [--dry-run]\n yoi pod prune --older-than [--force] [--dry-run]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi session prune --unreferenced [--older-than ] [--force] [--dry-run]\n yoi ticket [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi mcp list [--workspace ] [--profile ] [--json]\n yoi mcp show [--workspace ] [--profile ] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod Open the Pod Console by name (attach/restore/create)\n --socket Attach a Pod Console to a specific socket with --pod\n --session Resume a specific session segment in the Pod Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" ); } diff --git a/crates/yoi/src/plugin_cli.rs b/crates/yoi/src/plugin_cli.rs index da347b0b..c54251b3 100644 --- a/crates/yoi/src/plugin_cli.rs +++ b/crates/yoi/src/plugin_cli.rs @@ -9,10 +9,10 @@ use manifest::plugin::{ MaterializedPluginPackage, PluginConfig, PluginDiagnostic, PluginDiagnosticKind, PluginDiagnosticPhase, PluginDiscoveryLimits, PluginDiscoveryOptions, PluginDiscoveryReport, PluginExactVersion, PluginGrantConfig, PluginPackageManifest, PluginPermission, - PluginResolution, PluginSourceKind, PluginSurface, RUST_COMPONENT_TOOL_TEMPLATE, - ResolvedPlugin, ResolvedPluginRecord, SourceQualifiedPluginId, discover_plugins, - read_plugin_directory, read_plugin_package_file, resolve_enabled_plugins, - write_plugin_package_file, + PluginResolution, PluginSourceKind, PluginSurface, PluginTemplateResource, + RUST_COMPONENT_INSTANCE_TEMPLATE, RUST_COMPONENT_TOOL_TEMPLATE, ResolvedPlugin, + ResolvedPluginRecord, SourceQualifiedPluginId, discover_plugins, read_plugin_directory, + read_plugin_package_file, resolve_enabled_plugins, write_plugin_package_file, }; use manifest::{ProfileResolveOptions, ProfileResolver, ProfileSelector, paths}; use pod::feature::plugin::{PluginStaticInspection, inspect_resolved_plugin_static}; @@ -85,27 +85,29 @@ pub(crate) fn run(command: PluginCliCommand) -> Result<()> { } fn render_new(template: &str, destination: &Path, args: &PluginCliArgs) -> Result { - if template != "rust-component-tool" { - return Err(format!( - "unsupported plugin template `{template}` (supported: rust-component-tool)" - ) - .into()); + let (template_name, resources) = embedded_template_resources(template)?; + materialize_template(destination, resources)?; + let mut next_steps = vec![ + "Review plugin.toml and generated Rust source.".to_string(), + "Replace the placeholder plugin.component.wasm with a real built component before enabling or execution.".to_string(), + "Run `yoi plugin check ` and then `yoi plugin pack `.".to_string(), + ]; + if template == "rust-component-service" { + next_steps.insert( + 1, + "Implement Service ingress logic in handle_ingress and return ServiceOutput output_commands for host-owned WebSocket sends.".to_string(), + ); } - materialize_template(destination)?; let report = NewReport { command: "new", - template: "rust-component-tool", + template: template_name, destination: destination.display().to_string(), - files: RUST_COMPONENT_TOOL_TEMPLATE + files: resources .iter() .map(|resource| resource.path.to_string()) .collect(), safety: AuthoringSafetyReport::default(), - next_steps: vec![ - "Review plugin.toml and generated Rust source.".to_string(), - "Replace the placeholder plugin.component.wasm with a real built component before enabling or execution.".to_string(), - "Run `yoi plugin check ` and then `yoi plugin pack `.".to_string(), - ], + next_steps, }; if args.json { return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?)); @@ -113,7 +115,23 @@ fn render_new(template: &str, destination: &Path, args: &PluginCliArgs) -> Resul render_new_human(&report) } -fn materialize_template(destination: &Path) -> Result<()> { +fn embedded_template_resources( + template: &str, +) -> Result<(&'static str, &'static [PluginTemplateResource])> { + match template { + "rust-component-tool" => Ok(("rust-component-tool", RUST_COMPONENT_TOOL_TEMPLATE)), + "rust-component-service" => Ok(("rust-component-service", RUST_COMPONENT_INSTANCE_TEMPLATE)), + _ => Err(format!( + "unsupported plugin template `{template}` (supported: rust-component-tool, rust-component-service)" + ) + .into()), + } +} + +fn materialize_template( + destination: &Path, + resources: &'static [PluginTemplateResource], +) -> Result<()> { match fs::symlink_metadata(destination) { Ok(metadata) => { if metadata.file_type().is_symlink() { @@ -144,7 +162,7 @@ fn materialize_template(destination: &Path) -> Result<()> { Err(error) => return Err(error.into()), } - for resource in RUST_COMPONENT_TOOL_TEMPLATE { + for resource in resources { let relative = safe_template_relative_path(resource.path)?; let path = destination.join(relative); if let Some(parent) = path.parent() { @@ -2136,6 +2154,65 @@ mod tests { let human_check = render_check(&destination, &PluginCliArgs::default()).unwrap(); assert!(human_check.contains("[partial]")); assert!(human_check.contains("not ready to enable")); + + let service_destination = dir.path().join("my-service-plugin"); + let service_json = render_new( + "rust-component-service", + &service_destination, + &PluginCliArgs { + json: true, + ..PluginCliArgs::default() + }, + ) + .unwrap(); + let service_value: serde_json::Value = serde_json::from_str(&service_json).unwrap(); + assert_eq!(service_value["template"], "rust-component-service"); + assert!( + service_value["next_steps"] + .as_array() + .unwrap() + .iter() + .any(|step| step + .as_str() + .unwrap_or_default() + .contains("Service ingress")) + ); + for resource in RUST_COMPONENT_INSTANCE_TEMPLATE { + assert!( + service_destination.join(resource.path).is_file(), + "missing service {}", + resource.path + ); + } + let manifest = fs::read_to_string(service_destination.join("plugin.toml")).unwrap(); + assert!(manifest.contains("kind = \"wasm-component\"")); + assert!(manifest.contains("[[services]]")); + assert!(manifest.contains("[[ingresses]]")); + assert!(manifest.contains("{ kind = \"host_api\", api = \"websocket\" }")); + assert!(manifest.contains("[[websocket]]")); + assert!(manifest.contains("host = \"example.com\"")); + assert!(manifest.contains("path_prefixes = [\"/socket\"]")); + let source = fs::read_to_string(service_destination.join("src/lib.rs")).unwrap(); + assert!(source.contains("ServiceOutput::websocket_send")); + assert!(!source.contains("recv(timeout")); + let service_check = render_check(&service_destination, &PluginCliArgs::default()).unwrap(); + assert!(service_check.contains("plugin check:")); + assert!(service_check.contains("service")); + let service_package = dir.path().join("my-service-plugin.yoi-plugin"); + let service_pack_json = render_pack( + &service_destination, + Some(&service_package), + &PluginCliArgs { + json: true, + ..PluginCliArgs::default() + }, + ) + .unwrap(); + let service_pack_value: serde_json::Value = + serde_json::from_str(&service_pack_json).unwrap(); + assert_eq!(service_pack_value["status"], "packed"); + assert!(service_package.is_file()); + let error = render_new( "rust-component-tool", &destination, diff --git a/docs/development/plugin-development.md b/docs/development/plugin-development.md index 881ff48e..0e745dd7 100644 --- a/docs/development/plugin-development.md +++ b/docs/development/plugin-development.md @@ -23,7 +23,7 @@ Yoi's Plugin platform is meant to make extension behavior reviewable before it b Keep these layers separate when designing a Plugin. Do not make package discovery imply enablement. Do not make SDK/PDK convenience imply authority. Do not treat Rust helper APIs or host API wrappers as permission grants. The host always re-checks authority at registration/execution/API-call boundaries. -Yoi's preferred Plugin shape is **Tool first**. A good Tool Plugin has a narrow schema, deterministic input/output behavior, explicit side-effect metadata, and a minimal grant set. Long-running services, inbound events, and autonomous routing are future Service/Ingress work; they should not be hidden inside a Tool package. +Yoi's preferred Plugin shapes are **Tool first** for request/response capabilities and **Service/Ingress** for host-dispatched inbound events. A good Tool Plugin has a narrow schema, deterministic input/output behavior, explicit side-effect metadata, and a minimal grant set. A Service Plugin should keep long-lived transport ownership in the host and react to bounded ingress events by returning output commands. Component Model authoring is the supported path for Plugins. Legacy raw core-Wasm manifests (`kind = "wasm"` / `abi = "yoi-plugin-wasm-1"`) are retired and rejected by `yoi plugin check`, discovery, `list`, and `show`; use the Rust PDK/template and `kind = "wasm-component"` instead. @@ -42,10 +42,10 @@ Implemented foundation: - read-only `yoi plugin list/show` inspection; - local first-party authoring commands: `yoi plugin new`, `yoi plugin check`, and `yoi plugin pack`. -Still intentionally separate/future work: +Still intentionally limited or separate from this guide: - multi-language SDK/PDK crates; -- Service / Ingress surfaces; +- Service / Ingress surfaces, where the host owns transport lifecycle, dispatches bounded ingress events, and consumes output commands such as `websocket_send`; - WebSocket or inbound HTTP for bidirectional external event integrations; - public registry/install/update/signature tooling. @@ -78,6 +78,8 @@ Create a Rust Component Tool starter from embedded resources: ```bash yoi plugin new rust-component-tool ./my-plugin +# or, for a host-dispatched Service/Ingress example: +yoi plugin new rust-component-service ./my-service-plugin ``` `new` writes only inside the requested destination and refuses an existing non-empty destination or destination symlink. The generated template includes `plugin.toml`, Rust source, Cargo metadata, README next steps, and a placeholder `plugin.component.wasm` artifact so local `check`/`pack` validation can run immediately. Replace the placeholder with a real built component before enabling or executing the Plugin. @@ -115,7 +117,7 @@ For Tool Plugins: - return bounded summaries and content that are useful as Tool results; - avoid hiding long workflows, background daemons, or inbound event handling inside a Tool call. -A Tool should be a capability the model may choose to call, not a second agent runtime. If the desired behavior needs a long-lived connection, incoming events, or autonomous routing, treat that as future Service/Ingress design rather than stretching the Tool surface. +A Tool should be a capability the model may choose to call, not a second agent runtime. If the desired behavior needs a long-lived connection, incoming events, or autonomous routing, put the transport lifecycle behind a Service/Ingress surface and let the host dispatch bounded events; do not stretch the Tool surface into a hidden polling loop. Design package permissions as a review surface. A reviewer should be able to read `plugin.toml` plus the enablement grants and understand: @@ -163,6 +165,8 @@ Create a starter with: ```bash yoi plugin new rust-component-tool ./my-plugin +# or, for a host-dispatched Service/Ingress example: +yoi plugin new rust-component-service ./my-service-plugin ``` The generated package contains: @@ -327,6 +331,62 @@ path_prefixes = ["/v1/"] Yoi checks method, scheme, host, optional port, and path prefix against both the manifest declaration and enablement grant before any network I/O. `http://localhost`, loopback, private, and other local targets are never ambient; they require an explicit manifest request target and an explicit matching grant. The explicit request target is the declared URL authority; a granted DNS hostname may resolve to a loopback/private address without requiring a separate literal-IP grant, so reviewers should grant hostnames only when that resolution behavior is intended. Broad targets such as `host = "*"` are supported only as visibly broad request permissions in inspection/diagnostics. Embedded credentials, credential-like headers, oversize requests/responses, WebSocket URLs/upgrades, and SSE/event-stream requests are rejected. +## Service ingress and output commands + +Service Plugins export the `yoi:plugin/instance@1.0.0` world. The host starts one Plugin instance, owns external ingress transports, and calls `handle_ingress(name, event_json)` with bounded event envelopes. A WebSocket ingress event contains fields such as `kind`, `source`, `ingress_name`, `payload`, `created_at`, `attempt`, and `correlation_id`; the Rust PDK maps this to `PluginIngressEvent`. + +Service handlers return `ServiceOutput`, not ordinary ToolOutput. Side effects are requested through top-level `output_commands`. For a WebSocket reply, use the PDK helper: + +```rust +ServiceOutput::websocket_send( + &event, + "reply-1", + event.source.strip_prefix("websocket:").unwrap_or(&event.source), + "pong", +) +``` + +This serializes a `websocket_send` command with `source_event_id`, `command_id`, `payload.url`, `payload.text`, and a request timestamp. The host parses, bounds, grant-checks, and dispatches the command through the host-owned WebSocket driver. Do not create a long-running guest receive loop for Service integrations; incoming messages should arrive as ingress events. + +A minimal manifest shape is: + +```toml +surfaces = ["tool", "service", "ingress"] +permissions = [ + { kind = "surface", surface = "service" }, + { kind = "service", name = "example_service" }, + { kind = "surface", surface = "ingress" }, + { kind = "ingress", name = "example_ws" }, + { kind = "host_api", api = "websocket" }, +] + +[runtime] +kind = "wasm-component" +world = "yoi:plugin/instance@1.0.0" +component = "plugin.component.wasm" + +[[services]] +name = "example_service" +description = "Host-managed service instance." +lifecycle = "host-managed" + +[[ingresses]] +name = "example_ws" +description = "Handles host-owned WebSocket text events." +event_kinds = ["websocket_text", "websocket_close", "websocket_error"] +sources = ["websocket:wss://gateway.example.com/gateway"] +input_schema = { type = "object" } + +[[websocket]] +scheme = "wss" +host = "gateway.example.com" +path_prefixes = ["/gateway"] +``` + +The `host_api.websocket` permission and `[[websocket]]` target are required for `websocket_send` output commands. Runtime enablement grants must explicitly allow the same WebSocket target; the manifest declaration alone is not authority. + +Generate a fuller example with `yoi plugin new rust-component-service ./my-service-plugin`. + ## `websocket` host API The `websocket` host API is a separate grant-gated capability named `host_api.websocket`, not an extension of `host_api.request`. It opens host-owned WebSocket connections only when both the package manifest and enablement config declare matching targets. Tool-style/internal bounded use can still drive the lifecycle explicitly through `open`, `send-text`, `recv`, and `close`; incoming messages are returned only from bounded `recv` calls and are not injected into model context, history, Dashboard state, or Ticket state. Service Plugins should prefer the host-owned Service WebSocket driver instead of running a long-lived guest recv loop: declare a Service ingress source as `websocket:wss://host/path`, include the `websocket_text`/`websocket_close`/`websocket_error` event kinds you want delivered, and emit the Service output command `websocket_send` to send text back through the same grant-checked host connection. diff --git a/resources/plugin/templates/rust-component-instance/Cargo.toml b/resources/plugin/templates/rust-component-instance/Cargo.toml index 06a0cf83..2ae7cfa0 100644 --- a/resources/plugin/templates/rust-component-instance/Cargo.toml +++ b/resources/plugin/templates/rust-component-instance/Cargo.toml @@ -1,14 +1,22 @@ -[workspace] - [package] -name = "example-yoi-instance-plugin" +name = "yoi-rust-component-service-template" version = "0.1.0" edition = "2024" +license = "MIT" +publish = false + +# Keep the embedded template checkable in-place without making it a member of +# Yoi's root workspace. A copied starter remains a normal standalone package. +[workspace] [lib] crate-type = ["cdylib"] [dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" } -serde = { version = "1", features = ["derive"] } -serde_json = "1" + +# Out-of-tree Plugin packages should replace the local path with a pinned +# Yoi source revision. Use rev, not branch, for reproducible builds: +# yoi-plugin-pdk = { git = "https://gitea.hareworks.net/Hare/yoi.git", package = "yoi-plugin-pdk", rev = "" } diff --git a/resources/plugin/templates/rust-component-instance/README.md b/resources/plugin/templates/rust-component-instance/README.md index aad35af3..8a9930bc 100644 --- a/resources/plugin/templates/rust-component-instance/README.md +++ b/resources/plugin/templates/rust-component-instance/README.md @@ -1,9 +1,10 @@ -# Yoi instance Plugin template +# Rust Service 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. +This template targets the Component Model-only runtime (`runtime.kind = "wasm-component"`) and exports the `yoi:plugin/instance@1.0.0` world. -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. +It demonstrates both authoring surfaces supported by a shared Plugin instance: + +- `example_echo` is an ordinary request/response Tool handler. +- `example_ws` is a Service ingress handler. The host owns WebSocket receive/reconnect work and dispatches bounded `websocket_text` events into `handle_ingress`. The guest replies by returning a `websocket_send` output command in `ServiceOutput`; do not run a guest-side `recv(timeout)` polling loop. The manifest declares `host_api.websocket` plus a matching `[[websocket]]` target for the example URL. Enablement grants must explicitly allow the same WebSocket target before the host will send output commands. + +Build with `cargo component build --release` (or the project-specific build command used by your Plugin packaging flow), then run `yoi plugin check` / `yoi plugin pack` from the generated Plugin directory. diff --git a/resources/plugin/templates/rust-component-instance/plugin.toml b/resources/plugin/templates/rust-component-instance/plugin.toml index 71bbbaec..59495d37 100644 --- a/resources/plugin/templates/rust-component-instance/plugin.toml +++ b/resources/plugin/templates/rust-component-instance/plugin.toml @@ -1,16 +1,17 @@ schema_version = 1 -id = "example.rust_instance_plugin" -name = "Rust Instance Plugin Template" +id = "example.rust_service_plugin" +name = "Rust Service Plugin Template" version = "0.1.0" -description = "Example instance-oriented Yoi Plugin with shared Tool/Ingress state." +description = "Example Component Model Plugin with Tool and Service ingress handlers." surfaces = ["tool", "service", "ingress"] permissions = [ { kind = "surface", surface = "tool" }, - { kind = "tool", name = "example_instance_tool" }, + { kind = "tool", name = "example_echo" }, { kind = "surface", surface = "service" }, - { kind = "service", name = "example_instance_service" }, + { kind = "service", name = "example_service" }, { kind = "surface", surface = "ingress" }, - { kind = "ingress", name = "example_instance_ingress" }, + { kind = "ingress", name = "example_ws" }, + { kind = "host_api", api = "websocket" }, ] [runtime] @@ -19,17 +20,23 @@ 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." +name = "example_echo" +description = "Echo input text through the shared Plugin instance." input_schema = { type = "object" } [[services]] -name = "example_instance_service" -description = "Reports shared plugin instance lifecycle status." +name = "example_service" +description = "Host-managed service instance for bounded ingress events." lifecycle = "host-managed" [[ingresses]] -name = "example_instance_ingress" -description = "Accepts bounded in-process ingress events." -event_kinds = ["example"] +name = "example_ws" +description = "Handles host-owned WebSocket text events and returns websocket_send output commands." +event_kinds = ["websocket_text"] +sources = ["websocket:wss://example.com/socket"] input_schema = { type = "object" } + +[[websocket]] +scheme = "wss" +host = "example.com" +path_prefixes = ["/socket"] diff --git a/resources/plugin/templates/rust-component-instance/src/lib.rs b/resources/plugin/templates/rust-component-instance/src/lib.rs index 0cc5a31a..1a2750ca 100644 --- a/resources/plugin/templates/rust-component-instance/src/lib.rs +++ b/resources/plugin/templates/rust-component-instance/src/lib.rs @@ -1,6 +1,10 @@ +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use yoi_plugin_pdk::wit_bindgen; -use yoi_plugin_pdk::{export_plugin_instance, Plugin, PluginIngressEvent, PluginStatus, ToolOutput}; +use yoi_plugin_pdk::{ + export_plugin_instance, Plugin, PluginIngressEvent, PluginStatus, ServiceOutput, ToolError, + ToolOutput, +}; wit_bindgen::generate!({ world: "instance", @@ -9,24 +13,42 @@ wit_bindgen::generate!({ runtime_path: "yoi_plugin_pdk::wit_bindgen::rt", }); +#[derive(Default)] struct ExamplePlugin { - calls: u64, + count: u64, +} + +#[derive(Deserialize)] +struct EchoInput { + text: String, +} + +#[derive(Serialize)] +struct EchoOutput { + text: String, + count: u64, } impl Plugin for ExamplePlugin { - fn start(_config: Value) -> yoi_plugin_pdk::Result { - Ok(Self { calls: 0 }) + fn start(config: Value) -> Result { + Ok(Self { + count: config.get("start_count").and_then(Value::as_u64).unwrap_or(0), + }) } - fn handle_tool(&mut self, name: &str, input: Value) -> yoi_plugin_pdk::Result { - self.calls += 1; + fn handle_tool(&mut self, name: &str, input: Value) -> Result { + if name != "example_echo" { + return Err(ToolError::invalid_input(format!("unknown tool: {name}"))); + } + let input: EchoInput = + serde_json::from_value(input).map_err(|err| ToolError::invalid_input(err.to_string()))?; + self.count += 1; ToolOutput::json( - format!("{name} handled by shared instance"), - json!({ - "tool": name, - "calls": self.calls, - "input": input - }), + format!("echoed {} bytes", input.text.len()), + EchoOutput { + text: input.text, + count: self.count, + }, ) } @@ -34,18 +56,29 @@ impl Plugin for ExamplePlugin { &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 - })) + ) -> Result { + if name != "example_ws" { + return Ok(ServiceOutput::accepted(json!({ "ignored": name }))?); + } + + let Some(text) = event.websocket_text() else { + return Ok(ServiceOutput::accepted(json!({ + "accepted": true, + "kind": event.kind, + }))?); + }; + + self.count += 1; + ServiceOutput::websocket_send( + &event, + format!("example-reply-{}", self.count), + event.source.strip_prefix("websocket:").unwrap_or(&event.source), + format!("echo({}): {text}", self.count), + ) } - fn status(&self) -> yoi_plugin_pdk::Result { - Ok(PluginStatus::ready(json!({ "calls": self.calls }))) + fn status(&self) -> Result { + Ok(PluginStatus::ready(json!({ "count": self.count }))) } } diff --git a/resources/plugin/wit/yoi-plugin-instance-v1.wit b/resources/plugin/wit/yoi-plugin-instance-v1.wit index 59f39cb6..68a2e46f 100644 --- a/resources/plugin/wit/yoi-plugin-instance-v1.wit +++ b/resources/plugin/wit/yoi-plugin-instance-v1.wit @@ -5,9 +5,55 @@ world instance { import yoi:host/websocket@1.0.0; import yoi:host/fs@1.0.0; + /// Start one host-managed Plugin instance. `config-json` is the opaque + /// enablement config copied from the Profile/plugin grant record. The return + /// string is PluginStatus JSON: `{ "state": "ready|running|stopped|...", + /// "data": }`. export start: func(config-json: string) -> string; + + /// Execute a manifest-declared Tool on the shared instance. `input-json` is + /// ordinary Tool input JSON and the return string is ToolOutput JSON. export handle-tool: func(name: string, input-json: string) -> string; + + /// Handle one host-dispatched Service/Ingress event. `event-json` is an + /// ingress event envelope with at least: + /// + /// ```json + /// { + /// "kind": "websocket_text|websocket_close|websocket_error|...", + /// "source": "websocket:wss://host/path|...", + /// "ingress_name": "manifest_ingress_name", + /// "payload": { "text": "..." }, + /// "created_at": "RFC3339 timestamp", + /// "attempt": 1, + /// "correlation_id": "host event id" + /// } + /// ``` + /// + /// The return string is ServiceOutput JSON. To request host-mediated side + /// effects, return top-level `output_commands`, for example: + /// + /// ```json + /// { + /// "accepted": true, + /// "output_commands": [{ + /// "correlation_id": "command correlation id", + /// "source_event_id": "matching ingress correlation_id", + /// "command_id": "guest command id", + /// "kind": "websocket_send", + /// "payload": { "url": "wss://host/path", "text": "reply" }, + /// "requested_at": "RFC3339 timestamp" + /// }] + /// } + /// ``` + /// + /// Output commands are parsed, bounded, grant-checked, and executed by the + /// host. They are not ordinary ToolOutput and do not inject hidden context. export handle-ingress: func(name: string, event-json: string) -> string; + + /// Return PluginStatus JSON for the shared host-managed instance. export status: func() -> string; + + /// Stop the shared instance and return final PluginStatus JSON. export stop: func() -> string; } diff --git a/resources/plugin/wit/yoi-plugin-tool-v1.wit b/resources/plugin/wit/yoi-plugin-tool-v1.wit index 8dd7eebe..6bd1a034 100644 --- a/resources/plugin/wit/yoi-plugin-tool-v1.wit +++ b/resources/plugin/wit/yoi-plugin-tool-v1.wit @@ -5,8 +5,8 @@ world tool { import yoi:host/websocket@1.0.0; import yoi:host/fs@1.0.0; - /// Execute a manifest-declared Tool. `input-json` is the normal Tool input - /// JSON and the returned string is the same ToolOutput JSON accepted by the - /// legacy raw-Wasm ABI. + /// Execute a manifest-declared Tool. `input-json` is ordinary Tool input JSON + /// and the returned string is ToolOutput JSON accepted by the current + /// Component Model Plugin runtime. export call: func(tool-name: string, input-json: string) -> string; }