plugin: add rust pdk template

This commit is contained in:
Keisuke Hirata 2026-06-20 14:15:20 +09:00
parent 5f7f81bdde
commit 06287aca40
No known key found for this signature in database
17 changed files with 859 additions and 37 deletions

13
Cargo.lock generated
View File

@ -2623,6 +2623,7 @@ dependencies = [
"wasmtime", "wasmtime",
"wat", "wat",
"workflow", "workflow",
"yoi-plugin-pdk",
] ]
[[package]] [[package]]
@ -5388,6 +5389,7 @@ version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [ dependencies = [
"bitflags 2.11.0",
"wit-bindgen-rust-macro", "wit-bindgen-rust-macro",
] ]
@ -5553,6 +5555,17 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "yoi-plugin-pdk"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.18",
"toml",
"wit-bindgen",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.2" version = "0.8.2"

View File

@ -9,6 +9,7 @@ members = [
"crates/secrets", "crates/secrets",
"crates/manifest", "crates/manifest",
"crates/pod", "crates/pod",
"crates/plugin-pdk",
"crates/yoi", "crates/yoi",
"crates/pod-store", "crates/pod-store",
"crates/protocol", "crates/protocol",
@ -34,6 +35,7 @@ default-members = [
"crates/secrets", "crates/secrets",
"crates/manifest", "crates/manifest",
"crates/pod", "crates/pod",
"crates/plugin-pdk",
"crates/yoi", "crates/yoi",
"crates/pod-store", "crates/pod-store",
"crates/protocol", "crates/protocol",
@ -65,6 +67,7 @@ memory = { path = "crates/memory" }
ticket = { path = "crates/ticket" } ticket = { path = "crates/ticket" }
project-record = { path = "crates/project-record" } project-record = { path = "crates/project-record" }
pod = { path = "crates/pod" } pod = { path = "crates/pod" }
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
yoi = { path = "crates/yoi" } yoi = { path = "crates/yoi" }
pod-registry = { path = "crates/pod-registry" } pod-registry = { path = "crates/pod-registry" }
pod-store = { path = "crates/pod-store" } pod-store = { path = "crates/pod-store" }

View File

@ -15,6 +15,42 @@ const ZIP_COMPRESSION_STORED: u16 = 0;
const ZIP_UNIX_SYMLINK_TYPE: u32 = 0o120000; const ZIP_UNIX_SYMLINK_TYPE: u32 = 0o120000;
const ZIP_UNIX_FILE_TYPE_MASK: u32 = 0o170000; 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)] #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct PluginConfig { pub struct PluginConfig {
@ -1957,6 +1993,40 @@ mod tests {
use super::*; use super::*;
use tempfile::TempDir; 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] #[test]
fn discovers_valid_user_and_workspace_packages() { fn discovers_valid_user_and_workspace_packages() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();

View File

@ -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

View File

@ -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, yoi_plugin_pdk::ToolError> {
//! 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<String>) -> 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<String>,
}
impl ToolOutput {
/// Create an ordinary Tool output.
pub fn new(summary: impl Into<String>, content: Option<String>) -> 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<String>, value: impl Serialize) -> Result<Self, ToolError> {
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<String>) -> 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<Value>,
}
impl ToolError {
pub fn new(code: ToolErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: bounded_text(message.into(), MAX_ERROR_MESSAGE_BYTES),
details: None,
}
}
pub fn invalid_input(message: impl Into<String>) -> Self {
Self::new(ToolErrorCode::InvalidInput, message)
}
pub fn invalid_output(message: impl Into<String>) -> 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<String>) -> Self {
Self::new(ToolErrorCode::Denied, message)
}
pub fn unsupported_tool(tool_name: impl Into<String>) -> Self {
Self::new(
ToolErrorCode::UnsupportedTool,
format!("unsupported tool `{}`", tool_name.into()),
)
}
pub fn failed(message: impl Into<String>) -> 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<T>(input_json: &str) -> Result<T, ToolError>
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<I, F>(tool_name: &str, input_json: &str, handler: F) -> String
where
I: DeserializeOwned,
F: FnOnce(ToolContext, I) -> Result<ToolOutput, ToolError>,
{
let result = parse_json_input::<I>(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::<EchoInput, _>("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"));
}
}

View File

@ -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 = \"<pinned-yoi-revision>\""));
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}`"
);
}
}

View File

@ -44,6 +44,7 @@ dotenv = "0.15.0"
futures = { workspace = true } futures = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }
wat = "1.241.2" wat = "1.241.2"
yoi-plugin-pdk = { workspace = true }
[build-dependencies] [build-dependencies]
toml = { workspace = true } toml = { workspace = true }

View File

@ -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] #[tokio::test]
async fn malformed_input_json_fails_before_wasm_execution() { async fn malformed_input_json_fails_before_wasm_execution() {
let (_dir, record) = resolved_record_with_wasm(input_reaches_guest_module()); let (_dir, record) = resolved_record_with_wasm(input_reaches_guest_module());

View File

@ -100,7 +100,7 @@ The migration should be phased:
2. Add manifest/schema support for `runtime.kind = "wasm-component"` without executing it during discovery. 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. 3. Add a component runtime backend and typed host import/export binding.
4. Port `https` and `fs` host API designs to WIT-compatible interfaces. 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. 6. Decide whether the raw ABI remains supported, becomes legacy-only, or is deprecated after examples and tests move.
## Runtime/backend caution ## 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 imports, or core-Wasm bytes in a component package all fail closed with bounded
Plugin diagnostics or ordinary Tool errors. Plugin diagnostics or ordinary Tool errors.
See `docs/examples/plugin-component-tool/lib.rs` for a minimal See `docs/examples/plugin-component-tool/lib.rs` and the embedded
`wit-bindgen`/SDK-style authoring sketch. Package authors should generate `resources/plugin/templates/rust-component-tool/` starter for the preferred
bindings from `resources/plugin/wit`, build a component artifact, and set the Rust PDK authoring path. `yoi-plugin-pdk` is guest-side only: it re-exports
component runtime metadata above. `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 ### v1 request/response shape

