feat: integrate v2 Plugin contracts and authoring

This commit is contained in:
2026-10-28 13:53:26 +09:00
40 changed files with 3399 additions and 1661 deletions
Generated
+59 -6
View File
@@ -1853,6 +1853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [ dependencies = [
"foldhash 0.1.5", "foldhash 0.1.5",
"serde",
] ]
[[package]] [[package]]
@@ -2614,7 +2615,9 @@ dependencies = [
"agen", "agen",
"arc-swap", "arc-swap",
"builtin-source", "builtin-source",
"crc32fast",
"decodal", "decodal",
"jsonschema",
"protocol", "protocol",
"secrets", "secrets",
"serde", "serde",
@@ -2626,6 +2629,9 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"toml", "toml",
"tracing", "tracing",
"wasmparser 0.244.0",
"wit-component",
"wit-parser",
] ]
[[package]] [[package]]
@@ -5823,7 +5829,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [ dependencies = [
"leb128fmt", "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]] [[package]]
@@ -5834,8 +5850,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"indexmap", "indexmap",
"wasm-encoder", "wasm-encoder 0.244.0",
"wasmparser", "wasmparser 0.244.0",
] ]
[[package]] [[package]]
@@ -5861,6 +5877,39 @@ dependencies = [
"hashbrown 0.15.5", "hashbrown 0.15.5",
"indexmap", "indexmap",
"semver", "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]] [[package]]
@@ -6472,9 +6521,10 @@ dependencies = [
"serde", "serde",
"serde_derive", "serde_derive",
"serde_json", "serde_json",
"wasm-encoder", "wasm-encoder 0.244.0",
"wasm-metadata", "wasm-metadata",
"wasmparser", "wasmparser 0.244.0",
"wat",
"wit-parser", "wit-parser",
] ]
@@ -6493,7 +6543,7 @@ dependencies = [
"serde_derive", "serde_derive",
"serde_json", "serde_json",
"unicode-xid", "unicode-xid",
"wasmparser", "wasmparser 0.244.0",
] ]
[[package]] [[package]]
@@ -6740,7 +6790,10 @@ dependencies = [
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"toml", "toml",
"wasmparser 0.244.0",
"wit-bindgen", "wit-bindgen",
"wit-component",
"wit-parser",
] ]
[[package]] [[package]]
+6
View File
@@ -15,11 +15,17 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
serde_ignored = "0.1.14" serde_ignored = "0.1.14"
sha2 = "0.10" 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 } secrets = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
toml = { workspace = true } toml = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
[dev-dependencies] [dev-dependencies]
wit-component = { version = "0.244.0", features = ["dummy-module"] }
serial_test = "3.4.0" serial_test = "3.4.0"
tempfile = { workspace = true } tempfile = { workspace = true }
File diff suppressed because it is too large Load Diff
+572
View File
@@ -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());
}
}
+510
View File
@@ -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}");
}
}
+3
View File
@@ -15,3 +15,6 @@ wit-bindgen = "0.51.0"
[dev-dependencies] [dev-dependencies]
tempfile.workspace = true tempfile.workspace = true
toml.workspace = true toml.workspace = true
wasmparser = "0.244.0"
wit-component = "0.244.0"
wit-parser = "0.244.0"
+2 -6
View File
@@ -1,9 +1,5 @@
# plugin-pdk # 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 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.
- [`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
+184 -644
View File
@@ -2,146 +2,126 @@
use serde::Serialize; use serde::Serialize;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde_json::Value;
pub use wit_bindgen; pub use wit_bindgen;
pub type Result<T> = std::result::Result<T, ToolError>; 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-plugin@2.0.0";
pub const TOOL_WORLD: &str = "yoi:plugin/tool@1.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");
/// Repository WIT for the current Tool world, exposed for authoring tools and
/// tests. Runtime components should still generate bindings at compile time.
pub const TOOL_WIT: &str = include_str!("../../../resources/plugin/wit/yoi-plugin-tool-v1.wit");
/// Repository WIT for the grant-bound host APIs importable by Tool components.
pub const HOST_WIT: &str = pub const HOST_WIT: &str =
include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v1.wit"); include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit");
/// Serialized JSON limit defined by the offline Tool world's output contract.
pub const MAX_TOOL_OUTPUT_BYTES: usize = 64 * 1024; pub const MAX_TOOL_OUTPUT_BYTES: usize = 64 * 1024;
/// Byte limit for summaries in the offline Tool world. pub const MAX_TOOL_INPUT_BYTES: usize = 64 * 1024;
pub const MAX_SUMMARY_BYTES: usize = 1024; pub const MAX_CONFIG_BYTES: usize = 64 * 1024;
/// Conservative content cap that leaves room for JSON framing and escaping.
pub const MAX_CONTENT_BYTES: usize = MAX_TOOL_OUTPUT_BYTES - MAX_SUMMARY_BYTES - 4096;
/// Maximum structured error message bytes retained by the PDK.
pub const MAX_ERROR_MESSAGE_BYTES: usize = 4096; 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)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct ToolContext { pub struct ToolContext {
invocation_id: String,
tool_name: String, tool_name: String,
config_json: String,
remaining_ms: u32,
} }
impl ToolContext { impl ToolContext {
/// Create context for the manifest-declared Tool name selected by the host. pub fn new(
pub fn new(tool_name: impl Into<String>) -> Self { invocation_id: String,
tool_name: String,
config_json: String,
remaining_ms: u32,
) -> Self {
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 { pub fn tool_name(&self) -> &str {
&self.tool_name &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. /// Output crosses WIT as a record, never as a success envelope hiding an error.
/// /// Oversized output is rejected, not silently truncated into invalid JSON.
/// 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.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ToolOutput { pub struct ToolOutput {
summary: String, summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<String>, content: Option<String>,
structured_json: Option<String>,
} }
impl ToolOutput { impl ToolOutput {
/// Create an ordinary Tool output. pub fn new(summary: impl Into<String>, content: Option<String>) -> Result<Self> {
pub fn new(summary: impl Into<String>, content: Option<String>) -> Self { Self::checked(summary.into(), content, None)
let mut output = Self {
summary: normalize_summary(summary.into()),
content: content.map(|value| bounded_text(value, MAX_CONTENT_BYTES)),
};
output.bound_serialized_json();
output
} }
/// Create a Tool output whose content is typed JSON. pub fn summary(summary: impl Into<String>) -> Result<Self> {
pub fn json( Self::new(summary, None)
summary: impl Into<String>, }
value: impl Serialize,
) -> std::result::Result<Self, ToolError> { /// Structured JSON is separate from presentation text and needs a manifest output schema.
let content = serde_json::to_string(&value).map_err(ToolError::serialization)?; 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 { let output = Self {
summary: normalize_summary(summary.into()), summary,
content: Some(content), content,
structured_json,
}; };
let serialized = serde_json::to_string(&output).map_err(ToolError::serialization)?; let bytes = serde_json::to_vec(&output).map_err(ToolError::serialization)?;
if serialized.len() > MAX_TOOL_OUTPUT_BYTES { if bytes.len() > MAX_TOOL_OUTPUT_BYTES {
return Err(ToolError::invalid_output(format!( return Err(ToolError::invalid_output(
"serialized ToolOutput JSON exceeds {MAX_TOOL_OUTPUT_BYTES} bytes" "serialized output exceeds 64 KiB",
))); ));
} }
Ok(output) 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 { pub fn summary_text(&self) -> &str {
&self.summary &self.summary
} }
/// Return optional detailed content.
pub fn content(&self) -> Option<&str> { pub fn content(&self) -> Option<&str> {
self.content.as_deref() self.content.as_deref()
} }
pub fn structured_json(&self) -> Option<&str> {
/// Serialize to the ToolOutput JSON string returned by the WIT `call` export. self.structured_json.as_deref()
pub fn to_json_string(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| {
r#"{"summary":"tool error: serialization","content":"{\"error\":{\"code\":\"serialization\",\"message\":\"failed to serialize ToolOutput\"}}"}"#.to_string()
})
} }
fn bound_serialized_json(&mut self) { #[doc(hidden)]
loop { pub fn into_parts(self) -> (String, Option<String>, Option<String>) {
let Ok(serialized) = serde_json::to_string(self) else { (self.summary, self.content, self.structured_json)
return;
};
if serialized.len() <= MAX_TOOL_OUTPUT_BYTES {
return;
}
let Some(content) = self.content.take() else {
return;
};
if content.is_empty() {
return;
}
let overflow = serialized.len() - MAX_TOOL_OUTPUT_BYTES;
let target = content.len().saturating_sub(overflow + "…".len());
self.content = Some(bounded_text(content, target));
}
} }
} }
/// Stable, low-cardinality error codes for PDK-produced Tool errors. /// Guest business errors are not Host authorization decisions or effect receipts.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ToolErrorCode { pub enum ToolErrorCode {
InvalidInput, InvalidInput,
InvalidOutput, InvalidOutput,
Serialization, Serialization,
Denied,
UnsupportedTool, UnsupportedTool,
Failed, Failed,
} }
@@ -152,640 +132,200 @@ impl ToolErrorCode {
Self::InvalidInput => "invalid_input", Self::InvalidInput => "invalid_input",
Self::InvalidOutput => "invalid_output", Self::InvalidOutput => "invalid_output",
Self::Serialization => "serialization", Self::Serialization => "serialization",
Self::Denied => "denied",
Self::UnsupportedTool => "unsupported_tool", Self::UnsupportedTool => "unsupported_tool",
Self::Failed => "failed", Self::Failed => "failed",
} }
} }
} }
/// Structured, bounded error that is rendered as ordinary ToolOutput JSON. #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[derive(Clone, Debug, PartialEq, Serialize, thiserror::Error)]
#[error("{code:?}: {message}")] #[error("{code:?}: {message}")]
pub struct ToolError { pub struct ToolError {
code: ToolErrorCode, code: ToolErrorCode,
message: String, message: String,
#[serde(skip_serializing_if = "Option::is_none")]
details: Option<Value>,
} }
impl ToolError { impl ToolError {
pub fn new(code: ToolErrorCode, message: impl Into<String>) -> Self { pub fn new(code: ToolErrorCode, message: impl Into<String>) -> Self {
Self { let mut message = message.into();
code, if message.len() > MAX_ERROR_MESSAGE_BYTES {
message: bounded_text(message.into(), MAX_ERROR_MESSAGE_BYTES), let mut end = MAX_ERROR_MESSAGE_BYTES;
details: None, while !message.is_char_boundary(end) {
end -= 1;
}
message.truncate(end);
} }
Self { code, message }
} }
pub fn invalid_input(message: impl Into<String>) -> Self { pub fn invalid_input(message: impl Into<String>) -> Self {
Self::new(ToolErrorCode::InvalidInput, message) Self::new(ToolErrorCode::InvalidInput, message)
} }
pub fn invalid_output(message: impl Into<String>) -> Self { pub fn invalid_output(message: impl Into<String>) -> Self {
Self::new(ToolErrorCode::InvalidOutput, message) Self::new(ToolErrorCode::InvalidOutput, message)
} }
/// Serialization diagnostics must not include the original payload.
pub fn serialization(error: impl std::fmt::Display) -> Self { pub fn serialization(_error: impl std::fmt::Display) -> Self {
Self::new(ToolErrorCode::Serialization, error.to_string()) Self::new(ToolErrorCode::Serialization, "output serialization failed")
} }
pub fn unsupported_tool(_name: impl AsRef<str>) -> Self {
pub fn denied(message: impl Into<String>) -> Self { Self::new(ToolErrorCode::UnsupportedTool, "unsupported Tool name")
Self::new(ToolErrorCode::Denied, message)
} }
pub fn unsupported_tool(tool_name: impl Into<String>) -> Self {
Self::new(
ToolErrorCode::UnsupportedTool,
format!("unsupported tool `{}`", tool_name.into()),
)
}
pub fn failed(message: impl Into<String>) -> Self { pub fn failed(message: impl Into<String>) -> Self {
Self::new(ToolErrorCode::Failed, message) 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 { pub fn code(&self) -> ToolErrorCode {
self.code self.code
} }
pub fn code_str(&self) -> &'static str { pub fn code_str(&self) -> &'static str {
self.code.as_str() self.code.as_str()
} }
pub fn message(&self) -> &str { pub fn message(&self) -> &str {
&self.message &self.message
} }
}
/// Render this error as ordinary ToolOutput JSON content. fn parse_bounded_json<T: DeserializeOwned>(json: &str, limit: usize) -> Result<T> {
pub fn into_tool_output(self) -> ToolOutput { if json.len() > limit {
#[derive(Serialize)] return Err(ToolError::invalid_input("JSON exceeds 64 KiB"));
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))
} }
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: DeserializeOwned>(json: &str) -> Result<T> {
pub fn parse_json_input<T>(input_json: &str) -> std::result::Result<T, ToolError> parse_bounded_json(json, MAX_TOOL_INPUT_BYTES)
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}"
))
})
} }
/// Guest-side boundary that runs a typed JSON handler and converts the result to ToolOutput JSON. /// 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.
/// Convert parse, handler, and serialization failures to the normal output form. These errors pub fn run_json_tool<I: DeserializeOwned>(
/// are not host authorization decisions, and catching handler panics is not guaranteed. context: ToolContext,
/// The PDK does not depend on host/runtime crates or grant permissions through input conversion. input_json: &str,
pub fn run_json_tool<I, F>(tool_name: &str, input_json: &str, handler: F) -> String handler: impl FnOnce(ToolContext, I) -> Result<ToolOutput>,
where ) -> Result<ToolOutput> {
I: DeserializeOwned, let _: serde_json::Value = context.config()?;
F: FnOnce(ToolContext, I) -> std::result::Result<ToolOutput, ToolError>, let input = parse_json_input(input_json)?;
{ handler(context, input)
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(),
}
} }
/// Connect a typed JSON handler to a WIT `Guest` implementation and component export. /// Adapt bindings generated from any supported v2 Tool world. The generated glue
/// /// owns the canonical ABI; callers never implement pointer/length exports.
/// 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.
#[macro_export] #[macro_export]
macro_rules! export_component_tool { macro_rules! export_component_tool {
($adapter:ident, $handler:path) => { ($adapter:ident, $handler:path) => {
struct $adapter; struct $adapter;
impl exports::yoi::plugin::tool::Guest for $adapter {
impl Guest for $adapter { fn invoke(
fn call( input: exports::yoi::plugin::tool::Invocation,
tool_name: ::std::string::String, ) -> ::std::result::Result<
input_json: ::std::string::String, exports::yoi::plugin::tool::Output,
) -> ::std::string::String { exports::yoi::plugin::tool::ToolError,
$crate::run_json_tool(&tool_name, &input_json, $handler) > {
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); 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use serde_json::json; use serde_json::json;
#[derive(Debug, Deserialize)] #[derive(Deserialize)]
struct EchoInput { #[serde(deny_unknown_fields)]
struct Input {
text: String, text: String,
} }
#[derive(Debug, Serialize)] fn context(config: &str) -> ToolContext {
struct EchoOutput<'a> { ToolContext::new("call-1".into(), "echo".into(), config.into(), 500)
tool: &'a str,
text: String,
} }
#[test] #[test]
fn run_json_tool_parses_input_and_serializes_output() { fn typed_handler_keeps_structured_output_separate_from_text() {
let output = run_json_tool( let output = run_json_tool(context("{}"), r#"{"text":"hello"}"#, |ctx, input: Input| {
"example_echo", assert_eq!(ctx.invocation_id(), "call-1");
r#"{"text":"hello"}"#, assert_eq!(ctx.remaining_ms(), 500);
|ctx, input: EchoInput| { ToolOutput::json(
ToolOutput::json( "echoed",
format!("{} ok", ctx.tool_name()), json!({"text": input.text, "tool": ctx.tool_name()}),
EchoOutput { )
tool: ctx.tool_name(), })
text: input.text, .unwrap();
}, assert_eq!(output.content(), None);
)
},
);
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");
assert_eq!( assert_eq!(
value["output_commands"][0]["requested_at"], serde_json::from_str::<serde_json::Value>(output.structured_json().unwrap()).unwrap(),
"2026-06-25T00:00:00Z" json!({"text":"hello", "tool":"echo"})
); );
} }
#[test] #[test]
fn wit_constants_match_current_world() { fn invalid_input_or_config_never_runs_handler_or_returns_success() {
assert!(TOOL_WIT.contains("package yoi:plugin@1.0.0")); for (config, input) in [
assert!(TOOL_WIT.contains("world tool")); ("{}", r#"{"text":1}"#),
assert!(TOOL_WIT.contains("export call")); ("broken", r#"{"text":"ok"}"#),
assert_eq!(TOOL_WORLD, "yoi:plugin/tool@1.0.0"); ("{}", r#"{"text":"ok","extra":true}"#),
assert!(HOST_WIT.contains("interface request")); ] {
assert!(HOST_WIT.contains("interface websocket")); let error = run_json_tool::<Input>(context(config), input, |_, _| {
assert!(HOST_WIT.contains("interface fs")); panic!("handler must not run")
assert!(HOST_WIT.contains("%list: func")); })
assert!(INSTANCE_WIT.contains("world instance")); .unwrap_err();
assert!(INSTANCE_WIT.contains("export handle-ingress")); assert_eq!(error.code(), ToolErrorCode::InvalidInput);
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,
} }
} }
pub fn stopped() -> Self { #[test]
Self { fn payload_limits_reject_overflow_including_json_escaping() {
state: "stopped".to_string(), for text in [
data: Value::Null, "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)] #[test]
#[serde(rename_all = "snake_case")] fn business_failure_is_preserved_and_message_is_utf8_bounded() {
pub enum ServiceOutputCommandKind { let error = run_json_tool(context("{}"), "null", |_, _: ()| {
DiagnosticStatusUpdate, Err(ToolError::failed("語".repeat(MAX_ERROR_MESSAGE_BYTES)))
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,
}) })
.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
View File
@@ -1,247 +1,206 @@
//! Check the shipped templates against the real PDK and WIT for the guest target. //! Real Rust -> core Wasm -> Component coverage for the shipped starters.
//! Each check owns its source and lockfile; Cargo alone shares and validates artifacts. //! 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::fs;
use std::path::Path; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use toml::Value; use toml::Value;
use wit_parser::WorldItem;
const TEMPLATE_CARGO: &str = const TEMPLATES: [&str; 2] = ["rust-component-tool", "rust-component-https"];
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");
#[test] fn root() -> PathBuf {
fn rust_component_tool_template_has_expected_files() { Path::new(env!("CARGO_MANIFEST_DIR"))
let cargo: Value = toml::from_str(TEMPLATE_CARGO).expect("template Cargo.toml parses"); .join("../..")
assert_eq!(cargo["package"]["edition"].as_str(), Some("2024")); .canonicalize()
assert!(cargo["workspace"].is_table()); .unwrap()
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"));
} }
#[test] #[test]
fn rust_component_service_template_has_event_output_pattern() { fn template_wit_copies_match_the_versioned_contract() {
let cargo: Value = toml::from_str(SERVICE_TEMPLATE_CARGO).expect("service Cargo.toml parses"); let root = root();
assert_eq!(cargo["package"]["edition"].as_str(), Some("2024")); for template in TEMPLATES {
assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib")); for file in ["yoi-plugin-v2.wit", "deps/yoi-host/yoi-host-v2.wit"] {
assert_eq!( assert_eq!(
cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(), fs::read(root.join("resources/plugin/wit").join(file)).unwrap(),
Some("../../../../crates/plugin-pdk") fs::read(
); root.join("resources/plugin/templates")
assert!(SERVICE_TEMPLATE_CARGO.contains("rev = \"<pinned-yoi-commit-sha>\"")); .join(template)
.join("wit")
let plugin: Value = .join(file)
toml::from_str(SERVICE_TEMPLATE_PLUGIN).expect("service plugin.toml parses"); )
assert_eq!(plugin["schema_version"].as_integer(), Some(1)); .unwrap(),
assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component")); "{template}/{file} must be regenerated from the canonical WIT"
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\""));
} }
#[test] #[test]
fn component_tool_fixture_uses_pdk_bindings() { fn template_manifest_worlds_use_exact_versions_and_schemas() {
assert!(SAMPLE_LIB.contains("use yoi_plugin_pdk::wit_bindgen")); for template in TEMPLATES {
assert!(SAMPLE_LIB.contains("wit_bindgen::generate!")); let path = root().join("resources/plugin/templates").join(template);
assert!(SAMPLE_LIB.contains("generate_all")); let cargo: Value =
assert!(SAMPLE_LIB.contains("runtime_path: \"yoi_plugin_pdk::wit_bindgen::rt\"")); toml::from_str(&fs::read_to_string(path.join("Cargo.toml")).unwrap()).unwrap();
assert!(SAMPLE_LIB.contains("yoi_plugin_pdk::export_component_tool!")); assert!(cargo["workspace"].is_table());
assert!(!SAMPLE_LIB.contains("export_name")); 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] #[test]
fn embedded_template_cargo_checks_for_wasm_target() { fn pure_starter_builds_real_component_without_imports() {
cargo_check_template("rust-component-tool"); build_component(
"rust-component-tool",
"yoi_rust_component_tool_template",
&[],
);
} }
#[test] #[test]
fn embedded_service_template_cargo_checks_for_wasm_target() { fn https_starter_builds_real_component_with_only_explicit_host_imports() {
cargo_check_template("rust-component-instance"); 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) { fn build_component(template: &str, library: &str, expected_imports: &[&str]) {
let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")); let root = root();
let templates_dir = crate_dir.join("../../resources/plugin/templates"); let sdk = root.join("target/sdk");
let template_dir = templates_dir.join(template); fs::create_dir_all(&sdk).unwrap();
let source = root.join("resources/plugin/templates").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.
let fixture = tempfile::Builder::new() let fixture = tempfile::Builder::new()
.prefix(".cargo-check-") .prefix("build-")
.tempdir_in(&templates_dir) .tempdir_in(&sdk)
.expect("temporary template fixture"); .unwrap();
copy_template_tree(&template_dir, fixture.path(), true); copy_tree(&source, fixture.path());
let cargo_path = fixture.path().join("Cargo.toml");
// Both templates share the PDK dependencies. A separate persistent target let cargo = fs::read_to_string(&cargo_path).unwrap().replace(
// reuses those artifacts without sharing source or the outer Cargo build lock. "../../../../crates/plugin-pdk",
// Cargo fingerprints and its target lock handle changes and concurrent checks. root.join("crates/plugin-pdk").to_str().unwrap(),
let executable = std::env::current_exe().expect("current test executable"); );
let target_dir = executable fs::write(&cargo_path, cargo).unwrap();
.parent() let target = sdk.join("guest");
.and_then(|deps| deps.parent())
.expect("test executable has a profile directory")
.join("compile-tests/plugin-pdk-templates");
let output = Command::new(env!("CARGO")) let output = Command::new(env!("CARGO"))
.arg("check") .args([
"build",
"--release",
"--offline",
"--target",
"wasm32-unknown-unknown",
"--target-dir",
])
.arg(&target)
.arg("--manifest-path") .arg("--manifest-path")
.arg(fixture.path().join("Cargo.toml")) .arg(&cargo_path)
.arg("--target")
.arg("wasm32-unknown-unknown")
.arg("--offline")
.arg("--target-dir")
.arg(target_dir)
.current_dir(fixture.path()) .current_dir(fixture.path())
.env("CARGO_TERM_COLOR", "never") .env("CARGO_TERM_COLOR", "never")
.output() .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!( assert!(
output.status.success(), output.status.success(),
"template {template} cargo check failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}", "{template}: cargo build failed\n{}\n{}",
output.status,
String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr) 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) { fn copy_tree(source: &Path, destination: &Path) {
let metadata = fs::symlink_metadata(source).expect("read template directory metadata"); fs::create_dir_all(destination).unwrap();
assert!( for entry in fs::read_dir(source).unwrap() {
metadata.is_dir() && !metadata.file_type().is_symlink(), let entry = entry.unwrap();
"template input must be a real directory: {}", let kind = entry.file_type().unwrap();
source.display() assert!(!kind.is_symlink(), "template may not contain symlinks");
);
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()
);
let name = entry.file_name(); let name = entry.file_name();
// Preserve all compiler inputs, including build scripts and nested assets. if name == "target" || name == "Cargo.lock" {
// 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"))
{
continue; continue;
} }
let copied = destination.join(name); let copied = destination.join(name);
if kind.is_dir() { if kind.is_dir() {
fs::create_dir(&copied).expect("create template fixture directory"); copy_tree(&entry.path(), &copied);
copy_template_tree(&path, &copied, false);
} else { } else {
fs::copy(&path, &copied).expect("copy unmodified template input"); fs::copy(entry.path(), copied).unwrap();
} }
} }
} }
#[test] #[test]
fn pdk_runtime_dependencies_are_guest_side_only() { fn pdk_runtime_dependencies_are_guest_side_only() {
let cargo: Value = toml::from_str(PDK_CARGO).expect("PDK Cargo.toml parses"); let cargo: Value = toml::from_str(include_str!("../Cargo.toml")).unwrap();
let dependencies = cargo["dependencies"] for dependency in cargo["dependencies"].as_table().unwrap().keys() {
.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 {
assert!( assert!(
!dependencies.contains_key(name), ["serde", "serde_json", "thiserror", "wit-bindgen"].contains(&dependency.as_str()),
"PDK must not depend on host/runtime crate `{name}`" "unexpected Host dependency {dependency}"
); );
} }
} }
+33 -11
View File
@@ -1,25 +1,47 @@
//! Compile-pass coverage for generated v2 bindings and the public adapter macro.
use yoi_plugin_pdk::wit_bindgen; use yoi_plugin_pdk::wit_bindgen;
wit_bindgen::generate!({ wit_bindgen::generate!({
world: "tool", world: "tool-plugin",
path: "../../resources/plugin/wit", path: "../../resources/plugin/wit",
generate_all, generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt", 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 { yoi_plugin_pdk::export_component_tool!(Probe, handle);
fn call(tool_name: String, input_json: String) -> String {
yoi_plugin_pdk::run_json_tool(&tool_name, &input_json, |_ctx, input: serde_json::Value| { fn invocation(input_json: &str) -> exports::yoi::plugin::tool::Invocation {
yoi_plugin_pdk::ToolOutput::json("probe ok", input) 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] #[test]
fn wit_bindgen_generates_current_tool_world() { fn generated_adapter_returns_record_and_keeps_business_error_in_error_arm() {
let output = <Probe as Guest>::call("probe".to_string(), r#"{"ok":true}"#.to_string()); use exports::yoi::plugin::tool::Guest;
let value: serde_json::Value = serde_json::from_str(&output).unwrap(); let output = Probe::invoke(invocation(r#"{"ok":true}"#)).unwrap();
assert_eq!(value["summary"], "probe ok"); 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");
} }
+1 -1
View File
@@ -1847,7 +1847,7 @@ fn parse_plugin_pack_args(
} }
fn plugin_usage() -> &'static str { 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> { fn parse_mcp_args(args: &[String]) -> Result<mcp_cli::McpCliCommand, ParseError> {
+336 -119
View File
@@ -5,9 +5,10 @@ use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use manifest::plugin::{ use manifest::plugin::{
MaterializedPluginPackage, PluginDiagnostic, PluginDiagnosticKind, PluginDiagnosticPhase, InspectedPluginPackage, MaterializedPluginPackage, PluginConnectionSlot, PluginDiagnostic,
PluginPackageLimits, PluginPermission, PluginSourceKind, PluginTemplateResource, PluginDiagnosticKind, PluginDiagnosticPhase, PluginPackageLimits, PluginRequestedGrant,
RUST_COMPONENT_INSTANCE_TEMPLATE, RUST_COMPONENT_TOOL_TEMPLATE, SourceQualifiedPluginId, PluginRuntimeManifest, PluginSourceKind, PluginTemplateResource,
RUST_COMPONENT_HTTPS_TOOL_TEMPLATE, RUST_COMPONENT_TOOL_TEMPLATE, SourceQualifiedPluginId,
read_plugin_directory, read_plugin_package_file, write_plugin_package_file, read_plugin_directory, read_plugin_package_file, write_plugin_package_file,
}; };
use serde::Serialize; 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> { fn render_new(template: &str, destination: &Path, args: &PluginCliArgs) -> Result<String> {
let (template_name, resources) = embedded_template_resources(template)?; let (template_name, resources) = embedded_template_resources(template)?;
materialize_template(destination, resources)?; materialize_template(destination, resources)?;
let mut next_steps = vec![ let core_name = match template_name {
"Review plugin.toml and generated Rust source.".to_string(), "rust-component-https-tool" => "yoi_rust_component_https_template",
"Replace the placeholder plugin.component.wasm with a real built component before offline package verification.".to_string(), _ => "yoi_rust_component_tool_template",
"Run `yoi plugin check <path>` and then `yoi plugin pack <path>`.".to_string(), };
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 { let report = NewReport {
command: "new", command: "new",
template: template_name, template: template_name,
@@ -103,9 +102,12 @@ fn embedded_template_resources(
) -> Result<(&'static str, &'static [PluginTemplateResource])> { ) -> Result<(&'static str, &'static [PluginTemplateResource])> {
match template { match template {
"rust-component-tool" => Ok(("rust-component-tool", RUST_COMPONENT_TOOL_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!( _ => 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()), .into()),
} }
@@ -195,10 +197,11 @@ fn render_pack(input: &Path, output: Option<&Path>, args: &PluginCliArgs) -> Res
status: "packed", status: "packed",
input_path: input.display().to_string(), input_path: input.display().to_string(),
output_path: packed.output_path.display().to_string(), output_path: packed.output_path.display().to_string(),
package: PackageReport::from_materialized(&MaterializedPluginPackage { package: PackageReport::from_package(
package: packed.package, &packed.package,
files: materialized.files, &packed.artifact_digest,
}), packed.artifact_size,
),
safety: AuthoringSafetyReport::default(), safety: AuthoringSafetyReport::default(),
}; };
if args.json { if args.json {
@@ -229,25 +232,19 @@ fn build_check_report(input: &Path) -> CheckReport {
read_plugin_package_file(input, PluginSourceKind::Explicit, &limits) read_plugin_package_file(input, PluginSourceKind::Explicit, &limits)
}; };
match result { match result {
Ok(materialized) => { Ok(materialized) => CheckReport {
let placeholder_diagnostic = placeholder_component_diagnostic(&materialized); command: "check",
let status = if placeholder_diagnostic.is_some() { status: "verified",
"partial" input_path: input.display().to_string(),
} else { input_kind,
"verified" package: Some(PackageReport::from_materialized(&materialized)),
}; diagnostics: Vec::new(),
let diagnostics = placeholder_diagnostic.into_iter().collect(); safety: AuthoringSafetyReport::default(),
CheckReport { next_steps: vec![
command: "check", "Package schemas, actual Component imports/exports, and canonical archive metadata are statically valid; no Plugin code was executed.".to_string(),
status, "Static validation does not establish Host implementation, HTTP availability, installation, or Worker execution support.".to_string(),
input_path: input.display().to_string(), ],
input_kind, },
package: Some(PackageReport::from_materialized(&materialized)),
diagnostics,
safety: AuthoringSafetyReport::default(),
next_steps: check_next_steps(status),
}
}
Err(diagnostic) => CheckReport { Err(diagnostic) => CheckReport {
command: "check", command: "check",
status: "rejected", 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 { fn plugin_diagnostic_error(context: &str, diagnostic: PluginDiagnostic) -> String {
format!("{context} failed: {}", diagnostic.message) format!("{context} failed: {}", diagnostic.message)
} }
@@ -339,18 +296,19 @@ fn render_check_human(report: &CheckReport) -> Result<String> {
if let Some(package) = &report.package { if let Some(package) = &report.package {
writeln!( writeln!(
out, out,
"package: {} version={} digest={} entries={} source={} surfaces={} tools={}", "package: {} version={} package_digest={} entries={} source={} surfaces={} tools={}",
package.reference, package.reference,
package.version, package.version,
package.digest, package.package_digest,
package.entries.len(), package.entries.len(),
package.source, package.source,
join_or_none(&package.surfaces), join_or_none(&package.surfaces),
package.tools.len() package.tools.len()
)?; )?;
render_artifact_human(&mut out, package)?;
writeln!( writeln!(
out, 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() { if report.diagnostics.is_empty() {
@@ -385,12 +343,13 @@ fn render_pack_human(report: &PackReport) -> Result<String> {
)?; )?;
writeln!( writeln!(
out, out,
"package: {} version={} digest={} entries={}", "package: {} version={} package_digest={} entries={}",
report.package.reference, report.package.reference,
report.package.version, report.package.version,
report.package.digest, report.package.package_digest,
report.package.entries.len() report.package.entries.len()
)?; )?;
render_artifact_human(&mut out, &report.package)?;
writeln!( writeln!(
out, out,
"safety: deterministic stored .yoi-plugin archive; no Plugin execution; no config mutation" "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) 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)] #[derive(Serialize)]
struct AuthoringSafetyReport { struct AuthoringSafetyReport {
no_network: bool, no_network: bool,
@@ -456,50 +435,75 @@ struct PackageReport {
source: String, source: String,
version: String, version: String,
schema_version: u32, schema_version: u32,
digest: String, package_digest: String,
artifact_digest: String,
artifact_size: u64,
input_path: String, input_path: String,
entries: Vec<String>, entries: Vec<String>,
surfaces: Vec<String>, surfaces: Vec<String>,
tools: Vec<String>, tools: Vec<String>,
permissions: Vec<String>, runtime: PluginRuntimeManifest,
component: ComponentReport,
connections: Vec<PluginConnectionSlot>,
requested_grants: Vec<PluginRequestedGrant>,
} }
impl PackageReport { impl PackageReport {
fn from_materialized(materialized: &MaterializedPluginPackage) -> Self { 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 { Self {
reference: package_reference(&materialized.package.identity), reference: package_reference(&package.identity),
package: materialized.package.manifest.id.clone(), package: package.manifest.id.clone(),
source: materialized.package.identity.source.to_string(), source: package.identity.source.to_string(),
version: materialized.package.manifest.version.clone(), version: package.manifest.version.clone(),
schema_version: materialized.package.manifest.schema_version, schema_version: package.manifest.schema_version,
digest: materialized.package.digest.clone(), package_digest: package.package_digest.clone(),
input_path: materialized.package.input_path.display().to_string(), artifact_digest: artifact_digest.to_string(),
entries: materialized.package.entries.iter().cloned().collect(), artifact_size,
surfaces: materialized input_path: package.input_path.display().to_string(),
.package entries: package.entries.iter().cloned().collect(),
surfaces: package
.manifest .manifest
.surfaces .surfaces
.iter() .iter()
.map(ToString::to_string) .map(ToString::to_string)
.collect(), .collect(),
tools: materialized tools: package
.package
.manifest .manifest
.tools .tools
.iter() .iter()
.map(|tool| tool.name.clone()) .map(|tool| tool.name.clone())
.collect(), .collect(),
permissions: materialized runtime: package.manifest.runtime.clone(),
.package component: ComponentReport {
.manifest world: package.component.world.clone(),
.permissions imports: package.component.imports.clone(),
.iter() exports: package.component.exports.clone(),
.map(|permission| permission_name(permission.clone()).to_string()) },
.collect(), 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)] #[derive(Serialize)]
struct PluginDiagnosticReport { struct PluginDiagnosticReport {
kind: String, kind: String,
@@ -528,10 +532,6 @@ fn package_reference(identity: &SourceQualifiedPluginId) -> String {
identity.to_string() identity.to_string()
} }
fn permission_name(permission: PluginPermission) -> String {
permission.label()
}
fn join_or_none(values: &[String]) -> String { fn join_or_none(values: &[String]) -> String {
if values.is_empty() { if values.is_empty() {
"(none)".to_string() "(none)".to_string()
@@ -571,23 +571,240 @@ mod tests {
use super::*; use super::*;
use tempfile::tempdir; 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] #[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 dir = tempdir().unwrap();
let source = dir.path().join("example"); let source = dir.path().join("example");
let output = dir.path().join("example.yoi-plugin"); 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(); let checked: serde_json::Value =
assert!(created.contains("created plugin template")); 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(); let packed: serde_json::Value =
assert!(checked.contains("[partial]")); serde_json::from_str(&render_pack(&source, Some(&output), &args).unwrap()).unwrap();
assert!(checked.contains("explicit input path")); 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(); let human = render_check(&output, &PluginCliArgs::default()).unwrap();
assert!(packed.contains("[packed]")); assert!(human.contains("package_digest=sha256:"));
assert!(output.is_file()); 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] #[test]
+1
View File
@@ -7,6 +7,7 @@ pkgs.mkShell {
playwright-driver.browsers playwright-driver.browsers
rustc rustc
cargo cargo
lld
rustfmt rustfmt
pkgs.sccache pkgs.sccache
]; ];
+1 -1
View File
@@ -44,7 +44,7 @@ rustPlatform.buildRustPackage rec {
}; };
# All four WIP SDK crates resolve from crates.io 0.2.0 via Cargo.lock checksums. # 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 = { depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint, # Older fetchCargoVendor utilities used crates.io's API download endpoint,
+344
View File
@@ -0,0 +1,344 @@
# Workspace Plugin APIの設計契約
本書は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とする。
## 1. 三つのAPIと所有境界
Pluginを管理する権限とPluginコードに渡す権限を分離する。Serverに保存するからServer operatorしか導入できない、とはしない。
| 境界 | 呼び出し方向 | 責任 |
| --- | --- | --- |
| Workspace管理API | 認可された管理client → Server | 導入・更新・失効・削除、設定、検証結果、診断 |
| Component exports | Runtime Host → guest | Tool呼び出し、Service/event処理 |
| Host imports | guest → Runtime Host → 必要なdomain owner | 外部通信、拡張データ、診断等の限定された作用 |
ServerはWorkspace所属、設定、installation、artifact、永続操作の正本を持つ。Runtimeはplanを適用し、guestと接続・一時handleを所有する。公開HTTP DTOは`server-api`、domain認可と永続化は`workspace-server`、guest ABIは`resources/plugin/wit`とPDKの責任とする。Rust traitやServer RESTを汎用proxyとしてguestへ渡さない。
Workspace管理者は自Workspaceに導入・更新できる。読取りcallerにはWorkspace読取り権限、mutationにはWorkspace管理権限を要求し、既存の認証済みactorから判断する。operator権限は通常導入の前提にしない。Worker/model/repository/packageの自己申告は管理権限にならない。
Hostが実行する全操作の上限は、packageの要求、Workspaceの付与、Profileの縮小、呼出元から委譲可能な範囲、現在の対象資源・運用制約の積集合とする。Serviceはlaunch時に明示されたService用grantを使い、管理者のsession credentialを借用しない。SubWorkerへのPlugin継承も再解決された委譲範囲内のみとし、親のhandleを移送しない。
## 2. identityと保存するもの
installationはWorkspaceに属し、artifactはimmutable bytesで識別する。同じpackage名やversionは権限でも実体の一意性の証拠でもない。
- `installation_id`はServerが発行するWorkspace内の導入identity。package作者の`id`は名前であり、このidentityを置換しない。
- 一つのinstallationに複数のimmutable artifactを登録できる。updateは新しいdigestの登録であり既存bytesを置換しない。同じinstallation内のpackage id/versionに異なる内容を登録する場合はversion衝突として拒否する。他Workspaceの同名versionは検査・存在開示の対象にしない。
- 同一package idの別installationは許すが、設定aliasを一意にする。同じpackageを別credentialで使う用途をversion名の改変で代替しない。
- Tool公開名はinstallationの不変ID由来namespaceと宣言Tool名からHostが構成する。表示aliasの変更で既存planのTool名を変えない。衝突・長さ超過はplan検証時に拒否し、登録時に黙ってrenameしない。
- artifact blobの共有は物理dedupeに限定する。他Workspaceのcatalog、設定、導入履歴、data、grantは共有されない。hashを知っているだけでは取得できない。
### package digestと配送digest
既存`manifest::plugin`のdigestは、path順のfile集合をhashするpackage identityであり、ZIP全体のSHA-256ではない。両者を同じfieldとして検証しない。
- `package_digest`は既存のfile集合digestを維持する。schema/manifest/componentを含む論理内容の同一性を検証する。
- `artifact_digest`はServerが保存・配送するcanonical archive bytesのSHA-256とする。`artifact_size`と共に配送・cacheを検証する。
- Serverは検証済みfile集合から既存canonical pack規則でarchiveを生成し、そのbytesを一度保存する。取得時に再packしない。upload元ZIPのbytesと保存bytesが異なり得るため、両digestと保存sizeを応答する。
- planは両digest、package schema、exact Component world/API versionを固定する。Runtimeは受信bytesのhashに加え、package validatorで論理digestを検証する。
## 3. Workspace管理API
管理操作は既存のtyped `ServerApi`へ追加する。以下は採用するoperationの意味であり、別のPlugin専用HTTP frameworkは作らない。HTTP prefixは既存の`/api/w/{workspace}`とし、route/DTO/OpenAPI/clientは同じ宣言から生成する。
### 操作と競合
| 操作 | 契約 |
| --- | --- |
| `POST /plugin-uploads` | bounded binary `.yoi-plugin`を受け、caller/Workspaceに束縛した一時upload identityと検証結果を返す。install・enableはしない |
| `POST /plugins` | upload identity、Workspace内alias、operation keyでinstallationを作成。結果はinstallation/artifact identityと確定operationの識別子 |
| `GET /plugins` / `GET /plugins/{installation}` | Workspace-scoped bounded一覧・詳細。opaque cursor、page sizeの上限あり。guest codeは実行しない |
| `POST /plugins/{installation}/artifacts` | 新しい検証済みuploadからartifact追加。観測した最終catalog変更operationと新しいoperation keyが必要。既存plan/configは変更しない |
| `POST /plugins/{installation}/revoke` | 指定artifact digestの導入許可を失効。観測した最終catalog変更operation、理由、新しいoperation key。新規実行と既存実行の操作権限を撤回 |
| `POST /plugins/{installation}/uninstall` | 観測した最終catalog変更operationと新しいoperation keyで除去を要求。参照があれば拒否し、安全な阻害情報を返す |
| `GET /plugin-operations/{operation}` | 認可された同Workspace callerが保存結果を照合。結果不明のmutationを新しいkeyでやり直さない |
uploadはmanagement operationと分ける。巨大bytesをJSON/idempotency記録に埋めず、検証に失敗したuploadからcatalogが生じないようにするためである。upload identityは24時間で失効する一時資源で、同じcontentの再uploadは可能。失効後も既にcommitしたinstallationを消さない。usage/quotaは未確定uploadも含む。
catalog mutationのoperation keyはWorkspace・認証主体・操作kindに束縛し、canonical request fingerprintと保存結果をdomain更新と同じtransactionで確定する。fingerprintは対象、観測した最終catalog変更operation、内容digest、alias、理由など意味を変える入力を含み、生成された結果IDや秘密値を含まない。同じkey/入力には元の結果、異なる入力にはconflictを返す。replay時も現在の読取り権限を確認し、権限失効後に以前の結果を漏らさない。保存済みreplayは最新stateの観測ではない。
catalogの追加・失効・除去では、そのinstallationの最終変更を確定したoperation identityをtransaction内で比較してから更新する。時刻やstateだけでは、同じstateのままartifactが追加された後の古い削除判断を拒否できないためである。設定内容はconfig treeの実内容digestで比較し、別の汎用更新カウンタは導入しない。
configのenable/disable、artifact選択、接続binding、grant変更は既存Workspace config Preview/Commit/CASへ統合する。別のenable REST endpointを作って設定正本を二重化しない。Preview後のinstallation/schema変更はcommit時にも照合する。
### install・disable・revoke・uninstall
- installは導入可能なartifactを登録するだけで、Workerの開始やTool登録を行わない。
- updateとconfig disableは新規/replacementのplanに反映する。既存Workerのpinをhot reloadしない。即時停止が必要なら明示revokeを使う。
- revokeはinstallationとartifact digestの組に対して不可逆とする。同じ組を再許可して古い実行を復活させない。管理者が同じbytesを再利用する場合は新しいinstallationを明示作成し、新しいconfig/planを生成する。古いinstallationの失効tombstoneとpinは保持する。
- revokeは新規Host操作を拒否し、稼働中guestの取消・接続closeを要求する。既に外部へ送った効果の取り消しは保証しない。Remote Runtimeへ失効通知を送るだけでは認可が撤回された証拠にならない。保護操作のadmissionはownerでlive確認し、確認不能なら拒否する。stream接続はHostの失効監視で閉じ、監視を維持できない場合も継続しない。
- uninstallはconfig、active/pending/restorable Worker pinが参照する間は拒否する。revokeしてもpinを勝手に消さない。Worker retention解除・config変更・uninstallを別操作として扱う。
- installationの拡張データが残る場合もuninstallは拒否する。後述の明示purgeで消すか、データを保持したdisabled installationとして残す。
- artifact GCは全Workspaceの参照を確認する。DB参照より先に永続blobをpublishし、crash時の未参照blobは後で回収する。未publish blobをcatalogから参照させない。
参照検査・新規pin/config参照の作成・削除fenceは同じdomain transaction境界で競合を解決する。削除直前の単なるlist結果では判断しない。
HTTP errorは既存共通error envelopeへ合わせる。invalid=400、未認証=401、権限不足=403、不可視対象=404、最終catalog変更operation不一致/operation-key/参照競合=409、size=413、quota=429、owner unavailable=503を使う。他Workspace対象の存在をerror詳細から漏らさない。errorの`retryable`だけでmutationの新規再送を許可しない。
## 4. package宣言とABI version
新しい実行契約は`yoi:plugin@2.0.0`と`yoi:host@2.0.0`にする。JSON文字列だけのHost呼出し、数値handle、guest側WebSocket recvを変えるため、v1の意味を黙って差し替えない。
- 新しいmanifest schemaはv2とし、required worlds/interfaces、Tool schema、接続slot、拡張data schema、event subscriptionを静的に宣言する。schema contributionを得るためにguestを実行しない。
- Workspace設定はDCDLのclosed typed schemaで検証する。Toolのmodel-facing入力はJSON Schemaで定義する。JSON SchemaとDCDL schemaを同じものと仮定しない。
- Componentの実imports/exportsを静的に検査してmanifestと照合する。manifestの申告だけで未公開importをlinkしない。
- 初期実装はexact world/interface versionだけを受け入れる。semver rangeでHost APIを黙って置換しない。互換な追加は別versionとして明示サポートし、既存pinには元の契約を提供するかunsupportedで止める。
- 旧v1 packageはofflineで読めても新Runtimeでは実行対象にしない。v2向けrebuild/explicit installを要求し、汎用JSON adapterで旧権限経路を復活させない。
- PDK/template/validator/WITを一緒にv2へ更新する。今回の設計文書だけを根拠に既存v1 templateが新Runtimeで動くとは扱わない。
## 5. Component exports
Tool payloadだけはPluginごとにschemaが異なるため、schema検証済みUTF-8 JSONを運ぶ。それ以外の共通context、error、event envelope、Host request/resultはWIT record/variant/resourceで定義する。
以下は採用するinterfaceの形を説明する最小例で、現行WITへ追加済みという意味ではない。
```wit
package yoi:plugin@2.0.0;
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>;
}
world tool-plugin {
export tool;
}
```
`invocation-id`はHost生成の追跡identityであり権限ではない。contextにServer token、host path、任意Workspace selectorを含めない。configはplanのnon-secret snapshotで、secret値は含めない。structured outputには宣言されたoutput schemaを要求し、Hostが検証する。
Tool worldは呼出しごとにfresh Store/instanceとし、compiled Componentは共有できる。Toolのglobal/static memoryを永続化や他呼出しとの通信に使えない。v2初期Toolは応答一回のbounded callとし、guestがmodelへ直接streamを注入するAPIは設けない。
Service/event worldには`on-start(config) -> result`、`on-event(envelope) -> event-result`、`on-stop(reason) -> result`を設ける。いずれもfresh Storeで呼び、HostがWorker/installation/planに束縛したService resourceを保持する。ToolとServiceで共有する状態はHost経由の明示dataとし、guest memoryの残存を契約にしない。`status()`を呼んで診断のためにguestを実行せず、Hostが実行・接続・失敗の観測を返す。
`on-start`は設定検証とローカル初期化だけを行い、logging以外のHost作用を認めない。GETも副作用を持ち得るため、汎用HTTPや付随data書込みを初期化中に許可しない。外部event sourceの接続は全required Pluginの検証・導入成功後にHostが開始する。initialization失敗時に外部効果のrollbackを装わない。`on-stop`もloggingだけのbest effort通知で、resource解放の前提にしない。
## 6. Host importsとcapability lifetime
公開するinterfaceは機能ごとに分ける。汎用`invoke(method, json)`、汎用Server HTTP proxy、DB connection、host path、任意process実行APIは提供しない。
| interface | 操作の意味 |
| --- | --- |
| `connections` | 宣言slot名から、その呼出しに許可されたconnection resourceを取得 |
| `http` | connectionに束縛されたbounded request/response |
| `extension-data` | Plugin所有schemaのdataを対象object capability経由でread/CAS update |
| `logging` | bounded structured診断。model contextへの注入ではない |
| `workdir` | 後続で既存attachment-scoped operationを型付きにadapt。ambient filesystemは不可 |
| `websocket` | 後続のHost-owned driverへのsend/close。recvはevent配送で行う |
`connections.open(slot)`はWIT resourceを返す。slotはmanifestの宣言名で、Workspace設定が具体的な接続先とsecret参照にbindする。guestは任意credential IDや別Workspace IDを指定しない。resourceはStore/呼出し内だけで有効で、serialize・persist・別Workerへtransferできない。Serviceの長寿命接続自体はHost所有とし、各event処理時にfresh handleを発行する。
Hostは各importでcurrent invocation、installation、plan、付与範囲、対象資源、deadline/cancellationを照合する。lookup済みhandleを後続操作の無条件許可にしない。guestのComponent start sectionからの外部I/Oも禁止し、export呼出し用contextのないimportを拒否する。
planはconfig treeの内容digest、Profile projection digest、installation/artifact identities、接続binding identityとpolicyの内容digest、宣言schemaと実効grant、実行主体と委譲identityを固定する。管理操作を行った人の資格情報はguestの実行主体ではない。
| 変更・状態 | 既存plan/new export/restoreへの作用 |
| --- | --- |
| artifact追加、config enable/disable、Profileの選択・grant設定変更 | 新規/replacement planだけに適用。既存pinの集合や付与snapshotは変更しない |
| Workspace/対象資源のlive grant上限の縮小、明示的grant revoke、実行主体の委譲失効 | 既存snapshotとの積集合を直ちに縮小。次のadmission/Host操作を拒否し、進行中処理へ取消要求 |
| live grant拡大 | 古いsnapshotを広げない。追加利用には新規planが必要 |
| 接続binding失効、installation+artifactのrevoke | 新しいexport・接続・restoreを拒否。既存guest/driverを取消対象にする |
| 管理者の管理権限喪失 | 以後の管理操作を拒否。独立したWorker実行権限まで導入者の権限と同一視しない |
| ownerへ確認できない | fresh export、restore、保護操作・保存結果読取りを拒否。cached planを認可にしない |
各fresh exportの開始とrestoreにはServer ownerでinstallation/plan/実行主体のlive admissionを行う。pure Toolも例外にしない。既にadmit済みのローカル計算は取消・deadlineまで進み得るが、失効後の結果公開と追加Host作用は拒否する。初期Toolにoffline実行leaseは設けない。設定でのgrant変更とlive security grantの失効を混同しないよう、管理画面/APIの結果も適用対象を明示する。
WASIを一括linkしない。v2 worldに宣言したimportだけをallowlistし、ambient filesystem/network/environment/stdin/stdoutを与えない。clock/random等を追加する場合も個別のversioned interfaceとして明示する。PDKはこのimport集合だけでcompileできるようにする。
Wasmtimeのasync Host実装を使って待機中のexecutor threadを占有しない。ただしguestにexperimental async Component ABIを要求せず、初期WITは通常のcall/resultとする。Storeを並行利用せず、同一Service consumerへのevent処理を直列化する。
## 7. 外部HTTP接続
HTTPはURLを自由に受けるAPIではなく、connection resourceに対する相対pathのrequestとする。Server/Runtimeの資格情報を外部requestへ転用しない。
requestはmethod enum、absolute-path形式の相対path、query pair、許可されたheader pair、body bytes、要求timeoutを持つ。responseはstatus、許可されたheader、body bytesを持つ。4xx/5xxもHTTP応答として返し、transport errorと区別する。binaryはbyte listで運び、base64やJSON stringで二重に包まない。
初期Host interfaceの型は次の形にする。Tool worldにはこれらの必要なinterfaceを明示importし、未使用の機能を一括importするworldにはしない。
```wit
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};
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>,
}
send: func(target: borrow<connection>, input: request) -> result<response, host-error>;
}
```
queryはHostがpercent-encodeして送る。path中への`?`埋込みを拒否し、query/headerの重複・順序は許可されたprotocol semanticsを保持する。guest指定の接続/転送制御headerは拒否し、Hostが必要なものを生成する。effect-keyは最大128 UTF-8 bytesの非秘密のopaque文字列とし、Hostのinvocationに束縛する。
- Workspace bindingはexact origin(scheme/host/port)、許可method、segment境界のpath範囲、secret参照、資源上限を持つ。packageはslotと要求範囲を宣言できるが、bindingを自己発行できない。
- 初期APIはHTTPSのみ。TLS検証を無効にできない。raw socket、任意proxy、Unix socket、CONNECT、upgrade、redirect自動追従は提供しない。
- pathはHostが一度canonicalizeし検査・送信に同じ値を使う。scheme/authority変更、userinfo、fragment、backslash、encoded separator/dot traversalを拒否する。prefixの文字列一致で`/api`に`/apix`を含めない。
- HostがDNS解決した全候補を検査し、許可したaddressへ接続を固定する。接続時の再解決で検査を迂回しない。TLSの名前検証とSNIは許可hostnameを使う。
- loopback、link-local、metadata endpoint、Server/Runtime管理endpointを拒否する。private LANはServer運用policyが許す範囲内でWorkspaceが明示bindingできる。Workspace管理権限だけで共有Hostの内部network全体を開かない。
- `Authorization`、Cookie、Host、proxy/hop-by-hop header等はguestが指定できない。接続policyで許可したnon-secret headerだけを受け付ける。credentialはHostが指定origin/pathへだけ注入し、responseのSet-Cookie等を返さない。
- credentialの値をguestへ返すAPIはない。ただし相手serverがsecretをechoする可能性まで完全に除去できるとは主張しない。credentialの配送先はWorkspaceが管理する信頼境界として扱う。
- planはsecret参照と接続policyの内容digestを固定し、secret値は保存しない。通常のsecret rotationは同じbindingのHost解決で反映できるが、宛先/権限変更は新規planにする。live失効・policy縮小は古いplanにも効く。
- request/responseはbuffered boundedとし、上限到達時は切断してlimit errorを返す。切断されたresponseを成功に見せない。圧縮展開後の上限も検査する。
### 外部副作用と再試行
HostはHTTP requestを自動再送しない。GETであってもprovider固有semanticsがあるため、transport層が勝手に再試行しない。redirectもguestにresponseとして返すだけで、別originへの追跡は新しい認可済みbindingが必要になる。
全HTTP requestに`(Workspace, installation, Worker, consumer-or-tool-call, logical-invocation, effect-key)`のscopeを使い、request fingerprint、送信admission、保存結果を記録する。consumerはServiceの場合の固定identity、Toolの場合は永続Tool-call identityであり、別consumerで同じevent-idを処理してもreceiptを共有しない。GETも例外にせず、methodだけから副作用なしを推定しない。同じkey/内容は保存結果を返し、異なる内容はconflict。guestがkeyを変えれば新しい作用なので、package内の再試行も自動的に安全になるわけではない。
fingerprintはinstallation/plan、接続binding identityとslotとpolicyの内容digest、method、正規化path、順序を保つquery/header、body hashを含む。secretの値、残りdeadline、attemptは含めない。saved responseはboundedでWorkspace保護データとして保持し、diagnostic logとは分離する。request body/secret headerの生値をreceiptへ保存しない。同一requestの保存結果を返す際にも現在のinstallation/接続/読取り権限を検証する。
invocation identityはHostの永続Tool-call/event記録に結び付け、同じTool-callの回復や同じconsumer/eventの再配送では固定する。guestがidentityを指定・変更できない。新しいユーザーの呼出しは新identityとする。返却済みTool結果は再実行せず既存結果を使い、未完了の回復を行う場合だけ同じeffect-key領域を使う。
送信前にdurable admissionを記録し、記録不能なら送らない。送信した可能性があるままtimeout/切断/crashした場合は`indeterminate`とし、同じkeyでも汎用Hostは再送しない。provider固有の照合・idempotency APIがある場合だけ、そのadapterの契約で回復できる。Tool結果が失敗でも外部効果が取り消されたとは表示しない。
保持期限を過ぎたeffect keyは未使用keyとして扱わず`receipt-expired`で拒否する。古いinvocation/eventの再開も同様で、再実行は明示的な新しい依頼にする。invocationの受理時にHostがreceipt照合期限を固定し、期限の過ぎたidentityには新しいeffect-keyを追加できない。期限情報/tombstoneは関連Worker/eventの保持期間中残し、bodyを削除しても未実行扱いに戻さない。receipt保持とevent再配送可能期間をHostで連動させ、exactly-onceを約束しない。
## 8. 共通error、出力、診断
Host errorは閉じたWIT variantで返し、少なくともinvalid-input、denied、not-found、conflict、unsupported、quota-exceeded、deadline-exceeded、cancelled、unavailable、indeterminate、receipt-expiredを区別する。guest business errorは宣言されたcodeとbounded messageで返す。未知guest codeはplugin-failedへ正規化し、Host errorの権限をguestが偽装できないようにする。
Host errorには安全なmessage、診断ID、外部効果の観測(not-dispatched / response-observed / indeterminate)を付ける。`response-observed`はbusiness成功や外部transaction commitの証明ではない。再試行の可否はerror文字列でなくoperation契約で判断する。trap、不正output、resource超過はHostが実行失敗として記録する。
Tool outputは通常のWorker Tool結果・履歴経路へ流す。Pluginがhidden prompt、System instruction、任意Session entryを直接差し込むAPIはない。structured JSONはschema検証し、不正・超過時に切り詰めたJSONを返さない。textの表示短縮はHost projectionで明示する。
loggingはlevel、event code、安全なmessageとscalar fieldsに限定する。raw request/response、secret、prompt、host pathを標準診断に保存しない。Pluginが受け取った外部データ自体はuntrusted contentであり、命令や権限として扱わない。
## 9. 付随データの契約
Server拡張はdomain objectに関連付けるPlugin所有のtyped dataとし、既存tableへの任意column追加では実現しない。物理保存schemaはHostが管理する。`feature-storage`のnative migration/SQLite handleは信頼済みHost用でありguestへ公開しない。
addressはHost contextのWorkspace/installationと、認可済みobject resource、宣言collection、schema id/version、keyから決まる。schema id/versionも物理・論理namespaceに含め、新旧artifactが同じcellを別schemaで読み書きしない。guestにWorkspace selectorやraw table名を与えない。最初のobject kindはTicketとし、Hostが存在・Workspace・callerのread/write権限を検証したTicket resourceだけを使う。一般objectへの拡張はadapterと権限を追加して行う。
後続の`objects.resolve-ticket(ticket-ref, access)`はreadまたはextension-writeを要求し、現在のWorker主体・呼出元からの委譲とPlugin grantを検証して呼出し内だけ有効なWIT `ticket` resourceを返す。Tool入力のTicket refもevent envelopeのrefも単なる選択子であり、どちらも同じ解決を通す。ServiceにはService用grantを適用し、eventを受けたことだけでwriteを許可しない。削除済み・不可視・別Workspaceの対象はnot-foundとする。
`ticket-read.read(borrow<ticket>)`はread権限を再検査し、title、case state、content digest、bounded bodyとtruncated flagだけを返す専用read APIとする。thread、添付、認証・管理情報、将来増えるfieldを汎用serializeで公開しない。上限は64 KiB、完全本文が必要なら本APIではunsupportedとする。eventの削除通知自体は安全なmetadataとして配送できるが、削除済みTicket resourceをresolve/readできる意味ではない。
- packageはcollectionごとにschema id/versionとclosed typed schemaを宣言する。data valueのJSONはそのschemaで検証する。
- APIは`get`、`put(expected_last_write | never_written, operation_key, value)`、`delete(expected_last_write, operation_key)`、bounded `list(cursor, limit)`。任意query、SQL、cross-object transactionは公開しない。
- 同じcellへのput/deleteを確定した最終operation identityを、書込みtransaction内で観測値と比較する。CAS成功とoperation receiptを同じtransactionで保存し、自動読直しによるlast-write-winsへ変えない。同じ内容の再書込みや削除・再作成を時刻/内容hashだけで区別できないため、このoperationを比較する。
- deleteは最終delete operationのtombstoneを保持する。再作成はそのoperationを期待値にし、`never_written`を通さない。これによりABAを防ぐ。`get`は対象objectの認可成功後に限り、present(value, last-write)、never-written、deleted(last-delete)を区別する。不可視objectと不存在は同じnot-foundにする。
- data mutationのreceiptはHTTPと同じ実行scope(Workspace/installation/Worker/consumer-or-tool-call/logical-invocation)とguest operation-keyに束縛する。fingerprintに完全なcell address、put/delete種別、期待last-write、schemaとvalueのdigestを含める。異入力でkeyを再利用すればconflictとする。現在の対象認可確認後にreceipt照合をCAS検査より先に行い、成功済み同一要求を最新cellとの差で失敗にしない。照合期限・expired・tombstone保持はHTTPと同じinvocation規則を使う。
- `lookup-mutation(operation-key)`で同じinvocationの保存結果(committed: mutation identityと結果digest / not-recorded / expired)を現在の対象認可の下で照合できる。event再配送では先にreceiptを照合し、成功済みなら新しい値を読み直して同じkeyで別のputを作らない。ownerがnot-recordedを返した場合だけ現在のcellから要求を作れる。照合通信失敗はnot-recordedではない。外部HTTP・data・event ackを跨ぐ原子性は保証せず、handlerは段階ごとの保存結果で回復する。
- listはstable key順のbounded探索でありsnapshot全体の一貫性は保証しない。cursorはWorkspace/installation/object/queryに束縛し、不正・失効cursorを最初のpageに黙って戻さない。
- Ticketの通常fieldやworkflow判断をこのdata APIで変更できない。必要ならそのdomainの専用Host APIを別に設計する。
- 親object削除とPlugin data書込みはowner側の削除fenceで競合を解決する。削除後に新しい子dataを作らず、retention後にHostが回収する。
- schemaの破壊的変更は新しいschema version/namespaceとする。既存dataをguest起動時にmigrationしない。移行は新旧schemaを明示した認可付き操作として後続で設計し、初期APIでは自動変換しない。
- rollbackで旧artifactを使う場合も元schemaだけを読む。最新schemaへ読み替えない。
- revoke/disableではdataを消さない。Workspace管理者の明示purgeをoperation keyと期待data-generationで行い、先に実行をrevoke/fenceする。purge receiptを残し、uninstallとの一操作への暗黙統合をしない。
## 10. commit済みeventとService
最初のhookはcommit済みeventの観察とする。PluginをServer transaction内で実行せず、元操作の成功をPluginの応答待ちにしない。domain ownerがtransactional outboxへeventを記録し、Hostが認可・投影して配送する。共有の無制限event busは提供しない。
最初のeventはTicket変更通知とし、envelopeはevent-id、event-type/version、occurred-at、対象resource reference、変更を確定したdomain eventのidentityと対象内容digest、correlation/causation identity、bounded typed payloadを持つ。全文や秘密fieldは載せず、必要な詳細は認可付きAPIで取得する。削除eventは削除済みreferenceと安全なmetadataのみとし、resource accessを復活させない。
subscriptionはmanifest宣言・Workspace grant・Profile選択から確定し、guestが任意event名を動的subscribeしない。consumerはWorker/installation/plan内のservice単位であり、別Workerに同じPluginを入れると別consumerになる。Workspace-wide singletonや分散consumer groupは暗黙に作らない。
- event-idは再配送でも固定、attemptはHost生成の観測値。consumer内はHost保存順に一件ずつ処理する。Workspace全体や別consumer間の全順序は約束しない。
- `event-result`はack、retry-after、rejectのvariant。ackはHostのcursor commitで確定し、guestのreturnだけでは確定しない。
- deliveryは保持期間内のat-least-once。crash/timeoutで再配送され得るため、外部作用はevent-id由来invocationとstable effect-keyを使う。payload/keyを変えた再送を同一作用として扱わない。
- retry-afterはeffectが結果不明でない場合のみ再配送を予約できる。indeterminateな外部効果を含むeventは要確認でconsumerを止め、汎用retryで再送しない。
- reject、繰返し失敗、queue上限、retention gapではconsumerをpausedとして可視化する。silent drop、無限retry、詰まったeventの無断skipはしない。復旧・明示skipはWorkspace管理操作として監査する。
- cursorが保持範囲から外れたらgapを返し、最新位置へ自動復旧しない。payload取得時にも対象への現在のread権限を再検査する。
- disableは新規planに作用し、既存consumerの即時停止はrevoke/Worker stopで行う。restoreは同じconsumer identity/pin/cursorを使い、replacementは新しいconsumerとして開始する。開始時点以前のeventを自動backfillしない。
- 同じconsumerの旧・新Runtimeが同時に処理しないようHost実行leaseでfenceする。receipt/cursor mutationもleaseを検証する。
WebSocketは別のHost-owned source driverがeventへ変換する。guestの`recv(timeout)` loopは復活させない。network frame自体にdurable delivery保証はないため、domain outbox eventと同じ保証を表示しない。必要なら外部protocolのsequence/resumeをbridge側で使う。
処理前の検証・拒否hookは初期APIに含めない。将来導入時は対象操作ごとに入力/返却値/timeout/failure policy/再入・副作用を契約化し、汎用interceptorとしてServer内部を公開しない。
## 11. 資源上限と終了
以下は新APIの初期defaultとして採用する。既存実装値の転載ではない。Hostは実効limitを起動前に固定して診断へ公開し、運用上限は縮小できる。guestから無制限や上限引上げを要求できない。
| 資源 | 初期default |
| --- | --- |
| Tool input / output、event payload | 各64 KiB(UTF-8/serialized bytes) |
| config、付随dataの1 cell | 各64 KiB |
| guest linear memory | 256 MiB / Store |
| Wasmtime fuel | 10,000,000 / export call |
| Tool/event export wall deadline | 30秒、上位caller deadlineが短ければそちら |
| start / stop hook | 5秒 / 2秒 |
| HTTP request body / response body | 各1 MiB |
| HTTP header総量 | 16 KiB、64 pairまで |
| HTTP request deadline | 15秒、残りcall deadline以下 |
| guest発行Host操作 | 同時4、累計32 / call |
| log総量 | 16 KiB / call、超過はdrop件数のみ記録 |
| Service待機queue | 256 event、満杯時は配送をpause |
| event再試行 | 最大5 attempt、待機上限5分、その後pause |
| event自動再配送・effect receipt照合 | 最低7日、期限後は明示expired/gap |
| 管理list | default 50、上限100件 |
fuelはCPU時間の精密尺度ではない。wall deadline、memory/table/instance/resource数のWasmtime limiterと併用する。HTTPとguest実行で別々に30秒ずつ延長せず、同じcall deadlineから残り時間を計算する。package最大size/file数は既存validator上限を再利用し、値の第二正本を作らない。Workspace全体の保存/queue/実行同時数はHostの有限quotaでadmissionし、個別上限だけで総量を無制限にしない。
終了順は新規admission停止、invocation取消、接続/driver停止、期限付きon-stop、登録lease解除、Store/一時resource解放とする。取消中に届いた結果で終了済みinvocationへ追加作用を発行しない。失効後のon-stopは外部write禁止。guestがstopに応答しなくてもHostはresourceを回収する。既に送信された外部効果はrollbackしない。
## 12. 初期範囲と契約検証
最初の実行APIはTool、connection-bound HTTPS、loggingに絞る。付随データとcommit済みeventは本書の設計を採用する後続interfaceであり、初期worldへ動かないstubを登録しない。WebSocket、Workdir、MCP transport、判断hookも未対応importとして明示拒否する。MCP専用のprocess/credential APIを先行して作らず、必要なHost capabilityを個別に追加する。
最初のvertical contract testは、Server operatorでないWorkspace管理者によるupload/install/config、plan生成、Remote/Embedded双方のWasmtime Tool実行、Host HTTPS、通常Tool履歴への結果、同一pin restoreを通す。mock guestだけでHost境界の証明を代替しない。
境界testでは少なくとも次を固定する。
- 同じblobでも別Workspaceのcatalog/plan/secret/dataを読めず、digestだけのfetchができない。
- v1/未対応world、manifestと実importの不一致、未宣言Tool、invalid input/outputを実行前または結果公開前に拒否する。
- path canonicalization、DNS rebinding、redirect、credential header、private/control-plane宛先を実際のHost adapterで検証する。
- upload/publish/DB commitの部分失敗、CAS競合、同一key再送、異入力key reuse、uninstallとpin作成の競合を検証する。
- effectの送信前失敗、送信後応答喪失、receipt期限、cancel/revokeを区別し、indeterminateから自動再送しない。
- fuel/memory/wall deadline、Host待機中の取消、trap、不応答on-stopでもresourceが回収される。
- 後続data/eventではCAS/ABA、親削除fence、schema rollback、再配送、retention gap、lease競合、外部効果不明時pauseを検証する。
採用する契約と実装手段を区別する。DBのtable名、内部trait名、物理blob配置、adapterのmodule分割は実装裁量だが、Workspace分離、pin、APIの型・失敗保証・再試行・拡張境界はこの契約を満たす必要がある。
+13
View File
@@ -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,5 +1,5 @@
[package] [package]
name = "yoi-rust-component-service-template" name = "yoi-rust-component-https-template"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
@@ -14,7 +14,6 @@ crate-type = ["cdylib"]
[dependencies] [dependencies]
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" } yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" }
# Out-of-tree Plugin packages should replace the local path with a pinned # 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" id = "example.rust_component_tool"
name = "Rust Component Tool Template" name = "Rust Component Tool Template"
version = "0.1.0" version = "0.1.0"
surfaces = ["tool"] surfaces = ["tool"]
permissions = [ config_schema = "{}"
{ kind = "surface", surface = "tool" }, requested_grants = []
{ kind = "tool", name = "example_echo" },
]
[runtime] [runtime]
kind = "wasm-component" kind = "wasm-component"
component = "plugin.component.wasm" component = "plugin.component.wasm"
world = "yoi:plugin/tool@1.0.0" world = "yoi:plugin/tool-plugin@2.0.0"
required_imports = []
[[tools]] [[tools]]
name = "example_echo" name = "example_echo"
description = "Echo input text using the Rust PDK." description = "Echo input text using the Rust PDK."
input_schema = { type = "object", properties = { text = { type = "string" } }, required = ["text"], additionalProperties = false } 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 external_write = false
@@ -3,13 +3,14 @@ use yoi_plugin_pdk::wit_bindgen;
use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput}; use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput};
wit_bindgen::generate!({ wit_bindgen::generate!({
world: "tool", world: "tool-plugin",
path: "../../../../resources/plugin/wit", path: "wit",
generate_all, generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt", runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
}); });
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct EchoInput { struct EchoInput {
text: String, text: String,
} }
@@ -21,6 +22,9 @@ struct EchoOutput<'a> {
} }
fn handle_echo(ctx: ToolContext, input: EchoInput) -> Result<ToolOutput, ToolError> { 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() { if input.text.trim().is_empty() {
return Err(ToolError::invalid_input("`text` must not be 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>;
}
+49
View File
@@ -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;
}