From bc26a7daa3fe98f6d8f269957175b508ada251dd Mon Sep 17 00:00:00 2001 From: Teryl Taylor Date: Tue, 14 Apr 2026 16:53:21 -0600 Subject: [PATCH 1/2] feat: initial revision rust core. Signed-off-by: Teryl Taylor --- Cargo.lock | 724 ++++++++++++++++++++++++ Cargo.toml | 30 + crates/README.md | 209 +++++++ crates/cpex-core/Cargo.toml | 27 + crates/cpex-core/src/config.rs | 15 + crates/cpex-core/src/context.rs | 123 ++++ crates/cpex-core/src/error.rs | 127 +++++ crates/cpex-core/src/executor.rs | 566 ++++++++++++++++++ crates/cpex-core/src/hooks/adapter.rs | 125 ++++ crates/cpex-core/src/hooks/macros.rs | 105 ++++ crates/cpex-core/src/hooks/mod.rs | 29 + crates/cpex-core/src/hooks/payload.rs | 174 ++++++ crates/cpex-core/src/hooks/trait_def.rs | 267 +++++++++ crates/cpex-core/src/hooks/types.rs | 190 +++++++ crates/cpex-core/src/lib.rs | 30 + crates/cpex-core/src/manager.rs | 656 +++++++++++++++++++++ crates/cpex-core/src/plugin.rs | 414 ++++++++++++++ crates/cpex-core/src/registry.rs | 621 ++++++++++++++++++++ crates/cpex-sdk/Cargo.toml | 22 + crates/cpex-sdk/src/lib.rs | 28 + 20 files changed, 4482 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/README.md create mode 100644 crates/cpex-core/Cargo.toml create mode 100644 crates/cpex-core/src/config.rs create mode 100644 crates/cpex-core/src/context.rs create mode 100644 crates/cpex-core/src/error.rs create mode 100644 crates/cpex-core/src/executor.rs create mode 100644 crates/cpex-core/src/hooks/adapter.rs create mode 100644 crates/cpex-core/src/hooks/macros.rs create mode 100644 crates/cpex-core/src/hooks/mod.rs create mode 100644 crates/cpex-core/src/hooks/payload.rs create mode 100644 crates/cpex-core/src/hooks/trait_def.rs create mode 100644 crates/cpex-core/src/hooks/types.rs create mode 100644 crates/cpex-core/src/lib.rs create mode 100644 crates/cpex-core/src/manager.rs create mode 100644 crates/cpex-core/src/plugin.rs create mode 100644 crates/cpex-core/src/registry.rs create mode 100644 crates/cpex-sdk/Cargo.toml create mode 100644 crates/cpex-sdk/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..5cbba123 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,724 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpex-core" +version = "0.1.0" +dependencies = [ + "async-trait", + "paste", + "serde", + "serde_json", + "serde_yaml", + "thiserror", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "cpex-sdk" +version = "0.1.0" +dependencies = [ + "async-trait", + "cpex-core", + "serde", + "serde_json", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..39710358 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,30 @@ +# Location: ./Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# Workspace root for the CPEX Rust crates. + +[workspace] +resolver = "2" +members = [ + "crates/cpex-core", + "crates/cpex-sdk", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +authors = ["Teryl Taylor"] + +[workspace.dependencies] +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" +serde_json = "1" +async-trait = "0.1" +thiserror = "2" +tracing = "0.1" +uuid = { version = "1", features = ["v4"] } +paste = "1" diff --git a/crates/README.md b/crates/README.md new file mode 100644 index 00000000..62ace2ba --- /dev/null +++ b/crates/README.md @@ -0,0 +1,209 @@ +# CPEX Rust Core + +Phase 1a of the CPEX Rust plugin runtime. Provides the core types, 5-phase executor, and plugin manager for the ContextForge Plugin Extensibility Framework. + +## Status + +Phase 1a — core runtime functional, no language bindings yet. + +- `cpex-core`: Plugin trait, typed hooks, 5-phase executor, plugin manager +- `cpex-sdk`: Lean re-exports for plugin authors + +## Prerequisites + +### Install Rust + +If you don't have Rust installed: + +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +Follow the prompts, then restart your shell or run: + +```bash +source $HOME/.cargo/env +``` + +Verify the installation: + +```bash +rustc --version # should be 1.75+ (we develop on 1.94) +cargo --version +``` + +### Update an existing installation + +```bash +rustup update stable +``` + +### Build and test + +From the repository root: + +```bash +# Check that everything compiles +cargo check -p cpex-core -p cpex-sdk + +# Run all tests +cargo test -p cpex-core -p cpex-sdk +``` + +## What It Does + +A typed, 5-phase plugin execution framework where: + +- **Hooks have typed payloads** — no JSON parsing for native Rust plugins +- **Extensions are separate from payloads** — capability-filtered per plugin, modified independently +- **The framework never clones payloads** — handlers receive borrows, clone only when modifying +- **Plugin configs are trusted** — `PluginRef` holds config from the loader, not from the plugin +- **Two invoke paths** — `invoke::()` (typed, Rust) and `invoke_by_name()` (dynamic, Python/Go) + +## Quick Example + +```rust +use std::sync::Arc; +use async_trait::async_trait; +use cpex_core::context::{GlobalContext, PluginContext}; +use cpex_core::error::{PluginError, PluginViolation}; +use cpex_core::hooks::payload::{Extensions, FilteredExtensions}; +use cpex_core::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{Plugin, PluginConfig, PluginMode, OnError}; + +// 1. Define a payload +#[derive(Debug, Clone)] +struct ToolCallPayload { + tool_name: String, + include_ssn: bool, +} +cpex_core::impl_plugin_payload!(ToolCallPayload); + +// 2. Define a hook type +struct ToolPreInvoke; +impl HookTypeDef for ToolPreInvoke { + type Payload = ToolCallPayload; + type Result = PluginResult; + const NAME: &'static str = "tool_pre_invoke"; +} + +// 3. Write a plugin +struct SsnGuard { config: PluginConfig } + +#[async_trait] +impl Plugin for SsnGuard { + fn config(&self) -> &PluginConfig { &self.config } + async fn initialize(&self) -> Result<(), PluginError> { Ok(()) } + async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } +} + +impl HookHandler for SsnGuard { + fn handle( + &self, + payload: &ToolCallPayload, // borrow — zero cost + _extensions: &FilteredExtensions, + _ctx: &PluginContext, + ) -> PluginResult { + if payload.include_ssn { + PluginResult::deny(PluginViolation::new("ssn_denied", "Requires permission")) + } else { + PluginResult::allow() + } + } +} + +// 4. Register and invoke +async fn run() { + let mut manager = PluginManager::default(); + + let config = PluginConfig { + name: "ssn-guard".into(), + kind: "builtin".into(), + hooks: vec!["tool_pre_invoke".into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + ..Default::default() + }; + + let plugin = Arc::new(SsnGuard { config: config.clone() }); + manager.register_handler::(plugin, config).unwrap(); + manager.initialize().await.unwrap(); + + let payload = ToolCallPayload { + tool_name: "get_compensation".into(), + include_ssn: true, + }; + + let result = manager + .invoke::(payload, Extensions::default(), &GlobalContext::new("req-1")) + .await; + + assert!(!result.allowed); // denied — SSN access blocked +} +``` + +## Crate Structure + +``` +crates/ +├── cpex-core/src/ +│ ├── lib.rs — module declarations +│ ├── plugin.rs — Plugin trait (lifecycle), PluginConfig, PluginMode, OnError, PluginCondition +│ ├── error.rs — PluginError, PluginViolation +│ ├── context.rs — GlobalContext, PluginContext +│ ├── hooks/ +│ │ ├── payload.rs — PluginPayload trait (object-safe), Extensions, FilteredExtensions +│ │ ├── trait_def.rs — HookTypeDef, HookHandler, PluginResult +│ │ ├── adapter.rs — TypedHandlerAdapter (bridges typed handlers to type-erased dispatch) +│ │ ├── macros.rs — define_hook! macro +│ │ └── types.rs — HookType (string wrapper), hook_names, cmf_hook_names +│ ├── registry.rs — PluginRef (trusted config), PluginRegistry, AnyHookHandler, HookEntry +│ ├── executor.rs — 5-phase engine, PipelineResult, ErasedResultFields +│ ├── manager.rs — PluginManager (register_handler, invoke, invoke_by_name, lifecycle) +│ └── config.rs — (stub — unified YAML parsing, Phase 2) +└── cpex-sdk/src/ + └── lib.rs — lean re-exports for plugin authors +``` + +## 5-Phase Execution Model + +``` +SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET +``` + +| Phase | Can Block? | Can Modify? | Execution | +|-------|------------|-------------|-----------| +| Sequential | Yes | Yes (clone) | Serial, chained | +| Transform | No | Yes (clone) | Serial, chained | +| Audit | No | No | Serial | +| Concurrent | Yes | No | Parallel | +| FireAndForget | No | No | Background | + +All handlers receive `&Payload` (borrow). The framework holds ownership. Modified payloads are returned in `PluginResult::modified_payload` and replace the current payload in the pipeline. + +## Key Design Decisions + +- **PluginRef trust model** — configs come from the config loader, not from `plugin.config()`. Prevents plugins from tampering with their own priority, mode, or capabilities. +- **Borrow-based handlers** — handlers receive `&Payload`, not owned. Framework never clones. Plugins clone only when modifying. Enforced by Rust's borrow checker at compile time. +- **Single `invoke()` path** — one method on `AnyHookHandler`, not separate `invoke_owned`/`invoke_ref`. Simpler API, same behavior. +- **`PluginPayload` trait** — object-safe base for all payloads. `Box` instead of `Box` — type errors caught at compile time. +- **Extensions separate from payload** — capability-filtered per plugin, modified independently. Extension-only changes don't clone the payload. + +## Tests + +```bash +cargo test -p cpex-core -p cpex-sdk +``` + +27 unit tests + 6 doc tests covering: registration, priority ordering, trusted config tamper protection, 5-phase execution, allow/deny/modify results, lifecycle management, typed and dynamic invoke paths. + +## What's Next + +- **Phase 1b**: `cpex-ffi` + Go bindings (first language binding) +- **Phase 1c**: Conformance test corpus (YAML scenarios, Python + Rust) +- **Phase 2**: Unified YAML config parsing +- **Phase 3**: Full CMF extension types (MonotonicSet, Guarded, MetaExtension, etc.) + +See [CPEX Rust Core Proposal](../docs/cpex-rust-core-proposal.md) for the full roadmap. diff --git a/crates/cpex-core/Cargo.toml b/crates/cpex-core/Cargo.toml new file mode 100644 index 00000000..57421209 --- /dev/null +++ b/crates/cpex-core/Cargo.toml @@ -0,0 +1,27 @@ +# Location: ./crates/cpex-core/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# CPEX Core — pure Rust plugin runtime with no FFI dependencies. +# Contains the PluginManager, 5-phase executor, hook registry, +# config parser, and all core types. + +[package] +name = "cpex-core" +description = "CPEX plugin runtime core — PluginManager, executor, hooks, and config." +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +tokio = { workspace = true } +serde = { workspace = true } +serde_yaml = { workspace = true } +serde_json = { workspace = true } +async-trait = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } +paste = { workspace = true } diff --git a/crates/cpex-core/src/config.rs b/crates/cpex-core/src/config.rs new file mode 100644 index 00000000..02496747 --- /dev/null +++ b/crates/cpex-core/src/config.rs @@ -0,0 +1,15 @@ +// Location: ./crates/cpex-core/src/config.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Unified YAML configuration parsing. +// +// Parses the unified config format that combines global settings, +// plugin declarations, named policy groups, and per-entity routes +// into a single YAML document. +// +// Mirrors the unified config proposal in +// apl-plugins/docs/unified-config-proposal.md. + +// TODO: Implement CpexConfig, GlobalConfig, RouteEntry serde models diff --git a/crates/cpex-core/src/context.rs b/crates/cpex-core/src/context.rs new file mode 100644 index 00000000..bd65d032 --- /dev/null +++ b/crates/cpex-core/src/context.rs @@ -0,0 +1,123 @@ +// Location: ./crates/cpex-core/src/context.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Execution context types. +// +// Provides GlobalContext (shared across all plugins for a request) and +// PluginContext (per-plugin, per-invocation). These carry transient +// execution state — counters, caches, intermediate results. All data +// needed for policy evaluation comes from the payload's extensions +// (filtered by capabilities), not from context. +// +// Mirrors the Python framework's GlobalContext and PluginContext types +// in cpex/framework/models.py. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +// --------------------------------------------------------------------------- +// Global Context +// --------------------------------------------------------------------------- + +/// Shared execution context for a single request. +/// +/// Visible to all plugins during a hook invocation. Plugins can read +/// and contribute to `state` (mutable shared state) and read `metadata` +/// (read-only shared metadata set by the host). +/// +/// # Fields +/// +/// * `request_id` — Unique identifier for the current request. +/// * `user` — User identifier or principal (string or structured). +/// * `tenant_id` — Optional multi-tenant scope. +/// * `server_id` — Optional virtual server scope. +/// * `state` — Mutable shared state merged between plugins. +/// * `metadata` — Read-only metadata set by the host before dispatch. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GlobalContext { + /// Unique request identifier (typically a UUID). + pub request_id: String, + + /// User identifier or principal. + #[serde(default)] + pub user: Value, + + /// Multi-tenant scope identifier. + #[serde(default)] + pub tenant_id: Option, + + /// Virtual server scope identifier. + #[serde(default)] + pub server_id: Option, + + /// Mutable shared state — plugins can read and contribute. + /// Merged back after each plugin execution. + #[serde(default)] + pub state: HashMap, + + /// Read-only shared metadata set by the host. + #[serde(default)] + pub metadata: HashMap, +} + +impl GlobalContext { + /// Create a new context with just a request ID. + pub fn new(request_id: impl Into) -> Self { + Self { + request_id: request_id.into(), + user: Value::Null, + tenant_id: None, + server_id: None, + state: HashMap::new(), + metadata: HashMap::new(), + } + } +} + +// --------------------------------------------------------------------------- +// Plugin Context +// --------------------------------------------------------------------------- + +/// Per-plugin, per-invocation execution context. +/// +/// Each plugin receives its own `PluginContext` with isolated local +/// state and a reference to the shared global context. The framework +/// deep-copies the global context per plugin to prevent cross-plugin +/// state leakage. +/// +/// This type carries transient execution state only — counters, caches, +/// intermediate results. All data needed for policy evaluation comes +/// from the payload's extensions (filtered by capabilities). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginContext { + /// Plugin-local state. Private to this plugin, this invocation. + #[serde(default)] + pub local_state: HashMap, + + /// Snapshot of the global context for this invocation. + pub global_context: GlobalContext, +} + +impl PluginContext { + /// Create a new plugin context from a global context. + pub fn new(global_context: GlobalContext) -> Self { + Self { + local_state: HashMap::new(), + global_context, + } + } + + /// Get a value from local state. + pub fn get_local(&self, key: &str) -> Option<&Value> { + self.local_state.get(key) + } + + /// Set a value in local state. + pub fn set_local(&mut self, key: impl Into, value: Value) { + self.local_state.insert(key.into(), value); + } +} diff --git a/crates/cpex-core/src/error.rs b/crates/cpex-core/src/error.rs new file mode 100644 index 00000000..4b684d54 --- /dev/null +++ b/crates/cpex-core/src/error.rs @@ -0,0 +1,127 @@ +// Location: ./crates/cpex-core/src/error.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Error types for the CPEX plugin framework. +// +// Provides structured error types for plugin execution failures, +// policy violations, timeouts, and configuration errors. Mirrors +// the Python framework's PluginError, PluginViolation, and +// PluginViolationError types. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +// --------------------------------------------------------------------------- +// Plugin Errors +// --------------------------------------------------------------------------- + +/// Top-level error type for the CPEX framework. +/// +/// Covers plugin execution failures, policy violations, timeouts, +/// and configuration issues. Each variant carries enough context +/// for the caller to log, report, or recover. +#[derive(Debug, Error)] +pub enum PluginError { + /// A plugin raised an execution error. + #[error("plugin '{plugin_name}' failed: {message}")] + Execution { + plugin_name: String, + message: String, + #[source] + source: Option>, + }, + + /// A plugin exceeded its execution timeout. + #[error("plugin '{plugin_name}' timed out after {timeout_ms}ms")] + Timeout { + plugin_name: String, + timeout_ms: u64, + }, + + /// A plugin returned a policy violation (deny). + #[error("plugin '{plugin_name}' denied: {}", violation.reason)] + Violation { + plugin_name: String, + violation: PluginViolation, + }, + + /// Configuration parsing or validation failed. + #[error("configuration error: {message}")] + Config { message: String }, + + /// A hook type was not found in the registry. + #[error("unknown hook type: {hook_type}")] + UnknownHook { hook_type: String }, +} + +// --------------------------------------------------------------------------- +// Plugin Violations +// --------------------------------------------------------------------------- + +/// Structured policy violation returned by a plugin that denies execution. +/// +/// Carries a machine-readable code, human-readable reason, and optional +/// diagnostic details. Corresponds to the Python `PluginViolation` type. +/// +/// # Examples +/// +/// ``` +/// use cpex_core::error::PluginViolation; +/// +/// let v = PluginViolation::new("missing_permission", "User lacks pii_access"); +/// assert_eq!(v.code, "missing_permission"); +/// assert_eq!(v.reason, "User lacks pii_access"); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginViolation { + /// Machine-readable violation identifier (e.g., `"missing_permission"`). + pub code: String, + + /// Short human-readable reason for the denial. + pub reason: String, + + /// Optional detailed explanation. + pub description: Option, + + /// Structured diagnostic data for logging or debugging. + pub details: HashMap, + + /// Name of the plugin that produced the violation. + /// Set by the framework after the plugin returns, not by the plugin itself. + pub plugin_name: Option, +} + +impl PluginViolation { + /// Create a new violation with a code and reason. + pub fn new(code: impl Into, reason: impl Into) -> Self { + Self { + code: code.into(), + reason: reason.into(), + description: None, + details: HashMap::new(), + plugin_name: None, + } + } + + /// Attach a detailed description. + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// Attach structured diagnostic details. + pub fn with_details(mut self, details: HashMap) -> Self { + self.details = details; + self + } +} + +impl std::fmt::Display for PluginViolation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[{}] {}", self.code, self.reason) + } +} diff --git a/crates/cpex-core/src/executor.rs b/crates/cpex-core/src/executor.rs new file mode 100644 index 00000000..c7f250b3 --- /dev/null +++ b/crates/cpex-core/src/executor.rs @@ -0,0 +1,566 @@ +// Location: ./crates/cpex-core/src/executor.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// 5-phase plugin execution engine. +// +// Dispatches plugins in strict phase order: +// SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET +// +// Each phase has different authority (block/modify) and scheduling +// (serial/parallel/background). The executor reads all scheduling +// decisions from PluginRef.trusted_config — never from the plugin. +// +// Extensions are passed separately from the payload and capability- +// filtered per plugin before dispatch. Extension modifications are +// merged back independently from payload modifications. +// +// Error handling respects the plugin's on_error setting: +// - Fail: propagate error, halt pipeline +// - Ignore: log error, continue pipeline +// - Disable: log error, mark plugin disabled, continue +// +// Mirrors the Python framework's PluginExecutor in +// cpex/framework/manager.py. + +use std::any::Any; +use std::time::Duration; + +use tokio::time::timeout; +use tracing::{error, warn}; + +use crate::context::{GlobalContext, PluginContext}; +use crate::hooks::payload::{Extensions, FilteredExtensions, PluginPayload}; +use crate::plugin::OnError; +use crate::registry::{group_by_mode, HookEntry}; + +// --------------------------------------------------------------------------- +// Executor Configuration +// --------------------------------------------------------------------------- + +/// Configuration for the executor. +#[derive(Debug, Clone)] +pub struct ExecutorConfig { + /// Maximum execution time per plugin in seconds. + pub timeout_seconds: u64, + + /// Whether to halt on the first deny in concurrent mode. + pub short_circuit_on_deny: bool, +} + +impl Default for ExecutorConfig { + fn default() -> Self { + Self { + timeout_seconds: 30, + short_circuit_on_deny: true, + } + } +} + +// --------------------------------------------------------------------------- +// Pipeline Result +// --------------------------------------------------------------------------- + +/// Aggregate result from a full hook invocation across all phases. +/// +/// Wraps the final payload, extensions, and any violation. Mirrors +/// the Python `PipelineResult` with factory methods. +#[derive(Debug)] +pub struct PipelineResult { + /// Whether the pipeline completed without a deny. + pub allowed: bool, + + /// The final payload after all modifications (type-erased). + /// `None` if the pipeline was denied before any modifications. + pub payload: Option>, + + /// The final extensions after all modifications. + pub extensions: Extensions, + + /// The violation that caused a deny, if any. + pub violation: Option, +} + +impl PipelineResult { + /// Pipeline completed — all plugins allowed. + pub fn allowed_with(payload: Box, extensions: Extensions) -> Self { + Self { + allowed: true, + payload: Some(payload), + extensions, + violation: None, + } + } + + /// Pipeline was denied by a plugin. + pub fn denied(violation: crate::error::PluginViolation, extensions: Extensions) -> Self { + Self { + allowed: false, + payload: None, + extensions, + violation: Some(violation), + } + } +} + +// --------------------------------------------------------------------------- +// Executor +// --------------------------------------------------------------------------- + +/// 5-phase plugin execution engine. +/// +/// Dispatches hooks through the phase pipeline: +/// +/// ```text +/// SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET +/// ``` +/// +/// The executor is stateless — all state comes from the arguments. +/// One executor instance can serve multiple concurrent hook invocations. +pub struct Executor { + config: ExecutorConfig, +} + +impl Executor { + /// Create a new executor with the given configuration. + pub fn new(config: ExecutorConfig) -> Self { + Self { config } + } + + /// Execute a hook invocation through the 5-phase pipeline. + /// + /// # Arguments + /// + /// * `entries` — HookEntries for this hook, sorted by priority. + /// * `payload` — The typed payload (type-erased as Box). + /// * `extensions` — The full extensions (filtered per plugin before dispatch). + /// * `global_ctx` — Shared request context. + /// + /// # Returns + /// + /// A `PipelineResult` with the final payload, extensions, and + /// any violation. + pub async fn execute( + &self, + entries: &[HookEntry], + payload: Box, + extensions: Extensions, + global_ctx: &GlobalContext, + ) -> PipelineResult { + if entries.is_empty() { + return PipelineResult::allowed_with(payload, extensions); + } + + // Group entries by mode (from trusted_config) + let (sequential, transform, audit, concurrent, fire_and_forget) = + group_by_mode(entries); + + let mut current_payload = payload; + let mut current_extensions = extensions; + + // Phase 1: SEQUENTIAL — serial, chained, can block + modify + if let Some(v) = self + .run_serial_phase( + &sequential, + &mut current_payload, + &mut current_extensions, + global_ctx, + true, // can_block + true, // can_modify + "SEQUENTIAL", + ) + .await + { + return PipelineResult::denied(v, current_extensions); + } + + // Phase 2: TRANSFORM — serial, chained, can modify, cannot block + // can_block=false means denials are suppressed (returns None) + self.run_serial_phase( + &transform, + &mut current_payload, + &mut current_extensions, + global_ctx, + false, // can_block + true, // can_modify + "TRANSFORM", + ) + .await; + + // Phase 3: AUDIT — serial, read-only, discard results + self.run_ref_phase(&audit, &*current_payload, ¤t_extensions, global_ctx, "AUDIT") + .await; + + // Phase 4: CONCURRENT — parallel, can block, cannot modify + if let Some(violation) = self + .run_concurrent_phase(&concurrent, &*current_payload, ¤t_extensions, global_ctx) + .await + { + return PipelineResult::denied(violation, current_extensions); + } + + // Phase 5: FIRE_AND_FORGET — background, read-only, ignore results + self.run_ref_phase( + &fire_and_forget, + &*current_payload, + ¤t_extensions, + global_ctx, + "FIRE_AND_FORGET", + ) + .await; + + PipelineResult::allowed_with(current_payload, current_extensions) + } + + // ----------------------------------------------------------------------- + // Phase 1 & 2: Serial execution (SEQUENTIAL / TRANSFORM) + // ----------------------------------------------------------------------- + + /// Run a serial phase — plugins execute one at a time, each seeing + /// the (possibly modified) payload from the previous. + /// + /// The framework retains ownership of the payload. Handlers receive + /// a borrow and clone only if they modify. Modified payloads in + /// the result replace the current payload. + async fn run_serial_phase( + &self, + entries: &[HookEntry], + payload: &mut Box, + extensions: &mut Extensions, + global_ctx: &GlobalContext, + can_block: bool, + can_modify: bool, + phase_label: &str, + ) -> Option { + for entry in entries { + let plugin_name = entry.plugin_ref.name().to_string(); + let ctx = PluginContext::new(global_ctx.clone()); + let on_error = entry.plugin_ref.trusted_config().on_error; + + // TODO: Capability-filter extensions per plugin (Phase 3) + let filtered = FilteredExtensions::default(); + + // Execute with timeout — handler borrows the payload + let timeout_dur = Duration::from_secs(self.config.timeout_seconds); + let result = timeout(timeout_dur, async { + entry.handler.invoke(&**payload, &filtered, &ctx) + }) + .await; + + match result { + Ok(Ok(result_box)) => { + if let Some(erased) = extract_erased(result_box) { + // Check deny + if !erased.continue_processing && can_block { + if let Some(v) = erased.violation { + return Some(v); + } + } + + // Accept modifications + if can_modify { + if let Some(mp) = erased.modified_payload { + *payload = mp; + } + if let Some(me) = erased.modified_extensions { + // TODO: Merge with tier validation (Phase 3) + *extensions = me; + } + } + } + // If extract failed or no modifications — payload unchanged + } + Ok(Err(e)) => { + error!("{} plugin '{}' failed: {}", phase_label, plugin_name, e); + match on_error { + OnError::Fail => { + return Some(crate::error::PluginViolation::new( + "plugin_error", + format!("Plugin '{}' failed: {}", plugin_name, e), + )); + } + OnError::Ignore | OnError::Disable => { + // Payload unchanged — continue + } + } + } + Err(_) => { + error!("{} plugin '{}' timed out", phase_label, plugin_name); + match on_error { + OnError::Fail => { + return Some(crate::error::PluginViolation::new( + "plugin_timeout", + format!("Plugin '{}' timed out", plugin_name), + )); + } + OnError::Ignore | OnError::Disable => { + // Payload unchanged — continue + } + } + } + } + } + + None // no denial + } + + // ----------------------------------------------------------------------- + // Phase 3 & 5: Read-only execution (AUDIT / FIRE_AND_FORGET) + // ----------------------------------------------------------------------- + + /// Run a read-only phase — plugins receive &payload, results discarded. + async fn run_ref_phase( + &self, + entries: &[HookEntry], + payload: &dyn PluginPayload, + _extensions: &Extensions, + global_ctx: &GlobalContext, + phase_label: &str, + ) { + for entry in entries { + let plugin_name = entry.plugin_ref.name().to_string(); + let ctx = PluginContext::new(global_ctx.clone()); + let filtered = FilteredExtensions::default(); + let timeout_dur = Duration::from_secs(self.config.timeout_seconds); + + let result = timeout(timeout_dur, async { + entry.handler.invoke(payload, &filtered, &ctx) + }) + .await; + + match result { + Ok(Ok(_)) => {} // read-only — discard result + Ok(Err(e)) => { + warn!("{} plugin '{}' error (ignored): {}", phase_label, plugin_name, e); + } + Err(_) => { + warn!("{} plugin '{}' timed out (ignored)", phase_label, plugin_name); + } + } + } + } + + // ----------------------------------------------------------------------- + // Phase 4: Concurrent (parallel, fail-fast) + // ----------------------------------------------------------------------- + + /// Run the concurrent phase — plugins execute in parallel. + /// Returns the first violation if any plugin denies. + async fn run_concurrent_phase( + &self, + entries: &[HookEntry], + payload: &dyn PluginPayload, + _extensions: &Extensions, + global_ctx: &GlobalContext, + ) -> Option { + if entries.is_empty() { + return None; + } + + // For concurrent, all plugins receive the same read-only payload. + // We collect results and check for denials. + let mut results = Vec::with_capacity(entries.len()); + + for entry in entries { + let plugin_name = entry.plugin_ref.name().to_string(); + let on_error = entry.plugin_ref.trusted_config().on_error; + let ctx = PluginContext::new(global_ctx.clone()); + let filtered = FilteredExtensions::default(); + let timeout_dur = Duration::from_secs(self.config.timeout_seconds); + + let result = timeout(timeout_dur, async { + entry.handler.invoke(payload, &filtered, &ctx) + }) + .await; + + match result { + Ok(Ok(result_box)) => { + if let Some(erased) = extract_erased(result_box) { + if !erased.continue_processing { + let violation = erased.violation.unwrap_or_else(|| { + crate::error::PluginViolation::new( + "concurrent_deny", + format!("Plugin '{}' denied", plugin_name), + ) + }); + if self.config.short_circuit_on_deny { + return Some(violation); + } + results.push(Some(violation)); + } + } + } + Ok(Err(e)) => match on_error { + OnError::Fail => { + return Some(crate::error::PluginViolation::new( + "plugin_error", + format!("Plugin '{}' failed: {}", plugin_name, e), + )); + } + _ => { + warn!("CONCURRENT plugin '{}' error (ignored): {}", plugin_name, e); + } + }, + Err(_) => match on_error { + OnError::Fail => { + return Some(crate::error::PluginViolation::new( + "plugin_timeout", + format!("Plugin '{}' timed out", plugin_name), + )); + } + _ => { + warn!("CONCURRENT plugin '{}' timed out (ignored)", plugin_name); + } + }, + } + } + + // Return first denial if any were collected (non-short-circuit mode) + results.into_iter().flatten().next() + } +} + +impl Default for Executor { + fn default() -> Self { + Self::new(ExecutorConfig::default()) + } +} + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +// SerialResult removed — run_serial_phase now returns Option directly. + +// --------------------------------------------------------------------------- +// Erased Result Extraction +// --------------------------------------------------------------------------- + +/// Common fields extracted from a type-erased PluginResult. +/// +/// Handlers return `Box` which wraps this struct. The +/// executor extracts it via [`extract_erased()`] to read the +/// control flow fields without knowing the concrete payload type. +pub struct ErasedResultFields { + pub continue_processing: bool, + pub modified_payload: Option>, + pub modified_extensions: Option, + pub violation: Option, +} + +/// Extract erased result fields from a type-erased handler result. +/// +/// Takes ownership of the Box — the executor consumes the result. +pub fn extract_erased(result: Box) -> Option { + result.downcast::().ok().map(|b| *b) +} + +/// Convert a typed `PluginResult

