diff --git a/Cargo.lock b/Cargo.lock index 17c9eb51..197612d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -177,12 +168,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - [[package]] name = "arc-swap" version = "1.9.1" @@ -476,9 +461,6 @@ name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" -dependencies = [ - "allocator-api2", -] [[package]] name = "bytecount" @@ -662,15 +644,6 @@ 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" @@ -765,15 +738,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpp_demangle" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" -dependencies = [ - "cfg-if", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -792,148 +756,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cranelift-assembler-x64" -version = "0.132.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bc293b86236abcc45f2f72e2d18e2bd636f2a08b75eb286bae31e71e1430c91" -dependencies = [ - "cranelift-assembler-x64-meta", -] - -[[package]] -name = "cranelift-assembler-x64-meta" -version = "0.132.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b954c826eddaf1b001402cb8aecf1764c6f6d637ba69fb9e3311f1ebac965be6" -dependencies = [ - "cranelift-srcgen", -] - -[[package]] -name = "cranelift-bforest" -version = "0.132.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4053fa2575ef4a5c35d2708533df2200400ae979226cea9cc92a578b811bd4e7" -dependencies = [ - "cranelift-entity", - "wasmtime-internal-core", -] - -[[package]] -name = "cranelift-bitset" -version = "0.132.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d216663191014aa63e1d2cffd058e609eaf207646d40b739d88250f65b2c4f69" -dependencies = [ - "serde", - "serde_derive", - "wasmtime-internal-core", -] - -[[package]] -name = "cranelift-codegen" -version = "0.132.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a5e7e7aad6a425a51da1ad7ab9e5d280ea97eb7c7c4545fafb567915a75aadb" -dependencies = [ - "bumpalo", - "cranelift-assembler-x64", - "cranelift-bforest", - "cranelift-bitset", - "cranelift-codegen-meta", - "cranelift-codegen-shared", - "cranelift-control", - "cranelift-entity", - "cranelift-isle", - "gimli", - "hashbrown 0.17.1", - "libm", - "log", - "pulley-interpreter", - "regalloc2", - "rustc-hash", - "serde", - "smallvec", - "target-lexicon", - "wasmtime-internal-core", -] - -[[package]] -name = "cranelift-codegen-meta" -version = "0.132.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c421d80a9a85f806cb02a2983b5b5368a335c319795b1f1b4b771a24479af5b0" -dependencies = [ - "cranelift-assembler-x64-meta", - "cranelift-codegen-shared", - "cranelift-srcgen", - "heck", - "pulley-interpreter", -] - -[[package]] -name = "cranelift-codegen-shared" -version = "0.132.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78fdb83ab012d0ee6a44ced7ca8788a444f17cf821c62f95d6ef87c9f0262518" - -[[package]] -name = "cranelift-control" -version = "0.132.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b75adc6eb7bb4ac6365106afb6cac4f12fe1ddfa02ddc9fd7015ca1469b471b" -dependencies = [ - "arbitrary", -] - -[[package]] -name = "cranelift-entity" -version = "0.132.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668e56db75a54816cbdd7c7b7bfc558b08bf7b2cda9d0846491517e92f3b393b" -dependencies = [ - "cranelift-bitset", - "serde", - "serde_derive", - "wasmtime-internal-core", -] - -[[package]] -name = "cranelift-frontend" -version = "0.132.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c63892dc1cc3ae48680183fa66997f60ffe7f1e200c8d390f8ee66edff4aef5a" -dependencies = [ - "cranelift-codegen", - "log", - "smallvec", - "target-lexicon", -] - -[[package]] -name = "cranelift-isle" -version = "0.132.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94eaf429c32a12715429c7c6ddfdd43c170f4cdd7e97bfa507bd68a652091087" - -[[package]] -name = "cranelift-native" -version = "0.132.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd77674904ae9be11c1e1efdba54788b59f3d6658d747b97534bfbba2909aacc" -dependencies = [ - "cranelift-codegen", - "libc", - "target-lexicon", -] - -[[package]] -name = "cranelift-srcgen" -version = "0.132.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cba7c0ff5941842c36653da155580ce41e675c204a67ac1b4e1c478a9347bbb7" - [[package]] name = "crc32fast" version = "1.5.0" @@ -1374,18 +1196,6 @@ dependencies = [ "zeroize", ] -[[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" @@ -1788,18 +1598,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "gimli" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" -dependencies = [ - "fnv", - "hashbrown 0.16.1", - "indexmap", - "stable_deref_trait", -] - [[package]] name = "glob" version = "0.3.3" @@ -1922,11 +1720,6 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "foldhash 0.2.0", - "serde", - "serde_core", -] [[package]] name = "hashlink" @@ -2614,15 +2407,6 @@ 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" @@ -2713,15 +2497,6 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memfd" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" -dependencies = [ - "rustix 1.1.4", -] - [[package]] name = "memmap2" version = "0.9.10" @@ -3003,18 +2778,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f" -[[package]] -name = "object" -version = "0.39.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" -dependencies = [ - "crc32fast", - "hashbrown 0.17.1", - "indexmap", - "memchr", -] - [[package]] name = "oid-registry" version = "0.7.1" @@ -3410,18 +3173,6 @@ 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" @@ -3524,29 +3275,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "pulley-interpreter" -version = "45.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d9880c1985ccccaed3646b0ef793dc39a4b117403ed4afc6fa3ef6027c5200f" -dependencies = [ - "cranelift-bitset", - "log", - "pulley-macros", - "wasmtime-internal-core", -] - -[[package]] -name = "pulley-macros" -version = "45.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee249346855ad102580e474da5463f86f8a7d449e6d49e00fefb304e448e2983" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "quinn" version = "0.11.9" @@ -3824,20 +3552,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "regalloc2" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186" -dependencies = [ - "allocator-api2", - "bumpalo", - "hashbrown 0.17.1", - "log", - "rustc-hash", - "smallvec", -] - [[package]] name = "regex" version = "1.12.3" @@ -3971,12 +3685,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "rustc-demangle" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - [[package]] name = "rustc-hash" version = "2.1.2" @@ -4216,10 +3924,6 @@ 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" @@ -4536,9 +4240,6 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] [[package]] name = "socket2" @@ -4771,12 +4472,6 @@ 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" @@ -5702,27 +5397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser 0.244.0", -] - -[[package]] -name = "wasm-encoder" -version = "0.248.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac92cf547bc18d27ecc521015c08c353b4f18b84ab388bb6d1b6b682c620d9b6" -dependencies = [ - "leb128fmt", - "wasmparser 0.248.0", -] - -[[package]] -name = "wasm-encoder" -version = "0.252.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f" -dependencies = [ - "leb128fmt", - "wasmparser 0.252.0", + "wasmparser", ] [[package]] @@ -5733,8 +5408,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap", - "wasm-encoder 0.244.0", - "wasmparser 0.244.0", + "wasm-encoder", + "wasmparser", ] [[package]] @@ -5762,286 +5437,6 @@ dependencies = [ "semver", ] -[[package]] -name = "wasmparser" -version = "0.248.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a" -dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.17.1", - "indexmap", - "semver", - "serde", -] - -[[package]] -name = "wasmparser" -version = "0.252.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" -dependencies = [ - "bitflags 2.11.0", - "indexmap", - "semver", -] - -[[package]] -name = "wasmprinter" -version = "0.248.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b264a5410b008d4d199a92bf536eae703cbd614482fc1ec53831cf19e1c183" -dependencies = [ - "anyhow", - "termcolor", - "wasmparser 0.248.0", -] - -[[package]] -name = "wasmtime" -version = "45.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7ce9aa2c67f75fadcfdc6aa9097d03e7c39485dfe316f2ed6a7c0fd186c527" -dependencies = [ - "addr2line", - "async-trait", - "bitflags 2.11.0", - "bumpalo", - "cc", - "cfg-if", - "encoding_rs", - "libc", - "log", - "mach2", - "memfd", - "object", - "once_cell", - "postcard", - "pulley-interpreter", - "rustix 1.1.4", - "semver", - "serde", - "serde_derive", - "smallvec", - "target-lexicon", - "wasmparser 0.248.0", - "wasmtime-environ", - "wasmtime-internal-component-macro", - "wasmtime-internal-component-util", - "wasmtime-internal-core", - "wasmtime-internal-cranelift", - "wasmtime-internal-fiber", - "wasmtime-internal-jit-debug", - "wasmtime-internal-jit-icache-coherence", - "wasmtime-internal-unwinder", - "wasmtime-internal-versioned-export-macros", - "wasmtime-internal-winch", - "windows-sys 0.61.2", -] - -[[package]] -name = "wasmtime-environ" -version = "45.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8fb157bd1fbf689ac89d570433a700db6f33bdfcb5ffc30e3f1c49e4c70de71" -dependencies = [ - "anyhow", - "cpp_demangle", - "cranelift-bforest", - "cranelift-bitset", - "cranelift-entity", - "gimli", - "hashbrown 0.17.1", - "indexmap", - "log", - "object", - "postcard", - "rustc-demangle", - "semver", - "serde", - "serde_derive", - "sha2 0.10.9", - "smallvec", - "target-lexicon", - "wasm-encoder 0.248.0", - "wasmparser 0.248.0", - "wasmprinter", - "wasmtime-internal-component-util", - "wasmtime-internal-core", -] - -[[package]] -name = "wasmtime-internal-component-macro" -version = "45.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96c17f35fae2ab574667aba0c58fd56349a6f788ac42541a2e543116d5cfb91" -dependencies = [ - "anyhow", - "proc-macro2", - "quote", - "syn 2.0.117", - "wasmtime-internal-component-util", - "wasmtime-internal-wit-bindgen", - "wit-parser 0.248.0", -] - -[[package]] -name = "wasmtime-internal-component-util" -version = "45.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d2eeb9b53222859e6f5dc73d2ccfb33254d672469cac11b693a71912e2f3817" - -[[package]] -name = "wasmtime-internal-core" -version = "45.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1deaf6bc3430abd7497b00c64f06ca2b97ca0fe41af87836446ca30949965c" -dependencies = [ - "hashbrown 0.17.1", - "libm", - "serde", -] - -[[package]] -name = "wasmtime-internal-cranelift" -version = "45.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b845f83b5b04b11bc48329b53eb4fa8cf9f28a43c71ed8e1203f68ffa9806d1b" -dependencies = [ - "cfg-if", - "cranelift-codegen", - "cranelift-control", - "cranelift-entity", - "cranelift-frontend", - "cranelift-native", - "gimli", - "itertools", - "log", - "object", - "pulley-interpreter", - "smallvec", - "target-lexicon", - "thiserror 2.0.18", - "wasmparser 0.248.0", - "wasmtime-environ", - "wasmtime-internal-core", - "wasmtime-internal-unwinder", - "wasmtime-internal-versioned-export-macros", -] - -[[package]] -name = "wasmtime-internal-fiber" -version = "45.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10c8466f72965ae85c250f90aaa7992c089a2f8502009bd0d2c9e7d6409174a" -dependencies = [ - "cc", - "cfg-if", - "libc", - "rustix 1.1.4", - "wasmtime-environ", - "wasmtime-internal-versioned-export-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "wasmtime-internal-jit-debug" -version = "45.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d3adfecf5621b14d8f8871f4cb4ed9f844197b1ddefc702ef4c859552cd9551" -dependencies = [ - "cc", - "wasmtime-internal-versioned-export-macros", -] - -[[package]] -name = "wasmtime-internal-jit-icache-coherence" -version = "45.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d3c1e9fb618ec45c9b3477ea683cd37bee427273d7b13bba5c66a1caaf1dd6" -dependencies = [ - "cfg-if", - "libc", - "wasmtime-internal-core", - "windows-sys 0.61.2", -] - -[[package]] -name = "wasmtime-internal-unwinder" -version = "45.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aa91132b81f1e172ec7e7c3c114ac34209ee6b3524b3a8d6943af99803f66c5" -dependencies = [ - "cfg-if", - "cranelift-codegen", - "log", - "object", - "wasmtime-environ", -] - -[[package]] -name = "wasmtime-internal-versioned-export-macros" -version = "45.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea811ffe23f597cc7708327ea25d9eb018dcf760ffe15ccb7d0b27ad635de61" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "wasmtime-internal-winch" -version = "45.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "828b66175c54a0d00b4c1c1c76658d8aa73aeb9fa3553575c5eee56d40f2eb18" -dependencies = [ - "cranelift-codegen", - "gimli", - "log", - "object", - "target-lexicon", - "wasmparser 0.248.0", - "wasmtime-environ", - "wasmtime-internal-cranelift", - "winch-codegen", -] - -[[package]] -name = "wasmtime-internal-wit-bindgen" -version = "45.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae00896ad9bef1b3ca6401ae9a841daa6f357dd91541b6baf87082946d1bde1" -dependencies = [ - "anyhow", - "bitflags 2.11.0", - "heck", - "indexmap", - "wit-parser 0.248.0", -] - -[[package]] -name = "wast" -version = "252.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "942a3449d6a593fccc111a6241c8df52bda168af30e40bf9580d4394d7374c65" -dependencies = [ - "bumpalo", - "leb128fmt", - "memchr", - "unicode-width", - "wasm-encoder 0.252.0", -] - -[[package]] -name = "wat" -version = "1.252.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c72a4ba7088f7bac94cf516e49882bdf97068904a563768cf249efc839ec42cb" -dependencies = [ - "wast", -] - [[package]] name = "web-sys" version = "0.3.94" @@ -6266,25 +5661,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "winch-codegen" -version = "45.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89c09acfdfa281b3340e1e94ef3cf6618d69eab975280f881e154c29f49419c1" -dependencies = [ - "cranelift-assembler-x64", - "cranelift-codegen", - "gimli", - "regalloc2", - "smallvec", - "target-lexicon", - "thiserror 2.0.18", - "wasmparser 0.248.0", - "wasmtime-environ", - "wasmtime-internal-core", - "wasmtime-internal-cranelift", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -6548,7 +5924,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck", - "wit-parser 0.244.0", + "wit-parser", ] [[package]] @@ -6595,10 +5971,10 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder 0.244.0", + "wasm-encoder", "wasm-metadata", - "wasmparser 0.244.0", - "wit-parser 0.244.0", + "wasmparser", + "wit-parser", ] [[package]] @@ -6616,26 +5992,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.244.0", -] - -[[package]] -name = "wit-parser" -version = "0.248.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "247ad505da2915a082fe13204c5ba8788425aea1de54f43b284818cf82637856" -dependencies = [ - "anyhow", - "hashbrown 0.17.1", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser 0.248.0", + "wasmparser", ] [[package]] @@ -6698,11 +6055,8 @@ dependencies = [ "tracing", "tungstenite 0.28.0", "uuid", - "wasmtime", - "wat", "workdir", "workspace-api", - "yoi-plugin-pdk", ] [[package]] diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index d5ae7049..820f2af6 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -15,7 +15,6 @@ use serde::{Deserialize, Serialize}; use crate::defaults; use crate::model::{AuthRef, ModelManifest, ReasoningControl}; -use crate::plugin::PluginConfig; use crate::{ CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits, McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig, @@ -55,10 +54,6 @@ pub struct WorkerManifestConfig { /// disabled after cascade merge. #[serde(default)] pub feature: FeatureConfigPartial, - /// Explicit plugin package enablement entries. Discovery/resolution is a - /// separate step and does not run during config merge. - #[serde(default)] - pub plugins: PluginConfig, /// Explicit Model Context Protocol provider declarations. Config parsing /// never starts a local MCP subprocess. #[serde(default)] @@ -74,6 +69,7 @@ pub struct WorkerManifestConfig { } #[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct FeatureConfigPartial { #[serde(default)] pub task: Option, @@ -101,8 +97,6 @@ pub struct FeatureConfigPartial { pub merge_request: Option, #[serde(default)] pub orchestration: Option, - #[serde(default)] - pub plugins: Option, } impl FeatureConfigPartial { @@ -145,7 +139,6 @@ impl FeatureConfigPartial { other.orchestration, FeatureFlagConfigPartial::merge, ), - plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge), } } } @@ -370,10 +363,6 @@ impl From for FeatureConfig { .orchestration .map(FeatureFlagConfig::from) .unwrap_or_default(), - plugins: value - .plugins - .map(FeatureFlagConfig::from) - .unwrap_or_default(), } } } @@ -517,7 +506,6 @@ impl From for FeatureConfigPartial { ticket: Some(value.ticket.into()), merge_request: Some(value.merge_request.into()), orchestration: Some(value.orchestration.into()), - plugins: Some(value.plugins.into()), } } } @@ -654,6 +642,20 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er "unknown field in manifest: memory (removed; configure feature.memory)", )); } + if value.get("plugins").is_some() { + return Err(toml::de::Error::custom( + "unknown field in manifest: plugins (dynamic Plugins are not supported)", + )); + } + if value + .get("feature") + .and_then(toml::Value::as_table) + .is_some_and(|table| table.contains_key("plugins")) + { + return Err(toml::de::Error::custom( + "unknown field in manifest: feature.plugins (dynamic Plugins are not supported)", + )); + } if value .get("feature") .and_then(toml::Value::as_table) @@ -771,7 +773,6 @@ impl WorkerManifestConfig { PermissionConfigPartial::merge, ), feature: self.feature.merge(upper.feature), - plugins: merge_plugin_config(self.plugins, upper.plugins), mcp: merge_mcp_config(self.mcp, upper.mcp), compaction: merge_option( self.compaction, @@ -791,16 +792,6 @@ impl SkillsConfig { } } -fn merge_plugin_config(mut base: PluginConfig, upper: PluginConfig) -> PluginConfig { - let upper_has_resolved_plan = upper.has_resolved_plan(); - base.enabled.extend(upper.enabled); - if upper_has_resolved_plan { - base.resolved = upper.resolved; - base.diagnostics = upper.diagnostics; - } - base -} - fn merge_mcp_config(mut base: McpConfig, upper: McpConfig) -> McpConfig { base.stdio_servers.extend(upper.stdio_servers); base @@ -1289,7 +1280,6 @@ impl TryFrom for WorkerManifest { session, permissions, feature: FeatureConfig::from(cfg.feature), - plugins: cfg.plugins, mcp: cfg.mcp, compaction, web: cfg.web, @@ -1335,7 +1325,6 @@ mod tests { delegation_scope: ScopeConfig::default(), permissions: None, feature: FeatureConfigPartial::default(), - plugins: PluginConfig::default(), mcp: McpConfig::default(), session: None, compaction: None, diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index a7908702..af662f81 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -69,10 +69,6 @@ pub struct WorkerManifest { /// resolve disabled so Profile authors choose the exposed built-in surfaces. #[serde(default)] pub feature: FeatureConfig, - /// Explicit plugin package enablement. Discovery remains read-only; only - /// source-qualified entries listed here may resolve to active plugin metadata. - #[serde(default)] - pub plugins: plugin::PluginConfig, /// Explicit external Model Context Protocol provider configuration. This /// is config data only: declaring a server never starts a subprocess or /// grants OS sandboxing. Runtime MCP lifecycle/registration is a separate @@ -106,6 +102,7 @@ pub struct WorkerManifest { /// sessions, secrets, or resolved host state. Tool registration still applies /// the normal scope, host-authority, backend, memory, and network checks. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] pub struct FeatureConfig { #[serde(default)] pub task: FeatureFlagConfig, @@ -135,8 +132,6 @@ pub struct FeatureConfig { pub merge_request: MergeRequestFeatureConfig, #[serde(default)] pub orchestration: FeatureFlagConfig, - #[serde(default)] - pub plugins: FeatureFlagConfig, } impl Default for FeatureConfig { @@ -155,7 +150,6 @@ impl Default for FeatureConfig { ticket: TicketFeatureConfig::default(), merge_request: MergeRequestFeatureConfig::default(), orchestration: FeatureFlagConfig::disabled(), - plugins: FeatureFlagConfig::disabled(), } } } @@ -941,9 +935,7 @@ impl Default for CompactionConfig { impl WorkerManifest { pub fn requires_persisted_execution_snapshot(&self) -> bool { - self.profile.is_some() - || self.plugins.has_resolved_plan() - || self.feature.memory.workspace_settings.is_some() + self.profile.is_some() || self.feature.memory.workspace_settings.is_some() } /// Parse a manifest from a TOML string. @@ -1322,33 +1314,61 @@ model_id = "claude-sonnet-4-20250514" } #[test] - fn parse_plugin_enablement_config() { + fn dynamic_plugin_manifest_config_is_rejected() { let toml = format!( "{MINIMAL_REQUIRED}\n\ [[plugins.enabled]]\n\ - id = \"project:example\"\n\ - version = \"0.1.0\"\n\ - digest = \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n\ - surfaces = [\"hook\"]\n\n\ - [plugins.enabled.config]\n\ - greeting = \"hello\"\n" + id = \"project:example\"\n" ); - let manifest = WorkerManifest::from_toml(&toml).unwrap(); - assert_eq!(manifest.plugins.enabled.len(), 1); - let enabled = &manifest.plugins.enabled[0]; - assert_eq!(enabled.id, "project:example"); - assert_eq!( - enabled.version.as_ref().map(|version| version.0.as_str()), - Some("0.1.0") + let error = WorkerManifest::from_toml(&toml).unwrap_err(); + assert!( + error + .to_string() + .contains("dynamic Plugins are not supported"), + "unexpected error: {error}" ); - assert_eq!(enabled.surfaces, vec![plugin::PluginSurface::Hook]); - assert_eq!( - enabled - .config - .as_ref() - .and_then(|value| value.get("greeting")) - .and_then(|value| value.as_str()), - Some("hello") + } + + #[test] + fn persisted_manifest_with_dynamic_plugin_plan_is_rejected() { + let base = + serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap(); + + let mut top_level = base.clone(); + top_level.as_object_mut().unwrap().insert( + "plugins".to_string(), + serde_json::json!({ + "resolved": [{ + "package_path": "/tmp/ambient.yoi-plugin" + }] + }), + ); + let error = serde_json::from_value::(top_level).unwrap_err(); + assert!(error.to_string().contains("unknown field `plugins`")); + + let mut nested = base; + nested + .get_mut("feature") + .unwrap() + .as_object_mut() + .unwrap() + .insert( + "plugins".to_string(), + serde_json::json!({ "enabled": true }), + ); + let error = serde_json::from_value::(nested).unwrap_err(); + assert!(error.to_string().contains("unknown field `plugins`")); + } + + #[test] + fn dynamic_plugin_feature_flag_is_rejected() { + let toml = format!("{MINIMAL_REQUIRED}\n[feature.plugins]\nenabled = true\n"); + let error = WorkerManifest::from_toml(&toml).unwrap_err(); + assert!( + error + .to_string() + .contains("dynamic Plugins are not supported"), + "unexpected error: {error}" ); } diff --git a/crates/manifest/src/plugin.rs b/crates/manifest/src/plugin.rs index ddf77a05..1a14d8f2 100644 --- a/crates/manifest/src/plugin.rs +++ b/crates/manifest/src/plugin.rs @@ -91,118 +91,6 @@ pub const RUST_COMPONENT_INSTANCE_TEMPLATE: &[PluginTemplateResource] = &[ }, ]; -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct PluginConfig { - pub enabled: Vec, - /// Runtime restore metadata. Fresh resolution fills this from discovered packages; - /// restore uses it without selecting newer mutable-store contents. - pub resolved: Vec, - /// Safe bounded discovery/resolution diagnostics recorded with the resolved plan. - pub diagnostics: Vec, -} - -impl PluginConfig { - pub fn is_empty(&self) -> bool { - self.enabled.is_empty() && self.resolved.is_empty() - } - - pub fn has_resolved_plan(&self) -> bool { - !self.resolved.is_empty() || !self.diagnostics.is_empty() - } -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct PluginEnablementConfig { - /// Source-qualified plugin id such as `user:example`, `project:example`, or `builtin:example`. - pub id: String, - /// Optional exact package version requirement. Rich version constraints are deferred. - pub version: Option, - /// Optional deterministic digest pin in `sha256:` form. - pub digest: Option, - /// Optional explicit surface subset. When omitted, all declared package surfaces are selected. - pub surfaces: Vec, - /// Requested plugin grants. Non-empty authority-bearing grants currently fail closed. - pub grants: PluginGrantConfig, - /// Opaque plugin-local configuration copied into resolved metadata without interpretation. - pub config: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(transparent)] -pub struct PluginExactVersion(pub String); - -impl PluginExactVersion { - pub fn matches(&self, version: &str) -> bool { - self.0 == version - } -} - -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct PluginGrantConfig { - /// Source-qualified package id this grant is pinned to, for example `project:example`. - pub id: Option, - /// Exact package version this grant is pinned to. - pub version: Option, - /// Deterministic package digest this grant is pinned to. - pub digest: Option, - /// Explicit capabilities granted for the pinned package identity/version/digest. - pub permissions: Vec, - /// Bounded outbound request allowlist entries for `host_api.request`. - pub request: Vec, - /// Bounded outbound WebSocket target allowlist entries for `host_api.websocket`. - pub websocket: Vec, - /// Scoped filesystem allowlist entries for `host_api.fs`. - pub fs: Vec, -} - -impl PluginGrantConfig { - pub fn is_empty(&self) -> bool { - self.permissions.is_empty() - && self.request.is_empty() - && self.websocket.is_empty() - && self.fs.is_empty() - } - - pub fn binding_error( - &self, - identity: &SourceQualifiedPluginId, - digest: &str, - version: &str, - ) -> Option<&'static str> { - if self.is_empty() { - return None; - } - let Some(grant_id) = &self.id else { - return Some("plugin grant is missing a source-qualified package id binding"); - }; - match SourceQualifiedPluginId::parse(grant_id) { - Ok(grant_identity) if &grant_identity == identity => {} - Ok(_) => return Some("plugin grant package id binding does not match enabled package"), - Err(_) => { - return Some( - "plugin grant package id binding is not a valid source-qualified plugin id", - ); - } - } - let Some(grant_digest) = &self.digest else { - return Some("plugin grant is missing a deterministic digest binding"); - }; - if !digest_matches(grant_digest, digest) { - return Some("plugin grant digest binding does not match enabled package digest"); - } - let Some(grant_version) = &self.version else { - return Some("plugin grant is missing an exact package version binding"); - }; - if !grant_version.matches(version) { - return Some("plugin grant version binding does not match enabled package version"); - } - None - } -} - #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum PluginPermission { @@ -310,48 +198,6 @@ impl PluginWebSocketGrant { } } -#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct PluginFsGrant { - /// Absolute host path that bounds every relative `host_api.fs` request. - pub root: String, - /// Explicit operation kinds allowed below `root`; write does not imply read/list. - pub operations: Vec, -} - -impl PluginFsGrant { - pub fn label(&self) -> String { - let operations = if self.operations.is_empty() { - "".to_string() - } else { - self.operations - .iter() - .map(ToString::to_string) - .collect::>() - .join(",") - }; - format!("{} {}", self.root, operations) - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PluginFsOperation { - Read, - List, - Write, -} - -impl fmt::Display for PluginFsOperation { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Read => f.write_str("read"), - Self::List => f.write_str("list"), - Self::Write => f.write_str("write"), - } - } -} - impl PluginPermission { pub fn label(&self) -> String { match self { @@ -436,18 +282,12 @@ impl fmt::Display for PluginSurface { #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PluginSourceKind { - User, - Project, - Builtin, + Explicit, } impl PluginSourceKind { fn qualifier(self) -> &'static str { - match self { - PluginSourceKind::User => "user", - PluginSourceKind::Project => "project", - PluginSourceKind::Builtin => "builtin", - } + "explicit" } } @@ -479,9 +319,7 @@ impl SourceQualifiedPluginId { return Err(PluginIdParseError::InvalidLocalId); } let source = match source { - "user" => PluginSourceKind::User, - "project" => PluginSourceKind::Project, - "builtin" => PluginSourceKind::Builtin, + "explicit" => PluginSourceKind::Explicit, _ => return Err(PluginIdParseError::InvalidSource), }; Ok(Self { @@ -524,16 +362,14 @@ pub struct PluginPackageManifest { pub services: Vec, #[serde(default)] pub ingresses: Vec, - /// Permission requests declared by the package. These are requests only; - /// enablement grants must match them before runtime surfaces are exposed. + /// Permission declarations stored in the offline package. They grant no authority + /// and are not consumed by the current Worker runtime. #[serde(default)] pub permissions: Vec, - /// Manifest-declared URL targets for `host_api.request`. These are static permission requests; - /// enablement grants must explicitly approve matching targets. + /// URL target metadata retained in the offline package for a future installer. #[serde(default)] pub request: Vec, - /// Manifest-declared URL targets for `host_api.websocket`. These are independent from - /// `host_api.request` targets and require independent enablement grants. + /// WebSocket target metadata retained in the offline package for a future installer. #[serde(default)] pub websocket: Vec, } @@ -599,9 +435,8 @@ pub struct PluginToolManifest { pub name: String, pub description: String, pub input_schema: serde_json::Value, - /// Whether this Tool declares side effects outside the model-visible result. - /// The flag does not grant authority; it requires a matching external_write - /// request and grant before registration or execution. + /// Declares possible external side effects for a future installer. This flag + /// grants no authority and is not consumed by the current Worker runtime. #[serde(default)] pub external_write: bool, } @@ -635,8 +470,7 @@ pub struct PluginIngressManifest { } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct PluginDiscoveryLimits { - pub max_packages_per_store: usize, +pub struct PluginPackageLimits { pub max_package_size_bytes: u64, pub max_manifest_size_bytes: usize, pub max_entries_per_package: usize, @@ -644,10 +478,9 @@ pub struct PluginDiscoveryLimits { pub max_expanded_size_bytes: u64, } -impl Default for PluginDiscoveryLimits { +impl Default for PluginPackageLimits { fn default() -> Self { Self { - max_packages_per_store: 128, max_package_size_bytes: 16 * 1024 * 1024, max_manifest_size_bytes: 64 * 1024, max_entries_per_package: 512, @@ -657,37 +490,10 @@ impl Default for PluginDiscoveryLimits { } } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PluginDiscoveryOptions { - pub workspace_root: PathBuf, - pub user_data_home: Option, - pub limits: PluginDiscoveryLimits, -} - -impl PluginDiscoveryOptions { - pub fn new(workspace_root: impl Into) -> Self { - Self { - workspace_root: workspace_root.into(), - user_data_home: None, - limits: PluginDiscoveryLimits::default(), - } - } - - pub fn with_user_data_home(mut self, user_data_home: impl Into) -> Self { - self.user_data_home = Some(user_data_home.into()); - self - } - - pub fn with_limits(mut self, limits: PluginDiscoveryLimits) -> Self { - self.limits = limits; - self - } -} - #[derive(Clone, Debug, PartialEq)] -pub struct DiscoveredPluginPackage { +pub struct InspectedPluginPackage { pub identity: SourceQualifiedPluginId, - pub package_path: PathBuf, + pub input_path: PathBuf, pub package_label: String, pub digest: String, pub manifest: PluginPackageManifest, @@ -701,79 +507,14 @@ pub struct DiscoveredPluginPackage { /// execute Plugin code. #[derive(Clone, Debug, PartialEq)] pub struct MaterializedPluginPackage { - pub package: DiscoveredPluginPackage, + pub package: InspectedPluginPackage, pub files: BTreeMap>, } #[derive(Clone, Debug, PartialEq)] pub struct PackedPluginPackage { pub output_path: PathBuf, - pub package: DiscoveredPluginPackage, -} - -#[derive(Clone, Debug, Default, PartialEq)] -pub struct PluginDiscoveryReport { - pub packages: Vec, - pub diagnostics: Vec, -} - -impl PluginDiscoveryReport { - pub fn package(&self, identity: &SourceQualifiedPluginId) -> Vec<&DiscoveredPluginPackage> { - self.packages - .iter() - .filter(|package| &package.identity == identity) - .collect() - } -} - -#[derive(Clone, Debug, PartialEq)] -pub struct ResolvedPlugin { - pub identity: SourceQualifiedPluginId, - pub source: PluginSourceKind, - pub package_path: PathBuf, - pub package_label: String, - pub digest: String, - pub manifest: PluginPackageManifest, - pub enabled_surfaces: Vec, - pub grants: PluginGrantConfig, - pub config: Option, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct ResolvedPluginRecord { - pub identity: SourceQualifiedPluginId, - pub source: PluginSourceKind, - pub package_path: PathBuf, - pub package_label: String, - pub digest: String, - pub version: String, - pub manifest: PluginPackageManifest, - pub enabled_surfaces: Vec, - pub grants: PluginGrantConfig, - pub config: Option, -} - -impl ResolvedPluginRecord { - pub fn from_resolved(resolved: &ResolvedPlugin) -> Self { - Self { - identity: resolved.identity.clone(), - source: resolved.source, - package_path: resolved.package_path.clone(), - package_label: resolved.package_label.clone(), - digest: resolved.digest.clone(), - version: resolved.manifest.version.clone(), - manifest: resolved.manifest.clone(), - enabled_surfaces: resolved.enabled_surfaces.clone(), - grants: resolved.grants.clone(), - config: resolved.config.clone(), - } - } -} - -#[derive(Clone, Debug, Default, PartialEq)] -pub struct PluginResolution { - pub resolved: Vec, - pub diagnostics: Vec, + pub package: InspectedPluginPackage, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -818,11 +559,6 @@ impl PluginDiagnostic { self.package = Some(package.into()); self } - - fn with_digest(mut self, digest: impl Into) -> Self { - self.digest = Some(digest.into()); - self - } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -845,581 +581,20 @@ pub enum PluginDiagnosticKind { #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PluginDiagnosticPhase { - Discovery, + Inspection, Manifest, - Resolution, -} - -pub fn discover_plugins(options: &PluginDiscoveryOptions) -> PluginDiscoveryReport { - let mut report = PluginDiscoveryReport::default(); - let stores = plugin_stores(options); - - for store in stores { - discover_store(&store, &options.limits, &mut report); - } - - let mut counts: BTreeMap = BTreeMap::new(); - for package in &report.packages { - *counts.entry(package.identity.clone()).or_default() += 1; - } - for (identity, count) in counts { - if count > 1 { - report.diagnostics.push( - PluginDiagnostic::new( - PluginDiagnosticKind::Duplicate, - PluginDiagnosticPhase::Discovery, - "duplicate plugin package identity in one source store", - ) - .with_source(identity.source) - .with_identity(identity), - ); - } - } - - report.packages.sort_by(|left, right| { - left.identity - .cmp(&right.identity) - .then_with(|| left.digest.cmp(&right.digest)) - .then_with(|| left.package_label.cmp(&right.package_label)) - }); - report -} - -pub fn resolve_enabled_plugins( - config: &PluginConfig, - discovery: &PluginDiscoveryReport, -) -> PluginResolution { - let mut resolution = PluginResolution::default(); - - for enablement in &config.enabled { - let identity = match SourceQualifiedPluginId::parse(&enablement.id) { - Ok(identity) => identity, - Err(PluginIdParseError::Unqualified) => { - resolution.diagnostics.push( - PluginDiagnostic::new( - PluginDiagnosticKind::Ambiguous, - PluginDiagnosticPhase::Resolution, - "plugin enablement id must be source-qualified as user:, project:, or builtin:", - ) - .with_identity(&enablement.id), - ); - continue; - } - Err(PluginIdParseError::InvalidSource | PluginIdParseError::InvalidLocalId) => { - resolution.diagnostics.push( - PluginDiagnostic::new( - PluginDiagnosticKind::Malformed, - PluginDiagnosticPhase::Resolution, - "plugin enablement id is not a valid source-qualified plugin id", - ) - .with_identity(&enablement.id), - ); - continue; - } - }; - - let matches = discovery.package(&identity); - let package = match matches.as_slice() { - [] => { - resolution.diagnostics.push( - PluginDiagnostic::new( - PluginDiagnosticKind::Missing, - PluginDiagnosticPhase::Resolution, - "enabled plugin package was not discovered", - ) - .with_source(identity.source) - .with_identity(identity), - ); - continue; - } - [package] => *package, - _ => { - resolution.diagnostics.push( - PluginDiagnostic::new( - PluginDiagnosticKind::Duplicate, - PluginDiagnosticPhase::Resolution, - "enabled plugin package identity resolved to multiple discovered packages", - ) - .with_source(identity.source) - .with_identity(identity), - ); - continue; - } - }; - - if let Some(expected_digest) = &enablement.digest { - if !digest_matches(expected_digest, &package.digest) { - resolution.diagnostics.push( - PluginDiagnostic::new( - PluginDiagnosticKind::Digest, - PluginDiagnosticPhase::Resolution, - "enabled plugin digest pin does not match discovered package digest", - ) - .with_source(identity.source) - .with_identity(&identity) - .with_package(&package.package_label) - .with_digest(&package.digest), - ); - continue; - } - } - - if let Some(required_version) = &enablement.version { - if !required_version.matches(&package.manifest.version) { - resolution.diagnostics.push( - PluginDiagnostic::new( - PluginDiagnosticKind::Version, - PluginDiagnosticPhase::Resolution, - "enabled plugin exact version requirement does not match discovered package version", - ) - .with_source(identity.source) - .with_identity(&identity) - .with_package(&package.package_label) - .with_digest(&package.digest), - ); - continue; - } - } - - if let Some(message) = - enablement - .grants - .binding_error(&identity, &package.digest, &package.manifest.version) - { - resolution.diagnostics.push( - PluginDiagnostic::new( - PluginDiagnosticKind::Grant, - PluginDiagnosticPhase::Resolution, - message, - ) - .with_source(identity.source) - .with_identity(&identity) - .with_package(&package.package_label) - .with_digest(&package.digest), - ); - continue; - } - - let declared_surfaces = package.manifest.declared_surfaces(); - let selected_surfaces: BTreeSet<_> = if enablement.surfaces.is_empty() { - declared_surfaces.clone() - } else { - enablement.surfaces.iter().copied().collect() - }; - if let Some(surface) = selected_surfaces - .iter() - .find(|surface| !declared_surfaces.contains(surface)) - { - resolution.diagnostics.push( - PluginDiagnostic::new( - PluginDiagnosticKind::Surface, - PluginDiagnosticPhase::Resolution, - format!("enabled plugin requested undeclared surface `{surface}`"), - ) - .with_source(identity.source) - .with_identity(&identity) - .with_package(&package.package_label) - .with_digest(&package.digest), - ); - continue; - } - - resolution.resolved.push(ResolvedPlugin { - identity: identity.clone(), - source: identity.source, - package_path: package.package_path.clone(), - package_label: package.package_label.clone(), - digest: package.digest.clone(), - manifest: package.manifest.clone(), - enabled_surfaces: selected_surfaces.into_iter().collect(), - grants: enablement.grants.clone(), - config: enablement.config.clone(), - }); - } - - resolution -} - -pub fn resolve_plugin_config_for_startup( - config: &PluginConfig, - options: &PluginDiscoveryOptions, -) -> PluginConfig { - if config.enabled.is_empty() || config.has_resolved_plan() { - return config.clone(); - } - - let discovery = discover_plugins(options); - let resolution = resolve_enabled_plugins(config, &discovery); - let mut snapshot = config.clone(); - snapshot.resolved = resolution - .resolved - .iter() - .map(ResolvedPluginRecord::from_resolved) - .collect(); - snapshot.diagnostics = discovery.diagnostics; - snapshot.diagnostics.extend(resolution.diagnostics); - snapshot -} - -/// Reads the WebAssembly Component Model artifact selected by a resolved plugin -/// package manifest while preserving package digest pinning. -pub fn read_resolved_plugin_runtime_component( - record: &ResolvedPluginRecord, - limits: &PluginDiscoveryLimits, -) -> Result, PluginDiagnostic> { - let runtime = record.manifest.runtime.as_ref().ok_or_else(|| { - PluginDiagnostic::new( - PluginDiagnosticKind::Missing, - PluginDiagnosticPhase::Manifest, - "resolved plugin package does not declare a component runtime", - ) - .with_source(record.source) - .with_identity(&record.identity) - .with_package(&record.package_label) - .with_digest(&record.digest) - })?; - - if runtime.kind != PLUGIN_RUNTIME_COMPONENT_KIND { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Api, - PluginDiagnosticPhase::Manifest, - "plugin runtime kind is unsupported", - ) - .with_source(record.source) - .with_identity(&record.identity) - .with_package(&record.package_label) - .with_digest(&record.digest)); - } - if !matches!( - runtime.world.as_deref(), - Some(PLUGIN_COMPONENT_TOOL_WORLD) | Some(PLUGIN_COMPONENT_INSTANCE_WORLD) - ) { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Api, - PluginDiagnosticPhase::Manifest, - "plugin component world is unsupported", - ) - .with_source(record.source) - .with_identity(&record.identity) - .with_package(&record.package_label) - .with_digest(&record.digest)); - } - let component = runtime.component.as_deref().ok_or_else(|| { - PluginDiagnostic::new( - PluginDiagnosticKind::Missing, - PluginDiagnosticPhase::Manifest, - "plugin component runtime artifact is required", - ) - .with_source(record.source) - .with_identity(&record.identity) - .with_package(&record.package_label) - .with_digest(&record.digest) - })?; - - let metadata = fs::metadata(&record.package_path).map_err(|error| { - PluginDiagnostic::new( - PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Discovery, - format!( - "resolved plugin package metadata could not be read: {}", - safe_io_error(&error) - ), - ) - .with_source(record.source) - .with_identity(&record.identity) - .with_package(&record.package_label) - .with_digest(&record.digest) - })?; - if !metadata.is_file() { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Malformed, - PluginDiagnosticPhase::Discovery, - "resolved plugin package is not a regular file", - ) - .with_source(record.source) - .with_identity(&record.identity) - .with_package(&record.package_label) - .with_digest(&record.digest)); - } - if metadata.len() > limits.max_package_size_bytes { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Bounds, - PluginDiagnosticPhase::Discovery, - "resolved plugin package exceeds the configured package size bound", - ) - .with_source(record.source) - .with_identity(&record.identity) - .with_package(&record.package_label) - .with_digest(&record.digest)); - } - - let bytes = fs::read(&record.package_path).map_err(|error| { - PluginDiagnostic::new( - PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Discovery, - format!( - "resolved plugin package content could not be read: {}", - safe_io_error(&error) - ), - ) - .with_source(record.source) - .with_identity(&record.identity) - .with_package(&record.package_label) - .with_digest(&record.digest) - })?; - let archive = parse_stored_zip(&bytes, &record.package_label, record.source, limits)?; - let actual_digest = deterministic_digest(&archive.files); - if !digest_matches(&record.digest, &actual_digest) { - return Err(PluginDiagnostic::new( - PluginDiagnosticKind::Digest, - PluginDiagnosticPhase::Resolution, - "resolved plugin package digest does not match current package content", - ) - .with_source(record.source) - .with_identity(&record.identity) - .with_package(&record.package_label) - .with_digest(actual_digest)); - } - - validate_manifest_path( - component, - &archive, - &record.package_label, - record.source, - &record.manifest.id, - )?; - let normalized = normalize_archive_path(component).ok_or_else(|| { - PluginDiagnostic::new( - PluginDiagnosticKind::Traversal, - PluginDiagnosticPhase::Manifest, - "plugin manifest references a path outside the package root", - ) - .with_source(record.source) - .with_identity(&record.identity) - .with_package(&record.package_label) - .with_digest(&record.digest) - })?; - archive.files.get(&normalized).cloned().ok_or_else(|| { - PluginDiagnostic::new( - PluginDiagnosticKind::Missing, - PluginDiagnosticPhase::Manifest, - "plugin runtime component artifact is missing from the package", - ) - .with_source(record.source) - .with_identity(&record.identity) - .with_package(&record.package_label) - .with_digest(&record.digest) - }) -} - -#[derive(Clone, Debug)] -struct PluginStore { - source: PluginSourceKind, - path: PathBuf, -} - -fn plugin_stores(options: &PluginDiscoveryOptions) -> Vec { - let user_data_home = options - .user_data_home - .clone() - .or_else(|| std::env::var_os("XDG_DATA_HOME").map(PathBuf::from)) - .or_else(|| { - std::env::var_os("HOME") - .map(PathBuf::from) - .map(|home| home.join(".local/share")) - }); - - let mut stores = Vec::new(); - if let Some(user_data_home) = user_data_home { - stores.push(PluginStore { - source: PluginSourceKind::User, - path: user_data_home.join("yoi/plugins"), - }); - } - stores.push(PluginStore { - source: PluginSourceKind::Project, - path: options.workspace_root.join(".yoi/plugins"), - }); - stores -} - -fn discover_store( - store: &PluginStore, - limits: &PluginDiscoveryLimits, - report: &mut PluginDiscoveryReport, -) { - let canonical_store = match fs::canonicalize(&store.path) { - Ok(path) => path, - Err(error) if error.kind() == io::ErrorKind::NotFound => return, - Err(error) => { - report.diagnostics.push( - PluginDiagnostic::new( - PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Discovery, - format!("plugin store could not be read: {}", safe_io_error(&error)), - ) - .with_source(store.source), - ); - return; - } - }; - - let entries = match fs::read_dir(&canonical_store) { - Ok(entries) => entries, - Err(error) => { - report.diagnostics.push( - PluginDiagnostic::new( - PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Discovery, - format!( - "plugin store could not be listed: {}", - safe_io_error(&error) - ), - ) - .with_source(store.source), - ); - return; - } - }; - - let mut candidates = Vec::new(); - for entry in entries { - let Ok(entry) = entry else { - continue; - }; - let path = entry.path(); - if path.extension().and_then(|extension| extension.to_str()) == Some("yoi-plugin") { - candidates.push(path); - } - } - candidates.sort(); - - if candidates.len() > limits.max_packages_per_store { - report.diagnostics.push( - PluginDiagnostic::new( - PluginDiagnosticKind::Bounds, - PluginDiagnosticPhase::Discovery, - "plugin store contains more packages than the configured discovery bound", - ) - .with_source(store.source), - ); - candidates.truncate(limits.max_packages_per_store); - } - - for candidate in candidates { - let label = package_label(&candidate); - let canonical_candidate = match fs::canonicalize(&candidate) { - Ok(path) => path, - Err(error) => { - report.diagnostics.push( - PluginDiagnostic::new( - PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Discovery, - format!( - "plugin package could not be read: {}", - safe_io_error(&error) - ), - ) - .with_source(store.source) - .with_package(label), - ); - continue; - } - }; - if !canonical_candidate.starts_with(&canonical_store) { - report.diagnostics.push( - PluginDiagnostic::new( - PluginDiagnosticKind::Traversal, - PluginDiagnosticPhase::Discovery, - "plugin package path escapes its source store", - ) - .with_source(store.source) - .with_package(package_label(&candidate)), - ); - continue; - } - let metadata = match fs::metadata(&canonical_candidate) { - Ok(metadata) => metadata, - Err(error) => { - report.diagnostics.push( - PluginDiagnostic::new( - PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Discovery, - format!( - "plugin package metadata could not be read: {}", - safe_io_error(&error) - ), - ) - .with_source(store.source) - .with_package(label), - ); - continue; - } - }; - if !metadata.is_file() { - report.diagnostics.push( - PluginDiagnostic::new( - PluginDiagnosticKind::Malformed, - PluginDiagnosticPhase::Discovery, - "plugin package candidate is not a regular file", - ) - .with_source(store.source) - .with_package(label), - ); - continue; - } - if metadata.len() > limits.max_package_size_bytes { - report.diagnostics.push( - PluginDiagnostic::new( - PluginDiagnosticKind::Bounds, - PluginDiagnosticPhase::Discovery, - "plugin package exceeds the configured package size bound", - ) - .with_source(store.source) - .with_package(label), - ); - continue; - } - - match read_package(&canonical_candidate, &label, store.source, limits) { - Ok(package) => report.packages.push(package), - Err(diagnostic) => report.diagnostics.push(diagnostic), - } - } -} - -fn read_package( - path: &Path, - label: &str, - source: PluginSourceKind, - limits: &PluginDiscoveryLimits, -) -> Result { - let bytes = fs::read(path).map_err(|error| { - PluginDiagnostic::new( - PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Discovery, - format!( - "plugin package content could not be read: {}", - safe_io_error(&error) - ), - ) - .with_source(source) - .with_package(label) - })?; - materialize_archive(path, label, source, &bytes, limits) - .map(|materialized| materialized.package) } pub fn read_plugin_package_file( path: &Path, source: PluginSourceKind, - limits: &PluginDiscoveryLimits, + limits: &PluginPackageLimits, ) -> Result { let label = package_label(path); let metadata = fs::metadata(path).map_err(|error| { PluginDiagnostic::new( PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, format!( "plugin package metadata could not be read: {}", safe_io_error(&error) @@ -1431,7 +606,7 @@ pub fn read_plugin_package_file( if !metadata.is_file() { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Malformed, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin package candidate is not a regular file", ) .with_source(source) @@ -1440,7 +615,7 @@ pub fn read_plugin_package_file( if metadata.len() > limits.max_package_size_bytes { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Bounds, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin package exceeds the configured package size bound", ) .with_source(source) @@ -1449,7 +624,7 @@ pub fn read_plugin_package_file( let bytes = fs::read(path).map_err(|error| { PluginDiagnostic::new( PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, format!( "plugin package content could not be read: {}", safe_io_error(&error) @@ -1464,13 +639,13 @@ pub fn read_plugin_package_file( pub fn read_plugin_directory( path: &Path, source: PluginSourceKind, - limits: &PluginDiscoveryLimits, + limits: &PluginPackageLimits, ) -> Result { let label = package_label(path); let root = fs::canonicalize(path).map_err(|error| { PluginDiagnostic::new( PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, format!( "plugin directory could not be read: {}", safe_io_error(&error) @@ -1482,7 +657,7 @@ pub fn read_plugin_directory( let metadata = fs::metadata(&root).map_err(|error| { PluginDiagnostic::new( PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, format!( "plugin directory metadata could not be read: {}", safe_io_error(&error) @@ -1494,7 +669,7 @@ pub fn read_plugin_directory( if !metadata.is_dir() { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Malformed, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin directory input is not a directory", ) .with_source(source) @@ -1509,7 +684,7 @@ pub fn read_plugin_directory( pub fn write_plugin_package_file( materialized: &MaterializedPluginPackage, output_path: &Path, - limits: &PluginDiscoveryLimits, + limits: &PluginPackageLimits, ) -> Result { write_stored_zip_file(output_path, &materialized.files, limits)?; let package = read_plugin_package_file(output_path, materialized.package.source(), limits)?; @@ -1519,7 +694,7 @@ pub fn write_plugin_package_file( }) } -impl DiscoveredPluginPackage { +impl InspectedPluginPackage { pub fn source(&self) -> PluginSourceKind { self.identity.source } @@ -1530,7 +705,7 @@ fn materialize_archive( label: &str, source: PluginSourceKind, bytes: &[u8], - limits: &PluginDiscoveryLimits, + limits: &PluginPackageLimits, ) -> Result { let archive = parse_stored_zip(bytes, label, source, limits)?; materialize_files(path, label.to_string(), source, archive.files, limits) @@ -1541,7 +716,7 @@ fn materialize_files( label: String, source: PluginSourceKind, files: BTreeMap>, - limits: &PluginDiscoveryLimits, + limits: &PluginPackageLimits, ) -> Result { let archive = StoredArchive { files: files.clone(), @@ -1585,9 +760,9 @@ fn materialize_files( validate_manifest(&manifest, &archive, &label, source)?; let digest = deterministic_digest(&archive.files); let identity = SourceQualifiedPluginId::new(source, manifest.id.clone()); - let package = DiscoveredPluginPackage { + let package = InspectedPluginPackage { identity, - package_path: path.to_path_buf(), + input_path: path.to_path_buf(), package_label: label, digest, manifest, @@ -1601,14 +776,14 @@ fn collect_directory_files( dir: &Path, label: &str, source: PluginSourceKind, - limits: &PluginDiscoveryLimits, + limits: &PluginPackageLimits, files: &mut BTreeMap>, ) -> Result<(), PluginDiagnostic> { let mut entries = fs::read_dir(dir) .map_err(|error| { PluginDiagnostic::new( PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, format!( "plugin directory could not be listed: {}", safe_io_error(&error) @@ -1625,7 +800,7 @@ fn collect_directory_files( let metadata = fs::symlink_metadata(&path).map_err(|error| { PluginDiagnostic::new( PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, format!( "plugin directory entry metadata could not be read: {}", safe_io_error(&error) @@ -1637,7 +812,7 @@ fn collect_directory_files( if metadata.file_type().is_symlink() { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Traversal, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin directory contains a symlink entry", ) .with_source(source) @@ -1653,7 +828,7 @@ fn collect_directory_files( if metadata.len() > limits.max_file_size_bytes { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Bounds, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin directory file exceeds the configured per-file bound", ) .with_source(source) @@ -1662,7 +837,7 @@ fn collect_directory_files( let canonical = fs::canonicalize(&path).map_err(|error| { PluginDiagnostic::new( PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, format!( "plugin directory file could not be read: {}", safe_io_error(&error) @@ -1674,7 +849,7 @@ fn collect_directory_files( if !canonical.starts_with(root) { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Traversal, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin directory file escapes the package root", ) .with_source(source) @@ -1683,7 +858,7 @@ fn collect_directory_files( let relative = canonical.strip_prefix(root).map_err(|_| { PluginDiagnostic::new( PluginDiagnosticKind::Traversal, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin directory file escapes the package root", ) .with_source(source) @@ -1697,7 +872,7 @@ fn collect_directory_files( .ok_or_else(|| { PluginDiagnostic::new( PluginDiagnosticKind::Traversal, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin directory contains an unsafe relative path", ) .with_source(source) @@ -1706,7 +881,7 @@ fn collect_directory_files( let content = fs::read(&path).map_err(|error| { PluginDiagnostic::new( PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, format!( "plugin directory file could not be read: {}", safe_io_error(&error) @@ -1719,7 +894,7 @@ fn collect_directory_files( if files.len() > limits.max_entries_per_package { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Bounds, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin directory contains more files than the configured bound", ) .with_source(source) @@ -1732,7 +907,7 @@ fn collect_directory_files( if expanded_size > limits.max_expanded_size_bytes { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Bounds, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin directory expanded size exceeds the configured bound", ) .with_source(source) @@ -1745,12 +920,12 @@ fn collect_directory_files( fn write_stored_zip_file( output_path: &Path, files: &BTreeMap>, - limits: &PluginDiscoveryLimits, + limits: &PluginPackageLimits, ) -> Result<(), PluginDiagnostic> { if files.len() > limits.max_entries_per_package { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Bounds, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin package contains more entries than the configured bound", )); } @@ -1760,14 +935,14 @@ fn write_stored_zip_file( let name = normalize_archive_path(name).ok_or_else(|| { PluginDiagnostic::new( PluginDiagnosticKind::Traversal, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin package entry path escapes the archive root", ) })?; if content.len() as u64 > limits.max_file_size_bytes { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Bounds, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin package entry exceeds the configured per-file bound", )); } @@ -1818,7 +993,7 @@ fn write_stored_zip_file( if bytes.len() as u64 > limits.max_package_size_bytes { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Bounds, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin package exceeds the configured package size bound", )); } @@ -1829,7 +1004,7 @@ fn write_stored_zip_file( fs::create_dir_all(parent).map_err(|error| { PluginDiagnostic::new( PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, format!( "plugin package output directory could not be created: {}", safe_io_error(&error) @@ -1840,7 +1015,7 @@ fn write_stored_zip_file( fs::write(output_path, bytes).map_err(|error| { PluginDiagnostic::new( PluginDiagnosticKind::Io, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, format!( "plugin package output could not be written: {}", safe_io_error(&error) @@ -2063,7 +1238,7 @@ fn parse_stored_zip( bytes: &[u8], label: &str, source: PluginSourceKind, - limits: &PluginDiscoveryLimits, + limits: &PluginPackageLimits, ) -> Result { let eocd_offset = find_eocd(bytes).ok_or_else(|| { malformed_zip(label, source, "zip end-of-central-directory was not found") @@ -2092,7 +1267,7 @@ fn parse_stored_zip( if entry_count > limits.max_entries_per_package { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Bounds, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin package contains more entries than the configured bound", ) .with_source(source) @@ -2162,7 +1337,7 @@ fn parse_stored_zip( let name = normalize_archive_path(raw_name).ok_or_else(|| { PluginDiagnostic::new( PluginDiagnosticKind::Traversal, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin package entry path escapes the archive root", ) .with_source(source) @@ -2198,7 +1373,7 @@ fn parse_stored_zip( if is_zip_symlink(entry.external_attributes) { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Traversal, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin package contains a symlink entry", ) .with_source(source) @@ -2210,7 +1385,7 @@ fn parse_stored_zip( if entry.compression_method != ZIP_COMPRESSION_STORED { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Api, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin package uses an unsupported zip compression method", ) .with_source(source) @@ -2219,7 +1394,7 @@ fn parse_stored_zip( if u64::from(entry.uncompressed_size) > limits.max_file_size_bytes { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Bounds, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin package entry exceeds the configured per-file bound", ) .with_source(source) @@ -2229,7 +1404,7 @@ fn parse_stored_zip( if expanded_size > limits.max_expanded_size_bytes { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Bounds, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin package expanded size exceeds the configured bound", ) .with_source(source) @@ -2248,7 +1423,7 @@ fn parse_stored_zip( if files.insert(entry.name.clone(), data).is_some() { return Err(PluginDiagnostic::new( PluginDiagnosticKind::Duplicate, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, "plugin package contains duplicate normalized entry paths", ) .with_source(source) @@ -2326,7 +1501,7 @@ fn malformed_zip( ) -> PluginDiagnostic { PluginDiagnostic::new( PluginDiagnosticKind::Malformed, - PluginDiagnosticPhase::Discovery, + PluginDiagnosticPhase::Inspection, message, ) .with_source(source) @@ -2348,14 +1523,6 @@ fn deterministic_digest(files: &BTreeMap>) -> String { format!("sha256:{}", hex_lower(&hasher.finalize())) } -fn digest_matches(expected: &str, actual: &str) -> bool { - if let Some(hex) = expected.strip_prefix("sha256:") { - actual.strip_prefix("sha256:") == Some(hex) - } else { - false - } -} - fn hex_lower(bytes: &[u8]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let mut out = String::with_capacity(bytes.len() * 2); @@ -2460,1001 +1627,60 @@ fn is_safe_id(value: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use tempfile::TempDir; + use tempfile::tempdir; - #[test] - fn embedded_rust_component_tool_template_is_valid_package_shape() { - let paths: BTreeSet<_> = RUST_COMPONENT_TOOL_TEMPLATE - .iter() - .map(|file| file.path) - .collect(); - assert_eq!( - paths, - BTreeSet::from([ - "Cargo.toml", - "src/lib.rs", - "plugin.toml", - "plugin.component.wasm", - "README.md", - ]) - ); - assert!( - RUST_COMPONENT_TOOL_TEMPLATE - .iter() - .all(|file| !file.path.starts_with('/') && !file.path.contains("..")) - ); - - let manifest_text = RUST_COMPONENT_TOOL_TEMPLATE - .iter() - .find(|file| file.path == "plugin.toml") - .unwrap() - .contents; - let manifest: PluginPackageManifest = toml::from_str(manifest_text).unwrap(); - assert_eq!(manifest.schema_version, SUPPORTED_PLUGIN_API_VERSION); - assert_eq!( - manifest.runtime.as_ref().unwrap().kind, - PLUGIN_RUNTIME_COMPONENT_KIND - ); - assert_eq!( - manifest.runtime.as_ref().unwrap().world.as_deref(), - Some(PLUGIN_COMPONENT_TOOL_WORLD) - ); - assert_eq!(manifest.tools.len(), 1); + fn materialize_template(root: &Path) { + for resource in RUST_COMPONENT_TOOL_TEMPLATE { + let path = root.join(resource.path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, resource.contents).unwrap(); + } } #[test] - fn request_host_api_manifest_and_grant_parse_with_request_names() { - let manifest: PluginPackageManifest = toml::from_str( - r#" -schema_version = 1 -id = "example" -name = "Example" -version = "1.0.0" -description = "Example plugin" -surfaces = ["tool"] + fn explicit_package_round_trip_does_not_discover_ambient_stores() { + let dir = tempdir().unwrap(); + let source = dir.path().join("source"); + let ambient = dir.path().join(".yoi/plugins/ignored.yoi-plugin"); + fs::create_dir_all(&source).unwrap(); + fs::create_dir_all(ambient.parent().unwrap()).unwrap(); + fs::write(&ambient, b"malformed ambient package").unwrap(); + materialize_template(&source); -[[permissions]] -kind = "host_api" -api = "request" + let limits = PluginPackageLimits::default(); + let package = read_plugin_directory(&source, PluginSourceKind::Explicit, &limits).unwrap(); + let output = dir.path().join("explicit.yoi-plugin"); + write_plugin_package_file(&package, &output, &limits).unwrap(); + let reread = + read_plugin_package_file(&output, PluginSourceKind::Explicit, &limits).unwrap(); -[[request]] -scheme = "https" -host = "api.example.com" -port = 443 -methods = ["GET", "POST"] -path_prefixes = ["/v1/"] -"#, - ) - .unwrap(); - assert_eq!( - manifest.permissions, - vec![PluginPermission::host_api(PluginHostApi::Request)] - ); - assert_eq!(manifest.request.len(), 1); - assert_eq!(manifest.request[0].scheme, "https"); - assert_eq!(manifest.request[0].host, "api.example.com"); - assert_eq!(manifest.request[0].port, Some(443)); - assert_eq!( - manifest.request[0].label(), - "https://api.example.com:443 GET,POST /v1/" - ); - - let grants: PluginGrantConfig = toml::from_str( - r#" -permissions = [{ kind = "host_api", api = "request" }] - -[[request]] -scheme = "http" -host = "localhost" -port = 8080 -methods = ["GET"] -path_prefixes = ["/health"] -"#, - ) - .unwrap(); - assert_eq!( - grants.permissions, - vec![PluginPermission::host_api(PluginHostApi::Request)] - ); - assert_eq!(grants.request[0].scheme, "http"); - assert_eq!(grants.request[0].host, "localhost"); + assert_eq!(reread.package.identity.source, PluginSourceKind::Explicit); + assert_eq!(reread.package.digest, package.package.digest); } #[test] - fn legacy_https_request_names_are_not_accepted() { - let manifest_error = toml::from_str::( - r#" -schema_version = 1 -id = "example" -name = "Example" -version = "1.0.0" -description = "Example plugin" -surfaces = ["tool"] - -[[permissions]] -kind = "host_api" -api = "https" -"#, - ) - .expect_err(concat!( - "host_api.", - "https", - " must not be an active alias" - )); - assert!(manifest_error.to_string().contains("unknown variant")); - - let grant_error = toml::from_str::( - r#" -permissions = [{ kind = "host_api", api = "request" }] - -[[https]] -host = "api.example.com" -methods = ["GET"] -"#, - ) - .expect_err(concat!("grants.", "https", " must not be an active alias")); - assert!(grant_error.to_string().contains("unknown field")); - } - - #[test] - fn embedded_rust_component_instance_template_is_valid_package_shape() { - let paths: BTreeSet<_> = RUST_COMPONENT_INSTANCE_TEMPLATE - .iter() - .map(|file| file.path) - .collect(); - assert_eq!( - paths, - BTreeSet::from([ - "Cargo.toml", - "src/lib.rs", - "plugin.toml", - "plugin.component.wasm", - "README.md" - ]) - ); - assert!( - RUST_COMPONENT_INSTANCE_TEMPLATE - .iter() - .all(|file| !file.path.starts_with('/') && !file.path.contains("..")) - ); - let manifest_text = RUST_COMPONENT_INSTANCE_TEMPLATE - .iter() - .find(|file| file.path == "plugin.toml") - .unwrap() - .contents; - let manifest: PluginPackageManifest = toml::from_str(manifest_text).unwrap(); - assert_eq!( - manifest.runtime.as_ref().unwrap().world.as_deref(), - Some(PLUGIN_COMPONENT_INSTANCE_WORLD) - ); - assert_eq!(manifest.services.len(), 1); - assert_eq!(manifest.ingresses.len(), 1); - assert!( - manifest - .declared_surfaces() - .contains(&PluginSurface::Service) - ); - assert!( - manifest - .declared_surfaces() - .contains(&PluginSurface::Ingress) - ); - } - - #[test] - fn service_ingress_require_instance_component_world() { - let manifest: PluginPackageManifest = toml::from_str( - r#" -schema_version = 1 -id = "bad.service" -name = "Bad Service" -version = "0.1.0" -surfaces = ["service"] -permissions = [{ kind = "surface", surface = "service" }, { kind = "service", name = "svc" }] - -[runtime] -kind = "wasm-component" -world = "yoi:plugin/tool@1.0.0" -component = "plugin.component.wasm" - -[[services]] -name = "svc" -description = "bad" -"#, - ) - .unwrap(); - let archive = StoredArchive { - files: BTreeMap::from([("plugin.component.wasm".to_string(), b"placeholder".to_vec())]), - }; - let err = validate_manifest( - &manifest, - &archive, - "bad.service", - PluginSourceKind::Project, - ) - .unwrap_err(); - assert!(err.message.contains("service/ingress")); - } - - #[test] - fn legacy_raw_wasm_runtime_manifest_is_rejected() { - let temp = TempDir::new().unwrap(); - let workspace = temp.path().join("workspace"); - let plugins = workspace.join(".yoi/plugins"); - fs::create_dir_all(&plugins).unwrap(); - let manifest = r#" -schema_version = 1 -id = "legacy" -name = "Legacy" -version = "0.1.0" -surfaces = ["tool"] - -[runtime] -kind = "wasm" -entry = "plugin.wasm" -abi = "yoi-plugin-wasm-1" - -[[tools]] -name = "Echo" -description = "legacy" -input_schema = { type = "object" } -"#; - write_stored_zip( - &plugins.join("legacy.yoi-plugin"), - &[ - ("plugin.toml", manifest.as_bytes().to_vec(), 0), - ("plugin.wasm", b"not wasm".to_vec(), 0), - ], - ); - - let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace)); - - assert!(report.packages.is_empty()); - let diagnostic = report - .diagnostics - .iter() - .find(|diag| diag.kind == PluginDiagnosticKind::Api) + fn production_source_has_no_ambient_plugin_authority() { + let source = include_str!("plugin.rs") + .split("#[cfg(test)]") + .next() .unwrap(); - assert_eq!(diagnostic.phase, PluginDiagnosticPhase::Manifest); - assert_eq!(diagnostic.identity.as_deref(), Some("project:legacy")); - assert!(diagnostic.message.contains("legacy raw wasm")); - assert!(diagnostic.message.contains(PLUGIN_RUNTIME_COMPONENT_KIND)); - } - - #[test] - fn discovers_valid_user_and_workspace_packages() { - let temp = TempDir::new().unwrap(); - let workspace = temp.path().join("workspace"); - let user_data = temp.path().join("data"); - fs::create_dir_all(workspace.join(".yoi/plugins")).unwrap(); - fs::create_dir_all(user_data.join("yoi/plugins")).unwrap(); - write_plugin( - &user_data.join("yoi/plugins/user-one.yoi-plugin"), - "user_one", - &[PluginSurface::Hook], - &[("hooks/user.md", b"hello".as_slice())], - ); - write_plugin( - &workspace.join(".yoi/plugins/project-one.yoi-plugin"), - "project_one", - &[PluginSurface::Hook], - &[("hooks/project.md", b"hello".as_slice())], - ); - - let report = discover_plugins( - &PluginDiscoveryOptions::new(&workspace).with_user_data_home(&user_data), - ); - - assert_eq!(report.diagnostics, vec![]); - let identities: BTreeSet<_> = report - .packages - .iter() - .map(|package| package.identity.to_string()) - .collect(); - assert_eq!( - identities, - BTreeSet::from([ - "project:project_one".to_string(), - "user:user_one".to_string() - ]) - ); - assert!( - report - .packages - .iter() - .all(|package| package.digest.starts_with("sha256:")) - ); - } - - #[test] - fn discovery_only_does_not_activate_packages() { - let (report, config) = fixture_with_enabled_plugin(false); - - let resolution = resolve_enabled_plugins(&config, &report); - - assert_eq!(report.packages.len(), 1); - assert!(resolution.resolved.is_empty()); - assert!(resolution.diagnostics.is_empty()); - } - - #[test] - fn explicit_enablement_resolves_typed_metadata() { - let (report, config) = fixture_with_enabled_plugin(true); - - let resolution = resolve_enabled_plugins(&config, &report); - - assert_eq!(resolution.diagnostics, vec![]); - assert_eq!(resolution.resolved.len(), 1); - let resolved = &resolution.resolved[0]; - assert_eq!(resolved.identity.to_string(), "project:example"); - assert_eq!(resolved.enabled_surfaces, vec![PluginSurface::Hook]); - assert!(resolved.grants.is_empty()); - assert_eq!(resolved.manifest.id, "example"); - } - - #[test] - fn duplicate_and_unqualified_ids_fail_closed() { - let temp = TempDir::new().unwrap(); - let workspace = temp.path().join("workspace"); - let plugins = workspace.join(".yoi/plugins"); - fs::create_dir_all(&plugins).unwrap(); - write_plugin( - &plugins.join("one.yoi-plugin"), - "dup", - &[PluginSurface::Hook], - &[("hooks/a.md", b"a")], - ); - write_plugin( - &plugins.join("two.yoi-plugin"), - "dup", - &[PluginSurface::Hook], - &[("hooks/a.md", b"a")], - ); - let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace)); - - assert!( - report - .diagnostics - .iter() - .any(|diag| diag.kind == PluginDiagnosticKind::Duplicate) - ); - - let resolution = resolve_enabled_plugins( - &PluginConfig { - enabled: vec![ - PluginEnablementConfig { - id: "project:dup".to_string(), - ..PluginEnablementConfig::default() - }, - PluginEnablementConfig { - id: "dup".to_string(), - ..PluginEnablementConfig::default() - }, - ], - ..PluginConfig::default() - }, - &report, - ); - - assert!(resolution.resolved.is_empty()); - assert!( - resolution - .diagnostics - .iter() - .any(|diag| diag.kind == PluginDiagnosticKind::Duplicate) - ); - assert!( - resolution - .diagnostics - .iter() - .any(|diag| diag.kind == PluginDiagnosticKind::Ambiguous) - ); - } - - #[test] - fn digest_mismatch_fails_closed() { - let (report, _) = fixture_with_enabled_plugin(false); - let resolution = resolve_enabled_plugins( - &PluginConfig { - enabled: vec![PluginEnablementConfig { - id: "project:example".to_string(), - digest: Some("sha256:0000".to_string()), - ..PluginEnablementConfig::default() - }], - ..PluginConfig::default() - }, - &report, - ); - - assert!(resolution.resolved.is_empty()); - assert_eq!(resolution.diagnostics[0].kind, PluginDiagnosticKind::Digest); - } - - #[test] - fn exact_version_mismatch_fails_closed_with_distinct_diagnostic() { - let (report, _) = fixture_with_enabled_plugin(false); - let resolution = resolve_enabled_plugins( - &PluginConfig { - enabled: vec![PluginEnablementConfig { - id: "project:example".to_string(), - version: Some(PluginExactVersion("9.9.9".to_string())), - ..PluginEnablementConfig::default() - }], - ..PluginConfig::default() - }, - &report, - ); - - assert!(resolution.resolved.is_empty()); - assert_eq!( - resolution.diagnostics[0].kind, - PluginDiagnosticKind::Version - ); - assert_eq!( - resolution.diagnostics[0].phase, - PluginDiagnosticPhase::Resolution - ); - assert!( - !resolution - .diagnostics - .iter() - .any(|diag| diag.kind == PluginDiagnosticKind::Api) - ); - } - - #[test] - fn resolved_plan_pins_unpinned_enablement_for_restore() { - let temp = TempDir::new().unwrap(); - let workspace = temp.path().join("workspace"); - let plugins = workspace.join(".yoi/plugins"); - fs::create_dir_all(&plugins).unwrap(); - let package = plugins.join("example.yoi-plugin"); - write_plugin_version( - &package, - "example", - "0.1.0", - &[PluginSurface::Hook], - &[("hooks/example.md", b"v1")], - ); - let options = PluginDiscoveryOptions::new(&workspace); - let config = PluginConfig { - enabled: vec![PluginEnablementConfig { - id: "project:example".to_string(), - ..PluginEnablementConfig::default() - }], - ..PluginConfig::default() - }; - - let startup_snapshot = resolve_plugin_config_for_startup(&config, &options); - assert_eq!(startup_snapshot.resolved.len(), 1); - let restored_digest = startup_snapshot.resolved[0].digest.clone(); - assert_eq!(startup_snapshot.resolved[0].version, "0.1.0"); - - write_plugin_version( - &package, - "example", - "0.2.0", - &[PluginSurface::Hook], - &[("hooks/example.md", b"v2")], - ); - let fresh_snapshot = resolve_plugin_config_for_startup(&config, &options); - assert_ne!(fresh_snapshot.resolved[0].digest, restored_digest); - assert_eq!(fresh_snapshot.resolved[0].version, "0.2.0"); - - let restored_snapshot = resolve_plugin_config_for_startup(&startup_snapshot, &options); - assert_eq!(restored_snapshot.resolved[0].digest, restored_digest); - assert_eq!(restored_snapshot.resolved[0].version, "0.1.0"); - } - - #[test] - fn currently_documented_manifest_shape_is_accepted() { - let temp = TempDir::new().unwrap(); - let workspace = temp.path().join("workspace"); - let plugins = workspace.join(".yoi/plugins"); - fs::create_dir_all(&plugins).unwrap(); - let manifest = r#" -schema_version = 1 -id = "example.summarizer" -name = "Example Summarizer" -version = "0.1.0" -description = "Adds a custom summary command." -surfaces = ["hook"] - -[[hooks]] -id = "summary" -file = "hooks/summary.md" -"#; - write_stored_zip( - &plugins.join("documented.yoi-plugin"), - &[ - ("plugin.toml", manifest.as_bytes().to_vec(), 0), - ("hooks/summary.md", b"summarize".to_vec(), 0), - ], - ); - - let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace)); - - assert_eq!(report.diagnostics, vec![]); - assert_eq!(report.packages.len(), 1); - assert_eq!( - report.packages[0].identity.to_string(), - "project:example.summarizer" - ); - } - - #[test] - fn package_manifest_tool_surface_shape_is_accepted() { - let manifest: PluginPackageManifest = toml::from_str( - r#" -schema_version = 1 -id = "example.tool" -name = "Example Tool" -version = "0.1.0" - -[[tools]] -name = "ExampleTool" -description = "Runs a package-defined tool." -input_schema = { type = "object", properties = { query = { type = "string" } }, required = ["query"], additionalProperties = false } -"#, - ) - .unwrap(); - - assert_eq!(manifest.tools.len(), 1); - assert!(manifest.declared_surfaces().contains(&PluginSurface::Tool)); - assert_eq!(manifest.tools[0].name, "ExampleTool"); - assert_eq!(manifest.tools[0].input_schema["type"], "object"); - } - - #[test] - fn malformed_manifest_multibyte_diagnostic_is_bounded_and_redacted() { - let temp = TempDir::new().unwrap(); - let workspace = temp.path().join("workspace"); - let plugins = workspace.join(".yoi/plugins"); - fs::create_dir_all(&plugins).unwrap(); - let malformed = format!("schema_version = [\n# {}", "機密".repeat(200)); - write_stored_zip( - &plugins.join("bad-multibyte.yoi-plugin"), - &[("plugin.toml", malformed.into_bytes(), 0)], - ); - - let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace)); - - assert!(report.packages.is_empty()); - let diagnostic = report - .diagnostics - .iter() - .find(|diag| diag.kind == PluginDiagnosticKind::Malformed) - .unwrap(); - assert!(diagnostic.message.len() <= 241); - assert!(!diagnostic.message.contains("機密")); - } - - #[test] - fn traversal_root_escape_in_archive_fails_closed() { - let temp = TempDir::new().unwrap(); - let workspace = temp.path().join("workspace"); - let plugins = workspace.join(".yoi/plugins"); - fs::create_dir_all(&plugins).unwrap(); - write_stored_zip( - &plugins.join("escape.yoi-plugin"), - &[ - ( - "plugin.toml", - manifest("escape", "0.1.0", &[PluginSurface::Hook]).into_bytes(), - 0, - ), - ("../evil", b"x".to_vec(), 0), - ], - ); - - let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace)); - - assert!(report.packages.is_empty()); - assert_eq!(report.diagnostics[0].kind, PluginDiagnosticKind::Traversal); - } - - #[cfg(unix)] - #[test] - fn package_symlink_store_escape_fails_closed() { - use std::os::unix::fs::symlink; - - let temp = TempDir::new().unwrap(); - let workspace = temp.path().join("workspace"); - let plugins = workspace.join(".yoi/plugins"); - let outside = temp.path().join("outside"); - fs::create_dir_all(&plugins).unwrap(); - write_plugin( - &outside, - "outside", - &[PluginSurface::Hook], - &[("hooks/a.md", b"a")], - ); - symlink(&outside, plugins.join("outside.yoi-plugin")).unwrap(); - - let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace)); - - assert!(report.packages.is_empty()); - assert_eq!(report.diagnostics[0].kind, PluginDiagnosticKind::Traversal); - } - - #[test] - fn unsupported_api_and_malformed_manifest_fail_closed() { - let temp = TempDir::new().unwrap(); - let workspace = temp.path().join("workspace"); - let plugins = workspace.join(".yoi/plugins"); - fs::create_dir_all(&plugins).unwrap(); - write_stored_zip( - &plugins.join("bad-schema.yoi-plugin"), - &[( - "plugin.toml", - manifest_with_schema("bad_schema", "0.1.0", 999).into_bytes(), - 0, - )], - ); - write_stored_zip( - &plugins.join("bad-toml.yoi-plugin"), - &[("plugin.toml", b"not = [valid".to_vec(), 0)], - ); - - let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace)); - - assert!(report.packages.is_empty()); - assert!( - report - .diagnostics - .iter() - .any(|diag| diag.kind == PluginDiagnosticKind::Api) - ); - assert!( - report - .diagnostics - .iter() - .any(|diag| diag.kind == PluginDiagnosticKind::Malformed) - ); - } - - #[test] - fn typed_permission_grant_binding_resolves_only_exact_package_identity() { - let (report, _) = fixture_with_enabled_plugin(false); - let digest = report.packages[0].digest.clone(); - let exact_grants = PluginGrantConfig { - id: Some("project:example".to_string()), - version: Some(PluginExactVersion("0.1.0".to_string())), - digest: Some(digest.clone()), - permissions: vec![PluginPermission::surface(PluginSurface::Hook)], - request: Vec::new(), - websocket: Vec::new(), - fs: Vec::new(), - }; - let resolution = resolve_enabled_plugins( - &PluginConfig { - enabled: vec![PluginEnablementConfig { - id: "project:example".to_string(), - grants: exact_grants, - ..PluginEnablementConfig::default() - }], - ..PluginConfig::default() - }, - &report, - ); - assert!( - resolution.diagnostics.is_empty(), - "{:#?}", - resolution.diagnostics - ); - assert_eq!(resolution.resolved.len(), 1); - - for grants in [ - PluginGrantConfig { - id: Some("project:other".to_string()), - version: Some(PluginExactVersion("0.1.0".to_string())), - digest: Some(digest.clone()), - permissions: vec![PluginPermission::surface(PluginSurface::Hook)], - request: Vec::new(), - websocket: Vec::new(), - fs: Vec::new(), - }, - PluginGrantConfig { - id: Some("project:example".to_string()), - version: Some(PluginExactVersion("0.1.1".to_string())), - digest: Some(digest.clone()), - permissions: vec![PluginPermission::surface(PluginSurface::Hook)], - request: Vec::new(), - websocket: Vec::new(), - fs: Vec::new(), - }, - PluginGrantConfig { - id: Some("project:example".to_string()), - version: Some(PluginExactVersion("0.1.0".to_string())), - digest: Some("sha256:unrelated".to_string()), - permissions: vec![PluginPermission::surface(PluginSurface::Hook)], - request: Vec::new(), - websocket: Vec::new(), - fs: Vec::new(), - }, + for forbidden in [ + ".yoi/plugins", + "discover_plugins", + "resolve_enabled_plugins", + "resolve_plugin_config_for_startup", + "ResolvedPluginRecord", + "read_resolved_plugin_runtime_component", + "package_path", + "PluginSourceKind::Project", + "PluginSourceKind::User", ] { - let resolution = resolve_enabled_plugins( - &PluginConfig { - enabled: vec![PluginEnablementConfig { - id: "project:example".to_string(), - grants, - ..PluginEnablementConfig::default() - }], - ..PluginConfig::default() - }, - &report, - ); - assert!(resolution.resolved.is_empty()); assert!( - resolution - .diagnostics - .iter() - .any(|diag| diag.kind == PluginDiagnosticKind::Grant), - "{:#?}", - resolution.diagnostics + !source.contains(forbidden), + "ambient Plugin authority returned through {forbidden}" ); } } - - #[test] - fn unknown_permission_kind_fails_closed_at_manifest_parse_boundary() { - let error = toml::from_str::( - r#"schema_version = 1 -id = "example" -name = "Example" -version = "0.1.0" - -[[permissions]] -kind = "ambient_shell" -"#, - ) - .unwrap_err(); - assert!(error.to_string().contains("ambient_shell"), "{error}"); - } - - #[test] - fn surface_and_grant_failures_do_not_resolve() { - let (report, _) = fixture_with_enabled_plugin(false); - let resolution = resolve_enabled_plugins( - &PluginConfig { - enabled: vec![ - PluginEnablementConfig { - id: "project:example".to_string(), - surfaces: vec![PluginSurface::Tool], - ..PluginEnablementConfig::default() - }, - PluginEnablementConfig { - id: "project:example".to_string(), - grants: PluginGrantConfig { - permissions: vec![PluginPermission::surface(PluginSurface::Tool)], - ..PluginGrantConfig::default() - }, - ..PluginEnablementConfig::default() - }, - ], - ..PluginConfig::default() - }, - &report, - ); - - assert!(resolution.resolved.is_empty()); - assert!( - resolution - .diagnostics - .iter() - .any(|diag| diag.kind == PluginDiagnosticKind::Surface) - ); - assert!( - resolution - .diagnostics - .iter() - .any(|diag| diag.kind == PluginDiagnosticKind::Grant) - ); - } - - fn fixture_with_enabled_plugin(enabled: bool) -> (PluginDiscoveryReport, PluginConfig) { - let temp = TempDir::new().unwrap(); - let workspace = temp.path().join("workspace"); - let plugins = workspace.join(".yoi/plugins"); - fs::create_dir_all(&plugins).unwrap(); - write_plugin( - &plugins.join("example.yoi-plugin"), - "example", - &[PluginSurface::Hook], - &[("hooks/example.md", b"hello")], - ); - let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace)); - let config = PluginConfig { - enabled: if enabled { - vec![PluginEnablementConfig { - id: "project:example".to_string(), - ..PluginEnablementConfig::default() - }] - } else { - vec![] - }, - ..PluginConfig::default() - }; - (report, config) - } - - fn write_plugin( - path: &Path, - id: &str, - surfaces: &[PluginSurface], - extra_files: &[(&str, &[u8])], - ) { - write_plugin_version(path, id, "0.1.0", surfaces, extra_files); - } - - fn write_plugin_version( - path: &Path, - id: &str, - version: &str, - surfaces: &[PluginSurface], - extra_files: &[(&str, &[u8])], - ) { - let mut entries = vec![( - "plugin.toml", - manifest(id, version, surfaces).into_bytes(), - 0, - )]; - if surfaces.contains(&PluginSurface::Hook) - && !extra_files - .iter() - .any(|(path, _)| *path == "hooks/example.md") - { - entries.push(("hooks/example.md", b"hook".to_vec(), 0)); - } - entries.extend( - extra_files - .iter() - .map(|(path, content)| (*path, content.to_vec(), 0)), - ); - write_stored_zip(path, &entries); - } - - fn manifest(id: &str, version: &str, surfaces: &[PluginSurface]) -> String { - let mut manifest = manifest_with_schema(id, version, SUPPORTED_PLUGIN_API_VERSION); - if surfaces.contains(&PluginSurface::Hook) { - manifest.push_str("\n[[hooks]]\nid = \"startup\"\nfile = \"hooks/example.md\"\n"); - } - manifest - } - - fn manifest_with_schema(id: &str, version: &str, schema_version: u32) -> String { - format!( - "schema_version = {schema_version}\nid = \"{id}\"\nname = \"Example\"\nversion = \"{version}\"\n" - ) - } - - fn write_stored_zip(path: &Path, entries: &[(&str, Vec, u32)]) { - let mut bytes = Vec::new(); - let mut central = Vec::new(); - for (name, content, external_attributes) in entries { - let local_offset = bytes.len() as u32; - write_u32(&mut bytes, ZIP_LOCAL_FILE); - write_u16(&mut bytes, 20); - write_u16(&mut bytes, 0x0800); - write_u16(&mut bytes, ZIP_COMPRESSION_STORED); - write_u16(&mut bytes, 0); - write_u16(&mut bytes, 0); - write_u32(&mut bytes, 0); - write_u32(&mut bytes, content.len() as u32); - write_u32(&mut bytes, content.len() as u32); - write_u16(&mut bytes, name.len() as u16); - write_u16(&mut bytes, 0); - bytes.extend_from_slice(name.as_bytes()); - bytes.extend_from_slice(content); - - write_u32(&mut central, ZIP_CENTRAL_DIRECTORY); - write_u16(&mut central, 20); - write_u16(&mut central, 20); - write_u16(&mut central, 0x0800); - write_u16(&mut central, ZIP_COMPRESSION_STORED); - write_u16(&mut central, 0); - write_u16(&mut central, 0); - write_u32(&mut central, 0); - write_u32(&mut central, content.len() as u32); - write_u32(&mut central, content.len() as u32); - write_u16(&mut central, name.len() as u16); - write_u16(&mut central, 0); - write_u16(&mut central, 0); - write_u16(&mut central, 0); - write_u16(&mut central, 0); - write_u32(&mut central, *external_attributes); - write_u32(&mut central, local_offset); - central.extend_from_slice(name.as_bytes()); - } - let central_offset = bytes.len() as u32; - bytes.extend_from_slice(¢ral); - write_u32(&mut bytes, ZIP_EOCD); - write_u16(&mut bytes, 0); - write_u16(&mut bytes, 0); - write_u16(&mut bytes, entries.len() as u16); - write_u16(&mut bytes, entries.len() as u16); - write_u32(&mut bytes, central.len() as u32); - write_u32(&mut bytes, central_offset); - write_u16(&mut bytes, 0); - fs::write(path, bytes).unwrap(); - } - - fn write_u16(out: &mut Vec, value: u16) { - out.extend_from_slice(&value.to_le_bytes()); - } - - fn write_u32(out: &mut Vec, value: u32) { - out.extend_from_slice(&value.to_le_bytes()); - } - - #[test] - fn websocket_manifest_and_grants_parse_independently_from_request() { - let manifest: PluginPackageManifest = toml::from_str( - r#" -schema_version = 1 -id = "project:example" -name = "example" -version = "1.0.0" -surfaces = ["tool"] - -[runtime] -kind = "wasm-component" -component = "plugin.component.wasm" -world = "yoi:plugin/tool@1.0.0" - -[[permissions]] -kind = "host_api" -api = "request" - -[[permissions]] -kind = "host_api" -api = "websocket" - -[[request]] -scheme = "https" -host = "api.example.com" -methods = ["GET"] -path_prefixes = ["/v1"] - -[[websocket]] -scheme = "wss" -host = "gateway.example.com" -path_prefixes = ["/gateway"] -"#, - ) - .unwrap(); - assert_eq!(manifest.request.len(), 1); - assert_eq!(manifest.websocket.len(), 1); - assert_eq!( - manifest.request[0].label(), - "https://api.example.com GET /v1" - ); - assert_eq!( - manifest.websocket[0].label(), - "wss://gateway.example.com /gateway" - ); - assert_eq!( - manifest.permissions[1], - PluginPermission::host_api(PluginHostApi::WebSocket) - ); - - let grants: PluginGrantConfig = toml::from_str( - r#" -[[request]] -scheme = "https" -host = "api.example.com" -methods = ["GET"] -path_prefixes = ["/v1"] - -[[websocket]] -scheme = "wss" -host = "gateway.example.com" -path_prefixes = ["/gateway"] -"#, - ) - .unwrap(); - assert_eq!(grants.request.len(), 1); - assert_eq!(grants.websocket.len(), 1); - assert!(!grants.is_empty()); - } } diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 40bc0e1a..ccfc7a97 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -18,7 +18,6 @@ use crate::config::{ CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial, }; use crate::model::{AuthRef, ModelManifest}; -use crate::plugin::PluginConfig; use crate::{ EngineManifestConfig, McpConfig, McpStdioCwdPolicy, Permission, ResolveError, ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, WorkerMetaConfig, @@ -148,7 +147,6 @@ pub enum WorkspaceAuthorityRequirement { MergeRequest, Objective, Orchestration, - Plugins, Ticket, Worker, } @@ -162,7 +160,6 @@ impl fmt::Display for WorkspaceAuthorityRequirement { Self::MergeRequest => formatter.write_str("feature.merge_request"), Self::Objective => formatter.write_str("feature.objective"), Self::Orchestration => formatter.write_str("feature.orchestration"), - Self::Plugins => formatter.write_str("feature.plugins or plugin packages"), Self::Ticket => formatter.write_str("feature.ticket"), Self::Worker => formatter.write_str("feature.worker"), } @@ -202,9 +199,6 @@ pub fn validate_profile_execution_target( if feature.orchestration.enabled { requirements.insert(WorkspaceAuthorityRequirement::Orchestration); } - if feature.plugins.enabled || !manifest.plugins.is_empty() { - requirements.insert(WorkspaceAuthorityRequirement::Plugins); - } if feature.ticket.enabled || feature.ticket.authoring || feature.ticket.thread @@ -638,7 +632,6 @@ fn resolve_profile_value( session: profile.session, permissions: profile.permissions, feature: profile.feature, - plugins: profile.plugins, mcp: profile.mcp, compaction, web: profile.web, @@ -684,8 +677,6 @@ struct ProfileConfig { #[serde(default)] feature: FeatureConfigPartial, #[serde(default)] - plugins: PluginConfig, - #[serde(default)] mcp: McpConfig, #[serde(default)] compaction: Option, @@ -1267,6 +1258,51 @@ mod tests { } } + #[test] + fn ambient_plugin_directories_do_not_affect_builtin_profile_resolution() { + let tmp = TempDir::new().unwrap(); + let workspace = tmp.path().join("workspace/nested"); + std::fs::create_dir_all(&workspace).unwrap(); + for root in [tmp.path(), tmp.path().join("workspace").as_path()] { + let package = root.join(".yoi/plugins/broken.yoi-plugin"); + std::fs::create_dir_all(package.parent().unwrap()).unwrap(); + std::fs::write(package, b"malformed ambient package").unwrap(); + } + + let resolved = ProfileResolver::new() + .with_workspace_base(&workspace) + .resolve_for_target( + &ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"), + ProfileResolveOptions::with_worker_name("standalone-worker"), + ProfileExecutionTarget::Standalone, + ) + .unwrap(); + + assert_eq!(resolved.manifest.worker.name, "standalone-worker"); + } + + #[test] + fn profile_rejects_dynamic_plugin_configuration() { + let tmp = TempDir::new().unwrap(); + for body in [ + "[feature.plugins]\nenabled = true\n", + "[[plugins.enabled]]\nid = \"explicit:example\"\n", + ] { + let profile = write_profile(tmp.path(), "plugin.toml", body); + let error = ProfileResolver::new() + .with_workspace_base(tmp.path()) + .resolve( + &ProfileSelector::path(profile), + ProfileResolveOptions::with_worker_name("runtime-worker"), + ) + .unwrap_err(); + assert!( + error.to_string().contains("unknown field"), + "unexpected error: {error}" + ); + } + } + #[test] fn builtin_default_resolves_as_a_standalone_local_capability_profile() { let tmp = TempDir::new().unwrap(); @@ -1305,8 +1341,6 @@ mod tests { assert!(!resolved.manifest.feature.flow.enabled); assert!(!resolved.manifest.feature.worker.enabled); assert!(!resolved.manifest.feature.manage_workdir.enabled); - assert!(!resolved.manifest.feature.plugins.enabled); - assert!(resolved.manifest.plugins.is_empty()); } #[test] diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index 87046834..64d1be22 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -43,7 +43,6 @@ memory = { workspace = true } uuid = { workspace = true, features = ["v7"] } session-metrics = { workspace = true } arc-swap = "1.9.1" -wasmtime = { version = "45.0.2", default-features = false, features = ["std", "runtime", "cranelift", "component-model"] } tungstenite = { version = "0.28.0", default-features = false, features = ["handshake", "native-tls", "url"] } tokio-tungstenite = { version = "0.28.0", default-features = false, features = ["native-tls", "connect"] } futures-util = { version = "0.3", features = ["sink"] } @@ -53,5 +52,3 @@ dotenv = "0.15.0" futures = { workspace = true } serial_test = "3.4.0" tempfile = { workspace = true } -wat = "1.241.2" -yoi-plugin-pdk = { workspace = true } diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 26e8a77c..61826bf7 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1385,12 +1385,6 @@ where feature_registry .add_module(crate::feature::builtin::orchestration::orchestration_feature()); } - for module in crate::feature::plugin::plugin_tool_features_if_enabled( - feature_config.plugins.enabled, - &worker.manifest().plugins, - ) { - feature_registry = feature_registry.with_module(module); - } if let Some(workspace_root) = local_workspace_root.as_ref() { if let Some(module) = crate::feature::mcp::discover_stdio_tool_feature(&mcp_config, workspace_root).await diff --git a/crates/worker/src/feature.rs b/crates/worker/src/feature.rs index be935209..62665c09 100644 --- a/crates/worker/src/feature.rs +++ b/crates/worker/src/feature.rs @@ -2211,7 +2211,6 @@ pub enum FeatureInstallError { pub mod background; pub mod builtin; pub mod mcp; -pub mod plugin; pub(crate) mod session; #[cfg(test)] @@ -2224,6 +2223,31 @@ mod tests { use serde_json::json; use std::sync::atomic::{AtomicUsize, Ordering}; + #[test] + fn worker_feature_composition_has_no_dynamic_plugin_install_path() { + let feature_source = include_str!("feature.rs") + .split("#[cfg(test)]") + .next() + .unwrap(); + let controller_source = include_str!("controller.rs") + .split("#[cfg(test)]") + .next() + .unwrap(); + for forbidden in [ + "pub mod plugin", + "plugin_tool_features_if_enabled", + "ResolvedPluginRecord", + "read_resolved_plugin_runtime_component", + "feature.plugins", + ] { + assert!( + !feature_source.contains(forbidden) && !controller_source.contains(forbidden), + "dynamic Plugin install path returned through {forbidden}" + ); + } + assert_eq!(FeatureId::builtin("task").as_str(), "builtin:task"); + } + #[derive(Clone)] struct DummyClient; diff --git a/crates/worker/src/feature/plugin.rs b/crates/worker/src/feature/plugin.rs deleted file mode 100644 index 39dbfecf..00000000 --- a/crates/worker/src/feature/plugin.rs +++ /dev/null @@ -1,8870 +0,0 @@ -//! Plugin package contributions for model-visible Tool schemas. -//! -//! This module registers *enabled* plugin package tool surface definitions and -//! executes Tool calls through the sandboxed Component Model `wasm-component` -//! runtime. It deliberately does not grant filesystem, environment, hook, -//! service, ingress, or ambient network authority. Components can only reach -//! host APIs through explicit imports with matching permissions and scoped -//! allowlist grants. - -use std::collections::{HashMap, HashSet, VecDeque}; -use std::fs; -use std::io::{Read as _, Write as _}; -use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; -use std::path::{Component, Path, PathBuf}; -use std::sync::{ - Arc, Mutex, OnceLock, - atomic::{AtomicBool, Ordering}, -}; -use std::time::{Duration, Instant}; - -use agen::tool::{ - Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput, -}; -use async_trait::async_trait; -use futures_util::{SinkExt, StreamExt}; -use manifest::plugin::{ - PLUGIN_COMPONENT_INSTANCE_WORLD, PLUGIN_COMPONENT_TOOL_WORLD, PLUGIN_RUNTIME_COMPONENT_KIND, - PluginConfig, PluginDiscoveryLimits, PluginFsGrant, PluginFsOperation, PluginHostApi, - PluginPermission, PluginRequestGrant, PluginSurface, PluginToolManifest, PluginWebSocketGrant, - ResolvedPluginRecord, read_resolved_plugin_runtime_component, -}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use tokio::runtime::{ - Builder as TokioRuntimeBuilder, Handle as TokioHandle, Runtime as TokioRuntime, -}; -use tokio_tungstenite::tungstenite::client::IntoClientRequest; -use tokio_tungstenite::tungstenite::protocol::{Message, WebSocketConfig}; - -const LEGACY_PLUGIN_RUNTIME_WASM_KIND: &str = "wasm"; -const PLUGIN_SERVICE_WEBSOCKET_RECV_TIMEOUT: Duration = Duration::from_millis(250); - -use super::{ - FeatureDescriptor, FeatureId, FeatureInstallContext, FeatureInstallError, FeatureModule, - FeatureRuntimeKind, ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration, -}; - -/// Build Feature modules for enabled plugin packages when the profile exposes -/// the plugin Tool surface feature. -pub fn plugin_tool_features_if_enabled( - feature_enabled: bool, - config: &PluginConfig, -) -> Vec { - if !feature_enabled { - return Vec::new(); - } - plugin_tool_features(config) -} - -/// Build Feature modules for enabled plugin packages that declare Tool/Service/Ingress surfaces. -pub fn plugin_tool_features(config: &PluginConfig) -> Vec { - config - .resolved - .iter() - .filter(|record| { - record.enabled_surfaces.contains(&PluginSurface::Tool) - || record.enabled_surfaces.contains(&PluginSurface::Service) - || record.enabled_surfaces.contains(&PluginSurface::Ingress) - }) - .filter(|record| { - !record.manifest.tools.is_empty() - || !record.manifest.services.is_empty() - || !record.manifest.ingresses.is_empty() - }) - .cloned() - .map(PluginToolFeature::new) - .collect() -} - -#[derive(Clone)] -pub struct PluginToolFeature { - record: ResolvedPluginRecord, - feature_id: FeatureId, - registry: PluginInstanceRegistry, -} - -impl PluginToolFeature { - pub fn new(record: ResolvedPluginRecord) -> Self { - let feature_id = FeatureId::new(format!("plugin:{}:tool", record.identity)) - .expect("source-qualified plugin identity yields non-empty feature id"); - Self { - record, - feature_id, - registry: PluginInstanceRegistry::default(), - } - } - - fn ensure_instance(&self) -> Result { - self.registry.register(self.record.clone()) - } - - pub fn instance_status(&self) -> Option { - self.registry.status(&self.record.identity.to_string()) - } - - pub fn dispatch_ingress( - &self, - ingress_name: &str, - event: PluginIngressEvent, - ) -> Result { - if !surface_enabled(&self.record, PluginSurface::Ingress) { - return Err(PluginIngressDispatchError::InvalidEvent( - "plugin ingress surface is not enabled".to_string(), - )); - } - let handle = self - .registry - .handle(&self.record.identity.to_string()) - .ok_or_else(|| PluginIngressDispatchError::ServiceUnavailable { - state: PluginInstanceLifecycleState::Stopped, - })?; - handle.deliver_ingress(ingress_name, event) - } - - pub fn origin(&self) -> ToolOrigin { - ToolOrigin { - kind: "plugin".into(), - plugin_id: self.record.manifest.id.clone(), - plugin_ref: self.record.identity.to_string(), - source: self.record.identity.source.to_string(), - digest: self.record.digest.clone(), - package_version: self.record.version.clone(), - package_api_version: self.record.manifest.schema_version, - surface: "tool".into(), - } - } -} - -fn surface_enabled(record: &ResolvedPluginRecord, surface: PluginSurface) -> bool { - record.enabled_surfaces.contains(&surface) -} - -fn plugin_tool_origin(record: &ResolvedPluginRecord) -> ToolOrigin { - ToolOrigin { - kind: "plugin".into(), - plugin_id: record.manifest.id.clone(), - plugin_ref: record.identity.to_string(), - source: record.identity.source.to_string(), - digest: record.digest.clone(), - package_version: record.version.clone(), - package_api_version: record.manifest.schema_version, - surface: "tool".into(), - } -} - -fn plugin_service_id(record: &ResolvedPluginRecord, name: &str) -> ServiceId { - ServiceId::new(format!("plugin:{}:{name}", record.identity.to_string())) - .expect("plugin service id is generated from safe plugin identity/name") -} - -/// Static, read-only eligibility information for a resolved plugin package. -/// -/// This inspection mirrors the registration-time permission checks without -/// loading the WASM module, calling a plugin Tool, or executing plugin code. -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct PluginStaticInspection { - pub runtime: PluginRuntimeEligibility, - pub host_apis: Vec, - pub tools: Vec, - pub services: Vec, - pub ingresses: Vec, -} - -impl PluginStaticInspection { - pub fn statically_eligible(&self) -> bool { - self.runtime.eligible - && self.host_apis.iter().all(|api| api.eligible) - && self.tools.iter().all(|tool| tool.eligible) - && self.services.iter().all(|service| service.eligible) - && self.ingresses.iter().all(|ingress| ingress.eligible) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct PluginRuntimeEligibility { - pub eligible: bool, - pub status: String, - pub diagnostic: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct PluginPermissionEligibility { - pub permission: String, - pub requested: bool, - pub granted: bool, - pub eligible: bool, - pub diagnostic: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct PluginToolEligibility { - pub name: String, - pub permission: String, - pub requested: bool, - pub granted: bool, - pub eligible: bool, - pub external_write: bool, - pub diagnostic: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct PluginSurfaceEligibility { - pub name: String, - pub permission: String, - pub requested: bool, - pub granted: bool, - pub eligible: bool, - pub diagnostic: Option, -} - -/// Inspect static plugin runtime/tool eligibility without executing plugin code. -pub fn inspect_resolved_plugin_static(record: &ResolvedPluginRecord) -> PluginStaticInspection { - let runtime = match &record.manifest.runtime { - Some(runtime) - if runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND - && matches!( - runtime.world.as_deref(), - Some(PLUGIN_COMPONENT_TOOL_WORLD) | Some(PLUGIN_COMPONENT_INSTANCE_WORLD) - ) - && runtime.component.is_some() => - { - PluginRuntimeEligibility { - eligible: true, - status: format!( - "{}/{}", - PLUGIN_RUNTIME_COMPONENT_KIND, - runtime - .world - .as_deref() - .unwrap_or(PLUGIN_COMPONENT_TOOL_WORLD) - ), - diagnostic: None, - } - } - Some(runtime) if runtime.kind == LEGACY_PLUGIN_RUNTIME_WASM_KIND => { - let status = runtime - .abi - .as_deref() - .map(|abi| format!("{LEGACY_PLUGIN_RUNTIME_WASM_KIND}/{abi}")) - .unwrap_or_else(|| format!("{LEGACY_PLUGIN_RUNTIME_WASM_KIND}/")); - PluginRuntimeEligibility { - eligible: false, - status, - diagnostic: Some( - "legacy raw wasm plugin runtime is not an active execution path; use wasm-component" - .to_string(), - ), - } - } - Some(runtime) if runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND => { - let status = runtime - .world - .as_deref() - .map(|world| format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/{world}")) - .unwrap_or_else(|| format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/")); - PluginRuntimeEligibility { - eligible: false, - status, - diagnostic: Some("unsupported or missing plugin component world".to_string()), - } - } - Some(runtime) => PluginRuntimeEligibility { - eligible: false, - status: runtime.kind.clone(), - diagnostic: Some(format!( - "unsupported plugin runtime kind `{}`", - runtime.kind - )), - }, - None => PluginRuntimeEligibility { - eligible: false, - status: "none".to_string(), - diagnostic: Some("plugin runtime is not declared".to_string()), - }, - }; - - let mut host_apis: Vec<_> = [ - PluginHostApi::Request, - PluginHostApi::WebSocket, - PluginHostApi::Fs, - ] - .into_iter() - .filter_map(|api| { - let permission = PluginPermission::host_api(api); - let requested = permission_requested(record, &permission); - let granted = grant_allows(record, &permission); - if !requested && !granted { - return None; - } - let diagnostic = authorize_plugin_host_api(record, api) - .err() - .map(|error| error.bounded_message()); - Some(PluginPermissionEligibility { - permission: permission.label(), - requested, - granted, - eligible: diagnostic.is_none(), - diagnostic, - }) - }) - .collect(); - append_request_target_inspection(record, &mut host_apis); - append_websocket_target_inspection(record, &mut host_apis); - - let duplicate_tool_names = duplicate_tool_names(record); - let tools = if surface_enabled(record, PluginSurface::Tool) { - record - .manifest - .tools - .iter() - .map(|tool| { - let permission = PluginPermission::tool(&tool.name); - let requested = permission_requested(record, &permission); - let granted = grant_allows(record, &permission); - let mut diagnostics = validate_plugin_tool_definition(tool, &duplicate_tool_names); - if let Err(error) = authorize_plugin_tool(record, tool) { - diagnostics.push(error.bounded_message()); - } - let diagnostic = join_tool_diagnostics(diagnostics); - PluginToolEligibility { - name: tool.name.clone(), - permission: permission.label(), - requested, - granted, - eligible: diagnostic.is_none(), - external_write: tool.external_write, - diagnostic, - } - }) - .collect() - } else { - Vec::new() - }; - - let instance_world = record.manifest.runtime.as_ref().is_some_and(|runtime| { - runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND - && runtime.world.as_deref() == Some(PLUGIN_COMPONENT_INSTANCE_WORLD) - }); - let services = if surface_enabled(record, PluginSurface::Service) { - record - .manifest - .services - .iter() - .map(|service| { - let permission = PluginPermission::service(&service.name); - let requested = permission_requested(record, &permission); - let granted = grant_allows(record, &permission); - let mut diagnostics = Vec::new(); - if !instance_world { - diagnostics - .push("service requires instance-capable component world".to_string()); - } - if let Err(error) = authorize_plugin_service(record, &service.name) { - diagnostics.push(error.bounded_message()); - } - let diagnostic = join_tool_diagnostics(diagnostics); - PluginSurfaceEligibility { - name: service.name.clone(), - permission: permission.label(), - requested, - granted, - eligible: diagnostic.is_none(), - diagnostic, - } - }) - .collect() - } else { - Vec::new() - }; - let ingresses = if surface_enabled(record, PluginSurface::Ingress) { - record - .manifest - .ingresses - .iter() - .map(|ingress| { - let permission = PluginPermission::ingress(&ingress.name); - let requested = permission_requested(record, &permission); - let granted = grant_allows(record, &permission); - let mut diagnostics = Vec::new(); - if !instance_world { - diagnostics - .push("ingress requires instance-capable component world".to_string()); - } - if let Err(error) = authorize_plugin_ingress(record, &ingress.name) { - diagnostics.push(error.bounded_message()); - } - let diagnostic = join_tool_diagnostics(diagnostics); - PluginSurfaceEligibility { - name: ingress.name.clone(), - permission: permission.label(), - requested, - granted, - eligible: diagnostic.is_none(), - diagnostic, - } - }) - .collect() - } else { - Vec::new() - }; - - PluginStaticInspection { - runtime, - host_apis, - tools, - services, - ingresses, - } -} - -fn permission_requested(record: &ResolvedPluginRecord, permission: &PluginPermission) -> bool { - record - .manifest - .permissions - .iter() - .any(|requested| requested == permission) -} - -fn append_request_target_inspection( - record: &ResolvedPluginRecord, - host_apis: &mut Vec, -) { - for target in &record.manifest.request { - let covering_grant = record - .grants - .request - .iter() - .find(|grant| request_target_covers(grant, target)); - let intersecting_grant = covering_grant.or_else(|| { - record - .grants - .request - .iter() - .find(|grant| request_targets_intersect(target, grant)) - }); - let granted = intersecting_grant.is_some(); - let diagnostic = match (granted, covering_grant, target.is_broad()) { - (false, _, broad) => Some(format!( - "missing enabled request grant for manifest target{}", - if broad { "; broad/arbitrary target" } else { "" } - )), - (true, None, true) => Some( - "partially covered by enabled request grant; broad manifest target is constrained by narrower grants" - .to_string(), - ), - (true, None, false) => Some( - "partially covered by enabled request grant; only intersecting URLs are allowed" - .to_string(), - ), - (true, Some(grant), _) if grant.is_broad() => { - Some("covered by broad/arbitrary enabled request grant".to_string()) - } - _ => None, - }; - host_apis.push(PluginPermissionEligibility { - permission: format!("host_api.request target {}", target.label()), - requested: true, - granted, - eligible: granted, - diagnostic, - }); - } - for grant in &record.grants.request { - let matching_manifest = record - .manifest - .request - .iter() - .find(|target| request_targets_intersect(target, grant)); - if let Some(target) = matching_manifest { - let diagnostic = if grant.is_broad() { - Some( - "broad/arbitrary enabled request grant is constrained by manifest declarations" - .to_string(), - ) - } else if !request_target_covers(target, grant) { - Some( - "enabled request grant is only usable where it intersects manifest declarations" - .to_string(), - ) - } else { - None - }; - host_apis.push(PluginPermissionEligibility { - permission: format!("host_api.request grant {}", grant.label()), - requested: true, - granted: true, - eligible: true, - diagnostic, - }); - } else { - let broad = if grant.is_broad() { - "; broad/arbitrary target" - } else { - "" - }; - host_apis.push(PluginPermissionEligibility { - permission: format!("host_api.request grant-only {}", grant.label()), - requested: false, - granted: true, - eligible: false, - diagnostic: Some(format!( - "enabled request grant has no matching manifest declaration{broad}" - )), - }); - } - } -} - -fn append_websocket_target_inspection( - record: &ResolvedPluginRecord, - host_apis: &mut Vec, -) { - for target in &record.manifest.websocket { - let covering_grant = record - .grants - .websocket - .iter() - .find(|grant| websocket_target_covers(grant, target)); - let intersecting_grant = covering_grant.or_else(|| { - record - .grants - .websocket - .iter() - .find(|grant| websocket_targets_intersect(target, grant)) - }); - let granted = intersecting_grant.is_some(); - let diagnostic = match (granted, covering_grant, target.is_broad()) { - (false, _, broad) => Some(format!( - "missing enabled WebSocket grant for manifest target{}", - if broad { "; broad/arbitrary target" } else { "" } - )), - (true, None, true) => Some( - "partially covered by enabled WebSocket grant; broad manifest target is constrained by narrower grants" - .to_string(), - ), - (true, None, false) => Some( - "partially covered by enabled WebSocket grant; only intersecting URLs are allowed" - .to_string(), - ), - (true, Some(grant), _) if grant.is_broad() => { - Some("covered by broad/arbitrary enabled WebSocket grant".to_string()) - } - _ => None, - }; - host_apis.push(PluginPermissionEligibility { - permission: format!("host_api.websocket target {}", target.label()), - requested: true, - granted, - eligible: granted, - diagnostic, - }); - } - for grant in &record.grants.websocket { - let matching_manifest = record - .manifest - .websocket - .iter() - .find(|target| websocket_targets_intersect(target, grant)); - if let Some(target) = matching_manifest { - let diagnostic = if grant.is_broad() { - Some( - "broad/arbitrary enabled WebSocket grant is constrained by manifest declarations" - .to_string(), - ) - } else if !websocket_target_covers(target, grant) { - Some( - "enabled WebSocket grant is only usable where it intersects manifest declarations" - .to_string(), - ) - } else { - None - }; - host_apis.push(PluginPermissionEligibility { - permission: format!("host_api.websocket grant {}", grant.label()), - requested: true, - granted: true, - eligible: true, - diagnostic, - }); - } else { - let broad = if grant.is_broad() { - "; broad/arbitrary target" - } else { - "" - }; - host_apis.push(PluginPermissionEligibility { - permission: format!("host_api.websocket grant-only {}", grant.label()), - requested: false, - granted: true, - eligible: false, - diagnostic: Some(format!( - "enabled WebSocket grant has no matching manifest declaration{broad}" - )), - }); - } - } -} - -fn grant_allows(record: &ResolvedPluginRecord, permission: &PluginPermission) -> bool { - record - .grants - .permissions - .iter() - .any(|granted| granted == permission) -} - -fn duplicate_tool_names(record: &ResolvedPluginRecord) -> HashSet { - let mut seen = HashSet::new(); - let mut duplicates = HashSet::new(); - for tool in &record.manifest.tools { - if !seen.insert(tool.name.clone()) { - duplicates.insert(tool.name.clone()); - } - } - duplicates -} - -fn validate_plugin_tool_definition( - tool: &PluginToolManifest, - duplicate_tool_names: &HashSet, -) -> Vec { - let mut diagnostics = Vec::new(); - if duplicate_tool_names.contains(&tool.name) { - diagnostics.push(format!( - "tool `{}` has duplicate name within plugin manifest", - tool.name - )); - } - if let Err(reason) = validate_tool_name(&tool.name) { - diagnostics.push(format!("tool `{}` has invalid name: {reason}", tool.name)); - } - if let Err(reason) = validate_input_schema(&tool.input_schema) { - diagnostics.push(format!( - "tool `{}` has invalid input_schema: {reason}", - tool.name - )); - } - diagnostics -} - -fn join_tool_diagnostics(diagnostics: Vec) -> Option { - if diagnostics.is_empty() { - None - } else { - Some(bounded_message(diagnostics.join("; "))) - } -} - -impl FeatureModule for PluginToolFeature { - fn descriptor(&self) -> FeatureDescriptor { - let mut descriptor = - FeatureDescriptor { - id: self.feature_id.clone(), - runtime: FeatureRuntimeKind::ExternalPlugin, - display_name: self.record.manifest.name.clone(), - version: self.record.manifest.version.clone(), - description: self.record.manifest.description.clone().unwrap_or_else(|| { - format!("Plugin tool surface from {}", self.record.identity) - }), - tools: Vec::new(), - hooks: Vec::new(), - instructions: Vec::new(), - background_tasks: Vec::new(), - provides_services: Vec::new(), - requires_services: Vec::new(), - protocol_providers: Vec::new(), - }; - if surface_enabled(&self.record, PluginSurface::Service) { - for service in &self.record.manifest.services { - descriptor.provides_services.push(ServiceDeclaration::new( - plugin_service_id(&self.record, &service.name), - self.record.manifest.version.clone(), - service.description.clone(), - )); - } - } - if surface_enabled(&self.record, PluginSurface::Tool) { - for tool in &self.record.manifest.tools { - descriptor = descriptor.with_tool(ToolDeclaration::new( - tool.name.clone(), - tool.description.clone(), - )); - } - } - descriptor - } - - fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { - if surface_enabled(&self.record, PluginSurface::Tool) { - validate_declared_tool_names(&self.record)?; - } - let mut instance: Option = None; - let mut exposed = 0usize; - let mut denied = Vec::new(); - if surface_enabled(&self.record, PluginSurface::Service) { - for service in &self.record.manifest.services { - validate_tool_name(&service.name).map_err(|reason| { - FeatureInstallError::Install(format!( - "plugin {} service {} has invalid name: {reason}", - self.record.identity, service.name - )) - })?; - if let Err(error) = authorize_plugin_service(&self.record, &service.name) { - let message = format!( - "plugin {} service {} registration denied: {}", - self.record.identity, - service.name, - error.bounded_message() - ); - context.diagnostics().warning(message.clone()); - denied.push(message); - continue; - } - if instance.is_none() { - instance = Some(self.ensure_instance()?); - } - context.services().provide( - ServiceDeclaration::new( - plugin_service_id(&self.record, &service.name), - self.record.manifest.version.clone(), - service.description.clone(), - ), - Arc::new(instance.as_ref().expect("instance initialized").clone()), - )?; - exposed += 1; - } - } - if surface_enabled(&self.record, PluginSurface::Ingress) { - for ingress in &self.record.manifest.ingresses { - validate_tool_name(&ingress.name).map_err(|reason| { - FeatureInstallError::Install(format!( - "plugin {} ingress {} has invalid name: {reason}", - self.record.identity, ingress.name - )) - })?; - if let Err(error) = authorize_plugin_ingress(&self.record, &ingress.name) { - let message = format!( - "plugin {} ingress {} registration denied: {}", - self.record.identity, - ingress.name, - error.bounded_message() - ); - context.diagnostics().warning(message.clone()); - denied.push(message); - } else { - if instance.is_none() { - instance = Some(self.ensure_instance()?); - } - exposed += 1; - } - } - } - if surface_enabled(&self.record, PluginSurface::Tool) { - for tool in &self.record.manifest.tools { - validate_tool_name(&tool.name).map_err(|reason| { - FeatureInstallError::Install(format!( - "plugin {} tool {} has invalid name: {reason}", - self.record.identity, tool.name - )) - })?; - validate_input_schema(&tool.input_schema).map_err(|reason| { - FeatureInstallError::Install(format!( - "plugin {} tool {} has invalid input_schema: {reason}", - self.record.identity, tool.name - )) - })?; - if let Err(error) = authorize_plugin_tool(&self.record, tool) { - let message = format!( - "plugin {} tool {} registration denied: {}", - self.record.identity, - tool.name, - error.bounded_message() - ); - context.diagnostics().warning(message.clone()); - denied.push(message); - continue; - } - let tool_instance = match &instance { - Some(instance) => instance.clone(), - None => { - let created = self.ensure_instance()?; - instance = Some(created.clone()); - created - } - }; - context.tools().register(ToolContribution::new( - tool.name.clone(), - plugin_instance_tool_definition( - tool_instance, - tool.name.clone(), - tool.description.clone(), - tool.input_schema.clone(), - ), - ))?; - exposed += 1; - } - } - if exposed == 0 && !denied.is_empty() { - let summary = if denied.len() == 1 { - denied.remove(0) - } else { - format!( - "{} plugin tool registrations denied; first denial: {}", - denied.len(), - denied[0] - ) - }; - return Err(FeatureInstallError::Install(bounded_message(summary))); - } - Ok(()) - } -} - -impl PluginRequestClient for ReqwestPluginRequestClient { - fn execute( - &self, - request: &PluginRequestRequest, - url: &reqwest::Url, - limits: PluginRequestLimits, - ) -> Result { - let pinned_resolution = - resolve_request_target_for_client(url, &SystemPluginRequestResolver)?; - let method = reqwest::Method::from_bytes(request.method.as_bytes()).map_err(|_| { - PluginRequestError::new(format!("unsupported request method `{}`", request.method)) - })?; - let mut client_builder = reqwest::blocking::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .timeout(limits.timeout) - .no_proxy() - .user_agent("yoi-plugin-request-host-api/0.1"); - if let Some(pinned_resolution) = &pinned_resolution { - for domain in &pinned_resolution.domains { - client_builder = client_builder.resolve_to_addrs(domain, &pinned_resolution.addrs); - } - } - let client = client_builder.build().map_err(|error| { - PluginRequestError::new(format!("request client build failed: {error}")) - })?; - let mut builder = client.request(method, url.clone()).timeout(limits.timeout); - for header in &request.headers { - let name = - reqwest::header::HeaderName::from_bytes(header.name.as_bytes()).map_err(|_| { - PluginRequestError::new(format!( - "invalid request header name `{}`", - header.name - )) - })?; - let value = reqwest::header::HeaderValue::from_str(&header.value).map_err(|_| { - PluginRequestError::new(format!( - "invalid request header value for `{}`", - header.name - )) - })?; - builder = builder.header(name, value); - } - if let Some(body) = &request.body { - builder = builder.body(body.clone()); - } - let mut response = builder.send().map_err(|error| { - if error.is_timeout() { - PluginRequestError::new(format!("request to {} timed out", safe_url(url))) - } else { - PluginRequestError::new(format!("request to {} failed: {error}", safe_url(url))) - } - })?; - let status = response.status().as_u16(); - let headers = collect_request_response_headers(response.headers()); - let mut body = Vec::new(); - let read_limit = limits.max_response_bytes.saturating_add(1) as u64; - response - .by_ref() - .take(read_limit) - .read_to_end(&mut body) - .map_err(|error| { - PluginRequestError::new(format!("request response read failed: {error}")) - })?; - let truncated = body.len() > limits.max_response_bytes; - if truncated { - body.truncate(limits.max_response_bytes); - } - Ok(PluginRequestResponse { - status, - headers, - body: String::from_utf8_lossy(&body).into_owned(), - truncated, - }) - } -} - -fn execute_plugin_request_request( - record: &ResolvedPluginRecord, - client: &dyn PluginRequestClient, - request_bytes: &[u8], -) -> Result, PluginRequestError> { - if request_bytes.len() > PLUGIN_REQUEST_MAX_REQUEST_BYTES { - return Err(PluginRequestError::new(format!( - "request descriptor exceeds {} bytes", - PLUGIN_REQUEST_MAX_REQUEST_BYTES - ))); - } - authorize_plugin_host_api(record, PluginHostApi::Request).map_err(|error| { - PluginRequestError::new(format!( - "plugin host API dispatch denied: {}", - error.bounded_message() - )) - })?; - let request: PluginRequestRequest = serde_json::from_slice(request_bytes) - .map_err(|error| PluginRequestError::new(format!("invalid request JSON: {error}")))?; - let url = validate_plugin_request_request(record, &request)?; - let mut response = client.execute(&request, &url, PluginRequestLimits::default())?; - enforce_request_response_bounds(&mut response, PluginRequestLimits::default()); - serde_json::to_vec(&response).map_err(|error| { - PluginRequestError::new(format!("failed to encode request response: {error}")) - }) -} - -fn execute_plugin_websocket_open( - record: &ResolvedPluginRecord, - client: &dyn PluginWebSocketClient, - handles: &PluginWebSocketHandles, - bytes: &[u8], -) -> Result, PluginWebSocketError> { - let (request, url) = validate_plugin_websocket_open_request(record, bytes)?; - let limits = PluginWebSocketLimits::default(); - if !client.supports_bounded_open() { - return Err(PluginWebSocketError::new( - "host_api.websocket client cannot guarantee bounded/cancellable open; refusing to dial", - )); - } - let reservation = handles.reserve_open()?; - let connection = client.open(&request, &url, limits)?; - let handle = reservation.commit(connection)?; - serde_json::to_vec(&PluginWebSocketOpenResponse { - handle, - url: safe_url(&url), - }) - .map_err(|error| PluginWebSocketError::new(error.to_string())) -} - -fn execute_plugin_websocket_send_text( - handles: &PluginWebSocketHandles, - handle: u32, - bytes: &[u8], -) -> Result, PluginWebSocketError> { - if bytes.len() > PLUGIN_WEBSOCKET_MAX_TEXT_BYTES { - return Err(PluginWebSocketError::new(format!( - "WebSocket text frame exceeds {} bytes", - PLUGIN_WEBSOCKET_MAX_TEXT_BYTES - ))); - } - let text = std::str::from_utf8(bytes) - .map_err(|_| PluginWebSocketError::new("WebSocket send_text requires UTF-8 text"))?; - handles.with_connection(handle, |connection| connection.send_text(text))?; - serde_json::to_vec(&PluginWebSocketSendResponse { - sent: true, - bytes: bytes.len(), - }) - .map_err(|error| PluginWebSocketError::new(error.to_string())) -} - -fn execute_plugin_websocket_recv( - handles: &PluginWebSocketHandles, - handle: u32, - timeout_ms: u32, -) -> Result, PluginWebSocketError> { - let timeout = websocket_timeout(timeout_ms); - let response = handles.with_connection(handle, |connection| { - connection.recv_text(timeout, PLUGIN_WEBSOCKET_MAX_MESSAGE_BYTES) - })?; - serde_json::to_vec(&response).map_err(|error| PluginWebSocketError::new(error.to_string())) -} - -fn execute_plugin_websocket_close( - handles: &PluginWebSocketHandles, - handle: u32, -) -> Result, PluginWebSocketError> { - let closed = handles.close(handle)?; - serde_json::to_vec(&PluginWebSocketCloseResponse { closed }) - .map_err(|error| PluginWebSocketError::new(error.to_string())) -} -fn execute_plugin_fs_request( - record: &ResolvedPluginRecord, - operation: PluginFsRuntimeOperation, - request_bytes: &[u8], -) -> Result, PluginFsError> { - if request_bytes.len() > PLUGIN_FS_MAX_REQUEST_BYTES { - return Err(PluginFsError::new(format!( - "FS request descriptor exceeds {} bytes", - PLUGIN_FS_MAX_REQUEST_BYTES - ))); - } - authorize_plugin_host_api(record, PluginHostApi::Fs).map_err(|error| { - PluginFsError::new(format!( - "plugin host API dispatch denied: {}", - error.bounded_message() - )) - })?; - - match operation { - PluginFsRuntimeOperation::Read => { - let request: PluginFsPathRequest = - serde_json::from_slice(request_bytes).map_err(|error| { - PluginFsError::new(format!("invalid FS read request JSON: {error}")) - })?; - execute_plugin_fs_read(record, &request.path) - } - PluginFsRuntimeOperation::List => { - let request: PluginFsPathRequest = - serde_json::from_slice(request_bytes).map_err(|error| { - PluginFsError::new(format!("invalid FS list request JSON: {error}")) - })?; - execute_plugin_fs_list(record, &request.path) - } - PluginFsRuntimeOperation::Write => { - let request: PluginFsWriteRequest = - serde_json::from_slice(request_bytes).map_err(|error| { - PluginFsError::new(format!("invalid FS write request JSON: {error}")) - })?; - execute_plugin_fs_write(record, &request.path, request.content.as_bytes()) - } - } -} - -fn execute_plugin_fs_read( - record: &ResolvedPluginRecord, - path: &str, -) -> Result, PluginFsError> { - let target = authorize_fs_path(record, PluginFsRuntimeOperation::Read, path)?; - let meta = fs::metadata(&target.resolved).map_err(|error| { - PluginFsError::new(format!( - "FS read metadata failed for {}: {error}", - safe_fs_path(path) - )) - })?; - if !meta.is_file() { - return Err(PluginFsError::new(format!( - "FS read target is not a regular file: {}", - safe_fs_path(path) - ))); - } - let mut file = fs::File::open(&target.resolved).map_err(|error| { - PluginFsError::new(format!( - "FS read failed for {}: {error}", - safe_fs_path(path) - )) - })?; - let mut bytes = Vec::new(); - std::io::Read::by_ref(&mut file) - .take((PLUGIN_FS_MAX_READ_BYTES + 1) as u64) - .read_to_end(&mut bytes) - .map_err(|error| { - PluginFsError::new(format!( - "FS read failed for {}: {error}", - safe_fs_path(path) - )) - })?; - let truncated = bytes.len() > PLUGIN_FS_MAX_READ_BYTES; - if truncated { - bytes.truncate(PLUGIN_FS_MAX_READ_BYTES); - } - let response = PluginFsReadResponse { - path: safe_fs_path(path), - content: String::from_utf8_lossy(&bytes).into_owned(), - truncated, - }; - serde_json::to_vec(&response) - .map_err(|error| PluginFsError::new(format!("failed to encode FS read response: {error}"))) -} - -fn execute_plugin_fs_list( - record: &ResolvedPluginRecord, - path: &str, -) -> Result, PluginFsError> { - let target = authorize_fs_path(record, PluginFsRuntimeOperation::List, path)?; - let meta = fs::metadata(&target.resolved).map_err(|error| { - PluginFsError::new(format!( - "FS list metadata failed for {}: {error}", - safe_fs_path(path) - )) - })?; - if !meta.is_dir() { - return Err(PluginFsError::new(format!( - "FS list target is not a directory: {}", - safe_fs_path(path) - ))); - } - let mut entries = Vec::new(); - let mut truncated = false; - for entry in fs::read_dir(&target.resolved).map_err(|error| { - PluginFsError::new(format!( - "FS list failed for {}: {error}", - safe_fs_path(path) - )) - })? { - let entry = entry.map_err(|error| { - PluginFsError::new(format!( - "FS list failed for {}: {error}", - safe_fs_path(path) - )) - })?; - if entries.len() >= PLUGIN_FS_MAX_LIST_ENTRIES { - truncated = true; - break; - } - let name = entry.file_name().to_string_lossy().into_owned(); - let file_type = entry.file_type().map_err(|error| { - PluginFsError::new(format!( - "FS list failed for {}: {error}", - safe_fs_path(path) - )) - })?; - let kind = if file_type.is_dir() { - "dir" - } else if file_type.is_file() { - "file" - } else if file_type.is_symlink() { - "symlink" - } else { - "other" - }; - entries.push(PluginFsDirEntry { - name, - kind: kind.to_string(), - }); - } - entries.sort_by(|left, right| left.name.cmp(&right.name)); - let response = PluginFsListResponse { - path: safe_fs_path(path), - entries, - truncated, - }; - serde_json::to_vec(&response) - .map_err(|error| PluginFsError::new(format!("failed to encode FS list response: {error}"))) -} - -fn execute_plugin_fs_write( - record: &ResolvedPluginRecord, - path: &str, - content: &[u8], -) -> Result, PluginFsError> { - if content.len() > PLUGIN_FS_MAX_WRITE_BYTES { - return Err(PluginFsError::new(format!( - "FS write content exceeds {} bytes", - PLUGIN_FS_MAX_WRITE_BYTES - ))); - } - let target = authorize_fs_write_path(record, path)?; - let lock = plugin_fs_write_lock(target.lock_key.clone()); - let _guard = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); - let mut options = fs::OpenOptions::new(); - options.write(true).create(true).truncate(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt as _; - options.custom_flags(libc::O_NOFOLLOW); - } - let mut file = options.open(&target.resolved).map_err(|error| { - PluginFsError::new(format!( - "FS write failed for {}: {error}", - safe_fs_path(path) - )) - })?; - file.write_all(content).map_err(|error| { - PluginFsError::new(format!( - "FS write failed for {}: {error}", - safe_fs_path(path) - )) - })?; - file.sync_all().map_err(|error| { - PluginFsError::new(format!( - "FS write failed for {}: {error}", - safe_fs_path(path) - )) - })?; - let response = PluginFsWriteResponse { - path: safe_fs_path(path), - bytes_written: content.len(), - }; - serde_json::to_vec(&response) - .map_err(|error| PluginFsError::new(format!("failed to encode FS write response: {error}"))) -} - -fn enforce_request_response_bounds( - response: &mut PluginRequestResponse, - limits: PluginRequestLimits, -) { - if response.body.len() > limits.max_response_bytes { - truncate_string_to_boundary(&mut response.body, limits.max_response_bytes); - response.truncated = true; - } - if response.headers.len() > PLUGIN_REQUEST_MAX_RESPONSE_HEADERS { - response - .headers - .truncate(PLUGIN_REQUEST_MAX_RESPONSE_HEADERS); - } - for header in &mut response.headers { - header.value = bounded_header_value(&header.value); - } -} - -fn truncate_string_to_boundary(value: &mut String, max_len: usize) { - if value.len() <= max_len { - return; - } - let mut boundary = max_len; - while boundary > 0 && !value.is_char_boundary(boundary) { - boundary -= 1; - } - value.truncate(boundary); -} - -#[derive(Clone, Debug)] -struct PluginFsResolvedPath { - resolved: PathBuf, -} - -#[derive(Clone, Debug)] -struct PluginFsWritePath { - resolved: PathBuf, - lock_key: PathBuf, -} - -fn authorize_fs_path( - record: &ResolvedPluginRecord, - operation: PluginFsRuntimeOperation, - path: &str, -) -> Result { - let relative = parse_plugin_fs_relative_path(path)?; - let mut saw_operation_grant = false; - let mut last_error: Option = None; - for grant in &record.grants.fs { - if !grant_allows_operation(grant, operation) { - continue; - } - saw_operation_grant = true; - match resolve_plugin_fs_grant_root(grant) - .and_then(|root| resolve_existing_plugin_fs_path(&root, &relative, safe_fs_path(path))) - { - Ok(resolved) => return Ok(PluginFsResolvedPath { resolved }), - Err(error) => last_error = Some(error), - } - } - if !saw_operation_grant { - return Err(PluginFsError::new(format!( - "host_api.fs {} denied: no matching operation grant", - operation.as_str() - ))); - } - Err(last_error.unwrap_or_else(|| { - PluginFsError::new(format!( - "host_api.fs {} denied by scoped path policy", - operation.as_str() - )) - })) -} - -fn authorize_fs_write_path( - record: &ResolvedPluginRecord, - path: &str, -) -> Result { - let relative = parse_plugin_fs_relative_path(path)?; - if relative.as_os_str().is_empty() || relative.file_name().is_none() { - return Err(PluginFsError::new("FS write path must name a file")); - } - let mut saw_operation_grant = false; - let mut last_error: Option = None; - for grant in &record.grants.fs { - if !grant_allows_operation(grant, PluginFsRuntimeOperation::Write) { - continue; - } - saw_operation_grant = true; - match resolve_plugin_fs_grant_root(grant) - .and_then(|root| resolve_writable_plugin_fs_path(&root, &relative, safe_fs_path(path))) - { - Ok(resolved) => return Ok(resolved), - Err(error) => last_error = Some(error), - } - } - if !saw_operation_grant { - return Err(PluginFsError::new( - "host_api.fs write denied: no matching operation grant", - )); - } - Err(last_error - .unwrap_or_else(|| PluginFsError::new("host_api.fs write denied by scoped path policy"))) -} - -fn grant_allows_operation(grant: &PluginFsGrant, operation: PluginFsRuntimeOperation) -> bool { - let requested = operation.grant_operation(); - grant.operations.iter().any(|allowed| *allowed == requested) -} - -fn parse_plugin_fs_relative_path(path: &str) -> Result { - if path.as_bytes().len() > PLUGIN_FS_MAX_PATH_BYTES { - return Err(PluginFsError::new(format!( - "FS path exceeds {} bytes", - PLUGIN_FS_MAX_PATH_BYTES - ))); - } - if path.is_empty() { - return Err(PluginFsError::new("FS path must not be empty")); - } - if path.contains('\0') { - return Err(PluginFsError::new("FS path contains a NUL byte")); - } - let input = Path::new(path); - if input.is_absolute() { - return Err(PluginFsError::new( - "FS request paths must be relative to a configured fs grant root", - )); - } - let mut relative = PathBuf::new(); - for component in input.components() { - match component { - Component::Normal(component) => relative.push(component), - Component::CurDir => {} - Component::ParentDir => { - return Err(PluginFsError::new( - "FS path traversal outside the grant root is denied", - )); - } - Component::RootDir | Component::Prefix(_) => { - return Err(PluginFsError::new( - "FS request paths must be relative to a configured fs grant root", - )); - } - } - } - Ok(relative) -} - -fn resolve_plugin_fs_grant_root(grant: &PluginFsGrant) -> Result { - let root = grant.root.trim(); - if root.is_empty() || root.as_bytes().len() > PLUGIN_FS_MAX_PATH_BYTES { - return Err(PluginFsError::new("configured fs grant root is invalid")); - } - let root_path = Path::new(root); - if !root_path.is_absolute() { - return Err(PluginFsError::new( - "configured fs grant root must be an absolute path", - )); - } - let root_meta = fs::symlink_metadata(root_path) - .map_err(|_| PluginFsError::new("configured fs grant root is unavailable"))?; - if root_meta.file_type().is_symlink() { - return Err(PluginFsError::new( - "configured fs grant root must not be a symlink", - )); - } - let canonical = fs::canonicalize(root_path) - .map_err(|_| PluginFsError::new("configured fs grant root is unavailable"))?; - let metadata = fs::metadata(&canonical) - .map_err(|_| PluginFsError::new("configured fs grant root is unavailable"))?; - if !metadata.is_dir() { - return Err(PluginFsError::new( - "configured fs grant root must be a directory", - )); - } - Ok(canonical) -} - -fn resolve_existing_plugin_fs_path( - root: &Path, - relative: &Path, - requested: String, -) -> Result { - let target = root.join(relative); - reject_symlink_components(root, relative, true, &requested)?; - let canonical = fs::canonicalize(&target).map_err(|error| { - PluginFsError::new(format!("FS path is unavailable for {requested}: {error}")) - })?; - if !canonical.starts_with(root) { - return Err(PluginFsError::new(format!( - "FS path escapes the configured grant root: {requested}" - ))); - } - Ok(canonical) -} - -fn resolve_writable_plugin_fs_path( - root: &Path, - relative: &Path, - requested: String, -) -> Result { - let Some(file_name) = relative.file_name() else { - return Err(PluginFsError::new("FS write path must name a file")); - }; - let parent_relative = relative.parent().unwrap_or_else(|| Path::new("")); - reject_symlink_components(root, parent_relative, true, &requested)?; - let parent = fs::canonicalize(root.join(parent_relative)).map_err(|error| { - PluginFsError::new(format!( - "FS write parent is unavailable for {requested}: {error}" - )) - })?; - if !parent.starts_with(root) { - return Err(PluginFsError::new(format!( - "FS write parent escapes the configured grant root: {requested}" - ))); - } - let resolved = parent.join(file_name); - if let Ok(meta) = fs::symlink_metadata(&resolved) { - if meta.file_type().is_symlink() { - return Err(PluginFsError::new(format!( - "FS write target is a symlink: {requested}" - ))); - } - if meta.is_dir() { - return Err(PluginFsError::new(format!( - "FS write target is a directory: {requested}" - ))); - } - let canonical = fs::canonicalize(&resolved).map_err(|error| { - PluginFsError::new(format!( - "FS write target is unavailable for {requested}: {error}" - )) - })?; - if !canonical.starts_with(root) { - return Err(PluginFsError::new(format!( - "FS write target escapes the configured grant root: {requested}" - ))); - } - return Ok(PluginFsWritePath { - resolved, - lock_key: canonical, - }); - } - if !resolved.starts_with(root) { - return Err(PluginFsError::new(format!( - "FS write target escapes the configured grant root: {requested}" - ))); - } - Ok(PluginFsWritePath { - lock_key: resolved.clone(), - resolved, - }) -} - -fn reject_symlink_components( - root: &Path, - relative: &Path, - include_leaf: bool, - requested: &str, -) -> Result<(), PluginFsError> { - let mut current = root.to_path_buf(); - let components = relative.components().collect::>(); - for (index, component) in components.iter().enumerate() { - let Component::Normal(name) = component else { - continue; - }; - current.push(name); - if !include_leaf && index + 1 == components.len() { - break; - } - let metadata = fs::symlink_metadata(¤t).map_err(|error| { - PluginFsError::new(format!( - "FS path component is unavailable for {requested}: {error}" - )) - })?; - if metadata.file_type().is_symlink() { - return Err(PluginFsError::new(format!( - "FS path symlink escape is denied: {requested}" - ))); - } - } - Ok(()) -} - -fn plugin_fs_write_lock(path: PathBuf) -> Arc> { - static LOCKS: OnceLock>>>> = OnceLock::new(); - let map = LOCKS.get_or_init(|| Mutex::new(HashMap::new())); - let mut map = map.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); - map.entry(path) - .or_insert_with(|| Arc::new(Mutex::new(()))) - .clone() -} - -fn safe_fs_path(path: &str) -> String { - let mut sanitized = redact_secret_like(path).replace('\0', " "); - if sanitized.len() > 160 { - let mut boundary = 160; - while boundary > 0 && !sanitized.is_char_boundary(boundary) { - boundary -= 1; - } - sanitized.truncate(boundary); - sanitized.push('…'); - } - if sanitized.is_empty() { - ".".to_string() - } else { - sanitized - } -} - -fn validate_plugin_request_request( - record: &ResolvedPluginRecord, - request: &PluginRequestRequest, -) -> Result { - let method = request.method.trim().to_ascii_uppercase(); - if method != request.method || !PLUGIN_REQUEST_ALLOWED_METHODS.contains(&method.as_str()) { - return Err(PluginRequestError::new(format!( - "request method `{}` is not allowed", - request.method - ))); - } - if request.headers.len() > PLUGIN_REQUEST_MAX_REQUEST_HEADERS { - return Err(PluginRequestError::new(format!( - "request descriptor has too many headers (max {})", - PLUGIN_REQUEST_MAX_REQUEST_HEADERS - ))); - } - for header in &request.headers { - validate_request_header(header)?; - } - if let Some(body) = &request.body { - if body.len() > PLUGIN_REQUEST_MAX_REQUEST_BODY_BYTES { - return Err(PluginRequestError::new(format!( - "request body exceeds {} bytes", - PLUGIN_REQUEST_MAX_REQUEST_BODY_BYTES - ))); - } - } - let url = reqwest::Url::parse(&request.url) - .map_err(|error| PluginRequestError::new(format!("invalid request URL: {error}")))?; - match url.scheme() { - "http" | "https" => {} - "ws" | "wss" => { - return Err(PluginRequestError::new( - "WebSocket URLs are not supported by host_api.request", - )); - } - scheme => { - return Err(PluginRequestError::new(format!( - "unsupported request URL scheme {scheme:?}; only http and https are allowed" - ))); - } - } - if url.host_str().is_none() { - return Err(PluginRequestError::new("request URL must include a host")); - } - if !url.username().is_empty() || url.password().is_some() { - return Err(PluginRequestError::new( - "request URLs with embedded credentials are not allowed", - )); - } - validate_static_request_target(&url)?; - authorize_request_allowlist(record, &method, &url)?; - Ok(url) -} - -fn validate_request_header(header: &PluginRequestHeader) -> Result<(), PluginRequestError> { - if header.name.is_empty() || header.name.len() > PLUGIN_REQUEST_MAX_HEADER_NAME_BYTES { - return Err(PluginRequestError::new("request header name is invalid")); - } - if header.value.len() > PLUGIN_REQUEST_MAX_HEADER_VALUE_BYTES { - return Err(PluginRequestError::new(format!( - "request header `{}` exceeds {} bytes", - header.name, PLUGIN_REQUEST_MAX_HEADER_VALUE_BYTES - ))); - } - if is_sensitive_header(&header.name) { - return Err(PluginRequestError::new(format!( - "request header `{}` is credential-like and must be supplied by an explicit future secret-ref grant, not guest memory", - header.name - ))); - } - if header.name.eq_ignore_ascii_case("upgrade") - || (header.name.eq_ignore_ascii_case("connection") - && header - .value - .split(',') - .any(|value| value.trim().eq_ignore_ascii_case("upgrade"))) - { - return Err(PluginRequestError::new( - "persistent connection upgrade headers are not supported by host_api.request", - )); - } - if header.name.eq_ignore_ascii_case("accept") - && header - .value - .split(',') - .any(|value| value.trim().eq_ignore_ascii_case("text/event-stream")) - { - return Err(PluginRequestError::new( - "SSE/event-stream requests are not supported by host_api.request", - )); - } - reqwest::header::HeaderName::from_bytes(header.name.as_bytes()).map_err(|_| { - PluginRequestError::new(format!("invalid request header name `{}`", header.name)) - })?; - reqwest::header::HeaderValue::from_str(&header.value).map_err(|_| { - PluginRequestError::new(format!( - "invalid request header value for `{}`", - header.name - )) - })?; - Ok(()) -} - -fn authorize_request_allowlist( - record: &ResolvedPluginRecord, - method: &str, - url: &reqwest::Url, -) -> Result<(), PluginRequestError> { - if !request_targets_allow(&record.manifest.request, method, url) { - return Err(PluginRequestError::new(format!( - "host_api.request target {} {} is not declared by the plugin manifest", - method, - safe_url(url) - ))); - } - if !request_targets_allow(&record.grants.request, method, url) { - return Err(PluginRequestError::new(format!( - "host_api.request target {} {} is not covered by enabled request grants", - method, - safe_url(url) - ))); - } - Ok(()) -} - -fn validate_plugin_websocket_open_request( - record: &ResolvedPluginRecord, - bytes: &[u8], -) -> Result<(PluginWebSocketOpenRequest, reqwest::Url), PluginWebSocketError> { - if bytes.len() > PLUGIN_WEBSOCKET_MAX_OPEN_REQUEST_BYTES { - return Err(PluginWebSocketError::new(format!( - "WebSocket open descriptor exceeds {} bytes", - PLUGIN_WEBSOCKET_MAX_OPEN_REQUEST_BYTES - ))); - } - let request: PluginWebSocketOpenRequest = serde_json::from_slice(bytes).map_err(|error| { - PluginWebSocketError::new(format!("invalid WebSocket open request JSON: {error}")) - })?; - if !request.protocols.is_empty() { - return Err(PluginWebSocketError::new( - "WebSocket subprotocol negotiation is not supported by host_api.websocket v1", - )); - } - if !request.headers.is_empty() { - return Err(PluginWebSocketError::new( - "WebSocket handshake headers from guest memory are not supported; future secret-ref grants must inject credential-bearing headers explicitly", - )); - } - let url = reqwest::Url::parse(&request.url) - .map_err(|error| PluginWebSocketError::new(format!("invalid WebSocket URL: {error}")))?; - match url.scheme() { - "ws" | "wss" => {} - "http" | "https" => { - return Err(PluginWebSocketError::new( - "HTTP URLs are not supported by host_api.websocket", - )); - } - scheme => { - return Err(PluginWebSocketError::new(format!( - "unsupported WebSocket URL scheme {scheme:?}; only ws and wss are allowed" - ))); - } - } - if url.host_str().is_none() { - return Err(PluginWebSocketError::new( - "WebSocket URL must include a host", - )); - } - if !url.username().is_empty() || url.password().is_some() { - return Err(PluginWebSocketError::new( - "WebSocket URLs with embedded credentials are not allowed", - )); - } - validate_static_request_target(&url).map_err(|error| PluginWebSocketError::new(error.0))?; - authorize_websocket_allowlist(record, &url)?; - Ok((request, url)) -} - -fn authorize_websocket_allowlist( - record: &ResolvedPluginRecord, - url: &reqwest::Url, -) -> Result<(), PluginWebSocketError> { - if !websocket_targets_allow(&record.manifest.websocket, url) { - return Err(PluginWebSocketError::new(format!( - "host_api.websocket target {} is not declared by the plugin manifest", - safe_url(url) - ))); - } - if !websocket_targets_allow(&record.grants.websocket, url) { - return Err(PluginWebSocketError::new(format!( - "host_api.websocket target {} is not covered by enabled WebSocket grants", - safe_url(url) - ))); - } - Ok(()) -} - -fn websocket_timeout(timeout_ms: u32) -> Duration { - if timeout_ms == 0 { - return PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT; - } - let requested = Duration::from_millis(u64::from(timeout_ms)); - requested.min(PLUGIN_WEBSOCKET_MAX_TIMEOUT) -} - -fn request_targets_allow(targets: &[PluginRequestGrant], method: &str, url: &reqwest::Url) -> bool { - targets - .iter() - .any(|target| request_target_allows(target, method, url)) -} - -fn request_target_allows(target: &PluginRequestGrant, method: &str, url: &reqwest::Url) -> bool { - let scheme = target.scheme.trim().to_ascii_lowercase(); - if scheme.is_empty() || (scheme != "*" && scheme != url.scheme()) { - return false; - } - let Ok(host) = canonical_host(url) else { - return false; - }; - let target_host = normalize_host_literal(&target.host); - if target_host.is_empty() || (target_host != "*" && target_host != host) { - return false; - } - if let Some(port) = target.port { - if url.port_or_known_default() != Some(port) { - return false; - } - } - if target.methods.is_empty() - || !target - .methods - .iter() - .any(|allowed_method| allowed_method.trim().eq_ignore_ascii_case(method)) - { - return false; - } - target.path_prefixes.is_empty() - || target - .path_prefixes - .iter() - .any(|prefix| !prefix.is_empty() && url.path().starts_with(prefix)) -} - -fn request_targets_intersect(left: &PluginRequestGrant, right: &PluginRequestGrant) -> bool { - request_scheme_intersects(&left.scheme, &right.scheme) - && request_host_intersects(&left.host, &right.host) - && request_port_intersects(left.port, right.port) - && request_methods_intersect(&left.methods, &right.methods) - && request_paths_intersect(&left.path_prefixes, &right.path_prefixes) -} - -fn request_target_covers(covering: &PluginRequestGrant, covered: &PluginRequestGrant) -> bool { - request_scheme_covers(&covering.scheme, &covered.scheme) - && request_host_covers(&covering.host, &covered.host) - && request_port_covers(covering.port, covered.port) - && request_methods_cover(&covering.methods, &covered.methods) - && request_paths_cover(&covering.path_prefixes, &covered.path_prefixes) -} - -fn websocket_targets_allow(targets: &[PluginWebSocketGrant], url: &reqwest::Url) -> bool { - targets - .iter() - .any(|target| websocket_target_allows(target, url)) -} - -fn websocket_target_allows(target: &PluginWebSocketGrant, url: &reqwest::Url) -> bool { - let scheme = target.scheme.trim().to_ascii_lowercase(); - if scheme.is_empty() || (scheme != "*" && scheme != url.scheme()) { - return false; - } - let Ok(host) = canonical_host(url) else { - return false; - }; - let target_host = normalize_host_literal(&target.host); - if target_host.is_empty() || (target_host != "*" && target_host != host) { - return false; - } - if let Some(port) = target.port { - if url.port_or_known_default() != Some(port) { - return false; - } - } - target.path_prefixes.is_empty() - || target - .path_prefixes - .iter() - .any(|prefix| !prefix.is_empty() && url.path().starts_with(prefix)) -} - -fn websocket_targets_intersect(left: &PluginWebSocketGrant, right: &PluginWebSocketGrant) -> bool { - request_scheme_intersects(&left.scheme, &right.scheme) - && request_host_intersects(&left.host, &right.host) - && request_port_intersects(left.port, right.port) - && request_paths_intersect(&left.path_prefixes, &right.path_prefixes) -} - -fn websocket_target_covers( - covering: &PluginWebSocketGrant, - covered: &PluginWebSocketGrant, -) -> bool { - request_scheme_covers(&covering.scheme, &covered.scheme) - && request_host_covers(&covering.host, &covered.host) - && request_port_covers(covering.port, covered.port) - && request_paths_cover(&covering.path_prefixes, &covered.path_prefixes) -} - -fn request_scheme_intersects(left: &str, right: &str) -> bool { - let left = left.trim().to_ascii_lowercase(); - let right = right.trim().to_ascii_lowercase(); - !left.is_empty() && !right.is_empty() && (left == "*" || right == "*" || left == right) -} - -fn request_scheme_covers(covering: &str, covered: &str) -> bool { - let covering = covering.trim().to_ascii_lowercase(); - let covered = covered.trim().to_ascii_lowercase(); - !covering.is_empty() && !covered.is_empty() && (covering == "*" || covering == covered) -} - -fn request_host_intersects(left: &str, right: &str) -> bool { - let left = normalize_host_literal(left); - let right = normalize_host_literal(right); - !left.is_empty() && !right.is_empty() && (left == "*" || right == "*" || left == right) -} - -fn request_host_covers(covering: &str, covered: &str) -> bool { - let covering = normalize_host_literal(covering); - let covered = normalize_host_literal(covered); - !covering.is_empty() && !covered.is_empty() && (covering == "*" || covering == covered) -} - -fn request_port_intersects(left: Option, right: Option) -> bool { - left.is_none() || right.is_none() || left == right -} - -fn request_port_covers(covering: Option, covered: Option) -> bool { - covering.is_none() || covering == covered -} - -fn request_methods_intersect(left: &[String], right: &[String]) -> bool { - !left.is_empty() - && !right.is_empty() - && left.iter().any(|left_method| { - right - .iter() - .any(|right_method| left_method.trim().eq_ignore_ascii_case(right_method.trim())) - }) -} - -fn request_methods_cover(covering: &[String], covered: &[String]) -> bool { - !covering.is_empty() - && !covered.is_empty() - && covered.iter().all(|covered_method| { - covering.iter().any(|covering_method| { - covering_method - .trim() - .eq_ignore_ascii_case(covered_method.trim()) - }) - }) -} - -fn request_paths_intersect(left: &[String], right: &[String]) -> bool { - left.is_empty() - || right.is_empty() - || left.iter().any(|left_prefix| { - !left_prefix.is_empty() - && right.iter().any(|right_prefix| { - !right_prefix.is_empty() - && (left_prefix.starts_with(right_prefix) - || right_prefix.starts_with(left_prefix)) - }) - }) -} - -fn request_paths_cover(covering: &[String], covered: &[String]) -> bool { - if covering.is_empty() { - return true; - } - if covered.is_empty() { - return false; - } - covered.iter().all(|covered_prefix| { - !covered_prefix.is_empty() - && covering.iter().any(|covering_prefix| { - !covering_prefix.is_empty() && covered_prefix.starts_with(covering_prefix) - }) - }) -} - -fn normalize_host_literal(host: &str) -> String { - host.trim_end_matches('.') - .trim_start_matches('[') - .trim_end_matches(']') - .to_ascii_lowercase() -} - -fn has_usable_request_grant(record: &ResolvedPluginRecord) -> bool { - record.grants.request.iter().any(|grant| { - !grant.scheme.trim().is_empty() - && !grant.host.trim().is_empty() - && grant.methods.iter().any(|method| { - let method = method.trim().to_ascii_uppercase(); - PLUGIN_REQUEST_ALLOWED_METHODS.contains(&method.as_str()) - }) - }) -} - -fn has_declared_request_target(record: &ResolvedPluginRecord) -> bool { - record.manifest.request.iter().any(|target| { - !target.scheme.trim().is_empty() - && !target.host.trim().is_empty() - && target.methods.iter().any(|method| { - let method = method.trim().to_ascii_uppercase(); - PLUGIN_REQUEST_ALLOWED_METHODS.contains(&method.as_str()) - }) - }) -} - -fn has_usable_websocket_grant(record: &ResolvedPluginRecord) -> bool { - record.grants.websocket.iter().any(|grant| { - let scheme = grant.scheme.trim().to_ascii_lowercase(); - (scheme == "ws" || scheme == "wss" || scheme == "*") && !grant.host.trim().is_empty() - }) -} - -fn has_declared_websocket_target(record: &ResolvedPluginRecord) -> bool { - record.manifest.websocket.iter().any(|target| { - let scheme = target.scheme.trim().to_ascii_lowercase(); - (scheme == "ws" || scheme == "wss" || scheme == "*") && !target.host.trim().is_empty() - }) -} - -fn has_usable_fs_grant(record: &ResolvedPluginRecord) -> bool { - record.grants.fs.iter().any(|grant| { - !grant.root.trim().is_empty() - && Path::new(&grant.root).is_absolute() - && !grant.operations.is_empty() - }) -} - -fn canonical_host(url: &reqwest::Url) -> Result { - url.host_str() - .map(normalize_host_literal) - .filter(|host| !host.is_empty()) - .ok_or_else(|| PluginRequestError::new("request URL must include a host")) -} - -fn validate_static_request_target(url: &reqwest::Url) -> Result<(), PluginRequestError> { - let _host = canonical_host(url)?; - if url.cannot_be_a_base() { - return Err(PluginRequestError::new( - "request URL target is not hierarchical", - )); - } - Ok(()) -} - -fn resolve_request_target_for_client( - url: &reqwest::Url, - resolver: &dyn PluginRequestResolver, -) -> Result, PluginRequestError> { - let host = canonical_host(url)?; - if host.parse::().is_ok() { - return Ok(None); - } - let port = url.port_or_known_default().ok_or_else(|| { - PluginRequestError::new("request URL uses a scheme without a default port") - })?; - let addrs = resolver.resolve(&host, port)?; - if addrs.is_empty() { - return Err(PluginRequestError::new(format!( - "DNS lookup for {:?} returned no addresses", - host - ))); - } - let mut domains = Vec::new(); - if let Some(raw_host) = url.host_str() { - let raw_host = raw_host - .trim_start_matches('[') - .trim_end_matches(']') - .to_ascii_lowercase(); - if !raw_host.is_empty() { - domains.push(raw_host); - } - } - if !domains.contains(&host) { - domains.push(host); - } - Ok(Some(PinnedRequestResolution { domains, addrs })) -} - -fn collect_request_response_headers( - headers: &reqwest::header::HeaderMap, -) -> Vec { - headers - .iter() - .filter(|(name, _)| !is_sensitive_header(name.as_str())) - .take(PLUGIN_REQUEST_MAX_RESPONSE_HEADERS) - .filter_map(|(name, value)| { - value.to_str().ok().map(|value| PluginRequestHeader { - name: name.as_str().to_string(), - value: bounded_header_value(value), - }) - }) - .collect() -} - -fn bounded_header_value(value: &str) -> String { - let mut redacted = redact_secret_like(value); - if redacted.len() > PLUGIN_REQUEST_MAX_HEADER_VALUE_BYTES { - truncate_string_to_boundary(&mut redacted, PLUGIN_REQUEST_MAX_HEADER_VALUE_BYTES); - redacted.push('…'); - } - redacted -} - -fn is_sensitive_header(name: &str) -> bool { - matches!( - name.to_ascii_lowercase().as_str(), - "authorization" - | "proxy-authorization" - | "cookie" - | "set-cookie" - | "x-api-key" - | "x-auth-token" - | "api-key" - | "apikey" - ) -} - -fn safe_url(url: &reqwest::Url) -> String { - let host = url.host_str().unwrap_or(""); - let mut path = url.path().to_string(); - if path.len() > 120 { - truncate_string_to_boundary(&mut path, 120); - path.push('…'); - } - match url.port() { - Some(port) => format!("{}://{host}:{port}{path}", url.scheme()), - None => format!("{}://{host}{path}", url.scheme()), - } -} - -fn redact_secret_like(message: &str) -> String { - let mut value = message.to_string(); - for needle in [ - "authorization", - "proxy-authorization", - "cookie", - "set-cookie", - "x-api-key", - "x-auth-token", - "api-key", - "token", - "secret", - "password", - ] { - value = redact_after_secret_word(&value, needle); - } - value -} - -fn redact_after_secret_word(input: &str, needle: &str) -> String { - let lower = input.to_ascii_lowercase(); - let mut out = String::new(); - let mut cursor = 0usize; - while let Some(relative) = lower[cursor..].find(needle) { - let start = cursor + relative; - let mut end = start + needle.len(); - out.push_str(&input[cursor..end]); - let bytes = input.as_bytes(); - while end < input.len() && matches!(bytes[end], b' ' | b'=' | b':' | b'\t') { - out.push(bytes[end] as char); - end += 1; - } - let secret_start = end; - while end < input.len() && !matches!(bytes[end], b' ' | b',' | b';' | b'\n' | b'\r') { - end += 1; - } - if end > secret_start { - out.push_str(PLUGIN_REQUEST_REDACTION); - } - cursor = end; - } - out.push_str(&input[cursor..]); - out -} - -#[derive(Debug)] -struct PluginPermissionError(String); - -impl PluginPermissionError { - fn bounded_message(&self) -> String { - bounded_message(&self.0) - } -} - -fn authorize_plugin_tool( - record: &ResolvedPluginRecord, - tool: &PluginToolManifest, -) -> Result<(), PluginPermissionError> { - validate_grant_binding(record)?; - require_permission( - &record.manifest.permissions, - &PluginPermission::surface(PluginSurface::Tool), - "requested surfaces.tool permission is missing", - )?; - require_permission( - &record.grants.permissions, - &PluginPermission::surface(PluginSurface::Tool), - "granted surfaces.tool permission is missing", - )?; - if !permission_allows_tool(&record.manifest.permissions, &tool.name) { - return Err(PluginPermissionError(format!( - "requested tool permission for `{}` is missing", - tool.name - ))); - } - if !permission_allows_tool(&record.grants.permissions, &tool.name) { - return Err(PluginPermissionError(format!( - "granted tool permission for `{}` is missing", - tool.name - ))); - } - if tool.external_write { - require_permission( - &record.manifest.permissions, - &PluginPermission::ExternalWrite, - "requested external_write permission is missing", - )?; - require_permission( - &record.grants.permissions, - &PluginPermission::ExternalWrite, - "granted external_write permission is missing", - )?; - } - Ok(()) -} - -fn authorize_plugin_service( - record: &ResolvedPluginRecord, - service_name: &str, -) -> Result<(), PluginPermissionError> { - validate_grant_binding(record)?; - require_permission( - &record.manifest.permissions, - &PluginPermission::surface(PluginSurface::Service), - "requested surfaces.service permission is missing", - )?; - require_permission( - &record.grants.permissions, - &PluginPermission::surface(PluginSurface::Service), - "granted surfaces.service permission is missing", - )?; - let permission = PluginPermission::service(service_name); - require_permission( - &record.manifest.permissions, - &permission, - &format!("requested service permission for `{service_name}` is missing"), - )?; - require_permission( - &record.grants.permissions, - &permission, - &format!("granted service permission for `{service_name}` is missing"), - )?; - Ok(()) -} - -fn authorize_plugin_ingress( - record: &ResolvedPluginRecord, - ingress_name: &str, -) -> Result<(), PluginPermissionError> { - validate_grant_binding(record)?; - require_permission( - &record.manifest.permissions, - &PluginPermission::surface(PluginSurface::Ingress), - "requested surfaces.ingress permission is missing", - )?; - require_permission( - &record.grants.permissions, - &PluginPermission::surface(PluginSurface::Ingress), - "granted surfaces.ingress permission is missing", - )?; - let permission = PluginPermission::ingress(ingress_name); - require_permission( - &record.manifest.permissions, - &permission, - &format!("requested ingress permission for `{ingress_name}` is missing"), - )?; - require_permission( - &record.grants.permissions, - &permission, - &format!("granted ingress permission for `{ingress_name}` is missing"), - )?; - Ok(()) -} - -fn authorize_plugin_host_api( - record: &ResolvedPluginRecord, - api: PluginHostApi, -) -> Result<(), PluginPermissionError> { - validate_grant_binding(record)?; - let permission = PluginPermission::host_api(api); - require_permission( - &record.manifest.permissions, - &permission, - &format!("requested host_api.{api} permission is missing"), - )?; - require_permission( - &record.grants.permissions, - &permission, - &format!("granted host_api.{api} permission is missing"), - )?; - match api { - PluginHostApi::Request => { - if !has_declared_request_target(record) { - return Err(PluginPermissionError( - "manifest host_api.request target declaration is missing".to_string(), - )); - } - if !has_usable_request_grant(record) { - return Err(PluginPermissionError( - "enabled host_api.request grants are missing".to_string(), - )); - } - Ok(()) - } - PluginHostApi::WebSocket => { - if !has_declared_websocket_target(record) { - return Err(PluginPermissionError( - "manifest host_api.websocket target declaration is missing".to_string(), - )); - } - if !has_usable_websocket_grant(record) { - return Err(PluginPermissionError( - "enabled host_api.websocket grants are missing".to_string(), - )); - } - Ok(()) - } - PluginHostApi::Fs => { - if !has_usable_fs_grant(record) { - return Err(PluginPermissionError( - "granted host_api.fs scope allowlist is missing".to_string(), - )); - } - Ok(()) - } - } -} - -fn validate_grant_binding(record: &ResolvedPluginRecord) -> Result<(), PluginPermissionError> { - if let Some(message) = - record - .grants - .binding_error(&record.identity, &record.digest, &record.manifest.version) - { - return Err(PluginPermissionError(message.to_string())); - } - Ok(()) -} - -fn require_permission( - permissions: &[PluginPermission], - expected: &PluginPermission, - missing_message: &str, -) -> Result<(), PluginPermissionError> { - if permissions.iter().any(|permission| permission == expected) { - return Ok(()); - } - Err(PluginPermissionError(missing_message.to_string())) -} - -fn permission_allows_tool(permissions: &[PluginPermission], tool_name: &str) -> bool { - permissions.iter().any(|permission| match permission { - PluginPermission::Tool { name } => name == tool_name, - PluginPermission::ToolNamespace { namespace } => { - !namespace.is_empty() && tool_name.starts_with(namespace) - } - _ => false, - }) -} - -const PLUGIN_WASM_MAX_INPUT_BYTES: usize = 64 * 1024; -const PLUGIN_WASM_MAX_OUTPUT_BYTES: usize = 64 * 1024; -const PLUGIN_WASM_MAX_SUMMARY_BYTES: usize = 1024; -const PLUGIN_WASM_FUEL: u64 = 5_000_000; -const PLUGIN_WASM_TIMEOUT: Duration = Duration::from_secs(1); -const PLUGIN_SERVICE_INGRESS_QUEUE_CAPACITY: usize = 32; -const PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_COUNT: usize = 16; -const PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_PAYLOAD_BYTES: usize = 16 * 1024; -const PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_RESULTS: usize = 32; -#[cfg(test)] -const PLUGIN_SERVICE_INGRESS_DISPATCH_TIMEOUT: Duration = Duration::from_millis(25); -#[cfg(not(test))] -const PLUGIN_SERVICE_INGRESS_DISPATCH_TIMEOUT: Duration = Duration::from_secs(1); -const PLUGIN_WASM_MEMORY_BYTES: usize = 2 * 1024 * 1024; -const PLUGIN_WASM_TABLE_ELEMENTS: usize = 256; -const PLUGIN_REQUEST_MAX_REQUEST_BYTES: usize = 48 * 1024; -const PLUGIN_REQUEST_MAX_REQUEST_BODY_BYTES: usize = 32 * 1024; -const PLUGIN_REQUEST_MAX_REQUEST_HEADERS: usize = 16; -const PLUGIN_REQUEST_MAX_RESPONSE_HEADERS: usize = 16; -const PLUGIN_REQUEST_MAX_HEADER_NAME_BYTES: usize = 64; -const PLUGIN_REQUEST_MAX_HEADER_VALUE_BYTES: usize = 1024; -const PLUGIN_REQUEST_MAX_RESPONSE_BYTES: usize = 64 * 1024; -const PLUGIN_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); -const PLUGIN_REQUEST_ALLOWED_METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE"]; -const PLUGIN_REQUEST_REDACTION: &str = ""; -const PLUGIN_WEBSOCKET_MAX_OPEN_REQUEST_BYTES: usize = 8 * 1024; -const PLUGIN_WEBSOCKET_MAX_TEXT_BYTES: usize = 32 * 1024; -const PLUGIN_WEBSOCKET_MAX_FRAME_BYTES: usize = 32 * 1024; -const PLUGIN_WEBSOCKET_MAX_MESSAGE_BYTES: usize = 64 * 1024; -const PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS: usize = 4; -const PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); -const PLUGIN_WEBSOCKET_MAX_TIMEOUT: Duration = Duration::from_secs(30); -const PLUGIN_WEBSOCKET_MAX_HANDLE_AGE: Duration = Duration::from_secs(15 * 60); -const PLUGIN_WEBSOCKET_MAX_CONTROL_FRAMES: usize = 16; -const PLUGIN_FS_MAX_REQUEST_BYTES: usize = 64 * 1024; -const PLUGIN_FS_MAX_PATH_BYTES: usize = 4096; -const PLUGIN_FS_MAX_READ_BYTES: usize = 64 * 1024; -const PLUGIN_FS_MAX_WRITE_BYTES: usize = 64 * 1024; -const PLUGIN_FS_MAX_LIST_ENTRIES: usize = 256; - -fn wasm_component_store_limits() -> wasmtime::StoreLimits { - wasmtime::StoreLimitsBuilder::new() - .memory_size(PLUGIN_WASM_MEMORY_BYTES) - .table_elements(PLUGIN_WASM_TABLE_ELEMENTS) - .instances(1) - .tables(1) - .memories(1) - .trap_on_grow_failure(true) - .build() -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PluginFsRuntimeOperation { - Read, - List, - Write, -} - -impl PluginFsRuntimeOperation { - fn grant_operation(self) -> PluginFsOperation { - match self { - Self::Read => PluginFsOperation::Read, - Self::List => PluginFsOperation::List, - Self::Write => PluginFsOperation::Write, - } - } - - fn as_str(self) -> &'static str { - match self { - Self::Read => "read", - Self::List => "list", - Self::Write => "write", - } - } -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct PluginFsPathRequest { - path: String, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct PluginFsWriteRequest { - path: String, - content: String, -} - -#[derive(Clone, Debug, Serialize)] -struct PluginFsReadResponse { - path: String, - content: String, - truncated: bool, -} - -#[derive(Clone, Debug, Serialize)] -struct PluginFsListResponse { - path: String, - entries: Vec, - truncated: bool, -} - -#[derive(Clone, Debug, Serialize)] -struct PluginFsDirEntry { - name: String, - kind: String, -} - -#[derive(Clone, Debug, Serialize)] -struct PluginFsWriteResponse { - path: String, - bytes_written: usize, -} - -#[derive(Debug)] -struct PluginFsError { - message: String, -} - -impl PluginFsError { - fn new(message: impl Into) -> Self { - let message = message.into(); - Self { - message: bounded_message(redact_secret_like(&message)), - } - } -} - -impl std::fmt::Display for PluginFsError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.message) - } -} - -impl std::error::Error for PluginFsError {} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct PluginRequestRequest { - method: String, - url: String, - #[serde(default)] - headers: Vec, - #[serde(default)] - body: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct PluginRequestHeader { - name: String, - value: String, -} - -#[derive(Clone, Debug, Serialize)] -struct PluginRequestResponse { - status: u16, - headers: Vec, - body: String, - truncated: bool, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct PluginWebSocketOpenRequest { - url: String, - #[serde(default)] - protocols: Vec, - #[serde(default)] - headers: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct PluginServiceWebSocketSendCommandPayload { - url: String, - text: String, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct PluginServiceDiagnosticStatusCommandPayload { - #[serde(default)] - message: Option, - #[serde(default)] - status: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct PluginWebSocketOpenResponse { - handle: u32, - url: String, -} - -#[derive(Clone, Debug, Serialize)] -struct PluginWebSocketSendResponse { - sent: bool, - bytes: usize, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum PluginWebSocketRecvResponse { - Text { text: String }, - Closed, -} - -#[derive(Clone, Debug, Serialize)] -struct PluginWebSocketCloseResponse { - closed: bool, -} - -#[derive(Clone, Copy, Debug)] -struct PluginWebSocketLimits { - timeout: Duration, - max_message_bytes: usize, -} - -impl Default for PluginWebSocketLimits { - fn default() -> Self { - Self { - timeout: PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT, - max_message_bytes: PLUGIN_WEBSOCKET_MAX_MESSAGE_BYTES, - } - } -} - -#[derive(Debug)] -struct PluginWebSocketError(String); - -impl PluginWebSocketError { - fn new(message: impl Into) -> Self { - Self(redact_secret_like(&bounded_message(message.into()))) - } -} - -impl std::fmt::Display for PluginWebSocketError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } -} - -impl std::error::Error for PluginWebSocketError {} - -trait PluginWebSocketConnection: Send { - fn send_text(&mut self, text: &str) -> Result<(), PluginWebSocketError>; - fn recv_text( - &mut self, - timeout: Duration, - max_message_bytes: usize, - ) -> Result; - fn close(&mut self) -> Result<(), PluginWebSocketError>; -} - -trait PluginWebSocketClient: Send + Sync { - fn supports_bounded_open(&self) -> bool; - - fn open( - &self, - request: &PluginWebSocketOpenRequest, - url: &reqwest::Url, - limits: PluginWebSocketLimits, - ) -> Result, PluginWebSocketError>; -} - -struct TungstenitePluginWebSocketClient; - -type AsyncSystemWebSocket = - tokio_tungstenite::WebSocketStream>; - -struct PluginWebSocketRuntime { - runtime: Option, -} - -impl PluginWebSocketRuntime { - fn new(runtime: TokioRuntime) -> Self { - Self { - runtime: Some(runtime), - } - } - - fn get(&self) -> &TokioRuntime { - self.runtime - .as_ref() - .expect("plugin websocket runtime missing") - } -} - -impl Drop for PluginWebSocketRuntime { - fn drop(&mut self) { - let Some(runtime) = self.runtime.take() else { - return; - }; - if TokioHandle::try_current().is_ok() { - let _ = tokio::task::spawn_blocking(move || drop(runtime)); - } else { - drop(runtime); - } - } -} - -struct TungstenitePluginWebSocketConnection { - runtime: PluginWebSocketRuntime, - socket: AsyncSystemWebSocket, -} - -impl PluginWebSocketClient for TungstenitePluginWebSocketClient { - fn supports_bounded_open(&self) -> bool { - true - } - - fn open( - &self, - _request: &PluginWebSocketOpenRequest, - url: &reqwest::Url, - limits: PluginWebSocketLimits, - ) -> Result, PluginWebSocketError> { - let mut request = url.as_str().into_client_request().map_err(|error| { - PluginWebSocketError::new(format!("WebSocket request build failed: {error}")) - })?; - request.headers_mut().insert( - tokio_tungstenite::tungstenite::http::header::USER_AGENT, - tokio_tungstenite::tungstenite::http::HeaderValue::from_static("yoi-plugin-host/1"), - ); - let config = WebSocketConfig::default() - .max_message_size(Some(limits.max_message_bytes)) - .max_frame_size(Some(PLUGIN_WEBSOCKET_MAX_FRAME_BYTES)) - .accept_unmasked_frames(false); - let runtime = new_websocket_runtime()?; - let open = async { - tokio::time::timeout( - limits.timeout, - tokio_tungstenite::connect_async_tls_with_config( - request, - Some(config), - false, - None, - ), - ) - .await - }; - let (socket, _response) = block_on_websocket_future(runtime.get(), open) - .map_err(|error| { - PluginWebSocketError::new(format!( - "WebSocket open timed out after {} ms for {}: {error}", - limits.timeout.as_millis(), - safe_url(url) - )) - })? - .map_err(|error| { - PluginWebSocketError::new(format!( - "WebSocket connection failed for {}: {error}", - safe_url(url) - )) - })?; - Ok(Box::new(TungstenitePluginWebSocketConnection { - runtime, - socket, - })) - } -} - -impl PluginWebSocketConnection for TungstenitePluginWebSocketConnection { - fn send_text(&mut self, text: &str) -> Result<(), PluginWebSocketError> { - let send = tokio::time::timeout( - PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT, - self.socket.send(Message::Text(text.to_string().into())), - ); - block_on_websocket_future(self.runtime.get(), send) - .map_err(|_| PluginWebSocketError::new("WebSocket send timed out"))? - .map_err(|error| PluginWebSocketError::new(format!("WebSocket send failed: {error}"))) - } - - fn recv_text( - &mut self, - timeout: Duration, - max_message_bytes: usize, - ) -> Result { - for _ in 0..PLUGIN_WEBSOCKET_MAX_CONTROL_FRAMES { - let next = tokio::time::timeout(timeout, self.socket.next()); - let message = block_on_websocket_future(self.runtime.get(), next) - .map_err(|_| PluginWebSocketError::new("WebSocket receive timed out"))? - .ok_or_else(|| PluginWebSocketError::new("WebSocket stream ended"))? - .map_err(|error| { - PluginWebSocketError::new(format!("WebSocket receive failed: {error}")) - })?; - match message { - Message::Text(text) => { - if text.len() > max_message_bytes { - return Err(PluginWebSocketError::new(format!( - "WebSocket text message exceeds {} bytes", - max_message_bytes - ))); - } - return Ok(PluginWebSocketRecvResponse::Text { - text: text.to_string(), - }); - } - Message::Binary(_) => { - return Err(PluginWebSocketError::new( - "binary WebSocket messages are not supported by host_api.websocket v1", - )); - } - Message::Close(_) => return Ok(PluginWebSocketRecvResponse::Closed), - Message::Ping(payload) => { - let send = tokio::time::timeout( - PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT, - self.socket.send(Message::Pong(payload)), - ); - block_on_websocket_future(self.runtime.get(), send) - .map_err(|_| PluginWebSocketError::new("WebSocket pong timed out"))? - .map_err(|error| { - PluginWebSocketError::new(format!("WebSocket pong failed: {error}")) - })?; - } - Message::Pong(_) | Message::Frame(_) => continue, - } - } - Err(PluginWebSocketError::new( - "WebSocket receive exceeded bounded control-frame budget", - )) - } - - fn close(&mut self) -> Result<(), PluginWebSocketError> { - let close = tokio::time::timeout(PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT, self.socket.close(None)); - block_on_websocket_future(self.runtime.get(), close) - .map_err(|_| PluginWebSocketError::new("WebSocket close timed out"))? - .map_err(|error| PluginWebSocketError::new(format!("WebSocket close failed: {error}"))) - } -} - -fn new_websocket_runtime() -> Result { - let runtime = TokioRuntimeBuilder::new_current_thread() - .enable_all() - .build() - .map_err(|error| { - PluginWebSocketError::new(format!("WebSocket runtime build failed: {error}")) - })?; - Ok(PluginWebSocketRuntime::new(runtime)) -} - -fn block_on_websocket_future( - runtime: &TokioRuntime, - future: F, -) -> F::Output { - if TokioHandle::try_current().is_ok() { - tokio::task::block_in_place(|| runtime.block_on(future)) - } else { - runtime.block_on(future) - } -} - -#[derive(Clone, Default)] -struct PluginWebSocketHandles { - inner: Arc>, -} - -impl PluginWebSocketHandles { - fn reserve_open(&self) -> Result { - self.inner - .lock() - .expect("plugin websocket handle table poisoned") - .reserve_open()?; - Ok(PluginWebSocketOpenReservation { - handles: self.clone(), - active: true, - }) - } - - fn with_connection( - &self, - handle: u32, - f: impl FnOnce(&mut dyn PluginWebSocketConnection) -> Result, - ) -> Result { - self.inner - .lock() - .expect("plugin websocket handle table poisoned") - .with_connection(handle, f) - } - - fn close(&self, handle: u32) -> Result { - self.inner - .lock() - .expect("plugin websocket handle table poisoned") - .close(handle) - } - - fn close_all(&self) { - self.inner - .lock() - .expect("plugin websocket handle table poisoned") - .close_all(); - } - - #[cfg(test)] - fn reservation_count(&self) -> usize { - self.inner - .lock() - .expect("plugin websocket handle table poisoned") - .reservations - } -} - -struct PluginWebSocketOpenReservation { - handles: PluginWebSocketHandles, - active: bool, -} - -impl PluginWebSocketOpenReservation { - fn commit( - mut self, - connection: Box, - ) -> Result { - let result = self - .handles - .inner - .lock() - .expect("plugin websocket handle table poisoned") - .insert_reserved(connection); - self.active = false; - result - } -} - -impl Drop for PluginWebSocketOpenReservation { - fn drop(&mut self) { - if self.active { - self.handles - .inner - .lock() - .expect("plugin websocket handle table poisoned") - .release_reservation(); - self.active = false; - } - } -} - -#[derive(Default)] -struct PluginWebSocketHandleTable { - next: u32, - reservations: usize, - connections: HashMap, -} - -struct PluginWebSocketHandleEntry { - opened_at: Instant, - connection: Box, -} - -impl PluginWebSocketHandleTable { - fn reserve_open(&mut self) -> Result<(), PluginWebSocketError> { - self.expire_stale(); - if self.connections.len() + self.reservations >= PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS { - return Err(PluginWebSocketError::new(format!( - "host_api.websocket open connection limit ({}) exceeded before dialing", - PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS - ))); - } - self.reservations += 1; - Ok(()) - } - - fn release_reservation(&mut self) { - self.reservations = self.reservations.saturating_sub(1); - } - - fn insert_reserved( - &mut self, - mut connection: Box, - ) -> Result { - self.release_reservation(); - self.expire_stale(); - if self.connections.len() >= PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS { - let _ = connection.close(); - return Err(PluginWebSocketError::new(format!( - "host_api.websocket open connection limit ({}) exceeded while committing reserved handle", - PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS - ))); - } - let mut attempts = 0usize; - loop { - self.next = self.next.wrapping_add(1).max(1); - attempts += 1; - if !self.connections.contains_key(&self.next) { - let handle = self.next; - self.connections.insert( - handle, - PluginWebSocketHandleEntry { - opened_at: Instant::now(), - connection, - }, - ); - return Ok(handle); - } - if attempts > u32::MAX as usize { - return Err(PluginWebSocketError::new( - "WebSocket handle space exhausted", - )); - } - } - } - - fn with_connection( - &mut self, - handle: u32, - f: impl FnOnce(&mut dyn PluginWebSocketConnection) -> Result, - ) -> Result { - self.expire_stale(); - let entry = self.connections.get_mut(&handle).ok_or_else(|| { - PluginWebSocketError::new(format!("unknown WebSocket handle {handle}")) - })?; - f(entry.connection.as_mut()) - } - - fn close(&mut self, handle: u32) -> Result { - if let Some(mut entry) = self.connections.remove(&handle) { - entry.connection.close()?; - Ok(true) - } else { - Ok(false) - } - } - - fn close_all(&mut self) { - self.reservations = 0; - for (_, mut entry) in self.connections.drain() { - let _ = entry.connection.close(); - } - } - - fn expire_stale(&mut self) { - let now = Instant::now(); - let stale: Vec<_> = self - .connections - .iter() - .filter_map(|(handle, entry)| { - (now.duration_since(entry.opened_at) > PLUGIN_WEBSOCKET_MAX_HANDLE_AGE) - .then_some(*handle) - }) - .collect(); - for handle in stale { - let _ = self.close(handle); - } - } -} - -impl Drop for PluginWebSocketHandleTable { - fn drop(&mut self) { - self.close_all(); - } -} - -#[derive(Clone, Copy, Debug)] -struct PluginRequestLimits { - timeout: Duration, - max_response_bytes: usize, -} - -impl Default for PluginRequestLimits { - fn default() -> Self { - Self { - timeout: PLUGIN_REQUEST_TIMEOUT, - max_response_bytes: PLUGIN_REQUEST_MAX_RESPONSE_BYTES, - } - } -} - -trait PluginRequestClient: Send + Sync { - fn execute( - &self, - request: &PluginRequestRequest, - url: &reqwest::Url, - limits: PluginRequestLimits, - ) -> Result; -} - -struct ReqwestPluginRequestClient; -struct SystemPluginRequestResolver; - -#[derive(Clone, Debug)] -struct PinnedRequestResolution { - domains: Vec, - addrs: Vec, -} - -trait PluginRequestResolver { - fn resolve(&self, host: &str, port: u16) -> Result, PluginRequestError>; -} - -impl PluginRequestResolver for SystemPluginRequestResolver { - fn resolve(&self, host: &str, port: u16) -> Result, PluginRequestError> { - let mut addrs = Vec::new(); - for addr in (host, port).to_socket_addrs().map_err(|error| { - PluginRequestError::new(format!("DNS lookup failed for {:?}: {error}", host)) - })? { - addrs.push(addr); - } - Ok(addrs) - } -} - -#[derive(Debug)] -struct PluginRequestError(String); - -impl PluginRequestError { - fn new(message: impl Into) -> Self { - Self(redact_secret_like(&bounded_message(message.into()))) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub enum PluginInstanceLifecycleState { - Ready, - Starting, - Running, - Stopping, - Stopped, - Failed, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub enum PluginInstanceDiagnosticKind { - Lifecycle, - InvalidEvent, - QueueFull, - DispatchTimeout, - DispatchFailed, - ServiceUnavailable, - ServiceFailed, - ServiceStopped, - ServiceOutputCommandRecorded, - ServiceOutputCommandRejected, - ServiceOutputCommandUnsupported, - ServiceWebSocketConnected, - ServiceWebSocketClosed, - ServiceWebSocketError, - ServiceWebSocketSendFailed, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct PluginInstanceDiagnostic { - pub kind: PluginInstanceDiagnosticKind, - pub state: PluginInstanceLifecycleState, - pub message: String, -} - -impl PluginInstanceDiagnostic { - pub fn new(state: PluginInstanceLifecycleState, message: impl Into) -> Self { - Self::with_kind(PluginInstanceDiagnosticKind::Lifecycle, state, message) - } - - pub fn with_kind( - kind: PluginInstanceDiagnosticKind, - state: PluginInstanceLifecycleState, - message: impl Into, - ) -> Self { - Self { - kind, - state, - message: bounded_message(redact_secret_like(&message.into())), - } - } -} - -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] -pub struct PluginIngressDispatchCounters { - pub enqueued: u64, - pub dispatched: u64, - pub rejected: u64, - pub failed: u64, - pub timed_out: u64, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub enum PluginServiceWebSocketConnectionState { - Connecting, - Connected, - Closed, - Failed, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct PluginServiceWebSocketConnectionStatus { - pub url: String, - pub ingress_name: String, - pub state: PluginServiceWebSocketConnectionState, - pub last_frame_at: Option, - pub last_error: Option, - pub received_text_frames: u64, - pub sent_text_frames: u64, - pub queue_drops: u64, - pub send_failures: u64, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -pub struct PluginInstanceStatus { - pub plugin_ref: String, - pub lifecycle: PluginInstanceLifecycleState, - pub component_status: Option, - pub queue_depth: usize, - pub queue_capacity: usize, - pub last_error: Option, - pub dispatch_counters: PluginIngressDispatchCounters, - /// Last bounded Service output command outcomes. These are produced only by - /// Service/Ingress dispatch and are intentionally separate from ToolOutput. - pub output_command_results: Vec, - pub websocket_connections: Vec, - pub diagnostics: Vec, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -pub struct PluginIngressEvent { - pub kind: String, - pub source: String, - pub ingress_name: String, - pub payload: Value, - pub created_at: String, - pub attempt: u32, - pub correlation_id: String, -} - -impl PluginIngressEvent { - pub fn new( - ingress_name: impl Into, - kind: impl Into, - source: impl Into, - payload: Value, - ) -> Self { - Self { - kind: kind.into(), - source: source.into(), - ingress_name: ingress_name.into(), - payload, - created_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), - attempt: 1, - correlation_id: uuid::Uuid::now_v7().to_string(), - } - } -} - -/// Host-validated output command envelope returned by Service/Ingress handlers. -/// -/// Service output commands are intentionally distinct from ordinary plugin -/// `ToolOutput`: handlers can request bounded side effects, but the host parses, -/// validates, grant-checks, records diagnostics, and fail-closes before executing -/// any supported command. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct PluginServiceOutputCommandEnvelope { - pub correlation_id: String, - pub source_event_id: String, - pub command_id: String, - pub kind: PluginServiceOutputCommandKind, - pub payload: Value, - pub requested_at: String, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PluginServiceOutputCommandKind { - DiagnosticStatusUpdate, - HostRequestDispatch, - #[serde(rename = "websocket_send")] - WebSocketSend, -} - -impl PluginServiceOutputCommandKind { - fn as_str(self) -> &'static str { - match self { - Self::DiagnosticStatusUpdate => "diagnostic_status_update", - Self::HostRequestDispatch => "host_request_dispatch", - Self::WebSocketSend => "websocket_send", - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub enum PluginServiceOutputCommandStatus { - Recorded, - Rejected, - Unsupported, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -pub struct PluginServiceOutputCommandResult { - pub correlation_id: Option, - pub source_event_id: Option, - pub command_id: Option, - pub kind: Option, - pub status: PluginServiceOutputCommandStatus, - pub message: String, - pub recorded_at: String, -} - -impl PluginServiceOutputCommandResult { - fn rejected(message: impl Into) -> Self { - Self::from_parts( - None, - None, - None, - None, - PluginServiceOutputCommandStatus::Rejected, - message, - ) - } - - fn rejected_for( - command: &PluginServiceOutputCommandEnvelope, - message: impl Into, - ) -> Self { - Self::from_command(command, PluginServiceOutputCommandStatus::Rejected, message) - } - - fn unsupported( - command: &PluginServiceOutputCommandEnvelope, - message: impl Into, - ) -> Self { - Self::from_command( - command, - PluginServiceOutputCommandStatus::Unsupported, - message, - ) - } - - fn recorded(command: &PluginServiceOutputCommandEnvelope, message: impl Into) -> Self { - Self::from_command(command, PluginServiceOutputCommandStatus::Recorded, message) - } - - fn from_command( - command: &PluginServiceOutputCommandEnvelope, - status: PluginServiceOutputCommandStatus, - message: impl Into, - ) -> Self { - Self::from_parts( - Some(command.correlation_id.clone()), - Some(command.source_event_id.clone()), - Some(command.command_id.clone()), - Some(command.kind), - status, - message, - ) - } - - fn from_parts( - correlation_id: Option, - source_event_id: Option, - command_id: Option, - kind: Option, - status: PluginServiceOutputCommandStatus, - message: impl Into, - ) -> Self { - Self { - correlation_id, - source_event_id, - command_id, - kind, - status, - message: bounded_message(redact_secret_like(&message.into())), - recorded_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), - } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -pub struct PluginIngressDispatchReport { - pub plugin_ref: String, - pub ingress: String, - pub accepted: bool, - pub output: Value, - /// Results of host-side Service output command parsing/validation/grant-checking. - /// This path never feeds ordinary plugin ToolOutput handling. - pub output_command_results: Vec, - pub queue_depth: usize, - pub dispatch_counters: PluginIngressDispatchCounters, - pub diagnostics: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum PluginIngressDispatchError { - InvalidEvent(String), - QueueFull { capacity: usize }, - ServiceUnavailable { state: PluginInstanceLifecycleState }, - ServiceFailed(String), - ServiceStopped { state: PluginInstanceLifecycleState }, - DispatchTimeout { timeout: Duration }, - DispatchFailed(String), -} - -impl PluginIngressDispatchError { - fn bounded_message(&self) -> String { - match self { - Self::InvalidEvent(message) => bounded_message(format!( - "invalid plugin ingress event: {}", - redact_secret_like(message) - )), - Self::QueueFull { capacity } => bounded_message(format!( - "plugin ingress queue is full (capacity {capacity})" - )), - Self::ServiceUnavailable { state } => bounded_message(format!( - "plugin service is not running for ingress dispatch (state {state:?})" - )), - Self::ServiceFailed(message) => bounded_message(format!( - "plugin service is failed for ingress dispatch: {}", - redact_secret_like(message) - )), - Self::ServiceStopped { state } => bounded_message(format!( - "plugin service rejects ingress while stopping/stopped (state {state:?})" - )), - Self::DispatchTimeout { timeout } => bounded_message(format!( - "plugin ingress dispatch timed out after {timeout:?}" - )), - Self::DispatchFailed(message) => bounded_message(format!( - "plugin ingress dispatch failed closed: {}", - redact_secret_like(message) - )), - } - } - - fn diagnostic_kind(&self) -> PluginInstanceDiagnosticKind { - match self { - Self::InvalidEvent(_) => PluginInstanceDiagnosticKind::InvalidEvent, - Self::QueueFull { .. } => PluginInstanceDiagnosticKind::QueueFull, - Self::ServiceUnavailable { .. } => PluginInstanceDiagnosticKind::ServiceUnavailable, - Self::ServiceFailed(_) => PluginInstanceDiagnosticKind::ServiceFailed, - Self::ServiceStopped { .. } => PluginInstanceDiagnosticKind::ServiceStopped, - Self::DispatchTimeout { .. } => PluginInstanceDiagnosticKind::DispatchTimeout, - Self::DispatchFailed(_) => PluginInstanceDiagnosticKind::DispatchFailed, - } - } -} - -impl std::fmt::Display for PluginIngressDispatchError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.bounded_message()) - } -} - -impl std::error::Error for PluginIngressDispatchError {} - -#[derive(Clone, Debug)] -struct QueuedPluginIngress { - ingress_name: String, - event: PluginIngressEvent, - enqueued_at: Instant, -} - -#[derive(Clone, Default)] -pub struct PluginInstanceRegistry { - instances: Arc>>, -} - -impl PluginInstanceRegistry { - pub fn register( - &self, - record: ResolvedPluginRecord, - ) -> Result { - let key = record.identity.to_string(); - let mut instances = self - .instances - .lock() - .expect("plugin instance registry poisoned"); - if let Some(existing) = instances.get(&key) { - return Ok(existing.clone()); - } - let handle = PluginInstanceHandle::new(record).map_err(|error| { - FeatureInstallError::Install(format!( - "plugin instance startup failed closed: {}", - error.bounded_message() - )) - })?; - instances.insert(key, handle.clone()); - Ok(handle) - } - - pub fn status(&self, plugin_ref: &str) -> Option { - self.instances - .lock() - .expect("plugin instance registry poisoned") - .get(plugin_ref) - .map(PluginInstanceHandle::status) - } - - pub fn handle(&self, plugin_ref: &str) -> Option { - self.instances - .lock() - .expect("plugin instance registry poisoned") - .get(plugin_ref) - .cloned() - } - - pub fn stop(&self, plugin_ref: &str) -> Result, PluginWasmError> { - let handle = self - .instances - .lock() - .expect("plugin instance registry poisoned") - .get(plugin_ref) - .cloned(); - handle.map(|handle| handle.stop()).transpose() - } -} - -#[derive(Clone, Debug)] -struct PluginServiceWebSocketSubscription { - ingress_name: String, - source: String, - url: String, -} - -#[derive(Clone, Default)] -struct PluginServiceWebSocketDriver { - inner: Arc>>, -} - -struct PluginServiceWebSocketConnection { - status: PluginServiceWebSocketConnectionStatus, - connection: Option>>>, - stop: Arc, -} - -impl PluginServiceWebSocketDriver { - fn start_connection( - &self, - handle: PluginInstanceHandle, - client: Arc, - subscription: PluginServiceWebSocketSubscription, - ) { - if self.connection_count() >= PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS { - let message = format!( - "host-owned WebSocket connection limit ({}) exceeded for {}", - PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS, subscription.url - ); - self.insert_failed_status(&subscription, message.clone()); - handle.record_service_websocket_diagnostic( - PluginInstanceDiagnosticKind::ServiceWebSocketError, - message, - true, - ); - return; - } - - let request = PluginWebSocketOpenRequest { - url: subscription.url.clone(), - protocols: Vec::new(), - headers: Vec::new(), - }; - self.insert_status( - &subscription, - PluginServiceWebSocketConnectionState::Connecting, - None, - ); - - let (request, url) = match validate_plugin_service_websocket_open_request(&handle, &request) - { - Ok(value) => value, - Err(message) => { - self.update_status_error( - &subscription.url, - PluginServiceWebSocketConnectionState::Failed, - message.clone(), - ); - handle.record_service_websocket_diagnostic( - PluginInstanceDiagnosticKind::ServiceWebSocketError, - message, - true, - ); - return; - } - }; - if !client.supports_bounded_open() { - let message = "host-owned WebSocket client cannot guarantee bounded/cancellable open; refusing to dial".to_string(); - self.update_status_error( - &subscription.url, - PluginServiceWebSocketConnectionState::Failed, - message.clone(), - ); - handle.record_service_websocket_diagnostic( - PluginInstanceDiagnosticKind::ServiceWebSocketError, - message, - true, - ); - return; - } - let connection = match client.open(&request, &url, PluginWebSocketLimits::default()) { - Ok(connection) => Arc::new(Mutex::new(connection)), - Err(error) => { - let message = format!( - "host-owned WebSocket open failed for {}: {}", - safe_url(&url), - error.0 - ); - self.update_status_error( - &subscription.url, - PluginServiceWebSocketConnectionState::Failed, - message.clone(), - ); - handle.record_service_websocket_diagnostic( - PluginInstanceDiagnosticKind::ServiceWebSocketError, - message, - true, - ); - return; - } - }; - - let stop = Arc::new(AtomicBool::new(false)); - self.attach_connection(&subscription.url, connection.clone(), stop.clone()); - self.update_status_connected(&subscription.url); - handle.record_service_websocket_diagnostic( - PluginInstanceDiagnosticKind::ServiceWebSocketConnected, - format!("host-owned WebSocket connected: {}", safe_url(&url)), - false, - ); - - let driver = self.clone(); - std::thread::spawn(move || { - driver.reader_loop(handle, subscription, connection, stop); - }); - } - - fn reader_loop( - &self, - handle: PluginInstanceHandle, - subscription: PluginServiceWebSocketSubscription, - connection: Arc>>, - stop: Arc, - ) { - while !stop.load(Ordering::SeqCst) { - let recv = { - let mut connection = connection - .lock() - .expect("service websocket connection poisoned"); - connection.recv_text( - PLUGIN_SERVICE_WEBSOCKET_RECV_TIMEOUT, - PLUGIN_WEBSOCKET_MAX_MESSAGE_BYTES, - ) - }; - match recv { - Ok(PluginWebSocketRecvResponse::Text { text }) => { - self.record_frame(&subscription.url); - let event = PluginIngressEvent::new( - subscription.ingress_name.clone(), - "websocket_text", - subscription.source.clone(), - serde_json::json!({ - "url": subscription.url, - "text": text, - }), - ); - if let Err(error) = handle.deliver_ingress(&subscription.ingress_name, event) { - let message = error.bounded_message(); - self.record_queue_drop(&subscription.url, message.clone()); - handle.record_service_websocket_diagnostic( - error.diagnostic_kind(), - format!("host-owned WebSocket ingress drop: {message}"), - true, - ); - } - } - Ok(PluginWebSocketRecvResponse::Closed) => { - let message = "host-owned WebSocket closed by peer".to_string(); - self.update_status_error( - &subscription.url, - PluginServiceWebSocketConnectionState::Closed, - message.clone(), - ); - handle.record_service_websocket_diagnostic( - PluginInstanceDiagnosticKind::ServiceWebSocketClosed, - message.clone(), - false, - ); - let event = PluginIngressEvent::new( - subscription.ingress_name.clone(), - "websocket_close", - subscription.source.clone(), - serde_json::json!({"url": subscription.url, "reason": message}), - ); - let _ = handle.deliver_ingress(&subscription.ingress_name, event); - break; - } - Err(error) if error.0.contains("timed out") => continue, - Err(error) => { - let message = format!("host-owned WebSocket receive failed: {}", error.0); - self.update_status_error( - &subscription.url, - PluginServiceWebSocketConnectionState::Failed, - message.clone(), - ); - handle.record_service_websocket_diagnostic( - PluginInstanceDiagnosticKind::ServiceWebSocketError, - message.clone(), - true, - ); - let event = PluginIngressEvent::new( - subscription.ingress_name.clone(), - "websocket_error", - subscription.source.clone(), - serde_json::json!({"url": subscription.url, "error": message}), - ); - let _ = handle.deliver_ingress(&subscription.ingress_name, event); - break; - } - } - } - } - - fn send_text(&self, url: &str, text: &str) -> Result { - if text.len() > PLUGIN_WEBSOCKET_MAX_TEXT_BYTES { - return Err(format!( - "websocket_send text exceeds {} bytes", - PLUGIN_WEBSOCKET_MAX_TEXT_BYTES - )); - } - let parsed = - reqwest::Url::parse(url).map_err(|error| format!("invalid WebSocket URL: {error}"))?; - let key = parsed.as_str().to_string(); - let (display_url, connection) = { - let guard = self - .inner - .lock() - .expect("service websocket driver poisoned"); - let entry = guard.get(&key).ok_or_else(|| { - format!( - "no host-owned WebSocket connection is active for {}", - safe_url(&parsed) - ) - })?; - let Some(connection) = &entry.connection else { - return Err(format!( - "host-owned WebSocket connection is not connected for {}", - safe_url(&parsed) - )); - }; - (entry.status.url.clone(), connection.clone()) - }; - let send = connection - .lock() - .expect("service websocket connection poisoned") - .send_text(text); - match send { - Ok(()) => { - self.record_send_success(&key); - Ok(format!( - "websocket_send sent {} bytes to {display_url}", - text.len() - )) - } - Err(error) => { - let message = format!("websocket_send failed for {display_url}: {}", error.0); - self.record_send_failure(&key, message.clone()); - Err(message) - } - } - } - - fn statuses(&self) -> Vec { - let mut statuses: Vec<_> = self - .inner - .lock() - .expect("service websocket driver poisoned") - .values() - .map(|entry| entry.status.clone()) - .collect(); - statuses.sort_by(|left, right| left.url.cmp(&right.url)); - statuses - } - - fn stop_all(&self) { - let connections: Vec<_> = { - let mut guard = self - .inner - .lock() - .expect("service websocket driver poisoned"); - guard - .values_mut() - .filter_map(|entry| { - entry.stop.store(true, Ordering::SeqCst); - entry.status.state = PluginServiceWebSocketConnectionState::Closed; - entry.connection.clone() - }) - .collect() - }; - for connection in connections { - if let Ok(mut connection) = connection.lock() { - let _ = connection.close(); - } - } - } - - fn connection_count(&self) -> usize { - self.inner - .lock() - .expect("service websocket driver poisoned") - .len() - } - - fn insert_status( - &self, - subscription: &PluginServiceWebSocketSubscription, - state: PluginServiceWebSocketConnectionState, - error: Option, - ) { - let url = reqwest::Url::parse(&subscription.url) - .map(|url| safe_url(&url)) - .unwrap_or_else(|_| safe_fs_path(&subscription.url)); - self.inner - .lock() - .expect("service websocket driver poisoned") - .insert( - subscription.url.clone(), - PluginServiceWebSocketConnection { - status: PluginServiceWebSocketConnectionStatus { - url, - ingress_name: subscription.ingress_name.clone(), - state, - last_frame_at: None, - last_error: error, - received_text_frames: 0, - sent_text_frames: 0, - queue_drops: 0, - send_failures: 0, - }, - connection: None, - stop: Arc::new(AtomicBool::new(false)), - }, - ); - } - - fn insert_failed_status( - &self, - subscription: &PluginServiceWebSocketSubscription, - error: String, - ) { - self.insert_status( - subscription, - PluginServiceWebSocketConnectionState::Failed, - Some(error), - ); - } - - fn attach_connection( - &self, - key: &str, - connection: Arc>>, - stop: Arc, - ) { - if let Some(entry) = self - .inner - .lock() - .expect("service websocket driver poisoned") - .get_mut(key) - { - entry.connection = Some(connection); - entry.stop = stop; - } - } - - fn update_status_connected(&self, key: &str) { - if let Some(entry) = self - .inner - .lock() - .expect("service websocket driver poisoned") - .get_mut(key) - { - entry.status.state = PluginServiceWebSocketConnectionState::Connected; - entry.status.last_error = None; - } - } - - fn update_status_error( - &self, - key: &str, - state: PluginServiceWebSocketConnectionState, - error: String, - ) { - if let Some(entry) = self - .inner - .lock() - .expect("service websocket driver poisoned") - .get_mut(key) - { - entry.status.state = state; - entry.status.last_error = Some(bounded_message(redact_secret_like(&error))); - } - } - - fn record_frame(&self, key: &str) { - if let Some(entry) = self - .inner - .lock() - .expect("service websocket driver poisoned") - .get_mut(key) - { - entry.status.last_frame_at = - Some(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)); - entry.status.received_text_frames += 1; - } - } - - fn record_queue_drop(&self, key: &str, error: String) { - if let Some(entry) = self - .inner - .lock() - .expect("service websocket driver poisoned") - .get_mut(key) - { - entry.status.queue_drops += 1; - entry.status.last_error = Some(bounded_message(redact_secret_like(&error))); - } - } - - fn record_send_success(&self, key: &str) { - if let Some(entry) = self - .inner - .lock() - .expect("service websocket driver poisoned") - .get_mut(key) - { - entry.status.sent_text_frames += 1; - } - } - - fn record_send_failure(&self, key: &str, error: String) { - if let Some(entry) = self - .inner - .lock() - .expect("service websocket driver poisoned") - .get_mut(key) - { - entry.status.send_failures += 1; - entry.status.last_error = Some(bounded_message(redact_secret_like(&error))); - } - } -} - -fn validate_plugin_service_websocket_open_request( - handle: &PluginInstanceHandle, - request: &PluginWebSocketOpenRequest, -) -> Result<(PluginWebSocketOpenRequest, reqwest::Url), String> { - let record = { - let instance = handle.0.lock().expect("plugin instance poisoned"); - instance.record.clone() - }; - authorize_plugin_host_api(&record, PluginHostApi::WebSocket) - .map_err(|error| format!("host-owned websocket not granted: {}", error.0))?; - let bytes = serde_json::to_vec(request) - .map_err(|error| format!("failed to encode host-owned websocket open request: {error}"))?; - validate_plugin_websocket_open_request(&record, &bytes).map_err(|error| error.0) -} - -fn parse_plugin_service_websocket_source(source: &str) -> Option> { - let trimmed = source.trim(); - let raw = if let Some(rest) = trimmed.strip_prefix("websocket:") { - rest.trim() - } else if trimmed.starts_with("ws://") || trimmed.starts_with("wss://") { - trimmed - } else { - return None; - }; - if raw.is_empty() { - return Some(Err("websocket source URL is empty".to_string())); - } - let parsed = match reqwest::Url::parse(raw) { - Ok(parsed) => parsed, - Err(error) => return Some(Err(format!("invalid WebSocket URL: {error}"))), - }; - match parsed.scheme() { - "ws" | "wss" => Some(Ok(parsed.as_str().to_string())), - other => Some(Err(format!("unsupported WebSocket URL scheme: {other}"))), - } -} - -#[derive(Clone)] -pub struct PluginInstanceHandle(Arc>); - -impl PluginInstanceHandle { - fn new(record: ResolvedPluginRecord) -> Result { - Self::new_with_service_websocket_client(record, Arc::new(TungstenitePluginWebSocketClient)) - } - - #[cfg(test)] - fn new_with_test_websocket_client( - record: ResolvedPluginRecord, - service_websocket_client: Arc, - ) -> Result { - Self::new_with_service_websocket_client(record, service_websocket_client) - } - - fn new_with_service_websocket_client( - record: ResolvedPluginRecord, - service_websocket_client: Arc, - ) -> Result { - let runtime = PluginInstanceRuntime::new(&record)?; - let mut instance = PluginInstance { - record, - runtime, - lifecycle: PluginInstanceLifecycleState::Ready, - component_status: None, - ingress_queue: VecDeque::new(), - ingress_queue_capacity: PLUGIN_SERVICE_INGRESS_QUEUE_CAPACITY, - dispatch_counters: PluginIngressDispatchCounters::default(), - last_error: None, - output_command_results: Vec::new(), - service_websockets: PluginServiceWebSocketDriver::default(), - service_websocket_client, - diagnostics: Vec::new(), - }; - instance.start()?; - let handle = Self(Arc::new(Mutex::new(instance))); - handle.start_service_websockets(); - Ok(handle) - } - - fn handle_tool(&self, tool_name: &str, input: Vec) -> Result { - self.0 - .lock() - .expect("plugin instance poisoned") - .handle_tool(tool_name, input) - } - - pub fn deliver_ingress( - &self, - ingress_name: &str, - event: PluginIngressEvent, - ) -> Result { - let mut instance = self.0.lock().expect("plugin instance poisoned"); - instance.enqueue_ingress(ingress_name, event)?; - instance.dispatch_next_ingress() - } - - pub fn status(&self) -> PluginInstanceStatus { - let mut instance = self.0.lock().expect("plugin instance poisoned"); - instance.status() - } - - pub fn stop(&self) -> Result { - let mut instance = self.0.lock().expect("plugin instance poisoned"); - instance.stop()?; - Ok(instance.snapshot_status()) - } - - fn record_diagnostic(&self, diagnostic: PluginInstanceDiagnostic) { - if let Ok(mut instance) = self.0.lock() { - instance.lifecycle = diagnostic.state.clone(); - instance.last_error = Some(diagnostic.message.clone()); - instance.diagnostics.push(diagnostic); - } - } - - fn record_service_websocket_diagnostic( - &self, - kind: PluginInstanceDiagnosticKind, - message: impl Into, - mark_error: bool, - ) { - if let Ok(mut instance) = self.0.lock() { - let message = bounded_message(redact_secret_like(&message.into())); - if mark_error { - instance.last_error = Some(message.clone()); - } - let state = instance.lifecycle.clone(); - instance - .diagnostics - .push(PluginInstanceDiagnostic::with_kind(kind, state, message)); - } - } - - fn start_service_websockets(&self) { - let (driver, client, subscriptions, diagnostics) = { - let instance = self.0.lock().expect("plugin instance poisoned"); - let (subscriptions, diagnostics) = instance.service_websocket_subscriptions(); - let driver = instance.service_websockets.clone(); - let client = instance.service_websocket_client.clone(); - (driver, client, subscriptions, diagnostics) - }; - for diagnostic in diagnostics { - self.record_service_websocket_diagnostic( - PluginInstanceDiagnosticKind::ServiceWebSocketError, - diagnostic, - true, - ); - } - for subscription in subscriptions { - driver.start_connection(self.clone(), client.clone(), subscription); - } - } -} - -struct PluginInstance { - record: ResolvedPluginRecord, - runtime: PluginInstanceRuntime, - lifecycle: PluginInstanceLifecycleState, - component_status: Option, - ingress_queue: VecDeque, - ingress_queue_capacity: usize, - dispatch_counters: PluginIngressDispatchCounters, - last_error: Option, - output_command_results: Vec, - service_websockets: PluginServiceWebSocketDriver, - service_websocket_client: Arc, - diagnostics: Vec, -} - -impl PluginInstance { - fn start(&mut self) -> Result<(), PluginWasmError> { - self.lifecycle = PluginInstanceLifecycleState::Starting; - let start_result = match &mut self.runtime { - PluginInstanceRuntime::ComponentToolAdapter => { - self.lifecycle = PluginInstanceLifecycleState::Ready; - self.diagnostics.push(PluginInstanceDiagnostic::new( - PluginInstanceLifecycleState::Ready, - "component tool runtime registered behind PluginInstanceRegistry", - )); - Ok(()) - } - #[cfg(test)] - PluginInstanceRuntime::TestIngress { .. } => { - self.lifecycle = PluginInstanceLifecycleState::Running; - self.diagnostics.push(PluginInstanceDiagnostic::new( - PluginInstanceLifecycleState::Running, - "test ingress runtime initialized", - )); - Ok(()) - } - PluginInstanceRuntime::ComponentInstance(runtime) => { - match runtime.start(&self.record) { - Ok(status) => { - self.component_status = Some(status); - self.lifecycle = PluginInstanceLifecycleState::Running; - self.diagnostics.push(PluginInstanceDiagnostic::new( - PluginInstanceLifecycleState::Running, - "component instance start returned; host-managed ingress queue is running", - )); - Ok(()) - } - Err(error) => Err(error), - } - } - }; - if let Err(error) = start_result { - self.lifecycle = PluginInstanceLifecycleState::Failed; - self.record_runtime_error( - PluginInstanceDiagnosticKind::ServiceFailed, - format!("plugin component start failed: {}", error.bounded_message()), - ); - return Err(error); - } - Ok(()) - } - - fn service_websocket_subscriptions( - &self, - ) -> (Vec, Vec) { - if !surface_enabled(&self.record, PluginSurface::Service) - || !surface_enabled(&self.record, PluginSurface::Ingress) - { - return (Vec::new(), Vec::new()); - } - let mut subscriptions = Vec::new(); - let mut diagnostics = Vec::new(); - for ingress in &self.record.manifest.ingresses { - for source in &ingress.sources { - match parse_plugin_service_websocket_source(source) { - None => {} - Some(Ok(url)) => subscriptions.push(PluginServiceWebSocketSubscription { - ingress_name: ingress.name.clone(), - source: source.clone(), - url, - }), - Some(Err(message)) => diagnostics.push(format!( - "invalid WebSocket ingress source for {}: {message}", - ingress.name - )), - } - } - } - (subscriptions, diagnostics) - } - - fn handle_tool( - &mut self, - tool_name: &str, - input: Vec, - ) -> Result { - if !surface_enabled(&self.record, PluginSurface::Tool) { - return Err(PluginWasmError::Module( - "plugin tool surface is not enabled".to_string(), - )); - } - let tool = self - .record - .manifest - .tools - .iter() - .find(|tool| tool.name == tool_name) - .ok_or_else(|| { - PluginWasmError::Module( - "requested tool is not declared by plugin manifest".to_string(), - ) - })?; - authorize_plugin_tool(&self.record, tool).map_err(|error| { - PluginWasmError::Module(format!( - "plugin permission denied: {}", - error.bounded_message() - )) - })?; - match &mut self.runtime { - PluginInstanceRuntime::ComponentToolAdapter => { - run_plugin_component_tool(self.record.clone(), tool_name.to_string(), input) - } - #[cfg(test)] - PluginInstanceRuntime::TestIngress { tool_calls, .. } => { - *tool_calls += 1; - Ok(ToolOutput { - summary: format!("{tool_name}: {tool_calls}"), - content: Some(String::from_utf8_lossy(&input).to_string()), - - attachments: Vec::new(), - }) - } - PluginInstanceRuntime::ComponentInstance(runtime) => { - runtime.handle_tool(tool_name, input) - } - } - } - - fn enqueue_ingress( - &mut self, - ingress_name: &str, - event: PluginIngressEvent, - ) -> Result<(), PluginIngressDispatchError> { - self.validate_ingress_event(ingress_name, &event) - .map_err(|error| self.record_rejection(error))?; - if self.ingress_queue.len() >= self.ingress_queue_capacity { - let error = PluginIngressDispatchError::QueueFull { - capacity: self.ingress_queue_capacity, - }; - return Err(self.record_rejection(error)); - } - self.ingress_queue.push_back(QueuedPluginIngress { - ingress_name: ingress_name.to_string(), - event, - enqueued_at: Instant::now(), - }); - self.dispatch_counters.enqueued += 1; - Ok(()) - } - - fn validate_ingress_event( - &self, - ingress_name: &str, - event: &PluginIngressEvent, - ) -> Result<(), PluginIngressDispatchError> { - if !surface_enabled(&self.record, PluginSurface::Ingress) { - return Err(PluginIngressDispatchError::InvalidEvent( - "plugin ingress surface is not enabled".to_string(), - )); - } - match self.lifecycle { - PluginInstanceLifecycleState::Running => {} - PluginInstanceLifecycleState::Failed => { - return Err(PluginIngressDispatchError::ServiceFailed( - self.last_error - .clone() - .unwrap_or_else(|| "service is failed".to_string()), - )); - } - PluginInstanceLifecycleState::Stopping | PluginInstanceLifecycleState::Stopped => { - return Err(PluginIngressDispatchError::ServiceStopped { - state: self.lifecycle.clone(), - }); - } - _ => { - return Err(PluginIngressDispatchError::ServiceUnavailable { - state: self.lifecycle.clone(), - }); - } - } - if event.source.trim().is_empty() { - return Err(PluginIngressDispatchError::InvalidEvent( - "source must not be empty".to_string(), - )); - } - if event.ingress_name != ingress_name { - return Err(PluginIngressDispatchError::InvalidEvent(format!( - "event ingress `{}` does not match dispatch ingress `{ingress_name}`", - event.ingress_name - ))); - } - if event.kind.trim().is_empty() { - return Err(PluginIngressDispatchError::InvalidEvent( - "event kind must not be empty".to_string(), - )); - } - if event.created_at.trim().is_empty() { - return Err(PluginIngressDispatchError::InvalidEvent( - "created_at must not be empty".to_string(), - )); - } - if event.attempt == 0 { - return Err(PluginIngressDispatchError::InvalidEvent( - "attempt must be greater than zero".to_string(), - )); - } - if event.correlation_id.trim().is_empty() { - return Err(PluginIngressDispatchError::InvalidEvent( - "correlation_id must not be empty".to_string(), - )); - } - if serde_json::to_vec(event) - .map(|bytes| bytes.len()) - .unwrap_or(usize::MAX) - > PLUGIN_WASM_MAX_INPUT_BYTES - { - return Err(PluginIngressDispatchError::InvalidEvent(format!( - "plugin ingress event exceeds {} bytes", - PLUGIN_WASM_MAX_INPUT_BYTES - ))); - } - let ingress = self - .record - .manifest - .ingresses - .iter() - .find(|ingress| ingress.name == ingress_name) - .ok_or_else(|| { - PluginIngressDispatchError::InvalidEvent( - "requested ingress is not declared by plugin manifest".to_string(), - ) - })?; - if !ingress.event_kinds.is_empty() - && !ingress.event_kinds.iter().any(|kind| kind == &event.kind) - { - return Err(PluginIngressDispatchError::InvalidEvent(format!( - "event kind `{}` is not declared for ingress `{ingress_name}`", - event.kind - ))); - } - authorize_plugin_ingress(&self.record, ingress_name).map_err(|error| { - PluginIngressDispatchError::InvalidEvent(format!( - "plugin ingress permission denied: {}", - error.bounded_message() - )) - })?; - Ok(()) - } - - fn dispatch_next_ingress( - &mut self, - ) -> Result { - let Some(queued) = self.ingress_queue.pop_front() else { - return Err(PluginIngressDispatchError::InvalidEvent( - "plugin ingress queue is empty".to_string(), - )); - }; - let started_at = Instant::now(); - let result = self.dispatch_ingress_now(&queued.ingress_name, queued.event); - let elapsed = started_at.elapsed(); - if elapsed > PLUGIN_SERVICE_INGRESS_DISPATCH_TIMEOUT { - let error = PluginIngressDispatchError::DispatchTimeout { - timeout: PLUGIN_SERVICE_INGRESS_DISPATCH_TIMEOUT, - }; - self.dispatch_counters.timed_out += 1; - self.dispatch_counters.failed += 1; - self.lifecycle = PluginInstanceLifecycleState::Failed; - self.record_dispatch_error(&error); - return Err(error); - } - match result { - Ok(mut report) => { - let queue_latency_ms = queued.enqueued_at.elapsed().as_millis() as u64; - if report.output.get("queue_latency_ms").is_none() { - if let Some(map) = report.output.as_object_mut() { - map.insert( - "queue_latency_ms".to_string(), - Value::from(queue_latency_ms), - ); - } - } - self.dispatch_counters.dispatched += 1; - report.queue_depth = self.ingress_queue.len(); - report.dispatch_counters = self.dispatch_counters.clone(); - report.diagnostics = self.diagnostics.clone(); - Ok(report) - } - Err(error) => { - self.dispatch_counters.failed += 1; - self.lifecycle = PluginInstanceLifecycleState::Failed; - let dispatch_error = - PluginIngressDispatchError::DispatchFailed(error.bounded_message()); - self.record_dispatch_error(&dispatch_error); - Err(dispatch_error) - } - } - } - - fn dispatch_ingress_now( - &mut self, - ingress_name: &str, - event: PluginIngressEvent, - ) -> Result { - let output = match &mut self.runtime { - PluginInstanceRuntime::ComponentToolAdapter => { - return Err(PluginWasmError::Module( - "component tool runtime does not expose ingress dispatch".to_string(), - )); - } - #[cfg(test)] - PluginInstanceRuntime::TestIngress { - tool_calls, - ingress_calls, - } => { - if let Some(sleep_ms) = event.payload.get("sleep_ms").and_then(Value::as_u64) { - std::thread::sleep(Duration::from_millis(sleep_ms)); - } - if event.payload.get("fail").and_then(Value::as_bool) == Some(true) { - return Err(PluginWasmError::Execution( - "test ingress requested failure".to_string(), - )); - } - *ingress_calls += 1; - let mut output = serde_json::json!({ - "ingress": ingress_name, - "kind": event.kind.clone(), - "source": event.source.clone(), - "ingress_name": event.ingress_name.clone(), - "attempt": event.attempt, - "correlation_id": event.correlation_id.clone(), - "calls": *tool_calls, - "ingress_calls": *ingress_calls, - "payload": event.payload.clone(), - }); - if let (Some(map), Some(commands)) = ( - output.as_object_mut(), - event.payload.get("output_commands").cloned(), - ) { - map.insert("output_commands".to_string(), commands); - } - output - } - PluginInstanceRuntime::ComponentInstance(runtime) => { - runtime.handle_ingress(ingress_name, &event)? - } - }; - let output_command_results = self.process_service_output_commands(&output, &event); - Ok(PluginIngressDispatchReport { - plugin_ref: self.record.identity.to_string(), - ingress: ingress_name.to_string(), - accepted: true, - output, - output_command_results, - queue_depth: self.ingress_queue.len(), - dispatch_counters: self.dispatch_counters.clone(), - diagnostics: self.diagnostics.clone(), - }) - } - - fn stop(&mut self) -> Result<(), PluginWasmError> { - if self.lifecycle == PluginInstanceLifecycleState::Stopped { - return Ok(()); - } - self.lifecycle = PluginInstanceLifecycleState::Stopping; - self.diagnostics.push(PluginInstanceDiagnostic::new( - PluginInstanceLifecycleState::Stopping, - "plugin service stop requested; ingress queue is closed", - )); - self.ingress_queue.clear(); - self.service_websockets.stop_all(); - let stop_result = match &mut self.runtime { - PluginInstanceRuntime::ComponentToolAdapter => Ok(()), - #[cfg(test)] - PluginInstanceRuntime::TestIngress { .. } => Ok(()), - PluginInstanceRuntime::ComponentInstance(runtime) => match runtime.stop() { - Ok(status) => { - self.component_status = Some(status); - Ok(()) - } - Err(error) => Err(error), - }, - }; - if let Err(error) = stop_result { - self.lifecycle = PluginInstanceLifecycleState::Failed; - self.record_runtime_error( - PluginInstanceDiagnosticKind::ServiceFailed, - format!("plugin component stop failed: {}", error.bounded_message()), - ); - return Err(error); - } - self.lifecycle = PluginInstanceLifecycleState::Stopped; - self.diagnostics.push(PluginInstanceDiagnostic::new( - PluginInstanceLifecycleState::Stopped, - "plugin service stopped", - )); - Ok(()) - } - - fn snapshot_status(&self) -> PluginInstanceStatus { - PluginInstanceStatus { - plugin_ref: self.record.identity.to_string(), - lifecycle: self.lifecycle.clone(), - component_status: self.component_status.clone(), - queue_depth: self.ingress_queue.len(), - queue_capacity: self.ingress_queue_capacity, - last_error: self.last_error.clone(), - dispatch_counters: self.dispatch_counters.clone(), - output_command_results: self.output_command_results.clone(), - websocket_connections: self.service_websockets.statuses(), - diagnostics: self.diagnostics.clone(), - } - } - - fn status(&mut self) -> PluginInstanceStatus { - if self.lifecycle == PluginInstanceLifecycleState::Running { - if let PluginInstanceRuntime::ComponentInstance(runtime) = &mut self.runtime { - match runtime.status() { - Ok(status) => self.component_status = Some(status), - Err(error) => { - self.lifecycle = PluginInstanceLifecycleState::Failed; - self.record_runtime_error( - PluginInstanceDiagnosticKind::ServiceFailed, - format!( - "plugin component status failed: {}", - error.bounded_message() - ), - ); - } - } - } - } - self.snapshot_status() - } - - fn record_rejection( - &mut self, - error: PluginIngressDispatchError, - ) -> PluginIngressDispatchError { - self.dispatch_counters.rejected += 1; - self.record_dispatch_error(&error); - error - } - - fn record_dispatch_error(&mut self, error: &PluginIngressDispatchError) { - let state = match error { - PluginIngressDispatchError::DispatchFailed(_) - | PluginIngressDispatchError::DispatchTimeout { .. } - | PluginIngressDispatchError::ServiceFailed(_) => PluginInstanceLifecycleState::Failed, - PluginIngressDispatchError::ServiceStopped { .. } => self.lifecycle.clone(), - _ => self.lifecycle.clone(), - }; - self.record_runtime_error(error.diagnostic_kind(), error.bounded_message()); - if matches!(state, PluginInstanceLifecycleState::Failed) { - self.lifecycle = PluginInstanceLifecycleState::Failed; - } - } - - fn process_service_output_commands( - &mut self, - output: &Value, - event: &PluginIngressEvent, - ) -> Vec { - let results = match self.validate_service_output_commands(output, event) { - Ok(commands) => { - let mut results = Vec::with_capacity(commands.len()); - for command in commands { - results.push(self.execute_service_output_command(command)); - } - results - } - Err(results) => results, - }; - self.record_service_output_command_results(&results); - results - } - - fn validate_service_output_commands( - &self, - output: &Value, - event: &PluginIngressEvent, - ) -> Result, Vec> - { - let Some(values) = output.get("output_commands") else { - return Ok(Vec::new()); - }; - let Some(values) = values.as_array() else { - return Err(vec![PluginServiceOutputCommandResult::rejected( - "service output_commands must be an array", - )]); - }; - if values.len() > PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_COUNT { - return Err(vec![PluginServiceOutputCommandResult::rejected(format!( - "service output_commands exceeds {} commands", - PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_COUNT - ))]); - } - - let mut commands = Vec::with_capacity(values.len()); - let mut rejected = Vec::new(); - for value in values { - let command: PluginServiceOutputCommandEnvelope = - match serde_json::from_value(value.clone()) { - Ok(command) => command, - Err(error) => { - rejected.push(PluginServiceOutputCommandResult::rejected(format!( - "malformed service output command envelope: {error}" - ))); - continue; - } - }; - if let Err(message) = self.validate_service_output_command_envelope(&command, event) { - rejected.push(PluginServiceOutputCommandResult::rejected_for( - &command, message, - )); - continue; - } - if let Err(message) = self.grant_check_service_output_command(&command) { - rejected.push(PluginServiceOutputCommandResult::rejected_for( - &command, message, - )); - continue; - } - commands.push(command); - } - - if rejected.is_empty() { - Ok(commands) - } else { - Err(rejected) - } - } - - fn validate_service_output_command_envelope( - &self, - command: &PluginServiceOutputCommandEnvelope, - event: &PluginIngressEvent, - ) -> Result<(), String> { - validate_output_command_id("correlation_id", &command.correlation_id)?; - validate_output_command_id("source_event_id", &command.source_event_id)?; - validate_output_command_id("command_id", &command.command_id)?; - if command.source_event_id != event.correlation_id { - return Err("source_event_id must match the ingress event correlation_id".to_string()); - } - chrono::DateTime::parse_from_rfc3339(&command.requested_at) - .map_err(|error| format!("requested_at must be RFC3339: {error}"))?; - let payload_bytes = serde_json::to_vec(&command.payload) - .map_err(|error| format!("payload is not serializable JSON: {error}"))?; - if payload_bytes.len() > PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_PAYLOAD_BYTES { - return Err(format!( - "payload exceeds {} bytes", - PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_PAYLOAD_BYTES - )); - } - match command.kind { - PluginServiceOutputCommandKind::DiagnosticStatusUpdate => { - serde_json::from_value::( - command.payload.clone(), - ) - .map_err(|error| format!("invalid diagnostic_status_update payload: {error}"))?; - } - PluginServiceOutputCommandKind::HostRequestDispatch => { - let request: PluginRequestRequest = serde_json::from_value(command.payload.clone()) - .map_err(|error| format!("invalid host_request_dispatch payload: {error}"))?; - validate_plugin_request_request(&self.record, &request) - .map_err(|error| format!("host_request_dispatch target denied: {}", error.0))?; - } - PluginServiceOutputCommandKind::WebSocketSend => { - let payload: PluginServiceWebSocketSendCommandPayload = - serde_json::from_value(command.payload.clone()) - .map_err(|error| format!("invalid websocket_send payload: {error}"))?; - self.validate_service_websocket_send_payload(&payload)?; - } - } - Ok(()) - } - - fn grant_check_service_output_command( - &self, - command: &PluginServiceOutputCommandEnvelope, - ) -> Result<(), String> { - match command.kind { - PluginServiceOutputCommandKind::DiagnosticStatusUpdate => Ok(()), - PluginServiceOutputCommandKind::HostRequestDispatch => { - authorize_plugin_host_api(&self.record, PluginHostApi::Request) - .map_err(|error| format!("host_request_dispatch not granted: {}", error.0)) - } - PluginServiceOutputCommandKind::WebSocketSend => { - authorize_plugin_host_api(&self.record, PluginHostApi::WebSocket) - .map_err(|error| format!("websocket_send not granted: {}", error.0)) - } - } - } - - fn validate_service_websocket_send_payload( - &self, - payload: &PluginServiceWebSocketSendCommandPayload, - ) -> Result<(), String> { - if payload.text.len() > PLUGIN_WEBSOCKET_MAX_TEXT_BYTES { - return Err(format!( - "websocket_send text exceeds {} bytes", - PLUGIN_WEBSOCKET_MAX_TEXT_BYTES - )); - } - let url = reqwest::Url::parse(&payload.url) - .map_err(|error| format!("invalid WebSocket URL: {error}"))?; - match url.scheme() { - "ws" | "wss" => {} - "http" | "https" => { - return Err("HTTP URLs are not supported by websocket_send".to_string()); - } - scheme => { - return Err(format!( - "unsupported WebSocket URL scheme {scheme:?}; only ws and wss are allowed" - )); - } - } - if url.host_str().is_none() { - return Err("WebSocket URL must include a host".to_string()); - } - if !url.username().is_empty() || url.password().is_some() { - return Err("WebSocket URLs with embedded credentials are not allowed".to_string()); - } - validate_static_request_target(&url).map_err(|error| error.0)?; - authorize_websocket_allowlist(&self.record, &url).map_err(|error| error.0)?; - Ok(()) - } - - fn execute_service_output_command( - &mut self, - command: PluginServiceOutputCommandEnvelope, - ) -> PluginServiceOutputCommandResult { - match command.kind { - PluginServiceOutputCommandKind::DiagnosticStatusUpdate => { - let payload: PluginServiceDiagnosticStatusCommandPayload = - match serde_json::from_value(command.payload.clone()) { - Ok(payload) => payload, - Err(error) => { - return PluginServiceOutputCommandResult::rejected_for( - &command, - format!("invalid diagnostic_status_update payload: {error}"), - ); - } - }; - if let Some(status) = payload.status { - self.component_status = Some(status); - } - let message = payload - .message - .as_deref() - .map(bounded_message) - .unwrap_or_else(|| "plugin service status update recorded".to_string()); - PluginServiceOutputCommandResult::recorded(&command, message) - } - PluginServiceOutputCommandKind::HostRequestDispatch => { - PluginServiceOutputCommandResult::unsupported( - &command, - "host_request_dispatch output command is grant-checked but transport dispatch is unsupported in v0", - ) - } - PluginServiceOutputCommandKind::WebSocketSend => { - let payload: PluginServiceWebSocketSendCommandPayload = - match serde_json::from_value(command.payload.clone()) { - Ok(payload) => payload, - Err(error) => { - return PluginServiceOutputCommandResult::rejected_for( - &command, - format!("invalid websocket_send payload: {error}"), - ); - } - }; - match self - .service_websockets - .send_text(&payload.url, &payload.text) - { - Ok(message) => PluginServiceOutputCommandResult::recorded(&command, message), - Err(message) => { - self.diagnostics.push(PluginInstanceDiagnostic::with_kind( - PluginInstanceDiagnosticKind::ServiceWebSocketSendFailed, - self.lifecycle.clone(), - bounded_message(redact_secret_like(&message)), - )); - PluginServiceOutputCommandResult::rejected_for(&command, message) - } - } - } - } - } - - fn record_service_output_command_results( - &mut self, - results: &[PluginServiceOutputCommandResult], - ) { - if results.is_empty() { - return; - } - for result in results { - let kind = match result.status { - PluginServiceOutputCommandStatus::Recorded => { - PluginInstanceDiagnosticKind::ServiceOutputCommandRecorded - } - PluginServiceOutputCommandStatus::Rejected => { - PluginInstanceDiagnosticKind::ServiceOutputCommandRejected - } - PluginServiceOutputCommandStatus::Unsupported => { - PluginInstanceDiagnosticKind::ServiceOutputCommandUnsupported - } - }; - let command_label = result.command_id.as_deref().unwrap_or(""); - let command_kind = result - .kind - .map(PluginServiceOutputCommandKind::as_str) - .unwrap_or("unknown"); - let message = bounded_message(format!( - "service output command {command_label} ({command_kind}): {}", - result.message - )); - if !matches!(result.status, PluginServiceOutputCommandStatus::Recorded) { - self.last_error = Some(message.clone()); - } - self.diagnostics.push(PluginInstanceDiagnostic::with_kind( - kind, - self.lifecycle.clone(), - message, - )); - } - self.output_command_results.extend_from_slice(results); - if self.output_command_results.len() > PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_RESULTS { - let keep_from = - self.output_command_results.len() - PLUGIN_SERVICE_OUTPUT_COMMAND_MAX_RESULTS; - self.output_command_results.drain(0..keep_from); - } - } - - fn record_runtime_error( - &mut self, - kind: PluginInstanceDiagnosticKind, - message: impl Into, - ) { - let message = bounded_message(redact_secret_like(&message.into())); - self.last_error = Some(message.clone()); - self.diagnostics.push(PluginInstanceDiagnostic::with_kind( - kind, - self.lifecycle.clone(), - message, - )); - } -} - -enum PluginInstanceRuntime { - ComponentToolAdapter, - #[cfg(test)] - TestIngress { - tool_calls: u64, - ingress_calls: u64, - }, - ComponentInstance(PluginComponentInstanceRuntime), -} - -impl PluginInstanceRuntime { - fn new(record: &ResolvedPluginRecord) -> Result { - let Some(runtime) = record.manifest.runtime.as_ref() else { - return Err(PluginWasmError::Module( - "plugin runtime is not declared".to_string(), - )); - }; - match runtime.kind.as_str() { - #[cfg(test)] - "test-ingress" => Ok(Self::TestIngress { - tool_calls: 0, - ingress_calls: 0, - }), - PLUGIN_RUNTIME_COMPONENT_KIND - if runtime.world.as_deref() == Some(PLUGIN_COMPONENT_INSTANCE_WORLD) => - { - Ok(Self::ComponentInstance( - PluginComponentInstanceRuntime::instantiate(record)?, - )) - } - PLUGIN_RUNTIME_COMPONENT_KIND - if runtime.world.as_deref() == Some(PLUGIN_COMPONENT_TOOL_WORLD) => - { - Ok(Self::ComponentToolAdapter) - } - PLUGIN_RUNTIME_COMPONENT_KIND => Err(PluginWasmError::Module( - "unsupported or missing plugin component world".to_string(), - )), - LEGACY_PLUGIN_RUNTIME_WASM_KIND => Err(PluginWasmError::Module( - "legacy raw wasm plugin runtime is not supported; use wasm-component".to_string(), - )), - other => Err(PluginWasmError::Module(format!( - "unsupported plugin runtime kind `{other}`" - ))), - } - } -} - -struct PluginComponentInstanceRuntime { - store: wasmtime::Store, - instance: wasmtime::component::Instance, -} - -impl PluginComponentInstanceRuntime { - fn instantiate(record: &ResolvedPluginRecord) -> Result { - let limits = PluginDiscoveryLimits::default(); - let component_bytes = read_resolved_plugin_runtime_component(record, &limits) - .map_err(|diagnostic| PluginWasmError::Package(diagnostic.message))?; - if component_bytes.len() > limits.max_file_size_bytes as usize { - return Err(PluginWasmError::Package(format!( - "WASM component runtime artifact exceeds {} bytes", - limits.max_file_size_bytes - ))); - } - let mut config = wasmtime::Config::new(); - config.wasm_component_model(true); - config.consume_fuel(true); - config.max_wasm_stack(8 * 1024 * 1024); - let engine = wasmtime::Engine::new(&config) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - let component = - wasmtime::component::Component::new(&engine, &component_bytes).map_err(|error| { - PluginWasmError::Module(format!("component is incompatible: {error:?}")) - })?; - validate_component_imports(record, &engine, &component)?; - let mut linker = wasmtime::component::Linker::::new(&engine); - define_plugin_component_host_imports(&mut linker)?; - let mut store = wasmtime::Store::new( - &engine, - PluginComponentHostState { - record: record.clone(), - request_client: Arc::new(ReqwestPluginRequestClient), - websocket_client: Arc::new(TungstenitePluginWebSocketClient), - websocket_handles: PluginWebSocketHandles::default(), - store_limits: wasm_component_store_limits(), - }, - ); - store.limiter(|state| &mut state.store_limits); - store - .set_fuel(PLUGIN_WASM_FUEL) - .map_err(|error| PluginWasmError::Execution(error.to_string()))?; - let instance = linker - .instantiate(&mut store, &component) - .map_err(|error| PluginWasmError::Execution(error.to_string()))?; - Ok(Self { store, instance }) - } - - fn reset_fuel(&mut self) -> Result<(), PluginWasmError> { - self.store - .set_fuel(PLUGIN_WASM_FUEL) - .map_err(|error| PluginWasmError::Execution(error.to_string())) - } - - fn start(&mut self, record: &ResolvedPluginRecord) -> Result { - self.reset_fuel()?; - let start = self - .instance - .get_typed_func::<(&str,), (String,)>(&mut self.store, "start") - .map_err(|error| { - PluginWasmError::Module(format!( - "component does not export expected `{}` start function: {error}", - PLUGIN_COMPONENT_INSTANCE_WORLD - )) - })?; - let config_json = plugin_config_json(record); - let (status,) = start - .call(&mut self.store, (&config_json,)) - .map_err(|error| PluginWasmError::Execution(error.to_string()))?; - decode_plugin_lifecycle_output("start", &status) - } - - fn handle_tool( - &mut self, - tool_name: &str, - input: Vec, - ) -> Result { - self.reset_fuel()?; - let call = self - .instance - .get_typed_func::<(&str, &str), (String,)>(&mut self.store, "handle-tool") - .map_err(|error| { - PluginWasmError::Module(format!( - "component does not export expected `{}` handle-tool function: {error}", - PLUGIN_COMPONENT_INSTANCE_WORLD - )) - })?; - let input_json = std::str::from_utf8(&input).map_err(|error| { - PluginWasmError::Output(format!("plugin component input is not UTF-8: {error}")) - })?; - let (output,) = call - .call(&mut self.store, (tool_name, input_json)) - .map_err(|error| PluginWasmError::Execution(error.to_string()))?; - decode_plugin_wasm_output(output.as_bytes()) - } - - fn handle_ingress( - &mut self, - ingress_name: &str, - event: &PluginIngressEvent, - ) -> Result { - self.reset_fuel()?; - let call = self - .instance - .get_typed_func::<(&str, &str), (String,)>(&mut self.store, "handle-ingress") - .map_err(|error| { - PluginWasmError::Module(format!( - "component does not export expected `{}` handle-ingress function: {error}", - PLUGIN_COMPONENT_INSTANCE_WORLD - )) - })?; - let event_json = serde_json::to_string(event) - .map_err(|error| PluginWasmError::Output(error.to_string()))?; - let (output,) = call - .call(&mut self.store, (ingress_name, event_json.as_str())) - .map_err(|error| PluginWasmError::Execution(error.to_string()))?; - if output.len() > PLUGIN_WASM_MAX_OUTPUT_BYTES { - return Err(PluginWasmError::Output(format!( - "plugin ingress output exceeds {} bytes", - PLUGIN_WASM_MAX_OUTPUT_BYTES - ))); - } - serde_json::from_str(&output).map_err(|error| { - PluginWasmError::Output(format!("plugin ingress output is not JSON: {error}")) - }) - } - - fn stop(&mut self) -> Result { - self.reset_fuel()?; - let stop = self - .instance - .get_typed_func::<(), (String,)>(&mut self.store, "stop") - .map_err(|error| { - PluginWasmError::Module(format!( - "component does not export expected `{}` stop function: {error}", - PLUGIN_COMPONENT_INSTANCE_WORLD - )) - })?; - let (status,) = stop - .call(&mut self.store, ()) - .map_err(|error| PluginWasmError::Execution(error.to_string()))?; - self.store.data().websocket_handles.close_all(); - decode_plugin_lifecycle_output("stop", &status) - } - - fn status(&mut self) -> Result { - self.reset_fuel()?; - let status = self - .instance - .get_typed_func::<(), (String,)>(&mut self.store, "status") - .map_err(|error| { - PluginWasmError::Module(format!( - "component does not export expected `{}` status function: {error}", - PLUGIN_COMPONENT_INSTANCE_WORLD - )) - })?; - let (status,) = status - .call(&mut self.store, ()) - .map_err(|error| PluginWasmError::Execution(error.to_string()))?; - decode_plugin_lifecycle_output("status", &status) - } -} - -fn decode_plugin_lifecycle_output(phase: &str, output: &str) -> Result { - if output.len() > PLUGIN_WASM_MAX_OUTPUT_BYTES { - return Err(PluginWasmError::Output(format!( - "plugin component {phase} output exceeds {} bytes", - PLUGIN_WASM_MAX_OUTPUT_BYTES - ))); - } - let value: Value = serde_json::from_str(output).map_err(|error| { - PluginWasmError::Output(format!( - "plugin component {phase} output is not JSON: {error}" - )) - })?; - if let Some(error) = value.get("error") { - return Err(PluginWasmError::Execution(format!( - "plugin component {phase} returned error: {}", - bounded_message(error.to_string()) - ))); - } - if value.get("state").and_then(Value::as_str) == Some("failed") { - return Err(PluginWasmError::Execution(format!( - "plugin component {phase} returned failed status: {}", - bounded_message(value.to_string()) - ))); - } - Ok(value) -} - -fn plugin_config_json(record: &ResolvedPluginRecord) -> String { - serde_json::to_string(&record.config).unwrap_or_else(|_| "{}".to_string()) -} - -fn plugin_instance_tool_definition( - instance: PluginInstanceHandle, - name: String, - description: String, - input_schema: Value, -) -> ToolDefinition { - let origin = { - let guard = instance.0.lock().expect("plugin instance poisoned"); - plugin_tool_origin(&guard.record) - }; - Arc::new(move || { - ( - ToolMeta::new(name.clone()) - .description(description.clone()) - .input_schema(input_schema.clone()) - .origin(origin.clone()), - Arc::new(PluginInstanceTool { - instance: instance.clone(), - name: name.clone(), - origin: origin.clone(), - }) as Arc, - ) - }) -} - -struct PluginInstanceTool { - instance: PluginInstanceHandle, - name: String, - origin: ToolOrigin, -} - -#[async_trait] -impl Tool for PluginInstanceTool { - async fn execute( - &self, - input_json: &str, - _ctx: ToolExecutionContext, - ) -> Result { - if input_json.len() > PLUGIN_WASM_MAX_INPUT_BYTES { - return Err(ToolError::InvalidArgument(format!( - "plugin tool `{}` input exceeds {} bytes", - self.name, PLUGIN_WASM_MAX_INPUT_BYTES - ))); - } - serde_json::from_str::(input_json).map_err(|error| { - ToolError::InvalidArgument(format!( - "plugin tool `{}` input is not valid JSON: {}", - self.name, - bounded_message(error.to_string()) - )) - })?; - let instance = self.instance.clone(); - let name = self.name.clone(); - let plugin_ref = self.origin.plugin_ref.clone(); - let digest = self.origin.digest.clone(); - let input = input_json.as_bytes().to_vec(); - let execution = tokio::task::spawn_blocking(move || instance.handle_tool(&name, input)); - match tokio::time::timeout(PLUGIN_WASM_TIMEOUT, execution).await { - Ok(Ok(Ok(output))) => Ok(output), - Ok(Ok(Err(error))) => Err(ToolError::ExecutionFailed(format!( - "plugin tool `{}` from `{}` (digest {}) failed closed: {}", - self.name, - plugin_ref, - digest, - error.bounded_message() - ))), - Ok(Err(error)) => Err(ToolError::ExecutionFailed(format!( - "plugin tool `{}` from `{}` (digest {}) cancelled/failed to join: {}", - self.name, - plugin_ref, - digest, - bounded_message(error.to_string()) - ))), - Err(_) => { - self.instance - .record_diagnostic(PluginInstanceDiagnostic::new( - PluginInstanceLifecycleState::Failed, - format!("plugin tool timed out after {:?}", PLUGIN_WASM_TIMEOUT), - )); - Err(ToolError::ExecutionFailed(format!( - "plugin tool `{}` from `{}` (digest {}) timed out after {:?}", - self.name, plugin_ref, digest, PLUGIN_WASM_TIMEOUT - ))) - } - } - } -} - -#[derive(Debug)] -pub enum PluginWasmError { - Package(String), - Module(String), - Execution(String), - Output(String), -} - -impl PluginWasmError { - fn bounded_message(&self) -> String { - match self { - Self::Package(message) => { - bounded_message(format!("package/module load error: {message}")) - } - Self::Module(message) => bounded_message(format!("WASM module error: {message}")), - Self::Execution(message) => bounded_message(format!("WASM execution error: {message}")), - Self::Output(message) => bounded_message(format!("WASM output error: {message}")), - } - } -} - -#[derive(Clone)] -struct PluginComponentHostState { - record: ResolvedPluginRecord, - request_client: Arc, - websocket_client: Arc, - websocket_handles: PluginWebSocketHandles, - store_limits: wasmtime::StoreLimits, -} - -fn run_plugin_component_tool( - record: ResolvedPluginRecord, - tool_name: String, - input: Vec, -) -> Result { - run_plugin_component_tool_with_request_client( - record, - tool_name, - input, - Arc::new(ReqwestPluginRequestClient), - ) -} - -fn run_plugin_component_tool_with_request_client( - record: ResolvedPluginRecord, - tool_name: String, - input: Vec, - request_client: Arc, -) -> Result { - let tool = record - .manifest - .tools - .iter() - .find(|tool| tool.name == tool_name) - .ok_or_else(|| { - PluginWasmError::Module("requested tool is not declared by plugin manifest".to_string()) - })?; - authorize_plugin_tool(&record, tool).map_err(|error| { - PluginWasmError::Module(format!( - "plugin permission denied: {}", - error.bounded_message() - )) - })?; - - let limits = PluginDiscoveryLimits::default(); - let component_bytes = read_resolved_plugin_runtime_component(&record, &limits) - .map_err(|diagnostic| PluginWasmError::Package(diagnostic.message))?; - if component_bytes.len() > limits.max_file_size_bytes as usize { - return Err(PluginWasmError::Package(format!( - "WASM component runtime artifact exceeds {} bytes", - limits.max_file_size_bytes - ))); - } - - let mut config = wasmtime::Config::new(); - config.wasm_component_model(true); - config.consume_fuel(true); - config.max_wasm_stack(8 * 1024 * 1024); - let engine = wasmtime::Engine::new(&config) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - let component = - wasmtime::component::Component::new(&engine, &component_bytes).map_err(|error| { - PluginWasmError::Module(format!("component is incompatible: {error:?}")) - })?; - validate_component_imports(&record, &engine, &component)?; - - let mut linker = wasmtime::component::Linker::::new(&engine); - define_plugin_component_host_imports(&mut linker)?; - let mut store = wasmtime::Store::new( - &engine, - PluginComponentHostState { - record: record.clone(), - request_client, - websocket_client: Arc::new(TungstenitePluginWebSocketClient), - websocket_handles: PluginWebSocketHandles::default(), - store_limits: wasm_component_store_limits(), - }, - ); - store.limiter(|state| &mut state.store_limits); - store - .set_fuel(PLUGIN_WASM_FUEL) - .map_err(|error| PluginWasmError::Execution(error.to_string()))?; - let instance = linker - .instantiate(&mut store, &component) - .map_err(|error| PluginWasmError::Execution(error.to_string()))?; - let call = instance - .get_typed_func::<(&str, &str), (String,)>(&mut store, "call") - .map_err(|error| { - PluginWasmError::Module(format!( - "component does not export expected `{}` call function: {error}", - PLUGIN_COMPONENT_TOOL_WORLD - )) - })?; - let input_json = std::str::from_utf8(&input).map_err(|error| { - PluginWasmError::Output(format!("plugin component input is not UTF-8: {error}")) - })?; - // Wasmtime lifts the returned WIT `string` into a host `String` before the - // ordinary ToolOutput JSON cap can be applied. Keep the component store on - // the same memory/table/instance limits as the raw WASM runtime so an - // untrusted component can only force host string allocation from bounded - // component memory; oversized memories/tables/instances fail closed during - // instantiation/growth before this lift succeeds. - let (output,) = call - .call(&mut store, (&tool_name, input_json)) - .map_err(|error| PluginWasmError::Execution(error.to_string()))?; - decode_plugin_wasm_output(output.as_bytes()) -} - -fn validate_component_imports( - record: &ResolvedPluginRecord, - engine: &wasmtime::Engine, - component: &wasmtime::component::Component, -) -> Result<(), PluginWasmError> { - for (name, _) in component.component_type().imports(engine) { - match name { - "yoi:host/request@1.0.0" => { - authorize_plugin_host_api(record, PluginHostApi::Request).map_err(|error| { - PluginWasmError::Module(format!( - "plugin host API dispatch denied: {}", - error.bounded_message() - )) - })?; - } - "yoi:host/websocket@1.0.0" => { - authorize_plugin_host_api(record, PluginHostApi::WebSocket).map_err(|error| { - PluginWasmError::Module(format!( - "plugin host API dispatch denied: {}", - error.bounded_message() - )) - })?; - } - "yoi:host/fs@1.0.0" => { - authorize_plugin_host_api(record, PluginHostApi::Fs).map_err(|error| { - PluginWasmError::Module(format!( - "plugin host API dispatch denied: {}", - error.bounded_message() - )) - })?; - } - other => { - return Err(PluginWasmError::Module(format!( - "unsupported component import `{other}`; no WASI filesystem, ambient network, environment, or other imports are available" - ))); - } - } - } - Ok(()) -} - -fn define_plugin_component_host_imports( - linker: &mut wasmtime::component::Linker, -) -> Result<(), PluginWasmError> { - linker - .root() - .instance("yoi:host/request@1.0.0") - .map_err(|error| PluginWasmError::Module(error.to_string()))? - .func_wrap( - "request", - |store: wasmtime::StoreContextMut<'_, PluginComponentHostState>, - (request,): (String,)| - -> wasmtime::Result<(String,)> { - authorize_plugin_host_api(&store.data().record, PluginHostApi::Request) - .map_err(|error| wasmtime::Error::msg(error.bounded_message()))?; - let response = execute_plugin_request_request( - &store.data().record, - store.data().request_client.as_ref(), - request.as_bytes(), - ) - .map_err(|error| wasmtime::Error::msg(error.0))?; - Ok((String::from_utf8_lossy(&response).into_owned(),)) - }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - - let mut root = linker.root(); - let mut websocket = root - .instance("yoi:host/websocket@1.0.0") - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - websocket - .func_wrap( - "open", - |store: wasmtime::StoreContextMut<'_, PluginComponentHostState>, - (request,): (String,)| - -> wasmtime::Result<(String,)> { - authorize_plugin_host_api(&store.data().record, PluginHostApi::WebSocket) - .map_err(|error| wasmtime::Error::msg(error.bounded_message()))?; - execute_plugin_websocket_open( - &store.data().record, - store.data().websocket_client.as_ref(), - &store.data().websocket_handles, - request.as_bytes(), - ) - .map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),)) - .map_err(|error| wasmtime::Error::msg(error.0)) - }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - websocket - .func_wrap( - "send-text", - |store: wasmtime::StoreContextMut<'_, PluginComponentHostState>, - (handle, text): (u32, String)| - -> wasmtime::Result<(String,)> { - authorize_plugin_host_api(&store.data().record, PluginHostApi::WebSocket) - .map_err(|error| wasmtime::Error::msg(error.bounded_message()))?; - execute_plugin_websocket_send_text( - &store.data().websocket_handles, - handle, - text.as_bytes(), - ) - .map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),)) - .map_err(|error| wasmtime::Error::msg(error.0)) - }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - websocket - .func_wrap( - "recv", - |store: wasmtime::StoreContextMut<'_, PluginComponentHostState>, - (handle, timeout_ms): (u32, u32)| - -> wasmtime::Result<(String,)> { - authorize_plugin_host_api(&store.data().record, PluginHostApi::WebSocket) - .map_err(|error| wasmtime::Error::msg(error.bounded_message()))?; - execute_plugin_websocket_recv(&store.data().websocket_handles, handle, timeout_ms) - .map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),)) - .map_err(|error| wasmtime::Error::msg(error.0)) - }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - websocket - .func_wrap( - "close", - |store: wasmtime::StoreContextMut<'_, PluginComponentHostState>, - (handle,): (u32,)| - -> wasmtime::Result<(String,)> { - authorize_plugin_host_api(&store.data().record, PluginHostApi::WebSocket) - .map_err(|error| wasmtime::Error::msg(error.bounded_message()))?; - execute_plugin_websocket_close(&store.data().websocket_handles, handle) - .map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),)) - .map_err(|error| wasmtime::Error::msg(error.0)) - }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - - let mut fs = root - .instance("yoi:host/fs@1.0.0") - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - fs.func_wrap( - "read", - |store: wasmtime::StoreContextMut<'_, PluginComponentHostState>, - (request,): (String,)| - -> wasmtime::Result<(String,)> { - authorize_plugin_host_api(&store.data().record, PluginHostApi::Fs) - .map_err(|error| wasmtime::Error::msg(error.bounded_message()))?; - execute_plugin_fs_request( - &store.data().record, - PluginFsRuntimeOperation::Read, - request.as_bytes(), - ) - .map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),)) - .map_err(|error| wasmtime::Error::msg(error.message)) - }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - fs.func_wrap( - "list", - |store: wasmtime::StoreContextMut<'_, PluginComponentHostState>, - (request,): (String,)| - -> wasmtime::Result<(String,)> { - authorize_plugin_host_api(&store.data().record, PluginHostApi::Fs) - .map_err(|error| wasmtime::Error::msg(error.bounded_message()))?; - execute_plugin_fs_request( - &store.data().record, - PluginFsRuntimeOperation::List, - request.as_bytes(), - ) - .map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),)) - .map_err(|error| wasmtime::Error::msg(error.message)) - }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - fs.func_wrap( - "write", - |store: wasmtime::StoreContextMut<'_, PluginComponentHostState>, - (request,): (String,)| - -> wasmtime::Result<(String,)> { - authorize_plugin_host_api(&store.data().record, PluginHostApi::Fs) - .map_err(|error| wasmtime::Error::msg(error.bounded_message()))?; - execute_plugin_fs_request( - &store.data().record, - PluginFsRuntimeOperation::Write, - request.as_bytes(), - ) - .map(|bytes| (String::from_utf8_lossy(&bytes).into_owned(),)) - .map_err(|error| wasmtime::Error::msg(error.message)) - }, - ) - .map_err(|error| PluginWasmError::Module(error.to_string()))?; - Ok(()) -} -fn decode_plugin_wasm_output(bytes: &[u8]) -> Result { - if bytes.is_empty() { - return Err(PluginWasmError::Output( - "guest did not call output_write".into(), - )); - } - if bytes.len() > PLUGIN_WASM_MAX_OUTPUT_BYTES { - return Err(PluginWasmError::Output(format!( - "guest output exceeds {} bytes", - PLUGIN_WASM_MAX_OUTPUT_BYTES - ))); - } - let text = std::str::from_utf8(bytes) - .map_err(|error| PluginWasmError::Output(format!("guest output is not UTF-8: {error}")))?; - let value: Value = serde_json::from_str(text).map_err(|error| { - PluginWasmError::Output(format!("guest output is not valid JSON: {error}")) - })?; - let Value::Object(map) = value else { - return Err(PluginWasmError::Output( - "guest output JSON must be an object".into(), - )); - }; - for key in map.keys() { - if key != "summary" && key != "content" { - return Err(PluginWasmError::Output(format!( - "guest output contains unsupported key `{key}`" - ))); - } - } - let summary = match map.get("summary") { - Some(Value::String(summary)) if !summary.is_empty() => summary.clone(), - Some(Value::String(_)) => { - return Err(PluginWasmError::Output( - "guest output summary must not be empty".into(), - )); - } - Some(_) => { - return Err(PluginWasmError::Output( - "guest output summary must be a string".into(), - )); - } - None => { - return Err(PluginWasmError::Output( - "guest output must include a summary string".into(), - )); - } - }; - if summary.len() > PLUGIN_WASM_MAX_SUMMARY_BYTES { - return Err(PluginWasmError::Output(format!( - "guest output summary exceeds {} bytes", - PLUGIN_WASM_MAX_SUMMARY_BYTES - ))); - } - let content = match map.get("content") { - Some(Value::String(content)) => { - if content.len() > PLUGIN_WASM_MAX_OUTPUT_BYTES { - return Err(PluginWasmError::Output(format!( - "guest output content exceeds {} bytes", - PLUGIN_WASM_MAX_OUTPUT_BYTES - ))); - } - Some(content.clone()) - } - Some(Value::Null) | None => None, - Some(_) => { - return Err(PluginWasmError::Output( - "guest output content must be a string or null".into(), - )); - } - }; - Ok(ToolOutput { - summary, - content, - attachments: Vec::new(), - }) -} - -fn bounded_message(message: impl Into) -> String { - let message = message.into(); - let mut sanitized = String::with_capacity(message.len().min(512)); - for ch in message.chars() { - if ch.is_control() && ch != '\n' && ch != '\t' { - sanitized.push(' '); - } else { - sanitized.push(ch); - } - if sanitized.len() >= 512 { - sanitized.truncate(512); - sanitized.push('…'); - break; - } - } - sanitized -} - -fn validate_output_command_id(field: &str, value: &str) -> Result<(), String> { - if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) { - return Err(format!( - "{field} is empty, too long, or contains control characters" - )); - } - Ok(()) -} - -fn validate_declared_tool_names(record: &ResolvedPluginRecord) -> Result<(), FeatureInstallError> { - let mut seen = HashSet::new(); - for tool in &record.manifest.tools { - if !seen.insert(tool.name.as_str()) { - return Err(FeatureInstallError::DuplicateToolName { - tool: tool.name.clone(), - first_feature: format!("{} (same plugin package)", record.identity), - duplicate_feature: record.identity.to_string(), - }); - } - } - Ok(()) -} - -fn validate_tool_name(name: &str) -> Result<(), &'static str> { - if name.is_empty() { - return Err("name must not be empty"); - } - if name.len() > 128 { - return Err("name is longer than 128 bytes"); - } - if name.chars().any(|c| c.is_control() || c.is_whitespace()) { - return Err("name must not contain whitespace or control characters"); - } - Ok(()) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum SupportedSchemaType { - Object, - Array, - String, - Number, - Integer, - Boolean, - Null, -} - -impl SupportedSchemaType { - fn parse(value: &str) -> Option { - match value { - "object" => Some(Self::Object), - "array" => Some(Self::Array), - "string" => Some(Self::String), - "number" => Some(Self::Number), - "integer" => Some(Self::Integer), - "boolean" => Some(Self::Boolean), - "null" => Some(Self::Null), - _ => None, - } - } -} - -fn validate_input_schema(schema: &Value) -> Result<(), String> { - let ty = validate_schema_node(schema, "$", true)?; - if ty != SupportedSchemaType::Object { - return Err("root schema type must be `object`".into()); - } - Ok(()) -} - -fn validate_schema_node( - schema: &Value, - path: &str, - root: bool, -) -> Result { - let Value::Object(map) = schema else { - return Err(format!("{path}: schema node must be a JSON object")); - }; - - for key in map.keys() { - if !is_supported_schema_keyword(key) { - return Err(format!("{path}: unsupported schema keyword `{key}`")); - } - } - - let ty = match map.get("type") { - Some(Value::String(value)) => SupportedSchemaType::parse(value) - .ok_or_else(|| format!("{path}: unsupported schema type `{value}`"))?, - Some(_) => return Err(format!("{path}: type must be a string")), - None if root => return Err("root schema must declare type = `object`".into()), - None => return Err(format!("{path}: schema node must declare type")), - }; - - if let Some(title) = map.get("title") { - if !title.is_string() { - return Err(format!("{path}: title must be a string")); - } - } - if let Some(description) = map.get("description") { - if !description.is_string() { - return Err(format!("{path}: description must be a string")); - } - } - - let properties = map.get("properties"); - if let Some(properties) = properties { - if ty != SupportedSchemaType::Object { - return Err(format!( - "{path}: properties is only supported for object schemas" - )); - } - let Some(properties) = properties.as_object() else { - return Err(format!("{path}: properties must be a JSON object")); - }; - for (name, child_schema) in properties { - validate_schema_node(child_schema, &format!("{path}.properties.{name}"), false)?; - } - } - - if let Some(required) = map.get("required") { - if ty != SupportedSchemaType::Object { - return Err(format!( - "{path}: required is only supported for object schemas" - )); - } - let Some(required) = required.as_array() else { - return Err(format!("{path}: required must be an array")); - }; - let mut seen = HashSet::new(); - for entry in required { - let Some(name) = entry.as_str() else { - return Err(format!("{path}: required entries must be strings")); - }; - if !seen.insert(name) { - return Err(format!("{path}: required entries must be unique")); - } - if let Some(properties) = properties.and_then(Value::as_object) { - if !properties.contains_key(name) { - return Err(format!( - "{path}: required entry `{name}` is not declared in properties" - )); - } - } - } - } - - if let Some(additional) = map.get("additionalProperties") { - if ty != SupportedSchemaType::Object { - return Err(format!( - "{path}: additionalProperties is only supported for object schemas" - )); - } - match additional { - Value::Bool(_) => {} - Value::Object(_) => { - validate_schema_node(additional, &format!("{path}.additionalProperties"), false)?; - } - _ => { - return Err(format!( - "{path}: additionalProperties must be boolean or schema object" - )); - } - } - } - - if let Some(items) = map.get("items") { - if ty != SupportedSchemaType::Array { - return Err(format!("{path}: items is only supported for array schemas")); - } - validate_schema_node(items, &format!("{path}.items"), false)?; - } - - if let Some(enum_values) = map.get("enum") { - let Some(enum_values) = enum_values.as_array() else { - return Err(format!("{path}: enum must be an array")); - }; - if enum_values.is_empty() { - return Err(format!("{path}: enum must not be empty")); - } - for (index, value) in enum_values.iter().enumerate() { - if enum_values - .iter() - .skip(index + 1) - .any(|other| other == value) - { - return Err(format!("{path}: enum entries must be unique")); - } - } - } - - Ok(ty) -} - -fn is_supported_schema_keyword(key: &str) -> bool { - matches!( - key, - "type" - | "title" - | "description" - | "properties" - | "required" - | "additionalProperties" - | "items" - | "enum" - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use manifest::plugin::{ - PluginDiscoveryOptions, PluginEnablementConfig, PluginExactVersion, PluginGrantConfig, - PluginPackageManifest, PluginRequestGrant, PluginRuntimeManifest, SourceQualifiedPluginId, - resolve_plugin_config_for_startup, - }; - use serde_json::json; - use std::fs; - use std::path::Path; - use std::sync::{Arc, Mutex}; - use tempfile::TempDir; - - #[tokio::test(flavor = "multi_thread")] - async fn websocket_runtime_drop_is_safe_inside_async_context() { - let runtime = new_websocket_runtime().unwrap(); - drop(runtime); - } - - fn tool(name: &str) -> manifest::plugin::PluginToolManifest { - manifest::plugin::PluginToolManifest { - name: name.into(), - description: format!("{name} tool"), - input_schema: json!({"type":"object","properties":{},"additionalProperties":false}), - external_write: false, - } - } - - fn record(tools: Vec) -> ResolvedPluginRecord { - record_with_identity("project:example", tools) - } - - fn record_with_identity( - identity: &str, - tools: Vec, - ) -> ResolvedPluginRecord { - let parsed_identity = SourceQualifiedPluginId::parse(identity).unwrap(); - let permissions = tool_permissions(&tools); - ResolvedPluginRecord { - identity: parsed_identity.clone(), - source: parsed_identity.source, - package_path: std::path::PathBuf::from("/tmp/example.zip"), - package_label: "example.zip".into(), - digest: "sha256:abc".into(), - version: "0.1.0".into(), - manifest: PluginPackageManifest { - schema_version: 1, - id: "example".into(), - name: "Example".into(), - version: "0.1.0".into(), - description: None, - surfaces: vec![PluginSurface::Tool], - runtime: Some(manifest::plugin::PluginRuntimeManifest { - kind: "test-ingress".to_string(), - entry: None, - abi: None, - component: None, - world: None, - }), - hooks: Vec::new(), - tools, - services: Vec::new(), - ingresses: Vec::new(), - permissions: permissions.clone(), - request: Vec::new(), - websocket: Vec::new(), - }, - enabled_surfaces: vec![PluginSurface::Tool], - grants: PluginGrantConfig { - id: Some(parsed_identity.to_string()), - version: Some(PluginExactVersion("0.1.0".to_string())), - digest: Some("sha256:abc".to_string()), - permissions, - request: Vec::new(), - websocket: Vec::new(), - fs: Vec::new(), - }, - config: None, - } - } - - fn tool_permissions(tools: &[manifest::plugin::PluginToolManifest]) -> Vec { - let mut permissions = vec![PluginPermission::surface(PluginSurface::Tool)]; - permissions.extend( - tools - .iter() - .map(|tool| PluginPermission::tool(tool.name.clone())), - ); - permissions - } - - fn install_feature( - feature: PluginToolFeature, - ) -> ( - super::super::FeatureRegistryInstallReport, - Vec, - ) { - let mut pending = Vec::new(); - let mut hooks = crate::hook::HookRegistryBuilder::new(); - let report = super::super::FeatureRegistryBuilder::default() - .with_module(feature) - .install_into_pending(&mut pending, &mut hooks); - (report, pending) - } - - #[test] - fn component_lifecycle_rejects_start_error_status() { - let component = component_instance_with_outputs( - br#"{"error":{"message":"boom"}}"#, - br#"{"state":"ready"}"#, - br#"{"state":"stopped"}"#, - br#"{"summary":"tool"}"#, - br#"{"accepted":true}"#, - ); - let (_dir, mut record) = resolved_record_with_component(component); - record.manifest.runtime.as_mut().unwrap().world = - Some(PLUGIN_COMPONENT_INSTANCE_WORLD.into()); - let error = match PluginInstanceHandle::new(record) { - Ok(_) => panic!("component start error should fail instance creation"), - Err(error) => error, - }; - assert!(error.bounded_message().contains("start returned error")); - } - - #[test] - fn component_lifecycle_reports_status_and_stop_outputs() { - let component = component_instance_with_outputs( - br#"{"state":"ready","data":{"phase":"start"}}"#, - br#"{"state":"ready","data":{"phase":"status"}}"#, - br#"{"state":"stopped","data":{"phase":"stop"}}"#, - br#"{"summary":"tool"}"#, - br#"{"accepted":true}"#, - ); - let (_dir, mut record) = resolved_record_with_component(component); - record.manifest.runtime.as_mut().unwrap().world = - Some(PLUGIN_COMPONENT_INSTANCE_WORLD.into()); - let handle = PluginInstanceHandle::new(record).unwrap(); - let status = handle.status(); - assert_eq!(status.lifecycle, PluginInstanceLifecycleState::Running); - assert_eq!(status.component_status.unwrap()["data"]["phase"], "status"); - let stopped = handle.stop().unwrap(); - assert_eq!(stopped.lifecycle, PluginInstanceLifecycleState::Stopped); - assert_eq!(stopped.component_status.unwrap()["data"]["phase"], "stop"); - } - - fn add_service(record: &mut ResolvedPluginRecord, name: &str) { - record.manifest.surfaces.push(PluginSurface::Service); - record.enabled_surfaces.push(PluginSurface::Service); - record - .manifest - .services - .push(manifest::plugin::PluginServiceManifest { - name: name.into(), - description: "test service".into(), - lifecycle: "host-managed".into(), - status_schema: None, - side_effects: Vec::new(), - }); - record - .manifest - .permissions - .push(PluginPermission::surface(PluginSurface::Service)); - record - .manifest - .permissions - .push(PluginPermission::service(name)); - record - .grants - .permissions - .push(PluginPermission::surface(PluginSurface::Service)); - record - .grants - .permissions - .push(PluginPermission::service(name)); - } - - fn add_ingress(record: &mut ResolvedPluginRecord, name: &str) { - record.manifest.surfaces.push(PluginSurface::Ingress); - record.enabled_surfaces.push(PluginSurface::Ingress); - record - .manifest - .ingresses - .push(manifest::plugin::PluginIngressManifest { - name: name.into(), - description: "test ingress".into(), - event_kinds: vec!["test".into()], - input_schema: None, - sources: Vec::new(), - side_effects: Vec::new(), - }); - record - .manifest - .permissions - .push(PluginPermission::surface(PluginSurface::Ingress)); - record - .manifest - .permissions - .push(PluginPermission::ingress(name)); - record - .grants - .permissions - .push(PluginPermission::surface(PluginSurface::Ingress)); - record - .grants - .permissions - .push(PluginPermission::ingress(name)); - } - - fn test_ingress_event(ingress_name: &str, payload: Value) -> PluginIngressEvent { - PluginIngressEvent::new(ingress_name, "test", "unit", payload) - } - - fn service_output_command( - event: &PluginIngressEvent, - command_id: &str, - kind: &str, - payload: Value, - ) -> Value { - json!({ - "correlation_id": event.correlation_id.clone(), - "source_event_id": event.correlation_id.clone(), - "command_id": command_id, - "kind": kind, - "payload": payload, - "requested_at": event.created_at.clone(), - }) - } - - fn add_request_output_grant(record: &mut ResolvedPluginRecord) { - let permission = PluginPermission::host_api(PluginHostApi::Request); - record.manifest.permissions.push(permission.clone()); - record.grants.permissions.push(permission); - let target = PluginRequestGrant { - scheme: "https".to_string(), - host: "api.example.test".to_string(), - port: None, - methods: vec!["POST".to_string()], - path_prefixes: vec!["/v1".to_string()], - }; - record.manifest.request.push(target.clone()); - record.grants.request.push(target); - } - - fn add_websocket_output_grant(record: &mut ResolvedPluginRecord) { - let permission = PluginPermission::host_api(PluginHostApi::WebSocket); - record.manifest.permissions.push(permission.clone()); - record.grants.permissions.push(permission); - let target = PluginWebSocketGrant { - scheme: "wss".to_string(), - host: "ws.example.test".to_string(), - port: None, - path_prefixes: vec!["/events".to_string()], - }; - record.manifest.websocket.push(target.clone()); - record.grants.websocket.push(target); - } - - fn add_websocket_ingress_source(record: &mut ResolvedPluginRecord, source: &str) { - let ingress = record - .manifest - .ingresses - .iter_mut() - .find(|ingress| ingress.name == "shared_ingress") - .expect("shared ingress"); - ingress.event_kinds = vec![ - "test".into(), - "websocket_text".into(), - "websocket_close".into(), - "websocket_error".into(), - ]; - ingress.sources.push(source.to_string()); - } - - fn service_websocket_record() -> ResolvedPluginRecord { - let mut record = test_service_ingress_record(); - add_websocket_output_grant(&mut record); - add_websocket_ingress_source(&mut record, "websocket:wss://ws.example.test/events"); - record - } - - #[derive(Clone, Default)] - struct ServiceWebSocketClient { - events: Arc>>>, - sent: Arc>>, - opens: Arc, - send_error: Arc>>, - } - - impl ServiceWebSocketClient { - fn with_events(events: Vec>) -> Self { - Self { - events: Arc::new(Mutex::new(events.into())), - ..Self::default() - } - } - - fn sent(&self) -> Vec { - self.sent.lock().unwrap().clone() - } - - fn fail_sends_with(&self, message: &str) { - *self.send_error.lock().unwrap() = Some(message.to_string()); - } - } - - impl PluginWebSocketClient for ServiceWebSocketClient { - fn supports_bounded_open(&self) -> bool { - true - } - - fn open( - &self, - _request: &PluginWebSocketOpenRequest, - _url: &reqwest::Url, - _limits: PluginWebSocketLimits, - ) -> Result, PluginWebSocketError> { - self.opens.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(Box::new(ServiceWebSocketConnection { - events: self.events.clone(), - sent: self.sent.clone(), - send_error: self.send_error.clone(), - closed: Arc::new(std::sync::atomic::AtomicBool::new(false)), - })) - } - } - - struct ServiceWebSocketConnection { - events: Arc>>>, - sent: Arc>>, - send_error: Arc>>, - closed: Arc, - } - - impl PluginWebSocketConnection for ServiceWebSocketConnection { - fn send_text(&mut self, text: &str) -> Result<(), PluginWebSocketError> { - if let Some(error) = self.send_error.lock().unwrap().clone() { - return Err(PluginWebSocketError::new(error)); - } - self.sent.lock().unwrap().push(text.to_string()); - Ok(()) - } - - fn recv_text( - &mut self, - timeout: Duration, - _max_message_bytes: usize, - ) -> Result { - if self.closed.load(std::sync::atomic::Ordering::SeqCst) { - return Ok(PluginWebSocketRecvResponse::Closed); - } - if let Some(event) = self.events.lock().unwrap().pop_front() { - return event.map_err(PluginWebSocketError::new); - } - std::thread::sleep(timeout.min(Duration::from_millis(10))); - Err(PluginWebSocketError::new("receive timed out")) - } - - fn close(&mut self) -> Result<(), PluginWebSocketError> { - self.closed.store(true, std::sync::atomic::Ordering::SeqCst); - Ok(()) - } - } - - fn wait_until(mut condition: impl FnMut() -> bool) { - let deadline = std::time::Instant::now() + Duration::from_secs(2); - while std::time::Instant::now() < deadline { - if condition() { - return; - } - std::thread::sleep(Duration::from_millis(10)); - } - assert!(condition(), "condition did not become true before timeout"); - } - - #[test] - fn service_selected_ignores_unselected_tool_without_grants() { - let mut record = record(vec![tool("hidden_tool")]); - add_service(&mut record, "svc"); - record.enabled_surfaces = vec![PluginSurface::Service]; - record.manifest.permissions = vec![ - PluginPermission::surface(PluginSurface::Service), - PluginPermission::service("svc"), - ]; - record.grants.permissions = record.manifest.permissions.clone(); - let feature = PluginToolFeature::new(record); - assert!(feature.descriptor().tools.is_empty()); - assert_eq!(feature.descriptor().provides_services.len(), 1); - let (report, pending) = install_feature(feature.clone()); - assert!( - report.reports.iter().all(|report| report.installed), - "{report:#?}" - ); - assert!(pending.is_empty(), "unselected Tool must not register"); - assert_eq!(report.reports[0].provided_services.len(), 1); - assert_eq!( - feature.instance_status().unwrap().lifecycle, - PluginInstanceLifecycleState::Running - ); - } - - #[test] - fn tool_selected_ignores_unselected_service_ingress_even_with_grants() { - let mut record = record(vec![tool("visible_tool")]); - add_service(&mut record, "hidden_service"); - add_ingress(&mut record, "hidden_ingress"); - record.enabled_surfaces = vec![PluginSurface::Tool]; - let feature = PluginToolFeature::new(record); - assert!(feature.descriptor().provides_services.is_empty()); - assert_eq!(feature.descriptor().tools.len(), 1); - let (report, pending) = install_feature(feature.clone()); - assert!( - report.reports.iter().all(|report| report.installed), - "{report:#?}" - ); - assert_eq!(pending.len(), 1); - assert!(report.reports[0].provided_services.is_empty()); - let dispatch = feature.dispatch_ingress( - "hidden_ingress", - test_ingress_event("hidden_ingress", serde_json::json!({})), - ); - assert!( - dispatch - .unwrap_err() - .bounded_message() - .contains("not enabled") - ); - } - - #[test] - fn service_only_install_retains_host_managed_instance() { - let mut record = record(Vec::new()); - add_service(&mut record, "svc"); - record.manifest.runtime = Some(manifest::plugin::PluginRuntimeManifest { - kind: "test-ingress".into(), - entry: None, - abi: None, - component: None, - world: Some(PLUGIN_COMPONENT_INSTANCE_WORLD.into()), - }); - let feature = PluginToolFeature::new(record); - let (report, _pending) = install_feature(feature.clone()); - assert!( - report.reports.iter().all(|report| report.installed), - "{report:#?}" - ); - let status = feature.instance_status().expect("service instance started"); - assert_eq!(status.lifecycle, PluginInstanceLifecycleState::Running); - assert_eq!(status.queue_depth, 0); - assert_eq!(status.queue_capacity, PLUGIN_SERVICE_INGRESS_QUEUE_CAPACITY); - assert!(status.last_error.is_none()); - } - - fn test_service_ingress_record() -> ResolvedPluginRecord { - let mut record = record(Vec::new()); - add_service(&mut record, "svc"); - add_ingress(&mut record, "shared_ingress"); - record.manifest.runtime = Some(manifest::plugin::PluginRuntimeManifest { - kind: "test-ingress".into(), - entry: None, - abi: None, - component: None, - world: Some(PLUGIN_COMPONENT_INSTANCE_WORLD.into()), - }); - record - } - - #[test] - fn ingress_queue_dispatches_serially_and_reports_status() { - let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap(); - assert_eq!( - handle.status().lifecycle, - PluginInstanceLifecycleState::Running - ); - - let first = handle - .deliver_ingress( - "shared_ingress", - test_ingress_event("shared_ingress", serde_json::json!({ "seq": 1 })), - ) - .unwrap(); - let second = handle - .deliver_ingress( - "shared_ingress", - test_ingress_event("shared_ingress", serde_json::json!({ "seq": 2 })), - ) - .unwrap(); - - assert_eq!(first.output["ingress_calls"], 1); - assert_eq!(second.output["ingress_calls"], 2); - assert_eq!(second.queue_depth, 0); - assert_eq!(second.dispatch_counters.enqueued, 2); - assert_eq!(second.dispatch_counters.dispatched, 2); - let status = handle.status(); - assert_eq!(status.queue_depth, 0); - assert_eq!(status.dispatch_counters.enqueued, 2); - assert_eq!(status.dispatch_counters.dispatched, 2); - assert!(status.last_error.is_none()); - } - - #[test] - fn bounded_ingress_queue_rejects_full_queue() { - let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap(); - let mut instance = handle.0.lock().unwrap(); - instance.ingress_queue_capacity = 1; - instance - .enqueue_ingress( - "shared_ingress", - test_ingress_event("shared_ingress", serde_json::json!({ "seq": 1 })), - ) - .unwrap(); - let error = instance - .enqueue_ingress( - "shared_ingress", - test_ingress_event("shared_ingress", serde_json::json!({ "seq": 2 })), - ) - .unwrap_err(); - assert!(matches!( - error, - PluginIngressDispatchError::QueueFull { capacity: 1 } - )); - assert_eq!(instance.ingress_queue.len(), 1); - assert_eq!(instance.dispatch_counters.rejected, 1); - assert_eq!( - instance.diagnostics.last().unwrap().kind, - PluginInstanceDiagnosticKind::QueueFull - ); - } - - #[test] - fn ingress_dispatch_failure_marks_service_failed_and_rejects_later_events() { - let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap(); - let error = handle - .deliver_ingress( - "shared_ingress", - test_ingress_event("shared_ingress", serde_json::json!({ "fail": true })), - ) - .unwrap_err(); - assert!(matches!( - error, - PluginIngressDispatchError::DispatchFailed(_) - )); - let status = handle.status(); - assert_eq!(status.lifecycle, PluginInstanceLifecycleState::Failed); - assert_eq!(status.dispatch_counters.failed, 1); - assert_eq!( - status.diagnostics.last().unwrap().kind, - PluginInstanceDiagnosticKind::DispatchFailed - ); - - let retry = handle - .deliver_ingress( - "shared_ingress", - test_ingress_event("shared_ingress", serde_json::json!({ "seq": 3 })), - ) - .unwrap_err(); - assert!(matches!( - retry, - PluginIngressDispatchError::ServiceFailed(_) - )); - } - - #[test] - fn ingress_dispatch_timeout_records_typed_diagnostic() { - let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap(); - let error = handle - .deliver_ingress( - "shared_ingress", - test_ingress_event("shared_ingress", serde_json::json!({ "sleep_ms": 50 })), - ) - .unwrap_err(); - assert!(matches!( - error, - PluginIngressDispatchError::DispatchTimeout { .. } - )); - let status = handle.status(); - assert_eq!(status.lifecycle, PluginInstanceLifecycleState::Failed); - assert_eq!(status.dispatch_counters.timed_out, 1); - assert_eq!( - status.diagnostics.last().unwrap().kind, - PluginInstanceDiagnosticKind::DispatchTimeout - ); - } - - #[test] - fn stopped_service_rejects_ingress_events() { - let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap(); - handle.stop().unwrap(); - let error = handle - .deliver_ingress( - "shared_ingress", - test_ingress_event("shared_ingress", serde_json::json!({ "seq": 1 })), - ) - .unwrap_err(); - assert!(matches!( - error, - PluginIngressDispatchError::ServiceStopped { - state: PluginInstanceLifecycleState::Stopped - } - )); - } - - #[test] - fn invalid_ingress_event_is_typed_rejection() { - let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap(); - let mut event = test_ingress_event("shared_ingress", serde_json::json!({})); - event.correlation_id.clear(); - let error = handle.deliver_ingress("shared_ingress", event).unwrap_err(); - assert!(matches!(error, PluginIngressDispatchError::InvalidEvent(_))); - let status = handle.status(); - assert_eq!(status.dispatch_counters.rejected, 1); - assert_eq!( - status.diagnostics.last().unwrap().kind, - PluginInstanceDiagnosticKind::InvalidEvent - ); - } - - #[test] - fn service_output_command_records_diagnostic_status_separately_from_tool_output() { - let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap(); - let mut event = test_ingress_event("shared_ingress", json!({})); - let command = service_output_command( - &event, - "cmd-status", - "diagnostic_status_update", - json!({ - "message": "service became ready", - "status": {"ready": true} - }), - ); - event.payload = json!({"output_commands": [command]}); - - let report = handle.deliver_ingress("shared_ingress", event).unwrap(); - - assert_eq!(report.output_command_results.len(), 1); - assert_eq!( - report.output_command_results[0].kind, - Some(PluginServiceOutputCommandKind::DiagnosticStatusUpdate) - ); - assert_eq!( - report.output_command_results[0].status, - PluginServiceOutputCommandStatus::Recorded - ); - assert_eq!( - report.output["payload"]["output_commands"] - .as_array() - .unwrap() - .len(), - 1 - ); - let status = handle.status(); - assert_eq!(status.component_status, Some(json!({"ready": true}))); - assert_eq!(status.output_command_results.len(), 1); - assert!(status.diagnostics.iter().any(|diagnostic| { - diagnostic.kind == PluginInstanceDiagnosticKind::ServiceOutputCommandRecorded - && diagnostic.message.contains("cmd-status") - })); - } - - #[test] - fn service_output_command_rejects_ungranted_request_without_executing_status_update() { - let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap(); - let mut event = test_ingress_event("shared_ingress", json!({})); - let status_command = service_output_command( - &event, - "cmd-status", - "diagnostic_status_update", - json!({"status": {"should_not_record": true}}), - ); - let request_command = service_output_command( - &event, - "cmd-request", - "host_request_dispatch", - json!({ - "method": "POST", - "url": "https://api.example.test/v1/events" - }), - ); - event.payload = json!({"output_commands": [status_command, request_command]}); - - let report = handle.deliver_ingress("shared_ingress", event).unwrap(); - - assert_eq!(report.output_command_results.len(), 1); - assert_eq!( - report.output_command_results[0].status, - PluginServiceOutputCommandStatus::Rejected - ); - assert_eq!(handle.status().component_status, None); - assert!(handle.status().diagnostics.iter().any(|diagnostic| { - diagnostic.kind == PluginInstanceDiagnosticKind::ServiceOutputCommandRejected - && diagnostic.message.contains("cmd-request") - })); - } - - #[test] - fn service_output_commands_are_grant_checked_and_supported_transport_rejects_without_connection() - { - let mut record = test_service_ingress_record(); - add_request_output_grant(&mut record); - add_websocket_output_grant(&mut record); - let handle = PluginInstanceHandle::new(record).unwrap(); - let mut event = test_ingress_event("shared_ingress", json!({})); - let request_command = service_output_command( - &event, - "cmd-request", - "host_request_dispatch", - json!({ - "method": "POST", - "url": "https://api.example.test/v1/events" - }), - ); - let websocket_command = service_output_command( - &event, - "cmd-websocket", - "websocket_send", - json!({ - "url": "wss://ws.example.test/events", - "text": "hello" - }), - ); - event.payload = json!({"output_commands": [request_command, websocket_command]}); - - let report = handle.deliver_ingress("shared_ingress", event).unwrap(); - - assert_eq!(report.output_command_results.len(), 2); - let request_result = report - .output_command_results - .iter() - .find(|result| result.command_id.as_deref() == Some("cmd-request")) - .unwrap(); - let websocket_result = report - .output_command_results - .iter() - .find(|result| result.command_id.as_deref() == Some("cmd-websocket")) - .unwrap(); - assert_eq!( - request_result.status, - PluginServiceOutputCommandStatus::Unsupported - ); - assert_eq!( - websocket_result.status, - PluginServiceOutputCommandStatus::Rejected - ); - let status = handle.status(); - assert_eq!(status.output_command_results.len(), 2); - assert!(status.diagnostics.iter().any(|diagnostic| { - diagnostic.kind == PluginInstanceDiagnosticKind::ServiceOutputCommandUnsupported - && diagnostic.message.contains("cmd-request") - })); - assert!(status.diagnostics.iter().any(|diagnostic| { - diagnostic.kind == PluginInstanceDiagnosticKind::ServiceOutputCommandRejected - && diagnostic.message.contains("cmd-websocket") - })); - } - - #[test] - fn service_websocket_driver_enqueues_incoming_text_and_reports_close() { - let client = ServiceWebSocketClient::with_events(vec![ - Ok(PluginWebSocketRecvResponse::Text { - text: "hello service".into(), - }), - Ok(PluginWebSocketRecvResponse::Closed), - ]); - let handle = PluginInstanceHandle::new_with_test_websocket_client( - service_websocket_record(), - Arc::new(client.clone()), - ) - .unwrap(); - - wait_until(|| handle.status().dispatch_counters.dispatched >= 1); - let status = handle.status(); - assert!(status.dispatch_counters.dispatched >= 1); - assert_eq!(status.websocket_connections.len(), 1); - let connection = &status.websocket_connections[0]; - assert_eq!(connection.received_text_frames, 1); - assert!(connection.last_frame_at.is_some()); - assert_eq!(connection.queue_drops, 0); - assert!(status.diagnostics.iter().any(|diagnostic| { - diagnostic.kind == PluginInstanceDiagnosticKind::ServiceWebSocketClosed - })); - handle.stop().unwrap(); - } - - #[test] - fn websocket_send_output_command_sends_on_host_owned_connection() { - let client = ServiceWebSocketClient::default(); - let handle = PluginInstanceHandle::new_with_test_websocket_client( - service_websocket_record(), - Arc::new(client.clone()), - ) - .unwrap(); - wait_until(|| { - handle - .status() - .websocket_connections - .iter() - .any(|connection| { - connection.state == PluginServiceWebSocketConnectionState::Connected - }) - }); - let event = test_ingress_event("shared_ingress", json!({})); - let command_value = service_output_command( - &event, - "cmd-websocket", - "websocket_send", - json!({"url": "wss://ws.example.test/events", "text": "pong"}), - ); - let command: PluginServiceOutputCommandEnvelope = - serde_json::from_value(command_value).unwrap(); - - let result = handle - .0 - .lock() - .unwrap() - .execute_service_output_command(command); - - assert_eq!(result.status, PluginServiceOutputCommandStatus::Recorded); - assert_eq!(client.sent(), vec!["pong".to_string()]); - let status = handle.status(); - assert_eq!(status.websocket_connections[0].sent_text_frames, 1); - handle.stop().unwrap(); - } - - #[test] - fn websocket_send_output_command_records_send_failure_diagnostic() { - let client = ServiceWebSocketClient::default(); - client.fail_sends_with("transport write failed"); - let handle = PluginInstanceHandle::new_with_test_websocket_client( - service_websocket_record(), - Arc::new(client), - ) - .unwrap(); - wait_until(|| { - handle - .status() - .websocket_connections - .iter() - .any(|connection| { - connection.state == PluginServiceWebSocketConnectionState::Connected - }) - }); - let event = test_ingress_event("shared_ingress", json!({})); - let command: PluginServiceOutputCommandEnvelope = - serde_json::from_value(service_output_command( - &event, - "cmd-websocket-fail", - "websocket_send", - json!({"url": "wss://ws.example.test/events", "text": "pong"}), - )) - .unwrap(); - - let result = handle - .0 - .lock() - .unwrap() - .execute_service_output_command(command); - - assert_eq!(result.status, PluginServiceOutputCommandStatus::Rejected); - let status = handle.status(); - assert_eq!(status.websocket_connections[0].send_failures, 1); - assert!(status.diagnostics.iter().any(|diagnostic| { - diagnostic.kind == PluginInstanceDiagnosticKind::ServiceWebSocketSendFailed - && diagnostic.message.contains("transport write failed") - })); - handle.stop().unwrap(); - } - - #[test] - fn websocket_send_rejects_unauthorized_target_before_execution() { - let mut record = service_websocket_record(); - let handle = PluginInstanceHandle::new_with_test_websocket_client( - record.clone(), - Arc::new(ServiceWebSocketClient::default()), - ) - .unwrap(); - let event = test_ingress_event("shared_ingress", json!({})); - let command: PluginServiceOutputCommandEnvelope = - serde_json::from_value(service_output_command( - &event, - "cmd-websocket-denied", - "websocket_send", - json!({"url": "wss://ws.example.test/private", "text": "nope"}), - )) - .unwrap(); - - let error = handle - .0 - .lock() - .unwrap() - .validate_service_output_command_envelope(&command, &event) - .unwrap_err(); - - assert!( - error.contains("websocket_send target denied") - || error.contains("not declared by the plugin manifest"), - "{error}" - ); - assert_eq!(handle.status().websocket_connections[0].sent_text_frames, 0); - record.grants.permissions.retain(|permission| { - *permission != PluginPermission::host_api(PluginHostApi::WebSocket) - }); - let denied_handle = PluginInstanceHandle::new_with_test_websocket_client( - record, - Arc::new(ServiceWebSocketClient::default()), - ) - .unwrap(); - wait_until(|| !denied_handle.status().diagnostics.is_empty()); - assert!(denied_handle.status().diagnostics.iter().any(|diagnostic| { - diagnostic.kind == PluginInstanceDiagnosticKind::ServiceWebSocketError - && diagnostic.message.contains("not granted") - })); - handle.stop().unwrap(); - denied_handle.stop().unwrap(); - } - - #[test] - fn service_websocket_driver_reports_receive_error_as_diagnostic() { - let client = ServiceWebSocketClient::with_events(vec![Err( - "binary frames are not supported by host-owned service websocket".into(), - )]); - let handle = PluginInstanceHandle::new_with_test_websocket_client( - service_websocket_record(), - Arc::new(client), - ) - .unwrap(); - - wait_until(|| { - handle.status().websocket_connections[0].state - == PluginServiceWebSocketConnectionState::Failed - }); - let status = handle.status(); - assert!(status.diagnostics.iter().any(|diagnostic| { - diagnostic.kind == PluginInstanceDiagnosticKind::ServiceWebSocketError - && diagnostic.message.contains("binary frames") - })); - assert!( - status.websocket_connections[0] - .last_error - .as_deref() - .unwrap() - .contains("binary frames") - ); - handle.stop().unwrap(); - } - - #[test] - fn service_output_command_rejects_malformed_envelope() { - let handle = PluginInstanceHandle::new(test_service_ingress_record()).unwrap(); - let mut event = test_ingress_event("shared_ingress", json!({})); - event.payload = json!({"output_commands": [{"kind": "diagnostic_status_update"}]}); - - let report = handle.deliver_ingress("shared_ingress", event).unwrap(); - - assert_eq!(report.output_command_results.len(), 1); - assert_eq!( - report.output_command_results[0].status, - PluginServiceOutputCommandStatus::Rejected - ); - assert!( - report.output_command_results[0] - .message - .contains("malformed service output command envelope") - ); - } - - #[test] - fn installed_ingress_dispatch_uses_retained_shared_instance() { - let mut record = record(vec![tool("shared_tool")]); - add_ingress(&mut record, "shared_ingress"); - record.manifest.runtime = Some(manifest::plugin::PluginRuntimeManifest { - kind: "test-ingress".into(), - entry: None, - abi: None, - component: None, - world: Some(PLUGIN_COMPONENT_INSTANCE_WORLD.into()), - }); - let feature = PluginToolFeature::new(record); - let (report, pending) = install_feature(feature.clone()); - assert!( - report.reports.iter().all(|report| report.installed), - "{report:#?}" - ); - let (_meta, tool) = pending - .into_iter() - .map(|definition| definition()) - .find(|(meta, _tool)| meta.name == "shared_tool") - .unwrap(); - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_time() - .build() - .unwrap(); - let output = runtime - .block_on(tool.execute(r#"{"first":true}"#, ToolExecutionContext::default())) - .unwrap(); - assert!(output.summary.contains("shared_tool")); - let report = feature - .dispatch_ingress( - "shared_ingress", - test_ingress_event("shared_ingress", serde_json::json!({ "hello": "world" })), - ) - .unwrap(); - assert!(report.accepted); - assert_eq!(report.output["calls"], 1); - } - - #[test] - fn instance_ingress_dispatch_uses_shared_in_process_instance() { - let mut record = record(vec![tool("shared_tool")]); - record.manifest.surfaces.push(PluginSurface::Ingress); - record.enabled_surfaces.push(PluginSurface::Ingress); - record - .manifest - .ingresses - .push(manifest::plugin::PluginIngressManifest { - name: "shared_ingress".into(), - description: "test ingress".into(), - event_kinds: vec!["test".into()], - input_schema: None, - sources: Vec::new(), - side_effects: Vec::new(), - }); - record - .manifest - .permissions - .push(PluginPermission::surface(PluginSurface::Ingress)); - record - .manifest - .permissions - .push(PluginPermission::ingress("shared_ingress")); - record - .grants - .permissions - .push(PluginPermission::surface(PluginSurface::Ingress)); - record - .grants - .permissions - .push(PluginPermission::ingress("shared_ingress")); - let handle = PluginInstanceHandle(Arc::new(Mutex::new(PluginInstance { - record, - runtime: PluginInstanceRuntime::TestIngress { - tool_calls: 0, - ingress_calls: 0, - }, - lifecycle: PluginInstanceLifecycleState::Running, - component_status: None, - ingress_queue: VecDeque::new(), - ingress_queue_capacity: PLUGIN_SERVICE_INGRESS_QUEUE_CAPACITY, - dispatch_counters: PluginIngressDispatchCounters::default(), - last_error: None, - output_command_results: Vec::new(), - service_websockets: PluginServiceWebSocketDriver::default(), - service_websocket_client: Arc::new(TungstenitePluginWebSocketClient), - diagnostics: Vec::new(), - }))); - - let _tool = handle - .handle_tool("shared_tool", br#"{"first":true}"#.to_vec()) - .unwrap(); - let report = handle - .deliver_ingress( - "shared_ingress", - test_ingress_event("shared_ingress", serde_json::json!({ "hello": "world" })), - ) - .unwrap(); - assert!(report.accepted); - assert_eq!(report.output["calls"], 1); - assert_eq!(report.output["ingress"], "shared_ingress"); - } - - fn skipped_count(report: &super::super::FeatureRegistryInstallReport) -> usize { - report - .reports - .iter() - .map(|feature_report| feature_report.skipped.len()) - .sum() - } - - fn has_diagnostic(report: &super::super::FeatureRegistryInstallReport, needle: &str) -> bool { - report.reports.iter().any(|feature_report| { - feature_report - .diagnostics - .iter() - .any(|diagnostic| diagnostic.message.contains(needle)) - }) - } - - fn install_plugin_record( - record: ResolvedPluginRecord, - ) -> ( - super::super::FeatureRegistryInstallReport, - Vec, - ) { - let mut pending = Vec::new(); - let mut hooks = crate::hook::HookRegistryBuilder::new(); - let report = super::super::FeatureRegistryBuilder::default() - .with_module(PluginToolFeature::new(record)) - .install_into_pending(&mut pending, &mut hooks); - (report, pending) - } - - fn record_with_request_grant() -> ResolvedPluginRecord { - let mut record = record(vec![tool("RequestTool")]); - let request_permission = PluginPermission::HostApi { - api: PluginHostApi::Request, - }; - record.manifest.permissions.push(request_permission.clone()); - record.manifest.request.push(PluginRequestGrant { - scheme: "https".to_string(), - host: "api.example.test".to_string(), - port: None, - methods: vec!["GET".to_string(), "POST".to_string()], - path_prefixes: vec!["/v1".to_string()], - }); - record.grants.permissions.push(request_permission); - record.grants.request.push(PluginRequestGrant { - scheme: "https".to_string(), - host: "api.example.test".to_string(), - port: None, - methods: vec!["GET".to_string(), "POST".to_string()], - path_prefixes: vec!["/v1".to_string()], - }); - record - } - - struct MockRequestClient { - calls: Mutex, - response_body: String, - error: Mutex>, - } - - impl Default for MockRequestClient { - fn default() -> Self { - Self { - calls: Mutex::new(0), - response_body: "ok".to_string(), - error: Mutex::new(None), - } - } - } - - impl MockRequestClient { - fn call_count(&self) -> usize { - *self.calls.lock().expect("mock call lock") - } - } - - impl PluginRequestClient for MockRequestClient { - fn execute( - &self, - _request: &PluginRequestRequest, - _url: &reqwest::Url, - _limits: PluginRequestLimits, - ) -> Result { - *self.calls.lock().expect("mock call lock") += 1; - if let Some(error) = self.error.lock().expect("mock error lock").take() { - return Err(PluginRequestError::new(error)); - } - Ok(PluginRequestResponse { - status: 200, - headers: vec![PluginRequestHeader { - name: "content-type".to_string(), - value: "text/plain".to_string(), - }], - body: self.response_body.clone(), - truncated: false, - }) - } - } - - struct FakeRequestResolver { - calls: Mutex>, - addrs: Vec, - } - - impl FakeRequestResolver { - fn new(addrs: Vec) -> Self { - Self { - calls: Mutex::new(Vec::new()), - addrs, - } - } - - fn calls(&self) -> Vec<(String, u16)> { - self.calls.lock().expect("resolver calls lock").clone() - } - } - - impl PluginRequestResolver for FakeRequestResolver { - fn resolve(&self, host: &str, port: u16) -> Result, PluginRequestError> { - self.calls - .lock() - .expect("resolver calls lock") - .push((host.to_string(), port)); - Ok(self.addrs.clone()) - } - } - - fn request_request_json(method: &str, url: &str) -> String { - json!({ "method": method, "url": url }).to_string() - } - - fn fs_request_json(path: &str) -> String { - json!({ "path": path }).to_string() - } - - fn fs_write_request_json(path: &str, content: &str) -> String { - json!({ "path": path, "content": content }).to_string() - } - - fn record_with_fs_grant( - root: &Path, - operations: Vec, - ) -> ResolvedPluginRecord { - let mut record = record(vec![tool("FsTool")]); - let fs_permission = PluginPermission::HostApi { - api: PluginHostApi::Fs, - }; - record.manifest.permissions.push(fs_permission.clone()); - record.grants.permissions.push(fs_permission); - record.grants.fs.push(PluginFsGrant { - root: root.to_string_lossy().into_owned(), - operations, - }); - record - } - - #[test] - fn rejects_invalid_root_schema() { - let schema = json!({"type":"string"}); - assert!( - validate_input_schema(&schema) - .unwrap_err() - .contains("type must be `object`") - ); - } - - #[test] - fn rejects_unsupported_schema_keyword() { - let schema = json!({"type":"object","oneOf":[]}); - assert!( - validate_input_schema(&schema) - .unwrap_err() - .contains("unsupported schema keyword") - ); - } - - #[test] - fn rejects_invalid_nested_property_schema_node() { - let schema = json!({ - "type":"object", - "properties":{"query":"not-a-schema"}, - "required":["query"], - "additionalProperties":false - }); - let error = validate_input_schema(&schema).unwrap_err(); - assert!(error.contains("$.properties.query")); - assert!(error.contains("schema node must be a JSON object")); - } - - #[test] - fn rejects_invalid_recursive_schema_members() { - let duplicate_required = json!({ - "type":"object", - "properties":{"query":{"type":"string"}}, - "required":["query", "query"] - }); - assert!( - validate_input_schema(&duplicate_required) - .unwrap_err() - .contains("required entries must be unique") - ); - - let invalid_items = json!({ - "type":"object", - "properties":{"values":{"type":"array", "items":"not-a-schema"}} - }); - assert!( - validate_input_schema(&invalid_items) - .unwrap_err() - .contains("$.properties.values.items") - ); - - let invalid_additional = json!({ - "type":"object", - "additionalProperties":{"type":"unsupported"} - }); - assert!( - validate_input_schema(&invalid_additional) - .unwrap_err() - .contains("unsupported schema type") - ); - } - - #[test] - fn accepts_object_tool_schema() { - validate_input_schema(&json!({ - "type":"object", - "properties":{ - "query":{"type":"string", "description":"Search text"}, - "limit":{"type":"integer", "enum":[1, 5, 10]}, - "tags":{"type":"array", "items":{"type":"string"}} - }, - "required":["query"], - "additionalProperties":{"type":"string"} - })) - .unwrap(); - } - - #[test] - fn granted_fs_read_list_and_write_are_scoped() { - let root = TempDir::new().expect("temp root"); - fs::write(root.path().join("read.txt"), "hello fs").expect("write fixture"); - fs::create_dir(root.path().join("dir")).expect("create dir"); - fs::write(root.path().join("dir").join("entry.txt"), "entry").expect("write entry"); - let record = record_with_fs_grant( - root.path(), - vec![ - PluginFsOperation::Read, - PluginFsOperation::List, - PluginFsOperation::Write, - ], - ); - - let read = execute_plugin_fs_request( - &record, - PluginFsRuntimeOperation::Read, - fs_request_json("read.txt").as_bytes(), - ) - .expect("read allowed"); - let read: serde_json::Value = serde_json::from_slice(&read).expect("read response json"); - assert_eq!(read["content"], "hello fs"); - assert_eq!(read["truncated"], false); - - let list = execute_plugin_fs_request( - &record, - PluginFsRuntimeOperation::List, - fs_request_json("dir").as_bytes(), - ) - .expect("list allowed"); - let list: serde_json::Value = serde_json::from_slice(&list).expect("list response json"); - assert_eq!(list["entries"][0]["name"], "entry.txt"); - assert_eq!(list["entries"][0]["kind"], "file"); - - let write = execute_plugin_fs_request( - &record, - PluginFsRuntimeOperation::Write, - fs_write_request_json("written.txt", "new content").as_bytes(), - ) - .expect("write allowed"); - let write: serde_json::Value = serde_json::from_slice(&write).expect("write response json"); - assert_eq!(write["bytes_written"], "new content".len()); - assert_eq!( - fs::read_to_string(root.path().join("written.txt")).expect("read written"), - "new content" - ); - } - - #[test] - fn missing_fs_grant_denies_even_when_manifest_requests_api() { - let root = TempDir::new().expect("temp root"); - fs::write(root.path().join("secret.txt"), "must not leak").expect("write fixture"); - let mut record = record(vec![tool("FsTool")]); - record.manifest.permissions.push(PluginPermission::HostApi { - api: PluginHostApi::Fs, - }); - let error = execute_plugin_fs_request( - &record, - PluginFsRuntimeOperation::Read, - fs_request_json("secret.txt").as_bytes(), - ) - .expect_err("grant denied"); - assert!(error.message.contains("host_api.fs")); - assert!(!error.message.contains("must not leak")); - } - - #[test] - fn workspace_scope_is_not_inherited_without_plugin_fs_grant() { - let root = TempDir::new().expect("temp root"); - fs::write(root.path().join("workspace.txt"), "workspace authority").expect("write fixture"); - let record = record(vec![tool("FsTool")]); - let error = execute_plugin_fs_request( - &record, - PluginFsRuntimeOperation::Read, - fs_request_json("workspace.txt").as_bytes(), - ) - .expect_err("plugin grant required"); - assert!(error.message.contains("host_api.fs")); - assert!(!error.message.contains("workspace authority")); - } - - #[test] - fn fs_traversal_and_absolute_paths_are_rejected() { - let root = TempDir::new().expect("temp root"); - let record = record_with_fs_grant(root.path(), vec![PluginFsOperation::Read]); - for path in ["../outside.txt", "/etc/passwd"] { - let error = execute_plugin_fs_request( - &record, - PluginFsRuntimeOperation::Read, - fs_request_json(path).as_bytes(), - ) - .expect_err("path denied"); - assert!( - error.message.contains("relative") || error.message.contains("traversal"), - "unexpected error: {}", - error.message - ); - } - } - - #[cfg(unix)] - #[test] - fn fs_symlink_escape_is_rejected() { - let root = TempDir::new().expect("temp root"); - let outside = TempDir::new().expect("outside root"); - fs::write(outside.path().join("outside.txt"), "outside secret").expect("write outside"); - std::os::unix::fs::symlink(outside.path(), root.path().join("link")) - .expect("create symlink"); - let record = record_with_fs_grant(root.path(), vec![PluginFsOperation::Read]); - let error = execute_plugin_fs_request( - &record, - PluginFsRuntimeOperation::Read, - fs_request_json("link/outside.txt").as_bytes(), - ) - .expect_err("symlink denied"); - assert!(error.message.contains("symlink")); - assert!(!error.message.contains("outside secret")); - } - - #[test] - fn fs_read_write_and_list_bounds_are_enforced() { - let root = TempDir::new().expect("temp root"); - fs::write( - root.path().join("big.txt"), - vec![b'a'; PLUGIN_FS_MAX_READ_BYTES + 1], - ) - .expect("write big file"); - let list_dir = root.path().join("many"); - fs::create_dir(&list_dir).expect("create list dir"); - for index in 0..=PLUGIN_FS_MAX_LIST_ENTRIES { - fs::write(list_dir.join(format!("entry-{index:03}.txt")), "x") - .expect("write list entry"); - } - let record = record_with_fs_grant( - root.path(), - vec![ - PluginFsOperation::Read, - PluginFsOperation::List, - PluginFsOperation::Write, - ], - ); - let read = execute_plugin_fs_request( - &record, - PluginFsRuntimeOperation::Read, - fs_request_json("big.txt").as_bytes(), - ) - .expect("bounded read allowed"); - let read: serde_json::Value = serde_json::from_slice(&read).expect("read response json"); - assert_eq!(read["truncated"], true); - assert_eq!( - read["content"].as_str().expect("content").len(), - PLUGIN_FS_MAX_READ_BYTES - ); - - let list = execute_plugin_fs_request( - &record, - PluginFsRuntimeOperation::List, - fs_request_json("many").as_bytes(), - ) - .expect("bounded list allowed"); - let list: serde_json::Value = serde_json::from_slice(&list).expect("list response json"); - assert_eq!(list["truncated"], true); - assert_eq!( - list["entries"].as_array().expect("entries").len(), - PLUGIN_FS_MAX_LIST_ENTRIES - ); - - let too_large = "x".repeat(PLUGIN_FS_MAX_WRITE_BYTES + 1); - let error = execute_plugin_fs_request( - &record, - PluginFsRuntimeOperation::Write, - fs_write_request_json("too-large.txt", &too_large).as_bytes(), - ) - .expect_err("oversize write denied"); - assert!(error.message.contains("exceeds")); - assert!(!root.path().join("too-large.txt").exists()); - } - - #[test] - fn fs_diagnostics_redact_secret_like_path_segments() { - let root = TempDir::new().expect("temp root"); - let record = record_with_fs_grant(root.path(), vec![PluginFsOperation::Read]); - let error = execute_plugin_fs_request( - &record, - PluginFsRuntimeOperation::Read, - fs_request_json("secret=my-token-value.txt").as_bytes(), - ) - .expect_err("missing file"); - assert!(error.message.contains("secret")); - assert!(error.message.contains("")); - assert!(!error.message.contains("my-token-value")); - } - - #[test] - fn fs_writes_serialize_to_normalized_target() { - let root = TempDir::new().expect("temp root"); - let record = record_with_fs_grant(root.path(), vec![PluginFsOperation::Write]); - execute_plugin_fs_request( - &record, - PluginFsRuntimeOperation::Write, - fs_write_request_json("target.txt", "first").as_bytes(), - ) - .expect("first write"); - execute_plugin_fs_request( - &record, - PluginFsRuntimeOperation::Write, - fs_write_request_json("./target.txt", "second").as_bytes(), - ) - .expect("second write"); - assert_eq!( - fs::read_to_string(root.path().join("target.txt")).expect("read target"), - "second" - ); - } - - #[test] - fn origin_retains_plugin_metadata() { - let feature = PluginToolFeature::new(record(Vec::new())); - let origin = feature.origin(); - assert_eq!(origin.kind, "plugin"); - assert_eq!(origin.plugin_id, "example"); - assert_eq!(origin.plugin_ref, "project:example"); - assert_eq!(origin.source, "project"); - assert_eq!(origin.digest, "sha256:abc"); - assert_eq!(origin.package_version, "0.1.0"); - assert_eq!(origin.package_api_version, 1); - assert_eq!(origin.surface, "tool"); - } - - #[test] - fn disallowed_request_targets_deny_before_network() { - let record = record_with_request_grant(); - let client = MockRequestClient::default(); - for (method, url, needle) in [ - ("GET", "http://api.example.test/v1/data", "manifest"), - ("TRACE", "https://api.example.test/v1/data", "method"), - ("GET", "https://other.example.test/v1/data", "manifest"), - ("GET", "http://localhost/v1/data", "manifest"), - ("GET", "http://127.0.0.1/v1/data", "manifest"), - ("GET", "file:///tmp/secret", "scheme"), - ("GET", "ws://api.example.test/v1/data", "WebSocket"), - ] { - let error = execute_plugin_request_request( - &record, - &client, - request_request_json(method, url).as_bytes(), - ) - .expect_err("request denied"); - assert!( - error.0.contains(needle), - "{method} {url} produced {:?}, expected {needle}", - error.0 - ); - } - assert_eq!(client.call_count(), 0); - } - - #[test] - fn loopback_request_requires_explicit_manifest_and_grant() { - let mut record = record_with_request_grant(); - record.manifest.request.push(PluginRequestGrant { - scheme: "http".to_string(), - host: "localhost".to_string(), - port: Some(8080), - methods: vec!["GET".to_string()], - path_prefixes: vec!["/health".to_string()], - }); - let client = MockRequestClient::default(); - let denied = execute_plugin_request_request( - &record, - &client, - request_request_json("GET", "http://localhost:8080/health").as_bytes(), - ) - .expect_err("manifest-only local target denied"); - assert!(denied.0.contains("grants"), "{}", denied.0); - record.grants.request.push(PluginRequestGrant { - scheme: "http".to_string(), - host: "localhost".to_string(), - port: Some(8080), - methods: vec!["GET".to_string()], - path_prefixes: vec!["/health".to_string()], - }); - execute_plugin_request_request( - &record, - &client, - request_request_json("GET", "http://localhost:8080/health").as_bytes(), - ) - .expect("explicit loopback target allowed"); - assert_eq!(client.call_count(), 1); - } - - #[test] - fn upgrade_and_sse_headers_are_rejected() { - let record = record_with_request_grant(); - let client = MockRequestClient::default(); - for headers in [ - r#"[{"name":"Upgrade","value":"websocket"}]"#, - r#"[{"name":"Connection","value":"keep-alive, Upgrade"}]"#, - r#"[{"name":"Accept","value":"text/event-stream"}]"#, - ] { - let body = format!( - r#"{{"method":"GET","url":"https://api.example.test/v1/data","headers":{headers}}}"# - ); - let error = execute_plugin_request_request(&record, &client, body.as_bytes()) - .expect_err("persistent request header denied"); - assert!( - error.0.contains("not supported") || error.0.contains("upgrade"), - "{}", - error.0 - ); - } - assert_eq!(client.call_count(), 0); - } - - #[test] - fn dns_resolution_is_pinned_to_validated_public_socket_addresses() { - let url = reqwest::Url::parse("https://api.example.test:8443/v1/data").unwrap(); - let resolver = FakeRequestResolver::new(vec!["93.184.216.34:8443".parse().unwrap()]); - let pinned = resolve_request_target_for_client(&url, &resolver) - .expect("resolution") - .expect("hostname resolution is pinned"); - assert_eq!( - resolver.calls(), - vec![("api.example.test".to_string(), 8443)] - ); - assert_eq!(pinned.domains, vec!["api.example.test".to_string()]); - assert_eq!(pinned.addrs, vec!["93.184.216.34:8443".parse().unwrap()]); - - let mut builder = reqwest::blocking::Client::builder().no_proxy(); - for domain in &pinned.domains { - builder = builder.resolve_to_addrs(domain, &pinned.addrs); - } - builder.build().expect("client accepts pinned resolver"); - } - - #[test] - fn dns_resolution_can_pin_explicit_private_addresses() { - let url = reqwest::Url::parse("https://api.example.test/v1/data").unwrap(); - let resolver = FakeRequestResolver::new(vec!["127.0.0.1:443".parse().unwrap()]); - let pinned = resolve_request_target_for_client(&url, &resolver) - .expect("explicit request grants, not ambient network policy, govern local targets") - .expect("hostname resolution is pinned"); - assert_eq!(pinned.addrs, vec!["127.0.0.1:443".parse().unwrap()]); - } - - #[test] - fn timeout_and_secret_diagnostics_are_bounded_and_redacted() { - let record = record_with_request_grant(); - let client = MockRequestClient::default(); - *client.error.lock().expect("mock error lock") = - Some("timeout while using Authorization: Bearer SUPER_SECRET_TOKEN".to_string()); - let error = execute_plugin_request_request( - &record, - &client, - request_request_json("GET", "https://api.example.test/v1/data").as_bytes(), - ) - .expect_err("timeout error"); - assert!(error.0.contains("timeout")); - assert!(error.0.contains(PLUGIN_REQUEST_REDACTION)); - assert!(!error.0.contains("SUPER_SECRET_TOKEN")); - assert!(error.0.len() <= 513); - assert_eq!(client.call_count(), 1); - } - - #[test] - fn response_size_bound_truncates() { - let record = record_with_request_grant(); - let client = MockRequestClient { - calls: Mutex::new(0), - response_body: "x".repeat(PLUGIN_REQUEST_MAX_RESPONSE_BYTES + 8), - error: Mutex::new(None), - }; - let response = execute_plugin_request_request( - &record, - &client, - request_request_json("GET", "https://api.example.test/v1/data").as_bytes(), - ) - .expect("response"); - let value: Value = serde_json::from_slice(&response).expect("response json"); - assert_eq!(value["truncated"], true); - assert_eq!( - value["body"].as_str().expect("body").len(), - PLUGIN_REQUEST_MAX_RESPONSE_BYTES - ); - } - - #[test] - fn enabled_plugin_tool_registers_model_visible_schema_and_origin() { - let mut pending = Vec::new(); - let mut hooks = crate::hook::HookRegistryBuilder::new(); - let report = super::super::FeatureRegistryBuilder::default() - .with_module(PluginToolFeature::new(record(vec![tool("PluginSearch")]))) - .install_into_pending(&mut pending, &mut hooks); - - assert!( - report - .reports - .iter() - .all(|feature_report| feature_report.diagnostics.is_empty()), - "{:#?}", - report.reports - ); - assert_eq!(report.installed_tool_names(), vec!["PluginSearch"]); - assert_eq!(pending.len(), 1); - let (meta, _) = pending[0](); - assert_eq!(meta.name, "PluginSearch"); - assert_eq!(meta.input_schema["type"], "object"); - let origin = meta.origin.expect("plugin origin metadata"); - assert_eq!(origin.plugin_ref, "project:example"); - assert_eq!(origin.digest, "sha256:abc"); - assert_eq!(origin.source, "project"); - assert_eq!(origin.surface, "tool"); - } - - #[test] - fn no_grant_denies_plugin_tool_registration_and_runtime_execution() { - let mut record = record(vec![tool("PluginSearch")]); - record.grants = PluginGrantConfig::default(); - - let (report, pending) = install_plugin_record(record.clone()); - assert!(pending.is_empty()); - assert!(has_diagnostic(&report, "registration denied")); - assert!(has_diagnostic( - &report, - "granted surfaces.tool permission is missing" - )); - - let (_dir, mut runtime_record) = resolved_record_with_component( - component_tool_that_returns(br#"{"summary":"should not run"}"#), - ); - runtime_record.grants = PluginGrantConfig::default(); - let error = - run_plugin_component_tool(runtime_record, "PluginEcho".into(), br#"{}"#.to_vec()) - .unwrap_err() - .bounded_message(); - assert!(error.contains("plugin permission denied"), "{error}"); - assert!( - error.contains("granted surfaces.tool permission is missing"), - "{error}" - ); - assert!(error.len() < 700, "{error}"); - } - - #[test] - fn specific_tool_grant_registers_only_intended_plugin_tool() { - let mut record = record(vec![tool("PluginAllowed"), tool("PluginDenied")]); - record.grants.permissions = vec![ - PluginPermission::surface(PluginSurface::Tool), - PluginPermission::tool("PluginAllowed"), - ]; - - let (report, pending) = install_plugin_record(record); - assert_eq!(pending.len(), 1); - let (meta, _) = pending[0](); - assert_eq!(meta.name, "PluginAllowed"); - assert_eq!(report.installed_tool_names(), vec!["PluginAllowed"]); - assert!(has_diagnostic( - &report, - "granted tool permission for `PluginDenied` is missing" - )); - } - - #[test] - fn grant_binding_mismatches_do_not_authorize_plugin_tool() { - let mut unrelated = record(vec![tool("PluginSearch")]); - unrelated.grants.id = Some("project:other".to_string()); - let error = authorize_plugin_tool(&unrelated, &unrelated.manifest.tools[0]) - .unwrap_err() - .bounded_message(); - assert!( - error.contains("package id binding does not match"), - "{error}" - ); - - let mut bad_digest = record(vec![tool("PluginSearch")]); - bad_digest.grants.digest = Some("sha256:not-the-package".to_string()); - let error = authorize_plugin_tool(&bad_digest, &bad_digest.manifest.tools[0]) - .unwrap_err() - .bounded_message(); - assert!(error.contains("digest binding does not match"), "{error}"); - - let mut bad_version = record(vec![tool("PluginSearch")]); - bad_version.grants.version = Some(PluginExactVersion("9.9.9".to_string())); - let error = authorize_plugin_tool(&bad_version, &bad_version.manifest.tools[0]) - .unwrap_err() - .bounded_message(); - assert!(error.contains("version binding does not match"), "{error}"); - } - - #[test] - fn requested_surface_tool_and_external_write_permissions_are_required() { - let mut missing_surface = record(vec![tool("PluginSearch")]); - missing_surface.manifest.permissions = vec![PluginPermission::tool("PluginSearch")]; - let (report, pending) = install_plugin_record(missing_surface); - assert!(pending.is_empty()); - assert!(has_diagnostic( - &report, - "requested surfaces.tool permission is missing" - )); - - let mut missing_tool = record(vec![tool("PluginSearch")]); - missing_tool.manifest.permissions = vec![PluginPermission::surface(PluginSurface::Tool)]; - let (report, pending) = install_plugin_record(missing_tool); - assert!(pending.is_empty()); - assert!(has_diagnostic( - &report, - "requested tool permission for `PluginSearch` is missing" - )); - - let mut external_tool = tool("PluginWrite"); - external_tool.external_write = true; - let mut missing_external_request = record(vec![external_tool]); - let (report, pending) = install_plugin_record(missing_external_request.clone()); - assert!(pending.is_empty()); - assert!(has_diagnostic( - &report, - "requested external_write permission is missing" - )); - - missing_external_request - .manifest - .permissions - .push(PluginPermission::ExternalWrite); - let (report, pending) = install_plugin_record(missing_external_request); - assert!(pending.is_empty()); - assert!(has_diagnostic( - &report, - "granted external_write permission is missing" - )); - } - - #[test] - fn component_host_api_imports_are_permission_checked_by_manifest_and_grants() { - let (_dir, mut record) = resolved_record_with_component(component_tool_importing_request( - br#"{"summary":"should not run"}"#, - )); - record.manifest.permissions.retain(|permission| { - !matches!( - permission, - PluginPermission::HostApi { - api: PluginHostApi::Request - } - ) - }); - record.manifest.request.clear(); - record.grants.permissions.retain(|permission| { - !matches!( - permission, - PluginPermission::HostApi { - api: PluginHostApi::Request - } - ) - }); - record.grants.request.clear(); - - let error = - run_plugin_component_tool(record.clone(), "PluginEcho".into(), br#"{}"#.to_vec()) - .unwrap_err() - .bounded_message(); - assert!( - error.contains("requested host_api.request permission is missing"), - "{error}" - ); - - record - .manifest - .permissions - .push(PluginPermission::host_api(PluginHostApi::Request)); - record - .grants - .permissions - .push(PluginPermission::host_api(PluginHostApi::Request)); - let error = - run_plugin_component_tool(record.clone(), "PluginEcho".into(), br#"{}"#.to_vec()) - .unwrap_err() - .bounded_message(); - assert!( - error.contains("manifest host_api.request target declaration is missing"), - "{error}" - ); - - record.manifest.request.push(PluginRequestGrant { - scheme: "https".to_string(), - host: "api.example.test".to_string(), - port: None, - methods: vec!["GET".to_string()], - path_prefixes: vec!["/".to_string()], - }); - let error = run_plugin_component_tool(record, "PluginEcho".into(), br#"{}"#.to_vec()) - .unwrap_err() - .bounded_message(); - assert!( - error.contains("enabled host_api.request grants are missing"), - "{error}" - ); - } - - #[test] - fn package_without_enabled_tool_surface_registers_no_schema() { - let mut config = PluginConfig::default(); - let mut disabled = record(vec![tool("PluginSearch")]); - disabled.enabled_surfaces.clear(); - config.resolved.push(disabled); - - assert!(plugin_tool_features(&config).is_empty()); - } - - #[test] - fn disabled_profile_feature_registers_no_schema() { - let mut config = PluginConfig::default(); - config.resolved.push(record(vec![tool("PluginSearch")])); - - assert!(plugin_tool_features_if_enabled(false, &config).is_empty()); - assert_eq!(plugin_tool_features_if_enabled(true, &config).len(), 1); - } - - #[test] - fn duplicate_plugin_tool_names_are_rejected_with_diagnostic() { - let mut pending = Vec::new(); - let mut hooks = crate::hook::HookRegistryBuilder::new(); - let report = super::super::FeatureRegistryBuilder::default() - .with_module(PluginToolFeature::new(record(vec![tool("PluginSearch")]))) - .with_module(PluginToolFeature::new(record_with_identity( - "project:other", - vec![tool("PluginSearch")], - ))) - .install_into_pending(&mut pending, &mut hooks); - - assert!(pending.is_empty()); - assert_eq!(skipped_count(&report), 1); - assert!(has_diagnostic(&report, "duplicate tool contribution")); - } - - #[test] - fn builtin_tool_name_collision_is_rejected_with_diagnostic() { - let mut pending = Vec::new(); - let mut hooks = crate::hook::HookRegistryBuilder::new(); - let mut registered = std::collections::HashMap::new(); - registered.insert("Read".to_string(), FeatureId::builtin("preexisting-tool")); - - let report = super::super::FeatureRegistryBuilder::default() - .with_module(PluginToolFeature::new(record(vec![tool("Read")]))) - .install_into_pending_with_registered(&mut pending, &mut hooks, registered); - - assert!(pending.is_empty()); - assert_eq!(skipped_count(&report), 1); - assert!(has_diagnostic(&report, "duplicate tool contribution")); - } - - #[test] - fn invalid_input_schema_is_rejected_with_diagnostic() { - let mut invalid = tool("BadSchema"); - invalid.input_schema = json!({"type":"object","$ref":"#/defs/input"}); - let mut pending = Vec::new(); - let mut hooks = crate::hook::HookRegistryBuilder::new(); - - let report = super::super::FeatureRegistryBuilder::default() - .with_module(PluginToolFeature::new(record(vec![invalid]))) - .install_into_pending(&mut pending, &mut hooks); - - assert!(pending.is_empty()); - assert!(has_diagnostic(&report, "invalid input_schema")); - } - - #[test] - fn nested_invalid_input_schema_does_not_register_plugin_tool() { - let mut invalid = tool("BadNestedSchema"); - invalid.input_schema = json!({ - "type":"object", - "properties":{"query":"not-a-schema"}, - "required":["query"], - "additionalProperties":false - }); - let mut pending = Vec::new(); - let mut hooks = crate::hook::HookRegistryBuilder::new(); - - let report = super::super::FeatureRegistryBuilder::default() - .with_module(PluginToolFeature::new(record(vec![invalid]))) - .install_into_pending(&mut pending, &mut hooks); - - assert!(pending.is_empty()); - assert!(has_diagnostic(&report, "invalid input_schema")); - assert!(has_diagnostic(&report, "$.properties.query")); - } - - #[test] - fn pdk_tool_output_shape_is_accepted_by_wasm_decoder() { - let pdk_output = - yoi_plugin_pdk::ToolOutput::json("pdk ok", serde_json::json!({"answer": 42})) - .unwrap() - .to_json_string(); - - let output = decode_plugin_wasm_output(pdk_output.as_bytes()).unwrap(); - assert_eq!(output.summary, "pdk ok"); - assert_eq!(output.content.as_deref(), Some(r#"{"answer":42}"#)); - } - - fn write_component_plugin_package(path: &Path, component: &[u8], world: &str) { - let manifest = format!( - r#"schema_version = 1 -id = "example" -name = "Example" -version = "1.0.0" -description = "Example component plugin" -surfaces = ["tool"] - -[runtime] -kind = "wasm-component" -component = "plugin.component.wasm" -world = "{}" - -[[permissions]] -kind = "surface" -surface = "tool" - -[[permissions]] -kind = "tool" -name = "PluginEcho" - -[[tools]] -name = "PluginEcho" -description = "Echo plugin tool" -input_schema = {{ type = "object", additionalProperties = true }} -"#, - world - ); - write_stored_zip( - path, - &[ - ("plugin.toml", manifest.as_bytes()), - ("plugin.component.wasm", component), - ], - ); - } - - fn resolved_record_with_component(component: Vec) -> (TempDir, ResolvedPluginRecord) { - let dir = TempDir::new().unwrap(); - let package_dir = dir.path().join(".yoi/plugins"); - fs::create_dir_all(&package_dir).unwrap(); - let package_path = package_dir.join("component.yoi-plugin"); - write_component_plugin_package(&package_path, &component, PLUGIN_COMPONENT_TOOL_WORLD); - let config = PluginConfig { - enabled: vec![PluginEnablementConfig { - id: "project:example".parse().unwrap(), - surfaces: vec![PluginSurface::Tool], - ..PluginEnablementConfig::default() - }], - resolved: Vec::new(), - diagnostics: Vec::new(), - }; - let options = PluginDiscoveryOptions::new(dir.path()); - let resolved = resolve_plugin_config_for_startup(&config, &options); - assert!( - resolved.diagnostics.is_empty(), - "{:#?}", - resolved.diagnostics - ); - assert_eq!(resolved.resolved.len(), 1); - let mut record = resolved.resolved[0].clone(); - record.grants = PluginGrantConfig { - id: Some(record.identity.to_string()), - version: Some(PluginExactVersion(record.version.clone())), - digest: Some(record.digest.clone()), - permissions: tool_permissions(&record.manifest.tools), - request: Vec::new(), - websocket: Vec::new(), - fs: Vec::new(), - }; - (dir, record) - } - - fn wat_bytes(bytes: &[u8]) -> String { - bytes - .iter() - .map(|byte| format!(r#"\{:02x}"#, byte)) - .collect() - } - - fn component_instance_with_outputs( - start: &[u8], - status: &[u8], - stop: &[u8], - tool: &[u8], - ingress: &[u8], - ) -> Vec { - wat::parse_str(format!( - r#"(component - (core module $m - (memory (export "memory") 1) - (func (export "realloc") (param i32 i32 i32 i32) (result i32) - (if (result i32) (i32.eqz (local.get 0)) - (then (i32.const 8192)) - (else (local.get 0)))) - (data (i32.const 1024) "{}") - (data (i32.const 2048) "{}") - (data (i32.const 3072) "{}") - (data (i32.const 4096) "{}") - (data (i32.const 5120) "{}") - (func $write (param i32 i32) - (i32.store (i32.const 6144) (local.get 0)) - (i32.store (i32.const 6148) (local.get 1))) - (func (export "start") (param i32 i32) (result i32) - (call $write (i32.const 1024) (i32.const {})) - (i32.const 6144)) - (func (export "status") (result i32) - (call $write (i32.const 2048) (i32.const {})) - (i32.const 6144)) - (func (export "stop") (result i32) - (call $write (i32.const 3072) (i32.const {})) - (i32.const 6144)) - (func (export "tool") (param i32 i32 i32 i32) (result i32) - (call $write (i32.const 4096) (i32.const {})) - (i32.const 6144)) - (func (export "ingress") (param i32 i32 i32 i32) (result i32) - (call $write (i32.const 5120) (i32.const {})) - (i32.const 6144)) - ) - (core instance $i (instantiate $m)) - (alias core export $i "memory" (core memory $mem)) - (alias core export $i "realloc" (core func $realloc)) - (alias core export $i "start" (core func $start_core)) - (alias core export $i "status" (core func $status_core)) - (alias core export $i "stop" (core func $stop_core)) - (alias core export $i "tool" (core func $tool_core)) - (alias core export $i "ingress" (core func $ingress_core)) - (type $start_ty (func (param "config-json" string) (result string))) - (type $noarg_ty (func (result string))) - (type $twoarg_ty (func (param "name" string) (param "json" string) (result string))) - (func $start (type $start_ty) (canon lift (core func $start_core) (memory $mem) (realloc $realloc) string-encoding=utf8)) - (func $status (type $noarg_ty) (canon lift (core func $status_core) (memory $mem) (realloc $realloc) string-encoding=utf8)) - (func $stop (type $noarg_ty) (canon lift (core func $stop_core) (memory $mem) (realloc $realloc) string-encoding=utf8)) - (func $tool (type $twoarg_ty) (canon lift (core func $tool_core) (memory $mem) (realloc $realloc) string-encoding=utf8)) - (func $ingress (type $twoarg_ty) (canon lift (core func $ingress_core) (memory $mem) (realloc $realloc) string-encoding=utf8)) - (export "start" (func $start)) - (export "status" (func $status)) - (export "stop" (func $stop)) - (export "handle-tool" (func $tool)) - (export "handle-ingress" (func $ingress)) - )"#, - wat_bytes(start), - wat_bytes(status), - wat_bytes(stop), - wat_bytes(tool), - wat_bytes(ingress), - start.len(), - status.len(), - stop.len(), - tool.len(), - ingress.len(), - )) - .unwrap() - } - - fn component_tool_that_returns(output: &[u8]) -> Vec { - component_tool_with_memory_pages(output, 1) - } - - fn component_tool_with_memory_pages(output: &[u8], memory_pages: usize) -> Vec { - wat::parse_str(format!( - r#"(component - (core module $m - (memory (export "memory") {}) - (func (export "realloc") (param i32 i32 i32 i32) (result i32) - (if (result i32) (i32.eqz (local.get 0)) - (then (i32.const 8192)) - (else (local.get 0)))) - (data (i32.const 1024) "{}") - (func (export "call") (param i32 i32 i32 i32) (result i32) - (i32.store (i32.const 2048) (i32.const 1024)) - (i32.store (i32.const 2052) (i32.const {})) - (i32.const 2048)) - ) - (core instance $i (instantiate $m)) - (alias core export $i "memory" (core memory $mem)) - (alias core export $i "realloc" (core func $realloc)) - (alias core export $i "call" (core func $call_core)) - (type $call_ty (func (param "tool-name" string) (param "input-json" string) (result string))) - (func $call (type $call_ty) (canon lift (core func $call_core) (memory $mem) (realloc $realloc) string-encoding=utf8)) - (export "call" (func $call)) - )"#, - memory_pages, - wat_bytes(output), - output.len() - )) - .expect("valid component wat") - } - - fn component_tool_with_table_elements(output: &[u8], table_elements: usize) -> Vec { - wat::parse_str(format!( - r#"(component - (core module $m - (memory (export "memory") 1) - (table {} funcref) - (func (export "realloc") (param i32 i32 i32 i32) (result i32) - (if (result i32) (i32.eqz (local.get 0)) - (then (i32.const 8192)) - (else (local.get 0)))) - (data (i32.const 1024) "{}") - (func (export "call") (param i32 i32 i32 i32) (result i32) - (i32.store (i32.const 2048) (i32.const 1024)) - (i32.store (i32.const 2052) (i32.const {})) - (i32.const 2048)) - ) - (core instance $i (instantiate $m)) - (alias core export $i "memory" (core memory $mem)) - (alias core export $i "realloc" (core func $realloc)) - (alias core export $i "call" (core func $call_core)) - (type $call_ty (func (param "tool-name" string) (param "input-json" string) (result string))) - (func $call (type $call_ty) (canon lift (core func $call_core) (memory $mem) (realloc $realloc) string-encoding=utf8)) - (export "call" (func $call)) - )"#, - table_elements, - wat_bytes(output), - output.len() - )) - .expect("valid component wat") - } - - fn component_tool_importing_request(output: &[u8]) -> Vec { - wat::parse_str(format!( - r#"(component - (import "yoi:host/request@1.0.0" (instance $request (export "request" (func $request (param "request-json" string) (result string))))) - (core module $m - (memory (export "memory") 1) - (func (export "realloc") (param i32 i32 i32 i32) (result i32) - (if (result i32) (i32.eqz (local.get 0)) - (then (i32.const 8192)) - (else (local.get 0)))) - (data (i32.const 1024) "{}") - (func (export "call") (param i32 i32 i32 i32) (result i32) - (i32.store (i32.const 2048) (i32.const 1024)) - (i32.store (i32.const 2052) (i32.const {})) - (i32.const 2048)) - ) - (core instance $i (instantiate $m)) - (alias core export $i "memory" (core memory $mem)) - (alias core export $i "realloc" (core func $realloc)) - (alias core export $i "call" (core func $call_core)) - (type $call_ty (func (param "tool-name" string) (param "input-json" string) (result string))) - (func $call (type $call_ty) (canon lift (core func $call_core) (memory $mem) (realloc $realloc) string-encoding=utf8)) - (export "call" (func $call)) - )"#, - wat_bytes(output), - output.len() - )) - .expect("valid component wat") - } - - fn component_without_call_export() -> Vec { - wat::parse_str(r#"(component (core module $m) (core instance $i (instantiate $m)))"#) - .unwrap() - } - - fn raw_module_bytes() -> Vec { - wat::parse_str(r#"(module (func (export "call")))"#).unwrap() - } - - #[test] - fn component_tool_executes_through_ordinary_tool_result_path() { - let (_dir, record) = resolved_record_with_component(component_tool_that_returns( - br#"{"summary":"component ok","content":"ordinary tool result path"}"#, - )); - - let output = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec()) - .expect("component tool output"); - - assert_eq!(output.summary, "component ok"); - assert_eq!(output.content.as_deref(), Some("ordinary tool result path")); - } - - #[test] - fn component_memory_limit_fails_closed_before_string_lift() { - let oversized_memory_pages = (PLUGIN_WASM_MEMORY_BYTES / 65_536) + 1; - let (_dir, record) = resolved_record_with_component(component_tool_with_memory_pages( - br#"{"summary":"should not lift"}"#, - oversized_memory_pages, - )); - - let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec()) - .expect_err("component memory limit is enforced"); - - assert!(format!("{error:?}").contains("growing memory"), "{error:?}"); - } - - #[test] - fn component_table_limit_fails_closed() { - let (_dir, record) = resolved_record_with_component(component_tool_with_table_elements( - br#"{"summary":"should not run"}"#, - PLUGIN_WASM_TABLE_ELEMENTS + 1, - )); - - let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec()) - .expect_err("component table limit is enforced"); - - assert!(format!("{error:?}").contains("growing table"), "{error:?}"); - } - - #[test] - fn component_output_cap_still_fails_closed_after_bounded_lift() { - let output = format!( - r#"{{"summary":"too big","content":"{}"}}"#, - "x".repeat(PLUGIN_WASM_MAX_OUTPUT_BYTES) - ); - let (_dir, record) = - resolved_record_with_component(component_tool_with_memory_pages(output.as_bytes(), 2)); - - let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec()) - .expect_err("component output cap is enforced"); - - assert!(format!("{error:?}").contains("output exceeds"), "{error:?}"); - } - - #[test] - fn component_tool_denies_host_import_without_matching_grant() { - let (_dir, record) = resolved_record_with_component(component_tool_importing_request( - br#"{"summary":"component ok"}"#, - )); - - let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec()) - .expect_err("host import without grant is denied"); - - assert!( - format!("{error:?}").contains("plugin host API dispatch denied"), - "{error:?}" - ); - } - - #[test] - fn component_tool_missing_export_fails_closed() { - let (_dir, record) = resolved_record_with_component(component_without_call_export()); - - let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec()) - .expect_err("missing export fails closed"); - - assert!( - format!("{error:?}").contains("does not export expected"), - "{error:?}" - ); - } - - #[test] - fn core_wasm_is_not_silently_reinterpreted_as_component() { - let (_dir, record) = resolved_record_with_component(raw_module_bytes()); - - let error = run_plugin_component_tool(record, "PluginEcho".to_string(), b"{}".to_vec()) - .expect_err("core module is incompatible with component runtime"); - - assert!( - format!("{error:?}").contains("component is incompatible"), - "{error:?}" - ); - } - - #[test] - fn component_wrong_world_fails_closed_during_discovery() { - let dir = TempDir::new().unwrap(); - let package_dir = dir.path().join(".yoi/plugins"); - fs::create_dir_all(&package_dir).unwrap(); - let package_path = package_dir.join("component.yoi-plugin"); - write_component_plugin_package( - &package_path, - &component_tool_that_returns(br#"{"summary":"component ok"}"#), - "example:other/world@1.0.0", - ); - let config = PluginConfig { - enabled: vec![PluginEnablementConfig { - id: "project:example".parse().unwrap(), - surfaces: vec![PluginSurface::Tool], - ..PluginEnablementConfig::default() - }], - resolved: Vec::new(), - diagnostics: Vec::new(), - }; - let options = PluginDiscoveryOptions::new(dir.path()); - let resolved = resolve_plugin_config_for_startup(&config, &options); - - assert!(resolved.resolved.is_empty()); - assert!( - resolved.diagnostics.iter().any(|diagnostic| diagnostic - .message - .contains("component world is unsupported")), - "{:#?}", - resolved.diagnostics - ); - } - - #[test] - fn component_tool_registration_uses_existing_tool_registry_path() { - let (_dir, record) = resolved_record_with_component(component_tool_that_returns( - br#"{"summary":"component ok"}"#, - )); - let (report, pending) = install_plugin_record(record); - - assert_eq!(skipped_count(&report), 0, "{report:#?}"); - assert_eq!(pending.len(), 1); - let (meta, _) = pending[0](); - assert_eq!(meta.name, "PluginEcho"); - } - - #[test] - fn static_inspection_reports_covering_request_grants_as_runtime_eligible() { - let mut exact_manifest_broad_grant = record_with_request_grant(); - exact_manifest_broad_grant.grants.request = vec![PluginRequestGrant { - scheme: "*".to_string(), - host: "*".to_string(), - port: None, - methods: vec!["GET".to_string()], - path_prefixes: Vec::new(), - }]; - - let inspection = inspect_resolved_plugin_static(&exact_manifest_broad_grant); - let target = inspection - .host_apis - .iter() - .find(|api| { - api.permission - .starts_with("host_api.request target https://api.example.test") - }) - .expect("manifest request target inspection"); - assert!(target.requested); - assert!(target.granted); - assert!(target.eligible); - let diagnostic = target.diagnostic.as_deref().unwrap_or_default(); - assert!( - diagnostic.contains("broad/arbitrary") || diagnostic.contains("partially covered"), - "{target:#?}" - ); - let broad_grant = inspection - .host_apis - .iter() - .find(|api| api.permission.starts_with("host_api.request grant *://*")) - .expect("broad grant inspection"); - assert!(broad_grant.requested); - assert!(broad_grant.granted); - assert!(broad_grant.eligible); - assert!( - !inspection - .host_apis - .iter() - .any(|api| api.permission.starts_with("host_api.request grant-only")), - "{:#?}", - inspection.host_apis - ); - } - - #[test] - fn static_inspection_reports_request_grant_intersections_as_runtime_eligible() { - let mut broad_manifest_exact_grant = record_with_request_grant(); - broad_manifest_exact_grant.manifest.request = vec![PluginRequestGrant { - scheme: "*".to_string(), - host: "*".to_string(), - port: None, - methods: vec!["GET".to_string(), "POST".to_string()], - path_prefixes: Vec::new(), - }]; - - let inspection = inspect_resolved_plugin_static(&broad_manifest_exact_grant); - let target = inspection - .host_apis - .iter() - .find(|api| api.permission.starts_with("host_api.request target *://*")) - .expect("broad manifest target inspection"); - assert!(target.requested); - assert!(target.granted); - assert!(target.eligible); - assert!( - target - .diagnostic - .as_deref() - .unwrap_or_default() - .contains("partially covered"), - "{target:#?}" - ); - let exact_grant = inspection - .host_apis - .iter() - .find(|api| { - api.permission - .starts_with("host_api.request grant https://api.example.test") - }) - .expect("exact grant inspection"); - assert!(exact_grant.requested); - assert!(exact_grant.granted); - assert!(exact_grant.eligible); - assert!( - !inspection - .host_apis - .iter() - .any(|api| api.permission.starts_with("host_api.request grant-only")), - "{:#?}", - inspection.host_apis - ); - } - - #[test] - fn legacy_raw_wasm_runtime_is_rejected_without_fallback_execution() { - let mut record = record(vec![tool("PluginEcho")]); - record.manifest.runtime = Some(PluginRuntimeManifest { - kind: LEGACY_PLUGIN_RUNTIME_WASM_KIND.to_string(), - entry: Some("plugin.wasm".to_string()), - abi: Some("yoi-plugin-wasm-1".to_string()), - component: None, - world: None, - }); - - let inspection = inspect_resolved_plugin_static(&record); - assert!(!inspection.runtime.eligible); - assert!( - inspection - .runtime - .diagnostic - .as_deref() - .unwrap_or_default() - .contains("legacy raw wasm plugin runtime is not an active execution path") - ); - - let error = match PluginInstanceHandle::new(record) { - Ok(_) => panic!("legacy raw wasm runtime unexpectedly instantiated"), - Err(error) => error.bounded_message(), - }; - assert!( - error.contains("legacy raw wasm plugin runtime is not supported"), - "{error}" - ); - } - - #[test] - fn component_static_inspection_reports_component_runtime_without_execution() { - let mut record = record(vec![tool("Echo")]); - record.package_path = std::path::PathBuf::from("/no/such/component.wasm"); - record.manifest.runtime = Some(PluginRuntimeManifest { - kind: PLUGIN_RUNTIME_COMPONENT_KIND.to_string(), - entry: None, - abi: None, - component: Some("plugin.component.wasm".to_string()), - world: Some(PLUGIN_COMPONENT_TOOL_WORLD.to_string()), - }); - - let inspection = inspect_resolved_plugin_static(&record); - - assert!(inspection.runtime.eligible); - assert_eq!( - inspection.runtime.status, - format!("{PLUGIN_RUNTIME_COMPONENT_KIND}/{PLUGIN_COMPONENT_TOOL_WORLD}") - ); - assert!(inspection.runtime.diagnostic.is_none()); - } - - #[test] - fn static_inspection_does_not_read_or_execute_package() { - let mut record = record(vec![tool("Echo")]); - record.package_path = std::path::PathBuf::from("/no/such/plugin.wasm"); - record.manifest.runtime = Some(PluginRuntimeManifest { - kind: PLUGIN_RUNTIME_COMPONENT_KIND.to_string(), - entry: None, - abi: None, - component: Some("plugin.component.wasm".to_string()), - world: Some(PLUGIN_COMPONENT_TOOL_WORLD.to_string()), - }); - - let inspection = inspect_resolved_plugin_static(&record); - - assert!(inspection.runtime.eligible); - assert_eq!(inspection.tools.len(), 1); - assert!(inspection.tools[0].eligible); - assert!(inspection.statically_eligible()); - } - - #[test] - fn static_inspection_reports_missing_tool_grant() { - let mut record = record(vec![tool("Echo")]); - record.manifest.runtime = Some(PluginRuntimeManifest { - kind: PLUGIN_RUNTIME_COMPONENT_KIND.to_string(), - entry: None, - abi: None, - component: Some("plugin.component.wasm".to_string()), - world: Some(PLUGIN_COMPONENT_TOOL_WORLD.to_string()), - }); - record.grants.permissions = vec![PluginPermission::surface(PluginSurface::Tool)]; - - let inspection = inspect_resolved_plugin_static(&record); - - assert!(!inspection.statically_eligible()); - assert!(!inspection.tools[0].eligible); - assert!( - inspection.tools[0] - .diagnostic - .as_deref() - .unwrap_or_default() - .contains("grant") - ); - } - - #[test] - fn static_inspection_reports_invalid_tool_definition() { - let mut bad_schema = tool("Echo"); - bad_schema.input_schema = json!({"type":"string"}); - let mut record = record(vec![bad_schema]); - record.manifest.runtime = Some(PluginRuntimeManifest { - kind: PLUGIN_RUNTIME_COMPONENT_KIND.to_string(), - entry: None, - abi: None, - component: Some("plugin.component.wasm".to_string()), - world: Some(PLUGIN_COMPONENT_TOOL_WORLD.to_string()), - }); - - let inspection = inspect_resolved_plugin_static(&record); - - assert!(!inspection.statically_eligible()); - assert!(!inspection.tools[0].eligible); - let diagnostic = inspection.tools[0] - .diagnostic - .as_deref() - .unwrap_or_default(); - assert!(diagnostic.contains("invalid input_schema")); - assert!(diagnostic.contains("root schema type must be `object`")); - } - - #[test] - fn static_inspection_reports_invalid_and_duplicate_tool_names() { - let mut invalid = tool("Bad Tool"); - invalid.input_schema = json!({"type":"object"}); - let mut first_duplicate = tool("Echo"); - let mut second_duplicate = tool("Echo"); - first_duplicate.input_schema = json!({"type":"object"}); - second_duplicate.input_schema = json!({"type":"object"}); - let mut record = record(vec![invalid, first_duplicate, second_duplicate]); - record.manifest.runtime = Some(PluginRuntimeManifest { - kind: PLUGIN_RUNTIME_COMPONENT_KIND.to_string(), - entry: None, - abi: None, - component: Some("plugin.component.wasm".to_string()), - world: Some(PLUGIN_COMPONENT_TOOL_WORLD.to_string()), - }); - - let inspection = inspect_resolved_plugin_static(&record); - - assert!(!inspection.statically_eligible()); - assert!( - inspection.tools[0] - .diagnostic - .as_deref() - .unwrap_or_default() - .contains("invalid name") - ); - assert!( - inspection.tools[1] - .diagnostic - .as_deref() - .unwrap_or_default() - .contains("duplicate name") - ); - assert!( - inspection.tools[2] - .diagnostic - .as_deref() - .unwrap_or_default() - .contains("duplicate name") - ); - } - - fn write_stored_zip(path: &Path, files: &[(&str, &[u8])]) { - let mut out = Vec::new(); - let mut central = Vec::new(); - for (name, data) in files { - let offset = out.len() as u32; - let name_bytes = name.as_bytes(); - let crc = crc32(data); - write_u32(&mut out, 0x0403_4b50); - write_u16(&mut out, 20); - write_u16(&mut out, 0); - write_u16(&mut out, 0); - write_u16(&mut out, 0); - write_u16(&mut out, 0); - write_u32(&mut out, crc); - write_u32(&mut out, data.len() as u32); - write_u32(&mut out, data.len() as u32); - write_u16(&mut out, name_bytes.len() as u16); - write_u16(&mut out, 0); - out.extend_from_slice(name_bytes); - out.extend_from_slice(data); - - write_u32(&mut central, 0x0201_4b50); - write_u16(&mut central, 20); - write_u16(&mut central, 20); - write_u16(&mut central, 0); - write_u16(&mut central, 0); - write_u16(&mut central, 0); - write_u16(&mut central, 0); - write_u32(&mut central, crc); - write_u32(&mut central, data.len() as u32); - write_u32(&mut central, data.len() as u32); - write_u16(&mut central, name_bytes.len() as u16); - write_u16(&mut central, 0); - write_u16(&mut central, 0); - write_u16(&mut central, 0); - write_u16(&mut central, 0); - write_u32(&mut central, 0); - write_u32(&mut central, offset); - central.extend_from_slice(name_bytes); - } - let central_offset = out.len() as u32; - let central_size = central.len() as u32; - out.extend_from_slice(¢ral); - write_u32(&mut out, 0x0605_4b50); - write_u16(&mut out, 0); - write_u16(&mut out, 0); - write_u16(&mut out, files.len() as u16); - write_u16(&mut out, files.len() as u16); - write_u32(&mut out, central_size); - write_u32(&mut out, central_offset); - write_u16(&mut out, 0); - fs::write(path, out).unwrap(); - } - - fn write_u16(out: &mut Vec, value: u16) { - out.extend_from_slice(&value.to_le_bytes()); - } - - fn write_u32(out: &mut Vec, value: u32) { - out.extend_from_slice(&value.to_le_bytes()); - } - - fn crc32(data: &[u8]) -> u32 { - let mut crc = 0xffff_ffffu32; - for &byte in data { - crc ^= byte as u32; - for _ in 0..8 { - let mask = if crc & 1 == 1 { 0xedb8_8320 } else { 0 }; - crc = (crc >> 1) ^ mask; - } - } - !crc - } - - #[derive(Clone, Default)] - struct MockWebSocketClient { - closed: Arc, - opens: Arc, - } - - impl PluginWebSocketClient for MockWebSocketClient { - fn supports_bounded_open(&self) -> bool { - true - } - - fn open( - &self, - _request: &PluginWebSocketOpenRequest, - _url: &reqwest::Url, - _limits: PluginWebSocketLimits, - ) -> Result, PluginWebSocketError> { - self.opens.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(Box::new(MockWebSocketConnection { - closed: self.closed.clone(), - next_recv: Some(PluginWebSocketRecvResponse::Text { - text: "hello".to_string(), - }), - })) - } - } - - struct MockWebSocketConnection { - closed: Arc, - next_recv: Option, - } - - impl PluginWebSocketConnection for MockWebSocketConnection { - fn send_text(&mut self, _text: &str) -> Result<(), PluginWebSocketError> { - Ok(()) - } - - fn recv_text( - &mut self, - _timeout: Duration, - _max_message_bytes: usize, - ) -> Result { - Ok(self - .next_recv - .take() - .unwrap_or(PluginWebSocketRecvResponse::Closed)) - } - - fn close(&mut self) -> Result<(), PluginWebSocketError> { - self.closed - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - - Ok(()) - } - } - - #[derive(Clone, Default)] - struct FailingWebSocketClient { - opens: Arc, - } - - impl PluginWebSocketClient for FailingWebSocketClient { - fn supports_bounded_open(&self) -> bool { - true - } - - fn open( - &self, - _request: &PluginWebSocketOpenRequest, - _url: &reqwest::Url, - _limits: PluginWebSocketLimits, - ) -> Result, PluginWebSocketError> { - self.opens.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Err(PluginWebSocketError::new("simulated bounded open failure")) - } - } - - #[derive(Clone, Default)] - struct UnboundedWebSocketClient { - opens: Arc, - } - - impl PluginWebSocketClient for UnboundedWebSocketClient { - fn supports_bounded_open(&self) -> bool { - false - } - - fn open( - &self, - _request: &PluginWebSocketOpenRequest, - _url: &reqwest::Url, - _limits: PluginWebSocketLimits, - ) -> Result, PluginWebSocketError> { - self.opens.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Err(PluginWebSocketError::new("should not dial")) - } - } - fn websocket_grant( - scheme: &str, - host: &str, - port: Option, - paths: &[&str], - ) -> PluginWebSocketGrant { - PluginWebSocketGrant { - scheme: scheme.to_string(), - host: host.to_string(), - port, - path_prefixes: paths.iter().map(|path| (*path).to_string()).collect(), - } - } - - fn record_with_websocket( - manifest_targets: Vec, - grants: Vec, - ) -> ResolvedPluginRecord { - let mut record = record(vec![]); - record.manifest.permissions = vec![PluginPermission::host_api(PluginHostApi::WebSocket)]; - record.manifest.websocket = manifest_targets; - record.grants.websocket = grants; - record - } - - #[test] - fn websocket_max_open_connections_rejects_before_network_open() { - let record = record_with_websocket( - vec![websocket_grant( - "wss", - "gateway.example.com", - None, - &["/gateway"], - )], - vec![websocket_grant( - "wss", - "gateway.example.com", - None, - &["/gateway"], - )], - ); - let client = MockWebSocketClient::default(); - let handles = PluginWebSocketHandles::default(); - for _ in 0..PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS { - execute_plugin_websocket_open( - &record, - &client, - &handles, - br#"{"url":"wss://gateway.example.com/gateway"}"#, - ) - .unwrap(); - } - assert_eq!( - client.opens.load(std::sync::atomic::Ordering::SeqCst), - PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS - ); - let error = execute_plugin_websocket_open( - &record, - &client, - &handles, - br#"{"url":"wss://gateway.example.com/gateway"}"#, - ) - .unwrap_err(); - assert!(error.0.contains("before dialing")); - assert_eq!( - client.opens.load(std::sync::atomic::Ordering::SeqCst), - PLUGIN_WEBSOCKET_MAX_OPEN_CONNECTIONS - ); - } - - #[test] - fn websocket_open_failure_releases_capacity_reservation() { - let record = record_with_websocket( - vec![websocket_grant( - "wss", - "gateway.example.com", - None, - &["/gateway"], - )], - vec![websocket_grant( - "wss", - "gateway.example.com", - None, - &["/gateway"], - )], - ); - let failing = FailingWebSocketClient::default(); - let handles = PluginWebSocketHandles::default(); - let error = execute_plugin_websocket_open( - &record, - &failing, - &handles, - br#"{"url":"wss://gateway.example.com/gateway"}"#, - ) - .unwrap_err(); - assert!(error.0.contains("simulated bounded open failure")); - assert_eq!(handles.reservation_count(), 0); - - let client = MockWebSocketClient::default(); - let open = execute_plugin_websocket_open( - &record, - &client, - &handles, - br#"{"url":"wss://gateway.example.com/gateway"}"#, - ) - .unwrap(); - let open: PluginWebSocketOpenResponse = serde_json::from_slice(&open).unwrap(); - assert_eq!(open.handle, 1); - } - - #[test] - fn websocket_unbounded_open_client_fails_closed_before_dialing() { - let record = record_with_websocket( - vec![websocket_grant( - "wss", - "gateway.example.com", - None, - &["/gateway"], - )], - vec![websocket_grant( - "wss", - "gateway.example.com", - None, - &["/gateway"], - )], - ); - let client = UnboundedWebSocketClient::default(); - let handles = PluginWebSocketHandles::default(); - let error = execute_plugin_websocket_open( - &record, - &client, - &handles, - br#"{"url":"wss://gateway.example.com/gateway"}"#, - ) - .unwrap_err(); - assert!( - error - .0 - .contains("cannot guarantee bounded/cancellable open") - ); - assert_eq!(client.opens.load(std::sync::atomic::Ordering::SeqCst), 0); - assert_eq!(handles.reservation_count(), 0); - } - #[test] - fn websocket_open_send_recv_close_is_bounded_and_explicit() { - let record = record_with_websocket( - vec![websocket_grant( - "wss", - "gateway.example.com", - None, - &["/gateway"], - )], - vec![websocket_grant( - "wss", - "gateway.example.com", - None, - &["/gateway"], - )], - ); - let client = MockWebSocketClient::default(); - let handles = PluginWebSocketHandles::default(); - let open = execute_plugin_websocket_open( - &record, - &client, - &handles, - br#"{"url":"wss://gateway.example.com/gateway?v=10"}"#, - ) - .unwrap(); - let open: PluginWebSocketOpenResponse = serde_json::from_slice(&open).unwrap(); - assert_eq!(open.handle, 1); - assert_eq!(open.url, "wss://gateway.example.com/gateway"); - - let send = execute_plugin_websocket_send_text(&handles, open.handle, b"ping").unwrap(); - assert_eq!(send, br#"{"sent":true,"bytes":4}"#); - let recv = execute_plugin_websocket_recv(&handles, open.handle, 1).unwrap(); - assert_eq!(recv, br#"{"type":"text","text":"hello"}"#); - let close = execute_plugin_websocket_close(&handles, open.handle).unwrap(); - assert_eq!(close, br#"{"closed":true}"#); - assert_eq!(client.closed.load(std::sync::atomic::Ordering::SeqCst), 1); - } - - #[test] - fn websocket_open_requires_manifest_and_grant() { - let client = MockWebSocketClient::default(); - let handles = PluginWebSocketHandles::default(); - let missing_grant = record_with_websocket( - vec![websocket_grant( - "wss", - "gateway.example.com", - None, - &["/gateway"], - )], - vec![], - ); - let error = execute_plugin_websocket_open( - &missing_grant, - &client, - &handles, - br#"{"url":"wss://gateway.example.com/gateway"}"#, - ) - .unwrap_err(); - assert!(error.0.contains("enabled WebSocket grants")); - - let missing_manifest = record_with_websocket( - vec![], - vec![websocket_grant( - "wss", - "gateway.example.com", - None, - &["/gateway"], - )], - ); - let error = execute_plugin_websocket_open( - &missing_manifest, - &client, - &handles, - br#"{"url":"wss://gateway.example.com/gateway"}"#, - ) - .unwrap_err(); - assert!(error.0.contains("not declared")); - assert_eq!(client.opens.load(std::sync::atomic::Ordering::SeqCst), 0); - } - - #[test] - fn websocket_loopback_requires_explicit_manifest_and_grant() { - let client = MockWebSocketClient::default(); - let handles = PluginWebSocketHandles::default(); - let denied = record_with_websocket( - vec![websocket_grant("ws", "127.0.0.1", Some(8080), &["/socket"])], - vec![], - ); - assert!( - execute_plugin_websocket_open( - &denied, - &client, - &handles, - br#"{"url":"ws://127.0.0.1:8080/socket"}"#, - ) - .is_err() - ); - let allowed = record_with_websocket( - vec![websocket_grant("ws", "127.0.0.1", Some(8080), &["/socket"])], - vec![websocket_grant("ws", "127.0.0.1", Some(8080), &["/socket"])], - ); - assert!( - execute_plugin_websocket_open( - &allowed, - &client, - &handles, - br#"{"url":"ws://127.0.0.1:8080/socket"}"#, - ) - .is_ok() - ); - } - - #[test] - fn websocket_rejects_guest_headers_and_binary_send_surface() { - let record = record_with_websocket( - vec![websocket_grant( - "wss", - "gateway.example.com", - None, - &["/gateway"], - )], - vec![websocket_grant( - "wss", - "gateway.example.com", - None, - &["/gateway"], - )], - ); - let error = validate_plugin_websocket_open_request( - &record, - br#"{"url":"wss://gateway.example.com/gateway","headers":[{"name":"authorization","value":"secret"}]}"#, - ) - .unwrap_err(); - assert!(error.0.contains("handshake headers")); - let handles = PluginWebSocketHandles::default(); - let invalid_utf8 = [0xff, 0xfe]; - let error = execute_plugin_websocket_send_text(&handles, 1, &invalid_utf8).unwrap_err(); - assert!(error.0.contains("UTF-8")); - } - - #[test] - fn websocket_static_inspection_reports_grant_only_missing_and_broad() { - let grant_only = record_with_websocket( - vec![websocket_grant( - "wss", - "declared.example.com", - None, - &["/gateway"], - )], - vec![websocket_grant("*", "*", None, &[])], - ); - let inspection = inspect_resolved_plugin_static(&grant_only); - let ws: Vec<_> = inspection - .host_apis - .iter() - .filter(|item| item.permission.contains("host_api.websocket")) - .collect(); - assert!(ws.iter().any(|item| item.permission.contains("target"))); - assert!(ws.iter().any(|item| { - item.permission.contains("grant") - && item - .diagnostic - .as_deref() - .unwrap_or_default() - .contains("broad") - })); - - let missing = record_with_websocket( - vec![websocket_grant( - "wss", - "missing.example.com", - None, - &["/gateway"], - )], - vec![], - ); - let inspection = inspect_resolved_plugin_static(&missing); - assert!(inspection.host_apis.iter().any(|item| { - item.permission.contains("host_api.websocket target") - && item - .diagnostic - .as_deref() - .unwrap_or_default() - .contains("missing") - })); - } - - #[test] - fn request_host_api_still_rejects_websocket_urls() { - let record = record_with_request_grant(); - let client = MockRequestClient::default(); - let error = execute_plugin_request_request( - &record, - &client, - br#"{"method":"GET","url":"wss://api.example.com/socket"}"#, - ) - .unwrap_err(); - assert!(error.0.contains("WebSocket")); - } -} diff --git a/crates/worker/src/internal_worker.rs b/crates/worker/src/internal_worker.rs index 0f553102..25488987 100644 --- a/crates/worker/src/internal_worker.rs +++ b/crates/worker/src/internal_worker.rs @@ -127,7 +127,6 @@ where // parent manifest cannot accidentally grant its normal public tool surface // or recursively schedule Feature-owned background work. manifest.feature = Default::default(); - manifest.plugins = Default::default(); manifest.mcp = Default::default(); manifest.skills = None; manifest.compaction = None; @@ -681,7 +680,6 @@ pub(crate) fn prepare_internal_worker_from_spec( } = spec; manifest.worker.name = format!("internal-{}-{}", identity.kind, identity.run_id); manifest.feature = Default::default(); - manifest.plugins = Default::default(); manifest.mcp = Default::default(); manifest.skills = None; manifest.compaction = None; diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 6ec7c3cc..711aea53 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -926,7 +926,6 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi rules: p.rules.clone(), }), feature: manifest.feature.clone().into(), - plugins: manifest.plugins.clone(), mcp: manifest.mcp.clone(), compaction: manifest .compaction diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 93ec40ac..a2bfc1fb 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -7750,80 +7750,6 @@ permission = "read" }) ); } - - #[test] - fn plugin_resolved_manifest_snapshot_is_persisted_without_profile() { - let mut manifest = WorkerManifest::from_toml( - r#" -[worker] -name = "plugin-snapshot" - -[model] -scheme = "anthropic" -model_id = "claude-sonnet-4-20250514" - -[engine] -instruction = "saved" - -[[scope.allow]] -target = "/snapshot/workspace" -permission = "read" -"#, - ) - .unwrap(); - assert!(manifest.profile.is_none()); - assert!( - worker_metadata_for_manifest(&manifest, None, None, None) - .resolved_manifest_snapshot - .is_none() - ); - - manifest.plugins.resolved = vec![manifest::plugin::ResolvedPluginRecord { - identity: manifest::plugin::SourceQualifiedPluginId::new( - manifest::plugin::PluginSourceKind::Project, - "example", - ), - source: manifest::plugin::PluginSourceKind::Project, - package_path: PathBuf::from("/snapshot/workspace/.yoi/plugins/example.yoi-plugin"), - package_label: "example.yoi-plugin".to_string(), - digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - .to_string(), - version: "0.1.0".to_string(), - manifest: manifest::plugin::PluginPackageManifest { - schema_version: 1, - id: "example".to_string(), - name: "Example".to_string(), - version: "0.1.0".to_string(), - description: None, - surfaces: vec![manifest::plugin::PluginSurface::Hook], - runtime: None, - hooks: vec![], - tools: vec![], - services: vec![], - ingresses: vec![], - permissions: vec![], - request: vec![], - websocket: vec![], - }, - enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook], - grants: manifest::plugin::PluginGrantConfig::default(), - config: None, - }]; - - let metadata = worker_metadata_for_manifest(&manifest, None, None, None); - let snapshot = metadata - .resolved_manifest_snapshot - .expect("plugin-resolved manifest should be snapshotted"); - let restored = manifest::read_persisted_worker_manifest_snapshot(snapshot).unwrap(); - - assert!(restored.profile.is_none()); - assert_eq!(restored.plugins.resolved.len(), 1); - assert_eq!( - restored.plugins.resolved[0].digest, - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - ); - assert_eq!(restored.plugins.resolved[0].version, "0.1.0"); - } } #[cfg(test)] diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index f30737f3..246d0010 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -1345,7 +1345,7 @@ async fn run_login(backend_url: &str, no_wait: bool) -> Result<(), ParseError> { fn parse_plugin_args(args: &[String]) -> Result { let Some((subcommand, rest)) = args.split_first() else { return Err(ParseError( - "yoi plugin requires `new`, `check`, `pack`, `list`, or `show `".to_string(), + "yoi plugin requires `new`, `check`, or `pack`".to_string(), )); }; match subcommand.as_str() { @@ -1397,30 +1397,6 @@ fn parse_plugin_args(args: &[String]) -> Result { - let (plugin_args, positional) = parse_plugin_common_args(rest)?; - if !positional.is_empty() { - return Err(ParseError( - "yoi plugin list does not accept positional arguments".to_string(), - )); - } - Ok(plugin_cli::PluginCliCommand::List(plugin_args)) - } - "show" => { - let (plugin_args, positional) = parse_plugin_common_args(rest)?; - match positional.as_slice() { - [reference] => Ok(plugin_cli::PluginCliCommand::Show { - reference: reference.clone(), - args: plugin_args, - }), - [] => Err(ParseError( - "yoi plugin show requires a plugin ref".to_string(), - )), - _ => Err(ParseError( - "yoi plugin show accepts exactly one plugin ref".to_string(), - )), - } - } "--help" | "-h" => Err(ParseError(plugin_usage().to_string())), other => Err(ParseError(format!( "unknown yoi plugin subcommand `{other}`" @@ -1438,35 +1414,7 @@ fn parse_plugin_common_args( let arg = &args[index]; match arg.as_str() { "--json" => parsed.json = true, - "--workspace" => { - index += 1; - let Some(value) = args.get(index) else { - return Err(ParseError("--workspace requires a value".to_string())); - }; - parsed.workspace = Some(PathBuf::from(value)); - } - "--profile" => { - index += 1; - let Some(value) = args.get(index) else { - return Err(ParseError("--profile requires a value".to_string())); - }; - parsed.profile = Some(value.clone()); - } "--help" | "-h" => return Err(ParseError(plugin_usage().to_string())), - _ if arg.starts_with("--workspace=") => { - let value = arg.trim_start_matches("--workspace="); - if value.is_empty() { - return Err(ParseError("--workspace requires a value".to_string())); - } - parsed.workspace = Some(PathBuf::from(value)); - } - _ if arg.starts_with("--profile=") => { - let value = arg.trim_start_matches("--profile="); - if value.is_empty() { - return Err(ParseError("--profile requires a value".to_string())); - } - parsed.profile = Some(value.to_string()); - } _ if arg.starts_with('-') => { return Err(ParseError(format!("unknown yoi plugin option `{arg}`"))); } @@ -1506,7 +1454,7 @@ fn parse_plugin_pack_args( } fn plugin_usage() -> &'static str { - "usage: yoi plugin new [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show [--workspace PATH] [--profile REF] [--json]" + "usage: yoi plugin new [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]" } fn parse_mcp_args(args: &[String]) -> Result { @@ -2654,29 +2602,26 @@ backend = "shared" } #[test] - fn parse_plugin_list_and_show() { - match parse_args_from(["plugin", "list", "--workspace=/tmp/ws", "--json"]).unwrap() { - Mode::Plugin(plugin_cli::PluginCliCommand::List(options)) => { - assert_eq!(options.workspace, Some(PathBuf::from("/tmp/ws"))); - assert!(options.json); - } - _ => panic!("expected Plugin list mode"), + fn plugin_cli_rejects_ambient_catalog_commands_and_options() { + for args in [ + vec!["plugin", "list"], + vec!["plugin", "show", "project:echo"], + vec!["plugin", "check", "plugin", "--workspace=/tmp/ws"], + vec!["plugin", "check", "plugin", "--profile", "project:inspect"], + ] { + let error = parse_args_from(args).unwrap_err(); + assert!( + error.0.contains("unknown yoi plugin"), + "unexpected error: {error}" + ); } - match parse_args_from([ - "plugin", - "show", - "project:echo", - "--profile", - "project:inspect", - ]) - .unwrap() - { - Mode::Plugin(plugin_cli::PluginCliCommand::Show { reference, args }) => { - assert_eq!(reference, "project:echo"); - assert_eq!(args.profile.as_deref(), Some("project:inspect")); + match parse_args_from(["plugin", "check", "plugin.yoi-plugin", "--json"]).unwrap() { + Mode::Plugin(plugin_cli::PluginCliCommand::Check { input, args }) => { + assert_eq!(input, PathBuf::from("plugin.yoi-plugin")); + assert!(args.json); } - _ => panic!("expected Plugin show mode"), + _ => panic!("expected Plugin check mode"), } } diff --git a/crates/yoi/src/plugin_cli.rs b/crates/yoi/src/plugin_cli.rs index 6d379cef..9b93dc26 100644 --- a/crates/yoi/src/plugin_cli.rs +++ b/crates/yoi/src/plugin_cli.rs @@ -1,4 +1,3 @@ -use std::collections::BTreeMap; use std::error::Error; use std::fmt::Write as _; use std::fs; @@ -6,39 +5,24 @@ use std::io; use std::path::{Path, PathBuf}; use manifest::plugin::{ - MaterializedPluginPackage, PluginConfig, PluginDiagnostic, PluginDiagnosticKind, - PluginDiagnosticPhase, PluginDiscoveryLimits, PluginDiscoveryOptions, PluginDiscoveryReport, - PluginExactVersion, PluginGrantConfig, PluginPackageManifest, PluginPermission, - PluginResolution, PluginSourceKind, PluginSurface, PluginTemplateResource, - RUST_COMPONENT_INSTANCE_TEMPLATE, RUST_COMPONENT_TOOL_TEMPLATE, ResolvedPlugin, - ResolvedPluginRecord, SourceQualifiedPluginId, discover_plugins, read_plugin_directory, - read_plugin_package_file, resolve_enabled_plugins, write_plugin_package_file, + MaterializedPluginPackage, PluginDiagnostic, PluginDiagnosticKind, PluginDiagnosticPhase, + PluginPackageLimits, PluginPermission, PluginSourceKind, PluginTemplateResource, + RUST_COMPONENT_INSTANCE_TEMPLATE, RUST_COMPONENT_TOOL_TEMPLATE, SourceQualifiedPluginId, + read_plugin_directory, read_plugin_package_file, write_plugin_package_file, }; -use manifest::{ProfileResolveOptions, ProfileResolver, ProfileSelector, paths}; use serde::Serialize; -use worker::feature::plugin::{PluginStaticInspection, inspect_resolved_plugin_static}; type Result = std::result::Result>; -const MAX_LIST_ITEMS: usize = 128; -const MAX_SHOW_DIAGNOSTICS: usize = 48; -const MAX_LIST_DIAGNOSTICS_PER_ITEM: usize = 3; const MAX_TEXT: usize = 240; #[derive(Clone, Debug, Default)] pub(crate) struct PluginCliArgs { - pub workspace: Option, - pub profile: Option, pub json: bool, } #[derive(Clone, Debug)] pub(crate) enum PluginCliCommand { - List(PluginCliArgs), - Show { - reference: String, - args: PluginCliArgs, - }, New { template: String, destination: PathBuf, @@ -66,8 +50,6 @@ pub(crate) fn run(command: PluginCliCommand) -> Result<()> { return Ok(()); } let rendered = match command { - PluginCliCommand::List(args) => render_list(&args)?, - PluginCliCommand::Show { reference, args } => render_show(&reference, &args)?, PluginCliCommand::New { template, destination, @@ -89,13 +71,14 @@ fn render_new(template: &str, destination: &Path, args: &PluginCliArgs) -> Resul materialize_template(destination, resources)?; let mut next_steps = vec![ "Review plugin.toml and generated Rust source.".to_string(), - "Replace the placeholder plugin.component.wasm with a real built component before enabling or execution.".to_string(), + "Replace the placeholder plugin.component.wasm with a real built component before offline package verification.".to_string(), "Run `yoi plugin check ` and then `yoi plugin pack `.".to_string(), ]; if template == "rust-component-service" { next_steps.insert( 1, - "Implement Service ingress logic in handle_ingress and return ServiceOutput output_commands for host-owned WebSocket sends.".to_string(), + "Implement the proposed Service ingress output shape for offline package validation." + .to_string(), ); } let report = NewReport { @@ -199,8 +182,8 @@ fn render_check_report(report: &CheckReport, args: &PluginCliArgs) -> Result, args: &PluginCliArgs) -> Result { - let limits = PluginDiscoveryLimits::default(); - let materialized = read_plugin_directory(input, PluginSourceKind::Project, &limits) + let limits = PluginPackageLimits::default(); + let materialized = read_plugin_directory(input, PluginSourceKind::Explicit, &limits) .map_err(|diagnostic| plugin_diagnostic_error("plugin pack", diagnostic))?; let output_path = output .map(Path::to_path_buf) @@ -234,32 +217,26 @@ fn default_package_output_path(input: &Path) -> PathBuf { } fn build_check_report(input: &Path) -> CheckReport { - let limits = PluginDiscoveryLimits::default(); + let limits = PluginPackageLimits::default(); let input_kind = if input.is_dir() { "directory" } else { "package" }; let result = if input.is_dir() { - read_plugin_directory(input, PluginSourceKind::Project, &limits) + read_plugin_directory(input, PluginSourceKind::Explicit, &limits) } else { - read_plugin_package_file(input, PluginSourceKind::Project, &limits) + read_plugin_package_file(input, PluginSourceKind::Explicit, &limits) }; match result { Ok(materialized) => { - let static_inspection = inspect_materialized_package(&materialized); - let static_diagnostics = static_inspection_diagnostics(&static_inspection); let placeholder_diagnostic = placeholder_component_diagnostic(&materialized); - let status = if !static_diagnostics.is_empty() { - "rejected" - } else if placeholder_diagnostic.is_some() { + let status = if placeholder_diagnostic.is_some() { "partial" } else { - "active" + "verified" }; - let mut diagnostics = static_diagnostics; - diagnostics.extend(placeholder_diagnostic); - let reference = package_reference(&materialized.package.identity); + let diagnostics = placeholder_diagnostic.into_iter().collect(); CheckReport { command: "check", status, @@ -267,11 +244,8 @@ fn build_check_report(input: &Path) -> CheckReport { input_kind, package: Some(PackageReport::from_materialized(&materialized)), diagnostics, - static_inspection: Some(StaticInspectionReport::from_inspection( - &static_inspection, - )), safety: AuthoringSafetyReport::default(), - next_steps: check_next_steps(status, &reference), + next_steps: check_next_steps(status), } } Err(diagnostic) => CheckReport { @@ -281,7 +255,6 @@ fn build_check_report(input: &Path) -> CheckReport { input_kind, package: None, diagnostics: vec![PluginDiagnosticReport::from_diagnostic(&diagnostic)], - static_inspection: None, safety: AuthoringSafetyReport::default(), next_steps: vec![ "Fix the reported package diagnostic and run `yoi plugin check` again.".to_string(), @@ -290,85 +263,6 @@ fn build_check_report(input: &Path) -> CheckReport { } } -fn inspect_materialized_package( - materialized: &MaterializedPluginPackage, -) -> PluginStaticInspection { - let requested_permissions = materialized.package.manifest.permissions.clone(); - let record = ResolvedPluginRecord { - identity: materialized.package.identity.clone(), - source: materialized.package.identity.source, - package_path: materialized.package.package_path.clone(), - package_label: materialized.package.package_label.clone(), - digest: materialized.package.digest.clone(), - version: materialized.package.manifest.version.clone(), - manifest: materialized.package.manifest.clone(), - enabled_surfaces: materialized.package.manifest.surfaces.clone(), - grants: PluginGrantConfig { - id: Some(materialized.package.identity.to_string()), - version: Some(PluginExactVersion( - materialized.package.manifest.version.clone(), - )), - digest: Some(materialized.package.digest.clone()), - permissions: requested_permissions, - request: Vec::new(), - websocket: Vec::new(), - fs: Vec::new(), - }, - config: None, - }; - inspect_resolved_plugin_static(&record) -} - -fn static_inspection_diagnostics( - inspection: &PluginStaticInspection, -) -> Vec { - let mut diagnostics = Vec::new(); - if let Some(message) = &inspection.runtime.diagnostic { - diagnostics.push(PluginDiagnosticReport { - kind: "malformed".to_string(), - phase: "resolution".to_string(), - message: bound_text(message.clone()), - }); - } - for api in &inspection.host_apis { - if let Some(message) = &api.diagnostic { - diagnostics.push(PluginDiagnosticReport { - kind: "grant".to_string(), - phase: "resolution".to_string(), - message: bound_text(message.clone()), - }); - } - } - for tool in &inspection.tools { - if let Some(message) = &tool.diagnostic { - diagnostics.push(PluginDiagnosticReport { - kind: "malformed".to_string(), - phase: "resolution".to_string(), - message: bound_text(message.clone()), - }); - } - } - for service in &inspection.services { - if let Some(message) = &service.diagnostic { - diagnostics.push(PluginDiagnosticReport { - kind: "grant".to_string(), - phase: "resolution".to_string(), - message: bound_text(format!("service `{}`: {message}", service.name)), - }); - } - } - for ingress in &inspection.ingresses { - if let Some(message) = &ingress.diagnostic { - diagnostics.push(PluginDiagnosticReport { - kind: "grant".to_string(), - phase: "resolution".to_string(), - message: bound_text(format!("ingress `{}`: {message}", ingress.name)), - }); - } - } - diagnostics -} - fn placeholder_component_diagnostic( materialized: &MaterializedPluginPackage, ) -> Option { @@ -387,30 +281,25 @@ fn placeholder_component_diagnostic( kind: "placeholder".to_string(), phase: "runtime".to_string(), message: format!( - "plugin component runtime artifact `{component}` is the generated placeholder; replace it with a real built component before enabling or execution" + "plugin component runtime artifact `{component}` is the generated placeholder; replace it with a real built component before package verification" ), }) } -fn check_next_steps(status: &str, reference: &str) -> Vec { +fn check_next_steps(status: &str) -> Vec { match status { - "active" => vec![ - "Package metadata is valid without executing Plugin code.".to_string(), - format!( - "To enable after review, add an explicit plugin enablement entry for `{reference}` with matching digest and grants." - ), - "Run `yoi plugin pack ` to create a deterministic .yoi-plugin archive." + "verified" => vec![ + "Package metadata and archive contents are valid without executing Plugin code." + .to_string(), + "Keep this artifact for explicit offline inspection; Worker Plugin installation is not supported." .to_string(), ], "partial" => vec![ "Replace the generated placeholder component artifact with a real built component." .to_string(), - "Run `yoi plugin check ` again before enabling or execution.".to_string(), - "Do not enable this Plugin while check status is partial.".to_string(), + "Run `yoi plugin check ` again to verify the offline package.".to_string(), ], - _ => { - vec!["Fix the reported diagnostics before enabling or packing this Plugin.".to_string()] - } + _ => vec!["Fix the reported diagnostics before packing this Plugin.".to_string()], } } @@ -459,21 +348,10 @@ fn render_check_human(report: &CheckReport) -> Result { join_or_none(&package.surfaces), package.tools.len() )?; - match report.status { - "active" => writeln!( - out, - "enablement guidance: pin reference `{}` and digest `{}` explicitly; this command does not mutate config", - package.reference, package.digest - )?, - "partial" => writeln!( - out, - "enablement guidance: not ready to enable; replace the generated placeholder component and rerun check; this command does not mutate config" - )?, - _ => writeln!( - out, - "enablement guidance: not ready to enable; fix diagnostics first; this command does not mutate config" - )?, - } + writeln!( + out, + "installation: unsupported; this command only inspects the explicit input path" + )?; } if report.diagnostics.is_empty() { writeln!(out, "diagnostics: none")?; @@ -557,7 +435,6 @@ struct CheckReport { input_kind: &'static str, package: Option, diagnostics: Vec, - static_inspection: Option, safety: AuthoringSafetyReport, next_steps: Vec, } @@ -580,7 +457,7 @@ struct PackageReport { version: String, schema_version: u32, digest: String, - package_path: String, + input_path: String, entries: Vec, surfaces: Vec, tools: Vec, @@ -596,7 +473,7 @@ impl PackageReport { version: materialized.package.manifest.version.clone(), schema_version: materialized.package.manifest.schema_version, digest: materialized.package.digest.clone(), - package_path: materialized.package.package_path.display().to_string(), + input_path: materialized.package.input_path.display().to_string(), entries: materialized.package.entries.iter().cloned().collect(), surfaces: materialized .package @@ -642,9 +519,8 @@ impl PluginDiagnosticReport { fn diagnostic_phase(phase: &PluginDiagnosticPhase) -> &'static str { match phase { - PluginDiagnosticPhase::Discovery => "discovery", + PluginDiagnosticPhase::Inspection => "inspection", PluginDiagnosticPhase::Manifest => "manifest", - PluginDiagnosticPhase::Resolution => "resolution", } } @@ -656,246 +532,6 @@ fn permission_name(permission: PluginPermission) -> String { permission.label() } -#[derive(Serialize)] -struct StaticInspectionReport { - status: String, - diagnostics: usize, -} - -impl StaticInspectionReport { - fn from_inspection(inspection: &PluginStaticInspection) -> Self { - let diagnostics = static_inspection_diagnostics(inspection).len(); - let status = if diagnostics == 0 { - "active" - } else { - "rejected" - }; - Self { - status: status.to_string(), - diagnostics, - } - } -} - -fn render_list(args: &PluginCliArgs) -> Result { - let snapshot = build_snapshot(args)?; - if args.json { - return Ok(format!("{}\n", serde_json::to_string_pretty(&snapshot)?)); - } - - render_list_snapshot_human(&snapshot) -} - -fn render_list_snapshot_human(snapshot: &PluginInspectionSnapshot) -> Result { - let mut out = String::new(); - writeln!( - out, - "plugin packages (workspace: {})", - snapshot.workspace.display() - )?; - if snapshot.items.is_empty() { - writeln!(out, " (none)")?; - return Ok(out); - } - - for item in snapshot.items.iter().take(MAX_LIST_ITEMS) { - writeln!( - out, - "- {} [{}] version={} schema_version={} api_version={} package_path={} digest={} source={} enabled_surfaces={} tools={} diagnostics={}", - item.reference, - item.status, - item.version.as_deref().unwrap_or(""), - item.schema_version - .map(|version| version.to_string()) - .unwrap_or_else(|| "".to_string()), - item.api_version - .map(|version| version.to_string()) - .unwrap_or_else(|| "".to_string()), - item.package_path - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "".to_string()), - item.digest.as_deref().unwrap_or(""), - item.source.as_deref().unwrap_or(""), - join_or_none(&item.enabled_surfaces), - item.tools.len(), - item.diagnostics.len() - )?; - for diagnostic in item.diagnostics.iter().take(MAX_LIST_DIAGNOSTICS_PER_ITEM) { - writeln!(out, " diagnostic: {}", diagnostic.message)?; - } - } - if snapshot.items.len() > MAX_LIST_ITEMS { - writeln!( - out, - " ... {} more plugin package(s) omitted", - snapshot.items.len() - MAX_LIST_ITEMS - )?; - } - Ok(out) -} - -fn render_show(reference: &str, args: &PluginCliArgs) -> Result { - let snapshot = build_snapshot(args)?; - let item = select_item(&snapshot, reference)?; - if args.json { - return Ok(format!("{}\n", serde_json::to_string_pretty(item)?)); - } - - render_item_human(&item) -} - -fn render_item_human(item: &PluginInspectionItem) -> Result { - let mut out = String::new(); - writeln!(out, "plugin {}", item.reference)?; - writeln!(out, " status: {}", item.status)?; - writeln!( - out, - " source: {}", - item.source.as_deref().unwrap_or("") - )?; - writeln!( - out, - " package: {}", - item.package.as_deref().unwrap_or("") - )?; - writeln!( - out, - " package_path: {}", - item.package_path - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "".to_string()) - )?; - writeln!( - out, - " schema_version: {}", - item.schema_version - .map(|version| version.to_string()) - .unwrap_or_else(|| "".to_string()) - )?; - writeln!( - out, - " api_version: {}", - item.api_version - .map(|version| version.to_string()) - .unwrap_or_else(|| "".to_string()) - )?; - writeln!( - out, - " version: {}", - item.version.as_deref().unwrap_or("") - )?; - writeln!( - out, - " digest: {}", - item.digest.as_deref().unwrap_or("") - )?; - writeln!(out, " configured: {}", item.configured)?; - writeln!(out, " discovered: {}", item.discovered)?; - writeln!(out, " resolved: {}", item.resolved)?; - writeln!(out, " static_eligible: {}", item.static_eligible)?; - - writeln!( - out, - " declared_surfaces: {}", - join_or_none(&item.declared_surfaces) - )?; - writeln!( - out, - " enabled_surfaces: {}", - join_or_none(&item.enabled_surfaces) - )?; - writeln!( - out, - " requested_permissions: {}", - join_or_none(&item.requested_permissions) - )?; - writeln!( - out, - " configured_grants: {}", - join_or_none(&item.configured_grants) - )?; - writeln!( - out, - " configured_request_grants: {}", - join_or_none(&item.configured_request_grants) - )?; - writeln!( - out, - " configured_websocket_grants: {}", - join_or_none(&item.configured_websocket_grants) - )?; - writeln!( - out, - " configured_fs_grants: {}", - join_or_none(&item.configured_fs_grants) - )?; - - if let Some(runtime) = &item.static_runtime { - writeln!( - out, - " runtime: {} eligible={}", - runtime.runtime.status, runtime.runtime.eligible - )?; - if let Some(diagnostic) = &runtime.runtime.diagnostic { - writeln!(out, " diagnostic: {diagnostic}")?; - } - if !runtime.host_apis.is_empty() { - writeln!(out, " host_api_permissions:")?; - for api in &runtime.host_apis { - writeln!( - out, - " - {} requested={} granted={} eligible={}", - api.permission, api.requested, api.granted, api.eligible - )?; - if let Some(diagnostic) = &api.diagnostic { - writeln!(out, " diagnostic: {diagnostic}")?; - } - } - } - } - - if item.tools.is_empty() { - writeln!(out, " tools: (none)")?; - } else { - writeln!(out, " tools:")?; - for tool in &item.tools { - writeln!( - out, - " - {} permission={} requested={} granted={} eligible={} external_write={}", - tool.name, - tool.permission, - tool.requested, - tool.granted, - tool.eligible, - tool.external_write - )?; - if let Some(diagnostic) = &tool.diagnostic { - writeln!(out, " diagnostic: {diagnostic}")?; - } - } - } - - if item.diagnostics.is_empty() { - writeln!(out, " diagnostics: (none)")?; - } else { - writeln!(out, " diagnostics:")?; - for diagnostic in item.diagnostics.iter().take(MAX_SHOW_DIAGNOSTICS) { - writeln!(out, " - [{}] {}", diagnostic.kind, diagnostic.message)?; - } - if item.diagnostics.len() > MAX_SHOW_DIAGNOSTICS { - writeln!( - out, - " ... {} more diagnostic(s) omitted", - item.diagnostics.len() - MAX_SHOW_DIAGNOSTICS - )?; - } - } - - Ok(out) -} - fn join_or_none(values: &[String]) -> String { if values.is_empty() { "(none)".to_string() @@ -904,431 +540,6 @@ fn join_or_none(values: &[String]) -> String { } } -fn build_snapshot(args: &PluginCliArgs) -> Result { - let workspace = args - .workspace - .clone() - .map(Ok) - .unwrap_or_else(std::env::current_dir)?; - let workspace = fs::canonicalize(&workspace).map_err(|error| { - format!( - "workspace `{}` does not exist: {error}", - workspace.display() - ) - })?; - let config = load_plugin_config(args, &workspace)?; - - let options = PluginDiscoveryOptions { - workspace_root: workspace.clone(), - user_data_home: paths::data_dir(), - limits: PluginDiscoveryLimits::default(), - }; - let discovery = discover_plugins(&options); - let resolution = resolve_enabled_plugins(&config, &discovery); - - Ok(snapshot_from_resolution( - workspace, - &config, - &discovery, - &resolution, - )) -} - -fn load_plugin_config(args: &PluginCliArgs, workspace: &Path) -> Result { - let selector = args - .profile - .as_deref() - .map(ProfileSelector::parse_cli) - .unwrap_or(ProfileSelector::Default); - let resolved = ProfileResolver::new() - .with_workspace_base(workspace) - .resolve( - &selector, - ProfileResolveOptions::with_worker_name("plugin-inspect"), - )?; - Ok(resolved.manifest.plugins) -} - -fn snapshot_from_resolution( - workspace: PathBuf, - config: &PluginConfig, - discovery: &PluginDiscoveryReport, - resolution: &PluginResolution, -) -> PluginInspectionSnapshot { - let mut builders: BTreeMap = BTreeMap::new(); - - for package in &discovery.packages { - let key = package.identity.to_string(); - let builder = builders - .entry(key.clone()) - .or_insert_with(|| ItemBuilder::new(key)); - builder.discovered = true; - builder.package_present = true; - builder.source = Some(package.identity.source.to_string()); - builder.package = Some(package.package_label.clone()); - builder.package_path = Some(package.package_path.clone()); - builder.digest = Some(package.digest.clone()); - builder.version = Some(package.manifest.version.clone()); - builder.schema_version = Some(package.manifest.schema_version); - builder.api_version = Some(package.manifest.schema_version); - builder.declared_surfaces = surface_strings(package.manifest.surfaces.iter().copied()); - builder.requested_permissions = permission_strings(&package.manifest.permissions); - builder.tools = package - .manifest - .tools - .iter() - .map(|tool| ToolSummary { - name: tool.name.clone(), - permission: PluginPermission::tool(&tool.name).label(), - requested: permission_requested( - &package.manifest, - &PluginPermission::tool(&tool.name), - ), - granted: false, - eligible: false, - external_write: tool.external_write, - diagnostic: Some("plugin is discovered but not enabled".to_string()), - }) - .collect(); - } - - for enablement in &config.enabled { - let key = enablement.id.clone(); - let builder = builders - .entry(key.clone()) - .or_insert_with(|| ItemBuilder::new(key)); - builder.configured = true; - builder.enabled_surfaces = surface_strings(enablement.surfaces.iter().copied()); - builder.configured_grants = permission_strings(&enablement.grants.permissions); - builder.configured_request_grants = request_grant_strings(&enablement.grants.request); - builder.configured_websocket_grants = websocket_grant_strings(&enablement.grants.websocket); - builder.configured_fs_grants = fs_grant_strings(&enablement.grants.fs); - if let Ok(identity) = SourceQualifiedPluginId::parse(&enablement.id) { - builder - .source - .get_or_insert_with(|| identity.source.to_string()); - builder.package_path.get_or_insert_with(|| { - package_path_for_source( - &workspace, - identity.source, - &format!("{}.yoi-plugin", identity.local_id), - ) - }); - } - } - - for resolved in &resolution.resolved { - let key = resolved.identity.to_string(); - let builder = builders - .entry(key.clone()) - .or_insert_with(|| ItemBuilder::new(key)); - fill_resolved(builder, resolved); - } - - for diagnostic in discovery - .diagnostics - .iter() - .chain(resolution.diagnostics.iter()) - { - let rendered = DiagnosticSummary::from(diagnostic); - if let Some(reference) = diagnostic_reference(diagnostic) { - let builder = builders - .entry(reference.clone()) - .or_insert_with(|| ItemBuilder::new(reference)); - if let (Some(source), Some(package)) = (diagnostic.source, diagnostic.package.as_ref()) - { - builder.package_present = true; - builder.package.get_or_insert_with(|| package.clone()); - builder - .package_path - .get_or_insert_with(|| package_path_for_source(&workspace, source, package)); - } - builder.diagnostics.push(rendered); - } else if let (Some(source), Some(package)) = - (diagnostic.source, diagnostic.package.as_ref()) - { - let local_id = package_local_id(package); - let key = format!("{source}:{local_id}"); - let builder = builders - .entry(key.clone()) - .or_insert_with(|| ItemBuilder::new(key)); - builder.source.get_or_insert_with(|| source.to_string()); - builder.package_present = true; - builder.package.get_or_insert_with(|| package.clone()); - builder - .package_path - .get_or_insert_with(|| package_path_for_source(&workspace, source, package)); - builder.diagnostics.push(rendered); - } else { - let key = "".to_string(); - builders - .entry(key.clone()) - .or_insert_with(|| ItemBuilder::new(key)) - .diagnostics - .push(rendered); - } - } - - let items = builders - .into_values() - .map(ItemBuilder::finish) - .take(MAX_LIST_ITEMS + 1) - .collect(); - - PluginInspectionSnapshot { workspace, items } -} - -fn fill_resolved(builder: &mut ItemBuilder, resolved: &ResolvedPlugin) { - builder.configured = true; - builder.discovered = true; - builder.package_present = true; - builder.resolved = true; - builder.source = Some(resolved.identity.source.to_string()); - builder.package = Some(resolved.package_label.clone()); - builder.package_path = Some(resolved.package_path.clone()); - builder.digest = Some(resolved.digest.clone()); - builder.version = Some(resolved.manifest.version.clone()); - builder.schema_version = Some(resolved.manifest.schema_version); - builder.api_version = Some(resolved.manifest.schema_version); - builder.declared_surfaces = surface_strings(resolved.manifest.surfaces.iter().copied()); - builder.enabled_surfaces = surface_strings(resolved.enabled_surfaces.iter().copied()); - builder.requested_permissions = permission_strings(&resolved.manifest.permissions); - builder.configured_grants = permission_strings(&resolved.grants.permissions); - builder.configured_request_grants = request_grant_strings(&resolved.grants.request); - builder.configured_websocket_grants = websocket_grant_strings(&resolved.grants.websocket); - builder.configured_fs_grants = fs_grant_strings(&resolved.grants.fs); - - let record = ResolvedPluginRecord::from_resolved(resolved); - let static_runtime = inspect_resolved_plugin_static(&record); - for diagnostic in static_runtime - .runtime - .diagnostic - .iter() - .chain( - static_runtime - .host_apis - .iter() - .filter_map(|api| api.diagnostic.as_ref()), - ) - .chain( - static_runtime - .tools - .iter() - .filter_map(|tool| tool.diagnostic.as_ref()), - ) - .chain( - static_runtime - .services - .iter() - .filter_map(|service| service.diagnostic.as_ref()), - ) - .chain( - static_runtime - .ingresses - .iter() - .filter_map(|ingress| ingress.diagnostic.as_ref()), - ) - { - builder.diagnostics.push(DiagnosticSummary { - kind: "static_eligibility".to_string(), - message: bound_text(diagnostic.clone()), - }); - } - builder.static_eligible = static_runtime.statically_eligible(); - builder.tools = static_runtime - .tools - .iter() - .map(|tool| ToolSummary { - name: tool.name.clone(), - permission: tool.permission.clone(), - requested: tool.requested, - granted: tool.granted, - eligible: tool.eligible, - external_write: tool.external_write, - diagnostic: tool.diagnostic.clone().map(bound_text), - }) - .collect(); - builder.static_runtime = Some(static_runtime); -} - -fn diagnostic_reference(diagnostic: &PluginDiagnostic) -> Option { - diagnostic.identity.clone() -} - -fn select_item<'a>( - snapshot: &'a PluginInspectionSnapshot, - reference: &str, -) -> Result<&'a PluginInspectionItem> { - if let Some(item) = snapshot - .items - .iter() - .find(|item| item.reference == reference) - { - return Ok(item); - } - - let local_matches: Vec<_> = snapshot - .items - .iter() - .filter(|item| item.local_ref.as_deref() == Some(reference)) - .collect(); - match local_matches.as_slice() { - [item] => Ok(item), - [] => Err(format!( - "plugin package `{}` was not found", - bound_text(reference.to_string()) - ) - .into()), - _ => Err(format!( - "plugin package reference `{}` is ambiguous; use a source-qualified ref", - bound_text(reference.to_string()) - ) - .into()), - } -} - -fn surface_strings(surfaces: impl Iterator) -> Vec { - let mut values: Vec<_> = surfaces.map(|surface| surface.to_string()).collect(); - values.sort(); - values.dedup(); - values -} - -fn permission_strings(permissions: &[PluginPermission]) -> Vec { - let mut values: Vec<_> = permissions - .iter() - .map(|permission| permission.label()) - .collect(); - values.sort(); - values.dedup(); - values -} - -fn request_grant_strings(grants: &[manifest::plugin::PluginRequestGrant]) -> Vec { - let mut values: Vec<_> = grants.iter().map(|grant| grant.label()).collect(); - values.sort(); - values.dedup(); - values -} - -fn websocket_grant_strings(grants: &[manifest::plugin::PluginWebSocketGrant]) -> Vec { - let mut values: Vec<_> = grants.iter().map(|grant| grant.label()).collect(); - values.sort(); - values.dedup(); - values -} - -fn fs_grant_strings(grants: &[manifest::plugin::PluginFsGrant]) -> Vec { - let mut values: Vec<_> = grants.iter().map(|grant| grant.label()).collect(); - values.sort(); - values.dedup(); - values -} - -fn permission_requested(manifest: &PluginPackageManifest, permission: &PluginPermission) -> bool { - manifest - .permissions - .iter() - .any(|requested| requested == permission) -} - -fn package_local_id(package_label: &str) -> String { - package_label - .strip_suffix(".yoi-plugin") - .unwrap_or(package_label) - .to_string() -} - -fn package_path_for_source( - workspace: &Path, - source: PluginSourceKind, - package_label: &str, -) -> PathBuf { - match source { - PluginSourceKind::Project => workspace.join(".yoi/plugins").join(package_label), - PluginSourceKind::User => paths::data_dir() - .unwrap_or_else(|| PathBuf::from("")) - .join("yoi/plugins") - .join(package_label), - PluginSourceKind::Builtin => PathBuf::from("").join(package_label), - } -} - -fn local_ref(reference: &str) -> Option { - SourceQualifiedPluginId::parse(reference) - .ok() - .map(|identity| identity.local_id.to_string()) -} - -fn bound_text(mut text: String) -> String { - text = text.replace('\n', " "); - if text.chars().count() > MAX_TEXT { - text = text.chars().take(MAX_TEXT).collect::(); - text.push('…'); - } - text -} - -#[derive(Clone, Debug, Serialize)] -struct PluginInspectionSnapshot { - workspace: PathBuf, - items: Vec, -} - -#[derive(Clone, Debug, Serialize)] -struct PluginInspectionItem { - reference: String, - local_ref: Option, - status: String, - source: Option, - package: Option, - package_path: Option, - version: Option, - schema_version: Option, - api_version: Option, - digest: Option, - configured: bool, - discovered: bool, - resolved: bool, - static_eligible: bool, - declared_surfaces: Vec, - enabled_surfaces: Vec, - requested_permissions: Vec, - configured_grants: Vec, - configured_request_grants: Vec, - configured_websocket_grants: Vec, - configured_fs_grants: Vec, - tools: Vec, - static_runtime: Option, - diagnostics: Vec, -} - -#[derive(Clone, Debug, Serialize)] -struct ToolSummary { - name: String, - permission: String, - requested: bool, - granted: bool, - eligible: bool, - external_write: bool, - diagnostic: Option, -} - -#[derive(Clone, Debug, Serialize)] -struct DiagnosticSummary { - kind: String, - message: String, -} - -impl DiagnosticSummary { - fn from(diagnostic: &PluginDiagnostic) -> Self { - Self { - kind: diagnostic_kind(&diagnostic.kind).to_string(), - message: bound_text(diagnostic.message.clone()), - } - } -} - fn diagnostic_kind(kind: &PluginDiagnosticKind) -> &'static str { match kind { PluginDiagnosticKind::Missing => "missing", @@ -1346,1475 +557,68 @@ fn diagnostic_kind(kind: &PluginDiagnosticKind) -> &'static str { } } -#[derive(Clone, Debug)] -struct ItemBuilder { - reference: String, - configured: bool, - discovered: bool, - package_present: bool, - resolved: bool, - source: Option, - package: Option, - package_path: Option, - version: Option, - schema_version: Option, - api_version: Option, - digest: Option, - static_eligible: bool, - declared_surfaces: Vec, - enabled_surfaces: Vec, - requested_permissions: Vec, - configured_grants: Vec, - configured_request_grants: Vec, - configured_websocket_grants: Vec, - configured_fs_grants: Vec, - tools: Vec, - static_runtime: Option, - diagnostics: Vec, -} - -impl ItemBuilder { - fn new(reference: String) -> Self { - Self { - reference, - configured: false, - discovered: false, - package_present: false, - resolved: false, - source: None, - package: None, - package_path: None, - version: None, - schema_version: None, - api_version: None, - digest: None, - static_eligible: false, - declared_surfaces: Vec::new(), - enabled_surfaces: Vec::new(), - requested_permissions: Vec::new(), - configured_grants: Vec::new(), - configured_request_grants: Vec::new(), - configured_websocket_grants: Vec::new(), - configured_fs_grants: Vec::new(), - tools: Vec::new(), - static_runtime: None, - diagnostics: Vec::new(), - } - } - - fn finish(mut self) -> PluginInspectionItem { - self.diagnostics.sort_by(|left, right| { - (left.kind.as_str(), left.message.as_str()) - .cmp(&(right.kind.as_str(), right.message.as_str())) - }); - let usable_tool = self.tools.iter().any(|tool| tool.eligible); - let rejected_tool = self - .tools - .iter() - .any(|tool| !tool.eligible || tool.diagnostic.is_some()); - let static_runtime_rejected = self.static_runtime.as_ref().is_some_and(|runtime| { - !runtime.runtime.eligible - || runtime.runtime.diagnostic.is_some() - || runtime - .host_apis - .iter() - .any(|api| !api.eligible || api.diagnostic.is_some()) - }); - let has_diagnostic = - !self.diagnostics.is_empty() || rejected_tool || static_runtime_rejected; - let has_non_missing_diagnostic = self - .diagnostics - .iter() - .any(|diagnostic| diagnostic.kind != "missing"); - let status = if self.resolved { - if usable_tool && has_diagnostic { - "partial" - } else if usable_tool || (self.static_eligible && !self.enabled_surfaces.is_empty()) { - "active" - } else { - "rejected" - } - } else if self.discovered && !self.configured { - "disabled" - } else if self.configured && !self.discovered { - if self.package_present || has_non_missing_diagnostic { - "rejected" - } else { - "missing" - } - } else { - "rejected" - } - .to_string(); - let local_ref = local_ref(&self.reference); - PluginInspectionItem { - reference: self.reference, - local_ref, - status, - source: self.source, - package: self.package, - package_path: self.package_path, - version: self.version, - schema_version: self.schema_version, - api_version: self.api_version, - digest: self.digest, - configured: self.configured, - discovered: self.discovered, - resolved: self.resolved, - static_eligible: self.static_eligible, - declared_surfaces: self.declared_surfaces, - enabled_surfaces: self.enabled_surfaces, - requested_permissions: self.requested_permissions, - configured_grants: self.configured_grants, - configured_request_grants: self.configured_request_grants, - configured_websocket_grants: self.configured_websocket_grants, - configured_fs_grants: self.configured_fs_grants, - tools: self.tools, - static_runtime: self.static_runtime, - diagnostics: self.diagnostics, - } +fn bound_text(mut text: String) -> String { + text = text.replace('\n', " "); + if text.chars().count() > MAX_TEXT { + text = text.chars().take(MAX_TEXT).collect::(); + text.push('…'); } + text } #[cfg(test)] mod tests { use super::*; - use manifest::plugin::{PluginEnablementConfig, PluginExactVersion, PluginGrantConfig}; use tempfile::tempdir; - use worker::feature::plugin::{PluginPermissionEligibility, PluginRuntimeEligibility}; #[test] - fn active_list_and_show_json_are_structured_and_non_executing() { + fn explicit_template_check_and_pack_remain_offline_authoring_operations() { let dir = tempdir().unwrap(); - let workspace = dir.path(); - write_plugin_package(workspace, "echo"); - let config = enabled_config(workspace, true, None); - let snapshot = inspect_snapshot(workspace, &config); + let source = dir.path().join("example"); + let output = dir.path().join("example.yoi-plugin"); + let args = PluginCliArgs::default(); - assert_eq!(snapshot.items.len(), 1); - let item = select_item(&snapshot, "echo").unwrap(); - assert_eq!(item.status, "active"); - assert_eq!(item.tools[0].name, "Echo"); - assert!(item.static_eligible); - assert_eq!(item.package.as_deref(), Some("echo.yoi-plugin")); - assert_eq!(item.schema_version, Some(1)); - assert_eq!(item.api_version, Some(1)); - assert_eq!( - item.package_path.as_deref(), - Some(workspace.join(".yoi/plugins/echo.yoi-plugin").as_path()) - ); + let created = render_new("rust-component-tool", &source, &args).unwrap(); + assert!(created.contains("created plugin template")); - let list_json = serde_json::to_value(&snapshot).unwrap(); - assert_eq!(list_json["items"][0]["status"], "active"); - assert_eq!(list_json["items"][0]["schema_version"], 1); - assert_eq!(list_json["items"][0]["api_version"], 1); - assert_eq!( - list_json["items"][0]["package_path"], - workspace - .join(".yoi/plugins/echo.yoi-plugin") - .display() - .to_string() - ); - assert_eq!(list_json["items"][0]["enabled_surfaces"][0], "tool"); - assert_eq!(list_json["items"][0]["tools"][0]["granted"], true); + let checked = render_check(&source, &args).unwrap(); + assert!(checked.contains("[partial]")); + assert!(checked.contains("explicit input path")); - let show_json = serde_json::to_value(item).unwrap(); - assert_eq!(show_json["status"], "active"); - assert_eq!(show_json["schema_version"], 1); - assert_eq!(show_json["api_version"], 1); - assert_eq!( - show_json["package_path"], - workspace - .join(".yoi/plugins/echo.yoi-plugin") - .display() - .to_string() - ); - assert_eq!(show_json["configured_grants"][0], "surfaces.tool"); - assert_eq!(show_json["tools"][0]["permission"], "tool.Echo"); - - let show = render_item_human(&item).unwrap(); - assert!(show.contains("status: active")); - assert!(show.contains("schema_version: 1")); - assert!(show.contains("api_version: 1")); - assert!(show.contains("package_path:")); - assert!(show.contains("echo.yoi-plugin")); - assert!(show.contains("configured_grants: surfaces.tool, tool.Echo")); + let packed = render_pack(&source, Some(&output), &args).unwrap(); + assert!(packed.contains("[packed]")); + assert!(output.is_file()); } #[test] - fn render_show_distinguishes_request_grant_statuses_and_broad_targets() { - let item = PluginInspectionItem { - reference: "project:req".to_string(), - local_ref: Some("project:req".to_string()), - status: "configured".to_string(), - source: Some("project".to_string()), - package: Some("req".to_string()), - package_path: None, - version: Some("0.1.0".to_string()), - schema_version: Some(1), - api_version: Some(1), - digest: None, - configured: true, - discovered: true, - resolved: true, - static_eligible: true, - declared_surfaces: vec!["tool".to_string()], - enabled_surfaces: vec!["tool".to_string()], - requested_permissions: vec!["host_api.request".to_string(), "host_api.websocket".to_string()], - configured_grants: vec!["host_api.request".to_string(), "host_api.websocket".to_string()], - configured_request_grants: vec!["*://* GET * [broad-request]".to_string()], - configured_websocket_grants: vec!["*://* * [broad-websocket]".to_string()], - configured_fs_grants: Vec::new(), - tools: Vec::new(), - static_runtime: Some(PluginStaticInspection { - runtime: PluginRuntimeEligibility { - eligible: true, - status: "component".to_string(), - diagnostic: None, - }, - host_apis: vec![ - PluginPermissionEligibility { - permission: "host_api.request target https://api.example.test GET /v1/" - .to_string(), - requested: true, - granted: true, - eligible: true, - diagnostic: Some( - "covered by broad/arbitrary enabled request grant".to_string(), - ), - }, - PluginPermissionEligibility { - permission: "host_api.request grant *://* GET * [broad-request]" - .to_string(), - requested: true, - granted: true, - eligible: true, - diagnostic: Some( - "broad/arbitrary enabled request grant is constrained by manifest declarations" - .to_string(), - ), - }, - PluginPermissionEligibility { - permission: "host_api.websocket target wss://gateway.example.test /gateway" - .to_string(), - requested: true, - granted: false, - eligible: false, - diagnostic: Some( - "missing enabled WebSocket grant for manifest target".to_string(), - ), - }, - PluginPermissionEligibility { - permission: "host_api.websocket grant-only *://* * [broad-websocket]" - .to_string(), - requested: false, - granted: true, - eligible: false, - diagnostic: Some( - "enabled WebSocket grant has no matching manifest declaration; broad/arbitrary target" - .to_string(), - ), - }, - ], - tools: Vec::new(), - services: Vec::new(), - ingresses: Vec::new(), - }), - diagnostics: Vec::new(), - }; - - let json = serde_json::to_value(&item).unwrap(); - assert_eq!( - json["configured_request_grants"][0], - "*://* GET * [broad-request]" - ); - assert_eq!( - json["configured_websocket_grants"][0], - "*://* * [broad-websocket]" - ); - let human = render_item_human(&item).unwrap(); - assert!(human.contains("configured_websocket_grants: *://* * [broad-websocket]")); - assert!(human.contains("host_api.request target https://api.example.test")); - assert!(human.contains("requested=true granted=true eligible=true")); - assert!(human.contains("host_api.request grant *://*")); - assert!(human.contains("host_api.websocket target wss://gateway.example.test")); - assert!(human.contains("host_api.websocket grant-only *://*")); - assert!(human.contains("missing enabled WebSocket grant")); - assert!(human.contains("broad/arbitrary")); - } - - #[test] - fn service_only_enablement_ignores_unselected_tool_static_grants() { + fn malformed_explicit_package_is_rejected_without_discovery() { let dir = tempdir().unwrap(); - let workspace = dir.path(); - let digest = write_mixed_tool_service_package(workspace, "mixed"); - let mut config = PluginConfig::default(); - config.enabled.push(PluginEnablementConfig { - id: "project:mixed".to_string(), - digest: Some(digest.clone()), - version: Some(PluginExactVersion("0.1.0".to_string())), - surfaces: vec![PluginSurface::Service], - grants: PluginGrantConfig { - id: Some("project:mixed".to_string()), - version: Some(PluginExactVersion("0.1.0".to_string())), - digest: Some(digest), - permissions: vec![ - PluginPermission::surface(PluginSurface::Service), - PluginPermission::service("svc"), - ], - request: Vec::new(), - websocket: Vec::new(), - fs: Vec::new(), - }, - config: None, - }); + let package = dir.path().join("broken.yoi-plugin"); + fs::write(&package, b"not a package").unwrap(); - let snapshot = inspect_snapshot(workspace, &config); - let item = select_item(&snapshot, "project:mixed").unwrap(); - - assert_eq!(item.status, "active"); - assert!(item.static_eligible); - assert_eq!(item.enabled_surfaces, vec!["service"]); - assert!( - item.tools.is_empty(), - "unselected Tool must not be reported" - ); - assert!( - item.diagnostics - .iter() - .all(|diagnostic| !diagnostic.message.contains("tool.Echo")), - "unselected Tool grant diagnostics must not affect service-only enablement: {:#?}", - item.diagnostics - ); - - let show_json = serde_json::to_value(item).unwrap(); - assert_eq!(show_json["status"], "active"); - assert_eq!( - show_json["enabled_surfaces"], - serde_json::json!(["service"]) - ); - assert_eq!(show_json["tools"], serde_json::json!([])); - } - - #[test] - fn human_list_uses_required_status_vocabulary() { - let dir = tempdir().unwrap(); - let workspace = dir.path(); - write_plugin_package(workspace, "echo"); - write_plugin_package(workspace, "spare"); - let bad_digest = write_plugin_package(workspace, "bad"); - let mut config = enabled_config(workspace, true, None); - config.enabled.push(PluginEnablementConfig { - id: "project:missing".to_string(), - digest: None, - version: Some(PluginExactVersion("0.1.0".to_string())), - surfaces: vec![PluginSurface::Tool], - grants: PluginGrantConfig { - id: Some("project:missing".to_string()), - version: Some(PluginExactVersion("0.1.0".to_string())), - digest: None, - permissions: vec![ - PluginPermission::surface(PluginSurface::Tool), - PluginPermission::tool("Echo"), - ], - request: Vec::new(), - websocket: Vec::new(), - fs: Vec::new(), - }, - config: None, - }); - config.enabled.push(PluginEnablementConfig { - id: "project:bad".to_string(), - digest: Some("sha256:0000".to_string()), - version: Some(PluginExactVersion("0.1.0".to_string())), - surfaces: vec![PluginSurface::Tool], - grants: PluginGrantConfig { - id: Some("project:bad".to_string()), - version: Some(PluginExactVersion("0.1.0".to_string())), - digest: Some(bad_digest), - permissions: vec![ - PluginPermission::surface(PluginSurface::Tool), - PluginPermission::tool("Echo"), - ], - request: Vec::new(), - websocket: Vec::new(), - fs: Vec::new(), - }, - config: None, - }); - - let snapshot = inspect_snapshot(workspace, &config); - let statuses: std::collections::BTreeSet<_> = snapshot - .items - .iter() - .map(|item| item.status.as_str()) - .collect(); - assert_eq!( - statuses, - std::collections::BTreeSet::from(["active", "disabled", "missing", "rejected"]) - ); - let output = render_list_snapshot_human(&snapshot).unwrap(); - - assert!(output.contains("project:echo [active]")); - assert!(output.contains("project:spare [disabled]")); - assert!(output.contains("project:bad [rejected]")); - assert!(output.contains("project:missing [missing]")); - assert!(output.contains("schema_version=1")); - assert!(output.contains("api_version=1")); - assert!(output.contains("package_path=")); - assert!(output.contains("echo.yoi-plugin")); - assert!(output.contains("missing.yoi-plugin")); - assert!(output.contains("enabled_surfaces=tool")); - assert!(!output.contains("enabled-with-diagnostics")); - assert!(!output.contains("configured-")); - } - - #[test] - fn missing_ref_is_bounded_error() { - let snapshot = PluginInspectionSnapshot { - workspace: PathBuf::from("/tmp/workspace"), - items: Vec::new(), - }; - let error = select_item(&snapshot, "missing").unwrap_err().to_string(); - assert!(error.contains("missing")); - assert!(error.len() < 120); - } - - #[test] - fn grant_mismatch_is_inspectable() { - let dir = tempdir().unwrap(); - let workspace = dir.path(); - let digest = write_plugin_package(workspace, "echo"); - let mut config = enabled_config(workspace, false, Some(digest)); - config.enabled[0].grants.permissions = vec![PluginPermission::tool("Other")]; - - let snapshot = inspect_snapshot(workspace, &config); - let item = select_item(&snapshot, "project:echo").unwrap(); - assert_eq!(item.status, "rejected"); - assert!(!item.static_eligible); - assert!( - item.diagnostics - .iter() - .any(|diagnostic| diagnostic.kind == "static_eligibility") - ); - } - - #[test] - fn partial_status_represents_mixed_tool_usability() { - let dir = tempdir().unwrap(); - let workspace = dir.path(); - let digest = write_dual_tool_package(workspace, "echo"); - let mut config = enabled_config(workspace, true, Some(digest)); - config.enabled[0].grants.permissions = vec![ - PluginPermission::surface(PluginSurface::Tool), - PluginPermission::tool("Echo"), - ]; - - let snapshot = inspect_snapshot(workspace, &config); - let item = select_item(&snapshot, "project:echo").unwrap(); - - assert_eq!(item.status, "partial"); - assert!( - item.tools - .iter() - .any(|tool| tool.name == "Echo" && tool.eligible) - ); - assert!( - item.tools - .iter() - .any(|tool| tool.name == "Other" && !tool.eligible) - ); - } - - #[test] - fn invalid_manifest_and_digest_mismatch_are_rejected_diagnostics() { - let dir = tempdir().unwrap(); - let workspace = dir.path(); - fs::create_dir_all(workspace.join(".yoi/plugins")).unwrap(); - write_stored_zip( - &workspace.join(".yoi/plugins/invalid.yoi-plugin"), - &[("plugin.toml", b"not = [valid")], - ); - let invalid_snapshot = inspect_snapshot(workspace, &PluginConfig::default()); - let invalid_output = render_list_snapshot_human(&invalid_snapshot).unwrap(); - assert!(invalid_output.contains("[rejected]")); - assert!(invalid_output.contains("diagnostic:")); - - let digest = write_plugin_package(workspace, "bad"); - let mut config = PluginConfig::default(); - config.enabled.push(PluginEnablementConfig { - id: "project:bad".to_string(), - digest: Some("sha256:0000".to_string()), - version: Some(PluginExactVersion("0.1.0".to_string())), - surfaces: vec![PluginSurface::Tool], - grants: PluginGrantConfig { - id: Some("project:bad".to_string()), - version: Some(PluginExactVersion("0.1.0".to_string())), - digest: Some(digest), - permissions: vec![ - PluginPermission::surface(PluginSurface::Tool), - PluginPermission::tool("Echo"), - ], - request: Vec::new(), - websocket: Vec::new(), - fs: Vec::new(), - }, - config: None, - }); - let snapshot = inspect_snapshot(workspace, &config); - let item = select_item(&snapshot, "project:bad").unwrap(); - assert_eq!(item.status, "rejected"); - assert!( - item.diagnostics - .iter() - .any(|diagnostic| diagnostic.kind == "digest") - ); - } - - #[test] - fn legacy_raw_wasm_package_is_rejected_not_active_or_eligible() { - let dir = tempdir().unwrap(); - let workspace = dir.path(); - fs::create_dir_all(workspace.join(".yoi/plugins")).unwrap(); - write_stored_zip( - &workspace.join(".yoi/plugins/legacy.yoi-plugin"), - &[ - ("plugin.toml", plugin_legacy_manifest("legacy").as_bytes()), - ("plugin.wasm", b"not wasm"), - ], - ); - - let snapshot = inspect_snapshot(workspace, &PluginConfig::default()); - let legacy = select_item(&snapshot, "project:legacy").unwrap(); - assert_eq!(legacy.status, "rejected"); - assert!(!legacy.discovered); - assert!(!legacy.configured); - assert!(legacy.enabled_surfaces.is_empty()); - assert!(legacy.diagnostics.iter().any(|diagnostic| { - diagnostic.kind == "api" - && diagnostic.message.contains("legacy raw wasm") - && diagnostic.message.contains("wasm-component") - })); - - let list_output = render_list_snapshot_human(&snapshot).unwrap(); - assert!(list_output.contains("project:legacy [rejected]")); - assert!(!list_output.contains("project:legacy [active]")); - let show_output = render_item_human(legacy).unwrap(); - assert!(show_output.contains("status: rejected")); - assert!(show_output.contains("legacy raw wasm")); - } - - #[test] - fn configured_invalid_or_incompatible_package_is_rejected_not_missing() { - let dir = tempdir().unwrap(); - let workspace = dir.path(); - fs::create_dir_all(workspace.join(".yoi/plugins")).unwrap(); - write_stored_zip( - &workspace.join(".yoi/plugins/invalid.yoi-plugin"), - &[("plugin.toml", b"not = [valid")], - ); - let incompatible_manifest = plugin_manifest_with_schema("incompat", "Echo", 999); - write_stored_zip( - &workspace.join(".yoi/plugins/incompat.yoi-plugin"), - &[ - ("plugin.toml", incompatible_manifest.as_bytes()), - ("plugin.component.wasm", b"not wasm"), - ], - ); - let mut config = PluginConfig::default(); - config.enabled.push(enablement_without_digest( - "project:invalid", - "0.1.0", - &["Echo"], - )); - config.enabled.push(enablement_without_digest( - "project:incompat", - "0.1.0", - &["Echo"], - )); - - let snapshot = inspect_snapshot(workspace, &config); - let invalid = select_item(&snapshot, "project:invalid").unwrap(); - let incompatible = select_item(&snapshot, "project:incompat").unwrap(); - - assert_eq!(invalid.status, "rejected"); - assert_eq!(incompatible.status, "rejected"); - assert!(invalid.configured); - assert!(!invalid.discovered); - assert!(incompatible.configured); - assert!(!incompatible.discovered); - assert!(!invalid.diagnostics.is_empty()); - assert!( - incompatible - .diagnostics - .iter() - .any(|diagnostic| diagnostic.kind == "api") - ); - - let list_json = serde_json::to_value(&snapshot).unwrap(); - assert!(list_json["items"].as_array().unwrap().iter().any(|item| { - item["reference"] == "project:invalid" - && item["status"] == "rejected" - && item["package_path"] - .as_str() - .unwrap_or_default() - .ends_with(".yoi/plugins/invalid.yoi-plugin") - })); - let show_json = serde_json::to_value(incompatible).unwrap(); - assert_eq!(show_json["status"], "rejected"); - assert!( - show_json["diagnostics"][0]["message"] - .as_str() - .unwrap_or_default() - .contains("unsupported") - ); - - let list_output = render_list_snapshot_human(&snapshot).unwrap(); - assert!(list_output.contains("project:invalid [rejected]")); - assert!(list_output.contains("project:incompat [rejected]")); - assert!(!list_output.contains("project:invalid [missing]")); - let show_output = render_item_human(invalid).unwrap(); - assert!(show_output.contains("status: rejected")); - assert!(show_output.contains("diagnostics:")); - } - - #[test] - fn configured_present_package_with_missing_manifest_entries_is_rejected_not_missing() { - let dir = tempdir().unwrap(); - let workspace = dir.path(); - fs::create_dir_all(workspace.join(".yoi/plugins")).unwrap(); - write_stored_zip( - &workspace.join(".yoi/plugins/no_manifest.yoi-plugin"), - &[("plugin.component.wasm", b"not wasm")], - ); - let missing_runtime_manifest = plugin_manifest_missing_runtime_entry("missing_runtime"); - write_stored_zip( - &workspace.join(".yoi/plugins/missing_runtime.yoi-plugin"), - &[("plugin.toml", missing_runtime_manifest.as_bytes())], - ); - let mut config = PluginConfig::default(); - config.enabled.push(enablement_without_digest( - "project:no_manifest", - "0.1.0", - &["Echo"], - )); - config.enabled.push(enablement_without_digest( - "project:missing_runtime", - "0.1.0", - &["Echo"], - )); - - let snapshot = inspect_snapshot(workspace, &config); - let no_manifest = select_item(&snapshot, "project:no_manifest").unwrap(); - let missing_runtime = select_item(&snapshot, "project:missing_runtime").unwrap(); - - assert_eq!(no_manifest.status, "rejected"); - assert_eq!(missing_runtime.status, "rejected"); - assert!(no_manifest.configured); - assert!(!no_manifest.discovered); - assert!(missing_runtime.configured); - assert!(!missing_runtime.discovered); - assert!( - no_manifest - .diagnostics - .iter() - .any(|diagnostic| diagnostic.kind == "missing" - && diagnostic.message.contains("plugin.toml")) - ); - assert!( - missing_runtime - .diagnostics - .iter() - .any(|diagnostic| diagnostic.kind == "missing" - && diagnostic.message.contains("path not present")) - ); - - let list_json = serde_json::to_value(&snapshot).unwrap(); - assert!(list_json["items"].as_array().unwrap().iter().any(|item| { - item["reference"] == "project:no_manifest" - && item["status"] == "rejected" - && item["diagnostics"][0]["kind"] == "missing" - })); - let show_json = serde_json::to_value(missing_runtime).unwrap(); - assert_eq!(show_json["status"], "rejected"); - assert_eq!(show_json["diagnostics"][0]["kind"], "missing"); - - let list_output = render_list_snapshot_human(&snapshot).unwrap(); - assert!(list_output.contains("project:no_manifest [rejected]")); - assert!(list_output.contains("project:missing_runtime [rejected]")); - assert!(!list_output.contains("project:no_manifest [missing]")); - assert!(!list_output.contains("project:missing_runtime [missing]")); - let show_output = render_item_human(no_manifest).unwrap(); - assert!(show_output.contains("status: rejected")); - assert!(show_output.contains("plugin.toml")); - } - - #[test] - fn invalid_tool_schema_and_name_are_rejected_in_json_and_human_output() { - let dir = tempdir().unwrap(); - let workspace = dir.path(); - let bad_schema_manifest = plugin_manifest("bad_schema", "Echo", "string", &["Echo"]); - let bad_name_manifest = plugin_manifest("bad_name", "Bad Tool", "object", &["Bad Tool"]); - let bad_schema_digest = - write_plugin_manifest(workspace, "bad_schema", &bad_schema_manifest); - let bad_name_digest = write_plugin_manifest(workspace, "bad_name", &bad_name_manifest); - let mut config = PluginConfig::default(); - config.enabled.push(enablement( - "project:bad_schema", - "0.1.0", - bad_schema_digest, - &["Echo"], - )); - config.enabled.push(enablement( - "project:bad_name", - "0.1.0", - bad_name_digest, - &["Bad Tool"], - )); - - let snapshot = inspect_snapshot(workspace, &config); - let bad_schema = select_item(&snapshot, "project:bad_schema").unwrap(); - let bad_name = select_item(&snapshot, "project:bad_name").unwrap(); - - assert_eq!(bad_schema.status, "rejected"); - assert_eq!(bad_name.status, "rejected"); - assert!(!bad_schema.tools[0].eligible); - assert!(!bad_name.tools[0].eligible); - - let list_json = serde_json::to_value(&snapshot).unwrap(); - assert!(list_json["items"].as_array().unwrap().iter().any(|item| { - item["reference"] == "project:bad_schema" - && item["status"] == "rejected" - && item["tools"][0]["diagnostic"] - .as_str() - .unwrap_or_default() - .contains("invalid input_schema") - })); - let show_json = serde_json::to_value(bad_name).unwrap(); - assert_eq!(show_json["status"], "rejected"); - assert!( - show_json["tools"][0]["diagnostic"] - .as_str() - .unwrap_or_default() - .contains("invalid name") - ); - - let list_output = render_list_snapshot_human(&snapshot).unwrap(); - assert!(list_output.contains("project:bad_schema [rejected]")); - assert!(list_output.contains("project:bad_name [rejected]")); - let show_output = render_item_human(bad_schema).unwrap(); - assert!(show_output.contains("invalid input_schema")); - assert!(show_output.contains("eligible=false")); - } - - #[test] - fn plugin_new_creates_template_files_and_refuses_non_empty_destination() { - let dir = tempdir().unwrap(); - let destination = dir.path().join("my-plugin"); - - let output = render_new( - "rust-component-tool", - &destination, - &PluginCliArgs::default(), - ) - .unwrap(); - - assert!(output.contains("created plugin template")); - for resource in RUST_COMPONENT_TOOL_TEMPLATE { - assert!( - destination.join(resource.path).is_file(), - "missing {}", - resource.path - ); - } - let check_json = render_check( - &destination, - &PluginCliArgs { - json: true, - ..PluginCliArgs::default() - }, - ) - .unwrap(); - let check_value: serde_json::Value = serde_json::from_str(&check_json).unwrap(); - assert_eq!(check_value["status"], "partial"); - assert_eq!(check_value["diagnostics"][0]["kind"], "placeholder"); - assert!( - check_value["diagnostics"][0]["message"] - .as_str() - .unwrap() - .contains("generated placeholder") - ); - assert!( - check_value["next_steps"] - .as_array() - .unwrap() - .iter() - .any(|step| step.as_str().unwrap_or_default().contains("Do not enable")) - ); - let human_check = render_check(&destination, &PluginCliArgs::default()).unwrap(); - assert!(human_check.contains("[partial]")); - assert!(human_check.contains("not ready to enable")); - - let service_destination = dir.path().join("my-service-plugin"); - let service_json = render_new( - "rust-component-service", - &service_destination, - &PluginCliArgs { - json: true, - ..PluginCliArgs::default() - }, - ) - .unwrap(); - let service_value: serde_json::Value = serde_json::from_str(&service_json).unwrap(); - assert_eq!(service_value["template"], "rust-component-service"); - assert!( - service_value["next_steps"] - .as_array() - .unwrap() - .iter() - .any(|step| step - .as_str() - .unwrap_or_default() - .contains("Service ingress")) - ); - for resource in RUST_COMPONENT_INSTANCE_TEMPLATE { - assert!( - service_destination.join(resource.path).is_file(), - "missing service {}", - resource.path - ); - } - let manifest = fs::read_to_string(service_destination.join("plugin.toml")).unwrap(); - assert!(manifest.contains("kind = \"wasm-component\"")); - assert!(manifest.contains("[[services]]")); - assert!(manifest.contains("[[ingresses]]")); - assert!(manifest.contains("{ kind = \"host_api\", api = \"websocket\" }")); - assert!(manifest.contains("[[websocket]]")); - assert!(manifest.contains("host = \"example.com\"")); - assert!(manifest.contains("path_prefixes = [\"/socket\"]")); - let source = fs::read_to_string(service_destination.join("src/lib.rs")).unwrap(); - assert!(source.contains("ServiceOutput::websocket_send")); - assert!(!source.contains("recv(timeout")); - let service_check = render_check(&service_destination, &PluginCliArgs::default()).unwrap(); - assert!(service_check.contains("plugin check:")); - assert!(service_check.contains("service")); - let service_package = dir.path().join("my-service-plugin.yoi-plugin"); - let service_pack_json = render_pack( - &service_destination, - Some(&service_package), - &PluginCliArgs { - json: true, - ..PluginCliArgs::default() - }, - ) - .unwrap(); - let service_pack_value: serde_json::Value = - serde_json::from_str(&service_pack_json).unwrap(); - assert_eq!(service_pack_value["status"], "packed"); - assert!(service_package.is_file()); - - let error = render_new( - "rust-component-tool", - &destination, - &PluginCliArgs::default(), - ) - .unwrap_err() - .to_string(); - assert!(error.contains("not empty")); - } - - #[cfg(unix)] - #[test] - fn plugin_new_refuses_symlink_destination_without_following_it() { - let dir = tempdir().unwrap(); - let target = dir.path().join("target"); - fs::create_dir_all(&target).unwrap(); - let link = dir.path().join("linkdest"); - std::os::unix::fs::symlink(&target, &link).unwrap(); - - let error = render_new("rust-component-tool", &link, &PluginCliArgs::default()) - .unwrap_err() - .to_string(); - - assert!(error.contains("symlink")); - assert!(!target.join("plugin.toml").exists()); - } - - #[test] - fn plugin_check_accepts_valid_directory_and_reports_json_shape() { - let dir = tempdir().unwrap(); - let plugin = dir.path().join("plugin"); - fs::create_dir_all(&plugin).unwrap(); - fs::write( - plugin.join("plugin.toml"), - plugin_manifest("echo", "echo", "object", &["echo"]), - ) - .unwrap(); - fs::write(plugin.join("plugin.component.wasm"), b"not wasm").unwrap(); - - let human = render_check(&plugin, &PluginCliArgs::default()).unwrap(); - assert!(human.contains("[active]")); - assert!(human.contains("digest=")); - assert!(human.contains("does not mutate config")); - - let json = render_check( - &plugin, - &PluginCliArgs { - json: true, - ..PluginCliArgs::default() - }, - ) - .unwrap(); - let value: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert_eq!(value["command"], "check"); - assert_eq!(value["status"], "active"); - assert_eq!(value["input_kind"], "directory"); - assert_eq!(value["package"]["reference"], "project:echo"); - assert_eq!(value["safety"]["no_plugin_execution"], true); - } - - #[test] - fn plugin_check_rejects_legacy_raw_wasm_package() { - let dir = tempdir().unwrap(); - let plugin = dir.path().join("legacy"); - fs::create_dir_all(&plugin).unwrap(); - fs::write(plugin.join("plugin.toml"), plugin_legacy_manifest("legacy")).unwrap(); - fs::write(plugin.join("plugin.wasm"), b"not wasm").unwrap(); - - let report = build_check_report(&plugin); + let report = build_check_report(&package); assert_eq!(report.status, "rejected"); - assert!(report.diagnostics.iter().any(|diagnostic| { - diagnostic.kind == "api" - && diagnostic.message.contains("legacy raw wasm") - && diagnostic.message.contains("wasm-component") - })); - let human = render_check_report(&report, &PluginCliArgs::default()).unwrap(); - assert!(human.contains("[rejected]")); - assert!(human.contains("legacy raw wasm")); - let json = render_check_report( - &report, - &PluginCliArgs { - json: true, - ..PluginCliArgs::default() - }, - ) - .unwrap(); - let value: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert_eq!(value["status"], "rejected"); - assert!( - value["diagnostics"][0]["message"] - .as_str() - .unwrap_or_default() - .contains("wasm-component") - ); + assert!(report.package.is_none()); + assert_eq!(report.diagnostics.len(), 1); } #[test] - fn plugin_check_rejects_invalid_manifest_and_missing_runtime_artifact() { - let dir = tempdir().unwrap(); - let invalid = dir.path().join("invalid"); - fs::create_dir_all(&invalid).unwrap(); - fs::write( - invalid.join("plugin.toml"), - "schema_version = 1\nid = [\"bad\"]\n", - ) - .unwrap(); - - let invalid_json = render_check( - &invalid, - &PluginCliArgs { - json: true, - ..PluginCliArgs::default() - }, - ) - .unwrap(); - let invalid_value: serde_json::Value = serde_json::from_str(&invalid_json).unwrap(); - assert_eq!(invalid_value["status"], "rejected"); - assert_eq!(invalid_value["diagnostics"][0]["phase"], "manifest"); - - let missing = dir.path().join("missing-runtime"); - fs::create_dir_all(&missing).unwrap(); - fs::write( - missing.join("plugin.toml"), - plugin_manifest_missing_runtime_entry("missing_runtime"), - ) - .unwrap(); - let missing_output = render_check(&missing, &PluginCliArgs::default()).unwrap(); - assert!(missing_output.contains("rejected")); - assert!(missing_output.contains("path not present")); - } - - #[test] - fn plugin_check_rejects_unsafe_package_archive() { - let dir = tempdir().unwrap(); - let package = dir.path().join("unsafe.yoi-plugin"); - write_stored_zip( - &package, - &[ - ( - "plugin.toml", - plugin_manifest("unsafe", "Echo", "object", &["Echo"]).as_bytes(), - ), - ("../escape.wasm", b"not wasm"), - ], - ); - - let output = render_check(&package, &PluginCliArgs::default()).unwrap(); - assert!(output.contains("rejected")); - assert!(output.contains("escapes")); - } - - #[test] - fn plugin_pack_is_deterministic_and_discoverable() { - let dir = tempdir().unwrap(); - let plugin = dir.path().join("plugin"); - fs::create_dir_all(&plugin).unwrap(); - fs::write( - plugin.join("plugin.toml"), - plugin_manifest("echo", "echo", "object", &["echo"]), - ) - .unwrap(); - fs::write(plugin.join("plugin.component.wasm"), b"not wasm").unwrap(); - let first = dir.path().join("first.yoi-plugin"); - let second = dir.path().join("second.yoi-plugin"); - - let first_json = render_pack( - &plugin, - Some(&first), - &PluginCliArgs { - json: true, - ..PluginCliArgs::default() - }, - ) - .unwrap(); - let second_json = render_pack( - &plugin, - Some(&second), - &PluginCliArgs { - json: true, - ..PluginCliArgs::default() - }, - ) - .unwrap(); - assert_eq!(fs::read(&first).unwrap(), fs::read(&second).unwrap()); - let first_value: serde_json::Value = serde_json::from_str(&first_json).unwrap(); - let second_value: serde_json::Value = serde_json::from_str(&second_json).unwrap(); - assert_eq!(first_value["command"], "pack"); - assert_eq!(first_value["status"], "packed"); - assert_eq!( - first_value["package"]["digest"], - second_value["package"]["digest"] - ); - - let workspace = dir.path().join("workspace"); - fs::create_dir_all(workspace.join(".yoi/plugins")).unwrap(); - fs::copy(&first, workspace.join(".yoi/plugins/echo.yoi-plugin")).unwrap(); - let discovery = discover_plugins(&PluginDiscoveryOptions { - workspace_root: workspace, - user_data_home: None, - limits: PluginDiscoveryLimits::default(), - }); - assert_eq!(discovery.packages.len(), 1); - assert_eq!(discovery.packages[0].identity.to_string(), "project:echo"); - } - - #[test] - fn ambiguous_ref_is_bounded_error() { - let snapshot = PluginInspectionSnapshot { - workspace: PathBuf::from("/tmp/workspace"), - items: vec![ - ItemBuilder::new("project:echo".to_string()).finish(), - ItemBuilder::new("user:echo".to_string()).finish(), - ], - }; - - let error = select_item(&snapshot, "echo").unwrap_err().to_string(); - - assert!(error.contains("ambiguous")); - assert!(error.len() < 160); - } - - fn write_mixed_tool_service_package(workspace: &Path, id: &str) -> String { - let package_dir = workspace.join(".yoi/plugins"); - fs::create_dir_all(&package_dir).unwrap(); - let package = package_dir.join(format!("{id}.yoi-plugin")); - let manifest = format!( - r#"schema_version = 1 -id = "{id}" -name = "{id}" -version = "0.1.0" -description = "mixed surface package" -surfaces = ["tool", "service"] -permissions = [ - {{ kind = "surface", surface = "tool" }}, - {{ kind = "tool", name = "Echo" }}, - {{ kind = "surface", surface = "service" }}, - {{ kind = "service", name = "svc" }}, -] - -[runtime] -kind = "wasm-component" -world = "yoi:plugin/instance@1.0.0" -component = "plugin.component.wasm" - -[[tools]] -name = "Echo" -description = "unselected tool" -input_schema = {{ type = "object" }} - -[[services]] -name = "svc" -description = "selected service" -lifecycle = "host-managed" -"#, - ); - write_stored_zip( - &package, - &[ - ("plugin.toml", manifest.as_bytes()), - ("plugin.component.wasm", b"placeholder component bytes"), - ], - ); - let discovery = discover_plugins(&PluginDiscoveryOptions { - workspace_root: workspace.to_path_buf(), - user_data_home: None, - limits: PluginDiscoveryLimits::default(), - }); - discovery - .packages - .iter() - .find(|package| package.identity.local_id == id) - .unwrap() - .digest - .clone() - } - - fn inspect_snapshot(workspace: &Path, config: &PluginConfig) -> PluginInspectionSnapshot { - let discovery = discover_plugins(&PluginDiscoveryOptions { - workspace_root: workspace.to_path_buf(), - user_data_home: None, - limits: PluginDiscoveryLimits::default(), - }); - let resolution = resolve_enabled_plugins(config, &discovery); - snapshot_from_resolution(workspace.to_path_buf(), config, &discovery, &resolution) - } - - fn enabled_config( - workspace: &Path, - include_digest: bool, - digest_override: Option, - ) -> PluginConfig { - let digest = digest_override.unwrap_or_else(|| write_plugin_package(workspace, "echo")); - PluginConfig { - enabled: vec![PluginEnablementConfig { - id: "project:echo".to_string(), - digest: include_digest.then_some(digest.clone()), - version: Some(PluginExactVersion("0.1.0".to_string())), - surfaces: vec![PluginSurface::Tool], - grants: PluginGrantConfig { - id: Some("project:echo".to_string()), - version: Some(PluginExactVersion("0.1.0".to_string())), - digest: Some(digest), - permissions: vec![ - PluginPermission::surface(PluginSurface::Tool), - PluginPermission::tool("Echo"), - ], - request: Vec::new(), - websocket: Vec::new(), - fs: Vec::new(), - }, - config: None, - }], - ..PluginConfig::default() + fn cli_source_has_no_ambient_plugin_catalog_operations() { + let source = include_str!("plugin_cli.rs") + .split("#[cfg(test)]") + .next() + .unwrap(); + for forbidden in [ + "discover_plugins(", + "resolve_enabled_plugins(", + "PluginDiscoveryOptions", + "ProfileResolver", + ".yoi/plugins", + ] { + assert!( + !source.contains(forbidden), + "ambient Plugin authority returned through {forbidden}" + ); } } - - fn enablement( - id: &str, - version: &str, - digest: String, - tool_permissions: &[&str], - ) -> PluginEnablementConfig { - let mut permissions = vec![PluginPermission::surface(PluginSurface::Tool)]; - permissions.extend( - tool_permissions - .iter() - .map(|tool_name| PluginPermission::tool(*tool_name)), - ); - PluginEnablementConfig { - id: id.to_string(), - digest: Some(digest.clone()), - version: Some(PluginExactVersion(version.to_string())), - surfaces: vec![PluginSurface::Tool], - grants: PluginGrantConfig { - id: Some(id.to_string()), - version: Some(PluginExactVersion(version.to_string())), - digest: Some(digest), - permissions, - request: Vec::new(), - websocket: Vec::new(), - fs: Vec::new(), - }, - config: None, - } - } - - fn enablement_without_digest( - id: &str, - version: &str, - tool_permissions: &[&str], - ) -> PluginEnablementConfig { - let mut permissions = vec![PluginPermission::surface(PluginSurface::Tool)]; - permissions.extend( - tool_permissions - .iter() - .map(|tool_name| PluginPermission::tool(*tool_name)), - ); - PluginEnablementConfig { - id: id.to_string(), - digest: None, - version: Some(PluginExactVersion(version.to_string())), - surfaces: vec![PluginSurface::Tool], - grants: PluginGrantConfig { - id: Some(id.to_string()), - version: Some(PluginExactVersion(version.to_string())), - digest: None, - permissions, - request: Vec::new(), - websocket: Vec::new(), - fs: Vec::new(), - }, - config: None, - } - } - - fn plugin_manifest( - id: &str, - tool_name: &str, - schema_type: &str, - permission_tools: &[&str], - ) -> String { - plugin_manifest_with_schema_and_tool(id, tool_name, schema_type, permission_tools, 1) - } - - fn plugin_manifest_with_schema(id: &str, tool_name: &str, schema_version: u32) -> String { - plugin_manifest_with_schema_and_tool(id, tool_name, "object", &[tool_name], schema_version) - } - - fn plugin_manifest_missing_runtime_entry(id: &str) -> String { - format!( - r#" -schema_version = 1 -id = "{id}" -name = "{id}" -version = "0.1.0" -surfaces = ["tool"] -permissions = [{{ kind = "surface", surface = "tool" }}, {{ kind = "tool", name = "Echo" }}] - -[runtime] -kind = "wasm-component" -component = "missing.component.wasm" -world = "yoi:plugin/tool@1.0.0" - -[[tools]] -name = "Echo" -description = "Test tool" -input_schema = {{ type = "object" }} -"# - ) - } - - fn plugin_manifest_with_schema_and_tool( - id: &str, - tool_name: &str, - schema_type: &str, - permission_tools: &[&str], - schema_version: u32, - ) -> String { - let permissions = permission_tools - .iter() - .map(|tool| format!(r#"{{ kind = "tool", name = "{tool}" }}"#)) - .collect::>() - .join(", "); - format!( - r#" -schema_version = {schema_version} -id = "{id}" -name = "{id}" -version = "0.1.0" -surfaces = ["tool"] -permissions = [{{ kind = "surface", surface = "tool" }}, {permissions}] - -[runtime] -kind = "wasm-component" -component = "plugin.component.wasm" -world = "yoi:plugin/tool@1.0.0" - -[[tools]] -name = "{tool_name}" -description = "Test tool" -input_schema = {{ type = "{schema_type}" }} -"# - ) - } - - fn plugin_legacy_manifest(id: &str) -> String { - format!( - r#" -schema_version = 1 -id = "{id}" -name = "{id}" -version = "0.1.0" -surfaces = ["tool"] - -[runtime] -kind = "wasm" -entry = "plugin.wasm" -abi = "yoi-plugin-wasm-1" - -[[tools]] -name = "Echo" -description = "Legacy raw wasm tool" -input_schema = {{ type = "object" }} -"# - ) - } - - fn write_plugin_package(workspace: &Path, id: &str) -> String { - let manifest = format!( - r#" -schema_version = 1 -id = "{id}" -name = "{id}" -version = "0.1.0" -surfaces = ["tool"] -permissions = [{{ kind = "surface", surface = "tool" }}, {{ kind = "tool", name = "Echo" }}] - -[runtime] -kind = "wasm-component" -component = "plugin.component.wasm" -world = "yoi:plugin/tool@1.0.0" - -[[tools]] -name = "Echo" -description = "Echo input" -input_schema = {{ type = "object" }} -"# - ); - write_plugin_manifest(workspace, id, &manifest) - } - - fn write_dual_tool_package(workspace: &Path, id: &str) -> String { - let manifest = format!( - r#" -schema_version = 1 -id = "{id}" -name = "{id}" -version = "0.1.0" -surfaces = ["tool"] -permissions = [{{ kind = "surface", surface = "tool" }}, {{ kind = "tool", name = "Echo" }}, {{ kind = "tool", name = "Other" }}] - -[runtime] -kind = "wasm-component" -component = "plugin.component.wasm" -world = "yoi:plugin/tool@1.0.0" - -[[tools]] -name = "Echo" -description = "Echo input" -input_schema = {{ type = "object" }} - -[[tools]] -name = "Other" -description = "Other input" -input_schema = {{ type = "object" }} -"# - ); - write_plugin_manifest(workspace, id, &manifest) - } - - fn write_plugin_manifest(workspace: &Path, id: &str, manifest: &str) -> String { - let package_dir = workspace.join(".yoi/plugins"); - fs::create_dir_all(&package_dir).unwrap(); - let package = package_dir.join(format!("{id}.yoi-plugin")); - write_stored_zip( - &package, - &[ - ("plugin.toml", manifest.as_bytes()), - ("plugin.component.wasm", b"not wasm"), - ], - ); - - let discovery = discover_plugins(&PluginDiscoveryOptions { - workspace_root: workspace.to_path_buf(), - user_data_home: None, - limits: PluginDiscoveryLimits::default(), - }); - discovery - .packages - .iter() - .find(|package| package.identity.local_id == id) - .unwrap() - .digest - .clone() - } - - fn write_stored_zip(path: &Path, entries: &[(&str, &[u8])]) { - let mut bytes = Vec::new(); - let mut central = Vec::new(); - for (name, content) in entries { - let local_offset = bytes.len() as u32; - write_u32(&mut bytes, 0x0403_4b50); - write_u16(&mut bytes, 20); - write_u16(&mut bytes, 0x0800); - write_u16(&mut bytes, 0); - write_u16(&mut bytes, 0); - write_u16(&mut bytes, 0); - write_u32(&mut bytes, 0); - write_u32(&mut bytes, content.len() as u32); - write_u32(&mut bytes, content.len() as u32); - write_u16(&mut bytes, name.len() as u16); - write_u16(&mut bytes, 0); - bytes.extend_from_slice(name.as_bytes()); - bytes.extend_from_slice(content); - - write_u32(&mut central, 0x0201_4b50); - write_u16(&mut central, 20); - write_u16(&mut central, 20); - write_u16(&mut central, 0x0800); - write_u16(&mut central, 0); - write_u16(&mut central, 0); - write_u16(&mut central, 0); - write_u32(&mut central, 0); - write_u32(&mut central, content.len() as u32); - write_u32(&mut central, content.len() as u32); - write_u16(&mut central, name.len() as u16); - write_u16(&mut central, 0); - write_u16(&mut central, 0); - write_u16(&mut central, 0); - write_u16(&mut central, 0); - write_u32(&mut central, 0); - write_u32(&mut central, local_offset); - central.extend_from_slice(name.as_bytes()); - } - let central_offset = bytes.len() as u32; - bytes.extend_from_slice(¢ral); - write_u32(&mut bytes, 0x0605_4b50); - write_u16(&mut bytes, 0); - write_u16(&mut bytes, 0); - write_u16(&mut bytes, entries.len() as u16); - write_u16(&mut bytes, entries.len() as u16); - write_u32(&mut bytes, central.len() as u32); - write_u32(&mut bytes, central_offset); - write_u16(&mut bytes, 0); - fs::write(path, bytes).unwrap(); - } - - fn write_u16(bytes: &mut Vec, value: u16) { - bytes.extend_from_slice(&value.to_le_bytes()); - } - - fn write_u32(bytes: &mut Vec, value: u32) { - bytes.extend_from_slice(&value.to_le_bytes()); - } } diff --git a/docs/README.md b/docs/README.md index 6184056c..063965fc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,8 +14,8 @@ It is not a dumping ground for external research, old plans, API inventories, or 6. [`design/flow-state-graph.md`](design/flow-state-graph.md) — Workspace Flow sources, immutable revisions, transition attempts, and bounded internal verification. 7. [`design/profiles-manifests-prompts.md`](design/profiles-manifests-prompts.md) — reusable Profiles, resolved Manifests, and prompt resources. 8. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope. -9. [`design/plugin-packages.md`](design/plugin-packages.md) — plugin package distribution, discovery, and enablement boundaries. -10. [`development/plugin-development.md`](development/plugin-development.md) — how to build, package, enable, and inspect Yoi Plugins. +9. [`design/plugin-packages.md`](design/plugin-packages.md) — the retained offline package format and the prohibition on ambient Plugin authority. +10. [`development/plugin-development.md`](development/plugin-development.md) — explicit-path offline Plugin package authoring and inspection. 11. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records. 12. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions. 13. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary. diff --git a/docs/design/plugin-component-model.md b/docs/design/plugin-component-model.md index eb492650..e434d9a0 100644 --- a/docs/design/plugin-component-model.md +++ b/docs/design/plugin-component-model.md @@ -1,177 +1,29 @@ -# Plugin Component Model migration +# Plugin Component Model boundary -Yoi's original Plugin Tool runtime used a narrow core-WebAssembly ABI. That was the right MVP shape because it made sandboxing, bounded input/output, and fail-closed host imports explicit, but it is no longer the public authoring interface. +Dynamic Worker Plugin execution is not part of the current product. The `.yoi-plugin` component metadata retained in `manifest` is an offline package-format contract only. -The supported runtime kind is now `wasm-component`, using the WebAssembly Component Model for Plugin Tool authoring and host APIs. Component Model adoption means Plugin interfaces are described as typed WIT worlds and lowered through the canonical ABI, instead of every Plugin author or SDK wrapper hand-writing pointer/length memory plumbing. +## Current behavior -## What Component Model changes +- Worker creation and restore install no dynamic Plugin modules. +- Manifest/Profile input rejects `plugins` and `feature.plugins`. +- Runtime and Server startup perform no repository, ancestor, cwd, or user-data Plugin discovery. +- No persisted local `package_path` is execution authority. +- Only statically compiled trusted built-in Features contribute Worker capabilities. +- `yoi plugin check` parses an explicitly named directory or package without instantiating a component. -A core Wasm module exposes low-level functions and memory. Yoi's current Plugin Tool ABI is shaped like this: +The package validator may reject legacy core-Wasm artifacts and require Component Model metadata, but passing validation does not make an artifact installable or executable. -```text -export memory -export yoi_tool_call() -> i32 -import yoi:tool/tool_name_len() -> i32 -import yoi:tool/tool_name_read(ptr, len) -> i32 -import yoi:tool/input_len() -> i32 -import yoi:tool/input_read(ptr, len) -> i32 -import yoi:tool/output_write(ptr, len) -> i32 -``` +## Future platform constraints -This is small and auditable, but it makes raw ABI details part of the authoring model. A Component Model world can instead describe a typed contract: +A future Server Plugin platform may execute Wasmtime Component Model packages only after the architecture is implemented as a coherent authority boundary: -```wit -package yoi:plugin; +1. an operator installs an immutable package into Server-owned artifact authority; +2. a Workspace owner selects an installed package through an immutable Addon revision; +3. Backend authors a per-Worker execution plan containing exact identities, digests, configuration, and bounded grants; +4. Runtime fetches only Server-authorized digests and verifies package bytes and execution-plan identity; +5. Runtime instantiates a fresh bounded Wasmtime Store with no ambient WASI authority; and +6. restore uses the persisted execution plan and exact artifact rather than current Workspace settings or a filesystem path. -interface tool { - record request { - tool-name: string, - input-json: string, - } +Default components receive no filesystem, sockets, environment, clocks, randomness, subprocess, Workdir, broad Workspace client, credential, or network authority. Any host import must be narrow, typed, explicitly granted, live-revalidated where necessary, bounded, and audited. - record response { - output-json: string, - } - - variant tool-error { - invalid-input(string), - denied(string), - failed(string), - } - - run: func(req: request) -> result; -} - -world tool-plugin { - export tool; -} -``` - -The exact WIT is still design work, but the important boundary is fixed: the Plugin author sees typed values and generated bindings; the host sees typed imports/exports; Yoi still enforces package enablement and Plugin grants outside the component. - -## External patterns considered - -Common Wasm extension systems normally ship more than a runtime: - -- Extism-style systems provide host runtimes plus language PDKs. Plugin authors write normal typed functions while the PDK hides the raw ABI and host functions remain explicit. -- Spin-style systems combine a manifest, language SDK/templates, default-deny outbound/file capabilities, and Wasm components. -- wasmCloud-style systems separate components from capability providers and connect them through typed interfaces. -- The Component Model standardizes the interface layer with WIT and canonical ABI so host APIs can be versioned and bindings generated across languages. - -The shared lesson is that a usable Wasm Plugin system needs a manifest, explicit capabilities, generated or hand-written SDK bindings, examples/templates, inspection tooling, and a versioned ABI. Yoi already has the manifest/discovery/enablement/grant/runtime foundation; the missing long-term piece is the typed component authoring interface. - -## Yoi policy - -Adopting the Component Model must not change Yoi's authority model: - -- Package discovery is inventory only and does not register or execute a Plugin. -- Explicit enablement is required before any Tool surface is registered. -- Plugin grants are required before runtime execution and before `https` / `fs` / future host API calls. -- Component imports are not authority by themselves; host-side grant checks remain authoritative. -- Tool calls and Tool results continue through the ordinary ToolRegistry and Engine history path. -- No hidden context injection is introduced by component imports, resources, prompts, or SDK helpers. -- Plugin SDKs and templates are authoring aids, not trust boundaries. - -## Migration shape - -`runtime.kind = "wasm-component"` is the sole public Plugin runtime kind. Legacy raw core-Wasm declarations (`kind = "wasm"` / `abi = "yoi-plugin-wasm-1"`) are rejected by manifest validation and are surfaced only as bounded diagnostics; they are not active/eligible Plugins and are not executed. - -The migration is now focused on the component surface: - -1. Keep WIT packages/worlds for Tool Plugin and initial host APIs versioned under `resources/plugin/wit`. -2. Keep manifest/schema support centered on `runtime.kind = "wasm-component"`. -3. Keep the component runtime backend and typed host import/export binding as the active execution path. -4. Port future host API designs to WIT-compatible interfaces. -5. Keep the Rust PDK/template aligned with the component world. - -## Runtime/backend caution - -The legacy core-Wasm implementation used `wasmi` as a transitional backend. The active Plugin Tool runtime is now selected by package runtime metadata and executed through `wasmtime::component`; discovery and static inspection must continue to avoid executing package code. - -Keep the component backend selected by package runtime metadata and Profile/feature policy. Do not make all Plugin packages depend on component execution during discovery or inspection. - -## Relationship to pending host APIs - -`https` and `fs` host API Tickets should avoid baking in raw pointer/length interfaces as the long-term authoring contract. If they land before the component runtime, implement them in a way that can be represented as WIT records/results later, and document raw ABI wrappers as transitional. - -For example, `https` should be modeled as typed request/response data with explicit grant checks for host/method/path/body bounds. `fs` should be modeled as scoped read/list/write operations with path normalization and root-escape rejection. Those concepts translate well to WIT. - -## Non-goals - -- Component Model adoption does not imply WASI filesystem/network access. -- It does not replace Plugin grants with WIT imports. -- It does not introduce Service, Ingress, WebSocket, or inbound HTTP by itself. -- It does not merge Plugin and MCP. MCP remains a separate untrusted tool/resource/prompt bridge with its own policy. - -## Implemented runtime boundary - -Plugin Tool packages now select the runtime explicitly in `plugin.toml`: - -```toml -[runtime] -kind = "wasm-component" -component = "plugin.component.wasm" -world = "yoi:plugin/tool@1.0.0" -``` - -Legacy core-Wasm metadata is accepted only far enough to produce migration diagnostics: package checks and discovery reject `kind = "wasm"` / `abi = "yoi-plugin-wasm-1"`, `list`/`show` report those packages as rejected rather than active/eligible, and the active runtime path does not execute them. - -The component runtime uses `wasmtime::component` and expects the exported world -`yoi:plugin/tool@1.0.0` with a `call(tool-name: string, input-json: string) -> -string` export. The returned string is the normal ToolOutput JSON, so -registration and execution still flow through the existing ToolRegistry and -Engine Tool-result history path. - -Host imports are stable names under `yoi:host/*@1.0.0`; the repository WIT files -live in `resources/plugin/wit/`. Importing `yoi:host/request@1.0.0` or -`yoi:host/fs@1.0.0` is not authority. The runtime checks package grants before -component instantiation and checks again on every host call. No WASI filesystem, -network, environment, or other ambient imports are linked. - -Static discovery and `yoi plugin list/show` only parse package manifests and -reported runtime metadata. They do not instantiate or execute the component. -Wrong `world`, missing artifact metadata, missing `call` export, unsupported -imports, or core-Wasm bytes in a component package all fail closed with bounded -Plugin diagnostics or ordinary Tool errors. - -See `docs/examples/plugin-component-tool/lib.rs` and the embedded -`resources/plugin/templates/rust-component-tool/` starter for the preferred -Rust PDK authoring path. `yoi-plugin-pdk` is guest-side only: it re-exports -`wit-bindgen`, provides typed JSON input/output helpers, renders bounded -`ToolError` values as ordinary ToolOutput JSON, and does not depend on host -runtime crates or grant authority. Package authors should generate bindings from -`resources/plugin/wit`, build a component artifact, and set the component -runtime metadata above. - -### v1 request/response shape - -The v1 component world intentionally keeps Tool input, Tool output, and host API -payloads as JSON strings. This is a migration bridge that preserves the existing -ToolOutput schema, Tool history behavior, grant checks, and raw-Wasm host API -semantics while moving package authors onto WIT/canonical ABI bindings. -Structured WIT records for Tool requests/responses/errors and host request/FS -payloads are deferred to a follow-up API-design step rather than accidentally -omitted. - -## Instance lifecycle surface - -The first instance-capable world is `yoi:plugin/instance@1.0.0`. It moves -runtime ownership from per-Tool artifact execution to a host-managed -`PluginInstance`. The same instance handles Tool, Service, and Ingress surfaces, -so Plugin state/config/diagnostics can be shared without bypassing Yoi's normal -authority model. - -Important boundaries: - -- Tool calls still enter through `ToolRegistry` and return ordinary `ToolOutput` - that is visible in the Engine history path. -- Service and Ingress grants are separate from Tool grants. Sharing an instance - does not authorize a surface that lacks its own `surface.*` and per-surface - permission/grant. -- Ingress delivery accepts bounded typed untrusted events and returns explicit - JSON to the host. It does not call model Tools or mutate LLM context/history. -- Legacy raw-wasm and `yoi:plugin/tool@1.0.0` component packages are adapted - behind `PluginInstanceRegistry` for compatibility rather than executed through - a separate authority path. -- Host APIs such as `https` and `fs` remain independently grant-gated and still - reject ambient filesystem/network authority. +This future platform must not restore `.yoi/plugins`, user-data catalogs, cwd/ancestor discovery, native dynamic libraries, downloaded Cargo manifests, or local paths as compatibility authority. diff --git a/docs/design/plugin-packages.md b/docs/design/plugin-packages.md index 5ed5f9fb..2256541b 100644 --- a/docs/design/plugin-packages.md +++ b/docs/design/plugin-packages.md @@ -1,219 +1,31 @@ -# Plugin packages and discovery +# Plugin packages -Plugin packages are a distribution format, not an authority boundary. A package can be found on disk, inspected, validated, and cached without registering any Hook, exposing any Tool, starting any process, or initializing any WASM module. +Yoi retains `.yoi-plugin` as an offline authoring and inspection format. The format is not a Worker capability source. -The initial goal is a durable `.yoi-plugin` package format that later Tickets can implement in independent layers: discovery, archive validation/cache materialization, manifest/profile enablement, Plugin permission policy, declarative hooks, WASM runtime support, and any future MCP bridge. +## Current product boundary -## Package shape +Normal Worker creation, restore, Profile/Manifest resolution, Server/Runtime startup, and CLI execution do not discover Plugin catalogs from: -A `.yoi-plugin` file is a single-file archive. The archive format is a constrained ZIP profile because it is easy to inspect without executing code and can carry text manifests, WebAssembly Component Model modules, schemas, and license material. +- repository or ancestor `.yoi/plugins` directories; +- user-data Plugin directories; +- the current working directory; or +- persisted local `package_path` values. -The archive root must contain `plugin.toml` directly at the root. Packages should not require a wrapping directory whose name must match the plugin id. +`plugins` and `feature.plugins` are rejected in Worker Manifest/Profile input. Dynamic Plugin Tools, Services, Ingress handlers, and WASM components are not installed or executed. Worker capabilities come only from statically compiled trusted built-in Features. -Recommended root layout: +## Offline format operations -```text -plugin.toml # required package manifest -plugin.component.wasm # required when plugin.toml declares the component runtime -hooks/*.toml # optional declarative hook definitions -schemas/*.schema.json # optional JSON schemas for configuration or tool input/output -README.md # recommended human description -LICENSE* # recommended license text -assets/** # optional non-executable data assets +The CLI keeps only operations whose input or destination is explicit: + +```sh +yoi plugin new rust-component-tool ./example-plugin +yoi plugin check ./example-plugin +yoi plugin pack ./example-plugin --output ./example-plugin.yoi-plugin +yoi plugin check ./example-plugin.yoi-plugin ``` -The package layout is intentionally data-first. Placing a package in a store must never execute `module.wasm`, register hook metadata, or scan assets as prompts. Those steps happen only after explicit enablement and policy resolution. +These commands parse, validate, or write the named local artifact. They do not scan a Workspace, mutate Profile/Manifest configuration, install a package, register Worker Tools, or execute Plugin code. `plugin list` and `plugin show` were removed because their catalog semantics depended on ambient repository and user-data stores. -## `plugin.toml` +## Future installation authority -`plugin.toml` is the package authority for package identity and declared needs. It is not the authority for runtime grants. - -Currently implemented strict `plugin.toml` shape: - -```toml -schema_version = 1 -id = "example.summarizer" -name = "Example Summarizer" -version = "0.1.0" -description = "Adds a custom summary command." -surfaces = ["hook"] - -[[hooks]] -id = "summary" -file = "hooks/summary.md" -``` - -The package archive must contain both root `plugin.toml` and referenced runtime/content entries. Component runtime metadata is explicit and static inspection never executes the artifact: - -```toml -[runtime] -kind = "wasm-component" -component = "plugin.component.wasm" -world = "yoi:plugin/tool@1.0.0" -``` - -`wasm-component` is the public/recommended runtime kind, recorded in [Plugin Component Model migration](plugin-component-model.md). Legacy raw core-Wasm declarations (`kind = "wasm"` / `abi = "yoi-plugin-wasm-1"`) are retired: manifest validation rejects them and CLI inspection reports the package as rejected rather than active/eligible. - -First-pass fields accepted by the parser: - -- `schema_version`: required integer; unsupported versions fail closed. -- `id`: required unqualified local id. It is scoped by the source that discovered the package; it is not globally unique by itself. -- `name`, `version`, `description`: human metadata used in listings and diagnostics. -- `surfaces`: optional declared contribution surface names. -- `runtime`: optional component runtime metadata. Discovery records metadata and never executes it; unsupported/retired runtime kinds fail closed. -- `hooks`: optional hook metadata. Discovery records metadata and does not register hooks. - -Future descriptor sections such as `[package]`, `[permissions]`, richer `contributions`, or `runtime.kind = "declarative"` are aspirational and are intentionally rejected by the current strict parser until implemented safely. - -The `source` is not read from `plugin.toml`. It is assigned by the store that discovered the package. - -## Stores, sources, and trust - -Discovery should scan explicit stores and attach a source kind to each package: - -- `builtin:`: packages shipped with Yoi or installed as part of the binary distribution. -- `user:`: packages discovered under `${XDG_DATA_HOME:-~/.local/share}/yoi/plugins/`. -- `project:`: packages discovered under `/.yoi/plugins/`. - -Packages under `${XDG_DATA_HOME:-~/.local/share}/yoi/plugins/` or `/.yoi/plugins/` are discovery only. Their presence is never permission to register Hooks or Tools, initialize WASM, start processes, open files, use network providers, read secrets, or launch MCP servers. - -Trust differs by source, but none of the sources is self-authorizing: - -- Builtin packages can be trusted as shipped code/data, but still require explicit enablement for a Worker/Profile when they affect runtime behavior. -- User packages are local user-installed artifacts and should be visible to workspaces, but they cannot bypass manifest/profile/tool/scope/secret policy. -- Project packages are repository-controlled artifacts and should be treated as untrusted until explicitly enabled by local policy. Cloning a repository must not be enough to execute a package. - -## Identity and selector rules - -Runtime identity is source-qualified: `builtin:`, `user:`, and `project:` are distinct plugins even when `` is the same string. - -Durable enablement records should use source-qualified ids. Ambiguous unqualified ids fail closed. The implementation may offer convenience listing or search by bare id, but any operation that enables a package, grants permission, pins a digest, or records restored runtime state should require the fully qualified id. - -Collision handling: - -- Two packages with the same source-qualified id in the same effective store set are a discovery diagnostic and neither candidate is enabled implicitly. -- A `user:example` package does not override `builtin:example` unless a future explicit override rule says so. -- A `project:example` package does not override `user:example` or `builtin:example` by name alone. - -## Discovery versus enablement - -Discovery is a read-only inventory operation. It may report package metadata, validation errors, source, canonical store path, and deterministic digest. It must not initialize any runtime contribution. - -Enablement is a resolved runtime plan. It should come from Profile/manifest configuration or another explicit local policy layer, then be recorded into the resolved Manifest/session metadata used to start the Worker. Restored Workers should use that resolved enabled-plugin plan instead of silently re-running fresh discovery and picking newer packages. Fresh discovery must not silently upgrade a restored Worker. - -A minimal implemented enablement record is shaped like this. `version` is an exact package-version requirement; richer range constraints are deferred. `digest` is optional in authoring config, but fresh startup records the resolved digest into runtime metadata. - -```toml -[[plugins.enabled]] -id = "user:example" -version = "0.1.0" # optional exact package-version requirement -digest = "sha256:..." # optional pin in authoring, resolved in runtime metadata -config = { level = "concise" } -``` - -If no digest is pinned in authoring, fresh startup may resolve the newest acceptable discovered package according to explicit policy. Once a Worker is started, the resolved manifest/session metadata should record the exact source-qualified id and digest so restore is stable. - -## Permissions and grants - -Plugin permission declarations are requests, not grants. Effective grants are the result of Plugin-layer policy combined with existing Yoi authority layers: - -- resolved manifest/profile plugin enablement; -- Plugin policy for the source-qualified package id and deterministic digest; -- normal tool permission policy; -- filesystem scope checks; -- web provider enablement and network safety checks; -- secret references and secret-store policy; -- runtime limits for WASM or other execution engines. - -The Plugin package permission model must not reuse `worker::feature` HostAuthority or grant concepts. The feature layer is an API/contribution substrate; it is not a security boundary for untrusted plugin packages. Plugin grants need their own explicit policy that can fail closed before a Hook, Tool, WASM host function, provider bridge, or external runtime is exposed. - -When a package requests authority outside policy, diagnostics should explain the denied category and package identity without leaking raw secret values, environment contents, full private config, or large plugin-provided text. - -## Archive safety and materialization - -Archive handling should validate before runtime use: - -- Reject absolute paths, `..`, empty segments, Windows drive prefixes, NUL bytes, duplicate normalized paths, and paths that normalize outside the package root. -- Reject symlinks, hardlinks, device files, special files, and entries that are not regular files or directories. -- Enforce bounded extraction: maximum archive size, maximum expanded size, maximum entry count, maximum per-file size, and a compression-ratio limit. -- Validate every manifest-referenced path against the normalized entry set. -- Decode text manifests as UTF-8 and bound diagnostic excerpts. -- Ignore or normalize archive metadata such as mtimes, owners, groups, and executable bits; these should not affect runtime authority. - -After validation, compute a deterministic digest over the normalized materialized package, not over incidental ZIP ordering or timestamps. A stable digest input should include the format version, normalized relative path, file length, and file content hash for each regular file in sorted order. - -Runtime should materialize packages into a digest-keyed cache, for example: - -```text -/plugins/sha256-/ - plugin.toml - module.wasm - ... -``` - -Initialization should read from the digest-keyed cache, not directly from the mutable user/workspace store. This makes restore, diagnostics, and lock/pin behavior reproducible. - -Optional lock behavior can be added in a later Ticket: - -- an authoring-time pin in Profile/manifest configuration; -- a workspace lock file recording source-qualified id, version, source store, digest, and selected package path; -- restore metadata that records the actual digest used by the Worker. - -A lock or pin is selection authority, not execution authority. Enablement and grants are still required. - -## Diagnostics - -Diagnostics should be safe, bounded, and attributable: - -- Include source-qualified id when available, source kind, validation phase, and digest when computed. -- Prefer canonical store-relative paths or redacted absolute paths; avoid dumping large path lists. -- Never print raw secret values, provider tokens, environment dumps, or plugin-supplied opaque payloads. -- Treat package metadata and README text as untrusted content when showing it to an LLM or UI. -- Report discovery errors without disabling unrelated valid packages. - -## Runtime notes - -Declarative hooks are data contributions. Loading a declarative hook still requires explicit package enablement. Hook text should enter the system through the normal Hook/Engine paths, preserving the rule that model-affecting inputs are committed to history before they affect context when applicable. - -WASM packages should initialize only from the digest-keyed cache after enablement and grant resolution. The host should use a narrow ABI, bounded memory, fuel/time limits, bounded output, and explicit host functions. A WASM module must not inherit filesystem, network, tool, secret, process, or MCP authority from the package store path. - -Tool contributions from plugins should pass through the normal ToolRegistry and permission checks. Plugin-provided schemas can describe arguments, but schema presence is not permission to execute a tool. - -## MCP boundary - -MCP remains a separate feature-backed integration and is out of the initial Plugin package runtime. A `.yoi-plugin` package must not launch an MCP server or imply MCP enablement. - -A future MCP/plugin bridge would need its own Ticket covering external process authority, lifecycle, permission mapping, resource/prompt operations, diagnostics, and trust model. Until then, package metadata may mention compatibility for humans, but runtime packaging should ignore it. - -## Follow-up implementation cuts - -Good follow-up Tickets are intentionally separable: - -1. Manifest/Profile plugin enablement schema and resolved-session metadata, including restore behavior and digest pins. -2. Package discovery for builtin, user, and project stores with source-qualified identity and collision diagnostics. -3. `.yoi-plugin` archive validation, deterministic digest computation, and digest-keyed cache materialization. -4. Plugin-layer permission policy that combines package requests with existing tool/scope/web/secret/runtime allowlists without using `worker::feature` HostAuthority concepts. -5. Declarative hook package loading from enabled, materialized packages. -6. WASM package ABI, initialization limits, host-function grants, and Tool/Hook contribution plumbing. -7. Optional lock-file or pin update workflow for reproducible fresh startup. -8. Future MCP/plugin bridge, only if explicitly approved as a separate design and implementation effort. - -### Component Model Tool runtime - -Tool packages may use WebAssembly Component Model runtime metadata: - -```toml -[runtime] -kind = "wasm-component" -component = "plugin.component.wasm" -world = "yoi:plugin/tool@1.0.0" -``` - -For new Rust Tool packages, the preferred authoring path is the first-party -`yoi-plugin-pdk` plus the embedded `resources/plugin/templates/rust-component-tool/` -starter. The template uses a checkout-local path dependency for development and -documents a future out-of-tree pinned git `rev` dependency pattern. Crates.io -publication, remote template fetching, and package authoring commands are not -part of the current package/runtime contract. - -Legacy raw core-Wasm metadata remains documented only as a rejected migration diagnostic. Packages must not use `entry`/`abi`; discovery reports `kind = "wasm"` / `abi = "yoi-plugin-wasm-1"` packages as rejected without executing the artifact. Component execution still requires explicit package enablement, exact source/version/digest grants, and matching Tool/host API permissions. +Server-installed Plugin packages and Addons are future work. That platform must provide explicit immutable package identity and digest, Server-owned artifact delivery and Workspace selection, a Backend-authored per-Worker execution plan, Runtime verification, and sandboxed execution. It must not restore repository-local or user-data discovery as a compatibility fallback. diff --git a/docs/development/plugin-development.md b/docs/development/plugin-development.md index a19486ee..a94993f9 100644 --- a/docs/development/plugin-development.md +++ b/docs/development/plugin-development.md @@ -1,467 +1,45 @@ -# Plugin development +# Plugin package authoring -This guide is for building a Yoi Plugin outside the Yoi runtime codebase. It describes the current Plugin package shape, how to author a Tool Plugin, how to enable it in a workspace, and how to inspect/debug it. +Dynamic Worker Plugins are not currently installable or executable. This guide covers only the retained offline `.yoi-plugin` authoring format. -Yoi Plugins are intentionally explicit. The Plugin system is designed around the following host-side principles: +## Commands -- package discovery is inventory only; putting a package in `.yoi/plugins` does not enable, register, or execute it; -- a Profile/config entry must explicitly enable each Plugin package by source-qualified id, version, and digest; -- Plugin grants must allow each surface and host API before registration or execution can use it; -- Plugin code runs only through the configured sandbox runtime; -- Plugin packages do not inherit Worker workspace filesystem, network, environment, or Ticket authority; -- Tool calls and Tool results use the ordinary Yoi Tool/Engine history path; -- Plugin metadata, output, and diagnostics are untrusted unless Yoi host policy says otherwise. +Every retained command uses an explicit local input or destination: -## Design intent - -Yoi's Plugin platform is meant to make extension behavior reviewable before it becomes model-visible. A Plugin package should answer four separate questions: - -1. **What is this package?** `plugin.toml` declares identity, version, runtime, surfaces, requested permissions, and Tool schemas. -2. **Is it enabled here?** Workspace/Profile config chooses exact package refs and pinned digests. -3. **What may it do?** Plugin grants authorize Tool surfaces and host APIs such as `https` and `fs`. -4. **How does it interact with the model?** Tool schemas/results enter through ordinary ToolRegistry and Tool history paths. - -Keep these layers separate when designing a Plugin. Do not make package discovery imply enablement. Do not make SDK/PDK convenience imply authority. Do not treat Rust helper APIs or host API wrappers as permission grants. The host always re-checks authority at registration/execution/API-call boundaries. - -Yoi's preferred Plugin shapes are **Tool first** for request/response capabilities and **Service/Ingress** for host-dispatched inbound events. A good Tool Plugin has a narrow schema, deterministic input/output behavior, explicit side-effect metadata, and a minimal grant set. A Service Plugin should keep long-lived transport ownership in the host and react to bounded ingress events by returning output commands. - -Component Model authoring is the supported path for Plugins. Legacy raw core-Wasm manifests (`kind = "wasm"` / `abi = "yoi-plugin-wasm-1"`) are retired and rejected by `yoi plugin check`, discovery, `list`, and `show`; use the Rust PDK/template and `kind = "wasm-component"` instead. - -## Current status - -Implemented foundation: - -- package discovery from project/user Plugin stores; -- explicit enablement resolution; -- Tool surface registration; -- Plugin permission grants; -- Component Model Tool runtime; -- first-party Rust PDK helpers for Component Model Tool guests; -- embedded Rust Component Tool starter template; -- `https` and `fs` host APIs for Tool runtime; -- read-only `yoi plugin list/show` inspection; -- local first-party authoring commands: `yoi plugin new`, `yoi plugin check`, and `yoi plugin pack`. - -Still intentionally limited or separate from this guide: - -- multi-language SDK/PDK crates; -- Service / Ingress surfaces, where the host owns transport lifecycle, dispatches bounded ingress events, and consumes output commands such as `websocket_send`; -- WebSocket or inbound HTTP for bidirectional external event integrations; -- public registry/install/update/signature tooling. - -## Package locations - -Yoi discovers `.yoi-plugin` packages from: - -```text -/.yoi/plugins/*.yoi-plugin -${XDG_DATA_HOME:-~/.local/share}/yoi/plugins/*.yoi-plugin +```sh +yoi plugin new rust-component-tool ./example-plugin +yoi plugin new rust-component-service ./example-service +yoi plugin check ./example-plugin +yoi plugin pack ./example-plugin --output ./example-plugin.yoi-plugin +yoi plugin check ./example-plugin.yoi-plugin ``` -Use project packages for workspace-specific Plugins and user packages for personal reusable Plugins. Project packages should normally be committed only when the package content is safe and intended to be part of the project. +- `new` writes an embedded starter template to the named destination and refuses unsafe or non-empty destinations. +- `check` parses and validates the named directory or package without executing Plugin code. +- `pack` validates the named directory and writes a deterministic constrained archive. -## Package archive format +`list`, `show`, `--workspace`, and `--profile` are intentionally unavailable. They previously implied ambient Workspace/user catalog discovery. -A `.yoi-plugin` package is currently a bounded ZIP archive. For now, create it with stored entries, not compressed entries: +## Safety and authority -```bash -(cd my-plugin && zip -0 -r ../example.echo.yoi-plugin plugin.toml plugin.component.wasm) -``` +Offline package commands do not: -The archive root must contain `plugin.toml`. Runtime files referenced by the manifest must also be inside the archive. Yoi rejects path traversal, root escapes, malformed manifests, unsupported API/runtime versions, and other unsafe archive shapes. +- inspect repository or ancestor `.yoi/plugins` directories; +- inspect a user-data Plugin store; +- enable or install a package; +- mutate Profile or Manifest configuration; +- register Worker Tools, Services, or Ingress handlers; +- instantiate or execute a WASM component; or +- grant filesystem, network, secret, Ticket, or Workspace authority. -## Authoring CLI +`plugins` and `feature.plugins` are rejected by current Worker Manifest/Profile resolution. Statically compiled built-in Features are the only current Worker capability source. -Use the local authoring commands for first-party deterministic authoring. These commands never fetch remote templates, never run Plugin code, never mutate enablement configuration, and never generate or embed secrets. +## Package format -Create a Rust Component Tool starter from embedded resources: +A package directory contains `plugin.toml` plus the files named by that manifest. A packed `.yoi-plugin` uses the constrained deterministic archive format documented in [`../design/plugin-packages.md`](../design/plugin-packages.md). Validation rejects malformed metadata, unsafe paths, links, unsupported entries, bounds violations, digest inconsistencies, and legacy raw core-Wasm runtime declarations. -```bash -yoi plugin new rust-component-tool ./my-plugin -# or, for a host-dispatched Service/Ingress example: -yoi plugin new rust-component-service ./my-service-plugin -``` +Generated templates include a placeholder `plugin.component.wasm`. Replace it with a real built component before `check` can report the package as verified. A verified package is still only an offline artifact; verification does not install or authorize it. -`new` writes only inside the requested destination and refuses an existing non-empty destination or destination symlink. The generated template includes `plugin.toml`, Rust source, Cargo metadata, README next steps, and a placeholder `plugin.component.wasm` artifact so local `check`/`pack` validation can run immediately. Replace the placeholder with a real built component before enabling or executing the Plugin. +## Future Server Plugin platform -Validate a source directory or an existing `.yoi-plugin` archive: - -```bash -yoi plugin check ./my-plugin -yoi plugin check ./my-plugin --json -yoi plugin check ./my-plugin.yoi-plugin --json -``` - -`check` performs bounded static validation of the directory/archive shape, manifest, runtime declaration, referenced artifact presence, Tool schemas, permission declarations, host API declarations, archive safety, and deterministic digest when a package can be materialized. Component-world validation is metadata-only: it verifies the declared world string and runtime manifest shape, but it does not instantiate or execute the component. A generated placeholder component produces `status = "partial"` plus a diagnostic and is not enablement-ready until replaced. Invalid checks print the same structured report and exit non-zero. - -Pack a source directory into a deterministic stored `.yoi-plugin` archive: - -```bash -yoi plugin pack ./my-plugin -yoi plugin pack ./my-plugin --output ./my-plugin.yoi-plugin --json -``` - -`pack` rejects malformed manifests, missing runtime artifacts, symlinks/root escapes, and unsupported package shapes. The JSON output contains the stable package reference, output path, digest, entries, and safety flags. After review, copy the package to `.yoi/plugins/` (or the user Plugin store) and add explicit Profile/config enablement with pinned digest and grants; packing and checking do not do this for you. - -## Designing a Plugin - -Design a Plugin around the smallest reviewable contract that is useful to the model. - -For Tool Plugins: - -- expose one clear operation per Tool name; -- keep the input schema narrow and explicit; -- make side effects visible in the Tool name, description, and `external_write` / permission metadata; -- request only the host APIs needed for that Tool; -- prefer deterministic, structured output over conversational prose; -- return bounded summaries and content that are useful as Tool results; -- avoid hiding long workflows, background daemons, or inbound event handling inside a Tool call. - -A Tool should be a capability the model may choose to call, not a second agent runtime. If the desired behavior needs a long-lived connection, incoming events, or autonomous routing, put the transport lifecycle behind a Service/Ingress surface and let the host dispatch bounded events; do not stretch the Tool surface into a hidden polling loop. - -Design package permissions as a review surface. A reviewer should be able to read `plugin.toml` plus the enablement grants and understand: - -- what Tools become model-visible; -- what external side effects are possible; -- what hosts or paths can be touched; -- what data can flow back into ordinary Tool results. - -## Manifest: `plugin.toml` - -A minimal Component Model Tool Plugin manifest looks like this: - -```toml -schema_version = 1 -id = "example.echo" -name = "Example Echo" -version = "0.1.0" -surfaces = ["tool"] -permissions = [ - { kind = "surface", surface = "tool" }, - { kind = "tool", name = "example_echo" }, -] - -[runtime] -kind = "wasm-component" -component = "plugin.component.wasm" -world = "yoi:plugin/tool@1.0.0" - -[[tools]] -name = "example_echo" -description = "Echo input text." -input_schema = { type = "object", properties = { text = { type = "string" } }, required = ["text"], additionalProperties = false } -external_write = false -``` - -`wasm-component` is the public runtime kind. Legacy raw core-Wasm declarations such as `kind = "wasm"` / `abi = "yoi-plugin-wasm-1"` are no longer compatibility paths: static validation rejects them with a bounded diagnostic and they are not displayed as active/eligible Plugins. - -Do not rely on package presence to activate anything. Discovery only records inventory. - -## Rust PDK authoring - -Rust authoring with `yoi-plugin-pdk` is the supported path for new Tool Plugins. Raw core-Wasm ABI packages are retired and should be rewritten as Component Model packages before enabling. - -Create a starter with: - -```bash -yoi plugin new rust-component-tool ./my-plugin -# or, for a host-dispatched Service/Ingress example: -yoi plugin new rust-component-service ./my-service-plugin -``` - -The generated package contains: - -- `Cargo.toml` with a checkout-local `yoi-plugin-pdk` path dependency; -- `src/lib.rs` with the runtime binding setup and typed JSON Tool handling; -- `plugin.toml` targeting `kind = "wasm-component"`; -- README next steps and the out-of-tree pinned git `rev` dependency pattern. - -For an independent Plugin repository, replace the checkout-local path dependency with a pinned Yoi source revision. Use the repository root `.git` URL, not the browser `/src/branch/...` URL, and pin `rev` instead of tracking a moving branch: - -```toml -[dependencies] -serde = { version = "1.0", features = ["derive"] } -yoi-plugin-pdk = { git = "https://gitea.hareworks.net/Hare/yoi.git", package = "yoi-plugin-pdk", rev = "" } -``` - -As a Plugin author, treat the generated binding setup as template code. Edit the typed input/output structs and handler function rather than hand-writing runtime ABI glue. - -The important authoring shape is: - -```rust -use serde::{Deserialize, Serialize}; -use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput}; - -#[derive(Deserialize)] -struct EchoInput { - text: String, -} - -#[derive(Serialize)] -struct EchoOutput<'a> { - tool: &'a str, - text: String, -} - -fn handle_echo(ctx: ToolContext, input: EchoInput) -> Result { - ToolOutput::json( - format!("{} ok", ctx.tool_name()), - EchoOutput { - tool: ctx.tool_name(), - text: input.text, - }, - ) -} - -yoi_plugin_pdk::export_component_tool!(Plugin, handle_echo); -``` - -The PDK parses the runtime input string into a typed Rust value, passes a `ToolContext` containing the selected Tool name, and serializes `ToolOutput` JSON accepted by the current component runtime. `ToolError` values are structured and bounded, then rendered through the ordinary Tool result path; the component cannot inject hidden context. - -The PDK is guest-side only. It does not depend on Yoi host/runtime crates and does not grant filesystem, network, or environment authority. Host-side Plugin manifests and explicit enablement grants remain the authority boundary for Tool execution and for host APIs such as `https` and `fs`. - -The expected authoring flow is Rust-first: generate the starter, edit `src/lib.rs`, replace the local path dependency with a pinned `git` + `rev` dependency when the Plugin lives outside the Yoi checkout, build the Rust component artifact for `plugin.component.wasm`, run `yoi plugin check`, then `yoi plugin pack`. Crates.io publication and remote template fetching are intentionally deferred. Use `yoi plugin list/show` to inspect the packaged/enabled state before trying to execute the Tool. - -## Enabling a Plugin in a workspace - -Enablement belongs in the resolved Profile/config path for the workspace. Add it to the project Profile source selected by the Backend-managed, revisioned Workspace Virtual Config. Ambient files below the Workdir are not a Profile override layer and are not read when the Worker starts. Keep raw secrets and machine-local paths out of the Profile; refer to separately managed secrets where a capability supports them. - -The following TOML shows the equivalent low-level Profile/config artifact shape; it is not an ambient workspace override file: - -```toml -[feature.plugins] -enabled = true - -[[plugins.enabled]] -id = "project:example.echo" -version = "0.1.0" -digest = "sha256:" -surfaces = ["tool"] - -[plugins.enabled.grants] -id = "project:example.echo" -version = "0.1.0" -digest = "sha256:" -permissions = [ - { kind = "surface", surface = "tool" }, - { kind = "tool", name = "example_echo" }, -] -``` - -A source-qualified id is preferred: - -```text -project:example.echo -user:example.echo -builtin:example.echo -``` - -Unqualified ids can be ambiguous and should fail closed when more than one source matches. - -## Inspecting Plugins - -Use the read-only CLI inspection commands first: - -```bash -yoi plugin list -yoi plugin list --json -yoi plugin show project:example.echo -yoi plugin show project:example.echo --json -``` - -`list/show` must not execute Plugin code. They are intended to explain static state: - -- discovered packages; -- enabled vs disabled packages; -- missing packages referenced by enablement; -- invalid manifests; -- digest/version/source mismatches; -- granted/denied permissions; -- Tool registration eligibility; -- runtime metadata. - -Typical statuses: - -```text -active enabled and statically valid for at least one surface/tool -disabled discovered but not explicitly enabled -missing enablement references a package that is not discovered -rejected invalid manifest, incompatible API, digest mismatch, grant denial, etc. -partial usable package with some rejected surfaces/tools -``` - -## `request` host API - -The `request` host API is a one-shot outbound HTTP request API. It is meant for bounded Tool calls such as JSON POSTs or REST requests. It is not a WebSocket, SSE/event-stream, gateway, daemon, or inbound HTTP surface; persistent transports require a separate Plugin capability. - -Manifest permissions should request `host_api.request` in addition to the Tool permissions, and the package manifest must statically declare the URL targets it may call. Enablement grants must then allow the API and grant matching request targets. A grant without a matching manifest target is unsafe/unused and is shown as ineligible rather than expanding authority. - -Example manifest shape: - -```toml -permissions = [ - { kind = "surface", surface = "tool" }, - { kind = "tool", name = "http_post_json" }, - { kind = "host_api", api = "request" }, -] - -[[request]] -scheme = "https" -host = "api.example.com" -methods = ["POST"] -path_prefixes = ["/v1/"] -``` - -Example enablement grant shape: - -```toml -[plugins.enabled.grants] -permissions = [ - { kind = "surface", surface = "tool" }, - { kind = "tool", name = "http_post_json" }, - { kind = "host_api", api = "request" }, -] - -[[plugins.enabled.grants.request]] -scheme = "https" -host = "api.example.com" -methods = ["POST"] -path_prefixes = ["/v1/"] -``` - -Yoi checks method, scheme, host, optional port, and path prefix against both the manifest declaration and enablement grant before any network I/O. `http://localhost`, loopback, private, and other local targets are never ambient; they require an explicit manifest request target and an explicit matching grant. The explicit request target is the declared URL authority; a granted DNS hostname may resolve to a loopback/private address without requiring a separate literal-IP grant, so reviewers should grant hostnames only when that resolution behavior is intended. Broad targets such as `host = "*"` are supported only as visibly broad request permissions in inspection/diagnostics. Embedded credentials, credential-like headers, oversize requests/responses, WebSocket URLs/upgrades, and SSE/event-stream requests are rejected. - -## Service ingress and output commands - -Service Plugins export the `yoi:plugin/instance@1.0.0` world. The host starts one Plugin instance, owns external ingress transports, and calls `handle_ingress(name, event_json)` with bounded event envelopes. A WebSocket ingress event contains fields such as `kind`, `source`, `ingress_name`, `payload`, `created_at`, `attempt`, and `correlation_id`; the Rust PDK maps this to `PluginIngressEvent`. - -Service handlers return `ServiceOutput`, not ordinary ToolOutput. Side effects are requested through top-level `output_commands`. For a WebSocket reply, use the PDK helper: - -```rust -ServiceOutput::websocket_send( - &event, - "reply-1", - event.source.strip_prefix("websocket:").unwrap_or(&event.source), - "pong", -) -``` - -This serializes a `websocket_send` command with `source_event_id`, `command_id`, `payload.url`, `payload.text`, and a request timestamp. The host parses, bounds, grant-checks, and dispatches the command through the host-owned WebSocket driver. Do not create a long-running guest receive loop for Service integrations; incoming messages should arrive as ingress events. - -A minimal manifest shape is: - -```toml -surfaces = ["tool", "service", "ingress"] -permissions = [ - { kind = "surface", surface = "service" }, - { kind = "service", name = "example_service" }, - { kind = "surface", surface = "ingress" }, - { kind = "ingress", name = "example_ws" }, - { kind = "host_api", api = "websocket" }, -] - -[runtime] -kind = "wasm-component" -world = "yoi:plugin/instance@1.0.0" -component = "plugin.component.wasm" - -[[services]] -name = "example_service" -description = "Host-managed service instance." -lifecycle = "host-managed" - -[[ingresses]] -name = "example_ws" -description = "Handles host-owned WebSocket text events." -event_kinds = ["websocket_text", "websocket_close", "websocket_error"] -sources = ["websocket:wss://gateway.example.com/gateway"] -input_schema = { type = "object" } - -[[websocket]] -scheme = "wss" -host = "gateway.example.com" -path_prefixes = ["/gateway"] -``` - -The `host_api.websocket` permission and `[[websocket]]` target are required for `websocket_send` output commands. Runtime enablement grants must explicitly allow the same WebSocket target; the manifest declaration alone is not authority. - -Generate a fuller example with `yoi plugin new rust-component-service ./my-service-plugin`. - -## `websocket` host API - -The `websocket` host API is a separate grant-gated capability named `host_api.websocket`, not an extension of `host_api.request`. It opens host-owned WebSocket connections only when both the package manifest and enablement config declare matching targets. Tool-style/internal bounded use can still drive the lifecycle explicitly through `open`, `send-text`, `recv`, and `close`; incoming messages are returned only from bounded `recv` calls and are not injected into model context, history, Dashboard state, or Ticket state. Service Plugins should prefer the host-owned Service WebSocket driver instead of running a long-lived guest recv loop: declare a Service ingress source as `websocket:wss://host/path`, include the `websocket_text`/`websocket_close`/`websocket_error` event kinds you want delivered, and emit the Service output command `websocket_send` to send text back through the same grant-checked host connection. - -Example manifest shape: - -```toml -permissions = [ - { kind = "surface", surface = "tool" }, - { kind = "tool", name = "gateway_step" }, - { kind = "host_api", api = "websocket" }, -] - -[[websocket]] -scheme = "wss" -host = "gateway.example.com" -path_prefixes = ["/gateway"] -``` - -Example enablement grant shape: - -```toml -[plugins.enabled.grants] -permissions = [ - { kind = "surface", surface = "tool" }, - { kind = "tool", name = "gateway_step" }, - { kind = "host_api", api = "websocket" }, -] - -[[plugins.enabled.grants.websocket]] -scheme = "wss" -host = "gateway.example.com" -path_prefixes = ["/gateway"] -``` - -Yoi checks scheme (`ws`/`wss`), host, optional port, and path prefix against both declarations before opening the connection. Loopback/private/local targets are not ambient; they require explicit matching manifest and grant entries. Broad WebSocket targets such as `host = "*"` are reported as broad WebSocket diagnostics. v1 is text-only: `send-text` requires UTF-8, binary receive fails closed, guest-supplied handshake headers and embedded URL credentials are rejected, and SecretRef-based credential/header injection is future work. The host bounds open descriptors, text/message size, receive timeout, connection count, handle lifetime, and cleanup on close/instance stop/drop. - -## `fs` host API - -The `fs` host API is Plugin-scoped and grant-gated. Plugins do not inherit the Worker/workspace filesystem authority automatically. - -Example grant shape: - -```toml -[plugins.enabled.grants] -permissions = [ - { kind = "surface", surface = "tool" }, - { kind = "tool", name = "read_notes" }, - { kind = "host_api", api = "fs" }, -] - -[[plugins.enabled.grants.fs]] -root = "/absolute/path/to/plugin-data" -operations = ["read", "list"] -``` - -Yoi normalizes paths, rejects `..` traversal, rejects symlink/root escapes, and applies read/write/list bounds. Diagnostics must not include file contents. - -## Development checklist - -1. Create a package directory with `plugin.toml` and the runtime artifact. -2. Build the Wasm/component artifact. -3. Package with stored ZIP entries as `.yoi-plugin`. -4. Put it under `.yoi/plugins/` or the user Plugin store. -5. Run `yoi plugin list` and `yoi plugin show `. -6. Add explicit enablement and grants. -7. Re-run `yoi plugin show ` until status/diagnostics are correct. -8. Start Yoi with `features.plugins = true` in the resolved config/Profile. -9. Call the Tool and verify ordinary Tool result/history behavior. - -## Safety rules for Plugin authors - -- Do not assume ambient filesystem, network, or environment access. -- Do not put secrets in `plugin.toml` or package files. -- Request only the minimal host APIs and grants needed. -- Keep Tool output bounded and structured. -- Prefer Component Model authoring for new Plugins. -- Treat raw core-Wasm ABI support as retired; migration diagnostics may mention it, but authors should publish `wasm-component` packages. +Do not copy packages into repository or user-data catalogs. Future installation must go through Server-owned package/artifact authority, immutable identity and digest selection, Backend-authored Worker execution plans, Runtime verification, and sandboxed execution. That platform is separate work and must not reintroduce filesystem catalog fallback. diff --git a/resources/plugin/templates/rust-component-instance/README.md b/resources/plugin/templates/rust-component-instance/README.md index 8a9930bc..a68d591c 100644 --- a/resources/plugin/templates/rust-component-instance/README.md +++ b/resources/plugin/templates/rust-component-instance/README.md @@ -1,10 +1,7 @@ -# Rust Service Plugin Template +# Rust Component Model instance Plugin template -This template targets the Component Model-only runtime (`runtime.kind = "wasm-component"`) and exports the `yoi:plugin/instance@1.0.0` world. +This offline authoring template declares the proposed `yoi:plugin/instance@1.0.0` world. Yoi currently provides no Plugin installation or Worker execution path. -It demonstrates both authoring surfaces supported by a shared Plugin instance: +The example contains request/response Tool and Service ingress shapes that a future sandboxed Server Plugin platform may support. Their host API and grant metadata is inert in the current product and grants no authority. -- `example_echo` is an ordinary request/response Tool handler. -- `example_ws` is a Service ingress handler. The host owns WebSocket receive/reconnect work and dispatches bounded `websocket_text` events into `handle_ingress`. The guest replies by returning a `websocket_send` output command in `ServiceOutput`; do not run a guest-side `recv(timeout)` polling loop. The manifest declares `host_api.websocket` plus a matching `[[websocket]]` target for the example URL. Enablement grants must explicitly allow the same WebSocket target before the host will send output commands. - -Build with `cargo component build --release` (or the project-specific build command used by your Plugin packaging flow), then run `yoi plugin check` / `yoi plugin pack` from the generated Plugin directory. +Build with `cargo component build --release` (or the project-specific build command used by your package), then run `yoi plugin check .` and `yoi plugin pack .` against the explicit directory. Passing offline validation does not install or authorize the package. diff --git a/resources/plugin/templates/rust-component-tool/README.md b/resources/plugin/templates/rust-component-tool/README.md index 499eb8c2..c548bda1 100644 --- a/resources/plugin/templates/rust-component-tool/README.md +++ b/resources/plugin/templates/rust-component-tool/README.md @@ -4,13 +4,13 @@ This is the embedded starter template for a Yoi Component Model Tool Plugin writ ## What this template demonstrates -- `wasm-component` runtime targeting `yoi:plugin/tool@1.0.0`. +- Offline package metadata targeting the proposed `yoi:plugin/tool@1.0.0` world. - Guest-side runtime binding setup through the PDK. - Typed JSON input parsing through `run_json_tool` via `export_component_tool!`. - Typed JSON output serialization with `ToolOutput::json`. - Structured, bounded `ToolError` output for user-visible Tool failures. -The PDK is guest-side only. It does not grant filesystem, network, or environment authority. Host-side Plugin manifests and grants remain the authority boundary for Tool execution and host APIs. +The PDK is guest-side only. It does not grant filesystem, network, or environment authority. Yoi currently provides no Plugin installation or Worker execution path; this template is retained for offline package authoring. ## Checkout/development dependency @@ -26,7 +26,7 @@ If this template is copied to an independent Plugin repository, pin a Yoi source yoi-plugin-pdk = { git = "https://gitea.hareworks.net/Hare/yoi.git", package = "yoi-plugin-pdk", rev = "" } ``` -`plugin.component.wasm` in the template is a text placeholder so `yoi plugin check` and `yoi plugin pack` can exercise deterministic local package validation immediately. Replace it with a real built component before enabling or executing the Plugin. +`plugin.component.wasm` in the template is a text placeholder so `yoi plugin check` and `yoi plugin pack` can exercise deterministic local package validation immediately. Replace it with a real built component before treating the offline package as verified. ## Next steps @@ -34,4 +34,4 @@ yoi-plugin-pdk = { git = "https://gitea.hareworks.net/Hare/yoi.git", package = " 2. Replace `EchoInput` / `EchoOutput` and `handle_echo` with your Tool logic. 3. Build the Rust component artifact for `wasm32-unknown-unknown`, replacing the placeholder `plugin.component.wasm`. 4. Run `yoi plugin check .` and `yoi plugin pack . --output ./my-plugin.yoi-plugin`. -5. Copy the package to a Plugin store and add explicit enablement with pinned digest/grants after review. +5. Retain the package as an offline artifact. Yoi does not currently install or execute it. diff --git a/resources/profiles/default.dcdl b/resources/profiles/default.dcdl index 84f0f3c5..0bc74cae 100644 --- a/resources/profiles/default.dcdl +++ b/resources/profiles/default.dcdl @@ -29,6 +29,5 @@ import "./base.dcdl" // { complete = false; }; orchestration = { enabled = false; }; - plugins = { enabled = false; }; }; }