Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Meta Vens
ba1f7eb10b 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 <noreply@anthropic.com>
2026-10-28 06:45:57 +01:00
Meta Vens
2dc8d070e0 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 <noreply@anthropic.com>
2026-10-28 06:43:36 +01:00
Meta Vens
953eef26e4 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 <noreply@anthropic.com>
2026-10-28 06:40:39 +01:00
Meta Vens
925d055bb5 ServeError::FrameBound: no run of spaces in the message; the text is pinned (frame-bound-display-spaces)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 06:40:29 +01:00
Meta Vens
89b56b5300 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 <noreply@anthropic.com>
2026-10-28 06:40:24 +01:00
Meta Vens
ec9722a2d8 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 <noreply@anthropic.com>
2026-10-28 06:40:18 +01:00
Meta Vens
41c75bb9e7 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 <noreply@anthropic.com>
2026-10-28 06:16:11 +01:00
Meta Vens
15a41343ce rustfmt the connector kit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 06:05:16 +01:00
Meta Vens
2d383d7818 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 <noreply@anthropic.com>
2026-10-28 06:05:09 +01:00
Meta Vens
bf07b2cd38 Frame arithmetic is daemon-gated with its only consumers; boundary tests are finite
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 05:40:05 +01:00
Meta Vens
d281343dc9 Golden fixture README: Ghostlight pins CodexConnector 68fe94b; src/lib.rs is identical in both
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 05:33:02 +01:00
Meta Vens
749a58985b 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 <noreply@anthropic.com>
2026-10-28 05:32:26 +01:00
Meta Vens
868c248b4f 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 <noreply@anthropic.com>
2026-10-28 05:31:41 +01:00
Meta Vens
caddbc8d9a serve tests: pin the result bound's arithmetic and that in-bound results pass through untouched
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 05:12:11 +01:00
Meta Vens
ae28dbedfd 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 <noreply@anthropic.com>
2026-10-28 05:09:03 +01:00
Meta Vens
af99b3423f wire_v2 fixtures: the generator source, run once against CodexConnector fd7cec4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 05:06:59 +01:00
Meta Vens
d1d28f0fd7 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 <noreply@anthropic.com>
2026-10-28 05:05:58 +01:00
Meta Vens
a255b0d9bf service tests: name the record edit type for clippy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 04:42:30 +01:00
Meta Vens
2e6aa9363f 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 <noreply@anthropic.com>
2026-10-28 04:41:40 +01:00
Meta Vens
76f1c41cff 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 <noreply@anthropic.com>
2026-10-28 04:41:40 +01:00
Meta Vens
b26ee5e676 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 <noreply@anthropic.com>
2026-10-28 04:40:15 +01:00
Meta Vens
76e4e4f269 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>
2026-10-28 04:39:58 +01:00
36 changed files with 5348 additions and 15 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

838
Cargo.lock generated Normal file
View file

@ -0,0 +1,838 @@
# 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 = "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 = "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",
"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
View 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"

View file