` into `ErasedResultFields`. +/// +/// Called by `TypedHandlerAdapter` to bridge between the typed +/// result and the executor's type-erased dispatch. +pub fn erase_result( + result: crate::hooks::PluginResult

, +) -> Box { + Box::new(ErasedResultFields { + continue_processing: result.continue_processing, + modified_payload: result + .modified_payload + .map(|p| Box::new(p) as Box), + modified_extensions: result.modified_extensions, + violation: result.violation, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hooks::payload::PluginPayload; + use crate::hooks::PluginResult; + + #[derive(Debug, Clone)] + struct TestPayload { + value: String, + } + crate::impl_plugin_payload!(TestPayload); + + #[test] + fn test_erase_result_allow() { + let result: PluginResult = PluginResult::allow(); + let erased = erase_result(result); + let fields = extract_erased(erased).unwrap(); + assert!(fields.continue_processing); + assert!(fields.violation.is_none()); + assert!(fields.modified_payload.is_none()); + } + + #[test] + fn test_erase_result_deny() { + let result: PluginResult = PluginResult::deny( + crate::error::PluginViolation::new("test", "denied"), + ); + let erased = erase_result(result); + let fields = extract_erased(erased).unwrap(); + assert!(!fields.continue_processing); + assert_eq!(fields.violation.as_ref().unwrap().code, "test"); + } + + #[test] + fn test_erase_result_modify_payload() { + let result: PluginResult = PluginResult::modify_payload(TestPayload { + value: "modified".into(), + }); + let erased = erase_result(result); + let fields = extract_erased(erased).unwrap(); + assert!(fields.continue_processing); + assert!(fields.modified_payload.is_some()); + } + + #[test] + fn test_erase_result_modify_extensions() { + let mut ext = Extensions::default(); + ext.labels.insert("PII".into()); + let result: PluginResult = PluginResult::modify_extensions(ext); + let erased = erase_result(result); + let fields = extract_erased(erased).unwrap(); + assert!(fields.continue_processing); + assert!(fields.modified_extensions.is_some()); + assert!(fields.modified_extensions.as_ref().unwrap().labels.contains("PII")); + } + + #[test] + fn test_pipeline_result_allowed() { + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let result = PipelineResult::allowed_with(payload, Extensions::default()); + assert!(result.allowed); + assert!(result.payload.is_some()); + assert!(result.violation.is_none()); + } + + #[test] + fn test_pipeline_result_denied() { + let violation = crate::error::PluginViolation::new("test", "denied"); + let result = PipelineResult::denied(violation, Extensions::default()); + assert!(!result.allowed); + assert!(result.payload.is_none()); + assert!(result.violation.is_some()); + } + + #[tokio::test] + async fn test_executor_empty_entries() { + let executor = Executor::default(); + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let ctx = GlobalContext::new("req-1"); + + let result = executor + .execute(&[], payload, Extensions::default(), &ctx) + .await; + assert!(result.allowed); + assert!(result.payload.is_some()); + } +} diff --git a/crates/cpex-core/src/hooks/adapter.rs b/crates/cpex-core/src/hooks/adapter.rs new file mode 100644 index 00000000..ee96ad1a --- /dev/null +++ b/crates/cpex-core/src/hooks/adapter.rs @@ -0,0 +1,125 @@ +// Location: ./crates/cpex-core/src/hooks/adapter.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// TypedHandlerAdapter — bridges typed HookHandler to type-erased +// AnyHookHandler. +// +// This is framework plumbing that plugin authors never see. When a +// plugin is registered via `manager.register_handler::()`, the +// manager creates a TypedHandlerAdapter internally. The adapter +// translates between Box (what the executor passes) +// and the concrete payload type (what the handler expects). + +use std::marker::PhantomData; +use std::sync::Arc; + +use crate::context::PluginContext; +use crate::error::PluginError; +use crate::executor::erase_result; +use crate::hooks::payload::{FilteredExtensions, PluginPayload}; +use crate::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use crate::plugin::Plugin; +use crate::registry::AnyHookHandler; + +// --------------------------------------------------------------------------- +// Typed Handler Adapter +// --------------------------------------------------------------------------- + +/// Adapts a typed `HookHandler` into the type-erased `AnyHookHandler` +/// interface used by the executor. +/// +/// Created automatically by `PluginManager::register_handler()`. Plugin +/// authors never instantiate this directly. +/// +/// # Type Parameters +/// +/// - `H` — the hook type (implements `HookTypeDef`). +/// - `P` — the plugin type (implements `Plugin + HookHandler`). +pub struct TypedHandlerAdapter +where + H: HookTypeDef, + H::Result: Into>, + P: Plugin + HookHandler + 'static, +{ + /// The plugin instance. + plugin: Arc

, + + /// Phantom data to carry the hook type parameter. + _hook: PhantomData, +} + +impl TypedHandlerAdapter +where + H: HookTypeDef, + H::Result: Into>, + P: Plugin + HookHandler + 'static, +{ + /// Create a new adapter wrapping the given plugin. + pub fn new(plugin: Arc

) -> Self { + Self { + plugin, + _hook: PhantomData, + } + } +} + +impl AnyHookHandler for TypedHandlerAdapter +where + H: HookTypeDef, + H::Result: Into>, + P: Plugin + HookHandler + 'static, +{ + /// Downcast the type-erased payload to the concrete type and call + /// the plugin's typed `handle()` method. + /// + /// The framework retains ownership of the payload — the handler + /// receives a borrow (`&H::Payload`) and clones only if it needs + /// to modify. The result is erased back to `ErasedResultFields` + /// for the executor. + fn invoke( + &self, + payload: &dyn PluginPayload, + extensions: &FilteredExtensions, + ctx: &PluginContext, + ) -> Result, PluginError> { + let typed_ref: &H::Payload = payload + .as_any() + .downcast_ref::() + .ok_or_else(|| PluginError::Config { + message: format!( + "payload type mismatch for hook '{}': expected {}", + H::NAME, + std::any::type_name::() + ), + })?; + + let result = self.plugin.handle(typed_ref, extensions, ctx); + let plugin_result: PluginResult = result.into(); + + Ok(erase_result(plugin_result)) + } + + fn hook_type_name(&self) -> &'static str { + H::NAME + } +} + +// Send + Sync: safe because P: Send + Sync (from Plugin bound) +// and H is a zero-sized marker with no data. +unsafe impl Send for TypedHandlerAdapter +where + H: HookTypeDef, + H::Result: Into>, + P: Plugin + HookHandler + 'static, +{ +} + +unsafe impl Sync for TypedHandlerAdapter +where + H: HookTypeDef, + H::Result: Into>, + P: Plugin + HookHandler + 'static, +{ +} diff --git a/crates/cpex-core/src/hooks/macros.rs b/crates/cpex-core/src/hooks/macros.rs new file mode 100644 index 00000000..5040d3f5 --- /dev/null +++ b/crates/cpex-core/src/hooks/macros.rs @@ -0,0 +1,105 @@ +// Location: ./crates/cpex-core/src/hooks/macros.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// define_hook! macro. +// +// Generates a HookTypeDef marker struct, trait implementation, and +// a handler trait from a single declaration. This is the primary +// way to define new hooks — both built-in (CMF, tool, prompt) and +// custom (rate limiting, deployment gates, federation sync). +// +// The generated handler trait has a single method whose name is +// derived from the hook name. The handler receives: +// - payload: the typed payload (owned — executor decides borrow vs clone) +// - extensions: &FilteredExtensions (capability-gated, separate from payload) +// - ctx: &PluginContext +// +// And returns the hook's result type (typically PluginResult). + +/// Generates a hook type definition, marker struct, and handler trait. +/// +/// # Usage +/// +/// ```rust,ignore +/// define_hook! { +/// /// Doc comment for the hook. +/// MyHook, "my_hook" => { +/// payload: MyPayload, +/// result: PluginResult, +/// } +/// } +/// ``` +/// +/// This generates: +/// +/// 1. A marker struct `MyHook` implementing `HookTypeDef`. +/// 2. A handler trait `MyHookHandler` with a method `my_hook()`. +/// +/// The handler method receives: +/// - `payload: MyPayload` (owned) +/// - `extensions: &FilteredExtensions` +/// - `ctx: &PluginContext` +/// +/// And returns `PluginResult`. +/// +/// # CMF Pattern (one handler, multiple hook names) +/// +/// For CMF hooks where one handler covers multiple hook names: +/// +/// ```rust,ignore +/// define_hook! { +/// /// CMF message evaluation hook. +/// CmfHook, "cmf" => { +/// payload: MessagePayload, +/// result: PluginResult, +/// } +/// } +/// +/// // Register the same handler for multiple names: +/// // registry.register_for_names::(plugin, config, &[ +/// // "cmf.tool_pre_invoke", "cmf.llm_input", ... +/// // ]); +/// ``` +#[macro_export] +macro_rules! define_hook { + ( + $(#[$meta:meta])* + $name:ident, $hook_name:literal => { + payload: $payload:ty, + result: $result:ty $(,)? + } + ) => { + $(#[$meta])* + pub struct $name; + + impl $crate::hooks::trait_def::HookTypeDef for $name { + type Payload = $payload; + type Result = $result; + const NAME: &'static str = $hook_name; + } + + paste::paste! { + /// Handler trait for the + #[doc = concat!("`", stringify!($name), "`")] + /// hook. Implement this on your plugin to handle this hook type. + pub trait [<$name Handler>]: $crate::plugin::Plugin + Send + Sync { + /// Handle the + #[doc = concat!("`", $hook_name, "`")] + /// hook. + /// + /// The executor decides whether to pass a clone (for Sequential/ + /// Transform modes) or a borrow (for Audit/Concurrent/FireAndForget + /// modes) based on the plugin's mode. The handler signature always + /// takes owned payload — the executor handles the mechanics. + fn [<$hook_name>]( + &self, + payload: $payload, + extensions: &$crate::hooks::payload::FilteredExtensions, + ctx: &$crate::context::PluginContext, + ) -> $result; + } + } + }; +} diff --git a/crates/cpex-core/src/hooks/mod.rs b/crates/cpex-core/src/hooks/mod.rs new file mode 100644 index 00000000..7f4d6ce4 --- /dev/null +++ b/crates/cpex-core/src/hooks/mod.rs @@ -0,0 +1,29 @@ +// Location: ./crates/cpex-core/src/hooks/mod.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Hook system. +// +// Provides the core abstractions for defining and dispatching hooks: +// +// - [`HookTypeDef`] — marker trait associating a typed payload + result with a hook name. +// - [`PluginPayload`] — base trait for all hook payloads (mirrors Python's PluginPayload). +// - [`PluginResult`] — result type with separate payload and extension modifications. +// - [`FilteredExtensions`] — capability-gated extension view passed to handlers. +// - [`define_hook!`] — macro for declaring new hook types with handler traits. +// - [`hook_names`] / [`cmf_hook_names`] — string constants for built-in hooks. +// +// Hook types are open — hosts define their own using define_hook! alongside the built-ins. + +pub mod adapter; +pub mod macros; +pub mod payload; +pub mod trait_def; +pub mod types; + +// Re-export core types at the hooks level +pub use adapter::TypedHandlerAdapter; +pub use payload::{Extensions, FilteredExtensions, PluginPayload}; +pub use trait_def::{HookHandler, HookTypeDef, PluginResult}; +pub use types::{builtin_hook_types, hook_type_from_str, HookType}; diff --git a/crates/cpex-core/src/hooks/payload.rs b/crates/cpex-core/src/hooks/payload.rs new file mode 100644 index 00000000..c25f0247 --- /dev/null +++ b/crates/cpex-core/src/hooks/payload.rs @@ -0,0 +1,174 @@ +// Location: ./crates/cpex-core/src/hooks/payload.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// PluginPayload trait and Extensions stub. +// +// PluginPayload is the base trait for all hook payloads, mirroring +// Python's PluginPayload(BaseModel, frozen=True). All payloads in +// the framework implement this trait, giving the executor and +// registry a common bound for type safety. +// +// The trait is object-safe — the executor works with `Box` +// instead of `Box`, catching type errors at compile time. +// Downcasting to concrete types uses the `as_any()` method. +// +// Extensions is the typed container for all message extensions +// (security, delegation, HTTP, meta, etc.). It is always passed +// as a separate parameter to handlers — never inside the payload. +// This allows per-plugin capability filtering and independent +// modification without copying the payload. + +use std::any::Any; +use std::collections::HashMap; +use std::fmt; + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Extensions (stub — fleshed out in Phase 3 with full CMF types) +// --------------------------------------------------------------------------- + +/// Typed container for all message extensions. +/// +/// Each field corresponds to an extension with an explicit mutability +/// tier enforced by the processing pipeline. Extensions are always +/// passed separately from the payload to handlers. +/// +/// This is a Phase 1 stub with minimal fields. Phase 3 adds the +/// full CMF extension types (SecurityExtension with MonotonicSet, +/// DelegationExtension with scope-narrowing chain, HttpExtension +/// with Guarded, MetaExtension, etc.). +/// +/// Mirrors Python's `cpex.framework.extensions.Extensions`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Extensions { + /// Security labels (monotonic — add-only in the full implementation). + #[serde(default)] + pub labels: std::collections::HashSet, + + /// Custom extensions (mutable — no restrictions). + #[serde(default)] + pub custom: HashMap, +} + +/// Capability-filtered view of Extensions for a specific plugin. +/// +/// Built by the framework before dispatching to each plugin. Fields +/// the plugin hasn't declared capabilities for are `None`. Plugins +/// receive this as a separate parameter — never inside the payload. +/// +/// Phase 1 stub — Phase 3 adds per-field capability gating matching +/// the Python `filter_extensions()` implementation. +#[derive(Debug, Clone, Default)] +pub struct FilteredExtensions { + /// Security labels (visible with `read_labels` capability). + pub labels: Option>, + + /// Custom extensions (always visible). + pub custom: Option>, +} + +// --------------------------------------------------------------------------- +// PluginPayload Trait +// --------------------------------------------------------------------------- + +/// Base trait for all hook payloads. +/// +/// Mirrors Python's `PluginPayload(BaseModel, frozen=True)`. Every +/// payload type in the framework implements this trait. The executor +/// and registry use `Box` (not `Box`) +/// for type-safe dispatch. +/// +/// The trait is **object-safe** — it can be used behind `Box`, `&`, +/// and `Arc` without knowing the concrete type. This is achieved by +/// providing `clone_boxed()` instead of requiring `Clone` directly +/// (which is not object-safe), and `as_any()` / `as_any_mut()` for +/// downcasting to the concrete type when needed. +/// +/// Payloads are: +/// - Cloneable via `clone_boxed()` — the executor uses this for COW +/// when a modifying plugin (Sequential or Transform) needs ownership. +/// - `Send + Sync` — payloads may be shared across threads for +/// Concurrent mode plugins. +/// - `'static` — payloads must be owned types (no borrowed references). +/// +/// Extensions are **not** part of the payload. They are passed as a +/// separate `&FilteredExtensions` parameter to handlers. +/// +/// # Examples +/// +/// ``` +/// use cpex_core::hooks::payload::PluginPayload; +/// +/// #[derive(Debug, Clone)] +/// struct RateLimitPayload { +/// client_id: String, +/// request_count: u64, +/// } +/// +/// impl PluginPayload for RateLimitPayload { +/// fn clone_boxed(&self) -> Box { +/// Box::new(self.clone()) +/// } +/// fn as_any(&self) -> &dyn std::any::Any { self } +/// fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self } +/// } +/// ``` +pub trait PluginPayload: Send + Sync + 'static { + /// Clone this payload into a new `Box`. + /// + /// Used by the executor for copy-on-write: read-only modes borrow + /// the payload, modifying modes receive a clone via this method. + fn clone_boxed(&self) -> Box; + + /// Downcast to a concrete type via `&dyn Any`. + /// + /// Used by typed handler wrappers to recover the concrete payload + /// type from `Box`. + fn as_any(&self) -> &dyn Any; + + /// Downcast to a concrete type via `&mut dyn Any`. + fn as_any_mut(&mut self) -> &mut dyn Any; +} + +impl fmt::Debug for dyn PluginPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("dyn PluginPayload") + } +} + +// --------------------------------------------------------------------------- +// Blanket helper macro for implementing PluginPayload +// --------------------------------------------------------------------------- + +/// Implements `PluginPayload` for a type that is `Clone + Send + Sync + 'static`. +/// +/// Saves boilerplate — instead of writing the three methods manually, +/// just invoke this macro: +/// +/// ``` +/// use cpex_core::impl_plugin_payload; +/// +/// #[derive(Debug, Clone)] +/// struct MyPayload { value: i32 } +/// +/// impl_plugin_payload!(MyPayload); +/// ``` +#[macro_export] +macro_rules! impl_plugin_payload { + ($ty:ty) => { + impl $crate::hooks::payload::PluginPayload for $ty { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + } + }; +} diff --git a/crates/cpex-core/src/hooks/trait_def.rs b/crates/cpex-core/src/hooks/trait_def.rs new file mode 100644 index 00000000..34630778 --- /dev/null +++ b/crates/cpex-core/src/hooks/trait_def.rs @@ -0,0 +1,267 @@ +// Location: ./crates/cpex-core/src/hooks/trait_def.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// HookTypeDef trait and PluginResult type. +// +// Every hook in the CPEX framework is defined by a marker type that +// implements HookTypeDef. This associates a typed PluginPayload and +// PluginResult with a string name used for registry lookup and config. +// +// The hook type does NOT declare an access pattern (read-only vs +// mutating). The plugin's mode (from PluginRef.trusted_config) +// determines scheduling and authority at runtime. Security invariants +// come from the types inside the payload (Arc, MonotonicSet, +// Guarded), not from borrow mechanics. +// +// Extensions are always a separate parameter — never part of the +// payload. This allows capability-filtered views per plugin and +// independent modification of extensions without copying the payload. + +use crate::context::PluginContext; +use crate::error::PluginViolation; +use crate::hooks::payload::{Extensions, FilteredExtensions, PluginPayload}; +use crate::plugin::Plugin; + +// --------------------------------------------------------------------------- +// HookTypeDef Trait +// --------------------------------------------------------------------------- + +/// Defines a hook's contract: what goes in and what comes out. +/// +/// Each hook type is a zero-sized marker struct that implements this +/// trait. The framework uses the associated types for compile-time +/// dispatch and the NAME constant for registry lookup. +/// +/// The hook type does **not** declare an access pattern. The plugin's +/// mode (from `PluginRef.trusted_config`) determines whether the +/// executor passes a borrow or a clone: +/// +/// | Mode | Receives | Can Block? | Can Modify? | +/// |-----------------|-----------------|------------|-------------| +/// | Sequential | owned (clone) | Yes | Yes | +/// | Transform | owned (clone) | No | Yes | +/// | Audit | &Payload | No | No | +/// | Concurrent | &Payload | Yes | No | +/// | FireAndForget | &Payload | No | No | +/// +/// # Defining a Hook +/// +/// Use the [`define_hook!`] macro instead of implementing this trait +/// manually — the macro generates the marker struct, the trait impl, +/// and the handler trait in one declaration. +pub trait HookTypeDef: Send + Sync + 'static { + /// The typed payload that handlers receive. + /// Must implement [`PluginPayload`] (Clone + Send + Sync + 'static). + type Payload: PluginPayload; + + /// The typed result that handlers return. + type Result: Send + Sync; + + /// Hook name — used as the registry key and in config YAML. + /// + /// Multiple hook names can map to the same HookTypeDef (the CMF + /// pattern where one handler covers `cmf.tool_pre_invoke`, + /// `cmf.llm_input`, etc.). The primary NAME is used for + /// single-name registration; additional names are registered + /// via `register_for_names()`. + const NAME: &'static str; +} + +// --------------------------------------------------------------------------- +// Hook Handler Trait +// --------------------------------------------------------------------------- + +/// Typed handler for a specific hook type. +/// +/// Plugin authors implement this trait (alongside [`Plugin`]) to handle +/// a specific hook. The type parameter `H` ties the handler to a +/// `HookTypeDef`, ensuring the correct payload and result types at +/// compile time. +/// +/// The framework creates a type-erased adapter internally when you +/// register — you never touch `AnyHookHandler` directly. +/// +/// # Examples +/// +/// ```rust,ignore +/// impl HookHandler for MyPlugin { +/// fn handle( +/// &self, +/// payload: MessagePayload, +/// extensions: &FilteredExtensions, +/// ctx: &PluginContext, +/// ) -> PluginResult { +/// PluginResult::allow() +/// } +/// } +/// +/// // Registration — no AnyHookHandler needed: +/// manager.register_handler::(plugin, config)?; +/// ``` +pub trait HookHandler: Plugin + Send + Sync { + /// Handle the hook invocation. + /// + /// Receives a **borrow** of the typed payload, capability-filtered + /// extensions, and per-invocation context. Returns a typed result. + /// + /// The payload is immutable — Rust's borrow checker prevents + /// modification through `&H::Payload`. To modify, the plugin + /// must `clone()` the payload (or the fields it needs) and return + /// the modified copy in `PluginResult::modify_payload()`. This + /// pushes the clone cost to the plugin that actually needs it — + /// read-only plugins (validators, auditors) never pay for a copy. + fn handle( + &self, + payload: &H::Payload, + extensions: &FilteredExtensions, + ctx: &PluginContext, + ) -> H::Result; +} + +// --------------------------------------------------------------------------- +// Plugin Result +// --------------------------------------------------------------------------- + +/// Result returned by a hook handler. +/// +/// Payload and extension modifications are **separate** — this is a +/// core design decision. Extension-only changes (add a label, set a +/// header) don't require copying the payload. The payload is only +/// present in `modified_payload` when message content actually changed. +/// +/// The executor interprets the result based on the plugin's mode: +/// - Sequential/Transform: `modified_payload` and `modified_extensions` are accepted. +/// - Audit/Concurrent/FireAndForget: modifications are discarded. +/// - Sequential/Concurrent: `continue_processing = false` halts the pipeline. +/// - Transform/Audit/FireAndForget: blocks are suppressed. +/// +/// Mirrors Python's `PluginResult[T]` with separate `modified_payload` +/// and `modified_extensions` fields. +/// +/// # Examples +/// +/// ``` +/// use cpex_core::hooks::{PluginPayload, PluginResult}; +/// use cpex_core::error::PluginViolation; +/// +/// // Define a simple payload +/// #[derive(Debug, Clone)] +/// struct TestPayload { value: i32 } +/// cpex_core::impl_plugin_payload!(TestPayload); +/// +/// // Allow — no changes +/// let result: PluginResult = PluginResult::allow(); +/// assert!(result.continue_processing); +/// assert!(result.modified_payload.is_none()); +/// +/// // Deny +/// let result: PluginResult = PluginResult::deny( +/// PluginViolation::new("forbidden", "not allowed") +/// ); +/// assert!(!result.continue_processing); +/// assert!(result.violation.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct PluginResult { + /// Whether the pipeline should continue processing. + /// `false` halts the pipeline (deny). Only respected for + /// Sequential and Concurrent modes. + pub continue_processing: bool, + + /// Modified payload. `None` means no content modification. + /// Only accepted from Sequential and Transform mode plugins. + pub modified_payload: Option

