diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..56862bb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +schemas/*.json text eol=lf +crates/model-connector/tests/fixtures/wire_v4/* -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..996b5fc --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1274 @@ +# 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 = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[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 = "claude-connector" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs", + "hmac", + "libc", + "model-connector", + "rand_core 0.6.4", + "rmp-serde", + "rmpv", + "serde", + "serde_json", + "sha2", + "thiserror", + "zeroize", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[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 0.6.4", + "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.3.1" +source = "git+https://github.com/GameCult/CultLib.git?rev=a4eae9056152335662f1115f97bf35fb00ab6d23#a4eae9056152335662f1115f97bf35fb00ab6d23" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs-derive", + "fs2", + "redb", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "serde_stacker", + "stacker", + "uuid", + "windows-sys 0.59.0", +] + +[[package]] +name = "cultcache-rs-derive" +version = "0.2.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=a4eae9056152335662f1115f97bf35fb00ab6d23#a4eae9056152335662f1115f97bf35fb00ab6d23" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cultnet-rs" +version = "0.2.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=a4eae9056152335662f1115f97bf35fb00ab6d23#a4eae9056152335662f1115f97bf35fb00ab6d23" +dependencies = [ + "aes-gcm", + "anyhow", + "base64", + "chrono", + "cultcache-rs", + "ed25519-dalek", + "hmac", + "rand", + "rmp-serde", + "rmpv", + "serde", + "serde_bytes", + "serde_json", + "sha2", + "socket2", + "uuid", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[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.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[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 6.0.0", +] + +[[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 = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[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", + "anyhow", + "base64", + "cultcache-rs", + "cultnet-rs", + "rand_core 0.6.4", + "rmp-serde", + "rmpv", + "schemars", + "serde", + "serde_bytes", + "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 = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[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 = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[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 = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[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 = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[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 = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[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 = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redb" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de6c3b63e007e90ce536ec2ae4690826136a20ec8dbbbb400daef1bb999d2e36" +dependencies = [ + "libc", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "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 = "rmpv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" +dependencies = [ + "rmp", + "serde", + "serde_bytes", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.4", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +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 = "serde_stacker" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a" +dependencies = [ + "serde", + "serde_core", + "stacker", +] + +[[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 = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[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 = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "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.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[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-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[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 = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..10e8225 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] +members = ["crates/*"] +resolver = "3" + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "AGPL-3.0-only" +repository = "https://eureka.gamecult.org/forge/GameCult/ModelConnector" diff --git a/README.md b/README.md index 65a124e..cf5cd4f 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,107 @@ # 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 +The connector meters nothing in dollars. It holds the subscription token, lends it, and +publishes the plan's usage windows (`usage_readout`, `claude_usage_readout` on the lease socket, and `plan_quota` on every lease grant) for the +minds to read and decide their own burn. The one usage-derived refusal is the provider's own +(`ProviderRejected`), repeated until its reset. -The connector kit for GameCult minds: +Status: the `model-connector` crate and the `claude-connector` backend crate and binary are live +(`claude-connector serve|config|ask`); an OpenAI-compatible connector is not written. -- 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. +## Entry points -The problem it solves: a mind should reach any model through one inspectable, -typed contract, rather than each project wiring its own provider calls. +Crate `crates/model-connector`: -## Where it comes from +- `src/contract.rs` - the v4 wire contract (`gamecult.model.*.v4`): `ProviderRequest`, `TransportInvocation`, + `TransportResult`, `TransportReceipt`, the typed `StopReason`, `FailureKind` and `Refusal`, + `InputItem::ReasoningState` (a provider's opaque reasoning block, replayed by callers + verbatim via `TransportResult::transcript_items`; the service refuses another provider's + state in the in-flight tool exchange and drops it from earlier turns), + and the canonical byte and digest functions that bind a result to its request. Every + document is named MessagePack with unknown fields refused. +- `src/envelope.rs` - the AES-256-GCM `TransportEnvelope` (binary ciphertext), its + identity-binding AAD, the length-prefixed framing, and the CultNet carriage: each frame is a + `cultnet.operation_request.v0` / `operation_response.v0` for service + `gamecult.model.connector`, operation `invoke` (or `usage_readout`), its `message_id` the + request id and its payload the base64 of the sealed envelope. `usage_readout` answers a + `UsageReadout` (`gamecult.model.usage_readout.v1`): the backend's plan quota windows, each with + utilization, reset, status and age; + `ConnectorClient::usage_readout` asks. +- `schemas/` - one JSON Schema per v4 document, generated from the types. +- `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. -[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. +Crate `crates/claude-connector` (library and the `claude-connector` binary that wires it to `serve`): -## License +- `ClaudeCodeBackend` implements `ProviderBackend` for provider `claude-code` by driving the + unmodified `claude` binary, one child per call, in print stream-json mode with no tools, no MCP + servers, no settings sources and the system prompt replaced. Tool-less turns only: a request + with tools or a final tool result is refused before any spawn. A request's output schema goes + to the CLI as `--json-schema` and the result's structured output is the one assistant text. +- Reasoning state is a session handle (`ClaudeCodeHandle`) authenticated with a connector secret + and bound to the caller it was minted for; a handle that cannot be proven to match the request + and its caller re-seeds from the caller's transcript, and every resume forks. +- `ProcessPort` is the one OS seam (`StdProcessPort`, unix, kills the child's whole process + group); `SessionStore` and `StderrSink` are the other ports. `ClaudeCodeConfig` is what the + binary hands in; the credential is read from a systemd credential and reaches the child on a + pipe (`CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR`), never in an environment. `QuotaCell` keeps the + plan windows the CLI's `rate_limit_event`s report, for the usage readout and the lease grant. + +## Asking for one no-tools completion + +`claude-connector ask` is a caller of a running connector for a Mind's Self run: the prompt is all +of stdin, the answer text is stdout. + +``` +claude-connector ask --caller-id ID --connection-key-file FILE --model MODEL (--system TEXT | --system-file FILE) [--endpoint HOST:PORT] [--effort low|medium|high|max] [--max-output-tokens N] [--timeout-secs N] < prompt > answer +``` + +Defaults: endpoint `127.0.0.1:4110`, 8192 output tokens, 300 s. The key is read from the file (the +same text the connector reads for the caller; never argv or environment). There is no tools flag, +no working-directory flag and no other endpoint: the request is built by `ProviderRequest::new` +(no tools) and the model child's cwd and environment stay the connector's. Any failure (refused, +over a cap, model not allowed, wrong key or caller, timeout, provider failure, empty answer, empty +stdin) exits nonzero with one `claude-connector: ` line on stderr and nothing on stdout; +the line never carries the key, the prompt or the provider's message. Each run uses a fresh request +id. Code: `crates/claude-connector/src/ask.rs`; tests: `crates/claude-connector/tests/ask.rs`. + +Frame arithmetic is public in `src/envelope.rs`, measured on the carriage frame: `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`. + +The default feature set compiles contract, envelope and client only. The kit speaks v4 +only: a v2 frame gets no answer. + +## Verify + +``` +cargo test --locked --workspace +cargo test --locked --workspace --features model-connector/daemon +``` + +`crates/model-connector/tests/wire_golden_v4.rs` pins the v4 documents and one sealed carriage +frame to the fixtures in `crates/model-connector/tests/fixtures/wire_v4` (regenerate with +`WIRE_V4_REGENERATE=1`; see the fixtures' README), and +`crates/model-connector/tests/published_schemas.rs` fails when `schemas/` differs from the types +(`MODEL_CONNECTOR_REGENERATE_SCHEMAS=1` rewrites it). + +## 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. diff --git a/crates/claude-connector/Cargo.toml b/crates/claude-connector/Cargo.toml new file mode 100644 index 0000000..3b1d113 --- /dev/null +++ b/crates/claude-connector/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "claude-connector" +description = "Claude backend for the connector kit: drives the unmodified claude binary in print stream-json mode" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[[bin]] +name = "claude-connector" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +chrono = { version = "0.4", default-features = false } +hmac = "0.12" +cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a4eae9056152335662f1115f97bf35fb00ab6d23", package = "cultcache-rs" } +model-connector = { path = "../model-connector", features = ["daemon"] } +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" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[dev-dependencies] +rmpv = "1" diff --git a/crates/claude-connector/src/ask.rs b/crates/claude-connector/src/ask.rs new file mode 100644 index 0000000..ddd4e9f --- /dev/null +++ b/crates/claude-connector/src/ask.rs @@ -0,0 +1,336 @@ +//! `claude-connector ask`: one no-tools completion through a running connector. +//! +//! A thin caller over [`ConnectorClient`]. It builds a [`ProviderRequest`] with +//! `ProviderRequest::new`, which carries no tools, and has no field, flag or +//! path by which a caller could add one; the working directory and environment +//! of the model child belong to the connector. Every failure is an +//! [`AskError`] whose text names a cause and never echoes the key, the prompt +//! or the instructions. + +use std::net::SocketAddr; +use std::path::Path; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use model_connector::{ + ConnectorClient, ConnectorClientError, FailureKind, InputItem, ProviderRequest, + ReasoningEffort, StopReason, TransportDisposition, TransportEventPayload, TransportInvocation, + TransportOutcome, TransportResult, frame_bytes_for_payload, provider_request_sha256, +}; +use rand_core::{OsRng, RngCore}; +use zeroize::Zeroizing; + +/// The loopback endpoint a claude connector listens on. +pub const DEFAULT_ENDPOINT: &str = "127.0.0.1:4110"; +/// The payload bound the connector's own `config init` defaults to. +pub const PAYLOAD_BYTES: usize = 1 << 20; +const MAX_KEY_BYTES: usize = 1024; +pub const MAX_SYSTEM_BYTES: usize = 64 * 1024; + +pub struct AskArgs { + pub endpoint: SocketAddr, + pub caller_id: String, + pub key_file: std::path::PathBuf, + pub model: String, + pub instructions: String, + pub effort: Option, + pub max_output_tokens: u32, + pub timeout: Duration, + pub prompt: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum AskError { + #[error("the prompt on stdin is empty")] + EmptyPrompt, + #[error("the prompt on stdin is larger than the {PAYLOAD_BYTES} byte payload bound")] + PromptTooLarge, + #[error("the instructions are empty")] + EmptyInstructions, + #[error("the connection key file cannot be read")] + KeyUnreadable, + #[error("the connection key file is not text")] + KeyEncoding, + #[error("the connection key file is not a regular file")] + KeyNotRegular, + #[error("the connection key file is readable by group or world (chmod 600)")] + KeyExposed, + #[error("the instructions file cannot be read as text from a regular file")] + SystemUnreadable, + #[error("the instructions file is larger than {MAX_SYSTEM_BYTES} bytes")] + SystemTooLarge, + #[error("standard input cannot be read")] + StdinRead, + #[error("standard input is not UTF-8 text")] + StdinNotText, + #[error("the answer cannot be written to standard output")] + StdoutWrite, + #[error("nothing is listening at the connector endpoint")] + NotListening, + #[error( + "the connector closed the connection without answering (key, caller or frame not accepted)" + )] + ClosedWithoutAnswer, + #[error("the connection key file is empty or longer than {MAX_KEY_BYTES} bytes")] + KeyLength, + #[error("the connection key is not usable as a key (surrounding whitespace?)")] + KeyUnusable, + #[error("the request is invalid for the connector contract")] + Request, + #[error("timed out waiting for the connector")] + Timeout, + #[error("the connector connection failed: {0}")] + Connection(std::io::ErrorKind), + #[error("the connector answer was not accepted (wrong key, caller or tampered frame): {0}")] + Transport(model_connector::ServiceError), + #[error("the request exceeded a frame bound")] + FrameSize, + #[error("the connector refused the request: {0:?}")] + Refused(model_connector::Refusal), + #[error("the provider failed: {0:?}")] + Failed(FailureKind), + #[error("the answer stopped as {0:?}, not a completed text")] + NotCompleted(StopReason), + #[error("the answer carried a tool call, which this command never requests")] + ToolCall, + #[error("the answer is empty")] + EmptyAnswer, +} + +enum ReadFailure { + Unreadable, + NotRegular, + TooLarge, + Exposed, +} + +/// Opens `path` without blocking (a FIFO must not hang the open), requires the +/// opened file to be a regular file (a symlink is followed and its target +/// judged), optionally refuses one readable by group or world, and reads at +/// most `cap` bytes, failing if there are more. +fn read_regular(path: &Path, cap: usize, private: bool) -> Result>, ReadFailure> { + use std::io::Read; + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NONBLOCK); + } + let file = options.open(path).map_err(|_| ReadFailure::Unreadable)?; + let metadata = file.metadata().map_err(|_| ReadFailure::Unreadable)?; + if !metadata.is_file() { + return Err(ReadFailure::NotRegular); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if private && metadata.permissions().mode() & 0o077 != 0 { + return Err(ReadFailure::Exposed); + } + } + let mut bytes = Zeroizing::new(Vec::new()); + file.take(cap as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| ReadFailure::Unreadable)?; + if bytes.len() > cap { + return Err(ReadFailure::TooLarge); + } + Ok(bytes) +} + +/// Reads the key as the connector reads its own key file (the text without the +/// trailing line ending). The bytes are zeroized on drop and never shown. +pub fn read_key(path: &Path) -> Result, AskError> { + let raw = read_regular(path, MAX_KEY_BYTES, true).map_err(|failure| match failure { + ReadFailure::Unreadable => AskError::KeyUnreadable, + ReadFailure::NotRegular => AskError::KeyNotRegular, + ReadFailure::TooLarge => AskError::KeyLength, + ReadFailure::Exposed => AskError::KeyExposed, + })?; + let text = std::str::from_utf8(raw.as_slice()).map_err(|_| AskError::KeyEncoding)?; + let key = Zeroizing::new(text.trim_end_matches(['\r', '\n']).to_string()); + if key.is_empty() || key.len() > MAX_KEY_BYTES { + return Err(AskError::KeyLength); + } + if key.trim() != key.as_str() { + return Err(AskError::KeyUnusable); + } + Ok(key) +} + +/// The instructions file: a regular file of at most [`MAX_SYSTEM_BYTES`] of text. +pub fn read_system_file(path: &Path) -> Result { + let raw = read_regular(path, MAX_SYSTEM_BYTES, false).map_err(|failure| match failure { + ReadFailure::TooLarge => AskError::SystemTooLarge, + _ => AskError::SystemUnreadable, + })?; + String::from_utf8(raw.to_vec()).map_err(|_| AskError::SystemUnreadable) +} + +fn fresh_id(prefix: &str) -> String { + let mut nonce = [0_u8; 16]; + OsRng.fill_bytes(&mut nonce); + let hex: String = nonce.iter().map(|b| format!("{b:02x}")).collect(); + format!("{prefix}-{hex}") +} + +/// The one invocation `ask` ever sends: instructions, one user text, no tools. +pub fn build_invocation(args: &AskArgs) -> Result { + if args.prompt.trim().is_empty() { + return Err(AskError::EmptyPrompt); + } + if args.prompt.len() > PAYLOAD_BYTES { + return Err(AskError::PromptTooLarge); + } + if args.instructions.trim().is_empty() { + return Err(AskError::EmptyInstructions); + } + let mut request = ProviderRequest::new( + fresh_id("ask"), + fresh_id("ask-conversation"), + args.model.clone(), + args.instructions.clone(), + args.max_output_tokens, + ); + request.input = vec![InputItem::UserText { + text: args.prompt.clone(), + }]; + request.reasoning_effort = args.effort; + let digest = provider_request_sha256(&request).map_err(|_| AskError::Request)?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64); + let invocation = + TransportInvocation::new(args.caller_id.clone(), now + 120_000, digest, request) + .map_err(|_| AskError::Request)?; + invocation + .validate(now, 300_000) + .map_err(|_| AskError::Request)?; + Ok(invocation) +} + +/// The answer text of a result, or the one cause it is not an answer. +pub fn answer_text(result: &TransportResult) -> Result { + let (events, receipt) = match &result.disposition { + TransportDisposition::Refused(reason) => return Err(AskError::Refused(reason.clone())), + TransportDisposition::Transported { events, receipt } => (events, receipt), + }; + match &receipt.outcome { + TransportOutcome::Failed { kind, .. } => return Err(AskError::Failed(*kind)), + TransportOutcome::Completed { stop_reason, .. } => { + if !matches!(stop_reason, StopReason::EndTurn | StopReason::StopSequence) { + return Err(AskError::NotCompleted(*stop_reason)); + } + } + } + let mut text = String::new(); + for event in events { + match &event.payload { + TransportEventPayload::TextDelta { text: delta } => text.push_str(delta), + TransportEventPayload::ToolCall { .. } => return Err(AskError::ToolCall), + TransportEventPayload::ReasoningState { .. } => {} + } + } + if text.trim().is_empty() { + return Err(AskError::EmptyAnswer); + } + Ok(text) +} + +pub fn ask(args: &AskArgs) -> Result { + let invocation = build_invocation(args)?; + let key = read_key(&args.key_file)?; + let client = ConnectorClient::new( + args.endpoint, + key.as_str(), + frame_bytes_for_payload(PAYLOAD_BYTES), + Some(args.timeout), + ) + .map_err(|error| match error { + ConnectorClientError::Transport(_) => AskError::KeyUnusable, + _ => AskError::Request, + })?; + let result = client.execute(&invocation).map_err(|error| match error { + ConnectorClientError::Connection(io) + if matches!( + io.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + AskError::Timeout + } + ConnectorClientError::Connection(io) => match io.kind() { + std::io::ErrorKind::ConnectionRefused => AskError::NotListening, + std::io::ErrorKind::UnexpectedEof + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::BrokenPipe => AskError::ClosedWithoutAnswer, + kind => AskError::Connection(kind), + }, + ConnectorClientError::Transport(error) => AskError::Transport(error), + ConnectorClientError::FrameSize => AskError::FrameSize, + ConnectorClientError::InvalidConfig | ConnectorClientError::RequestIdTooLong => { + AskError::Request + } + })?; + answer_text(&result) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ClaudeCodeConfig, ClaudeCodeLimits}; + use crate::handle::{SessionStore, plan}; + use model_connector::ToolDefinition; + + struct NoSessions; + impl SessionStore for NoSessions { + fn exists(&self, _: &str) -> bool { + false + } + } + + fn args() -> AskArgs { + AskArgs { + endpoint: DEFAULT_ENDPOINT.parse().unwrap(), + caller_id: "c".into(), + key_file: "/nonexistent".into(), + model: "m".into(), + instructions: "i".into(), + effort: None, + max_output_tokens: 16, + timeout: Duration::from_secs(1), + prompt: "p".into(), + } + } + + /// The claude backend's own planner accepts what `ask` builds and refuses + /// the same request once a tool is forced into it. + #[test] + fn the_backend_planner_refuses_a_forced_tool_and_accepts_the_asked_request() { + let config = ClaudeCodeConfig { + claude_binary: "/claude".into(), + claude_binary_sha256: [0; 32], + work_dir: "/w".into(), + config_dir: "/c".into(), + prompt_dir: "/p".into(), + token_credential: "t".into(), + handle_secret_credential: "h".into(), + instance: "i".into(), + limits: ClaudeCodeLimits { + deadline: Duration::from_secs(1), + max_line_bytes: 1024, + max_total_bytes: 4096, + stderr_tail_bytes: 64, + }, + }; + let mut request = build_invocation(&args()).unwrap().request; + let secret = [7_u8; 32]; + assert!(plan(&request, &config, &secret, "c", &NoSessions).is_ok()); + request.tools.push(ToolDefinition { + name: "t".into(), + description: "d".into(), + parameters_json: "{}".into(), + }); + assert!(plan(&request, &config, &secret, "c", &NoSessions).is_err()); + } +} diff --git a/crates/claude-connector/src/backend.rs b/crates/claude-connector/src/backend.rs new file mode 100644 index 0000000..0bc2d77 --- /dev/null +++ b/crates/claude-connector/src/backend.rs @@ -0,0 +1,474 @@ +use std::fs::File; +use std::io; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Instant; + +use model_connector::{ + FailureKind, InputItem, PlanQuotaSnapshot, ProviderBackend, ProviderCall, ProviderRequest, + TransportEvent, TransportEventPayload, TransportOutcome, TransportResult, Usage, +}; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use crate::config::{ClaudeCodeConfig, PROVIDER}; +use crate::handle::{ + Binding, ClaudeCodeHandle, MIN_SECRET_BYTES, SessionStore, plan, prefix_digest, stdin_bytes, +}; +use crate::launch::{PromptFile, argv, child_env}; +use crate::process::{ProcessPort, RunningChild, StdoutRead}; +use crate::quota::QuotaCell; +use crate::stream::{Failure, Finished, Step, StreamState}; + +/// The meter's call: the cheapest model, a trivial prompt, and an answer cap +/// that only bounds a runaway. +const METER_MODEL: &str = "claude-haiku-5-5"; +const METER_CALLER: &str = "meter"; +const METER_MAX_OUTPUT_TOKENS: u32 = 16; + +/// Where the child's credential comes from, read afresh at every use. The +/// connector's own environment is never a source (ruling +/// claude-connector-token-credential). +pub trait TokenSource: Send + Sync { + /// The token, or `None` when there is none or it is empty. + fn read(&self) -> Option>; + /// What `config check` names when `read` has no token. + fn missing(&self) -> &'static str { + "token_credential" + } +} + +/// A token in a file named by a systemd credential: the file +/// `$CREDENTIALS_DIRECTORY/`, read on every call so a rotated credential +/// is picked up by the next spawn. +pub struct CredentialTokenSource { + directory: Option, + name: String, + /// The configuration field `missing` names when the credential is absent. + field: &'static str, +} + +impl CredentialTokenSource { + /// The credential `name` under the directory systemd names in + /// `CREDENTIALS_DIRECTORY`; with no such variable the source has no token. + /// The variable names a directory, never a token. + pub fn from_environment(name: impl Into) -> Self { + Self { + directory: std::env::var_os("CREDENTIALS_DIRECTORY").map(PathBuf::from), + name: name.into(), + field: "token_credential", + } + } + + /// The same source, naming `field` when its credential is absent. + pub fn for_field(mut self, field: &'static str) -> Self { + self.field = field; + self + } + + pub fn in_directory(directory: impl Into, name: impl Into) -> Self { + Self { + directory: Some(directory.into()), + name: name.into(), + field: "token_credential", + } + } +} + +impl TokenSource for CredentialTokenSource { + fn read(&self) -> Option> { + let directory = self.directory.as_ref()?; + let mut token = Zeroizing::new(std::fs::read_to_string(directory.join(&self.name)).ok()?); + let trimmed = token.trim_end_matches(['\r', '\n']).len(); + token.truncate(trimmed); + (!token.is_empty()).then_some(token) + } + + /// No directory at all is the unit's environment lacking its credentials, + /// which is not the same fault as a credential the directory lacks. + fn missing(&self) -> &'static str { + if self.directory.is_none() { + "credentials_directory" + } else { + self.field + } + } +} + +/// Where the tail of a child's stderr goes. The backend never puts it in a +/// result; the connector binary wires this to its log. +pub trait StderrSink: Send + Sync { + fn stderr_tail(&self, tail: &[u8]); +} + +/// Answers each call with one `claude` child process: never more than one per +/// call, and a failed call is never run again. +pub struct ClaudeCodeBackend { + config: ClaudeCodeConfig, + port: Box, + sessions: Box, + sink: Box, + tokens: Box, + handle_secret: Box, + quota: Arc, +} + +/// The handle secret, or `None` when it is absent or shorter than a key. +fn usable_secret(source: &dyn TokenSource) -> Option> { + source + .read() + .filter(|secret| secret.len() >= MIN_SECRET_BYTES) +} + +impl ClaudeCodeBackend { + /// The backend over real processes and the CLI's real session store. + #[cfg(unix)] + pub fn new(config: ClaudeCodeConfig, sink: Box) -> Self { + let port = Box::new(crate::process::StdProcessPort::new(config.limits)); + let sessions = Box::new(crate::handle::FsSessionStore::new( + config.config_dir.clone(), + )); + let tokens = Box::new(CredentialTokenSource::from_environment( + config.token_credential.clone(), + )); + let handle_secret = Box::new( + CredentialTokenSource::from_environment(config.handle_secret_credential.clone()) + .for_field("handle_secret_credential"), + ); + Self::with_ports(config, port, sessions, sink, tokens, handle_secret) + } + + pub fn with_ports( + config: ClaudeCodeConfig, + port: Box, + sessions: Box, + sink: Box, + tokens: Box, + handle_secret: Box, + ) -> Self { + Self { + config, + port, + sessions, + sink, + tokens, + handle_secret, + quota: Arc::new(QuotaCell::new()), + } + } + + /// The plan windows every stream this backend reads feeds. + pub fn quota(&self) -> Arc { + self.quota.clone() + } + + /// The meter's call: the smallest real one, through the same child, + /// stream and cell as any other. The stream's `rate_limit_event` is what + /// the caller wants; this returns what the call billed, or the failure's + /// kind (never its message). + pub(crate) fn read_meter(&self) -> Result { + let mut request = ProviderRequest::new( + METER_CALLER, + METER_CALLER, + METER_MODEL, + "Reply with the single word ok.", + METER_MAX_OUTPUT_TOKENS, + ); + request.input = vec![InputItem::UserText { + text: "ok?".to_string(), + }]; + match self.run(&request, METER_CALLER, true).1 { + TransportOutcome::Completed { usage, .. } => Ok(usage), + TransportOutcome::Failed { kind, .. } => Err(format!("{kind:?}")), + } + } + + /// The one pin check: `ready` and every spawn use it, and nothing caches + /// its answer. + fn binary_matches_pin(&self) -> bool { + binary_matches_pin(&self.config) + } + + /// `ephemeral` leaves no transcript behind: the meter keeps no handle and + /// resumes nothing, every other call is resumed by its session. + fn run( + &self, + request: &ProviderRequest, + caller: &str, + ephemeral: bool, + ) -> (Vec, TransportOutcome) { + let Some(secret) = usable_secret(self.handle_secret.as_ref()) else { + return failed( + FailureKind::Authentication, + "claude-code: the connector has no handle secret".to_string(), + Some(zero_usage()), + ); + }; + let plan = match plan( + request, + &self.config, + secret.as_bytes(), + caller, + self.sessions.as_ref(), + ) { + Ok(plan) => plan, + Err(error) => { + return failed( + FailureKind::InvalidRequest, + error.to_string(), + Some(zero_usage()), + ); + } + }; + let Some(token) = self.tokens.read() else { + return failed( + FailureKind::Authentication, + "claude-code: the connector has no credential to give the child".to_string(), + Some(zero_usage()), + ); + }; + if !self.binary_matches_pin() { + return failure(Failure::never_reached( + FailureKind::Provider, + "claude-code: the claude binary does not match its pin", + )); + } + let Ok(prompt) = PromptFile::create(&self.config.prompt_dir, &request.instructions) else { + return failure(Failure::never_reached( + FailureKind::Provider, + "claude-code: the system prompt could not be staged", + )); + }; + let argv = argv(&self.config, request, &plan, prompt.path(), ephemeral); + let env = child_env(&self.config, request); + let deadline = Instant::now() + self.config.limits.deadline; + + let spawned = self.port.spawn( + &argv, + &env, + &self.config.work_dir, + stdin_bytes(plan.blocks()), + &token, + ); + let Ok(mut child) = spawned else { + return failure(Failure::never_reached( + FailureKind::Provider, + "claude-code: the child could not be spawned", + )); + }; + let finished = read_stream( + child.as_mut(), + deadline, + &request.model, + request.output_schema_json.as_deref(), + &self.quota, + ); + child.kill(); + child.wait(); + let tail = child.stderr_tail(); + if !tail.is_empty() { + self.sink.stderr_tail(&tail); + } + + match finished { + Ok(finished) => self.completed(request, caller, secret.as_bytes(), finished), + Err(failure_) => failure(failure_), + } + } + + fn completed( + &self, + request: &ProviderRequest, + caller: &str, + secret: &[u8], + finished: Finished, + ) -> (Vec, TransportOutcome) { + let mut payloads: Vec = finished + .texts + .iter() + .map(|text| TransportEventPayload::TextDelta { text: text.clone() }) + .collect(); + + let mut prefix = request.input.clone(); + if !finished.texts.is_empty() { + prefix.push(InputItem::AssistantText { + text: finished.texts.concat(), + }); + } + // A schema turn leaves the CLI's StructuredOutput tool exchange in its + // session, and a later turn without the schema runs with no tools: it + // must not resume that session. So a schema turn hands out no handle, + // and whatever follows it is seeded from the caller's transcript. + if request.output_schema_json.is_none() { + let handle = ClaudeCodeHandle::Ended { + session_id: finished.session_id.clone(), + instance: self.config.instance.clone(), + prefix_sha256: prefix_digest( + secret, + &Binding { + caller, + instructions: &request.instructions, + model: &request.model, + session_id: &finished.session_id, + instance: &self.config.instance, + items: &prefix, + }, + ), + }; + payloads.push(TransportEventPayload::ReasoningState { + state: handle.encode(), + }); + } + + let events = payloads + .into_iter() + .enumerate() + .map(|(sequence, payload)| TransportEvent { + sequence: sequence as u64, + payload, + }) + .collect(); + ( + events, + TransportOutcome::Completed { + provider_response_id: finished.message_id, + stop_reason: finished.stop_reason, + usage: finished.usage, + }, + ) + } +} + +impl ProviderBackend for ClaudeCodeBackend { + fn ready(&self) -> Result<(), &'static str> { + readiness( + &self.config, + self.tokens.as_ref(), + self.handle_secret.as_ref(), + ) + } + + fn provider(&self) -> &str { + PROVIDER + } + + fn execute(&self, call: &ProviderCall) -> TransportResult { + let (events, outcome) = self.run(call.request(), call.caller_runtime_id(), false); + call.transported(events, outcome) + } + + fn plan_quota(&self) -> PlanQuotaSnapshot { + self.quota.snapshot() + } +} + +fn read_stream( + child: &mut dyn RunningChild, + deadline: Instant, + model: &str, + schema: Option<&str>, + quota: &QuotaCell, +) -> Result { + let mut state = StreamState::new(model, schema, quota); + let finished = loop { + match child.read_line(deadline) { + StdoutRead::Line(line) => { + if let Step::Done(done) = state.feed(&line) { + break *done; + } + } + StdoutRead::Eof => { + return Err(Failure::cost_unknown( + FailureKind::Provider, + "claude-code: the stream ended without a result", + )); + } + StdoutRead::Timeout => { + return Err(Failure::cost_unknown( + FailureKind::Timeout, + "claude-code: the deadline passed before a result", + )); + } + StdoutRead::Breach => { + return Err(Failure::cost_unknown( + FailureKind::Provider, + "claude-code: the output passed its size cap", + )); + } + } + }; + // The result is in; give the child until its deadline to close stdout. + while matches!(child.read_line(deadline), StdoutRead::Line(_)) {} + finished +} + +fn binary_matches_pin(config: &ClaudeCodeConfig) -> bool { + let Ok(mut file) = File::open(&config.claude_binary) else { + return false; + }; + let mut hasher = Sha256::new(); + io::copy(&mut file, &mut hasher).is_ok() + && <[u8; 32]>::from(hasher.finalize()) == config.claude_binary_sha256 +} + +/// Whether the backend can take work, naming the first config field that +/// cannot be used. `config check` and `serve` both ask this. +pub(crate) fn readiness( + config: &ClaudeCodeConfig, + tokens: &dyn TokenSource, + handle_secret: &dyn TokenSource, +) -> Result<(), &'static str> { + if !config.work_dir.is_dir() { + return Err("work_dir"); + } + if !config.config_dir.is_dir() { + return Err("config_dir"); + } + if !config.prompt_dir.is_dir() { + return Err("prompt_dir"); + } + if !binary_matches_pin(config) { + return Err("claude_binary_sha256"); + } + // Last: outside the unit the credential is the one thing always absent, + // and it must not hide the faults the document itself has. + if usable_secret(handle_secret).is_none() { + return Err(handle_secret.missing()); + } + if tokens.read().is_none() { + return Err(tokens.missing()); + } + Ok(()) +} + +fn zero_usage() -> Usage { + Usage { + input_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + output_tokens: 0, + reasoning_tokens: None, + } +} + +fn failed( + kind: FailureKind, + message: String, + usage: Option, +) -> (Vec, TransportOutcome) { + ( + Vec::new(), + TransportOutcome::Failed { + kind, + message, + usage, + }, + ) +} + +fn failure(failure: Failure) -> (Vec, TransportOutcome) { + failed(failure.kind, failure.message.to_string(), failure.usage) +} + +#[cfg(test)] +mod tests; diff --git a/crates/claude-connector/src/backend/tests.rs b/crates/claude-connector/src/backend/tests.rs new file mode 100644 index 0000000..ecd98fa --- /dev/null +++ b/crates/claude-connector/src/backend/tests.rs @@ -0,0 +1,2792 @@ +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use model_connector::{ + CallerAdmission, PlanQuotaSnapshot, ReasoningEffort, StopReason, ToolDefinition, + TransportAdmission, TransportDisposition, TransportInvocation, TransportKey, TransportReceipt, + TransportService, decrypt_result, encrypt_invocation, +}; + +use super::*; +use crate::config::ClaudeCodeLimits; +use crate::handle::{FsSessionStore, SessionStore}; +use crate::launch::ChildEnv; + +const CANARY_TOKEN: &str = "canary-oauth-token-value"; +/// The variable the connector's own environment must never hand on. +const TOKEN_ENV: &str = "CLAUDE_CODE_OAUTH_TOKEN"; +const TOKEN_FD_ENV: &str = "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR"; +const MODEL: &str = "claude-haiku-5-5"; +const INSTANCE: &str = "persona-a"; +const SESSION: &str = "11111111-2222-3333-4444-555555555555"; +const HEX_SESSION: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; +const OTHER_MODEL: &str = "claude-opus-5"; +const FORK: &str = "66666666-7777-8888-9999-000000000000"; +const FRAME: usize = 1 << 20; + +// ---- fixtures, written by hand from the fields the probes observed ---- + +fn init_line(model: &str, session: &str) -> String { + format!( + r#"{{"type":"system","subtype":"init","session_id":"{session}","model":"{model}","tools":[],"apiKeySource":"none"}}"# + ) +} + +fn text_line(id: &str, text: &str) -> String { + format!( + r#"{{"type":"assistant","message":{{"id":"{id}","role":"assistant","content":[{{"type":"text","text":"{text}"}}],"stop_reason":null}},"session_id":"{SESSION}"}}"# + ) +} + +fn thinking_line(id: &str) -> String { + format!( + r#"{{"type":"assistant","message":{{"id":"{id}","role":"assistant","content":[{{"type":"thinking","thinking":"","signature":"sig"}}]}}}}"# + ) +} + +fn tool_use_line() -> String { + r#"{"type":"assistant","message":{"id":"msg_t","content":[{"type":"tool_use","id":"toolu_1","name":"x","input":{}}]}}"#.to_string() +} + +fn rate_limit_line(status: &str) -> String { + format!( + r#"{{"type":"rate_limit_event","rate_limit_info":{{"status":"{status}","rateLimitType":"five_hour"}}}}"# + ) +} + +const USAGE: &str = r#"{"claude-haiku-5-5":{"inputTokens":2,"outputTokens":4,"cacheReadInputTokens":7,"cacheCreationInputTokens":1043,"thinkingTokens":3,"costUSD":0.5}}"#; + +/// What a resumed session's `modelUsage` says: every turn so far. Every result +/// fixture carries it beside the turn's own `model_usage_since_last_result`, +/// so reading the wrong field shows in a count. +const CUMULATIVE: &str = r#"{"claude-haiku-5-5":{"inputTokens":900000,"outputTokens":900000,"cacheReadInputTokens":900000,"cacheCreationInputTokens":900000,"thinkingTokens":900000,"costUSD":99.0}}"#; + +fn result_line(stop_reason: &str, since_last: &str) -> String { + result_line_with(stop_reason, since_last, "") +} + +/// A success result with `extra` (a leading comma and fields) appended. +fn result_line_with(stop_reason: &str, since_last: &str, extra: &str) -> String { + format!( + r#"{{"type":"result","subtype":"success","is_error":false,"stop_reason":"{stop_reason}","session_id":"{SESSION}","total_cost_usd":99.0,"modelUsage":{CUMULATIVE},"model_usage_since_last_result":{since_last}{extra}}}"# + ) +} + +// The shapes `claude -p --json-schema` produced on the pinned CLI (2.1.312): +// the answer is a call of the CLI's own StructuredOutput tool, the result +// carries it as `structured_output` with stop_reason `tool_use`, and a +// `rate_limit_event` reports the plan's windows under `unifiedWindows`. +const SCHEMA: &str = r#"{"type":"object","properties":{"answer":{"type":"string"},"n":{"type":"integer"}},"required":["answer","n"],"additionalProperties":false}"#; +const STRUCTURED: &str = r#"{"answer":"say hello","n":0}"#; + +fn structured_call_line(name: &str) -> String { + format!( + r#"{{"type":"assistant","message":{{"id":"msg_s","role":"assistant","content":[{{"type":"tool_use","id":"toolu_01","name":"{name}","input":{STRUCTURED},"caller":{{"type":"direct"}}}}],"stop_reason":null}},"session_id":"{SESSION}"}}"# + ) +} + +fn structured_tool_result_line() -> String { + format!( + r#"{{"type":"user","message":{{"role":"user","content":[{{"tool_use_id":"toolu_01","type":"tool_result","content":"Structured output provided successfully"}}]}},"parent_tool_use_id":null,"session_id":"{SESSION}"}}"# + ) +} + +fn plan_rate_limit_line() -> String { + r#"{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":4102444800,"rateLimitType":"five_hour","overageStatus":"rejected","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0.08,"resetsAt":4102444800,"status":"allowed"},"seven_day":{"utilization":0.3,"resetsAt":4102531200,"status":"allowed"}}}}"#.to_string() +} + +fn structured_result_line() -> String { + result_line_with( + "tool_use", + USAGE, + &format!(r#","structured_output":{STRUCTURED},"result":"ignored""#), + ) +} + +fn structured_script() -> Script { + Script::lines(vec![ + init_line(MODEL, SESSION), + structured_call_line("StructuredOutput"), + structured_tool_result_line(), + plan_rate_limit_line(), + structured_result_line(), + ]) +} + +fn schema_request() -> ProviderRequest { + let mut request = request(vec![user("hello")]); + request.output_schema_json = Some(SCHEMA.to_string()); + request +} + +fn a_tool() -> ToolDefinition { + ToolDefinition { + name: "lookup".into(), + description: "d".into(), + parameters_json: "{}".into(), + } +} + +fn error_result_line(extra: &str) -> String { + format!( + r#"{{"type":"result","subtype":"error_during_execution","is_error":true,"session_id":"{SESSION}","total_cost_usd":99.0,"modelUsage":{CUMULATIVE},"model_usage_since_last_result":{USAGE}{extra}}}"# + ) +} + +fn ok_script(session: &str, text: &str) -> Script { + Script::lines(vec![ + init_line(MODEL, session), + text_line("msg_1", text), + rate_limit_line("allowed"), + result_line("end_turn", USAGE), + ]) +} + +fn usage( + input: u64, + cache_read: u64, + cache_write: u64, + output: u64, + reasoning: Option, +) -> Usage { + Usage { + input_tokens: input, + cache_read_tokens: cache_read, + cache_write_tokens: cache_write, + output_tokens: output, + reasoning_tokens: reasoning, + } +} + +fn user(text: &str) -> InputItem { + InputItem::UserText { text: text.into() } +} + +fn assistant(text: &str) -> InputItem { + InputItem::AssistantText { text: text.into() } +} + +fn request(input: Vec) -> ProviderRequest { + let mut request = ProviderRequest::new("req", "conv", MODEL, "Be terse.", 1024); + request.input = input; + request +} + +// ---- the scripted port: replays fixture lines and records what it was given ---- + +#[derive(Clone, Copy)] +enum Terminal { + Eof, + Timeout, + Breach, +} + +struct Script { + lines: Vec, + terminal: Terminal, +} + +impl Script { + fn lines(lines: Vec) -> Self { + Self { + lines, + terminal: Terminal::Eof, + } + } + + fn ending(mut self, terminal: Terminal) -> Self { + self.terminal = terminal; + self + } +} + +#[derive(Clone, Default)] +struct Seen { + argv: Vec, + env: Vec<(String, String)>, + cwd: PathBuf, + stdin: Vec, + /// The token the backend handed the port, which a real port puts on a pipe. + token: String, + /// The file `--system-prompt-file` named, as it was when the child started. + prompt_file: Option<(PathBuf, String)>, + /// Every deadline the backend passed to a read of this child. + deadlines: Arc>>, +} + +struct ScriptedPort { + scripts: Mutex>, + spawns: Arc, + seen: Arc>, + stderr: Vec, +} + +struct ScriptedChild { + lines: VecDeque, + terminal: Terminal, + stderr: Vec, + deadlines: Arc>>, +} + +impl ProcessPort for ScriptedPort { + fn spawn( + &self, + argv: &[String], + env: &ChildEnv, + cwd: &Path, + stdin: Vec, + token: &str, + ) -> io::Result> { + self.spawns.fetch_add(1, Ordering::SeqCst); + let deadlines = Arc::new(Mutex::new(Vec::new())); + *self.seen.lock().unwrap() = Seen { + prompt_file: arg_after(argv, "--system-prompt-file").map(|path| { + ( + PathBuf::from(path), + std::fs::read_to_string(path).expect("the prompt file exists at spawn"), + ) + }), + deadlines: deadlines.clone(), + argv: argv.to_vec(), + env: env + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + cwd: cwd.to_path_buf(), + stdin, + token: token.to_string(), + }; + let script = self + .scripts + .lock() + .unwrap() + .pop_front() + .expect("the test scripted no further spawn"); + Ok(Box::new(ScriptedChild { + lines: script.lines.into(), + terminal: script.terminal, + stderr: self.stderr.clone(), + deadlines, + })) + } +} + +impl RunningChild for ScriptedChild { + fn read_line(&mut self, deadline: Instant) -> StdoutRead { + self.deadlines.lock().unwrap().push(deadline); + match self.lines.pop_front() { + Some(line) => StdoutRead::Line(line.into_bytes()), + None => match self.terminal { + Terminal::Eof => StdoutRead::Eof, + Terminal::Timeout => StdoutRead::Timeout, + Terminal::Breach => StdoutRead::Breach, + }, + } + } + fn stderr_tail(&self) -> Vec { + self.stderr.clone() + } + fn kill(&mut self) {} + fn wait(&mut self) {} +} + +struct Sessions(Vec); + +impl SessionStore for Sessions { + fn exists(&self, session_id: &str) -> bool { + self.0.iter().any(|known| known == session_id) + } +} + +#[derive(Clone, Default)] +struct Sink(Arc>>); + +impl StderrSink for Sink { + fn stderr_tail(&self, tail: &[u8]) { + self.0.lock().unwrap().extend_from_slice(tail); + } +} + +/// The bytes of the stand-in `claude` the scripted rigs pin. +const PINNED: &[u8] = b"the pinned claude bytes"; + +fn pin_of(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() +} + +/// A directory holding a pinned `claude` file and an empty `prompts` directory. +fn sandbox() -> TempDir { + let dir = TempDir::new(); + std::fs::write(dir.path().join("claude"), PINNED).unwrap(); + std::fs::create_dir_all(dir.path().join("prompts")).unwrap(); + dir +} + +fn config(dir: &Path, instance: &str, limits: ClaudeCodeLimits) -> ClaudeCodeConfig { + ClaudeCodeConfig { + claude_binary: dir.join("claude"), + claude_binary_sha256: pin_of(PINNED), + work_dir: PathBuf::from("/work"), + config_dir: PathBuf::from("/cfg"), + prompt_dir: dir.join("prompts"), + token_credential: "claude-token".to_string(), + handle_secret_credential: "claude-handle-secret".to_string(), + instance: instance.to_string(), + limits, + } +} + +/// A token source with a fixed answer. +struct FixedToken(Option<&'static str>); + +impl TokenSource for FixedToken { + fn read(&self) -> Option> { + self.0.map(|token| Zeroizing::new(token.to_string())) + } +} + +fn canary_token() -> Box { + Box::new(FixedToken(Some(CANARY_TOKEN))) +} + +/// A handle secret source with a fixed answer; it names its own field. +struct FixedSecret(Option<&'static str>); + +impl TokenSource for FixedSecret { + fn read(&self) -> Option> { + self.0.map(|secret| Zeroizing::new(secret.to_string())) + } + + fn missing(&self) -> &'static str { + "handle_secret_credential" + } +} + +const SECRET: &str = "handle-secret-0000000000000000000000000000000000000000000000"; +const OTHER_SECRET: &str = "handle-secret-1111111111111111111111111111111111111111111111"; + +fn canary_secret() -> Box { + Box::new(FixedSecret(Some(SECRET))) +} + +fn limits() -> ClaudeCodeLimits { + ClaudeCodeLimits { + deadline: Duration::from_secs(30), + max_line_bytes: 1 << 20, + max_total_bytes: 1 << 24, + stderr_tail_bytes: 4096, + } +} + +struct Rig { + dir: TempDir, + backend: ClaudeCodeBackend, + spawns: Arc, + seen: Arc>, + sink: Sink, +} + +fn rig_for(instance: &str, scripts: Vec