From 3ba0e7f64fe3957b80dab0e49a3ab86fbfe6db10 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 30 Aug 2026 03:27:37 -0500 Subject: [PATCH] feat(runner): add durable semantic tool bridge (#12378) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The runner package has a reviewed semantic action catalog and dispatcher > - The Rust runner process needs the same fail-closed authorization boundary > - Provider calls must remain correlated and idempotent across durable recovery > - Input and result values must satisfy the authorized operation schemas > - This pull request adds a package-local durable semantic tool bridge > - It does not advertise tools to Codex or enable the Paperclip Runner adapter ## Linked Issues or Issue Description **Subsystem affected** `packages/paperclip-runner/runner` semantic tool authorization and correlation. **Problem or motivation** The Rust runner needs a durable representation of the run-scoped tools that the control plane authorizes. It must reject unknown operations, catalog drift, invalid values, and conflicting duplicate calls or results before a provider integration can use those tools. **Proposed solution** Add a serialized provider tool bridge. Validate the authorized catalog and its JSON Schemas. Validate each call and result. Keep pending and completed identities so retries are idempotent and conflicts fail closed. **Alternatives considered** Trusting provider arguments would bypass the run-scoped catalog. Validating only in TypeScript would leave the Rust process without a recovery-safe authorization boundary. Adding provider behavior in this pull request would make the review unit too broad. **Roadmap alignment** This adds a package-local safety boundary for the Codex-first runner path. It does not enable a new adapter or change an existing direct adapter path. ## What Changed - Added the versioned authorized-tool, pending-call, and result contracts. - Added canonical SHA-256 catalog binding and drift rejection. - Added JSON Schema compilation and input and response validation. - Added duplicate-call and duplicate-result idempotency with conflict rejection. - Added bounds for catalogs, schemas, values, and retained call identities. - Added the Rust `jsonschema` dependency and its Cargo lock entries. - Added focused tests for authorization, recovery, envelopes, bounds, and conflicts. ## Verification - `cargo fmt --manifest-path packages/paperclip-runner/runner/Cargo.toml --all -- --check` - `cargo test --manifest-path packages/paperclip-runner/runner/Cargo.toml -p paperclip-runner-core` (64 tests) - `cargo clippy --manifest-path packages/paperclip-runner/runner/Cargo.toml -p paperclip-runner-core --all-targets -- -D warnings -A clippy::manual_is_multiple_of -A clippy::filter_map_bool_then` - `pnpm -r typecheck` - `pnpm build` - The repository test runner also reached unrelated server worktree suites. Those suites fail on the current macOS worktree with database deadlocks and filesystem fixture assumptions. This pull request does not change those files. The applicable GitHub checks remain the handoff authority. ## Risks The main risks are accepting a tool that the run did not authorize and replaying a conflicting provider result. The bridge validates the catalog, operation identity, JSON Schema, call identity, and result identity before it changes durable state. The new Cargo dependency is package-local. This pull request changes no GitHub workflow and no pnpm lockfile. ## Model Used OpenAI Codex with GPT-5 and repository tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- packages/paperclip-runner/runner/Cargo.lock | 776 +++++++++++++++++ packages/paperclip-runner/runner/Cargo.toml | 1 + .../runner/crates/runner-core/Cargo.toml | 1 + .../runner/crates/runner-core/src/lib.rs | 1 + .../crates/runner-core/src/provider_bridge.rs | 815 ++++++++++++++++++ .../runner-core/tests/provider_bridge.rs | 606 +++++++++++++ 6 files changed, 2200 insertions(+) create mode 100644 packages/paperclip-runner/runner/crates/runner-core/src/provider_bridge.rs create mode 100644 packages/paperclip-runner/runner/crates/runner-core/tests/provider_bridge.rs 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); +}