, + + /// Modified extensions. `None` means no extension changes. + /// Merged back by the framework using tier validation + /// (immutable rejected, monotonic superset-checked, etc.). + /// Only accepted from Sequential and Transform mode plugins. + pub modified_extensions: Option, + + /// Policy violation. Present when `continue_processing` is `false`. + pub violation: Option, + + /// Optional metadata from the plugin (telemetry, diagnostics). + /// Not used for scheduling or policy decisions. + pub metadata: Option, +} + +impl PluginResult

{ + /// Allow — payload continues unchanged, no extension changes. + pub fn allow() -> Self { + Self { + continue_processing: true, + modified_payload: None, + modified_extensions: None, + violation: None, + metadata: None, + } + } + + /// Deny — pipeline halts with a violation. + pub fn deny(violation: PluginViolation) -> Self { + Self { + continue_processing: false, + modified_payload: None, + modified_extensions: None, + violation: Some(violation), + metadata: None, + } + } + + /// Modify payload only — extensions unchanged. + pub fn modify_payload(payload: P) -> Self { + Self { + continue_processing: true, + modified_payload: Some(payload), + modified_extensions: None, + violation: None, + metadata: None, + } + } + + /// Modify extensions only — payload unchanged. + pub fn modify_extensions(extensions: Extensions) -> Self { + Self { + continue_processing: true, + modified_payload: None, + modified_extensions: Some(extensions), + violation: None, + metadata: None, + } + } + + /// Modify both payload and extensions. + pub fn modify(payload: P, extensions: Extensions) -> Self { + Self { + continue_processing: true, + modified_payload: Some(payload), + modified_extensions: Some(extensions), + violation: None, + metadata: None, + } + } + + /// Whether this result represents a denial. + pub fn is_denied(&self) -> bool { + !self.continue_processing + } + + /// Whether this result carries a modified payload. + pub fn is_payload_modified(&self) -> bool { + self.modified_payload.is_some() + } + + /// Whether this result carries modified extensions. + pub fn is_extensions_modified(&self) -> bool { + self.modified_extensions.is_some() + } +} + +impl Default for PluginResult

{ + fn default() -> Self { + Self::allow() + } +} diff --git a/crates/cpex-core/src/hooks/types.rs b/crates/cpex-core/src/hooks/types.rs new file mode 100644 index 00000000..3295d1a6 --- /dev/null +++ b/crates/cpex-core/src/hooks/types.rs @@ -0,0 +1,190 @@ +// Location: ./crates/cpex-core/src/hooks/types.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Hook type definitions. +// +// Hook types are open strings — hosts define hook points appropriate +// to their execution lifecycle. This module provides a newtype wrapper +// for type safety and built-in constants for the common hook points. +// +// The framework does not prescribe a fixed set of hook points. Each +// host places `invoke_hook()` calls at sites appropriate to its +// processing pipeline. The constants below cover the standard +// MCP/CMF lifecycle but hosts may register additional types. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Hook Type +// --------------------------------------------------------------------------- + +/// A named hook point in the host's execution lifecycle. +/// +/// Wraps a string identifier. Hook types are open — hosts register +/// their own alongside the built-in constants. +/// +/// # Examples +/// +/// ``` +/// use cpex_core::hooks::HookType; +/// use cpex_core::hooks::types::hook_names; +/// +/// // Use a built-in name constant +/// let hook = HookType::new(hook_names::TOOL_PRE_INVOKE); +/// assert_eq!(hook.as_str(), "tool_pre_invoke"); +/// +/// // Define a custom hook +/// let custom = HookType::new("generation_pre_call"); +/// assert_eq!(custom.as_str(), "generation_pre_call"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct HookType(String); + +impl HookType { + /// Create a new hook type from a string. + pub fn new(name: impl Into) -> Self { + Self(name.into()) + } + + /// Return the hook type as a string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for HookType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl From<&str> for HookType { + fn from(s: &str) -> Self { + Self::new(s) + } +} + +impl From for HookType { + fn from(s: String) -> Self { + Self(s) + } +} + +// --------------------------------------------------------------------------- +// Built-in Hook String Constants +// --------------------------------------------------------------------------- +// Canonical string names for built-in hooks. Use these with +// HookType::new() or pass them directly to APIs that accept &str. + +/// Legacy hook names — typed payloads (ToolPreInvokePayload, etc.). +pub mod hook_names { + // Tool lifecycle + pub const TOOL_PRE_INVOKE: &str = "tool_pre_invoke"; + pub const TOOL_POST_INVOKE: &str = "tool_post_invoke"; + + // Prompt lifecycle + pub const PROMPT_PRE_FETCH: &str = "prompt_pre_fetch"; + pub const PROMPT_POST_FETCH: &str = "prompt_post_fetch"; + + // Resource lifecycle + pub const RESOURCE_PRE_FETCH: &str = "resource_pre_fetch"; + pub const RESOURCE_POST_FETCH: &str = "resource_post_fetch"; + + // Identity and delegation + pub const IDENTITY_RESOLVE: &str = "identity_resolve"; + pub const TOKEN_DELEGATE: &str = "token_delegate"; +} + +/// CMF hook names — MessagePayload wrapping a CMF Message. +/// The `cmf.` prefix lets legacy and CMF plugins coexist at the +/// same interception point. The gateway fires both at each event. +pub mod cmf_hook_names { + // Tool lifecycle + pub const TOOL_PRE_INVOKE: &str = "cmf.tool_pre_invoke"; + pub const TOOL_POST_INVOKE: &str = "cmf.tool_post_invoke"; + + // LLM lifecycle (CMF only — no legacy equivalent) + pub const LLM_INPUT: &str = "cmf.llm_input"; + pub const LLM_OUTPUT: &str = "cmf.llm_output"; + + // Prompt lifecycle + pub const PROMPT_PRE_FETCH: &str = "cmf.prompt_pre_fetch"; + pub const PROMPT_POST_FETCH: &str = "cmf.prompt_post_fetch"; + + // Resource lifecycle + pub const RESOURCE_PRE_FETCH: &str = "cmf.resource_pre_fetch"; + pub const RESOURCE_POST_FETCH: &str = "cmf.resource_post_fetch"; +} + +// --------------------------------------------------------------------------- +// Built-in hook type helpers +// --------------------------------------------------------------------------- + +/// Returns all built-in hook types with their canonical string values. +/// +/// Called once during PluginManager initialization to populate the +/// hook registry. Hosts add their own hook types after this. +pub fn builtin_hook_types() -> Vec { + vec![ + // Legacy (typed payloads) + HookType::new("tool_pre_invoke"), + HookType::new("tool_post_invoke"), + HookType::new("prompt_pre_fetch"), + HookType::new("prompt_post_fetch"), + HookType::new("resource_pre_fetch"), + HookType::new("resource_post_fetch"), + HookType::new("identity_resolve"), + HookType::new("token_delegate"), + // CMF (MessagePayload) + HookType::new("cmf.tool_pre_invoke"), + HookType::new("cmf.tool_post_invoke"), + HookType::new("cmf.llm_input"), + HookType::new("cmf.llm_output"), + HookType::new("cmf.prompt_pre_fetch"), + HookType::new("cmf.prompt_post_fetch"), + HookType::new("cmf.resource_pre_fetch"), + HookType::new("cmf.resource_post_fetch"), + ] +} + +/// Look up a hook type by name. Returns the canonical instance if +/// it matches a built-in, otherwise creates a new custom HookType. +pub fn hook_type_from_str(name: &str) -> HookType { + HookType::new(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hook_type_equality() { + let a = HookType::new("tool_pre_invoke"); + let b = HookType::new("tool_pre_invoke"); + assert_eq!(a, b); + } + + #[test] + fn test_hook_type_display() { + let h = HookType::new("cmf.llm_input"); + assert_eq!(h.to_string(), "cmf.llm_input"); + } + + #[test] + fn test_hook_type_from_str() { + let h: HookType = "custom_hook".into(); + assert_eq!(h.as_str(), "custom_hook"); + } + + #[test] + fn test_builtin_hook_types_count() { + let builtins = builtin_hook_types(); + // 8 legacy + 8 CMF + assert_eq!(builtins.len(), 16); + } +} diff --git a/crates/cpex-core/src/lib.rs b/crates/cpex-core/src/lib.rs new file mode 100644 index 00000000..0caf440e --- /dev/null +++ b/crates/cpex-core/src/lib.rs @@ -0,0 +1,30 @@ +// Location: ./crates/cpex-core/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX Core library root. +// +// Pure Rust plugin runtime with no FFI, WASM, or PyO3 dependencies. +// Provides the PluginManager, 5-phase executor, hook registry, +// unified config parser, and all core types. +// +// # Modules +// +// - [`plugin`] — Plugin trait, PluginRef, PluginMetadata, PluginConfig +// - [`hooks`] — HookType (open string registry), payload/result traits +// - [`executor`] — 5-phase execution engine (sequential → transform → audit → concurrent → fire_and_forget) +// - [`manager`] — PluginManager lifecycle and hook dispatch +// - [`registry`] — PluginInstanceRegistry and HookRegistry +// - [`config`] — Unified YAML configuration parsing +// - [`context`] — GlobalContext and PluginContext +// - [`error`] — Error types, violations, and result types + +pub mod config; +pub mod context; +pub mod error; +pub mod executor; +pub mod hooks; +pub mod manager; +pub mod plugin; +pub mod registry; diff --git a/crates/cpex-core/src/manager.rs b/crates/cpex-core/src/manager.rs new file mode 100644 index 00000000..2736424f --- /dev/null +++ b/crates/cpex-core/src/manager.rs @@ -0,0 +1,656 @@ +// Location: ./crates/cpex-core/src/manager.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Plugin manager. +// +// Owns the plugin lifecycle (initialize, dispatch, shutdown) and +// the PluginRegistry. Provides two invoke paths: +// +// - `invoke::()` — typed dispatch for Rust callers. Zero-cost. +// The hook type is known at compile time; no registry lookup or +// downcast needed for the payload. +// +// - `invoke_by_name()` — dynamic dispatch for Python/Go/WASM callers. +// Hook name resolved from the registry; payload passed as +// Box. +// +// The manager reads plugin configs from the config loader and wraps +// each plugin in a PluginRef with the authoritative config. Plugins +// never provide their own config to the manager. Trust flows: +// config loader → manager → PluginRef → executor +// +// Mirrors the Python framework's PluginManager in +// cpex/framework/manager.py. + +use std::sync::Arc; + +use tracing::{error, info}; + +use crate::context::GlobalContext; +use crate::error::PluginError; +use crate::executor::{Executor, ExecutorConfig, PipelineResult}; +use crate::hooks::adapter::TypedHandlerAdapter; +use crate::hooks::payload::{Extensions, PluginPayload}; +use crate::hooks::trait_def::{HookHandler, HookTypeDef, PluginResult}; +use crate::hooks::HookType; +use crate::plugin::{Plugin, PluginConfig}; +use crate::registry::{AnyHookHandler, PluginRef, PluginRegistry}; + +// --------------------------------------------------------------------------- +// Manager Configuration +// --------------------------------------------------------------------------- + +/// Configuration for the PluginManager. +#[derive(Debug, Clone)] +pub struct ManagerConfig { + /// Executor configuration (timeout, short-circuit behavior). + pub executor: ExecutorConfig, +} + +impl Default for ManagerConfig { + fn default() -> Self { + Self { + executor: ExecutorConfig::default(), + } + } +} + +// --------------------------------------------------------------------------- +// Plugin Manager +// --------------------------------------------------------------------------- + +/// Central plugin lifecycle and dispatch manager. +/// +/// Owns the plugin registry and executor. Provides the public API +/// that host systems (ContextForge, Kagenti, etc.) call to register +/// plugins and invoke hooks. +/// +/// # Lifecycle +/// +/// ```text +/// new() → register plugins → initialize() → invoke hooks → shutdown() +/// ``` +/// +/// # Two Invoke Paths +/// +/// - **`invoke::()`** — typed dispatch. The hook type `H` is known +/// at compile time. Payload type-checked at compile time. Used by +/// Rust callers. +/// +/// - **`invoke_by_name()`** — dynamic dispatch. The hook name is a +/// string. Payload is `Box`. Used by Python/Go/WASM +/// callers via the FFI or PyO3 bindings. +/// +/// Both paths use the same registry, executor, and 5-phase pipeline. +/// +/// # Trust Model +/// +/// The manager wraps each plugin in a `PluginRef` with an authoritative +/// config from the config loader. The executor reads all scheduling +/// decisions from `PluginRef.trusted_config` — never from the plugin. +pub struct PluginManager { + /// Plugin registry — stores PluginRefs and hook-to-handler mappings. + registry: PluginRegistry, + + /// Executor — stateless 5-phase pipeline engine. + executor: Executor, + + /// Manager configuration. + config: ManagerConfig, + + /// Whether initialize() has been called. + initialized: bool, +} + +impl PluginManager { + /// Create a new PluginManager with the given configuration. + pub fn new(config: ManagerConfig) -> Self { + Self { + registry: PluginRegistry::new(), + executor: Executor::new(config.executor.clone()), + config, + initialized: false, + } + } + + // ----------------------------------------------------------------------- + // Registration + // ----------------------------------------------------------------------- + + /// Register a plugin handler for its primary hook name. + /// + /// This is the preferred registration method. The framework creates + /// the type-erased adapter internally — no `AnyHookHandler` needed. + /// + /// # Type Parameters + /// + /// - `H` — the hook type (implements `HookTypeDef`). + /// - `P` — the plugin type (implements `Plugin + HookHandler`). + /// + /// # Arguments + /// + /// - `plugin` — the plugin implementation. + /// - `config` — authoritative config from the config loader. + /// + /// # Examples + /// + /// ```rust,ignore + /// manager.register_handler::(plugin, config)?; + /// ``` + pub fn register_handler( + &mut self, + plugin: Arc

, + config: PluginConfig, + ) -> Result<(), PluginError> + where + H: HookTypeDef, + H::Result: Into>, + P: Plugin + HookHandler + 'static, + { + let handler: Arc = + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); + self.registry + .register::(plugin, config, handler) + .map_err(|msg| PluginError::Config { message: msg }) + } + + /// Register a plugin handler for multiple hook names. + /// + /// This is the CMF pattern — one handler covers multiple hook + /// names (`cmf.tool_pre_invoke`, `cmf.llm_input`, etc.). + /// + /// # Examples + /// + /// ```rust,ignore + /// manager.register_handler_for_names::( + /// plugin, config, + /// &["cmf.tool_pre_invoke", "cmf.llm_input", "cmf.llm_output"], + /// )?; + /// ``` + pub fn register_handler_for_names( + &mut self, + plugin: Arc

