diff --git a/packages/paperclip-runner/runner/Cargo.lock b/packages/paperclip-runner/runner/Cargo.lock index 770faf90ba..d5a2e4c120 100644 --- a/packages/paperclip-runner/runner/Cargo.lock +++ b/packages/paperclip-runner/runner/Cargo.lock @@ -37,6 +37,62 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + [[package]] name = "block-buffer" version = "0.10.4" @@ -46,6 +102,24 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytes" version = "1.12.1" @@ -114,6 +188,70 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fancy-regex" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476de73bddf2ef8490aa4ee8f1cf40b430bf1d56c48c22080e5186952cd580e6" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fraction" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fee0259ffdc3d7bd64438b6b08437884d4df5700f9cb8a23b079c3958ae578" +dependencies = [ + "num", + "num-bigint", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -142,9 +280,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -157,6 +297,23 @@ dependencies = [ "polyval", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hmac" version = "0.12.1" @@ -182,6 +339,110 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "inout" version = "0.1.4" @@ -197,12 +458,89 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.50.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29b6bb4e22283b09119c671e57ac6a185e9a0c70c31585e56b729b626d877753" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.50.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497fbf3b1ca53b1e23a253f636e88e7a2031387388b8140199a0f240fcadf647" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.50.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84ca3f39446973a810fc90fc2645ada8c24284f6deb009c78b776813973887d" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "num-cmp", + "num-traits", + "serde_json", +] + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[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.34" @@ -215,12 +553,108 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "opaque-debug" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "paperclip-runner-core" version = "0.0.0" @@ -228,12 +662,42 @@ dependencies = [ "aes-gcm", "getrandom 0.3.4", "hmac", + "jsonschema", "serde", "serde_json", "sha2", "tungstenite", ] +[[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 = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "polyval" version = "0.6.2" @@ -246,6 +710,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -317,6 +790,93 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "referencing" +version = "0.50.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0a32a2a2c2d7dd0ee54705b5fd641be73037271928f68e9b0e7205b71c9d30" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.229" @@ -382,6 +942,39 @@ dependencies = [ "digest", ] +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -410,6 +1003,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "thiserror" version = "2.0.20" @@ -430,6 +1034,16 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tungstenite" version = "0.28.0" @@ -453,6 +1067,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -475,12 +1095,34 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -496,12 +1138,92 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.56" @@ -522,6 +1244,60 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/packages/paperclip-runner/runner/Cargo.toml b/packages/paperclip-runner/runner/Cargo.toml index bf407f5011..ef4df0e2f3 100644 --- a/packages/paperclip-runner/runner/Cargo.toml +++ b/packages/paperclip-runner/runner/Cargo.toml @@ -12,6 +12,7 @@ publish = false aes-gcm = "0.10" getrandom = "0.3" hmac = "0.12" +jsonschema = { version = "0.50", default-features = false } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10" diff --git a/packages/paperclip-runner/runner/crates/runner-core/Cargo.toml b/packages/paperclip-runner/runner/crates/runner-core/Cargo.toml index 6754c383b2..cb5c9678ca 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/Cargo.toml +++ b/packages/paperclip-runner/runner/crates/runner-core/Cargo.toml @@ -9,6 +9,7 @@ publish.workspace = true aes-gcm.workspace = true getrandom.workspace = true hmac.workspace = true +jsonschema.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs index 9960607bab..247563c0a2 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod fake_harness; pub mod local_runner; pub mod process_supervisor; pub mod provider_backend; +pub mod provider_bridge; pub mod provider_events; pub mod replay; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_bridge.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_bridge.rs new file mode 100644 index 0000000000..f5ef365baf --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_bridge.rs @@ -0,0 +1,815 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::error::Error; +use std::fmt::{self, Display, Formatter}; + +use serde::de::{self, MapAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +pub const TOOL_SET_SCHEMA: &str = "paperclip.runner.authorized-tools.v1"; +pub const TOOL_CALL_SCHEMA: &str = "paperclip.prp.semantic_tool.v1"; +pub const TOOL_RESULT_COMMAND: &str = "semantic_tool.result"; +const MAX_AUTHORIZED_TOOLS: usize = 256; +const MAX_DESCRIPTION_BYTES: usize = 16 * 1024; +const MAX_SCHEMA_BYTES: usize = 1024 * 1024; +const MAX_TOOL_SET_BYTES: usize = 4 * 1024 * 1024; +const MAX_TOOL_VALUE_BYTES: usize = 1024 * 1024; +// Settled results are authoritative replay receipts and live for the durable +// run. Bound their complete encoded map, while reserving enough room for every +// active call to later produce a maximum-sized result. The 1 KiB allowance +// covers the map key, bounded call/operation identities, JSON field names, and +// escaping around a 1 MiB result value. +const MAX_SETTLED_RESULT_BYTES: usize = 8 * 1024 * 1024; +const MAX_SETTLED_RESULT_ENTRY_BYTES: usize = MAX_TOOL_VALUE_BYTES + 1024; +const MAX_RETAINED_CALLS: usize = 4_096; +const MAX_SETTLED_CALL_IDS: usize = 65_536; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AuthorizedTool { + pub operation_id: String, + pub version: u64, + pub description: String, + pub input_schema: Value, + pub response_schema: Value, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AuthorizedToolSet { + pub schema: String, + pub schema_version: u64, + pub catalog_digest: String, + pub operations: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PendingToolCall { + pub call_id: String, + pub operation_id: String, + pub input: Value, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ToolResult { + pub call_id: String, + pub operation_id: String, + pub result: Value, + #[serde(default)] + pub is_error: bool, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProviderToolBridge { + authorized: BTreeMap, + catalog_digest: Option, + pending: BTreeMap, + #[serde(deserialize_with = "deserialize_retained_results")] + completed: BTreeMap, + #[serde(default, deserialize_with = "deserialize_retained_results")] + settled_results: BTreeMap, + // Derived from completed + settled results. It is intentionally omitted + // from durable JSON and recomputed by attach_existing_run so old state and + // tampered counters cannot bypass the byte envelope. + #[serde(skip)] + retained_result_bytes: usize, + // Compatibility tombstones for state written before settled results were + // retained. They still fail closed on call-id reuse, but cannot replay a + // value that the older state format discarded. + #[serde(default)] + settled_call_ids: BTreeSet, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProviderBridgeError(String); + +impl ProviderBridgeError { + fn invalid(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl Display for ProviderBridgeError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl Error for ProviderBridgeError {} + +impl ProviderToolBridge { + pub fn prepare(&mut self, tool_set: AuthorizedToolSet) -> Result<(), ProviderBridgeError> { + self.prepare_internal(tool_set, false) + } + + pub fn attach_run(&mut self, tool_set: AuthorizedToolSet) -> Result<(), ProviderBridgeError> { + if !self.pending.is_empty() { + return Err(ProviderBridgeError::invalid( + "cannot attach a new run while provider tool calls are pending", + )); + } + self.prepare_internal(tool_set, true)?; + self.completed.clear(); + self.settled_results.clear(); + self.retained_result_bytes = 0; + self.settled_call_ids.clear(); + Ok(()) + } + + pub fn attach_existing_run(&mut self) -> Result<(), ProviderBridgeError> { + // Pending calls are durable run state. Re-attaching the same run must + // preserve them so an interrupted dispatcher can resume or replay the + // authoritative result. `attach_run` remains the boundary that rejects + // carrying pending calls into a different run. + if self + .authorized + .iter() + .any(|(operation_id, tool)| operation_id != &tool.operation_id) + { + return Err(ProviderBridgeError::invalid( + "recovered authorized tool identities are inconsistent", + )); + } + let Some(catalog_digest) = self.catalog_digest.clone() else { + if self.authorized.is_empty() + && self.pending.is_empty() + && self.completed.is_empty() + && self.settled_results.is_empty() + && self.settled_call_ids.is_empty() + { + self.retained_result_bytes = 0; + return Ok(()); + } + return Err(ProviderBridgeError::invalid( + "recovered authorized tools omit the catalog digest", + )); + }; + let recovered_tool_set = AuthorizedToolSet { + schema: TOOL_SET_SCHEMA.to_owned(), + schema_version: 1, + catalog_digest, + operations: self.authorized.values().cloned().collect(), + }; + validate_authorized_tool_set(&recovered_tool_set).map_err(|error| { + ProviderBridgeError::invalid(format!( + "recovered authorized tool catalog is invalid: {error}" + )) + })?; + if self + .settled_call_ids + .len() + .checked_add(self.settled_results.len()) + .and_then(|total| total.checked_add(self.pending.len())) + .and_then(|total| total.checked_add(self.completed.len())) + .is_none_or(|total| total > MAX_SETTLED_CALL_IDS) + || self.pending.len().saturating_add(self.completed.len()) > MAX_RETAINED_CALLS + || self + .settled_call_ids + .iter() + .any(|call_id| !is_stable_call_id(call_id)) + || self.settled_results.iter().any(|(call_id, result)| { + !is_stable_call_id(call_id) + || call_id != &result.call_id + || self.settled_call_ids.contains(call_id) + || validate_retained_result(result).is_err() + || validate_tool_result_contract(&self.authorized, result).is_err() + }) + || self.settled_call_ids.iter().any(|call_id| { + self.pending.contains_key(call_id) || self.completed.contains_key(call_id) + }) + || self.settled_results.keys().any(|call_id| { + self.pending.contains_key(call_id) || self.completed.contains_key(call_id) + }) + || self.pending.iter().any(|(call_id, call)| { + !is_stable_call_id(call_id) + || call_id != &call.call_id + || self.completed.contains_key(call_id) + || self.settled_call_ids.contains(call_id) + || self.settled_results.contains_key(call_id) + || validate_pending_tool_call(&self.authorized, call).is_err() + }) + || self.completed.iter().any(|(call_id, result)| { + !is_stable_call_id(call_id) + || call_id != &result.call_id + || self.pending.contains_key(call_id) + || validate_retained_result(result).is_err() + || validate_tool_result_contract(&self.authorized, result).is_err() + }) + { + return Err(ProviderBridgeError::invalid( + "recovered provider tool call state is invalid", + )); + } + self.retained_result_bytes = + retained_result_bytes(self.settled_results.iter().chain(self.completed.iter()))?; + self.ensure_settled_result_capacity(0).map_err(|_| { + ProviderBridgeError::invalid( + "recovered provider tool results exceed the durable byte limit", + ) + })?; + Ok(()) + } + + fn prepare_internal( + &mut self, + tool_set: AuthorizedToolSet, + allow_catalog_change: bool, + ) -> Result<(), ProviderBridgeError> { + if !self.pending.is_empty() { + return Err(ProviderBridgeError::invalid( + "cannot change authorized tools while provider calls are pending", + )); + } + validate_authorized_tool_set(&tool_set)?; + if !allow_catalog_change { + if let Some(existing) = &self.catalog_digest { + if existing != &tool_set.catalog_digest { + return Err(ProviderBridgeError::invalid( + "authorized tool set changed across a durable session", + )); + } + } + } + self.catalog_digest = Some(tool_set.catalog_digest); + self.authorized = tool_set + .operations + .into_iter() + .map(|tool| (tool.operation_id.clone(), tool)) + .collect(); + Ok(()) + } + + pub fn authorized_tools(&self) -> impl Iterator { + self.authorized.values() + } + + pub fn begin_call( + &mut self, + call_id: String, + operation_id: String, + input: Value, + ) -> Result { + let call = PendingToolCall { + call_id: call_id.clone(), + operation_id, + input, + }; + validate_pending_tool_call(&self.authorized, &call)?; + if let Some(existing) = self.pending.get(&call_id) { + return if existing == &call { + Ok(existing.clone()) + } else { + Err(ProviderBridgeError::invalid( + "conflicting duplicate provider tool call", + )) + }; + } + if self.completed.contains_key(&call_id) + || self.settled_results.contains_key(&call_id) + || self.settled_call_ids.contains(&call_id) + { + return Err(ProviderBridgeError::invalid( + "provider reused a completed tool call id", + )); + } + if self.pending.len().saturating_add(self.completed.len()) >= MAX_RETAINED_CALLS { + return Err(ProviderBridgeError::invalid( + "provider tool receipt limit reached for the active turn", + )); + } + // Reserve durable identity space before accepting work. Settlement can + // then never fail merely because earlier turns filled the ledger and + // leave completed receipts stranded in the active-turn budget. + if self + .settled_call_ids + .len() + .saturating_add(self.settled_results.len()) + .saturating_add(self.pending.len()) + .saturating_add(self.completed.len()) + >= MAX_SETTLED_CALL_IDS + { + return Err(ProviderBridgeError::invalid( + "durable provider tool call identity limit reached", + )); + } + // Reserve the worst-case encoded result before accepting the call. + // This makes apply_result and settlement infallible with respect to + // durable result capacity: accepted work can always retain its exact + // authoritative replay value. + self.ensure_settled_result_capacity(1)?; + self.pending.insert(call_id, call.clone()); + Ok(call) + } + + pub fn apply_result(&mut self, result: ToolResult) -> Result { + if result.call_id.is_empty() + || result.call_id.len() > 160 + || result.call_id.chars().any(char::is_control) + { + return Err(ProviderBridgeError::invalid( + "tool result call id is invalid", + )); + } + validate_operation_id(&result.operation_id)?; + bounded_json(&result.result, MAX_TOOL_VALUE_BYTES, "provider tool result")?; + if let Some(existing) = self.completed.get(&result.call_id) { + return if existing == &result { + Ok(existing.result.clone()) + } else { + Err(ProviderBridgeError::invalid( + "conflicting duplicate tool result", + )) + }; + } + if let Some(existing) = self.settled_results.get(&result.call_id) { + return if existing == &result { + Ok(existing.result.clone()) + } else { + Err(ProviderBridgeError::invalid( + "conflicting duplicate settled tool result", + )) + }; + } + if self.settled_call_ids.contains(&result.call_id) { + return Err(ProviderBridgeError::invalid( + "legacy settled tool result cannot be replayed", + )); + } + let pending = self.pending.get(&result.call_id).ok_or_else(|| { + ProviderBridgeError::invalid("tool result does not match a pending provider call") + })?; + if pending.operation_id != result.operation_id { + return Err(ProviderBridgeError::invalid( + "tool result operation does not match its call", + )); + } + validate_tool_result_contract(&self.authorized, &result)?; + let result_bytes = retained_result_entry_bytes(&result.call_id, &result)?; + let next_retained_bytes = self + .retained_result_bytes + .checked_add(result_bytes) + .ok_or_else(|| ProviderBridgeError::invalid("durable provider result size overflow"))?; + let remaining_pending_reserve = self + .pending + .len() + .saturating_sub(1) + .checked_mul(MAX_SETTLED_RESULT_ENTRY_BYTES) + .ok_or_else(|| ProviderBridgeError::invalid("durable provider result size overflow"))?; + if next_retained_bytes + .checked_add(remaining_pending_reserve) + .is_none_or(|bytes| bytes > MAX_SETTLED_RESULT_BYTES) + { + return Err(ProviderBridgeError::invalid( + "durable provider tool result byte limit reached", + )); + } + self.pending.remove(&result.call_id); + self.retained_result_bytes = next_retained_bytes; + self.completed + .insert(result.call_id.clone(), result.clone()); + Ok(result.result) + } + + pub fn settle_turn(&mut self) -> Result<(), ProviderBridgeError> { + if !self.pending.is_empty() { + return Err(ProviderBridgeError::invalid( + "cannot settle provider tool receipts while calls are pending", + )); + } + self.retained_result_bytes = + retained_result_bytes(self.settled_results.iter().chain(self.completed.iter()))?; + self.ensure_settled_result_capacity(0)?; + // The identity capacity was reserved in `begin_call`, so moving the + // authoritative receipts cannot fail a valid admitted turn. The check + // above rejects only recovered state that bypassed attach validation. + self.settled_results.append(&mut self.completed); + Ok(()) + } + + pub fn pending_calls(&self) -> impl Iterator { + self.pending.values() + } + + fn ensure_settled_result_capacity( + &self, + additional_pending: usize, + ) -> Result<(), ProviderBridgeError> { + let pending_count = self + .pending + .len() + .checked_add(additional_pending) + .ok_or_else(|| { + ProviderBridgeError::invalid("durable provider result count overflow") + })?; + let pending_reserve = pending_count + .checked_mul(MAX_SETTLED_RESULT_ENTRY_BYTES) + .ok_or_else(|| ProviderBridgeError::invalid("durable provider result size overflow"))?; + if self + .retained_result_bytes + .checked_add(pending_reserve) + .is_none_or(|bytes| bytes > MAX_SETTLED_RESULT_BYTES) + { + return Err(ProviderBridgeError::invalid( + "durable provider tool result byte limit reached", + )); + } + Ok(()) + } +} + +fn validate_retained_result(result: &ToolResult) -> Result<(), ProviderBridgeError> { + if !is_stable_call_id(&result.call_id) { + return Err(ProviderBridgeError::invalid( + "retained tool result call id is invalid", + )); + } + validate_operation_id(&result.operation_id)?; + bounded_json( + &result.result, + MAX_TOOL_VALUE_BYTES, + "retained provider tool result", + ) +} + +fn validate_pending_tool_call( + authorized: &BTreeMap, + call: &PendingToolCall, +) -> Result<(), ProviderBridgeError> { + if !is_stable_call_id(&call.call_id) { + return Err(ProviderBridgeError::invalid("tool call id is invalid")); + } + validate_operation_id(&call.operation_id)?; + let tool = authorized.get(&call.operation_id).ok_or_else(|| { + ProviderBridgeError::invalid(format!( + "provider requested unauthorized tool {}", + call.operation_id + )) + })?; + let validator = jsonschema::validator_for(&tool.input_schema).map_err(|_| { + ProviderBridgeError::invalid(format!( + "tool {} has an invalid durable input JSON Schema", + call.operation_id + )) + })?; + if !validator.is_valid(&call.input) { + return Err(ProviderBridgeError::invalid(format!( + "provider arguments for {} failed JSON Schema validation", + call.operation_id + ))); + } + bounded_json(&call.input, MAX_TOOL_VALUE_BYTES, "provider tool input") +} + +fn validate_tool_result_contract( + authorized: &BTreeMap, + result: &ToolResult, +) -> Result<(), ProviderBridgeError> { + let tool = authorized.get(&result.operation_id).ok_or_else(|| { + ProviderBridgeError::invalid("tool result operation is no longer authorized") + })?; + let validator = jsonschema::validator_for(&tool.response_schema).map_err(|_| { + ProviderBridgeError::invalid(format!( + "tool {} has an invalid durable response JSON Schema", + result.operation_id + )) + })?; + let response = semantic_response_value(result)?; + if !result.is_error { + // Paperclip semantic dispatchers return an authoritative envelope; + // provider contracts describe the operation-specific value inside + // `result`. Direct values remain valid for compatibility with v1 + // peers that do not wrap their semantic result. + if let Some(response) = response { + if !validator.is_valid(response) { + return Err(ProviderBridgeError::invalid(format!( + "tool result for {} failed JSON Schema validation", + result.operation_id + ))); + } + } + } + Ok(()) +} + +fn validate_authorized_tool_set(tool_set: &AuthorizedToolSet) -> Result<(), ProviderBridgeError> { + if tool_set.schema != TOOL_SET_SCHEMA || tool_set.schema_version != 1 { + return Err(ProviderBridgeError::invalid( + "unsupported authorized tool-set contract", + )); + } + if !is_sha256_digest(&tool_set.catalog_digest) { + return Err(ProviderBridgeError::invalid( + "authorized tool set requires a canonical sha256 catalog digest", + )); + } + if tool_set.operations.len() > MAX_AUTHORIZED_TOOLS { + return Err(ProviderBridgeError::invalid( + "authorized tool set exceeds the operation limit", + )); + } + bounded_json(tool_set, MAX_TOOL_SET_BYTES, "authorized tool set")?; + let mut names = BTreeSet::new(); + for tool in &tool_set.operations { + validate_operation_id(&tool.operation_id)?; + if tool.version != 1 { + return Err(ProviderBridgeError::invalid(format!( + "unsupported tool version for {}", + tool.operation_id + ))); + } + if tool.description.trim().is_empty() + || tool.description.len() > MAX_DESCRIPTION_BYTES + || tool.description.contains('\0') + || !tool.input_schema.is_object() + || !tool.response_schema.is_object() + { + return Err(ProviderBridgeError::invalid(format!( + "tool {} has an incomplete provider contract", + tool.operation_id + ))); + } + bounded_json( + &tool.input_schema, + MAX_SCHEMA_BYTES, + "tool input JSON Schema", + )?; + bounded_json( + &tool.response_schema, + MAX_SCHEMA_BYTES, + "tool response JSON Schema", + )?; + jsonschema::validator_for(&tool.input_schema).map_err(|_| { + ProviderBridgeError::invalid(format!( + "tool {} has an invalid input JSON Schema", + tool.operation_id + )) + })?; + jsonschema::validator_for(&tool.response_schema).map_err(|_| { + ProviderBridgeError::invalid(format!( + "tool {} has an invalid response JSON Schema", + tool.operation_id + )) + })?; + if !names.insert(tool.operation_id.clone()) { + return Err(ProviderBridgeError::invalid( + "authorized tool names must be unique", + )); + } + } + let computed_digest = authorized_tool_catalog_digest(&tool_set.operations)?; + if tool_set.catalog_digest != computed_digest { + return Err(ProviderBridgeError::invalid( + "authorized tool catalog digest does not match its operations", + )); + } + Ok(()) +} + +fn retained_result_entry_bytes( + call_id: &str, + result: &ToolResult, +) -> Result { + // A two-item tuple has the same delimiter cost as a one-entry JSON map. + // Summing tuples therefore equals one entry exactly and conservatively + // overcounts a multi-entry map by one byte per additional receipt. + encoded_json_bytes(&(call_id, result), "retained provider tool result") +} + +fn retained_result_bytes<'a>( + results: impl IntoIterator, +) -> Result { + results + .into_iter() + .try_fold(0usize, |total, (call_id, result)| { + total + .checked_add(retained_result_entry_bytes(call_id, result)?) + .ok_or_else(|| { + ProviderBridgeError::invalid("durable provider result size overflow") + }) + }) +} + +fn deserialize_retained_results<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct RetainedResultsVisitor; + + impl<'de> Visitor<'de> for RetainedResultsVisitor { + type Value = BTreeMap; + + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("a bounded map of retained provider tool results") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + if map + .size_hint() + .is_some_and(|entries| entries > MAX_SETTLED_CALL_IDS) + { + return Err(de::Error::custom( + "retained provider tool result count exceeds the durable limit", + )); + } + let mut results = BTreeMap::new(); + let mut retained_bytes = 0usize; + while let Some((call_id, result)) = map.next_entry::()? { + if results.len() >= MAX_SETTLED_CALL_IDS { + return Err(de::Error::custom( + "retained provider tool result count exceeds the durable limit", + )); + } + validate_retained_result(&result).map_err(de::Error::custom)?; + if call_id != result.call_id { + return Err(de::Error::custom( + "retained provider tool result identity is inconsistent", + )); + } + retained_bytes = retained_bytes + .checked_add( + retained_result_entry_bytes(&call_id, &result) + .map_err(de::Error::custom)?, + ) + .ok_or_else(|| de::Error::custom("durable provider result size overflow"))?; + if retained_bytes > MAX_SETTLED_RESULT_BYTES { + return Err(de::Error::custom( + "retained provider tool results exceed the durable byte limit", + )); + } + if results.insert(call_id, result).is_some() { + return Err(de::Error::custom( + "retained provider tool result identities must be unique", + )); + } + } + Ok(results) + } + } + + deserializer.deserialize_map(RetainedResultsVisitor) +} + +fn is_stable_call_id(value: &str) -> bool { + !value.is_empty() && value.len() <= 160 && !value.chars().any(char::is_control) +} + +pub fn authorized_tool_catalog_digest( + operations: &[AuthorizedTool], +) -> Result { + // Catalog identity is independent of projection order. Durable bridge + // state stores tools in a BTreeMap, so hashing operation-id order here + // keeps an accepted catalog byte-stable when it is serialized and + // recovered. + let mut canonical_operations = operations.iter().collect::>(); + canonical_operations.sort_by(|left, right| left.operation_id.cmp(&right.operation_id)); + let value = serde_json::to_value(canonical_operations) + .map_err(|_| ProviderBridgeError::invalid("authorized tool catalog is not serializable"))?; + let canonical = canonical_json(&value); + let digest = Sha256::digest(canonical.as_bytes()); + Ok(format!("sha256:{digest:x}")) +} + +fn canonical_json(value: &Value) -> String { + match value { + Value::Null => "null".to_owned(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + Value::String(value) => { + serde_json::to_string(value).expect("serializing an in-memory JSON string cannot fail") + } + Value::Array(values) => format!( + "[{}]", + values + .iter() + .map(canonical_json) + .collect::>() + .join(",") + ), + Value::Object(object) => { + let mut entries = object.iter().collect::>(); + entries.sort_by_key(|(key, _)| *key); + format!( + "{{{}}}", + entries + .into_iter() + .map(|(key, value)| format!( + "{}:{}", + serde_json::to_string(key) + .expect("serializing an in-memory JSON key cannot fail"), + canonical_json(value) + )) + .collect::>() + .join(",") + ) + } + } +} + +fn semantic_response_value(result: &ToolResult) -> Result, ProviderBridgeError> { + let Some(envelope) = result.result.as_object() else { + return Ok(Some(&result.result)); + }; + let Some(ok) = envelope.get("ok").and_then(Value::as_bool) else { + return Ok(Some(&result.result)); + }; + if !envelope.contains_key("operationId") && !envelope.contains_key("callId") { + return Ok(Some(&result.result)); + } + if envelope.get("operationId").and_then(Value::as_str) != Some(&result.operation_id) + || envelope.get("callId").and_then(Value::as_str) != Some(&result.call_id) + { + return Err(ProviderBridgeError::invalid( + "semantic result envelope does not match its provider call", + )); + } + if ok { + envelope.get("result").map(Some).ok_or_else(|| { + ProviderBridgeError::invalid("successful semantic result omitted result") + }) + } else if envelope.get("denial").is_some() || envelope.get("error").is_some() { + Ok(None) + } else { + Err(ProviderBridgeError::invalid( + "failed semantic result omitted denial or error", + )) + } +} + +fn validate_operation_id(value: &str) -> Result<(), ProviderBridgeError> { + let mut chars = value.chars(); + let first = chars + .next() + .is_some_and(|character| character.is_ascii_alphanumeric()); + let rest = chars.all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.' | ':') + }); + if first && rest && value.len() <= 160 { + Ok(()) + } else { + Err(ProviderBridgeError::invalid("tool operation id is invalid")) + } +} + +fn is_sha256_digest(value: &str) -> bool { + let Some(hex) = value.strip_prefix("sha256:") else { + return false; + }; + hex.len() == 64 + && hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn bounded_json( + value: &impl Serialize, + max_bytes: usize, + label: &str, +) -> Result<(), ProviderBridgeError> { + let bytes = encoded_json_bytes(value, label)?; + if bytes > max_bytes { + return Err(ProviderBridgeError::invalid(format!( + "{label} exceeds the {max_bytes} byte limit" + ))); + } + Ok(()) +} + +fn encoded_json_bytes(value: &impl Serialize, label: &str) -> Result { + serde_json::to_vec(value) + .map(|bytes| bytes.len()) + .map_err(|_| ProviderBridgeError::invalid(format!("{label} is not serializable"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn worst_case_result_identity_overhead_fits_the_admission_reserve() { + let call_id = "\\".repeat(160); + let result = ToolResult { + call_id: call_id.clone(), + operation_id: "x".repeat(160), + result: Value::String("x".repeat(MAX_TOOL_VALUE_BYTES - 2)), + is_error: false, + }; + + assert_eq!( + encoded_json_bytes(&result.result, "test result").unwrap(), + MAX_TOOL_VALUE_BYTES + ); + assert!( + retained_result_entry_bytes(&call_id, &result).unwrap() + <= MAX_SETTLED_RESULT_ENTRY_BYTES + ); + } +} diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/provider_bridge.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/provider_bridge.rs new file mode 100644 index 0000000000..17303b3bf9 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/provider_bridge.rs @@ -0,0 +1,606 @@ +use paperclip_runner_core::provider_bridge::{ + authorized_tool_catalog_digest, AuthorizedTool, AuthorizedToolSet, ProviderToolBridge, + ToolResult, TOOL_SET_SCHEMA, +}; +use serde_json::json; + +fn tools(digest: &str) -> AuthorizedToolSet { + let mut tool_set = AuthorizedToolSet { + schema: TOOL_SET_SCHEMA.to_owned(), + schema_version: 1, + catalog_digest: digest.to_owned(), + operations: vec![AuthorizedTool { + operation_id: "get_task_context".to_owned(), + version: 1, + description: "Read the active task context.".to_owned(), + input_schema: json!({"type": "object"}), + response_schema: json!({"type": "object"}), + }], + }; + if digest == "computed" { + tool_set.catalog_digest = authorized_tool_catalog_digest(&tool_set.operations).unwrap(); + } + tool_set +} + +fn digest(suffix: char) -> String { + format!("sha256:{}", suffix.to_string().repeat(64)) +} + +#[test] +fn forwards_only_authorized_calls_and_correlates_results() { + let mut bridge = ProviderToolBridge::default(); + bridge.prepare(tools("computed")).unwrap(); + let call = bridge + .begin_call( + "call-1".to_owned(), + "get_task_context".to_owned(), + json!({}), + ) + .unwrap(); + assert_eq!(call.operation_id, "get_task_context"); + let value = bridge + .apply_result(ToolResult { + call_id: "call-1".to_owned(), + operation_id: "get_task_context".to_owned(), + result: json!({"ok": true}), + is_error: false, + }) + .unwrap(); + assert_eq!(value, json!({"ok": true})); + assert_eq!(bridge.pending_calls().count(), 0); +} + +#[test] +fn rejects_unknown_tools_and_conflicting_duplicate_results() { + let mut bridge = ProviderToolBridge::default(); + bridge.prepare(tools("computed")).unwrap(); + assert!(bridge + .begin_call("call-x".to_owned(), "not_authorized".to_owned(), json!({})) + .is_err()); + bridge + .begin_call( + "call-1".to_owned(), + "get_task_context".to_owned(), + json!({}), + ) + .unwrap(); + let result = ToolResult { + call_id: "call-1".to_owned(), + operation_id: "get_task_context".to_owned(), + result: json!({"ok": true}), + is_error: false, + }; + bridge.apply_result(result.clone()).unwrap(); + bridge.apply_result(result).unwrap(); + assert!(bridge + .apply_result(ToolResult { + call_id: "call-1".to_owned(), + operation_id: "get_task_context".to_owned(), + result: json!({"ok": false}), + is_error: false, + }) + .is_err()); +} + +#[test] +fn durable_session_refuses_catalog_drift() { + let mut bridge = ProviderToolBridge::default(); + let first = tools("computed"); + bridge.prepare(first.clone()).unwrap(); + let mut changed = first.clone(); + changed.operations[0].description = "Changed without changing the supplied digest.".to_owned(); + assert!(bridge.prepare(changed).is_err()); + let mut changed = first; + changed.operations[0].description = "Changed with a new digest.".to_owned(); + changed.catalog_digest = authorized_tool_catalog_digest(&changed.operations).unwrap(); + assert!(bridge.prepare(changed).is_err()); + let encoded = serde_json::to_string(&bridge).unwrap(); + let recovered: ProviderToolBridge = serde_json::from_str(&encoded).unwrap(); + assert_eq!(recovered, bridge); +} + +#[test] +fn catalog_digest_matches_the_typescript_canonical_json_contract() { + assert_eq!( + authorized_tool_catalog_digest(&tools("computed").operations).unwrap(), + "sha256:4e0332535c9e2ff1f5e43089517ee1b46654bfc9cb2ed51efbea4be50db21009" + ); +} + +#[test] +fn validates_the_operation_value_inside_a_semantic_dispatch_envelope() { + let mut set = tools("sha256:catalog-a"); + set.operations[0].response_schema = json!({ + "type": "object", + "properties": { "value": { "type": "string" } }, + "required": ["value"], + "additionalProperties": false + }); + let mut bridge = ProviderToolBridge::default(); + set.catalog_digest = digest('a'); + set.catalog_digest = authorized_tool_catalog_digest(&set.operations).unwrap(); + bridge.prepare(set).unwrap(); + bridge + .begin_call("call-1".into(), "get_task_context".into(), json!({})) + .unwrap(); + bridge + .apply_result(ToolResult { + call_id: "call-1".into(), + operation_id: "get_task_context".into(), + result: json!({ + "ok": true, + "operationId": "get_task_context", + "callId": "call-1", + "result": { "value": "accepted" }, + "stateRevision": 2 + }), + is_error: false, + }) + .unwrap(); + assert_eq!(bridge.pending_calls().count(), 0); +} + +#[test] +fn rejects_noncanonical_digests_and_oversized_contract_values() { + let mut bridge = ProviderToolBridge::default(); + assert!(bridge.prepare(tools("sha256:catalog-a")).is_err()); + + let mut set = tools(&digest('a')); + set.operations[0].description = "x".repeat(16 * 1024 + 1); + assert!(bridge.prepare(set).is_err()); + + let mut bridge = ProviderToolBridge::default(); + bridge.prepare(tools("computed")).unwrap(); + assert!(bridge + .begin_call( + "call-large".into(), + "get_task_context".into(), + json!({ "value": "x".repeat(1024 * 1024) }), + ) + .is_err()); +} + +#[test] +fn keeps_pending_calls_when_a_result_envelope_has_wrong_identity() { + let mut bridge = ProviderToolBridge::default(); + bridge.prepare(tools("computed")).unwrap(); + bridge + .begin_call("call-1".into(), "get_task_context".into(), json!({})) + .unwrap(); + + assert!(bridge + .apply_result(ToolResult { + call_id: "call-1".into(), + operation_id: "get_task_context".into(), + result: json!({ + "ok": false, + "operationId": "get_task_context", + "callId": "another-call", + "error": { "message": "denied" } + }), + is_error: true, + }) + .is_err()); + assert_eq!(bridge.pending_calls().count(), 1); +} + +#[test] +fn recovery_preserves_completed_call_replay_identities() { + let mut bridge = ProviderToolBridge::default(); + bridge.prepare(tools("computed")).unwrap(); + bridge + .begin_call("call-1".into(), "get_task_context".into(), json!({})) + .unwrap(); + bridge + .apply_result(ToolResult { + call_id: "call-1".into(), + operation_id: "get_task_context".into(), + result: json!({"ok": true}), + is_error: false, + }) + .unwrap(); + + let encoded = serde_json::to_string(&bridge).unwrap(); + let mut recovered: ProviderToolBridge = serde_json::from_str(&encoded).unwrap(); + recovered.attach_existing_run().unwrap(); + assert!(recovered + .begin_call("call-1".into(), "get_task_context".into(), json!({})) + .is_err()); +} + +#[test] +fn recovery_preserves_pending_calls_for_the_existing_run() { + let mut bridge = ProviderToolBridge::default(); + bridge.prepare(tools("computed")).unwrap(); + bridge + .begin_call("call-1".into(), "get_task_context".into(), json!({})) + .unwrap(); + + let encoded = serde_json::to_string(&bridge).unwrap(); + let mut recovered: ProviderToolBridge = serde_json::from_str(&encoded).unwrap(); + recovered.attach_existing_run().unwrap(); + + let pending = recovered.pending_calls().collect::>(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].call_id, "call-1"); + assert!(recovered + .begin_call("call-1".into(), "get_task_context".into(), json!({})) + .is_ok()); +} + +#[test] +fn recovery_preserves_a_pristine_bridge_without_a_catalog() { + let bridge = ProviderToolBridge::default(); + let encoded = serde_json::to_string(&bridge).unwrap(); + let mut recovered: ProviderToolBridge = serde_json::from_str(&encoded).unwrap(); + + recovered + .attach_existing_run() + .expect("a pristine pre-catalog snapshot remains recoverable"); + assert_eq!(recovered, bridge); +} + +#[test] +fn recovery_rejects_nonempty_state_without_a_catalog_digest() { + let mut bridge = ProviderToolBridge::default(); + bridge.prepare(tools("computed")).unwrap(); + let mut encoded = serde_json::to_value(&bridge).unwrap(); + encoded["catalogDigest"] = serde_json::Value::Null; + let mut recovered: ProviderToolBridge = serde_json::from_value(encoded).unwrap(); + + let error = recovered + .attach_existing_run() + .expect_err("nonempty recovered state must remain bound to a catalog digest"); + assert!(error.to_string().contains("omit the catalog digest")); +} + +#[test] +fn recovery_rejects_tampered_authorization_catalog_bindings() { + let mut bridge = ProviderToolBridge::default(); + bridge.prepare(tools("computed")).unwrap(); + let encoded = serde_json::to_value(&bridge).unwrap(); + + let mut changed_contract = encoded.clone(); + changed_contract["authorized"]["get_task_context"]["inputSchema"] = json!({ + "type": "object", + "properties": { "includeSecrets": { "type": "boolean" } } + }); + let mut recovered: ProviderToolBridge = serde_json::from_value(changed_contract).unwrap(); + let error = recovered + .attach_existing_run() + .expect_err("recovery must recompute the catalog digest"); + assert!(error.to_string().contains("catalog digest")); + + let mut changed_map_key = encoded; + let authorized = changed_map_key["authorized"].as_object_mut().unwrap(); + let tool = authorized.remove("get_task_context").unwrap(); + authorized.insert("delete_company".to_owned(), tool); + let mut recovered: ProviderToolBridge = serde_json::from_value(changed_map_key).unwrap(); + let error = recovered + .attach_existing_run() + .expect_err("recovery must bind map keys to declared operation identities"); + assert!(error.to_string().contains("identities are inconsistent")); +} + +#[test] +fn recovery_rejects_tampered_pending_call_contracts() { + let mut bridge = ProviderToolBridge::default(); + bridge.prepare(tools("computed")).unwrap(); + bridge + .begin_call("call-1".into(), "get_task_context".into(), json!({})) + .unwrap(); + let encoded = serde_json::to_value(&bridge).unwrap(); + + let mut unauthorized = encoded.clone(); + unauthorized["pending"]["call-1"]["operationId"] = json!("delete_company"); + let mut recovered: ProviderToolBridge = serde_json::from_value(unauthorized).unwrap(); + assert!(recovered.attach_existing_run().is_err()); + + let mut invalid_input = encoded; + invalid_input["pending"]["call-1"]["input"] = json!(["not", "an", "object"]); + let mut recovered: ProviderToolBridge = serde_json::from_value(invalid_input).unwrap(); + assert!(recovered.attach_existing_run().is_err()); + + let mut oversized_input = serde_json::to_value(&bridge).unwrap(); + oversized_input["pending"]["call-1"]["input"] = json!({"value": "x".repeat(1024 * 1024)}); + let mut recovered: ProviderToolBridge = serde_json::from_value(oversized_input).unwrap(); + assert!(recovered.attach_existing_run().is_err()); +} + +#[test] +fn recovery_rejects_tampered_retained_result_contracts() { + let mut bridge = ProviderToolBridge::default(); + bridge.prepare(tools("computed")).unwrap(); + bridge + .begin_call("call-1".into(), "get_task_context".into(), json!({})) + .unwrap(); + bridge + .apply_result(ToolResult { + call_id: "call-1".into(), + operation_id: "get_task_context".into(), + result: json!({"ok": true}), + is_error: false, + }) + .unwrap(); + let completed = serde_json::to_value(&bridge).unwrap(); + + let mut unauthorized = completed.clone(); + unauthorized["completed"]["call-1"]["operationId"] = json!("delete_company"); + let mut recovered: ProviderToolBridge = serde_json::from_value(unauthorized).unwrap(); + assert!(recovered.attach_existing_run().is_err()); + + let mut invalid_output = completed; + invalid_output["completed"]["call-1"]["result"] = json!(["not", "an", "object"]); + let mut recovered: ProviderToolBridge = serde_json::from_value(invalid_output).unwrap(); + assert!(recovered.attach_existing_run().is_err()); + + bridge.settle_turn().unwrap(); + let mut invalid_settled_output = serde_json::to_value(&bridge).unwrap(); + invalid_settled_output["settledResults"]["call-1"]["result"] = json!("invalid"); + let mut recovered: ProviderToolBridge = serde_json::from_value(invalid_settled_output).unwrap(); + assert!(recovered.attach_existing_run().is_err()); +} + +#[test] +fn recovery_preserves_a_reverse_ordered_authorization_catalog() { + let mut tool_set = tools("computed"); + tool_set.operations.push(AuthorizedTool { + operation_id: "answer_status_question".to_owned(), + version: 1, + description: "Answer a status question.".to_owned(), + input_schema: json!({"type": "object"}), + response_schema: json!({"type": "object"}), + }); + assert!(tool_set.operations[0].operation_id > tool_set.operations[1].operation_id); + tool_set.catalog_digest = authorized_tool_catalog_digest(&tool_set.operations).unwrap(); + + let mut bridge = ProviderToolBridge::default(); + bridge.prepare(tool_set).unwrap(); + let encoded = serde_json::to_string(&bridge).unwrap(); + let mut recovered: ProviderToolBridge = serde_json::from_str(&encoded).unwrap(); + + recovered + .attach_existing_run() + .expect("recovery must preserve a valid catalog regardless of projection order"); + assert_eq!(recovered.authorized_tools().count(), 2); +} + +#[test] +fn settles_completed_receipts_before_the_next_turn() { + let mut bridge = ProviderToolBridge::default(); + bridge.prepare(tools("computed")).unwrap(); + + for index in 0..4_096 { + let call_id = format!("call-{index}"); + bridge + .begin_call(call_id.clone(), "get_task_context".into(), json!({})) + .unwrap(); + bridge + .apply_result(ToolResult { + call_id, + operation_id: "get_task_context".into(), + result: json!({"ok": true}), + is_error: false, + }) + .unwrap(); + } + + assert!(bridge + .begin_call("call-next".into(), "get_task_context".into(), json!({})) + .is_err()); + bridge.settle_turn().unwrap(); + assert!(bridge + .begin_call("call-next".into(), "get_task_context".into(), json!({})) + .is_ok()); +} + +#[test] +fn settlement_preserves_call_ids_for_the_durable_run() { + let mut bridge = ProviderToolBridge::default(); + bridge.prepare(tools("computed")).unwrap(); + bridge + .begin_call("call-1".into(), "get_task_context".into(), json!({})) + .unwrap(); + bridge + .apply_result(ToolResult { + call_id: "call-1".into(), + operation_id: "get_task_context".into(), + result: json!({"ok": true}), + is_error: false, + }) + .unwrap(); + bridge.settle_turn().unwrap(); + + let replay = ToolResult { + call_id: "call-1".into(), + operation_id: "get_task_context".into(), + result: json!({"ok": true}), + is_error: false, + }; + assert_eq!( + bridge.apply_result(replay.clone()).unwrap(), + json!({"ok": true}) + ); + assert!(bridge + .apply_result(ToolResult { + result: json!({"ok": false}), + ..replay + }) + .is_err()); + + assert!(bridge + .begin_call("call-1".into(), "get_task_context".into(), json!({})) + .is_err()); + bridge + .begin_call("call-2".into(), "get_task_context".into(), json!({})) + .unwrap(); + + let encoded = serde_json::to_string(&bridge).unwrap(); + let mut recovered: ProviderToolBridge = serde_json::from_str(&encoded).unwrap(); + recovered.attach_existing_run().unwrap(); + assert_eq!( + recovered + .apply_result(ToolResult { + call_id: "call-1".into(), + operation_id: "get_task_context".into(), + result: json!({"ok": true}), + is_error: false, + }) + .unwrap(), + json!({"ok": true}) + ); + assert!(recovered + .begin_call("call-1".into(), "get_task_context".into(), json!({})) + .is_err()); +} + +#[test] +fn reserves_identity_capacity_before_accepting_a_call() { + let mut bridge = ProviderToolBridge::default(); + bridge.prepare(tools("computed")).unwrap(); + + let mut encoded = serde_json::to_value(&bridge).unwrap(); + encoded["settledCallIds"] = serde_json::Value::Array( + (0..65_535) + .map(|index| serde_json::Value::String(format!("settled-{index}"))) + .collect(), + ); + let mut bridge: ProviderToolBridge = serde_json::from_value(encoded).unwrap(); + bridge.attach_existing_run().unwrap(); + + bridge + .begin_call("last-call".into(), "get_task_context".into(), json!({})) + .unwrap(); + bridge + .apply_result(ToolResult { + call_id: "last-call".into(), + operation_id: "get_task_context".into(), + result: json!({"ok": true}), + is_error: false, + }) + .unwrap(); + bridge.settle_turn().unwrap(); + + assert!(bridge + .begin_call("overflow".into(), "get_task_context".into(), json!({})) + .is_err()); + assert!(bridge.settle_turn().is_ok()); +} + +#[test] +fn reserves_settled_result_bytes_before_accepting_a_call() { + let mut bridge = ProviderToolBridge::default(); + bridge.prepare(tools("computed")).unwrap(); + let large_result = json!({"value": "x".repeat(900 * 1024)}); + let mut completed = 0; + + for index in 0..20 { + let call_id = format!("large-call-{index}"); + if bridge + .begin_call(call_id.clone(), "get_task_context".into(), json!({})) + .is_err() + { + break; + } + bridge + .apply_result(ToolResult { + call_id, + operation_id: "get_task_context".into(), + result: large_result.clone(), + is_error: false, + }) + .expect("an admitted call has reserved its maximum durable result"); + completed += 1; + } + + assert!((2..20).contains(&completed)); + assert!(bridge + .begin_call( + "over-byte-limit".into(), + "get_task_context".into(), + json!({}) + ) + .is_err()); + bridge + .settle_turn() + .expect("settlement cannot strand results whose bytes were reserved at admission"); + assert_eq!( + bridge + .apply_result(ToolResult { + call_id: "large-call-0".into(), + operation_id: "get_task_context".into(), + result: large_result, + is_error: false, + }) + .unwrap(), + json!({"value": "x".repeat(900 * 1024)}) + ); +} + +#[test] +fn recovery_rejects_an_oversized_settled_result_envelope() { + let mut bridge = ProviderToolBridge::default(); + bridge.prepare(tools("computed")).unwrap(); + let mut encoded = serde_json::to_value(&bridge).unwrap(); + let settled = encoded["settledResults"].as_object_mut().unwrap(); + for index in 0..10 { + let call_id = format!("recovered-large-{index}"); + settled.insert( + call_id.clone(), + json!({ + "callId": call_id, + "operationId": "get_task_context", + "result": {"value": "x".repeat(900 * 1024)}, + "isError": false + }), + ); + } + + let error = serde_json::from_value::(encoded) + .expect_err("decoding must stop a settled result envelope above 8 MiB"); + assert!(error.to_string().contains("durable byte limit")); +} + +#[test] +fn recovery_rejects_state_without_room_for_a_pending_result() { + let mut bridge = ProviderToolBridge::default(); + bridge.prepare(tools("computed")).unwrap(); + let mut encoded = serde_json::to_value(&bridge).unwrap(); + let settled = encoded["settledResults"].as_object_mut().unwrap(); + for index in 0..8 { + let call_id = format!("recovered-large-{index}"); + settled.insert( + call_id.clone(), + json!({ + "callId": call_id, + "operationId": "get_task_context", + "result": {"value": "x".repeat(900 * 1024)}, + "isError": false + }), + ); + } + encoded["pending"]["pending-call"] = json!({ + "callId": "pending-call", + "operationId": "get_task_context", + "input": {} + }); + + let mut recovered: ProviderToolBridge = serde_json::from_value(encoded).unwrap(); + let error = recovered + .attach_existing_run() + .expect_err("recovery must reserve a maximum result for every pending call"); + assert!(error.to_string().contains("durable byte limit")); +} + +#[test] +fn refuses_to_settle_receipts_while_calls_are_pending() { + let mut bridge = ProviderToolBridge::default(); + bridge.prepare(tools("computed")).unwrap(); + bridge + .begin_call("call-1".into(), "get_task_context".into(), json!({})) + .unwrap(); + + assert!(bridge.settle_turn().is_err()); + assert_eq!(bridge.pending_calls().count(), 1); +}