diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 56862bb..0000000 --- a/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -schemas/*.json text eol=lf -crates/model-connector/tests/fixtures/wire_v4/* -text diff --git a/Cargo.lock b/Cargo.lock index 996b5fc..01f3693 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,33 +52,12 @@ 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" @@ -134,32 +113,6 @@ dependencies = [ "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" @@ -182,7 +135,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", + "rand_core", "typenum", ] @@ -197,8 +150,8 @@ dependencies = [ [[package]] name = "cultcache-rs" -version = "0.3.1" -source = "git+https://github.com/GameCult/CultLib.git?rev=a4eae9056152335662f1115f97bf35fb00ab6d23#a4eae9056152335662f1115f97bf35fb00ab6d23" +version = "0.1.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=e171eca32329baa928f4a1d810401a8b4c857029#e171eca32329baa928f4a1d810401a8b4c857029" dependencies = [ "anyhow", "chrono", @@ -209,84 +162,20 @@ dependencies = [ "serde", "serde_bytes", "serde_json", - "serde_stacker", - "stacker", "uuid", - "windows-sys 0.59.0", + "windows-sys", ] [[package]] name = "cultcache-rs-derive" -version = "0.2.0" -source = "git+https://github.com/GameCult/CultLib.git?rev=a4eae9056152335662f1115f97bf35fb00ab6d23#a4eae9056152335662f1115f97bf35fb00ab6d23" +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 = "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" @@ -295,45 +184,8 @@ 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" @@ -395,18 +247,6 @@ dependencies = [ "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" @@ -415,7 +255,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", ] [[package]] @@ -428,15 +268,6 @@ dependencies = [ "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" @@ -510,16 +341,10 @@ name = "model-connector" version = "0.1.0" dependencies = [ "aes-gcm", - "anyhow", - "base64", "cultcache-rs", - "cultnet-rs", - "rand_core 0.6.4", + "rand_core", "rmp-serde", - "rmpv", - "schemars", "serde", - "serde_bytes", "serde_json", "sha2", "thiserror", @@ -535,15 +360,6 @@ 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" @@ -562,16 +378,6 @@ 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" @@ -584,15 +390,6 @@ dependencies = [ "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" @@ -602,16 +399,6 @@ 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" @@ -621,38 +408,12 @@ 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" @@ -662,15 +423,6 @@ 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" @@ -680,26 +432,6 @@ 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" @@ -719,63 +451,12 @@ dependencies = [ "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" @@ -816,17 +497,6 @@ dependencies = [ "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" @@ -840,17 +510,6 @@ dependencies = [ "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" @@ -868,54 +527,12 @@ 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" @@ -1009,15 +626,6 @@ 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" @@ -1144,15 +752,6 @@ 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" @@ -1162,15 +761,6 @@ 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" @@ -1235,32 +825,6 @@ 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" diff --git a/README.md b/README.md index cf5cd4f..433f540 100644 --- a/README.md +++ b/README.md @@ -4,34 +4,18 @@ 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. -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. - -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. +Status: the `model-connector` crate is live. No connector binary exists yet +(the Claude connector is first, then an OpenAI-compatible one). ## Entry points Crate `crates/model-connector`: -- `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/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), @@ -40,49 +24,16 @@ Crate `crates/model-connector`: `ProviderBackend` trait. A connector binary implements `ProviderBackend` for its provider and calls `serve`; the kit names no provider. -Crate `crates/claude-connector` (library and the `claude-connector` binary that wires it to `serve`): - -- `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 +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`. -The default feature set compiles contract, envelope and client only. The kit speaks v4 -only: a v2 frame gets no answer. +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. ## Verify @@ -91,11 +42,8 @@ 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). +`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 diff --git a/crates/claude-connector/Cargo.toml b/crates/claude-connector/Cargo.toml deleted file mode 100644 index 3b1d113..0000000 --- a/crates/claude-connector/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[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 deleted file mode 100644 index ddd4e9f..0000000 --- a/crates/claude-connector/src/ask.rs +++ /dev/null @@ -1,336 +0,0 @@ -//! `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 deleted file mode 100644 index 0bc2d77..0000000 --- a/crates/claude-connector/src/backend.rs +++ /dev/null @@ -1,474 +0,0 @@ -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 deleted file mode 100644 index ecd98fa..0000000 --- a/crates/claude-connector/src/backend/tests.rs +++ /dev/null @@ -1,2792 +0,0 @@ -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