, + config: PluginConfig, + names: &[&str], + ) -> Result<(), PluginError> + where + H: HookTypeDef, + H::Result: Into>, + P: Plugin + HookHandler + 'static, + { + let handler: Arc = + Arc::new(TypedHandlerAdapter::::new(Arc::clone(&plugin))); + self.registry + .register_for_names::(plugin, config, handler, names) + .map_err(|msg| PluginError::Config { message: msg }) + } + + /// Register with an explicit AnyHookHandler (advanced use). + /// + /// For cases where the automatic adapter doesn't fit — e.g., + /// Python/WASM bridge hosts that implement AnyHookHandler directly. + /// Most callers should use `register_handler` instead. + pub fn register_raw( + &mut self, + plugin: Arc, + config: PluginConfig, + handler: Arc, + ) -> Result<(), PluginError> { + self.registry + .register::(plugin, config, handler) + .map_err(|msg| PluginError::Config { message: msg }) + } + + /// Register a plugin using hook names from its config (legacy path). + /// + /// No typed handler — the plugin is registered in the name index + /// only. Used for backward compatibility with plugins that don't + /// use the typed hook system. + pub fn register_legacy( + &mut self, + plugin: Arc, + config: PluginConfig, + ) -> Result<(), PluginError> { + self.registry + .register_legacy(plugin, config) + .map_err(|msg| PluginError::Config { message: msg }) + } + + // ----------------------------------------------------------------------- + // Lifecycle + // ----------------------------------------------------------------------- + + /// Initialize all registered plugins. + /// + /// Calls `plugin.initialize()` on each registered plugin. Must be + /// called before invoking any hooks. Idempotent — calling twice + /// has no effect. + pub async fn initialize(&mut self) -> Result<(), PluginError> { + if self.initialized { + return Ok(()); + } + + info!( + "Initializing PluginManager with {} plugins", + self.registry.plugin_count() + ); + + for name in self.registry.plugin_names() { + if let Some(plugin_ref) = self.registry.get(name) { + let plugin = plugin_ref.plugin().clone(); + let plugin_name = name.to_string(); + + if let Err(e) = plugin.initialize().await { + error!("Failed to initialize plugin '{}': {}", plugin_name, e); + return Err(PluginError::Execution { + plugin_name, + message: format!("initialization failed: {}", e), + source: Some(Box::new(e)), + }); + } + } + } + + self.initialized = true; + info!("PluginManager initialized successfully"); + Ok(()) + } + + /// Shutdown all registered plugins. + /// + /// Calls `plugin.shutdown()` on each registered plugin in reverse + /// registration order. Errors are logged but do not halt the + /// shutdown process — all plugins get a chance to clean up. + pub async fn shutdown(&mut self) { + if !self.initialized { + return; + } + + info!("Shutting down PluginManager"); + + for name in self.registry.plugin_names() { + if let Some(plugin_ref) = self.registry.get(name) { + let plugin = plugin_ref.plugin().clone(); + + if let Err(e) = plugin.shutdown().await { + error!("Error shutting down plugin '{}': {}", name, e); + // Continue — don't let one plugin's failure block others + } + } + } + + self.initialized = false; + info!("PluginManager shutdown complete"); + } + + // ----------------------------------------------------------------------- + // Hook Invocation — Dynamic (invoke_by_name) + // ----------------------------------------------------------------------- + + /// Invoke a hook by name with a type-erased payload. + /// + /// This is the dynamic dispatch path used by Python/Go/WASM + /// callers via FFI or PyO3 bindings. The hook name is resolved + /// from the registry and dispatched through the 5-phase executor. + /// + /// # Arguments + /// + /// * `hook_name` — the hook name string (e.g., `"cmf.tool_pre_invoke"`). + /// * `payload` — the payload as `Box`. + /// * `extensions` — the full extensions (filtered per plugin by the executor). + /// * `global_ctx` — shared request context. + /// + /// # Returns + /// + /// A `PipelineResult` with the final payload, extensions, and + /// any violation. + pub async fn invoke_by_name( + &self, + hook_name: &str, + payload: Box, + extensions: Extensions, + global_ctx: &GlobalContext, + ) -> PipelineResult { + let hook_type = HookType::new(hook_name); + let entries = self.registry.entries_for_hook(&hook_type); + + if entries.is_empty() { + return PipelineResult::allowed_with(payload, extensions); + } + + self.executor + .execute(entries, payload, extensions, global_ctx) + .await + } + + // ----------------------------------------------------------------------- + // Hook Invocation — Typed (invoke::) + // ----------------------------------------------------------------------- + + /// Invoke a typed hook. + /// + /// This is the compile-time dispatch path used by Rust callers. + /// The hook type `H` determines the payload and result types. + /// Dispatch goes through the same registry and 5-phase executor + /// as `invoke_by_name()`. + /// + /// # Type Parameters + /// + /// - `H` — the hook type (implements `HookTypeDef`). + /// + /// # Arguments + /// + /// * `payload` — the typed payload. + /// * `extensions` — the full extensions. + /// * `global_ctx` — shared request context. + /// + /// # Returns + /// + /// A `PipelineResult` with the final payload (type-erased — + /// caller downcasts via `as_any()`), extensions, and any violation. + pub async fn invoke( + &self, + payload: H::Payload, + extensions: Extensions, + global_ctx: &GlobalContext, + ) -> PipelineResult { + let hook_type = HookType::new(H::NAME); + let entries = self.registry.entries_for_hook(&hook_type); + + if entries.is_empty() { + let boxed: Box = Box::new(payload); + return PipelineResult::allowed_with(boxed, extensions); + } + + let boxed: Box = Box::new(payload); + self.executor + .execute(entries, boxed, extensions, global_ctx) + .await + } + + // ----------------------------------------------------------------------- + // Query Methods + // ----------------------------------------------------------------------- + + /// Whether any plugins are registered for the given hook name. + pub fn has_hooks_for(&self, hook_name: &str) -> bool { + self.registry.has_hooks_for(&HookType::new(hook_name)) + } + + /// Look up a plugin by name. + pub fn get_plugin(&self, name: &str) -> Option<&PluginRef> { + self.registry.get(name) + } + + /// Total number of registered plugins. + pub fn plugin_count(&self) -> usize { + self.registry.plugin_count() + } + + /// All registered plugin names. + pub fn plugin_names(&self) -> Vec<&str> { + self.registry.plugin_names() + } + + /// Whether the manager has been initialized. + pub fn is_initialized(&self) -> bool { + self.initialized + } + + /// Unregister a plugin by name. + pub fn unregister(&mut self, name: &str) -> Option { + self.registry.unregister(name) + } +} + +impl Default for PluginManager { + fn default() -> Self { + Self::new(ManagerConfig::default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::PluginContext; + use crate::error::PluginViolation; + use crate::hooks::payload::FilteredExtensions; + use crate::hooks::{HookHandler, PluginResult}; + use crate::plugin::{OnError, PluginMode}; + use async_trait::async_trait; + + // -- Test payload -- + + #[derive(Debug, Clone)] + struct TestPayload { + value: String, + } + crate::impl_plugin_payload!(TestPayload); + + // -- Test hook type -- + + struct TestHook; + impl HookTypeDef for TestHook { + type Payload = TestPayload; + type Result = PluginResult; + const NAME: &'static str = "test_hook"; + } + + // -- Test plugins: implement Plugin + HookHandler -- + // No AnyHookHandler boilerplate — the framework handles it. + + /// Plugin that allows everything. + struct AllowPlugin { + cfg: PluginConfig, + } + + #[async_trait] + impl Plugin for AllowPlugin { + fn config(&self) -> &PluginConfig { &self.cfg } + async fn initialize(&self) -> Result<(), PluginError> { Ok(()) } + async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } + } + + impl HookHandler for AllowPlugin { + fn handle( + &self, + _payload: &TestPayload, + _extensions: &FilteredExtensions, + _ctx: &PluginContext, + ) -> PluginResult { + PluginResult::allow() + } + } + + /// Plugin that denies everything. + struct DenyPlugin { + cfg: PluginConfig, + } + + #[async_trait] + impl Plugin for DenyPlugin { + fn config(&self) -> &PluginConfig { &self.cfg } + async fn initialize(&self) -> Result<(), PluginError> { Ok(()) } + async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } + } + + impl HookHandler for DenyPlugin { + fn handle( + &self, + _payload: &TestPayload, + _extensions: &FilteredExtensions, + _ctx: &PluginContext, + ) -> PluginResult { + PluginResult::deny(PluginViolation::new("denied", "test denial")) + } + } + + // -- Helper -- + + fn make_config(name: &str, priority: i32, mode: PluginMode) -> PluginConfig { + PluginConfig { + name: name.to_string(), + kind: "test".to_string(), + description: None, + author: None, + version: None, + hooks: vec!["test_hook".to_string()], + mode, + priority, + on_error: OnError::Fail, + capabilities: Default::default(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + } + } + + // -- Tests -- + + #[tokio::test] + async fn test_manager_lifecycle() { + let mut mgr = PluginManager::default(); + assert!(!mgr.is_initialized()); + assert_eq!(mgr.plugin_count(), 0); + + mgr.initialize().await.unwrap(); + assert!(mgr.is_initialized()); + + // Idempotent + mgr.initialize().await.unwrap(); + + mgr.shutdown().await; + assert!(!mgr.is_initialized()); + } + + #[tokio::test] + async fn test_invoke_by_name_no_plugins() { + let mgr = PluginManager::default(); + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let ctx = GlobalContext::new("req-1"); + + let result = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), &ctx) + .await; + + assert!(result.allowed); + assert!(result.payload.is_some()); + } + + #[tokio::test] + async fn test_invoke_by_name_allow() { + let mut mgr = PluginManager::default(); + let config = make_config("allow-plugin", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + + // Clean registration — no AnyHookHandler needed + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let ctx = GlobalContext::new("req-1"); + + let result = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), &ctx) + .await; + + assert!(result.allowed); + } + + #[tokio::test] + async fn test_invoke_by_name_deny() { + let mut mgr = PluginManager::default(); + let config = make_config("deny-plugin", 10, PluginMode::Sequential); + let plugin = Arc::new(DenyPlugin { cfg: config.clone() }); + + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let ctx = GlobalContext::new("req-1"); + + let result = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), &ctx) + .await; + + assert!(!result.allowed); + assert_eq!(result.violation.as_ref().unwrap().code, "denied"); + } + + #[tokio::test] + async fn test_invoke_typed() { + let mut mgr = PluginManager::default(); + let config = make_config("allow-plugin", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload = TestPayload { + value: "typed".into(), + }; + let ctx = GlobalContext::new("req-1"); + + let result = mgr + .invoke::(payload, Extensions::default(), &ctx) + .await; + + assert!(result.allowed); + } + + #[tokio::test] + async fn test_has_hooks_for() { + let mut mgr = PluginManager::default(); + assert!(!mgr.has_hooks_for("test_hook")); + + let config = make_config("p1", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + mgr.register_handler::(plugin, config).unwrap(); + + assert!(mgr.has_hooks_for("test_hook")); + assert!(!mgr.has_hooks_for("other_hook")); + } + + #[tokio::test] + async fn test_unregister() { + let mut mgr = PluginManager::default(); + let config = make_config("removable", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + mgr.register_handler::(plugin, config).unwrap(); + + assert_eq!(mgr.plugin_count(), 1); + mgr.unregister("removable"); + assert_eq!(mgr.plugin_count(), 0); + assert!(!mgr.has_hooks_for("test_hook")); + } + + #[tokio::test] + async fn test_audit_plugin_cannot_block() { + let mut mgr = PluginManager::default(); + let config = make_config("audit-denier", 10, PluginMode::Audit); + let plugin = Arc::new(DenyPlugin { cfg: config.clone() }); + + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { + value: "test".into(), + }); + let ctx = GlobalContext::new("req-1"); + + let result = mgr + .invoke_by_name("test_hook", payload, Extensions::default(), &ctx) + .await; + + // Audit mode — deny is suppressed, pipeline continues + assert!(result.allowed); + } +} diff --git a/crates/cpex-core/src/plugin.rs b/crates/cpex-core/src/plugin.rs new file mode 100644 index 00000000..9d4a00a6 --- /dev/null +++ b/crates/cpex-core/src/plugin.rs @@ -0,0 +1,414 @@ +// Location: ./crates/cpex-core/src/plugin.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Plugin trait and supporting types. +// +// Defines the core Plugin trait that all plugin implementations satisfy — +// native Rust, WASM hosts, Python bridge hosts, and dlopen'd shared +// libraries. Also defines PluginConfig (YAML-declared plugin settings), +// PluginMode (5-phase execution modes), and OnError (failure behavior). +// +// The Plugin trait handles lifecycle only (initialize, shutdown, config). +// Hook-specific logic is defined by handler traits generated by the +// define_hook! macro (see hooks/macros.rs). A plugin implements Plugin +// for lifecycle + one or more handler traits for the hooks it handles. +// +// The manager wraps each plugin in a PluginRef with an authoritative +// config from the config loader — the plugin's own config() is for +// the plugin's reading only, never used by the executor for scheduling. + +use std::collections::HashSet; +use std::fmt; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::error::PluginError; + +// --------------------------------------------------------------------------- +// Plugin Trait +// --------------------------------------------------------------------------- + +/// Core plugin interface — lifecycle management only. +/// +/// Every plugin in the CPEX framework — regardless of language or +/// deployment model — implements this trait. It covers lifecycle +/// (initialize, shutdown) and identity (config). Hook-specific logic +/// is defined separately by handler traits generated by `define_hook!`. +/// +/// # Lifecycle +/// +/// 1. `initialize()` — called once after loading, before any hooks fire. +/// 2. Hook handlers — called on each hook invocation (defined by handler traits). +/// 3. `shutdown()` — called once during graceful teardown. +/// +/// # Hook Handlers +/// +/// A plugin implements one or more handler traits alongside Plugin: +/// +/// ```rust,ignore +/// impl Plugin for MyPlugin { +/// fn config(&self) -> &PluginConfig { &self.config } +/// async fn initialize(&self) -> Result<(), PluginError> { Ok(()) } +/// async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } +/// } +/// +/// impl CmfHookHandler for MyPlugin { +/// fn cmf_hook(&self, payload: MessagePayload, ext: &FilteredExtensions, ctx: &PluginContext) -> PluginResult { +/// PluginResult::allow() +/// } +/// } +/// ``` +/// +/// # Trust Model +/// +/// The manager wraps each plugin in a `PluginRef` with an authoritative +/// config from the config loader. The executor reads scheduling decisions +/// (mode, priority, hooks, capabilities) from the `PluginRef` — never +/// from `plugin.config()`. The plugin's own `config()` is available for +/// the plugin's reading during hook execution. +/// +/// # Implementors +/// +/// - Native Rust plugins (implement directly) +/// - `cpex-hosts::wasm` (bridges to WASM guest via wasmtime) +/// - `cpex-hosts::python` (bridges to Python plugin classes via PyO3) +/// - `cpex-hosts::native` (bridges to dlopen'd shared libraries) +#[async_trait] +pub trait Plugin: Send + Sync { + /// Returns the plugin's configuration. + /// + /// Available for the plugin's own reading during hook execution. + /// The manager/executor never reads this — they use the authoritative + /// config from `PluginRef.trusted_config()`. + fn config(&self) -> &PluginConfig; + + /// One-time initialization after loading. + /// + /// Called before any hook invocations. Use this to establish + /// connections, load resources, or validate configuration. + async fn initialize(&self) -> Result<(), PluginError>; + + /// Graceful shutdown. + /// + /// Called once during teardown. Use this to flush buffers, close + /// connections, or release resources. + async fn shutdown(&self) -> Result<(), PluginError>; +} + +// --------------------------------------------------------------------------- +// Plugin Configuration +// --------------------------------------------------------------------------- + +/// Declared plugin configuration from the unified YAML config. +/// +/// Controls how the framework loads, schedules, and gates the plugin. +/// Corresponds to a single entry in the `plugins:` list in config YAML. +/// +/// The manager holds the authoritative copy in `PluginRef.trusted_config`. +/// The plugin receives its own copy for reading via `Plugin::config()`. +/// +/// # Examples +/// +/// ```yaml +/// plugins: +/// - name: apl-policy +/// kind: builtin +/// hooks: [tool_pre_invoke, tool_post_invoke] +/// mode: sequential +/// priority: 10 +/// on_error: fail +/// capabilities: [read_security, append_labels] +/// config: +/// policy_file: apl/demo/hr_policy.yaml +/// ``` +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PluginConfig { + /// Unique plugin name. + pub name: String, + + /// Plugin kind — determines how the framework loads it. + /// + /// - `"builtin"` — compiled into the runtime + /// - `"native://path/to/lib.so"` — dlopen'd shared library + /// - `"wasm://path/to/plugin.wasm"` — wasmtime sandbox + /// - `"python://module.path.ClassName"` — PyO3 bridge + /// - `"external"` — MCP/gRPC/Unix socket transport + pub kind: String, + + /// Human-readable description. + #[serde(default)] + pub description: Option, + + /// Plugin author or team. + #[serde(default)] + pub author: Option, + + /// Semantic version string. + #[serde(default)] + pub version: Option, + + /// Hook names this plugin handles. + #[serde(default)] + pub hooks: Vec, + + /// Execution mode — determines scheduling behavior and authority. + #[serde(default)] + pub mode: PluginMode, + + /// Execution priority — lower numbers execute first within each mode. + #[serde(default = "default_priority")] + pub priority: i32, + + /// Error handling behavior when the plugin fails. + #[serde(default)] + pub on_error: OnError, + + /// Declared capabilities for extension visibility gating. + /// + /// Controls which extensions the plugin can see and modify. + /// Extensions not covered by declared capabilities appear as + /// `None` in the filtered view. + #[serde(default)] + pub capabilities: HashSet, + + /// Tags for categorization and searchability. + #[serde(default)] + pub tags: Vec, + + /// Legacy conditions for when the plugin should execute. + /// + /// Each condition narrows the plugin's scope by server, tenant, + /// tool name, prompt name, etc. If any condition in the list + /// matches, the plugin runs. If the list is empty (default), + /// the plugin runs unconditionally. + /// + /// **Backward compatibility:** Conditions are the legacy mechanism + /// for scoping plugins. When the host uses the unified routing + /// system (`routes:` in config YAML), routing rules handle scope + /// matching and conditions on the plugin are ignored. The two + /// mechanisms should not be used together on the same plugin. + #[serde(default)] + pub conditions: Vec, + + /// Plugin-specific configuration (opaque to the framework). + #[serde(default)] + pub config: Option, +} + +fn default_priority() -> i32 { + 100 +} + +// --------------------------------------------------------------------------- +// Plugin Condition (legacy scoping) +// --------------------------------------------------------------------------- + +/// Condition for when a plugin should execute. +/// +/// Narrows plugin scope to specific servers, tenants, tools, prompts, +/// resources, or agents. All fields are optional — only specified +/// fields participate in matching. Within a field, any match suffices +/// (OR semantics). Across fields, all must match (AND semantics). +/// +/// This is the legacy scoping mechanism. The unified routing system +/// (`routes:` in config) supersedes this — when routes are used, +/// conditions are ignored. +/// +/// Mirrors Python's `PluginCondition` in `cpex/framework/models.py`. +/// +/// # Examples +/// +/// ``` +/// use cpex_core::plugin::PluginCondition; +/// +/// // Only run for specific tools on specific servers +/// let cond = PluginCondition { +/// server_ids: Some(vec!["server-1".into(), "server-2".into()].into_iter().collect()), +/// tools: Some(vec!["get_compensation".into()].into_iter().collect()), +/// ..Default::default() +/// }; +/// assert!(cond.server_ids.as_ref().unwrap().contains("server-1")); +/// assert!(cond.tools.as_ref().unwrap().contains("get_compensation")); +/// ``` +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PluginCondition { + /// Set of server IDs — plugin runs only on these servers. + #[serde(default)] + pub server_ids: Option>, + + /// Set of tenant IDs — plugin runs only for these tenants. + #[serde(default)] + pub tenant_ids: Option>, + + /// Set of tool names — plugin runs only for these tools. + #[serde(default)] + pub tools: Option>, + + /// Set of prompt names — plugin runs only for these prompts. + #[serde(default)] + pub prompts: Option>, + + /// Set of resource identifiers — plugin runs only for these resources. + #[serde(default)] + pub resources: Option>, + + /// Set of agent identifiers — plugin runs only for these agents. + #[serde(default)] + pub agents: Option>, + + /// User patterns (glob or regex) — plugin runs only for matching users. + #[serde(default)] + pub user_patterns: Option>, + + /// Content types — plugin runs only for these content types. + #[serde(default)] + pub content_types: Option>, +} + +impl PluginCondition { + /// Whether this condition matches the given context. + /// + /// A field that is `None` is treated as "any" (no restriction). + /// A field that is `Some(set)` matches if the given value is in the set. + /// All specified fields must match (AND semantics). + pub fn matches( + &self, + server_id: Option<&str>, + tenant_id: Option<&str>, + tool: Option<&str>, + prompt: Option<&str>, + resource: Option<&str>, + agent: Option<&str>, + ) -> bool { + let check = |field: &Option>, value: Option<&str>| -> bool { + match field { + None => true, // not specified — matches anything + Some(set) => match value { + Some(v) => set.contains(v), + None => false, // field required but no value provided + }, + } + }; + + check(&self.server_ids, server_id) + && check(&self.tenant_ids, tenant_id) + && check(&self.tools, tool) + && check(&self.prompts, prompt) + && check(&self.resources, resource) + && check(&self.agents, agent) + } +} + +// --------------------------------------------------------------------------- +// Plugin Mode +// --------------------------------------------------------------------------- + +/// Execution mode — determines a plugin's scheduling behavior and authority. +/// +/// The 5-phase model defines both what a plugin *can do* (block, modify) +/// and *how it runs* (serial, parallel, background). Scheduling is derived +/// from mode; plugin authors don't control it directly. +/// +/// # Execution Order +/// +/// ```text +/// SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET +/// ``` +/// +/// # Mode Capabilities +/// +/// | Mode | Can Block? | Can Modify? | Execution | +/// |----------------|------------|-------------|-----------------| +/// | Sequential | Yes | Yes | Serial, chained | +/// | Transform | No | Yes | Serial, chained | +/// | Audit | No | No | Serial | +/// | Concurrent | Yes | No | Parallel | +/// | FireAndForget | No | No | Background | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum PluginMode { + /// Policy enforcement + transformation. Serial, chained. Can block and modify. + #[default] + Sequential, + + /// Data shaping (PII redaction, normalization). Serial, chained. Can modify, cannot block. + Transform, + + /// Observation and logging. Serial, read-only. Cannot block or modify. + Audit, + + /// Independent policy gates. Parallel, fail-fast. Can block, cannot modify. + Concurrent, + + /// Telemetry and async side effects. Background tasks. Cannot block or modify. + FireAndForget, + + /// Plugin is disabled — skipped during execution. + Disabled, +} + +impl PluginMode { + /// Whether this mode allows the plugin to block the pipeline. + pub fn can_block(&self) -> bool { + matches!(self, Self::Sequential | Self::Concurrent) + } + + /// Whether this mode allows the plugin to modify the payload. + pub fn can_modify(&self) -> bool { + matches!(self, Self::Sequential | Self::Transform) + } + + /// Whether the framework waits for this plugin to complete. + pub fn is_awaited(&self) -> bool { + !matches!(self, Self::FireAndForget | Self::Disabled) + } +} + +impl fmt::Display for PluginMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Sequential => write!(f, "sequential"), + Self::Transform => write!(f, "transform"), + Self::Audit => write!(f, "audit"), + Self::Concurrent => write!(f, "concurrent"), + Self::FireAndForget => write!(f, "fire_and_forget"), + Self::Disabled => write!(f, "disabled"), + } + } +} + +// --------------------------------------------------------------------------- +// Error Handling Mode +// --------------------------------------------------------------------------- + +/// Error handling behavior when a plugin fails. +/// +/// Independent of [`PluginMode`] — any mode can use any error behavior. +/// Controls whether plugin failures halt the pipeline, are logged and +/// skipped, or cause the plugin to be auto-disabled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum OnError { + /// Pipeline halts and error propagates. Fail-safe enforcement. + #[default] + Fail, + + /// Error logged, pipeline continues. For non-critical plugins. + Ignore, + + /// Plugin auto-disabled after error. Prevents repeated failures. + Disable, +} + +impl fmt::Display for OnError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Fail => write!(f, "fail"), + Self::Ignore => write!(f, "ignore"), + Self::Disable => write!(f, "disable"), + } + } +} diff --git a/crates/cpex-core/src/registry.rs b/crates/cpex-core/src/registry.rs new file mode 100644 index 00000000..03f50ee6 --- /dev/null +++ b/crates/cpex-core/src/registry.rs @@ -0,0 +1,621 @@ +// Location: ./crates/cpex-core/src/registry.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Plugin and hook registries. +// +// PluginRef wraps a plugin implementation with the manager's +// authoritative config. The config comes from the config loader, +// NOT from the plugin — the plugin never provides its own config +// to the manager. This prevents a plugin from tampering with its +// own priority, mode, or capabilities. +// +// Trust flows one direction: +// config loader → manager → PluginRef → executor +// The plugin is just a recipient, not a source. +// +// The registry supports two registration paths: +// +// 1. **Typed** (`register::()`) — for Rust plugins implementing +// a handler trait generated by define_hook!. The handler is stored +// type-erased alongside the PluginRef. At dispatch time, the typed +// path (`invoke::()`) downcasts back; the dynamic path +// (`invoke_by_name()`) calls through the type-erased interface. +// +// 2. **Name-based** (`register_for_names::()`) — same handler +// registered under multiple hook names (the CMF pattern). +// +// Mirrors the Python framework's PluginRef and PluginInstanceRegistry +// in cpex/framework/base.py and cpex/framework/registry.py. + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::context::PluginContext; +use crate::hooks::payload::{FilteredExtensions, PluginPayload}; +use crate::hooks::trait_def::HookTypeDef; +use crate::hooks::HookType; +use crate::plugin::{Plugin, PluginConfig, PluginMode}; + +// --------------------------------------------------------------------------- +// Plugin Ref — trusted wrapper +// --------------------------------------------------------------------------- + +/// Manager-owned wrapper that pairs a plugin with its authoritative config. +/// +/// The `trusted_config` comes from the config loader / manager — never +/// from the plugin itself. The executor reads all scheduling decisions +/// (mode, priority, hooks, capabilities, on_error) from this config. +/// +/// The plugin receives a copy of its config at construction time so it +/// can read its own settings during hook execution. But the manager/executor +/// never reads config back from the plugin. +/// +/// Trust flow: +/// ```text +/// config loader → manager → PluginRef.trusted_config → executor +/// ↘ plugin (receives a copy, cannot influence scheduling) +/// ``` +#[derive(Clone)] +pub struct PluginRef { + /// The plugin implementation. + plugin: Arc, + + /// Authoritative config from the config loader. + /// The executor uses this for all scheduling and capability decisions. + trusted_config: PluginConfig, + + /// Unique identifier assigned by the registry. + id: String, +} + +impl PluginRef { + /// Create a new PluginRef with an independently-sourced config. + /// + /// The `trusted_config` must come from the config loader or manager, + /// NOT from `plugin.config()`. The plugin may hold its own copy + /// for reading during execute(), but the manager never consults it. + pub fn new(plugin: Arc, trusted_config: PluginConfig) -> Self { + let id = uuid::Uuid::new_v4().to_string(); + Self { + plugin, + trusted_config, + id, + } + } + + /// The authoritative config used by the executor for all decisions. + pub fn trusted_config(&self) -> &PluginConfig { + &self.trusted_config + } + + /// The plugin implementation (for calling initialize/shutdown). + pub fn plugin(&self) -> &Arc { + &self.plugin + } + + /// Unique identifier assigned at registration. + pub fn id(&self) -> &str { + &self.id + } + + /// Convenience: plugin name from the trusted config. + pub fn name(&self) -> &str { + &self.trusted_config.name + } + + /// Convenience: plugin mode from the trusted config. + pub fn mode(&self) -> PluginMode { + self.trusted_config.mode + } + + /// Convenience: plugin priority from the trusted config. + pub fn priority(&self) -> i32 { + self.trusted_config.priority + } +} + +// --------------------------------------------------------------------------- +// Type-Erased Hook Handler +// --------------------------------------------------------------------------- + +/// Type-erased interface for calling a hook handler. +/// +/// The executor uses this to dispatch hooks without knowing the +/// concrete handler trait at compile time. Each handler wraps a +/// plugin that implements a specific handler trait (e.g., +/// `CmfHookHandler`) and translates between type-erased payloads +/// and the typed handler method. +/// +/// Two invoke paths: +/// - `invoke_owned()` — for Sequential/Transform modes. Receives +/// an owned payload (Box). The handler downcasts and calls +/// the typed method. +/// - `invoke_ref()` — for Audit/Concurrent/FireAndForget modes. +/// Receives a borrowed payload (&dyn Any). No clone needed. +pub trait AnyHookHandler: Send + Sync { + /// Call the handler with a borrowed payload. + /// + /// The handler downcasts `&dyn PluginPayload` to the concrete + /// type via `as_any()`. The framework always retains ownership + /// of the payload — the handler clones only if it needs to modify. + /// + /// Returns an `ErasedResultFields` (see executor module) wrapped + /// as `Box`. If the handler modified the payload, the + /// modified copy is in `ErasedResultFields.modified_payload`. + fn invoke( + &self, + payload: &dyn PluginPayload, + extensions: &FilteredExtensions, + ctx: &PluginContext, + ) -> Result, crate::error::PluginError>; + + /// The hook type name this handler was registered for. + fn hook_type_name(&self) -> &'static str; +} + +// --------------------------------------------------------------------------- +// Hook Entry — PluginRef + handler paired together +// --------------------------------------------------------------------------- + +/// A registered hook handler paired with its PluginRef. +/// +/// The executor uses `plugin_ref` for scheduling decisions (mode, +/// priority, capabilities) and `handler` for actual dispatch. +#[derive(Clone)] +pub struct HookEntry { + /// The plugin wrapper with authoritative config. + pub plugin_ref: PluginRef, + + /// The type-erased handler for this specific hook. + pub handler: Arc, +} + +// --------------------------------------------------------------------------- +// Plugin Registry +// --------------------------------------------------------------------------- + +/// Manages registered plugin instances and hook handler mappings. +/// +/// Stores `PluginRef` wrappers by name and `HookEntry` (PluginRef + +/// handler) by hook name. The executor reads scheduling decisions +/// from `PluginRef.trusted_config` and dispatches through the +/// type-erased handler. +/// +/// Supports two registration patterns: +/// +/// - `register::()` — typed registration for a single hook name +/// (derived from `H::NAME`). +/// - `register_for_names::()` — typed registration for multiple +/// hook names (the CMF pattern where one handler covers +/// `cmf.tool_pre_invoke`, `cmf.llm_input`, etc.). +pub struct PluginRegistry { + /// Plugins keyed by name (for lookup and lifecycle). + plugins: HashMap, + + /// Hook name → list of HookEntries, sorted by priority. + hook_index: HashMap>, +} + +impl PluginRegistry { + /// Create an empty registry. + pub fn new() -> Self { + Self { + plugins: HashMap::new(), + hook_index: HashMap::new(), + } + } + + /// Register a typed hook handler for its primary hook name. + /// + /// The handler is registered under `H::NAME`. The `config` must + /// come from the config loader — not from the plugin. The plugin + /// must implement the handler trait generated by `define_hook!`. + /// + /// # Type Parameters + /// + /// - `H` — the hook type (implements `HookTypeDef`). + /// + /// # Arguments + /// + /// - `plugin` — the plugin implementation (must also implement the handler trait). + /// - `config` — authoritative config from the config loader. + /// - `handler` — type-erased handler wrapping the plugin's handler trait impl. + pub fn register( + &mut self, + plugin: Arc, + config: PluginConfig, + handler: Arc, + ) -> Result<(), String> { + self.register_for_names_inner(plugin, config, handler, &[H::NAME]) + } + + /// Register a typed hook handler for multiple hook names. + /// + /// This is the CMF pattern — one handler trait impl covers multiple + /// hook names (`cmf.tool_pre_invoke`, `cmf.llm_input`, etc.). + /// + /// # Arguments + /// + /// - `plugin` — the plugin implementation. + /// - `config` — authoritative config from the config loader. + /// - `handler` — type-erased handler. + /// - `names` — hook names to register under. + pub fn register_for_names( + &mut self, + plugin: Arc, + config: PluginConfig, + handler: Arc, + names: &[&str], + ) -> Result<(), String> { + self.register_for_names_inner(plugin, config, handler, names) + } + + /// Register a plugin with its authoritative config using hook names + /// from the config (legacy path — no typed handler). + /// + /// This is the backward-compatible path for plugins that don't use + /// the typed hook system. The plugin is registered in the name index + /// and hook index, but without a type-erased handler. The executor + /// must use `invoke_by_name()` with payload conversion for these. + pub fn register_legacy( + &mut self, + plugin: Arc, + config: PluginConfig, + ) -> Result<(), String> { + let name = config.name.clone(); + + if self.plugins.contains_key(&name) { + return Err(format!("plugin '{}' is already registered", name)); + } + + let plugin_ref = PluginRef::new(plugin, config); + self.plugins.insert(name, plugin_ref); + Ok(()) + } + + /// Internal: register handler under one or more hook names. + fn register_for_names_inner( + &mut self, + plugin: Arc, + config: PluginConfig, + handler: Arc, + names: &[&str], + ) -> Result<(), String> { + let name = config.name.clone(); + + if self.plugins.contains_key(&name) { + return Err(format!("plugin '{}' is already registered", name)); + } + + let plugin_ref = PluginRef::new(plugin, config); + + // Add to hook index for each specified hook name + for hook_name in names { + let hook_type = HookType::new(*hook_name); + let entry = HookEntry { + plugin_ref: plugin_ref.clone(), + handler: Arc::clone(&handler), + }; + self.hook_index.entry(hook_type).or_default().push(entry); + } + + // Sort each affected hook's entry list by trusted priority + for hook_name in names { + let hook_type = HookType::new(*hook_name); + if let Some(entries) = self.hook_index.get_mut(&hook_type) { + entries.sort_by_key(|e| e.plugin_ref.priority()); + } + } + + self.plugins.insert(name, plugin_ref); + Ok(()) + } + + /// Unregister a plugin by name. + /// + /// Removes the PluginRef from the name index and all HookEntries + /// from the hook index. Returns the PluginRef if found. + pub fn unregister(&mut self, name: &str) -> Option { + let plugin_ref = self.plugins.remove(name)?; + + // Remove from hook index + for entries in self.hook_index.values_mut() { + entries.retain(|e| e.plugin_ref.name() != name); + } + + // Clean up empty hook entries + self.hook_index.retain(|_, entries| !entries.is_empty()); + + Some(plugin_ref) + } + + /// Look up a PluginRef by name. + pub fn get(&self, name: &str) -> Option<&PluginRef> { + self.plugins.get(name) + } + + /// Returns all HookEntries for a given hook name, sorted by priority. + /// + /// Returns an empty slice if no plugins are registered for the hook. + pub fn entries_for_hook(&self, hook_type: &HookType) -> &[HookEntry] { + self.hook_index + .get(hook_type) + .map(|v| v.as_slice()) + .unwrap_or(&[]) + } + + /// Whether any plugins are registered for the given hook name. + pub fn has_hooks_for(&self, hook_type: &HookType) -> bool { + self.hook_index + .get(hook_type) + .map(|v| !v.is_empty()) + .unwrap_or(false) + } + + /// Total number of registered plugins. + pub fn plugin_count(&self) -> usize { + self.plugins.len() + } + + /// All registered plugin names. + pub fn plugin_names(&self) -> Vec<&str> { + self.plugins.keys().map(|s| s.as_str()).collect() + } +} + +impl Default for PluginRegistry { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Group HookEntries by mode (used by the executor) +// --------------------------------------------------------------------------- + +/// Groups a list of HookEntries by their execution mode. +/// +/// Reads mode from `plugin_ref.trusted_config` — never from the plugin. +/// Returns a tuple of five vectors in execution order: +/// (sequential, transform, audit, concurrent, fire_and_forget). +/// Disabled plugins are excluded. +pub fn group_by_mode( + entries: &[HookEntry], +) -> ( + Vec, + Vec, + Vec, + Vec, + Vec, +) { + let mut sequential = Vec::new(); + let mut transform = Vec::new(); + let mut audit = Vec::new(); + let mut concurrent = Vec::new(); + let mut fire_and_forget = Vec::new(); + + for entry in entries { + match entry.plugin_ref.mode() { + PluginMode::Sequential => sequential.push(entry.clone()), + PluginMode::Transform => transform.push(entry.clone()), + PluginMode::Audit => audit.push(entry.clone()), + PluginMode::Concurrent => concurrent.push(entry.clone()), + PluginMode::FireAndForget => fire_and_forget.push(entry.clone()), + PluginMode::Disabled => {} // skip + } + } + + (sequential, transform, audit, concurrent, fire_and_forget) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::PluginError; + use crate::hooks::payload::PluginPayload; + use crate::hooks::PluginResult; + use async_trait::async_trait; + + // -- Test payload and hook type -- + + #[derive(Debug, Clone)] + struct TestPayload { + value: String, + } + crate::impl_plugin_payload!(TestPayload); + + // -- Test handler (type-erased wrapper) -- + + /// A simple AnyHookHandler that wraps a function for testing. + struct TestHandler; + + impl AnyHookHandler for TestHandler { + fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &FilteredExtensions, + _ctx: &PluginContext, + ) -> Result, PluginError> { + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + // -- Test plugin -- + + struct TestPlugin { + cfg: PluginConfig, + } + + fn make_config(name: &str, hooks: Vec<&str>, priority: i32) -> PluginConfig { + PluginConfig { + name: name.to_string(), + kind: "test".to_string(), + description: None, + author: None, + version: None, + hooks: hooks.into_iter().map(String::from).collect(), + mode: PluginMode::Sequential, + priority, + on_error: Default::default(), + capabilities: Default::default(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + } + } + + impl TestPlugin { + fn new(cfg: PluginConfig) -> Self { + Self { cfg } + } + } + + #[async_trait] + impl Plugin for TestPlugin { + fn config(&self) -> &PluginConfig { + &self.cfg + } + async fn initialize(&self) -> Result<(), PluginError> { + Ok(()) + } + async fn shutdown(&self) -> Result<(), PluginError> { + Ok(()) + } + } + + // -- Tests -- + + #[test] + fn test_register_typed_and_lookup() { + let mut reg = PluginRegistry::new(); + let config = make_config("test-plugin", vec!["test_hook"], 10); + let plugin = Arc::new(TestPlugin::new(config.clone())); + let handler: Arc = Arc::new(TestHandler); + + // Use register_for_names_inner directly since we don't have a real HookTypeDef + reg.register_for_names_inner(plugin, config, handler, &["test_hook"]) + .unwrap(); + + assert_eq!(reg.plugin_count(), 1); + assert!(reg.get("test-plugin").is_some()); + assert!(reg.has_hooks_for(&HookType::new("test_hook"))); + assert!(!reg.has_hooks_for(&HookType::new("other_hook"))); + + let entries = reg.entries_for_hook(&HookType::new("test_hook")); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].plugin_ref.name(), "test-plugin"); + } + + #[test] + fn test_register_for_multiple_names() { + let mut reg = PluginRegistry::new(); + let config = make_config("cmf-plugin", vec![], 10); + let plugin = Arc::new(TestPlugin::new(config.clone())); + let handler: Arc = Arc::new(TestHandler); + + reg.register_for_names_inner( + plugin, + config, + handler, + &["cmf.tool_pre_invoke", "cmf.tool_post_invoke", "cmf.llm_input"], + ) + .unwrap(); + + assert_eq!(reg.plugin_count(), 1); + assert!(reg.has_hooks_for(&HookType::new("cmf.tool_pre_invoke"))); + assert!(reg.has_hooks_for(&HookType::new("cmf.tool_post_invoke"))); + assert!(reg.has_hooks_for(&HookType::new("cmf.llm_input"))); + assert!(!reg.has_hooks_for(&HookType::new("cmf.llm_output"))); + } + + #[test] + fn test_duplicate_registration_fails() { + let mut reg = PluginRegistry::new(); + let c1 = make_config("dup", vec![], 10); + let c2 = make_config("dup", vec![], 20); + let p1 = Arc::new(TestPlugin::new(c1.clone())); + let p2 = Arc::new(TestPlugin::new(c2.clone())); + let h1: Arc = Arc::new(TestHandler); + let h2: Arc = Arc::new(TestHandler); + + assert!(reg.register_for_names_inner(p1, c1, h1, &["hook_a"]).is_ok()); + assert!(reg.register_for_names_inner(p2, c2, h2, &["hook_a"]).is_err()); + } + + #[test] + fn test_priority_ordering_uses_trusted_config() { + let mut reg = PluginRegistry::new(); + let c_low = make_config("low", vec![], 100); + let c_high = make_config("high", vec![], 10); + let p_low = Arc::new(TestPlugin::new(c_low.clone())); + let p_high = Arc::new(TestPlugin::new(c_high.clone())); + let h1: Arc = Arc::new(TestHandler); + let h2: Arc = Arc::new(TestHandler); + + reg.register_for_names_inner(p_low, c_low, h1, &["hook_a"]).unwrap(); + reg.register_for_names_inner(p_high, c_high, h2, &["hook_a"]).unwrap(); + + let entries = reg.entries_for_hook(&HookType::new("hook_a")); + assert_eq!(entries[0].plugin_ref.name(), "high"); // priority 10 first + assert_eq!(entries[1].plugin_ref.name(), "low"); // priority 100 second + } + + #[test] + fn test_unregister() { + let mut reg = PluginRegistry::new(); + let config = make_config("removable", vec![], 10); + let plugin = Arc::new(TestPlugin::new(config.clone())); + let handler: Arc = Arc::new(TestHandler); + + reg.register_for_names_inner(plugin, config, handler, &["hook_a"]) + .unwrap(); + + assert_eq!(reg.plugin_count(), 1); + reg.unregister("removable"); + assert_eq!(reg.plugin_count(), 0); + assert!(!reg.has_hooks_for(&HookType::new("hook_a"))); + } + + #[test] + fn test_plugin_ref_id_is_unique() { + let c1 = make_config("a", vec![], 10); + let c2 = make_config("b", vec![], 10); + let p1 = Arc::new(TestPlugin::new(c1.clone())); + let p2 = Arc::new(TestPlugin::new(c2.clone())); + let ref1 = PluginRef::new(p1, c1); + let ref2 = PluginRef::new(p2, c2); + assert_ne!(ref1.id(), ref2.id()); + } + + #[test] + fn test_tampered_plugin_config_ignored() { + let trusted = make_config("sneaky", vec![], 100); + let mut tampered = trusted.clone(); + tampered.priority = 1; + let plugin = Arc::new(TestPlugin::new(tampered)); + + let plugin_ref = PluginRef::new(plugin, trusted); + assert_eq!(plugin_ref.priority(), 100); + } + + #[test] + fn test_handler_invoke() { + let handler = TestHandler; + let payload = TestPayload { + value: "test".into(), + }; + let ext = FilteredExtensions::default(); + let ctx = PluginContext::new(crate::context::GlobalContext::new("req-1")); + + let result = handler.invoke(&payload as &dyn PluginPayload, &ext, &ctx).unwrap(); + let fields = crate::executor::extract_erased(result).unwrap(); + assert!(fields.continue_processing); + } +} diff --git a/crates/cpex-sdk/Cargo.toml b/crates/cpex-sdk/Cargo.toml new file mode 100644 index 00000000..1077a33f --- /dev/null +++ b/crates/cpex-sdk/Cargo.toml @@ -0,0 +1,22 @@ +# Location: ./crates/cpex-sdk/Cargo.toml +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Teryl Taylor +# +# CPEX SDK — lean crate for plugin authors. +# Re-exports the Plugin trait and payload/result types from cpex-core +# without pulling in the PluginManager, hosts, or FFI. + +[package] +name = "cpex-sdk" +description = "CPEX plugin author SDK — Plugin trait, payloads, and result types." +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +cpex-core = { path = "../cpex-core" } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/cpex-sdk/src/lib.rs b/crates/cpex-sdk/src/lib.rs new file mode 100644 index 00000000..6992d196 --- /dev/null +++ b/crates/cpex-sdk/src/lib.rs @@ -0,0 +1,28 @@ +// Location: ./crates/cpex-sdk/src/lib.rs +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CPEX SDK — lean crate for plugin authors. +// +// Re-exports the Plugin trait and supporting types from cpex-core. +// Plugin authors depend on this crate instead of the full runtime, +// keeping their dependency tree minimal. This is also the crate +// that WASM plugins compile against. + +// Plugin lifecycle +pub use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; + +// Hook system +pub use cpex_core::hooks::{ + Extensions, FilteredExtensions, HookHandler, HookTypeDef, PluginPayload, PluginResult, +}; + +// Context +pub use cpex_core::context::PluginContext; + +// Errors +pub use cpex_core::error::{PluginError, PluginViolation}; + +// Re-export the define_hook! macro +pub use cpex_core::define_hook; From 1613aaf7ad7ed0c59f3a0129dab564ad201e0b31 Mon Sep 17 00:00:00 2001 From: Teryl Taylor Date: Tue, 21 Apr 2026 21:01:32 -0600 Subject: [PATCH 2/2] fix: addressed comments in PR. Updated PluginContext to match spec. Signed-off-by: Teryl Taylor --- Cargo.lock | 102 ++++- Cargo.toml | 1 + crates/cpex-core/Cargo.toml | 2 +- crates/cpex-core/src/context.rs | 152 ++++--- crates/cpex-core/src/executor.rs | 332 ++++++++++---- crates/cpex-core/src/hooks/adapter.rs | 23 +- crates/cpex-core/src/hooks/macros.rs | 57 +-- crates/cpex-core/src/hooks/trait_def.rs | 7 +- crates/cpex-core/src/lib.rs | 2 +- crates/cpex-core/src/manager.rs | 557 ++++++++++++++++++++++-- crates/cpex-core/src/registry.rs | 90 ++-- 11 files changed, 1009 insertions(+), 316 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5cbba123..b06faa5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,7 +48,7 @@ name = "cpex-core" version = "0.1.0" dependencies = [ "async-trait", - "paste", + "futures", "serde", "serde_json", "serde_yaml", @@ -90,6 +90,94 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -231,12 +319,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -376,6 +458,12 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" diff --git a/Cargo.toml b/Cargo.toml index 39710358..03fcb104 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,3 +28,4 @@ thiserror = "2" tracing = "0.1" uuid = { version = "1", features = ["v4"] } paste = "1" +futures = "0.3" diff --git a/crates/cpex-core/Cargo.toml b/crates/cpex-core/Cargo.toml index 57421209..4e0d4006 100644 --- a/crates/cpex-core/Cargo.toml +++ b/crates/cpex-core/Cargo.toml @@ -24,4 +24,4 @@ async-trait = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } -paste = { workspace = true } +futures = { workspace = true } diff --git a/crates/cpex-core/src/context.rs b/crates/cpex-core/src/context.rs index bd65d032..59e61a8a 100644 --- a/crates/cpex-core/src/context.rs +++ b/crates/cpex-core/src/context.rs @@ -5,109 +5,74 @@ // // Execution context types. // -// Provides GlobalContext (shared across all plugins for a request) and -// PluginContext (per-plugin, per-invocation). These carry transient -// execution state — counters, caches, intermediate results. All data -// needed for policy evaluation comes from the payload's extensions -// (filtered by capabilities), not from context. +// Provides PluginContext — the per-plugin, per-invocation execution +// context carrying transient state (counters, caches, intermediate +// results). All data needed for policy evaluation comes from the +// payload's extensions (filtered by capabilities), not from context. // -// Mirrors the Python framework's GlobalContext and PluginContext types -// in cpex/framework/models.py. +// PluginContext has two state maps: +// - local_state: private to this plugin, this invocation +// - global_state: shared across plugins in a pipeline +// +// Identity, request metadata, tenant scope, etc. live in extensions +// (MetaExtension, SecurityExtension), not in the context. +// +// Mirrors the spec's PluginContext in plugin-framework-spec-v2.md §8.1. use std::collections::HashMap; use serde::{Deserialize, Serialize}; use serde_json::Value; -// --------------------------------------------------------------------------- -// Global Context -// --------------------------------------------------------------------------- - -/// Shared execution context for a single request. -/// -/// Visible to all plugins during a hook invocation. Plugins can read -/// and contribute to `state` (mutable shared state) and read `metadata` -/// (read-only shared metadata set by the host). -/// -/// # Fields -/// -/// * `request_id` — Unique identifier for the current request. -/// * `user` — User identifier or principal (string or structured). -/// * `tenant_id` — Optional multi-tenant scope. -/// * `server_id` — Optional virtual server scope. -/// * `state` — Mutable shared state merged between plugins. -/// * `metadata` — Read-only metadata set by the host before dispatch. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GlobalContext { - /// Unique request identifier (typically a UUID). - pub request_id: String, - - /// User identifier or principal. - #[serde(default)] - pub user: Value, - - /// Multi-tenant scope identifier. - #[serde(default)] - pub tenant_id: Option, - - /// Virtual server scope identifier. - #[serde(default)] - pub server_id: Option, - - /// Mutable shared state — plugins can read and contribute. - /// Merged back after each plugin execution. - #[serde(default)] - pub state: HashMap, - - /// Read-only shared metadata set by the host. - #[serde(default)] - pub metadata: HashMap, -} - -impl GlobalContext { - /// Create a new context with just a request ID. - pub fn new(request_id: impl Into) -> Self { - Self { - request_id: request_id.into(), - user: Value::Null, - tenant_id: None, - server_id: None, - state: HashMap::new(), - metadata: HashMap::new(), - } - } -} - // --------------------------------------------------------------------------- // Plugin Context // --------------------------------------------------------------------------- /// Per-plugin, per-invocation execution context. /// -/// Each plugin receives its own `PluginContext` with isolated local -/// state and a reference to the shared global context. The framework -/// deep-copies the global context per plugin to prevent cross-plugin -/// state leakage. +/// Each plugin receives its own `PluginContext` with: +/// +/// - `local_state` — private to this plugin, this invocation. Fresh +/// each time. Used for per-plugin counters, caches, scratch data. +/// - `global_state` — shared across all plugins in a pipeline. The +/// executor merges changes back after serial phases so subsequent +/// plugins see contributions from earlier ones. /// -/// This type carries transient execution state only — counters, caches, -/// intermediate results. All data needed for policy evaluation comes -/// from the payload's extensions (filtered by capabilities). +/// All data needed for policy evaluation (identity, tenant, request +/// metadata) comes from the payload's extensions, capability-gated +/// per plugin. Context is purely for transient execution state. +/// +/// ```text +/// PluginContext +/// ├── local_state: HashMap # Per-plugin, per-request. Private. +/// └── global_state: HashMap # Shared across plugins. Use with care. +/// ``` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginContext { /// Plugin-local state. Private to this plugin, this invocation. #[serde(default)] pub local_state: HashMap, - /// Snapshot of the global context for this invocation. - pub global_context: GlobalContext, + /// Shared state across all plugins in the pipeline. + /// The executor merges changes back after each serial-phase plugin. + #[serde(default)] + pub global_state: HashMap, } impl PluginContext { - /// Create a new plugin context from a global context. - pub fn new(global_context: GlobalContext) -> Self { + /// Create a new empty plugin context. + pub fn new() -> Self { + Self { + local_state: HashMap::new(), + global_state: HashMap::new(), + } + } + + /// Create a plugin context with pre-populated global state. + pub fn with_global_state(global_state: HashMap) -> Self { Self { local_state: HashMap::new(), - global_context, + global_state, } } @@ -120,4 +85,35 @@ impl PluginContext { pub fn set_local(&mut self, key: impl Into, value: Value) { self.local_state.insert(key.into(), value); } + + /// Get a value from global state. + pub fn get_global(&self, key: &str) -> Option<&Value> { + self.global_state.get(key) + } + + /// Set a value in global state. + pub fn set_global(&mut self, key: impl Into, value: Value) { + self.global_state.insert(key.into(), value); + } +} + +impl Default for PluginContext { + fn default() -> Self { + Self::new() + } } + +// --------------------------------------------------------------------------- +// Plugin Context Table +// --------------------------------------------------------------------------- + +/// Lookup table of `PluginContext` instances indexed by plugin ID. +/// +/// Threaded across hook invocations so that a plugin's `local_state` +/// persists from one hook to the next within the same request lifecycle +/// (e.g., `pre_invoke` → `post_invoke`). +/// +/// The caller receives the table back in `PipelineResult` and passes +/// it into the next hook invocation. On the first hook call, pass +/// `None` — the executor creates fresh contexts for each plugin. +pub type PluginContextTable = HashMap; diff --git a/crates/cpex-core/src/executor.rs b/crates/cpex-core/src/executor.rs index c7f250b3..4b1188ef 100644 --- a/crates/cpex-core/src/executor.rs +++ b/crates/cpex-core/src/executor.rs @@ -25,12 +25,14 @@ // cpex/framework/manager.py. use std::any::Any; +use std::collections::HashMap; +use std::sync::Arc; use std::time::Duration; use tokio::time::timeout; use tracing::{error, warn}; -use crate::context::{GlobalContext, PluginContext}; +use crate::context::{PluginContext, PluginContextTable}; use crate::hooks::payload::{Extensions, FilteredExtensions, PluginPayload}; use crate::plugin::OnError; use crate::registry::{group_by_mode, HookEntry}; @@ -64,8 +66,10 @@ impl Default for ExecutorConfig { /// Aggregate result from a full hook invocation across all phases. /// -/// Wraps the final payload, extensions, and any violation. Mirrors -/// the Python `PipelineResult` with factory methods. +/// Wraps the final payload, extensions, any violation, and the +/// context table. The caller should pass `context_table` into the +/// next hook invocation to preserve per-plugin local state across +/// hooks in the same request lifecycle. #[derive(Debug)] pub struct PipelineResult { /// Whether the pipeline completed without a deny. @@ -80,26 +84,40 @@ pub struct PipelineResult { /// The violation that caused a deny, if any. pub violation: Option, + + /// Plugin contexts indexed by plugin ID. Thread this into the + /// next hook invocation to preserve per-plugin `local_state`. + pub context_table: PluginContextTable, } impl PipelineResult { /// Pipeline completed — all plugins allowed. - pub fn allowed_with(payload: Box, extensions: Extensions) -> Self { + pub fn allowed_with( + payload: Box, + extensions: Extensions, + context_table: PluginContextTable, + ) -> Self { Self { allowed: true, payload: Some(payload), extensions, violation: None, + context_table, } } /// Pipeline was denied by a plugin. - pub fn denied(violation: crate::error::PluginViolation, extensions: Extensions) -> Self { + pub fn denied( + violation: crate::error::PluginViolation, + extensions: Extensions, + context_table: PluginContextTable, + ) -> Self { Self { allowed: false, payload: None, extensions, violation: Some(violation), + context_table, } } } @@ -133,23 +151,26 @@ impl Executor { /// # Arguments /// /// * `entries` — HookEntries for this hook, sorted by priority. - /// * `payload` — The typed payload (type-erased as Box). + /// * `payload` — The typed payload (type-erased as Box). /// * `extensions` — The full extensions (filtered per plugin before dispatch). - /// * `global_ctx` — Shared request context. + /// * `context_table` — Optional context table from a previous hook invocation. + /// If `None`, fresh contexts are created for each plugin. /// /// # Returns /// - /// A `PipelineResult` with the final payload, extensions, and - /// any violation. + /// A `PipelineResult` with the final payload, extensions, violation, + /// and the updated context table for threading into the next hook. pub async fn execute( &self, entries: &[HookEntry], payload: Box, extensions: Extensions, - global_ctx: &GlobalContext, + context_table: Option, ) -> PipelineResult { + let mut ctx_table = context_table.unwrap_or_default(); + if entries.is_empty() { - return PipelineResult::allowed_with(payload, extensions); + return PipelineResult::allowed_with(payload, extensions, ctx_table); } // Group entries by mode (from trusted_config) @@ -165,14 +186,14 @@ impl Executor { &sequential, &mut current_payload, &mut current_extensions, - global_ctx, + &mut ctx_table, true, // can_block true, // can_modify "SEQUENTIAL", ) .await { - return PipelineResult::denied(v, current_extensions); + return PipelineResult::denied(v, current_extensions, ctx_table); } // Phase 2: TRANSFORM — serial, chained, can modify, cannot block @@ -181,7 +202,7 @@ impl Executor { &transform, &mut current_payload, &mut current_extensions, - global_ctx, + &mut ctx_table, false, // can_block true, // can_modify "TRANSFORM", @@ -189,28 +210,25 @@ impl Executor { .await; // Phase 3: AUDIT — serial, read-only, discard results - self.run_ref_phase(&audit, &*current_payload, ¤t_extensions, global_ctx, "AUDIT") + self.run_ref_phase(&audit, &*current_payload, ¤t_extensions, &ctx_table, "AUDIT") .await; // Phase 4: CONCURRENT — parallel, can block, cannot modify if let Some(violation) = self - .run_concurrent_phase(&concurrent, &*current_payload, ¤t_extensions, global_ctx) + .run_concurrent_phase(&concurrent, &*current_payload, ¤t_extensions, &ctx_table) .await { - return PipelineResult::denied(violation, current_extensions); + return PipelineResult::denied(violation, current_extensions, ctx_table); } // Phase 5: FIRE_AND_FORGET — background, read-only, ignore results - self.run_ref_phase( + self.spawn_fire_and_forget( &fire_and_forget, &*current_payload, - ¤t_extensions, - global_ctx, - "FIRE_AND_FORGET", - ) - .await; + &ctx_table, + ); - PipelineResult::allowed_with(current_payload, current_extensions) + PipelineResult::allowed_with(current_payload, current_extensions, ctx_table) } // ----------------------------------------------------------------------- @@ -223,37 +241,54 @@ impl Executor { /// The framework retains ownership of the payload. Handlers receive /// a borrow and clone only if they modify. Modified payloads in /// the result replace the current payload. + /// + /// Each plugin's context is looked up in the context table (preserving + /// `local_state` from previous hooks) or created fresh. After execution, + /// `global_state` changes are merged back so the next plugin sees them. async fn run_serial_phase( &self, entries: &[HookEntry], payload: &mut Box, extensions: &mut Extensions, - global_ctx: &GlobalContext, + ctx_table: &mut PluginContextTable, can_block: bool, can_modify: bool, phase_label: &str, ) -> Option { + // Extract current global state from the table (use last plugin's + // global_state, or start empty). We maintain a running copy that + // gets set on each plugin's context and merged back after. + let mut global_state = ctx_table + .values() + .last() + .map(|c| c.global_state.clone()) + .unwrap_or_default(); + for entry in entries { let plugin_name = entry.plugin_ref.name().to_string(); - let ctx = PluginContext::new(global_ctx.clone()); + let plugin_id = entry.plugin_ref.id().to_string(); let on_error = entry.plugin_ref.trusted_config().on_error; + // Look up existing context (preserves local_state from prior hooks) + // or create a fresh one. Set global_state to the current running copy. + let mut ctx = ctx_table.remove(&plugin_id).unwrap_or_default(); + ctx.global_state = global_state.clone(); + // TODO: Capability-filter extensions per plugin (Phase 3) let filtered = FilteredExtensions::default(); // Execute with timeout — handler borrows the payload let timeout_dur = Duration::from_secs(self.config.timeout_seconds); - let result = timeout(timeout_dur, async { - entry.handler.invoke(&**payload, &filtered, &ctx) - }) - .await; + let result = timeout(timeout_dur, entry.handler.invoke(&**payload, &filtered, &mut ctx)) + .await; match result { Ok(Ok(result_box)) => { if let Some(erased) = extract_erased(result_box) { // Check deny if !erased.continue_processing && can_block { - if let Some(v) = erased.violation { + if let Some(mut v) = erased.violation { + v.plugin_name = Some(plugin_name.clone()); return Some(v); } } @@ -268,6 +303,13 @@ impl Executor { *extensions = me; } } + + // Merge global state changes back from the handler. + // The handler received &mut PluginContext and may have + // written to ctx.global_state directly. + if ctx.global_state != global_state { + global_state = ctx.global_state.clone(); + } } // If extract failed or no modifications — payload unchanged } @@ -275,13 +317,17 @@ impl Executor { error!("{} plugin '{}' failed: {}", phase_label, plugin_name, e); match on_error { OnError::Fail => { - return Some(crate::error::PluginViolation::new( + let mut v = crate::error::PluginViolation::new( "plugin_error", format!("Plugin '{}' failed: {}", plugin_name, e), - )); + ); + v.plugin_name = Some(plugin_name); + return Some(v); } - OnError::Ignore | OnError::Disable => { - // Payload unchanged — continue + OnError::Ignore => {} + OnError::Disable => { + warn!("{} plugin '{}' disabled after error", phase_label, plugin_name); + entry.plugin_ref.disable(); } } } @@ -289,17 +335,29 @@ impl Executor { error!("{} plugin '{}' timed out", phase_label, plugin_name); match on_error { OnError::Fail => { - return Some(crate::error::PluginViolation::new( + let mut v = crate::error::PluginViolation::new( "plugin_timeout", format!("Plugin '{}' timed out", plugin_name), - )); + ); + v.plugin_name = Some(plugin_name); + return Some(v); } - OnError::Ignore | OnError::Disable => { - // Payload unchanged — continue + OnError::Ignore => {} + OnError::Disable => { + warn!("{} plugin '{}' disabled after error", phase_label, plugin_name); + entry.plugin_ref.disable(); } } } } + + // Store context back into the table (preserves local_state + // for the next hook invocation via the returned context_table). + // Note: global_state merging from plugin writes is deferred — + // handlers currently receive &PluginContext (shared ref) so + // they can't mutate global_state directly. When we add write-back + // (via PluginResult or interior mutability), merge here. + ctx_table.insert(plugin_id, ctx); } None // no denial @@ -315,19 +373,29 @@ impl Executor { entries: &[HookEntry], payload: &dyn PluginPayload, _extensions: &Extensions, - global_ctx: &GlobalContext, + ctx_table: &PluginContextTable, phase_label: &str, ) { + // Read-only phases get a snapshot of global state but don't merge back. + let global_state: HashMap = ctx_table + .values() + .last() + .map(|c| c.global_state.clone()) + .unwrap_or_default(); + for entry in entries { let plugin_name = entry.plugin_ref.name().to_string(); - let ctx = PluginContext::new(global_ctx.clone()); + let plugin_id = entry.plugin_ref.id(); + let mut ctx = ctx_table + .get(plugin_id) + .cloned() + .map(|mut c| { c.global_state = global_state.clone(); c }) + .unwrap_or_else(|| PluginContext::with_global_state(global_state.clone())); let filtered = FilteredExtensions::default(); let timeout_dur = Duration::from_secs(self.config.timeout_seconds); - let result = timeout(timeout_dur, async { - entry.handler.invoke(payload, &filtered, &ctx) - }) - .await; + let result = timeout(timeout_dur, entry.handler.invoke(payload, &filtered, &mut ctx)) + .await; match result { Ok(Ok(_)) => {} // read-only — discard result @@ -345,79 +413,183 @@ impl Executor { // Phase 4: Concurrent (parallel, fail-fast) // ----------------------------------------------------------------------- - /// Run the concurrent phase — plugins execute in parallel. + /// Run the concurrent phase — plugins execute truly in parallel. /// Returns the first violation if any plugin denies. async fn run_concurrent_phase( &self, entries: &[HookEntry], payload: &dyn PluginPayload, _extensions: &Extensions, - global_ctx: &GlobalContext, + ctx_table: &PluginContextTable, ) -> Option { if entries.is_empty() { return None; } - // For concurrent, all plugins receive the same read-only payload. - // We collect results and check for denials. - let mut results = Vec::with_capacity(entries.len()); + // Clone the payload once so each spawned task can borrow from + // an owned, 'static copy. Each task gets its own Arc'd clone. + let shared_payload: Arc> = + Arc::new(payload.clone_boxed()); + let timeout_dur = Duration::from_secs(self.config.timeout_seconds); + + // Snapshot global state for all concurrent plugins + let global_state: HashMap = ctx_table + .values() + .last() + .map(|c| c.global_state.clone()) + .unwrap_or_default(); + + // Spawn all handlers concurrently — each task returns just + // the invoke result. We zip outcomes back with entries to + // access PluginRef for disable() without cloning it into the spawn. + let mut handles = Vec::with_capacity(entries.len()); for entry in entries { - let plugin_name = entry.plugin_ref.name().to_string(); + let handler = Arc::clone(&entry.handler); + let payload_clone = Arc::clone(&shared_payload); + let plugin_id = entry.plugin_ref.id().to_string(); + let mut ctx = ctx_table + .get(&plugin_id) + .cloned() + .map(|mut c| { c.global_state = global_state.clone(); c }) + .unwrap_or_else(|| PluginContext::with_global_state(global_state.clone())); + let dur = timeout_dur; + + let handle = tokio::spawn(async move { + let filtered = FilteredExtensions::default(); + timeout(dur, handler.invoke(&**payload_clone, &filtered, &mut ctx)).await + }); + + handles.push(handle); + } + + // Collect results — zip with entries for PluginRef access + let outcomes = futures::future::join_all(handles).await; + let mut denials = Vec::new(); + + for (entry, outcome) in entries.iter().zip(outcomes) { + let plugin_name = entry.plugin_ref.name(); let on_error = entry.plugin_ref.trusted_config().on_error; - let ctx = PluginContext::new(global_ctx.clone()); - let filtered = FilteredExtensions::default(); - let timeout_dur = Duration::from_secs(self.config.timeout_seconds); - let result = timeout(timeout_dur, async { - entry.handler.invoke(payload, &filtered, &ctx) - }) - .await; + let result = match outcome { + Ok(r) => r, + Err(e) => { + error!("CONCURRENT task panicked: {}", e); + continue; + } + }; match result { Ok(Ok(result_box)) => { if let Some(erased) = extract_erased(result_box) { if !erased.continue_processing { - let violation = erased.violation.unwrap_or_else(|| { + let mut violation = erased.violation.unwrap_or_else(|| { crate::error::PluginViolation::new( "concurrent_deny", format!("Plugin '{}' denied", plugin_name), ) }); + violation.plugin_name = Some(plugin_name.to_string()); if self.config.short_circuit_on_deny { return Some(violation); } - results.push(Some(violation)); + denials.push(violation); } } } Ok(Err(e)) => match on_error { OnError::Fail => { - return Some(crate::error::PluginViolation::new( + let mut v = crate::error::PluginViolation::new( "plugin_error", format!("Plugin '{}' failed: {}", plugin_name, e), - )); + ); + v.plugin_name = Some(plugin_name.to_string()); + return Some(v); } - _ => { + OnError::Ignore => { warn!("CONCURRENT plugin '{}' error (ignored): {}", plugin_name, e); } + OnError::Disable => { + warn!("CONCURRENT plugin '{}' disabled after error", plugin_name); + entry.plugin_ref.disable(); + } }, Err(_) => match on_error { OnError::Fail => { - return Some(crate::error::PluginViolation::new( + let mut v = crate::error::PluginViolation::new( "plugin_timeout", format!("Plugin '{}' timed out", plugin_name), - )); + ); + v.plugin_name = Some(plugin_name.to_string()); + return Some(v); } - _ => { + OnError::Ignore => { warn!("CONCURRENT plugin '{}' timed out (ignored)", plugin_name); } + OnError::Disable => { + warn!("CONCURRENT plugin '{}' disabled after timeout", plugin_name); + entry.plugin_ref.disable(); + } }, } } // Return first denial if any were collected (non-short-circuit mode) - results.into_iter().flatten().next() + denials.into_iter().next() + } + + // ----------------------------------------------------------------------- + // Phase 5: Fire-and-Forget (background, no await) + // ----------------------------------------------------------------------- + + /// Spawn fire-and-forget handlers as background tasks. + /// + /// Each handler runs in its own `tokio::spawn` — the pipeline does + /// not wait for them. Errors and timeouts are logged but have no + /// effect on the pipeline result. + fn spawn_fire_and_forget( + &self, + entries: &[HookEntry], + payload: &dyn PluginPayload, + ctx_table: &PluginContextTable, + ) { + if entries.is_empty() { + return; + } + + let timeout_dur = Duration::from_secs(self.config.timeout_seconds); + let global_state: HashMap = ctx_table + .values() + .last() + .map(|c| c.global_state.clone()) + .unwrap_or_default(); + + for entry in entries { + let plugin_name = entry.plugin_ref.name().to_string(); + let handler = Arc::clone(&entry.handler); + let owned_payload = payload.clone_boxed(); + let mut ctx = PluginContext::with_global_state(global_state.clone()); + let dur = timeout_dur; + + tokio::spawn(async move { + let filtered = FilteredExtensions::default(); + let result = timeout( + dur, + handler.invoke(&*owned_payload, &filtered, &mut ctx), + ) + .await; + + match result { + Ok(Ok(_)) => {} // discard + Ok(Err(e)) => { + warn!("FIRE_AND_FORGET plugin '{}' error (ignored): {}", plugin_name, e); + } + Err(_) => { + warn!("FIRE_AND_FORGET plugin '{}' timed out (ignored)", plugin_name); + } + } + }); + } } } @@ -452,8 +624,16 @@ pub struct ErasedResultFields { /// Extract erased result fields from a type-erased handler result. /// /// Takes ownership of the Box — the executor consumes the result. +/// Logs a warning if the downcast fails (indicates a handler returned +/// the wrong type — a framework bug, not a plugin error). pub fn extract_erased(result: Box) -> Option { - result.downcast::().ok().map(|b| *b) + match result.downcast::() { + Ok(b) => Some(*b), + Err(_) => { + warn!("extract_erased: downcast failed — handler returned unexpected type"); + None + } + } } /// Convert a typed `PluginResult

