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 <noreply@anthropic.com>
This commit is contained in:
parent
1cf879f1e0
commit
76e4e4f269
9 changed files with 2150 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
861
Cargo.lock
generated
Normal file
861
Cargo.lock
generated
Normal file
|
|
@ -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"
|
||||
9
Cargo.toml
Normal file
9
Cargo.toml
Normal file
|
|
@ -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"
|
||||
26
crates/model-connector/Cargo.toml
Normal file
26
crates/model-connector/Cargo.toml
Normal file
|
|
@ -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 }
|
||||
179
crates/model-connector/src/client.rs
Normal file
179
crates/model-connector/src/client.rs
Normal file
|
|
@ -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<Duration>,
|
||||
}
|
||||
|
||||
impl ConnectorClient {
|
||||
pub fn new(
|
||||
endpoint: SocketAddr,
|
||||
connection_key: impl Into<String>,
|
||||
max_frame_bytes: usize,
|
||||
response_timeout: Option<Duration>,
|
||||
) -> Result<Self, ConnectorClientError> {
|
||||
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<TransportResult, ConnectorClientError> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
649
crates/model-connector/src/contract.rs
Normal file
649
crates/model-connector/src/contract.rs
Normal file
|
|
@ -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<Self, ServiceError> {
|
||||
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<InputItem>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub reasoning_summary: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
pub output_format_name: Option<String>,
|
||||
pub previous_response_id: Option<String>,
|
||||
pub tools: Vec<ToolDefinition>,
|
||||
pub tool_choice: ToolChoice,
|
||||
pub parallel_tool_calls: bool,
|
||||
pub output_schema_json: Option<String>,
|
||||
pub max_output_tokens: Option<u32>,
|
||||
pub prompt_cache_key: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderRequest {
|
||||
pub fn new(
|
||||
request_id: impl Into<String>,
|
||||
conversation_id: impl Into<String>,
|
||||
model: impl Into<String>,
|
||||
instructions: impl Into<String>,
|
||||
) -> 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<String>,
|
||||
expires_at_unix_ms: u64,
|
||||
native_request_sha256: [u8; 32],
|
||||
request: ProviderRequest,
|
||||
) -> Result<Self, ContractError> {
|
||||
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<TransportEvent>,
|
||||
receipt: Box<TransportReceipt>,
|
||||
},
|
||||
}
|
||||
|
||||
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<TransportEvent>,
|
||||
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<String>,
|
||||
input_tokens: Option<u64>,
|
||||
output_tokens: Option<u64>,
|
||||
reasoning_output_tokens: Option<u64>,
|
||||
cached_input_tokens: Option<u64>,
|
||||
},
|
||||
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<Vec<u8>, 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<Vec<u8>, 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::<serde_json::Value>(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)
|
||||
);
|
||||
}
|
||||
}
|
||||
286
crates/model-connector/src/envelope.rs
Normal file
286
crates/model-connector/src/envelope.rs
Normal file
|
|
@ -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<u8>,
|
||||
}
|
||||
|
||||
pub fn encrypt_invocation(
|
||||
invocation: &TransportInvocation,
|
||||
security: &TransportKey,
|
||||
) -> Result<TransportEnvelope, ServiceError> {
|
||||
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<TransportInvocation, ServiceError> {
|
||||
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<TransportEnvelope, ServiceError> {
|
||||
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<TransportResult, ServiceError> {
|
||||
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<Vec<u8>, 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<TransportEnvelope, ServiceError> {
|
||||
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<Vec<u8>, 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<Vec<u8>, 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
129
crates/model-connector/src/fixtures.rs
Normal file
129
crates/model-connector/src/fixtures.rs
Normal file
|
|
@ -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()
|
||||
}
|
||||
|
||||
10
crates/model-connector/src/lib.rs
Normal file
10
crates/model-connector/src/lib.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
mod client;
|
||||
mod contract;
|
||||
mod envelope;
|
||||
|
||||
#[cfg(test)]
|
||||
mod fixtures;
|
||||
|
||||
pub use client::*;
|
||||
pub use contract::*;
|
||||
pub use envelope::*;
|
||||
Loading…
Reference in a new issue