feat: provide v2 Plugin Component contracts and offline validation

Validate exact Component interfaces and static Tool/DCDL schemas without guest execution. Share canonical archive packing and independent package/artifact verification; build real pure and HTTPS PDK fixtures and retire v1/Service authoring stubs.
This commit is contained in:
2026-10-28 13:31:47 +09:00
parent 06883c51b7
commit 2d195f4058
40 changed files with 3056 additions and 1662 deletions
@@ -1,38 +0,0 @@
//! Minimal Component Model Tool plugin authoring sketch using `yoi-plugin-pdk`.
//!
//! Build this as a `wasm32-unknown-unknown` cdylib with Component Model tooling
//! and package the adapted component as `plugin.component.wasm`.
use serde::{Deserialize, Serialize};
use yoi_plugin_pdk::wit_bindgen;
use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput};
wit_bindgen::generate!({
world: "tool",
path: "../../../resources/plugin/wit",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
#[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> {
ToolOutput::json(
format!("{} ok", ctx.tool_name()),
EchoOutput {
tool: ctx.tool_name(),
text: input.text,
},
)
}
yoi_plugin_pdk::export_component_tool!(Plugin, handle_echo);
Binary file not shown.
Binary file not shown.
+157 -198
View File
@@ -1,247 +1,206 @@
//! Check the shipped templates against the real PDK and WIT for the guest target.
//! Each check owns its source and lockfile; Cargo alone shares and validates artifacts.
//! Real Rust -> core Wasm -> Component coverage for the shipped starters.
//! Only filesystem paths are relocated; WIT and guest source are unchanged.
//! Requires the wasm32-unknown-unknown Rust std target and cached Cargo dependencies.
//! No guest is instantiated and no HTTPS Host is faked by this suite.
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::process::Command;
use toml::Value;
use wit_parser::WorldItem;
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 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");
// Preserve the removed document's source as a byte-identical fixture to keep the test input unchanged.
const SAMPLE_LIB: &str = include_str!("fixtures/component-tool-source.txt");
const PDK_CARGO: &str = include_str!("../Cargo.toml");
const TEMPLATES: [&str; 2] = ["rust-component-tool", "rust-component-https"];
#[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!(cargo["workspace"].is_table());
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-commit-sha>\""));
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("use yoi_plugin_pdk::wit_bindgen"));
assert!(TEMPLATE_LIB.contains("wit_bindgen::generate!"));
assert!(TEMPLATE_LIB.contains("generate_all"));
assert!(TEMPLATE_LIB.contains("runtime_path: \"yoi_plugin_pdk::wit_bindgen::rt\""));
assert!(TEMPLATE_LIB.contains("yoi_plugin_pdk::export_component_tool!"));
assert!(TEMPLATE_LIB.contains("ToolOutput::json"));
fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.unwrap()
}
#[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 = \"<pinned-yoi-commit-sha>\""));
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_PLUGIN.contains("kind = \"wasm\""));
fn template_wit_copies_match_the_versioned_contract() {
let root = root();
for template in TEMPLATES {
for file in ["yoi-plugin-v2.wit", "deps/yoi-host/yoi-host-v2.wit"] {
assert_eq!(
fs::read(root.join("resources/plugin/wit").join(file)).unwrap(),
fs::read(
root.join("resources/plugin/templates")
.join(template)
.join("wit")
.join(file)
)
.unwrap(),
"{template}/{file} must be regenerated from the canonical WIT"
);
}
}
}
#[test]
fn component_tool_fixture_uses_pdk_bindings() {
assert!(SAMPLE_LIB.contains("use yoi_plugin_pdk::wit_bindgen"));
assert!(SAMPLE_LIB.contains("wit_bindgen::generate!"));
assert!(SAMPLE_LIB.contains("generate_all"));
assert!(SAMPLE_LIB.contains("runtime_path: \"yoi_plugin_pdk::wit_bindgen::rt\""));
assert!(SAMPLE_LIB.contains("yoi_plugin_pdk::export_component_tool!"));
assert!(!SAMPLE_LIB.contains("export_name"));
fn template_manifest_worlds_use_exact_versions_and_schemas() {
for template in TEMPLATES {
let path = root().join("resources/plugin/templates").join(template);
let cargo: Value =
toml::from_str(&fs::read_to_string(path.join("Cargo.toml")).unwrap()).unwrap();
assert!(cargo["workspace"].is_table());
assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib"));
let manifest: Value =
toml::from_str(&fs::read_to_string(path.join("plugin.toml")).unwrap()).unwrap();
assert_eq!(manifest["schema_version"].as_integer(), Some(2));
assert_eq!(manifest["runtime"]["kind"].as_str(), Some("wasm-component"));
assert!(
manifest["runtime"]["world"]
.as_str()
.unwrap()
.ends_with("@2.0.0")
);
for tool in manifest["tools"].as_array().unwrap() {
for schema in ["input_schema", "output_schema"] {
assert_eq!(tool[schema]["type"].as_str(), Some("object"));
assert_eq!(tool[schema]["additionalProperties"].as_bool(), Some(false));
}
}
}
}
#[test]
fn embedded_template_cargo_checks_for_wasm_target() {
cargo_check_template("rust-component-tool");
fn pure_starter_builds_real_component_without_imports() {
build_component(
"rust-component-tool",
"yoi_rust_component_tool_template",
&[],
);
}
#[test]
fn embedded_service_template_cargo_checks_for_wasm_target() {
cargo_check_template("rust-component-instance");
fn https_starter_builds_real_component_with_only_explicit_host_imports() {
build_component(
"rust-component-https",
"yoi_rust_component_https_template",
&[
"yoi:host/connections@2.0.0",
"yoi:host/errors@2.0.0",
"yoi:host/http@2.0.0",
],
);
}
fn cargo_check_template(template: &str) {
let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let templates_dir = crate_dir.join("../../resources/plugin/templates");
let template_dir = templates_dir.join(template);
// A sibling fixture preserves the shipped relative PDK and WIT paths verbatim.
// Never create or remove a lockfile in the shipped template: another invocation
// may be checking it at the same time, or a developer may own that lockfile.
fn build_component(template: &str, library: &str, expected_imports: &[&str]) {
let root = root();
let sdk = root.join("target/sdk");
fs::create_dir_all(&sdk).unwrap();
let source = root.join("resources/plugin/templates").join(template);
let fixture = tempfile::Builder::new()
.prefix(".cargo-check-")
.tempdir_in(&templates_dir)
.expect("temporary template fixture");
copy_template_tree(&template_dir, fixture.path(), true);
// Both templates share the PDK dependencies. A separate persistent target
// reuses those artifacts without sharing source or the outer Cargo build lock.
// Cargo fingerprints and its target lock handle changes and concurrent checks.
let executable = std::env::current_exe().expect("current test executable");
let target_dir = executable
.parent()
.and_then(|deps| deps.parent())
.expect("test executable has a profile directory")
.join("compile-tests/plugin-pdk-templates");
.prefix("build-")
.tempdir_in(&sdk)
.unwrap();
copy_tree(&source, fixture.path());
let cargo_path = fixture.path().join("Cargo.toml");
let cargo = fs::read_to_string(&cargo_path).unwrap().replace(
"../../../../crates/plugin-pdk",
root.join("crates/plugin-pdk").to_str().unwrap(),
);
fs::write(&cargo_path, cargo).unwrap();
let target = sdk.join("guest");
let output = Command::new(env!("CARGO"))
.arg("check")
.args([
"build",
"--release",
"--offline",
"--target",
"wasm32-unknown-unknown",
"--target-dir",
])
.arg(&target)
.arg("--manifest-path")
.arg(fixture.path().join("Cargo.toml"))
.arg("--target")
.arg("wasm32-unknown-unknown")
.arg("--offline")
.arg("--target-dir")
.arg(target_dir)
.arg(&cargo_path)
.current_dir(fixture.path())
.env("CARGO_TERM_COLOR", "never")
.output()
.expect("spawn cargo check for embedded template");
.expect("run guest Cargo build");
let logs = sdk.join("logs");
fs::create_dir_all(&logs).unwrap();
fs::write(logs.join(format!("{template}.stdout.log")), &output.stdout).unwrap();
fs::write(logs.join(format!("{template}.stderr.log")), &output.stderr).unwrap();
assert!(
output.status.success(),
"template {template} cargo check failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}",
output.status,
"{template}: cargo build failed\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let core =
fs::read(target.join(format!("wasm32-unknown-unknown/release/{library}.wasm"))).unwrap();
let component = wit_component::ComponentEncoder::default()
.module(&core)
.unwrap()
.validate(true)
.encode()
.unwrap();
wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::all())
.validate_all(&component)
.unwrap();
let wit_parser::decoding::DecodedWasm::Component(resolve, world) =
wit_component::decode(&component).unwrap()
else {
panic!("expected Component, not WIT package")
};
let world = &resolve.worlds[world];
let mut imports: Vec<_> = world
.imports
.keys()
.map(|key| resolve.name_world_key(key))
.collect();
imports.sort();
assert_eq!(imports, expected_imports);
let exports: Vec<_> = world
.exports
.keys()
.map(|key| resolve.name_world_key(key))
.collect();
assert_eq!(exports, ["yoi:plugin/tool@2.0.0"]);
let WorldItem::Interface { id, .. } = world.exports.values().next().unwrap() else {
panic!("Tool interface export required")
};
assert_eq!(
resolve.interfaces[*id].functions.keys().collect::<Vec<_>>(),
[&"invoke".to_string()]
);
// Retain actual package inputs for the shared validator/pack owner, not a mock .wasm.
let package = sdk.join("fixtures").join(template);
fs::create_dir_all(&package).unwrap();
fs::copy(source.join("plugin.toml"), package.join("plugin.toml")).unwrap();
fs::write(package.join("plugin.component.wasm"), component).unwrap();
}
fn copy_template_tree(source: &Path, destination: &Path, is_root: bool) {
let metadata = fs::symlink_metadata(source).expect("read template directory metadata");
assert!(
metadata.is_dir() && !metadata.file_type().is_symlink(),
"template input must be a real directory: {}",
source.display()
);
for entry in fs::read_dir(source).expect("read template directory") {
let entry = entry.expect("read template entry");
let path = entry.path();
let kind = entry.file_type().expect("read template entry type");
assert!(
!kind.is_symlink() && (kind.is_dir() || kind.is_file()),
"unsupported template input (including symlinks): {}",
path.display()
);
fn copy_tree(source: &Path, destination: &Path) {
fs::create_dir_all(destination).unwrap();
for entry in fs::read_dir(source).unwrap() {
let entry = entry.unwrap();
let kind = entry.file_type().unwrap();
assert!(!kind.is_symlink(), "template may not contain symlinks");
let name = entry.file_name();
// Preserve all compiler inputs, including build scripts and nested assets.
// Only omit the root build output and lockfile: each isolated consumer must
// resolve its own lockfile, as the original unlocked template check did.
if is_root
&& ((kind.is_dir() && name == "target") || (kind.is_file() && name == "Cargo.lock"))
{
if name == "target" || name == "Cargo.lock" {
continue;
}
let copied = destination.join(name);
if kind.is_dir() {
fs::create_dir(&copied).expect("create template fixture directory");
copy_template_tree(&path, &copied, false);
copy_tree(&entry.path(), &copied);
} else {
fs::copy(&path, &copied).expect("copy unmodified template input");
fs::copy(entry.path(), copied).unwrap();
}
}
}
#[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 = [
"worker",
"yoi-worker",
"agen",
"tui",
"yoi-tui",
"client",
"yoi-client",
"manifest",
"yoi-manifest",
"ticket",
"yoi-ticket",
];
for name in forbidden {
let cargo: Value = toml::from_str(include_str!("../Cargo.toml")).unwrap();
for dependency in cargo["dependencies"].as_table().unwrap().keys() {
assert!(
!dependencies.contains_key(name),
"PDK must not depend on host/runtime crate `{name}`"
["serde", "serde_json", "thiserror", "wit-bindgen"].contains(&dependency.as_str()),
"unexpected Host dependency {dependency}"
);
}
}
+33 -11
View File
@@ -1,25 +1,47 @@
//! Compile-pass coverage for generated v2 bindings and the public adapter macro.
use yoi_plugin_pdk::wit_bindgen;
wit_bindgen::generate!({
world: "tool",
world: "tool-plugin",
path: "../../resources/plugin/wit",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
struct Probe;
fn handle(
ctx: yoi_plugin_pdk::ToolContext,
input: serde_json::Value,
) -> yoi_plugin_pdk::Result<yoi_plugin_pdk::ToolOutput> {
if ctx.tool_name() != "probe" {
return Err(yoi_plugin_pdk::ToolError::unsupported_tool(ctx.tool_name()));
}
yoi_plugin_pdk::ToolOutput::json("probe ok", input)
}
impl Guest for Probe {
fn call(tool_name: String, input_json: String) -> String {
yoi_plugin_pdk::run_json_tool(&tool_name, &input_json, |_ctx, input: serde_json::Value| {
yoi_plugin_pdk::ToolOutput::json("probe ok", input)
})
yoi_plugin_pdk::export_component_tool!(Probe, handle);
fn invocation(input_json: &str) -> exports::yoi::plugin::tool::Invocation {
exports::yoi::plugin::tool::Invocation {
invocation_id: "call-1".into(),
tool_name: "probe".into(),
input_json: input_json.into(),
config_json: "{}".into(),
remaining_ms: 1000,
}
}
#[test]
fn wit_bindgen_generates_current_tool_world() {
let output = <Probe as Guest>::call("probe".to_string(), r#"{"ok":true}"#.to_string());
let value: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(value["summary"], "probe ok");
fn generated_adapter_returns_record_and_keeps_business_error_in_error_arm() {
use exports::yoi::plugin::tool::Guest;
let output = Probe::invoke(invocation(r#"{"ok":true}"#)).unwrap();
assert_eq!(output.summary, "probe ok");
assert_eq!(output.content, None);
assert_eq!(output.structured_json.as_deref(), Some(r#"{"ok":true}"#));
assert_eq!(
Probe::invoke(invocation("broken")).unwrap_err().code,
"invalid_input"
);
let mut input = invocation("{}");
input.tool_name = "unknown".into();
assert_eq!(Probe::invoke(input).unwrap_err().code, "unsupported_tool");
}