` into `ErasedResultFields`. @@ -534,7 +714,11 @@ mod tests { let payload: Box = Box::new(TestPayload { value: "test".into(), }); - let result = PipelineResult::allowed_with(payload, Extensions::default()); + let result = PipelineResult::allowed_with( + payload, + Extensions::default(), + PluginContextTable::new(), + ); assert!(result.allowed); assert!(result.payload.is_some()); assert!(result.violation.is_none()); @@ -543,7 +727,11 @@ mod tests { #[test] fn test_pipeline_result_denied() { let violation = crate::error::PluginViolation::new("test", "denied"); - let result = PipelineResult::denied(violation, Extensions::default()); + let result = PipelineResult::denied( + violation, + Extensions::default(), + PluginContextTable::new(), + ); assert!(!result.allowed); assert!(result.payload.is_none()); assert!(result.violation.is_some()); @@ -555,10 +743,8 @@ mod tests { let payload: Box = Box::new(TestPayload { value: "test".into(), }); - let ctx = GlobalContext::new("req-1"); - let result = executor - .execute(&[], payload, Extensions::default(), &ctx) + .execute(&[], payload, Extensions::default(), None) .await; assert!(result.allowed); assert!(result.payload.is_some()); diff --git a/crates/cpex-core/src/hooks/adapter.rs b/crates/cpex-core/src/hooks/adapter.rs index ee96ad1a..e0339b95 100644 --- a/crates/cpex-core/src/hooks/adapter.rs +++ b/crates/cpex-core/src/hooks/adapter.rs @@ -65,6 +65,7 @@ where } } +#[async_trait::async_trait] impl AnyHookHandler for TypedHandlerAdapter where H: HookTypeDef, @@ -78,11 +79,11 @@ where /// receives a borrow (`&H::Payload`) and clones only if it needs /// to modify. The result is erased back to `ErasedResultFields` /// for the executor. - fn invoke( + async fn invoke( &self, payload: &dyn PluginPayload, extensions: &FilteredExtensions, - ctx: &PluginContext, + ctx: &mut PluginContext, ) -> Result, PluginError> { let typed_ref: &H::Payload = payload .as_any() @@ -105,21 +106,3 @@ where H::NAME } } - -// Send + Sync: safe because P: Send + Sync (from Plugin bound) -// and H is a zero-sized marker with no data. -unsafe impl Send for TypedHandlerAdapter -where - H: HookTypeDef, - H::Result: Into>, - P: Plugin + HookHandler + 'static, -{ -} - -unsafe impl Sync for TypedHandlerAdapter -where - H: HookTypeDef, - H::Result: Into>, - P: Plugin + HookHandler + 'static, -{ -} diff --git a/crates/cpex-core/src/hooks/macros.rs b/crates/cpex-core/src/hooks/macros.rs index 5040d3f5..80012acf 100644 --- a/crates/cpex-core/src/hooks/macros.rs +++ b/crates/cpex-core/src/hooks/macros.rs @@ -5,20 +5,16 @@ // // define_hook! macro. // -// Generates a HookTypeDef marker struct, trait implementation, and -// a handler trait from a single declaration. This is the primary -// way to define new hooks — both built-in (CMF, tool, prompt) and -// custom (rate limiting, deployment gates, federation sync). +// Generates a HookTypeDef marker struct and trait implementation +// from a single declaration. This is the primary way to define new +// hooks — both built-in (CMF, tool, prompt) and custom (rate +// limiting, deployment gates, federation sync). // -// The generated handler trait has a single method whose name is -// derived from the hook name. The handler receives: -// - payload: the typed payload (owned — executor decides borrow vs clone) -// - extensions: &FilteredExtensions (capability-gated, separate from payload) -// - ctx: &PluginContext -// -// And returns the hook's result type (typically PluginResult). +// Plugins implement the generic HookHandler trait (from +// trait_def.rs) for the generated marker struct. The handler +// receives a borrowed payload and returns the hook's result type. -/// Generates a hook type definition, marker struct, and handler trait. +/// Generates a hook type definition and marker struct. /// /// # Usage /// @@ -32,17 +28,8 @@ /// } /// ``` /// -/// This generates: -/// -/// 1. A marker struct `MyHook` implementing `HookTypeDef`. -/// 2. A handler trait `MyHookHandler` with a method `my_hook()`. -/// -/// The handler method receives: -/// - `payload: MyPayload` (owned) -/// - `extensions: &FilteredExtensions` -/// - `ctx: &PluginContext` -/// -/// And returns `PluginResult`. +/// This generates a marker struct `MyHook` implementing `HookTypeDef`. +/// Plugins handle it by implementing `HookHandler`. /// /// # CMF Pattern (one handler, multiple hook names) /// @@ -58,7 +45,7 @@ /// } /// /// // Register the same handler for multiple names: -/// // registry.register_for_names::(plugin, config, &[ +/// // manager.register_handler_for_names::(plugin, config, &[ /// // "cmf.tool_pre_invoke", "cmf.llm_input", ... /// // ]); /// ``` @@ -79,27 +66,5 @@ macro_rules! define_hook { type Result = $result; const NAME: &'static str = $hook_name; } - - paste::paste! { - /// Handler trait for the - #[doc = concat!("`", stringify!($name), "`")] - /// hook. Implement this on your plugin to handle this hook type. - pub trait [<$name Handler>]: $crate::plugin::Plugin + Send + Sync { - /// Handle the - #[doc = concat!("`", $hook_name, "`")] - /// hook. - /// - /// The executor decides whether to pass a clone (for Sequential/ - /// Transform modes) or a borrow (for Audit/Concurrent/FireAndForget - /// modes) based on the plugin's mode. The handler signature always - /// takes owned payload — the executor handles the mechanics. - fn [<$hook_name>]( - &self, - payload: $payload, - extensions: &$crate::hooks::payload::FilteredExtensions, - ctx: &$crate::context::PluginContext, - ) -> $result; - } - } }; } diff --git a/crates/cpex-core/src/hooks/trait_def.rs b/crates/cpex-core/src/hooks/trait_def.rs index 34630778..a437c955 100644 --- a/crates/cpex-core/src/hooks/trait_def.rs +++ b/crates/cpex-core/src/hooks/trait_def.rs @@ -116,7 +116,7 @@ pub trait HookHandler: Plugin + Send + Sync { &self, payload: &H::Payload, extensions: &FilteredExtensions, - ctx: &PluginContext, + ctx: &mut PluginContext, ) -> H::Result; } @@ -195,6 +195,7 @@ impl PluginResult

