From 57bbf14e1a03b72a8cf24913263cac4137985177 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 20 Jun 2026 01:58:53 +0900 Subject: [PATCH] plugin: implement component model runtime --- Cargo.lock | 648 +++++++++++++++++++- crates/manifest/src/plugin.rs | 283 ++++++++- crates/pod/Cargo.toml | 1 + crates/pod/src/feature/plugin.rs | 585 +++++++++++++++++- docs/design/plugin-component-model.md | 44 ++ docs/design/plugin-packages.md | 26 + docs/examples/plugin-component-tool/lib.rs | 23 + package.nix | 2 +- resources/plugin/wit/yoi-host-v1.wit | 15 + resources/plugin/wit/yoi-plugin-tool-v1.wit | 11 + 10 files changed, 1569 insertions(+), 69 deletions(-) create mode 100644 docs/examples/plugin-component-tool/lib.rs create mode 100644 resources/plugin/wit/yoi-host-v1.wit create mode 100644 resources/plugin/wit/yoi-plugin-tool-v1.wit diff --git a/Cargo.lock b/Cargo.lock index 65bb4de8..1789ed70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +dependencies = [ + "gimli", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -82,6 +91,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + [[package]] name = "arc-swap" version = "1.9.1" @@ -222,6 +237,9 @@ name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +dependencies = [ + "allocator-api2", +] [[package]] name = "bytemuck" @@ -351,6 +369,15 @@ dependencies = [ "cc", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -422,6 +449,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -440,6 +476,157 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-assembler-x64" +version = "0.132.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bc293b86236abcc45f2f72e2d18e2bd636f2a08b75eb286bae31e71e1430c91" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.132.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b954c826eddaf1b001402cb8aecf1764c6f6d637ba69fb9e3311f1ebac965be6" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.132.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4053fa2575ef4a5c35d2708533df2200400ae979226cea9cc92a578b811bd4e7" +dependencies = [ + "cranelift-entity", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-bitset" +version = "0.132.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d216663191014aa63e1d2cffd058e609eaf207646d40b739d88250f65b2c4f69" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen" +version = "0.132.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a5e7e7aad6a425a51da1ad7ab9e5d280ea97eb7c7c4545fafb567915a75aadb" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.17.1", + "libm", + "log", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "smallvec", + "target-lexicon", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.132.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c421d80a9a85f806cb02a2983b5b5368a335c319795b1f1b4b771a24479af5b0" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.132.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78fdb83ab012d0ee6a44ced7ca8788a444f17cf821c62f95d6ef87c9f0262518" + +[[package]] +name = "cranelift-control" +version = "0.132.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b75adc6eb7bb4ac6365106afb6cac4f12fe1ddfa02ddc9fd7015ca1469b471b" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.132.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668e56db75a54816cbdd7c7b7bfc558b08bf7b2cda9d0846491517e92f3b393b" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-frontend" +version = "0.132.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c63892dc1cc3ae48680183fa66997f60ffe7f1e200c8d390f8ee66edff4aef5a" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.132.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94eaf429c32a12715429c7c6ddfdd43c170f4cdd7e97bfa507bd68a652091087" + +[[package]] +name = "cranelift-native" +version = "0.132.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd77674904ae9be11c1e1efdba54788b59f3d6658d747b97534bfbba2909aacc" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.132.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cba7c0ff5941842c36653da155580ce41e675c204a67ac1b4e1c478a9347bbb7" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -700,6 +887,18 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1027,6 +1226,18 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap", + "stable_deref_trait", +] + [[package]] name = "glob" version = "0.3.3" @@ -1122,6 +1333,17 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] + [[package]] name = "heck" version = "0.5.0" @@ -1463,12 +1685,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1793,6 +2015,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "manifest" version = "0.1.0" @@ -1852,6 +2083,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix 1.1.4", +] + [[package]] name = "memmap2" version = "0.9.10" @@ -2077,6 +2317,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f" +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "crc32fast", + "hashbrown 0.17.1", + "indexmap", + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2368,6 +2620,7 @@ dependencies = [ "tracing", "uuid", "wasmi", + "wasmtime", "wat", "workflow", ] @@ -2403,6 +2656,18 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2499,6 +2764,29 @@ dependencies = [ "unicase", ] +[[package]] +name = "pulley-interpreter" +version = "45.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d9880c1985ccccaed3646b0ef793dc39a4b117403ed4afc6fa3ef6027c5200f" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "45.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee249346855ad102580e474da5463f86f8a7d449e6d49e00fefb304e448e2983" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2770,6 +3058,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "regalloc2" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.17.1", + "log", + "rustc-hash", + "smallvec", +] + [[package]] name = "regex" version = "1.12.3" @@ -2860,6 +3162,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3091,6 +3399,10 @@ name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -3341,6 +3653,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -3501,6 +3816,12 @@ dependencies = [ "libc", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "target-triple" version = "1.0.0" @@ -4260,12 +4581,22 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.246.2" +version = "0.248.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61fb705ce81adde29d2a8e99d87995e39a6e927358c91398f374474746070ef7" +checksum = "ac92cf547bc18d27ecc521015c08c353b4f18b84ab388bb6d1b6b682c620d9b6" dependencies = [ "leb128fmt", - "wasmparser 0.246.2", + "wasmparser 0.248.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f" +dependencies = [ + "leb128fmt", + "wasmparser 0.252.0", ] [[package]] @@ -4357,9 +4688,22 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.246.2" +version = "0.248.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71cde4757396defafd25417cfb36aa3161027d06d865b0c24baaae229aac005d" +checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.17.1", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" dependencies = [ "bitflags 2.11.0", "indexmap", @@ -4367,23 +4711,257 @@ dependencies = [ ] [[package]] -name = "wast" -version = "246.0.2" +name = "wasmprinter" +version = "0.248.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe3fe8e3bf88ad96d031b4181ddbd64634b17cb0d06dfc3de589ef43591a9a62" +checksum = "30b264a5410b008d4d199a92bf536eae703cbd614482fc1ec53831cf19e1c183" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.248.0", +] + +[[package]] +name = "wasmtime" +version = "45.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7ce9aa2c67f75fadcfdc6aa9097d03e7c39485dfe316f2ed6a7c0fd186c527" +dependencies = [ + "addr2line", + "async-trait", + "bitflags 2.11.0", + "bumpalo", + "cc", + "cfg-if", + "encoding_rs", + "libc", + "log", + "mach2", + "memfd", + "object", + "once_cell", + "postcard", + "pulley-interpreter", + "rustix 1.1.4", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasmparser 0.248.0", + "wasmtime-environ", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wasmtime-internal-winch", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-environ" +version = "45.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fb157bd1fbf689ac89d570433a700db6f33bdfcb5ffc30e3f1c49e4c70de71" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "hashbrown 0.17.1", + "indexmap", + "log", + "object", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "sha2 0.10.9", + "smallvec", + "target-lexicon", + "wasm-encoder 0.248.0", + "wasmparser 0.248.0", + "wasmprinter", + "wasmtime-internal-component-util", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "45.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96c17f35fae2ab574667aba0c58fd56349a6f788ac42541a2e543116d5cfb91" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser 0.248.0", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "45.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d2eeb9b53222859e6f5dc73d2ccfb33254d672469cac11b693a71912e2f3817" + +[[package]] +name = "wasmtime-internal-core" +version = "45.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1deaf6bc3430abd7497b00c64f06ca2b97ca0fe41af87836446ca30949965c" +dependencies = [ + "hashbrown 0.17.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "45.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b845f83b5b04b11bc48329b53eb4fa8cf9f28a43c71ed8e1203f68ffa9806d1b" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools", + "log", + "object", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.248.0", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "45.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10c8466f72965ae85c250f90aaa7992c089a2f8502009bd0d2c9e7d6409174a" +dependencies = [ + "cc", + "cfg-if", + "libc", + "rustix 1.1.4", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "45.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d3adfecf5621b14d8f8871f4cb4ed9f844197b1ddefc702ef4c859552cd9551" +dependencies = [ + "cc", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "45.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d3c1e9fb618ec45c9b3477ea683cd37bee427273d7b13bba5c66a1caaf1dd6" +dependencies = [ + "cfg-if", + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-unwinder" +version = "45.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa91132b81f1e172ec7e7c3c114ac34209ee6b3524b3a8d6943af99803f66c5" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "log", + "object", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "45.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea811ffe23f597cc7708327ea25d9eb018dcf760ffe15ccb7d0b27ad635de61" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "wasmtime-internal-winch" +version = "45.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "828b66175c54a0d00b4c1c1c76658d8aa73aeb9fa3553575c5eee56d40f2eb18" +dependencies = [ + "cranelift-codegen", + "gimli", + "log", + "object", + "target-lexicon", + "wasmparser 0.248.0", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "winch-codegen", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "45.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae00896ad9bef1b3ca6401ae9a841daa6f357dd91541b6baf87082946d1bde1" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "heck", + "indexmap", + "wit-parser 0.248.0", +] + +[[package]] +name = "wast" +version = "252.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "942a3449d6a593fccc111a6241c8df52bda168af30e40bf9580d4394d7374c65" dependencies = [ "bumpalo", "leb128fmt", "memchr", "unicode-width", - "wasm-encoder 0.246.2", + "wasm-encoder 0.252.0", ] [[package]] name = "wat" -version = "1.246.2" +version = "1.252.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd7fda1199b94fff395c2d19a153f05dbe7807630316fa9673367666fd2ad8c" +checksum = "c72a4ba7088f7bac94cf516e49882bdf97068904a563768cf249efc839ec42cb" dependencies = [ "wast", ] @@ -4529,6 +5107,25 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "winch-codegen" +version = "45.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89c09acfdfa281b3340e1e94ef3cf6618d69eab975280f881e154c29f49419c1" +dependencies = [ + "cranelift-assembler-x64", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.248.0", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -4802,7 +5399,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck", - "wit-parser", + "wit-parser 0.244.0", ] [[package]] @@ -4852,7 +5449,7 @@ dependencies = [ "wasm-encoder 0.244.0", "wasm-metadata", "wasmparser 0.244.0", - "wit-parser", + "wit-parser 0.244.0", ] [[package]] @@ -4873,6 +5470,25 @@ dependencies = [ "wasmparser 0.244.0", ] +[[package]] +name = "wit-parser" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "247ad505da2915a082fe13204c5ba8788425aea1de54f43b284818cf82637856" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.248.0", +] + [[package]] name = "workflow" version = "0.1.0" diff --git a/crates/manifest/src/plugin.rs b/crates/manifest/src/plugin.rs index 0fc9d8ab..338620de 100644 --- a/crates/manifest/src/plugin.rs +++ b/crates/manifest/src/plugin.rs @@ -378,12 +378,28 @@ impl PluginPackageManifest { } } +pub const PLUGIN_RUNTIME_WASM_KIND: &str = "wasm"; +pub const PLUGIN_RUNTIME_WASM_ABI: &str = "yoi-plugin-wasm-1"; +/// Manifest runtime kind for WebAssembly Component Model Tool packages. +/// +/// Component runtime manifests must set `component` to the packaged component +/// artifact path and `world` to [`PLUGIN_COMPONENT_TOOL_WORLD`]. Raw core-Wasm +/// packages remain explicit `kind = "wasm"` plus `abi = "yoi-plugin-wasm-1"`. +pub const PLUGIN_RUNTIME_COMPONENT_KIND: &str = "wasm-component"; +pub const PLUGIN_COMPONENT_TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0"; + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PluginRuntimeManifest { pub kind: String, - pub entry: 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)] @@ -837,7 +853,7 @@ pub fn read_resolved_plugin_runtime_module( .with_digest(&record.digest) })?; - if runtime.kind != "wasm" { + if runtime.kind != PLUGIN_RUNTIME_WASM_KIND { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Api, PluginDiagnosticPhase::Manifest, @@ -848,7 +864,7 @@ pub fn read_resolved_plugin_runtime_module( .with_package(&record.package_label) .with_digest(&record.digest)); } - if runtime.abi.as_deref() != Some("yoi-plugin-wasm-1") { + if runtime.abi.as_deref() != Some(PLUGIN_RUNTIME_WASM_ABI) { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Api, PluginDiagnosticPhase::Manifest, @@ -860,6 +876,18 @@ pub fn read_resolved_plugin_runtime_module( .with_digest(&record.digest)); } + let entry = runtime.entry.as_deref().ok_or_else(|| { + PluginDiagnostic::new( + PluginDiagnosticKind::Missing, + PluginDiagnosticPhase::Manifest, + "plugin WASM runtime entry is required", + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(&record.digest) + })?; + let metadata = fs::metadata(&record.package_path).map_err(|error| { PluginDiagnostic::new( PluginDiagnosticKind::Io, @@ -926,13 +954,13 @@ pub fn read_resolved_plugin_runtime_module( } validate_manifest_path( - &runtime.entry, + entry, &archive, &record.package_label, record.source, &record.manifest.id, )?; - let normalized = normalize_archive_path(&runtime.entry).ok_or_else(|| { + let normalized = normalize_archive_path(entry).ok_or_else(|| { PluginDiagnostic::new( PluginDiagnosticKind::Traversal, PluginDiagnosticPhase::Manifest, @@ -956,6 +984,154 @@ pub fn read_resolved_plugin_runtime_module( }) } +/// Reads the WebAssembly Component Model artifact selected by a resolved plugin +/// package manifest while preserving package digest pinning. +pub fn read_resolved_plugin_runtime_component( + record: &ResolvedPluginRecord, + limits: &PluginDiscoveryLimits, +) -> Result, PluginDiagnostic> { + let runtime = record.manifest.runtime.as_ref().ok_or_else(|| { + PluginDiagnostic::new( + PluginDiagnosticKind::Missing, + PluginDiagnosticPhase::Manifest, + "resolved plugin package does not declare a component runtime", + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(&record.digest) + })?; + + if runtime.kind != PLUGIN_RUNTIME_COMPONENT_KIND { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Api, + PluginDiagnosticPhase::Manifest, + "plugin runtime kind is unsupported", + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(&record.digest)); + } + if runtime.world.as_deref() != Some(PLUGIN_COMPONENT_TOOL_WORLD) { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Api, + PluginDiagnosticPhase::Manifest, + "plugin component world is unsupported", + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(&record.digest)); + } + let component = runtime.component.as_deref().ok_or_else(|| { + PluginDiagnostic::new( + PluginDiagnosticKind::Missing, + PluginDiagnosticPhase::Manifest, + "plugin component runtime artifact is required", + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(&record.digest) + })?; + + let metadata = fs::metadata(&record.package_path).map_err(|error| { + PluginDiagnostic::new( + PluginDiagnosticKind::Io, + PluginDiagnosticPhase::Discovery, + format!( + "resolved plugin package metadata could not be read: {}", + safe_io_error(&error) + ), + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(&record.digest) + })?; + if !metadata.is_file() { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Malformed, + PluginDiagnosticPhase::Discovery, + "resolved plugin package is not a regular file", + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(&record.digest)); + } + if metadata.len() > limits.max_package_size_bytes { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Bounds, + PluginDiagnosticPhase::Discovery, + "resolved plugin package exceeds the configured package size bound", + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(&record.digest)); + } + + let bytes = fs::read(&record.package_path).map_err(|error| { + PluginDiagnostic::new( + PluginDiagnosticKind::Io, + PluginDiagnosticPhase::Discovery, + format!( + "resolved plugin package content could not be read: {}", + safe_io_error(&error) + ), + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(&record.digest) + })?; + let archive = parse_stored_zip(&bytes, &record.package_label, record.source, limits)?; + let actual_digest = deterministic_digest(&archive.files); + if !digest_matches(&record.digest, &actual_digest) { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Digest, + PluginDiagnosticPhase::Resolution, + "resolved plugin package digest does not match current package content", + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(actual_digest)); + } + + validate_manifest_path( + component, + &archive, + &record.package_label, + record.source, + &record.manifest.id, + )?; + let normalized = normalize_archive_path(component).ok_or_else(|| { + PluginDiagnostic::new( + PluginDiagnosticKind::Traversal, + PluginDiagnosticPhase::Manifest, + "plugin manifest references a path outside the package root", + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(&record.digest) + })?; + archive.files.get(&normalized).cloned().ok_or_else(|| { + PluginDiagnostic::new( + PluginDiagnosticKind::Missing, + PluginDiagnosticPhase::Manifest, + "plugin runtime component artifact is missing from the package", + ) + .with_source(record.source) + .with_identity(&record.identity) + .with_package(&record.package_label) + .with_digest(&record.digest) + }) +} + #[derive(Clone, Debug)] struct PluginStore { source: PluginSourceKind, @@ -1237,27 +1413,84 @@ fn validate_manifest( .with_package(label)); } if let Some(runtime) = &manifest.runtime { - if runtime.kind != "wasm" { - 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)); + match runtime.kind.as_str() { + PLUGIN_RUNTIME_WASM_KIND => { + if runtime.abi.as_deref() != Some(PLUGIN_RUNTIME_WASM_ABI) { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Api, + PluginDiagnosticPhase::Manifest, + "plugin WASM ABI is unsupported", + ) + .with_source(source) + .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) + .with_package(label)); + } + let Some(entry) = runtime.entry.as_deref() else { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Missing, + PluginDiagnosticPhase::Manifest, + "plugin WASM runtime entry is required", + ) + .with_source(source) + .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) + .with_package(label)); + }; + if runtime.component.is_some() || runtime.world.is_some() { + return Err(PluginDiagnostic::new( + PluginDiagnosticKind::Malformed, + PluginDiagnosticPhase::Manifest, + "plugin WASM runtime must not declare component metadata", + ) + .with_source(source) + .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) + .with_package(label)); + } + validate_manifest_path(entry, archive, label, source, &manifest.id)?; + } + 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 runtime.world.as_deref() != Some(PLUGIN_COMPONENT_TOOL_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)); + } } - if runtime.abi.as_deref() != Some("yoi-plugin-wasm-1") { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Api, - PluginDiagnosticPhase::Manifest, - "plugin WASM ABI is unsupported", - ) - .with_source(source) - .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) - .with_package(label)); - } - validate_manifest_path(&runtime.entry, archive, label, source, &manifest.id)?; } for hook in &manifest.hooks { if !is_safe_id(&hook.id) { diff --git a/crates/pod/Cargo.toml b/crates/pod/Cargo.toml index d56bfc07..6bb0a8d6 100644 --- a/crates/pod/Cargo.toml +++ b/crates/pod/Cargo.toml @@ -37,6 +37,7 @@ uuid = { workspace = true, features = ["v7"] } session-metrics = { workspace = true } arc-swap = "1.9.1" wasmi = { version = "0.51.1", default-features = false, features = ["std", "extra-checks"] } +wasmtime = { version = "45.0.2", default-features = false, features = ["std", "runtime", "cranelift", "component-model"] } [dev-dependencies] dotenv = "0.15.0" diff --git a/crates/pod/src/feature/plugin.rs b/crates/pod/src/feature/plugin.rs index 255427ac..9493c2a4 100644 --- a/crates/pod/src/feature/plugin.rs +++ b/crates/pod/src/feature/plugin.rs @@ -21,8 +21,10 @@ use llm_worker::tool::{ Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput, }; use manifest::plugin::{ - PluginConfig, PluginDiscoveryLimits, PluginFsGrant, PluginFsOperation, PluginHostApi, - PluginPermission, PluginSurface, PluginToolManifest, ResolvedPluginRecord, + PLUGIN_COMPONENT_TOOL_WORLD, PLUGIN_RUNTIME_COMPONENT_KIND, PLUGIN_RUNTIME_WASM_ABI, + PLUGIN_RUNTIME_WASM_KIND, PluginConfig, PluginDiscoveryLimits, PluginFsGrant, + PluginFsOperation, PluginHostApi, PluginPermission, PluginSurface, PluginToolManifest, + ResolvedPluginRecord, read_resolved_plugin_runtime_component, read_resolved_plugin_runtime_module, }; use serde::{Deserialize, Serialize}; @@ -134,26 +136,51 @@ pub struct PluginToolEligibility { pub fn inspect_resolved_plugin_static(record: &ResolvedPluginRecord) -> PluginStaticInspection { let runtime = match &record.manifest.runtime { Some(runtime) - if runtime.kind == "wasm" && runtime.abi.as_deref() == Some("yoi-plugin-wasm-1") => + if runtime.kind == PLUGIN_RUNTIME_WASM_KIND + && runtime.abi.as_deref() == Some(PLUGIN_RUNTIME_WASM_ABI) + && runtime.entry.is_some() => { PluginRuntimeEligibility { eligible: true, - status: "wasm/yoi-plugin-wasm-1".to_string(), + status: format!("{PLUGIN_RUNTIME_WASM_KIND}/{PLUGIN_RUNTIME_WASM_ABI}"), diagnostic: None, } } - Some(runtime) if runtime.kind == "wasm" => { + Some(runtime) if runtime.kind == PLUGIN_RUNTIME_WASM_KIND => { let status = runtime .abi .as_deref() - .map(|abi| format!("wasm/{abi}")) - .unwrap_or_else(|| "wasm/".to_string()); + .map(|abi| format!("{PLUGIN_RUNTIME_WASM_KIND}/{abi}")) + .unwrap_or_else(|| format!("{PLUGIN_RUNTIME_WASM_KIND}/")); PluginRuntimeEligibility { eligible: false, status, diagnostic: Some("unsupported or missing plugin runtime ABI".to_string()), } } + Some(runtime) + if runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND + && runtime.world.as_deref() == Some(PLUGIN_COMPONENT_TOOL_WORLD) + && runtime.component.is_some() => + { + PluginRuntimeEligibility { + eligible: true, + status: format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/{PLUGIN_COMPONENT_TOOL_WORLD}"), + diagnostic: None, + } + } + Some(runtime) if runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND => { + let status = runtime + .world + .as_deref() + .map(|world| format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/{world}")) + .unwrap_or_else(|| format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/")); + PluginRuntimeEligibility { + eligible: false, + status, + diagnostic: Some("unsupported or missing plugin component world".to_string()), + } + } Some(runtime) => PluginRuntimeEligibility { eligible: false, status: runtime.kind.clone(), @@ -1708,26 +1735,25 @@ impl Tool for PluginWasmTool { let plugin_ref = self.origin.plugin_ref.clone(); let digest = self.origin.digest.clone(); let input = input_json.as_bytes().to_vec(); - let execution = - tokio::task::spawn_blocking(move || run_plugin_wasm_tool(record, name, input)); + let execution = tokio::task::spawn_blocking(move || run_plugin_tool(record, name, input)); match tokio::time::timeout(PLUGIN_WASM_TIMEOUT, execution).await { Ok(Ok(Ok(output))) => Ok(output), Ok(Ok(Err(error))) => Err(ToolError::ExecutionFailed(format!( - "plugin WASM tool `{}` from `{}` (digest {}) failed closed: {}", + "plugin tool `{}` from `{}` (digest {}) failed closed: {}", self.name, plugin_ref, digest, error.bounded_message() ))), Ok(Err(error)) => Err(ToolError::ExecutionFailed(format!( - "plugin WASM tool `{}` from `{}` (digest {}) cancelled/failed to join: {}", + "plugin tool `{}` from `{}` (digest {}) cancelled/failed to join: {}", self.name, plugin_ref, digest, bounded_message(error.to_string()) ))), Err(_) => Err(ToolError::ExecutionFailed(format!( - "plugin WASM tool `{}` from `{}` (digest {}) timed out after {:?}", + "plugin tool `{}` from `{}` (digest {}) timed out after {:?}", self.name, plugin_ref, digest, PLUGIN_WASM_TIMEOUT ))), } @@ -1767,6 +1793,28 @@ struct PluginWasmHostState { store_limits: wasmi::StoreLimits, } +fn run_plugin_tool( + record: ResolvedPluginRecord, + tool_name: String, + input: Vec, +) -> Result { + match record + .manifest + .runtime + .as_ref() + .map(|runtime| runtime.kind.as_str()) + { + Some(PLUGIN_RUNTIME_WASM_KIND) => run_plugin_wasm_tool(record, tool_name, input), + Some(PLUGIN_RUNTIME_COMPONENT_KIND) => run_plugin_component_tool(record, tool_name, input), + Some(other) => Err(PluginWasmError::Module(format!( + "unsupported plugin runtime kind `{other}`" + ))), + None => Err(PluginWasmError::Package( + "plugin runtime is not declared".to_string(), + )), + } +} + fn run_plugin_wasm_tool( record: ResolvedPluginRecord, tool_name: String, @@ -1864,6 +1912,216 @@ fn run_plugin_wasm_tool_with_https_client( decode_plugin_wasm_output(&store.data().output) } +#[derive(Clone)] +struct PluginComponentHostState { + record: ResolvedPluginRecord, + https_client: Arc, +} + +fn run_plugin_component_tool( + record: ResolvedPluginRecord, + tool_name: String, + input: Vec, +) -> Result { + run_plugin_component_tool_with_https_client( + record, + tool_name, + input, + Arc::new(ReqwestPluginHttpsClient), + ) +} + +fn run_plugin_component_tool_with_https_client( + record: ResolvedPluginRecord, + tool_name: String, + input: Vec, + https_client: Arc, +) -> Result { + let tool = record + .manifest + .tools + .iter() + .find(|tool| tool.name == tool_name) + .ok_or_else(|| { + PluginWasmError::Module("requested tool is not declared by plugin manifest".to_string()) + })?; + authorize_plugin_tool(&record, tool).map_err(|error| { + PluginWasmError::Module(format!( + "plugin permission denied: {}", + error.bounded_message() + )) + })?; + + let limits = PluginDiscoveryLimits::default(); + let component_bytes = read_resolved_plugin_runtime_component(&record, &limits) + .map_err(|diagnostic| PluginWasmError::Package(diagnostic.message))?; + if component_bytes.len() > limits.max_file_size_bytes as usize { + return Err(PluginWasmError::Package(format!( + "WASM component runtime artifact exceeds {} bytes", + limits.max_file_size_bytes + ))); + } + + let mut config = wasmtime::Config::new(); + config.wasm_component_model(true); + config.consume_fuel(true); + config.max_wasm_stack(8 * 1024 * 1024); + let engine = wasmtime::Engine::new(&config) + .map_err(|error| PluginWasmError::Module(error.to_string()))?; + let component = + wasmtime::component::Component::new(&engine, &component_bytes).map_err(|error| { + PluginWasmError::Module(format!("component is incompatible: {error:?}")) + })?; + validate_component_imports(&record, &engine, &component)?; + + let mut linker = wasmtime::component::Linker::::new(&engine); + define_plugin_component_host_imports(&mut linker)?; + let mut store = wasmtime::Store::new( + &engine, + PluginComponentHostState { + record: record.clone(), + https_client, + }, + ); + store + .set_fuel(PLUGIN_WASM_FUEL) + .map_err(|error| PluginWasmError::Execution(error.to_string()))?; + let instance = linker + .instantiate(&mut store, &component) + .map_err(|error| PluginWasmError::Execution(error.to_string()))?; + let call = instance + .get_typed_func::<(&str, &str), (String,)>(&mut store, "call") + .map_err(|error| { + PluginWasmError::Module(format!( + "component does not export expected `{}` call function: {error}", + PLUGIN_COMPONENT_TOOL_WORLD + )) + })?; + let input_json = std::str::from_utf8(&input).map_err(|error| { + PluginWasmError::Output(format!("plugin component input is not UTF-8: {error}")) + })?; + let (output,) = call + .call(&mut store, (&tool_name, input_json)) + .map_err(|error| PluginWasmError::Execution(error.to_string()))?; + decode_plugin_wasm_output(output.as_bytes()) +} + +fn validate_component_imports( + record: &ResolvedPluginRecord, + engine: &wasmtime::Engine, + component: &wasmtime::component::Component, +) -> Result<(), PluginWasmError> { + for (name, _) in component.component_type().imports(engine) { + match name { + "yoi:host/https@1.0.0" => { + authorize_plugin_host_api(record, PluginHostApi::Https).map_err(|error| { + PluginWasmError::Module(format!( + "plugin host API dispatch denied: {}", + error.bounded_message() + )) + })?; + } + "yoi:host/fs@1.0.0" => { + authorize_plugin_host_api(record, PluginHostApi::Fs).map_err(|error| { + PluginWasmError::Module(format!( + "plugin host API dispatch denied: {}", + error.bounded_message() + )) + })?; + } + other => { + return Err(PluginWasmError::Module(format!( + "unsupported component import `{other}`; no WASI filesystem, ambient network, environment, or other imports are available" + ))); + } + } + } + Ok(()) +} + +fn define_plugin_component_host_imports( + linker: &mut wasmtime::component::Linker, +) -> Result<(), PluginWasmError> { + linker + .root() + .instance("yoi:host/https@1.0.0") + .map_err(|error| PluginWasmError::Module(error.to_string()))? + .func_wrap( + "request", + |store: wasmtime::StoreContextMut<'_, PluginComponentHostState>, + (request,): (String,)| + -> wasmtime::Result<(String,)> { + authorize_plugin_host_api(&store.data().record, PluginHostApi::Https) + .map_err(|error| wasmtime::Error::msg(error.bounded_message()))?; + let response = execute_plugin_https_request( + &store.data().record, + store.data().https_client.as_ref(), + request.as_bytes(), + ) + .map_err(|error| wasmtime::Error::msg(error.0))?; + Ok((String::from_utf8_lossy(&response).into_owned(),)) + }, + ) + .map_err(|error| PluginWasmError::Module(error.to_string()))?; + + let mut root = linker.root(); + let mut fs = root + .instance("yoi:host/fs@1.0.0") + .map_err(|error| PluginWasmError::Module(error.to_string()))?; + fs.func_wrap( + "read", + |store: wasmtime::StoreContextMut<'_, PluginComponentHostState>, + (request,): (String,)| + -> wasmtime::Result<(String,)> { + authorize_plugin_host_api(&store.data().record, PluginHostApi::Fs) + .map_err(|error| wasmtime::Error::msg(error.bounded_message()))?; + execute_plugin_fs_request( + &store.data().record, + PluginFsRuntimeOperation::Read, + request.as_bytes(), + ) + .map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),)) + .map_err(|error| wasmtime::Error::msg(error.message)) + }, + ) + .map_err(|error| PluginWasmError::Module(error.to_string()))?; + fs.func_wrap( + "list", + |store: wasmtime::StoreContextMut<'_, PluginComponentHostState>, + (request,): (String,)| + -> wasmtime::Result<(String,)> { + authorize_plugin_host_api(&store.data().record, PluginHostApi::Fs) + .map_err(|error| wasmtime::Error::msg(error.bounded_message()))?; + execute_plugin_fs_request( + &store.data().record, + PluginFsRuntimeOperation::List, + request.as_bytes(), + ) + .map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),)) + .map_err(|error| wasmtime::Error::msg(error.message)) + }, + ) + .map_err(|error| PluginWasmError::Module(error.to_string()))?; + fs.func_wrap( + "write", + |store: wasmtime::StoreContextMut<'_, PluginComponentHostState>, + (request,): (String,)| + -> wasmtime::Result<(String,)> { + authorize_plugin_host_api(&store.data().record, PluginHostApi::Fs) + .map_err(|error| wasmtime::Error::msg(error.bounded_message()))?; + execute_plugin_fs_request( + &store.data().record, + PluginFsRuntimeOperation::Write, + request.as_bytes(), + ) + .map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),)) + .map_err(|error| wasmtime::Error::msg(error.message)) + }, + ) + .map_err(|error| PluginWasmError::Module(error.to_string()))?; + Ok(()) +} + fn validate_wasm_imports( record: &ResolvedPluginRecord, module: &wasmi::Module, @@ -3679,9 +3937,11 @@ mod tests { fn record_with_missing_package_runtime() -> ResolvedPluginRecord { let mut record = record(vec![tool("PluginSearch")]); record.manifest.runtime = Some(PluginRuntimeManifest { - kind: "wasm".into(), - entry: "plugin.wasm".into(), - abi: Some("yoi-plugin-wasm-1".into()), + kind: PLUGIN_RUNTIME_WASM_KIND.into(), + entry: Some("plugin.wasm".into()), + abi: Some(PLUGIN_RUNTIME_WASM_ABI.into()), + component: None, + world: None, }); record } @@ -3736,6 +3996,269 @@ mod tests { (dir, record) } + fn write_component_plugin_package(path: &Path, component: &[u8], world: &str) { + let manifest = format!( + r#"schema_version = 1 +id = "example" +name = "Example" +version = "1.0.0" +description = "Example component plugin" +surfaces = ["tool"] + +[runtime] +kind = "wasm-component" +component = "plugin.component.wasm" +world = "{}" + +[[permissions]] +kind = "surface" +surface = "tool" + +[[permissions]] +kind = "tool" +name = "PluginEcho" + +[[tools]] +name = "PluginEcho" +description = "Echo plugin tool" +input_schema = {{ type = "object", additionalProperties = true }} +"#, + world + ); + write_stored_zip( + path, + &[ + ("plugin.toml", manifest.as_bytes()), + ("plugin.component.wasm", component), + ], + ); + } + + fn resolved_record_with_component(component: Vec) -> (TempDir, ResolvedPluginRecord) { + let dir = TempDir::new().unwrap(); + let package_dir = dir.path().join(".yoi/plugins"); + fs::create_dir_all(&package_dir).unwrap(); + let package_path = package_dir.join("component.yoi-plugin"); + write_component_plugin_package(&package_path, &component, PLUGIN_COMPONENT_TOOL_WORLD); + let config = PluginConfig { + enabled: vec![PluginEnablementConfig { + id: "project:example".parse().unwrap(), + surfaces: vec![PluginSurface::Tool], + ..PluginEnablementConfig::default() + }], + resolved: Vec::new(), + diagnostics: Vec::new(), + }; + let options = PluginDiscoveryOptions::new(dir.path()); + let resolved = resolve_plugin_config_for_startup(&config, &options); + assert!( + resolved.diagnostics.is_empty(), + "{:#?}", + resolved.diagnostics + ); + assert_eq!(resolved.resolved.len(), 1); + let mut record = resolved.resolved[0].clone(); + record.grants = PluginGrantConfig { + id: Some(record.identity.to_string()), + version: Some(PluginExactVersion(record.version.clone())), + digest: Some(record.digest.clone()), + permissions: tool_permissions(&record.manifest.tools), + https: Vec::new(), + fs: Vec::new(), + }; + (dir, record) + } + + fn component_tool_that_returns(output: &[u8]) -> Vec { + wat::parse_str(format!( + r#"(component + (core module $m + (memory (export "memory") 1) + (func (export "realloc") (param i32 i32 i32 i32) (result i32) + (if (result i32) (i32.eqz (local.get 0)) + (then (i32.const 8192)) + (else (local.get 0)))) + (data (i32.const 1024) "{}") + (func (export "call") (param i32 i32 i32 i32) (result i32) + (i32.store (i32.const 2048) (i32.const 1024)) + (i32.store (i32.const 2052) (i32.const {})) + (i32.const 2048)) + ) + (core instance $i (instantiate $m)) + (alias core export $i "memory" (core memory $mem)) + (alias core export $i "realloc" (core func $realloc)) + (alias core export $i "call" (core func $call_core)) + (type $call_ty (func (param "tool-name" string) (param "input-json" string) (result string))) + (func $call (type $call_ty) (canon lift (core func $call_core) (memory $mem) (realloc $realloc) string-encoding=utf8)) + (export "call" (func $call)) + )"#, + wat_bytes(output), + output.len() + )) + .expect("valid component wat") + } + + fn component_tool_importing_https(output: &[u8]) -> Vec { + wat::parse_str(format!( + r#"(component + (import "yoi:host/https@1.0.0" (instance $https (export "request" (func $request (param "request-json" string) (result string))))) + (core module $m + (memory (export "memory") 1) + (func (export "realloc") (param i32 i32 i32 i32) (result i32) + (if (result i32) (i32.eqz (local.get 0)) + (then (i32.const 8192)) + (else (local.get 0)))) + (data (i32.const 1024) "{}") + (func (export "call") (param i32 i32 i32 i32) (result i32) + (i32.store (i32.const 2048) (i32.const 1024)) + (i32.store (i32.const 2052) (i32.const {})) + (i32.const 2048)) + ) + (core instance $i (instantiate $m)) + (alias core export $i "memory" (core memory $mem)) + (alias core export $i "realloc" (core func $realloc)) + (alias core export $i "call" (core func $call_core)) + (type $call_ty (func (param "tool-name" string) (param "input-json" string) (result string))) + (func $call (type $call_ty) (canon lift (core func $call_core) (memory $mem) (realloc $realloc) string-encoding=utf8)) + (export "call" (func $call)) + )"#, + wat_bytes(output), + output.len() + )) + .expect("valid component wat") + } + + fn component_without_call_export() -> Vec { + wat::parse_str(r#"(component (core module $m) (core instance $i (instantiate $m)))"#) + .unwrap() + } + + fn raw_module_bytes() -> Vec { + wat::parse_str(r#"(module (func (export "call")))"#).unwrap() + } + + #[test] + fn component_tool_executes_through_ordinary_tool_result_path() { + let (_dir, record) = resolved_record_with_component(component_tool_that_returns( + br#"{"summary":"component ok","content":"ordinary tool result path"}"#, + )); + + let output = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec()) + .expect("component tool output"); + + assert_eq!(output.summary, "component ok"); + assert_eq!(output.content.as_deref(), Some("ordinary tool result path")); + } + + #[test] + fn component_tool_denies_host_import_without_matching_grant() { + let (_dir, record) = resolved_record_with_component(component_tool_importing_https( + br#"{"summary":"component ok"}"#, + )); + + let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec()) + .expect_err("host import without grant is denied"); + + assert!( + format!("{error:?}").contains("plugin host API dispatch denied"), + "{error:?}" + ); + } + + #[test] + fn component_tool_missing_export_fails_closed() { + let (_dir, record) = resolved_record_with_component(component_without_call_export()); + + let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec()) + .expect_err("missing export fails closed"); + + assert!( + format!("{error:?}").contains("does not export expected"), + "{error:?}" + ); + } + + #[test] + fn core_wasm_is_not_silently_reinterpreted_as_component() { + let (_dir, record) = resolved_record_with_component(raw_module_bytes()); + + let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec()) + .expect_err("core module is incompatible with component runtime"); + + assert!( + format!("{error:?}").contains("component is incompatible"), + "{error:?}" + ); + } + + #[test] + fn component_wrong_world_fails_closed_during_discovery() { + let dir = TempDir::new().unwrap(); + let package_dir = dir.path().join(".yoi/plugins"); + fs::create_dir_all(&package_dir).unwrap(); + let package_path = package_dir.join("component.yoi-plugin"); + write_component_plugin_package( + &package_path, + &component_tool_that_returns(br#"{"summary":"component ok"}"#), + "example:other/world@1.0.0", + ); + let config = PluginConfig { + enabled: vec![PluginEnablementConfig { + id: "project:example".parse().unwrap(), + surfaces: vec![PluginSurface::Tool], + ..PluginEnablementConfig::default() + }], + resolved: Vec::new(), + diagnostics: Vec::new(), + }; + let options = PluginDiscoveryOptions::new(dir.path()); + let resolved = resolve_plugin_config_for_startup(&config, &options); + + assert!(resolved.resolved.is_empty()); + assert!( + resolved.diagnostics.iter().any(|diagnostic| diagnostic + .message + .contains("component world is unsupported")), + "{:#?}", + resolved.diagnostics + ); + } + + #[test] + fn component_tool_registration_uses_existing_tool_registry_path() { + let (_dir, record) = resolved_record_with_component(component_tool_that_returns( + br#"{"summary":"component ok"}"#, + )); + let (report, pending) = install_plugin_record(record); + + assert_eq!(skipped_count(&report), 0, "{report:#?}"); + assert_eq!(pending.len(), 1); + let (meta, _) = pending[0](); + assert_eq!(meta.name, "PluginEcho"); + } + + #[test] + fn component_static_inspection_reports_component_runtime_without_execution() { + let mut record = record(vec![tool("Echo")]); + record.package_path = std::path::PathBuf::from("/no/such/component.wasm"); + record.manifest.runtime = Some(PluginRuntimeManifest { + kind: PLUGIN_RUNTIME_COMPONENT_KIND.to_string(), + entry: None, + abi: None, + component: Some("plugin.component.wasm".to_string()), + world: Some(PLUGIN_COMPONENT_TOOL_WORLD.to_string()), + }); + + let inspection = inspect_resolved_plugin_static(&record); + + assert!(inspection.runtime.eligible); + assert_eq!( + inspection.runtime.status, + format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/{PLUGIN_COMPONENT_TOOL_WORLD}") + ); + assert!(inspection.runtime.diagnostic.is_none()); + } + fn write_plugin_package(path: &Path, wasm: &[u8]) { let manifest = br#"schema_version = 1 id = "example" @@ -3884,9 +4407,11 @@ input_schema = { type = "object", additionalProperties = true } let mut record = record(vec![tool("Echo")]); record.package_path = std::path::PathBuf::from("/no/such/plugin.wasm"); record.manifest.runtime = Some(PluginRuntimeManifest { - kind: "wasm".to_string(), - entry: "plugin.wasm".to_string(), - abi: Some("yoi-plugin-wasm-1".to_string()), + kind: PLUGIN_RUNTIME_WASM_KIND.to_string(), + entry: Some("plugin.wasm".to_string()), + abi: Some(PLUGIN_RUNTIME_WASM_ABI.to_string()), + component: None, + world: None, }); let inspection = inspect_resolved_plugin_static(&record); @@ -3901,9 +4426,11 @@ input_schema = { type = "object", additionalProperties = true } fn static_inspection_reports_missing_tool_grant() { let mut record = record(vec![tool("Echo")]); record.manifest.runtime = Some(PluginRuntimeManifest { - kind: "wasm".to_string(), - entry: "plugin.wasm".to_string(), - abi: Some("yoi-plugin-wasm-1".to_string()), + kind: PLUGIN_RUNTIME_WASM_KIND.to_string(), + entry: Some("plugin.wasm".to_string()), + abi: Some(PLUGIN_RUNTIME_WASM_ABI.to_string()), + component: None, + world: None, }); record.grants.permissions = vec![PluginPermission::surface(PluginSurface::Tool)]; @@ -3926,9 +4453,11 @@ input_schema = { type = "object", additionalProperties = true } bad_schema.input_schema = json!({"type":"string"}); let mut record = record(vec![bad_schema]); record.manifest.runtime = Some(PluginRuntimeManifest { - kind: "wasm".to_string(), - entry: "plugin.wasm".to_string(), - abi: Some("yoi-plugin-wasm-1".to_string()), + kind: PLUGIN_RUNTIME_WASM_KIND.to_string(), + entry: Some("plugin.wasm".to_string()), + abi: Some(PLUGIN_RUNTIME_WASM_ABI.to_string()), + component: None, + world: None, }); let inspection = inspect_resolved_plugin_static(&record); @@ -3953,9 +4482,11 @@ input_schema = { type = "object", additionalProperties = true } second_duplicate.input_schema = json!({"type":"object"}); let mut record = record(vec![invalid, first_duplicate, second_duplicate]); record.manifest.runtime = Some(PluginRuntimeManifest { - kind: "wasm".to_string(), - entry: "plugin.wasm".to_string(), - abi: Some("yoi-plugin-wasm-1".to_string()), + kind: PLUGIN_RUNTIME_WASM_KIND.to_string(), + entry: Some("plugin.wasm".to_string()), + abi: Some(PLUGIN_RUNTIME_WASM_ABI.to_string()), + component: None, + world: None, }); let inspection = inspect_resolved_plugin_static(&record); diff --git a/docs/design/plugin-component-model.md b/docs/design/plugin-component-model.md index 9b341b94..3c913cc7 100644 --- a/docs/design/plugin-component-model.md +++ b/docs/design/plugin-component-model.md @@ -121,3 +121,47 @@ For example, `https` should be modeled as typed request/response data with expli - It does not replace Plugin grants with WIT imports. - It does not introduce Service, Ingress, WebSocket, or inbound HTTP by itself. - It does not merge Plugin and MCP. MCP remains a separate untrusted tool/resource/prompt bridge with its own policy. + +## Implemented runtime boundary + +Plugin Tool packages now select the runtime explicitly in `plugin.toml`: + +```toml +[runtime] +kind = "wasm-component" +component = "plugin.component.wasm" +world = "yoi:plugin/tool@1.0.0" +``` + +The legacy core-Wasm ABI remains explicit and is not reinterpreted as a +component: + +```toml +[runtime] +kind = "wasm" +entry = "plugin.wasm" +abi = "yoi-plugin-wasm-1" +``` + +The component runtime uses `wasmtime::component` and expects the exported world +`yoi:plugin/tool@1.0.0` with a `call(tool-name: string, input-json: string) -> +string` export. The returned string is the same ToolOutput JSON used by the raw +runtime, so registration and execution still flow through the existing +ToolRegistry and Worker Tool-result history path. + +Host imports are stable names under `yoi:host/*@1.0.0`; the repository WIT files +live in `resources/plugin/wit/`. Importing `yoi:host/https@1.0.0` or +`yoi:host/fs@1.0.0` is not authority. The runtime checks package grants before +component instantiation and checks again on every host call. No WASI filesystem, +network, environment, or other ambient imports are linked. + +Static discovery and `yoi plugin list/show` only parse package manifests and +reported runtime metadata. They do not instantiate or execute the component. +Wrong `world`, missing artifact metadata, missing `call` export, unsupported +imports, or core-Wasm bytes in a component package all fail closed with bounded +Plugin diagnostics or ordinary Tool errors. + +See `docs/examples/plugin-component-tool/lib.rs` for a minimal +`wit-bindgen`/SDK-style authoring sketch. Package authors should generate +bindings from `resources/plugin/wit`, build a component artifact, and set the +component runtime metadata above. diff --git a/docs/design/plugin-packages.md b/docs/design/plugin-packages.md index c61f8ae8..3028a536 100644 --- a/docs/design/plugin-packages.md +++ b/docs/design/plugin-packages.md @@ -204,3 +204,29 @@ Good follow-up Tickets are intentionally separable: 6. WASM package ABI, initialization limits, host-function grants, and Tool/Hook contribution plumbing. 7. Optional lock-file or pin update workflow for reproducible fresh startup. 8. Future MCP/plugin bridge, only if explicitly approved as a separate design and implementation effort. + +### Component Model Tool runtime + +Tool packages may use WebAssembly Component Model runtime metadata: + +```toml +[runtime] +kind = "wasm-component" +component = "plugin.component.wasm" +world = "yoi:plugin/tool@1.0.0" +``` + +This is separate from the legacy raw core-Wasm runtime: + +```toml +[runtime] +kind = "wasm" +entry = "plugin.wasm" +abi = "yoi-plugin-wasm-1" +``` + +Component packages must not use `entry`/`abi`; raw packages must not use +`component`/`world`. Discovery reports the selected runtime kind/world without +executing the artifact. Component execution still requires explicit package +enablement, exact source/version/digest grants, and matching Tool/host API +permissions. diff --git a/docs/examples/plugin-component-tool/lib.rs b/docs/examples/plugin-component-tool/lib.rs new file mode 100644 index 00000000..5b2724f7 --- /dev/null +++ b/docs/examples/plugin-component-tool/lib.rs @@ -0,0 +1,23 @@ +//! Minimal Component Model Tool plugin authoring sketch. +//! +//! Build this as a `wasm32-unknown-unknown` cdylib with `wit-bindgen`-generated +//! exports and package the adapted component as `plugin.component.wasm`. + +wit_bindgen::generate!({ + world: "tool", + path: "../../../resources/plugin/wit", +}); + +struct Plugin; + +impl Guest for Plugin { + fn call(tool_name: String, input_json: String) -> String { + // Ordinary ToolOutput JSON. The runtime routes this through the normal + // Worker/Tool result path; no context is injected by the component. + format!( + r#"{{"summary":"component tool {tool_name}","content":"input was {input_json}"}}"# + ) + } +} + +export!(Plugin); diff --git a/package.nix b/package.nix index 40f7d23c..17592a50 100644 --- a/package.nix +++ b/package.nix @@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-xqax43t9IevkNG2lZvfRP562ORKb3aHxUNsQwS1FK/k="; + cargoHash = "sha256-i4U7wXPoWIHA4EAJZva2HQXNN8P5+RhGVGNBAOZVGk0="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint, diff --git a/resources/plugin/wit/yoi-host-v1.wit b/resources/plugin/wit/yoi-host-v1.wit new file mode 100644 index 00000000..ae66da92 --- /dev/null +++ b/resources/plugin/wit/yoi-host-v1.wit @@ -0,0 +1,15 @@ +package yoi:host@1.0.0; + +/// Grant-bound HTTPS host API. Importing this interface does not grant +/// authority; package grants are checked before registration/execution and on +/// every host call. +interface https { + request: func(request-json: string) -> string; +} + +/// Grant-bound filesystem host API. No ambient WASI filesystem is exposed. +interface fs { + read: func(request-json: string) -> string; + list: func(request-json: string) -> string; + write: func(request-json: string) -> string; +} diff --git a/resources/plugin/wit/yoi-plugin-tool-v1.wit b/resources/plugin/wit/yoi-plugin-tool-v1.wit new file mode 100644 index 00000000..74286424 --- /dev/null +++ b/resources/plugin/wit/yoi-plugin-tool-v1.wit @@ -0,0 +1,11 @@ +package yoi:plugin@1.0.0; + +world tool { + import yoi:host/https@1.0.0; + import yoi:host/fs@1.0.0; + + /// Execute a manifest-declared Tool. `input-json` is the normal Tool input + /// JSON and the returned string is the same ToolOutput JSON accepted by the + /// legacy raw-Wasm ABI. + export call: func(tool-name: string, input-json: string) -> string; +}