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:
Generated
+59
-6
@@ -1853,6 +1853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash 0.1.5",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2614,7 +2615,9 @@ dependencies = [
|
||||
"agen",
|
||||
"arc-swap",
|
||||
"builtin-source",
|
||||
"crc32fast",
|
||||
"decodal",
|
||||
"jsonschema",
|
||||
"protocol",
|
||||
"secrets",
|
||||
"serde",
|
||||
@@ -2626,6 +2629,9 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
"tracing",
|
||||
"wasmparser 0.244.0",
|
||||
"wit-component",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5822,7 +5828,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
"wasmparser 0.244.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.258.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f64765e43c000fae008a48db8b288e175e237fc9ce20a26586836878c1bee89f"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser 0.258.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5833,8 +5849,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
"wasm-encoder 0.244.0",
|
||||
"wasmparser 0.244.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5860,6 +5876,39 @@ dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.258.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe9269dd817e0e7021bf534ccd9761bc2d64778b6708e41228b8439d75eaa0b1"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wast"
|
||||
version = "258.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97f7defc7ecca8b19ac7f824598eadd0c53985ee00c74060d65051e9da5b58a1"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"leb128fmt",
|
||||
"memchr",
|
||||
"unicode-width",
|
||||
"wasm-encoder 0.258.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wat"
|
||||
version = "1.258.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7555c008cca87f2ac58d9f83ccda7e7b44611093ce28eb28f052e7c78024b9bf"
|
||||
dependencies = [
|
||||
"wast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6471,9 +6520,10 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-encoder 0.244.0",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wasmparser 0.244.0",
|
||||
"wat",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
@@ -6492,7 +6542,7 @@ dependencies = [
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
"wasmparser 0.244.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6739,7 +6789,10 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
"wasmparser 0.244.0",
|
||||
"wit-bindgen",
|
||||
"wit-component",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -15,11 +15,17 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_ignored = "0.1.14"
|
||||
sha2 = "0.10"
|
||||
crc32fast = "1"
|
||||
wasmparser = "0.244.0"
|
||||
wit-parser = "0.244.0"
|
||||
wit-component = "0.244.0"
|
||||
jsonschema = { version = "0.33.0", default-features = false }
|
||||
secrets = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wit-component = { version = "0.244.0", features = ["dummy-module"] }
|
||||
serial_test = "3.4.0"
|
||||
tempfile = { workspace = true }
|
||||
|
||||
+736
-488
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,572 @@
|
||||
//! Static validation of the exact, supported Plugin Component ABI.
|
||||
//!
|
||||
//! Component metadata is inferred from validated bytes, never trusted from a
|
||||
//! producer's custom sections. This module does not instantiate guest code.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use wit_component::{DecodedWasm, WitPrinter};
|
||||
use wit_parser::{InterfaceId, Resolve, WorldId, WorldItem, WorldKey};
|
||||
|
||||
const PLUGIN_WIT: &str = include_str!("../../../../resources/plugin/wit/yoi-plugin-v2.wit");
|
||||
const HOST_WIT: &str =
|
||||
include_str!("../../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit");
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ComponentMetadata {
|
||||
pub world: String,
|
||||
pub imports: Vec<String>,
|
||||
pub exports: Vec<String>,
|
||||
}
|
||||
|
||||
/// Validates an executable Component against the embedded exact-version world.
|
||||
///
|
||||
/// `required_imports` is a set (duplicates are rejected); it must equal both the
|
||||
/// selected world's imports and the actual Component imports. Returned names are
|
||||
/// fully qualified and sorted. The world name comes from the supported contract,
|
||||
/// not a producer-supplied custom section: executable Components do not encode
|
||||
/// the original WIT world name.
|
||||
pub fn validate_component(
|
||||
bytes: &[u8],
|
||||
world: &str,
|
||||
required_imports: &[String],
|
||||
) -> Result<ComponentMetadata, String> {
|
||||
let world_name = match world {
|
||||
"yoi:plugin/tool-plugin@2.0.0" => "tool-plugin",
|
||||
"yoi:plugin/https-tool-plugin@2.0.0" => "https-tool-plugin",
|
||||
"yoi:plugin/logged-tool-plugin@2.0.0" => "logged-tool-plugin",
|
||||
"yoi:plugin/https-logged-tool-plugin@2.0.0" => "https-logged-tool-plugin",
|
||||
_ => return Err(format!("unsupported Component world `{world}`")),
|
||||
};
|
||||
if !wasmparser::Parser::is_component(bytes) {
|
||||
return Err("expected a WebAssembly Component, not a core module".into());
|
||||
}
|
||||
wasmparser::Validator::new()
|
||||
.validate_all(bytes)
|
||||
.map_err(|error| format!("invalid WebAssembly Component: {error}"))?;
|
||||
let (actual, actual_world) = match wit_component::decode(bytes)
|
||||
.map_err(|error| format!("cannot decode Component interface: {error:#}"))?
|
||||
{
|
||||
DecodedWasm::Component(resolve, world) => (resolve, world),
|
||||
DecodedWasm::WitPackage(..) => {
|
||||
return Err("expected an executable Component, not a WIT type package".into());
|
||||
}
|
||||
};
|
||||
let (expected, expected_world) = contract(world_name)?;
|
||||
let expected_imports = surface(&expected, expected_world, true)?;
|
||||
let expected_exports = surface(&expected, expected_world, false)?;
|
||||
let actual_imports = surface(&actual, actual_world, true)?;
|
||||
let actual_exports = surface(&actual, actual_world, false)?;
|
||||
let required: BTreeSet<_> = required_imports.iter().cloned().collect();
|
||||
if required.len() != required_imports.len() {
|
||||
return Err("duplicate required Component import in manifest".into());
|
||||
}
|
||||
let imports: Vec<_> = expected_imports.keys().cloned().collect();
|
||||
let exports: Vec<_> = expected_exports.keys().cloned().collect();
|
||||
if required != expected_imports.keys().cloned().collect() {
|
||||
return Err(format!(
|
||||
"manifest required_imports do not match world `{world}`: expected {imports:?}"
|
||||
));
|
||||
}
|
||||
if actual_imports.keys().ne(expected_imports.keys()) {
|
||||
return Err(format!(
|
||||
"Component imports do not match world `{world}`: expected {imports:?}, found {:?}",
|
||||
actual_imports.keys().collect::<Vec<_>>()
|
||||
));
|
||||
}
|
||||
if actual_exports.keys().ne(expected_exports.keys()) {
|
||||
return Err(format!(
|
||||
"Component exports do not match world `{world}`: expected {exports:?}, found {:?}",
|
||||
actual_exports.keys().collect::<Vec<_>>()
|
||||
));
|
||||
}
|
||||
|
||||
// Compare all decoded interfaces, including those owning referenced types,
|
||||
// not only function names or the world surface. Printing preserves the
|
||||
// qualified owner of type aliases and resource identity, borrow/own, field
|
||||
// names/order, and nested signatures. Resolve::merge is not an ABI check.
|
||||
let expected_interfaces = named_interfaces(&expected)?;
|
||||
for (name, id) in named_interfaces(&actual)? {
|
||||
let expected_id = expected_interfaces
|
||||
.get(&name)
|
||||
.ok_or_else(|| format!("unsupported Component interface `{name}`"))?;
|
||||
if interface_text(&actual, id)? != interface_text(&expected, *expected_id)? {
|
||||
return Err(format!("Component interface `{name}` has an incorrect ABI"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ComponentMetadata {
|
||||
world: world.to_string(),
|
||||
imports,
|
||||
exports,
|
||||
})
|
||||
}
|
||||
|
||||
fn contract(world_name: &str) -> Result<(Resolve, WorldId), String> {
|
||||
let mut resolve = Resolve::default();
|
||||
resolve
|
||||
.push_str("yoi-host-v2.wit", HOST_WIT)
|
||||
.map_err(|error| format!("invalid embedded Host WIT: {error:#}"))?;
|
||||
let package = resolve
|
||||
.push_str("yoi-plugin-v2.wit", PLUGIN_WIT)
|
||||
.map_err(|error| format!("invalid embedded Plugin WIT: {error:#}"))?;
|
||||
let world = resolve
|
||||
.select_world(&[package], Some(world_name))
|
||||
.map_err(|error| format!("invalid embedded Plugin world: {error:#}"))?;
|
||||
Ok((resolve, world))
|
||||
}
|
||||
|
||||
fn surface(
|
||||
resolve: &Resolve,
|
||||
world: WorldId,
|
||||
imports: bool,
|
||||
) -> Result<BTreeMap<String, InterfaceId>, String> {
|
||||
let items = if imports {
|
||||
&resolve.worlds[world].imports
|
||||
} else {
|
||||
&resolve.worlds[world].exports
|
||||
};
|
||||
let mut names = BTreeMap::new();
|
||||
for (key, item) in items {
|
||||
let (WorldKey::Interface(key_id), WorldItem::Interface { id, .. }) = (key, item) else {
|
||||
return Err(
|
||||
"Component world contains an unsupported bare function/type or named interface"
|
||||
.into(),
|
||||
);
|
||||
};
|
||||
if key_id != id {
|
||||
return Err("Component interface identity does not match its world key".into());
|
||||
}
|
||||
let name = resolve
|
||||
.id_of(*id)
|
||||
.ok_or_else(|| "Component interface has no exact package identity".to_string())?;
|
||||
if names.insert(name.clone(), *id).is_some() {
|
||||
return Err(format!("duplicate Component interface `{name}`"));
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
fn named_interfaces(resolve: &Resolve) -> Result<BTreeMap<String, InterfaceId>, String> {
|
||||
let mut names = BTreeMap::new();
|
||||
for (id, _) in resolve.interfaces.iter() {
|
||||
let name = resolve
|
||||
.id_of(id)
|
||||
.ok_or_else(|| "Component interface has no exact package identity".to_string())?;
|
||||
if names.insert(name.clone(), id).is_some() {
|
||||
return Err(format!("duplicate Component interface `{name}`"));
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
fn interface_text(resolve: &Resolve, id: InterfaceId) -> Result<String, String> {
|
||||
// Declaration order is not ABI; record fields, variant cases, parameters,
|
||||
// and results remain ordered. Sorting maps also canonicalizes use aliases
|
||||
// and resource methods without changing their nominal owners.
|
||||
let mut resolve = resolve.clone();
|
||||
resolve.interfaces[id].types.sort_keys();
|
||||
resolve.interfaces[id].functions.sort_keys();
|
||||
let mut printer = WitPrinter::default();
|
||||
printer.emit_docs(false);
|
||||
printer
|
||||
.print_interface(&resolve, id)
|
||||
.map_err(|error| format!("cannot inspect Component ABI: {error:#}"))?;
|
||||
Ok(printer.output.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wit_component::{ComponentEncoder, StringEncoding, dummy_module, embed_component_metadata};
|
||||
use wit_parser::ManglingAndAbi;
|
||||
|
||||
const PURE: &str = "yoi:plugin/tool-plugin@2.0.0";
|
||||
const HTTPS: &str = "yoi:plugin/https-tool-plugin@2.0.0";
|
||||
const LOGGED: &str = "yoi:plugin/logged-tool-plugin@2.0.0";
|
||||
const HTTPS_LOGGED: &str = "yoi:plugin/https-logged-tool-plugin@2.0.0";
|
||||
|
||||
fn fixture(host: &str, plugin: &str, world_name: &str) -> Vec<u8> {
|
||||
let mut resolve = Resolve::default();
|
||||
resolve.push_str("host.wit", host).unwrap();
|
||||
let package = resolve.push_str("plugin.wit", plugin).unwrap();
|
||||
let world = resolve.select_world(&[package], Some(world_name)).unwrap();
|
||||
encode(&resolve, world)
|
||||
}
|
||||
|
||||
fn encode(resolve: &Resolve, world: WorldId) -> Vec<u8> {
|
||||
let mut module = dummy_module(resolve, world, ManglingAndAbi::Standard32);
|
||||
embed_component_metadata(&mut module, resolve, world, StringEncoding::UTF8).unwrap();
|
||||
ComponentEncoder::default()
|
||||
.module(&module)
|
||||
.unwrap()
|
||||
.validate(true)
|
||||
.encode()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn https_imports() -> Vec<String> {
|
||||
[
|
||||
"yoi:host/connections@2.0.0",
|
||||
"yoi:host/errors@2.0.0",
|
||||
"yoi:host/http@2.0.0",
|
||||
]
|
||||
.map(str::to_string)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn logged_imports(https: bool) -> Vec<String> {
|
||||
let mut imports = if https {
|
||||
https_imports()
|
||||
} else {
|
||||
vec!["yoi:host/errors@2.0.0".into()]
|
||||
};
|
||||
imports.push("yoi:host/logging@2.0.0".into());
|
||||
imports
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_world_components_return_sorted_metadata_without_running_guest() {
|
||||
for (name, world, imports) in [
|
||||
("tool-plugin", PURE, vec![]),
|
||||
("https-tool-plugin", HTTPS, https_imports()),
|
||||
("logged-tool-plugin", LOGGED, logged_imports(false)),
|
||||
(
|
||||
"https-logged-tool-plugin",
|
||||
HTTPS_LOGGED,
|
||||
logged_imports(true),
|
||||
),
|
||||
] {
|
||||
let component = fixture(HOST_WIT, PLUGIN_WIT, name);
|
||||
assert_eq!(
|
||||
validate_component(&component, world, &imports).unwrap(),
|
||||
ComponentMetadata {
|
||||
world: world.into(),
|
||||
imports,
|
||||
exports: vec!["yoi:plugin/tool@2.0.0".into()],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logging_worlds_require_exact_manifest_and_actual_import_sets() {
|
||||
for (name, world, imports) in [
|
||||
("logged-tool-plugin", LOGGED, logged_imports(false)),
|
||||
(
|
||||
"https-logged-tool-plugin",
|
||||
HTTPS_LOGGED,
|
||||
logged_imports(true),
|
||||
),
|
||||
] {
|
||||
let component = fixture(HOST_WIT, PLUGIN_WIT, name);
|
||||
let mut missing = imports.clone();
|
||||
missing.retain(|name| !name.contains("/logging@"));
|
||||
assert!(
|
||||
validate_component(&component, world, &missing)
|
||||
.unwrap_err()
|
||||
.contains("manifest required_imports")
|
||||
);
|
||||
let plugin = PLUGIN_WIT.replace("import yoi:host/logging@2.0.0;", "");
|
||||
let component = fixture(HOST_WIT, &plugin, name);
|
||||
assert!(
|
||||
validate_component(&component, world, &imports)
|
||||
.unwrap_err()
|
||||
.contains("Component imports")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logging_world_and_interface_versions_are_exact() {
|
||||
let logging = HOST_WIT
|
||||
.split_once("interface logging {")
|
||||
.unwrap()
|
||||
.1
|
||||
.replace(
|
||||
"use errors.{host-error};",
|
||||
"use yoi:host/errors@2.0.0.{host-error};",
|
||||
);
|
||||
for (name, world, imports) in [
|
||||
("logged-tool-plugin", LOGGED, logged_imports(false)),
|
||||
(
|
||||
"https-logged-tool-plugin",
|
||||
HTTPS_LOGGED,
|
||||
logged_imports(true),
|
||||
),
|
||||
] {
|
||||
let supported = fixture(HOST_WIT, PLUGIN_WIT, name);
|
||||
for version in ["1.0.0", "2.0.1", "9.0.0"] {
|
||||
let unsupported_world = world.replace("@2.0.0", &format!("@{version}"));
|
||||
assert!(
|
||||
validate_component(&supported, &unsupported_world, &imports)
|
||||
.unwrap_err()
|
||||
.contains("unsupported Component world")
|
||||
);
|
||||
let host = format!(
|
||||
"{HOST_WIT}\npackage yoi:host@{version} {{ interface logging {{ {logging} }}"
|
||||
);
|
||||
let plugin = PLUGIN_WIT.replace("logging@2.0.0", &format!("logging@{version}"));
|
||||
let component = fixture(&host, &plugin, name);
|
||||
assert!(
|
||||
validate_component(&component, world, &imports)
|
||||
.unwrap_err()
|
||||
.contains("Component imports")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_logging_nested_payload_and_signature_changes_are_rejected() {
|
||||
for (from, to) in [
|
||||
(
|
||||
"trace, debug, info, warn, error",
|
||||
"debug, trace, info, warn, error",
|
||||
),
|
||||
("number(f64)", "number(f32)"),
|
||||
("signed(s64)", "signed(u64)"),
|
||||
("value: scalar", "value: string"),
|
||||
("fields: list<field>", "fields: option<field>"),
|
||||
("event-code: string", "event-name: string"),
|
||||
(
|
||||
"message: string,\n fields:",
|
||||
"message: string, extra: bool,\n fields:",
|
||||
),
|
||||
("log: func(input: entry)", "log: func(input: string)"),
|
||||
(
|
||||
"log: func(input: entry) -> result<_, host-error>",
|
||||
"log: func(input: entry) -> result<string, host-error>",
|
||||
),
|
||||
("log: func", "extra: func(); log: func"),
|
||||
] {
|
||||
assert!(HOST_WIT.contains(from));
|
||||
let host = HOST_WIT.replace(from, to);
|
||||
for (name, world, imports) in [
|
||||
("logged-tool-plugin", LOGGED, logged_imports(false)),
|
||||
(
|
||||
"https-logged-tool-plugin",
|
||||
HTTPS_LOGGED,
|
||||
logged_imports(true),
|
||||
),
|
||||
] {
|
||||
let component = fixture(&host, PLUGIN_WIT, name);
|
||||
let error = validate_component(&component, world, &imports).unwrap_err();
|
||||
assert!(
|
||||
error.contains("yoi:host/logging@2.0.0") && error.contains("incorrect ABI"),
|
||||
"mutation: {from} -> {to}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validated_metadata_has_serializable_public_fields() {
|
||||
let component = fixture(HOST_WIT, PLUGIN_WIT, "logged-tool-plugin");
|
||||
let metadata = validate_component(&component, LOGGED, &logged_imports(false)).unwrap();
|
||||
let json = serde_json::to_value(&metadata).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({
|
||||
"world": LOGGED,
|
||||
"imports": ["yoi:host/errors@2.0.0", "yoi:host/logging@2.0.0"],
|
||||
"exports": ["yoi:plugin/tool@2.0.0"],
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ComponentMetadata>(json).unwrap(),
|
||||
metadata
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_unknown_and_wrong_manifest_worlds_are_rejected() {
|
||||
let component = fixture(HOST_WIT, PLUGIN_WIT, "tool-plugin");
|
||||
for world in [
|
||||
"yoi:plugin/tool-plugin@1.0.0",
|
||||
"yoi:plugin/tool-plugin@2.0.1",
|
||||
] {
|
||||
assert!(
|
||||
validate_component(&component, world, &[])
|
||||
.unwrap_err()
|
||||
.contains("unsupported Component world")
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
validate_component(&component, HTTPS, &https_imports())
|
||||
.unwrap_err()
|
||||
.contains("Component imports")
|
||||
);
|
||||
for version in ["1.0.0", "9.0.0"] {
|
||||
let changed = PLUGIN_WIT.replace("2.0.0", version);
|
||||
let changed_host = HOST_WIT.replace("2.0.0", version);
|
||||
let component = fixture(&changed_host, &changed, "tool-plugin");
|
||||
assert!(
|
||||
validate_component(&component, PURE, &[])
|
||||
.unwrap_err()
|
||||
.contains("Component exports")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_host_interface_versions_are_not_semver_compatible() {
|
||||
for version in ["1.0.0", "2.0.1", "9.0.0"] {
|
||||
let host = HOST_WIT.replace("@2.0.0", &format!("@{version}"));
|
||||
let plugin = PLUGIN_WIT
|
||||
.replace("errors@2.0.0", &format!("errors@{version}"))
|
||||
.replace("connections@2.0.0", &format!("connections@{version}"))
|
||||
.replace("http@2.0.0", &format!("http@{version}"))
|
||||
.replace("logging@2.0.0", &format!("logging@{version}"));
|
||||
let component = fixture(&host, &plugin, "https-tool-plugin");
|
||||
assert!(
|
||||
validate_component(&component, HTTPS, &https_imports())
|
||||
.unwrap_err()
|
||||
.contains("Component imports")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_import_set_must_equal_actual_and_supported_world() {
|
||||
let component = fixture(HOST_WIT, PLUGIN_WIT, "https-tool-plugin");
|
||||
let mut reordered = https_imports();
|
||||
reordered.reverse();
|
||||
assert!(validate_component(&component, HTTPS, &reordered).is_ok());
|
||||
let mut duplicate = https_imports();
|
||||
duplicate.push(duplicate[0].clone());
|
||||
for imports in [
|
||||
vec![],
|
||||
vec!["wasi:filesystem/types@0.2.0".into()],
|
||||
duplicate,
|
||||
] {
|
||||
assert!(validate_component(&component, HTTPS, &imports).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unadvertised_imports_and_extra_exports_are_rejected() {
|
||||
for (from, to) in [
|
||||
("export tool;", ""),
|
||||
(
|
||||
"world tool-plugin {",
|
||||
"world tool-plugin { import surprise: func();",
|
||||
),
|
||||
(
|
||||
"world tool-plugin {",
|
||||
"world tool-plugin { export surprise: func();",
|
||||
),
|
||||
(
|
||||
"world tool-plugin {",
|
||||
"world tool-plugin { import yoi:host/logging@2.0.0;",
|
||||
),
|
||||
] {
|
||||
let plugin = PLUGIN_WIT.replace(from, to);
|
||||
let component = fixture(HOST_WIT, &plugin, "tool-plugin");
|
||||
assert!(validate_component(&component, PURE, &[]).is_err());
|
||||
}
|
||||
let plugin = PLUGIN_WIT.replace(
|
||||
"world tool-plugin {",
|
||||
"world tool-plugin { import wasi:unpublished/io@0.2.0;",
|
||||
);
|
||||
let host = format!(
|
||||
"{HOST_WIT}\npackage wasi:unpublished@0.2.0 {{ interface io {{ read: func(); }} }}"
|
||||
);
|
||||
let component = fixture(&host, &plugin, "tool-plugin");
|
||||
assert!(validate_component(&component, PURE, &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_interface_name_with_changed_nested_signature_is_rejected() {
|
||||
for (from, to) in [
|
||||
("remaining-ms: u32", "remaining-ms: u64"),
|
||||
("remaining-ms: u32", "time-left: u32"),
|
||||
("content: option<string>", "content: list<string>"),
|
||||
("summary: string", "summary: string, extra: bool"),
|
||||
("input: invocation", "input: output"),
|
||||
("result<output, tool-error>", "result<tool-error, output>"),
|
||||
("invoke: func", "extra: func(); invoke: func"),
|
||||
] {
|
||||
assert!(PLUGIN_WIT.contains(from));
|
||||
let plugin = PLUGIN_WIT.replace(from, to);
|
||||
let component = fixture(HOST_WIT, &plugin, "tool-plugin");
|
||||
assert!(
|
||||
validate_component(&component, PURE, &[])
|
||||
.unwrap_err()
|
||||
.contains("incorrect ABI"),
|
||||
"mutation: {from} -> {to}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_resource_identity_ownership_and_nested_records_are_exact() {
|
||||
for (from, to) in [
|
||||
("borrow<connection>", "own<connection>"),
|
||||
("use connections.{connection};", "resource connection;"),
|
||||
("status: u16", "status: u32"),
|
||||
("value: string", "value: list<u8>"),
|
||||
(
|
||||
"resource connection;",
|
||||
"resource connection; resource other;",
|
||||
),
|
||||
("target: borrow<connection>", "target: borrow<other>"),
|
||||
("diagnostic-id: string", "diagnostic-id: u64"),
|
||||
] {
|
||||
let mut host = HOST_WIT.replace(from, to);
|
||||
if to == "target: borrow<other>" {
|
||||
host = host.replace("interface http {", "interface http { resource other;");
|
||||
}
|
||||
assert_ne!(host, HOST_WIT);
|
||||
let component = fixture(&host, PLUGIN_WIT, "https-tool-plugin");
|
||||
assert!(
|
||||
validate_component(&component, HTTPS, &https_imports())
|
||||
.unwrap_err()
|
||||
.contains("incorrect ABI"),
|
||||
"mutation: {from} -> {to}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_declaration_order_is_not_part_of_the_abi() {
|
||||
let (mut resolve, world) = contract("https-tool-plugin").unwrap();
|
||||
for (_, interface) in resolve.interfaces.iter_mut() {
|
||||
interface.types.reverse();
|
||||
interface.functions.reverse();
|
||||
}
|
||||
let component = encode(&resolve, world);
|
||||
assert!(validate_component(&component, HTTPS, &https_imports()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docs_are_not_part_of_the_abi() {
|
||||
let plugin = PLUGIN_WIT.replace(
|
||||
"interface tool {",
|
||||
"/// Different documentation.\ninterface tool {",
|
||||
);
|
||||
let component = fixture(HOST_WIT, &plugin, "tool-plugin");
|
||||
assert!(validate_component(&component, PURE, &[]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_module_wit_type_package_and_invalid_component_are_rejected() {
|
||||
let (resolve, world) = contract("tool-plugin").unwrap();
|
||||
let module = dummy_module(&resolve, world, ManglingAndAbi::Standard32);
|
||||
assert!(
|
||||
validate_component(&module, PURE, &[])
|
||||
.unwrap_err()
|
||||
.contains("core module")
|
||||
);
|
||||
let package = resolve.worlds[world].package.unwrap();
|
||||
let types = wit_component::encode(&resolve, package).unwrap();
|
||||
assert!(
|
||||
validate_component(&types, PURE, &[])
|
||||
.unwrap_err()
|
||||
.contains("WIT type package")
|
||||
);
|
||||
let mut component = encode(&resolve, world);
|
||||
component.pop();
|
||||
assert!(validate_component(&component, PURE, &[]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
//! Offline schema admission shared by package consumers. No guest, DCDL evaluator,
|
||||
//! import loader, or ambient JSON Schema retriever participates in validation.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use decodal::{Ast, Expr, ExprId, Literal, SyntaxTokenKind as Token};
|
||||
use serde_json::Value;
|
||||
|
||||
const MAX_SCHEMA_BYTES: usize = 64 * 1024;
|
||||
const MAX_DEPTH: usize = 32;
|
||||
const MAX_NODES: usize = 4096;
|
||||
const TOOL_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema";
|
||||
|
||||
/// Admit a bounded, offline JSON Schema 2020-12 document (the default dialect).
|
||||
///
|
||||
/// Unknown keywords/dialects, resource IDs, dynamic/anchor/external references,
|
||||
/// dangling references and recursive reference graphs are unsupported and rejected.
|
||||
/// Local JSON Pointer references must target schema positions, not annotation data.
|
||||
/// This checks the schema, not a Tool payload; callers must still validate payloads.
|
||||
pub fn validate_tool_schema(schema: &Value) -> Result<(), String> {
|
||||
bound_json(schema)?;
|
||||
let mut graph = BTreeMap::new();
|
||||
inspect_tool_schema(schema, "", &mut graph)?;
|
||||
let mut complete = BTreeMap::new();
|
||||
for path in graph.keys() {
|
||||
check_reference_graph(path, &graph, &mut BTreeSet::new(), &mut complete, 0)?;
|
||||
}
|
||||
// Use the fixed built-in meta-schema, never schema-directed dialect retrieval.
|
||||
jsonschema::draft202012::meta::validate(schema)
|
||||
.map_err(|error| format!("invalid Tool JSON Schema: {error}"))?;
|
||||
// The compiler normally skips unreferenced $defs. Compile every admitted
|
||||
// schema position through one in-memory resource, so invalid regexes/formats
|
||||
// cannot hide in an unused definition. These URNs are generated, not input.
|
||||
const RESOURCE: &str = "urn:yoi:plugin:tool-schema";
|
||||
let resource = jsonschema::Resource::from_contents(schema.clone())
|
||||
.map_err(|error| format!("invalid Tool JSON Schema resource: {error}"))?;
|
||||
let all_schemas = serde_json::json!({
|
||||
"allOf": graph.keys().map(|path| {
|
||||
serde_json::json!({"$ref": format!("{RESOURCE}#{}", uri_fragment(path))})
|
||||
}).collect::<Vec<_>>()
|
||||
});
|
||||
jsonschema::options()
|
||||
.with_draft(jsonschema::Draft::Draft202012)
|
||||
.with_resource(RESOURCE, resource)
|
||||
.with_retriever(NoRetrieval)
|
||||
.should_validate_formats(true)
|
||||
.should_ignore_unknown_formats(false)
|
||||
.build(&all_schemas)
|
||||
.map_err(|error| format!("invalid Tool JSON Schema: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct NoRetrieval;
|
||||
|
||||
impl jsonschema::Retrieve for NoRetrieval {
|
||||
fn retrieve(
|
||||
&self,
|
||||
_uri: &jsonschema::Uri<String>,
|
||||
) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||
Err("external schema retrieval is forbidden".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn bound_json(value: &Value) -> Result<(), String> {
|
||||
let mut stack = vec![(value, 0)];
|
||||
let mut count = 0;
|
||||
while let Some((value, depth)) = stack.pop() {
|
||||
count += 1;
|
||||
if count > MAX_NODES || depth > MAX_DEPTH {
|
||||
return Err("Tool JSON Schema exceeds static complexity limits".into());
|
||||
}
|
||||
match value {
|
||||
Value::Array(values) => stack.extend(values.iter().map(|v| (v, depth + 1))),
|
||||
Value::Object(values) => stack.extend(values.values().map(|v| (v, depth + 1))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if serde_json::to_vec(value).map_err(|e| e.to_string())?.len() > MAX_SCHEMA_BYTES {
|
||||
return Err("Tool JSON Schema exceeds static byte limit".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pointer_child(parent: &str, key: &str) -> String {
|
||||
format!("{parent}/{}", key.replace('~', "~0").replace('/', "~1"))
|
||||
}
|
||||
|
||||
fn uri_fragment(pointer: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
const HEX: &[u8; 16] = b"0123456789ABCDEF";
|
||||
for byte in pointer.bytes() {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/') {
|
||||
encoded.push(char::from(byte));
|
||||
} else {
|
||||
encoded.push('%');
|
||||
encoded.push(char::from(HEX[usize::from(byte >> 4)]));
|
||||
encoded.push(char::from(HEX[usize::from(byte & 15)]));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn inspect_tool_schema(
|
||||
schema: &Value,
|
||||
path: &str,
|
||||
graph: &mut BTreeMap<String, Vec<String>>,
|
||||
) -> Result<(), String> {
|
||||
graph.insert(path.into(), Vec::new());
|
||||
if schema.is_boolean() {
|
||||
return Ok(());
|
||||
}
|
||||
let object = schema
|
||||
.as_object()
|
||||
.ok_or_else(|| format!("schema at #{path} must be an object or boolean"))?;
|
||||
let mut children = Vec::new();
|
||||
let mut references = Vec::new();
|
||||
for (key, value) in object {
|
||||
let location = pointer_child(path, key);
|
||||
match key.as_str() {
|
||||
"$schema" => {
|
||||
if !path.is_empty() || value.as_str() != Some(TOOL_DIALECT) {
|
||||
return Err(format!("unsupported Tool schema dialect at #{location}"));
|
||||
}
|
||||
}
|
||||
"$ref" => {
|
||||
let reference = value
|
||||
.as_str()
|
||||
.ok_or_else(|| format!("$ref at #{location} must be a string"))?;
|
||||
let target = reference.strip_prefix('#').ok_or_else(|| {
|
||||
format!("external Tool schema reference at #{location} is forbidden")
|
||||
})?;
|
||||
if (!target.is_empty() && !target.starts_with('/')) || target.contains('%') {
|
||||
return Err(format!(
|
||||
"only local JSON Pointer $ref is supported at #{location}"
|
||||
));
|
||||
}
|
||||
references.push(target.to_owned());
|
||||
}
|
||||
"$defs" | "properties" | "patternProperties" | "dependentSchemas" => {
|
||||
let entries = value
|
||||
.as_object()
|
||||
.ok_or_else(|| format!("schema map at #{location} must be an object"))?;
|
||||
for (name, child) in entries {
|
||||
children.push((pointer_child(&location, name), child));
|
||||
}
|
||||
}
|
||||
"allOf" | "anyOf" | "oneOf" | "prefixItems" => {
|
||||
let entries = value
|
||||
.as_array()
|
||||
.ok_or_else(|| format!("schema list at #{location} must be an array"))?;
|
||||
for (index, child) in entries.iter().enumerate() {
|
||||
children.push((pointer_child(&location, &index.to_string()), child));
|
||||
}
|
||||
}
|
||||
"items"
|
||||
| "contains"
|
||||
| "additionalProperties"
|
||||
| "unevaluatedProperties"
|
||||
| "unevaluatedItems"
|
||||
| "propertyNames"
|
||||
| "not"
|
||||
| "if"
|
||||
| "then"
|
||||
| "else" => {
|
||||
children.push((location, value));
|
||||
}
|
||||
// These contain data, not subschemas. A property name or literal value
|
||||
// resembling a keyword must not acquire schema semantics.
|
||||
"type" | "enum" | "const" | "multipleOf" | "maximum" | "exclusiveMaximum"
|
||||
| "minimum" | "exclusiveMinimum" | "maxLength" | "minLength" | "pattern" | "format"
|
||||
| "maxItems" | "minItems" | "uniqueItems" | "maxContains" | "minContains"
|
||||
| "maxProperties" | "minProperties" | "required" | "dependentRequired" | "title"
|
||||
| "description" | "default" | "examples" | "deprecated" | "readOnly" | "writeOnly"
|
||||
| "$comment" => {}
|
||||
_ => return Err(format!("unsupported Tool schema keyword at #{location}")),
|
||||
}
|
||||
}
|
||||
references.extend(children.iter().map(|(path, _)| path.clone()));
|
||||
graph.insert(path.into(), references);
|
||||
for (path, child) in children {
|
||||
inspect_tool_schema(child, &path, graph)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_reference_graph(
|
||||
path: &str,
|
||||
graph: &BTreeMap<String, Vec<String>>,
|
||||
active: &mut BTreeSet<String>,
|
||||
complete: &mut BTreeMap<String, usize>,
|
||||
depth: usize,
|
||||
) -> Result<usize, String> {
|
||||
if active.contains(path) {
|
||||
return Err(format!(
|
||||
"recursive Tool schema reference at #{path} is unsupported"
|
||||
));
|
||||
}
|
||||
if depth > MAX_DEPTH {
|
||||
return Err("Tool schema reference graph exceeds static depth limit".into());
|
||||
}
|
||||
let children = graph
|
||||
.get(path)
|
||||
.ok_or_else(|| format!("Tool schema reference does not target a schema: #{path}"))?;
|
||||
if let Some(height) = complete.get(path) {
|
||||
if depth + height > MAX_DEPTH {
|
||||
return Err("Tool schema reference graph exceeds static depth limit".into());
|
||||
}
|
||||
return Ok(*height);
|
||||
}
|
||||
active.insert(path.into());
|
||||
let mut height = 0;
|
||||
for child in children {
|
||||
height = height.max(1 + check_reference_graph(child, graph, active, complete, depth + 1)?);
|
||||
}
|
||||
active.remove(path);
|
||||
complete.insert(path.into(), height);
|
||||
Ok(height)
|
||||
}
|
||||
|
||||
/// Admit a closed static subset of actual DCDL syntax using decodal's parser/AST.
|
||||
///
|
||||
/// The root must be an object. Nested closed objects, homogeneous array constraints
|
||||
/// (`[...String]`), primitive types (`String`, `Bool`, `Int`, `Float`) and scalar
|
||||
/// singleton literals are supported. Object fields must have unique single-part
|
||||
/// paths. Imports, open objects/maps, Unknown, defaults, references, expressions,
|
||||
/// calls and user functions are rejected, not evaluated. This does not validate a
|
||||
/// config instance or establish that its values contain no secrets.
|
||||
pub fn validate_config_schema(source: &str) -> Result<(), String> {
|
||||
if source.len() > MAX_SCHEMA_BYTES {
|
||||
return Err("DCDL config schema exceeds static byte limit".into());
|
||||
}
|
||||
let tokens = decodal::tokenize_source(source)
|
||||
.map_err(|error| format!("invalid DCDL config schema: {}", error.message))?;
|
||||
if tokens.len() > MAX_NODES {
|
||||
return Err("DCDL config schema exceeds static token limit".into());
|
||||
}
|
||||
// Guard parser recursion before parsing untrusted source. Forbid operators
|
||||
// outside the subset here as well, so long right-associative expressions
|
||||
// cannot consume the parser stack before the AST admission check.
|
||||
let mut depth = 0usize;
|
||||
for (index, token) in tokens.iter().enumerate() {
|
||||
match &token.kind {
|
||||
Token::LBrace | Token::LBracket | Token::LParen => {
|
||||
depth += 1;
|
||||
if depth > MAX_DEPTH {
|
||||
return Err("DCDL config schema exceeds static depth limit".into());
|
||||
}
|
||||
}
|
||||
Token::RBrace | Token::RBracket | Token::RParen => {
|
||||
depth = depth.saturating_sub(1);
|
||||
}
|
||||
Token::Minus
|
||||
if tokens
|
||||
.get(index + 1)
|
||||
.is_some_and(|next| matches!(next.kind, Token::Int(_) | Token::Float(_))) => {}
|
||||
Token::Ident(_)
|
||||
| Token::Int(_)
|
||||
| Token::Float(_)
|
||||
| Token::String(_)
|
||||
| Token::True
|
||||
| Token::False
|
||||
| Token::Semicolon
|
||||
| Token::Comma
|
||||
| Token::Ellipsis
|
||||
| Token::Colon
|
||||
| Token::Equal
|
||||
| Token::Comment
|
||||
| Token::Eof => {}
|
||||
_ => return Err("unsupported expression in static DCDL config schema".into()),
|
||||
}
|
||||
}
|
||||
let parsed = decodal::parse_source(source)
|
||||
.map_err(|error| format!("invalid DCDL config schema: {}", error.message))?;
|
||||
let root = unparenthesized(&parsed.ast, parsed.root);
|
||||
if !matches!(parsed.ast.get(root).expr, Expr::Object { .. }) {
|
||||
return Err("DCDL config schema root must be a closed object".into());
|
||||
}
|
||||
inspect_config_schema(&parsed.ast, root)
|
||||
}
|
||||
|
||||
fn unparenthesized(ast: &Ast, mut id: ExprId) -> ExprId {
|
||||
while let Expr::Parenthesized { expr } = ast.get(id).expr {
|
||||
id = expr;
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn inspect_config_schema(ast: &Ast, id: ExprId) -> Result<(), String> {
|
||||
match &ast.get(unparenthesized(ast, id)).expr {
|
||||
Expr::Object { fields, rest } => {
|
||||
if rest.is_some() {
|
||||
return Err(
|
||||
"DCDL config schema objects must be closed (no rest constraint)".into(),
|
||||
);
|
||||
}
|
||||
let mut names = BTreeSet::new();
|
||||
for field in fields {
|
||||
if field.path.len() != 1 || !names.insert(&field.path[0]) {
|
||||
return Err("DCDL config schema requires unique single-part field paths".into());
|
||||
}
|
||||
inspect_config_schema(ast, field.value)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Expr::ArrayConstraint { item } => inspect_config_schema(ast, *item),
|
||||
Expr::Ident(name) if matches!(name.as_str(), "String" | "Bool" | "Int" | "Float") => Ok(()),
|
||||
Expr::Literal(Literal::Float(value)) if !value.is_finite() => {
|
||||
Err("DCDL config schema requires finite scalar literals".into())
|
||||
}
|
||||
Expr::Literal(_) => Ok(()),
|
||||
Expr::Unary {
|
||||
op: decodal::ast::UnaryOp::Neg,
|
||||
expr,
|
||||
} if matches!(ast.get(*expr).expr, Expr::Literal(Literal::Int(_)))
|
||||
|| matches!(ast.get(*expr).expr, Expr::Literal(Literal::Float(v)) if v.is_finite()) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
_ => Err("unsupported node in closed static DCDL config schema".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn tool_schema_accepts_typed_objects_and_local_definitions() {
|
||||
let schema = json!({
|
||||
"$schema": TOOL_DIALECT,
|
||||
"$defs": {"entry/type": {"type": "string", "minLength": 1}},
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {"$ref": "#/$defs/entry~1type"},
|
||||
"values": {"type": "array", "items": {"type": "integer"}}
|
||||
},
|
||||
"required": ["label"],
|
||||
"additionalProperties": false
|
||||
});
|
||||
validate_tool_schema(&schema).unwrap();
|
||||
for schema in [json!({}), json!(true), json!(false)] {
|
||||
validate_tool_schema(&schema).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_rejects_invalid_keyword_shapes_and_types() {
|
||||
for schema in [
|
||||
json!(null),
|
||||
json!([]),
|
||||
json!({"type": "unknown"}),
|
||||
json!({"required": "label"}),
|
||||
json!({"properties": []}),
|
||||
json!({"properties": {"nested": {"type": 3}}}),
|
||||
json!({"allOf": []}),
|
||||
json!({"items": []}),
|
||||
json!({"minLength": -1}),
|
||||
json!({"pattern": "["}),
|
||||
json!({"required": ["x", "x"]}),
|
||||
json!({"format": "unknown-format"}),
|
||||
] {
|
||||
assert!(validate_tool_schema(&schema).is_err(), "accepted {schema}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_rejects_unknown_dialects_keywords_and_nested_extensions() {
|
||||
for schema in [
|
||||
json!({"$schema": "https://example.invalid/schema"}),
|
||||
json!({"$schema": "http://json-schema.org/draft-07/schema#"}),
|
||||
json!({"typo": true}),
|
||||
json!({"$id": "https://example.invalid/base"}),
|
||||
json!({"$dynamicRef": "#node"}),
|
||||
json!({"$anchor": "node"}),
|
||||
json!({"$defs": {"unused": {"typo": true}}}),
|
||||
json!({"properties": {"x": {"$schema": TOOL_DIALECT}}}),
|
||||
json!({"contentSchema": {"type": "string"}}),
|
||||
] {
|
||||
assert!(validate_tool_schema(&schema).is_err(), "accepted {schema}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_rejects_external_dangling_and_recursive_references() {
|
||||
for reference in [
|
||||
"https://example.invalid/schema",
|
||||
"file:///etc/passwd",
|
||||
"other.json",
|
||||
"//example.invalid/schema",
|
||||
"urn:example:schema",
|
||||
"#missing",
|
||||
"#/$defs/missing",
|
||||
"#",
|
||||
"#/%24defs/x",
|
||||
] {
|
||||
assert!(
|
||||
validate_tool_schema(&json!({"$ref": reference})).is_err(),
|
||||
"{reference}"
|
||||
);
|
||||
}
|
||||
for schema in [
|
||||
json!({"$defs": {"unused": {"$ref": "https://example.invalid/schema"}}}),
|
||||
json!({"default": {"type": "string"}, "$ref": "#/default"}),
|
||||
json!({"$defs": {"a": {"$ref": "#/$defs/b"}, "b": {"$ref": "#/$defs/a"}}}),
|
||||
] {
|
||||
assert!(validate_tool_schema(&schema).is_err(), "accepted {schema}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_rejects_invalid_unreferenced_definitions() {
|
||||
for schema in [
|
||||
json!({"$defs": {"unused": {"pattern": "["}}}),
|
||||
json!({"$defs": {"unused": {"format": "unknown-format"}}}),
|
||||
json!({"$defs": {"unused": {"properties": {"x": {"pattern": "["}}}}}),
|
||||
] {
|
||||
assert!(validate_tool_schema(&schema).is_err(), "accepted {schema}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_treats_annotations_and_property_names_as_data() {
|
||||
validate_tool_schema(&json!({
|
||||
"properties": {
|
||||
"$ref": {"type": "string"},
|
||||
"unknown": {"const": {"typo": 1}},
|
||||
"space # % ~ / 日本語": {"type": "string"}
|
||||
},
|
||||
"default": {"$ref": "https://example.invalid/data", "typo": 3},
|
||||
"examples": [{"$schema": "not a schema"}]
|
||||
}))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_schema_accepts_closed_dcdl_types_and_singletons() {
|
||||
for source in [
|
||||
"{}",
|
||||
"label = String; enabled = Bool; retries = Int; ratio = Float",
|
||||
"{ nested = { label = String }; tags = [...String]; rows = [...{ id = Int }] }",
|
||||
"{ mode = \"safe\"; enabled = true; count = 3; ratio = 0.5; offset = -2; delta = -1.5 }",
|
||||
"# DCDL comment\n { label = (String) }",
|
||||
] {
|
||||
validate_config_schema(source).unwrap_or_else(|error| panic!("{source}: {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_schema_rejects_open_untyped_and_dynamic_dcdl() {
|
||||
for source in [
|
||||
"Unknown",
|
||||
"String",
|
||||
"[...String]",
|
||||
"{ x = Unknown }",
|
||||
"{ ...String }",
|
||||
"{ x = String; ...Unknown }",
|
||||
"{ nested = { ...Int } }",
|
||||
"{ x = [...Unknown] }",
|
||||
"import \"remote.dcdl\"",
|
||||
"{ x = import \"/etc/passwd\" }",
|
||||
"let T = String in { x = T }",
|
||||
"{ x = MissingType }",
|
||||
"{ x = env(\"TOKEN\") }",
|
||||
"{ x = String default \"hello\" }",
|
||||
"{ x = (v) => v }",
|
||||
"{ x = 1 + 1 }",
|
||||
"{ x = [] }",
|
||||
"{ x = [String] }",
|
||||
"{ x = _ }",
|
||||
"{ x = String; x = Int }",
|
||||
"{ x.y = String }",
|
||||
"{ x = String",
|
||||
"{\"type\": \"object\"}",
|
||||
"{ x = ---1 }",
|
||||
] {
|
||||
assert!(validate_config_schema(source).is_err(), "accepted {source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_reject_oversize_and_deep_inputs_without_evaluation() {
|
||||
assert!(validate_config_schema(&" ".repeat(MAX_SCHEMA_BYTES + 1)).is_err());
|
||||
assert!(
|
||||
validate_config_schema(&format!("{}String{}", "(".repeat(1000), ")".repeat(1000)))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
validate_tool_schema(&json!({"description": "x".repeat(MAX_SCHEMA_BYTES)})).is_err()
|
||||
);
|
||||
let mut nested = json!({});
|
||||
for _ in 0..MAX_DEPTH + 1 {
|
||||
nested = json!({"not": nested});
|
||||
}
|
||||
assert!(validate_tool_schema(&nested).is_err());
|
||||
// Each suffix is visited and cached before the next, longer chain.
|
||||
let mut definitions = serde_json::Map::new();
|
||||
definitions.insert("n00".into(), json!({"type": "string"}));
|
||||
for index in 1..MAX_DEPTH + 2 {
|
||||
definitions.insert(
|
||||
format!("n{index:02}"),
|
||||
json!({
|
||||
"$ref": format!("#/$defs/n{:02}", index - 1)
|
||||
}),
|
||||
);
|
||||
}
|
||||
let error = validate_tool_schema(&json!({"$defs": definitions})).unwrap_err();
|
||||
assert!(error.contains("depth limit"), "{error}");
|
||||
}
|
||||
}
|
||||
@@ -15,3 +15,6 @@ wit-bindgen = "0.51.0"
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
toml.workspace = true
|
||||
wasmparser = "0.244.0"
|
||||
wit-component = "0.244.0"
|
||||
wit-parser = "0.244.0"
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
# plugin-pdk
|
||||
|
||||
Guest-side JSON/WIT helpers for offline Component Model packages. Without depending on host/runtime crates, it converts Tool and Service inputs/outputs and bounds Tool output size. Using the PDK or validating a package does not authorize installation or execution in a Worker.
|
||||
Guest-side Rust helpers for the versioned Component Model Tool contract. [`ToolContext`] decodes only non-secret invocation data; [`ToolOutput`] rejects oversized data and separates structured output from presentation text. [`export_component_tool!`] adapts generated WIT bindings and preserves the result's error arm.
|
||||
|
||||
## Main entry points
|
||||
|
||||
- [`run_json_tool`] and [`export_component_tool!`] — typed Tool input/output boundary
|
||||
- [`ToolOutput`] and [`ToolError`] — bounded output and errors
|
||||
- [`ServiceOutput`] and [`export_plugin_instance!`] — instance output contracts
|
||||
Host authorization, schema validation, effect receipts and execution belong to the Host, not this crate. The PDK does not import WASI, implement a Host, or provide Service/event/Workdir/WebSocket stubs. Old v1 packages require an explicit v2 rebuild rather than a compatibility adapter.
|
||||
|
||||
+184
-644
@@ -2,146 +2,126 @@
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
|
||||
pub use wit_bindgen;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ToolError>;
|
||||
|
||||
/// 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 TOOL_WORLD: &str = "yoi:plugin/tool-plugin@2.0.0";
|
||||
pub const HTTPS_TOOL_WORLD: &str = "yoi:plugin/https-tool-plugin@2.0.0";
|
||||
pub const TOOL_WIT: &str = include_str!("../../../resources/plugin/wit/yoi-plugin-v2.wit");
|
||||
pub const HOST_WIT: &str =
|
||||
include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v1.wit");
|
||||
|
||||
/// Serialized JSON limit defined by the offline Tool world's output contract.
|
||||
include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit");
|
||||
pub const MAX_TOOL_OUTPUT_BYTES: usize = 64 * 1024;
|
||||
/// Byte limit for summaries in the offline Tool world.
|
||||
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_TOOL_INPUT_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_CONFIG_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_ERROR_MESSAGE_BYTES: usize = 4096;
|
||||
|
||||
/// Per-call context passed to a typed JSON handler.
|
||||
/// Non-secret call snapshot. Invocation identity tracks effects; it grants no authority.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ToolContext {
|
||||
invocation_id: String,
|
||||
tool_name: String,
|
||||
config_json: String,
|
||||
remaining_ms: u32,
|
||||
}
|
||||
|
||||
impl ToolContext {
|
||||
/// Create context for the manifest-declared Tool name selected by the host.
|
||||
pub fn new(tool_name: impl Into<String>) -> Self {
|
||||
pub fn new(
|
||||
invocation_id: String,
|
||||
tool_name: String,
|
||||
config_json: String,
|
||||
remaining_ms: u32,
|
||||
) -> Self {
|
||||
Self {
|
||||
tool_name: bounded_text(tool_name.into(), MAX_SUMMARY_BYTES),
|
||||
invocation_id,
|
||||
tool_name,
|
||||
config_json,
|
||||
remaining_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// Manifest-declared Tool name supplied by the host runtime.
|
||||
pub fn invocation_id(&self) -> &str {
|
||||
&self.invocation_id
|
||||
}
|
||||
pub fn tool_name(&self) -> &str {
|
||||
&self.tool_name
|
||||
}
|
||||
pub fn remaining_ms(&self) -> u32 {
|
||||
self.remaining_ms
|
||||
}
|
||||
|
||||
/// Decode the plan's non-secret config. Host validation remains authoritative.
|
||||
pub fn config<T: DeserializeOwned>(&self) -> Result<T> {
|
||||
parse_bounded_json(&self.config_json, MAX_CONFIG_BYTES)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded Tool output formatted on the guest side.
|
||||
///
|
||||
/// Shorten text at character boundaries and recheck the serialized JSON limit, including escapes.
|
||||
/// Typed JSON through [`Self::json`] errors on overflow rather than truncating and breaking
|
||||
/// its structure. Providing this output format does not provide Plugin execution by Workers.
|
||||
/// Output crosses WIT as a record, never as a success envelope hiding an error.
|
||||
/// Oversized output is rejected, not silently truncated into invalid JSON.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct ToolOutput {
|
||||
summary: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
structured_json: 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
|
||||
pub fn new(summary: impl Into<String>, content: Option<String>) -> Result<Self> {
|
||||
Self::checked(summary.into(), content, None)
|
||||
}
|
||||
|
||||
/// Create a Tool output whose content is typed JSON.
|
||||
pub fn json(
|
||||
summary: impl Into<String>,
|
||||
value: impl Serialize,
|
||||
) -> std::result::Result<Self, ToolError> {
|
||||
let content = serde_json::to_string(&value).map_err(ToolError::serialization)?;
|
||||
pub fn summary(summary: impl Into<String>) -> Result<Self> {
|
||||
Self::new(summary, None)
|
||||
}
|
||||
|
||||
/// Structured JSON is separate from presentation text and needs a manifest output schema.
|
||||
pub fn json(summary: impl Into<String>, value: impl Serialize) -> Result<Self> {
|
||||
let json = serde_json::to_string(&value).map_err(ToolError::serialization)?;
|
||||
Self::checked(summary.into(), None, Some(json))
|
||||
}
|
||||
|
||||
fn checked(
|
||||
summary: String,
|
||||
content: Option<String>,
|
||||
structured_json: Option<String>,
|
||||
) -> Result<Self> {
|
||||
let output = Self {
|
||||
summary: normalize_summary(summary.into()),
|
||||
content: Some(content),
|
||||
summary,
|
||||
content,
|
||||
structured_json,
|
||||
};
|
||||
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"
|
||||
)));
|
||||
let bytes = serde_json::to_vec(&output).map_err(ToolError::serialization)?;
|
||||
if bytes.len() > MAX_TOOL_OUTPUT_BYTES {
|
||||
return Err(ToolError::invalid_output(
|
||||
"serialized output exceeds 64 KiB",
|
||||
));
|
||||
}
|
||||
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()
|
||||
})
|
||||
pub fn structured_json(&self) -> Option<&str> {
|
||||
self.structured_json.as_deref()
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
#[doc(hidden)]
|
||||
pub fn into_parts(self) -> (String, Option<String>, Option<String>) {
|
||||
(self.summary, self.content, self.structured_json)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable, low-cardinality error codes for PDK-produced Tool errors.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
/// Guest business errors are not Host authorization decisions or effect receipts.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ToolErrorCode {
|
||||
InvalidInput,
|
||||
InvalidOutput,
|
||||
Serialization,
|
||||
Denied,
|
||||
UnsupportedTool,
|
||||
Failed,
|
||||
}
|
||||
@@ -152,640 +132,200 @@ impl ToolErrorCode {
|
||||
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)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, 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,
|
||||
let mut message = message.into();
|
||||
if message.len() > MAX_ERROR_MESSAGE_BYTES {
|
||||
let mut end = MAX_ERROR_MESSAGE_BYTES;
|
||||
while !message.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
message.truncate(end);
|
||||
}
|
||||
Self { code, message }
|
||||
}
|
||||
|
||||
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())
|
||||
/// Serialization diagnostics must not include the original payload.
|
||||
pub fn serialization(_error: impl std::fmt::Display) -> Self {
|
||||
Self::new(ToolErrorCode::Serialization, "output serialization failed")
|
||||
}
|
||||
|
||||
pub fn denied(message: impl Into<String>) -> Self {
|
||||
Self::new(ToolErrorCode::Denied, message)
|
||||
pub fn unsupported_tool(_name: impl AsRef<str>) -> Self {
|
||||
Self::new(ToolErrorCode::UnsupportedTool, "unsupported Tool name")
|
||||
}
|
||||
|
||||
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))
|
||||
fn parse_bounded_json<T: DeserializeOwned>(json: &str, limit: usize) -> Result<T> {
|
||||
if json.len() > limit {
|
||||
return Err(ToolError::invalid_input("JSON exceeds 64 KiB"));
|
||||
}
|
||||
serde_json::from_str(json)
|
||||
.map_err(|_| ToolError::invalid_input("JSON does not match the expected schema"))
|
||||
}
|
||||
|
||||
/// Parse the WIT `input-json` string into a typed input value.
|
||||
pub fn parse_json_input<T>(input_json: &str) -> std::result::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}"
|
||||
))
|
||||
})
|
||||
pub fn parse_json_input<T: DeserializeOwned>(json: &str) -> Result<T> {
|
||||
parse_bounded_json(json, MAX_TOOL_INPUT_BYTES)
|
||||
}
|
||||
|
||||
/// Guest-side boundary that runs a typed JSON handler and converts the result to ToolOutput JSON.
|
||||
///
|
||||
/// Convert parse, handler, and serialization failures to the normal output form. These errors
|
||||
/// are not host authorization decisions, and catching handler panics is not guaranteed.
|
||||
/// The PDK does not depend on host/runtime crates or grant permissions through input conversion.
|
||||
pub fn run_json_tool<I, F>(tool_name: &str, input_json: &str, handler: F) -> String
|
||||
where
|
||||
I: DeserializeOwned,
|
||||
F: FnOnce(ToolContext, I) -> std::result::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(),
|
||||
}
|
||||
/// Host performs schema and admission checks; this boundary only decodes guest data.
|
||||
/// Failures remain the WIT result's error arm. No retry or success stub is introduced.
|
||||
pub fn run_json_tool<I: DeserializeOwned>(
|
||||
context: ToolContext,
|
||||
input_json: &str,
|
||||
handler: impl FnOnce(ToolContext, I) -> Result<ToolOutput>,
|
||||
) -> Result<ToolOutput> {
|
||||
let _: serde_json::Value = context.config()?;
|
||||
let input = parse_json_input(input_json)?;
|
||||
handler(context, input)
|
||||
}
|
||||
|
||||
/// Connect a typed JSON handler to a WIT `Guest` implementation and component export.
|
||||
///
|
||||
/// Use guest-generated bindings and centralize handler conversion in [`run_json_tool`].
|
||||
/// This macro grants no host API permissions and is not a mechanism for registering or
|
||||
/// executing dynamic Plugins in the current Worker.
|
||||
/// Adapt bindings generated from any supported v2 Tool world. The generated glue
|
||||
/// owns the canonical ABI; callers never implement pointer/length exports.
|
||||
#[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)
|
||||
impl exports::yoi::plugin::tool::Guest for $adapter {
|
||||
fn invoke(
|
||||
input: exports::yoi::plugin::tool::Invocation,
|
||||
) -> ::std::result::Result<
|
||||
exports::yoi::plugin::tool::Output,
|
||||
exports::yoi::plugin::tool::ToolError,
|
||||
> {
|
||||
let context = $crate::ToolContext::new(
|
||||
input.invocation_id,
|
||||
input.tool_name,
|
||||
input.config_json,
|
||||
input.remaining_ms,
|
||||
);
|
||||
$crate::run_json_tool(context, &input.input_json, $handler)
|
||||
.map(|output| {
|
||||
let (summary, content, structured_json) = output.into_parts();
|
||||
exports::yoi::plugin::tool::Output {
|
||||
summary,
|
||||
content,
|
||||
structured_json,
|
||||
}
|
||||
})
|
||||
.map_err(|error| exports::yoi::plugin::tool::ToolError {
|
||||
code: error.code_str().to_owned(),
|
||||
message: error.message().to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EchoInput {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Input {
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct EchoOutput<'a> {
|
||||
tool: &'a str,
|
||||
text: String,
|
||||
fn context(config: &str) -> ToolContext {
|
||||
ToolContext::new("call-1".into(), "echo".into(), config.into(), 500)
|
||||
}
|
||||
|
||||
#[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 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");
|
||||
fn typed_handler_keeps_structured_output_separate_from_text() {
|
||||
let output = run_json_tool(context("{}"), r#"{"text":"hello"}"#, |ctx, input: Input| {
|
||||
assert_eq!(ctx.invocation_id(), "call-1");
|
||||
assert_eq!(ctx.remaining_ms(), 500);
|
||||
ToolOutput::json(
|
||||
"echoed",
|
||||
json!({"text": input.text, "tool": ctx.tool_name()}),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(output.content(), None);
|
||||
assert_eq!(
|
||||
value["output_commands"][0]["requested_at"],
|
||||
"2026-06-25T00:00:00Z"
|
||||
serde_json::from_str::<serde_json::Value>(output.structured_json().unwrap()).unwrap(),
|
||||
json!({"text":"hello", "tool":"echo"})
|
||||
);
|
||||
}
|
||||
|
||||
#[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 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"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Versioned Component Model instance world handled by the host-managed
|
||||
/// PluginInstanceRegistry.
|
||||
pub const PLUGIN_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0";
|
||||
|
||||
/// Repository WIT for the current instance world.
|
||||
pub const INSTANCE_WIT: &str =
|
||||
include_str!("../../../resources/plugin/wit/yoi-plugin-instance-v1.wit");
|
||||
|
||||
#[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<String>,
|
||||
url: impl Into<String>,
|
||||
text: impl Into<String>,
|
||||
) -> Result<ServiceOutputCommand> {
|
||||
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)]
|
||||
pub struct PluginStatus {
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
impl PluginStatus {
|
||||
pub fn ready(data: Value) -> Self {
|
||||
Self {
|
||||
state: "ready".to_string(),
|
||||
data,
|
||||
fn invalid_input_or_config_never_runs_handler_or_returns_success() {
|
||||
for (config, input) in [
|
||||
("{}", r#"{"text":1}"#),
|
||||
("broken", r#"{"text":"ok"}"#),
|
||||
("{}", r#"{"text":"ok","extra":true}"#),
|
||||
] {
|
||||
let error = run_json_tool::<Input>(context(config), input, |_, _| {
|
||||
panic!("handler must not run")
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code(), ToolErrorCode::InvalidInput);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stopped() -> Self {
|
||||
Self {
|
||||
state: "stopped".to_string(),
|
||||
data: Value::Null,
|
||||
#[test]
|
||||
fn payload_limits_reject_overflow_including_json_escaping() {
|
||||
for text in [
|
||||
"x".repeat(MAX_TOOL_OUTPUT_BYTES),
|
||||
"\"".repeat(MAX_TOOL_OUTPUT_BYTES / 2),
|
||||
"語".repeat(MAX_TOOL_OUTPUT_BYTES / 3),
|
||||
] {
|
||||
assert_eq!(
|
||||
ToolOutput::json("large", &text).unwrap_err().code(),
|
||||
ToolErrorCode::InvalidOutput
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
parse_json_input::<serde_json::Value>(&format!(
|
||||
"\"{}\"",
|
||||
"x".repeat(MAX_TOOL_INPUT_BYTES)
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
let large = format!("\"{}\"", "x".repeat(MAX_CONFIG_BYTES));
|
||||
assert!(context(&large).config::<serde_json::Value>().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
kind: ServiceOutputCommandKind,
|
||||
payload: impl Serialize,
|
||||
) -> Result<Self> {
|
||||
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,
|
||||
#[test]
|
||||
fn business_failure_is_preserved_and_message_is_utf8_bounded() {
|
||||
let error = run_json_tool(context("{}"), "null", |_, _: ()| {
|
||||
Err(ToolError::failed("語".repeat(MAX_ERROR_MESSAGE_BYTES)))
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code_str(), "failed");
|
||||
assert!(error.message().len() <= MAX_ERROR_MESSAGE_BYTES);
|
||||
assert!(error.message().ends_with('語'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Instance processing results and output commands requested from the host.
|
||||
///
|
||||
/// The guest does not send anything here; it returns commands as data tied to the original
|
||||
/// ingress event. Command creation/serialization and host authorization/execution are separate
|
||||
/// boundaries. This type provides neither network permissions nor the current Worker execution mechanism.
|
||||
#[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<ServiceOutputCommand>,
|
||||
}
|
||||
|
||||
impl ServiceOutput {
|
||||
pub fn accepted(data: impl Serialize) -> Result<Self> {
|
||||
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<String>,
|
||||
url: impl Into<String>,
|
||||
text: impl Into<String>,
|
||||
) -> Result<Self> {
|
||||
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<Self>;
|
||||
fn handle_tool(&mut self, name: &str, input: Value) -> Result<ToolOutput>;
|
||||
fn handle_ingress(&mut self, name: &str, event: PluginIngressEvent) -> Result<ServiceOutput>;
|
||||
fn status(&self) -> Result<PluginStatus> {
|
||||
Ok(PluginStatus::ready(Value::Null))
|
||||
}
|
||||
fn stop(&mut self) -> Result<PluginStatus> {
|
||||
Ok(PluginStatus::stopped())
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn plugin_instance_error(message: impl Into<String>) -> String {
|
||||
serde_json::json!({ "error": { "message": message.into() } }).to_string()
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn plugin_instance_status(status: &PluginStatus) -> String {
|
||||
serde_json::to_string(status).unwrap_or_else(|error| plugin_instance_error(error.to_string()))
|
||||
}
|
||||
|
||||
/// Implement the generated Component Model `Guest` trait for an instance Plugin
|
||||
/// and export it with the `wit-bindgen` generated `export!` macro.
|
||||
///
|
||||
/// The caller must invoke `wit_bindgen::generate!` for the `instance` world
|
||||
/// first, with `runtime_path: "yoi_plugin_pdk::wit_bindgen::rt"`. That defines
|
||||
/// the `Guest` trait and `export!` macro in the current module.
|
||||
#[macro_export]
|
||||
macro_rules! export_plugin_instance {
|
||||
($adapter:ident, $plugin:ty) => {
|
||||
struct $adapter;
|
||||
|
||||
thread_local! {
|
||||
static YOI_PLUGIN_INSTANCE: ::std::cell::RefCell<::std::option::Option<$plugin>> = const { ::std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
impl Guest for $adapter {
|
||||
fn start(config_json: ::std::string::String) -> ::std::string::String {
|
||||
let config = serde_json::from_str(&config_json).unwrap_or(serde_json::Value::Null);
|
||||
match <$plugin as $crate::Plugin>::start(config) {
|
||||
Ok(plugin) => {
|
||||
YOI_PLUGIN_INSTANCE.with(|slot| *slot.borrow_mut() = Some(plugin));
|
||||
$crate::plugin_instance_status(&$crate::PluginStatus::ready(serde_json::Value::Null))
|
||||
}
|
||||
Err(error) => $crate::plugin_instance_error(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_tool(
|
||||
name: ::std::string::String,
|
||||
input_json: ::std::string::String,
|
||||
) -> ::std::string::String {
|
||||
let input = serde_json::from_str(&input_json).unwrap_or(serde_json::Value::Null);
|
||||
YOI_PLUGIN_INSTANCE.with(|slot| {
|
||||
let mut slot = slot.borrow_mut();
|
||||
let Some(plugin) = slot.as_mut() else {
|
||||
return $crate::plugin_instance_error("plugin instance has not been started");
|
||||
};
|
||||
match plugin.handle_tool(&name, input) {
|
||||
Ok(output) => output.to_json_string(),
|
||||
Err(error) => error.into_tool_output().to_json_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_ingress(
|
||||
name: ::std::string::String,
|
||||
event_json: ::std::string::String,
|
||||
) -> ::std::string::String {
|
||||
let event = match serde_json::from_str::<$crate::PluginIngressEvent>(&event_json) {
|
||||
Ok(event) => event,
|
||||
Err(error) => return $crate::plugin_instance_error(error.to_string()),
|
||||
};
|
||||
YOI_PLUGIN_INSTANCE.with(|slot| {
|
||||
let mut slot = slot.borrow_mut();
|
||||
let Some(plugin) = slot.as_mut() else {
|
||||
return $crate::plugin_instance_error("plugin instance has not been started");
|
||||
};
|
||||
match plugin.handle_ingress(&name, event) {
|
||||
Ok(output) => serde_json::to_string(&output)
|
||||
.unwrap_or_else(|error| $crate::plugin_instance_error(error.to_string())),
|
||||
Err(error) => $crate::plugin_instance_error(error.to_string()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn status() -> ::std::string::String {
|
||||
YOI_PLUGIN_INSTANCE.with(|slot| {
|
||||
let slot = slot.borrow();
|
||||
let Some(plugin) = slot.as_ref() else {
|
||||
return $crate::plugin_instance_error("plugin instance has not been started");
|
||||
};
|
||||
match plugin.status() {
|
||||
Ok(status) => $crate::plugin_instance_status(&status),
|
||||
Err(error) => $crate::plugin_instance_error(error.to_string()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn stop() -> ::std::string::String {
|
||||
YOI_PLUGIN_INSTANCE.with(|slot| {
|
||||
let mut slot = slot.borrow_mut();
|
||||
let Some(plugin) = slot.as_mut() else {
|
||||
return $crate::plugin_instance_error("plugin instance has not been started");
|
||||
};
|
||||
match plugin.stop() {
|
||||
Ok(status) => {
|
||||
let output = $crate::plugin_instance_status(&status);
|
||||
*slot = None;
|
||||
output
|
||||
}
|
||||
Err(error) => $crate::plugin_instance_error(error.to_string()),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export!($adapter);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -1847,7 +1847,7 @@ fn parse_plugin_pack_args(
|
||||
}
|
||||
|
||||
fn plugin_usage() -> &'static str {
|
||||
"usage: yoi plugin new <rust-component-tool|rust-component-service> <path-or-name> [--json]\n yoi plugin check <path-or-package> [--json]\n yoi plugin pack <path> [--output <file>] [--json]"
|
||||
"usage: yoi plugin new <rust-component-tool|rust-component-https-tool> <path-or-name> [--json]\n yoi plugin check <path-or-package> [--json]\n yoi plugin pack <path> [--output <file>] [--json]"
|
||||
}
|
||||
|
||||
fn parse_mcp_args(args: &[String]) -> Result<mcp_cli::McpCliCommand, ParseError> {
|
||||
|
||||
+336
-119
@@ -5,9 +5,10 @@ use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use manifest::plugin::{
|
||||
MaterializedPluginPackage, PluginDiagnostic, PluginDiagnosticKind, PluginDiagnosticPhase,
|
||||
PluginPackageLimits, PluginPermission, PluginSourceKind, PluginTemplateResource,
|
||||
RUST_COMPONENT_INSTANCE_TEMPLATE, RUST_COMPONENT_TOOL_TEMPLATE, SourceQualifiedPluginId,
|
||||
InspectedPluginPackage, MaterializedPluginPackage, PluginConnectionSlot, PluginDiagnostic,
|
||||
PluginDiagnosticKind, PluginDiagnosticPhase, PluginPackageLimits, PluginRequestedGrant,
|
||||
PluginRuntimeManifest, PluginSourceKind, PluginTemplateResource,
|
||||
RUST_COMPONENT_HTTPS_TOOL_TEMPLATE, RUST_COMPONENT_TOOL_TEMPLATE, SourceQualifiedPluginId,
|
||||
read_plugin_directory, read_plugin_package_file, write_plugin_package_file,
|
||||
};
|
||||
use serde::Serialize;
|
||||
@@ -69,18 +70,16 @@ pub(crate) fn run(command: PluginCliCommand) -> Result<()> {
|
||||
fn render_new(template: &str, destination: &Path, args: &PluginCliArgs) -> Result<String> {
|
||||
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 offline package verification.".to_string(),
|
||||
"Run `yoi plugin check <path>` and then `yoi plugin pack <path>`.".to_string(),
|
||||
let core_name = match template_name {
|
||||
"rust-component-https-tool" => "yoi_rust_component_https_template",
|
||||
_ => "yoi_rust_component_tool_template",
|
||||
};
|
||||
let next_steps = vec![
|
||||
"Review plugin.toml and generated Rust source. In Cargo.toml, replace the repository-relative yoi-plugin-pdk dependency path with an absolute local crates/plugin-pdk path or a pinned Yoi Git commit.".to_string(),
|
||||
"In the generated directory, install the wasm32-unknown-unknown Rust target and wasm-tools, then run `cargo build --release --target wasm32-unknown-unknown --target-dir ../guest-target`. Keep build output outside the package input directory so check/pack do not include build files.".to_string(),
|
||||
format!("Run `wasm-tools component new ../guest-target/wasm32-unknown-unknown/release/{core_name}.wasm -o plugin.component.wasm` to create the actual Component; cargo check alone does not build it."),
|
||||
"Run `yoi plugin check <path>` and then `yoi plugin pack <path>`; static validation does not establish Host implementation or execution support.".to_string(),
|
||||
];
|
||||
if template == "rust-component-service" {
|
||||
next_steps.insert(
|
||||
1,
|
||||
"Implement the proposed Service ingress output shape for offline package validation."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let report = NewReport {
|
||||
command: "new",
|
||||
template: template_name,
|
||||
@@ -103,9 +102,12 @@ fn embedded_template_resources(
|
||||
) -> 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)),
|
||||
"rust-component-https-tool" => Ok((
|
||||
"rust-component-https-tool",
|
||||
RUST_COMPONENT_HTTPS_TOOL_TEMPLATE,
|
||||
)),
|
||||
_ => Err(format!(
|
||||
"unsupported plugin template `{template}` (supported: rust-component-tool, rust-component-service)"
|
||||
"unsupported plugin template `{template}` (supported: rust-component-tool, rust-component-https-tool); Service and other surfaces are not supported"
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
@@ -195,10 +197,11 @@ fn render_pack(input: &Path, output: Option<&Path>, args: &PluginCliArgs) -> Res
|
||||
status: "packed",
|
||||
input_path: input.display().to_string(),
|
||||
output_path: packed.output_path.display().to_string(),
|
||||
package: PackageReport::from_materialized(&MaterializedPluginPackage {
|
||||
package: packed.package,
|
||||
files: materialized.files,
|
||||
}),
|
||||
package: PackageReport::from_package(
|
||||
&packed.package,
|
||||
&packed.artifact_digest,
|
||||
packed.artifact_size,
|
||||
),
|
||||
safety: AuthoringSafetyReport::default(),
|
||||
};
|
||||
if args.json {
|
||||
@@ -229,25 +232,19 @@ fn build_check_report(input: &Path) -> CheckReport {
|
||||
read_plugin_package_file(input, PluginSourceKind::Explicit, &limits)
|
||||
};
|
||||
match result {
|
||||
Ok(materialized) => {
|
||||
let placeholder_diagnostic = placeholder_component_diagnostic(&materialized);
|
||||
let status = if placeholder_diagnostic.is_some() {
|
||||
"partial"
|
||||
} else {
|
||||
"verified"
|
||||
};
|
||||
let diagnostics = placeholder_diagnostic.into_iter().collect();
|
||||
CheckReport {
|
||||
command: "check",
|
||||
status,
|
||||
input_path: input.display().to_string(),
|
||||
input_kind,
|
||||
package: Some(PackageReport::from_materialized(&materialized)),
|
||||
diagnostics,
|
||||
safety: AuthoringSafetyReport::default(),
|
||||
next_steps: check_next_steps(status),
|
||||
}
|
||||
}
|
||||
Ok(materialized) => CheckReport {
|
||||
command: "check",
|
||||
status: "verified",
|
||||
input_path: input.display().to_string(),
|
||||
input_kind,
|
||||
package: Some(PackageReport::from_materialized(&materialized)),
|
||||
diagnostics: Vec::new(),
|
||||
safety: AuthoringSafetyReport::default(),
|
||||
next_steps: vec![
|
||||
"Package schemas, actual Component imports/exports, and canonical archive metadata are statically valid; no Plugin code was executed.".to_string(),
|
||||
"Static validation does not establish Host implementation, HTTP availability, installation, or Worker execution support.".to_string(),
|
||||
],
|
||||
},
|
||||
Err(diagnostic) => CheckReport {
|
||||
command: "check",
|
||||
status: "rejected",
|
||||
@@ -263,46 +260,6 @@ fn build_check_report(input: &Path) -> CheckReport {
|
||||
}
|
||||
}
|
||||
|
||||
fn placeholder_component_diagnostic(
|
||||
materialized: &MaterializedPluginPackage,
|
||||
) -> Option<PluginDiagnosticReport> {
|
||||
let runtime = materialized.package.manifest.runtime.as_ref()?;
|
||||
let component = runtime.component.as_deref()?;
|
||||
let component_bytes = materialized.files.get(component)?;
|
||||
let placeholder_bytes = RUST_COMPONENT_TOOL_TEMPLATE
|
||||
.iter()
|
||||
.find(|resource| resource.path == "plugin.component.wasm")?
|
||||
.contents
|
||||
.as_bytes();
|
||||
if component_bytes != placeholder_bytes {
|
||||
return None;
|
||||
}
|
||||
Some(PluginDiagnosticReport {
|
||||
kind: "placeholder".to_string(),
|
||||
phase: "runtime".to_string(),
|
||||
message: format!(
|
||||
"plugin component runtime artifact `{component}` is the generated placeholder; replace it with a real built component before package verification"
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn check_next_steps(status: &str) -> Vec<String> {
|
||||
match status {
|
||||
"verified" => vec![
|
||||
"Package metadata and archive contents are valid without executing Plugin code."
|
||||
.to_string(),
|
||||
"Keep this artifact for explicit offline inspection; Worker Plugin installation is not supported."
|
||||
.to_string(),
|
||||
],
|
||||
"partial" => vec![
|
||||
"Replace the generated placeholder component artifact with a real built component."
|
||||
.to_string(),
|
||||
"Run `yoi plugin check <path>` again to verify the offline package.".to_string(),
|
||||
],
|
||||
_ => vec!["Fix the reported diagnostics before packing this Plugin.".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_diagnostic_error(context: &str, diagnostic: PluginDiagnostic) -> String {
|
||||
format!("{context} failed: {}", diagnostic.message)
|
||||
}
|
||||
@@ -339,18 +296,19 @@ fn render_check_human(report: &CheckReport) -> Result<String> {
|
||||
if let Some(package) = &report.package {
|
||||
writeln!(
|
||||
out,
|
||||
"package: {} version={} digest={} entries={} source={} surfaces={} tools={}",
|
||||
"package: {} version={} package_digest={} entries={} source={} surfaces={} tools={}",
|
||||
package.reference,
|
||||
package.version,
|
||||
package.digest,
|
||||
package.package_digest,
|
||||
package.entries.len(),
|
||||
package.source,
|
||||
join_or_none(&package.surfaces),
|
||||
package.tools.len()
|
||||
)?;
|
||||
render_artifact_human(&mut out, package)?;
|
||||
writeln!(
|
||||
out,
|
||||
"installation: unsupported; this command only inspects the explicit input path"
|
||||
"scope: static validation of the explicit input path only; no Host implementation or execution support is established"
|
||||
)?;
|
||||
}
|
||||
if report.diagnostics.is_empty() {
|
||||
@@ -385,12 +343,13 @@ fn render_pack_human(report: &PackReport) -> Result<String> {
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
"package: {} version={} digest={} entries={}",
|
||||
"package: {} version={} package_digest={} entries={}",
|
||||
report.package.reference,
|
||||
report.package.version,
|
||||
report.package.digest,
|
||||
report.package.package_digest,
|
||||
report.package.entries.len()
|
||||
)?;
|
||||
render_artifact_human(&mut out, &report.package)?;
|
||||
writeln!(
|
||||
out,
|
||||
"safety: deterministic stored .yoi-plugin archive; no Plugin execution; no config mutation"
|
||||
@@ -398,6 +357,26 @@ fn render_pack_human(report: &PackReport) -> Result<String> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn render_artifact_human(out: &mut String, package: &PackageReport) -> Result<()> {
|
||||
writeln!(
|
||||
out,
|
||||
"canonical archive: artifact_digest={} artifact_size={} bytes",
|
||||
package.artifact_digest, package.artifact_size
|
||||
)?;
|
||||
writeln!(out, "component world: {}", package.component.world)?;
|
||||
writeln!(
|
||||
out,
|
||||
"component imports: {}",
|
||||
join_or_none(&package.component.imports)
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
"component exports: {}",
|
||||
join_or_none(&package.component.exports)
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AuthoringSafetyReport {
|
||||
no_network: bool,
|
||||
@@ -456,50 +435,75 @@ struct PackageReport {
|
||||
source: String,
|
||||
version: String,
|
||||
schema_version: u32,
|
||||
digest: String,
|
||||
package_digest: String,
|
||||
artifact_digest: String,
|
||||
artifact_size: u64,
|
||||
input_path: String,
|
||||
entries: Vec<String>,
|
||||
surfaces: Vec<String>,
|
||||
tools: Vec<String>,
|
||||
permissions: Vec<String>,
|
||||
runtime: PluginRuntimeManifest,
|
||||
component: ComponentReport,
|
||||
connections: Vec<PluginConnectionSlot>,
|
||||
requested_grants: Vec<PluginRequestedGrant>,
|
||||
}
|
||||
|
||||
impl PackageReport {
|
||||
fn from_materialized(materialized: &MaterializedPluginPackage) -> Self {
|
||||
Self::from_package(
|
||||
&materialized.package,
|
||||
&materialized.artifact_digest,
|
||||
materialized.artifact_size,
|
||||
)
|
||||
}
|
||||
|
||||
fn from_package(
|
||||
package: &InspectedPluginPackage,
|
||||
artifact_digest: &str,
|
||||
artifact_size: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
reference: package_reference(&materialized.package.identity),
|
||||
package: materialized.package.manifest.id.clone(),
|
||||
source: materialized.package.identity.source.to_string(),
|
||||
version: materialized.package.manifest.version.clone(),
|
||||
schema_version: materialized.package.manifest.schema_version,
|
||||
digest: materialized.package.digest.clone(),
|
||||
input_path: materialized.package.input_path.display().to_string(),
|
||||
entries: materialized.package.entries.iter().cloned().collect(),
|
||||
surfaces: materialized
|
||||
.package
|
||||
reference: package_reference(&package.identity),
|
||||
package: package.manifest.id.clone(),
|
||||
source: package.identity.source.to_string(),
|
||||
version: package.manifest.version.clone(),
|
||||
schema_version: package.manifest.schema_version,
|
||||
package_digest: package.package_digest.clone(),
|
||||
artifact_digest: artifact_digest.to_string(),
|
||||
artifact_size,
|
||||
input_path: package.input_path.display().to_string(),
|
||||
entries: package.entries.iter().cloned().collect(),
|
||||
surfaces: package
|
||||
.manifest
|
||||
.surfaces
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect(),
|
||||
tools: materialized
|
||||
.package
|
||||
tools: package
|
||||
.manifest
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.clone())
|
||||
.collect(),
|
||||
permissions: materialized
|
||||
.package
|
||||
.manifest
|
||||
.permissions
|
||||
.iter()
|
||||
.map(|permission| permission_name(permission.clone()).to_string())
|
||||
.collect(),
|
||||
runtime: package.manifest.runtime.clone(),
|
||||
component: ComponentReport {
|
||||
world: package.component.world.clone(),
|
||||
imports: package.component.imports.clone(),
|
||||
exports: package.component.exports.clone(),
|
||||
},
|
||||
connections: package.manifest.connections.clone(),
|
||||
requested_grants: package.manifest.requested_grants.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ComponentReport {
|
||||
world: String,
|
||||
imports: Vec<String>,
|
||||
exports: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PluginDiagnosticReport {
|
||||
kind: String,
|
||||
@@ -528,10 +532,6 @@ fn package_reference(identity: &SourceQualifiedPluginId) -> String {
|
||||
identity.to_string()
|
||||
}
|
||||
|
||||
fn permission_name(permission: PluginPermission) -> String {
|
||||
permission.label()
|
||||
}
|
||||
|
||||
fn join_or_none(values: &[String]) -> String {
|
||||
if values.is_empty() {
|
||||
"(none)".to_string()
|
||||
@@ -571,23 +571,240 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn built_tool_package(destination: &Path) {
|
||||
render_new(
|
||||
"rust-component-tool",
|
||||
destination,
|
||||
&PluginCliArgs::default(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
destination.join("plugin.component.wasm"),
|
||||
include_bytes!("../../plugin-pdk/tests/fixtures/tool-v2.component.wasm"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_template_check_and_pack_remain_offline_authoring_operations() {
|
||||
fn tool_templates_explain_actual_component_build_without_shipping_a_placeholder() {
|
||||
let dir = tempdir().unwrap();
|
||||
for template in ["rust-component-tool", "rust-component-https-tool"] {
|
||||
let destination = dir.path().join(template);
|
||||
let rendered =
|
||||
render_new(template, &destination, &PluginCliArgs { json: true }).unwrap();
|
||||
let report: serde_json::Value = serde_json::from_str(&rendered).unwrap();
|
||||
assert_eq!(report["template"], template);
|
||||
assert!(destination.join("plugin.toml").is_file());
|
||||
assert!(destination.join("src/lib.rs").is_file());
|
||||
assert!(!destination.join("plugin.component.wasm").exists());
|
||||
let steps = report["next_steps"].to_string();
|
||||
assert!(steps.contains("yoi-plugin-pdk"));
|
||||
assert!(steps.contains(
|
||||
"cargo build --release --target wasm32-unknown-unknown --target-dir ../guest-target"
|
||||
));
|
||||
assert!(steps.contains("wasm-tools component new"));
|
||||
let cargo: toml::Value =
|
||||
toml::from_str(&fs::read_to_string(destination.join("Cargo.toml")).unwrap())
|
||||
.unwrap();
|
||||
let core_name = cargo["package"]["name"].as_str().unwrap().replace('-', "_");
|
||||
assert!(steps.contains(&format!(
|
||||
"wasm-tools component new ../guest-target/wasm32-unknown-unknown/release/{core_name}.wasm -o plugin.component.wasm"
|
||||
)));
|
||||
assert!(steps.contains("does not establish Host implementation"));
|
||||
assert_eq!(report["safety"]["no_plugin_execution"], true);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_templates_are_rejected_before_creating_a_destination() {
|
||||
let dir = tempdir().unwrap();
|
||||
let destination = dir.path().join("unsupported");
|
||||
for template in [
|
||||
"rust-component-service",
|
||||
"rust-component-instance",
|
||||
"unknown",
|
||||
] {
|
||||
let error = render_new(template, &destination, &PluginCliArgs::default()).unwrap_err();
|
||||
assert!(error.to_string().contains("unsupported plugin template"));
|
||||
assert!(!destination.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbuilt_and_noncomponent_templates_are_rejected_not_partially_verified_or_packed() {
|
||||
let dir = tempdir().unwrap();
|
||||
let source = dir.path().join("unbuilt");
|
||||
let output = dir.path().join("rejected.yoi-plugin");
|
||||
let args = PluginCliArgs::default();
|
||||
render_new("rust-component-tool", &source, &args).unwrap();
|
||||
let missing = build_check_report(&source);
|
||||
assert_eq!(missing.status, "rejected");
|
||||
assert!(missing.package.is_none());
|
||||
assert!(!missing.diagnostics.is_empty());
|
||||
assert!(render_pack(&source, Some(&output), &args).is_err());
|
||||
assert!(!output.exists());
|
||||
|
||||
for bytes in [b"placeholder".as_slice(), b"\0asm\x01\0\0\0".as_slice()] {
|
||||
fs::write(source.join("plugin.component.wasm"), bytes).unwrap();
|
||||
let rejected = build_check_report(&source);
|
||||
assert_eq!(rejected.status, "rejected");
|
||||
assert!(rejected.package.is_none());
|
||||
assert!(!rejected.diagnostics.is_empty());
|
||||
let human = render_check_report(&rejected, &args).unwrap();
|
||||
assert!(human.contains("[rejected]"));
|
||||
assert!(human.contains("diagnostics:"));
|
||||
assert!(render_pack(&source, Some(&output), &args).is_err());
|
||||
assert!(!output.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_component_check_pack_and_readback_report_both_digests_and_canonical_size() {
|
||||
let dir = tempdir().unwrap();
|
||||
let source = dir.path().join("example");
|
||||
let output = dir.path().join("example.yoi-plugin");
|
||||
let args = PluginCliArgs::default();
|
||||
let second_output = dir.path().join("second.yoi-plugin");
|
||||
let args = PluginCliArgs { json: true };
|
||||
built_tool_package(&source);
|
||||
|
||||
let created = render_new("rust-component-tool", &source, &args).unwrap();
|
||||
assert!(created.contains("created plugin template"));
|
||||
let checked: serde_json::Value =
|
||||
serde_json::from_str(&render_check(&source, &args).unwrap()).unwrap();
|
||||
assert_eq!(checked["status"], "verified");
|
||||
assert_eq!(checked["package"]["schema_version"], 2);
|
||||
assert_eq!(checked["safety"]["no_network"], true);
|
||||
assert_eq!(checked["safety"]["no_plugin_execution"], true);
|
||||
assert!(checked["package"].get("digest").is_none());
|
||||
assert!(checked["package"].get("permissions").is_none());
|
||||
assert_eq!(
|
||||
checked["package"]["component"]["imports"],
|
||||
serde_json::json!([])
|
||||
);
|
||||
assert_eq!(
|
||||
checked["package"]["component"]["exports"],
|
||||
serde_json::json!(["yoi:plugin/tool@2.0.0"])
|
||||
);
|
||||
assert_eq!(
|
||||
checked["package"]["runtime"]["world"],
|
||||
"yoi:plugin/tool-plugin@2.0.0"
|
||||
);
|
||||
assert_ne!(
|
||||
checked["package"]["package_digest"],
|
||||
checked["package"]["artifact_digest"]
|
||||
);
|
||||
for field in ["package_digest", "artifact_digest"] {
|
||||
assert!(
|
||||
checked["package"][field]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("sha256:")
|
||||
);
|
||||
}
|
||||
|
||||
let checked = render_check(&source, &args).unwrap();
|
||||
assert!(checked.contains("[partial]"));
|
||||
assert!(checked.contains("explicit input path"));
|
||||
let packed: serde_json::Value =
|
||||
serde_json::from_str(&render_pack(&source, Some(&output), &args).unwrap()).unwrap();
|
||||
assert_eq!(packed["status"], "packed");
|
||||
let readback: serde_json::Value =
|
||||
serde_json::from_str(&render_check(&output, &args).unwrap()).unwrap();
|
||||
assert_eq!(readback["status"], "verified");
|
||||
for field in [
|
||||
"package_digest",
|
||||
"artifact_digest",
|
||||
"artifact_size",
|
||||
"component",
|
||||
] {
|
||||
assert_eq!(checked["package"][field], packed["package"][field]);
|
||||
assert_eq!(checked["package"][field], readback["package"][field]);
|
||||
}
|
||||
assert_eq!(
|
||||
packed["package"]["artifact_size"].as_u64().unwrap(),
|
||||
fs::metadata(&output).unwrap().len()
|
||||
);
|
||||
render_pack(&source, Some(&second_output), &args).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(&output).unwrap(),
|
||||
fs::read(&second_output).unwrap()
|
||||
);
|
||||
|
||||
let packed = render_pack(&source, Some(&output), &args).unwrap();
|
||||
assert!(packed.contains("[packed]"));
|
||||
assert!(output.is_file());
|
||||
let human = render_check(&output, &PluginCliArgs::default()).unwrap();
|
||||
assert!(human.contains("package_digest=sha256:"));
|
||||
assert!(human.contains("artifact_digest=sha256:"));
|
||||
assert!(human.contains("artifact_size="));
|
||||
assert!(human.contains("static validation of the explicit input path only"));
|
||||
let human_pack = render_pack(&source, Some(&output), &PluginCliArgs::default()).unwrap();
|
||||
assert!(human_pack.contains("package_digest=sha256:"));
|
||||
assert!(human_pack.contains("artifact_digest=sha256:"));
|
||||
assert!(human_pack.contains("artifact_size="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn https_component_reports_static_requests_not_host_execution_support() {
|
||||
let dir = tempdir().unwrap();
|
||||
let source = dir.path().join("https");
|
||||
let output = dir.path().join("https.yoi-plugin");
|
||||
let args = PluginCliArgs { json: true };
|
||||
render_new("rust-component-https-tool", &source, &args).unwrap();
|
||||
fs::write(
|
||||
source.join("plugin.component.wasm"),
|
||||
include_bytes!("../../plugin-pdk/tests/fixtures/https-tool-v2.component.wasm"),
|
||||
)
|
||||
.unwrap();
|
||||
let report: serde_json::Value =
|
||||
serde_json::from_str(&render_check(&source, &args).unwrap()).unwrap();
|
||||
assert_eq!(report["status"], "verified");
|
||||
assert_eq!(
|
||||
report["package"]["component"]["world"],
|
||||
"yoi:plugin/https-tool-plugin@2.0.0"
|
||||
);
|
||||
assert_eq!(
|
||||
report["package"]["component"]["imports"],
|
||||
serde_json::json!([
|
||||
"yoi:host/connections@2.0.0",
|
||||
"yoi:host/errors@2.0.0",
|
||||
"yoi:host/http@2.0.0"
|
||||
])
|
||||
);
|
||||
assert_eq!(report["package"]["connections"][0]["name"], "api");
|
||||
assert_eq!(
|
||||
report["package"]["requested_grants"],
|
||||
serde_json::json!([
|
||||
{ "kind": "https", "slot": "api" }
|
||||
])
|
||||
);
|
||||
assert!(
|
||||
report["next_steps"]
|
||||
.to_string()
|
||||
.contains("does not establish Host implementation, HTTP availability")
|
||||
);
|
||||
assert_eq!(report["safety"]["no_network"], true);
|
||||
assert_eq!(report["safety"]["no_plugin_execution"], true);
|
||||
render_pack(&source, Some(&output), &args).unwrap();
|
||||
assert_eq!(build_check_report(&output).status, "verified");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_surface_and_legacy_schema_diagnostics_reach_cli_reports() {
|
||||
let dir = tempdir().unwrap();
|
||||
let source = dir.path().join("invalid");
|
||||
built_tool_package(&source);
|
||||
let manifest_path = source.join("plugin.toml");
|
||||
let manifest = fs::read_to_string(&manifest_path).unwrap();
|
||||
for invalid in [
|
||||
manifest.replace("schema_version = 2", "schema_version = 1"),
|
||||
manifest.replace("surfaces = [\"tool\"]", "surfaces = [\"service\"]"),
|
||||
] {
|
||||
assert_ne!(manifest, invalid);
|
||||
fs::write(&manifest_path, invalid).unwrap();
|
||||
let rendered = render_check(&source, &PluginCliArgs { json: true }).unwrap();
|
||||
let report: serde_json::Value = serde_json::from_str(&rendered).unwrap();
|
||||
assert_eq!(report["status"], "rejected");
|
||||
assert!(report["package"].is_null());
|
||||
assert!(
|
||||
!report["diagnostics"][0]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -7,6 +7,7 @@ pkgs.mkShell {
|
||||
playwright-driver.browsers
|
||||
rustc
|
||||
cargo
|
||||
lld
|
||||
rustfmt
|
||||
pkgs.sccache
|
||||
];
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ rustPlatform.buildRustPackage rec {
|
||||
};
|
||||
|
||||
# All four WIP SDK crates resolve from crates.io 0.2.0 via Cargo.lock checksums.
|
||||
cargoHash = "sha256-L1RR+zdcVxMGvEonGxERgcvzjxzXro4n2j2syBeR/m0=";
|
||||
cargoHash = "sha256-cLDfqFu6H9YjaL5EpTs5NE2GzkeS0qYcj5Ys/6U5u40=";
|
||||
|
||||
depsExtraArgs = {
|
||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Workspace Plugin APIの設計契約
|
||||
|
||||
本書はWorkspace管理のPluginに採用する設計を定める。実行形式はWebAssembly Component Model、engineはWasmtime、guest/Host境界はWITとする。実装済みAPIの利用ガイドではない。既存の[v1 Tool world](wit/yoi-plugin-tool-v1.wit)、[v1 instance world](wit/yoi-plugin-instance-v1.wit)、[v1 Host imports](wit/deps/yoi-host/yoi-host-v1.wit)、PDK、templateは旧v1のoffline authoring資産であり、本書のv2実行契約をまだ提供していない。
|
||||
本書はWorkspace管理のPluginに採用する設計を定める。実行形式はWebAssembly Component Model、engineはWasmtime、guest/Host境界はWITとする。実装済みAPIの利用ガイドではない。旧[v1 Tool world](legacy/wit/yoi-plugin-tool-v1.wit)、[v1 instance world](legacy/wit/yoi-plugin-instance-v1.wit)、[v1 Host imports](legacy/wit/deps/yoi-host/yoi-host-v1.wit)は歴史資料として隔離する。v2の機械的契約は[Tool worlds](wit/yoi-plugin-v2.wit)と[Host imports](wit/deps/yoi-host/yoi-host-v2.wit)で定める。PDKやtemplateのbuild成功はHost実装やWorker実行経路の完成を意味しない。
|
||||
|
||||
管理、guest実行、Server上の拡張データを横断する判断をWIT資産の近くで一か所に保持する。実装時には各wire型と境界testを契約の機械的な根拠にし、ここには所有境界・失敗保証・採用理由を残す。作業順と進捗の正本はO-4、T-469、T-465、T-533とする。
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerate authoring copies and real, never-instantiated Components from Rust.
|
||||
# Prerequisites: Cargo/Rust with wasm32-unknown-unknown std and lld on PATH.
|
||||
set -euo pipefail
|
||||
root="$(cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
cd "$root"
|
||||
for template in rust-component-tool rust-component-https; do
|
||||
cp resources/plugin/wit/yoi-plugin-v2.wit "resources/plugin/templates/$template/wit/yoi-plugin-v2.wit"
|
||||
cp resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit "resources/plugin/templates/$template/wit/deps/yoi-host/yoi-host-v2.wit"
|
||||
done
|
||||
cargo test --locked -p yoi-plugin-pdk --target-dir target/sdk/test --test template
|
||||
cp target/sdk/fixtures/rust-component-tool/plugin.component.wasm crates/plugin-pdk/tests/fixtures/tool-v2.component.wasm
|
||||
cp target/sdk/fixtures/rust-component-https/plugin.component.wasm crates/plugin-pdk/tests/fixtures/https-tool-v2.component.wasm
|
||||
+1
-2
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "yoi-rust-component-service-template"
|
||||
name = "yoi-rust-component-https-template"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
@@ -14,7 +14,6 @@ crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" }
|
||||
|
||||
# Out-of-tree Plugin packages should replace the local path with a pinned
|
||||
@@ -0,0 +1,5 @@
|
||||
# Connection-bound HTTPS Tool雛形
|
||||
|
||||
接続先とsecretをguestが選べないよう、宣言slotからcall-local resourceを取得する。副作用の観測を失敗時にも保持し、自動再送やrollback成功を装わない。HTTP error statusはresponseとして扱う。
|
||||
|
||||
このソースをComponentへbuildできることは、HTTPS Hostの実装・認可・receipt保存を提供した証拠にはならない。packageの要求grantはWorkspace bindingや実行権限を自己発行しない。
|
||||
@@ -0,0 +1,28 @@
|
||||
schema_version = 2
|
||||
id = "example.rust_component_https"
|
||||
name = "Rust HTTPS Component Tool Template"
|
||||
version = "0.1.0"
|
||||
surfaces = ["tool"]
|
||||
config_schema = "{}"
|
||||
requested_grants = [{ kind = "https", slot = "api" }]
|
||||
|
||||
[runtime]
|
||||
kind = "wasm-component"
|
||||
component = "plugin.component.wasm"
|
||||
world = "yoi:plugin/https-tool-plugin@2.0.0"
|
||||
required_imports = ["yoi:host/errors@2.0.0", "yoi:host/connections@2.0.0", "yoi:host/http@2.0.0"]
|
||||
|
||||
[[tools]]
|
||||
name = "example_status"
|
||||
description = "Observe the status response through an explicitly bound HTTPS slot."
|
||||
input_schema = { type = "object", properties = {}, additionalProperties = false }
|
||||
output_schema = { type = "object", properties = { status = { type = "integer" }, body_bytes = { type = "integer" } }, required = ["status", "body_bytes"], additionalProperties = false }
|
||||
# Even GET may have side effects. Host receipt/admission policy still applies.
|
||||
error_codes = ["invalid_input", "invalid_output", "serialization", "unsupported_tool", "failed"]
|
||||
external_write = true
|
||||
|
||||
[[connections]]
|
||||
name = "api"
|
||||
# Package requests HTTPS bounds; Workspace binding supplies origin and secrets.
|
||||
methods = ["GET"]
|
||||
path_prefixes = ["/status"]
|
||||
@@ -0,0 +1,61 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use yoi_plugin_pdk::wit_bindgen;
|
||||
use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput};
|
||||
|
||||
wit_bindgen::generate!({
|
||||
world: "https-tool-plugin",
|
||||
path: "wit",
|
||||
generate_all,
|
||||
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||
});
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Input {}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Output {
|
||||
status: u16,
|
||||
body_bytes: usize,
|
||||
}
|
||||
|
||||
fn handle_status(ctx: ToolContext, _input: Input) -> Result<ToolOutput, ToolError> {
|
||||
use yoi::host::{connections, http};
|
||||
if ctx.tool_name() != "example_status" {
|
||||
return Err(ToolError::unsupported_tool(ctx.tool_name()));
|
||||
}
|
||||
// This resource is call-local; the slot has no URL or credential value.
|
||||
let target = connections::open("api").map_err(host_failure)?;
|
||||
let response = http::send(
|
||||
&target,
|
||||
&http::Request {
|
||||
method: http::Method::Get,
|
||||
path: "/status".into(),
|
||||
query: Vec::new(),
|
||||
headers: Vec::new(),
|
||||
body: Vec::new(),
|
||||
timeout_ms: ctx.remaining_ms().min(15_000),
|
||||
// Stable within this logical invocation. Never automatically retry a failure.
|
||||
effect_key: "status-v1".into(),
|
||||
},
|
||||
)
|
||||
.map_err(host_failure)?;
|
||||
// HTTP 4xx/5xx remain responses, not transport failures. Do not log raw bodies.
|
||||
ToolOutput::json(
|
||||
"HTTP response observed",
|
||||
Output {
|
||||
status: response.status,
|
||||
body_bytes: response.body.len(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn host_failure(error: yoi::host::errors::HostError) -> ToolError {
|
||||
// Preserve the effect observation for the caller without claiming rollback or retry safety.
|
||||
ToolError::failed(format!(
|
||||
"Host {:?}; effect {:?}; diagnostic {}",
|
||||
error.code, error.effect, error.diagnostic_id
|
||||
))
|
||||
}
|
||||
|
||||
yoi_plugin_pdk::export_component_tool!(Plugin, handle_status);
|
||||
@@ -0,0 +1,61 @@
|
||||
package yoi:host@2.0.0;
|
||||
|
||||
interface errors {
|
||||
enum code {
|
||||
invalid-input, denied, not-found, conflict, unsupported,
|
||||
quota-exceeded, deadline-exceeded, cancelled, unavailable,
|
||||
indeterminate, receipt-expired,
|
||||
}
|
||||
enum effect-observation { not-dispatched, response-observed, indeterminate }
|
||||
record host-error {
|
||||
code: code,
|
||||
message: string,
|
||||
diagnostic-id: string,
|
||||
effect: effect-observation,
|
||||
}
|
||||
}
|
||||
|
||||
interface connections {
|
||||
use errors.{host-error};
|
||||
/// Call-local resource. No serialization, persistence or cross-Worker transfer.
|
||||
resource connection;
|
||||
open: func(slot: string) -> result<connection, host-error>;
|
||||
}
|
||||
|
||||
interface http {
|
||||
use connections.{connection};
|
||||
use errors.{host-error};
|
||||
enum method { get, head, post, put, patch, delete, options }
|
||||
record pair { name: string, value: string }
|
||||
record request {
|
||||
method: method,
|
||||
path: string,
|
||||
query: list<pair>,
|
||||
headers: list<pair>,
|
||||
body: list<u8>,
|
||||
timeout-ms: u32,
|
||||
effect-key: string,
|
||||
}
|
||||
record response {
|
||||
status: u16,
|
||||
headers: list<pair>,
|
||||
body: list<u8>,
|
||||
}
|
||||
/// Host authorizes each operation and does not automatically retry or follow redirects.
|
||||
send: func(target: borrow<connection>, input: request) -> result<response, host-error>;
|
||||
}
|
||||
|
||||
interface logging {
|
||||
use errors.{host-error};
|
||||
enum level { trace, debug, info, warn, error }
|
||||
variant scalar { text(string), boolean(bool), signed(s64), unsigned(u64), number(f64) }
|
||||
record field { name: string, value: scalar }
|
||||
record entry {
|
||||
level: level,
|
||||
event-code: string,
|
||||
message: string,
|
||||
fields: list<field>,
|
||||
}
|
||||
/// Bounded diagnostic data, not model context. Never include secrets or raw payloads.
|
||||
log: func(input: entry) -> result<_, host-error>;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package yoi:plugin@2.0.0;
|
||||
|
||||
/// Only plugin-specific payloads use schema-validated JSON. Context is not authority.
|
||||
interface tool {
|
||||
record invocation {
|
||||
invocation-id: string,
|
||||
tool-name: string,
|
||||
input-json: string,
|
||||
config-json: string,
|
||||
remaining-ms: u32,
|
||||
}
|
||||
record output {
|
||||
summary: string,
|
||||
content: option<string>,
|
||||
structured-json: option<string>,
|
||||
}
|
||||
record tool-error {
|
||||
code: string,
|
||||
message: string,
|
||||
}
|
||||
invoke: func(input: invocation) -> result<output, tool-error>;
|
||||
}
|
||||
|
||||
/// Pure computation: no ambient WASI or unused Host capabilities.
|
||||
world tool-plugin {
|
||||
export tool;
|
||||
}
|
||||
|
||||
world https-tool-plugin {
|
||||
import yoi:host/errors@2.0.0;
|
||||
import yoi:host/connections@2.0.0;
|
||||
import yoi:host/http@2.0.0;
|
||||
export tool;
|
||||
}
|
||||
|
||||
/// Logging is an explicit capability, never an ambient console.
|
||||
world logged-tool-plugin {
|
||||
import yoi:host/errors@2.0.0;
|
||||
import yoi:host/logging@2.0.0;
|
||||
export tool;
|
||||
}
|
||||
|
||||
world https-logged-tool-plugin {
|
||||
import yoi:host/errors@2.0.0;
|
||||
import yoi:host/connections@2.0.0;
|
||||
import yoi:host/http@2.0.0;
|
||||
import yoi:host/logging@2.0.0;
|
||||
export tool;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
# Rust Component Instance Plugin雛形
|
||||
|
||||
提案段階の`yoi:plugin/instance@1.0.0`を対象にしたオフラインpackageの雛形である。YoiはPluginのインストールやWorker内での実行を提供していない。
|
||||
|
||||
Tool・Serviceの要求と応答は将来の実行境界を検討するための例である。host APIやgrantのmetadataは現在の製品では権限を付与せず、オフライン検査の成功も実行の許可を意味しない。
|
||||
@@ -1,3 +0,0 @@
|
||||
# Build with:
|
||||
# cargo component build --release
|
||||
# cp target/wasm32-wasip1/release/example_yoi_instance_plugin.wasm plugin.component.wasm
|
||||
@@ -1,42 +0,0 @@
|
||||
schema_version = 1
|
||||
id = "example.rust_service_plugin"
|
||||
name = "Rust Service Plugin Template"
|
||||
version = "0.1.0"
|
||||
description = "Example Component Model Plugin with Tool and Service ingress handlers."
|
||||
surfaces = ["tool", "service", "ingress"]
|
||||
permissions = [
|
||||
{ kind = "surface", surface = "tool" },
|
||||
{ kind = "tool", name = "example_echo" },
|
||||
{ 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"
|
||||
|
||||
[[tools]]
|
||||
name = "example_echo"
|
||||
description = "Echo input text through the shared Plugin instance."
|
||||
input_schema = { type = "object" }
|
||||
|
||||
[[services]]
|
||||
name = "example_service"
|
||||
description = "Host-managed service instance for bounded ingress events."
|
||||
lifecycle = "host-managed"
|
||||
|
||||
[[ingresses]]
|
||||
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"]
|
||||
@@ -1,85 +0,0 @@
|
||||
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, ServiceOutput, ToolError,
|
||||
ToolOutput,
|
||||
};
|
||||
|
||||
wit_bindgen::generate!({
|
||||
world: "instance",
|
||||
path: "../../../../resources/plugin/wit",
|
||||
generate_all,
|
||||
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||
});
|
||||
|
||||
#[derive(Default)]
|
||||
struct ExamplePlugin {
|
||||
count: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EchoInput {
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EchoOutput {
|
||||
text: String,
|
||||
count: u64,
|
||||
}
|
||||
|
||||
impl Plugin for ExamplePlugin {
|
||||
fn start(config: Value) -> Result<Self, ToolError> {
|
||||
Ok(Self {
|
||||
count: config.get("start_count").and_then(Value::as_u64).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_tool(&mut self, name: &str, input: Value) -> Result<ToolOutput, ToolError> {
|
||||
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!("echoed {} bytes", input.text.len()),
|
||||
EchoOutput {
|
||||
text: input.text,
|
||||
count: self.count,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn handle_ingress(
|
||||
&mut self,
|
||||
name: &str,
|
||||
event: PluginIngressEvent,
|
||||
) -> Result<ServiceOutput, ToolError> {
|
||||
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) -> Result<PluginStatus, ToolError> {
|
||||
Ok(PluginStatus::ready(json!({ "count": self.count })))
|
||||
}
|
||||
}
|
||||
|
||||
export_plugin_instance!(ExamplePluginComponent, ExamplePlugin);
|
||||
@@ -1,5 +1,5 @@
|
||||
# Rust Component Plugin雛形
|
||||
# Rust Tool Component雛形
|
||||
|
||||
提案段階の`yoi:plugin/tool@1.0.0`を対象にしたオフラインpackageの雛形である。YoiはPluginのインストールやWorker内での実行を提供していない。
|
||||
guest境界は生成されたWIT bindingsに任せ、手書きpointer ABIとWASI依存を持たせない。呼出し間の永続状態やServiceを成功stubとして公開しない。
|
||||
|
||||
guest側の型付き入出力とエラー表現はRust PDKが担当し、ファイル・ネットワーク・環境への権限は付与しない。`plugin.component.wasm`はpackage検査用のテキストplaceholderであり、実行可能なcomponentの証拠にはならない。
|
||||
`plugin.component.wasm`はソースからbuildして配置する成果物であり、placeholderをpackageへ混入させない。PDKからのbuild成功と、Hostの認可・実行経路の完成は別の判断である。
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
placeholder component artifact for authoring-template checks; replace with a built wasm component before enabling.
|
||||
@@ -1,20 +1,21 @@
|
||||
schema_version = 1
|
||||
schema_version = 2
|
||||
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" },
|
||||
]
|
||||
config_schema = "{}"
|
||||
requested_grants = []
|
||||
|
||||
[runtime]
|
||||
kind = "wasm-component"
|
||||
component = "plugin.component.wasm"
|
||||
world = "yoi:plugin/tool@1.0.0"
|
||||
world = "yoi:plugin/tool-plugin@2.0.0"
|
||||
required_imports = []
|
||||
|
||||
[[tools]]
|
||||
name = "example_echo"
|
||||
description = "Echo input text using the Rust PDK."
|
||||
input_schema = { type = "object", properties = { text = { type = "string" } }, required = ["text"], additionalProperties = false }
|
||||
output_schema = { type = "object", properties = { tool = { type = "string" }, text = { type = "string" } }, required = ["tool", "text"], additionalProperties = false }
|
||||
error_codes = ["invalid_input", "invalid_output", "serialization", "unsupported_tool", "failed"]
|
||||
external_write = false
|
||||
|
||||
@@ -3,13 +3,14 @@ use yoi_plugin_pdk::wit_bindgen;
|
||||
use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput};
|
||||
|
||||
wit_bindgen::generate!({
|
||||
world: "tool",
|
||||
path: "../../../../resources/plugin/wit",
|
||||
world: "tool-plugin",
|
||||
path: "wit",
|
||||
generate_all,
|
||||
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||
});
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EchoInput {
|
||||
text: String,
|
||||
}
|
||||
@@ -21,6 +22,9 @@ struct EchoOutput<'a> {
|
||||
}
|
||||
|
||||
fn handle_echo(ctx: ToolContext, input: EchoInput) -> Result<ToolOutput, ToolError> {
|
||||
if ctx.tool_name() != "example_echo" {
|
||||
return Err(ToolError::unsupported_tool(ctx.tool_name()));
|
||||
}
|
||||
if input.text.trim().is_empty() {
|
||||
return Err(ToolError::invalid_input("`text` must not be empty"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package yoi:host@2.0.0;
|
||||
|
||||
interface errors {
|
||||
enum code {
|
||||
invalid-input, denied, not-found, conflict, unsupported,
|
||||
quota-exceeded, deadline-exceeded, cancelled, unavailable,
|
||||
indeterminate, receipt-expired,
|
||||
}
|
||||
enum effect-observation { not-dispatched, response-observed, indeterminate }
|
||||
record host-error {
|
||||
code: code,
|
||||
message: string,
|
||||
diagnostic-id: string,
|
||||
effect: effect-observation,
|
||||
}
|
||||
}
|
||||
|
||||
interface connections {
|
||||
use errors.{host-error};
|
||||
/// Call-local resource. No serialization, persistence or cross-Worker transfer.
|
||||
resource connection;
|
||||
open: func(slot: string) -> result<connection, host-error>;
|
||||
}
|
||||
|
||||
interface http {
|
||||
use connections.{connection};
|
||||
use errors.{host-error};
|
||||
enum method { get, head, post, put, patch, delete, options }
|
||||
record pair { name: string, value: string }
|
||||
record request {
|
||||
method: method,
|
||||
path: string,
|
||||
query: list<pair>,
|
||||
headers: list<pair>,
|
||||
body: list<u8>,
|
||||
timeout-ms: u32,
|
||||
effect-key: string,
|
||||
}
|
||||
record response {
|
||||
status: u16,
|
||||
headers: list<pair>,
|
||||
body: list<u8>,
|
||||
}
|
||||
/// Host authorizes each operation and does not automatically retry or follow redirects.
|
||||
send: func(target: borrow<connection>, input: request) -> result<response, host-error>;
|
||||
}
|
||||
|
||||
interface logging {
|
||||
use errors.{host-error};
|
||||
enum level { trace, debug, info, warn, error }
|
||||
variant scalar { text(string), boolean(bool), signed(s64), unsigned(u64), number(f64) }
|
||||
record field { name: string, value: scalar }
|
||||
record entry {
|
||||
level: level,
|
||||
event-code: string,
|
||||
message: string,
|
||||
fields: list<field>,
|
||||
}
|
||||
/// Bounded diagnostic data, not model context. Never include secrets or raw payloads.
|
||||
log: func(input: entry) -> result<_, host-error>;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package yoi:plugin@2.0.0;
|
||||
|
||||
/// Only plugin-specific payloads use schema-validated JSON. Context is not authority.
|
||||
interface tool {
|
||||
record invocation {
|
||||
invocation-id: string,
|
||||
tool-name: string,
|
||||
input-json: string,
|
||||
config-json: string,
|
||||
remaining-ms: u32,
|
||||
}
|
||||
record output {
|
||||
summary: string,
|
||||
content: option<string>,
|
||||
structured-json: option<string>,
|
||||
}
|
||||
record tool-error {
|
||||
code: string,
|
||||
message: string,
|
||||
}
|
||||
invoke: func(input: invocation) -> result<output, tool-error>;
|
||||
}
|
||||
|
||||
/// Pure computation: no ambient WASI or unused Host capabilities.
|
||||
world tool-plugin {
|
||||
export tool;
|
||||
}
|
||||
|
||||
world https-tool-plugin {
|
||||
import yoi:host/errors@2.0.0;
|
||||
import yoi:host/connections@2.0.0;
|
||||
import yoi:host/http@2.0.0;
|
||||
export tool;
|
||||
}
|
||||
|
||||
/// Logging is an explicit capability, never an ambient console.
|
||||
world logged-tool-plugin {
|
||||
import yoi:host/errors@2.0.0;
|
||||
import yoi:host/logging@2.0.0;
|
||||
export tool;
|
||||
}
|
||||
|
||||
world https-logged-tool-plugin {
|
||||
import yoi:host/errors@2.0.0;
|
||||
import yoi:host/connections@2.0.0;
|
||||
import yoi:host/http@2.0.0;
|
||||
import yoi:host/logging@2.0.0;
|
||||
export tool;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package yoi:host@2.0.0;
|
||||
|
||||
interface errors {
|
||||
enum code {
|
||||
invalid-input, denied, not-found, conflict, unsupported,
|
||||
quota-exceeded, deadline-exceeded, cancelled, unavailable,
|
||||
indeterminate, receipt-expired,
|
||||
}
|
||||
enum effect-observation { not-dispatched, response-observed, indeterminate }
|
||||
record host-error {
|
||||
code: code,
|
||||
message: string,
|
||||
diagnostic-id: string,
|
||||
effect: effect-observation,
|
||||
}
|
||||
}
|
||||
|
||||
interface connections {
|
||||
use errors.{host-error};
|
||||
/// Call-local resource. No serialization, persistence or cross-Worker transfer.
|
||||
resource connection;
|
||||
open: func(slot: string) -> result<connection, host-error>;
|
||||
}
|
||||
|
||||
interface http {
|
||||
use connections.{connection};
|
||||
use errors.{host-error};
|
||||
enum method { get, head, post, put, patch, delete, options }
|
||||
record pair { name: string, value: string }
|
||||
record request {
|
||||
method: method,
|
||||
path: string,
|
||||
query: list<pair>,
|
||||
headers: list<pair>,
|
||||
body: list<u8>,
|
||||
timeout-ms: u32,
|
||||
effect-key: string,
|
||||
}
|
||||
record response {
|
||||
status: u16,
|
||||
headers: list<pair>,
|
||||
body: list<u8>,
|
||||
}
|
||||
/// Host authorizes each operation and does not automatically retry or follow redirects.
|
||||
send: func(target: borrow<connection>, input: request) -> result<response, host-error>;
|
||||
}
|
||||
|
||||
interface logging {
|
||||
use errors.{host-error};
|
||||
enum level { trace, debug, info, warn, error }
|
||||
variant scalar { text(string), boolean(bool), signed(s64), unsigned(u64), number(f64) }
|
||||
record field { name: string, value: scalar }
|
||||
record entry {
|
||||
level: level,
|
||||
event-code: string,
|
||||
message: string,
|
||||
fields: list<field>,
|
||||
}
|
||||
/// Bounded diagnostic data, not model context. Never include secrets or raw payloads.
|
||||
log: func(input: entry) -> result<_, host-error>;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package yoi:plugin@2.0.0;
|
||||
|
||||
/// Only plugin-specific payloads use schema-validated JSON. Context is not authority.
|
||||
interface tool {
|
||||
record invocation {
|
||||
invocation-id: string,
|
||||
tool-name: string,
|
||||
input-json: string,
|
||||
config-json: string,
|
||||
remaining-ms: u32,
|
||||
}
|
||||
record output {
|
||||
summary: string,
|
||||
content: option<string>,
|
||||
structured-json: option<string>,
|
||||
}
|
||||
record tool-error {
|
||||
code: string,
|
||||
message: string,
|
||||
}
|
||||
invoke: func(input: invocation) -> result<output, tool-error>;
|
||||
}
|
||||
|
||||
/// Pure computation: no ambient WASI or unused Host capabilities.
|
||||
world tool-plugin {
|
||||
export tool;
|
||||
}
|
||||
|
||||
world https-tool-plugin {
|
||||
import yoi:host/errors@2.0.0;
|
||||
import yoi:host/connections@2.0.0;
|
||||
import yoi:host/http@2.0.0;
|
||||
export tool;
|
||||
}
|
||||
|
||||
/// Logging is an explicit capability, never an ambient console.
|
||||
world logged-tool-plugin {
|
||||
import yoi:host/errors@2.0.0;
|
||||
import yoi:host/logging@2.0.0;
|
||||
export tool;
|
||||
}
|
||||
|
||||
world https-logged-tool-plugin {
|
||||
import yoi:host/errors@2.0.0;
|
||||
import yoi:host/connections@2.0.0;
|
||||
import yoi:host/http@2.0.0;
|
||||
import yoi:host/logging@2.0.0;
|
||||
export tool;
|
||||
}
|
||||
Reference in New Issue
Block a user