diff --git a/Cargo.lock b/Cargo.lock index 061aa366b..aae036296 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1853,6 +1853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash 0.1.5", + "serde", ] [[package]] @@ -2614,7 +2615,9 @@ dependencies = [ "agen", "arc-swap", "builtin-source", + "crc32fast", "decodal", + "jsonschema", "protocol", "secrets", "serde", @@ -2626,6 +2629,9 @@ dependencies = [ "thiserror 2.0.18", "toml", "tracing", + "wasmparser 0.244.0", + "wit-component", + "wit-parser", ] [[package]] @@ -5822,7 +5828,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64765e43c000fae008a48db8b288e175e237fc9ce20a26586836878c1bee89f" +dependencies = [ + "leb128fmt", + "wasmparser 0.258.3", ] [[package]] @@ -5833,8 +5849,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap", - "wasm-encoder", - "wasmparser", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", ] [[package]] @@ -5860,6 +5876,39 @@ dependencies = [ "hashbrown 0.15.5", "indexmap", "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe9269dd817e0e7021bf534ccd9761bc2d64778b6708e41228b8439d75eaa0b1" +dependencies = [ + "bitflags 2.11.0", + "indexmap", +] + +[[package]] +name = "wast" +version = "258.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97f7defc7ecca8b19ac7f824598eadd0c53985ee00c74060d65051e9da5b58a1" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder 0.258.3", +] + +[[package]] +name = "wat" +version = "1.258.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7555c008cca87f2ac58d9f83ccda7e7b44611093ce28eb28f052e7c78024b9bf" +dependencies = [ + "wast", ] [[package]] @@ -6471,9 +6520,10 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder", + "wasm-encoder 0.244.0", "wasm-metadata", - "wasmparser", + "wasmparser 0.244.0", + "wat", "wit-parser", ] @@ -6492,7 +6542,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.244.0", ] [[package]] @@ -6739,7 +6789,10 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "toml", + "wasmparser 0.244.0", "wit-bindgen", + "wit-component", + "wit-parser", ] [[package]] diff --git a/crates/manifest/Cargo.toml b/crates/manifest/Cargo.toml index abdc916a2..a8cf30a79 100644 --- a/crates/manifest/Cargo.toml +++ b/crates/manifest/Cargo.toml @@ -15,11 +15,17 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_ignored = "0.1.14" sha2 = "0.10" +crc32fast = "1" +wasmparser = "0.244.0" +wit-parser = "0.244.0" +wit-component = "0.244.0" +jsonschema = { version = "0.33.0", default-features = false } secrets = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } tracing = { workspace = true } [dev-dependencies] +wit-component = { version = "0.244.0", features = ["dummy-module"] } serial_test = "3.4.0" tempfile = { workspace = true } diff --git a/crates/manifest/src/plugin.rs b/crates/manifest/src/plugin.rs index 9686f3979..33eb85330 100644 --- a/crates/manifest/src/plugin.rs +++ b/crates/manifest/src/plugin.rs @@ -8,10 +8,14 @@ //! //! Archives use a restricted stored-ZIP subset. Unsafe paths, links, unsupported //! entries, size/count limit violations, and inconsistent headers are rejected; -//! the file set is digested in order. Directory inputs also reject internal links -//! and undergo the same validation, so distribution formats share the package +//! the file set is digested in order. Canonical ZIP32 output stores files in +//! path order with fixed metadata, CRC32, no compression, extras or comments. +//! Directory inputs also reject internal links and undergo the same validation, +//! so distribution formats share the package //! contract. Component Model metadata is required; legacy core-Wasm runtime -//! declarations are rejected. Validation covers the offline format, not safety +//! declarations are rejected. Tool schemas use the offline 2020-12 subset in +//! [`schema`]; non-secret config uses closed static DCDL syntax, never JSON Schema. +//! Validation covers the offline format, not safety //! established by running components. use serde::{Deserialize, Serialize}; @@ -19,14 +23,17 @@ use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::fs; -use std::io; +use std::io::{self, Read}; use std::path::{Path, PathBuf}; -const SUPPORTED_PLUGIN_API_VERSION: u32 = 1; +pub const SUPPORTED_PLUGIN_API_VERSION: u32 = 2; + +pub mod component; +pub mod schema; const ZIP_EOCD: u32 = 0x0605_4b50; const ZIP_CENTRAL_DIRECTORY: u32 = 0x0201_4b50; const ZIP_LOCAL_FILE: u32 = 0x0403_4b50; -const ZIP_FLAG_ENCRYPTED: u16 = 0x0001; + const ZIP_COMPRESSION_STORED: u16 = 0; const ZIP_UNIX_SYMLINK_TYPE: u32 = 0o120000; const ZIP_UNIX_FILE_TYPE_MASK: u32 = 0o170000; @@ -40,9 +47,17 @@ pub struct PluginTemplateResource { /// Embedded starter template for Rust Component Model Tool Plugins. /// /// The template is data only: it performs no filesystem/network operations and -/// grants no authority. Future authoring CLI commands can materialize these -/// files into a chosen destination after applying their own overwrite policy. +/// grants no authority. Authoring commands materialize these files only into an +/// explicitly chosen empty destination. pub const RUST_COMPONENT_TOOL_TEMPLATE: &[PluginTemplateResource] = &[ + PluginTemplateResource { + path: "wit/yoi-plugin-v2.wit", + contents: include_str!("../../../resources/plugin/wit/yoi-plugin-v2.wit"), + }, + PluginTemplateResource { + path: "wit/deps/yoi-host/yoi-host-v2.wit", + contents: include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit"), + }, PluginTemplateResource { path: "Cargo.toml", contents: include_str!( @@ -61,240 +76,90 @@ pub const RUST_COMPONENT_TOOL_TEMPLATE: &[PluginTemplateResource] = &[ "../../../resources/plugin/templates/rust-component-tool/plugin.toml" ), }, - PluginTemplateResource { - path: "plugin.component.wasm", - contents: include_str!( - "../../../resources/plugin/templates/rust-component-tool/plugin.component.wasm" - ), - }, PluginTemplateResource { path: "README.md", contents: include_str!("../../../resources/plugin/templates/rust-component-tool/README.md"), }, ]; -/// Embedded starter template for Rust Component Model instance Plugins. -pub const RUST_COMPONENT_INSTANCE_TEMPLATE: &[PluginTemplateResource] = &[ +/// HTTPS authoring imports typed resources; it does not implement Host networking. +pub const RUST_COMPONENT_HTTPS_TOOL_TEMPLATE: &[PluginTemplateResource] = &[ + PluginTemplateResource { + path: "wit/yoi-plugin-v2.wit", + contents: include_str!("../../../resources/plugin/wit/yoi-plugin-v2.wit"), + }, + PluginTemplateResource { + path: "wit/deps/yoi-host/yoi-host-v2.wit", + contents: include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit"), + }, PluginTemplateResource { path: "Cargo.toml", contents: include_str!( - "../../../resources/plugin/templates/rust-component-instance/Cargo.toml" + "../../../resources/plugin/templates/rust-component-https/Cargo.toml" ), }, PluginTemplateResource { path: "src/lib.rs", contents: include_str!( - "../../../resources/plugin/templates/rust-component-instance/src/lib.rs" + "../../../resources/plugin/templates/rust-component-https/src/lib.rs" ), }, PluginTemplateResource { path: "plugin.toml", contents: include_str!( - "../../../resources/plugin/templates/rust-component-instance/plugin.toml" - ), - }, - PluginTemplateResource { - path: "plugin.component.wasm", - contents: include_str!( - "../../../resources/plugin/templates/rust-component-instance/plugin.component.wasm" + "../../../resources/plugin/templates/rust-component-https/plugin.toml" ), }, PluginTemplateResource { path: "README.md", contents: include_str!( - "../../../resources/plugin/templates/rust-component-instance/README.md" + "../../../resources/plugin/templates/rust-component-https/README.md" ), }, ]; -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] -pub enum PluginPermission { - Surface { surface: PluginSurface }, - Tool { name: String }, - ToolNamespace { namespace: String }, - Service { name: String }, - Ingress { name: String }, - ExternalWrite, - HostApi { api: PluginHostApi }, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct PluginRequestGrant { - /// Exact URL scheme allowed by this target, for example `https` or `http`; `*` is broad. - pub scheme: String, - /// Exact request host allowed by this target. `*` is broad and must be surfaced in diagnostics. - pub host: String, - /// Optional exact port. `None` means the scheme default or any explicit port for that host. - pub port: Option, - /// Uppercase HTTP methods allowed for this target, for example `GET` or `POST`. - pub methods: Vec, - /// Optional path prefixes allowed for this target. Empty means any absolute path on the host. - pub path_prefixes: Vec, -} - -impl PluginRequestGrant { - pub fn label(&self) -> String { - let scheme = if self.scheme.trim().is_empty() { - "" - } else { - self.scheme.as_str() - }; - let host = if self.host.trim().is_empty() { - "" - } else { - self.host.as_str() - }; - let port = self.port.map(|port| format!(":{port}")).unwrap_or_default(); - let methods = if self.methods.is_empty() { - "".to_string() - } else { - self.methods.join(",") - }; - let paths = if self.path_prefixes.is_empty() { - "*".to_string() - } else { - self.path_prefixes.join(",") - }; - let broad = if self.is_broad() { - " [broad-request]" - } else { - "" - }; - format!("{scheme}://{host}{port} {methods} {paths}{broad}") - } - - pub fn is_broad(&self) -> bool { - self.scheme.trim() == "*" || self.host.trim() == "*" || self.path_prefixes.is_empty() - } -} - -#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct PluginWebSocketGrant { - /// Exact URL scheme allowed by this WebSocket target: `wss` or `ws`; `*` is broad. - pub scheme: String, - /// Exact WebSocket host allowed by this target. `*` is broad and must be surfaced in diagnostics. - pub host: String, - /// Optional exact port. `None` means the scheme default or any explicit port for that host. - pub port: Option, - /// Optional path prefixes allowed for this target. Empty means any absolute path on the host. - pub path_prefixes: Vec, -} - -impl PluginWebSocketGrant { - pub fn label(&self) -> String { - let scheme = if self.scheme.trim().is_empty() { - "" - } else { - self.scheme.as_str() - }; - let host = if self.host.trim().is_empty() { - "" - } else { - self.host.as_str() - }; - let port = self.port.map(|port| format!(":{port}")).unwrap_or_default(); - let paths = if self.path_prefixes.is_empty() { - "*".to_string() - } else { - self.path_prefixes.join(",") - }; - let broad = if self.is_broad() { - " [broad-websocket]" - } else { - "" - }; - format!("{scheme}://{host}{port} {paths}{broad}") - } - - pub fn is_broad(&self) -> bool { - self.scheme.trim() == "*" || self.host.trim() == "*" || self.path_prefixes.is_empty() - } -} - -impl PluginPermission { - pub fn label(&self) -> String { - match self { - Self::Surface { surface } => format!("surfaces.{surface}"), - Self::Tool { name } => format!("tool.{name}"), - Self::ToolNamespace { namespace } => format!("tool_namespace.{namespace}"), - Self::Service { name } => format!("service.{name}"), - Self::Ingress { name } => format!("ingress.{name}"), - Self::ExternalWrite => "external_write".to_string(), - Self::HostApi { api } => format!("host_api.{api}"), - } - } - - pub fn surface(surface: PluginSurface) -> Self { - Self::Surface { surface } - } - - pub fn tool(name: impl Into) -> Self { - Self::Tool { name: name.into() } - } - - pub fn tool_namespace(namespace: impl Into) -> Self { - Self::ToolNamespace { - namespace: namespace.into(), - } - } - - pub fn service(name: impl Into) -> Self { - Self::Service { name: name.into() } - } - - pub fn ingress(name: impl Into) -> Self { - Self::Ingress { name: name.into() } - } - - pub fn host_api(api: PluginHostApi) -> Self { - Self::HostApi { api } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PluginHostApi { - Request, - #[serde(rename = "websocket")] - WebSocket, - Fs, -} - -impl fmt::Display for PluginHostApi { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Request => f.write_str("request"), - Self::WebSocket => f.write_str("websocket"), - Self::Fs => f.write_str("fs"), - } - } -} - +/// The only supported v2 surface; future surfaces are not success stubs. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PluginSurface { - Hook, Tool, - Service, - Ingress, - Wasm, } impl fmt::Display for PluginSurface { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - PluginSurface::Hook => f.write_str("hook"), - PluginSurface::Tool => f.write_str("tool"), - PluginSurface::Service => f.write_str("service"), - PluginSurface::Ingress => f.write_str("ingress"), - PluginSurface::Wasm => f.write_str("wasm"), - } + f.write_str("tool") } } +/// Capability requests are metadata, never effective grants. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PluginRequestedGrant { + Logging, + Https { slot: String }, +} + +/// A slot requests HTTPS protocol bounds, not credentials or concrete bindings. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginConnectionSlot { + pub name: String, + pub methods: Vec, + pub path_prefixes: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum PluginHttpMethod { + Get, + Head, + Post, + Put, + Patch, + Delete, + Options, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PluginSourceKind { @@ -366,82 +231,38 @@ pub struct PluginPackageManifest { pub name: String, pub version: String, pub description: Option, - #[serde(default)] pub surfaces: Vec, - #[serde(default)] - pub runtime: Option, - #[serde(default)] - pub hooks: Vec, - #[serde(default)] + pub runtime: PluginRuntimeManifest, pub tools: Vec, + /// Closed static DCDL type expression, not JSON Schema or guest code. + pub config_schema: String, #[serde(default)] - pub services: Vec, + pub connections: Vec, #[serde(default)] - pub ingresses: Vec, - /// Permission declarations stored in the offline package. They grant no authority - /// and are not consumed by the current Worker runtime. - #[serde(default)] - pub permissions: Vec, - /// URL target metadata retained in the offline package for a future installer. - #[serde(default)] - pub request: Vec, - /// WebSocket target metadata retained in the offline package for a future installer. - #[serde(default)] - pub websocket: Vec, + pub requested_grants: Vec, } impl PluginPackageManifest { pub fn declared_surfaces(&self) -> BTreeSet { - let mut surfaces: BTreeSet<_> = self.surfaces.iter().copied().collect(); - if !self.hooks.is_empty() { - surfaces.insert(PluginSurface::Hook); - } - if !self.tools.is_empty() { - surfaces.insert(PluginSurface::Tool); - } - if !self.services.is_empty() { - surfaces.insert(PluginSurface::Service); - } - if !self.ingresses.is_empty() { - surfaces.insert(PluginSurface::Ingress); - } - if self.runtime.is_some() { - surfaces.insert(PluginSurface::Wasm); - } - surfaces + self.surfaces.iter().copied().collect() } } -const LEGACY_PLUGIN_RUNTIME_WASM_KIND: &str = "wasm"; -const LEGACY_PLUGIN_RUNTIME_WASM_ABI: &str = "yoi-plugin-wasm-1"; -/// Component Model declaration kind accepted in offline packages. -/// -/// Validates component paths and corresponding worlds; rejects legacy core-Wasm -/// declarations. This constant implies neither runtime registration nor verified -/// component execution. pub const PLUGIN_RUNTIME_COMPONENT_KIND: &str = "wasm-component"; -pub const PLUGIN_COMPONENT_TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0"; -pub const PLUGIN_COMPONENT_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0"; +pub const PLUGIN_COMPONENT_TOOL_WORLD: &str = "yoi:plugin/tool-plugin@2.0.0"; +pub const PLUGIN_COMPONENT_HTTPS_TOOL_WORLD: &str = "yoi:plugin/https-tool-plugin@2.0.0"; +pub const PLUGIN_COMPONENT_LOGGED_TOOL_WORLD: &str = "yoi:plugin/logged-tool-plugin@2.0.0"; +pub const PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD: &str = + "yoi:plugin/https-logged-tool-plugin@2.0.0"; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PluginRuntimeManifest { pub kind: String, - #[serde(default)] - pub entry: Option, - #[serde(default)] - pub abi: Option, - #[serde(default)] - pub component: Option, - #[serde(default)] - pub world: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct PluginHookManifest { - pub id: String, - pub file: String, + pub component: String, + pub world: String, + /// Exact imports, including type interfaces, without semver ranges. + pub required_imports: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -450,40 +271,13 @@ pub struct PluginToolManifest { pub name: String, pub description: String, pub input_schema: serde_json::Value, - /// Declares possible external side effects for a future installer. This flag - /// grants no authority and is not consumed by the current Worker runtime. + pub output_schema: serde_json::Value, + #[serde(default)] + pub error_codes: Vec, #[serde(default)] pub external_write: bool, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct PluginServiceManifest { - pub name: String, - pub description: String, - #[serde(default)] - pub lifecycle: String, - #[serde(default)] - pub status_schema: Option, - #[serde(default)] - pub side_effects: Vec, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct PluginIngressManifest { - pub name: String, - pub description: String, - #[serde(default)] - pub event_kinds: Vec, - #[serde(default)] - pub input_schema: Option, - #[serde(default)] - pub sources: Vec, - #[serde(default)] - pub side_effects: Vec, -} - #[derive(Clone, Debug, PartialEq, Eq)] pub struct PluginPackageLimits { pub max_package_size_bytes: u64, @@ -510,7 +304,8 @@ pub struct InspectedPluginPackage { pub identity: SourceQualifiedPluginId, pub input_path: PathBuf, pub package_label: String, - pub digest: String, + pub package_digest: String, + pub component: component::ComponentMetadata, pub manifest: PluginPackageManifest, pub entries: BTreeSet, } @@ -524,12 +319,26 @@ pub struct InspectedPluginPackage { pub struct MaterializedPluginPackage { pub package: InspectedPluginPackage, pub files: BTreeMap>, + /// Digest and size of canonical archive bytes, not the upload ZIP. + pub artifact_digest: String, + pub artifact_size: u64, } #[derive(Clone, Debug, PartialEq)] pub struct PackedPluginPackage { pub output_path: PathBuf, pub package: InspectedPluginPackage, + pub artifact_digest: String, + pub artifact_size: u64, +} + +/// Immutable candidate bytes to publish once; fetch must return these exact bytes. +#[derive(Clone, Debug, PartialEq)] +pub struct CanonicalPluginArtifact { + pub bytes: Vec, + pub package_digest: String, + pub artifact_digest: String, + pub artifact_size: u64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -636,7 +445,7 @@ pub fn read_plugin_package_file( .with_source(source) .with_package(label)); } - let bytes = fs::read(path).map_err(|error| { + let bytes = read_bounded_file(path, limits.max_package_size_bytes).map_err(|error| { PluginDiagnostic::new( PluginDiagnosticKind::Io, PluginDiagnosticPhase::Inspection, @@ -701,14 +510,102 @@ pub fn write_plugin_package_file( output_path: &Path, limits: &PluginPackageLimits, ) -> Result { - write_stored_zip_file(output_path, &materialized.files, limits)?; + let artifact = pack_plugin_package(materialized, limits)?; + if let Some(parent) = output_path.parent().filter(|p| !p.as_os_str().is_empty()) { + fs::create_dir_all(parent).map_err(|_| { + malformed_zip( + "output", + materialized.package.source(), + "cannot create output directory", + ) + })?; + } + fs::write(output_path, &artifact.bytes).map_err(|_| { + malformed_zip( + "output", + materialized.package.source(), + "cannot write package", + ) + })?; let package = read_plugin_package_file(output_path, materialized.package.source(), limits)?; Ok(PackedPluginPackage { output_path: output_path.to_path_buf(), package: package.package, + artifact_digest: artifact.artifact_digest, + artifact_size: artifact.artifact_size, }) } +/// Shared Server installer/authoring canonicalization. Revalidates mutable file data. +pub fn pack_plugin_package( + materialized: &MaterializedPluginPackage, + limits: &PluginPackageLimits, +) -> Result { + let bytes = canonical_archive_bytes(&materialized.files, limits)?; + let checked = read_plugin_package_bytes(&bytes, materialized.package.source(), limits)?; + Ok(CanonicalPluginArtifact { + package_digest: checked.package.package_digest, + artifact_size: bytes.len() as u64, + artifact_digest: artifact_digest(&bytes), + bytes, + }) +} + +/// Offline upload inspection; accepts bounded stored ZIP and returns canonical metadata. +pub fn read_plugin_package_bytes( + bytes: &[u8], + source: PluginSourceKind, + limits: &PluginPackageLimits, +) -> Result { + materialize_archive( + Path::new("explicit.yoi-plugin"), + "explicit.yoi-plugin", + source, + bytes, + limits, + ) +} + +/// Runtime/cache verification of exact canonical bytes and both independent digests. +/// This function confers no access, admission or execution authority. +pub fn verify_plugin_artifact( + bytes: &[u8], + expected_package_digest: &str, + expected_artifact_digest: &str, + expected_size: u64, + limits: &PluginPackageLimits, +) -> Result { + if bytes.len() as u64 > limits.max_package_size_bytes { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Bounds, + PluginDiagnosticPhase::Inspection, + "artifact exceeds size limit", + )); + } + if expected_size != bytes.len() as u64 || artifact_digest(bytes) != expected_artifact_digest { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Digest, + PluginDiagnosticPhase::Inspection, + "artifact digest/size mismatch", + )); + } + let package = read_plugin_package_bytes(bytes, PluginSourceKind::Explicit, limits)?; + if package.package.package_digest != expected_package_digest + || canonical_archive_bytes(&package.files, limits)? != bytes + { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Digest, + PluginDiagnosticPhase::Inspection, + "package digest mismatch or noncanonical artifact", + )); + } + Ok(package) +} + +fn artifact_digest(bytes: &[u8]) -> String { + format!("sha256:{}", hex_lower(&Sha256::digest(bytes))) +} + impl InspectedPluginPackage { pub fn source(&self) -> PluginSourceKind { self.identity.source @@ -722,6 +619,13 @@ fn materialize_archive( bytes: &[u8], limits: &PluginPackageLimits, ) -> Result { + if bytes.len() as u64 > limits.max_package_size_bytes { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Bounds, + PluginDiagnosticPhase::Inspection, + "archive exceeds size limit", + )); + } let archive = parse_stored_zip(bytes, label, source, limits)?; materialize_files(path, label.to_string(), source, archive.files, limits) } @@ -772,18 +676,25 @@ fn materialize_files( .with_source(source) .with_package(label.clone()) })?; - validate_manifest(&manifest, &archive, &label, source)?; + let component = validate_manifest(&manifest, &archive, &label, source)?; let digest = deterministic_digest(&archive.files); let identity = SourceQualifiedPluginId::new(source, manifest.id.clone()); let package = InspectedPluginPackage { identity, input_path: path.to_path_buf(), package_label: label, - digest, + package_digest: digest, + component, manifest, entries: archive.files.keys().cloned().collect(), }; - Ok(MaterializedPluginPackage { package, files }) + let canonical = canonical_archive_bytes(&files, limits)?; + Ok(MaterializedPluginPackage { + package, + files, + artifact_digest: artifact_digest(&canonical), + artifact_size: canonical.len() as u64, + }) } fn collect_directory_files( @@ -807,8 +718,9 @@ fn collect_directory_files( .with_source(source) .with_package(label.to_string()) })? - .filter_map(|entry| entry.ok().map(|entry| entry.path())) - .collect::>(); + .map(|entry| entry.map(|entry| entry.path())) + .collect::, _>>() + .map_err(|_| malformed_zip(label, source, "cannot read directory entry"))?; entries.sort(); for path in entries { @@ -893,7 +805,7 @@ fn collect_directory_files( .with_source(source) .with_package(label.to_string()) })?; - let content = fs::read(&path).map_err(|error| { + let content = read_bounded_file(&path, limits.max_file_size_bytes).map_err(|error| { PluginDiagnostic::new( PluginDiagnosticKind::Io, PluginDiagnosticPhase::Inspection, @@ -932,11 +844,10 @@ fn collect_directory_files( Ok(()) } -fn write_stored_zip_file( - output_path: &Path, +fn canonical_archive_bytes( files: &BTreeMap>, limits: &PluginPackageLimits, -) -> Result<(), PluginDiagnostic> { +) -> Result, PluginDiagnostic> { if files.len() > limits.max_entries_per_package { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Bounds, @@ -944,6 +855,32 @@ fn write_stored_zip_file( "plugin package contains more entries than the configured bound", )); } + let expanded = files + .values() + .try_fold(0u64, |sum, v| sum.checked_add(v.len() as u64)); + if files.len() > u16::MAX as usize + || expanded + .is_none_or(|n| n > limits.max_expanded_size_bytes || n > limits.max_package_size_bytes) + { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Bounds, + PluginDiagnosticPhase::Inspection, + "archive exceeds count/expanded bounds", + )); + } + let archive_size = files.iter().try_fold(22u64, |total, (name, content)| { + total + .checked_add(76)? + .checked_add((name.len() as u64).checked_mul(2)?)? + .checked_add(content.len() as u64) + }); + if archive_size.is_none_or(|n| n > limits.max_package_size_bytes || n > u32::MAX as u64) { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Bounds, + PluginDiagnosticPhase::Inspection, + "archive exceeds ZIP32/package bounds", + )); + } let mut bytes = Vec::new(); let mut central = Vec::new(); for (name, content) in files { @@ -961,6 +898,17 @@ fn write_stored_zip_file( "plugin package entry exceeds the configured per-file bound", )); } + if name.len() > u16::MAX as usize + || content.len() > u32::MAX as usize + || bytes.len() > u32::MAX as usize + { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Bounds, + PluginDiagnosticPhase::Inspection, + "ZIP32 bounds exceeded", + )); + } + let crc = crc32fast::hash(content); let local_offset = bytes.len() as u32; write_u32_vec(&mut bytes, ZIP_LOCAL_FILE); write_u16_vec(&mut bytes, 20); @@ -968,7 +916,7 @@ fn write_stored_zip_file( write_u16_vec(&mut bytes, ZIP_COMPRESSION_STORED); write_u16_vec(&mut bytes, 0); write_u16_vec(&mut bytes, 0); - write_u32_vec(&mut bytes, 0); + write_u32_vec(&mut bytes, crc); write_u32_vec(&mut bytes, content.len() as u32); write_u32_vec(&mut bytes, content.len() as u32); write_u16_vec(&mut bytes, name.len() as u16); @@ -983,7 +931,7 @@ fn write_stored_zip_file( write_u16_vec(&mut central, ZIP_COMPRESSION_STORED); write_u16_vec(&mut central, 0); write_u16_vec(&mut central, 0); - write_u32_vec(&mut central, 0); + write_u32_vec(&mut central, crc); write_u32_vec(&mut central, content.len() as u32); write_u32_vec(&mut central, content.len() as u32); write_u16_vec(&mut central, name.len() as u16); @@ -1012,32 +960,7 @@ fn write_stored_zip_file( "plugin package exceeds the configured package size bound", )); } - if let Some(parent) = output_path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - fs::create_dir_all(parent).map_err(|error| { - PluginDiagnostic::new( - PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Inspection, - format!( - "plugin package output directory could not be created: {}", - safe_io_error(&error) - ), - ) - })?; - } - fs::write(output_path, bytes).map_err(|error| { - PluginDiagnostic::new( - PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Inspection, - format!( - "plugin package output could not be written: {}", - safe_io_error(&error) - ), - ) - })?; - Ok(()) + Ok(bytes) } fn write_u16_vec(out: &mut Vec, value: u16) { @@ -1053,154 +976,151 @@ fn validate_manifest( archive: &StoredArchive, label: &str, source: PluginSourceKind, -) -> Result<(), PluginDiagnostic> { +) -> Result { + let reject = |kind, message: String| { + PluginDiagnostic::new(kind, PluginDiagnosticPhase::Manifest, message) + .with_source(source) + .with_package(label) + .with_identity(SourceQualifiedPluginId::new(source, &manifest.id)) + }; if manifest.schema_version != SUPPORTED_PLUGIN_API_VERSION { - return Err(PluginDiagnostic::new( + return Err(reject( PluginDiagnosticKind::Api, - PluginDiagnosticPhase::Manifest, - "plugin schema/API version is unsupported", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); + "unsupported schema: v2 rebuild required".into(), + )); } - if !is_safe_id(&manifest.id) { - return Err(PluginDiagnostic::new( + if !is_safe_id(&manifest.id) + || manifest.name.trim().is_empty() + || manifest.version.trim().is_empty() + { + return Err(reject( PluginDiagnosticKind::Malformed, - PluginDiagnosticPhase::Manifest, - "plugin manifest id is not a safe local id", - ) - .with_source(source) - .with_package(label)); + "safe id, name and version are required".into(), + )); } - if manifest.name.trim().is_empty() || manifest.version.trim().is_empty() { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Malformed, - PluginDiagnosticPhase::Manifest, - "plugin manifest name and version are required", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); - } - if let Some(runtime) = &manifest.runtime { - match runtime.kind.as_str() { - LEGACY_PLUGIN_RUNTIME_WASM_KIND => { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Api, - PluginDiagnosticPhase::Manifest, - format!( - "legacy raw wasm plugin runtime `{LEGACY_PLUGIN_RUNTIME_WASM_KIND}` / `{}` is retired; use `{PLUGIN_RUNTIME_COMPONENT_KIND}`", - runtime - .abi - .as_deref() - .unwrap_or(LEGACY_PLUGIN_RUNTIME_WASM_ABI) - ), - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); - } - PLUGIN_RUNTIME_COMPONENT_KIND => { - if runtime.abi.is_some() || runtime.entry.is_some() { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Malformed, - PluginDiagnosticPhase::Manifest, - "plugin component runtime must not declare raw WASM ABI metadata", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); - } - if !matches!( - runtime.world.as_deref(), - Some(PLUGIN_COMPONENT_TOOL_WORLD) | Some(PLUGIN_COMPONENT_INSTANCE_WORLD) - ) { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Api, - PluginDiagnosticPhase::Manifest, - "plugin component world is unsupported", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); - } - let Some(component) = runtime.component.as_deref() else { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Missing, - PluginDiagnosticPhase::Manifest, - "plugin component runtime artifact is required", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); - }; - validate_manifest_path(component, archive, label, source, &manifest.id)?; - } - _ => { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Api, - PluginDiagnosticPhase::Manifest, - "plugin runtime kind is unsupported", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); - } - } - } - let instance_capable = manifest.runtime.as_ref().is_some_and(|runtime| { - runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND - && runtime.world.as_deref() == Some(PLUGIN_COMPONENT_INSTANCE_WORLD) - }); - if (!manifest.services.is_empty() || !manifest.ingresses.is_empty()) && !instance_capable { - return Err(PluginDiagnostic::new( + if manifest.surfaces != [PluginSurface::Tool] || manifest.tools.is_empty() { + return Err(reject( PluginDiagnosticKind::Surface, - PluginDiagnosticPhase::Manifest, - "plugin service/ingress declarations require the yoi:plugin/instance@1.0.0 component world", + "only nonempty Tool packages are supported".into(), + )); + } + let runtime = &manifest.runtime; + if runtime.kind != PLUGIN_RUNTIME_COMPONENT_KIND { + return Err(reject( + PluginDiagnosticKind::Api, + "only wasm-component v2 is supported".into(), + )); + } + validate_manifest_path(&runtime.component, archive, label, source, &manifest.id)?; + schema::validate_config_schema(&manifest.config_schema).map_err(|e| { + reject( + PluginDiagnosticKind::Malformed, + format!("invalid config schema: {e}"), ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); - } - for service in &manifest.services { - if !is_safe_id(&service.name) { - return Err(PluginDiagnostic::new( + })?; + let mut names = BTreeSet::new(); + for tool in &manifest.tools { + if !is_safe_id(&tool.name) + || tool.description.trim().is_empty() + || !names.insert(&tool.name) + { + return Err(reject( + PluginDiagnosticKind::Duplicate, + "Tool names must be safe, unique and described".into(), + )); + } + for value in [&tool.input_schema, &tool.output_schema] { + schema::validate_tool_schema(value).map_err(|e| { + reject( + PluginDiagnosticKind::Malformed, + format!("invalid Tool schema: {e}"), + ) + })?; + } + let errors: BTreeSet<_> = tool.error_codes.iter().collect(); + if errors.len() != tool.error_codes.len() || tool.error_codes.iter().any(|v| !is_safe_id(v)) + { + return Err(reject( PluginDiagnosticKind::Malformed, - PluginDiagnosticPhase::Manifest, - "plugin service name is not safe", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); + "business error codes must be safe and unique".into(), + )); } } - for ingress in &manifest.ingresses { - if !is_safe_id(&ingress.name) { - return Err(PluginDiagnostic::new( + let mut slots = BTreeSet::new(); + for slot in &manifest.connections { + if !is_safe_id(&slot.name) + || !slots.insert(&slot.name) + || slot.methods.is_empty() + || slot.methods.iter().collect::>().len() != slot.methods.len() + || slot.path_prefixes.is_empty() + || slot + .path_prefixes + .iter() + .any(|p| !valid_http_path_prefix(p)) + || slot.path_prefixes.iter().collect::>().len() != slot.path_prefixes.len() + { + return Err(reject( PluginDiagnosticKind::Malformed, - PluginDiagnosticPhase::Manifest, - "plugin ingress name is not safe", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); + "invalid or duplicate HTTPS slot/request bounds".into(), + )); } } - for hook in &manifest.hooks { - if !is_safe_id(&hook.id) { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Malformed, - PluginDiagnosticPhase::Manifest, - "plugin hook id is not safe", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); + let mut requested_slots = BTreeSet::new(); + let mut logging = false; + for grant in &manifest.requested_grants { + match grant { + PluginRequestedGrant::Logging if !logging => logging = true, + PluginRequestedGrant::Https { slot } + if slots.contains(slot) && requested_slots.insert(slot) => {} + _ => { + return Err(reject( + PluginDiagnosticKind::Malformed, + "undeclared or duplicate requested grant".into(), + )); + } } - validate_manifest_path(&hook.file, archive, label, source, &manifest.id)?; } - Ok(()) + if slots != requested_slots { + return Err(reject( + PluginDiagnosticKind::Malformed, + "each connection slot requires an HTTPS grant request".into(), + )); + } + let http = runtime + .required_imports + .iter() + .any(|v| v == "yoi:host/http@2.0.0"); + let connections = runtime + .required_imports + .iter() + .any(|v| v == "yoi:host/connections@2.0.0"); + let logs = runtime + .required_imports + .iter() + .any(|v| v == "yoi:host/logging@2.0.0"); + if http != !slots.is_empty() || http != connections || logging != logs { + return Err(reject( + PluginDiagnosticKind::Api, + "Host imports and requested grants/slots disagree".into(), + )); + } + component::validate_component( + &archive.files[&runtime.component], + &runtime.world, + &runtime.required_imports, + ) + .map_err(|e| reject(PluginDiagnosticKind::Api, format!("Component ABI: {e}"))) +} + +fn valid_http_path_prefix(path: &str) -> bool { + path.starts_with('/') + && !path.starts_with("//") + && !path.bytes().any(|b| { + b.is_ascii_control() + || b.is_ascii_whitespace() + || matches!(b, b'?' | b'#' | b'\\' | b'%' | b':') + }) + && !path.split('/').any(|v| matches!(v, "." | "..")) } fn validate_manifest_path( @@ -1241,6 +1161,7 @@ struct StoredArchive { #[derive(Clone, Debug)] struct CentralDirectoryEntry { name: String, + crc: u32, compressed_size: u32, uncompressed_size: u32, local_header_offset: u32, @@ -1272,6 +1193,17 @@ fn parse_stored_zip( let central_offset = read_u32(eocd, 16) .ok_or_else(|| malformed_zip(label, source, "zip end record is truncated"))? as usize; + let comment_len = read_u16(eocd, 20).unwrap_or(u16::MAX) as usize; + if eocd_offset.checked_add(22 + comment_len) != Some(bytes.len()) + || read_u16(eocd, 8) != Some(entry_count as u16) + || central_offset.checked_add(central_size) != Some(eocd_offset) + { + return Err(malformed_zip( + label, + source, + "inconsistent ZIP directory/end bounds", + )); + } if disk_number != 0 || central_disk != 0 { return Err(malformed_zip( label, @@ -1364,7 +1296,15 @@ fn parse_stored_zip( .ok_or_else(|| { malformed_zip(label, source, "zip central directory entry is too large") })?; + if cursor > eocd_offset || read_u16(bytes, name_start - 12) != Some(0) { + return Err(malformed_zip( + label, + source, + "ZIP directory entry outside bounds or multi-disk entry", + )); + } entries.push(CentralDirectoryEntry { + crc: read_u32(bytes, name_start - 30).unwrap(), name, compressed_size, uncompressed_size, @@ -1375,15 +1315,15 @@ fn parse_stored_zip( }); } + if cursor != eocd_offset { + return Err(malformed_zip(label, source, "ZIP directory size mismatch")); + } let mut files = BTreeMap::new(); let mut expanded_size = 0u64; + let mut ranges = Vec::new(); for entry in entries { - if entry.flags & ZIP_FLAG_ENCRYPTED != 0 { - return Err(malformed_zip( - label, - source, - "encrypted zip packages are unsupported", - )); + if entry.flags & !0x0800 != 0 { + return Err(malformed_zip(label, source, "unsupported ZIP flags")); } if is_zip_symlink(entry.external_attributes) { return Err(PluginDiagnostic::new( @@ -1425,7 +1365,19 @@ fn parse_stored_zip( .with_source(source) .with_package(label)); } - let data = read_stored_entry(bytes, &entry, label, source)?; + let (data, end) = read_stored_entry(bytes, &entry, label, source)?; + if end > central_offset + || ranges + .iter() + .any(|&(start, stop)| (entry.local_header_offset as usize) < stop && end > start) + { + return Err(malformed_zip( + label, + source, + "overlapping ZIP entries or directory data", + )); + } + ranges.push((entry.local_header_offset as usize, end)); if data.len() != entry.uncompressed_size as usize || data.len() != entry.compressed_size as usize { @@ -1454,7 +1406,7 @@ fn read_stored_entry( entry: &CentralDirectoryEntry, label: &str, source: PluginSourceKind, -) -> Result, PluginDiagnostic> { +) -> Result<(Vec, usize), PluginDiagnostic> { let cursor = entry.local_header_offset as usize; if read_u32(bytes, cursor) != Some(ZIP_LOCAL_FILE) { return Err(malformed_zip( @@ -1473,7 +1425,14 @@ fn read_stored_entry( let extra_len = read_u16(bytes, cursor + 28) .ok_or_else(|| malformed_zip(label, source, "zip local file header is truncated"))? as usize; - if local_flags != entry.flags || local_method != entry.compression_method { + let name_start = cursor + 30; + if bytes.get(name_start..name_start.saturating_add(name_len)) != Some(entry.name.as_bytes()) + || read_u32(bytes, cursor + 14) != Some(entry.crc) + || read_u32(bytes, cursor + 18) != Some(entry.compressed_size) + || read_u32(bytes, cursor + 22) != Some(entry.uncompressed_size) + || local_flags != entry.flags + || local_method != entry.compression_method + { return Err(malformed_zip( label, source, @@ -1495,7 +1454,11 @@ fn read_stored_entry( "zip entry data points outside the package", )); } - Ok(bytes[data_start..data_end].to_vec()) + let data = &bytes[data_start..data_end]; + if crc32fast::hash(data) != entry.crc { + return Err(malformed_zip(label, source, "ZIP CRC mismatch")); + } + Ok((data.to_vec(), data_end)) } fn find_eocd(bytes: &[u8]) -> Option { @@ -1596,6 +1559,17 @@ fn package_label(path: &Path) -> String { .to_string() } +fn read_bounded_file(path: &Path, max: u64) -> io::Result> { + let mut bytes = Vec::new(); + fs::File::open(path)? + .take(max.saturating_add(1)) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > max { + return Err(io::Error::from(io::ErrorKind::InvalidData)); + } + Ok(bytes) +} + fn safe_io_error(error: &io::Error) -> &'static str { match error.kind() { io::ErrorKind::NotFound => "not found", @@ -1644,14 +1618,72 @@ mod tests { use super::*; use tempfile::tempdir; - fn materialize_template(root: &Path) { - for resource in RUST_COMPONENT_TOOL_TEMPLATE { - let path = root.join(resource.path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).unwrap(); - } - fs::write(path, resource.contents).unwrap(); - } + fn fixture_files() -> BTreeMap> { + let mut resolve = wit_parser::Resolve::default(); + resolve + .push_str( + "host.wit", + include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit"), + ) + .unwrap(); + let package = resolve + .push_str( + "plugin.wit", + include_str!("../../../resources/plugin/wit/yoi-plugin-v2.wit"), + ) + .unwrap(); + let world = resolve + .select_world(&[package], Some("tool-plugin")) + .unwrap(); + let mut module = + wit_component::dummy_module(&resolve, world, wit_parser::ManglingAndAbi::Standard32); + wit_component::embed_component_metadata( + &mut module, + &resolve, + world, + wit_component::StringEncoding::UTF8, + ) + .unwrap(); + let component = wit_component::ComponentEncoder::default() + .module(&module) + .unwrap() + .validate(true) + .encode() + .unwrap(); + let manifest = r#" +schema_version = 2 +id = "example.pure" +name = "Pure" +version = "0.1.0" +surfaces = ["tool"] +config_schema = "{}" +[runtime] +kind = "wasm-component" +component = "plugin.component.wasm" +world = "yoi:plugin/tool-plugin@2.0.0" +required_imports = [] +[[tools]] +name = "echo" +description = "Echo input" +input_schema = { type = "object", additionalProperties = false } +output_schema = { type = "object", additionalProperties = false } +"#; + BTreeMap::from([ + ("plugin.toml".into(), manifest.as_bytes().to_vec()), + ("plugin.component.wasm".into(), component), + ]) + } + + fn inspect( + files: BTreeMap>, + ) -> Result { + materialize_files( + Path::new("fixture"), + "fixture".into(), + PluginSourceKind::Explicit, + files, + &PluginPackageLimits::default(), + ) } #[test] @@ -1662,17 +1694,233 @@ mod tests { fs::create_dir_all(&source).unwrap(); fs::create_dir_all(ambient.parent().unwrap()).unwrap(); fs::write(&ambient, b"malformed ambient package").unwrap(); - materialize_template(&source); - + for (name, content) in fixture_files() { + fs::write(source.join(name), content).unwrap(); + } let limits = PluginPackageLimits::default(); let package = read_plugin_directory(&source, PluginSourceKind::Explicit, &limits).unwrap(); let output = dir.path().join("explicit.yoi-plugin"); - write_plugin_package_file(&package, &output, &limits).unwrap(); + let packed = write_plugin_package_file(&package, &output, &limits).unwrap(); let reread = read_plugin_package_file(&output, PluginSourceKind::Explicit, &limits).unwrap(); - assert_eq!(reread.package.identity.source, PluginSourceKind::Explicit); - assert_eq!(reread.package.digest, package.package.digest); + assert_eq!( + reread.package.package_digest, + package.package.package_digest + ); + assert_eq!(reread.artifact_digest, packed.artifact_digest); + assert_eq!(reread.artifact_size, fs::metadata(output).unwrap().len()); + } + + #[test] + fn canonical_pack_is_reproducible_and_digests_have_distinct_meanings() { + let package = inspect(fixture_files()).unwrap(); + let limits = PluginPackageLimits::default(); + let a = pack_plugin_package(&package, &limits).unwrap(); + let b = pack_plugin_package(&package, &limits).unwrap(); + assert_eq!(a, b); + assert_eq!(a.package_digest, deterministic_digest(&package.files)); + assert_eq!(a.artifact_digest, artifact_digest(&a.bytes)); + assert_ne!(a.package_digest, a.artifact_digest); + verify_plugin_artifact( + &a.bytes, + &a.package_digest, + &a.artifact_digest, + a.artifact_size, + &limits, + ) + .unwrap(); + let mut noncanonical = a.bytes.clone(); + // A ZIP comment changes transport bytes, not the logical file set. + let end = noncanonical.len(); + noncanonical[end - 2..].copy_from_slice(&4u16.to_le_bytes()); + noncanonical.extend_from_slice(b"note"); + let uploaded = + read_plugin_package_bytes(&noncanonical, PluginSourceKind::Explicit, &limits).unwrap(); + assert_eq!(uploaded.package.package_digest, a.package_digest); + assert_eq!(pack_plugin_package(&uploaded, &limits).unwrap(), a); + assert!( + verify_plugin_artifact( + &noncanonical, + &a.package_digest, + &artifact_digest(&noncanonical), + noncanonical.len() as u64, + &limits + ) + .is_err() + ); + } + + #[test] + fn artifact_verification_rejects_each_digest_size_and_changed_bytes() { + let artifact = pack_plugin_package( + &inspect(fixture_files()).unwrap(), + &PluginPackageLimits::default(), + ) + .unwrap(); + for (package, transport, size) in [ + ( + "sha256:wrong", + artifact.artifact_digest.as_str(), + artifact.artifact_size, + ), + ( + artifact.package_digest.as_str(), + "sha256:wrong", + artifact.artifact_size, + ), + ( + artifact.package_digest.as_str(), + artifact.artifact_digest.as_str(), + artifact.artifact_size + 1, + ), + ] { + assert_eq!( + verify_plugin_artifact( + &artifact.bytes, + package, + transport, + size, + &PluginPackageLimits::default() + ) + .unwrap_err() + .kind, + PluginDiagnosticKind::Digest + ); + } + let mut bytes = artifact.bytes.clone(); + bytes[40] ^= 1; + assert!( + verify_plugin_artifact( + &bytes, + &artifact.package_digest, + &artifact.artifact_digest, + artifact.artifact_size, + &PluginPackageLimits::default() + ) + .is_err() + ); + } + + #[test] + fn version_surface_schema_slot_and_component_mismatches_are_rejected() { + let base = fixture_files(); + let text = String::from_utf8(base["plugin.toml"].clone()).unwrap(); + for (from, to) in [ + ("schema_version = 2", "schema_version = 1"), + ("schema_version = 2", "schema_version = 999"), + ("surfaces = [\"tool\"]", "surfaces = [\"service\"]"), + ("tool-plugin@2.0.0", "tool-plugin@2.0.1"), + ("tool-plugin@2.0.0", "https-tool-plugin@2.0.0"), + ("type = \"object\"", "type = \"nonexistent\""), + ( + "config_schema = \"{}\"", + "config_schema = \"{ ...String }\"", + ), + ( + "required_imports = []", + "required_imports = [\"yoi:host/http@2.0.0\"]", + ), + ] { + let mut files = base.clone(); + let changed = text.replace(from, to); + assert_ne!(changed, text); + files.insert("plugin.toml".into(), changed.into_bytes()); + assert!(inspect(files).is_err(), "mutation {from} -> {to}"); + } + for extra in [ + "\n[[connections]]\nname = \"api\"\nmethods = [\"GET\"]\npath_prefixes = [\"/\"]", + "\n[[requested_grants]]\nkind = \"https\"\nslot = \"missing\"", + "\n[[requested_grants]]\nkind = \"logging\"", + ] { + let mut files = base.clone(); + files.insert("plugin.toml".into(), format!("{text}{extra}").into_bytes()); + assert!(inspect(files).is_err()); + } + let mut files = base; + files.insert("plugin.component.wasm".into(), b"not a component".to_vec()); + assert!(inspect(files).is_err()); + } + + #[test] + fn stored_archive_rejects_traversal_duplicates_corruption_and_size_bounds() { + let limits = PluginPackageLimits::default(); + let mut files = fixture_files(); + files.insert("../escape".into(), vec![]); + assert_eq!( + canonical_archive_bytes(&files, &limits).unwrap_err().kind, + PluginDiagnosticKind::Traversal + ); + // Equal-length paths let both local and central names be changed coherently. + let bytes = canonical_archive_bytes( + &BTreeMap::from([("aa".into(), vec![1]), ("bb".into(), vec![2])]), + &limits, + ) + .unwrap(); + for (name, replacement, kind) in [ + (b"bb", b"aa", PluginDiagnosticKind::Duplicate), + (b"bb", b"..", PluginDiagnosticKind::Traversal), + ] { + let mut mutated = bytes.clone(); + for index in 0..mutated.len() - 1 { + if &mutated[index..index + 2] == name { + mutated[index..index + 2].copy_from_slice(replacement); + } + } + assert_eq!( + parse_stored_zip(&mutated, "fixture", PluginSourceKind::Explicit, &limits) + .unwrap_err() + .kind, + kind + ); + } + let mut corrupt = bytes.clone(); + corrupt[32] ^= 1; + assert!( + parse_stored_zip(&corrupt, "fixture", PluginSourceKind::Explicit, &limits).is_err() + ); + for small in [ + PluginPackageLimits { + max_package_size_bytes: 1, + ..limits.clone() + }, + PluginPackageLimits { + max_entries_per_package: 1, + ..limits.clone() + }, + PluginPackageLimits { + max_file_size_bytes: 0, + ..limits.clone() + }, + PluginPackageLimits { + max_expanded_size_bytes: 1, + ..limits.clone() + }, + ] { + assert!(read_plugin_package_bytes(&bytes, PluginSourceKind::Explicit, &small).is_err()); + } + } + + #[test] + fn pack_revalidates_mutated_files_and_directory_rejects_symlinks() { + let mut package = inspect(fixture_files()).unwrap(); + package.files.insert("plugin.component.wasm".into(), vec![]); + assert!(pack_plugin_package(&package, &PluginPackageLimits::default()).is_err()); + #[cfg(unix)] + { + let dir = tempdir().unwrap(); + std::os::unix::fs::symlink("outside", dir.path().join("link")).unwrap(); + assert_eq!( + read_plugin_directory( + dir.path(), + PluginSourceKind::Explicit, + &PluginPackageLimits::default() + ) + .unwrap_err() + .kind, + PluginDiagnosticKind::Traversal + ); + } } #[test] diff --git a/crates/manifest/src/plugin/component.rs b/crates/manifest/src/plugin/component.rs new file mode 100644 index 000000000..56babc532 --- /dev/null +++ b/crates/manifest/src/plugin/component.rs @@ -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, + pub exports: Vec, +} + +/// 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 { + 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::>() + )); + } + 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::>() + )); + } + + // 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, 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, 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 { + // 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 { + 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 { + 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 { + [ + "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 { + 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", "fields: option"), + ("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", + ), + ("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::(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", "content: list"), + ("summary: string", "summary: string, extra: bool"), + ("input: invocation", "input: output"), + ("result", "result"), + ("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", "own"), + ("use connections.{connection};", "resource connection;"), + ("status: u16", "status: u32"), + ("value: string", "value: list"), + ( + "resource connection;", + "resource connection; resource other;", + ), + ("target: borrow", "target: borrow"), + ("diagnostic-id: string", "diagnostic-id: u64"), + ] { + let mut host = HOST_WIT.replace(from, to); + if to == "target: borrow" { + 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()); + } +} diff --git a/crates/manifest/src/plugin/schema.rs b/crates/manifest/src/plugin/schema.rs new file mode 100644 index 000000000..cb64c24bf --- /dev/null +++ b/crates/manifest/src/plugin/schema.rs @@ -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::>() + }); + 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, + ) -> Result> { + 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>, +) -> 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>, + active: &mut BTreeSet, + complete: &mut BTreeMap, + depth: usize, +) -> Result { + 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}"); + } +} diff --git a/crates/plugin-pdk/Cargo.toml b/crates/plugin-pdk/Cargo.toml index 99202a5b2..2c04cbb86 100644 --- a/crates/plugin-pdk/Cargo.toml +++ b/crates/plugin-pdk/Cargo.toml @@ -15,3 +15,6 @@ wit-bindgen = "0.51.0" [dev-dependencies] tempfile.workspace = true toml.workspace = true +wasmparser = "0.244.0" +wit-component = "0.244.0" +wit-parser = "0.244.0" diff --git a/crates/plugin-pdk/README.md b/crates/plugin-pdk/README.md index 50e2ee049..59a6fed99 100644 --- a/crates/plugin-pdk/README.md +++ b/crates/plugin-pdk/README.md @@ -1,9 +1,5 @@ # plugin-pdk -Guest-side JSON/WIT helpers for offline Component Model packages. Without depending on host/runtime crates, it converts Tool and Service inputs/outputs and bounds Tool output size. Using the PDK or validating a package does not authorize installation or execution in a Worker. +Guest-side Rust helpers for the versioned Component Model Tool contract. [`ToolContext`] decodes only non-secret invocation data; [`ToolOutput`] rejects oversized data and separates structured output from presentation text. [`export_component_tool!`] adapts generated WIT bindings and preserves the result's error arm. -## Main entry points - -- [`run_json_tool`] and [`export_component_tool!`] — typed Tool input/output boundary -- [`ToolOutput`] and [`ToolError`] — bounded output and errors -- [`ServiceOutput`] and [`export_plugin_instance!`] — instance output contracts +Host authorization, schema validation, effect receipts and execution belong to the Host, not this crate. The PDK does not import WASI, implement a Host, or provide Service/event/Workdir/WebSocket stubs. Old v1 packages require an explicit v2 rebuild rather than a compatibility adapter. diff --git a/crates/plugin-pdk/src/lib.rs b/crates/plugin-pdk/src/lib.rs index 592a45687..364833a44 100644 --- a/crates/plugin-pdk/src/lib.rs +++ b/crates/plugin-pdk/src/lib.rs @@ -2,146 +2,126 @@ use serde::Serialize; use serde::de::DeserializeOwned; -use serde_json::Value; pub use wit_bindgen; pub type Result = std::result::Result; -/// Current Yoi Component Model Tool world targeted by this PDK. -pub const TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0"; - -/// Repository WIT for the current Tool world, exposed for authoring tools and -/// tests. Runtime components should still generate bindings at compile time. -pub const TOOL_WIT: &str = include_str!("../../../resources/plugin/wit/yoi-plugin-tool-v1.wit"); - -/// Repository WIT for the grant-bound host APIs importable by Tool components. +pub const TOOL_WORLD: &str = "yoi:plugin/tool-plugin@2.0.0"; +pub const HTTPS_TOOL_WORLD: &str = "yoi:plugin/https-tool-plugin@2.0.0"; +pub const TOOL_WIT: &str = include_str!("../../../resources/plugin/wit/yoi-plugin-v2.wit"); pub const HOST_WIT: &str = - include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v1.wit"); - -/// Serialized JSON limit defined by the offline Tool world's output contract. + include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit"); pub const MAX_TOOL_OUTPUT_BYTES: usize = 64 * 1024; -/// Byte limit for summaries in the offline Tool world. -pub const MAX_SUMMARY_BYTES: usize = 1024; -/// Conservative content cap that leaves room for JSON framing and escaping. -pub const MAX_CONTENT_BYTES: usize = MAX_TOOL_OUTPUT_BYTES - MAX_SUMMARY_BYTES - 4096; -/// Maximum structured error message bytes retained by the PDK. +pub const MAX_TOOL_INPUT_BYTES: usize = 64 * 1024; +pub const MAX_CONFIG_BYTES: usize = 64 * 1024; pub const MAX_ERROR_MESSAGE_BYTES: usize = 4096; -/// Per-call context passed to a typed JSON handler. +/// Non-secret call snapshot. Invocation identity tracks effects; it grants no authority. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ToolContext { + invocation_id: String, tool_name: String, + config_json: String, + remaining_ms: u32, } impl ToolContext { - /// Create context for the manifest-declared Tool name selected by the host. - pub fn new(tool_name: impl Into) -> Self { + pub fn new( + invocation_id: String, + tool_name: String, + config_json: String, + remaining_ms: u32, + ) -> Self { Self { - tool_name: bounded_text(tool_name.into(), MAX_SUMMARY_BYTES), + invocation_id, + tool_name, + config_json, + remaining_ms, } } - /// Manifest-declared Tool name supplied by the host runtime. + pub fn invocation_id(&self) -> &str { + &self.invocation_id + } pub fn tool_name(&self) -> &str { &self.tool_name } + pub fn remaining_ms(&self) -> u32 { + self.remaining_ms + } + + /// Decode the plan's non-secret config. Host validation remains authoritative. + pub fn config(&self) -> Result { + parse_bounded_json(&self.config_json, MAX_CONFIG_BYTES) + } } -/// Bounded Tool output formatted on the guest side. -/// -/// Shorten text at character boundaries and recheck the serialized JSON limit, including escapes. -/// Typed JSON through [`Self::json`] errors on overflow rather than truncating and breaking -/// its structure. Providing this output format does not provide Plugin execution by Workers. +/// Output crosses WIT as a record, never as a success envelope hiding an error. +/// Oversized output is rejected, not silently truncated into invalid JSON. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct ToolOutput { summary: String, - #[serde(skip_serializing_if = "Option::is_none")] content: Option, + structured_json: Option, } impl ToolOutput { - /// Create an ordinary Tool output. - pub fn new(summary: impl Into, content: Option) -> Self { - let mut output = Self { - summary: normalize_summary(summary.into()), - content: content.map(|value| bounded_text(value, MAX_CONTENT_BYTES)), - }; - output.bound_serialized_json(); - output + pub fn new(summary: impl Into, content: Option) -> Result { + Self::checked(summary.into(), content, None) } - /// Create a Tool output whose content is typed JSON. - pub fn json( - summary: impl Into, - value: impl Serialize, - ) -> std::result::Result { - let content = serde_json::to_string(&value).map_err(ToolError::serialization)?; + pub fn summary(summary: impl Into) -> Result { + Self::new(summary, None) + } + + /// Structured JSON is separate from presentation text and needs a manifest output schema. + pub fn json(summary: impl Into, value: impl Serialize) -> Result { + let json = serde_json::to_string(&value).map_err(ToolError::serialization)?; + Self::checked(summary.into(), None, Some(json)) + } + + fn checked( + summary: String, + content: Option, + structured_json: Option, + ) -> Result { let output = Self { - summary: normalize_summary(summary.into()), - content: Some(content), + summary, + content, + structured_json, }; - let serialized = serde_json::to_string(&output).map_err(ToolError::serialization)?; - if serialized.len() > MAX_TOOL_OUTPUT_BYTES { - return Err(ToolError::invalid_output(format!( - "serialized ToolOutput JSON exceeds {MAX_TOOL_OUTPUT_BYTES} bytes" - ))); + let bytes = serde_json::to_vec(&output).map_err(ToolError::serialization)?; + if bytes.len() > MAX_TOOL_OUTPUT_BYTES { + return Err(ToolError::invalid_output( + "serialized output exceeds 64 KiB", + )); } Ok(output) } - /// Create a summary-only Tool output. - pub fn summary(summary: impl Into) -> Self { - Self::new(summary, None) - } - - /// Return the bounded summary. pub fn summary_text(&self) -> &str { &self.summary } - - /// Return optional detailed content. pub fn content(&self) -> Option<&str> { self.content.as_deref() } - - /// Serialize to the ToolOutput JSON string returned by the WIT `call` export. - pub fn to_json_string(&self) -> String { - serde_json::to_string(self).unwrap_or_else(|_| { - r#"{"summary":"tool error: serialization","content":"{\"error\":{\"code\":\"serialization\",\"message\":\"failed to serialize ToolOutput\"}}"}"#.to_string() - }) + pub fn structured_json(&self) -> Option<&str> { + self.structured_json.as_deref() } - fn bound_serialized_json(&mut self) { - loop { - let Ok(serialized) = serde_json::to_string(self) else { - return; - }; - if serialized.len() <= MAX_TOOL_OUTPUT_BYTES { - return; - } - let Some(content) = self.content.take() else { - return; - }; - if content.is_empty() { - return; - } - - let overflow = serialized.len() - MAX_TOOL_OUTPUT_BYTES; - let target = content.len().saturating_sub(overflow + "…".len()); - self.content = Some(bounded_text(content, target)); - } + #[doc(hidden)] + pub fn into_parts(self) -> (String, Option, Option) { + (self.summary, self.content, self.structured_json) } } -/// Stable, low-cardinality error codes for PDK-produced Tool errors. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] +/// Guest business errors are not Host authorization decisions or effect receipts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ToolErrorCode { InvalidInput, InvalidOutput, Serialization, - Denied, UnsupportedTool, Failed, } @@ -152,640 +132,200 @@ impl ToolErrorCode { Self::InvalidInput => "invalid_input", Self::InvalidOutput => "invalid_output", Self::Serialization => "serialization", - Self::Denied => "denied", Self::UnsupportedTool => "unsupported_tool", Self::Failed => "failed", } } } -/// Structured, bounded error that is rendered as ordinary ToolOutput JSON. -#[derive(Clone, Debug, PartialEq, Serialize, thiserror::Error)] +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] #[error("{code:?}: {message}")] pub struct ToolError { code: ToolErrorCode, message: String, - #[serde(skip_serializing_if = "Option::is_none")] - details: Option, } impl ToolError { pub fn new(code: ToolErrorCode, message: impl Into) -> Self { - Self { - code, - message: bounded_text(message.into(), MAX_ERROR_MESSAGE_BYTES), - details: None, + let mut message = message.into(); + if message.len() > MAX_ERROR_MESSAGE_BYTES { + let mut end = MAX_ERROR_MESSAGE_BYTES; + while !message.is_char_boundary(end) { + end -= 1; + } + message.truncate(end); } + Self { code, message } } - pub fn invalid_input(message: impl Into) -> Self { Self::new(ToolErrorCode::InvalidInput, message) } - pub fn invalid_output(message: impl Into) -> Self { Self::new(ToolErrorCode::InvalidOutput, message) } - - pub fn serialization(error: impl std::fmt::Display) -> Self { - Self::new(ToolErrorCode::Serialization, error.to_string()) + /// Serialization diagnostics must not include the original payload. + pub fn serialization(_error: impl std::fmt::Display) -> Self { + Self::new(ToolErrorCode::Serialization, "output serialization failed") } - - pub fn denied(message: impl Into) -> Self { - Self::new(ToolErrorCode::Denied, message) + pub fn unsupported_tool(_name: impl AsRef) -> Self { + Self::new(ToolErrorCode::UnsupportedTool, "unsupported Tool name") } - - pub fn unsupported_tool(tool_name: impl Into) -> Self { - Self::new( - ToolErrorCode::UnsupportedTool, - format!("unsupported tool `{}`", tool_name.into()), - ) - } - pub fn failed(message: impl Into) -> Self { Self::new(ToolErrorCode::Failed, message) } - - pub fn with_details(mut self, details: impl Serialize) -> Self { - self.details = serde_json::to_value(details).ok(); - self - } - pub fn code(&self) -> ToolErrorCode { self.code } - pub fn code_str(&self) -> &'static str { self.code.as_str() } - pub fn message(&self) -> &str { &self.message } +} - /// Render this error as ordinary ToolOutput JSON content. - pub fn into_tool_output(self) -> ToolOutput { - #[derive(Serialize)] - struct ErrorBody<'a> { - error: ErrorPayload<'a>, - } - - #[derive(Serialize)] - struct ErrorPayload<'a> { - code: &'static str, - message: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - details: Option<&'a Value>, - } - - let summary = format!("tool error: {}", self.code.as_str()); - let payload = ErrorBody { - error: ErrorPayload { - code: self.code.as_str(), - message: &self.message, - details: self.details.as_ref(), - }, - }; - let mut content = serde_json::to_string(&payload).unwrap_or_else(|_| { - format!( - r#"{{"error":{{"code":"{}","message":"failed to serialize error details"}}}}"#, - ToolErrorCode::Serialization.as_str() - ) - }); - if content.len() > MAX_CONTENT_BYTES { - let payload = ErrorBody { - error: ErrorPayload { - code: self.code.as_str(), - message: &self.message, - details: None, - }, - }; - content = serde_json::to_string(&payload).unwrap_or_else(|_| { - r#"{"error":{"code":"serialization","message":"failed to serialize error"}}"# - .to_string() - }); - } - ToolOutput::new(summary, Some(content)) +fn parse_bounded_json(json: &str, limit: usize) -> Result { + if json.len() > limit { + return Err(ToolError::invalid_input("JSON exceeds 64 KiB")); } + serde_json::from_str(json) + .map_err(|_| ToolError::invalid_input("JSON does not match the expected schema")) } -/// Parse the WIT `input-json` string into a typed input value. -pub fn parse_json_input(input_json: &str) -> std::result::Result -where - T: DeserializeOwned, -{ - serde_json::from_str(input_json).map_err(|error| { - ToolError::invalid_input(format!( - "tool input is not valid JSON for this schema: {error}" - )) - }) +pub fn parse_json_input(json: &str) -> Result { + parse_bounded_json(json, MAX_TOOL_INPUT_BYTES) } -/// Guest-side boundary that runs a typed JSON handler and converts the result to ToolOutput JSON. -/// -/// Convert parse, handler, and serialization failures to the normal output form. These errors -/// are not host authorization decisions, and catching handler panics is not guaranteed. -/// The PDK does not depend on host/runtime crates or grant permissions through input conversion. -pub fn run_json_tool(tool_name: &str, input_json: &str, handler: F) -> String -where - I: DeserializeOwned, - F: FnOnce(ToolContext, I) -> std::result::Result, -{ - let result = parse_json_input::(input_json).and_then(|input| { - let context = ToolContext::new(tool_name); - handler(context, input) - }); - match result { - Ok(output) => output.to_json_string(), - Err(error) => error.into_tool_output().to_json_string(), - } +/// Host performs schema and admission checks; this boundary only decodes guest data. +/// Failures remain the WIT result's error arm. No retry or success stub is introduced. +pub fn run_json_tool( + context: ToolContext, + input_json: &str, + handler: impl FnOnce(ToolContext, I) -> Result, +) -> Result { + let _: serde_json::Value = context.config()?; + let input = parse_json_input(input_json)?; + handler(context, input) } -/// Connect a typed JSON handler to a WIT `Guest` implementation and component export. -/// -/// Use guest-generated bindings and centralize handler conversion in [`run_json_tool`]. -/// This macro grants no host API permissions and is not a mechanism for registering or -/// executing dynamic Plugins in the current Worker. +/// Adapt bindings generated from any supported v2 Tool world. The generated glue +/// owns the canonical ABI; callers never implement pointer/length exports. #[macro_export] macro_rules! export_component_tool { ($adapter:ident, $handler:path) => { struct $adapter; - - impl Guest for $adapter { - fn call( - tool_name: ::std::string::String, - input_json: ::std::string::String, - ) -> ::std::string::String { - $crate::run_json_tool(&tool_name, &input_json, $handler) + impl exports::yoi::plugin::tool::Guest for $adapter { + fn invoke( + input: exports::yoi::plugin::tool::Invocation, + ) -> ::std::result::Result< + exports::yoi::plugin::tool::Output, + exports::yoi::plugin::tool::ToolError, + > { + let context = $crate::ToolContext::new( + input.invocation_id, + input.tool_name, + input.config_json, + input.remaining_ms, + ); + $crate::run_json_tool(context, &input.input_json, $handler) + .map(|output| { + let (summary, content, structured_json) = output.into_parts(); + exports::yoi::plugin::tool::Output { + summary, + content, + structured_json, + } + }) + .map_err(|error| exports::yoi::plugin::tool::ToolError { + code: error.code_str().to_owned(), + message: error.message().to_owned(), + }) } } - export!($adapter); }; } -fn normalize_summary(summary: String) -> String { - let summary = bounded_text(summary, MAX_SUMMARY_BYTES); - if summary.trim().is_empty() { - "tool completed".to_string() - } else { - summary - } -} - -fn bounded_text(mut value: String, max_bytes: usize) -> String { - if max_bytes == 0 { - return String::new(); - } - - value = value - .chars() - .map(|ch| { - if ch.is_control() && ch != '\n' && ch != '\t' { - ' ' - } else { - ch - } - }) - .collect(); - if value.len() <= max_bytes { - return value; - } - - let suffix = "…"; - let budget = max_bytes.saturating_sub(suffix.len()); - let mut cut = budget.min(value.len()); - while cut > 0 && !value.is_char_boundary(cut) { - cut -= 1; - } - value.truncate(cut); - value.push_str(suffix); - value -} - #[cfg(test)] mod tests { use super::*; - use serde::{Deserialize, Serialize}; + use serde::Deserialize; use serde_json::json; - #[derive(Debug, Deserialize)] - struct EchoInput { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Input { text: String, } - #[derive(Debug, Serialize)] - struct EchoOutput<'a> { - tool: &'a str, - text: String, + fn context(config: &str) -> ToolContext { + ToolContext::new("call-1".into(), "echo".into(), config.into(), 500) } #[test] - fn run_json_tool_parses_input_and_serializes_output() { - let output = run_json_tool( - "example_echo", - r#"{"text":"hello"}"#, - |ctx, input: EchoInput| { - ToolOutput::json( - format!("{} ok", ctx.tool_name()), - EchoOutput { - tool: ctx.tool_name(), - text: input.text, - }, - ) - }, - ); - - let value: Value = serde_json::from_str(&output).unwrap(); - assert_eq!(value["summary"], "example_echo ok"); - let content: Value = serde_json::from_str(value["content"].as_str().unwrap()).unwrap(); - assert_eq!(content, json!({"tool":"example_echo","text":"hello"})); - assert!(output.len() <= MAX_TOOL_OUTPUT_BYTES); - } - - #[test] - fn run_json_tool_renders_error_output() { - let output = - run_json_tool::("example_echo", r#"{"text":1}"#, |_ctx, _input| { - Ok(ToolOutput::summary("unreachable")) - }); - - let value: Value = serde_json::from_str(&output).unwrap(); - assert_eq!(value["summary"], "tool error: invalid_input"); - let content: Value = serde_json::from_str(value["content"].as_str().unwrap()).unwrap(); - assert_eq!(content["error"]["code"], "invalid_input"); - assert!(content["error"]["message"].as_str().unwrap().len() <= MAX_ERROR_MESSAGE_BYTES); - assert!(output.len() <= MAX_TOOL_OUTPUT_BYTES); - } - - #[test] - fn json_output_rejects_oversized_serialized_tool_output() { - let too_large = vec!["quoted \" text"; MAX_TOOL_OUTPUT_BYTES / 4]; - let error = ToolOutput::json("too large", too_large).unwrap_err(); - - assert_eq!(error.code(), ToolErrorCode::InvalidOutput); - } - - #[test] - fn tool_error_bounds_message_and_control_characters() { - let message = format!("bad\u{0007}{}", "x".repeat(MAX_ERROR_MESSAGE_BYTES * 2)); - let error = ToolError::failed(message); - - assert_eq!(error.code_str(), "failed"); - assert!(!error.message().contains('\u{0007}')); - assert!(error.message().len() <= MAX_ERROR_MESSAGE_BYTES + "…".len()); - } - - #[test] - fn service_output_helper_builds_runtime_command_envelope() { - let event = PluginIngressEvent { - kind: "websocket_text".to_string(), - source: "websocket:wss://example.test/socket".to_string(), - ingress_name: "example_ws".to_string(), - payload: json!({"text":"ping"}), - created_at: "2026-06-25T00:00:00Z".to_string(), - attempt: 1, - correlation_id: "event-1".to_string(), - }; - - assert_eq!(event.websocket_text(), Some("ping")); - let output = - ServiceOutput::websocket_send(&event, "reply-1", "wss://example.test/socket", "pong") - .unwrap(); - let value = serde_json::to_value(output).unwrap(); - - assert_eq!(value["accepted"], true); - assert_eq!(value["output_commands"][0]["source_event_id"], "event-1"); - assert_eq!(value["output_commands"][0]["command_id"], "reply-1"); - assert_eq!(value["output_commands"][0]["kind"], "websocket_send"); - assert_eq!(value["output_commands"][0]["payload"]["text"], "pong"); + fn typed_handler_keeps_structured_output_separate_from_text() { + let output = run_json_tool(context("{}"), r#"{"text":"hello"}"#, |ctx, input: Input| { + assert_eq!(ctx.invocation_id(), "call-1"); + assert_eq!(ctx.remaining_ms(), 500); + ToolOutput::json( + "echoed", + json!({"text": input.text, "tool": ctx.tool_name()}), + ) + }) + .unwrap(); + assert_eq!(output.content(), None); assert_eq!( - value["output_commands"][0]["requested_at"], - "2026-06-25T00:00:00Z" + serde_json::from_str::(output.structured_json().unwrap()).unwrap(), + json!({"text":"hello", "tool":"echo"}) ); } #[test] - fn wit_constants_match_current_world() { - assert!(TOOL_WIT.contains("package yoi:plugin@1.0.0")); - assert!(TOOL_WIT.contains("world tool")); - assert!(TOOL_WIT.contains("export call")); - assert_eq!(TOOL_WORLD, "yoi:plugin/tool@1.0.0"); - assert!(HOST_WIT.contains("interface request")); - assert!(HOST_WIT.contains("interface websocket")); - assert!(HOST_WIT.contains("interface fs")); - assert!(HOST_WIT.contains("%list: func")); - assert!(INSTANCE_WIT.contains("world instance")); - assert!(INSTANCE_WIT.contains("export handle-ingress")); - assert!(INSTANCE_WIT.contains("websocket_send")); - } -} - -/// Versioned Component Model instance world handled by the host-managed -/// PluginInstanceRegistry. -pub const PLUGIN_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0"; - -/// Repository WIT for the current instance world. -pub const INSTANCE_WIT: &str = - include_str!("../../../resources/plugin/wit/yoi-plugin-instance-v1.wit"); - -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct PluginIngressEvent { - pub kind: String, - pub source: String, - #[serde(default)] - pub ingress_name: String, - #[serde(default)] - pub payload: Value, - #[serde(default)] - pub created_at: String, - #[serde(default = "default_attempt")] - pub attempt: u32, - #[serde(default)] - pub correlation_id: String, -} - -impl PluginIngressEvent { - /// Return the text payload carried by a host-owned WebSocket ingress event. - pub fn websocket_text(&self) -> Option<&str> { - self.payload.get("text").and_then(Value::as_str) - } - - /// Build a `websocket_send` output command that replies through the - /// host-owned Service WebSocket driver. The host still validates the target - /// URL and matching grants before sending. - pub fn websocket_send( - &self, - command_id: impl Into, - url: impl Into, - text: impl Into, - ) -> Result { - ServiceOutputCommand::new( - self, - command_id, - ServiceOutputCommandKind::WebSocketSend, - serde_json::json!({ "url": url.into(), "text": text.into() }), - ) - } -} - -fn default_attempt() -> u32 { - 1 -} - -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -pub struct PluginStatus { - pub state: String, - #[serde(default)] - pub data: Value, -} - -impl PluginStatus { - pub fn ready(data: Value) -> Self { - Self { - state: "ready".to_string(), - data, + fn invalid_input_or_config_never_runs_handler_or_returns_success() { + for (config, input) in [ + ("{}", r#"{"text":1}"#), + ("broken", r#"{"text":"ok"}"#), + ("{}", r#"{"text":"ok","extra":true}"#), + ] { + let error = run_json_tool::(context(config), input, |_, _| { + panic!("handler must not run") + }) + .unwrap_err(); + assert_eq!(error.code(), ToolErrorCode::InvalidInput); } } - pub fn stopped() -> Self { - Self { - state: "stopped".to_string(), - data: Value::Null, + #[test] + fn payload_limits_reject_overflow_including_json_escaping() { + for text in [ + "x".repeat(MAX_TOOL_OUTPUT_BYTES), + "\"".repeat(MAX_TOOL_OUTPUT_BYTES / 2), + "語".repeat(MAX_TOOL_OUTPUT_BYTES / 3), + ] { + assert_eq!( + ToolOutput::json("large", &text).unwrap_err().code(), + ToolErrorCode::InvalidOutput + ); } + assert!( + parse_json_input::(&format!( + "\"{}\"", + "x".repeat(MAX_TOOL_INPUT_BYTES) + )) + .is_err() + ); + let large = format!("\"{}\"", "x".repeat(MAX_CONFIG_BYTES)); + assert!(context(&large).config::().is_err()); } -} -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ServiceOutputCommandKind { - DiagnosticStatusUpdate, - HostRequestDispatch, - #[serde(rename = "websocket_send")] - WebSocketSend, -} - -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct ServiceOutputCommand { - pub correlation_id: String, - pub source_event_id: String, - pub command_id: String, - pub kind: ServiceOutputCommandKind, - pub payload: Value, - pub requested_at: String, -} - -impl ServiceOutputCommand { - pub fn new( - event: &PluginIngressEvent, - command_id: impl Into, - kind: ServiceOutputCommandKind, - payload: impl Serialize, - ) -> Result { - let command_id = sanitize_command_id(command_id.into()); - if command_id.is_empty() { - return Err(ToolError::invalid_output( - "service output command_id must not be empty", - )); - } - let source_event_id = event.correlation_id.clone(); - if source_event_id.is_empty() { - return Err(ToolError::invalid_output( - "service output command requires ingress event correlation_id", - )); - } - let requested_at = if event.created_at.is_empty() { - "1970-01-01T00:00:00Z".to_string() - } else { - event.created_at.clone() - }; - Ok(Self { - correlation_id: sanitize_command_id(format!("{source_event_id}:{command_id}")), - source_event_id, - command_id, - kind, - payload: serde_json::to_value(payload).map_err(ToolError::serialization)?, - requested_at, + #[test] + fn business_failure_is_preserved_and_message_is_utf8_bounded() { + let error = run_json_tool(context("{}"), "null", |_, _: ()| { + Err(ToolError::failed("語".repeat(MAX_ERROR_MESSAGE_BYTES))) }) + .unwrap_err(); + assert_eq!(error.code_str(), "failed"); + assert!(error.message().len() <= MAX_ERROR_MESSAGE_BYTES); + assert!(error.message().ends_with('語')); } } - -/// Instance processing results and output commands requested from the host. -/// -/// The guest does not send anything here; it returns commands as data tied to the original -/// ingress event. Command creation/serialization and host authorization/execution are separate -/// boundaries. This type provides neither network permissions nor the current Worker execution mechanism. -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct ServiceOutput { - #[serde(default)] - pub accepted: bool, - #[serde(default, skip_serializing_if = "Value::is_null")] - pub data: Value, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub output_commands: Vec, -} - -impl ServiceOutput { - pub fn accepted(data: impl Serialize) -> Result { - 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, - url: impl Into, - text: impl Into, - ) -> Result { - 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; - fn handle_tool(&mut self, name: &str, input: Value) -> Result; - fn handle_ingress(&mut self, name: &str, event: PluginIngressEvent) -> Result; - fn status(&self) -> Result { - Ok(PluginStatus::ready(Value::Null)) - } - fn stop(&mut self) -> Result { - Ok(PluginStatus::stopped()) - } -} - -#[doc(hidden)] -pub fn plugin_instance_error(message: impl Into) -> 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); - }; -} diff --git a/crates/plugin-pdk/tests/fixtures/component-tool-source.txt b/crates/plugin-pdk/tests/fixtures/component-tool-source.txt deleted file mode 100644 index 4311cc2f0..000000000 --- a/crates/plugin-pdk/tests/fixtures/component-tool-source.txt +++ /dev/null @@ -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::json( - format!("{} ok", ctx.tool_name()), - EchoOutput { - tool: ctx.tool_name(), - text: input.text, - }, - ) -} - -yoi_plugin_pdk::export_component_tool!(Plugin, handle_echo); diff --git a/crates/plugin-pdk/tests/fixtures/https-tool-v2.component.wasm b/crates/plugin-pdk/tests/fixtures/https-tool-v2.component.wasm new file mode 100644 index 000000000..0012b1af2 Binary files /dev/null and b/crates/plugin-pdk/tests/fixtures/https-tool-v2.component.wasm differ diff --git a/crates/plugin-pdk/tests/fixtures/tool-v2.component.wasm b/crates/plugin-pdk/tests/fixtures/tool-v2.component.wasm new file mode 100644 index 000000000..792a4e6b2 Binary files /dev/null and b/crates/plugin-pdk/tests/fixtures/tool-v2.component.wasm differ diff --git a/crates/plugin-pdk/tests/template.rs b/crates/plugin-pdk/tests/template.rs index 3e0d11992..79d9431bf 100644 --- a/crates/plugin-pdk/tests/template.rs +++ b/crates/plugin-pdk/tests/template.rs @@ -1,247 +1,206 @@ -//! Check the shipped templates against the real PDK and WIT for the guest target. -//! Each check owns its source and lockfile; Cargo alone shares and validates artifacts. - +//! Real Rust -> core Wasm -> Component coverage for the shipped starters. +//! Only filesystem paths are relocated; WIT and guest source are unchanged. +//! Requires the wasm32-unknown-unknown Rust std target and cached Cargo dependencies. +//! No guest is instantiated and no HTTPS Host is faked by this suite. use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Command; use toml::Value; +use wit_parser::WorldItem; -const TEMPLATE_CARGO: &str = - include_str!("../../../resources/plugin/templates/rust-component-tool/Cargo.toml"); -const TEMPLATE_LIB: &str = - include_str!("../../../resources/plugin/templates/rust-component-tool/src/lib.rs"); -const TEMPLATE_PLUGIN: &str = - include_str!("../../../resources/plugin/templates/rust-component-tool/plugin.toml"); -const SERVICE_TEMPLATE_CARGO: &str = - include_str!("../../../resources/plugin/templates/rust-component-instance/Cargo.toml"); -const SERVICE_TEMPLATE_LIB: &str = - include_str!("../../../resources/plugin/templates/rust-component-instance/src/lib.rs"); -const SERVICE_TEMPLATE_PLUGIN: &str = - include_str!("../../../resources/plugin/templates/rust-component-instance/plugin.toml"); -// Preserve the removed document's source as a byte-identical fixture to keep the test input unchanged. -const SAMPLE_LIB: &str = include_str!("fixtures/component-tool-source.txt"); -const PDK_CARGO: &str = include_str!("../Cargo.toml"); +const TEMPLATES: [&str; 2] = ["rust-component-tool", "rust-component-https"]; -#[test] -fn rust_component_tool_template_has_expected_files() { - let cargo: Value = toml::from_str(TEMPLATE_CARGO).expect("template Cargo.toml parses"); - assert_eq!(cargo["package"]["edition"].as_str(), Some("2024")); - assert!(cargo["workspace"].is_table()); - assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib")); - assert_eq!( - cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(), - Some("../../../../crates/plugin-pdk") - ); - assert!(TEMPLATE_CARGO.contains("rev = \"\"")); - - let plugin: Value = toml::from_str(TEMPLATE_PLUGIN).expect("template plugin.toml parses"); - assert_eq!(plugin["schema_version"].as_integer(), Some(1)); - assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component")); - assert_eq!( - plugin["runtime"]["world"].as_str(), - Some("yoi:plugin/tool@1.0.0") - ); - assert!(plugin["tools"].as_array().expect("tools array").len() == 1); - - assert!(TEMPLATE_LIB.contains("use yoi_plugin_pdk::wit_bindgen")); - assert!(TEMPLATE_LIB.contains("wit_bindgen::generate!")); - assert!(TEMPLATE_LIB.contains("generate_all")); - assert!(TEMPLATE_LIB.contains("runtime_path: \"yoi_plugin_pdk::wit_bindgen::rt\"")); - assert!(TEMPLATE_LIB.contains("yoi_plugin_pdk::export_component_tool!")); - assert!(TEMPLATE_LIB.contains("ToolOutput::json")); +fn root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .unwrap() } #[test] -fn rust_component_service_template_has_event_output_pattern() { - let cargo: Value = toml::from_str(SERVICE_TEMPLATE_CARGO).expect("service Cargo.toml parses"); - assert_eq!(cargo["package"]["edition"].as_str(), Some("2024")); - assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib")); - assert_eq!( - cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(), - Some("../../../../crates/plugin-pdk") - ); - assert!(SERVICE_TEMPLATE_CARGO.contains("rev = \"\"")); - - let plugin: Value = - toml::from_str(SERVICE_TEMPLATE_PLUGIN).expect("service plugin.toml parses"); - assert_eq!(plugin["schema_version"].as_integer(), Some(1)); - assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component")); - assert_eq!( - plugin["runtime"]["world"].as_str(), - Some("yoi:plugin/instance@1.0.0") - ); - assert!( - plugin["permissions"] - .as_array() - .expect("permissions array") - .iter() - .any(|permission| permission["kind"].as_str() == Some("host_api") - && permission["api"].as_str() == Some("websocket")) - ); - assert_eq!( - plugin["services"].as_array().expect("services array").len(), - 1 - ); - let ingress = &plugin["ingresses"].as_array().expect("ingresses array")[0]; - assert!( - ingress["event_kinds"] - .as_array() - .expect("event kinds") - .iter() - .any(|kind| kind.as_str() == Some("websocket_text")) - ); - assert!( - ingress["sources"] - .as_array() - .expect("sources") - .iter() - .any(|source| source.as_str() == Some("websocket:wss://example.com/socket")) - ); - let websocket = &plugin["websocket"].as_array().expect("websocket targets")[0]; - assert_eq!(websocket["scheme"].as_str(), Some("wss")); - assert_eq!(websocket["host"].as_str(), Some("example.com")); - assert!( - websocket["path_prefixes"] - .as_array() - .expect("websocket path prefixes") - .iter() - .any(|prefix| prefix.as_str() == Some("/socket")) - ); - - assert!(SERVICE_TEMPLATE_LIB.contains("world: \"instance\"")); - assert!(SERVICE_TEMPLATE_LIB.contains("PluginIngressEvent")); - assert!(SERVICE_TEMPLATE_LIB.contains("ServiceOutput::websocket_send")); - assert!(!SERVICE_TEMPLATE_LIB.contains("recv(timeout")); - assert!(!SERVICE_TEMPLATE_PLUGIN.contains("kind = \"wasm\"")); +fn template_wit_copies_match_the_versioned_contract() { + let root = root(); + for template in TEMPLATES { + for file in ["yoi-plugin-v2.wit", "deps/yoi-host/yoi-host-v2.wit"] { + assert_eq!( + fs::read(root.join("resources/plugin/wit").join(file)).unwrap(), + fs::read( + root.join("resources/plugin/templates") + .join(template) + .join("wit") + .join(file) + ) + .unwrap(), + "{template}/{file} must be regenerated from the canonical WIT" + ); + } + } } #[test] -fn component_tool_fixture_uses_pdk_bindings() { - assert!(SAMPLE_LIB.contains("use yoi_plugin_pdk::wit_bindgen")); - assert!(SAMPLE_LIB.contains("wit_bindgen::generate!")); - assert!(SAMPLE_LIB.contains("generate_all")); - assert!(SAMPLE_LIB.contains("runtime_path: \"yoi_plugin_pdk::wit_bindgen::rt\"")); - assert!(SAMPLE_LIB.contains("yoi_plugin_pdk::export_component_tool!")); - assert!(!SAMPLE_LIB.contains("export_name")); +fn template_manifest_worlds_use_exact_versions_and_schemas() { + for template in TEMPLATES { + let path = root().join("resources/plugin/templates").join(template); + let cargo: Value = + toml::from_str(&fs::read_to_string(path.join("Cargo.toml")).unwrap()).unwrap(); + assert!(cargo["workspace"].is_table()); + assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib")); + let manifest: Value = + toml::from_str(&fs::read_to_string(path.join("plugin.toml")).unwrap()).unwrap(); + assert_eq!(manifest["schema_version"].as_integer(), Some(2)); + assert_eq!(manifest["runtime"]["kind"].as_str(), Some("wasm-component")); + assert!( + manifest["runtime"]["world"] + .as_str() + .unwrap() + .ends_with("@2.0.0") + ); + for tool in manifest["tools"].as_array().unwrap() { + for schema in ["input_schema", "output_schema"] { + assert_eq!(tool[schema]["type"].as_str(), Some("object")); + assert_eq!(tool[schema]["additionalProperties"].as_bool(), Some(false)); + } + } + } } #[test] -fn embedded_template_cargo_checks_for_wasm_target() { - cargo_check_template("rust-component-tool"); +fn pure_starter_builds_real_component_without_imports() { + build_component( + "rust-component-tool", + "yoi_rust_component_tool_template", + &[], + ); } #[test] -fn embedded_service_template_cargo_checks_for_wasm_target() { - cargo_check_template("rust-component-instance"); +fn https_starter_builds_real_component_with_only_explicit_host_imports() { + build_component( + "rust-component-https", + "yoi_rust_component_https_template", + &[ + "yoi:host/connections@2.0.0", + "yoi:host/errors@2.0.0", + "yoi:host/http@2.0.0", + ], + ); } -fn cargo_check_template(template: &str) { - let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let templates_dir = crate_dir.join("../../resources/plugin/templates"); - let template_dir = templates_dir.join(template); - - // A sibling fixture preserves the shipped relative PDK and WIT paths verbatim. - // Never create or remove a lockfile in the shipped template: another invocation - // may be checking it at the same time, or a developer may own that lockfile. +fn build_component(template: &str, library: &str, expected_imports: &[&str]) { + let root = root(); + let sdk = root.join("target/sdk"); + fs::create_dir_all(&sdk).unwrap(); + let source = root.join("resources/plugin/templates").join(template); let fixture = tempfile::Builder::new() - .prefix(".cargo-check-") - .tempdir_in(&templates_dir) - .expect("temporary template fixture"); - copy_template_tree(&template_dir, fixture.path(), true); - - // Both templates share the PDK dependencies. A separate persistent target - // reuses those artifacts without sharing source or the outer Cargo build lock. - // Cargo fingerprints and its target lock handle changes and concurrent checks. - let executable = std::env::current_exe().expect("current test executable"); - let target_dir = executable - .parent() - .and_then(|deps| deps.parent()) - .expect("test executable has a profile directory") - .join("compile-tests/plugin-pdk-templates"); + .prefix("build-") + .tempdir_in(&sdk) + .unwrap(); + copy_tree(&source, fixture.path()); + let cargo_path = fixture.path().join("Cargo.toml"); + let cargo = fs::read_to_string(&cargo_path).unwrap().replace( + "../../../../crates/plugin-pdk", + root.join("crates/plugin-pdk").to_str().unwrap(), + ); + fs::write(&cargo_path, cargo).unwrap(); + let target = sdk.join("guest"); let output = Command::new(env!("CARGO")) - .arg("check") + .args([ + "build", + "--release", + "--offline", + "--target", + "wasm32-unknown-unknown", + "--target-dir", + ]) + .arg(&target) .arg("--manifest-path") - .arg(fixture.path().join("Cargo.toml")) - .arg("--target") - .arg("wasm32-unknown-unknown") - .arg("--offline") - .arg("--target-dir") - .arg(target_dir) + .arg(&cargo_path) .current_dir(fixture.path()) .env("CARGO_TERM_COLOR", "never") .output() - .expect("spawn cargo check for embedded template"); - + .expect("run guest Cargo build"); + let logs = sdk.join("logs"); + fs::create_dir_all(&logs).unwrap(); + fs::write(logs.join(format!("{template}.stdout.log")), &output.stdout).unwrap(); + fs::write(logs.join(format!("{template}.stderr.log")), &output.stderr).unwrap(); assert!( output.status.success(), - "template {template} cargo check failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}", - output.status, + "{template}: cargo build failed\n{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); + let core = + fs::read(target.join(format!("wasm32-unknown-unknown/release/{library}.wasm"))).unwrap(); + let component = wit_component::ComponentEncoder::default() + .module(&core) + .unwrap() + .validate(true) + .encode() + .unwrap(); + wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::all()) + .validate_all(&component) + .unwrap(); + let wit_parser::decoding::DecodedWasm::Component(resolve, world) = + wit_component::decode(&component).unwrap() + else { + panic!("expected Component, not WIT package") + }; + let world = &resolve.worlds[world]; + let mut imports: Vec<_> = world + .imports + .keys() + .map(|key| resolve.name_world_key(key)) + .collect(); + imports.sort(); + assert_eq!(imports, expected_imports); + let exports: Vec<_> = world + .exports + .keys() + .map(|key| resolve.name_world_key(key)) + .collect(); + assert_eq!(exports, ["yoi:plugin/tool@2.0.0"]); + let WorldItem::Interface { id, .. } = world.exports.values().next().unwrap() else { + panic!("Tool interface export required") + }; + assert_eq!( + resolve.interfaces[*id].functions.keys().collect::>(), + [&"invoke".to_string()] + ); + + // Retain actual package inputs for the shared validator/pack owner, not a mock .wasm. + let package = sdk.join("fixtures").join(template); + fs::create_dir_all(&package).unwrap(); + fs::copy(source.join("plugin.toml"), package.join("plugin.toml")).unwrap(); + fs::write(package.join("plugin.component.wasm"), component).unwrap(); } -fn copy_template_tree(source: &Path, destination: &Path, is_root: bool) { - let metadata = fs::symlink_metadata(source).expect("read template directory metadata"); - assert!( - metadata.is_dir() && !metadata.file_type().is_symlink(), - "template input must be a real directory: {}", - source.display() - ); - for entry in fs::read_dir(source).expect("read template directory") { - let entry = entry.expect("read template entry"); - let path = entry.path(); - let kind = entry.file_type().expect("read template entry type"); - assert!( - !kind.is_symlink() && (kind.is_dir() || kind.is_file()), - "unsupported template input (including symlinks): {}", - path.display() - ); - +fn copy_tree(source: &Path, destination: &Path) { + fs::create_dir_all(destination).unwrap(); + for entry in fs::read_dir(source).unwrap() { + let entry = entry.unwrap(); + let kind = entry.file_type().unwrap(); + assert!(!kind.is_symlink(), "template may not contain symlinks"); let name = entry.file_name(); - // Preserve all compiler inputs, including build scripts and nested assets. - // Only omit the root build output and lockfile: each isolated consumer must - // resolve its own lockfile, as the original unlocked template check did. - if is_root - && ((kind.is_dir() && name == "target") || (kind.is_file() && name == "Cargo.lock")) - { + if name == "target" || name == "Cargo.lock" { continue; } let copied = destination.join(name); if kind.is_dir() { - fs::create_dir(&copied).expect("create template fixture directory"); - copy_template_tree(&path, &copied, false); + copy_tree(&entry.path(), &copied); } else { - fs::copy(&path, &copied).expect("copy unmodified template input"); + fs::copy(entry.path(), copied).unwrap(); } } } #[test] fn pdk_runtime_dependencies_are_guest_side_only() { - let cargo: Value = toml::from_str(PDK_CARGO).expect("PDK Cargo.toml parses"); - let dependencies = cargo["dependencies"] - .as_table() - .expect("dependencies table"); - let forbidden = [ - "worker", - "yoi-worker", - "agen", - "tui", - "yoi-tui", - "client", - "yoi-client", - "manifest", - "yoi-manifest", - "ticket", - "yoi-ticket", - ]; - - for name in forbidden { + let cargo: Value = toml::from_str(include_str!("../Cargo.toml")).unwrap(); + for dependency in cargo["dependencies"].as_table().unwrap().keys() { assert!( - !dependencies.contains_key(name), - "PDK must not depend on host/runtime crate `{name}`" + ["serde", "serde_json", "thiserror", "wit-bindgen"].contains(&dependency.as_str()), + "unexpected Host dependency {dependency}" ); } } diff --git a/crates/plugin-pdk/tests/wit_bindgen_probe.rs b/crates/plugin-pdk/tests/wit_bindgen_probe.rs index 041e3a290..92b4d81e4 100644 --- a/crates/plugin-pdk/tests/wit_bindgen_probe.rs +++ b/crates/plugin-pdk/tests/wit_bindgen_probe.rs @@ -1,25 +1,47 @@ +//! Compile-pass coverage for generated v2 bindings and the public adapter macro. use yoi_plugin_pdk::wit_bindgen; wit_bindgen::generate!({ - world: "tool", + world: "tool-plugin", path: "../../resources/plugin/wit", generate_all, runtime_path: "yoi_plugin_pdk::wit_bindgen::rt", }); -struct Probe; +fn handle( + ctx: yoi_plugin_pdk::ToolContext, + input: serde_json::Value, +) -> yoi_plugin_pdk::Result { + if ctx.tool_name() != "probe" { + return Err(yoi_plugin_pdk::ToolError::unsupported_tool(ctx.tool_name())); + } + yoi_plugin_pdk::ToolOutput::json("probe ok", input) +} -impl Guest for Probe { - fn call(tool_name: String, input_json: String) -> String { - yoi_plugin_pdk::run_json_tool(&tool_name, &input_json, |_ctx, input: serde_json::Value| { - yoi_plugin_pdk::ToolOutput::json("probe ok", input) - }) +yoi_plugin_pdk::export_component_tool!(Probe, handle); + +fn invocation(input_json: &str) -> exports::yoi::plugin::tool::Invocation { + exports::yoi::plugin::tool::Invocation { + invocation_id: "call-1".into(), + tool_name: "probe".into(), + input_json: input_json.into(), + config_json: "{}".into(), + remaining_ms: 1000, } } #[test] -fn wit_bindgen_generates_current_tool_world() { - let output = ::call("probe".to_string(), r#"{"ok":true}"#.to_string()); - let value: serde_json::Value = serde_json::from_str(&output).unwrap(); - assert_eq!(value["summary"], "probe ok"); +fn generated_adapter_returns_record_and_keeps_business_error_in_error_arm() { + use exports::yoi::plugin::tool::Guest; + let output = Probe::invoke(invocation(r#"{"ok":true}"#)).unwrap(); + assert_eq!(output.summary, "probe ok"); + assert_eq!(output.content, None); + assert_eq!(output.structured_json.as_deref(), Some(r#"{"ok":true}"#)); + assert_eq!( + Probe::invoke(invocation("broken")).unwrap_err().code, + "invalid_input" + ); + let mut input = invocation("{}"); + input.tool_name = "unknown".into(); + assert_eq!(Probe::invoke(input).unwrap_err().code, "unsupported_tool"); } diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 12871d0ec..1d3a5128e 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -1847,7 +1847,7 @@ fn parse_plugin_pack_args( } fn plugin_usage() -> &'static str { - "usage: yoi plugin new [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]" + "usage: yoi plugin new [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]" } fn parse_mcp_args(args: &[String]) -> Result { diff --git a/crates/yoi/src/plugin_cli.rs b/crates/yoi/src/plugin_cli.rs index 9b93dc263..ee82cdc68 100644 --- a/crates/yoi/src/plugin_cli.rs +++ b/crates/yoi/src/plugin_cli.rs @@ -5,9 +5,10 @@ use std::io; use std::path::{Path, PathBuf}; use manifest::plugin::{ - MaterializedPluginPackage, PluginDiagnostic, PluginDiagnosticKind, PluginDiagnosticPhase, - PluginPackageLimits, PluginPermission, PluginSourceKind, PluginTemplateResource, - RUST_COMPONENT_INSTANCE_TEMPLATE, RUST_COMPONENT_TOOL_TEMPLATE, SourceQualifiedPluginId, + InspectedPluginPackage, MaterializedPluginPackage, PluginConnectionSlot, PluginDiagnostic, + PluginDiagnosticKind, PluginDiagnosticPhase, PluginPackageLimits, PluginRequestedGrant, + PluginRuntimeManifest, PluginSourceKind, PluginTemplateResource, + RUST_COMPONENT_HTTPS_TOOL_TEMPLATE, RUST_COMPONENT_TOOL_TEMPLATE, SourceQualifiedPluginId, read_plugin_directory, read_plugin_package_file, write_plugin_package_file, }; use serde::Serialize; @@ -69,18 +70,16 @@ pub(crate) fn run(command: PluginCliCommand) -> Result<()> { fn render_new(template: &str, destination: &Path, args: &PluginCliArgs) -> Result { let (template_name, resources) = embedded_template_resources(template)?; materialize_template(destination, resources)?; - let mut next_steps = vec![ - "Review plugin.toml and generated Rust source.".to_string(), - "Replace the placeholder plugin.component.wasm with a real built component before offline package verification.".to_string(), - "Run `yoi plugin check ` and then `yoi plugin pack `.".to_string(), + let core_name = match template_name { + "rust-component-https-tool" => "yoi_rust_component_https_template", + _ => "yoi_rust_component_tool_template", + }; + let next_steps = vec![ + "Review plugin.toml and generated Rust source. In Cargo.toml, replace the repository-relative yoi-plugin-pdk dependency path with an absolute local crates/plugin-pdk path or a pinned Yoi Git commit.".to_string(), + "In the generated directory, install the wasm32-unknown-unknown Rust target and wasm-tools, then run `cargo build --release --target wasm32-unknown-unknown --target-dir ../guest-target`. Keep build output outside the package input directory so check/pack do not include build files.".to_string(), + format!("Run `wasm-tools component new ../guest-target/wasm32-unknown-unknown/release/{core_name}.wasm -o plugin.component.wasm` to create the actual Component; cargo check alone does not build it."), + "Run `yoi plugin check ` and then `yoi plugin pack `; static validation does not establish Host implementation or execution support.".to_string(), ]; - if template == "rust-component-service" { - next_steps.insert( - 1, - "Implement the proposed Service ingress output shape for offline package validation." - .to_string(), - ); - } let report = NewReport { command: "new", template: template_name, @@ -103,9 +102,12 @@ fn embedded_template_resources( ) -> Result<(&'static str, &'static [PluginTemplateResource])> { match template { "rust-component-tool" => Ok(("rust-component-tool", RUST_COMPONENT_TOOL_TEMPLATE)), - "rust-component-service" => Ok(("rust-component-service", RUST_COMPONENT_INSTANCE_TEMPLATE)), + "rust-component-https-tool" => Ok(( + "rust-component-https-tool", + RUST_COMPONENT_HTTPS_TOOL_TEMPLATE, + )), _ => Err(format!( - "unsupported plugin template `{template}` (supported: rust-component-tool, rust-component-service)" + "unsupported plugin template `{template}` (supported: rust-component-tool, rust-component-https-tool); Service and other surfaces are not supported" ) .into()), } @@ -195,10 +197,11 @@ fn render_pack(input: &Path, output: Option<&Path>, args: &PluginCliArgs) -> Res status: "packed", input_path: input.display().to_string(), output_path: packed.output_path.display().to_string(), - package: PackageReport::from_materialized(&MaterializedPluginPackage { - package: packed.package, - files: materialized.files, - }), + package: PackageReport::from_package( + &packed.package, + &packed.artifact_digest, + packed.artifact_size, + ), safety: AuthoringSafetyReport::default(), }; if args.json { @@ -229,25 +232,19 @@ fn build_check_report(input: &Path) -> CheckReport { read_plugin_package_file(input, PluginSourceKind::Explicit, &limits) }; match result { - Ok(materialized) => { - let placeholder_diagnostic = placeholder_component_diagnostic(&materialized); - let status = if placeholder_diagnostic.is_some() { - "partial" - } else { - "verified" - }; - let diagnostics = placeholder_diagnostic.into_iter().collect(); - CheckReport { - command: "check", - status, - input_path: input.display().to_string(), - input_kind, - package: Some(PackageReport::from_materialized(&materialized)), - diagnostics, - safety: AuthoringSafetyReport::default(), - next_steps: check_next_steps(status), - } - } + Ok(materialized) => CheckReport { + command: "check", + status: "verified", + input_path: input.display().to_string(), + input_kind, + package: Some(PackageReport::from_materialized(&materialized)), + diagnostics: Vec::new(), + safety: AuthoringSafetyReport::default(), + next_steps: vec![ + "Package schemas, actual Component imports/exports, and canonical archive metadata are statically valid; no Plugin code was executed.".to_string(), + "Static validation does not establish Host implementation, HTTP availability, installation, or Worker execution support.".to_string(), + ], + }, Err(diagnostic) => CheckReport { command: "check", status: "rejected", @@ -263,46 +260,6 @@ fn build_check_report(input: &Path) -> CheckReport { } } -fn placeholder_component_diagnostic( - materialized: &MaterializedPluginPackage, -) -> Option { - 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 { - 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 ` again to verify the offline package.".to_string(), - ], - _ => vec!["Fix the reported diagnostics before packing this Plugin.".to_string()], - } -} - fn plugin_diagnostic_error(context: &str, diagnostic: PluginDiagnostic) -> String { format!("{context} failed: {}", diagnostic.message) } @@ -339,18 +296,19 @@ fn render_check_human(report: &CheckReport) -> Result { if let Some(package) = &report.package { writeln!( out, - "package: {} version={} digest={} entries={} source={} surfaces={} tools={}", + "package: {} version={} package_digest={} entries={} source={} surfaces={} tools={}", package.reference, package.version, - package.digest, + package.package_digest, package.entries.len(), package.source, join_or_none(&package.surfaces), package.tools.len() )?; + render_artifact_human(&mut out, package)?; writeln!( out, - "installation: unsupported; this command only inspects the explicit input path" + "scope: static validation of the explicit input path only; no Host implementation or execution support is established" )?; } if report.diagnostics.is_empty() { @@ -385,12 +343,13 @@ fn render_pack_human(report: &PackReport) -> Result { )?; writeln!( out, - "package: {} version={} digest={} entries={}", + "package: {} version={} package_digest={} entries={}", report.package.reference, report.package.version, - report.package.digest, + report.package.package_digest, report.package.entries.len() )?; + render_artifact_human(&mut out, &report.package)?; writeln!( out, "safety: deterministic stored .yoi-plugin archive; no Plugin execution; no config mutation" @@ -398,6 +357,26 @@ fn render_pack_human(report: &PackReport) -> Result { Ok(out) } +fn render_artifact_human(out: &mut String, package: &PackageReport) -> Result<()> { + writeln!( + out, + "canonical archive: artifact_digest={} artifact_size={} bytes", + package.artifact_digest, package.artifact_size + )?; + writeln!(out, "component world: {}", package.component.world)?; + writeln!( + out, + "component imports: {}", + join_or_none(&package.component.imports) + )?; + writeln!( + out, + "component exports: {}", + join_or_none(&package.component.exports) + )?; + Ok(()) +} + #[derive(Serialize)] struct AuthoringSafetyReport { no_network: bool, @@ -456,50 +435,75 @@ struct PackageReport { source: String, version: String, schema_version: u32, - digest: String, + package_digest: String, + artifact_digest: String, + artifact_size: u64, input_path: String, entries: Vec, surfaces: Vec, tools: Vec, - permissions: Vec, + runtime: PluginRuntimeManifest, + component: ComponentReport, + connections: Vec, + requested_grants: Vec, } impl PackageReport { fn from_materialized(materialized: &MaterializedPluginPackage) -> Self { + Self::from_package( + &materialized.package, + &materialized.artifact_digest, + materialized.artifact_size, + ) + } + + fn from_package( + package: &InspectedPluginPackage, + artifact_digest: &str, + artifact_size: u64, + ) -> Self { Self { - reference: package_reference(&materialized.package.identity), - package: materialized.package.manifest.id.clone(), - source: materialized.package.identity.source.to_string(), - version: materialized.package.manifest.version.clone(), - schema_version: materialized.package.manifest.schema_version, - digest: materialized.package.digest.clone(), - input_path: materialized.package.input_path.display().to_string(), - entries: materialized.package.entries.iter().cloned().collect(), - surfaces: materialized - .package + reference: package_reference(&package.identity), + package: package.manifest.id.clone(), + source: package.identity.source.to_string(), + version: package.manifest.version.clone(), + schema_version: package.manifest.schema_version, + package_digest: package.package_digest.clone(), + artifact_digest: artifact_digest.to_string(), + artifact_size, + input_path: package.input_path.display().to_string(), + entries: package.entries.iter().cloned().collect(), + surfaces: package .manifest .surfaces .iter() .map(ToString::to_string) .collect(), - tools: materialized - .package + tools: package .manifest .tools .iter() .map(|tool| tool.name.clone()) .collect(), - permissions: materialized - .package - .manifest - .permissions - .iter() - .map(|permission| permission_name(permission.clone()).to_string()) - .collect(), + runtime: package.manifest.runtime.clone(), + component: ComponentReport { + world: package.component.world.clone(), + imports: package.component.imports.clone(), + exports: package.component.exports.clone(), + }, + connections: package.manifest.connections.clone(), + requested_grants: package.manifest.requested_grants.clone(), } } } +#[derive(Serialize)] +struct ComponentReport { + world: String, + imports: Vec, + exports: Vec, +} + #[derive(Serialize)] struct PluginDiagnosticReport { kind: String, @@ -528,10 +532,6 @@ fn package_reference(identity: &SourceQualifiedPluginId) -> String { identity.to_string() } -fn permission_name(permission: PluginPermission) -> String { - permission.label() -} - fn join_or_none(values: &[String]) -> String { if values.is_empty() { "(none)".to_string() @@ -571,23 +571,240 @@ mod tests { use super::*; use tempfile::tempdir; + fn built_tool_package(destination: &Path) { + render_new( + "rust-component-tool", + destination, + &PluginCliArgs::default(), + ) + .unwrap(); + fs::write( + destination.join("plugin.component.wasm"), + include_bytes!("../../plugin-pdk/tests/fixtures/tool-v2.component.wasm"), + ) + .unwrap(); + } + #[test] - fn explicit_template_check_and_pack_remain_offline_authoring_operations() { + fn tool_templates_explain_actual_component_build_without_shipping_a_placeholder() { + let dir = tempdir().unwrap(); + for template in ["rust-component-tool", "rust-component-https-tool"] { + let destination = dir.path().join(template); + let rendered = + render_new(template, &destination, &PluginCliArgs { json: true }).unwrap(); + let report: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(report["template"], template); + assert!(destination.join("plugin.toml").is_file()); + assert!(destination.join("src/lib.rs").is_file()); + assert!(!destination.join("plugin.component.wasm").exists()); + let steps = report["next_steps"].to_string(); + assert!(steps.contains("yoi-plugin-pdk")); + assert!(steps.contains( + "cargo build --release --target wasm32-unknown-unknown --target-dir ../guest-target" + )); + assert!(steps.contains("wasm-tools component new")); + let cargo: toml::Value = + toml::from_str(&fs::read_to_string(destination.join("Cargo.toml")).unwrap()) + .unwrap(); + let core_name = cargo["package"]["name"].as_str().unwrap().replace('-', "_"); + assert!(steps.contains(&format!( + "wasm-tools component new ../guest-target/wasm32-unknown-unknown/release/{core_name}.wasm -o plugin.component.wasm" + ))); + assert!(steps.contains("does not establish Host implementation")); + assert_eq!(report["safety"]["no_plugin_execution"], true); + } + } + + #[test] + fn unsupported_templates_are_rejected_before_creating_a_destination() { + let dir = tempdir().unwrap(); + let destination = dir.path().join("unsupported"); + for template in [ + "rust-component-service", + "rust-component-instance", + "unknown", + ] { + let error = render_new(template, &destination, &PluginCliArgs::default()).unwrap_err(); + assert!(error.to_string().contains("unsupported plugin template")); + assert!(!destination.exists()); + } + } + + #[test] + fn unbuilt_and_noncomponent_templates_are_rejected_not_partially_verified_or_packed() { + let dir = tempdir().unwrap(); + let source = dir.path().join("unbuilt"); + let output = dir.path().join("rejected.yoi-plugin"); + let args = PluginCliArgs::default(); + render_new("rust-component-tool", &source, &args).unwrap(); + let missing = build_check_report(&source); + assert_eq!(missing.status, "rejected"); + assert!(missing.package.is_none()); + assert!(!missing.diagnostics.is_empty()); + assert!(render_pack(&source, Some(&output), &args).is_err()); + assert!(!output.exists()); + + for bytes in [b"placeholder".as_slice(), b"\0asm\x01\0\0\0".as_slice()] { + fs::write(source.join("plugin.component.wasm"), bytes).unwrap(); + let rejected = build_check_report(&source); + assert_eq!(rejected.status, "rejected"); + assert!(rejected.package.is_none()); + assert!(!rejected.diagnostics.is_empty()); + let human = render_check_report(&rejected, &args).unwrap(); + assert!(human.contains("[rejected]")); + assert!(human.contains("diagnostics:")); + assert!(render_pack(&source, Some(&output), &args).is_err()); + assert!(!output.exists()); + } + } + + #[test] + fn actual_component_check_pack_and_readback_report_both_digests_and_canonical_size() { let dir = tempdir().unwrap(); let source = dir.path().join("example"); let output = dir.path().join("example.yoi-plugin"); - let args = PluginCliArgs::default(); + let second_output = dir.path().join("second.yoi-plugin"); + let args = PluginCliArgs { json: true }; + built_tool_package(&source); - let created = render_new("rust-component-tool", &source, &args).unwrap(); - assert!(created.contains("created plugin template")); + let checked: serde_json::Value = + serde_json::from_str(&render_check(&source, &args).unwrap()).unwrap(); + assert_eq!(checked["status"], "verified"); + assert_eq!(checked["package"]["schema_version"], 2); + assert_eq!(checked["safety"]["no_network"], true); + assert_eq!(checked["safety"]["no_plugin_execution"], true); + assert!(checked["package"].get("digest").is_none()); + assert!(checked["package"].get("permissions").is_none()); + assert_eq!( + checked["package"]["component"]["imports"], + serde_json::json!([]) + ); + assert_eq!( + checked["package"]["component"]["exports"], + serde_json::json!(["yoi:plugin/tool@2.0.0"]) + ); + assert_eq!( + checked["package"]["runtime"]["world"], + "yoi:plugin/tool-plugin@2.0.0" + ); + assert_ne!( + checked["package"]["package_digest"], + checked["package"]["artifact_digest"] + ); + for field in ["package_digest", "artifact_digest"] { + assert!( + checked["package"][field] + .as_str() + .unwrap() + .starts_with("sha256:") + ); + } - let checked = render_check(&source, &args).unwrap(); - assert!(checked.contains("[partial]")); - assert!(checked.contains("explicit input path")); + let packed: serde_json::Value = + serde_json::from_str(&render_pack(&source, Some(&output), &args).unwrap()).unwrap(); + assert_eq!(packed["status"], "packed"); + let readback: serde_json::Value = + serde_json::from_str(&render_check(&output, &args).unwrap()).unwrap(); + assert_eq!(readback["status"], "verified"); + for field in [ + "package_digest", + "artifact_digest", + "artifact_size", + "component", + ] { + assert_eq!(checked["package"][field], packed["package"][field]); + assert_eq!(checked["package"][field], readback["package"][field]); + } + assert_eq!( + packed["package"]["artifact_size"].as_u64().unwrap(), + fs::metadata(&output).unwrap().len() + ); + render_pack(&source, Some(&second_output), &args).unwrap(); + assert_eq!( + fs::read(&output).unwrap(), + fs::read(&second_output).unwrap() + ); - let packed = render_pack(&source, Some(&output), &args).unwrap(); - assert!(packed.contains("[packed]")); - assert!(output.is_file()); + let human = render_check(&output, &PluginCliArgs::default()).unwrap(); + assert!(human.contains("package_digest=sha256:")); + assert!(human.contains("artifact_digest=sha256:")); + assert!(human.contains("artifact_size=")); + assert!(human.contains("static validation of the explicit input path only")); + let human_pack = render_pack(&source, Some(&output), &PluginCliArgs::default()).unwrap(); + assert!(human_pack.contains("package_digest=sha256:")); + assert!(human_pack.contains("artifact_digest=sha256:")); + assert!(human_pack.contains("artifact_size=")); + } + + #[test] + fn https_component_reports_static_requests_not_host_execution_support() { + let dir = tempdir().unwrap(); + let source = dir.path().join("https"); + let output = dir.path().join("https.yoi-plugin"); + let args = PluginCliArgs { json: true }; + render_new("rust-component-https-tool", &source, &args).unwrap(); + fs::write( + source.join("plugin.component.wasm"), + include_bytes!("../../plugin-pdk/tests/fixtures/https-tool-v2.component.wasm"), + ) + .unwrap(); + let report: serde_json::Value = + serde_json::from_str(&render_check(&source, &args).unwrap()).unwrap(); + assert_eq!(report["status"], "verified"); + assert_eq!( + report["package"]["component"]["world"], + "yoi:plugin/https-tool-plugin@2.0.0" + ); + assert_eq!( + report["package"]["component"]["imports"], + serde_json::json!([ + "yoi:host/connections@2.0.0", + "yoi:host/errors@2.0.0", + "yoi:host/http@2.0.0" + ]) + ); + assert_eq!(report["package"]["connections"][0]["name"], "api"); + assert_eq!( + report["package"]["requested_grants"], + serde_json::json!([ + { "kind": "https", "slot": "api" } + ]) + ); + assert!( + report["next_steps"] + .to_string() + .contains("does not establish Host implementation, HTTP availability") + ); + assert_eq!(report["safety"]["no_network"], true); + assert_eq!(report["safety"]["no_plugin_execution"], true); + render_pack(&source, Some(&output), &args).unwrap(); + assert_eq!(build_check_report(&output).status, "verified"); + } + + #[test] + fn unsupported_surface_and_legacy_schema_diagnostics_reach_cli_reports() { + let dir = tempdir().unwrap(); + let source = dir.path().join("invalid"); + built_tool_package(&source); + let manifest_path = source.join("plugin.toml"); + let manifest = fs::read_to_string(&manifest_path).unwrap(); + for invalid in [ + manifest.replace("schema_version = 2", "schema_version = 1"), + manifest.replace("surfaces = [\"tool\"]", "surfaces = [\"service\"]"), + ] { + assert_ne!(manifest, invalid); + fs::write(&manifest_path, invalid).unwrap(); + let rendered = render_check(&source, &PluginCliArgs { json: true }).unwrap(); + let report: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(report["status"], "rejected"); + assert!(report["package"].is_null()); + assert!( + !report["diagnostics"][0]["message"] + .as_str() + .unwrap() + .is_empty() + ); + } } #[test] diff --git a/devshell.nix b/devshell.nix index 28ff25612..e348a500b 100644 --- a/devshell.nix +++ b/devshell.nix @@ -7,6 +7,7 @@ pkgs.mkShell { playwright-driver.browsers rustc cargo + lld rustfmt pkgs.sccache ]; diff --git a/package.nix b/package.nix index 3f035b2f3..004642674 100644 --- a/package.nix +++ b/package.nix @@ -44,7 +44,7 @@ rustPlatform.buildRustPackage rec { }; # All four WIP SDK crates resolve from crates.io 0.2.0 via Cargo.lock checksums. - cargoHash = "sha256-L1RR+zdcVxMGvEonGxERgcvzjxzXro4n2j2syBeR/m0="; + cargoHash = "sha256-cLDfqFu6H9YjaL5EpTs5NE2GzkeS0qYcj5Ys/6U5u40="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint, diff --git a/resources/plugin/API.md b/resources/plugin/API.md index ecd371b36..6c1734386 100644 --- a/resources/plugin/API.md +++ b/resources/plugin/API.md @@ -1,6 +1,6 @@ # Workspace Plugin APIの設計契約 -本書はWorkspace管理のPluginに採用する設計を定める。実行形式はWebAssembly Component Model、engineはWasmtime、guest/Host境界はWITとする。実装済みAPIの利用ガイドではない。既存の[v1 Tool world](wit/yoi-plugin-tool-v1.wit)、[v1 instance world](wit/yoi-plugin-instance-v1.wit)、[v1 Host imports](wit/deps/yoi-host/yoi-host-v1.wit)、PDK、templateは旧v1のoffline authoring資産であり、本書のv2実行契約をまだ提供していない。 +本書はWorkspace管理のPluginに採用する設計を定める。実行形式はWebAssembly Component Model、engineはWasmtime、guest/Host境界はWITとする。実装済みAPIの利用ガイドではない。旧[v1 Tool world](legacy/wit/yoi-plugin-tool-v1.wit)、[v1 instance world](legacy/wit/yoi-plugin-instance-v1.wit)、[v1 Host imports](legacy/wit/deps/yoi-host/yoi-host-v1.wit)は歴史資料として隔離する。v2の機械的契約は[Tool worlds](wit/yoi-plugin-v2.wit)と[Host imports](wit/deps/yoi-host/yoi-host-v2.wit)で定める。PDKやtemplateのbuild成功はHost実装やWorker実行経路の完成を意味しない。 管理、guest実行、Server上の拡張データを横断する判断をWIT資産の近くで一か所に保持する。実装時には各wire型と境界testを契約の機械的な根拠にし、ここには所有境界・失敗保証・採用理由を残す。作業順と進捗の正本はO-4、T-469、T-465、T-533とする。 diff --git a/resources/plugin/generate-fixtures.sh b/resources/plugin/generate-fixtures.sh new file mode 100644 index 000000000..d7d836d27 --- /dev/null +++ b/resources/plugin/generate-fixtures.sh @@ -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 diff --git a/resources/plugin/wit/deps/yoi-host/yoi-host-v1.wit b/resources/plugin/legacy/wit/deps/yoi-host/yoi-host-v1.wit similarity index 100% rename from resources/plugin/wit/deps/yoi-host/yoi-host-v1.wit rename to resources/plugin/legacy/wit/deps/yoi-host/yoi-host-v1.wit diff --git a/resources/plugin/wit/yoi-plugin-instance-v1.wit b/resources/plugin/legacy/wit/yoi-plugin-instance-v1.wit similarity index 100% rename from resources/plugin/wit/yoi-plugin-instance-v1.wit rename to resources/plugin/legacy/wit/yoi-plugin-instance-v1.wit diff --git a/resources/plugin/wit/yoi-plugin-tool-v1.wit b/resources/plugin/legacy/wit/yoi-plugin-tool-v1.wit similarity index 100% rename from resources/plugin/wit/yoi-plugin-tool-v1.wit rename to resources/plugin/legacy/wit/yoi-plugin-tool-v1.wit diff --git a/resources/plugin/templates/rust-component-instance/Cargo.toml b/resources/plugin/templates/rust-component-https/Cargo.toml similarity index 91% rename from resources/plugin/templates/rust-component-instance/Cargo.toml rename to resources/plugin/templates/rust-component-https/Cargo.toml index 2ba3b8fc4..086861eb6 100644 --- a/resources/plugin/templates/rust-component-instance/Cargo.toml +++ b/resources/plugin/templates/rust-component-https/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "yoi-rust-component-service-template" +name = "yoi-rust-component-https-template" version = "0.1.0" edition = "2024" license = "MIT" @@ -14,7 +14,6 @@ crate-type = ["cdylib"] [dependencies] serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" } # Out-of-tree Plugin packages should replace the local path with a pinned diff --git a/resources/plugin/templates/rust-component-https/README.md b/resources/plugin/templates/rust-component-https/README.md new file mode 100644 index 000000000..640227935 --- /dev/null +++ b/resources/plugin/templates/rust-component-https/README.md @@ -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や実行権限を自己発行しない。 diff --git a/resources/plugin/templates/rust-component-https/plugin.toml b/resources/plugin/templates/rust-component-https/plugin.toml new file mode 100644 index 000000000..8c95aa80f --- /dev/null +++ b/resources/plugin/templates/rust-component-https/plugin.toml @@ -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"] diff --git a/resources/plugin/templates/rust-component-https/src/lib.rs b/resources/plugin/templates/rust-component-https/src/lib.rs new file mode 100644 index 000000000..0f1607a4a --- /dev/null +++ b/resources/plugin/templates/rust-component-https/src/lib.rs @@ -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 { + 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); diff --git a/resources/plugin/templates/rust-component-https/wit/deps/yoi-host/yoi-host-v2.wit b/resources/plugin/templates/rust-component-https/wit/deps/yoi-host/yoi-host-v2.wit new file mode 100644 index 000000000..c6f40d2c7 --- /dev/null +++ b/resources/plugin/templates/rust-component-https/wit/deps/yoi-host/yoi-host-v2.wit @@ -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; +} + +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, + headers: list, + body: list, + timeout-ms: u32, + effect-key: string, + } + record response { + status: u16, + headers: list, + body: list, + } + /// Host authorizes each operation and does not automatically retry or follow redirects. + send: func(target: borrow, input: request) -> result; +} + +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, + } + /// Bounded diagnostic data, not model context. Never include secrets or raw payloads. + log: func(input: entry) -> result<_, host-error>; +} diff --git a/resources/plugin/templates/rust-component-https/wit/yoi-plugin-v2.wit b/resources/plugin/templates/rust-component-https/wit/yoi-plugin-v2.wit new file mode 100644 index 000000000..9c7f3b02d --- /dev/null +++ b/resources/plugin/templates/rust-component-https/wit/yoi-plugin-v2.wit @@ -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, + structured-json: option, + } + record tool-error { + code: string, + message: string, + } + invoke: func(input: invocation) -> result; +} + +/// 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; +} diff --git a/resources/plugin/templates/rust-component-instance/README.md b/resources/plugin/templates/rust-component-instance/README.md deleted file mode 100644 index 7d0c2fd01..000000000 --- a/resources/plugin/templates/rust-component-instance/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Rust Component Instance Plugin雛形 - -提案段階の`yoi:plugin/instance@1.0.0`を対象にしたオフラインpackageの雛形である。YoiはPluginのインストールやWorker内での実行を提供していない。 - -Tool・Serviceの要求と応答は将来の実行境界を検討するための例である。host APIやgrantのmetadataは現在の製品では権限を付与せず、オフライン検査の成功も実行の許可を意味しない。 diff --git a/resources/plugin/templates/rust-component-instance/plugin.component.wasm b/resources/plugin/templates/rust-component-instance/plugin.component.wasm deleted file mode 100644 index 66d02db0a..000000000 --- a/resources/plugin/templates/rust-component-instance/plugin.component.wasm +++ /dev/null @@ -1,3 +0,0 @@ -# Build with: -# cargo component build --release -# cp target/wasm32-wasip1/release/example_yoi_instance_plugin.wasm plugin.component.wasm diff --git a/resources/plugin/templates/rust-component-instance/plugin.toml b/resources/plugin/templates/rust-component-instance/plugin.toml deleted file mode 100644 index 59495d374..000000000 --- a/resources/plugin/templates/rust-component-instance/plugin.toml +++ /dev/null @@ -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"] diff --git a/resources/plugin/templates/rust-component-instance/src/lib.rs b/resources/plugin/templates/rust-component-instance/src/lib.rs deleted file mode 100644 index 1a2750ca0..000000000 --- a/resources/plugin/templates/rust-component-instance/src/lib.rs +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - Ok(PluginStatus::ready(json!({ "count": self.count }))) - } -} - -export_plugin_instance!(ExamplePluginComponent, ExamplePlugin); diff --git a/resources/plugin/templates/rust-component-tool/README.md b/resources/plugin/templates/rust-component-tool/README.md index 506ff64ee..91bd6bcbb 100644 --- a/resources/plugin/templates/rust-component-tool/README.md +++ b/resources/plugin/templates/rust-component-tool/README.md @@ -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の認可・実行経路の完成は別の判断である。 diff --git a/resources/plugin/templates/rust-component-tool/plugin.component.wasm b/resources/plugin/templates/rust-component-tool/plugin.component.wasm deleted file mode 100644 index 6193fb382..000000000 --- a/resources/plugin/templates/rust-component-tool/plugin.component.wasm +++ /dev/null @@ -1 +0,0 @@ -placeholder component artifact for authoring-template checks; replace with a built wasm component before enabling. diff --git a/resources/plugin/templates/rust-component-tool/plugin.toml b/resources/plugin/templates/rust-component-tool/plugin.toml index 9c5192d0a..7bd09c91b 100644 --- a/resources/plugin/templates/rust-component-tool/plugin.toml +++ b/resources/plugin/templates/rust-component-tool/plugin.toml @@ -1,20 +1,21 @@ -schema_version = 1 +schema_version = 2 id = "example.rust_component_tool" name = "Rust Component Tool Template" version = "0.1.0" surfaces = ["tool"] -permissions = [ - { kind = "surface", surface = "tool" }, - { kind = "tool", name = "example_echo" }, -] +config_schema = "{}" +requested_grants = [] [runtime] kind = "wasm-component" component = "plugin.component.wasm" -world = "yoi:plugin/tool@1.0.0" +world = "yoi:plugin/tool-plugin@2.0.0" +required_imports = [] [[tools]] name = "example_echo" description = "Echo input text using the Rust PDK." input_schema = { type = "object", properties = { text = { type = "string" } }, required = ["text"], additionalProperties = false } +output_schema = { type = "object", properties = { tool = { type = "string" }, text = { type = "string" } }, required = ["tool", "text"], additionalProperties = false } +error_codes = ["invalid_input", "invalid_output", "serialization", "unsupported_tool", "failed"] external_write = false diff --git a/resources/plugin/templates/rust-component-tool/src/lib.rs b/resources/plugin/templates/rust-component-tool/src/lib.rs index c954aedc5..f5ff9d116 100644 --- a/resources/plugin/templates/rust-component-tool/src/lib.rs +++ b/resources/plugin/templates/rust-component-tool/src/lib.rs @@ -3,13 +3,14 @@ use yoi_plugin_pdk::wit_bindgen; use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput}; wit_bindgen::generate!({ - world: "tool", - path: "../../../../resources/plugin/wit", + world: "tool-plugin", + path: "wit", generate_all, runtime_path: "yoi_plugin_pdk::wit_bindgen::rt", }); #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct EchoInput { text: String, } @@ -21,6 +22,9 @@ struct EchoOutput<'a> { } fn handle_echo(ctx: ToolContext, input: EchoInput) -> Result { + if ctx.tool_name() != "example_echo" { + return Err(ToolError::unsupported_tool(ctx.tool_name())); + } if input.text.trim().is_empty() { return Err(ToolError::invalid_input("`text` must not be empty")); } diff --git a/resources/plugin/templates/rust-component-tool/wit/deps/yoi-host/yoi-host-v2.wit b/resources/plugin/templates/rust-component-tool/wit/deps/yoi-host/yoi-host-v2.wit new file mode 100644 index 000000000..c6f40d2c7 --- /dev/null +++ b/resources/plugin/templates/rust-component-tool/wit/deps/yoi-host/yoi-host-v2.wit @@ -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; +} + +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, + headers: list, + body: list, + timeout-ms: u32, + effect-key: string, + } + record response { + status: u16, + headers: list, + body: list, + } + /// Host authorizes each operation and does not automatically retry or follow redirects. + send: func(target: borrow, input: request) -> result; +} + +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, + } + /// Bounded diagnostic data, not model context. Never include secrets or raw payloads. + log: func(input: entry) -> result<_, host-error>; +} diff --git a/resources/plugin/templates/rust-component-tool/wit/yoi-plugin-v2.wit b/resources/plugin/templates/rust-component-tool/wit/yoi-plugin-v2.wit new file mode 100644 index 000000000..9c7f3b02d --- /dev/null +++ b/resources/plugin/templates/rust-component-tool/wit/yoi-plugin-v2.wit @@ -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, + structured-json: option, + } + record tool-error { + code: string, + message: string, + } + invoke: func(input: invocation) -> result; +} + +/// 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; +} diff --git a/resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit b/resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit new file mode 100644 index 000000000..c6f40d2c7 --- /dev/null +++ b/resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit @@ -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; +} + +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, + headers: list, + body: list, + timeout-ms: u32, + effect-key: string, + } + record response { + status: u16, + headers: list, + body: list, + } + /// Host authorizes each operation and does not automatically retry or follow redirects. + send: func(target: borrow, input: request) -> result; +} + +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, + } + /// Bounded diagnostic data, not model context. Never include secrets or raw payloads. + log: func(input: entry) -> result<_, host-error>; +} diff --git a/resources/plugin/wit/yoi-plugin-v2.wit b/resources/plugin/wit/yoi-plugin-v2.wit new file mode 100644 index 000000000..9c7f3b02d --- /dev/null +++ b/resources/plugin/wit/yoi-plugin-v2.wit @@ -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, + structured-json: option, + } + record tool-error { + code: string, + message: string, + } + invoke: func(input: invocation) -> result; +} + +/// 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; +}