From d9e9c0f13deac6f04758a4364ee0ddfcafff13b8 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 28 Oct 2026 16:54:53 +0900 Subject: [PATCH 1/2] feat: execute v2 Plugin tools in isolated Wasmtime invocations Reuse canonical artifact and ABI verification; inject live Host admission and operation adapters without product authority or ambient WASI. Bound fresh Stores, resources, CPU, common deadlines and diagnostics, and regress actual PDK/adversarial Components. --- Cargo.lock | 698 +++++++++++++- Cargo.toml | 2 + crates/plugin-runtime/Cargo.toml | 25 + crates/plugin-runtime/README.md | 11 + crates/plugin-runtime/src/executor.rs | 708 ++++++++++++++ crates/plugin-runtime/src/host.rs | 96 ++ crates/plugin-runtime/src/lib.rs | 16 + crates/plugin-runtime/src/sandbox.rs | 99 ++ crates/plugin-runtime/tests/executor.rs | 952 +++++++++++++++++++ crates/plugin-runtime/tests/guest/Cargo.lock | 367 +++++++ crates/plugin-runtime/tests/guest/Cargo.toml | 13 + crates/plugin-runtime/tests/guest/src/lib.rs | 138 +++ package.nix | 2 +- 13 files changed, 3120 insertions(+), 7 deletions(-) create mode 100644 crates/plugin-runtime/Cargo.toml create mode 100644 crates/plugin-runtime/README.md create mode 100644 crates/plugin-runtime/src/executor.rs create mode 100644 crates/plugin-runtime/src/host.rs create mode 100644 crates/plugin-runtime/src/lib.rs create mode 100644 crates/plugin-runtime/src/sandbox.rs create mode 100644 crates/plugin-runtime/tests/executor.rs create mode 100644 crates/plugin-runtime/tests/guest/Cargo.lock create mode 100644 crates/plugin-runtime/tests/guest/Cargo.toml create mode 100644 crates/plugin-runtime/tests/guest/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index d9ec4b4f5..c02cc76e6 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.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -213,6 +222,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" + [[package]] name = "arc-swap" version = "1.9.1" @@ -556,6 +571,9 @@ name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +dependencies = [ + "allocator-api2", +] [[package]] name = "bytecount" @@ -740,6 +758,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" @@ -853,6 +880,144 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-assembler-x64" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c088d3406f0c0252efa7445adfd2d05736bfb5218838f64eaf79d567077aed14" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c03f887a763abb9c1dc08f722aa82b69067fda623b6f0273050f45f8b1a6776" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206887a11a43f507fee320a218dc365980bfc42ec2696792079a9f8c9369e90" +dependencies = [ + "cranelift-entity", +] + +[[package]] +name = "cranelift-bitset" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac0790c83cfdab95709c5d0105fd888221e3af9049a7d7ec376ec901ab4e4dba" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-codegen" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a98aed2d262eda69310e84bae8e053ee4f17dbdd3347b8d9156aa618ba2de0a" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.15.5", + "log", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "smallvec", + "target-lexicon", + "wasmtime-internal-math", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6906852826988563e9b0a9232ad951f53a47aa41ffd02f8ac852d3f41aae836a" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a50105aab667b5cc845f2be37c78475d7cc127cd8ec0a31f7b2b71d526099a7" + +[[package]] +name = "cranelift-control" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6adcc7aa7c0bc1727176a6f2d99c28a9e79a541ccd5ca911a0cb352da8befa36" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "981b56af777f9a34ea6dcce93255125776d391410c2a68b75bed5941b714fa15" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-frontend" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea982589684dfb71afecb9fc09555c3a266300a1162a60d7fa39d41a5705b1c" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0422686b22ed6a1f33cc40e3c43eb84b67155788568d1a5cac8439d3dca1783" + +[[package]] +name = "cranelift-native" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f697bbbe135c655ea1deb7af0bae4a5c4fae2c88fdfc0fa57b34ae58c91040" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.125.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "718efe674f3df645462677e22a3128e890d88ba55821bb091083d257707be76c" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1303,6 +1468,18 @@ dependencies = [ "serde", ] +[[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" @@ -1749,6 +1926,17 @@ dependencies = [ "polyval", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +dependencies = [ + "fallible-iterator", + "indexmap", + "stable_deref_trait", +] + [[package]] name = "glob" version = "0.3.3" @@ -2608,6 +2796,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" @@ -2631,7 +2828,7 @@ dependencies = [ "tracing", "wasmparser 0.244.0", "wit-component", - "wit-parser", + "wit-parser 0.244.0", ] [[package]] @@ -2704,6 +2901,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memfd" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57804b2c9b69967f1536a56f86297e367a33b19e98852ed624b84551cdbc0d90" +dependencies = [ + "rustix 1.1.4", +] + [[package]] name = "memmap2" version = "0.9.10" @@ -3038,6 +3244,18 @@ dependencies = [ "url", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "crc32fast", + "hashbrown 0.15.5", + "indexmap", + "memchr", +] + [[package]] name = "object_store" version = "0.13.2" @@ -3465,6 +3683,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 = "postscript" version = "0.14.1" @@ -3577,6 +3807,29 @@ dependencies = [ "unicase", ] +[[package]] +name = "pulley-interpreter" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beafc309a2d35e16cc390644d88d14dfa45e45e15075ec6a9e37f6dfb43e926f" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-math", +] + +[[package]] +name = "pulley-macros" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885fbb6c07454cfc8725a18a1da3cfc328ee8c53fb8d0671ea313edc8567947" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "quinn" version = "0.11.9" @@ -3630,7 +3883,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3868,6 +4121,20 @@ dependencies = [ "serde_json", ] +[[package]] +name = "regalloc2" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08effbc1fa53aaebff69521a5c05640523fab037b34a4a2c109506bc938246fa" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.15.5", + "log", + "rustc-hash", + "smallvec", +] + [[package]] name = "regex" version = "1.12.3" @@ -4255,6 +4522,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" @@ -4605,6 +4876,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -4864,6 +5138,12 @@ dependencies = [ "xattr", ] +[[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" @@ -5199,6 +5479,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -5822,6 +6103,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be00faa2b4950c76fe618c409d2c3ea5a3c9422013e079482d78544bb2d184c" +dependencies = [ + "leb128fmt", + "wasmparser 0.239.0", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -5867,6 +6158,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", + "serde", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -5888,6 +6192,256 @@ checksum = "fe9269dd817e0e7021bf534ccd9761bc2d64778b6708e41228b8439d75eaa0b1" dependencies = [ "bitflags 2.11.0", "indexmap", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3981f3d51f39f24f5fc90f93049a90f08dbbca8deba602cd46bb8ca67a94718" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.239.0", +] + +[[package]] +name = "wasmprinter" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09390d7b2bd7b938e563e4bff10aa345ef2e27a3bc99135697514ef54495e68f" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasmtime" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f81eafc07c867be94c47e0dc66355d9785e09107a18901f76a20701ba0663ad7" +dependencies = [ + "addr2line", + "anyhow", + "async-trait", + "bitflags 2.11.0", + "bumpalo", + "cc", + "cfg-if", + "encoding_rs", + "hashbrown 0.15.5", + "indexmap", + "libc", + "log", + "mach2", + "memfd", + "object", + "once_cell", + "postcard", + "pulley-interpreter", + "rustix 1.1.4", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasmparser 0.239.0", + "wasmtime-environ", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-math", + "wasmtime-internal-slab", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wasmtime-internal-winch", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-environ" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78587abe085a44a13c90fa16fea6db014e9883e627a7044d7f0cb397ad08d1da" +dependencies = [ + "anyhow", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "indexmap", + "log", + "object", + "postcard", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasm-encoder 0.239.0", + "wasmparser 0.239.0", + "wasmprinter 0.239.0", + "wasmtime-internal-component-util", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d843bb444f2d1509ea9304ad749242d1fa5de95cde67665bfcdcafa0f360925c" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser 0.239.0", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801ee1a80ab66f065a88c6a62f2d495d5540d027b366757c6a53e9c42f153aef" + +[[package]] +name = "wasmtime-internal-cranelift" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb50f1c50365c32e557266ca85acdf77696c44a3f98797ba6af58cebc6d6d1e" +dependencies = [ + "anyhow", + "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.239.0", + "wasmtime-environ", + "wasmtime-internal-math", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9308cdb17f8d51e3164185616d809e28c29a6515c03b9dd95c89436b71f6d154" +dependencies = [ + "anyhow", + "cc", + "cfg-if", + "libc", + "rustix 1.1.4", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9b63a22bf2a8b6a149a41c6768bc17a8b2e3288a249cb8216987fbd7128e81" +dependencies = [ + "cc", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb8e042b6e3de2f3d708279f89f50b4b9aa1b9bab177300cdffb0ffcd2816df5" +dependencies = [ + "anyhow", + "cfg-if", + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-internal-math" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c1f0674f38cd7d014eb1a49ea1d1766cca1a64459e8856ee118a10005302e16" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmtime-internal-slab" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb24b7535306713e7a250f8b71e35f05b6a5031bf9c3ed7330c308e899cbe7d3" + +[[package]] +name = "wasmtime-internal-unwinder" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21d5a80e2623a49cb8e8c419542337b8fe0260b162c40dcc201080a84cbe9b7c" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "log", + "object", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23e277f734b9256359b21517c3b0c26a2a9de6c53a51b670ae55cdcde548bf4e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "wasmtime-internal-winch" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4dc9333737142f6ece4369c8bcdda03a11edbd43d8fbd3e15004c194b9b743" +dependencies = [ + "anyhow", + "cranelift-codegen", + "gimli", + "log", + "object", + "target-lexicon", + "wasmparser 0.239.0", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "winch-codegen", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f758625553fe33fdce0713f63bb7784c4f5fecb7f7cd4813414519ec24b6a4c" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "heck", + "indexmap", + "wit-parser 0.239.0", ] [[package]] @@ -6169,6 +6723,26 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "winch-codegen" +version = "38.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c0bb17ae9bf89ebc74512150e6ee0a27b1eac5ff3b54d8cec264f4b4255022d" +dependencies = [ + "anyhow", + "cranelift-assembler-x64", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.239.0", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "wasmtime-internal-math", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -6255,6 +6829,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -6288,13 +6871,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -6307,6 +6907,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -6319,6 +6925,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -6331,12 +6943,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -6349,6 +6973,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -6361,6 +6991,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -6373,6 +7009,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -6385,6 +7027,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "1.0.1" @@ -6474,7 +7122,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck", - "wit-parser", + "wit-parser 0.244.0", ] [[package]] @@ -6525,7 +7173,25 @@ dependencies = [ "wasm-metadata", "wasmparser 0.244.0", "wat", - "wit-parser", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-parser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55c92c939d667b7bf0c6bf2d1f67196529758f99a2a45a3355cc56964fd5315d" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.239.0", ] [[package]] @@ -6793,7 +7459,27 @@ dependencies = [ "wasmparser 0.244.0", "wit-bindgen", "wit-component", - "wit-parser", + "wit-parser 0.244.0", +] + +[[package]] +name = "yoi-plugin-runtime" +version = "0.1.0" +dependencies = [ + "async-trait", + "jsonschema", + "manifest", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "wasmprinter 0.244.0", + "wasmtime", + "wat", + "wit-component", + "wit-parser 0.244.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4b39e0fd8..38c7516a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "crates/runtime-api", "crates/server-api", "crates/plugin-pdk", + "crates/plugin-runtime", "crates/yoi", "crates/protocol", "crates/session-metrics", @@ -57,6 +58,7 @@ default-members = [ "crates/runtime-api", "crates/server-api", "crates/plugin-pdk", + "crates/plugin-runtime", "crates/yoi", "crates/protocol", "crates/session-metrics", diff --git a/crates/plugin-runtime/Cargo.toml b/crates/plugin-runtime/Cargo.toml new file mode 100644 index 000000000..6eeed5817 --- /dev/null +++ b/crates/plugin-runtime/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "yoi-plugin-runtime" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +async-trait.workspace = true +manifest.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["rt", "macros", "sync", "time"] } +tokio-util = { version = "0.7", features = ["rt"] } +jsonschema = { version = "0.33.0", default-features = false } +wasmtime = { version = "=38.0.4", default-features = false, features = ["async", "component-model", "cranelift", "runtime", "std", "threads"] } + +[dev-dependencies] +tempfile.workspace = true +wit-component = { version = "0.244.0", features = ["dummy-module"] } +wit-parser = "0.244.0" +wasmprinter = "0.244.0" +wat = "1" +tokio = { workspace = true, features = ["rt-multi-thread", "test-util"] } diff --git a/crates/plugin-runtime/README.md b/crates/plugin-runtime/README.md new file mode 100644 index 000000000..440bff337 --- /dev/null +++ b/crates/plugin-runtime/README.md @@ -0,0 +1,11 @@ +# yoi-plugin-runtime + +Runtime-owned, standalone execution of exact v2 Tool Components. [`Executor`] compiles only artifacts reverified through `manifest::plugin`, then creates a fresh Store and instance for each invocation. It does not resolve catalogs, config, Worker plans, delivery caches or repository paths, and does not register Tools with a Worker. + +[`host`] is an explicit asynchronous injection boundary, not an implementation of Server authority or HTTPS. Every invocation (including pure computation) requires live admission and result-publication authorization. Providers must bind their authenticated installation/plan/subject and narrowed grants to [`host::InvocationContext`], authorize every protected operation, and make pending work cancellation-safe. There is no default provider or product allow-all fallback. Dropping a provider future must release temporary resources; an already dispatched external effect is not rolled back. Durable effect receipts, secret resolution, URL/DNS/TLS policy and real network transport belong to the Host provider, not this sandbox. + +Component instantiation is not a Tool export: all Host imports, including logging, are rejected until the export context is installed. Service start/stop semantics are outside this crate. Only exact WIT imports are linked, without WASI, filesystem, environment, process, clock or network ambient authority. Call-local connection resources hold opaque provider objects; guest resource representations never identify a Server resource or issue a grant. + +A single deadline covers admission, instantiation, guest execution, asynchronous Host waits, post-return and publication. Fuel bounds computation and cooperatively yields; epoch interruption cooperatively yields as well. Store memory/table/instance limits, call-local resource and operation counts, serialized payload and diagnostic budgets complement those bounds. Text/JSON overflows fail rather than returning truncated success; excess diagnostic entries are dropped and counted. Guest diagnostics are untrusted, must not contain secrets, and are separate from model output. Host adapters must not log raw request/response or credentials. + +Compile results may be shared, but invocation memory, resource tables and imported state are never shared. The caller owns finite aggregate admission/concurrency quotas; per-invocation limits alone do not bound an unbounded number of callers. A validated digest is content identity, never admission authority. This standalone core is not connected to normal Worker launch/restore; product integration must supply the live Server provider. diff --git a/crates/plugin-runtime/src/executor.rs b/crates/plugin-runtime/src/executor.rs new file mode 100644 index 000000000..127eabf17 --- /dev/null +++ b/crates/plugin-runtime/src/executor.rs @@ -0,0 +1,708 @@ +use crate::sandbox::SandboxLimits; +use crate::{ + bindings, + host::{self, Connection, Host, InvocationContext, Operation, errors, http, logging}, +}; +use manifest::plugin::{self, PluginPackageLimits, PluginPackageManifest, PluginToolManifest}; +use serde::Serialize; +use std::{sync::Arc, time::Duration}; +use tokio::time::Instant; +use wasmtime::component::{Component, Linker, Resource, ResourceTable}; +use wasmtime::{Config, Engine, Store}; + +#[derive(Clone, Debug)] +pub struct Limits { + pub payload_bytes: usize, + pub config_bytes: usize, + pub memory_bytes: usize, + pub table_elements: usize, + pub instances: usize, + pub memories: usize, + pub tables: usize, + pub resources: usize, + pub fuel: u64, + pub wall_time: Duration, + pub host_operations: usize, + pub http_body_bytes: usize, + pub http_header_bytes: usize, + pub http_header_pairs: usize, + pub log_bytes: usize, +} +impl Default for Limits { + fn default() -> Self { + Self { + payload_bytes: 64 * 1024, + config_bytes: 64 * 1024, + memory_bytes: 256 * 1024 * 1024, + table_elements: 10_000, + instances: 32, + memories: 4, + tables: 8, + resources: 32, + fuel: 10_000_000, + wall_time: Duration::from_secs(30), + host_operations: 32, + http_body_bytes: 1024 * 1024, + http_header_bytes: 16 * 1024, + http_header_pairs: 64, + log_bytes: 16 * 1024, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ExecutionError { + #[error("invalid artifact: {0}")] + Artifact(String), + #[error("invalid invocation: {0}")] + InvalidInput(String), + #[error("invalid output: {0}")] + InvalidOutput(String), + #[error("invocation cancelled")] + Cancelled, + #[error("invocation deadline exceeded")] + Deadline, + #[error("Host denied or failed operation: {0:?}")] + Host(errors::HostError), + #[error("guest error {code}: {message}")] + Guest { code: String, message: String }, + #[error("Component trap or resource limit: {0}")] + Trap(String), +} + +/// Content identity only; both digests are independently verified by the package owner. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ArtifactIdentity { + pub package_digest: String, + pub artifact_digest: String, + pub artifact_size: u64, +} + +/// Immutable compiled bytes and declarations, not execution authority. +/// Private fields prevent mutable package metadata from bypassing verification. +pub struct CompiledTool { + component: Component, + manifest: PluginPackageManifest, + identity: ArtifactIdentity, +} +impl CompiledTool { + pub fn identity(&self) -> &ArtifactIdentity { + &self.identity + } +} + +#[derive(Debug)] +pub struct InvocationResult { + pub output: bindings::exports::yoi::plugin::tool::Output, + pub dropped_logs: u64, +} + +pub struct Executor { + engine: Engine, + limits: Limits, +} +impl Executor { + /// Limits are fixed by the Host, never selected by guest input. + pub fn new(limits: Limits) -> wasmtime::Result { + let ceiling = Limits::default(); + macro_rules! check_ceiling { + ($($field:ident),+ $(,)?) => { $( + if limits.$field > ceiling.$field { + return Err(wasmtime::Error::msg(concat!("limit exceeds supported ceiling: ", stringify!($field)))); + } + )+ }; + } + check_ceiling!( + payload_bytes, + config_bytes, + memory_bytes, + table_elements, + instances, + memories, + tables, + resources, + fuel, + wall_time, + host_operations, + http_body_bytes, + http_header_bytes, + http_header_pairs, + log_bytes + ); + let mut config = Config::new(); + config + .async_support(true) + .wasm_component_model(true) + .consume_fuel(true) + .epoch_interruption(true) + .wasm_threads(false) + .wasm_shared_everything_threads(false); + Ok(Self { + engine: Engine::new(&config)?, + limits, + }) + } + + pub fn limits(&self) -> &Limits { + &self.limits + } + + /// Reuses the canonical package/digest/ABI validator; no path discovery or + /// duplicate pack/hash implementation is involved. Compilation executes no guest. + pub fn compile( + &self, + artifact: &[u8], + package_digest: &str, + artifact_digest: &str, + artifact_size: u64, + ) -> Result { + let verified = plugin::verify_plugin_artifact( + artifact, + package_digest, + artifact_digest, + artifact_size, + &PluginPackageLimits::default(), + ) + .map_err(|e| ExecutionError::Artifact(e.message))?; + let manifest = verified.package.manifest; + let component = Component::new(&self.engine, &verified.files[&manifest.runtime.component]) + .map_err(|e| ExecutionError::Artifact(e.to_string()))?; + Ok(CompiledTool { + component, + manifest, + identity: ArtifactIdentity { + package_digest: package_digest.into(), + artifact_digest: artifact_digest.into(), + artifact_size, + }, + }) + } + + /// All computation and Host waits share the earlier of the caller deadline + /// and local ceiling. Dropping this future also drops its Store and resources. + pub async fn invoke( + &self, + compiled: &CompiledTool, + mut context: InvocationContext, + provider: Arc, + tool_name: &str, + input_json: &str, + config_json: &str, + ) -> Result { + context.deadline = context.deadline.min(Instant::now() + self.limits.wall_time); + context.cancellation = context.cancellation.child_token(); + let _lifetime = InvocationLifetime(context.cancellation.clone()); + let deadline = context.deadline; + let cancellation = context.cancellation.clone(); + let execution = self.run( + compiled, + context, + provider, + tool_name, + input_json, + config_json, + ); + tokio::pin!(execution); + let mut tick = tokio::time::interval(Duration::from_millis(10)); + loop { + tokio::select! { + biased; + _ = cancellation.cancelled() => return Err(ExecutionError::Cancelled), + _ = tokio::time::sleep_until(deadline) => return Err(ExecutionError::Deadline), + result = &mut execution => { + if cancellation.is_cancelled() { return Err(ExecutionError::Cancelled); } + if Instant::now() >= deadline { return Err(ExecutionError::Deadline); } + return result; + }, + _ = tick.tick() => self.engine.increment_epoch(), + } + } + } + + async fn run( + &self, + compiled: &CompiledTool, + context: InvocationContext, + provider: Arc, + tool_name: &str, + input_json: &str, + config_json: &str, + ) -> Result { + if !Engine::same(&self.engine, compiled.component.engine()) { + return Err(ExecutionError::Artifact( + "Component belongs to another executor".into(), + )); + } + if context.artifact != compiled.identity || context.tool_name != tool_name { + return Err(ExecutionError::InvalidInput( + "artifact/Tool does not match Host-bound context".into(), + )); + } + let tool = compiled + .manifest + .tools + .iter() + .find(|t| t.name == tool_name) + .ok_or_else(|| ExecutionError::InvalidInput("undeclared Tool".into()))?; + if input_json.len() > self.limits.payload_bytes + || config_json.len() > self.limits.config_bytes + || context.invocation_id.len() > 1024 + { + return Err(ExecutionError::InvalidInput( + "input/config/identity exceeds limit".into(), + )); + } + validate_json(&tool.input_schema, input_json).map_err(ExecutionError::InvalidInput)?; + serde_json::from_str::(config_json) + .map_err(|e| ExecutionError::InvalidInput(e.to_string()))?; + provider + .authorize(&context, Operation::Admit) + .await + .map_err(ExecutionError::Host)?; + check_current(&context).map_err(ExecutionError::Host)?; + let limits = &self.limits; + let state = State { + context: None, + provider: provider.clone(), + limits: limits.clone(), + manifest: compiled.manifest.clone(), + table: ResourceTable::new(), + operations: 0, + resources: 0, + log_bytes: 0, + dropped_logs: 0, + store_limits: SandboxLimits::new(limits), + }; + let mut store = Store::new(&self.engine, state); + store.limiter(|state| &mut state.store_limits); + store.set_fuel(limits.fuel).map_err(trap)?; + store + .fuel_async_yield_interval(Some(10_000)) + .map_err(trap)?; + store.set_epoch_deadline(1); + store.epoch_deadline_async_yield_and_update(1); + let mut linker = Linker::new(&self.engine); + bindings::HttpsLoggedToolPlugin::add_to_linker::<_, wasmtime::component::HasSelf<_>>( + &mut linker, + |s| s, + ) + .map_err(trap)?; + let instance = bindings::HttpsLoggedToolPlugin::instantiate_async( + &mut store, + &compiled.component, + &linker, + ) + .await + .map_err(trap)?; + // No import during instantiate/start may act, including logging. Service + // on-start is a different world and has no adapter here. + check_current(&context).map_err(ExecutionError::Host)?; + store.data_mut().context = Some(context.clone()); + let invocation = bindings::exports::yoi::plugin::tool::Invocation { + invocation_id: context.invocation_id.clone(), + tool_name: tool_name.into(), + input_json: input_json.into(), + config_json: config_json.into(), + remaining_ms: context + .deadline + .saturating_duration_since(Instant::now()) + .as_millis() + .min(u32::MAX as u128) as u32, + }; + let result = instance + .yoi_plugin_tool() + .call_invoke(&mut store, &invocation) + .await + .map_err(trap)?; + check_current(&context).map_err(ExecutionError::Host)?; + provider + .authorize(&context, Operation::Publish) + .await + .map_err(ExecutionError::Host)?; + check_current(&context).map_err(ExecutionError::Host)?; + let output = match result { + Ok(output) => { + validate_output(tool, &output, limits.payload_bytes)?; + output + } + Err(error) => { + if error.message.len().saturating_add(error.code.len()) > limits.payload_bytes { + return Err(ExecutionError::InvalidOutput( + "guest error exceeds limit".into(), + )); + } + let code = if tool.error_codes.contains(&error.code) { + error.code + } else { + "plugin-failed".into() + }; + #[derive(Serialize)] + struct Failure<'a> { + code: &'a str, + message: &'a str, + } + if serde_json::to_vec(&Failure { + code: &code, + message: &error.message, + }) + .unwrap() + .len() + > limits.payload_bytes + { + return Err(ExecutionError::InvalidOutput( + "serialized guest error exceeds limit".into(), + )); + } + return Err(ExecutionError::Guest { + code, + message: error.message, + }); + } + }; + Ok(InvocationResult { + output, + dropped_logs: store.data().dropped_logs, + }) + } +} +fn trap(e: wasmtime::Error) -> ExecutionError { + ExecutionError::Trap(format!("{e:#}")) +} +fn validate_json(schema: &serde_json::Value, json: &str) -> Result<(), String> { + let value: serde_json::Value = serde_json::from_str(json).map_err(|e| e.to_string())?; + // Schema admission already forbids external refs/dialects in manifest::plugin. + let validator = jsonschema::options() + .with_draft(jsonschema::Draft::Draft202012) + .should_validate_formats(true) + .build(schema) + .map_err(|e| e.to_string())?; + validator.validate(&value).map_err(|e| e.to_string()) +} +fn validate_output( + tool: &PluginToolManifest, + output: &bindings::exports::yoi::plugin::tool::Output, + max: usize, +) -> Result<(), ExecutionError> { + let raw_bytes = output + .summary + .len() + .saturating_add(output.content.as_ref().map_or(0, String::len)) + .saturating_add(output.structured_json.as_ref().map_or(0, String::len)); + if raw_bytes > max { + return Err(ExecutionError::InvalidOutput("output exceeds limit".into())); + } + // Use the complete serialized envelope budget, not separate per-field limits. + #[derive(Serialize)] + struct Envelope<'a> { + summary: &'a str, + content: &'a Option, + structured_json: &'a Option, + } + let bytes = serde_json::to_vec(&Envelope { + summary: &output.summary, + content: &output.content, + structured_json: &output.structured_json, + }) + .unwrap(); + if bytes.len() > max { + return Err(ExecutionError::InvalidOutput("output exceeds limit".into())); + } + let json = output + .structured_json + .as_deref() + .ok_or_else(|| ExecutionError::InvalidOutput("missing structured output".into()))?; + validate_json(&tool.output_schema, json).map_err(ExecutionError::InvalidOutput) +} + +struct InvocationLifetime(host::CancellationToken); +impl Drop for InvocationLifetime { + fn drop(&mut self) { + self.0.cancel(); + } +} + +struct State { + context: Option, + provider: Arc, + limits: Limits, + manifest: PluginPackageManifest, + table: ResourceTable, + operations: usize, + resources: usize, + log_bytes: usize, + dropped_logs: u64, + store_limits: SandboxLimits, +} +fn check_current(context: &InvocationContext) -> Result<(), errors::HostError> { + if context.cancellation.is_cancelled() { + return Err(host::error(errors::Code::Cancelled, "invocation cancelled")); + } + if Instant::now() >= context.deadline { + return Err(host::error( + errors::Code::DeadlineExceeded, + "invocation deadline exceeded", + )); + } + Ok(()) +} +impl State { + fn current(&self) -> wasmtime::Result { + let context = self + .context + .as_ref() + .ok_or_else(|| wasmtime::Error::msg("Host import outside Tool export context"))?; + check_current(context).map_err(|_| wasmtime::Error::msg("invocation no longer active"))?; + Ok(context.clone()) + } + fn operation(&mut self) -> Result<(), errors::HostError> { + if self.operations >= self.limits.host_operations { + return Err(host::error( + errors::Code::QuotaExceeded, + "Host operation limit", + )); + } + self.operations += 1; + Ok(()) + } +} +impl bindings::yoi::host::errors::Host for State {} +impl bindings::yoi::host::connections::HostConnection for State { + async fn drop(&mut self, resource: Resource) -> wasmtime::Result<()> { + self.table.delete(resource)?; + self.resources -= 1; + Ok(()) + } +} +impl bindings::yoi::host::connections::Host for State { + async fn open( + &mut self, + slot: String, + ) -> wasmtime::Result, errors::HostError>> { + let context = self.current()?; + if let Err(e) = self.operation() { + return Ok(Err(e)); + } + let declared = self.manifest.connections.iter().any(|s| s.name == slot); + if !declared || !context.grants.connection_slots.contains(&slot) { + return Ok(Err(host::error( + errors::Code::Denied, + "connection slot not granted", + ))); + } + if self.resources >= self.limits.resources { + return Ok(Err(host::error( + errors::Code::QuotaExceeded, + "resource limit", + ))); + } + if let Err(e) = self + .provider + .authorize(&context, Operation::Open { slot: &slot }) + .await + { + return Ok(Err(e)); + } + check_current(&context).map_err(|_| wasmtime::Error::msg("invocation no longer active"))?; + let binding = match self.provider.open(&context, &slot).await { + Ok(v) => v, + Err(e) => return Ok(Err(e)), + }; + check_current(&context).map_err(|_| wasmtime::Error::msg("late connection result"))?; + let resource = self.table.push(Connection { slot, binding })?; + self.resources += 1; + Ok(Ok(resource)) + } +} +impl bindings::yoi::host::http::Host for State { + async fn send( + &mut self, + target: Resource, + mut input: http::Request, + ) -> wasmtime::Result> { + let context = self.current()?; + if let Err(e) = self.operation() { + return Ok(Err(e)); + } + let connection = self.table.get(&target)?; + let slot = connection.slot.clone(); + let binding = connection.binding.clone(); + if !context.grants.connection_slots.contains(&slot) { + return Ok(Err(host::error( + errors::Code::Denied, + "connection slot not granted", + ))); + } + let declaration = self + .manifest + .connections + .iter() + .find(|s| s.name == slot) + .unwrap(); + let method = match input.method { + http::Method::Get => plugin::PluginHttpMethod::Get, + http::Method::Head => plugin::PluginHttpMethod::Head, + http::Method::Post => plugin::PluginHttpMethod::Post, + http::Method::Put => plugin::PluginHttpMethod::Put, + http::Method::Patch => plugin::PluginHttpMethod::Patch, + http::Method::Delete => plugin::PluginHttpMethod::Delete, + http::Method::Options => plugin::PluginHttpMethod::Options, + }; + // Canonical URL/DNS/TLS/secret and narrower binding checks belong to the + // provider. These static ceilings are not a network protection claim. + if !declaration.methods.contains(&method) + || !declaration.path_prefixes.iter().any(|p| { + input.path == *p + || input + .path + .strip_prefix(p) + .is_some_and(|rest| p.ends_with('/') || rest.starts_with('/')) + }) + { + return Ok(Err(host::error( + errors::Code::Denied, + "request exceeds package ceiling", + ))); + } + if !request_bounded(&input, &self.limits) { + return Ok(Err(host::error( + errors::Code::QuotaExceeded, + "request exceeds limit", + ))); + } + input.timeout_ms = input.timeout_ms.min(15_000).min( + context + .deadline + .saturating_duration_since(Instant::now()) + .as_millis() + .min(u32::MAX as u128) as u32, + ); + let send_deadline = context + .deadline + .min(Instant::now() + Duration::from_millis(input.timeout_ms as u64)); + match tokio::time::timeout_at( + send_deadline, + self.provider.authorize( + &context, + Operation::Send { + slot: &slot, + binding: binding.as_ref(), + request: &input, + }, + ), + ) + .await + { + Ok(Ok(())) => (), + Ok(Err(error)) => return Ok(Err(error)), + Err(_) => { + return Ok(Err(host::error( + errors::Code::DeadlineExceeded, + "HTTP authorization deadline exceeded", + ))); + } + } + check_current(&context).map_err(|_| wasmtime::Error::msg("invocation no longer active"))?; + if Instant::now() >= send_deadline { + return Ok(Err(host::error( + errors::Code::DeadlineExceeded, + "HTTP deadline exceeded before dispatch", + ))); + } + let response = match tokio::time::timeout_at( + send_deadline, + self.provider.send(&context, binding.as_ref(), input), + ) + .await + { + Ok(response) => response, + Err(_) => Err(errors::HostError { + code: errors::Code::DeadlineExceeded, + message: "HTTP wait deadline exceeded".into(), + diagnostic_id: String::new(), + effect: errors::EffectObservation::Indeterminate, + }), + }; + check_current(&context).map_err(|_| wasmtime::Error::msg("late HTTP result"))?; + Ok(response.and_then(|response| { + if response.body.len() > self.limits.http_body_bytes + || !pairs_bounded(&response.headers, &self.limits) + { + Err(errors::HostError { + code: errors::Code::QuotaExceeded, + message: "response exceeds limit".into(), + diagnostic_id: String::new(), + effect: errors::EffectObservation::ResponseObserved, + }) + } else { + Ok(response) + } + })) + } +} +fn pairs_bounded(pairs: &[http::Pair], limits: &Limits) -> bool { + pairs.len() <= limits.http_header_pairs + && pairs + .iter() + .try_fold(0usize, |n, p| { + n.checked_add(p.name.len())?.checked_add(p.value.len()) + }) + .is_some_and(|n| n <= limits.http_header_bytes) +} +fn request_bounded(request: &http::Request, limits: &Limits) -> bool { + request.body.len() <= limits.http_body_bytes + && request.path.len() <= limits.payload_bytes + && request.effect_key.len() <= 128 + && !request.effect_key.is_empty() + && pairs_bounded(&request.headers, limits) + && pairs_bounded(&request.query, limits) +} +impl bindings::yoi::host::logging::Host for State { + async fn log( + &mut self, + input: logging::Entry, + ) -> wasmtime::Result> { + let context = self.current()?; + if !context.grants.logging + || !self + .manifest + .requested_grants + .contains(&plugin::PluginRequestedGrant::Logging) + { + return Ok(Err(host::error( + errors::Code::Denied, + "logging not granted", + ))); + } + let size = input + .event_code + .len() + .saturating_add(input.message.len()) + .saturating_add( + input + .fields + .iter() + .map(|f| { + f.name.len().saturating_add(match &f.value { + logging::Scalar::Text(s) => s.len(), + _ => 8, + }) + }) + .sum::(), + ); + if size > self.limits.log_bytes.saturating_sub(self.log_bytes) { + self.dropped_logs = self.dropped_logs.saturating_add(1); + return Ok(Ok(())); + } + if let Err(e) = self.operation() { + return Ok(Err(e)); + } + if let Err(e) = self.provider.authorize(&context, Operation::Log).await { + return Ok(Err(e)); + } + check_current(&context).map_err(|_| wasmtime::Error::msg("invocation no longer active"))?; + self.log_bytes += size; + let result = self.provider.log(&context, input).await; + check_current(&context).map_err(|_| wasmtime::Error::msg("late log result"))?; + Ok(result) + } +} diff --git a/crates/plugin-runtime/src/host.rs b/crates/plugin-runtime/src/host.rs new file mode 100644 index 000000000..e92d71d59 --- /dev/null +++ b/crates/plugin-runtime/src/host.rs @@ -0,0 +1,96 @@ +//! Host-bound identity, grants and asynchronous provider contracts. +//! +//! The provider is the authority adapter: opaque bindings are not credentials +//! supplied by guests. Implementations must live-check authorization, remain +//! cancellation-safe, bound their own allocations and never auto-retry effects. + +pub use crate::bindings::yoi::host::{errors, http, logging}; +use async_trait::async_trait; +use std::{any::Any, collections::BTreeSet, sync::Arc}; +use tokio::time::Instant; +pub use tokio_util::sync::CancellationToken; + +/// Constructed by the authenticated Runtime integration, never from guest JSON. +/// All fields are a snapshot upper bound, not a cached live authorization. +#[derive(Clone, Debug)] +pub struct InvocationContext { + pub workspace: String, + pub installation: String, + pub plan: String, + pub subject: String, + pub invocation_id: String, + pub artifact: crate::ArtifactIdentity, + pub tool_name: String, + pub grants: Grants, + pub deadline: Instant, + pub cancellation: CancellationToken, +} + +#[derive(Clone, Debug, Default)] +pub struct Grants { + pub logging: bool, + pub connection_slots: BTreeSet, +} + +/// Opaque Host-owned capability, stored only within one invocation's Store. +/// The provider must not interpret a guest resource representation as identity. +pub struct Connection { + pub(crate) slot: String, + pub(crate) binding: Arc, +} + +/// Each operation requires a fresh owner check; admission covers pure Tools too. +pub enum Operation<'a> { + Admit, + Publish, + Open { + slot: &'a str, + }, + Send { + slot: &'a str, + binding: &'a (dyn Any + Send + Sync), + request: &'a http::Request, + }, + Log, +} + +/// No default implementation is supplied. Test providers belong to test targets. +/// +/// `authorize` must check the current installation/artifact/plan/subject and +/// operation against live owner policy. `open`/`send` must also enforce concrete +/// binding policy, secret handling and effect receipts at dispatch time. The +/// executor only enforces static package and invocation ceilings. All waits are +/// dropped on cancellation/deadline; providers must not leave detached work. +#[async_trait] +pub trait Host: Send + Sync { + async fn authorize( + &self, + context: &InvocationContext, + operation: Operation<'_>, + ) -> Result<(), errors::HostError>; + async fn open( + &self, + context: &InvocationContext, + slot: &str, + ) -> Result, errors::HostError>; + async fn send( + &self, + context: &InvocationContext, + binding: &(dyn Any + Send + Sync), + request: http::Request, + ) -> Result; + async fn log( + &self, + context: &InvocationContext, + entry: logging::Entry, + ) -> Result<(), errors::HostError>; +} + +pub(crate) fn error(code: errors::Code, message: &str) -> errors::HostError { + errors::HostError { + code, + message: message.into(), + diagnostic_id: String::new(), + effect: errors::EffectObservation::NotDispatched, + } +} diff --git a/crates/plugin-runtime/src/lib.rs b/crates/plugin-runtime/src/lib.rs new file mode 100644 index 000000000..656378b30 --- /dev/null +++ b/crates/plugin-runtime/src/lib.rs @@ -0,0 +1,16 @@ +#![doc = include_str!("../README.md")] + +pub mod bindings { + wasmtime::component::bindgen!({ + path: "../../resources/plugin/wit", + world: "https-logged-tool-plugin", + imports: { default: async | trappable }, + exports: { default: async }, + with: { "yoi:host/connections/connection": crate::host::Connection }, + }); +} + +mod executor; +pub mod host; +mod sandbox; +pub use executor::*; diff --git a/crates/plugin-runtime/src/sandbox.rs b/crates/plugin-runtime/src/sandbox.rs new file mode 100644 index 000000000..f10daffac --- /dev/null +++ b/crates/plugin-runtime/src/sandbox.rs @@ -0,0 +1,99 @@ +//! Aggregate Store budgets: Wasmtime's built-in memory/table sizes are per +//! object, so count accepted growth across all objects as well. Nothing in a +//! Tool Store outlives that invocation; no cross-Store accounting is retained. +use crate::Limits; +use wasmtime::{ResourceLimiter, StoreLimits, StoreLimitsBuilder}; + +pub(crate) struct SandboxLimits { + inner: StoreLimits, + memory: Budget, + table: Budget, +} +struct Budget { + used: usize, + last_growth: usize, + ceiling: usize, +} +impl Budget { + fn new(ceiling: usize) -> Self { + Self { + used: 0, + last_growth: 0, + ceiling, + } + } + fn reserve(&mut self, current: usize, desired: usize, kind: &str) -> wasmtime::Result<()> { + let growth = desired.saturating_sub(current); + if growth > self.ceiling.saturating_sub(self.used) { + return Err(wasmtime::Error::msg(format!( + "aggregate {kind} budget exceeded" + ))); + } + self.used += growth; + self.last_growth = growth; + Ok(()) + } + fn failed(&mut self) { + self.used -= self.last_growth; + self.last_growth = 0; + } +} +impl SandboxLimits { + pub(crate) fn new(limits: &Limits) -> Self { + Self { + inner: StoreLimitsBuilder::new() + .memory_size(limits.memory_bytes) + .table_elements(limits.table_elements) + .instances(limits.instances) + .memories(limits.memories) + .tables(limits.tables) + .trap_on_grow_failure(true) + .build(), + memory: Budget::new(limits.memory_bytes), + table: Budget::new(limits.table_elements), + } + } +} +impl ResourceLimiter for SandboxLimits { + fn memory_growing( + &mut self, + current: usize, + desired: usize, + maximum: Option, + ) -> wasmtime::Result { + if !self.inner.memory_growing(current, desired, maximum)? { + return Ok(false); + } + self.memory.reserve(current, desired, "memory")?; + Ok(true) + } + fn table_growing( + &mut self, + current: usize, + desired: usize, + maximum: Option, + ) -> wasmtime::Result { + if !self.inner.table_growing(current, desired, maximum)? { + return Ok(false); + } + self.table.reserve(current, desired, "table")?; + Ok(true) + } + fn memory_grow_failed(&mut self, error: wasmtime::Error) -> wasmtime::Result<()> { + self.memory.failed(); + self.inner.memory_grow_failed(error) + } + fn table_grow_failed(&mut self, error: wasmtime::Error) -> wasmtime::Result<()> { + self.table.failed(); + self.inner.table_grow_failed(error) + } + fn instances(&self) -> usize { + self.inner.instances() + } + fn memories(&self) -> usize { + self.inner.memories() + } + fn tables(&self) -> usize { + self.inner.tables() + } +} diff --git a/crates/plugin-runtime/tests/executor.rs b/crates/plugin-runtime/tests/executor.rs new file mode 100644 index 000000000..cc0241cb0 --- /dev/null +++ b/crates/plugin-runtime/tests/executor.rs @@ -0,0 +1,952 @@ +//! Actual Component -> Wasmtime -> async Host adapter integration, no network. +//! T-791 binaries exercise the unmodified PDK; the adversarial Rust guest is built +//! once with wasm32-unknown-unknown std and offline Cargo, as in the PDK suite. +//! Provider authority/HTTP are substituted here, not the executor or WIT boundary. + +use async_trait::async_trait; +use manifest::plugin::{self, CanonicalPluginArtifact, PluginPackageLimits, PluginSourceKind}; +use std::{ + any::Any, + collections::BTreeSet, + fs, + path::Path, + process::Command, + sync::{ + Arc, Mutex, OnceLock, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; +use tokio::{sync::Semaphore, time::Instant}; +use yoi_plugin_runtime::{ + host::{errors, http, logging, *}, + *, +}; + +const PURE: &str = + include_str!("../../../resources/plugin/templates/rust-component-tool/plugin.toml"); +const HTTPS: &str = + include_str!("../../../resources/plugin/templates/rust-component-https/plugin.toml"); +const PURE_COMPONENT: &[u8] = + include_bytes!("../../plugin-pdk/tests/fixtures/tool-v2.component.wasm"); +const HTTPS_COMPONENT: &[u8] = + include_bytes!("../../plugin-pdk/tests/fixtures/https-tool-v2.component.wasm"); + +fn artifact( + manifest: &str, + bytes: &[u8], +) -> Result { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("plugin.toml"), manifest).unwrap(); + fs::write(dir.path().join("plugin.component.wasm"), bytes).unwrap(); + let package = plugin::read_plugin_directory( + dir.path(), + PluginSourceKind::Explicit, + &PluginPackageLimits::default(), + )?; + plugin::pack_plugin_package(&package, &PluginPackageLimits::default()) +} +fn compile(executor: &Executor, artifact: &CanonicalPluginArtifact) -> CompiledTool { + executor + .compile( + &artifact.bytes, + &artifact.package_digest, + &artifact.artifact_digest, + artifact.artifact_size, + ) + .unwrap() +} +fn guest() -> &'static [u8] { + static COMPONENT: OnceLock> = OnceLock::new(); + COMPONENT.get_or_init(|| { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let target = root.join("target/plugin-executor-guest"); + let output = Command::new(env!("CARGO")) + .args([ + "build", + "--locked", + "--offline", + "--release", + "--target", + "wasm32-unknown-unknown", + "--manifest-path", + ]) + .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/guest/Cargo.toml")) + .arg("--target-dir") + .arg(&target) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let core = fs::read(target.join("wasm32-unknown-unknown/release/executor_test_guest.wasm")) + .unwrap(); + wit_component::ComponentEncoder::default() + .module(&core) + .unwrap() + .validate(true) + .encode() + .unwrap() + }) +} +fn guest_manifest() -> String { + HTTPS.replace("https-tool-plugin@", "https-logged-tool-plugin@") + .replace("\"yoi:host/http@2.0.0\"]", "\"yoi:host/http@2.0.0\", \"yoi:host/logging@2.0.0\"]") + .replace("requested_grants = [", "requested_grants = [{ kind = \"logging\" }, ") + .replace("input_schema = { type = \"object\", properties = {}, additionalProperties = false }", "input_schema = { type = \"string\" }") + .replace("output_schema = { type = \"object\", properties = { status = { type = \"integer\" }, body_bytes = { type = \"integer\" } }, required = [\"status\", \"body_bytes\"], additionalProperties = false }", "output_schema = { type = \"object\", properties = { calls = { type = \"integer\" } }, required = [\"calls\"], additionalProperties = false }") + .replace("\"failed\"]", "\"failed\", \"typed\", \"host\"]") +} +fn context(component: &CompiledTool, id: &str) -> InvocationContext { + InvocationContext { + workspace: "workspace-a".into(), + installation: "installed-a".into(), + plan: "exact-plan".into(), + subject: "worker-a".into(), + invocation_id: id.into(), + artifact: component.identity().clone(), + tool_name: "example_status".into(), + grants: Grants { + logging: true, + connection_slots: BTreeSet::from(["api".into()]), + }, + deadline: Instant::now() + Duration::from_secs(10), + cancellation: CancellationToken::new(), + } +} + +#[derive(Clone, Copy, PartialEq)] +enum Behavior { + Respond, + Wait, + CancelAtResponse, + DenyAdmission, + DenySend, + DenyPublish, +} +struct TestHost { + behavior: Behavior, + entered: Semaphore, + release: Semaphore, + active: Arc, + resources: Arc, + sends: AtomicUsize, + opens: AtomicUsize, + logs: Mutex>, + admissions: AtomicUsize, + publications: AtomicUsize, + tokens: Mutex>, +} +impl TestHost { + fn new(behavior: Behavior) -> Arc { + Arc::new(Self { + behavior, + entered: Semaphore::new(0), + release: Semaphore::new(0), + active: Arc::default(), + resources: Arc::default(), + sends: AtomicUsize::new(0), + opens: AtomicUsize::new(0), + logs: Mutex::default(), + admissions: AtomicUsize::new(0), + publications: AtomicUsize::new(0), + tokens: Mutex::default(), + }) + } + fn released(&self) { + assert_eq!(self.active.load(Ordering::SeqCst), 0); + assert_eq!(self.resources.load(Ordering::SeqCst), 0); + assert!( + self.tokens + .lock() + .unwrap() + .iter() + .all(CancellationToken::is_cancelled) + ); + } +} +struct Counter(Arc); +impl Counter { + fn new(counter: &Arc) -> Self { + counter.fetch_add(1, Ordering::SeqCst); + Self(counter.clone()) + } +} +impl Drop for Counter { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } +} +struct Binding { + id: String, + _counter: Counter, +} +fn denied() -> errors::HostError { + errors::HostError { + code: errors::Code::Denied, + message: "denied".into(), + diagnostic_id: "test-only".into(), + effect: errors::EffectObservation::NotDispatched, + } +} +#[async_trait] +impl Host for TestHost { + async fn authorize( + &self, + context: &InvocationContext, + operation: Operation<'_>, + ) -> Result<(), errors::HostError> { + let deny = match operation { + Operation::Admit => { + self.tokens + .lock() + .unwrap() + .push(context.cancellation.clone()); + self.admissions.fetch_add(1, Ordering::SeqCst); + self.behavior == Behavior::DenyAdmission + } + Operation::Publish => { + self.publications.fetch_add(1, Ordering::SeqCst); + self.behavior == Behavior::DenyPublish + } + Operation::Send { .. } => self.behavior == Behavior::DenySend, + _ => false, + }; + if deny { Err(denied()) } else { Ok(()) } + } + async fn open( + &self, + context: &InvocationContext, + _: &str, + ) -> Result, errors::HostError> { + self.opens.fetch_add(1, Ordering::SeqCst); + Ok(Arc::new(Binding { + id: context.invocation_id.clone(), + _counter: Counter::new(&self.resources), + })) + } + async fn send( + &self, + context: &InvocationContext, + binding: &(dyn Any + Send + Sync), + request: http::Request, + ) -> Result { + assert_eq!( + binding.downcast_ref::().unwrap().id, + context.invocation_id + ); + assert_eq!(request.path, "/status"); + assert!(request.timeout_ms <= 15_000); + let _active = Counter::new(&self.active); + self.sends.fetch_add(1, Ordering::SeqCst); + self.entered.add_permits(1); + if self.behavior == Behavior::Wait { + self.release.acquire().await.unwrap().forget(); + } + if self.behavior == Behavior::CancelAtResponse { + context.cancellation.cancel(); + } + Ok(http::Response { + status: 201, + headers: vec![], + body: context.invocation_id.as_bytes().to_vec(), + }) + } + async fn log( + &self, + context: &InvocationContext, + entry: logging::Entry, + ) -> Result<(), errors::HostError> { + assert_eq!(entry.event_code, "count"); + self.logs + .lock() + .unwrap() + .push(context.invocation_id.clone()); + self.entered.add_permits(1); + Ok(()) + } +} +fn executor(limits: Limits) -> Executor { + Executor::new(limits).unwrap() +} +fn adversary(ex: &Executor) -> CompiledTool { + compile(ex, &artifact(&guest_manifest(), guest()).unwrap()) +} +async fn call( + ex: &Executor, + component: &CompiledTool, + host: Arc, + mode: &str, +) -> Result { + ex.invoke( + component, + context(component, mode), + host, + "example_status", + &format!("\"{mode}\""), + "{}", + ) + .await +} + +#[tokio::test] +async fn pdk_pure_tool_preserves_typed_results_and_requires_live_admission() { + let ex = executor(Limits::default()); + let component = compile(&ex, &artifact(PURE, PURE_COMPONENT).unwrap()); + let host = TestHost::new(Behavior::Respond); + let result = ex + .invoke( + &component, + echo_context(&component, "pure"), + host.clone(), + "example_echo", + r#"{"text":"hello"}"#, + "{}", + ) + .await + .unwrap(); + let value: serde_json::Value = + serde_json::from_str(result.output.structured_json.as_ref().unwrap()).unwrap(); + assert_eq!(value["text"], "hello"); + assert_eq!(host.admissions.load(Ordering::SeqCst), 1); + assert_eq!(host.publications.load(Ordering::SeqCst), 1); + let error = ex + .invoke( + &component, + echo_context(&component, "empty"), + host.clone(), + "example_echo", + r#"{"text":""}"#, + "{}", + ) + .await + .unwrap_err(); + assert!(matches!(error, ExecutionError::Guest { code, .. } if code == "invalid_input")); + for behavior in [Behavior::DenyAdmission, Behavior::DenyPublish] { + assert!(matches!( + ex.invoke( + &component, + echo_context(&component, "denied"), + TestHost::new(behavior), + "example_echo", + r#"{"text":"x"}"#, + "{}" + ) + .await, + Err(ExecutionError::Host(_)) + )); + } +} + +#[tokio::test] +async fn pdk_https_uses_only_bound_host_capability_and_drops_it() { + let ex = executor(Limits::default()); + let component = compile(&ex, &artifact(HTTPS, HTTPS_COMPONENT).unwrap()); + let host = TestHost::new(Behavior::Respond); + let result = ex + .invoke( + &component, + context(&component, "https"), + host.clone(), + "example_status", + "{}", + "{}", + ) + .await + .unwrap(); + let value: serde_json::Value = + serde_json::from_str(result.output.structured_json.as_ref().unwrap()).unwrap(); + assert_eq!(value["status"], 201); + assert_eq!(host.sends.load(Ordering::SeqCst), 1); + host.released(); + let denied = TestHost::new(Behavior::DenySend); + assert!( + ex.invoke( + &component, + context(&component, "no-send"), + denied.clone(), + "example_status", + "{}", + "{}" + ) + .await + .is_err() + ); + assert_eq!(denied.sends.load(Ordering::SeqCst), 0); + denied.released(); +} + +#[tokio::test] +async fn fresh_store_isolates_memory_and_logs_are_bounded_not_model_output() { + let ex = executor(Limits { + log_bytes: 40, + ..Limits::default() + }); + let component = adversary(&ex); + let host = TestHost::new(Behavior::Respond); + for _ in 0..2 { + let result = call(&ex, &component, host.clone(), "logging") + .await + .unwrap(); + assert_eq!( + result.output.structured_json.as_deref(), + Some("{\"calls\":1}") + ); + assert_eq!(result.dropped_logs, 2); + assert_eq!(result.output.summary, "logging"); + } + assert_eq!(host.logs.lock().unwrap().len(), 2); + for (mode, code) in [("typed", "typed"), ("unknown", "plugin-failed")] { + assert!( + matches!(call(&ex, &component, host.clone(), mode).await, Err(ExecutionError::Guest { code: actual, .. }) if actual == code) + ); + } +} + +#[tokio::test] +async fn undeclared_tool_and_invalid_input_are_rejected_before_admission() { + let ex = executor(Limits::default()); + let component = compile(&ex, &artifact(PURE, PURE_COMPONENT).unwrap()); + let host = TestHost::new(Behavior::Respond); + for (tool, input) in [ + ("missing", "{}"), + ("example_echo", "{}"), + ("example_echo", "not-json"), + ("example_echo", r#"{"text":3}"#), + ] { + assert!(matches!( + ex.invoke( + &component, + echo_context(&component, "bad"), + host.clone(), + tool, + input, + "{}" + ) + .await, + Err(ExecutionError::InvalidInput(_)) + )); + } + assert_eq!(host.admissions.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn malformed_schema_output_and_oversized_results_never_publish_success() { + let ex = executor(Limits::default()); + let component = adversary(&ex); + for mode in ["bad-json", "bad-schema", "output", "error-output"] { + assert!(matches!( + call(&ex, &component, TestHost::new(Behavior::Respond), mode).await, + Err(ExecutionError::InvalidOutput(_)) + )); + } +} + +#[tokio::test] +async fn trap_fuel_memory_and_resource_exhaustion_leave_next_call_fresh() { + let ex = executor(Limits { + memory_bytes: 4 * 1024 * 1024, + resources: 2, + fuel: 200_000, + ..Limits::default() + }); + let component = adversary(&ex); + let host = TestHost::new(Behavior::Respond); + for mode in ["trap", "loop", "memory", "resources"] { + assert!( + matches!( + call(&ex, &component, host.clone(), mode).await, + Err(ExecutionError::Trap(_)) + ), + "{mode}" + ); + host.released(); + assert_eq!( + call(&ex, &component, host.clone(), "fresh") + .await + .unwrap() + .output + .structured_json + .as_deref(), + Some("{\"calls\":1}") + ); + } +} + +#[tokio::test] +async fn stale_and_foreign_resource_representations_cannot_dispatch_http() { + let ex = executor(Limits::default()); + let component = adversary(&ex); + for mode in ["stale", "foreign"] { + let host = TestHost::new(Behavior::Respond); + assert!( + matches!( + call(&ex, &component, host.clone(), mode).await, + Err(ExecutionError::Trap(_)) + ), + "{mode}" + ); + assert_eq!(host.sends.load(Ordering::SeqCst), 0); + host.released(); + } +} + +#[tokio::test] +async fn cancellation_during_host_wait_drops_future_and_call_resources() { + let ex = executor(Limits::default()); + let component = adversary(&ex); + let host = TestHost::new(Behavior::Wait); + let ctx = context(&component, "cancel-wait"); + let cancellation = ctx.cancellation.clone(); + let invocation = ex.invoke( + &component, + ctx, + host.clone(), + "example_status", + "\"http\"", + "{}", + ); + let cancellation_task = async { + tokio::time::timeout(Duration::from_secs(5), host.entered.acquire()) + .await + .unwrap() + .unwrap() + .forget(); + cancellation.cancel(); + }; + let (result, ()) = tokio::join!(invocation, cancellation_task); + assert!(matches!(result, Err(ExecutionError::Cancelled))); + host.released(); + host.release.add_permits(1); + assert_eq!(host.publications.load(Ordering::SeqCst), 0); +} + +#[tokio::test(start_paused = true)] +async fn host_wait_uses_common_wall_deadline_and_drops_resources() { + let ex = executor(Limits { + wall_time: Duration::from_secs(1), + ..Limits::default() + }); + let component = adversary(&ex); + let host = TestHost::new(Behavior::Wait); + let invocation = call(&ex, &component, host.clone(), "http"); + let clock = async { + tokio::time::timeout(Duration::from_secs(5), host.entered.acquire()) + .await + .unwrap() + .unwrap() + .forget(); + tokio::time::advance(Duration::from_secs(2)).await; + }; + let (result, ()) = tokio::join!(invocation, clock); + assert!(matches!(result, Err(ExecutionError::Deadline))); + host.released(); +} + +#[tokio::test] +async fn cancelled_late_response_is_not_published() { + let ex = executor(Limits::default()); + let component = adversary(&ex); + let host = TestHost::new(Behavior::CancelAtResponse); + assert!(matches!( + call(&ex, &component, host.clone(), "http").await, + Err(ExecutionError::Cancelled) + )); + assert_eq!(host.publications.load(Ordering::SeqCst), 0); + host.released(); +} + +#[tokio::test] +async fn concurrent_invocations_never_mix_identity_memory_or_capabilities() { + let ex = executor(Limits::default()); + let component = adversary(&ex); + let host = TestHost::new(Behavior::Wait); + let a = ex.invoke( + &component, + context(&component, "a"), + host.clone(), + "example_status", + "\"http\"", + "{}", + ); + let b = ex.invoke( + &component, + context(&component, "b"), + host.clone(), + "example_status", + "\"http\"", + "{}", + ); + let release = async { + tokio::time::timeout(Duration::from_secs(5), host.entered.acquire()) + .await + .unwrap() + .unwrap() + .forget(); + tokio::time::timeout(Duration::from_secs(5), host.entered.acquire()) + .await + .unwrap() + .unwrap() + .forget(); + host.release.add_permits(2); + }; + let (a, b, ()) = tokio::join!(a, b, release); + assert_eq!(a.unwrap().output.summary, "a"); + assert_eq!(b.unwrap().output.summary, "b"); + host.released(); +} + +#[test] +fn wrong_import_or_interface_version_is_rejected_by_shared_validator() { + assert!(artifact(PURE, HTTPS_COMPONENT).is_err()); + assert!(artifact(&HTTPS.replace("2.0.0", "1.0.0"), HTTPS_COMPONENT).is_err()); + let ex = executor(Limits::default()); + let package = artifact(PURE, PURE_COMPONENT).unwrap(); + assert!(matches!( + ex.compile( + &package.bytes, + &package.artifact_digest, + &package.package_digest, + package.artifact_size + ), + Err(ExecutionError::Artifact(_)) + )); +} + +fn echo_context(component: &CompiledTool, id: &str) -> InvocationContext { + let mut c = context(component, id); + c.tool_name = "example_echo".into(); + c +} + +fn generated_component(transform: impl FnOnce(String) -> String) -> Vec { + let mut resolve = wit_parser::Resolve::default(); + let package = resolve + .push_dir(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../resources/plugin/wit")) + .unwrap() + .0; + let world = resolve + .select_world(&[package], Some("https-logged-tool-plugin")) + .unwrap(); + let bytes = + wit_component::dummy_module(&resolve, world, wit_parser::ManglingAndAbi::Standard32); + let wat = transform(wasmprinter::print_bytes(bytes).unwrap()); + let mut core = wat::parse_str(wat).unwrap(); + wit_component::embed_component_metadata( + &mut core, + &resolve, + world, + wit_component::StringEncoding::UTF8, + ) + .unwrap(); + wit_component::ComponentEncoder::default() + .module(&core) + .unwrap() + .validate(true) + .encode() + .unwrap() +} +fn startup_component(logging: bool) -> Vec { + generated_component(|wat| { + // Mutate only a generated initializer, retaining exact canonical types. + // These calls run during instantiation, not the Tool export. + let body = if logging { + "i32.const 0 ".repeat(7) + "i32.const 128 call 3" + } else { + "i32.const 0 i32.const 0 i32.const 128 call 0".into() + }; + let needle = "(func (;7;) (type 6))"; + assert!(wat.contains(needle)); + wat.replace("(memory (;0;) 0)", "(memory (;0;) 1)") + .replace(needle, &format!("(func (;7;) (type 6) {body})")) + }) +} + +#[tokio::test] +async fn instantiate_without_export_context_rejects_connections_and_logging() { + let ex = executor(Limits::default()); + for logging in [false, true] { + let component = compile( + &ex, + &artifact(&guest_manifest(), &startup_component(logging)).unwrap(), + ); + let host = TestHost::new(Behavior::Respond); + let error = call(&ex, &component, host.clone(), "fresh") + .await + .unwrap_err(); + assert!(matches!(error, ExecutionError::Trap(_))); + assert_eq!(host.opens.load(Ordering::SeqCst), 0); + assert_eq!(host.sends.load(Ordering::SeqCst), 0); + assert!(host.logs.lock().unwrap().is_empty()); + host.released(); + } +} + +#[tokio::test] +async fn host_context_cannot_admit_another_artifact_or_tool() { + let ex = executor(Limits::default()); + let component = compile(&ex, &artifact(PURE, PURE_COMPONENT).unwrap()); + let host = TestHost::new(Behavior::Respond); + for changed_artifact in [false, true] { + let mut context = echo_context(&component, "bad-bound-context"); + if changed_artifact { + context.artifact.package_digest.push('x'); + } else { + context.tool_name = "different-tool".into(); + } + assert!(matches!( + ex.invoke( + &component, + context, + host.clone(), + "example_echo", + r#"{"text":"x"}"#, + "{}" + ) + .await, + Err(ExecutionError::InvalidInput(_)) + )); + } + assert_eq!(host.admissions.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn initial_memory_table_and_instance_limits_apply_at_instantiation() { + for limits in [ + Limits { + memory_bytes: 0, + ..Limits::default() + }, + Limits { + table_elements: 0, + ..Limits::default() + }, + Limits { + instances: 0, + ..Limits::default() + }, + Limits { + memories: 0, + ..Limits::default() + }, + Limits { + tables: 0, + ..Limits::default() + }, + ] { + let ex = executor(limits); + let component = adversary(&ex); + assert!(matches!( + call(&ex, &component, TestHost::new(Behavior::Respond), "fresh").await, + Err(ExecutionError::Trap(_)) + )); + } +} + +#[tokio::test(start_paused = true)] +async fn running_cpu_loop_obeys_wall_deadline_without_consuming_all_fuel() { + let ex = executor(Limits { + wall_time: Duration::from_secs(1), + ..Limits::default() + }); + let component = adversary(&ex); + let host = TestHost::new(Behavior::Respond); + let invocation = call(&ex, &component, host.clone(), "wall-loop"); + let clock = async { + tokio::time::timeout(Duration::from_secs(5), host.entered.acquire()) + .await + .unwrap() + .unwrap() + .forget(); + tokio::time::advance(Duration::from_secs(2)).await; + }; + let (result, ()) = tokio::join!(invocation, clock); + assert!(matches!(result, Err(ExecutionError::Deadline))); + assert_eq!(host.logs.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn missing_grants_and_host_operation_ceiling_never_dispatch() { + let ex = executor(Limits::default()); + let component = adversary(&ex); + for mode in ["logging", "http"] { + let host = TestHost::new(Behavior::Respond); + let mut context = context(&component, mode); + context.grants = Grants::default(); + assert!(matches!( + ex.invoke( + &component, + context, + host.clone(), + "example_status", + &format!("\"{mode}\""), + "{}" + ) + .await, + Err(ExecutionError::Guest { .. }) + )); + assert_eq!(host.opens.load(Ordering::SeqCst), 0); + assert!(host.logs.lock().unwrap().is_empty()); + } + let ex = executor(Limits { + host_operations: 1, + ..Limits::default() + }); + let component = adversary(&ex); + let host = TestHost::new(Behavior::Respond); + assert!(call(&ex, &component, host.clone(), "http").await.is_err()); + assert_eq!(host.opens.load(Ordering::SeqCst), 1); + assert_eq!(host.sends.load(Ordering::SeqCst), 0); + host.released(); +} + +#[tokio::test] +async fn oversized_input_and_config_are_rejected_before_admission() { + let ex = executor(Limits { + payload_bytes: 32, + config_bytes: 16, + ..Limits::default() + }); + let component = compile(&ex, &artifact(PURE, PURE_COMPONENT).unwrap()); + let host = TestHost::new(Behavior::Respond); + for (input, config) in [ + (format!(r#"{{"text":"{}"}}"#, "x".repeat(32)), "{}".into()), + ( + r#"{"text":"ok"}"#.into(), + format!(r#"{{"x":"{}"}}"#, "x".repeat(16)), + ), + ] { + assert!(matches!( + ex.invoke( + &component, + echo_context(&component, "size"), + host.clone(), + "example_echo", + &input, + &config + ) + .await, + Err(ExecutionError::InvalidInput(_)) + )); + } + assert_eq!(host.admissions.load(Ordering::SeqCst), 0); +} + +#[tokio::test(start_paused = true)] +async fn http_wait_has_its_own_shorter_deadline_without_extending_the_call() { + let ex = executor(Limits::default()); + let component = adversary(&ex); + let host = TestHost::new(Behavior::Wait); + let mut context = context(&component, "request-timeout"); + context.deadline = Instant::now() + Duration::from_secs(30); + let invocation = ex.invoke( + &component, + context, + host.clone(), + "example_status", + "\"http\"", + "{}", + ); + let clock = async { + tokio::time::timeout(Duration::from_secs(5), host.entered.acquire()) + .await + .unwrap() + .unwrap() + .forget(); + tokio::time::advance(Duration::from_secs(16)).await; + }; + let (result, ()) = tokio::join!(invocation, clock); + assert!( + matches!(&result, Err(ExecutionError::Guest { code, message }) if code == "host" && message == "deadline-exceeded:indeterminate"), + "{result:?}" + ); + host.released(); +} + +#[tokio::test] +async fn bounded_http_response_is_not_truncated_into_success() { + let ex = executor(Limits { + http_body_bytes: 1, + ..Limits::default() + }); + let component = adversary(&ex); + let host = TestHost::new(Behavior::Respond); + let result = call(&ex, &component, host.clone(), "http").await; + assert!( + matches!(&result, Err(ExecutionError::Guest { code, message }) if code == "host" && message == "quota-exceeded:response-observed"), + "{result:?}" + ); + assert_eq!(host.sends.load(Ordering::SeqCst), 1); + host.released(); +} + +#[tokio::test] +async fn dropping_invoke_cancels_only_its_child_lifetime_and_reclaims_store() { + let ex = executor(Limits::default()); + let component = adversary(&ex); + let host = TestHost::new(Behavior::Wait); + let context = context(&component, "dropped"); + let parent_token = context.cancellation.clone(); + let mut invocation = Box::pin(ex.invoke( + &component, + context, + host.clone(), + "example_status", + "\"http\"", + "{}", + )); + tokio::select! { + result = &mut invocation => panic!("unexpected completion: {result:?}"), + entered = tokio::time::timeout(Duration::from_secs(5), host.entered.acquire()) => entered.unwrap().unwrap().forget(), + } + drop(invocation); + assert!(!parent_token.is_cancelled()); + host.released(); +} + +#[tokio::test] +async fn multiple_memories_and_tables_cannot_multiply_store_budgets() { + for memory in [false, true] { + let ex = executor(Limits { + memory_bytes: 4 * 1024 * 1024, + table_elements: 100, + ..Limits::default() + }); + let bytes = generated_component(|wat| { + wat.replace( + "(memory (;0;) 0)", + if memory { + "(memory (;0;) 40) (memory 40)" + } else { + "(memory (;0;) 0) (table 80 funcref) (table 80 funcref)" + }, + ) + }); + let component = compile(&ex, &artifact(&guest_manifest(), &bytes).unwrap()); + let error = call(&ex, &component, TestHost::new(Behavior::Respond), "fresh") + .await + .unwrap_err(); + let expected = if memory { + "aggregate memory budget exceeded" + } else { + "aggregate table budget exceeded" + }; + assert!( + matches!(&error, ExecutionError::Trap(message) if message.contains(expected)), + "{error:?}" + ); + } +} + +#[test] +fn operational_limits_cannot_raise_the_supported_ceiling() { + assert!( + Executor::new(Limits { + wall_time: Duration::MAX, + ..Limits::default() + }) + .is_err() + ); +} diff --git a/crates/plugin-runtime/tests/guest/Cargo.lock b/crates/plugin-runtime/tests/guest/Cargo.lock new file mode 100644 index 000000000..c9ddd828e --- /dev/null +++ b/crates/plugin-runtime/tests/guest/Cargo.lock @@ -0,0 +1,367 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "executor-test-guest" +version = "0.1.0" +dependencies = [ + "yoi-plugin-pdk", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "yoi-plugin-pdk" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror", + "wit-bindgen", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/plugin-runtime/tests/guest/Cargo.toml b/crates/plugin-runtime/tests/guest/Cargo.toml new file mode 100644 index 000000000..d10ebc574 --- /dev/null +++ b/crates/plugin-runtime/tests/guest/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "executor-test-guest" +version = "0.1.0" +edition = "2024" +publish = false + +[workspace] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +yoi-plugin-pdk = { path = "../../../plugin-pdk" } diff --git a/crates/plugin-runtime/tests/guest/src/lib.rs b/crates/plugin-runtime/tests/guest/src/lib.rs new file mode 100644 index 000000000..685a56fce --- /dev/null +++ b/crates/plugin-runtime/tests/guest/src/lib.rs @@ -0,0 +1,138 @@ +use yoi_plugin_pdk::wit_bindgen; +wit_bindgen::generate!({ + path: "../../../../resources/plugin/wit", + world: "https-logged-tool-plugin", + generate_all, + runtime_path: "yoi_plugin_pdk::wit_bindgen::rt", +}); +use exports::yoi::plugin::tool::{Guest, Invocation, Output, ToolError}; +use std::sync::atomic::{AtomicU32, Ordering}; +use yoi::host::{connections, http, logging}; + +static CALLS: AtomicU32 = AtomicU32::new(0); +struct Plugin; +impl Guest for Plugin { + fn invoke(input: Invocation) -> Result { + let count = CALLS.fetch_add(1, Ordering::SeqCst) + 1; + let mode = input.input_json.trim_matches('"'); + match mode { + "trap" => panic!("guest trap"), + "loop" => loop { + std::hint::black_box(CALLS.load(Ordering::SeqCst)); + }, + "wall-loop" => { + logging::log(&logging::Entry { + level: logging::Level::Info, + event_code: "count".into(), + message: "loop entered".into(), + fields: vec![], + }) + .map_err(failure)?; + loop { + std::hint::black_box(CALLS.load(Ordering::SeqCst)); + } + } + "memory" => { + core::arch::wasm32::memory_grow::<0>(8192); + } + "error-output" => { + return Err(ToolError { + code: "typed".into(), + message: "\"".repeat(40_000), + }); + } + "typed" | "unknown" => { + return Err(ToolError { + code: mode.into(), + message: "bounded business failure".into(), + }); + } + "logging" => { + for _ in 0..3 { + logging::log(&logging::Entry { + level: logging::Level::Info, + event_code: "count".into(), + message: "safe diagnostic".into(), + fields: vec![logging::Field { + name: "calls".into(), + value: logging::Scalar::Unsigned(count as u64), + }], + }) + .map_err(failure)?; + } + } + "http" | "stale" | "foreign" | "resources" => { + if mode == "resources" { + let _resources: Vec<_> = + (0..64).map(|_| connections::open("api").unwrap()).collect(); + } + let target = if mode == "foreign" { + // An attacker can fabricate a representation, not a capability. + unsafe { connections::Connection::from_handle(0) } + } else { + connections::open("api").map_err(failure)? + }; + let target = if mode == "stale" { + let handle = target.handle(); + drop(target); + unsafe { connections::Connection::from_handle(handle) } + } else { + target + }; + let response = http::send( + &target, + &http::Request { + method: http::Method::Get, + path: "/status".into(), + query: vec![], + headers: vec![], + body: vec![], + timeout_ms: 15_000, + effect_key: "one".into(), + }, + ) + .map_err(failure)?; + return Ok(Output { + summary: String::from_utf8(response.body).unwrap(), + content: None, + structured_json: Some(format!("{{\"calls\":{count}}}")), + }); + } + _ => (), + } + Ok(Output { + summary: if mode == "output" { + "x".repeat(70_000) + } else { + input.invocation_id + }, + content: None, + structured_json: Some(if mode == "bad-json" { + "not-json".into() + } else if mode == "bad-schema" { + "{}".into() + } else { + format!("{{\"calls\":{count}}}") + }), + }) + } +} +fn failure(error: yoi::host::errors::HostError) -> ToolError { + ToolError { + code: "host".into(), + message: format!( + "{}:{}", + match error.code { + yoi::host::errors::Code::DeadlineExceeded => "deadline-exceeded", + yoi::host::errors::Code::QuotaExceeded => "quota-exceeded", + _ => "host-rejected", + }, + match error.effect { + yoi::host::errors::EffectObservation::NotDispatched => "not-dispatched", + yoi::host::errors::EffectObservation::ResponseObserved => "response-observed", + yoi::host::errors::EffectObservation::Indeterminate => "indeterminate", + } + ), + } +} +export!(Plugin); diff --git a/package.nix b/package.nix index 004642674..78c2416fb 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-cLDfqFu6H9YjaL5EpTs5NE2GzkeS0qYcj5Ys/6U5u40="; + cargoHash = "sha256-bT0HZacyAThuZPFw7T7etyy80K0C8Y1tlJkhkjAjA34="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint, From 45e24375d5ab4f04e5bfcc0e51de2a2528e158d0 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 28 Oct 2026 17:14:25 +0900 Subject: [PATCH 2/2] fix: reject unmetered private Component resource allocators Address formal review R1 by failing closed on guest resource definitions and canonical resource.new in outer and nested Components before Store creation. Preserve shared artifact/ABI verification and imported Host connections; regress resource ceilings zero/default, 64 live handles in core start, nested definitions and fresh execution after rejection. --- Cargo.lock | 1 + crates/plugin-runtime/Cargo.toml | 1 + crates/plugin-runtime/README.md | 2 + crates/plugin-runtime/src/executor.rs | 7 ++- crates/plugin-runtime/src/sandbox.rs | 33 +++++++++++ crates/plugin-runtime/tests/executor.rs | 78 +++++++++++++++++++++++++ package.nix | 2 +- 7 files changed, 122 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c02cc76e6..63fbf2a18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7475,6 +7475,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", + "wasmparser 0.244.0", "wasmprinter 0.244.0", "wasmtime", "wat", diff --git a/crates/plugin-runtime/Cargo.toml b/crates/plugin-runtime/Cargo.toml index 6eeed5817..a24b8ff89 100644 --- a/crates/plugin-runtime/Cargo.toml +++ b/crates/plugin-runtime/Cargo.toml @@ -14,6 +14,7 @@ thiserror.workspace = true tokio = { workspace = true, features = ["rt", "macros", "sync", "time"] } tokio-util = { version = "0.7", features = ["rt"] } jsonschema = { version = "0.33.0", default-features = false } +wasmparser = "0.244.0" wasmtime = { version = "=38.0.4", default-features = false, features = ["async", "component-model", "cranelift", "runtime", "std", "threads"] } [dev-dependencies] diff --git a/crates/plugin-runtime/README.md b/crates/plugin-runtime/README.md index 440bff337..c70471a70 100644 --- a/crates/plugin-runtime/README.md +++ b/crates/plugin-runtime/README.md @@ -8,4 +8,6 @@ Component instantiation is not a Tool export: all Host imports, including loggin A single deadline covers admission, instantiation, guest execution, asynchronous Host waits, post-return and publication. Fuel bounds computation and cooperatively yields; epoch interruption cooperatively yields as well. Store memory/table/instance limits, call-local resource and operation counts, serialized payload and diagnostic budgets complement those bounds. Text/JSON overflows fail rather than returning truncated success; excess diagnostic entries are dropped and counted. Guest diagnostics are untrusted, must not contain secrets, and are separate from model output. Host adapters must not log raw request/response or credentials. +The Tool sandbox accepts only imported Host resources. Wasmtime's Store limiter does not meter guest-defined Component resource tables, so compile rejects private resource definitions and canonical `resource.new` throughout concrete nested Components before any Store exists. This is an additional execution-policy restriction after shared package/ABI verification, not a second package validator. It preserves the WIT connection imports, whose creation is metered by the adapter, without claiming a quota for unmetered internal resource allocators. Unsupported execution policy is distinct from corrupt artifact identity. + Compile results may be shared, but invocation memory, resource tables and imported state are never shared. The caller owns finite aggregate admission/concurrency quotas; per-invocation limits alone do not bound an unbounded number of callers. A validated digest is content identity, never admission authority. This standalone core is not connected to normal Worker launch/restore; product integration must supply the live Server provider. diff --git a/crates/plugin-runtime/src/executor.rs b/crates/plugin-runtime/src/executor.rs index 127eabf17..9023e4d41 100644 --- a/crates/plugin-runtime/src/executor.rs +++ b/crates/plugin-runtime/src/executor.rs @@ -54,6 +54,8 @@ impl Default for Limits { pub enum ExecutionError { #[error("invalid artifact: {0}")] Artifact(String), + #[error("unsupported Component execution policy: {0}")] + UnsupportedComponent(String), #[error("invalid invocation: {0}")] InvalidInput(String), #[error("invalid output: {0}")] @@ -165,7 +167,10 @@ impl Executor { ) .map_err(|e| ExecutionError::Artifact(e.message))?; let manifest = verified.package.manifest; - let component = Component::new(&self.engine, &verified.files[&manifest.runtime.component]) + let component_bytes = &verified.files[&manifest.runtime.component]; + crate::sandbox::check_component_resources(component_bytes) + .map_err(|e| ExecutionError::UnsupportedComponent(e.to_string()))?; + let component = Component::new(&self.engine, component_bytes) .map_err(|e| ExecutionError::Artifact(e.to_string()))?; Ok(CompiledTool { component, diff --git a/crates/plugin-runtime/src/sandbox.rs b/crates/plugin-runtime/src/sandbox.rs index f10daffac..9b8be9eb8 100644 --- a/crates/plugin-runtime/src/sandbox.rs +++ b/crates/plugin-runtime/src/sandbox.rs @@ -4,6 +4,39 @@ use crate::Limits; use wasmtime::{ResourceLimiter, StoreLimits, StoreLimitsBuilder}; +/// Additional execution-policy check, not a replacement ABI/package validator. +/// Wasmtime's Store limiter does not meter Component-internal resource tables. +/// The Tool ABI needs only imported Host resources, whose creation is metered +/// by our adapter; guest-defined resources and allocators therefore fail closed. +/// `parse_all` visits concrete nested Components as well as the outer one. +pub(crate) fn check_component_resources(bytes: &[u8]) -> wasmtime::Result<()> { + use wasmparser::{CanonicalFunction, ComponentType, Parser, Payload}; + for payload in Parser::new(0).parse_all(bytes) { + match payload? { + Payload::ComponentTypeSection(types) => { + for ty in types { + if matches!(ty?, ComponentType::Resource { .. }) { + return Err(wasmtime::Error::msg( + "guest-defined Component resources are unsupported by the Tool sandbox", + )); + } + } + } + Payload::ComponentCanonicalSection(functions) => { + for function in functions { + if matches!(function?, CanonicalFunction::ResourceNew { .. }) { + return Err(wasmtime::Error::msg( + "guest Component resource.new is unsupported by the Tool sandbox", + )); + } + } + } + _ => (), + } + } + Ok(()) +} + pub(crate) struct SandboxLimits { inner: StoreLimits, memory: Budget, diff --git a/crates/plugin-runtime/tests/executor.rs b/crates/plugin-runtime/tests/executor.rs index cc0241cb0..33d2d59b3 100644 --- a/crates/plugin-runtime/tests/executor.rs +++ b/crates/plugin-runtime/tests/executor.rs @@ -950,3 +950,81 @@ fn operational_limits_cannot_raise_the_supported_ceiling() { .is_err() ); } + +fn private_resource_component(nested_depth: usize, allocate: bool) -> Vec { + let mut payload = "(type $private (resource (rep i32)))".to_string(); + if allocate { + // The i32 drop discards a representation, not the live resource handle. + // A core start creates handles during Component instantiation without + // changing the T-791 fixture's public imports or exports. + payload.push_str(&format!( + r#" + (core func $new (canon resource.new $private)) + (core module $allocate + (import "r" "new" (func $new (param i32) (result i32))) + (func $start {}) + (start $start) + ) + (core instance $resource-funcs (export "new" (func $new))) + (core instance $allocated (instantiate $allocate (with "r" (instance $resource-funcs)))) + "#, + "i32.const 7 call $new drop ".repeat(64) + )); + } + for _ in 0..nested_depth { + payload = format!("(component $nested {payload}) (instance (instantiate $nested))"); + } + let wat = wasmprinter::print_bytes(PURE_COMPONENT).unwrap(); + let end = wat.rfind(')').unwrap(); + wat::parse_str(format!("{}{}{}", &wat[..end], payload, &wat[end..])).unwrap() +} + +#[tokio::test] +async fn private_and_nested_component_resources_fail_closed_before_instantiation() { + for resources in [0, Limits::default().resources] { + let ex = executor(Limits { + resources, + ..Limits::default() + }); + let fresh = compile(&ex, &artifact(PURE, PURE_COMPONENT).unwrap()); + let host = TestHost::new(Behavior::Respond); + for nested_depth in 0..=2 { + for allocate in [false, true] { + let bytes = private_resource_component(nested_depth, allocate); + // The canonical package/ABI is valid. Reject only the unsupported + // execution policy, after verification and before a Store exists. + let package = artifact(PURE, &bytes).unwrap(); + let error = ex + .compile( + &package.bytes, + &package.package_digest, + &package.artifact_digest, + package.artifact_size, + ) + .err() + .expect("private resources must be rejected"); + assert!( + matches!(&error, ExecutionError::UnsupportedComponent(message) if message.contains("guest-defined Component resources")), + "depth={nested_depth}, resources={resources}, allocate={allocate}: {error:?}" + ); + host.released(); + } + } + assert_eq!(host.admissions.load(Ordering::SeqCst), 0); + let result = ex + .invoke( + &fresh, + echo_context(&fresh, "fresh-after-rejection"), + host.clone(), + "example_echo", + r#"{"text":"fresh"}"#, + "{}", + ) + .await + .unwrap(); + let structured: serde_json::Value = + serde_json::from_str(result.output.structured_json.as_deref().unwrap()).unwrap(); + assert_eq!(structured["text"], "fresh"); + host.released(); + } +} diff --git a/package.nix b/package.nix index 78c2416fb..fc3bee854 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-bT0HZacyAThuZPFw7T7etyy80K0C8Y1tlJkhkjAjA34="; + cargoHash = "sha256-ikCD2DBORUchmnPk6bLZjgPVPbnNh4rVsSz43S164Eo="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint,