diff --git a/.gitattributes b/.gitattributes index 56862bb..0a1d199 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,2 @@ schemas/*.json text eol=lf -crates/model-connector/tests/fixtures/wire_v4/* -text +crates/model-connector/tests/fixtures/wire_v3/* -text diff --git a/Cargo.lock b/Cargo.lock index 3709c14..3c3ff8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,25 +134,6 @@ dependencies = [ "inout", ] -[[package]] -name = "claude-connector" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "cultcache-rs", - "hmac", - "libc", - "model-connector", - "rmp-serde", - "rmpv", - "serde", - "serde_json", - "sha2", - "thiserror", - "zeroize", -] - [[package]] name = "const-oid" version = "0.9.6" @@ -509,7 +490,6 @@ name = "model-connector" version = "0.1.0" dependencies = [ "aes-gcm", - "anyhow", "base64", "cultcache-rs", "cultnet-rs", diff --git a/README.md b/README.md index 74bd5b5..cbcccb5 100644 --- a/README.md +++ b/README.md @@ -4,34 +4,23 @@ 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`, 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 are live. No -connector binary exists yet (the Claude connector binary is first, then an OpenAI-compatible one). +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`, +- `src/contract.rs` - the v3 wire contract (`gamecult.model.*.v3`): `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. + `gamecult.model.connector`, operation `invoke`, its `message_id` the request id and its + payload the base64 of the sealed envelope. +- `schemas/` - one JSON Schema per v3 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), @@ -40,22 +29,6 @@ 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 only; the binary that wires it to `serve` is not written): - -- `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. - 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. @@ -63,7 +36,7 @@ a frame allows, and `MAX_IDENTITY_BYTES` (256) bounds caller ids, request ids an 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 +The default feature set compiles contract, envelope and client only. The kit speaks v3 only: a v2 frame gets no answer. ## Verify @@ -73,9 +46,9 @@ 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/wire_golden_v3.rs` pins the v3 documents and one sealed carriage +frame to the fixtures in `crates/model-connector/tests/fixtures/wire_v3` (regenerate with +`WIRE_V3_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). diff --git a/crates/claude-connector/Cargo.toml b/crates/claude-connector/Cargo.toml deleted file mode 100644 index 6cb9e45..0000000 --- a/crates/claude-connector/Cargo.toml +++ /dev/null @@ -1,31 +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"] } -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/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