@ -1,26 +1,55 @@
# 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
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 echoing the id (the connection is dropped only if that reply cannot fit the frame); `ConnectorClient` refuses such an id locally with `RequestIdTooLong`.
[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.
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.
## License
## Verify
```
cargo test --locked --workspace
cargo test --locked --workspace --features model-connector/daemon
```
`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
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.

View file

@ -0,0 +1,23 @@
[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"

View file

@ -0,0 +1,206 @@
use std::net::{SocketAddr, TcpStream};
use std::time::Duration;
use thiserror::Error;
use zeroize::Zeroizing;
use crate::contract::{TransportInvocation, TransportKey, TransportResult};
use crate::envelope::{
MAX_IDENTITY_BYTES, 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> {
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
.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("request id is longer than the connector identity bound")]
RequestIdTooLong,
#[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();
}
#[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"
);
}
}

View file

@ -0,0 +1,641 @@
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)
);
}
}

View file

@ -0,0 +1,514 @@
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>,
}
/// The longest caller id, request id or model name a service admits. The
/// 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, 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.
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),
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))
}
/// 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<usize> {
// 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(
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("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,
#[error("sealed result does not fit the frame bound")]
FrameBound,
}
#[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)
);
}
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)));
}
#[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(),
frame_bytes_for_payload(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 = 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.
if id.len() == MAX_IDENTITY_BYTES {
assert!(
worst <= real + sealed.ciphertext.len() + 64,
"{worst} {real}"
);
}
}
}
}
#[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}"
);
}
}
}

View file

@ -0,0 +1,128 @@
//! 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()
}

View file

@ -0,0 +1,20 @@
mod client;
mod contract;
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::*;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,2 @@
*.mp binary
*.bin binary

View file

@ -0,0 +1,21 @@
# Golden v2 wire fixtures
Bytes of the v2 connector wire, produced once by CodexConnector at commit
`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
CodexConnector at that commit:
cp <ModelConnector>/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 <name> <hex>` 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.

View file

@ -0,0 +1,6 @@
栙$gamecult.codex.transport_envelope.v2璸arity-caller﹔equest-1狪nvocation溙<6E>塘h虄虒抬x泰Y探<1B>環谭听题虠虆3陶 糖烃虘誊h抬台涕[酞藤!A屉虊甜铁疼;屉铁| 酞躺`!铁铁虤抬虗
虂k剔'vCZ锑號虒虉 8J抬汤d蹋虠N炭F炭zm6<6D><36>
眺添甜\\NMq號套虠 贴1替苔摊惕 4*{趟胎瘫
讨苔e糖台虖
贪虇i贪G*S虂坛碳桃胎坍誊!N汤萄虪虃太虣7虂糖贪天虄汤态c苔趟 桃滩1k提~h踢3$腾虪K踢贪贴誊4{逃踢碳泰U.)烫汰踏r虪听天唐^bbI虉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屉

View file

@ -0,0 +1,7 @@
–Ù$gamecult.codex.transport_envelope.v2­parity-caller©request-1¦ResultœÌ…Ì¢ÌÇ"̇Ì<E280A1>GÌ©ÜCÌôÌË Ì<>ÌéHÌßÌ—ÌÿÌÐÌ´ÌÝÌá\Ìö]2/ÌÎe"̘
Ì¿ÌáÌìÌü{GÌÚṴ̧^b3Ìûj̢̗̟ÌÃÌ’sÌ•*Ì» ÌøÌä= ̸̄<ÌÁ{ÌýÌã.ÌØ8~̫̓CÌâ
̱̘ÌË
[Ì‚ÌÉoÌ–(47ÌÌBML̲Ìó̢̳Ì<C2A2>hp,ÌîÌ®wÌê6DHÌðbÌÏÌÌb̹@a̧ÌÑ̵̎Ì´Ì<>kÌäÌ’ÌáÌóÌ€ÌÑN+ÌüMÌÀjG %Ìß/Ìô
ÌÁÌò̸%Ì»Ì<C2BB>@sT`ÌâÌ’}P̈ÌÒ ÌëÌÊÌÞ Ì«Ìâ:ÌÚ̟̖̔Ìû^Ìô>ÌûX.x
r]F8Ì<38>ÌÛÌ‹Ì÷Ì<C3B7>5ÌÚGÌÆÌºÌÃÌ®C@(ÌóÌÓ̾{̾ÌÙMÌÓ~)Ìà 7ÌÐÌòI+'v ÌøUnP̈$U(NÌêÌçÌüsÌ¢T^[ÌÊYÌæÌ<C3A6>zÌâ.wÌÆeÌ™C̦̿̄"U^trÌöÌ<C3B6>ÌÙ̦Ìï̯̬ÌçÌÜÌž+ÌÑ/b̯̭̼Ìî*$Ì× ÌŠÌ²̘ÌÕ\K5̧f
Ìò̃b}̦̬o.Ẁ̧&Ìå̶̑[

View file

@ -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 <name> <hex>` 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<T: Serialize>(value: &T) -> Vec<u8> {
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()),
);
}
}
}

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller<65>§Refused¨Capacity

View file

@ -0,0 +1 @@
”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller<65>§Refused§Expired

View file

@ -0,0 +1 @@
”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller<65>§Refused´IdentitySubstitution

View file

@ -0,0 +1 @@
”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller<65>§Refused¨InFlight

View file

@ -0,0 +1 @@
”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller<65>§Refused­Indeterminate

View file

@ -0,0 +1 @@
”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller<65>§Refused©Malformed

View file

@ -0,0 +1 @@
”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller<65>§Refused¦Policy

View file

@ -0,0 +1 @@
”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller<65>§RefusedºProviderDigestSubstitution

View file

@ -0,0 +1 @@
”Ù"gamecult.codex.transport_result.v2©request-1­parity-caller<65>§Refused®ReplayConflict

Binary file not shown.

View file

@ -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<T: Serialize>(value: &T) -> Vec<u8> {
rmp_serde::to_vec(value).unwrap()
}
/// The value serializes to the golden bytes and the golden bytes decode back to it.
fn assert_wire<T: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug>(
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());
}