View File

@ -52,7 +52,7 @@ entry = "plugin.wasm"
abi = "yoi-plugin-wasm-1" 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 ```toml
[runtime] [runtime]
@ -216,6 +216,13 @@ component = "plugin.component.wasm"
world = "yoi:plugin/tool@1.0.0" 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: This is separate from the legacy raw core-Wasm runtime:
```toml ```toml

View File

@ -20,13 +20,15 @@ Implemented foundation:
- Plugin permission grants; - Plugin permission grants;
- raw core-Wasm Tool runtime; - raw core-Wasm Tool runtime;
- Component Model 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; - `https` and `fs` host APIs for Tool runtime;
- read-only `yoi plugin list/show` inspection. - read-only `yoi plugin list/show` inspection.
Still intentionally separate/future work: Still intentionally separate/future work:
- `yoi plugin new/check/pack` authoring commands; - `yoi plugin new/check/pack` authoring commands;
- polished multi-language SDK/PDK crates; - multi-language SDK/PDK crates;
- Service / Ingress surfaces; - Service / Ingress surfaces;
- WebSocket or inbound HTTP for bidirectional bridges; - WebSocket or inbound HTTP for bidirectional bridges;
- public registry/install/update/signature tooling. - 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. 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 ```text
docs/examples/plugin-component-tool/lib.rs docs/examples/plugin-component-tool/lib.rs
@ -101,27 +118,43 @@ docs/examples/plugin-component-tool/lib.rs
The important authoring shape is: The important authoring shape is:
```rust ```rust
wit_bindgen::generate!({ use serde::{Deserialize, Serialize};
use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput};
yoi_plugin_pdk::wit_bindgen::generate!({
world: "tool", world: "tool",
path: "../../../resources/plugin/wit", path: "../../../resources/plugin/wit",
}); });
struct Plugin; #[derive(Deserialize)]
struct EchoInput {
text: String,
}
impl Guest for Plugin { #[derive(Serialize)]
fn call(tool_name: String, input_json: String) -> String { struct EchoOutput<'a> {
format!( tool: &'a str,
r#"{{"summary":"component tool {tool_name}","content":"input was {input_json}"}}"# text: String,
}
fn handle_echo(ctx: ToolContext, input: EchoInput) -> Result<ToolOutput, ToolError> {
ToolOutput::json(
format!("{} ok", ctx.tool_name()),
EchoOutput {
tool: ctx.tool_name(),
text: input.text,
},
) )
} }
}
export!(Plugin); 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 ## Enabling a Plugin in a workspace

View File

@ -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 //! Build this as a `wasm32-unknown-unknown` cdylib with Component Model tooling
//! exports and package the adapted component as `plugin.component.wasm`. //! 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", world: "tool",
path: "../../../resources/plugin/wit", path: "../../../resources/plugin/wit",
}); });
struct Plugin; #[derive(Debug, Deserialize)]
struct EchoInput {
text: String,
}
impl Guest for Plugin { #[derive(Debug, Serialize)]
fn call(tool_name: String, input_json: String) -> String { struct EchoOutput<'a> {
// Ordinary ToolOutput JSON. The runtime routes this through the normal tool: &'a str,
// Worker/Tool result path; no context is injected by the component. text: String,
format!( }
r#"{{"summary":"component tool {tool_name}","content":"input was {input_json}"}}"#
fn handle_echo(ctx: ToolContext, input: EchoInput) -> Result<ToolOutput, ToolError> {
ToolOutput::json(
format!("{} ok", ctx.tool_name()),
EchoOutput {
tool: ctx.tool_name(),
text: input.text,
},
) )
} }
}
export!(Plugin); yoi_plugin_pdk::export_component_tool!(Plugin, handle_echo);

View File

@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter; filter = sourceFilter;
}; };
cargoHash = "sha256-i4U7wXPoWIHA4EAJZva2HQXNN8P5+RhGVGNBAOZVGk0="; cargoHash = "sha256-gMDU496wWn3LYhlXwxczHW/tT3IJxclwJtIY6ZjomtQ=";
depsExtraArgs = { depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint, # Older fetchCargoVendor utilities used crates.io's API download endpoint,

View File

@ -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 = "<pinned-yoi-revision>" }

View File

@ -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 = "<pinned-yoi-revision>" }
```
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.

View File

@ -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

View File

@ -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<ToolOutput, ToolError> {
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);