From d9e9c0f13deac6f04758a4364ee0ddfcafff13b8 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 28 Oct 2026 16:54:53 +0900 Subject: [PATCH] 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,