{ continue_processing: true, modified_payload: None, modified_extensions: None, + violation: None, metadata: None, } @@ -206,6 +207,7 @@ impl PluginResult

{ continue_processing: false, modified_payload: None, modified_extensions: None, + violation: Some(violation), metadata: None, } @@ -217,6 +219,7 @@ impl PluginResult

{ continue_processing: true, modified_payload: Some(payload), modified_extensions: None, + violation: None, metadata: None, } @@ -228,6 +231,7 @@ impl PluginResult

{ continue_processing: true, modified_payload: None, modified_extensions: Some(extensions), + violation: None, metadata: None, } @@ -239,6 +243,7 @@ impl PluginResult

{ continue_processing: true, modified_payload: Some(payload), modified_extensions: Some(extensions), + violation: None, metadata: None, } diff --git a/crates/cpex-core/src/lib.rs b/crates/cpex-core/src/lib.rs index 0caf440e..2743b238 100644 --- a/crates/cpex-core/src/lib.rs +++ b/crates/cpex-core/src/lib.rs @@ -17,7 +17,7 @@ // - [`manager`] — PluginManager lifecycle and hook dispatch // - [`registry`] — PluginInstanceRegistry and HookRegistry // - [`config`] — Unified YAML configuration parsing -// - [`context`] — GlobalContext and PluginContext +// - [`context`] — PluginContext (local_state + global_state) // - [`error`] — Error types, violations, and result types pub mod config; diff --git a/crates/cpex-core/src/manager.rs b/crates/cpex-core/src/manager.rs index 2736424f..a2a6e8a3 100644 --- a/crates/cpex-core/src/manager.rs +++ b/crates/cpex-core/src/manager.rs @@ -28,7 +28,7 @@ use std::sync::Arc; use tracing::{error, info}; -use crate::context::GlobalContext; +use crate::context::PluginContextTable; use crate::error::PluginError; use crate::executor::{Executor, ExecutorConfig, PipelineResult}; use crate::hooks::adapter::TypedHandlerAdapter; @@ -97,9 +97,6 @@ pub struct PluginManager { /// Executor — stateless 5-phase pipeline engine. executor: Executor, - /// Manager configuration. - config: ManagerConfig, - /// Whether initialize() has been called. initialized: bool, } @@ -109,8 +106,7 @@ impl PluginManager { pub fn new(config: ManagerConfig) -> Self { Self { registry: PluginRegistry::new(), - executor: Executor::new(config.executor.clone()), - config, + executor: Executor::new(config.executor), initialized: false, } } @@ -203,21 +199,6 @@ impl PluginManager { .map_err(|msg| PluginError::Config { message: msg }) } - /// Register a plugin using hook names from its config (legacy path). - /// - /// No typed handler — the plugin is registered in the name index - /// only. Used for backward compatibility with plugins that don't - /// use the typed hook system. - pub fn register_legacy( - &mut self, - plugin: Arc, - config: PluginConfig, - ) -> Result<(), PluginError> { - self.registry - .register_legacy(plugin, config) - .map_err(|msg| PluginError::Config { message: msg }) - } - // ----------------------------------------------------------------------- // Lifecycle // ----------------------------------------------------------------------- @@ -237,6 +218,8 @@ impl PluginManager { self.registry.plugin_count() ); + let mut initialized_plugins: Vec = Vec::new(); + for name in self.registry.plugin_names() { if let Some(plugin_ref) = self.registry.get(name) { let plugin = plugin_ref.plugin().clone(); @@ -244,12 +227,27 @@ impl PluginManager { if let Err(e) = plugin.initialize().await { error!("Failed to initialize plugin '{}': {}", plugin_name, e); + + // Clean up already-initialized plugins + for init_name in initialized_plugins.iter().rev() { + if let Some(pr) = self.registry.get(init_name) { + if let Err(shutdown_err) = pr.plugin().shutdown().await { + error!( + "Error shutting down plugin '{}' during rollback: {}", + init_name, shutdown_err + ); + } + } + } + return Err(PluginError::Execution { plugin_name, message: format!("initialization failed: {}", e), source: Some(Box::new(e)), }); } + + initialized_plugins.push(plugin_name); } } @@ -300,28 +298,34 @@ impl PluginManager { /// * `hook_name` — the hook name string (e.g., `"cmf.tool_pre_invoke"`). /// * `payload` — the payload as `Box`. /// * `extensions` — the full extensions (filtered per plugin by the executor). - /// * `global_ctx` — shared request context. + /// * `context_table` — optional context table from a previous hook + /// invocation. Pass `None` on the first hook call; thread the + /// returned table into subsequent calls to preserve per-plugin state. /// /// # Returns /// - /// A `PipelineResult` with the final payload, extensions, and - /// any violation. + /// A `PipelineResult` with the final payload, extensions, violation, + /// and the updated context table. pub async fn invoke_by_name( &self, hook_name: &str, payload: Box, extensions: Extensions, - global_ctx: &GlobalContext, + context_table: Option, ) -> PipelineResult { let hook_type = HookType::new(hook_name); let entries = self.registry.entries_for_hook(&hook_type); if entries.is_empty() { - return PipelineResult::allowed_with(payload, extensions); + return PipelineResult::allowed_with( + payload, + extensions, + context_table.unwrap_or_default(), + ); } self.executor - .execute(entries, payload, extensions, global_ctx) + .execute(entries, payload, extensions, context_table) .await } @@ -344,29 +348,34 @@ impl PluginManager { /// /// * `payload` — the typed payload. /// * `extensions` — the full extensions. - /// * `global_ctx` — shared request context. + /// * `context_table` — optional context table from a previous hook. /// /// # Returns /// /// A `PipelineResult` with the final payload (type-erased — - /// caller downcasts via `as_any()`), extensions, and any violation. + /// caller downcasts via `as_any()`), extensions, violation, and + /// the updated context table. pub async fn invoke( &self, payload: H::Payload, extensions: Extensions, - global_ctx: &GlobalContext, + context_table: Option, ) -> PipelineResult { let hook_type = HookType::new(H::NAME); let entries = self.registry.entries_for_hook(&hook_type); if entries.is_empty() { let boxed: Box = Box::new(payload); - return PipelineResult::allowed_with(boxed, extensions); + return PipelineResult::allowed_with( + boxed, + extensions, + context_table.unwrap_or_default(), + ); } let boxed: Box = Box::new(payload); self.executor - .execute(entries, boxed, extensions, global_ctx) + .execute(entries, boxed, extensions, context_table) .await } @@ -458,7 +467,7 @@ mod tests { &self, _payload: &TestPayload, _extensions: &FilteredExtensions, - _ctx: &PluginContext, + _ctx: &mut PluginContext, ) -> PluginResult { PluginResult::allow() } @@ -481,15 +490,47 @@ mod tests { &self, _payload: &TestPayload, _extensions: &FilteredExtensions, - _ctx: &PluginContext, + _ctx: &mut PluginContext, ) -> PluginResult { PluginResult::deny(PluginViolation::new("denied", "test denial")) } } - // -- Helper -- + /// Handler that always returns an error (for testing on_error behavior). + struct ErrorHandler; + + #[async_trait] + impl AnyHookHandler for ErrorHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> Result, PluginError> { + Err(PluginError::Execution { + plugin_name: "error-plugin".into(), + message: "simulated failure".into(), + source: None, + }) + } + + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + // -- Helpers -- fn make_config(name: &str, priority: i32, mode: PluginMode) -> PluginConfig { + make_config_with_on_error(name, priority, mode, OnError::Fail) + } + + fn make_config_with_on_error( + name: &str, + priority: i32, + mode: PluginMode, + on_error: OnError, + ) -> PluginConfig { PluginConfig { name: name.to_string(), kind: "test".to_string(), @@ -499,7 +540,7 @@ mod tests { hooks: vec!["test_hook".to_string()], mode, priority, - on_error: OnError::Fail, + on_error, capabilities: Default::default(), tags: Vec::new(), conditions: Vec::new(), @@ -531,10 +572,10 @@ mod tests { let payload: Box = Box::new(TestPayload { value: "test".into(), }); - let ctx = GlobalContext::new("req-1"); + let result = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), &ctx) + .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; assert!(result.allowed); @@ -554,10 +595,10 @@ mod tests { let payload: Box = Box::new(TestPayload { value: "test".into(), }); - let ctx = GlobalContext::new("req-1"); + let result = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), &ctx) + .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; assert!(result.allowed); @@ -575,10 +616,10 @@ mod tests { let payload: Box = Box::new(TestPayload { value: "test".into(), }); - let ctx = GlobalContext::new("req-1"); + let result = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), &ctx) + .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; assert!(!result.allowed); @@ -597,10 +638,10 @@ mod tests { let payload = TestPayload { value: "typed".into(), }; - let ctx = GlobalContext::new("req-1"); + let result = mgr - .invoke::(payload, Extensions::default(), &ctx) + .invoke::(payload, Extensions::default(), None) .await; assert!(result.allowed); @@ -644,13 +685,437 @@ mod tests { let payload: Box = Box::new(TestPayload { value: "test".into(), }); - let ctx = GlobalContext::new("req-1"); + let result = mgr - .invoke_by_name("test_hook", payload, Extensions::default(), &ctx) + .invoke_by_name("test_hook", payload, Extensions::default(), None) .await; // Audit mode — deny is suppressed, pipeline continues assert!(result.allowed); } + + #[tokio::test] + async fn test_on_error_disable_skips_plugin_on_subsequent_invocations() { + let mut mgr = PluginManager::default(); + + // Register an error handler with on_error: Disable + let config = make_config_with_on_error( + "flaky-plugin", 10, PluginMode::Sequential, OnError::Disable, + ); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let handler: Arc = Arc::new(ErrorHandler); + mgr.register_raw::(plugin, config, handler).unwrap(); + + // Also register a normal allow plugin (lower priority = runs second) + let config2 = make_config("allow-plugin", 20, PluginMode::Sequential); + let plugin2 = Arc::new(AllowPlugin { cfg: config2.clone() }); + mgr.register_handler::(plugin2, config2).unwrap(); + + mgr.initialize().await.unwrap(); + + + // First invocation — flaky plugin errors, gets disabled, pipeline continues + // because on_error is Disable (not Fail). allow-plugin still runs. + let payload: Box = Box::new(TestPayload { value: "first".into() }); + let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + assert!(result.allowed); + + // Verify the plugin is now disabled + let plugin_ref = mgr.get_plugin("flaky-plugin").unwrap(); + assert!(plugin_ref.is_disabled()); + assert_eq!(plugin_ref.mode(), PluginMode::Disabled); + + // Second invocation — flaky plugin should be skipped entirely + // (group_by_mode filters it out). Only allow-plugin runs. + let payload2: Box = Box::new(TestPayload { value: "second".into() }); + let result2 = mgr.invoke_by_name("test_hook", payload2, Extensions::default(), None).await; + assert!(result2.allowed); + } + + #[tokio::test] + async fn test_on_error_ignore_continues_without_disabling() { + let mut mgr = PluginManager::default(); + + // Register an error handler with on_error: Ignore + let config = make_config_with_on_error( + "flaky-plugin", 10, PluginMode::Sequential, OnError::Ignore, + ); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let handler: Arc = Arc::new(ErrorHandler); + mgr.register_raw::(plugin, config, handler).unwrap(); + + mgr.initialize().await.unwrap(); + + + // First invocation — plugin errors, ignored, pipeline continues + let payload: Box = Box::new(TestPayload { value: "test".into() }); + let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + assert!(result.allowed); + + // Plugin should NOT be disabled — still in its original mode + let plugin_ref = mgr.get_plugin("flaky-plugin").unwrap(); + assert!(!plugin_ref.is_disabled()); + assert_eq!(plugin_ref.mode(), PluginMode::Sequential); + } + + #[tokio::test] + async fn test_on_error_fail_halts_pipeline() { + let mut mgr = PluginManager::default(); + + // Register an error handler with on_error: Fail (default) + let config = make_config_with_on_error( + "strict-plugin", 10, PluginMode::Sequential, OnError::Fail, + ); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let handler: Arc = Arc::new(ErrorHandler); + mgr.register_raw::(plugin, config, handler).unwrap(); + + mgr.initialize().await.unwrap(); + + + // Invocation — plugin errors, pipeline halts with a violation + let payload: Box = Box::new(TestPayload { value: "test".into() }); + let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + assert!(!result.allowed); + assert_eq!(result.violation.as_ref().unwrap().code, "plugin_error"); + assert_eq!( + result.violation.as_ref().unwrap().plugin_name.as_deref(), + Some("strict-plugin"), + ); + } + + // -- Additional test plugins -- + + /// Plugin that modifies the payload (for Transform mode testing). + struct TransformPlugin { + cfg: PluginConfig, + } + + #[async_trait] + impl Plugin for TransformPlugin { + fn config(&self) -> &PluginConfig { &self.cfg } + async fn initialize(&self) -> Result<(), PluginError> { Ok(()) } + async fn shutdown(&self) -> Result<(), PluginError> { Ok(()) } + } + + impl HookHandler for TransformPlugin { + fn handle( + &self, + payload: &TestPayload, + _extensions: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + PluginResult::modify_payload(TestPayload { + value: format!("{}_transformed", payload.value), + }) + } + } + + /// Handler that sleeps (for timeout and fire-and-forget testing). + struct SlowHandler { + delay_ms: u64, + } + + #[async_trait] + impl AnyHookHandler for SlowHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> Result, PluginError> { + tokio::time::sleep(std::time::Duration::from_millis(self.delay_ms)).await; + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + // -- Bug-covering tests -- + + #[tokio::test] + async fn test_transform_modifies_payload() { + let mut mgr = PluginManager::default(); + let config = make_config("transformer", 10, PluginMode::Transform); + let plugin = Arc::new(TransformPlugin { cfg: config.clone() }); + + mgr.register_handler::(plugin, config).unwrap(); + mgr.initialize().await.unwrap(); + + let payload = TestPayload { value: "original".into() }; + + let result = mgr.invoke::(payload, Extensions::default(), None).await; + + assert!(result.allowed); + let final_payload = result.payload.unwrap(); + let typed = final_payload.as_any().downcast_ref::().unwrap(); + assert_eq!(typed.value, "original_transformed"); + } + + #[tokio::test] + async fn test_concurrent_multiple_plugins_all_run() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + // Shared counter to prove both plugins actually ran + static CALL_COUNT: AtomicUsize = AtomicUsize::new(0); + CALL_COUNT.store(0, Ordering::SeqCst); + + struct CountingHandler; + + #[async_trait] + impl AnyHookHandler for CountingHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> Result, PluginError> { + // Small sleep to ensure both tasks are spawned before either finishes + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + CALL_COUNT.fetch_add(1, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mut mgr = PluginManager::default(); + + let c1 = make_config("concurrent-1", 10, PluginMode::Concurrent); + let p1 = Arc::new(AllowPlugin { cfg: c1.clone() }); + let h1: Arc = Arc::new(CountingHandler); + mgr.register_raw::(p1, c1, h1).unwrap(); + + let c2 = make_config("concurrent-2", 20, PluginMode::Concurrent); + let p2 = Arc::new(AllowPlugin { cfg: c2.clone() }); + let h2: Arc = Arc::new(CountingHandler); + mgr.register_raw::(p2, c2, h2).unwrap(); + + mgr.initialize().await.unwrap(); + + let start = std::time::Instant::now(); + let payload: Box = Box::new(TestPayload { value: "test".into() }); + let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + let elapsed = start.elapsed(); + + assert!(result.allowed); + assert_eq!(CALL_COUNT.load(Ordering::SeqCst), 2); + // If they ran in parallel, total time should be ~50ms, not ~100ms + assert!(elapsed.as_millis() < 90, "concurrent plugins ran serially: {}ms", elapsed.as_millis()); + } + + #[tokio::test] + async fn test_timeout_fires_on_slow_handler() { + // Create a manager with a very short timeout + let config = ManagerConfig { + executor: crate::executor::ExecutorConfig { + timeout_seconds: 1, + short_circuit_on_deny: true, + }, + }; + let mut mgr = PluginManager::new(config); + + // Register a handler that sleeps longer than the timeout + let plugin_config = make_config("slow-plugin", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { cfg: plugin_config.clone() }); + let handler: Arc = Arc::new(SlowHandler { delay_ms: 5000 }); + mgr.register_raw::(plugin, plugin_config, handler).unwrap(); + + mgr.initialize().await.unwrap(); + + let start = std::time::Instant::now(); + let payload: Box = Box::new(TestPayload { value: "test".into() }); + let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + let elapsed = start.elapsed(); + + // Should have timed out and denied (on_error: Fail) + assert!(!result.allowed); + assert_eq!(result.violation.as_ref().unwrap().code, "plugin_timeout"); + // Should have returned in ~1s, not 5s + assert!(elapsed.as_secs() < 3, "timeout didn't fire: {}s", elapsed.as_secs()); + } + + #[tokio::test] + async fn test_fire_and_forget_returns_before_task_completes() { + use std::sync::atomic::{AtomicBool, Ordering}; + + static TASK_COMPLETED: AtomicBool = AtomicBool::new(false); + TASK_COMPLETED.store(false, Ordering::SeqCst); + + struct SlowFireAndForgetHandler; + + #[async_trait] + impl AnyHookHandler for SlowFireAndForgetHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &FilteredExtensions, + _ctx: &mut PluginContext, + ) -> Result, PluginError> { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + TASK_COMPLETED.store(true, Ordering::SeqCst); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + + fn hook_type_name(&self) -> &'static str { + "test_hook" + } + } + + let mut mgr = PluginManager::default(); + + let config = make_config("fire-forget", 10, PluginMode::FireAndForget); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let handler: Arc = Arc::new(SlowFireAndForgetHandler); + mgr.register_raw::(plugin, config, handler).unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { value: "test".into() }); + let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + + // Pipeline should return immediately — before the background task finishes + assert!(result.allowed); + assert!(!TASK_COMPLETED.load(Ordering::SeqCst), "fire-and-forget task completed before pipeline returned"); + + // Wait for the background task to finish + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + assert!(TASK_COMPLETED.load(Ordering::SeqCst), "fire-and-forget task never completed"); + } + + #[tokio::test] + async fn test_global_state_flows_between_serial_plugins() { + // Plugin A writes to global_state; Plugin B reads it. + + struct WriterHandler; + + #[async_trait] + impl AnyHookHandler for WriterHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &FilteredExtensions, + ctx: &mut PluginContext, + ) -> Result, PluginError> { + ctx.set_global("writer_was_here", serde_json::Value::Bool(true)); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { "test_hook" } + } + + struct ReaderHandler { + saw_writer: std::sync::Arc, + } + + #[async_trait] + impl AnyHookHandler for ReaderHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &FilteredExtensions, + ctx: &mut PluginContext, + ) -> Result, PluginError> { + if ctx.get_global("writer_was_here").is_some() { + self.saw_writer.store(true, std::sync::atomic::Ordering::SeqCst); + } + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { "test_hook" } + } + + let saw_writer = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let mut mgr = PluginManager::default(); + + // Writer runs first (priority 10) + let c1 = make_config("writer", 10, PluginMode::Sequential); + let p1 = Arc::new(AllowPlugin { cfg: c1.clone() }); + let h1: Arc = Arc::new(WriterHandler); + mgr.register_raw::(p1, c1, h1).unwrap(); + + // Reader runs second (priority 20) + let c2 = make_config("reader", 20, PluginMode::Sequential); + let p2 = Arc::new(AllowPlugin { cfg: c2.clone() }); + let h2: Arc = Arc::new(ReaderHandler { saw_writer: saw_writer.clone() }); + mgr.register_raw::(p2, c2, h2).unwrap(); + + mgr.initialize().await.unwrap(); + + let payload: Box = Box::new(TestPayload { value: "test".into() }); + let result = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + + assert!(result.allowed); + assert!( + saw_writer.load(std::sync::atomic::Ordering::SeqCst), + "reader plugin did not see writer's global_state change" + ); + } + + #[tokio::test] + async fn test_local_state_persists_across_hook_invocations() { + // Plugin writes to local_state on first hook call. + // Context table is threaded into second call — local_state preserved. + + struct LocalWriterHandler; + + #[async_trait] + impl AnyHookHandler for LocalWriterHandler { + async fn invoke( + &self, + _payload: &dyn PluginPayload, + _extensions: &FilteredExtensions, + ctx: &mut PluginContext, + ) -> Result, PluginError> { + // Increment a counter in local_state + let count = ctx.get_local("call_count") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + ctx.set_local("call_count", serde_json::Value::from(count + 1)); + let result: PluginResult = PluginResult::allow(); + Ok(crate::executor::erase_result(result)) + } + fn hook_type_name(&self) -> &'static str { "test_hook" } + } + + let mut mgr = PluginManager::default(); + + let config = make_config("counter", 10, PluginMode::Sequential); + let plugin = Arc::new(AllowPlugin { cfg: config.clone() }); + let handler: Arc = Arc::new(LocalWriterHandler); + mgr.register_raw::(plugin, config, handler).unwrap(); + + mgr.initialize().await.unwrap(); + + // First invocation — no context table, starts fresh + let payload: Box = Box::new(TestPayload { value: "first".into() }); + let result1 = mgr.invoke_by_name("test_hook", payload, Extensions::default(), None).await; + assert!(result1.allowed); + + // Check call_count = 1 in the returned context table + let table = &result1.context_table; + let ctx = table.values().next().expect("context table should have one entry"); + assert_eq!(ctx.get_local("call_count").unwrap().as_u64().unwrap(), 1); + + // Second invocation — pass the context table from the first call + let payload2: Box = Box::new(TestPayload { value: "second".into() }); + let result2 = mgr.invoke_by_name( + "test_hook", payload2, Extensions::default(), Some(result1.context_table), + ).await; + assert!(result2.allowed); + + // call_count should now be 2 — local_state persisted across invocations + let table2 = &result2.context_table; + let ctx2 = table2.values().next().expect("context table should have one entry"); + assert_eq!(ctx2.get_local("call_count").unwrap().as_u64().unwrap(), 2); + } } diff --git a/crates/cpex-core/src/registry.rs b/crates/cpex-core/src/registry.rs index 03f50ee6..cbc88a9c 100644 --- a/crates/cpex-core/src/registry.rs +++ b/crates/cpex-core/src/registry.rs @@ -30,6 +30,7 @@ // in cpex/framework/base.py and cpex/framework/registry.py. use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use crate::context::PluginContext; @@ -68,6 +69,12 @@ pub struct PluginRef { /// Unique identifier assigned by the registry. id: String, + + /// Runtime circuit breaker — set to true when `on_error: Disable` + /// triggers. Once set, `mode()` returns `Disabled` and the plugin + /// is skipped by `group_by_mode()` on all subsequent invocations. + /// Uses `Arc` so clones (in HookEntry) share the same flag. + disabled: Arc, } impl PluginRef { @@ -82,6 +89,7 @@ impl PluginRef { plugin, trusted_config, id, + disabled: Arc::new(AtomicBool::new(false)), } } @@ -105,9 +113,28 @@ impl PluginRef { &self.trusted_config.name } - /// Convenience: plugin mode from the trusted config. + /// Effective mode — returns `Disabled` if the runtime circuit breaker + /// has tripped, otherwise returns the configured mode. pub fn mode(&self) -> PluginMode { - self.trusted_config.mode + if self.disabled.load(Ordering::Relaxed) { + PluginMode::Disabled + } else { + self.trusted_config.mode + } + } + + /// Runtime-disable this plugin (one-way circuit breaker). + /// + /// Called by the executor when a plugin errors with `on_error: Disable`. + /// All clones of this PluginRef (in HookEntry, etc.) share the same + /// `AtomicBool`, so the disable is instantly visible across the system. + pub fn disable(&self) { + self.disabled.store(true, Ordering::Relaxed); + } + + /// Whether this plugin has been runtime-disabled. + pub fn is_disabled(&self) -> bool { + self.disabled.load(Ordering::Relaxed) } /// Convenience: plugin priority from the trusted config. @@ -128,27 +155,26 @@ impl PluginRef { /// `CmfHookHandler`) and translates between type-erased payloads /// and the typed handler method. /// -/// Two invoke paths: -/// - `invoke_owned()` — for Sequential/Transform modes. Receives -/// an owned payload (Box). The handler downcasts and calls -/// the typed method. -/// - `invoke_ref()` — for Audit/Concurrent/FireAndForget modes. -/// Receives a borrowed payload (&dyn Any). No clone needed. +/// The executor dispatches through this trait for all five phases. +/// The handler receives a borrowed payload — the framework retains +/// ownership. Plugins clone only when modifying. +/// +/// `invoke` is async so that plugins can perform I/O (HTTP calls, +/// Redis, vault lookups) without blocking the tokio runtime, and +/// so that `tokio::time::timeout` can actually observe and cancel +/// long-running handlers. +#[async_trait::async_trait] pub trait AnyHookHandler: Send + Sync { /// Call the handler with a borrowed payload. /// - /// The handler downcasts `&dyn PluginPayload` to the concrete - /// type via `as_any()`. The framework always retains ownership - /// of the payload — the handler clones only if it needs to modify. - /// /// Returns an `ErasedResultFields` (see executor module) wrapped /// as `Box`. If the handler modified the payload, the /// modified copy is in `ErasedResultFields.modified_payload`. - fn invoke( + async fn invoke( &self, payload: &dyn PluginPayload, extensions: &FilteredExtensions, - ctx: &PluginContext, + ctx: &mut PluginContext, ) -> Result, crate::error::PluginError>; /// The hook type name this handler was registered for. @@ -252,29 +278,6 @@ impl PluginRegistry { self.register_for_names_inner(plugin, config, handler, names) } - /// Register a plugin with its authoritative config using hook names - /// from the config (legacy path — no typed handler). - /// - /// This is the backward-compatible path for plugins that don't use - /// the typed hook system. The plugin is registered in the name index - /// and hook index, but without a type-erased handler. The executor - /// must use `invoke_by_name()` with payload conversion for these. - pub fn register_legacy( - &mut self, - plugin: Arc, - config: PluginConfig, - ) -> Result<(), String> { - let name = config.name.clone(); - - if self.plugins.contains_key(&name) { - return Err(format!("plugin '{}' is already registered", name)); - } - - let plugin_ref = PluginRef::new(plugin, config); - self.plugins.insert(name, plugin_ref); - Ok(()) - } - /// Internal: register handler under one or more hook names. fn register_for_names_inner( &mut self, @@ -431,12 +434,13 @@ mod tests { /// A simple AnyHookHandler that wraps a function for testing. struct TestHandler; + #[async_trait] impl AnyHookHandler for TestHandler { - fn invoke( + async fn invoke( &self, _payload: &dyn PluginPayload, _extensions: &FilteredExtensions, - _ctx: &PluginContext, + _ctx: &mut PluginContext, ) -> Result, PluginError> { let result: PluginResult = PluginResult::allow(); Ok(crate::executor::erase_result(result)) @@ -605,16 +609,16 @@ mod tests { assert_eq!(plugin_ref.priority(), 100); } - #[test] - fn test_handler_invoke() { + #[tokio::test] + async fn test_handler_invoke() { let handler = TestHandler; let payload = TestPayload { value: "test".into(), }; let ext = FilteredExtensions::default(); - let ctx = PluginContext::new(crate::context::GlobalContext::new("req-1")); + let mut ctx = PluginContext::new(); - let result = handler.invoke(&payload as &dyn PluginPayload, &ext, &ctx).unwrap(); + let result = handler.invoke(&payload as &dyn PluginPayload, &ext, &mut ctx).await.unwrap(); let fields = crate::executor::extract_erased(result).unwrap(); assert!(fields.continue_processing); }