diff --git a/Cargo.lock b/Cargo.lock index 1789ed70..a5c7fd56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2623,6 +2623,7 @@ dependencies = [ "wasmtime", "wat", "workflow", + "yoi-plugin-pdk", ] [[package]] @@ -5388,6 +5389,7 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ + "bitflags 2.11.0", "wit-bindgen-rust-macro", ] @@ -5553,6 +5555,17 @@ dependencies = [ "tempfile", ] +[[package]] +name = "yoi-plugin-pdk" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", + "toml", + "wit-bindgen", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 424360ab..2dadc297 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/secrets", "crates/manifest", "crates/pod", + "crates/plugin-pdk", "crates/yoi", "crates/pod-store", "crates/protocol", @@ -34,6 +35,7 @@ default-members = [ "crates/secrets", "crates/manifest", "crates/pod", + "crates/plugin-pdk", "crates/yoi", "crates/pod-store", "crates/protocol", @@ -65,6 +67,7 @@ memory = { path = "crates/memory" } ticket = { path = "crates/ticket" } project-record = { path = "crates/project-record" } pod = { path = "crates/pod" } +yoi-plugin-pdk = { path = "crates/plugin-pdk" } yoi = { path = "crates/yoi" } pod-registry = { path = "crates/pod-registry" } pod-store = { path = "crates/pod-store" } diff --git a/crates/manifest/src/plugin.rs b/crates/manifest/src/plugin.rs index 338620de..b005752b 100644 --- a/crates/manifest/src/plugin.rs +++ b/crates/manifest/src/plugin.rs @@ -15,6 +15,42 @@ const ZIP_COMPRESSION_STORED: u16 = 0; const ZIP_UNIX_SYMLINK_TYPE: u32 = 0o120000; const ZIP_UNIX_FILE_TYPE_MASK: u32 = 0o170000; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PluginTemplateResource { + pub path: &'static str, + pub contents: &'static str, +} + +/// Embedded starter template for Rust Component Model Tool Plugins. +/// +/// The template is data only: it performs no filesystem/network operations and +/// grants no authority. Future authoring CLI commands can materialize these +/// files into a chosen destination after applying their own overwrite policy. +pub const RUST_COMPONENT_TOOL_TEMPLATE: &[PluginTemplateResource] = &[ + PluginTemplateResource { + path: "Cargo.toml", + contents: include_str!( + "../../../resources/plugin/templates/rust-component-tool/Cargo.toml" + ), + }, + PluginTemplateResource { + path: "src/lib.rs", + contents: include_str!( + "../../../resources/plugin/templates/rust-component-tool/src/lib.rs" + ), + }, + PluginTemplateResource { + path: "plugin.toml", + contents: include_str!( + "../../../resources/plugin/templates/rust-component-tool/plugin.toml" + ), + }, + PluginTemplateResource { + path: "README.md", + contents: include_str!("../../../resources/plugin/templates/rust-component-tool/README.md"), + }, +]; + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct PluginConfig { @@ -1957,6 +1993,40 @@ mod tests { use super::*; use tempfile::TempDir; + #[test] + fn embedded_rust_component_tool_template_is_valid_package_shape() { + let paths: BTreeSet<_> = RUST_COMPONENT_TOOL_TEMPLATE + .iter() + .map(|file| file.path) + .collect(); + assert_eq!( + paths, + BTreeSet::from(["Cargo.toml", "src/lib.rs", "plugin.toml", "README.md"]) + ); + assert!( + RUST_COMPONENT_TOOL_TEMPLATE + .iter() + .all(|file| !file.path.starts_with('/') && !file.path.contains("..")) + ); + + let manifest_text = RUST_COMPONENT_TOOL_TEMPLATE + .iter() + .find(|file| file.path == "plugin.toml") + .unwrap() + .contents; + let manifest: PluginPackageManifest = toml::from_str(manifest_text).unwrap(); + assert_eq!(manifest.schema_version, SUPPORTED_PLUGIN_API_VERSION); + assert_eq!( + manifest.runtime.as_ref().unwrap().kind, + PLUGIN_RUNTIME_COMPONENT_KIND + ); + assert_eq!( + manifest.runtime.as_ref().unwrap().world.as_deref(), + Some(PLUGIN_COMPONENT_TOOL_WORLD) + ); + assert_eq!(manifest.tools.len(), 1); + } + #[test] fn discovers_valid_user_and_workspace_packages() { let temp = TempDir::new().unwrap(); diff --git a/crates/plugin-pdk/Cargo.toml b/crates/plugin-pdk/Cargo.toml new file mode 100644 index 00000000..025aa81e --- /dev/null +++ b/crates/plugin-pdk/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "yoi-plugin-pdk" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "Guest-side Rust PDK helpers for Yoi Component Model Tool plugins" +publish = false + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +thiserror.workspace = true +wit-bindgen = "0.51.0" + +[dev-dependencies] +toml.workspace = true diff --git a/crates/plugin-pdk/src/lib.rs b/crates/plugin-pdk/src/lib.rs new file mode 100644 index 00000000..898f953d --- /dev/null +++ b/crates/plugin-pdk/src/lib.rs @@ -0,0 +1,468 @@ +//! Guest-side helpers for Yoi Component Model Tool plugins. +//! +//! This crate is intentionally small and guest-only: it depends on JSON/WIT +//! binding support, but not on Yoi host/runtime crates. It grants no authority; +//! package manifests and host-side Plugin grants decide whether a Tool or host +//! API can run. +//! +//! Component authors still generate the WIT bindings in their guest crate, then +//! delegate the exported `call` function to [`run_json_tool`] or to the +//! [`export_component_tool!`] macro: +//! +//! ```ignore +//! yoi_plugin_pdk::wit_bindgen::generate!({ +//! world: "tool", +//! path: "../../../../resources/plugin/wit", +//! }); +//! +//! fn echo( +//! ctx: yoi_plugin_pdk::ToolContext, +//! input: EchoInput, +//! ) -> Result { +//! yoi_plugin_pdk::ToolOutput::json( +//! format!("{} ok", ctx.tool_name()), +//! EchoOutput { text: input.text }, +//! ) +//! } +//! +//! yoi_plugin_pdk::export_component_tool!(Plugin, echo); +//! ``` + +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; + +pub use wit_bindgen; + +/// Current Yoi Component Model Tool world targeted by this PDK. +pub const TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0"; + +/// Repository WIT for the current Tool world, exposed for authoring tools and +/// tests. Runtime components should still generate bindings at compile time. +pub const TOOL_WIT: &str = include_str!("../../../resources/plugin/wit/yoi-plugin-tool-v1.wit"); + +/// Repository WIT for the grant-bound host APIs importable by Tool components. +pub const HOST_WIT: &str = include_str!("../../../resources/plugin/wit/yoi-host-v1.wit"); + +/// Maximum serialized ToolOutput JSON accepted by Yoi's current Plugin runtime. +pub const MAX_TOOL_OUTPUT_BYTES: usize = 64 * 1024; +/// Maximum summary bytes accepted by Yoi's current Plugin runtime. +pub const MAX_SUMMARY_BYTES: usize = 1024; +/// Conservative content cap that leaves room for JSON framing and escaping. +pub const MAX_CONTENT_BYTES: usize = MAX_TOOL_OUTPUT_BYTES - MAX_SUMMARY_BYTES - 4096; +/// Maximum structured error message bytes retained by the PDK. +pub const MAX_ERROR_MESSAGE_BYTES: usize = 4096; + +/// Per-call context passed to a typed JSON handler. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ToolContext { + tool_name: String, +} + +impl ToolContext { + /// Create context for the manifest-declared Tool name selected by the host. + pub fn new(tool_name: impl Into) -> Self { + Self { + tool_name: bounded_text(tool_name.into(), MAX_SUMMARY_BYTES), + } + } + + /// Manifest-declared Tool name supplied by the host runtime. + pub fn tool_name(&self) -> &str { + &self.tool_name + } +} + +/// ToolOutput JSON shape accepted by the current Yoi Plugin runtime. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct ToolOutput { + summary: String, + #[serde(skip_serializing_if = "Option::is_none")] + content: Option, +} + +impl ToolOutput { + /// Create an ordinary Tool output. + pub fn new(summary: impl Into, content: Option) -> Self { + let mut output = Self { + summary: normalize_summary(summary.into()), + content: content.map(|value| bounded_text(value, MAX_CONTENT_BYTES)), + }; + output.bound_serialized_json(); + output + } + + /// Create a Tool output whose content is typed JSON. + pub fn json(summary: impl Into, value: impl Serialize) -> Result { + let content = serde_json::to_string(&value).map_err(ToolError::serialization)?; + let output = Self { + summary: normalize_summary(summary.into()), + content: Some(content), + }; + let serialized = serde_json::to_string(&output).map_err(ToolError::serialization)?; + if serialized.len() > MAX_TOOL_OUTPUT_BYTES { + return Err(ToolError::invalid_output(format!( + "serialized ToolOutput JSON exceeds {MAX_TOOL_OUTPUT_BYTES} bytes" + ))); + } + Ok(output) + } + + /// Create a summary-only Tool output. + pub fn summary(summary: impl Into) -> Self { + Self::new(summary, None) + } + + /// Return the bounded summary. + pub fn summary_text(&self) -> &str { + &self.summary + } + + /// Return optional detailed content. + pub fn content(&self) -> Option<&str> { + self.content.as_deref() + } + + /// Serialize to the ToolOutput JSON string returned by the WIT `call` export. + pub fn to_json_string(&self) -> String { + serde_json::to_string(self).unwrap_or_else(|_| { + r#"{"summary":"tool error: serialization","content":"{\"error\":{\"code\":\"serialization\",\"message\":\"failed to serialize ToolOutput\"}}"}"#.to_string() + }) + } + + fn bound_serialized_json(&mut self) { + loop { + let Ok(serialized) = serde_json::to_string(self) else { + return; + }; + if serialized.len() <= MAX_TOOL_OUTPUT_BYTES { + return; + } + let Some(content) = self.content.take() else { + return; + }; + if content.is_empty() { + return; + } + + let overflow = serialized.len() - MAX_TOOL_OUTPUT_BYTES; + let target = content.len().saturating_sub(overflow + "…".len()); + self.content = Some(bounded_text(content, target)); + } + } +} + +/// Stable, low-cardinality error codes for PDK-produced Tool errors. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolErrorCode { + InvalidInput, + InvalidOutput, + Serialization, + Denied, + UnsupportedTool, + Failed, +} + +impl ToolErrorCode { + pub fn as_str(self) -> &'static str { + match self { + Self::InvalidInput => "invalid_input", + Self::InvalidOutput => "invalid_output", + Self::Serialization => "serialization", + Self::Denied => "denied", + Self::UnsupportedTool => "unsupported_tool", + Self::Failed => "failed", + } + } +} + +/// Structured, bounded error that is rendered as ordinary ToolOutput JSON. +#[derive(Clone, Debug, PartialEq, Serialize, thiserror::Error)] +#[error("{code:?}: {message}")] +pub struct ToolError { + code: ToolErrorCode, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + details: Option, +} + +impl ToolError { + pub fn new(code: ToolErrorCode, message: impl Into) -> Self { + Self { + code, + message: bounded_text(message.into(), MAX_ERROR_MESSAGE_BYTES), + details: None, + } + } + + pub fn invalid_input(message: impl Into) -> Self { + Self::new(ToolErrorCode::InvalidInput, message) + } + + pub fn invalid_output(message: impl Into) -> Self { + Self::new(ToolErrorCode::InvalidOutput, message) + } + + pub fn serialization(error: impl std::fmt::Display) -> Self { + Self::new(ToolErrorCode::Serialization, error.to_string()) + } + + pub fn denied(message: impl Into) -> Self { + Self::new(ToolErrorCode::Denied, message) + } + + pub fn unsupported_tool(tool_name: impl Into) -> Self { + Self::new( + ToolErrorCode::UnsupportedTool, + format!("unsupported tool `{}`", tool_name.into()), + ) + } + + pub fn failed(message: impl Into) -> Self { + Self::new(ToolErrorCode::Failed, message) + } + + pub fn with_details(mut self, details: impl Serialize) -> Self { + self.details = serde_json::to_value(details).ok(); + self + } + + pub fn code(&self) -> ToolErrorCode { + self.code + } + + pub fn code_str(&self) -> &'static str { + self.code.as_str() + } + + pub fn message(&self) -> &str { + &self.message + } + + /// Render this error as ordinary ToolOutput JSON content. + pub fn into_tool_output(self) -> ToolOutput { + #[derive(Serialize)] + struct ErrorBody<'a> { + error: ErrorPayload<'a>, + } + + #[derive(Serialize)] + struct ErrorPayload<'a> { + code: &'static str, + message: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + details: Option<&'a Value>, + } + + let summary = format!("tool error: {}", self.code.as_str()); + let payload = ErrorBody { + error: ErrorPayload { + code: self.code.as_str(), + message: &self.message, + details: self.details.as_ref(), + }, + }; + let mut content = serde_json::to_string(&payload).unwrap_or_else(|_| { + format!( + r#"{{"error":{{"code":"{}","message":"failed to serialize error details"}}}}"#, + ToolErrorCode::Serialization.as_str() + ) + }); + if content.len() > MAX_CONTENT_BYTES { + let payload = ErrorBody { + error: ErrorPayload { + code: self.code.as_str(), + message: &self.message, + details: None, + }, + }; + content = serde_json::to_string(&payload).unwrap_or_else(|_| { + r#"{"error":{"code":"serialization","message":"failed to serialize error"}}"# + .to_string() + }); + } + ToolOutput::new(summary, Some(content)) + } +} + +/// Parse the WIT `input-json` string into a typed input value. +pub fn parse_json_input(input_json: &str) -> Result +where + T: DeserializeOwned, +{ + serde_json::from_str(input_json).map_err(|error| { + ToolError::invalid_input(format!( + "tool input is not valid JSON for this schema: {error}" + )) + }) +} + +/// Execute a typed JSON Tool handler and return ToolOutput JSON for the runtime. +/// +/// Handler errors and parse/serialization failures are not panics or host-side +/// authority decisions. They are rendered into ordinary ToolOutput JSON so the +/// runtime can route them through the normal Tool result path. +pub fn run_json_tool(tool_name: &str, input_json: &str, handler: F) -> String +where + I: DeserializeOwned, + F: FnOnce(ToolContext, I) -> Result, +{ + let result = parse_json_input::(input_json).and_then(|input| { + let context = ToolContext::new(tool_name); + handler(context, input) + }); + match result { + Ok(output) => output.to_json_string(), + Err(error) => error.into_tool_output().to_json_string(), + } +} + +/// Implement the generated Component Model `Guest` trait for a typed JSON +/// handler and export it with the `wit-bindgen` generated `export!` macro. +/// +/// The caller must invoke `yoi_plugin_pdk::wit_bindgen::generate!` for the +/// `tool` world first, which defines the `Guest` trait and `export!` macro in +/// the current module. The generated component still imports only WIT-declared +/// host APIs; this macro does not grant filesystem, network, or environment +/// authority. +#[macro_export] +macro_rules! export_component_tool { + ($adapter:ident, $handler:path) => { + struct $adapter; + + impl Guest for $adapter { + fn call( + tool_name: ::std::string::String, + input_json: ::std::string::String, + ) -> ::std::string::String { + $crate::run_json_tool(&tool_name, &input_json, $handler) + } + } + + export!($adapter); + }; +} + +fn normalize_summary(summary: String) -> String { + let summary = bounded_text(summary, MAX_SUMMARY_BYTES); + if summary.trim().is_empty() { + "tool completed".to_string() + } else { + summary + } +} + +fn bounded_text(mut value: String, max_bytes: usize) -> String { + if max_bytes == 0 { + return String::new(); + } + + value = value + .chars() + .map(|ch| { + if ch.is_control() && ch != '\n' && ch != '\t' { + ' ' + } else { + ch + } + }) + .collect(); + if value.len() <= max_bytes { + return value; + } + + let suffix = "…"; + let budget = max_bytes.saturating_sub(suffix.len()); + let mut cut = budget.min(value.len()); + while cut > 0 && !value.is_char_boundary(cut) { + cut -= 1; + } + value.truncate(cut); + value.push_str(suffix); + value +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + use serde_json::json; + + #[derive(Debug, Deserialize)] + struct EchoInput { + text: String, + } + + #[derive(Debug, Serialize)] + struct EchoOutput<'a> { + tool: &'a str, + text: String, + } + + #[test] + fn run_json_tool_parses_input_and_serializes_output() { + let output = run_json_tool( + "example_echo", + r#"{"text":"hello"}"#, + |ctx, input: EchoInput| { + ToolOutput::json( + format!("{} ok", ctx.tool_name()), + EchoOutput { + tool: ctx.tool_name(), + text: input.text, + }, + ) + }, + ); + + let value: Value = serde_json::from_str(&output).unwrap(); + assert_eq!(value["summary"], "example_echo ok"); + let content: Value = serde_json::from_str(value["content"].as_str().unwrap()).unwrap(); + assert_eq!(content, json!({"tool":"example_echo","text":"hello"})); + assert!(output.len() <= MAX_TOOL_OUTPUT_BYTES); + } + + #[test] + fn run_json_tool_renders_error_output() { + let output = + run_json_tool::("example_echo", r#"{"text":1}"#, |_ctx, _input| { + Ok(ToolOutput::summary("unreachable")) + }); + + let value: Value = serde_json::from_str(&output).unwrap(); + assert_eq!(value["summary"], "tool error: invalid_input"); + let content: Value = serde_json::from_str(value["content"].as_str().unwrap()).unwrap(); + assert_eq!(content["error"]["code"], "invalid_input"); + assert!(content["error"]["message"].as_str().unwrap().len() <= MAX_ERROR_MESSAGE_BYTES); + assert!(output.len() <= MAX_TOOL_OUTPUT_BYTES); + } + + #[test] + fn json_output_rejects_oversized_serialized_tool_output() { + let too_large = vec!["quoted \" text"; MAX_TOOL_OUTPUT_BYTES / 4]; + let error = ToolOutput::json("too large", too_large).unwrap_err(); + + assert_eq!(error.code(), ToolErrorCode::InvalidOutput); + } + + #[test] + fn tool_error_bounds_message_and_control_characters() { + let message = format!("bad\u{0007}{}", "x".repeat(MAX_ERROR_MESSAGE_BYTES * 2)); + let error = ToolError::failed(message); + + assert_eq!(error.code_str(), "failed"); + assert!(!error.message().contains('\u{0007}')); + assert!(error.message().len() <= MAX_ERROR_MESSAGE_BYTES + "…".len()); + } + + #[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 fs")); + } +} diff --git a/crates/plugin-pdk/tests/template.rs b/crates/plugin-pdk/tests/template.rs new file mode 100644 index 00000000..009cea58 --- /dev/null +++ b/crates/plugin-pdk/tests/template.rs @@ -0,0 +1,73 @@ +use toml::Value; + +const TEMPLATE_CARGO: &str = + include_str!("../../../resources/plugin/templates/rust-component-tool/Cargo.toml"); +const TEMPLATE_LIB: &str = + include_str!("../../../resources/plugin/templates/rust-component-tool/src/lib.rs"); +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 SAMPLE_LIB: &str = include_str!("../../../docs/examples/plugin-component-tool/lib.rs"); +const PDK_CARGO: &str = include_str!("../Cargo.toml"); + +#[test] +fn rust_component_tool_template_has_expected_files() { + let cargo: Value = toml::from_str(TEMPLATE_CARGO).expect("template 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!(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)); + assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component")); + assert_eq!( + plugin["runtime"]["world"].as_str(), + Some("yoi:plugin/tool@1.0.0") + ); + assert!(plugin["tools"].as_array().expect("tools array").len() == 1); + + assert!(TEMPLATE_LIB.contains("yoi_plugin_pdk::wit_bindgen::generate!")); + assert!(TEMPLATE_LIB.contains("yoi_plugin_pdk::export_component_tool!")); + assert!(TEMPLATE_LIB.contains("ToolOutput::json")); + assert!(TEMPLATE_README.contains("Component Model Tool Plugin")); +} + +#[test] +fn documented_sample_uses_pdk_component_path() { + assert!(SAMPLE_LIB.contains("yoi_plugin_pdk::wit_bindgen::generate!")); + assert!(SAMPLE_LIB.contains("yoi_plugin_pdk::export_component_tool!")); + assert!(!SAMPLE_LIB.contains("export_name")); +} + +#[test] +fn pdk_runtime_dependencies_are_guest_side_only() { + let cargo: Value = toml::from_str(PDK_CARGO).expect("PDK Cargo.toml parses"); + let dependencies = cargo["dependencies"] + .as_table() + .expect("dependencies table"); + let forbidden = [ + "pod", + "yoi-pod", + "llm-worker", + "tui", + "yoi-tui", + "client", + "yoi-client", + "manifest", + "yoi-manifest", + "ticket", + "yoi-ticket", + ]; + + for name in forbidden { + assert!( + !dependencies.contains_key(name), + "PDK must not depend on host/runtime crate `{name}`" + ); + } +} diff --git a/crates/pod/Cargo.toml b/crates/pod/Cargo.toml index 6bb0a8d6..42606ef0 100644 --- a/crates/pod/Cargo.toml +++ b/crates/pod/Cargo.toml @@ -44,6 +44,7 @@ dotenv = "0.15.0" futures = { workspace = true } tempfile = { workspace = true } wat = "1.241.2" +yoi-plugin-pdk = { workspace = true } [build-dependencies] toml = { workspace = true } diff --git a/crates/pod/src/feature/plugin.rs b/crates/pod/src/feature/plugin.rs index f03393f9..3ea94c52 100644 --- a/crates/pod/src/feature/plugin.rs +++ b/crates/pod/src/feature/plugin.rs @@ -3868,6 +3868,18 @@ mod tests { ); } + #[test] + fn pdk_tool_output_shape_is_accepted_by_wasm_decoder() { + let pdk_output = + yoi_plugin_pdk::ToolOutput::json("pdk ok", serde_json::json!({"answer": 42})) + .unwrap() + .to_json_string(); + + let output = decode_plugin_wasm_output(pdk_output.as_bytes()).unwrap(); + assert_eq!(output.summary, "pdk ok"); + assert_eq!(output.content.as_deref(), Some(r#"{"answer":42}"#)); + } + #[tokio::test] async fn malformed_input_json_fails_before_wasm_execution() { let (_dir, record) = resolved_record_with_wasm(input_reaches_guest_module()); diff --git a/docs/design/plugin-component-model.md b/docs/design/plugin-component-model.md index 1346e1b7..f3ab5ee5 100644 --- a/docs/design/plugin-component-model.md +++ b/docs/design/plugin-component-model.md @@ -100,7 +100,7 @@ The migration should be phased: 2. Add manifest/schema support for `runtime.kind = "wasm-component"` without executing it during discovery. 3. Add a component runtime backend and typed host import/export binding. 4. Port `https` and `fs` host API designs to WIT-compatible interfaces. -5. Add a Rust authoring SDK/template around the component world. +5. Add a Rust PDK/template around the component world. 6. Decide whether the raw ABI remains supported, becomes legacy-only, or is deprecated after examples and tests move. ## Runtime/backend caution @@ -161,10 +161,14 @@ Wrong `world`, missing artifact metadata, missing `call` export, unsupported imports, or core-Wasm bytes in a component package all fail closed with bounded Plugin diagnostics or ordinary Tool errors. -See `docs/examples/plugin-component-tool/lib.rs` for a minimal -`wit-bindgen`/SDK-style authoring sketch. Package authors should generate -bindings from `resources/plugin/wit`, build a component artifact, and set the -component runtime metadata above. +See `docs/examples/plugin-component-tool/lib.rs` and the embedded +`resources/plugin/templates/rust-component-tool/` starter for the preferred +Rust PDK authoring path. `yoi-plugin-pdk` is guest-side only: it re-exports +`wit-bindgen`, provides typed JSON input/output helpers, renders bounded +`ToolError` values as ordinary ToolOutput JSON, and does not depend on host +runtime crates or grant authority. Package authors should generate bindings from +`resources/plugin/wit`, build a component artifact, and set the component +runtime metadata above. ### v1 request/response shape diff --git a/docs/design/plugin-packages.md b/docs/design/plugin-packages.md index 3028a536..0a303361 100644 --- a/docs/design/plugin-packages.md +++ b/docs/design/plugin-packages.md @@ -52,7 +52,7 @@ entry = "plugin.wasm" abi = "yoi-plugin-wasm-1" ``` -The preferred future WASM authoring/runtime shape is the WebAssembly Component Model, recorded in [Plugin Component Model migration](plugin-component-model.md). Component packages should be explicit and source-compatible rather than silently changing the existing raw core-Wasm runtime: +The preferred WASM authoring/runtime shape is the WebAssembly Component Model, recorded in [Plugin Component Model migration](plugin-component-model.md). Component packages should be explicit and source-compatible rather than silently changing the existing raw core-Wasm runtime: ```toml [runtime] @@ -216,6 +216,13 @@ component = "plugin.component.wasm" world = "yoi:plugin/tool@1.0.0" ``` +For new Rust Tool packages, the preferred authoring path is the first-party +`yoi-plugin-pdk` plus the embedded `resources/plugin/templates/rust-component-tool/` +starter. The template uses a checkout-local path dependency for development and +documents a future out-of-tree pinned git `rev` dependency pattern. Crates.io +publication, remote template fetching, and package authoring commands are not +part of the current package/runtime contract. + This is separate from the legacy raw core-Wasm runtime: ```toml diff --git a/docs/development/plugin-development.md b/docs/development/plugin-development.md index 0c11bbd7..fc46081f 100644 --- a/docs/development/plugin-development.md +++ b/docs/development/plugin-development.md @@ -20,13 +20,15 @@ Implemented foundation: - Plugin permission grants; - raw core-Wasm Tool runtime; - Component Model Tool runtime; +- first-party Rust PDK helpers for Component Model Tool guests; +- embedded Rust Component Tool starter template; - `https` and `fs` host APIs for Tool runtime; - read-only `yoi plugin list/show` inspection. Still intentionally separate/future work: - `yoi plugin new/check/pack` authoring commands; -- polished multi-language SDK/PDK crates; +- multi-language SDK/PDK crates; - Service / Ingress surfaces; - WebSocket or inbound HTTP for bidirectional bridges; - public registry/install/update/signature tooling. @@ -90,9 +92,24 @@ abi = "yoi-plugin-wasm-1" Do not rely on package presence to activate anything. Discovery only records inventory. -## Component Model authoring sketch +## Component Model + Rust PDK authoring -Yoi's Component Model Tool world is stored in `resources/plugin/wit/`. A minimal Rust sketch is available at: +Component Model authoring with `yoi-plugin-pdk` is the preferred path for new Tool Plugins. The raw core-Wasm ABI remains available only as compatibility/transitional runtime support. + +Yoi's Component Model Tool world is stored in `resources/plugin/wit/`. The embedded Rust starter template is available as data in the Yoi source tree at: + +```text +resources/plugin/templates/rust-component-tool/ +``` + +It contains: + +- `Cargo.toml` with a checkout-local `yoi-plugin-pdk` path dependency; +- `src/lib.rs` with WIT binding generation and typed JSON Tool handling; +- `plugin.toml` targeting `kind = "wasm-component"` and `world = "yoi:plugin/tool@1.0.0"`; +- README next steps and the future out-of-tree pinned git `rev` dependency pattern. + +A minimal PDK-backed Rust sketch is also available at: ```text docs/examples/plugin-component-tool/lib.rs @@ -101,27 +118,43 @@ docs/examples/plugin-component-tool/lib.rs The important authoring shape is: ```rust -wit_bindgen::generate!({ +use serde::{Deserialize, Serialize}; +use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput}; + +yoi_plugin_pdk::wit_bindgen::generate!({ world: "tool", path: "../../../resources/plugin/wit", }); -struct Plugin; - -impl Guest for Plugin { - fn call(tool_name: String, input_json: String) -> String { - format!( - r#"{{"summary":"component tool {tool_name}","content":"input was {input_json}"}}"# - ) - } +#[derive(Deserialize)] +struct EchoInput { + text: String, } -export!(Plugin); +#[derive(Serialize)] +struct EchoOutput<'a> { + tool: &'a str, + text: String, +} + +fn handle_echo(ctx: ToolContext, input: EchoInput) -> Result { + ToolOutput::json( + format!("{} ok", ctx.tool_name()), + EchoOutput { + tool: ctx.tool_name(), + text: input.text, + }, + ) +} + +yoi_plugin_pdk::export_component_tool!(Plugin, handle_echo); ``` -The returned string is ordinary `ToolOutput` JSON. It is routed through the normal Tool result path; the component cannot inject hidden context. +`run_json_tool` parses the WIT `input-json` string into a typed input, passes a `ToolContext` containing the selected Tool name, and serializes `ToolOutput` JSON accepted by the current component runtime. `ToolError` values are structured and bounded, then rendered through the ordinary Tool result path; the component cannot inject hidden context. -The exact build pipeline depends on the authoring toolchain (`wit-bindgen`, component adapter tooling, etc.). Until `yoi plugin new/check/pack` exists, Plugin authors should treat the example as the ABI contract sketch and use `yoi plugin list/show` plus focused runtime tests to verify packages. +The PDK is guest-side only. It does not depend on Yoi host/runtime crates and does not grant filesystem, network, or environment authority. Host-side Plugin manifests and explicit enablement grants remain the authority boundary for Tool execution and for WIT host APIs such as `yoi:host/https` and `yoi:host/fs`. + +The exact component build pipeline depends on the authoring toolchain (`wit-bindgen`, component adapter tooling, etc.). Crates.io publication, remote template fetching, and `yoi plugin new/check/pack` are intentionally deferred. Until they exist, Plugin authors should treat the template/example as the ABI contract sketch and use `yoi plugin list/show` plus focused runtime tests to verify packages. ## Enabling a Plugin in a workspace diff --git a/docs/examples/plugin-component-tool/lib.rs b/docs/examples/plugin-component-tool/lib.rs index 5b2724f7..94afd294 100644 --- a/docs/examples/plugin-component-tool/lib.rs +++ b/docs/examples/plugin-component-tool/lib.rs @@ -1,23 +1,35 @@ -//! Minimal Component Model Tool plugin authoring sketch. +//! Minimal Component Model Tool plugin authoring sketch using `yoi-plugin-pdk`. //! -//! Build this as a `wasm32-unknown-unknown` cdylib with `wit-bindgen`-generated -//! exports and package the adapted component as `plugin.component.wasm`. +//! Build this as a `wasm32-unknown-unknown` cdylib with Component Model tooling +//! and package the adapted component as `plugin.component.wasm`. -wit_bindgen::generate!({ +use serde::{Deserialize, Serialize}; +use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput}; + +yoi_plugin_pdk::wit_bindgen::generate!({ world: "tool", path: "../../../resources/plugin/wit", }); -struct Plugin; - -impl Guest for Plugin { - fn call(tool_name: String, input_json: String) -> String { - // Ordinary ToolOutput JSON. The runtime routes this through the normal - // Worker/Tool result path; no context is injected by the component. - format!( - r#"{{"summary":"component tool {tool_name}","content":"input was {input_json}"}}"# - ) - } +#[derive(Debug, Deserialize)] +struct EchoInput { + text: String, } -export!(Plugin); +#[derive(Debug, Serialize)] +struct EchoOutput<'a> { + tool: &'a str, + text: String, +} + +fn handle_echo(ctx: ToolContext, input: EchoInput) -> Result { + ToolOutput::json( + format!("{} ok", ctx.tool_name()), + EchoOutput { + tool: ctx.tool_name(), + text: input.text, + }, + ) +} + +yoi_plugin_pdk::export_component_tool!(Plugin, handle_echo); diff --git a/package.nix b/package.nix index 17592a50..f1238c7a 100644 --- a/package.nix +++ b/package.nix @@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-i4U7wXPoWIHA4EAJZva2HQXNN8P5+RhGVGNBAOZVGk0="; + cargoHash = "sha256-gMDU496wWn3LYhlXwxczHW/tT3IJxclwJtIY6ZjomtQ="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint, diff --git a/resources/plugin/templates/rust-component-tool/Cargo.toml b/resources/plugin/templates/rust-component-tool/Cargo.toml new file mode 100644 index 00000000..e852c364 --- /dev/null +++ b/resources/plugin/templates/rust-component-tool/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "yoi-rust-component-tool-template" +version = "0.1.0" +edition = "2024" +license = "MIT" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" } + +# Future out-of-tree Plugin packages should pin the Yoi revision instead of +# relying on crates.io publication or remote template fetching, for example: +# yoi-plugin-pdk = { git = "https://github.com/example/yoi.git", package = "yoi-plugin-pdk", rev = "" } diff --git a/resources/plugin/templates/rust-component-tool/README.md b/resources/plugin/templates/rust-component-tool/README.md new file mode 100644 index 00000000..aa07b2bf --- /dev/null +++ b/resources/plugin/templates/rust-component-tool/README.md @@ -0,0 +1,39 @@ +# Rust Component Tool Template + +This is the embedded starter template for a Yoi Component Model Tool Plugin written with the first-party Rust PDK. + +## What this template demonstrates + +- `wasm-component` runtime targeting `yoi:plugin/tool@1.0.0`. +- Guest-side WIT binding generation through the PDK's `wit_bindgen` re-export. +- Typed JSON input parsing through `run_json_tool` via `export_component_tool!`. +- Typed JSON output serialization with `ToolOutput::json`. +- Structured, bounded `ToolError` output for user-visible Tool failures. + +The PDK is guest-side only. It does not grant filesystem, network, or environment authority. Host-side Plugin manifests and grants remain the authority boundary for Tool execution and host APIs. + +## Checkout/development dependency + +Inside the Yoi checkout this template uses a local path dependency: + +```toml +yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" } +``` + +If this template is copied elsewhere before crates.io publication exists, pin a Yoi source revision instead of fetching an unpinned remote template: + +```toml +yoi-plugin-pdk = { git = "https://github.com/example/yoi.git", package = "yoi-plugin-pdk", rev = "" } +``` + +Crates.io publication, remote template fetching, and `yoi plugin new/check/pack` are intentionally deferred to later authoring-tooling work. + +## Next steps + +1. Replace package/plugin ids, names, descriptions, and Tool schema. +2. Replace `EchoInput` / `EchoOutput` and `handle_echo` with your Tool logic. +3. Build a component for `wasm32-unknown-unknown` with the Component Model tooling used by your environment. +4. Package `plugin.toml` and `plugin.component.wasm` into a `.yoi-plugin` archive. +5. Use `yoi plugin list` / `yoi plugin show` plus focused runtime tests to inspect and validate the package. + +The exact component build/pack command is not part of this template yet because deterministic `yoi plugin new/check/pack` authoring commands are a separate planned Ticket. diff --git a/resources/plugin/templates/rust-component-tool/plugin.toml b/resources/plugin/templates/rust-component-tool/plugin.toml new file mode 100644 index 00000000..9c5192d0 --- /dev/null +++ b/resources/plugin/templates/rust-component-tool/plugin.toml @@ -0,0 +1,20 @@ +schema_version = 1 +id = "example.rust_component_tool" +name = "Rust Component Tool Template" +version = "0.1.0" +surfaces = ["tool"] +permissions = [ + { kind = "surface", surface = "tool" }, + { kind = "tool", name = "example_echo" }, +] + +[runtime] +kind = "wasm-component" +component = "plugin.component.wasm" +world = "yoi:plugin/tool@1.0.0" + +[[tools]] +name = "example_echo" +description = "Echo input text using the Rust PDK." +input_schema = { type = "object", properties = { text = { type = "string" } }, required = ["text"], additionalProperties = false } +external_write = false diff --git a/resources/plugin/templates/rust-component-tool/src/lib.rs b/resources/plugin/templates/rust-component-tool/src/lib.rs new file mode 100644 index 00000000..ab8301c7 --- /dev/null +++ b/resources/plugin/templates/rust-component-tool/src/lib.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; +use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput}; + +yoi_plugin_pdk::wit_bindgen::generate!({ + world: "tool", + path: "../../../../resources/plugin/wit", +}); + +#[derive(Debug, Deserialize)] +struct EchoInput { + text: String, +} + +#[derive(Debug, Serialize)] +struct EchoOutput<'a> { + tool: &'a str, + text: String, +} + +fn handle_echo(ctx: ToolContext, input: EchoInput) -> Result { + if input.text.trim().is_empty() { + return Err(ToolError::invalid_input("`text` must not be empty")); + } + + ToolOutput::json( + format!("{} echoed text", ctx.tool_name()), + EchoOutput { + tool: ctx.tool_name(), + text: input.text, + }, + ) +} + +yoi_plugin_pdk::export_component_tool!(Plugin, handle_echo);