From 76e4e4f2694fe544534cf7c42103386c4f80fabc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 04:39:58 +0100 Subject: [PATCH 01/22] Lift the connector contract, envelope and client from CodexConnector fd7cec4 Workspace with the model-connector crate. contract.rs, envelope.rs and client.rs are CodexConnector lib.rs moved verbatim under the rename rule (strip the leading Codex from identifiers); schema id strings are unchanged, so the v2 wire is unchanged. canonical_responses_body stays behind: it is OpenAI Responses lowering. The moved tests keep their bodies; shared fixtures live in fixtures.rs. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + Cargo.lock | 861 +++++++++++++++++++++++++ Cargo.toml | 9 + crates/model-connector/Cargo.toml | 26 + crates/model-connector/src/client.rs | 179 +++++ crates/model-connector/src/contract.rs | 649 +++++++++++++++++++ crates/model-connector/src/envelope.rs | 286 ++++++++ crates/model-connector/src/fixtures.rs | 129 ++++ crates/model-connector/src/lib.rs | 10 + 9 files changed, 2150 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/model-connector/Cargo.toml create mode 100644 crates/model-connector/src/client.rs create mode 100644 crates/model-connector/src/contract.rs create mode 100644 crates/model-connector/src/envelope.rs create mode 100644 crates/model-connector/src/fixtures.rs create mode 100644 crates/model-connector/src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a3dfca5 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,861 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "codex-connector" +version = "0.1.0" +source = "git+https://github.com/GameCult/CodexConnector.git?rev=fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8#fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8" +dependencies = [ + "aes-gcm", + "base64", + "rand_core", + "rmp-serde", + "serde", + "serde_json", + "sha2", + "thiserror", + "zeroize", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cultcache-rs" +version = "0.1.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=e171eca32329baa928f4a1d810401a8b4c857029#e171eca32329baa928f4a1d810401a8b4c857029" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs-derive", + "fs2", + "redb", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "uuid", + "windows-sys", +] + +[[package]] +name = "cultcache-rs-derive" +version = "0.1.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=e171eca32329baa928f4a1d810401a8b4c857029#e171eca32329baa928f4a1d810401a8b4c857029" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "model-connector" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "codex-connector", + "cultcache-rs", + "rand_core", + "rmp-serde", + "serde", + "serde_json", + "sha2", + "thiserror", + "zeroize", +] + +[[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 = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redb" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de6c3b63e007e90ce536ec2ae4690826136a20ec8dbbbb400daef1bb999d2e36" +dependencies = [ + "libc", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "uuid" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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 = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..10e8225 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] +members = ["crates/*"] +resolver = "3" + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "AGPL-3.0-only" +repository = "https://eureka.gamecult.org/forge/GameCult/ModelConnector" diff --git a/crates/model-connector/Cargo.toml b/crates/model-connector/Cargo.toml new file mode 100644 index 0000000..cb30f96 --- /dev/null +++ b/crates/model-connector/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "model-connector" +description = "Provider-neutral connector contract, envelope, client, replay service and serve loop for GameCult minds" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[features] +default = [] +daemon = ["dep:cultcache-rs"] + +[dependencies] +aes-gcm = "0.10" +cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "e171eca32329baa928f4a1d810401a8b4c857029", package = "cultcache-rs", optional = true } +rand_core = { version = "0.6", features = ["getrandom"] } +rmp-serde = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "2" +zeroize = "1" + +[dev-dependencies] +codex-connector = { git = "https://github.com/GameCult/CodexConnector.git", rev = "fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8", default-features = false } diff --git a/crates/model-connector/src/client.rs b/crates/model-connector/src/client.rs new file mode 100644 index 0000000..ea8ea3d --- /dev/null +++ b/crates/model-connector/src/client.rs @@ -0,0 +1,179 @@ +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +use thiserror::Error; +use zeroize::Zeroizing; + +use crate::contract::{TransportInvocation, TransportKey, TransportResult}; +use crate::envelope::{ + ServiceError, TransportFrameError, decrypt_result, encrypt_invocation, read_transport_frame, + write_transport_frame, +}; + +#[derive(Clone)] +pub struct ConnectorClient { + endpoint: SocketAddr, + security: TransportKey, + max_frame_bytes: usize, + response_timeout: Option, +} + +impl ConnectorClient { + pub fn new( + endpoint: SocketAddr, + connection_key: impl Into, + max_frame_bytes: usize, + response_timeout: Option, + ) -> Result { + if endpoint.port() == 0 + || endpoint.ip().is_unspecified() + || endpoint.ip().is_multicast() + || endpoint.ip() == std::net::Ipv4Addr::BROADCAST + || !(4096..=u32::MAX as usize).contains(&max_frame_bytes) + || response_timeout.is_some_and(|timeout| timeout.is_zero()) + { + return Err(ConnectorClientError::InvalidConfig); + } + let connection_key = Zeroizing::new(connection_key.into()); + let security = TransportKey::from_connection_secret(connection_key.as_str())?; + Ok(Self { + endpoint, + security, + max_frame_bytes, + response_timeout, + }) + } + + pub fn execute( + &self, + invocation: &TransportInvocation, + ) -> Result { + let request = rmp_serde::to_vec(&encrypt_invocation(invocation, &self.security)?) + .map_err(|_| ConnectorClientError::Encoding)?; + let connect_timeout = self + .response_timeout + .unwrap_or(Duration::from_secs(10)) + .min(Duration::from_secs(10)); + let mut stream = TcpStream::connect_timeout(&self.endpoint, connect_timeout) + .map_err(ConnectorClientError::Connection)?; + stream + .set_read_timeout(self.response_timeout) + .and_then(|()| stream.set_write_timeout(self.response_timeout)) + .map_err(ConnectorClientError::Connection)?; + write_transport_frame(&mut stream, &request, self.max_frame_bytes) + .map_err(client_frame_error)?; + let response = + read_transport_frame(&mut stream, self.max_frame_bytes).map_err(client_frame_error)?; + let envelope = + rmp_serde::from_slice(&response).map_err(|_| ConnectorClientError::Encoding)?; + decrypt_result(&envelope, &self.security, invocation).map_err(Into::into) + } +} + + +#[cfg(test)] +mod client_endpoint_tests { + use super::*; + + #[test] + fn client_admits_explicit_unicast_and_refuses_non_destinations() { + for endpoint in ["127.0.0.1:4103", "172.19.0.1:4103", "[::1]:4103"] { + assert!( + ConnectorClient::new( + endpoint.parse().unwrap(), + "bounded-client-endpoint-key", + 64 * 1024, + Some(Duration::from_secs(1)), + ) + .is_ok(), + "{endpoint} should be an admissible explicit destination" + ); + } + for endpoint in [ + "0.0.0.0:4103", + "255.255.255.255:4103", + "224.0.0.1:4103", + "[::]:4103", + "[ff02::1]:4103", + "127.0.0.1:0", + ] { + assert!(matches!( + ConnectorClient::new( + endpoint.parse().unwrap(), + "bounded-client-endpoint-key", + 64 * 1024, + Some(Duration::from_secs(1)), + ), + Err(ConnectorClientError::InvalidConfig) + )); + } + } +} + + +#[derive(Debug, Error)] +pub enum ConnectorClientError { + #[error("invalid connector client configuration")] + InvalidConfig, + #[error("connector client connection failed")] + Connection(#[source] std::io::Error), + #[error("connector frame exceeded its bound")] + FrameSize, + #[error("connector MessagePack encoding failed")] + Encoding, + #[error(transparent)] + Transport(#[from] ServiceError), +} + +fn client_frame_error(error: TransportFrameError) -> ConnectorClientError { + match error { + TransportFrameError::Connection(error) => ConnectorClientError::Connection(error), + TransportFrameError::Size => ConnectorClientError::FrameSize, + } +} + +#[cfg(test)] +mod fixture_tests { + use super::*; + use crate::fixtures::*; + use crate::{contract::*, envelope::*}; + + #[test] + fn shared_client_transports_one_exact_encrypted_request_and_result() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let invocation = invocation(); + let expected = invocation.clone(); + let security = security("epiphany-distinct-test-key"); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_transport_frame(&mut stream, 64 * 1024).unwrap(); + let envelope: TransportEnvelope = rmp_serde::from_slice(&request).unwrap(); + assert_eq!(decrypt_invocation(&envelope, &security).unwrap(), expected); + let response = encrypt_result( + &TransportResult::refused(&expected, Refusal::Policy), + &security, + ) + .unwrap(); + write_transport_frame( + &mut stream, + &rmp_serde::to_vec(&response).unwrap(), + 64 * 1024, + ) + .unwrap(); + }); + + let client = ConnectorClient::new( + endpoint, + "epiphany-distinct-test-key", + 64 * 1024, + Some(Duration::from_secs(1)), + ) + .unwrap(); + assert_eq!( + client.execute(&invocation).unwrap().disposition, + TransportDisposition::Refused(Refusal::Policy) + ); + server.join().unwrap(); + } +} diff --git a/crates/model-connector/src/contract.rs b/crates/model-connector/src/contract.rs new file mode 100644 index 0000000..2f11a48 --- /dev/null +++ b/crates/model-connector/src/contract.rs @@ -0,0 +1,649 @@ +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use zeroize::Zeroize; + +use crate::envelope::ServiceError; + +pub const PROVIDER_REQUEST_SCHEMA_ID: &str = "gamecult.codex.provider_request.v2"; +pub const INVOCATION_SCHEMA_ID: &str = "gamecult.codex.transport_invocation.v2"; +pub const RESULT_SCHEMA_ID: &str = "gamecult.codex.transport_result.v2"; +pub const RECEIPT_SCHEMA_ID: &str = "gamecult.codex.transport_receipt.v2"; +pub const ENVELOPE_SCHEMA_ID: &str = "gamecult.codex.transport_envelope.v2"; + +#[derive(Clone, PartialEq, Eq)] +pub struct TransportKey(pub(crate) [u8; 32]); + +impl TransportKey { + pub fn from_connection_secret(secret: &str) -> Result { + if secret.trim().is_empty() || secret.trim() != secret { + return Err(ServiceError::InvalidAdmission); + } + Ok(Self(Sha256::digest(secret.as_bytes()).into())) + } +} + +impl Drop for TransportKey { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderRequest { + pub schema_id: String, + pub request_id: String, + pub conversation_id: String, + pub model: String, + pub instructions: String, + pub input: Vec, + pub reasoning_effort: Option, + pub reasoning_summary: Option, + pub service_tier: Option, + pub output_format_name: Option, + pub previous_response_id: Option, + pub tools: Vec, + pub tool_choice: ToolChoice, + pub parallel_tool_calls: bool, + pub output_schema_json: Option, + pub max_output_tokens: Option, + pub prompt_cache_key: Option, +} + +impl ProviderRequest { + pub fn new( + request_id: impl Into, + conversation_id: impl Into, + model: impl Into, + instructions: impl Into, + ) -> Self { + Self { + schema_id: PROVIDER_REQUEST_SCHEMA_ID.to_string(), + request_id: request_id.into(), + conversation_id: conversation_id.into(), + model: model.into(), + instructions: instructions.into(), + input: Vec::new(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + output_format_name: None, + previous_response_id: None, + tools: Vec::new(), + tool_choice: ToolChoice::Auto, + parallel_tool_calls: false, + output_schema_json: None, + max_output_tokens: None, + prompt_cache_key: None, + } + } + + pub fn validate(&self) -> Result<(), ContractError> { + if self.schema_id != PROVIDER_REQUEST_SCHEMA_ID { + return Err(ContractError::Schema); + } + require_id(&self.request_id, "request_id")?; + require_id(&self.conversation_id, "conversation_id")?; + require_id(&self.model, "model")?; + require_content(&self.instructions, "instructions")?; + if matches!(self.max_output_tokens, Some(0)) { + return Err(ContractError::Invalid("max_output_tokens")); + } + if self + .prompt_cache_key + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err(ContractError::Invalid("prompt_cache_key")); + } + for (value, field) in [ + (self.reasoning_effort.as_deref(), "reasoning_effort"), + (self.reasoning_summary.as_deref(), "reasoning_summary"), + (self.service_tier.as_deref(), "service_tier"), + (self.output_format_name.as_deref(), "output_format_name"), + (self.previous_response_id.as_deref(), "previous_response_id"), + ] { + if let Some(value) = value { + require_id(value, field)?; + } + } + if let Some(schema) = &self.output_schema_json { + require_content(schema, "output_schema_json")?; + require_json_object(schema, "output_schema_json")?; + require_provider_name( + self.output_format_name + .as_deref() + .ok_or(ContractError::Invalid("output_format_name"))?, + "output_format_name", + )?; + } else if self.output_format_name.is_some() { + return Err(ContractError::Invalid("output_schema_json")); + } + + if self.tools.is_empty() && self.tool_choice == ToolChoice::Required { + return Err(ContractError::Invalid("tool_choice")); + } + + let mut tool_names = HashSet::new(); + for tool in &self.tools { + require_provider_name(&tool.name, "tool.name")?; + require_content(&tool.description, "tool.description")?; + require_content(&tool.parameters_json, "tool.parameters_json")?; + require_json_object(&tool.parameters_json, "tool.parameters_json")?; + if !tool_names.insert(tool.name.as_str()) { + return Err(ContractError::DuplicateTool(tool.name.clone())); + } + } + for item in &self.input { + match item { + InputItem::UserText { text } | InputItem::AssistantText { text } => { + require_content(text, "input.text")?; + } + InputItem::ToolCall { + call_id, + name, + arguments, + } => { + require_provider_call_id(call_id, "input.call_id")?; + require_provider_name(name, "input.tool_name")?; + require_content(arguments, "input.arguments")?; + } + InputItem::ToolResult { call_id, output } => { + require_provider_call_id(call_id, "input.call_id")?; + require_content(output, "input.output")?; + } + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolChoice { + Auto, + Required, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum InputItem { + UserText { + text: String, + }, + AssistantText { + text: String, + }, + ToolCall { + call_id: String, + name: String, + arguments: String, + }, + ToolResult { + call_id: String, + output: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolDefinition { + pub name: String, + pub description: String, + pub parameters_json: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportInvocation { + pub schema_id: String, + pub caller_runtime_id: String, + pub expires_at_unix_ms: u64, + pub native_request_sha256: [u8; 32], + pub provider_request_sha256: [u8; 32], + pub request: ProviderRequest, +} + +impl TransportInvocation { + pub fn new( + caller_runtime_id: impl Into, + expires_at_unix_ms: u64, + native_request_sha256: [u8; 32], + request: ProviderRequest, + ) -> Result { + let provider_request_sha256 = provider_request_sha256(&request)?; + Ok(Self { + schema_id: INVOCATION_SCHEMA_ID.to_string(), + caller_runtime_id: caller_runtime_id.into(), + expires_at_unix_ms, + native_request_sha256, + provider_request_sha256, + request, + }) + } + + pub fn validate(&self, now_unix_ms: u64, max_expiry_skew_ms: u64) -> Result<(), ContractError> { + if self.schema_id != INVOCATION_SCHEMA_ID { + return Err(ContractError::Schema); + } + require_id(&self.caller_runtime_id, "caller_runtime_id")?; + if self.native_request_sha256 == [0; 32] { + return Err(ContractError::Invalid("native_request_sha256")); + } + self.request.validate()?; + if self.provider_request_sha256 != provider_request_sha256(&self.request)? { + return Err(ContractError::ProviderDigest); + } + if self.expires_at_unix_ms < now_unix_ms + || self.expires_at_unix_ms > now_unix_ms.saturating_add(max_expiry_skew_ms) + { + return Err(ContractError::Expiry); + } + Ok(()) + } + + pub fn request_id(&self) -> &str { + &self.request.request_id + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportResult { + pub schema_id: String, + pub request_id: String, + pub caller_runtime_id: String, + pub disposition: TransportDisposition, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransportDisposition { + Refused(Refusal), + Transported { + events: Vec, + receipt: Box, + }, +} + +impl TransportResult { + pub fn refused(invocation: &TransportInvocation, reason: Refusal) -> Self { + Self { + schema_id: RESULT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + disposition: TransportDisposition::Refused(reason), + } + } + + pub fn transported( + invocation: &TransportInvocation, + events: Vec, + receipt: TransportReceipt, + ) -> Self { + Self { + schema_id: RESULT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + disposition: TransportDisposition::Transported { + events, + receipt: Box::new(receipt), + }, + } + } + + pub fn validate_against( + &self, + invocation: &TransportInvocation, + ) -> Result<(), ContractError> { + if self.schema_id != RESULT_SCHEMA_ID { + return Err(ContractError::Schema); + } + if self.request_id != invocation.request_id() + || self.caller_runtime_id != invocation.caller_runtime_id + { + return Err(ContractError::Identity); + } + + if let TransportDisposition::Transported { events, receipt } = &self.disposition { + for (expected, event) in events.iter().enumerate() { + if event.sequence != expected as u64 { + return Err(ContractError::EventSequence); + } + event.validate()?; + } + receipt.validate_against(invocation)?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransportEventPayload { + TextDelta { + text: String, + }, + ToolCall { + call_id: String, + name: String, + arguments: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportEvent { + pub sequence: u64, + pub payload: TransportEventPayload, +} + +impl TransportEvent { + fn validate(&self) -> Result<(), ContractError> { + match &self.payload { + TransportEventPayload::TextDelta { text } => require_content(text, "event.text"), + TransportEventPayload::ToolCall { + call_id, + name, + arguments, + } => { + require_id(call_id, "event.call_id")?; + require_id(name, "event.tool_name")?; + require_content(arguments, "event.arguments") + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportReceipt { + pub schema_id: String, + pub request_id: String, + pub caller_runtime_id: String, + pub native_request_sha256: [u8; 32], + pub provider_request_sha256: [u8; 32], + pub model: String, + pub transport: String, + pub outcome: TransportOutcome, +} + +impl TransportReceipt { + pub fn validate_against( + &self, + invocation: &TransportInvocation, + ) -> Result<(), ContractError> { + if self.schema_id != RECEIPT_SCHEMA_ID { + return Err(ContractError::Schema); + } + if self.request_id != invocation.request_id() + || self.caller_runtime_id != invocation.caller_runtime_id + || self.model != invocation.request.model + { + return Err(ContractError::Identity); + } + if self.provider_request_sha256 != invocation.provider_request_sha256 { + return Err(ContractError::ProviderDigest); + } + if self.native_request_sha256 != invocation.native_request_sha256 { + return Err(ContractError::NativeDigest); + } + require_id(&self.transport, "receipt.transport")?; + match &self.outcome { + TransportOutcome::Completed { + provider_response_id: Some(value), + .. + } => require_id(value, "receipt.provider_response_id")?, + TransportOutcome::Failed { + failure_kind, + message, + } => { + require_id(failure_kind, "receipt.failure_kind")?; + require_content(message, "receipt.failure_message")?; + } + TransportOutcome::Completed { + provider_response_id: None, + .. + } => {} + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransportOutcome { + Completed { + provider_response_id: Option, + input_tokens: Option, + output_tokens: Option, + reasoning_output_tokens: Option, + cached_input_tokens: Option, + }, + Failed { + failure_kind: String, + message: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Refusal { + Expired, + IdentitySubstitution, + ProviderDigestSubstitution, + Policy, + Capacity, + InFlight, + Indeterminate, + ReplayConflict, + Malformed, +} + +pub fn canonical_provider_request_bytes( + request: &ProviderRequest, +) -> Result, ContractError> { + request.validate()?; + rmp_serde::to_vec(request).map_err(|_| ContractError::Encoding) +} + +pub fn provider_request_sha256(request: &ProviderRequest) -> Result<[u8; 32], ContractError> { + Ok(Sha256::digest(canonical_provider_request_bytes(request)?).into()) +} + +pub fn canonical_invocation_bytes( + invocation: &TransportInvocation, +) -> Result, ContractError> { + invocation.request.validate()?; + if invocation.provider_request_sha256 != provider_request_sha256(&invocation.request)? { + return Err(ContractError::ProviderDigest); + } + rmp_serde::to_vec(invocation).map_err(|_| ContractError::Encoding) +} + +pub(crate) fn require_id(value: &str, field: &'static str) -> Result<(), ContractError> { + if value.trim().is_empty() || value.trim() != value { + return Err(ContractError::Invalid(field)); + } + Ok(()) +} + +fn require_content(value: &str, field: &'static str) -> Result<(), ContractError> { + if value.is_empty() { + return Err(ContractError::Invalid(field)); + } + Ok(()) +} + +fn require_json_object(value: &str, field: &'static str) -> Result<(), ContractError> { + if !matches!( + serde_json::from_str::(value), + Ok(serde_json::Value::Object(_)) + ) { + return Err(ContractError::Invalid(field)); + } + Ok(()) +} + +fn require_provider_name(value: &str, field: &'static str) -> Result<(), ContractError> { + if value.is_empty() + || value.len() > 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(ContractError::Invalid(field)); + } + Ok(()) +} + +fn require_provider_call_id(value: &str, field: &'static str) -> Result<(), ContractError> { + if value.is_empty() || value.len() > 64 || !value.is_ascii() { + return Err(ContractError::Invalid(field)); + } + Ok(()) +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ContractError { + #[error("unexpected schema")] + Schema, + #[error("invalid {0}")] + Invalid(&'static str), + #[error("duplicate tool {0:?}")] + DuplicateTool(String), + #[error("request identity substitution")] + Identity, + #[error("provider request digest substitution")] + ProviderDigest, + #[error("native request digest substitution")] + NativeDigest, + #[error("invocation expiry is outside the admitted horizon")] + Expiry, + #[error("transport event sequence is not contiguous")] + EventSequence, + #[error("canonical MessagePack encoding failed")] + Encoding, +} + + +#[cfg(test)] +mod tests { + use super::*; + + use crate::fixtures::*; + + #[test] + fn provider_digest_is_stable_and_content_addressed() { + let original = request(); + let same = original.clone(); + assert_eq!( + provider_request_sha256(&original).unwrap(), + provider_request_sha256(&same).unwrap() + ); + let mut changed = original.clone(); + changed.instructions.push_str(" Changed."); + assert_ne!( + provider_request_sha256(&original).unwrap(), + provider_request_sha256(&changed).unwrap() + ); + } + + #[test] + fn provider_contract_refuses_transport_side_fixup_pressure() { + let mut request = request(); + request.output_format_name = Some("epiphany.role.result".to_string()); + request.output_schema_json = Some(r#"{"type":"object"}"#.to_string()); + assert_eq!( + request.validate(), + Err(ContractError::Invalid("output_format_name")) + ); + + request.output_format_name = None; + request.output_schema_json = None; + request.input.push(InputItem::ToolResult { + call_id: "x".repeat(65), + output: "evidence".to_string(), + }); + assert_eq!( + request.validate(), + Err(ContractError::Invalid("input.call_id")) + ); + } + + #[test] + fn invocation_refuses_provider_request_substitution() { + let mut invocation = invocation(); + invocation.request.model = "substituted".to_string(); + assert_eq!( + invocation.validate(1_000, 2_000), + Err(ContractError::ProviderDigest) + ); + } + + #[test] + fn invocation_refuses_expired_or_blank_native_provenance() { + let mut invocation = invocation(); + assert_eq!( + invocation.validate(2_001, 2_000), + Err(ContractError::Expiry) + ); + invocation.expires_at_unix_ms = 2_500; + invocation.native_request_sha256 = [0; 32]; + assert_eq!( + invocation.validate(2_001, 2_000), + Err(ContractError::Invalid("native_request_sha256")) + ); + } + + #[test] + fn typed_tool_round_survives_messagepack() { + let invocation = invocation(); + let bytes = rmp_serde::to_vec(&invocation).unwrap(); + let decoded: TransportInvocation = rmp_serde::from_slice(&bytes).unwrap(); + assert_eq!(decoded, invocation); + assert!(matches!( + decoded.request.tools.as_slice(), + [ToolDefinition { name, .. }] if name == "read_source" + )); + } + + #[test] + fn transported_result_binds_identity_digest_and_event_order() { + let invocation = invocation(); + let result = TransportResult::transported( + &invocation, + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + }], + receipt(&invocation), + ); + assert_eq!(result.validate_against(&invocation), Ok(())); + + let mut reordered = result; + let TransportDisposition::Transported { events, .. } = &mut reordered.disposition + else { + unreachable!() + }; + events[0].sequence = 1; + assert_eq!( + reordered.validate_against(&invocation), + Err(ContractError::EventSequence) + ); + } + + #[test] + fn receipt_refuses_caller_substitution() { + let invocation = invocation(); + let mut substituted_caller = receipt(&invocation); + substituted_caller.caller_runtime_id = "ghostlight-dungeon-yggdrasil".to_string(); + assert_eq!( + substituted_caller.validate_against(&invocation), + Err(ContractError::Identity) + ); + + let mut substituted_native_basis = receipt(&invocation); + substituted_native_basis.native_request_sha256 = [8; 32]; + assert_eq!( + substituted_native_basis.validate_against(&invocation), + Err(ContractError::NativeDigest) + ); + } +} diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs new file mode 100644 index 0000000..713eab8 --- /dev/null +++ b/crates/model-connector/src/envelope.rs @@ -0,0 +1,286 @@ +use std::io::{Read, Write}; + +use aes_gcm::aead::{Aead, Payload}; +use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; +use rand_core::{OsRng, RngCore}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::contract::{ + ContractError, ENVELOPE_SCHEMA_ID, TransportInvocation, TransportKey, TransportResult, + canonical_invocation_bytes, require_id, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum EnvelopeKind { + Invocation, + Result, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportEnvelope { + pub schema_id: String, + pub caller_runtime_id: String, + pub request_id: String, + pub message_kind: EnvelopeKind, + pub nonce: [u8; 12], + pub ciphertext: Vec, +} + +pub fn encrypt_invocation( + invocation: &TransportInvocation, + security: &TransportKey, +) -> Result { + let plaintext = canonical_invocation_bytes(invocation)?; + encrypt_message( + &invocation.caller_runtime_id, + invocation.request_id(), + EnvelopeKind::Invocation, + &plaintext, + security, + ) +} + +pub fn decrypt_invocation( + envelope: &TransportEnvelope, + security: &TransportKey, +) -> Result { + validate_envelope(envelope, EnvelopeKind::Invocation)?; + let plaintext = decrypt_message(envelope, security)?; + let invocation: TransportInvocation = + rmp_serde::from_slice(&plaintext).map_err(|_| ServiceError::Encoding)?; + if invocation.caller_runtime_id != envelope.caller_runtime_id + || invocation.request_id() != envelope.request_id + { + return Err(ServiceError::OuterIdentity); + } + Ok(invocation) +} + +pub fn encrypt_result( + result: &TransportResult, + security: &TransportKey, +) -> Result { + let plaintext = rmp_serde::to_vec(result).map_err(|_| ServiceError::Encoding)?; + encrypt_message( + &result.caller_runtime_id, + &result.request_id, + EnvelopeKind::Result, + &plaintext, + security, + ) +} + +pub fn decrypt_result( + envelope: &TransportEnvelope, + security: &TransportKey, + invocation: &TransportInvocation, +) -> Result { + validate_envelope(envelope, EnvelopeKind::Result)?; + let plaintext = decrypt_message(envelope, security)?; + let result: TransportResult = + rmp_serde::from_slice(&plaintext).map_err(|_| ServiceError::Encoding)?; + if result.caller_runtime_id != envelope.caller_runtime_id + || result.request_id != envelope.request_id + { + return Err(ServiceError::OuterIdentity); + } + result.validate_against(invocation)?; + Ok(result) +} + +#[derive(Debug)] +pub(crate) enum TransportFrameError { + Connection(std::io::Error), + Size, +} + + +pub(crate) fn read_transport_frame( + reader: &mut impl Read, + max_frame_bytes: usize, +) -> Result, TransportFrameError> { + let mut length = [0_u8; 4]; + reader + .read_exact(&mut length) + .map_err(TransportFrameError::Connection)?; + let length = u32::from_be_bytes(length) as usize; + if length == 0 || length > max_frame_bytes { + return Err(TransportFrameError::Size); + } + let mut payload = vec![0_u8; length]; + reader + .read_exact(&mut payload) + .map_err(TransportFrameError::Connection)?; + Ok(payload) +} + +pub(crate) fn write_transport_frame( + writer: &mut impl Write, + payload: &[u8], + max_frame_bytes: usize, +) -> Result<(), TransportFrameError> { + if payload.is_empty() || payload.len() > max_frame_bytes || payload.len() > u32::MAX as usize { + return Err(TransportFrameError::Size); + } + writer + .write_all(&(payload.len() as u32).to_be_bytes()) + .and_then(|()| writer.write_all(payload)) + .and_then(|()| writer.flush()) + .map_err(TransportFrameError::Connection) +} + +pub(crate) fn encrypt_message( + caller_runtime_id: &str, + request_id: &str, + message_kind: EnvelopeKind, + plaintext: &[u8], + security: &TransportKey, +) -> Result { + require_id(caller_runtime_id, "caller_runtime_id")?; + require_id(request_id, "request_id")?; + let mut nonce = [0; 12]; + OsRng.fill_bytes(&mut nonce); + let cipher = Aes256Gcm::new_from_slice(&security.0).map_err(|_| ServiceError::Encryption)?; + let aad = envelope_aad( + ENVELOPE_SCHEMA_ID, + caller_runtime_id, + request_id, + message_kind, + )?; + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: plaintext, + aad: &aad, + }, + ) + .map_err(|_| ServiceError::Encryption)?; + Ok(TransportEnvelope { + schema_id: ENVELOPE_SCHEMA_ID.to_string(), + caller_runtime_id: caller_runtime_id.to_string(), + request_id: request_id.to_string(), + message_kind, + nonce, + ciphertext, + }) +} + +pub(crate) fn decrypt_message( + envelope: &TransportEnvelope, + security: &TransportKey, +) -> Result, ServiceError> { + let cipher = Aes256Gcm::new_from_slice(&security.0).map_err(|_| ServiceError::Encryption)?; + let aad = envelope_aad( + &envelope.schema_id, + &envelope.caller_runtime_id, + &envelope.request_id, + envelope.message_kind, + )?; + cipher + .decrypt( + Nonce::from_slice(&envelope.nonce), + Payload { + msg: &envelope.ciphertext, + aad: &aad, + }, + ) + .map_err(|_| ServiceError::Encryption) +} + +pub(crate) fn envelope_aad( + schema_id: &str, + caller_runtime_id: &str, + request_id: &str, + message_kind: EnvelopeKind, +) -> Result, ServiceError> { + rmp_serde::to_vec(&(schema_id, caller_runtime_id, request_id, message_kind)) + .map_err(|_| ServiceError::Encoding) +} + +pub(crate) fn validate_envelope( + envelope: &TransportEnvelope, + expected_kind: EnvelopeKind, +) -> Result<(), ServiceError> { + if envelope.schema_id != ENVELOPE_SCHEMA_ID || envelope.message_kind != expected_kind { + return Err(ServiceError::Envelope); + } + require_id(&envelope.caller_runtime_id, "caller_runtime_id")?; + require_id(&envelope.request_id, "request_id")?; + if envelope.ciphertext.is_empty() { + return Err(ServiceError::Envelope); + } + Ok(()) +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ServiceError { + #[error(transparent)] + Contract(#[from] ContractError), + #[error("invalid caller admission")] + InvalidAdmission, + #[error("caller runtime was admitted twice")] + DuplicateCaller, + #[error("callers must not share transport keys")] + SharedCallerKey, + #[error("connector replay store failed: {0}")] + ReplayStore(String), + #[error("connector replay record is malformed")] + InvalidReplayRecord, + #[error("caller key changed while durable replay records still exist")] + ReplayCallerKeyMismatch, + #[error("caller runtime is not admitted")] + CallerNotAdmitted, + #[error("transport payload exceeds the caller bound")] + PayloadTooLarge, + #[error("unexpected transport envelope")] + Envelope, + #[error("transport envelope identity substitution")] + OuterIdentity, + #[error("transport encryption or authentication failed")] + Encryption, + #[error("transport MessagePack encoding failed")] + Encoding, + #[error("completion did not carry a transported provider outcome")] + InvalidCompletion, + #[error("completion has no active request")] + NoActiveRequest, + #[error("completion does not match the active request")] + ActiveRequestMismatch, +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixtures::*; + + #[test] + fn encrypted_invocation_hides_content_and_binds_outer_identity() { + let invocation = invocation(); + let epiphany_key = security("epiphany-distinct-test-key"); + let envelope = encrypt_invocation(&invocation, &epiphany_key).unwrap(); + assert!( + !envelope + .ciphertext + .windows("Projected state".len()) + .any(|window| window == b"Projected state") + ); + assert_eq!( + decrypt_invocation(&envelope, &epiphany_key).unwrap(), + invocation + ); + assert_eq!( + decrypt_invocation(&envelope, &security("wrong-caller-key")), + Err(ServiceError::Encryption) + ); + + let mut substituted = envelope; + substituted.request_id = "request-2".to_string(); + assert_eq!( + decrypt_invocation(&substituted, &epiphany_key), + Err(ServiceError::Encryption) + ); + } +} diff --git a/crates/model-connector/src/fixtures.rs b/crates/model-connector/src/fixtures.rs new file mode 100644 index 0000000..bb89264 --- /dev/null +++ b/crates/model-connector/src/fixtures.rs @@ -0,0 +1,129 @@ +//! Shared fixtures for the carried contract, envelope, client and service tests. +use crate::contract::*; + +#[cfg(feature = "daemon")] +use crate::service::*; + +#[cfg(feature = "daemon")] +use std::sync::atomic::{AtomicUsize, Ordering}; + +#[cfg(feature = "daemon")] +static TEST_STORE_SEQUENCE: AtomicUsize = AtomicUsize::new(0); + +#[cfg(feature = "daemon")] +pub(crate) struct TestStore { + pub(crate) root: std::path::PathBuf, + pub(crate) path: std::path::PathBuf, +} + +#[cfg(feature = "daemon")] +impl TestStore { + pub(crate) fn new() -> Self { + let sequence = TEST_STORE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "model-connector-replay-{}-{sequence}", + std::process::id() + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("replay.cc"); + Self { root, path } + } +} + +#[cfg(feature = "daemon")] +impl Drop for TestStore { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +pub(crate) fn request() -> ProviderRequest { + let mut request = ProviderRequest::new( + "request-1", + "conversation-1", + "gpt-5.4", + "Return the typed result.", + ); + request.input.push(InputItem::UserText { + text: "Projected state".to_string(), + }); + request.tools.push(ToolDefinition { + name: "read_source".to_string(), + description: "Read one admitted source.".to_string(), + parameters_json: r#"{"type":"object"}"#.to_string(), + }); + request +} + +pub(crate) fn invocation() -> TransportInvocation { + TransportInvocation::new("epiphany-yggdrasil", 2_000, [7; 32], request()).unwrap() +} + +pub(crate) fn receipt(invocation: &TransportInvocation) -> TransportReceipt { + TransportReceipt { + schema_id: RECEIPT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + native_request_sha256: invocation.native_request_sha256, + provider_request_sha256: invocation.provider_request_sha256, + model: invocation.request.model.clone(), + transport: "fixture-sse".to_string(), + outcome: TransportOutcome::Completed { + provider_response_id: Some("response-1".to_string()), + input_tokens: Some(10), + output_tokens: Some(3), + reasoning_output_tokens: Some(1), + cached_input_tokens: None, + }, + } +} +#[cfg(feature = "daemon")] +pub(crate) fn caller( + caller_runtime_id: &str, + key: &str, + max_concurrent_requests: usize, +) -> CallerAdmission { + CallerAdmission::new( + caller_runtime_id, + key, + 1, + ["gpt-5.4".to_string()], + max_concurrent_requests, + 64 * 1024, + 32_768, + ) + .unwrap() +} + +#[cfg(feature = "daemon")] +pub(crate) fn open_service(store: &TestStore, max_concurrent_requests: usize) -> TransportService { + TransportService::open( + &store.path, + [ + caller( + "epiphany-yggdrasil", + "epiphany-distinct-test-key", + max_concurrent_requests, + ), + caller( + "ghostlight-yggdrasil", + "ghostlight-distinct-test-key", + max_concurrent_requests, + ), + ], + 5_000, + ) + .unwrap() +} + +pub(crate) fn security(key: &str) -> TransportKey { + TransportKey::from_connection_secret(key).unwrap() +} + +#[cfg(feature = "daemon")] +pub(crate) fn invocation_for(caller_runtime_id: &str, request_id: &str) -> TransportInvocation { + let mut request = request(); + request.request_id = request_id.to_string(); + TransportInvocation::new(caller_runtime_id, 2_000, [7; 32], request).unwrap() +} + diff --git a/crates/model-connector/src/lib.rs b/crates/model-connector/src/lib.rs new file mode 100644 index 0000000..f288476 --- /dev/null +++ b/crates/model-connector/src/lib.rs @@ -0,0 +1,10 @@ +mod client; +mod contract; +mod envelope; + +#[cfg(test)] +mod fixtures; + +pub use client::*; +pub use contract::*; +pub use envelope::*; From b26ee5e676df5c4986e0a8cae77221550ef63586 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 04:40:15 +0100 Subject: [PATCH 02/22] Lift the replay service and serve loop behind a ProviderBackend trait service.rs is CodexConnector lib.rs 954-1313 verbatim under the rename rule. serve.rs is the accept loop of daemon.rs over ProviderBackend (ready, execute), ServeLimits, CallerConfig and load_admissions; no config document, no provider, no Idunn route observation. serve refuses a non-loopback listener and drops non-loopback peers. ready() is consulted once, before replay state is opened and before the first accept, as base consulted readiness at startup; execute is called on the request path without consulting it (base only checked readiness for health). New tests: end-to-end replay with a counting backend, readiness timing, limits, peer and listener refusal, connection permits, and behavioural kills for survivors in service policy, payload bound, refusal mapping, restart record validation and completion mismatch. Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/lib.rs | 10 + crates/model-connector/src/serve.rs | 649 +++++++++++++++++++++ crates/model-connector/src/service.rs | 788 ++++++++++++++++++++++++++ 3 files changed, 1447 insertions(+) create mode 100644 crates/model-connector/src/serve.rs create mode 100644 crates/model-connector/src/service.rs diff --git a/crates/model-connector/src/lib.rs b/crates/model-connector/src/lib.rs index f288476..ba1642b 100644 --- a/crates/model-connector/src/lib.rs +++ b/crates/model-connector/src/lib.rs @@ -5,6 +5,16 @@ mod envelope; #[cfg(test)] mod fixtures; +#[cfg(feature = "daemon")] +mod serve; +#[cfg(feature = "daemon")] +mod service; + pub use client::*; pub use contract::*; pub use envelope::*; + +#[cfg(feature = "daemon")] +pub use serve::*; +#[cfg(feature = "daemon")] +pub use service::*; diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs new file mode 100644 index 0000000..1b613f5 --- /dev/null +++ b/crates/model-connector/src/serve.rs @@ -0,0 +1,649 @@ +use std::fs; +use std::net::{IpAddr, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use zeroize::Zeroizing; + +use crate::contract::{TransportInvocation, TransportResult}; +use crate::envelope::{ + ServiceError, TransportEnvelope, TransportFrameError, read_transport_frame, + write_transport_frame, +}; +use crate::service::{CallerAdmission, TransportAdmission, TransportService}; + +/// The only provider seam of the connector kit. A connector binary implements +/// it over one model provider; the kit names no provider. +pub trait ProviderBackend: Send + Sync { + /// Whether the provider can take work. `serve` consults it once, before it + /// opens the replay store or accepts a connection; the request path does + /// not consult it. + fn ready(&self) -> bool; + /// Executes one admitted invocation. The service has already bound the + /// invocation to its caller and recorded its replay claim. + fn execute(&self, invocation: &TransportInvocation) -> TransportResult; +} + +/// Bounds of one serve loop. The connector binary's own document owns where +/// they come from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ServeLimits { + pub max_frame_bytes: usize, + pub max_connections: usize, + pub socket_timeout_ms: u64, + pub max_expiry_skew_ms: u64, +} + +impl ServeLimits { + fn validate(&self) -> Result<(), ServeError> { + if self.max_frame_bytes == 0 + || self.max_frame_bytes > u32::MAX as usize + || self.max_connections == 0 + || self.socket_timeout_ms == 0 + || self.max_expiry_skew_ms == 0 + { + return Err(ServeError::InvalidConfig("serve limits")); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CallerConfig { + pub caller_runtime_id: String, + pub connection_key_file: PathBuf, + pub connection_key_epoch: u32, + pub allowed_models: Vec, + pub max_concurrent_requests: usize, + pub max_payload_bytes: usize, + pub max_output_tokens: u32, +} + +pub fn required_connection_capacity(callers: &[CallerConfig]) -> Result { + Ok(caller_request_capacity(callers)?.max(8)) +} + +pub fn caller_request_capacity(callers: &[CallerConfig]) -> Result { + callers + .iter() + .try_fold(0_usize, |total, caller| { + total.checked_add(caller.max_concurrent_requests) + }) + .ok_or(ServeError::InvalidConfig("connection capacity")) +} + +pub fn load_admissions(callers: &[CallerConfig]) -> Result, ServeError> { + let mut admissions = Vec::with_capacity(callers.len()); + for caller in callers { + let raw = Zeroizing::new( + fs::read(&caller.connection_key_file).map_err(ServeError::ConnectionKey)?, + ); + let secret = std::str::from_utf8(raw.as_slice()) + .map_err(|_| ServeError::InvalidConfig("caller connection key encoding"))? + .trim_end_matches(['\r', '\n']); + admissions.push( + CallerAdmission::new( + caller.caller_runtime_id.clone(), + secret.to_string(), + caller.connection_key_epoch, + caller.allowed_models.clone(), + caller.max_concurrent_requests, + caller.max_payload_bytes, + caller.max_output_tokens, + ) + .map_err(ServeError::Service)?, + ); + } + Ok(admissions) +} + +/// Serves admitted callers on a loopback listener until the listener fails. +/// The caller binds; a listener that is not bound to loopback is refused, and +/// peers that are not loopback are dropped. +pub fn serve( + listener: TcpListener, + limits: ServeLimits, + admissions: Vec, + replay_store: &Path, + backend: Arc, +) -> Result<(), ServeError> { + limits.validate()?; + if admissions.is_empty() { + return Err(ServeError::InvalidConfig("callers")); + } + if !listener + .local_addr() + .map_err(ServeError::Listen)? + .ip() + .is_loopback() + { + return Err(ServeError::InvalidConfig("listener address")); + } + if !backend.ready() { + return Err(ServeError::BackendUnready); + } + let service = Arc::new(Mutex::new(TransportService::open( + replay_store, + admissions, + limits.max_expiry_skew_ms, + )?)); + let active_connections = Arc::new(AtomicUsize::new(0)); + let timeout = Duration::from_millis(limits.socket_timeout_ms); + + loop { + let (stream, peer) = listener.accept().map_err(ServeError::Listen)?; + if !peer_admitted(peer.ip()) { + continue; + } + let Some(permit) = + ConnectionPermit::acquire(active_connections.clone(), limits.max_connections) + else { + continue; + }; + let service = service.clone(); + let backend = backend.clone(); + let max_frame_bytes = limits.max_frame_bytes; + thread::Builder::new() + .name("model-connector-request".to_string()) + .spawn(move || { + let _permit = permit; + let _ = serve_connection( + stream, + &service, + backend.as_ref(), + max_frame_bytes, + timeout, + ); + }) + .map_err(ServeError::Thread)?; + } +} + +fn peer_admitted(peer: IpAddr) -> bool { + peer.is_loopback() +} + +fn serve_connection( + mut stream: TcpStream, + service: &Mutex, + backend: &dyn ProviderBackend, + max_frame_bytes: usize, + timeout: Duration, +) -> Result<(), ServeError> { + stream + .set_read_timeout(Some(timeout)) + .and_then(|()| stream.set_write_timeout(Some(timeout))) + .map_err(ServeError::Connection)?; + let request = read_transport_frame(&mut stream, max_frame_bytes).map_err(frame_error)?; + let response = execute_inference_frame(&request, service, backend)?; + write_transport_frame(&mut stream, &response, max_frame_bytes).map_err(frame_error) +} + +fn execute_inference_frame( + request: &[u8], + service: &Mutex, + backend: &dyn ProviderBackend, +) -> Result, ServeError> { + let envelope: TransportEnvelope = + rmp_serde::from_slice(request).map_err(|_| ServeError::FrameEncoding)?; + let admission = service + .lock() + .map_err(|_| ServeError::ServicePoisoned)? + .begin(&envelope, unix_ms()?)?; + let response = match admission { + TransportAdmission::Reply(response) => response, + TransportAdmission::Execute(claim) => { + let result = backend.execute(claim.invocation()); + service + .lock() + .map_err(|_| ServeError::ServicePoisoned)? + .complete(*claim, result)? + } + }; + rmp_serde::to_vec(&response).map_err(|_| ServeError::FrameEncoding) +} + +fn frame_error(error: TransportFrameError) -> ServeError { + match error { + TransportFrameError::Connection(error) => ServeError::Connection(error), + TransportFrameError::Size => ServeError::FrameSize, + } +} + +fn unix_ms() -> Result { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| ServeError::Clock)? + .as_millis(); + u64::try_from(millis).map_err(|_| ServeError::Clock) +} + +struct ConnectionPermit { + active: Arc, +} + +impl ConnectionPermit { + fn acquire(active: Arc, limit: usize) -> Option { + let mut observed = active.load(Ordering::Acquire); + loop { + if observed >= limit { + return None; + } + match active.compare_exchange_weak( + observed, + observed + 1, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return Some(Self { active }), + Err(current) => observed = current, + } + } + } +} + +impl Drop for ConnectionPermit { + fn drop(&mut self) { + self.active.fetch_sub(1, Ordering::AcqRel); + } +} + +#[derive(Debug, Error)] +pub enum ServeError { + #[error("invalid serve configuration field {0}")] + InvalidConfig(&'static str), + #[error("connector connection key is unavailable")] + ConnectionKey(#[source] std::io::Error), + #[error(transparent)] + Service(#[from] ServiceError), + #[error("provider backend is not ready")] + BackendUnready, + #[error("connector service lock was poisoned")] + ServicePoisoned, + #[error("connector listener failed")] + Listen(#[source] std::io::Error), + #[error("connector request thread failed")] + Thread(#[source] std::io::Error), + #[error("connector connection failed")] + Connection(#[source] std::io::Error), + #[error("connector frame exceeded its bound")] + FrameSize, + #[error("connector frame was not valid MessagePack")] + FrameEncoding, + #[error("system clock is before the Unix epoch")] + Clock, +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + use std::net::Ipv4Addr; + + use super::*; + use crate::client::ConnectorClient; + use crate::contract::{ + Refusal, TransportDisposition, TransportEvent, TransportEventPayload, TransportResult, + }; + use crate::fixtures::{TestStore, caller, invocation_for, receipt, request}; + + const CALLER: &str = "epiphany-yggdrasil"; + const KEY: &str = "epiphany-distinct-test-key"; + + struct CountingBackend { + ready: bool, + ready_calls: AtomicUsize, + executions: AtomicUsize, + } + + impl CountingBackend { + fn new(ready: bool) -> Arc { + Arc::new(Self { + ready, + ready_calls: AtomicUsize::new(0), + executions: AtomicUsize::new(0), + }) + } + } + + impl ProviderBackend for CountingBackend { + fn ready(&self) -> bool { + self.ready_calls.fetch_add(1, Ordering::SeqCst); + self.ready + } + + fn execute(&self, invocation: &TransportInvocation) -> TransportResult { + self.executions.fetch_add(1, Ordering::SeqCst); + TransportResult::transported( + invocation, + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: "done".to_string(), + }, + }], + receipt(invocation), + ) + } + } + + fn limits() -> ServeLimits { + ServeLimits { + max_frame_bytes: 64 * 1024, + max_connections: 8, + socket_timeout_ms: 5_000, + max_expiry_skew_ms: 300_000, + } + } + + /// The test's own clock, so a mutated `unix_ms` cannot move the fixture with it. + fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64 + } + + /// What `serve` returned, or `None` when it is still serving after two seconds. + fn outcome( + listener: TcpListener, + limits: ServeLimits, + admissions: Vec, + path: &Path, + backend: Arc, + ) -> Option> { + let (sender, receiver) = std::sync::mpsc::channel(); + let path = path.to_path_buf(); + thread::spawn(move || { + let _ = sender.send(serve(listener, limits, admissions, &path, backend)); + }); + receiver.recv_timeout(Duration::from_secs(2)).ok() + } + + fn fresh(request_id: &str) -> TransportInvocation { + let mut invocation = invocation_for(CALLER, request_id); + invocation.expires_at_unix_ms = now_ms() + 60_000; + invocation + } + + fn start( + backend: Arc, + store: &TestStore, + ) -> (ConnectorClient, std::net::SocketAddr) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let path = store.path.clone(); + thread::spawn(move || { + serve( + listener, + limits(), + vec![caller(CALLER, KEY, 4)], + &path, + backend, + ) + }); + let client = + ConnectorClient::new(endpoint, KEY, 64 * 1024, Some(Duration::from_secs(5))).unwrap(); + (client, endpoint) + } + + fn callers(capacities: &[usize]) -> Vec { + capacities + .iter() + .enumerate() + .map(|(index, capacity)| CallerConfig { + caller_runtime_id: format!("caller-{index}"), + connection_key_file: PathBuf::from(format!("caller-{index}.key")), + connection_key_epoch: 1, + allowed_models: vec!["gpt-5.6-luna".to_string()], + max_concurrent_requests: *capacity, + max_payload_bytes: 1_048_576, + max_output_tokens: 16_384, + }) + .collect() + } + + #[test] + fn serve_end_to_end_replays_without_a_second_execution() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let (client, _) = start(backend.clone(), &store); + let invocation = fresh("request-1"); + + let first = client.execute(&invocation).unwrap(); + let second = client.execute(&invocation).unwrap(); + + assert!(matches!( + first.disposition, + TransportDisposition::Transported { .. } + )); + assert_eq!(first, second); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + + let mut conflicting_request = request(); + conflicting_request.request_id = "request-1".to_string(); + conflicting_request.instructions = "A different instruction.".to_string(); + let mut conflicting = TransportInvocation::new( + CALLER, + invocation.expires_at_unix_ms, + [7; 32], + conflicting_request, + ) + .unwrap(); + conflicting.expires_at_unix_ms = invocation.expires_at_unix_ms; + let refused = client.execute(&conflicting).unwrap(); + assert_eq!( + refused.disposition, + TransportDisposition::Refused(Refusal::ReplayConflict) + ); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + } + + #[test] + fn serve_replay_is_byte_identical_on_the_wire() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let (_client, endpoint) = start(backend.clone(), &store); + let security = crate::fixtures::security(KEY); + let request = rmp_serde::to_vec( + &crate::envelope::encrypt_invocation(&fresh("request-wire"), &security).unwrap(), + ) + .unwrap(); + let mut replies = Vec::new(); + for _ in 0..2 { + let mut stream = TcpStream::connect(endpoint).unwrap(); + write_transport_frame(&mut stream, &request, 64 * 1024).unwrap(); + replies.push(read_transport_frame(&mut stream, 64 * 1024).unwrap()); + } + assert_eq!(replies[0], replies[1]); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + } + + #[test] + fn serve_refuses_to_start_on_an_unready_backend() { + let store = TestStore::new(); + let backend = CountingBackend::new(false); + let result = outcome( + TcpListener::bind("127.0.0.1:0").unwrap(), + limits(), + vec![caller(CALLER, KEY, 4)], + &store.path, + backend.clone(), + ); + assert!(matches!(result, Some(Err(ServeError::BackendUnready)))); + assert_eq!(backend.executions.load(Ordering::SeqCst), 0); + assert!( + !store.path.exists(), + "an unready backend must not open replay state" + ); + } + + #[test] + fn serve_consults_readiness_at_start_and_never_on_the_request_path() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let (client, _) = start(backend.clone(), &store); + client.execute(&fresh("request-a")).unwrap(); + client.execute(&fresh("request-b")).unwrap(); + assert_eq!(backend.executions.load(Ordering::SeqCst), 2); + assert_eq!(backend.ready_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn serve_refuses_a_listener_that_is_not_loopback() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let result = outcome( + TcpListener::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap(), + limits(), + vec![caller(CALLER, KEY, 4)], + &store.path, + backend.clone(), + ); + assert!(matches!( + result, + Some(Err(ServeError::InvalidConfig("listener address"))) + )); + assert_eq!(backend.ready_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn serve_refuses_unbounded_limits_and_admits_the_widest_frame() { + let store = TestStore::new(); + let mutations: [fn(&mut ServeLimits); 5] = [ + |limits| limits.max_frame_bytes = 0, + |limits| limits.max_frame_bytes = u32::MAX as usize + 1, + |limits| limits.max_connections = 0, + |limits| limits.socket_timeout_ms = 0, + |limits| limits.max_expiry_skew_ms = 0, + ]; + for mutate in mutations { + let mut bounded = limits(); + mutate(&mut bounded); + let result = outcome( + TcpListener::bind("127.0.0.1:0").unwrap(), + bounded, + vec![caller(CALLER, KEY, 4)], + &store.path, + CountingBackend::new(true), + ); + assert!(matches!( + result, + Some(Err(ServeError::InvalidConfig("serve limits"))) + )); + } + let widest = ServeLimits { + max_frame_bytes: u32::MAX as usize, + ..limits() + }; + let result = outcome( + TcpListener::bind("127.0.0.1:0").unwrap(), + widest, + vec![caller(CALLER, KEY, 4)], + &store.path, + CountingBackend::new(true), + ); + assert!(result.is_none(), "the widest frame the wire can carry must serve"); + } + + #[test] + fn serve_refuses_an_empty_caller_set() { + let store = TestStore::new(); + let result = outcome( + TcpListener::bind("127.0.0.1:0").unwrap(), + limits(), + Vec::new(), + &store.path, + CountingBackend::new(true), + ); + assert!(matches!( + result, + Some(Err(ServeError::InvalidConfig("callers"))) + )); + } + + #[test] + fn only_loopback_peers_are_admitted() { + assert!(peer_admitted(IpAddr::from([127, 0, 0, 1]))); + assert!(peer_admitted(IpAddr::from(std::net::Ipv6Addr::LOCALHOST))); + assert!(!peer_admitted(IpAddr::from([192, 168, 1, 7]))); + assert!(!peer_admitted(IpAddr::from([172, 19, 0, 1]))); + } + + #[test] + fn connection_permits_are_bounded_and_returned_on_drop() { + let active = Arc::new(AtomicUsize::new(0)); + let first = ConnectionPermit::acquire(active.clone(), 2).unwrap(); + let second = ConnectionPermit::acquire(active.clone(), 2).unwrap(); + assert!(ConnectionPermit::acquire(active.clone(), 2).is_none()); + drop(first); + let third = ConnectionPermit::acquire(active.clone(), 2).unwrap(); + assert!(ConnectionPermit::acquire(active.clone(), 2).is_none()); + drop((second, third)); + assert_eq!(active.load(Ordering::SeqCst), 0); + } + + #[test] + fn caller_admission_reserves_every_independently_admitted_request_slot() { + let both = callers(&[8, 8]); + assert_eq!(caller_request_capacity(&both).unwrap(), 16); + assert_eq!(required_connection_capacity(&both).unwrap(), 16); + assert_eq!(required_connection_capacity(&callers(&[3])).unwrap(), 8); + assert!(matches!( + caller_request_capacity(&callers(&[usize::MAX, 1])), + Err(ServeError::InvalidConfig("connection capacity")) + )); + } + + #[test] + fn provider_capacity_is_caller_execution_capacity_not_listener_capacity() { + let callers = callers(&[4]); + assert_eq!(caller_request_capacity(&callers).unwrap(), 4); + assert_eq!(required_connection_capacity(&callers).unwrap(), 8); + } + + #[test] + fn direct_pipe_frame_is_big_endian_and_refuses_oversize_before_allocation() { + let mut encoded = Vec::new(); + write_transport_frame(&mut encoded, b"typed", 16).unwrap(); + assert_eq!(&encoded[..4], &5_u32.to_be_bytes()); + assert_eq!( + read_transport_frame(&mut Cursor::new(encoded), 16).unwrap(), + b"typed" + ); + + let oversized = 17_u32.to_be_bytes().to_vec(); + assert!(matches!( + read_transport_frame(&mut Cursor::new(oversized), 16), + Err(TransportFrameError::Size) + )); + } + + #[test] + fn load_admissions_reads_the_key_file_without_its_trailing_newline() { + let store = TestStore::new(); + let key_file = store.root.join("caller.key"); + fs::write(&key_file, format!("{KEY}\r\n")).unwrap(); + let mut config = callers(&[4]); + config[0].caller_runtime_id = CALLER.to_string(); + config[0].connection_key_file = key_file; + config[0].allowed_models = vec!["gpt-5.4".to_string()]; + config[0].max_payload_bytes = 64 * 1024; + let admissions = load_admissions(&config).unwrap(); + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let backend = CountingBackend::new(true); + let path = store.path.clone(); + let served = backend.clone(); + thread::spawn(move || serve(listener, limits(), admissions, &path, served)); + let client = + ConnectorClient::new(endpoint, KEY, 64 * 1024, Some(Duration::from_secs(5))).unwrap(); + client.execute(&fresh("request-key")).unwrap(); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + } +} diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs new file mode 100644 index 0000000..1413537 --- /dev/null +++ b/crates/model-connector/src/service.rs @@ -0,0 +1,788 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use cultcache_rs::{CultCache, DatabaseEntry, OwnedRedbMessagePackBackingStore}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use crate::contract::{ + ContractError, Refusal, TransportDisposition, TransportInvocation, TransportKey, + TransportResult, canonical_invocation_bytes, require_id, +}; +use crate::envelope::{ + EnvelopeKind, ServiceError, TransportEnvelope, decrypt_invocation, encrypt_result, + validate_envelope, +}; + +pub struct CallerAdmission { + caller_runtime_id: String, + connection_key_epoch: u32, + security: TransportKey, + allowed_models: HashSet, + max_concurrent_requests: usize, + max_payload_bytes: usize, + max_output_tokens: u32, +} + +impl CallerAdmission { + pub fn new( + caller_runtime_id: impl Into, + connection_key: impl Into, + connection_key_epoch: u32, + allowed_models: impl IntoIterator, + max_concurrent_requests: usize, + max_payload_bytes: usize, + max_output_tokens: u32, + ) -> Result { + let caller_runtime_id = caller_runtime_id.into(); + require_id(&caller_runtime_id, "caller_runtime_id")?; + let allowed_models = allowed_models.into_iter().collect::>(); + if connection_key_epoch == 0 + || allowed_models.is_empty() + || allowed_models + .iter() + .any(|model| require_id(model, "allowed_model").is_err()) + || max_concurrent_requests == 0 + || max_payload_bytes == 0 + || max_output_tokens == 0 + { + return Err(ServiceError::InvalidAdmission); + } + let connection_key = Zeroizing::new(connection_key.into()); + let security = TransportKey::from_connection_secret(connection_key.as_str())?; + Ok(Self { + caller_runtime_id, + connection_key_epoch, + security, + allowed_models, + max_concurrent_requests, + max_payload_bytes, + max_output_tokens, + }) + } + + pub fn caller_runtime_id(&self) -> &str { + &self.caller_runtime_id + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct RequestIdentity { + caller_runtime_id: String, + request_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +enum ReplayState { + Active, + Completed { response: TransportEnvelope }, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "gamecult.codex.replay_record.v1")] +struct ReplayRecord { + #[cultcache(key = 0)] + caller_runtime_id: String, + #[cultcache(key = 1)] + request_id: String, + #[cultcache(key = 2)] + connection_key_epoch: u32, + #[cultcache(key = 3)] + invocation_sha256: [u8; 32], + #[cultcache(key = 4)] + expires_at_unix_ms: u64, + #[cultcache(key = 5)] + state: ReplayState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExecutionClaim { + invocation: TransportInvocation, + invocation_sha256: [u8; 32], +} + +impl ExecutionClaim { + pub fn invocation(&self) -> &TransportInvocation { + &self.invocation + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TransportAdmission { + Execute(Box), + Reply(TransportEnvelope), +} + +pub struct TransportService { + callers: HashMap, + live: HashSet, + replay_store: CultCache, + max_expiry_skew_ms: u64, +} + +impl TransportService { + pub fn open( + replay_store_path: &Path, + admissions: impl IntoIterator, + max_expiry_skew_ms: u64, + ) -> Result { + if max_expiry_skew_ms == 0 { + return Err(ServiceError::InvalidAdmission); + } + let mut callers = HashMap::new(); + let mut keys = HashSet::new(); + for admission in admissions { + if !keys.insert(admission.security.0) { + return Err(ServiceError::SharedCallerKey); + } + let caller_runtime_id = admission.caller_runtime_id.clone(); + if callers.insert(caller_runtime_id, admission).is_some() { + return Err(ServiceError::DuplicateCaller); + } + } + if callers.is_empty() { + return Err(ServiceError::InvalidAdmission); + } + + let mut replay_store = CultCache::new(); + replay_store + .register_entry_type::() + .map_err(replay_store_error)?; + replay_store.add_generic_backing_store( + OwnedRedbMessagePackBackingStore::new(replay_store_path).map_err(replay_store_error)?, + ); + replay_store + .pull_all_backing_stores() + .map_err(replay_store_error)?; + + for (key, record) in replay_store + .get_all_with_keys::() + .map_err(replay_store_error)? + { + let identity = RequestIdentity { + caller_runtime_id: record.caller_runtime_id.clone(), + request_id: record.request_id.clone(), + }; + if key != replay_key(&identity) + || record.invocation_sha256 == [0; 32] + || record.expires_at_unix_ms == 0 + { + return Err(ServiceError::InvalidReplayRecord); + } + let Some(caller) = callers.get(&record.caller_runtime_id) else { + continue; + }; + if record.connection_key_epoch != caller.connection_key_epoch { + return Err(ServiceError::ReplayCallerKeyMismatch); + } + } + Ok(Self { + callers, + live: HashSet::new(), + replay_store, + max_expiry_skew_ms, + }) + } + + pub fn begin( + &mut self, + envelope: &TransportEnvelope, + now_unix_ms: u64, + ) -> Result { + validate_envelope(envelope, EnvelopeKind::Invocation)?; + let caller = self + .callers + .get(&envelope.caller_runtime_id) + .ok_or(ServiceError::CallerNotAdmitted)?; + if envelope.ciphertext.len() > caller.max_payload_bytes { + return Err(ServiceError::PayloadTooLarge); + } + let security = caller.security.clone(); + let max_concurrent_requests = caller.max_concurrent_requests; + let invocation = decrypt_invocation(envelope, &security)?; + if let Err(error) = invocation.validate(now_unix_ms, self.max_expiry_skew_ms) { + return self.reply_refusal(&invocation, contract_refusal(&error), &security); + } + if !caller.allowed_models.contains(&invocation.request.model) + || invocation + .request + .max_output_tokens + .is_some_and(|limit| limit > caller.max_output_tokens) + { + return self.reply_refusal(&invocation, Refusal::Policy, &security); + } + + let identity = RequestIdentity { + caller_runtime_id: invocation.caller_runtime_id.clone(), + request_id: invocation.request_id().to_string(), + }; + let invocation_sha256: [u8; 32] = + Sha256::digest(canonical_invocation_bytes(&invocation)?).into(); + if let Some(record) = self + .replay_store + .get::(&replay_key(&identity)) + .map_err(replay_store_error)? + { + if record.invocation_sha256 != invocation_sha256 { + return self.reply_refusal(&invocation, Refusal::ReplayConflict, &security); + } + return match record.state { + ReplayState::Completed { response } => { + Ok(TransportAdmission::Reply(response)) + } + ReplayState::Active => { + let refusal = if self.live.contains(&identity) { + Refusal::InFlight + } else { + Refusal::Indeterminate + }; + self.reply_refusal(&invocation, refusal, &security) + } + }; + } + let active_for_caller = self + .live + .iter() + .filter(|key| key.caller_runtime_id == invocation.caller_runtime_id) + .count(); + if active_for_caller >= max_concurrent_requests { + return self.reply_refusal(&invocation, Refusal::Capacity, &security); + } + + let replay_record = ReplayRecord { + caller_runtime_id: identity.caller_runtime_id.clone(), + request_id: identity.request_id.clone(), + connection_key_epoch: caller.connection_key_epoch, + invocation_sha256, + expires_at_unix_ms: invocation.expires_at_unix_ms, + state: ReplayState::Active, + }; + self.replay_store + .put(replay_key(&identity), &replay_record) + .map_err(replay_store_error)?; + self.live.insert(identity); + Ok(TransportAdmission::Execute(Box::new( + ExecutionClaim { + invocation, + invocation_sha256, + }, + ))) + } + + pub fn complete( + &mut self, + claim: ExecutionClaim, + result: TransportResult, + ) -> Result { + if !matches!( + &result.disposition, + TransportDisposition::Transported { .. } + ) { + return Err(ServiceError::InvalidCompletion); + } + result.validate_against(&claim.invocation)?; + let identity = RequestIdentity { + caller_runtime_id: claim.invocation.caller_runtime_id.clone(), + request_id: claim.invocation.request_id().to_string(), + }; + if !self.live.contains(&identity) { + return Err(ServiceError::NoActiveRequest); + } + let active = self + .replay_store + .get::(&replay_key(&identity)) + .map_err(replay_store_error)? + .ok_or(ServiceError::NoActiveRequest)?; + if active.invocation_sha256 != claim.invocation_sha256 + || !matches!(active.state, ReplayState::Active) + { + return Err(ServiceError::ActiveRequestMismatch); + } + let caller = self + .callers + .get(&identity.caller_runtime_id) + .ok_or(ServiceError::CallerNotAdmitted)?; + let response = encrypt_result(&result, &caller.security)?; + let replay_record = ReplayRecord { + caller_runtime_id: identity.caller_runtime_id.clone(), + request_id: identity.request_id.clone(), + connection_key_epoch: caller.connection_key_epoch, + invocation_sha256: claim.invocation_sha256, + expires_at_unix_ms: claim.invocation.expires_at_unix_ms, + state: ReplayState::Completed { + response: response.clone(), + }, + }; + self.replay_store + .put(replay_key(&identity), &replay_record) + .map_err(replay_store_error)?; + self.live.remove(&identity); + Ok(response) + } + + fn reply_refusal( + &self, + invocation: &TransportInvocation, + refusal: Refusal, + security: &TransportKey, + ) -> Result { + Ok(TransportAdmission::Reply(encrypt_result( + &TransportResult::refused(invocation, refusal), + security, + )?)) + } +} + +fn replay_key(identity: &RequestIdentity) -> String { + let mut hasher = Sha256::new(); + hasher.update((identity.caller_runtime_id.len() as u64).to_be_bytes()); + hasher.update(identity.caller_runtime_id.as_bytes()); + hasher.update((identity.request_id.len() as u64).to_be_bytes()); + hasher.update(identity.request_id.as_bytes()); + const HEX: &[u8; 16] = b"0123456789abcdef"; + let digest = hasher.finalize(); + let mut key = String::with_capacity(64); + for byte in digest { + key.push(HEX[(byte >> 4) as usize] as char); + key.push(HEX[(byte & 0x0f) as usize] as char); + } + key +} + +fn replay_store_error(error: impl std::fmt::Display) -> ServiceError { + ServiceError::ReplayStore(error.to_string()) +} + +fn contract_refusal(error: &ContractError) -> Refusal { + match error { + ContractError::Expiry => Refusal::Expired, + ContractError::Identity => Refusal::IdentitySubstitution, + ContractError::ProviderDigest => Refusal::ProviderDigestSubstitution, + _ => Refusal::Malformed, + } +} + + +#[cfg(all(test, feature = "daemon"))] +mod tests { + use super::*; + use crate::fixtures::*; + use crate::{contract::*, envelope::*}; + + #[test] + fn service_refuses_shared_keys_and_isolates_caller_request_identities() { + let store = TestStore::new(); + assert!(matches!( + TransportService::open( + &store.path, + [ + caller("epiphany-yggdrasil", "shared-key", 1), + caller("ghostlight-yggdrasil", "shared-key", 1), + ], + 5_000, + ), + Err(ServiceError::SharedCallerKey) + )); + + let epiphany = invocation_for("epiphany-yggdrasil", "shared-request-id"); + let ghostlight = invocation_for("ghostlight-yggdrasil", "shared-request-id"); + let mut service = open_service(&store, 1); + assert!(matches!( + service + .begin( + &encrypt_invocation(&epiphany, &security("epiphany-distinct-test-key")) + .unwrap(), + 1_000, + ) + .unwrap(), + TransportAdmission::Execute(_) + )); + assert!(matches!( + service + .begin( + &encrypt_invocation(&ghostlight, &security("ghostlight-distinct-test-key")) + .unwrap(), + 1_000, + ) + .unwrap(), + TransportAdmission::Execute(_) + )); + } + + #[test] + fn service_returns_exact_completed_replay_and_refuses_conflicting_replay() { + let store = TestStore::new(); + let invocation = invocation(); + let security = security("epiphany-distinct-test-key"); + let mut service = open_service(&store, 1); + let claim = match service + .begin(&encrypt_invocation(&invocation, &security).unwrap(), 1_000) + .unwrap() + { + TransportAdmission::Execute(claim) => claim, + TransportAdmission::Reply(_) => panic!("first invocation must execute"), + }; + let response = service + .complete( + *claim, + TransportResult::transported( + &invocation, + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + }], + receipt(&invocation), + ), + ) + .unwrap(); + drop(service); + let mut service = open_service(&store, 1); + + let replay = match service + .begin(&encrypt_invocation(&invocation, &security).unwrap(), 1_000) + .unwrap() + { + TransportAdmission::Reply(response) => response, + TransportAdmission::Execute(_) => panic!("completed invocation re-executed"), + }; + assert_eq!(replay, response); + let decoded = decrypt_result(&replay, &security, &invocation).unwrap(); + assert!(matches!( + decoded.disposition, + TransportDisposition::Transported { .. } + )); + + let mut changed_request = request(); + changed_request.instructions.push_str(" Different cargo."); + let conflicting = + TransportInvocation::new("epiphany-yggdrasil", 2_000, [7; 32], changed_request) + .unwrap(); + let refusal = match service + .begin(&encrypt_invocation(&conflicting, &security).unwrap(), 1_000) + .unwrap() + { + TransportAdmission::Reply(response) => response, + TransportAdmission::Execute(_) => panic!("conflicting replay executed"), + }; + assert!(matches!( + decrypt_result(&refusal, &security, &conflicting) + .unwrap() + .disposition, + TransportDisposition::Refused(Refusal::ReplayConflict) + )); + } + + #[test] + fn service_refuses_duplicate_inflight_and_per_caller_capacity() { + let store = TestStore::new(); + let first = invocation_for("epiphany-yggdrasil", "request-1"); + let second = invocation_for("epiphany-yggdrasil", "request-2"); + let security = security("epiphany-distinct-test-key"); + let mut service = open_service(&store, 1); + let first_envelope = encrypt_invocation(&first, &security).unwrap(); + assert!(matches!( + service.begin(&first_envelope, 1_000).unwrap(), + TransportAdmission::Execute(_) + )); + + for (invocation, expected) in [ + (&first, Refusal::InFlight), + (&second, Refusal::Capacity), + ] { + let response = match service + .begin(&encrypt_invocation(invocation, &security).unwrap(), 1_000) + .unwrap() + { + TransportAdmission::Reply(response) => response, + TransportAdmission::Execute(_) => panic!("refused invocation executed"), + }; + assert_eq!( + decrypt_result(&response, &security, invocation) + .unwrap() + .disposition, + TransportDisposition::Refused(expected) + ); + } + } + + #[test] + fn restart_refuses_ambiguous_claim_without_consuming_live_capacity() { + let store = TestStore::new(); + let first = invocation_for("epiphany-yggdrasil", "request-1"); + let second = invocation_for("epiphany-yggdrasil", "request-2"); + let security = security("epiphany-distinct-test-key"); + let mut service = open_service(&store, 1); + assert!(matches!( + service + .begin(&encrypt_invocation(&first, &security).unwrap(), 1_000) + .unwrap(), + TransportAdmission::Execute(_) + )); + drop(service); + + let mut restarted = open_service(&store, 1); + let ambiguous = match restarted + .begin(&encrypt_invocation(&first, &security).unwrap(), 1_000) + .unwrap() + { + TransportAdmission::Reply(response) => response, + TransportAdmission::Execute(_) => panic!("ambiguous claim re-executed"), + }; + assert_eq!( + decrypt_result(&ambiguous, &security, &first) + .unwrap() + .disposition, + TransportDisposition::Refused(Refusal::Indeterminate) + ); + assert!(matches!( + restarted + .begin(&encrypt_invocation(&second, &security).unwrap(), 1_000) + .unwrap(), + TransportAdmission::Execute(_) + )); + drop(restarted); + assert!(matches!( + TransportService::open( + &store.path, + [CallerAdmission::new( + "epiphany-yggdrasil", + "rotated-connection-key", + 2, + ["gpt-5.4".to_string()], + 1, + 64 * 1024, + 32_768, + ) + .unwrap()], + 5_000, + ), + Err(ServiceError::ReplayCallerKeyMismatch) + )); + } + + const CALLER: &str = "epiphany-yggdrasil"; + const KEY: &str = "epiphany-distinct-test-key"; + + fn run( + service: &mut TransportService, + request_id: &str, + edit: impl FnOnce(&mut ProviderRequest), + ) -> (TransportAdmission, TransportInvocation) { + let mut request = invocation_for(CALLER, request_id).request; + edit(&mut request); + let invocation = TransportInvocation::new(CALLER, 2_000, [7; 32], request).unwrap(); + let envelope = encrypt_invocation(&invocation, &security(KEY)).unwrap(); + (service.begin(&envelope, 1_000).unwrap(), invocation) + } + + fn refusal_of(admission: TransportAdmission, invocation: &TransportInvocation) -> Refusal { + let TransportAdmission::Reply(envelope) = admission else { + panic!("expected a refusal reply"); + }; + match decrypt_result(&envelope, &security(KEY), invocation) + .unwrap() + .disposition + { + TransportDisposition::Refused(refusal) => refusal, + TransportDisposition::Transported { .. } => panic!("expected a refusal"), + } + } + + fn raw_envelope(invocation: &TransportInvocation) -> TransportEnvelope { + encrypt_message( + &invocation.caller_runtime_id, + invocation.request_id(), + EnvelopeKind::Invocation, + &rmp_serde::to_vec(invocation).unwrap(), + &security(KEY), + ) + .unwrap() + } + + #[test] + fn caller_admission_reports_its_runtime_id() { + assert_eq!( + caller("ghostlight-yggdrasil", "some-key", 1).caller_runtime_id(), + "ghostlight-yggdrasil" + ); + } + + #[test] + fn policy_refuses_unlisted_models_and_output_tokens_over_the_caller_cap() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + + let (admission, invocation) = run(&mut service, "unlisted-model", |request| { + request.model = "other-model".to_string(); + }); + assert_eq!(refusal_of(admission, &invocation), Refusal::Policy); + + let (admission, invocation) = run(&mut service, "over-cap", |request| { + request.max_output_tokens = Some(32_769); + }); + assert_eq!(refusal_of(admission, &invocation), Refusal::Policy); + + for (id, tokens) in [ + ("at-cap", Some(32_768)), + ("under-cap", Some(100)), + ("unset", None), + ] { + let (admission, _) = run(&mut service, id, |request| { + request.max_output_tokens = tokens; + }); + assert!(matches!(admission, TransportAdmission::Execute(_)), "{id}"); + } + } + + #[test] + fn payload_bound_is_inclusive_of_the_encrypted_size() { + let invocation = invocation_for(CALLER, "sized"); + let envelope = encrypt_invocation(&invocation, &security(KEY)).unwrap(); + let size = envelope.ciphertext.len(); + let admit = |max_payload_bytes: usize| { + let store = TestStore::new(); + let admission = CallerAdmission::new( + CALLER, + KEY, + 1, + ["gpt-5.4".to_string()], + 4, + max_payload_bytes, + 32_768, + ) + .unwrap(); + TransportService::open(&store.path, [admission], 5_000) + .unwrap() + .begin(&envelope, 1_000) + }; + assert!(matches!(admit(size), Ok(TransportAdmission::Execute(_)))); + assert_eq!(admit(size - 1).unwrap_err(), ServiceError::PayloadTooLarge); + } + + #[test] + fn contract_failures_are_refused_with_the_matching_refusal() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + + let mut expired = invocation_for(CALLER, "expired"); + expired.expires_at_unix_ms = 10; + let reply = service.begin(&raw_envelope(&expired), 1_000).unwrap(); + assert_eq!(refusal_of(reply, &expired), Refusal::Expired); + + let mut substituted = invocation_for(CALLER, "substituted"); + substituted.provider_request_sha256 = [9; 32]; + let reply = service.begin(&raw_envelope(&substituted), 1_000).unwrap(); + assert_eq!( + refusal_of(reply, &substituted), + Refusal::ProviderDigestSubstitution + ); + + let mut malformed = invocation_for(CALLER, "malformed"); + malformed.native_request_sha256 = [0; 32]; + let reply = service.begin(&raw_envelope(&malformed), 1_000).unwrap(); + assert_eq!(refusal_of(reply, &malformed), Refusal::Malformed); + } + + fn seed(path: &Path, key: &str, edit: impl FnOnce(&mut ReplayRecord)) { + let mut record = ReplayRecord { + caller_runtime_id: CALLER.to_string(), + request_id: "seeded".to_string(), + connection_key_epoch: 1, + invocation_sha256: [1; 32], + expires_at_unix_ms: 5_000, + state: ReplayState::Active, + }; + edit(&mut record); + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.add_generic_backing_store(OwnedRedbMessagePackBackingStore::new(path).unwrap()); + cache.pull_all_backing_stores().unwrap(); + cache.put(key.to_string(), &record).unwrap(); + } + + fn seeded_key() -> String { + replay_key(&RequestIdentity { + caller_runtime_id: CALLER.to_string(), + request_id: "seeded".to_string(), + }) + } + + #[test] + fn restart_refuses_each_malformed_replay_record() { + let control = TestStore::new(); + seed(&control.path, &seeded_key(), |_| {}); + assert!(TransportService::open(&control.path, [caller(CALLER, KEY, 1)], 5_000).is_ok()); + + let cases: [(&str, fn(&mut ReplayRecord)); 3] = [ + ("wrong-key", |_| {}), + ("zero-digest", |record| record.invocation_sha256 = [0; 32]), + ("zero-expiry", |record| record.expires_at_unix_ms = 0), + ]; + for (name, edit) in cases { + let store = TestStore::new(); + let key = if name == "wrong-key" { + "not-the-replay-key".to_string() + } else { + seeded_key() + }; + seed(&store.path, &key, edit); + assert!( + matches!( + TransportService::open(&store.path, [caller(CALLER, KEY, 1)], 5_000), + Err(ServiceError::InvalidReplayRecord) + ), + "{name}" + ); + } + } + + #[test] + fn completion_refuses_a_claim_that_is_not_the_active_request() { + let store = TestStore::new(); + let mut service = open_service(&store, 4); + let (admission, invocation) = run(&mut service, "request-1", |_| {}); + let TransportAdmission::Execute(claim) = admission else { + panic!("first invocation must execute"); + }; + let result = TransportResult::transported( + claim.invocation(), + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: "done".to_string(), + }, + }], + receipt(claim.invocation()), + ); + + let mut forged = (*claim).clone(); + forged.invocation_sha256 = [1; 32]; + assert_eq!( + service.complete(forged, result.clone()).unwrap_err(), + ServiceError::ActiveRequestMismatch + ); + + let key = replay_key(&RequestIdentity { + caller_runtime_id: CALLER.to_string(), + request_id: invocation.request_id().to_string(), + }); + let mut record = service + .replay_store + .get::(&key) + .unwrap() + .unwrap(); + record.state = ReplayState::Completed { + response: encrypt_result(&result, &security(KEY)).unwrap(), + }; + service.replay_store.put(key, &record).unwrap(); + assert_eq!( + service.complete(*claim, result).unwrap_err(), + ServiceError::ActiveRequestMismatch + ); + } +} From 76f1c41cff09799563fd7ce8e5623d0cf37b6260 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 04:41:40 +0100 Subject: [PATCH 03/22] Add the v2 wire parity test against CodexConnector fd7cec4 A kit-encrypted invocation and result decrypt and validate under codex-connector fd7cec4 and the reverse; canonical invocation bytes, canonical provider request bytes, the provider digest and the five schema ids are equal. The dev-dependency and this test are deleted by connector-contract-v3, which changes the wire. Co-Authored-By: Claude Opus 5.5 --- .../model-connector/tests/wire_parity_v2.rs | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 crates/model-connector/tests/wire_parity_v2.rs diff --git a/crates/model-connector/tests/wire_parity_v2.rs b/crates/model-connector/tests/wire_parity_v2.rs new file mode 100644 index 0000000..fa2d3ad --- /dev/null +++ b/crates/model-connector/tests/wire_parity_v2.rs @@ -0,0 +1,242 @@ +//! The lift is wire-identical to CodexConnector fd7cec4, the v2 library +//! Ghostlight pins. Deleted by cut connector-contract-v3, which changes the wire. + +use codex_connector as v2; +use model_connector as kit; +use serde::Serialize; +use serde::de::DeserializeOwned; + +const KEY: &str = "parity-distinct-test-key"; +const CALLER: &str = "parity-caller"; + +/// Moves a value across the two crates by its MessagePack bytes, the shape the wire carries. +fn cross(value: &T) -> U { + rmp_serde::from_slice(&rmp_serde::to_vec(value).unwrap()).unwrap() +} + +fn bytes(value: &T) -> Vec { + rmp_serde::to_vec(value).unwrap() +} + +fn kit_request() -> kit::ProviderRequest { + let mut request = + kit::ProviderRequest::new("request-1", "conversation-1", "gpt-5.4", "Be exact."); + request.input = vec![ + kit::InputItem::UserText { + text: "state".to_string(), + }, + kit::InputItem::AssistantText { + text: "reply".to_string(), + }, + kit::InputItem::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + kit::InputItem::ToolResult { + call_id: "call-1".to_string(), + output: "ok".to_string(), + }, + ]; + request.tools = vec![kit::ToolDefinition { + name: "read_source".to_string(), + description: "Read one admitted source.".to_string(), + parameters_json: r#"{"type":"object"}"#.to_string(), + }]; + request.tool_choice = kit::ToolChoice::Required; + request.reasoning_effort = Some("high".to_string()); + request.parallel_tool_calls = true; + request.max_output_tokens = Some(512); + request.prompt_cache_key = Some("cache-1".to_string()); + request +} + +fn v2_request() -> v2::CodexProviderRequest { + let mut request = + v2::CodexProviderRequest::new("request-1", "conversation-1", "gpt-5.4", "Be exact."); + request.input = vec![ + v2::CodexInputItem::UserText { + text: "state".to_string(), + }, + v2::CodexInputItem::AssistantText { + text: "reply".to_string(), + }, + v2::CodexInputItem::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + v2::CodexInputItem::ToolResult { + call_id: "call-1".to_string(), + output: "ok".to_string(), + }, + ]; + request.tools = vec![v2::CodexToolDefinition { + name: "read_source".to_string(), + description: "Read one admitted source.".to_string(), + parameters_json: r#"{"type":"object"}"#.to_string(), + }]; + request.tool_choice = v2::CodexToolChoice::Required; + request.reasoning_effort = Some("high".to_string()); + request.parallel_tool_calls = true; + request.max_output_tokens = Some(512); + request.prompt_cache_key = Some("cache-1".to_string()); + request +} + +fn kit_invocation() -> kit::TransportInvocation { + kit::TransportInvocation::new(CALLER, 2_000, [7; 32], kit_request()).unwrap() +} + +fn v2_invocation() -> v2::CodexTransportInvocation { + v2::CodexTransportInvocation::new(CALLER, 2_000, [7; 32], v2_request()).unwrap() +} + +fn kit_result(invocation: &kit::TransportInvocation) -> kit::TransportResult { + let receipt = kit::TransportReceipt { + schema_id: kit::RECEIPT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + native_request_sha256: invocation.native_request_sha256, + provider_request_sha256: invocation.provider_request_sha256, + model: invocation.request.model.clone(), + transport: "parity-sse".to_string(), + outcome: kit::TransportOutcome::Completed { + provider_response_id: Some("response-1".to_string()), + input_tokens: Some(10), + output_tokens: Some(3), + reasoning_output_tokens: Some(1), + cached_input_tokens: None, + }, + }; + kit::TransportResult::transported( + invocation, + vec![ + kit::TransportEvent { + sequence: 0, + payload: kit::TransportEventPayload::TextDelta { + text: "hello".to_string(), + }, + }, + kit::TransportEvent { + sequence: 1, + payload: kit::TransportEventPayload::ToolCall { + call_id: "call-2".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + }, + ], + receipt, + ) +} + +fn v2_result(invocation: &v2::CodexTransportInvocation) -> v2::CodexTransportResult { + let receipt = v2::CodexTransportReceipt { + schema_id: v2::RECEIPT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + native_request_sha256: invocation.native_request_sha256, + provider_request_sha256: invocation.provider_request_sha256, + model: invocation.request.model.clone(), + transport: "parity-sse".to_string(), + outcome: v2::CodexTransportOutcome::Completed { + provider_response_id: Some("response-1".to_string()), + input_tokens: Some(10), + output_tokens: Some(3), + reasoning_output_tokens: Some(1), + cached_input_tokens: None, + }, + }; + v2::CodexTransportResult::transported( + invocation, + vec![ + v2::CodexTransportEvent { + sequence: 0, + payload: v2::CodexTransportEventPayload::TextDelta { + text: "hello".to_string(), + }, + }, + v2::CodexTransportEvent { + sequence: 1, + payload: v2::CodexTransportEventPayload::ToolCall { + call_id: "call-2".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + }, + ], + receipt, + ) +} + +fn kit_key() -> kit::TransportKey { + kit::TransportKey::from_connection_secret(KEY).unwrap() +} + +fn v2_key() -> v2::CodexTransportKey { + v2::CodexTransportKey::from_connection_secret(KEY).unwrap() +} + +#[test] +fn schema_ids_are_the_v2_ids() { + assert_eq!( + kit::PROVIDER_REQUEST_SCHEMA_ID, + v2::PROVIDER_REQUEST_SCHEMA_ID + ); + assert_eq!(kit::INVOCATION_SCHEMA_ID, v2::INVOCATION_SCHEMA_ID); + assert_eq!(kit::RESULT_SCHEMA_ID, v2::RESULT_SCHEMA_ID); + assert_eq!(kit::RECEIPT_SCHEMA_ID, v2::RECEIPT_SCHEMA_ID); + assert_eq!(kit::ENVELOPE_SCHEMA_ID, v2::ENVELOPE_SCHEMA_ID); +} + +#[test] +fn canonical_bytes_and_provider_digest_match_v2() { + let from_kit = kit_invocation(); + let from_v2 = v2_invocation(); + assert_eq!(bytes(&from_kit), bytes(&from_v2)); + assert_eq!( + kit::canonical_invocation_bytes(&from_kit).unwrap(), + v2::canonical_invocation_bytes(&from_v2).unwrap() + ); + assert_eq!( + kit::canonical_provider_request_bytes(&from_kit.request).unwrap(), + v2::canonical_provider_request_bytes(&from_v2.request).unwrap() + ); + assert_eq!( + kit::provider_request_sha256(&from_kit.request).unwrap(), + v2::provider_request_sha256(&from_v2.request).unwrap() + ); +} + +#[test] +fn kit_invocation_decrypts_and_validates_under_v2() { + let sent = kit_invocation(); + let envelope = kit::encrypt_invocation(&sent, &kit_key()).unwrap(); + let received = v2::decrypt_invocation(&cross(&envelope), &v2_key()).unwrap(); + assert_eq!(bytes(&received), bytes(&sent)); +} + +#[test] +fn v2_invocation_decrypts_and_validates_under_kit() { + let sent = v2_invocation(); + let envelope = v2::encrypt_invocation(&sent, &v2_key()).unwrap(); + let received = kit::decrypt_invocation(&cross(&envelope), &kit_key()).unwrap(); + assert_eq!(bytes(&received), bytes(&sent)); +} + +#[test] +fn kit_result_decrypts_and_validates_under_v2() { + let sent = kit_result(&kit_invocation()); + let envelope = kit::encrypt_result(&sent, &kit_key()).unwrap(); + let received = v2::decrypt_result(&cross(&envelope), &v2_key(), &v2_invocation()).unwrap(); + assert_eq!(bytes(&received), bytes(&sent)); +} + +#[test] +fn v2_result_decrypts_and_validates_under_kit() { + let sent = v2_result(&v2_invocation()); + let envelope = v2::encrypt_result(&sent, &v2_key()).unwrap(); + let received = kit::decrypt_result(&cross(&envelope), &kit_key(), &kit_invocation()).unwrap(); + assert_eq!(bytes(&received), bytes(&sent)); +} From 2e6aa9363f6db57c486d949baf566172fa35ddf8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 04:41:40 +0100 Subject: [PATCH 04/22] README front door for the live model-connector crate and its provenance Entry points, verify commands, and the AGPL-3.0-only relicense record: lifted from CodexConnector fd7cec4, formerly PolyForm Noncommercial. Co-Authored-By: Claude Opus 5.5 --- README.md | 52 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 65a124e..1155500 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,48 @@ # ModelConnector -Status: planned. Nothing is implemented in this repository yet. +The connector kit for GameCult minds: one typed, encrypted, replay-safe contract +between a caller that needs a model turn and a daemon that holds the provider +credential, so no project wires its own provider calls. -## What it will hold +Status: the `model-connector` crate is live. No connector binary exists yet +(the Claude connector is first, then an OpenAI-compatible one). -The connector kit for GameCult minds: +## Entry points -- the connector contract, a Rust crate defining the typed boundary every model - turn of a GameCult mind goes through; -- connector daemons that implement the contract against a model provider, - Claude first, then an OpenAI-compatible connector. +Crate `crates/model-connector`: -The problem it solves: a mind should reach any model through one inspectable, -typed contract, rather than each project wiring its own provider calls. +- `src/contract.rs` - the v2 wire contract: `ProviderRequest`, `TransportInvocation`, + `TransportResult`, `TransportReceipt`, `Refusal`, and the canonical byte and digest + functions that bind a result to its request. +- `src/envelope.rs` - the AES-256-GCM `TransportEnvelope`, its identity-binding AAD, and + the length-prefixed framing. +- `src/client.rs` - `ConnectorClient`, the one-request caller side. +- `src/service.rs` (feature `daemon`) - `TransportService`: caller admission, durable + replay (a retried request is answered byte-identically and the provider runs once), + in-flight and capacity refusals. +- `src/serve.rs` (feature `daemon`) - `serve()`, the loopback accept loop, over the + `ProviderBackend` trait. A connector binary implements `ProviderBackend` for its + provider and calls `serve`; the kit names no provider. -## Where it comes from +The default feature set compiles contract, envelope and client only. Schema ids are +still `gamecult.codex.*.v2`; renaming them changes the wire and belongs to a later +contract version. -[CodexConnector](https://github.com/GameCult/CodexConnector) -(GameCult/CodexConnector) is the existing connector this kit is lifted from. -The lift is the first piece of work here; until it lands there are no entry -points to run. +## Verify -## License +``` +cargo test --locked --workspace +cargo test --locked --workspace --features model-connector/daemon +``` + +`tests/wire_parity_v2.rs` pins the kit byte-for-byte to CodexConnector fd7cec4 in both +directions (it fetches that revision as a dev-dependency). + +## Provenance and license AGPL-3.0-only. See `LICENSE`. + +The contract, envelope, client, replay service and serve loop were lifted from +[CodexConnector](https://github.com/GameCult/CodexConnector) at fd7cec4, formerly +PolyForm Noncommercial. Its sole copyright holder relicensed the lifted material as +AGPL-3.0-only for this repository. From a255b0d9bfb066ba3f8e60ee2394a2b7dc87c4ad Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 04:42:30 +0100 Subject: [PATCH 05/22] service tests: name the record edit type for clippy Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/service.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index 1413537..16c719b 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -718,7 +718,8 @@ mod tests { seed(&control.path, &seeded_key(), |_| {}); assert!(TransportService::open(&control.path, [caller(CALLER, KEY, 1)], 5_000).is_ok()); - let cases: [(&str, fn(&mut ReplayRecord)); 3] = [ + type Edit = fn(&mut ReplayRecord); + let cases: [(&str, Edit); 3] = [ ("wrong-key", |_| {}), ("zero-digest", |record| record.invocation_sha256 = [0; 32]), ("zero-expiry", |record| record.expires_at_unix_ms = 0), From d1d28f0fd78a689be51893cb2175b596199066b9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 05:05:58 +0100 Subject: [PATCH 06/22] serve owns the config cross-bounds and bounds every result; oversize payload is refused on the wire serve now refuses, before opening anything, a frame under 8192 bytes, fewer connections than max(sum of caller slots, 8), and a caller payload bound outside 4096..=frame-4096 (the base daemon's validate, unchanged). A provider result larger than frame-4096 becomes a typed result_size failure before it is recorded, so a retry replays deliverable bytes. A payload over a caller's bound is refused Policy on the wire instead of dropping the connection. The orphan capacity helpers are deleted; the accept loop takes its connection source so a test can present a non-loopback peer. Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/envelope.rs | 4 - crates/model-connector/src/serve.rs | 356 ++++++++++++++++++++----- crates/model-connector/src/service.rs | 37 +-- 3 files changed, 309 insertions(+), 88 deletions(-) diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index 713eab8..091452f 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -95,7 +95,6 @@ pub(crate) enum TransportFrameError { Size, } - pub(crate) fn read_transport_frame( reader: &mut impl Read, max_frame_bytes: usize, @@ -232,8 +231,6 @@ pub enum ServiceError { ReplayCallerKeyMismatch, #[error("caller runtime is not admitted")] CallerNotAdmitted, - #[error("transport payload exceeds the caller bound")] - PayloadTooLarge, #[error("unexpected transport envelope")] Envelope, #[error("transport envelope identity substitution")] @@ -250,7 +247,6 @@ pub enum ServiceError { ActiveRequestMismatch, } - #[cfg(test)] mod tests { use super::*; diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 1b613f5..4158bdc 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -1,5 +1,5 @@ use std::fs; -use std::net::{IpAddr, TcpListener, TcpStream}; +use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; @@ -10,7 +10,10 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use zeroize::Zeroizing; -use crate::contract::{TransportInvocation, TransportResult}; +use crate::contract::{ + RECEIPT_SCHEMA_ID, TransportDisposition, TransportInvocation, TransportOutcome, + TransportReceipt, TransportResult, +}; use crate::envelope::{ ServiceError, TransportEnvelope, TransportFrameError, read_transport_frame, write_transport_frame, @@ -39,9 +42,17 @@ pub struct ServeLimits { pub max_expiry_skew_ms: u64, } +/// What a frame carries besides the caller's payload: the envelope fields and +/// the headroom a result needs. The base daemon's budget, unchanged. +const FRAME_OVERHEAD_BUDGET: usize = 4096; + impl ServeLimits { - fn validate(&self) -> Result<(), ServeError> { - if self.max_frame_bytes == 0 + /// The bounds serve refuses before it opens anything: each limit nonzero, + /// a frame that can carry a minimum payload plus its overhead, enough + /// connections for every caller's request slots, and every caller's payload + /// bound between the minimum and what a frame can carry. + fn validate(&self, admissions: &[CallerAdmission]) -> Result<(), ServeError> { + if self.max_frame_bytes < FRAME_OVERHEAD_BUDGET * 2 || self.max_frame_bytes > u32::MAX as usize || self.max_connections == 0 || self.socket_timeout_ms == 0 @@ -49,8 +60,31 @@ impl ServeLimits { { return Err(ServeError::InvalidConfig("serve limits")); } + if admissions.is_empty() { + return Err(ServeError::InvalidConfig("callers")); + } + let slots = admissions + .iter() + .try_fold(0_usize, |total, caller| { + total.checked_add(caller.max_concurrent_requests()) + }) + .ok_or(ServeError::InvalidConfig("connection capacity"))?; + if self.max_connections < slots.max(8) { + return Err(ServeError::InvalidConfig("connection capacity")); + } + if admissions.iter().any(|caller| { + caller.max_payload_bytes() < FRAME_OVERHEAD_BUDGET + || caller.max_payload_bytes() > self.max_frame_bytes - FRAME_OVERHEAD_BUDGET + }) { + return Err(ServeError::InvalidConfig("caller admission")); + } Ok(()) } + + /// The largest encoded result a frame can carry once its envelope is added. + fn max_result_bytes(&self) -> usize { + self.max_frame_bytes - FRAME_OVERHEAD_BUDGET + } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -64,19 +98,6 @@ pub struct CallerConfig { pub max_output_tokens: u32, } -pub fn required_connection_capacity(callers: &[CallerConfig]) -> Result { - Ok(caller_request_capacity(callers)?.max(8)) -} - -pub fn caller_request_capacity(callers: &[CallerConfig]) -> Result { - callers - .iter() - .try_fold(0_usize, |total, caller| { - total.checked_add(caller.max_concurrent_requests) - }) - .ok_or(ServeError::InvalidConfig("connection capacity")) -} - pub fn load_admissions(callers: &[CallerConfig]) -> Result, ServeError> { let mut admissions = Vec::with_capacity(callers.len()); for caller in callers { @@ -112,10 +133,7 @@ pub fn serve( replay_store: &Path, backend: Arc, ) -> Result<(), ServeError> { - limits.validate()?; - if admissions.is_empty() { - return Err(ServeError::InvalidConfig("callers")); - } + limits.validate(&admissions)?; if !listener .local_addr() .map_err(ServeError::Listen)? @@ -132,11 +150,22 @@ pub fn serve( admissions, limits.max_expiry_skew_ms, )?)); + accept_loop(|| listener.accept(), limits, service, backend) +} + +/// The accept loop over any source of connections, so a test can present a +/// peer address that a loopback listener never reports. +fn accept_loop( + accept: impl Fn() -> std::io::Result<(TcpStream, SocketAddr)>, + limits: ServeLimits, + service: Arc>, + backend: Arc, +) -> Result<(), ServeError> { let active_connections = Arc::new(AtomicUsize::new(0)); let timeout = Duration::from_millis(limits.socket_timeout_ms); loop { - let (stream, peer) = listener.accept().map_err(ServeError::Listen)?; + let (stream, peer) = accept().map_err(ServeError::Listen)?; if !peer_admitted(peer.ip()) { continue; } @@ -147,18 +176,11 @@ pub fn serve( }; let service = service.clone(); let backend = backend.clone(); - let max_frame_bytes = limits.max_frame_bytes; thread::Builder::new() .name("model-connector-request".to_string()) .spawn(move || { let _permit = permit; - let _ = serve_connection( - stream, - &service, - backend.as_ref(), - max_frame_bytes, - timeout, - ); + let _ = serve_connection(stream, &service, backend.as_ref(), &limits, timeout); }) .map_err(ServeError::Thread)?; } @@ -172,22 +194,23 @@ fn serve_connection( mut stream: TcpStream, service: &Mutex, backend: &dyn ProviderBackend, - max_frame_bytes: usize, + limits: &ServeLimits, timeout: Duration, ) -> Result<(), ServeError> { stream .set_read_timeout(Some(timeout)) .and_then(|()| stream.set_write_timeout(Some(timeout))) .map_err(ServeError::Connection)?; - let request = read_transport_frame(&mut stream, max_frame_bytes).map_err(frame_error)?; - let response = execute_inference_frame(&request, service, backend)?; - write_transport_frame(&mut stream, &response, max_frame_bytes).map_err(frame_error) + let request = read_transport_frame(&mut stream, limits.max_frame_bytes).map_err(frame_error)?; + let response = execute_inference_frame(&request, service, backend, limits)?; + write_transport_frame(&mut stream, &response, limits.max_frame_bytes).map_err(frame_error) } fn execute_inference_frame( request: &[u8], service: &Mutex, backend: &dyn ProviderBackend, + limits: &ServeLimits, ) -> Result, ServeError> { let envelope: TransportEnvelope = rmp_serde::from_slice(request).map_err(|_| ServeError::FrameEncoding)?; @@ -198,7 +221,11 @@ fn execute_inference_frame( let response = match admission { TransportAdmission::Reply(response) => response, TransportAdmission::Execute(claim) => { - let result = backend.execute(claim.invocation()); + let result = bound_result( + claim.invocation(), + backend.execute(claim.invocation()), + limits.max_result_bytes(), + ); service .lock() .map_err(|_| ServeError::ServicePoisoned)? @@ -208,6 +235,40 @@ fn execute_inference_frame( rmp_serde::to_vec(&response).map_err(|_| ServeError::FrameEncoding) } +/// A result the frame cannot carry is never recorded: it becomes a failed +/// receipt in the same shape, so the caller is told and a retry replays bytes +/// that can be delivered. +fn bound_result( + invocation: &TransportInvocation, + result: TransportResult, + max_result_bytes: usize, +) -> TransportResult { + if rmp_serde::to_vec(&result).is_ok_and(|bytes| bytes.len() <= max_result_bytes) { + return result; + } + let transport = match &result.disposition { + TransportDisposition::Transported { receipt, .. } => receipt.transport.clone(), + TransportDisposition::Refused(_) => "model-connector".to_string(), + }; + TransportResult::transported( + invocation, + Vec::new(), + TransportReceipt { + schema_id: RECEIPT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + native_request_sha256: invocation.native_request_sha256, + provider_request_sha256: invocation.provider_request_sha256, + model: invocation.request.model.clone(), + transport, + outcome: TransportOutcome::Failed { + failure_kind: "result_size".to_string(), + message: "provider result exceeded its byte bound".to_string(), + }, + }, + ) +} + fn frame_error(error: TransportFrameError) -> ServeError { match error { TransportFrameError::Connection(error) => ServeError::Connection(error), @@ -287,12 +348,14 @@ mod tests { use super::*; use crate::client::ConnectorClient; use crate::contract::{ - Refusal, TransportDisposition, TransportEvent, TransportEventPayload, TransportResult, + Refusal, TransportDisposition, TransportEvent, TransportEventPayload, TransportOutcome, + TransportResult, }; use crate::fixtures::{TestStore, caller, invocation_for, receipt, request}; const CALLER: &str = "epiphany-yggdrasil"; const KEY: &str = "epiphany-distinct-test-key"; + const FRAME: usize = 128 * 1024; struct CountingBackend { ready: bool, @@ -333,7 +396,7 @@ mod tests { fn limits() -> ServeLimits { ServeLimits { - max_frame_bytes: 64 * 1024, + max_frame_bytes: FRAME, max_connections: 8, socket_timeout_ms: 5_000, max_expiry_skew_ms: 300_000, @@ -373,21 +436,21 @@ mod tests { fn start( backend: Arc, store: &TestStore, + ) -> (ConnectorClient, std::net::SocketAddr) { + start_with(backend, store, vec![caller(CALLER, KEY, 4)]) + } + + fn start_with( + backend: Arc, + store: &TestStore, + admissions: Vec, ) -> (ConnectorClient, std::net::SocketAddr) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let endpoint = listener.local_addr().unwrap(); let path = store.path.clone(); - thread::spawn(move || { - serve( - listener, - limits(), - vec![caller(CALLER, KEY, 4)], - &path, - backend, - ) - }); + thread::spawn(move || serve(listener, limits(), admissions, &path, backend)); let client = - ConnectorClient::new(endpoint, KEY, 64 * 1024, Some(Duration::from_secs(5))).unwrap(); + ConnectorClient::new(endpoint, KEY, FRAME, Some(Duration::from_secs(5))).unwrap(); (client, endpoint) } @@ -456,8 +519,8 @@ mod tests { let mut replies = Vec::new(); for _ in 0..2 { let mut stream = TcpStream::connect(endpoint).unwrap(); - write_transport_frame(&mut stream, &request, 64 * 1024).unwrap(); - replies.push(read_transport_frame(&mut stream, 64 * 1024).unwrap()); + write_transport_frame(&mut stream, &request, FRAME).unwrap(); + replies.push(read_transport_frame(&mut stream, FRAME).unwrap()); } assert_eq!(replies[0], replies[1]); assert_eq!(backend.executions.load(Ordering::SeqCst), 1); @@ -514,8 +577,9 @@ mod tests { #[test] fn serve_refuses_unbounded_limits_and_admits_the_widest_frame() { let store = TestStore::new(); - let mutations: [fn(&mut ServeLimits); 5] = [ + let mutations: [fn(&mut ServeLimits); 6] = [ |limits| limits.max_frame_bytes = 0, + |limits| limits.max_frame_bytes = 8191, |limits| limits.max_frame_bytes = u32::MAX as usize + 1, |limits| limits.max_connections = 0, |limits| limits.socket_timeout_ms = 0, @@ -547,7 +611,10 @@ mod tests { &store.path, CountingBackend::new(true), ); - assert!(result.is_none(), "the widest frame the wire can carry must serve"); + assert!( + result.is_none(), + "the widest frame the wire can carry must serve" + ); } #[test] @@ -587,23 +654,180 @@ mod tests { assert_eq!(active.load(Ordering::SeqCst), 0); } - #[test] - fn caller_admission_reserves_every_independently_admitted_request_slot() { - let both = callers(&[8, 8]); - assert_eq!(caller_request_capacity(&both).unwrap(), 16); - assert_eq!(required_connection_capacity(&both).unwrap(), 16); - assert_eq!(required_connection_capacity(&callers(&[3])).unwrap(), 8); - assert!(matches!( - caller_request_capacity(&callers(&[usize::MAX, 1])), - Err(ServeError::InvalidConfig("connection capacity")) - )); + fn admission(id: &str, key: &str, slots: usize, payload: usize) -> CallerAdmission { + CallerAdmission::new(id, key, 1, ["gpt-5.4".to_string()], slots, payload, 32_768).unwrap() + } + + /// `Some(field)` when serve refused the configuration, `None` while it serves. + fn refusal(limits: ServeLimits, admissions: Vec) -> Option<&'static str> { + let store = TestStore::new(); + match outcome( + TcpListener::bind("127.0.0.1:0").unwrap(), + limits, + admissions, + &store.path, + CountingBackend::new(true), + ) { + None => None, + Some(Err(ServeError::InvalidConfig(field))) => Some(field), + Some(other) => panic!("unexpected serve outcome {other:?}"), + } } #[test] - fn provider_capacity_is_caller_execution_capacity_not_listener_capacity() { - let callers = callers(&[4]); - assert_eq!(caller_request_capacity(&callers).unwrap(), 4); - assert_eq!(required_connection_capacity(&callers).unwrap(), 8); + fn serve_refuses_fewer_connections_than_the_callers_request_slots() { + let two = || { + vec![ + admission("a", "key-a-distinct", 8, 8192), + admission("b", "key-b-distinct", 8, 8192), + ] + }; + let at = |connections| ServeLimits { + max_connections: connections, + ..limits() + }; + assert_eq!(refusal(at(15), two()), Some("connection capacity")); + assert_eq!(refusal(at(16), two()), None); + let small = || vec![admission("a", "key-a-distinct", 3, 8192)]; + assert_eq!(refusal(at(7), small()), Some("connection capacity")); + assert_eq!(refusal(at(8), small()), None); + let overflowing = vec![ + admission("a", "key-a-distinct", usize::MAX, 8192), + admission("b", "key-b-distinct", 1, 8192), + ]; + assert_eq!( + refusal(at(usize::MAX), overflowing), + Some("connection capacity") + ); + } + + #[test] + fn serve_refuses_a_frame_or_payload_bound_the_other_cannot_carry() { + let frame = |bytes| ServeLimits { + max_frame_bytes: bytes, + ..limits() + }; + let payload = |bytes| vec![admission("a", "key-a-distinct", 4, bytes)]; + assert_eq!(refusal(frame(8191), payload(4096)), Some("serve limits")); + // The tightest legal pair: a 4096-byte payload in an 8192-byte frame. + assert_eq!(refusal(frame(8192), payload(4096)), None); + assert_eq!( + refusal(frame(8192), payload(4097)), + Some("caller admission") + ); + assert_eq!(refusal(frame(12288), payload(8192)), None); + assert_eq!( + refusal(frame(12288), payload(8193)), + Some("caller admission") + ); + assert_eq!( + refusal(frame(FRAME), payload(4095)), + Some("caller admission") + ); + let mixed = vec![ + admission("a", "key-a-distinct", 4, 8192), + admission("b", "key-b-distinct", 4, FRAME), + ]; + assert_eq!(refusal(frame(FRAME), mixed), Some("caller admission")); + } + + #[test] + fn a_payload_over_the_caller_bound_is_refused_on_the_wire() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let (client, _) = start_with( + backend.clone(), + &store, + vec![admission(CALLER, KEY, 4, 8192)], + ); + let mut invocation = fresh("request-big"); + invocation.request.instructions = "x".repeat(10_000); + invocation.provider_request_sha256 = + crate::contract::provider_request_sha256(&invocation.request).unwrap(); + let reply = client.execute(&invocation).unwrap(); + assert_eq!( + reply.disposition, + TransportDisposition::Refused(Refusal::Policy) + ); + assert_eq!(backend.executions.load(Ordering::SeqCst), 0); + } + + struct OversizeBackend { + executions: AtomicUsize, + } + + impl ProviderBackend for OversizeBackend { + fn ready(&self) -> bool { + true + } + + fn execute(&self, invocation: &TransportInvocation) -> TransportResult { + self.executions.fetch_add(1, Ordering::SeqCst); + TransportResult::transported( + invocation, + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: "y".repeat(FRAME), + }, + }], + receipt(invocation), + ) + } + } + + #[test] + fn a_result_larger_than_the_frame_fails_typed_and_is_never_replayed_as_completed() { + let store = TestStore::new(); + let backend = Arc::new(OversizeBackend { + executions: AtomicUsize::new(0), + }); + let (client, _) = start_with(backend.clone(), &store, vec![caller(CALLER, KEY, 4)]); + let invocation = fresh("request-oversize"); + let first = client.execute(&invocation).unwrap(); + let second = client.execute(&invocation).unwrap(); + let TransportDisposition::Transported { events, receipt } = &first.disposition else { + panic!("expected a transported failure"); + }; + assert!(events.is_empty()); + assert_eq!( + receipt.outcome, + TransportOutcome::Failed { + failure_kind: "result_size".to_string(), + message: "provider result exceeded its byte bound".to_string(), + } + ); + assert_eq!(receipt.transport, "fixture-sse"); + assert_eq!(first, second, "the retry replays the deliverable failure"); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + } + + #[test] + fn the_accept_loop_drops_a_peer_that_is_not_loopback() { + let run = |reported: SocketAddr| { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let service = Arc::new(Mutex::new( + TransportService::open(&store.path, vec![caller(CALLER, KEY, 4)], 300_000).unwrap(), + )); + let served = backend.clone(); + thread::spawn(move || { + accept_loop( + || listener.accept().map(|(stream, _)| (stream, reported)), + limits(), + service, + served, + ) + }); + let client = + ConnectorClient::new(endpoint, KEY, FRAME, Some(Duration::from_secs(5))).unwrap(); + let reply = client.execute(&fresh("request-peer")); + (reply.is_ok(), backend.executions.load(Ordering::SeqCst)) + }; + assert_eq!(run("127.0.0.1:50000".parse().unwrap()), (true, 1)); + assert_eq!(run("192.168.1.7:50000".parse().unwrap()), (false, 0)); } #[test] @@ -642,7 +866,7 @@ mod tests { let served = backend.clone(); thread::spawn(move || serve(listener, limits(), admissions, &path, served)); let client = - ConnectorClient::new(endpoint, KEY, 64 * 1024, Some(Duration::from_secs(5))).unwrap(); + ConnectorClient::new(endpoint, KEY, FRAME, Some(Duration::from_secs(5))).unwrap(); client.execute(&fresh("request-key")).unwrap(); assert_eq!(backend.executions.load(Ordering::SeqCst), 1); } diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index 16c719b..36d3d31 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -65,6 +65,14 @@ impl CallerAdmission { pub fn caller_runtime_id(&self) -> &str { &self.caller_runtime_id } + + pub(crate) fn max_concurrent_requests(&self) -> usize { + self.max_concurrent_requests + } + + pub(crate) fn max_payload_bytes(&self) -> usize { + self.max_payload_bytes + } } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -195,12 +203,12 @@ impl TransportService { .callers .get(&envelope.caller_runtime_id) .ok_or(ServiceError::CallerNotAdmitted)?; - if envelope.ciphertext.len() > caller.max_payload_bytes { - return Err(ServiceError::PayloadTooLarge); - } let security = caller.security.clone(); let max_concurrent_requests = caller.max_concurrent_requests; let invocation = decrypt_invocation(envelope, &security)?; + if envelope.ciphertext.len() > caller.max_payload_bytes { + return self.reply_refusal(&invocation, Refusal::Policy, &security); + } if let Err(error) = invocation.validate(now_unix_ms, self.max_expiry_skew_ms) { return self.reply_refusal(&invocation, contract_refusal(&error), &security); } @@ -228,9 +236,7 @@ impl TransportService { return self.reply_refusal(&invocation, Refusal::ReplayConflict, &security); } return match record.state { - ReplayState::Completed { response } => { - Ok(TransportAdmission::Reply(response)) - } + ReplayState::Completed { response } => Ok(TransportAdmission::Reply(response)), ReplayState::Active => { let refusal = if self.live.contains(&identity) { Refusal::InFlight @@ -262,12 +268,10 @@ impl TransportService { .put(replay_key(&identity), &replay_record) .map_err(replay_store_error)?; self.live.insert(identity); - Ok(TransportAdmission::Execute(Box::new( - ExecutionClaim { - invocation, - invocation_sha256, - }, - ))) + Ok(TransportAdmission::Execute(Box::new(ExecutionClaim { + invocation, + invocation_sha256, + }))) } pub fn complete( @@ -363,7 +367,6 @@ fn contract_refusal(error: &ContractError) -> Refusal { } } - #[cfg(all(test, feature = "daemon"))] mod tests { use super::*; @@ -490,10 +493,7 @@ mod tests { TransportAdmission::Execute(_) )); - for (invocation, expected) in [ - (&first, Refusal::InFlight), - (&second, Refusal::Capacity), - ] { + for (invocation, expected) in [(&first, Refusal::InFlight), (&second, Refusal::Capacity)] { let response = match service .begin(&encrypt_invocation(invocation, &security).unwrap(), 1_000) .unwrap() @@ -661,7 +661,8 @@ mod tests { .begin(&envelope, 1_000) }; assert!(matches!(admit(size), Ok(TransportAdmission::Execute(_)))); - assert_eq!(admit(size - 1).unwrap_err(), ServiceError::PayloadTooLarge); + let refused = admit(size - 1).unwrap(); + assert_eq!(refusal_of(refused, &invocation), Refusal::Policy); } #[test] From af99b3423f0cca84538fb70a826a2fb07f6eeed7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 05:06:59 +0100 Subject: [PATCH 07/22] wire_v2 fixtures: the generator source, run once against CodexConnector fd7cec4 Co-Authored-By: Claude Opus 5.5 --- .../tests/fixtures/wire_v2/generator.rs | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 crates/model-connector/tests/fixtures/wire_v2/generator.rs diff --git a/crates/model-connector/tests/fixtures/wire_v2/generator.rs b/crates/model-connector/tests/fixtures/wire_v2/generator.rs new file mode 100644 index 0000000..baf36cc --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/generator.rs @@ -0,0 +1,171 @@ +//! Generator of the golden v2 wire fixtures beside it. It is not compiled by this +//! workspace: it was run once, as an example of CodexConnector at commit +//! fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8 (see README.md here for the command). +//! It prints one `FIXTURE ` line per file. + +use codex_connector as v2; +use serde::Serialize; + +const KEY: &str = "parity-distinct-test-key"; +const CALLER: &str = "parity-caller"; + +fn bytes(value: &T) -> Vec { + rmp_serde::to_vec(value).unwrap() +} + +fn emit(name: &str, data: &[u8]) { + let hex: String = data.iter().map(|byte| format!("{byte:02x}")).collect(); + println!("FIXTURE {name} {hex}"); +} + +fn request(choice: v2::CodexToolChoice) -> v2::CodexProviderRequest { + let mut request = + v2::CodexProviderRequest::new("request-1", "conversation-1", "gpt-5.4", "Be exact."); + request.input = vec![ + v2::CodexInputItem::UserText { + text: "state".to_string(), + }, + v2::CodexInputItem::AssistantText { + text: "reply".to_string(), + }, + v2::CodexInputItem::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + v2::CodexInputItem::ToolResult { + call_id: "call-1".to_string(), + output: "ok".to_string(), + }, + ]; + request.tools = vec![v2::CodexToolDefinition { + name: "read_source".to_string(), + description: "Read one admitted source.".to_string(), + parameters_json: r#"{"type":"object"}"#.to_string(), + }]; + request.tool_choice = choice; + request.reasoning_effort = Some("high".to_string()); + request.parallel_tool_calls = true; + request.max_output_tokens = Some(512); + request.prompt_cache_key = Some("cache-1".to_string()); + request +} + +fn invocation(choice: v2::CodexToolChoice) -> v2::CodexTransportInvocation { + v2::CodexTransportInvocation::new(CALLER, 2_000, [7; 32], request(choice)).unwrap() +} + +fn receipt( + invocation: &v2::CodexTransportInvocation, + outcome: v2::CodexTransportOutcome, +) -> v2::CodexTransportReceipt { + v2::CodexTransportReceipt { + schema_id: v2::RECEIPT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + native_request_sha256: invocation.native_request_sha256, + provider_request_sha256: invocation.provider_request_sha256, + model: invocation.request.model.clone(), + transport: "parity-sse".to_string(), + outcome, + } +} + +fn main() { + let key = v2::CodexTransportKey::from_connection_secret(KEY).unwrap(); + let auto = invocation(v2::CodexToolChoice::Auto); + let required = invocation(v2::CodexToolChoice::Required); + emit("invocation_auto.mp", &bytes(&auto)); + emit("invocation_required.mp", &bytes(&required)); + emit( + "canonical_invocation.bin", + &v2::canonical_invocation_bytes(&required).unwrap(), + ); + emit( + "canonical_provider_request.bin", + &v2::canonical_provider_request_bytes(&required.request).unwrap(), + ); + emit( + "provider_request_sha256.bin", + &v2::provider_request_sha256(&required.request).unwrap(), + ); + emit( + "envelope_invocation.mp", + &bytes(&v2::encrypt_invocation(&required, &key).unwrap()), + ); + + let completed = v2::CodexTransportResult::transported( + &required, + vec![ + v2::CodexTransportEvent { + sequence: 0, + payload: v2::CodexTransportEventPayload::TextDelta { + text: "hello".to_string(), + }, + }, + v2::CodexTransportEvent { + sequence: 1, + payload: v2::CodexTransportEventPayload::ToolCall { + call_id: "call-2".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + }, + ], + receipt( + &required, + v2::CodexTransportOutcome::Completed { + provider_response_id: Some("response-1".to_string()), + input_tokens: Some(10), + output_tokens: Some(3), + reasoning_output_tokens: Some(1), + cached_input_tokens: Some(4), + }, + ), + ); + emit("result_transported.mp", &bytes(&completed)); + emit( + "envelope_result_transported.mp", + &bytes(&v2::encrypt_result(&completed, &key).unwrap()), + ); + + let failed = v2::CodexTransportResult::transported( + &required, + Vec::new(), + receipt( + &required, + v2::CodexTransportOutcome::Failed { + failure_kind: "result_size".to_string(), + message: "provider result exceeded its byte bound".to_string(), + }, + ), + ); + emit("result_failed.mp", &bytes(&failed)); + emit( + "envelope_result_failed.mp", + &bytes(&v2::encrypt_result(&failed, &key).unwrap()), + ); + + use v2::CodexRefusal as R; + let refusals = [ + ("expired", R::Expired), + ("identity_substitution", R::IdentitySubstitution), + ("provider_digest_substitution", R::ProviderDigestSubstitution), + ("policy", R::Policy), + ("capacity", R::Capacity), + ("in_flight", R::InFlight), + ("indeterminate", R::Indeterminate), + ("replay_conflict", R::ReplayConflict), + ("malformed", R::Malformed), + ]; + for (name, refusal) in refusals { + let refused = v2::CodexTransportResult::refused(&required, refusal); + emit(&format!("result_refused_{name}.mp"), &bytes(&refused)); + if name == "replay_conflict" { + emit( + "envelope_result_refused_replay_conflict.mp", + &bytes(&v2::encrypt_result(&refused, &key).unwrap()), + ); + } + } +} From ae28dbedfdb485929272fb17e7a932fa31bdfcfc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 05:08:56 +0100 Subject: [PATCH 08/22] Golden v2 wire fixtures replace the CodexConnector dev-dependency; pin envelope validation and frame bounds wire_parity_v2 linked CodexConnector (PolyForm Noncommercial) into the kit's test build. The v2 bytes it compared against are now committed, generated once from fd7cec4 (generator and command beside them): invocations for both tool-choice values, a completed, a failed and a refused result for every refusal, canonical bytes, the provider digest and v2-sealed envelopes the kit must decrypt. envelope.rs tests pin the outer-identity match on each field, envelope validation, and the inclusive, never-empty frame bounds. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 23 -- crates/model-connector/Cargo.toml | 3 - crates/model-connector/src/envelope.rs | 116 +++++++ .../tests/fixtures/wire_v2/.gitattributes | 2 + .../tests/fixtures/wire_v2/README.md | 20 ++ .../fixtures/wire_v2/canonical_invocation.bin | Bin 0 -> 413 bytes .../wire_v2/canonical_provider_request.bin | Bin 0 -> 267 bytes .../fixtures/wire_v2/envelope_invocation.mp | 6 + .../wire_v2/envelope_result_failed.mp | 7 + ...envelope_result_refused_replay_conflict.mp | Bin 0 -> 252 bytes .../wire_v2/envelope_result_transported.mp | Bin 0 -> 609 bytes .../tests/fixtures/wire_v2/invocation_auto.mp | Bin 0 -> 408 bytes .../fixtures/wire_v2/invocation_required.mp | Bin 0 -> 413 bytes .../wire_v2/provider_request_sha256.bin | Bin 0 -> 32 bytes .../tests/fixtures/wire_v2/result_failed.mp | Bin 0 -> 307 bytes .../wire_v2/result_refused_capacity.mp | 1 + .../wire_v2/result_refused_expired.mp | 1 + .../result_refused_identity_substitution.mp | 1 + .../wire_v2/result_refused_in_flight.mp | 1 + .../wire_v2/result_refused_indeterminate.mp | 1 + .../wire_v2/result_refused_malformed.mp | 1 + .../fixtures/wire_v2/result_refused_policy.mp | 1 + ...lt_refused_provider_digest_substitution.mp | 1 + .../wire_v2/result_refused_replay_conflict.mp | 1 + .../fixtures/wire_v2/result_transported.mp | Bin 0 -> 327 bytes .../model-connector/tests/wire_golden_v2.rs | 301 ++++++++++++++++++ .../model-connector/tests/wire_parity_v2.rs | 242 -------------- 27 files changed, 461 insertions(+), 268 deletions(-) create mode 100644 crates/model-connector/tests/fixtures/wire_v2/.gitattributes create mode 100644 crates/model-connector/tests/fixtures/wire_v2/README.md create mode 100644 crates/model-connector/tests/fixtures/wire_v2/canonical_invocation.bin create mode 100644 crates/model-connector/tests/fixtures/wire_v2/canonical_provider_request.bin create mode 100644 crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/envelope_result_refused_replay_conflict.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/envelope_result_transported.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/invocation_auto.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/invocation_required.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/provider_request_sha256.bin create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_failed.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp create mode 100644 crates/model-connector/tests/fixtures/wire_v2/result_transported.mp create mode 100644 crates/model-connector/tests/wire_golden_v2.rs delete mode 100644 crates/model-connector/tests/wire_parity_v2.rs diff --git a/Cargo.lock b/Cargo.lock index a3dfca5..01f3693 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,12 +58,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - [[package]] name = "block-buffer" version = "0.10.4" @@ -119,22 +113,6 @@ dependencies = [ "inout", ] -[[package]] -name = "codex-connector" -version = "0.1.0" -source = "git+https://github.com/GameCult/CodexConnector.git?rev=fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8#fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8" -dependencies = [ - "aes-gcm", - "base64", - "rand_core", - "rmp-serde", - "serde", - "serde_json", - "sha2", - "thiserror", - "zeroize", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -363,7 +341,6 @@ name = "model-connector" version = "0.1.0" dependencies = [ "aes-gcm", - "codex-connector", "cultcache-rs", "rand_core", "rmp-serde", diff --git a/crates/model-connector/Cargo.toml b/crates/model-connector/Cargo.toml index cb30f96..49f83db 100644 --- a/crates/model-connector/Cargo.toml +++ b/crates/model-connector/Cargo.toml @@ -21,6 +21,3 @@ serde_json = "1" sha2 = "0.10" thiserror = "2" zeroize = "1" - -[dev-dependencies] -codex-connector = { git = "https://github.com/GameCult/CodexConnector.git", rev = "fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8", default-features = false } diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index 091452f..abebbb5 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -279,4 +279,120 @@ mod tests { Err(ServiceError::Encryption) ); } + + fn sealed_invocation(caller: &str, request_id: &str, kind: EnvelopeKind) -> TransportEnvelope { + let invocation = invocation(); + let plaintext = canonical_invocation_bytes(&invocation).unwrap(); + encrypt_message( + caller, + request_id, + kind, + &plaintext, + &security("epiphany-distinct-test-key"), + ) + .unwrap() + } + + #[test] + fn the_outer_identity_must_match_the_sealed_invocation_on_each_field() { + let key = security("epiphany-distinct-test-key"); + let invocation = invocation(); + let caller = invocation.caller_runtime_id.clone(); + let request_id = invocation.request_id().to_string(); + let matching = sealed_invocation(&caller, &request_id, EnvelopeKind::Invocation); + assert_eq!(decrypt_invocation(&matching, &key).unwrap(), invocation); + for envelope in [ + sealed_invocation("other-caller", &request_id, EnvelopeKind::Invocation), + sealed_invocation(&caller, "other-request", EnvelopeKind::Invocation), + ] { + assert_eq!( + decrypt_invocation(&envelope, &key), + Err(ServiceError::OuterIdentity) + ); + } + } + + #[test] + fn the_outer_identity_must_match_the_sealed_result_on_each_field() { + let key = security("epiphany-distinct-test-key"); + let invocation = invocation(); + let result = TransportResult::transported(&invocation, Vec::new(), receipt(&invocation)); + let plaintext = rmp_serde::to_vec(&result).unwrap(); + let seal = |caller: &str, request_id: &str| { + encrypt_message(caller, request_id, EnvelopeKind::Result, &plaintext, &key).unwrap() + }; + let caller = result.caller_runtime_id.clone(); + let request_id = result.request_id.clone(); + assert_eq!( + decrypt_result(&seal(&caller, &request_id), &key, &invocation).unwrap(), + result + ); + for envelope in [ + seal("other-caller", &request_id), + seal(&caller, "other-request"), + ] { + assert_eq!( + decrypt_result(&envelope, &key, &invocation), + Err(ServiceError::OuterIdentity) + ); + } + } + + #[test] + fn a_malformed_envelope_is_refused_before_any_decryption() { + let key = security("epiphany-distinct-test-key"); + let invocation = invocation(); + let good = encrypt_invocation(&invocation, &key).unwrap(); + + let mut schema = good.clone(); + schema.schema_id = "gamecult.codex.transport_envelope.v1".to_string(); + let wrong_kind = sealed_invocation("epiphany-yggdrasil", "request-1", EnvelopeKind::Result); + let mut empty = good.clone(); + empty.ciphertext.clear(); + for envelope in [schema, wrong_kind, empty] { + assert_eq!( + decrypt_invocation(&envelope, &key), + Err(ServiceError::Envelope) + ); + } + for field in ["caller_runtime_id", "request_id"] { + let mut blank = good.clone(); + if field == "caller_runtime_id" { + blank.caller_runtime_id = " ".to_string(); + } else { + blank.request_id = String::new(); + } + assert_eq!( + decrypt_invocation(&blank, &key), + Err(ServiceError::Contract(ContractError::Invalid(field))) + ); + } + } + + #[test] + fn a_frame_is_bounded_inclusively_and_never_empty() { + use std::io::Cursor; + let read = |length: u32, body: usize, max: usize| { + let mut frame = length.to_be_bytes().to_vec(); + frame.extend(vec![1; body]); + read_transport_frame(&mut Cursor::new(frame), max) + }; + assert_eq!(read(16, 16, 16).unwrap(), vec![1; 16]); + assert!(matches!(read(17, 17, 16), Err(TransportFrameError::Size))); + assert!(matches!(read(0, 0, 16), Err(TransportFrameError::Size))); + // A bound above the length never turns an empty frame into an empty payload. + assert!(matches!(read(0, 0, 1_000), Err(TransportFrameError::Size))); + + let write = |payload: &[u8], max: usize| { + let mut out = Vec::new(); + write_transport_frame(&mut out, payload, max).map(|()| out) + }; + assert_eq!(write(&[1; 16], 16).unwrap().len(), 20); + assert!(matches!( + write(&[1; 17], 16), + Err(TransportFrameError::Size) + )); + assert!(matches!(write(&[], 16), Err(TransportFrameError::Size))); + assert!(matches!(write(&[], 1_000), Err(TransportFrameError::Size))); + } } diff --git a/crates/model-connector/tests/fixtures/wire_v2/.gitattributes b/crates/model-connector/tests/fixtures/wire_v2/.gitattributes new file mode 100644 index 0000000..bf62110 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/.gitattributes @@ -0,0 +1,2 @@ +*.mp binary +*.bin binary diff --git a/crates/model-connector/tests/fixtures/wire_v2/README.md b/crates/model-connector/tests/fixtures/wire_v2/README.md new file mode 100644 index 0000000..b9fa9e5 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/README.md @@ -0,0 +1,20 @@ +# Golden v2 wire fixtures + +Bytes of the v2 connector wire, produced once by CodexConnector at commit +`fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8` (the library Ghostlight pins), +and pinned by `tests/wire_golden_v2.rs` so that the kit cannot drift from +them. No CodexConnector code or dependency is part of this repository. + +Generator: `generator.rs` (not compiled here). Command, run in a clone of +CodexConnector at that commit: + + cp /crates/model-connector/tests/fixtures/wire_v2/generator.rs examples/gen_wire_v2.rs + cargo run --locked -q --example gen_wire_v2 + +It prints `FIXTURE ` lines; each file is the hex decoded. It was +run on Yggdrasil through the verify container with codex-connector at that +commit as a dev-dependency of the kit at d1d28f0 (same source). The `.mp` +files are rmp-serde output; `envelope_*` files carry random nonces and +AES-256-GCM ciphertext sealed under the connection key +`parity-distinct-test-key`. Regenerating changes the envelopes' bytes; the +plaintext fixtures must regenerate byte-identically. diff --git a/crates/model-connector/tests/fixtures/wire_v2/canonical_invocation.bin b/crates/model-connector/tests/fixtures/wire_v2/canonical_invocation.bin new file mode 100644 index 0000000000000000000000000000000000000000..727edf6cf9555f9c02825cc276855e2a1ffeb884 GIT binary patch literal 413 zcmah_u}T9$6bv@0Z7i&gE7HiFoW#aX1wSCfI&AhH$?E0y+uq!1gY8Xhx`nYo%}N~{9B>Y>f03pE=R^K~E<7K9&4&QM6- z4tItDQv&N2XPyUYlAg{}M*1Hl5}MK*&?h}-={-fGqYdgvy57tXQA`86*rs#(P?aL} zw&<0<=pjE(t#i841Mt2KA z4s^2CP!4L|aDs1QihXX}II>8El<4Id;K zSMYR|mBcT^`T80O8mUM;wcRkl575y3C6FYvA6FrmXbak>9;KbM-M~3}BduM$s;E4D z_mFxNT;&y((^>nr1NsQ@vd06mbobA>$?{E{`Qo_n-H+wO6-cBW8zSgrze#*e8BI#C bOXhCxpoq=7-8aTV|7^A=y_J$G2T%V2OD}Km literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp b/crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp new file mode 100644 index 0000000..3f95738 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/envelope_invocation.mp @@ -0,0 +1,6 @@ +–Ù$gamecult.codex.transport_envelope.v2­parity-caller©request-1ªInvocationœÌ£VÌÁḩ̃̌xÌ©Y̽Ü­hÌ·ÌýÌâÌ—Ì…3ÌÕ ÌÇÌþÌ‘ÌÜḩ̨Ìé[̪ÌÙ!AÌë̉ÌðÌúÌÛ;ÌëÌú| ̪ÌÉ`!ÌúÌú̧̚Ì� +Ì�kÌÞ'vCZÌà̖̌̈ 8J̧ÌÀḍ̗NÌ¿FÌ¿zm6Ìÿ +Ì÷ÌíÌð\\NMqÌ–Ì×Ì— Ìù1ÌæÌ¦Ì¯Ìè 4*{ÌË̥̱ +ÌÖ̦eÌÇ̨Ì� +̰̆ḭG*SÌ�̳̼ÌÒ̥̮ÌÜ!NÌÀÌÑ̠̫̙̂7Ì�ÌÇ̰Ìì̃ÌÀ̬c̦ÌË ÌÒ̲1kÌá~hÌß3$ÌÚÌ KÌß̰ÌùÌÜ4{ÌÓÌß̼̩U.)ÌÌ̭̤rÌ ÌýÌìÌÆ^bbÏy^̳ÌßÌÌÌ�ÌÉ:Ì Ìš-ÌåYXoÌÓÌÒ[Qy̘̫ +XÌÓI:3̽ÌÔ̰D&V'-ÌòufÌŒX1ÌøÌÒ.Ì�|4cÌãÌ‹EÌåAÌßÌæÌŸÌÍQÌÝLÌ‚-Ìáw̰uÌ´3̹>Ìï$QzÌÀk m'+ÌÐÌ¢G Ì´sÌ®00 f)Ì·C̯̙*̆Ì’ÌÛy~ÌùHzÌøÌåÌý"ÌÇÌ¢*%ÌôoÌÉ̯ÌÙxÌŽ}Ì‹ÌÏÌÝYÌÈCÌîÌ´Ì¿uÌü̸x/gÌëÌñ̘X-ÌÙÌÂ̮̆NÌìRI):HÌñÌÉÌ’ÌÀ̗̉fÌÛÌ '\s̨ Ì�s̉ÌÄGVaÌÓ̶̲C\\`=p ÌžwÌŽHÌÖ4ÌÆÌØ>lfLÌ£ÌþÌäÌÖvÌ¿ÌÞJÌë \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp b/crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp new file mode 100644 index 0000000..4b94559 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/envelope_result_failed.mp @@ -0,0 +1,7 @@ +–Ù$gamecult.codex.transport_envelope.v2­parity-caller©request-1¦ResultœÌ…Ì¢ÌÇ"̇Ì�GÌ©ÜCÌôÌË Ì�ÌéHÌßÌ—ÌÿÌÐÌ´ÌÝÌá\Ìö]2/ÌÎe"̘ +Ì¿ÌáÌìÌü{GÌÚṴ̧^b3Ìûj̢̗̟ÌÃÌ’sÌ•*Ì» ÌøÌä= ̸̄<ÌÁ{ÌýÌã.ÌØ8~̫̓CÌâ +̱̘ÌË +[Ì‚ÌÉoÌ–(47ÌÌBML̲Ìó̢̳Ì�hp,ÌîÌ®wÌê6DHÌðbÌÏÌÌb̹@a̧ÌÑ̵̎Ì´Ì�kÌäÌ’ÌáÌóÌ€ÌÑN+ÌüMÌÀjG %Ìß/Ìô +ÌÁÌò̸%Ì»Ì�@sT`ÌâÌ’}P̈ÌÒ ÌëÌÊÌÞ Ì«Ìâ:ÌÚ̟̖̔Ìû^Ìô>ÌûX.x +r]F8Ì�ÌÛÌ‹Ì÷Ì�5ÌÚGÌÆÌºÌÃÌ®C@(ÌóÌÓ̾{̾ÌÙMÌÓ~)Ìà 7ÌÐÌòI+'v ÌøUnP̈$U(NÌêÌçÌüsÌ¢T^[ÌÊYÌæÌ�zÌâ.wÌÆeÌ™C̦̿̄"U^trÌöÌ�ÌÙ̦Ìï̯̬ÌçÌÜÌž+ÌÑ/b̯̭̼Ìî*$Ì× ÌŠÌ²̘ÌÕ\K5̧f +Ìò̃b}̦̬o.Ẁ̧&Ìå̶̑[ \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/envelope_result_refused_replay_conflict.mp b/crates/model-connector/tests/fixtures/wire_v2/envelope_result_refused_replay_conflict.mp new file mode 100644 index 0000000000000000000000000000000000000000..10e3726559fa374cd41b9b35c34f97bac50dd09f GIT binary patch literal 252 zcmVow+yG@3%-hVD z%)-o`e9W^F%(2YrHq4O`%&g4Y%-PK1%>53`kY&ujNzCoc-OSp|w9K_c%%Ty@ifzpJ zJj}?<;6FXg+01~<_{@#Wrp)EciF;?v?9B4a_H4|q%)!jT%Ql; zi5fzshECDCNW}`R(A1wvVW6QFW|7*^Bqk9?Zye6md7krpxMw0vhEmB`Zg$2Pt!Jdl z7^mG}aoDVOXQ5=Nkjz$_6jKq~Wi!}|omEPs!EBc7?RKfGTyi*-syn4ED39bBK5pXM&8R@Qxs6ZBQ#IeUjNv`M)xrji^lP%uo>cK@ zpG#LH@t3dkFp#9qSJTFNW*NN&eq43gXiHVn%D9*x+}8vNls7if$wYD>wQ{Btz)US` z$LVD*I5aOxXCia)mWb&tvm&pzzeF0o!EU`K=h0mjI1=2l>fRR&Ep?86Mn4F7akFlQ~vTr?D8%#2&3uBfQo!!-ARxZ31hwSed*K zQOXzQbPNfxk(|OqxYNUU!^$>>h504HWSxP0R`Dpb^z%o-6{c7Y=Q&S!C$@_b>O`zM z8Vg0CgS=uY!GcdOy6oX{%_%nYagKC0&har!oHxY`pWq>%=se6yG4;n}ypE$aN`xM- m;cgo3J5|v_`|+*}=K1Yk!F9bL`tp+sO65GD`6!Dt9Qg;?kz>{X literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/fixtures/wire_v2/invocation_auto.mp b/crates/model-connector/tests/fixtures/wire_v2/invocation_auto.mp new file mode 100644 index 0000000000000000000000000000000000000000..e505ca2bba33dc48ec8cfe6a607ba0f19a5c34eb GIT binary patch literal 408 zcmah_Jxjw-6b&w2Ttsj+WY9qph$w=a3XTq?4o>y?a%rP!UVQH+w3H6{1431t9HdnH zAr&0OrRv~v3msevot<3t7ns@(;^LVOmvb)XoQuIijun819$H*DP_$4oUj;&9*7YO7 z8M?x^qUBD&q>Ck!GtUFrOZA7Ep85xgga-SZzLuy>C)D4jXCiu{*F}1#H-j$M_ULm? zqpMk)hV(H_kMlc2ZDwHni4;f?xemyzgyRqtCUII5YT9>P_z_6;6V$XDxd6>oYo)ab zIusd4>pX7mDv%i{V)a@fLx?*YO1TO3aeS2!c%`_R5yIO_`kmd3TAKE)1mx`?7(h0E~4*92^2i Tqf%!fikEJ0N^5Y&^I+QF(pIMz literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/fixtures/wire_v2/invocation_required.mp b/crates/model-connector/tests/fixtures/wire_v2/invocation_required.mp new file mode 100644 index 0000000000000000000000000000000000000000..727edf6cf9555f9c02825cc276855e2a1ffeb884 GIT binary patch literal 413 zcmah_u}T9$6bv@0Z7i&gE7HiFoW#aX1wSCfI&AhH$?E0y+uq!1gY8Xhx`nYo%}N~{9B>Y>f03pE=R^K~E<7K9&4&QM6- z4tItDQv&N2XPyUYlAg{}M*1Hl5}MK*&?h}-={-fGqYdgvy57tXQA`86*rs#(P?aL} zw&<0<=pjE(t#i841Mt2KA z4s^2CwXig`xJ1`*Z9!sD zW=W-Pa$-(SYEk3r5SZfBlt~k2+*HP6R&r`)0Y1a-FetDS06-;YE}XfWdgkYueI{of zFq~QECU#~@{+U@)YG=-zS#f6NnRfj%&1W7JElA5BBSyHT!R9TX$ Kkd$AVmjVEMQG6Ky literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp new file mode 100644 index 0000000..4425e6a --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_capacity.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused¨Capacity \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp new file mode 100644 index 0000000..47fff91 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_expired.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused§Expired \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp new file mode 100644 index 0000000..8da919c --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_identity_substitution.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused´IdentitySubstitution \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp new file mode 100644 index 0000000..0c9f9d3 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_in_flight.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused¨InFlight \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp new file mode 100644 index 0000000..068bed3 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_indeterminate.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused­Indeterminate \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp new file mode 100644 index 0000000..f106b9f --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_malformed.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused©Malformed \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp new file mode 100644 index 0000000..28712db --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_policy.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused¦Policy \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp new file mode 100644 index 0000000..76b2169 --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_provider_digest_substitution.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§RefusedºProviderDigestSubstitution \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp b/crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp new file mode 100644 index 0000000..9f621de --- /dev/null +++ b/crates/model-connector/tests/fixtures/wire_v2/result_refused_replay_conflict.mp @@ -0,0 +1 @@ +”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller�§Refused®ReplayConflict \ No newline at end of file diff --git a/crates/model-connector/tests/fixtures/wire_v2/result_transported.mp b/crates/model-connector/tests/fixtures/wire_v2/result_transported.mp new file mode 100644 index 0000000000000000000000000000000000000000..f4fedf862d0f0cd231090f0848610542f5a65cfa GIT binary patch literal 327 zcmbPoQz<<$H#NC5r$jF~KP9z7ucRn3uecz;s3g89wHPE^X0);>wXig`xJ1`*Z9!sD zW=W-Pa$-(SYEk3r5SZfBlu45&F*L3WNv$YxNzExqoVYY2H76&35@X|vko^1{XP}13 z%Rt(6jaC<>CZ@y}=a&{Gr!K0lopDncj}wwpGYjy!;SPfWI{^Sxa^}LByQyb>p4n$| z<^jW*b#7v3rsSWQC8c)e%$XHuR-S3sKhu2XQBiKvnPu8%PM!I4W=+WQ^nwyyQ$3Sa kP#+f;r#7y1&d)8#Ni9iDnYs!XI0gB6#i_c6T+ECt0JtTD5dZ)H literal 0 HcmV?d00001 diff --git a/crates/model-connector/tests/wire_golden_v2.rs b/crates/model-connector/tests/wire_golden_v2.rs new file mode 100644 index 0000000..198b731 --- /dev/null +++ b/crates/model-connector/tests/wire_golden_v2.rs @@ -0,0 +1,301 @@ +//! The kit speaks the v2 wire of CodexConnector fd7cec4, the library Ghostlight pins. +//! The golden bytes in `fixtures/wire_v2` were produced by that library (see its README); +//! nothing of it is linked here. Deleted with the fixtures by cut connector-contract-v3, +//! which changes the wire. + +use model_connector as kit; +use serde::Serialize; +use serde::de::DeserializeOwned; + +const KEY: &str = "parity-distinct-test-key"; +const CALLER: &str = "parity-caller"; + +macro_rules! golden { + ($name:literal) => { + include_bytes!(concat!("fixtures/wire_v2/", $name)) as &[u8] + }; +} + +fn bytes(value: &T) -> Vec { + rmp_serde::to_vec(value).unwrap() +} + +/// The value serializes to the golden bytes and the golden bytes decode back to it. +fn assert_wire( + name: &str, + value: &T, + golden: &[u8], +) { + assert_eq!(bytes(value), golden, "{name}: serialized bytes"); + let decoded: T = rmp_serde::from_slice(golden).unwrap(); + assert_eq!(&decoded, value, "{name}: decoded value"); +} + +fn request(choice: kit::ToolChoice) -> kit::ProviderRequest { + let mut request = + kit::ProviderRequest::new("request-1", "conversation-1", "gpt-5.4", "Be exact."); + request.input = vec![ + kit::InputItem::UserText { + text: "state".to_string(), + }, + kit::InputItem::AssistantText { + text: "reply".to_string(), + }, + kit::InputItem::ToolCall { + call_id: "call-1".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + kit::InputItem::ToolResult { + call_id: "call-1".to_string(), + output: "ok".to_string(), + }, + ]; + request.tools = vec![kit::ToolDefinition { + name: "read_source".to_string(), + description: "Read one admitted source.".to_string(), + parameters_json: r#"{"type":"object"}"#.to_string(), + }]; + request.tool_choice = choice; + request.reasoning_effort = Some("high".to_string()); + request.parallel_tool_calls = true; + request.max_output_tokens = Some(512); + request.prompt_cache_key = Some("cache-1".to_string()); + request +} + +fn invocation(choice: kit::ToolChoice) -> kit::TransportInvocation { + kit::TransportInvocation::new(CALLER, 2_000, [7; 32], request(choice)).unwrap() +} + +fn receipt( + invocation: &kit::TransportInvocation, + outcome: kit::TransportOutcome, +) -> kit::TransportReceipt { + kit::TransportReceipt { + schema_id: kit::RECEIPT_SCHEMA_ID.to_string(), + request_id: invocation.request_id().to_string(), + caller_runtime_id: invocation.caller_runtime_id.clone(), + native_request_sha256: invocation.native_request_sha256, + provider_request_sha256: invocation.provider_request_sha256, + model: invocation.request.model.clone(), + transport: "parity-sse".to_string(), + outcome, + } +} + +fn completed(invocation: &kit::TransportInvocation) -> kit::TransportResult { + kit::TransportResult::transported( + invocation, + vec![ + kit::TransportEvent { + sequence: 0, + payload: kit::TransportEventPayload::TextDelta { + text: "hello".to_string(), + }, + }, + kit::TransportEvent { + sequence: 1, + payload: kit::TransportEventPayload::ToolCall { + call_id: "call-2".to_string(), + name: "read_source".to_string(), + arguments: "{}".to_string(), + }, + }, + ], + receipt( + invocation, + kit::TransportOutcome::Completed { + provider_response_id: Some("response-1".to_string()), + input_tokens: Some(10), + output_tokens: Some(3), + reasoning_output_tokens: Some(1), + cached_input_tokens: Some(4), + }, + ), + ) +} + +fn failed(invocation: &kit::TransportInvocation) -> kit::TransportResult { + kit::TransportResult::transported( + invocation, + Vec::new(), + receipt( + invocation, + kit::TransportOutcome::Failed { + failure_kind: "result_size".to_string(), + message: "provider result exceeded its byte bound".to_string(), + }, + ), + ) +} + +fn key() -> kit::TransportKey { + kit::TransportKey::from_connection_secret(KEY).unwrap() +} + +fn refusals() -> [(&'static str, kit::Refusal, &'static [u8]); 9] { + use kit::Refusal as R; + [ + ("expired", R::Expired, golden!("result_refused_expired.mp")), + ( + "identity_substitution", + R::IdentitySubstitution, + golden!("result_refused_identity_substitution.mp"), + ), + ( + "provider_digest_substitution", + R::ProviderDigestSubstitution, + golden!("result_refused_provider_digest_substitution.mp"), + ), + ("policy", R::Policy, golden!("result_refused_policy.mp")), + ( + "capacity", + R::Capacity, + golden!("result_refused_capacity.mp"), + ), + ( + "in_flight", + R::InFlight, + golden!("result_refused_in_flight.mp"), + ), + ( + "indeterminate", + R::Indeterminate, + golden!("result_refused_indeterminate.mp"), + ), + ( + "replay_conflict", + R::ReplayConflict, + golden!("result_refused_replay_conflict.mp"), + ), + ( + "malformed", + R::Malformed, + golden!("result_refused_malformed.mp"), + ), + ] +} + +#[test] +fn schema_ids_are_the_v2_ids() { + assert_eq!( + kit::PROVIDER_REQUEST_SCHEMA_ID, + "gamecult.codex.provider_request.v2" + ); + assert_eq!( + kit::INVOCATION_SCHEMA_ID, + "gamecult.codex.transport_invocation.v2" + ); + assert_eq!(kit::RESULT_SCHEMA_ID, "gamecult.codex.transport_result.v2"); + assert_eq!( + kit::RECEIPT_SCHEMA_ID, + "gamecult.codex.transport_receipt.v2" + ); + assert_eq!( + kit::ENVELOPE_SCHEMA_ID, + "gamecult.codex.transport_envelope.v2" + ); +} + +#[test] +fn invocations_carry_the_v2_bytes_for_each_tool_choice() { + assert_wire( + "auto", + &invocation(kit::ToolChoice::Auto), + golden!("invocation_auto.mp"), + ); + assert_wire( + "required", + &invocation(kit::ToolChoice::Required), + golden!("invocation_required.mp"), + ); +} + +#[test] +fn results_carry_the_v2_bytes_for_success_and_failure() { + let invocation = invocation(kit::ToolChoice::Required); + assert_wire( + "transported", + &completed(&invocation), + golden!("result_transported.mp"), + ); + assert_wire("failed", &failed(&invocation), golden!("result_failed.mp")); +} + +#[test] +fn every_refusal_carries_its_v2_bytes() { + let invocation = invocation(kit::ToolChoice::Required); + for (name, refusal, golden) in refusals() { + assert_wire( + name, + &kit::TransportResult::refused(&invocation, refusal), + golden, + ); + } +} + +#[test] +fn canonical_bytes_and_provider_digest_are_the_v2_bytes() { + let invocation = invocation(kit::ToolChoice::Required); + assert_eq!( + kit::canonical_invocation_bytes(&invocation).unwrap(), + golden!("canonical_invocation.bin") + ); + assert_eq!( + kit::canonical_provider_request_bytes(&invocation.request).unwrap(), + golden!("canonical_provider_request.bin") + ); + assert_eq!( + kit::provider_request_sha256(&invocation.request) + .unwrap() + .as_slice(), + golden!("provider_request_sha256.bin") + ); +} + +#[test] +fn v2_sealed_envelopes_decrypt_and_validate_under_the_kit() { + let invocation = invocation(kit::ToolChoice::Required); + let envelope: kit::TransportEnvelope = + rmp_serde::from_slice(golden!("envelope_invocation.mp")).unwrap(); + let received = kit::decrypt_invocation(&envelope, &key()).unwrap(); + assert_eq!(bytes(&received), bytes(&invocation)); + + let results = [ + ( + golden!("envelope_result_transported.mp"), + completed(&invocation), + ), + (golden!("envelope_result_failed.mp"), failed(&invocation)), + ( + golden!("envelope_result_refused_replay_conflict.mp"), + kit::TransportResult::refused(&invocation, kit::Refusal::ReplayConflict), + ), + ]; + for (sealed, expected) in results { + let envelope: kit::TransportEnvelope = rmp_serde::from_slice(sealed).unwrap(); + let received = kit::decrypt_result(&envelope, &key(), &invocation).unwrap(); + assert_eq!(bytes(&received), bytes(&expected)); + } +} + +#[test] +fn kit_sealed_envelopes_have_the_v2_shape() { + let invocation = invocation(kit::ToolChoice::Required); + let golden: kit::TransportEnvelope = + rmp_serde::from_slice(golden!("envelope_invocation.mp")).unwrap(); + let sealed = kit::encrypt_invocation(&invocation, &key()).unwrap(); + assert_eq!(sealed.schema_id, golden.schema_id); + assert_eq!(sealed.caller_runtime_id, golden.caller_runtime_id); + assert_eq!(sealed.request_id, golden.request_id); + assert_eq!(sealed.message_kind, golden.message_kind); + assert_eq!(sealed.ciphertext.len(), golden.ciphertext.len()); + assert_ne!(sealed.nonce, golden.nonce, "nonces are fresh per seal"); + + let golden: kit::TransportEnvelope = + rmp_serde::from_slice(golden!("envelope_result_transported.mp")).unwrap(); + let sealed = kit::encrypt_result(&completed(&invocation), &key()).unwrap(); + assert_eq!(sealed.message_kind, golden.message_kind); + assert_eq!(sealed.ciphertext.len(), golden.ciphertext.len()); +} diff --git a/crates/model-connector/tests/wire_parity_v2.rs b/crates/model-connector/tests/wire_parity_v2.rs deleted file mode 100644 index fa2d3ad..0000000 --- a/crates/model-connector/tests/wire_parity_v2.rs +++ /dev/null @@ -1,242 +0,0 @@ -//! The lift is wire-identical to CodexConnector fd7cec4, the v2 library -//! Ghostlight pins. Deleted by cut connector-contract-v3, which changes the wire. - -use codex_connector as v2; -use model_connector as kit; -use serde::Serialize; -use serde::de::DeserializeOwned; - -const KEY: &str = "parity-distinct-test-key"; -const CALLER: &str = "parity-caller"; - -/// Moves a value across the two crates by its MessagePack bytes, the shape the wire carries. -fn cross(value: &T) -> U { - rmp_serde::from_slice(&rmp_serde::to_vec(value).unwrap()).unwrap() -} - -fn bytes(value: &T) -> Vec { - rmp_serde::to_vec(value).unwrap() -} - -fn kit_request() -> kit::ProviderRequest { - let mut request = - kit::ProviderRequest::new("request-1", "conversation-1", "gpt-5.4", "Be exact."); - request.input = vec![ - kit::InputItem::UserText { - text: "state".to_string(), - }, - kit::InputItem::AssistantText { - text: "reply".to_string(), - }, - kit::InputItem::ToolCall { - call_id: "call-1".to_string(), - name: "read_source".to_string(), - arguments: "{}".to_string(), - }, - kit::InputItem::ToolResult { - call_id: "call-1".to_string(), - output: "ok".to_string(), - }, - ]; - request.tools = vec![kit::ToolDefinition { - name: "read_source".to_string(), - description: "Read one admitted source.".to_string(), - parameters_json: r#"{"type":"object"}"#.to_string(), - }]; - request.tool_choice = kit::ToolChoice::Required; - request.reasoning_effort = Some("high".to_string()); - request.parallel_tool_calls = true; - request.max_output_tokens = Some(512); - request.prompt_cache_key = Some("cache-1".to_string()); - request -} - -fn v2_request() -> v2::CodexProviderRequest { - let mut request = - v2::CodexProviderRequest::new("request-1", "conversation-1", "gpt-5.4", "Be exact."); - request.input = vec![ - v2::CodexInputItem::UserText { - text: "state".to_string(), - }, - v2::CodexInputItem::AssistantText { - text: "reply".to_string(), - }, - v2::CodexInputItem::ToolCall { - call_id: "call-1".to_string(), - name: "read_source".to_string(), - arguments: "{}".to_string(), - }, - v2::CodexInputItem::ToolResult { - call_id: "call-1".to_string(), - output: "ok".to_string(), - }, - ]; - request.tools = vec![v2::CodexToolDefinition { - name: "read_source".to_string(), - description: "Read one admitted source.".to_string(), - parameters_json: r#"{"type":"object"}"#.to_string(), - }]; - request.tool_choice = v2::CodexToolChoice::Required; - request.reasoning_effort = Some("high".to_string()); - request.parallel_tool_calls = true; - request.max_output_tokens = Some(512); - request.prompt_cache_key = Some("cache-1".to_string()); - request -} - -fn kit_invocation() -> kit::TransportInvocation { - kit::TransportInvocation::new(CALLER, 2_000, [7; 32], kit_request()).unwrap() -} - -fn v2_invocation() -> v2::CodexTransportInvocation { - v2::CodexTransportInvocation::new(CALLER, 2_000, [7; 32], v2_request()).unwrap() -} - -fn kit_result(invocation: &kit::TransportInvocation) -> kit::TransportResult { - let receipt = kit::TransportReceipt { - schema_id: kit::RECEIPT_SCHEMA_ID.to_string(), - request_id: invocation.request_id().to_string(), - caller_runtime_id: invocation.caller_runtime_id.clone(), - native_request_sha256: invocation.native_request_sha256, - provider_request_sha256: invocation.provider_request_sha256, - model: invocation.request.model.clone(), - transport: "parity-sse".to_string(), - outcome: kit::TransportOutcome::Completed { - provider_response_id: Some("response-1".to_string()), - input_tokens: Some(10), - output_tokens: Some(3), - reasoning_output_tokens: Some(1), - cached_input_tokens: None, - }, - }; - kit::TransportResult::transported( - invocation, - vec![ - kit::TransportEvent { - sequence: 0, - payload: kit::TransportEventPayload::TextDelta { - text: "hello".to_string(), - }, - }, - kit::TransportEvent { - sequence: 1, - payload: kit::TransportEventPayload::ToolCall { - call_id: "call-2".to_string(), - name: "read_source".to_string(), - arguments: "{}".to_string(), - }, - }, - ], - receipt, - ) -} - -fn v2_result(invocation: &v2::CodexTransportInvocation) -> v2::CodexTransportResult { - let receipt = v2::CodexTransportReceipt { - schema_id: v2::RECEIPT_SCHEMA_ID.to_string(), - request_id: invocation.request_id().to_string(), - caller_runtime_id: invocation.caller_runtime_id.clone(), - native_request_sha256: invocation.native_request_sha256, - provider_request_sha256: invocation.provider_request_sha256, - model: invocation.request.model.clone(), - transport: "parity-sse".to_string(), - outcome: v2::CodexTransportOutcome::Completed { - provider_response_id: Some("response-1".to_string()), - input_tokens: Some(10), - output_tokens: Some(3), - reasoning_output_tokens: Some(1), - cached_input_tokens: None, - }, - }; - v2::CodexTransportResult::transported( - invocation, - vec![ - v2::CodexTransportEvent { - sequence: 0, - payload: v2::CodexTransportEventPayload::TextDelta { - text: "hello".to_string(), - }, - }, - v2::CodexTransportEvent { - sequence: 1, - payload: v2::CodexTransportEventPayload::ToolCall { - call_id: "call-2".to_string(), - name: "read_source".to_string(), - arguments: "{}".to_string(), - }, - }, - ], - receipt, - ) -} - -fn kit_key() -> kit::TransportKey { - kit::TransportKey::from_connection_secret(KEY).unwrap() -} - -fn v2_key() -> v2::CodexTransportKey { - v2::CodexTransportKey::from_connection_secret(KEY).unwrap() -} - -#[test] -fn schema_ids_are_the_v2_ids() { - assert_eq!( - kit::PROVIDER_REQUEST_SCHEMA_ID, - v2::PROVIDER_REQUEST_SCHEMA_ID - ); - assert_eq!(kit::INVOCATION_SCHEMA_ID, v2::INVOCATION_SCHEMA_ID); - assert_eq!(kit::RESULT_SCHEMA_ID, v2::RESULT_SCHEMA_ID); - assert_eq!(kit::RECEIPT_SCHEMA_ID, v2::RECEIPT_SCHEMA_ID); - assert_eq!(kit::ENVELOPE_SCHEMA_ID, v2::ENVELOPE_SCHEMA_ID); -} - -#[test] -fn canonical_bytes_and_provider_digest_match_v2() { - let from_kit = kit_invocation(); - let from_v2 = v2_invocation(); - assert_eq!(bytes(&from_kit), bytes(&from_v2)); - assert_eq!( - kit::canonical_invocation_bytes(&from_kit).unwrap(), - v2::canonical_invocation_bytes(&from_v2).unwrap() - ); - assert_eq!( - kit::canonical_provider_request_bytes(&from_kit.request).unwrap(), - v2::canonical_provider_request_bytes(&from_v2.request).unwrap() - ); - assert_eq!( - kit::provider_request_sha256(&from_kit.request).unwrap(), - v2::provider_request_sha256(&from_v2.request).unwrap() - ); -} - -#[test] -fn kit_invocation_decrypts_and_validates_under_v2() { - let sent = kit_invocation(); - let envelope = kit::encrypt_invocation(&sent, &kit_key()).unwrap(); - let received = v2::decrypt_invocation(&cross(&envelope), &v2_key()).unwrap(); - assert_eq!(bytes(&received), bytes(&sent)); -} - -#[test] -fn v2_invocation_decrypts_and_validates_under_kit() { - let sent = v2_invocation(); - let envelope = v2::encrypt_invocation(&sent, &v2_key()).unwrap(); - let received = kit::decrypt_invocation(&cross(&envelope), &kit_key()).unwrap(); - assert_eq!(bytes(&received), bytes(&sent)); -} - -#[test] -fn kit_result_decrypts_and_validates_under_v2() { - let sent = kit_result(&kit_invocation()); - let envelope = kit::encrypt_result(&sent, &kit_key()).unwrap(); - let received = v2::decrypt_result(&cross(&envelope), &v2_key(), &v2_invocation()).unwrap(); - assert_eq!(bytes(&received), bytes(&sent)); -} - -#[test] -fn v2_result_decrypts_and_validates_under_kit() { - let sent = v2_result(&v2_invocation()); - let envelope = v2::encrypt_result(&sent, &v2_key()).unwrap(); - let received = kit::decrypt_result(&cross(&envelope), &kit_key(), &kit_invocation()).unwrap(); - assert_eq!(bytes(&received), bytes(&sent)); -} From caddbc8d9a9226344addb795d15b63b4d623b845 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 05:12:11 +0100 Subject: [PATCH 09/22] serve tests: pin the result bound's arithmetic and that in-bound results pass through untouched Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/serve.rs | 49 +++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 4158bdc..fec618f 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -485,6 +485,18 @@ mod tests { TransportDisposition::Transported { .. } )); assert_eq!(first, second); + let TransportDisposition::Transported { events, receipt } = &first.disposition else { + unreachable!("matched above"); + }; + assert_eq!( + events.len(), + 1, + "a result within the bound is delivered as the provider made it" + ); + assert!(matches!( + receipt.outcome, + TransportOutcome::Completed { .. } + )); assert_eq!(backend.executions.load(Ordering::SeqCst), 1); let mut conflicting_request = request(); @@ -802,6 +814,43 @@ mod tests { assert_eq!(backend.executions.load(Ordering::SeqCst), 1); } + #[test] + fn a_result_is_bounded_by_the_frame_less_its_overhead_inclusively() { + let invocation = fresh("request-bound"); + let result_with = |text_len: usize| { + TransportResult::transported( + &invocation, + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: "z".repeat(text_len), + }, + }], + receipt(&invocation), + ) + }; + let bounded = ServeLimits { + max_frame_bytes: 20_000, + ..limits() + }; + let max = bounded.max_result_bytes(); + assert_eq!(max, 20_000 - 4096); + // MessagePack headers are the same width across this range, so one probe fixes the size. + let probe = rmp_serde::to_vec(&result_with(10_000)).unwrap().len(); + let exact = 10_000 + max - probe; + assert_eq!(rmp_serde::to_vec(&result_with(exact)).unwrap().len(), max); + assert_eq!( + bound_result(&invocation, result_with(exact), max), + result_with(exact) + ); + let over = bound_result(&invocation, result_with(exact + 1), max); + let TransportDisposition::Transported { receipt, .. } = &over.disposition else { + panic!("expected a transported failure"); + }; + assert!(matches!(receipt.outcome, TransportOutcome::Failed { .. })); + over.validate_against(&invocation).unwrap(); + } + #[test] fn the_accept_loop_drops_a_peer_that_is_not_loopback() { let run = |reported: SocketAddr| { From 868c248b4f1502029c89b564efe3dc0e1928329c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 05:31:41 +0100 Subject: [PATCH 10/22] Seal-measured frame bounds: results fit the frame as written, payload bound derives from the worst encoding complete() measures the serialized sealed envelope against the frame and records the typed result_size failure (fixed transport, itself guaranteed to fit) when it does not fit, so no Completed record is undeliverable. serve's validation derives the caller payload bound from the real worst-case encoding of the int-array ciphertext instead of plaintext length. Identities are capped at 256 bytes so the stand-in's size is bounded. The wire encoding is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/envelope.rs | 74 +++++ crates/model-connector/src/serve.rs | 377 ++++++++++++++++--------- crates/model-connector/src/service.rs | 231 ++++++++++++++- 3 files changed, 536 insertions(+), 146 deletions(-) diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index abebbb5..2fbd820 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -27,6 +27,37 @@ pub struct TransportEnvelope { pub ciphertext: Vec, } +/// The longest caller id, request id or model name a service admits. The +/// frame arithmetic below depends on it, so a longer one is refused up front. +pub(crate) const MAX_IDENTITY_BYTES: usize = 256; + +/// The most bytes a serialized envelope holding `ciphertext_len` ciphertext +/// bytes can occupy. The v2 wire serializes the ciphertext as a MessagePack +/// array of integers: a byte of 128 or more takes two bytes, so the worst case +/// is twice the ciphertext, plus the array header and the envelope's other +/// fields at their longest. Measured from the real encoding, not assumed. +pub(crate) fn worst_frame_bytes(ciphertext_len: usize) -> usize { + let shell = rmp_serde::to_vec(&TransportEnvelope { + schema_id: ENVELOPE_SCHEMA_ID.to_string(), + caller_runtime_id: "x".repeat(MAX_IDENTITY_BYTES), + request_id: "x".repeat(MAX_IDENTITY_BYTES), + message_kind: EnvelopeKind::Invocation, + nonce: [0xff; 12], + ciphertext: Vec::new(), + }) + .map_or(usize::MAX, |bytes| bytes.len()); + // The shell holds an empty array header of one byte. + let header = match ciphertext_len { + 0..=15 => 1, + 16..=65_535 => 3, + _ => 5, + }; + shell + .saturating_sub(1) + .saturating_add(header) + .saturating_add(ciphertext_len.saturating_mul(2)) +} + pub fn encrypt_invocation( invocation: &TransportInvocation, security: &TransportKey, @@ -245,6 +276,8 @@ pub enum ServiceError { NoActiveRequest, #[error("completion does not match the active request")] ActiveRequestMismatch, + #[error("sealed result does not fit the frame bound")] + FrameBound, } #[cfg(test)] @@ -395,4 +428,45 @@ mod tests { assert!(matches!(write(&[], 16), Err(TransportFrameError::Size))); assert!(matches!(write(&[], 1_000), Err(TransportFrameError::Size))); } + + #[test] + fn the_worst_frame_is_the_real_encoding_of_an_all_high_ciphertext() { + let long = "x".repeat(MAX_IDENTITY_BYTES); + for length in [0, 1, 15, 16, 17, 255, 256, 65_535, 65_536, 70_000] { + let envelope = TransportEnvelope { + schema_id: ENVELOPE_SCHEMA_ID.to_string(), + caller_runtime_id: long.clone(), + request_id: long.clone(), + message_kind: EnvelopeKind::Invocation, + nonce: [0xff; 12], + ciphertext: vec![0xff; length], + }; + assert_eq!( + rmp_serde::to_vec(&envelope).unwrap().len(), + worst_frame_bytes(length), + "{length}" + ); + } + } + + #[test] + fn a_sealed_envelope_never_exceeds_the_worst_frame_for_its_ciphertext() { + let key = security("epiphany-distinct-test-key"); + for id in ["a".to_string(), "i".repeat(MAX_IDENTITY_BYTES)] { + for plaintext in [1, 100, 5_000, 70_000] { + let sealed = + encrypt_message(&id, &id, EnvelopeKind::Result, &vec![0x41; plaintext], &key) + .unwrap(); + let real = rmp_serde::to_vec(&sealed).unwrap().len(); + let worst = worst_frame_bytes(sealed.ciphertext.len()); + assert!(real <= worst, "{real} > {worst}"); + // Real ciphertext is random, so about half its bytes take two + // bytes: the worst case is not wildly above what is written. + assert!( + worst <= real + sealed.ciphertext.len() + 64, + "{worst} {real}" + ); + } + } + } } diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index fec618f..23b5bed 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -10,12 +10,9 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use zeroize::Zeroizing; -use crate::contract::{ - RECEIPT_SCHEMA_ID, TransportDisposition, TransportInvocation, TransportOutcome, - TransportReceipt, TransportResult, -}; +use crate::contract::{TransportInvocation, TransportResult}; use crate::envelope::{ - ServiceError, TransportEnvelope, TransportFrameError, read_transport_frame, + ServiceError, TransportEnvelope, TransportFrameError, read_transport_frame, worst_frame_bytes, write_transport_frame, }; use crate::service::{CallerAdmission, TransportAdmission, TransportService}; @@ -42,17 +39,19 @@ pub struct ServeLimits { pub max_expiry_skew_ms: u64, } -/// What a frame carries besides the caller's payload: the envelope fields and -/// the headroom a result needs. The base daemon's budget, unchanged. -const FRAME_OVERHEAD_BUDGET: usize = 4096; +/// The smallest caller payload bound the base daemon admitted. +const MIN_PAYLOAD_BYTES: usize = 4096; impl ServeLimits { /// The bounds serve refuses before it opens anything: each limit nonzero, - /// a frame that can carry a minimum payload plus its overhead, enough - /// connections for every caller's request slots, and every caller's payload - /// bound between the minimum and what a frame can carry. + /// a frame that carries the smallest payload (which also carries the failure + /// that stands in for an oversize result), enough connections for every caller's request + /// slots, and every caller's payload bound between the minimum and the + /// largest whose sealed frame, at its worst-case encoding, fits the frame. + /// An admitted payload therefore always fits, and `complete` is given the + /// same frame bound to fit every result in. fn validate(&self, admissions: &[CallerAdmission]) -> Result<(), ServeError> { - if self.max_frame_bytes < FRAME_OVERHEAD_BUDGET * 2 + if self.max_frame_bytes < worst_frame_bytes(MIN_PAYLOAD_BYTES) || self.max_frame_bytes > u32::MAX as usize || self.max_connections == 0 || self.socket_timeout_ms == 0 @@ -73,18 +72,13 @@ impl ServeLimits { return Err(ServeError::InvalidConfig("connection capacity")); } if admissions.iter().any(|caller| { - caller.max_payload_bytes() < FRAME_OVERHEAD_BUDGET - || caller.max_payload_bytes() > self.max_frame_bytes - FRAME_OVERHEAD_BUDGET + caller.max_payload_bytes() < MIN_PAYLOAD_BYTES + || worst_frame_bytes(caller.max_payload_bytes()) > self.max_frame_bytes }) { return Err(ServeError::InvalidConfig("caller admission")); } Ok(()) } - - /// The largest encoded result a frame can carry once its envelope is added. - fn max_result_bytes(&self) -> usize { - self.max_frame_bytes - FRAME_OVERHEAD_BUDGET - } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -221,54 +215,16 @@ fn execute_inference_frame( let response = match admission { TransportAdmission::Reply(response) => response, TransportAdmission::Execute(claim) => { - let result = bound_result( - claim.invocation(), - backend.execute(claim.invocation()), - limits.max_result_bytes(), - ); + let result = backend.execute(claim.invocation()); service .lock() .map_err(|_| ServeError::ServicePoisoned)? - .complete(*claim, result)? + .complete(*claim, result, limits.max_frame_bytes)? } }; rmp_serde::to_vec(&response).map_err(|_| ServeError::FrameEncoding) } -/// A result the frame cannot carry is never recorded: it becomes a failed -/// receipt in the same shape, so the caller is told and a retry replays bytes -/// that can be delivered. -fn bound_result( - invocation: &TransportInvocation, - result: TransportResult, - max_result_bytes: usize, -) -> TransportResult { - if rmp_serde::to_vec(&result).is_ok_and(|bytes| bytes.len() <= max_result_bytes) { - return result; - } - let transport = match &result.disposition { - TransportDisposition::Transported { receipt, .. } => receipt.transport.clone(), - TransportDisposition::Refused(_) => "model-connector".to_string(), - }; - TransportResult::transported( - invocation, - Vec::new(), - TransportReceipt { - schema_id: RECEIPT_SCHEMA_ID.to_string(), - request_id: invocation.request_id().to_string(), - caller_runtime_id: invocation.caller_runtime_id.clone(), - native_request_sha256: invocation.native_request_sha256, - provider_request_sha256: invocation.provider_request_sha256, - model: invocation.request.model.clone(), - transport, - outcome: TransportOutcome::Failed { - failure_kind: "result_size".to_string(), - message: "provider result exceeded its byte bound".to_string(), - }, - }, - ) -} - fn frame_error(error: TransportFrameError) -> ServeError { match error { TransportFrameError::Connection(error) => ServeError::Connection(error), @@ -355,7 +311,9 @@ mod tests { const CALLER: &str = "epiphany-yggdrasil"; const KEY: &str = "epiphany-distinct-test-key"; - const FRAME: usize = 128 * 1024; + const FRAME: usize = 256 * 1024; + /// AES-256-GCM appends a 16-byte tag to the ciphertext. + const GCM_TAG: usize = 16; struct CountingBackend { ready: bool, @@ -586,12 +544,30 @@ mod tests { assert_eq!(backend.ready_calls.load(Ordering::SeqCst), 0); } + /// The smallest frame serve admits: the smallest payload at its worst-case + /// sealed encoding. + fn smallest_frame() -> usize { + worst_frame_bytes(MIN_PAYLOAD_BYTES) + } + + #[test] + fn the_smallest_frame_also_carries_the_failure_that_stands_in_for_a_result() { + let long = "x".repeat(crate::envelope::MAX_IDENTITY_BYTES); + let plaintext = rmp_serde::to_vec(&crate::service::result_size_failure( + &long, &long, &long, [0xff; 32], [0xff; 32], + )) + .unwrap() + .len(); + let worst = worst_frame_bytes(plaintext + GCM_TAG); + assert!(worst <= smallest_frame(), "{worst} > {}", smallest_frame()); + } + #[test] fn serve_refuses_unbounded_limits_and_admits_the_widest_frame() { let store = TestStore::new(); let mutations: [fn(&mut ServeLimits); 6] = [ |limits| limits.max_frame_bytes = 0, - |limits| limits.max_frame_bytes = 8191, + |limits| limits.max_frame_bytes = smallest_frame() - 1, |limits| limits.max_frame_bytes = u32::MAX as usize + 1, |limits| limits.max_connections = 0, |limits| limits.socket_timeout_ms = 0, @@ -720,22 +696,36 @@ mod tests { ..limits() }; let payload = |bytes| vec![admission("a", "key-a-distinct", 4, bytes)]; - assert_eq!(refusal(frame(8191), payload(4096)), Some("serve limits")); - // The tightest legal pair: a 4096-byte payload in an 8192-byte frame. - assert_eq!(refusal(frame(8192), payload(4096)), None); + let tightest = smallest_frame(); assert_eq!( - refusal(frame(8192), payload(4097)), - Some("caller admission") + refusal(frame(tightest - 1), payload(4096)), + Some("serve limits") ); - assert_eq!(refusal(frame(12288), payload(8192)), None); + // The tightest legal pair: the smallest payload in the smallest frame. + assert_eq!(refusal(frame(tightest), payload(4096)), None); assert_eq!( - refusal(frame(12288), payload(8193)), + refusal(frame(tightest), payload(4097)), Some("caller admission") ); assert_eq!( - refusal(frame(FRAME), payload(4095)), + refusal(frame(tightest), payload(4095)), Some("caller admission") ); + // The bound is the sealed frame at its worst encoding, not the payload: + // a payload that is the whole frame cannot be carried. + assert_eq!( + refusal(frame(FRAME), payload(FRAME)), + Some("caller admission") + ); + for bytes in [8192, 65_535, 65_536, 100_000] { + let widest = worst_frame_bytes(bytes); + assert_eq!(refusal(frame(widest), payload(bytes)), None, "{bytes}"); + assert_eq!( + refusal(frame(widest - 1), payload(bytes)), + Some("caller admission"), + "{bytes}" + ); + } let mixed = vec![ admission("a", "key-a-distinct", 4, 8192), admission("b", "key-b-distinct", 4, FRAME), @@ -743,6 +733,35 @@ mod tests { assert_eq!(refusal(frame(FRAME), mixed), Some("caller admission")); } + #[test] + fn a_payload_at_the_widest_bound_its_frame_admits_is_carried_and_served() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let invocation = fresh(&"q".repeat(256)); + let key = crate::fixtures::security(KEY); + let sealed = crate::envelope::encrypt_invocation(&invocation, &key).unwrap(); + let bound = sealed.ciphertext.len(); + let frame = worst_frame_bytes(bound); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let path = store.path.clone(); + let served = backend.clone(); + let tight = ServeLimits { + max_frame_bytes: frame, + ..limits() + }; + let admissions = vec![admission(CALLER, KEY, 4, bound)]; + thread::spawn(move || serve(listener, tight, admissions, &path, served)); + let client = + ConnectorClient::new(endpoint, KEY, frame, Some(Duration::from_secs(5))).unwrap(); + let reply = client.execute(&invocation).unwrap(); + assert!(matches!( + reply.disposition, + TransportDisposition::Transported { .. } + )); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + } + #[test] fn a_payload_over_the_caller_bound_is_refused_on_the_wire() { let store = TestStore::new(); @@ -764,91 +783,177 @@ mod tests { assert_eq!(backend.executions.load(Ordering::SeqCst), 0); } - struct OversizeBackend { + /// Answers every invocation with one text event of a chosen size. + struct SizedBackend { + text_len: usize, + transport: String, executions: AtomicUsize, } - impl ProviderBackend for OversizeBackend { + impl SizedBackend { + fn new(text_len: usize) -> Arc { + Self::with_transport(text_len, "fixture-sse".to_string()) + } + + fn with_transport(text_len: usize, transport: String) -> Arc { + Arc::new(Self { + text_len, + transport, + executions: AtomicUsize::new(0), + }) + } + } + + impl ProviderBackend for SizedBackend { fn ready(&self) -> bool { true } fn execute(&self, invocation: &TransportInvocation) -> TransportResult { self.executions.fetch_add(1, Ordering::SeqCst); - TransportResult::transported( - invocation, - vec![TransportEvent { - sequence: 0, - payload: TransportEventPayload::TextDelta { - text: "y".repeat(FRAME), - }, - }], - receipt(invocation), - ) + sized_result(invocation, self.text_len, &self.transport) + } + } + + fn sized_result( + invocation: &TransportInvocation, + text_len: usize, + transport: &str, + ) -> TransportResult { + let mut receipt = receipt(invocation); + receipt.transport = transport.to_string(); + TransportResult::transported( + invocation, + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: "y".repeat(text_len), + }, + }], + receipt, + ) + } + + fn encoded_len(result: &TransportResult) -> usize { + rmp_serde::to_vec(result).unwrap().len() + } + + /// Two calls with one request id, through a live serve loop. + fn call_twice( + backend: Arc, + request_id: &str, + ) -> (TransportResult, TransportResult, usize) { + let store = TestStore::new(); + let (client, _) = start_with(backend.clone(), &store, vec![caller(CALLER, KEY, 4)]); + let invocation = fresh(request_id); + let first = client.execute(&invocation).expect("first call delivered"); + let second = client.execute(&invocation).expect("retry delivered"); + (first, second, backend.executions.load(Ordering::SeqCst)) + } + + fn failed_result_size(result: &TransportResult) -> bool { + matches!( + &result.disposition, + TransportDisposition::Transported { events, receipt } + if events.is_empty() + && receipt.transport == "model-connector" + && receipt.outcome == TransportOutcome::Failed { + failure_kind: "result_size".to_string(), + message: "provider result exceeded its byte bound".to_string(), + } + ) + } + + fn id_cases() -> [String; 2] { + ["request-short".to_string(), "r".repeat(256)] + } + + /// The encoded size of a result carrying no text, for this request id. + fn empty_result_len(request_id: &str) -> usize { + encoded_len(&sized_result(&fresh(request_id), 0, "fixture-sse")) + } + + #[test] + fn a_result_whose_plaintext_fits_but_whose_sealed_frame_does_not_fails_typed_and_replays() { + // A plaintext within the frame less its overhead seals to about one and + // a half times its size, so it cannot be carried; measuring the + // plaintext would record it as completed and every retry would die. + for request_id in id_cases() { + let base = empty_result_len(&request_id); + let room = FRAME - 4096; + for plaintext in [room, room - 1, FRAME * 3 / 4] { + let backend = SizedBackend::new(plaintext - base); + let (first, second, executions) = call_twice(backend, &request_id); + assert!( + failed_result_size(&first), + "{} {plaintext}", + request_id.len() + ); + assert_eq!(first, second, "the retry replays the deliverable failure"); + assert_eq!(executions, 1); + } } } #[test] fn a_result_larger_than_the_frame_fails_typed_and_is_never_replayed_as_completed() { - let store = TestStore::new(); - let backend = Arc::new(OversizeBackend { - executions: AtomicUsize::new(0), - }); - let (client, _) = start_with(backend.clone(), &store, vec![caller(CALLER, KEY, 4)]); - let invocation = fresh("request-oversize"); - let first = client.execute(&invocation).unwrap(); - let second = client.execute(&invocation).unwrap(); - let TransportDisposition::Transported { events, receipt } = &first.disposition else { - panic!("expected a transported failure"); - }; - assert!(events.is_empty()); - assert_eq!( - receipt.outcome, - TransportOutcome::Failed { - failure_kind: "result_size".to_string(), - message: "provider result exceeded its byte bound".to_string(), - } - ); - assert_eq!(receipt.transport, "fixture-sse"); - assert_eq!(first, second, "the retry replays the deliverable failure"); - assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + let (first, second, executions) = call_twice(SizedBackend::new(FRAME), "request-oversize"); + assert!(failed_result_size(&first)); + assert_eq!(first, second); + assert_eq!(executions, 1); } #[test] - fn a_result_is_bounded_by_the_frame_less_its_overhead_inclusively() { - let invocation = fresh("request-bound"); - let result_with = |text_len: usize| { - TransportResult::transported( - &invocation, - vec![TransportEvent { - sequence: 0, - payload: TransportEventPayload::TextDelta { - text: "z".repeat(text_len), - }, - }], - receipt(&invocation), - ) - }; - let bounded = ServeLimits { - max_frame_bytes: 20_000, - ..limits() - }; - let max = bounded.max_result_bytes(); - assert_eq!(max, 20_000 - 4096); - // MessagePack headers are the same width across this range, so one probe fixes the size. - let probe = rmp_serde::to_vec(&result_with(10_000)).unwrap().len(); - let exact = 10_000 + max - probe; - assert_eq!(rmp_serde::to_vec(&result_with(exact)).unwrap().len(), max); - assert_eq!( - bound_result(&invocation, result_with(exact), max), - result_with(exact) + fn a_stand_in_failure_never_carries_the_backends_transport() { + let (first, second, executions) = call_twice( + SizedBackend::with_transport(FRAME, "t".repeat(FRAME)), + "request-transport", ); - let over = bound_result(&invocation, result_with(exact + 1), max); - let TransportDisposition::Transported { receipt, .. } = &over.disposition else { - panic!("expected a transported failure"); - }; - assert!(matches!(receipt.outcome, TransportOutcome::Failed { .. })); - over.validate_against(&invocation).unwrap(); + assert!(failed_result_size(&first)); + assert_eq!(first, second); + assert_eq!(executions, 1); + } + + #[test] + fn a_result_within_what_the_worst_encoding_leaves_is_delivered_intact() { + // The largest plaintext whose sealed frame fits at the worst encoding is + // always carried, whatever the ciphertext bytes turn out to be. + for request_id in id_cases() { + let base = empty_result_len(&request_id); + let mut plaintext = base; + while worst_frame_bytes(plaintext + 1 + GCM_TAG) <= FRAME { + plaintext += 1; + } + let (first, second, _) = call_twice(SizedBackend::new(plaintext - base), &request_id); + let TransportDisposition::Transported { events, receipt } = &first.disposition else { + panic!("expected a transported result"); + }; + assert_eq!(events.len(), 1, "{}", request_id.len()); + assert!(matches!( + receipt.outcome, + TransportOutcome::Completed { .. } + )); + assert_eq!(first, second); + } + } + + #[test] + fn a_result_just_over_the_worst_encoding_is_delivered_whatever_the_nonce_makes_of_it() { + // Past the guaranteed size the sealed frame may or may not fit; either + // way the caller gets an answer, and the retry gets the same one. + for request_id in id_cases() { + let base = empty_result_len(&request_id); + let mut plaintext = base; + while worst_frame_bytes(plaintext + GCM_TAG) <= FRAME { + plaintext += 1; + } + for extra in [0, 1, 2] { + let backend = SizedBackend::new(plaintext + extra - base); + let (first, second, executions) = call_twice(backend, &request_id); + assert_eq!(first, second); + assert_eq!(executions, 1); + } + } } #[test] diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index 36d3d31..6836df1 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -7,12 +7,13 @@ use sha2::{Digest, Sha256}; use zeroize::Zeroizing; use crate::contract::{ - ContractError, Refusal, TransportDisposition, TransportInvocation, TransportKey, - TransportResult, canonical_invocation_bytes, require_id, + ContractError, RECEIPT_SCHEMA_ID, RESULT_SCHEMA_ID, Refusal, TransportDisposition, + TransportInvocation, TransportKey, TransportOutcome, TransportReceipt, TransportResult, + canonical_invocation_bytes, require_id, }; use crate::envelope::{ - EnvelopeKind, ServiceError, TransportEnvelope, decrypt_invocation, encrypt_result, - validate_envelope, + EnvelopeKind, MAX_IDENTITY_BYTES, ServiceError, TransportEnvelope, decrypt_invocation, + encrypt_result, validate_envelope, }; pub struct CallerAdmission { @@ -40,9 +41,10 @@ impl CallerAdmission { let allowed_models = allowed_models.into_iter().collect::>(); if connection_key_epoch == 0 || allowed_models.is_empty() - || allowed_models - .iter() - .any(|model| require_id(model, "allowed_model").is_err()) + || caller_runtime_id.len() > MAX_IDENTITY_BYTES + || allowed_models.iter().any(|model| { + require_id(model, "allowed_model").is_err() || model.len() > MAX_IDENTITY_BYTES + }) || max_concurrent_requests == 0 || max_payload_bytes == 0 || max_output_tokens == 0 @@ -199,6 +201,9 @@ impl TransportService { now_unix_ms: u64, ) -> Result { validate_envelope(envelope, EnvelopeKind::Invocation)?; + if envelope.request_id.len() > MAX_IDENTITY_BYTES { + return Err(ContractError::Invalid("request_id").into()); + } let caller = self .callers .get(&envelope.caller_runtime_id) @@ -274,10 +279,16 @@ impl TransportService { }))) } + /// Records a result and seals it. `max_frame_bytes` bounds the serialized + /// envelope, which is what a frame carries: a result whose sealed envelope + /// does not fit is recorded as the typed `result_size` failure instead, so + /// no completed record is ever undeliverable and a retry replays bytes + /// that fit. pub fn complete( &mut self, claim: ExecutionClaim, result: TransportResult, + max_frame_bytes: usize, ) -> Result { if !matches!( &result.disposition, @@ -307,7 +318,22 @@ impl TransportService { .callers .get(&identity.caller_runtime_id) .ok_or(ServiceError::CallerNotAdmitted)?; - let response = encrypt_result(&result, &caller.security)?; + let mut response = encrypt_result(&result, &caller.security)?; + if !fits_frame(&response, max_frame_bytes)? { + response = encrypt_result( + &result_size_failure( + &claim.invocation.caller_runtime_id, + claim.invocation.request_id(), + &claim.invocation.request.model, + claim.invocation.native_request_sha256, + claim.invocation.provider_request_sha256, + ), + &caller.security, + )?; + if !fits_frame(&response, max_frame_bytes)? { + return Err(ServiceError::FrameBound); + } + } let replay_record = ReplayRecord { caller_runtime_id: identity.caller_runtime_id.clone(), request_id: identity.request_id.clone(), @@ -338,6 +364,48 @@ impl TransportService { } } +fn fits_frame(envelope: &TransportEnvelope, max_frame_bytes: usize) -> Result { + rmp_serde::to_vec(envelope) + .map(|bytes| bytes.len() <= max_frame_bytes) + .map_err(|_| ServiceError::Encoding) +} + +/// The transport named on the stand-in failure, fixed so its size is bounded. +const STAND_IN_TRANSPORT: &str = "model-connector"; + +/// The failed result that stands in for one whose sealed frame cannot be +/// carried: the same shape a provider failure takes, with nothing a provider +/// controls but the identities the invocation already bound. +pub(crate) fn result_size_failure( + caller_runtime_id: &str, + request_id: &str, + model: &str, + native_request_sha256: [u8; 32], + provider_request_sha256: [u8; 32], +) -> TransportResult { + TransportResult { + schema_id: RESULT_SCHEMA_ID.to_string(), + request_id: request_id.to_string(), + caller_runtime_id: caller_runtime_id.to_string(), + disposition: TransportDisposition::Transported { + events: Vec::new(), + receipt: Box::new(TransportReceipt { + schema_id: RECEIPT_SCHEMA_ID.to_string(), + request_id: request_id.to_string(), + caller_runtime_id: caller_runtime_id.to_string(), + native_request_sha256, + provider_request_sha256, + model: model.to_string(), + transport: STAND_IN_TRANSPORT.to_string(), + outcome: TransportOutcome::Failed { + failure_kind: "result_size".to_string(), + message: "provider result exceeded its byte bound".to_string(), + }, + }), + }, + } +} + fn replay_key(identity: &RequestIdentity) -> String { let mut hasher = Sha256::new(); hasher.update((identity.caller_runtime_id.len() as u64).to_be_bytes()); @@ -441,6 +509,7 @@ mod tests { }], receipt(&invocation), ), + 1 << 20, ) .unwrap(); drop(service); @@ -765,7 +834,9 @@ mod tests { let mut forged = (*claim).clone(); forged.invocation_sha256 = [1; 32]; assert_eq!( - service.complete(forged, result.clone()).unwrap_err(), + service + .complete(forged, result.clone(), 1 << 20) + .unwrap_err(), ServiceError::ActiveRequestMismatch ); @@ -783,8 +854,148 @@ mod tests { }; service.replay_store.put(key, &record).unwrap(); assert_eq!( - service.complete(*claim, result).unwrap_err(), + service.complete(*claim, result, 1 << 20).unwrap_err(), ServiceError::ActiveRequestMismatch ); } + + #[test] + fn identities_are_bounded_at_admission_and_on_the_request() { + let admit = |caller_id: String, model: String| { + CallerAdmission::new(caller_id, KEY, 1, [model], 4, 64 * 1024, 32_768) + }; + let at = "i".repeat(MAX_IDENTITY_BYTES); + let over = "i".repeat(MAX_IDENTITY_BYTES + 1); + assert!(admit(at.clone(), at.clone()).is_ok()); + assert!(matches!( + admit(over.clone(), at.clone()), + Err(ServiceError::InvalidAdmission) + )); + assert!(matches!( + admit(at.clone(), over.clone()), + Err(ServiceError::InvalidAdmission) + )); + + let store = TestStore::new(); + let mut service = open_service(&store, 8); + let ok = invocation_for(CALLER, &at); + assert!(matches!( + service.begin(&raw_envelope(&ok), 1_000).unwrap(), + TransportAdmission::Execute(_) + )); + let long = invocation_for(CALLER, &over); + assert_eq!( + service.begin(&raw_envelope(&long), 1_000).err(), + Some(ServiceError::Contract(ContractError::Invalid("request_id"))) + ); + } + + #[test] + fn a_frame_check_is_inclusive_of_the_serialized_envelope() { + let envelope = + encrypt_invocation(&invocation_for(CALLER, "sized"), &security(KEY)).unwrap(); + let size = rmp_serde::to_vec(&envelope).unwrap().len(); + assert_eq!(fits_frame(&envelope, size), Ok(true)); + assert_eq!(fits_frame(&envelope, size - 1), Ok(false)); + } + + fn claim_for( + service: &mut TransportService, + request_id: &str, + ) -> (Box, TransportInvocation) { + let invocation = invocation_for(CALLER, request_id); + match service.begin(&raw_envelope(&invocation), 1_000).unwrap() { + TransportAdmission::Execute(claim) => (claim, invocation), + TransportAdmission::Reply(_) => panic!("expected an execution claim"), + } + } + + fn text_result( + invocation: &TransportInvocation, + text: &str, + transport: &str, + ) -> TransportResult { + let mut receipt = receipt(invocation); + receipt.transport = transport.to_string(); + TransportResult::transported( + invocation, + vec![TransportEvent { + sequence: 0, + payload: TransportEventPayload::TextDelta { + text: text.to_string(), + }, + }], + receipt, + ) + } + + #[test] + fn a_result_whose_sealed_envelope_exceeds_the_frame_is_recorded_as_the_stand_in_failure() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + let (claim, invocation) = claim_for(&mut service, "stand-in"); + let big = text_result(&invocation, &"y".repeat(4000), &"t".repeat(5000)); + let response = service.complete(*claim, big, 2000).unwrap(); + assert!(rmp_serde::to_vec(&response).unwrap().len() <= 2000); + let stand_in = decrypt_result(&response, &security(KEY), &invocation).unwrap(); + assert_eq!( + stand_in, + result_size_failure( + CALLER, + "stand-in", + &invocation.request.model, + invocation.native_request_sha256, + invocation.provider_request_sha256, + ) + ); + let TransportDisposition::Transported { receipt, .. } = &stand_in.disposition else { + panic!("expected a transported failure"); + }; + assert_eq!(receipt.transport, "model-connector"); + + // The recorded replay is the stand-in's exact bytes. + let TransportAdmission::Reply(replay) = + service.begin(&raw_envelope(&invocation), 1_000).unwrap() + else { + panic!("expected a replay"); + }; + assert_eq!(replay, response); + } + + #[test] + fn a_result_whose_sealed_envelope_fits_is_recorded_as_the_backend_made_it() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + let (claim, invocation) = claim_for(&mut service, "fits"); + let small = text_result(&invocation, "done", "fixture-sse"); + let response = service.complete(*claim, small.clone(), 1 << 20).unwrap(); + assert_eq!( + decrypt_result(&response, &security(KEY), &invocation).unwrap(), + small + ); + } + + #[test] + fn nothing_is_recorded_when_not_even_the_stand_in_fits() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + let (claim, invocation) = claim_for(&mut service, "nothing-fits"); + let big = text_result(&invocation, "y", "fixture-sse"); + assert_eq!( + service.complete(*claim, big, 10).unwrap_err(), + ServiceError::FrameBound + ); + // Still active, never completed: the retry is told it is in flight. + let TransportAdmission::Reply(reply) = + service.begin(&raw_envelope(&invocation), 1_000).unwrap() + else { + panic!("expected a refusal"); + }; + assert_eq!( + decrypt_result(&reply, &security(KEY), &invocation) + .unwrap() + .disposition, + TransportDisposition::Refused(Refusal::InFlight) + ); + } } From 749a58985b8f4f1ed2853331df6df0c59a34a3da Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 05:32:26 +0100 Subject: [PATCH 11/22] Frame bound tests: pad the widest-payload invocation past the minimum bound, tightness only at the longest ids Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/envelope.rs | 10 ++++++---- crates/model-connector/src/serve.rs | 5 ++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index 2fbd820..77a077c 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -462,10 +462,12 @@ mod tests { assert!(real <= worst, "{real} > {worst}"); // Real ciphertext is random, so about half its bytes take two // bytes: the worst case is not wildly above what is written. - assert!( - worst <= real + sealed.ciphertext.len() + 64, - "{worst} {real}" - ); + if id.len() == MAX_IDENTITY_BYTES { + assert!( + worst <= real + sealed.ciphertext.len() + 64, + "{worst} {real}" + ); + } } } } diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 23b5bed..9b34596 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -737,7 +737,10 @@ mod tests { fn a_payload_at_the_widest_bound_its_frame_admits_is_carried_and_served() { let store = TestStore::new(); let backend = CountingBackend::new(true); - let invocation = fresh(&"q".repeat(256)); + let mut invocation = fresh(&"q".repeat(256)); + invocation.request.instructions = "x".repeat(5_000); + invocation.provider_request_sha256 = + crate::contract::provider_request_sha256(&invocation.request).unwrap(); let key = crate::fixtures::security(KEY); let sealed = crate::envelope::encrypt_invocation(&invocation, &key).unwrap(); let bound = sealed.ciphertext.len(); From d281343dc92d330207478198444fc42493b83d0a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 05:33:02 +0100 Subject: [PATCH 12/22] Golden fixture README: Ghostlight pins CodexConnector 68fe94b; src/lib.rs is identical in both Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/tests/fixtures/wire_v2/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/model-connector/tests/fixtures/wire_v2/README.md b/crates/model-connector/tests/fixtures/wire_v2/README.md index b9fa9e5..476b362 100644 --- a/crates/model-connector/tests/fixtures/wire_v2/README.md +++ b/crates/model-connector/tests/fixtures/wire_v2/README.md @@ -1,8 +1,9 @@ # Golden v2 wire fixtures Bytes of the v2 connector wire, produced once by CodexConnector at commit -`fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8` (the library Ghostlight pins), -and pinned by `tests/wire_golden_v2.rs` so that the kit cannot drift from +`fd7cec4176f4e3f9a5aaa75e77ba10ce5e1522f8`. Ghostlight pins CodexConnector +`68fe94b`, not that commit; `src/lib.rs`, the whole v2 wire, is identical in +both, so these bytes are Ghostlight's wire. They are pinned by `tests/wire_golden_v2.rs`, so the kit cannot drift from them. No CodexConnector code or dependency is part of this repository. Generator: `generator.rs` (not compiled here). Command, run in a clone of From bf07b2cd3823fd6dcb89786ecf4f14c6696e051e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 05:40:05 +0100 Subject: [PATCH 13/22] Frame arithmetic is daemon-gated with its only consumers; boundary tests are finite Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/envelope.rs | 4 ++++ crates/model-connector/src/serve.rs | 20 +++++++++++--------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index 77a077c..48e1548 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -29,6 +29,7 @@ pub struct TransportEnvelope { /// The longest caller id, request id or model name a service admits. The /// frame arithmetic below depends on it, so a longer one is refused up front. +#[cfg(feature = "daemon")] pub(crate) const MAX_IDENTITY_BYTES: usize = 256; /// The most bytes a serialized envelope holding `ciphertext_len` ciphertext @@ -36,6 +37,7 @@ pub(crate) const MAX_IDENTITY_BYTES: usize = 256; /// array of integers: a byte of 128 or more takes two bytes, so the worst case /// is twice the ciphertext, plus the array header and the envelope's other /// fields at their longest. Measured from the real encoding, not assumed. +#[cfg(feature = "daemon")] pub(crate) fn worst_frame_bytes(ciphertext_len: usize) -> usize { let shell = rmp_serde::to_vec(&TransportEnvelope { schema_id: ENVELOPE_SCHEMA_ID.to_string(), @@ -429,6 +431,7 @@ mod tests { assert!(matches!(write(&[], 1_000), Err(TransportFrameError::Size))); } + #[cfg(feature = "daemon")] #[test] fn the_worst_frame_is_the_real_encoding_of_an_all_high_ciphertext() { let long = "x".repeat(MAX_IDENTITY_BYTES); @@ -449,6 +452,7 @@ mod tests { } } + #[cfg(feature = "daemon")] #[test] fn a_sealed_envelope_never_exceeds_the_worst_frame_for_its_ciphertext() { let key = security("epiphany-distinct-test-key"); diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 9b34596..1c763e9 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -917,16 +917,21 @@ mod tests { assert_eq!(executions, 1); } + /// The largest result plaintext whose sealed frame fits at the worst encoding. + fn widest_guaranteed_plaintext(base: usize) -> usize { + (base..=FRAME) + .take_while(|plaintext| worst_frame_bytes(plaintext + GCM_TAG) <= FRAME) + .last() + .unwrap_or(base) + } + #[test] fn a_result_within_what_the_worst_encoding_leaves_is_delivered_intact() { // The largest plaintext whose sealed frame fits at the worst encoding is // always carried, whatever the ciphertext bytes turn out to be. for request_id in id_cases() { let base = empty_result_len(&request_id); - let mut plaintext = base; - while worst_frame_bytes(plaintext + 1 + GCM_TAG) <= FRAME { - plaintext += 1; - } + let plaintext = widest_guaranteed_plaintext(base); let (first, second, _) = call_twice(SizedBackend::new(plaintext - base), &request_id); let TransportDisposition::Transported { events, receipt } = &first.disposition else { panic!("expected a transported result"); @@ -946,11 +951,8 @@ mod tests { // way the caller gets an answer, and the retry gets the same one. for request_id in id_cases() { let base = empty_result_len(&request_id); - let mut plaintext = base; - while worst_frame_bytes(plaintext + GCM_TAG) <= FRAME { - plaintext += 1; - } - for extra in [0, 1, 2] { + let plaintext = widest_guaranteed_plaintext(base); + for extra in [1, 2, 3] { let backend = SizedBackend::new(plaintext + extra - base); let (first, second, executions) = call_twice(backend, &request_id); assert_eq!(first, second); From 2d383d781885a20d7b5860118da03ddb8447a3ed Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 06:05:09 +0100 Subject: [PATCH 14/22] Fix pass 4: long request ids are refused on the wire, frame rule is public, a FrameBound completion frees its slot, replays are re-measured against the frame - begin(): an id past MAX_IDENTITY_BYTES gets Refusal::Malformed naming the request by its SHA-256, so the reply fits the smallest frame; no dropped connection - frame_bytes_for_payload, payload_bytes_for_frame and MAX_IDENTITY_BYTES are public; ServeError::FrameBound states the frame needed - complete(): FrameBound releases the caller's slot - begin() takes the frame: a recorded result that no longer fits is replaced by the stand-in failure Co-Authored-By: Claude Opus 5.5 --- README.md | 7 + crates/model-connector/src/envelope.rs | 54 +++-- crates/model-connector/src/serve.rs | 107 +++++++-- crates/model-connector/src/service.rs | 288 ++++++++++++++++++++----- 4 files changed, 372 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 1155500..d01fb9e 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,13 @@ Crate `crates/model-connector`: `ProviderBackend` trait. A connector binary implements `ProviderBackend` for its provider and calls `serve`; the kit names no provider. +Frame arithmetic is public in `src/envelope.rs`: `frame_bytes_for_payload(n)` is the frame a +caller payload bound of `n` needs, `payload_bytes_for_frame(frame)` is the largest payload bound +a frame allows, and `MAX_IDENTITY_BYTES` (256) bounds caller ids, request ids and model names. +`serve` refuses a frame/payload pair that violates them (`ServeError::FrameBound` states the frame +needed); the smallest legal frame is `frame_bytes_for_payload(MIN_PAYLOAD_BYTES)`. A request id past +the bound is refused on the wire as `Refusal::Malformed`, the reply naming it by its SHA-256. + The default feature set compiles contract, envelope and client only. Schema ids are still `gamecult.codex.*.v2`; renaming them changes the wire and belongs to a later contract version. diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index 48e1548..a0df968 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -28,17 +28,18 @@ pub struct TransportEnvelope { } /// The longest caller id, request id or model name a service admits. The -/// frame arithmetic below depends on it, so a longer one is refused up front. -#[cfg(feature = "daemon")] -pub(crate) const MAX_IDENTITY_BYTES: usize = 256; +/// frame arithmetic below depends on it. A request whose id is longer is +/// refused on the wire as malformed. +pub const MAX_IDENTITY_BYTES: usize = 256; /// The most bytes a serialized envelope holding `ciphertext_len` ciphertext -/// bytes can occupy. The v2 wire serializes the ciphertext as a MessagePack -/// array of integers: a byte of 128 or more takes two bytes, so the worst case -/// is twice the ciphertext, plus the array header and the envelope's other +/// bytes can occupy, with ids at their longest: the frame a caller payload +/// bound (`CallerAdmission`'s `max_payload_bytes`, a bound on ciphertext +/// length) needs. The v2 wire serializes the ciphertext as a MessagePack array +/// of integers: a byte of 128 or more takes two bytes, so the worst case is +/// twice the ciphertext, plus the array header and the envelope's other /// fields at their longest. Measured from the real encoding, not assumed. -#[cfg(feature = "daemon")] -pub(crate) fn worst_frame_bytes(ciphertext_len: usize) -> usize { +pub fn frame_bytes_for_payload(ciphertext_len: usize) -> usize { let shell = rmp_serde::to_vec(&TransportEnvelope { schema_id: ENVELOPE_SCHEMA_ID.to_string(), caller_runtime_id: "x".repeat(MAX_IDENTITY_BYTES), @@ -60,6 +61,26 @@ pub(crate) fn worst_frame_bytes(ciphertext_len: usize) -> usize { .saturating_add(ciphertext_len.saturating_mul(2)) } +/// The largest payload bound whose worst-case frame fits `max_frame_bytes`: +/// the inverse of [`frame_bytes_for_payload`]. `None` when the frame cannot +/// carry even an empty envelope. +pub fn payload_bytes_for_frame(max_frame_bytes: usize) -> Option { + if frame_bytes_for_payload(0) > max_frame_bytes { + return None; + } + // frame_bytes_for_payload is increasing; the answer is at most half the frame. + let (mut low, mut high) = (0, max_frame_bytes / 2); + while low < high { + let mid = low + (high - low).div_ceil(2); + if frame_bytes_for_payload(mid) <= max_frame_bytes { + low = mid; + } else { + high = mid - 1; + } + } + Some(low) +} + pub fn encrypt_invocation( invocation: &TransportInvocation, security: &TransportKey, @@ -431,7 +452,6 @@ mod tests { assert!(matches!(write(&[], 1_000), Err(TransportFrameError::Size))); } - #[cfg(feature = "daemon")] #[test] fn the_worst_frame_is_the_real_encoding_of_an_all_high_ciphertext() { let long = "x".repeat(MAX_IDENTITY_BYTES); @@ -446,13 +466,12 @@ mod tests { }; assert_eq!( rmp_serde::to_vec(&envelope).unwrap().len(), - worst_frame_bytes(length), + frame_bytes_for_payload(length), "{length}" ); } } - #[cfg(feature = "daemon")] #[test] fn a_sealed_envelope_never_exceeds_the_worst_frame_for_its_ciphertext() { let key = security("epiphany-distinct-test-key"); @@ -462,7 +481,7 @@ mod tests { encrypt_message(&id, &id, EnvelopeKind::Result, &vec![0x41; plaintext], &key) .unwrap(); let real = rmp_serde::to_vec(&sealed).unwrap().len(); - let worst = worst_frame_bytes(sealed.ciphertext.len()); + let worst = frame_bytes_for_payload(sealed.ciphertext.len()); assert!(real <= worst, "{real} > {worst}"); // Real ciphertext is random, so about half its bytes take two // bytes: the worst case is not wildly above what is written. @@ -475,4 +494,15 @@ mod tests { } } } + + #[test] + fn the_payload_bound_a_frame_allows_inverts_the_frame_a_payload_needs() { + assert_eq!(payload_bytes_for_frame(frame_bytes_for_payload(0) - 1), None); + assert_eq!(payload_bytes_for_frame(frame_bytes_for_payload(0)), Some(0)); + for bytes in [1, 15, 16, 4096, 65_535, 65_536, 100_000, 2_000_000] { + let frame = frame_bytes_for_payload(bytes); + assert_eq!(payload_bytes_for_frame(frame), Some(bytes), "{bytes}"); + assert!(payload_bytes_for_frame(frame - 1).unwrap() < bytes, "{bytes}"); + } + } } diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 1c763e9..e27a501 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -12,7 +12,7 @@ use zeroize::Zeroizing; use crate::contract::{TransportInvocation, TransportResult}; use crate::envelope::{ - ServiceError, TransportEnvelope, TransportFrameError, read_transport_frame, worst_frame_bytes, + ServiceError, TransportEnvelope, TransportFrameError, read_transport_frame, frame_bytes_for_payload, write_transport_frame, }; use crate::service::{CallerAdmission, TransportAdmission, TransportService}; @@ -39,8 +39,9 @@ pub struct ServeLimits { pub max_expiry_skew_ms: u64, } -/// The smallest caller payload bound the base daemon admitted. -const MIN_PAYLOAD_BYTES: usize = 4096; +/// The smallest caller payload bound serve admits. The smallest legal frame is +/// `frame_bytes_for_payload(MIN_PAYLOAD_BYTES)`. +pub const MIN_PAYLOAD_BYTES: usize = 4096; impl ServeLimits { /// The bounds serve refuses before it opens anything: each limit nonzero, @@ -51,7 +52,15 @@ impl ServeLimits { /// An admitted payload therefore always fits, and `complete` is given the /// same frame bound to fit every result in. fn validate(&self, admissions: &[CallerAdmission]) -> Result<(), ServeError> { - if self.max_frame_bytes < worst_frame_bytes(MIN_PAYLOAD_BYTES) + let smallest = frame_bytes_for_payload(MIN_PAYLOAD_BYTES); + if self.max_frame_bytes > 0 && self.max_frame_bytes < smallest { + return Err(ServeError::FrameBound { + max_frame_bytes: self.max_frame_bytes, + payload_bytes: MIN_PAYLOAD_BYTES, + needed_frame_bytes: smallest, + }); + } + if self.max_frame_bytes == 0 || self.max_frame_bytes > u32::MAX as usize || self.max_connections == 0 || self.socket_timeout_ms == 0 @@ -71,11 +80,18 @@ impl ServeLimits { if self.max_connections < slots.max(8) { return Err(ServeError::InvalidConfig("connection capacity")); } - if admissions.iter().any(|caller| { - caller.max_payload_bytes() < MIN_PAYLOAD_BYTES - || worst_frame_bytes(caller.max_payload_bytes()) > self.max_frame_bytes - }) { - return Err(ServeError::InvalidConfig("caller admission")); + for caller in admissions { + if caller.max_payload_bytes() < MIN_PAYLOAD_BYTES { + return Err(ServeError::InvalidConfig("caller admission")); + } + let needed = frame_bytes_for_payload(caller.max_payload_bytes()); + if needed > self.max_frame_bytes { + return Err(ServeError::FrameBound { + max_frame_bytes: self.max_frame_bytes, + payload_bytes: caller.max_payload_bytes(), + needed_frame_bytes: needed, + }); + } } Ok(()) } @@ -211,7 +227,7 @@ fn execute_inference_frame( let admission = service .lock() .map_err(|_| ServeError::ServicePoisoned)? - .begin(&envelope, unix_ms()?)?; + .begin(&envelope, unix_ms()?, limits.max_frame_bytes)?; let response = match admission { TransportAdmission::Reply(response) => response, TransportAdmission::Execute(claim) => { @@ -274,6 +290,14 @@ impl Drop for ConnectionPermit { pub enum ServeError { #[error("invalid serve configuration field {0}")] InvalidConfig(&'static str), + #[error( + "max_frame_bytes {max_frame_bytes} cannot carry a {payload_bytes}-byte payload bound: at least {needed_frame_bytes} frame bytes are needed" + )] + FrameBound { + max_frame_bytes: usize, + payload_bytes: usize, + needed_frame_bytes: usize, + }, #[error("connector connection key is unavailable")] ConnectionKey(#[source] std::io::Error), #[error(transparent)] @@ -496,6 +520,45 @@ mod tests { assert_eq!(backend.executions.load(Ordering::SeqCst), 1); } + #[test] + fn serve_refuses_a_long_request_id_on_the_wire_without_dropping_the_connection() { + let store = TestStore::new(); + let backend = CountingBackend::new(true); + let (_client, endpoint) = start(backend.clone(), &store); + let security = crate::fixtures::security(KEY); + let send = |id: &str| { + let request = rmp_serde::to_vec( + &crate::envelope::encrypt_invocation(&fresh(id), &security).unwrap(), + ) + .unwrap(); + let mut stream = TcpStream::connect(endpoint).unwrap(); + write_transport_frame(&mut stream, &request, FRAME).unwrap(); + let reply = read_transport_frame(&mut stream, FRAME).unwrap(); + let envelope: TransportEnvelope = rmp_serde::from_slice(&reply).unwrap(); + let result: TransportResult = rmp_serde::from_slice( + &crate::envelope::decrypt_message(&envelope, &security).unwrap(), + ) + .unwrap(); + (reply.len(), result) + }; + let (_, served) = send(&"i".repeat(crate::envelope::MAX_IDENTITY_BYTES)); + assert!(matches!( + served.disposition, + TransportDisposition::Transported { .. } + )); + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + for length in [257, 258, 5_000, 60_000] { + let (size, result) = send(&"i".repeat(length)); + assert_eq!( + result.disposition, + TransportDisposition::Refused(Refusal::Malformed), + "{length}" + ); + assert!(size <= smallest_frame(), "{length}: {size}"); + } + assert_eq!(backend.executions.load(Ordering::SeqCst), 1); + } + #[test] fn serve_refuses_to_start_on_an_unready_backend() { let store = TestStore::new(); @@ -547,7 +610,7 @@ mod tests { /// The smallest frame serve admits: the smallest payload at its worst-case /// sealed encoding. fn smallest_frame() -> usize { - worst_frame_bytes(MIN_PAYLOAD_BYTES) + frame_bytes_for_payload(MIN_PAYLOAD_BYTES) } #[test] @@ -558,16 +621,15 @@ mod tests { )) .unwrap() .len(); - let worst = worst_frame_bytes(plaintext + GCM_TAG); + let worst = frame_bytes_for_payload(plaintext + GCM_TAG); assert!(worst <= smallest_frame(), "{worst} > {}", smallest_frame()); } #[test] fn serve_refuses_unbounded_limits_and_admits_the_widest_frame() { let store = TestStore::new(); - let mutations: [fn(&mut ServeLimits); 6] = [ + let mutations: [fn(&mut ServeLimits); 5] = [ |limits| limits.max_frame_bytes = 0, - |limits| limits.max_frame_bytes = smallest_frame() - 1, |limits| limits.max_frame_bytes = u32::MAX as usize + 1, |limits| limits.max_connections = 0, |limits| limits.socket_timeout_ms = 0, @@ -658,6 +720,7 @@ mod tests { ) { None => None, Some(Err(ServeError::InvalidConfig(field))) => Some(field), + Some(Err(ServeError::FrameBound { .. })) => Some("frame bound"), Some(other) => panic!("unexpected serve outcome {other:?}"), } } @@ -699,13 +762,13 @@ mod tests { let tightest = smallest_frame(); assert_eq!( refusal(frame(tightest - 1), payload(4096)), - Some("serve limits") + Some("frame bound") ); // The tightest legal pair: the smallest payload in the smallest frame. assert_eq!(refusal(frame(tightest), payload(4096)), None); assert_eq!( refusal(frame(tightest), payload(4097)), - Some("caller admission") + Some("frame bound") ); assert_eq!( refusal(frame(tightest), payload(4095)), @@ -715,14 +778,14 @@ mod tests { // a payload that is the whole frame cannot be carried. assert_eq!( refusal(frame(FRAME), payload(FRAME)), - Some("caller admission") + Some("frame bound") ); for bytes in [8192, 65_535, 65_536, 100_000] { - let widest = worst_frame_bytes(bytes); + let widest = frame_bytes_for_payload(bytes); assert_eq!(refusal(frame(widest), payload(bytes)), None, "{bytes}"); assert_eq!( refusal(frame(widest - 1), payload(bytes)), - Some("caller admission"), + Some("frame bound"), "{bytes}" ); } @@ -730,7 +793,7 @@ mod tests { admission("a", "key-a-distinct", 4, 8192), admission("b", "key-b-distinct", 4, FRAME), ]; - assert_eq!(refusal(frame(FRAME), mixed), Some("caller admission")); + assert_eq!(refusal(frame(FRAME), mixed), Some("frame bound")); } #[test] @@ -744,7 +807,7 @@ mod tests { let key = crate::fixtures::security(KEY); let sealed = crate::envelope::encrypt_invocation(&invocation, &key).unwrap(); let bound = sealed.ciphertext.len(); - let frame = worst_frame_bytes(bound); + let frame = frame_bytes_for_payload(bound); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let endpoint = listener.local_addr().unwrap(); let path = store.path.clone(); @@ -920,7 +983,7 @@ mod tests { /// The largest result plaintext whose sealed frame fits at the worst encoding. fn widest_guaranteed_plaintext(base: usize) -> usize { (base..=FRAME) - .take_while(|plaintext| worst_frame_bytes(plaintext + GCM_TAG) <= FRAME) + .take_while(|plaintext| frame_bytes_for_payload(plaintext + GCM_TAG) <= FRAME) .last() .unwrap_or(base) } diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index 6836df1..4ec8cb6 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -195,15 +195,19 @@ impl TransportService { }) } + /// Admits one request. `max_frame_bytes` is the frame every reply must + /// fit, the same bound `complete` is given: a recorded result that no + /// longer fits it (the service reopened at a smaller frame) is answered + /// with the typed `result_size` failure, which is recorded in its place. + /// A request id longer than `MAX_IDENTITY_BYTES` is refused as malformed; + /// the refusal names the id by its digest so it fits any legal frame. pub fn begin( &mut self, envelope: &TransportEnvelope, now_unix_ms: u64, + max_frame_bytes: usize, ) -> Result { validate_envelope(envelope, EnvelopeKind::Invocation)?; - if envelope.request_id.len() > MAX_IDENTITY_BYTES { - return Err(ContractError::Invalid("request_id").into()); - } let caller = self .callers .get(&envelope.caller_runtime_id) @@ -211,6 +215,12 @@ impl TransportService { let security = caller.security.clone(); let max_concurrent_requests = caller.max_concurrent_requests; let invocation = decrypt_invocation(envelope, &security)?; + if envelope.request_id.len() > MAX_IDENTITY_BYTES { + return Ok(TransportAdmission::Reply(encrypt_result( + &unnamed_refusal(&invocation, Refusal::Malformed), + &security, + )?)); + } if envelope.ciphertext.len() > caller.max_payload_bytes { return self.reply_refusal(&invocation, Refusal::Policy, &security); } @@ -240,8 +250,25 @@ impl TransportService { if record.invocation_sha256 != invocation_sha256 { return self.reply_refusal(&invocation, Refusal::ReplayConflict, &security); } - return match record.state { - ReplayState::Completed { response } => Ok(TransportAdmission::Reply(response)), + return match &record.state { + ReplayState::Completed { response } => { + if fits_frame(response, max_frame_bytes)? { + return Ok(TransportAdmission::Reply(response.clone())); + } + let stand_in = self.seal_stand_in(&invocation, &security, max_frame_bytes)?; + self.replay_store + .put( + replay_key(&identity), + &ReplayRecord { + state: ReplayState::Completed { + response: stand_in.clone(), + }, + ..record.clone() + }, + ) + .map_err(replay_store_error)?; + Ok(TransportAdmission::Reply(stand_in)) + } ReplayState::Active => { let refusal = if self.live.contains(&identity) { Refusal::InFlight @@ -318,22 +345,23 @@ impl TransportService { .callers .get(&identity.caller_runtime_id) .ok_or(ServiceError::CallerNotAdmitted)?; - let mut response = encrypt_result(&result, &caller.security)?; + let security = caller.security.clone(); + let mut response = encrypt_result(&result, &security)?; if !fits_frame(&response, max_frame_bytes)? { - response = encrypt_result( - &result_size_failure( - &claim.invocation.caller_runtime_id, - claim.invocation.request_id(), - &claim.invocation.request.model, - claim.invocation.native_request_sha256, - claim.invocation.provider_request_sha256, - ), - &caller.security, - )?; - if !fits_frame(&response, max_frame_bytes)? { - return Err(ServiceError::FrameBound); - } + response = match self.seal_stand_in(&claim.invocation, &security, max_frame_bytes) { + Ok(stand_in) => stand_in, + Err(error) => { + // Nothing is recorded, but the caller's slot is not held + // by a request that can no longer finish. + self.live.remove(&identity); + return Err(error); + } + }; } + let caller = self + .callers + .get(&identity.caller_runtime_id) + .ok_or(ServiceError::CallerNotAdmitted)?; let replay_record = ReplayRecord { caller_runtime_id: identity.caller_runtime_id.clone(), request_id: identity.request_id.clone(), @@ -351,6 +379,30 @@ impl TransportService { Ok(response) } + /// The `result_size` failure sealed for `invocation`, if it fits the frame. + fn seal_stand_in( + &self, + invocation: &TransportInvocation, + security: &TransportKey, + max_frame_bytes: usize, + ) -> Result { + let stand_in = encrypt_result( + &result_size_failure( + &invocation.caller_runtime_id, + invocation.request_id(), + &invocation.request.model, + invocation.native_request_sha256, + invocation.provider_request_sha256, + ), + security, + )?; + if fits_frame(&stand_in, max_frame_bytes)? { + Ok(stand_in) + } else { + Err(ServiceError::FrameBound) + } + } + fn reply_refusal( &self, invocation: &TransportInvocation, @@ -412,14 +464,27 @@ fn replay_key(identity: &RequestIdentity) -> String { hasher.update(identity.caller_runtime_id.as_bytes()); hasher.update((identity.request_id.len() as u64).to_be_bytes()); hasher.update(identity.request_id.as_bytes()); + hex(hasher.finalize().into()) +} + +fn hex(digest: [u8; 32]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; - let digest = hasher.finalize(); - let mut key = String::with_capacity(64); + let mut text = String::with_capacity(64); for byte in digest { - key.push(HEX[(byte >> 4) as usize] as char); - key.push(HEX[(byte & 0x0f) as usize] as char); + text.push(HEX[(byte >> 4) as usize] as char); + text.push(HEX[(byte & 0x0f) as usize] as char); + } + text +} + +/// A refusal for an invocation whose request id is too long to echo: it names +/// the request by the hex SHA-256 of the id (64 bytes), so the reply fits the +/// smallest legal frame whatever the id's length. +fn unnamed_refusal(invocation: &TransportInvocation, reason: Refusal) -> TransportResult { + TransportResult { + request_id: hex(Sha256::digest(invocation.request_id().as_bytes()).into()), + ..TransportResult::refused(invocation, reason) } - key } fn replay_store_error(error: impl std::fmt::Display) -> ServiceError { @@ -441,6 +506,8 @@ mod tests { use crate::fixtures::*; use crate::{contract::*, envelope::*}; + const FRAME: usize = 1 << 20; + #[test] fn service_refuses_shared_keys_and_isolates_caller_request_identities() { let store = TestStore::new(); @@ -464,7 +531,7 @@ mod tests { .begin( &encrypt_invocation(&epiphany, &security("epiphany-distinct-test-key")) .unwrap(), - 1_000, + 1_000, FRAME, ) .unwrap(), TransportAdmission::Execute(_) @@ -474,7 +541,7 @@ mod tests { .begin( &encrypt_invocation(&ghostlight, &security("ghostlight-distinct-test-key")) .unwrap(), - 1_000, + 1_000, FRAME, ) .unwrap(), TransportAdmission::Execute(_) @@ -488,7 +555,7 @@ mod tests { let security = security("epiphany-distinct-test-key"); let mut service = open_service(&store, 1); let claim = match service - .begin(&encrypt_invocation(&invocation, &security).unwrap(), 1_000) + .begin(&encrypt_invocation(&invocation, &security).unwrap(), 1_000, FRAME) .unwrap() { TransportAdmission::Execute(claim) => claim, @@ -516,7 +583,7 @@ mod tests { let mut service = open_service(&store, 1); let replay = match service - .begin(&encrypt_invocation(&invocation, &security).unwrap(), 1_000) + .begin(&encrypt_invocation(&invocation, &security).unwrap(), 1_000, FRAME) .unwrap() { TransportAdmission::Reply(response) => response, @@ -535,7 +602,7 @@ mod tests { TransportInvocation::new("epiphany-yggdrasil", 2_000, [7; 32], changed_request) .unwrap(); let refusal = match service - .begin(&encrypt_invocation(&conflicting, &security).unwrap(), 1_000) + .begin(&encrypt_invocation(&conflicting, &security).unwrap(), 1_000, FRAME) .unwrap() { TransportAdmission::Reply(response) => response, @@ -558,13 +625,13 @@ mod tests { let mut service = open_service(&store, 1); let first_envelope = encrypt_invocation(&first, &security).unwrap(); assert!(matches!( - service.begin(&first_envelope, 1_000).unwrap(), + service.begin(&first_envelope, 1_000, FRAME).unwrap(), TransportAdmission::Execute(_) )); for (invocation, expected) in [(&first, Refusal::InFlight), (&second, Refusal::Capacity)] { let response = match service - .begin(&encrypt_invocation(invocation, &security).unwrap(), 1_000) + .begin(&encrypt_invocation(invocation, &security).unwrap(), 1_000, FRAME) .unwrap() { TransportAdmission::Reply(response) => response, @@ -588,7 +655,7 @@ mod tests { let mut service = open_service(&store, 1); assert!(matches!( service - .begin(&encrypt_invocation(&first, &security).unwrap(), 1_000) + .begin(&encrypt_invocation(&first, &security).unwrap(), 1_000, FRAME) .unwrap(), TransportAdmission::Execute(_) )); @@ -596,7 +663,7 @@ mod tests { let mut restarted = open_service(&store, 1); let ambiguous = match restarted - .begin(&encrypt_invocation(&first, &security).unwrap(), 1_000) + .begin(&encrypt_invocation(&first, &security).unwrap(), 1_000, FRAME) .unwrap() { TransportAdmission::Reply(response) => response, @@ -610,7 +677,7 @@ mod tests { ); assert!(matches!( restarted - .begin(&encrypt_invocation(&second, &security).unwrap(), 1_000) + .begin(&encrypt_invocation(&second, &security).unwrap(), 1_000, FRAME) .unwrap(), TransportAdmission::Execute(_) )); @@ -646,7 +713,7 @@ mod tests { edit(&mut request); let invocation = TransportInvocation::new(CALLER, 2_000, [7; 32], request).unwrap(); let envelope = encrypt_invocation(&invocation, &security(KEY)).unwrap(); - (service.begin(&envelope, 1_000).unwrap(), invocation) + (service.begin(&envelope, 1_000, FRAME).unwrap(), invocation) } fn refusal_of(admission: TransportAdmission, invocation: &TransportInvocation) -> Refusal { @@ -727,7 +794,7 @@ mod tests { .unwrap(); TransportService::open(&store.path, [admission], 5_000) .unwrap() - .begin(&envelope, 1_000) + .begin(&envelope, 1_000, FRAME) }; assert!(matches!(admit(size), Ok(TransportAdmission::Execute(_)))); let refused = admit(size - 1).unwrap(); @@ -741,12 +808,12 @@ mod tests { let mut expired = invocation_for(CALLER, "expired"); expired.expires_at_unix_ms = 10; - let reply = service.begin(&raw_envelope(&expired), 1_000).unwrap(); + let reply = service.begin(&raw_envelope(&expired), 1_000, FRAME).unwrap(); assert_eq!(refusal_of(reply, &expired), Refusal::Expired); let mut substituted = invocation_for(CALLER, "substituted"); substituted.provider_request_sha256 = [9; 32]; - let reply = service.begin(&raw_envelope(&substituted), 1_000).unwrap(); + let reply = service.begin(&raw_envelope(&substituted), 1_000, FRAME).unwrap(); assert_eq!( refusal_of(reply, &substituted), Refusal::ProviderDigestSubstitution @@ -754,7 +821,7 @@ mod tests { let mut malformed = invocation_for(CALLER, "malformed"); malformed.native_request_sha256 = [0; 32]; - let reply = service.begin(&raw_envelope(&malformed), 1_000).unwrap(); + let reply = service.begin(&raw_envelope(&malformed), 1_000, FRAME).unwrap(); assert_eq!(refusal_of(reply, &malformed), Refusal::Malformed); } @@ -880,14 +947,46 @@ mod tests { let mut service = open_service(&store, 8); let ok = invocation_for(CALLER, &at); assert!(matches!( - service.begin(&raw_envelope(&ok), 1_000).unwrap(), + service.begin(&raw_envelope(&ok), 1_000, FRAME).unwrap(), TransportAdmission::Execute(_) )); - let long = invocation_for(CALLER, &over); - assert_eq!( - service.begin(&raw_envelope(&long), 1_000).err(), - Some(ServiceError::Contract(ContractError::Invalid("request_id"))) - ); + } + + #[test] + fn a_request_id_past_the_bound_is_refused_malformed_in_a_reply_that_fits_the_smallest_frame() { + let smallest = frame_bytes_for_payload(4096); + for length in [ + MAX_IDENTITY_BYTES + 1, + MAX_IDENTITY_BYTES + 2, + 5_000, + 100_000, + 400_000, + ] { + let store = TestStore::new(); + let mut service = open_service(&store, 1); + let id = "i".repeat(length); + let long = invocation_for(CALLER, &id); + let TransportAdmission::Reply(reply) = service + .begin(&raw_envelope(&long), 1_000, smallest) + .unwrap() + else { + panic!("an over-long id must be refused, not executed ({length})"); + }; + assert!( + rmp_serde::to_vec(&reply).unwrap().len() <= smallest, + "{length}" + ); + let result: TransportResult = + rmp_serde::from_slice(&decrypt_message(&reply, &security(KEY)).unwrap()).unwrap(); + assert_eq!( + result.disposition, + TransportDisposition::Refused(Refusal::Malformed) + ); + assert_eq!(result.request_id, reply.request_id); + assert_eq!(result.request_id.len(), 64, "the id is named, not echoed"); + // Nothing was recorded or held: the caller's one slot is free. + let (_claim, _) = claim_for(&mut service, "after-the-long-one"); + } } #[test] @@ -904,7 +1003,7 @@ mod tests { request_id: &str, ) -> (Box, TransportInvocation) { let invocation = invocation_for(CALLER, request_id); - match service.begin(&raw_envelope(&invocation), 1_000).unwrap() { + match service.begin(&raw_envelope(&invocation), 1_000, FRAME).unwrap() { TransportAdmission::Execute(claim) => (claim, invocation), TransportAdmission::Reply(_) => panic!("expected an execution claim"), } @@ -955,7 +1054,7 @@ mod tests { // The recorded replay is the stand-in's exact bytes. let TransportAdmission::Reply(replay) = - service.begin(&raw_envelope(&invocation), 1_000).unwrap() + service.begin(&raw_envelope(&invocation), 1_000, FRAME).unwrap() else { panic!("expected a replay"); }; @@ -975,6 +1074,94 @@ mod tests { ); } + #[test] + fn a_frame_bound_failure_releases_the_callers_slot() { + let store = TestStore::new(); + let mut service = open_service(&store, 1); + let (claim, invocation) = claim_for(&mut service, "wedge"); + let big = text_result(&invocation, "y", "fixture-sse"); + assert_eq!( + service.complete(*claim, big, 10).unwrap_err(), + ServiceError::FrameBound + ); + // The caller's only slot is free again, and the dead request is no + // longer in flight. + let (_next, _) = claim_for(&mut service, "after-wedge"); + let TransportAdmission::Reply(reply) = service + .begin(&raw_envelope(&invocation), 1_000, FRAME) + .unwrap() + else { + panic!("expected a refusal"); + }; + assert_eq!( + decrypt_result(&reply, &security(KEY), &invocation) + .unwrap() + .disposition, + TransportDisposition::Refused(Refusal::Indeterminate) + ); + } + + #[test] + fn a_recorded_result_replayed_after_a_restart_at_a_smaller_frame_is_re_measured() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + let (claim, invocation) = claim_for(&mut service, "restart"); + let big = text_result(&invocation, &"y".repeat(4000), "fixture-sse"); + let recorded = service.complete(*claim, big, FRAME).unwrap(); + assert!(rmp_serde::to_vec(&recorded).unwrap().len() > 2000); + drop(service); + + let mut service = open_service(&store, 8); + let TransportAdmission::Reply(same) = service + .begin(&raw_envelope(&invocation), 1_000, FRAME) + .unwrap() + else { + panic!("expected a replay"); + }; + assert_eq!(same, recorded, "a frame that still fits replays the record"); + drop(service); + + let mut service = open_service(&store, 8); + let TransportAdmission::Reply(smaller) = service + .begin(&raw_envelope(&invocation), 1_000, 2000) + .unwrap() + else { + panic!("expected a replay"); + }; + assert!(rmp_serde::to_vec(&smaller).unwrap().len() <= 2000); + assert_eq!( + decrypt_result(&smaller, &security(KEY), &invocation).unwrap(), + result_size_failure( + CALLER, + "restart", + &invocation.request.model, + invocation.native_request_sha256, + invocation.provider_request_sha256, + ) + ); + // The stand-in is what is recorded now, byte for byte, at any frame. + let TransportAdmission::Reply(again) = service + .begin(&raw_envelope(&invocation), 1_000, FRAME) + .unwrap() + else { + panic!("expected a replay"); + }; + assert_eq!(again, smaller); + } + + #[test] + fn a_replay_whose_stand_in_cannot_fit_either_is_a_frame_bound_failure() { + let store = TestStore::new(); + let mut service = open_service(&store, 8); + let (claim, invocation) = claim_for(&mut service, "no-room"); + let big = text_result(&invocation, "y", "fixture-sse"); + service.complete(*claim, big, FRAME).unwrap(); + assert_eq!( + service.begin(&raw_envelope(&invocation), 1_000, 10).err(), + Some(ServiceError::FrameBound) + ); + } + #[test] fn nothing_is_recorded_when_not_even_the_stand_in_fits() { let store = TestStore::new(); @@ -985,9 +1172,10 @@ mod tests { service.complete(*claim, big, 10).unwrap_err(), ServiceError::FrameBound ); - // Still active, never completed: the retry is told it is in flight. + // Still active, never completed, and no longer live: the retry is told the + // outcome is indeterminate. let TransportAdmission::Reply(reply) = - service.begin(&raw_envelope(&invocation), 1_000).unwrap() + service.begin(&raw_envelope(&invocation), 1_000, FRAME).unwrap() else { panic!("expected a refusal"); }; @@ -995,7 +1183,7 @@ mod tests { decrypt_result(&reply, &security(KEY), &invocation) .unwrap() .disposition, - TransportDisposition::Refused(Refusal::InFlight) + TransportDisposition::Refused(Refusal::Indeterminate) ); } } From 15a41343ce028960637e289da528d629b2587cc2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 06:05:16 +0100 Subject: [PATCH 15/22] rustfmt the connector kit Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/client.rs | 2 - crates/model-connector/src/contract.rs | 14 ++--- crates/model-connector/src/envelope.rs | 10 +++- crates/model-connector/src/fixtures.rs | 1 - crates/model-connector/src/serve.rs | 14 ++--- crates/model-connector/src/service.rs | 75 ++++++++++++++++++++------ 6 files changed, 73 insertions(+), 43 deletions(-) diff --git a/crates/model-connector/src/client.rs b/crates/model-connector/src/client.rs index ea8ea3d..b2997ce 100644 --- a/crates/model-connector/src/client.rs +++ b/crates/model-connector/src/client.rs @@ -70,7 +70,6 @@ impl ConnectorClient { } } - #[cfg(test)] mod client_endpoint_tests { use super::*; @@ -110,7 +109,6 @@ mod client_endpoint_tests { } } - #[derive(Debug, Error)] pub enum ConnectorClientError { #[error("invalid connector client configuration")] diff --git a/crates/model-connector/src/contract.rs b/crates/model-connector/src/contract.rs index 2f11a48..7ae5be5 100644 --- a/crates/model-connector/src/contract.rs +++ b/crates/model-connector/src/contract.rs @@ -289,10 +289,7 @@ impl TransportResult { } } - pub fn validate_against( - &self, - invocation: &TransportInvocation, - ) -> Result<(), ContractError> { + pub fn validate_against(&self, invocation: &TransportInvocation) -> Result<(), ContractError> { if self.schema_id != RESULT_SCHEMA_ID { return Err(ContractError::Schema); } @@ -363,10 +360,7 @@ pub struct TransportReceipt { } impl TransportReceipt { - pub fn validate_against( - &self, - invocation: &TransportInvocation, - ) -> Result<(), ContractError> { + pub fn validate_against(&self, invocation: &TransportInvocation) -> Result<(), ContractError> { if self.schema_id != RECEIPT_SCHEMA_ID { return Err(ContractError::Schema); } @@ -518,7 +512,6 @@ pub enum ContractError { Encoding, } - #[cfg(test)] mod tests { use super::*; @@ -618,8 +611,7 @@ mod tests { assert_eq!(result.validate_against(&invocation), Ok(())); let mut reordered = result; - let TransportDisposition::Transported { events, .. } = &mut reordered.disposition - else { + let TransportDisposition::Transported { events, .. } = &mut reordered.disposition else { unreachable!() }; events[0].sequence = 1; diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index a0df968..f8d4a22 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -497,12 +497,18 @@ mod tests { #[test] fn the_payload_bound_a_frame_allows_inverts_the_frame_a_payload_needs() { - assert_eq!(payload_bytes_for_frame(frame_bytes_for_payload(0) - 1), None); + assert_eq!( + payload_bytes_for_frame(frame_bytes_for_payload(0) - 1), + None + ); assert_eq!(payload_bytes_for_frame(frame_bytes_for_payload(0)), Some(0)); for bytes in [1, 15, 16, 4096, 65_535, 65_536, 100_000, 2_000_000] { let frame = frame_bytes_for_payload(bytes); assert_eq!(payload_bytes_for_frame(frame), Some(bytes), "{bytes}"); - assert!(payload_bytes_for_frame(frame - 1).unwrap() < bytes, "{bytes}"); + assert!( + payload_bytes_for_frame(frame - 1).unwrap() < bytes, + "{bytes}" + ); } } } diff --git a/crates/model-connector/src/fixtures.rs b/crates/model-connector/src/fixtures.rs index bb89264..9b48c1d 100644 --- a/crates/model-connector/src/fixtures.rs +++ b/crates/model-connector/src/fixtures.rs @@ -126,4 +126,3 @@ pub(crate) fn invocation_for(caller_runtime_id: &str, request_id: &str) -> Trans request.request_id = request_id.to_string(); TransportInvocation::new(caller_runtime_id, 2_000, [7; 32], request).unwrap() } - diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index e27a501..0ea0766 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -12,8 +12,8 @@ use zeroize::Zeroizing; use crate::contract::{TransportInvocation, TransportResult}; use crate::envelope::{ - ServiceError, TransportEnvelope, TransportFrameError, read_transport_frame, frame_bytes_for_payload, - write_transport_frame, + ServiceError, TransportEnvelope, TransportFrameError, frame_bytes_for_payload, + read_transport_frame, write_transport_frame, }; use crate::service::{CallerAdmission, TransportAdmission, TransportService}; @@ -766,20 +766,14 @@ mod tests { ); // The tightest legal pair: the smallest payload in the smallest frame. assert_eq!(refusal(frame(tightest), payload(4096)), None); - assert_eq!( - refusal(frame(tightest), payload(4097)), - Some("frame bound") - ); + assert_eq!(refusal(frame(tightest), payload(4097)), Some("frame bound")); assert_eq!( refusal(frame(tightest), payload(4095)), Some("caller admission") ); // The bound is the sealed frame at its worst encoding, not the payload: // a payload that is the whole frame cannot be carried. - assert_eq!( - refusal(frame(FRAME), payload(FRAME)), - Some("frame bound") - ); + assert_eq!(refusal(frame(FRAME), payload(FRAME)), Some("frame bound")); for bytes in [8192, 65_535, 65_536, 100_000] { let widest = frame_bytes_for_payload(bytes); assert_eq!(refusal(frame(widest), payload(bytes)), None, "{bytes}"); diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index 4ec8cb6..a2a7768 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -531,7 +531,8 @@ mod tests { .begin( &encrypt_invocation(&epiphany, &security("epiphany-distinct-test-key")) .unwrap(), - 1_000, FRAME, + 1_000, + FRAME, ) .unwrap(), TransportAdmission::Execute(_) @@ -541,7 +542,8 @@ mod tests { .begin( &encrypt_invocation(&ghostlight, &security("ghostlight-distinct-test-key")) .unwrap(), - 1_000, FRAME, + 1_000, + FRAME, ) .unwrap(), TransportAdmission::Execute(_) @@ -555,7 +557,11 @@ mod tests { let security = security("epiphany-distinct-test-key"); let mut service = open_service(&store, 1); let claim = match service - .begin(&encrypt_invocation(&invocation, &security).unwrap(), 1_000, FRAME) + .begin( + &encrypt_invocation(&invocation, &security).unwrap(), + 1_000, + FRAME, + ) .unwrap() { TransportAdmission::Execute(claim) => claim, @@ -583,7 +589,11 @@ mod tests { let mut service = open_service(&store, 1); let replay = match service - .begin(&encrypt_invocation(&invocation, &security).unwrap(), 1_000, FRAME) + .begin( + &encrypt_invocation(&invocation, &security).unwrap(), + 1_000, + FRAME, + ) .unwrap() { TransportAdmission::Reply(response) => response, @@ -602,7 +612,11 @@ mod tests { TransportInvocation::new("epiphany-yggdrasil", 2_000, [7; 32], changed_request) .unwrap(); let refusal = match service - .begin(&encrypt_invocation(&conflicting, &security).unwrap(), 1_000, FRAME) + .begin( + &encrypt_invocation(&conflicting, &security).unwrap(), + 1_000, + FRAME, + ) .unwrap() { TransportAdmission::Reply(response) => response, @@ -631,7 +645,11 @@ mod tests { for (invocation, expected) in [(&first, Refusal::InFlight), (&second, Refusal::Capacity)] { let response = match service - .begin(&encrypt_invocation(invocation, &security).unwrap(), 1_000, FRAME) + .begin( + &encrypt_invocation(invocation, &security).unwrap(), + 1_000, + FRAME, + ) .unwrap() { TransportAdmission::Reply(response) => response, @@ -655,7 +673,11 @@ mod tests { let mut service = open_service(&store, 1); assert!(matches!( service - .begin(&encrypt_invocation(&first, &security).unwrap(), 1_000, FRAME) + .begin( + &encrypt_invocation(&first, &security).unwrap(), + 1_000, + FRAME + ) .unwrap(), TransportAdmission::Execute(_) )); @@ -663,7 +685,11 @@ mod tests { let mut restarted = open_service(&store, 1); let ambiguous = match restarted - .begin(&encrypt_invocation(&first, &security).unwrap(), 1_000, FRAME) + .begin( + &encrypt_invocation(&first, &security).unwrap(), + 1_000, + FRAME, + ) .unwrap() { TransportAdmission::Reply(response) => response, @@ -677,7 +703,11 @@ mod tests { ); assert!(matches!( restarted - .begin(&encrypt_invocation(&second, &security).unwrap(), 1_000, FRAME) + .begin( + &encrypt_invocation(&second, &security).unwrap(), + 1_000, + FRAME + ) .unwrap(), TransportAdmission::Execute(_) )); @@ -808,12 +838,16 @@ mod tests { let mut expired = invocation_for(CALLER, "expired"); expired.expires_at_unix_ms = 10; - let reply = service.begin(&raw_envelope(&expired), 1_000, FRAME).unwrap(); + let reply = service + .begin(&raw_envelope(&expired), 1_000, FRAME) + .unwrap(); assert_eq!(refusal_of(reply, &expired), Refusal::Expired); let mut substituted = invocation_for(CALLER, "substituted"); substituted.provider_request_sha256 = [9; 32]; - let reply = service.begin(&raw_envelope(&substituted), 1_000, FRAME).unwrap(); + let reply = service + .begin(&raw_envelope(&substituted), 1_000, FRAME) + .unwrap(); assert_eq!( refusal_of(reply, &substituted), Refusal::ProviderDigestSubstitution @@ -821,7 +855,9 @@ mod tests { let mut malformed = invocation_for(CALLER, "malformed"); malformed.native_request_sha256 = [0; 32]; - let reply = service.begin(&raw_envelope(&malformed), 1_000, FRAME).unwrap(); + let reply = service + .begin(&raw_envelope(&malformed), 1_000, FRAME) + .unwrap(); assert_eq!(refusal_of(reply, &malformed), Refusal::Malformed); } @@ -1003,7 +1039,10 @@ mod tests { request_id: &str, ) -> (Box, TransportInvocation) { let invocation = invocation_for(CALLER, request_id); - match service.begin(&raw_envelope(&invocation), 1_000, FRAME).unwrap() { + match service + .begin(&raw_envelope(&invocation), 1_000, FRAME) + .unwrap() + { TransportAdmission::Execute(claim) => (claim, invocation), TransportAdmission::Reply(_) => panic!("expected an execution claim"), } @@ -1053,8 +1092,9 @@ mod tests { assert_eq!(receipt.transport, "model-connector"); // The recorded replay is the stand-in's exact bytes. - let TransportAdmission::Reply(replay) = - service.begin(&raw_envelope(&invocation), 1_000, FRAME).unwrap() + let TransportAdmission::Reply(replay) = service + .begin(&raw_envelope(&invocation), 1_000, FRAME) + .unwrap() else { panic!("expected a replay"); }; @@ -1174,8 +1214,9 @@ mod tests { ); // Still active, never completed, and no longer live: the retry is told the // outcome is indeterminate. - let TransportAdmission::Reply(reply) = - service.begin(&raw_envelope(&invocation), 1_000, FRAME).unwrap() + let TransportAdmission::Reply(reply) = service + .begin(&raw_envelope(&invocation), 1_000, FRAME) + .unwrap() else { panic!("expected a refusal"); }; From 41c75bb9e7c1afe252b1a6a1efc02ba3e0a56f0c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 06:16:11 +0100 Subject: [PATCH 16/22] payload_bytes_for_frame: closed form instead of a search; serve checks the plain limits before the frame Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/envelope.rs | 28 +++++++++++++------------- crates/model-connector/src/serve.rs | 16 +++++++-------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/crates/model-connector/src/envelope.rs b/crates/model-connector/src/envelope.rs index f8d4a22..5400eac 100644 --- a/crates/model-connector/src/envelope.rs +++ b/crates/model-connector/src/envelope.rs @@ -65,20 +65,20 @@ pub fn frame_bytes_for_payload(ciphertext_len: usize) -> usize { /// the inverse of [`frame_bytes_for_payload`]. `None` when the frame cannot /// carry even an empty envelope. pub fn payload_bytes_for_frame(max_frame_bytes: usize) -> Option { - if frame_bytes_for_payload(0) > max_frame_bytes { - return None; - } - // frame_bytes_for_payload is increasing; the answer is at most half the frame. - let (mut low, mut high) = (0, max_frame_bytes / 2); - while low < high { - let mid = low + (high - low).div_ceil(2); - if frame_bytes_for_payload(mid) <= max_frame_bytes { - low = mid; - } else { - high = mid - 1; - } - } - Some(low) + // frame_bytes_for_payload(0) is the shell plus a one-byte array header. + let fixed = frame_bytes_for_payload(0) - 1; + let room = max_frame_bytes.checked_sub(fixed)?; + // Each array header width bounds the payloads it can describe; the + // widest payload any width admits is the answer. + let by_header = |header: usize, cap: usize| room.checked_sub(header).map(|r| (r / 2).min(cap)); + [ + by_header(5, usize::MAX), + by_header(3, 65_535), + by_header(1, 15), + ] + .into_iter() + .flatten() + .max() } pub fn encrypt_invocation( diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 0ea0766..9e3c9ff 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -52,14 +52,6 @@ impl ServeLimits { /// An admitted payload therefore always fits, and `complete` is given the /// same frame bound to fit every result in. fn validate(&self, admissions: &[CallerAdmission]) -> Result<(), ServeError> { - let smallest = frame_bytes_for_payload(MIN_PAYLOAD_BYTES); - if self.max_frame_bytes > 0 && self.max_frame_bytes < smallest { - return Err(ServeError::FrameBound { - max_frame_bytes: self.max_frame_bytes, - payload_bytes: MIN_PAYLOAD_BYTES, - needed_frame_bytes: smallest, - }); - } if self.max_frame_bytes == 0 || self.max_frame_bytes > u32::MAX as usize || self.max_connections == 0 @@ -68,6 +60,14 @@ impl ServeLimits { { return Err(ServeError::InvalidConfig("serve limits")); } + let smallest = frame_bytes_for_payload(MIN_PAYLOAD_BYTES); + if self.max_frame_bytes < smallest { + return Err(ServeError::FrameBound { + max_frame_bytes: self.max_frame_bytes, + payload_bytes: MIN_PAYLOAD_BYTES, + needed_frame_bytes: smallest, + }); + } if admissions.is_empty() { return Err(ServeError::InvalidConfig("callers")); } From ec9722a2d82f5258e531cac18bf0fcc2753c1289 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 06:40:18 +0100 Subject: [PATCH 17/22] Long request ids: the Malformed refusal echoes the id; the client refuses locally (long-id-refusal-unreadable) Naming a request by the SHA-256 of its id is a wire meaning v2 does not have: no v2 client could read that reply. begin now seals Refused(Malformed) with the request's own id and drops the connection (FrameBound) only when that sealed refusal cannot fit the frame. ConnectorClient refuses an id past MAX_IDENTITY_BYTES before connecting, with the typed RequestIdTooLong error. Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/client.rs | 33 +++++++++++- crates/model-connector/src/serve.rs | 19 ++++--- crates/model-connector/src/service.rs | 74 +++++++++++++++++---------- 3 files changed, 87 insertions(+), 39 deletions(-) diff --git a/crates/model-connector/src/client.rs b/crates/model-connector/src/client.rs index b2997ce..a1cbcb3 100644 --- a/crates/model-connector/src/client.rs +++ b/crates/model-connector/src/client.rs @@ -6,8 +6,8 @@ use zeroize::Zeroizing; use crate::contract::{TransportInvocation, TransportKey, TransportResult}; use crate::envelope::{ - ServiceError, TransportFrameError, decrypt_result, encrypt_invocation, read_transport_frame, - write_transport_frame, + MAX_IDENTITY_BYTES, ServiceError, TransportFrameError, decrypt_result, encrypt_invocation, + read_transport_frame, write_transport_frame, }; #[derive(Clone)] @@ -48,6 +48,9 @@ impl ConnectorClient { &self, invocation: &TransportInvocation, ) -> Result { + if invocation.request_id().len() > MAX_IDENTITY_BYTES { + return Err(ConnectorClientError::RequestIdTooLong); + } let request = rmp_serde::to_vec(&encrypt_invocation(invocation, &self.security)?) .map_err(|_| ConnectorClientError::Encoding)?; let connect_timeout = self @@ -119,6 +122,8 @@ pub enum ConnectorClientError { FrameSize, #[error("connector MessagePack encoding failed")] Encoding, + #[error("request id is longer than the connector identity bound")] + RequestIdTooLong, #[error(transparent)] Transport(#[from] ServiceError), } @@ -174,4 +179,28 @@ mod fixture_tests { ); server.join().unwrap(); } + + #[test] + fn an_over_long_request_id_is_refused_locally_before_any_byte_is_sent() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let client = ConnectorClient::new( + listener.local_addr().unwrap(), + "epiphany-distinct-test-key", + 64 * 1024, + Some(Duration::from_secs(1)), + ) + .unwrap(); + let mut long = invocation(); + long.request.request_id = "r".repeat(MAX_IDENTITY_BYTES + 1); + assert!(matches!( + client.execute(&long), + Err(ConnectorClientError::RequestIdTooLong) + )); + assert_eq!( + listener.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock, + "the client must not even connect" + ); + } } diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 9e3c9ff..6bac7c1 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -521,40 +521,39 @@ mod tests { } #[test] - fn serve_refuses_a_long_request_id_on_the_wire_without_dropping_the_connection() { + fn serve_refuses_a_long_request_id_on_the_wire_echoing_the_id_without_dropping_the_connection() + { let store = TestStore::new(); let backend = CountingBackend::new(true); let (_client, endpoint) = start(backend.clone(), &store); let security = crate::fixtures::security(KEY); + // Read the way ConnectorClient reads a reply: decrypt_result, which + // requires the result to name the request by its own id. let send = |id: &str| { + let invocation = fresh(id); let request = rmp_serde::to_vec( - &crate::envelope::encrypt_invocation(&fresh(id), &security).unwrap(), + &crate::envelope::encrypt_invocation(&invocation, &security).unwrap(), ) .unwrap(); let mut stream = TcpStream::connect(endpoint).unwrap(); write_transport_frame(&mut stream, &request, FRAME).unwrap(); let reply = read_transport_frame(&mut stream, FRAME).unwrap(); let envelope: TransportEnvelope = rmp_serde::from_slice(&reply).unwrap(); - let result: TransportResult = rmp_serde::from_slice( - &crate::envelope::decrypt_message(&envelope, &security).unwrap(), - ) - .unwrap(); - (reply.len(), result) + crate::envelope::decrypt_result(&envelope, &security, &invocation).unwrap() }; - let (_, served) = send(&"i".repeat(crate::envelope::MAX_IDENTITY_BYTES)); + let served = send(&"i".repeat(crate::envelope::MAX_IDENTITY_BYTES)); assert!(matches!( served.disposition, TransportDisposition::Transported { .. } )); assert_eq!(backend.executions.load(Ordering::SeqCst), 1); for length in [257, 258, 5_000, 60_000] { - let (size, result) = send(&"i".repeat(length)); + let result = send(&"i".repeat(length)); assert_eq!( result.disposition, TransportDisposition::Refused(Refusal::Malformed), "{length}" ); - assert!(size <= smallest_frame(), "{length}: {size}"); } assert_eq!(backend.executions.load(Ordering::SeqCst), 1); } diff --git a/crates/model-connector/src/service.rs b/crates/model-connector/src/service.rs index a2a7768..e377c19 100644 --- a/crates/model-connector/src/service.rs +++ b/crates/model-connector/src/service.rs @@ -199,8 +199,9 @@ impl TransportService { /// fit, the same bound `complete` is given: a recorded result that no /// longer fits it (the service reopened at a smaller frame) is answered /// with the typed `result_size` failure, which is recorded in its place. - /// A request id longer than `MAX_IDENTITY_BYTES` is refused as malformed; - /// the refusal names the id by its digest so it fits any legal frame. + /// A request id longer than `MAX_IDENTITY_BYTES` is refused as malformed, + /// the refusal echoing the id like every other. The connection is dropped + /// (`FrameBound`) only when that sealed refusal does not fit `max_frame_bytes`. pub fn begin( &mut self, envelope: &TransportEnvelope, @@ -216,10 +217,15 @@ impl TransportService { let max_concurrent_requests = caller.max_concurrent_requests; let invocation = decrypt_invocation(envelope, &security)?; if envelope.request_id.len() > MAX_IDENTITY_BYTES { - return Ok(TransportAdmission::Reply(encrypt_result( - &unnamed_refusal(&invocation, Refusal::Malformed), + let refusal = encrypt_result( + &TransportResult::refused(&invocation, Refusal::Malformed), &security, - )?)); + )?; + return if fits_frame(&refusal, max_frame_bytes)? { + Ok(TransportAdmission::Reply(refusal)) + } else { + Err(ServiceError::FrameBound) + }; } if envelope.ciphertext.len() > caller.max_payload_bytes { return self.reply_refusal(&invocation, Refusal::Policy, &security); @@ -477,16 +483,6 @@ fn hex(digest: [u8; 32]) -> String { text } -/// A refusal for an invocation whose request id is too long to echo: it names -/// the request by the hex SHA-256 of the id (64 bytes), so the reply fits the -/// smallest legal frame whatever the id's length. -fn unnamed_refusal(invocation: &TransportInvocation, reason: Refusal) -> TransportResult { - TransportResult { - request_id: hex(Sha256::digest(invocation.request_id().as_bytes()).into()), - ..TransportResult::refused(invocation, reason) - } -} - fn replay_store_error(error: impl std::fmt::Display) -> ServiceError { ServiceError::ReplayStore(error.to_string()) } @@ -989,42 +985,66 @@ mod tests { } #[test] - fn a_request_id_past_the_bound_is_refused_malformed_in_a_reply_that_fits_the_smallest_frame() { - let smallest = frame_bytes_for_payload(4096); + fn a_request_id_past_the_bound_is_refused_malformed_echoing_the_id() { for length in [ MAX_IDENTITY_BYTES + 1, MAX_IDENTITY_BYTES + 2, 5_000, 100_000, - 400_000, ] { let store = TestStore::new(); let mut service = open_service(&store, 1); let id = "i".repeat(length); let long = invocation_for(CALLER, &id); - let TransportAdmission::Reply(reply) = service - .begin(&raw_envelope(&long), 1_000, smallest) - .unwrap() + let TransportAdmission::Reply(reply) = + service.begin(&raw_envelope(&long), 1_000, FRAME).unwrap() else { panic!("an over-long id must be refused, not executed ({length})"); }; - assert!( - rmp_serde::to_vec(&reply).unwrap().len() <= smallest, - "{length}" - ); + assert_eq!(reply.request_id, id, "{length}"); let result: TransportResult = rmp_serde::from_slice(&decrypt_message(&reply, &security(KEY)).unwrap()).unwrap(); assert_eq!( result.disposition, TransportDisposition::Refused(Refusal::Malformed) ); - assert_eq!(result.request_id, reply.request_id); - assert_eq!(result.request_id.len(), 64, "the id is named, not echoed"); + assert_eq!(result.request_id, id); + result.validate_against(&long).unwrap(); // Nothing was recorded or held: the caller's one slot is free. let (_claim, _) = claim_for(&mut service, "after-the-long-one"); } } + #[test] + fn an_over_long_id_drops_the_connection_only_when_its_refusal_cannot_fit() { + let id = "i".repeat(5_000); + let long = invocation_for(CALLER, &id); + let sealed = encrypt_result( + &TransportResult::refused(&long, Refusal::Malformed), + &security(KEY), + ) + .unwrap(); + // The sealed size varies with the random nonce and ciphertext bytes + // (a byte of 128 or more takes two), so bracket it: the ciphertext + // plus the id is a floor, twice the ciphertext plus the id and the + // envelope's other fields is a ceiling. + let floor = sealed.ciphertext.len() + id.len(); + let ceiling = 2 * sealed.ciphertext.len() + id.len() + 512; + let store = TestStore::new(); + let mut service = open_service(&store, 1); + assert!(matches!( + service.begin(&raw_envelope(&long), 1_000, ceiling).unwrap(), + TransportAdmission::Reply(_) + )); + assert_eq!( + service + .begin(&raw_envelope(&long), 1_000, floor - 1) + .unwrap_err(), + ServiceError::FrameBound + ); + let (_claim, _) = claim_for(&mut service, "after-the-drop"); + } + #[test] fn a_frame_check_is_inclusive_of_the_serialized_envelope() { let envelope = From 89b56b5300f3b91bd0c77d875cc11ba143df55ee Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 06:40:24 +0100 Subject: [PATCH 18/22] serve: pin that serve hands begin its own max frame (serve-frame-wiring-unpinned) A record completed under a wide frame and replayed by a serve at a narrower one must come back as the typed stand-in; passing begin any other frame (usize::MAX) delivers the wide record the narrow write cannot carry. Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/serve.rs | 39 +++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 6bac7c1..e307fb6 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -558,6 +558,45 @@ mod tests { assert_eq!(backend.executions.load(Ordering::SeqCst), 1); } + #[test] + fn serve_measures_a_replayed_record_against_its_own_max_frame() { + // A record completed under a wide frame, replayed by a serve at a + // narrower one, must come back as the typed stand-in that fits the + // narrower frame; handing `begin` any other frame delivers the wide + // record, which the narrow write then cannot carry. + let store = TestStore::new(); + let invocation = fresh("request-narrowed"); + let wide = SizedBackend::new(80_000); + let (client, _) = start_with(wide.clone(), &store, vec![caller(CALLER, KEY, 4)]); + let first = client.execute(&invocation).unwrap(); + assert!(!failed_result_size(&first)); + + let narrow = 100_000; + let narrowed = ServeLimits { + max_frame_bytes: narrow, + ..limits() + }; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let path = store.path.clone(); + let backend = SizedBackend::new(80_000); + let served = backend.clone(); + thread::spawn(move || { + serve( + listener, + narrowed, + vec![caller(CALLER, KEY, 4)], + &path, + served, + ) + }); + let client = + ConnectorClient::new(endpoint, KEY, narrow, Some(Duration::from_secs(5))).unwrap(); + let replayed = client.execute(&invocation).unwrap(); + assert!(failed_result_size(&replayed)); + assert_eq!(backend.executions.load(Ordering::SeqCst), 0); + } + #[test] fn serve_refuses_to_start_on_an_unready_backend() { let store = TestStore::new(); From 925d055bb535d9913219be070f071329b2cdb6ed Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 06:40:29 +0100 Subject: [PATCH 19/22] ServeError::FrameBound: no run of spaces in the message; the text is pinned (frame-bound-display-spaces) Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/serve.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index e307fb6..a6b8faf 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -291,7 +291,7 @@ pub enum ServeError { #[error("invalid serve configuration field {0}")] InvalidConfig(&'static str), #[error( - "max_frame_bytes {max_frame_bytes} cannot carry a {payload_bytes}-byte payload bound: at least {needed_frame_bytes} frame bytes are needed" + "max_frame_bytes {max_frame_bytes} cannot carry a {payload_bytes}-byte payload bound: at least {needed_frame_bytes} frame bytes are needed" )] FrameBound { max_frame_bytes: usize, @@ -597,6 +597,19 @@ mod tests { assert_eq!(backend.executions.load(Ordering::SeqCst), 0); } + #[test] + fn frame_bound_error_states_the_frame_needed() { + assert_eq!( + ServeError::FrameBound { + max_frame_bytes: 1000, + payload_bytes: 2000, + needed_frame_bytes: 4000, + } + .to_string(), + "max_frame_bytes 1000 cannot carry a 2000-byte payload bound: at least 4000 frame bytes are needed" + ); + } + #[test] fn serve_refuses_to_start_on_an_unready_backend() { let store = TestStore::new(); From 953eef26e45ef3e33af123733cb47758c55edb50 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 06:40:39 +0100 Subject: [PATCH 20/22] README: Verify names the golden fixture test; the long-id refusal echoes the id (readme-stale-parity-test) Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d01fb9e..433f540 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ caller payload bound of `n` needs, `payload_bytes_for_frame(frame)` is the large a frame allows, and `MAX_IDENTITY_BYTES` (256) bounds caller ids, request ids and model names. `serve` refuses a frame/payload pair that violates them (`ServeError::FrameBound` states the frame needed); the smallest legal frame is `frame_bytes_for_payload(MIN_PAYLOAD_BYTES)`. A request id past -the bound is refused on the wire as `Refusal::Malformed`, the reply naming it by its SHA-256. +the bound is refused on the wire as `Refusal::Malformed`, the reply echoing the id (the connection is dropped only if that reply cannot fit the frame); `ConnectorClient` refuses such an id locally with `RequestIdTooLong`. The default feature set compiles contract, envelope and client only. Schema ids are still `gamecult.codex.*.v2`; renaming them changes the wire and belongs to a later @@ -42,8 +42,8 @@ cargo test --locked --workspace cargo test --locked --workspace --features model-connector/daemon ``` -`tests/wire_parity_v2.rs` pins the kit byte-for-byte to CodexConnector fd7cec4 in both -directions (it fetches that revision as a dev-dependency). +`crates/model-connector/tests/wire_golden_v2.rs` pins the kit byte-for-byte to the golden v2 +fixtures in `crates/model-connector/tests/fixtures/wire_v2`. ## Provenance and license From 2dc8d070e0981bfa92149ab1c7cb664b47c83bc4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 06:43:36 +0100 Subject: [PATCH 21/22] serve wiring test: complete the wide record through the service, since serve holds the replay store (serve-frame-wiring-unpinned) Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/serve.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index a6b8faf..4bd29a4 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -566,10 +566,26 @@ mod tests { // record, which the narrow write then cannot carry. let store = TestStore::new(); let invocation = fresh("request-narrowed"); - let wide = SizedBackend::new(80_000); - let (client, _) = start_with(wide.clone(), &store, vec![caller(CALLER, KEY, 4)]); - let first = client.execute(&invocation).unwrap(); - assert!(!failed_result_size(&first)); + // The wide completion goes straight through the service: serve holds + // the replay store for as long as it runs, so a second serve cannot + // open it while the first lives. + let mut service = + TransportService::open(&store.path, vec![caller(CALLER, KEY, 4)], 300_000).unwrap(); + let envelope = + crate::envelope::encrypt_invocation(&invocation, &crate::fixtures::security(KEY)) + .unwrap(); + let TransportAdmission::Execute(claim) = service.begin(&envelope, now_ms(), FRAME).unwrap() + else { + panic!("a fresh request must execute"); + }; + service + .complete( + *claim, + sized_result(&invocation, 80_000, "fixture-sse"), + FRAME, + ) + .unwrap(); + drop(service); let narrow = 100_000; let narrowed = ServeLimits { From ba1f7eb10b3a254fac6d3c8d6fe50abd92304ba6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 06:45:57 +0100 Subject: [PATCH 22/22] serve wiring test: frames sized to the caller's payload bound, so serve starts (serve-frame-wiring-unpinned) Co-Authored-By: Claude Opus 5.5 --- crates/model-connector/src/serve.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/model-connector/src/serve.rs b/crates/model-connector/src/serve.rs index 4bd29a4..8eda706 100644 --- a/crates/model-connector/src/serve.rs +++ b/crates/model-connector/src/serve.rs @@ -574,20 +574,21 @@ mod tests { let envelope = crate::envelope::encrypt_invocation(&invocation, &crate::fixtures::security(KEY)) .unwrap(); - let TransportAdmission::Execute(claim) = service.begin(&envelope, now_ms(), FRAME).unwrap() + let TransportAdmission::Execute(claim) = + service.begin(&envelope, now_ms(), 1 << 20).unwrap() else { panic!("a fresh request must execute"); }; service .complete( *claim, - sized_result(&invocation, 80_000, "fixture-sse"), - FRAME, + sized_result(&invocation, 150_000, "fixture-sse"), + 1 << 20, ) .unwrap(); drop(service); - let narrow = 100_000; + let narrow = 140_000; let narrowed = ServeLimits { max_frame_bytes: narrow, ..limits() @@ -595,7 +596,7 @@ mod tests { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let endpoint = listener.local_addr().unwrap(); let path = store.path.clone(); - let backend = SizedBackend::new(80_000); + let backend = SizedBackend::new(150_000); let served = backend.clone(); thread::spawn(move || { serve(