diff --git a/.gitattributes b/.gitattributes index 0a1d199..56862bb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,2 @@ schemas/*.json text eol=lf -crates/model-connector/tests/fixtures/wire_v3/* -text +crates/model-connector/tests/fixtures/wire_v4/* -text diff --git a/Cargo.lock b/Cargo.lock index 3c3ff8d..3709c14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,6 +134,25 @@ 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" @@ -490,6 +509,7 @@ 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 596f571..74bd5b5 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,19 @@ 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. -Status: the `model-connector` crate is live. No connector binary exists yet -(the Claude connector is first, then an OpenAI-compatible one). +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). ## Entry points Crate `crates/model-connector`: -- `src/contract.rs` - the v3 wire contract (`gamecult.model.*.v3`): `ProviderRequest`, `TransportInvocation`, +- `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 @@ -21,9 +26,12 @@ Crate `crates/model-connector`: - `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`, 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. + `gamecult.model.connector`, operation `invoke` (or `usage_readout`), its `message_id` the + request id and its payload the base64 of the sealed envelope. `usage_readout` answers a + `UsageReadout` (`gamecult.model.usage_readout.v1`): the backend's plan quota windows, each with + utilization, reset, status and age; + `ConnectorClient::usage_readout` asks. +- `schemas/` - one JSON Schema per v4 document, generated from the types. - `src/client.rs` - `ConnectorClient`, the one-request caller side. - `src/service.rs` (feature `daemon`) - `TransportService`: caller admission, durable replay (a retried request is answered byte-identically and the provider runs once), @@ -32,6 +40,22 @@ 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. @@ -39,7 +63,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 v3 +The default feature set compiles contract, envelope and client only. The kit speaks v4 only: a v2 frame gets no answer. ## Verify @@ -49,9 +73,9 @@ cargo test --locked --workspace cargo test --locked --workspace --features model-connector/daemon ``` -`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/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). diff --git a/crates/claude-connector/Cargo.toml b/crates/claude-connector/Cargo.toml new file mode 100644 index 0000000..6cb9e45 --- /dev/null +++ b/crates/claude-connector/Cargo.toml @@ -0,0 +1,31 @@ +[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 new file mode 100644 index 0000000..0bc2d77 --- /dev/null +++ b/crates/claude-connector/src/backend.rs @@ -0,0 +1,474 @@ +use std::fs::File; +use std::io; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Instant; + +use model_connector::{ + FailureKind, InputItem, PlanQuotaSnapshot, ProviderBackend, ProviderCall, ProviderRequest, + TransportEvent, TransportEventPayload, TransportOutcome, TransportResult, Usage, +}; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use crate::config::{ClaudeCodeConfig, PROVIDER}; +use crate::handle::{ + Binding, ClaudeCodeHandle, MIN_SECRET_BYTES, SessionStore, plan, prefix_digest, stdin_bytes, +}; +use crate::launch::{PromptFile, argv, child_env}; +use crate::process::{ProcessPort, RunningChild, StdoutRead}; +use crate::quota::QuotaCell; +use crate::stream::{Failure, Finished, Step, StreamState}; + +/// The meter's call: the cheapest model, a trivial prompt, and an answer cap +/// that only bounds a runaway. +const METER_MODEL: &str = "claude-haiku-5-5"; +const METER_CALLER: &str = "meter"; +const METER_MAX_OUTPUT_TOKENS: u32 = 16; + +/// Where the child's credential comes from, read afresh at every use. The +/// connector's own environment is never a source (ruling +/// claude-connector-token-credential). +pub trait TokenSource: Send + Sync { + /// The token, or `None` when there is none or it is empty. + fn read(&self) -> Option>; + /// What `config check` names when `read` has no token. + fn missing(&self) -> &'static str { + "token_credential" + } +} + +/// A token in a file named by a systemd credential: the file +/// `$CREDENTIALS_DIRECTORY/`, read on every call so a rotated credential +/// is picked up by the next spawn. +pub struct CredentialTokenSource { + directory: Option, + name: String, + /// The configuration field `missing` names when the credential is absent. + field: &'static str, +} + +impl CredentialTokenSource { + /// The credential `name` under the directory systemd names in + /// `CREDENTIALS_DIRECTORY`; with no such variable the source has no token. + /// The variable names a directory, never a token. + pub fn from_environment(name: impl Into) -> Self { + Self { + directory: std::env::var_os("CREDENTIALS_DIRECTORY").map(PathBuf::from), + name: name.into(), + field: "token_credential", + } + } + + /// The same source, naming `field` when its credential is absent. + pub fn for_field(mut self, field: &'static str) -> Self { + self.field = field; + self + } + + pub fn in_directory(directory: impl Into, name: impl Into) -> Self { + Self { + directory: Some(directory.into()), + name: name.into(), + field: "token_credential", + } + } +} + +impl TokenSource for CredentialTokenSource { + fn read(&self) -> Option> { + let directory = self.directory.as_ref()?; + let mut token = Zeroizing::new(std::fs::read_to_string(directory.join(&self.name)).ok()?); + let trimmed = token.trim_end_matches(['\r', '\n']).len(); + token.truncate(trimmed); + (!token.is_empty()).then_some(token) + } + + /// No directory at all is the unit's environment lacking its credentials, + /// which is not the same fault as a credential the directory lacks. + fn missing(&self) -> &'static str { + if self.directory.is_none() { + "credentials_directory" + } else { + self.field + } + } +} + +/// Where the tail of a child's stderr goes. The backend never puts it in a +/// result; the connector binary wires this to its log. +pub trait StderrSink: Send + Sync { + fn stderr_tail(&self, tail: &[u8]); +} + +/// Answers each call with one `claude` child process: never more than one per +/// call, and a failed call is never run again. +pub struct ClaudeCodeBackend { + config: ClaudeCodeConfig, + port: Box, + sessions: Box, + sink: Box, + tokens: Box, + handle_secret: Box, + quota: Arc, +} + +/// The handle secret, or `None` when it is absent or shorter than a key. +fn usable_secret(source: &dyn TokenSource) -> Option> { + source + .read() + .filter(|secret| secret.len() >= MIN_SECRET_BYTES) +} + +impl ClaudeCodeBackend { + /// The backend over real processes and the CLI's real session store. + #[cfg(unix)] + pub fn new(config: ClaudeCodeConfig, sink: Box) -> Self { + let port = Box::new(crate::process::StdProcessPort::new(config.limits)); + let sessions = Box::new(crate::handle::FsSessionStore::new( + config.config_dir.clone(), + )); + let tokens = Box::new(CredentialTokenSource::from_environment( + config.token_credential.clone(), + )); + let handle_secret = Box::new( + CredentialTokenSource::from_environment(config.handle_secret_credential.clone()) + .for_field("handle_secret_credential"), + ); + Self::with_ports(config, port, sessions, sink, tokens, handle_secret) + } + + pub fn with_ports( + config: ClaudeCodeConfig, + port: Box, + sessions: Box, + sink: Box, + tokens: Box, + handle_secret: Box, + ) -> Self { + Self { + config, + port, + sessions, + sink, + tokens, + handle_secret, + quota: Arc::new(QuotaCell::new()), + } + } + + /// The plan windows every stream this backend reads feeds. + pub fn quota(&self) -> Arc { + self.quota.clone() + } + + /// The meter's call: the smallest real one, through the same child, + /// stream and cell as any other. The stream's `rate_limit_event` is what + /// the caller wants; this returns what the call billed, or the failure's + /// kind (never its message). + pub(crate) fn read_meter(&self) -> Result { + let mut request = ProviderRequest::new( + METER_CALLER, + METER_CALLER, + METER_MODEL, + "Reply with the single word ok.", + METER_MAX_OUTPUT_TOKENS, + ); + request.input = vec![InputItem::UserText { + text: "ok?".to_string(), + }]; + match self.run(&request, METER_CALLER, true).1 { + TransportOutcome::Completed { usage, .. } => Ok(usage), + TransportOutcome::Failed { kind, .. } => Err(format!("{kind:?}")), + } + } + + /// The one pin check: `ready` and every spawn use it, and nothing caches + /// its answer. + fn binary_matches_pin(&self) -> bool { + binary_matches_pin(&self.config) + } + + /// `ephemeral` leaves no transcript behind: the meter keeps no handle and + /// resumes nothing, every other call is resumed by its session. + fn run( + &self, + request: &ProviderRequest, + caller: &str, + ephemeral: bool, + ) -> (Vec, TransportOutcome) { + let Some(secret) = usable_secret(self.handle_secret.as_ref()) else { + return failed( + FailureKind::Authentication, + "claude-code: the connector has no handle secret".to_string(), + Some(zero_usage()), + ); + }; + let plan = match plan( + request, + &self.config, + secret.as_bytes(), + caller, + self.sessions.as_ref(), + ) { + Ok(plan) => plan, + Err(error) => { + return failed( + FailureKind::InvalidRequest, + error.to_string(), + Some(zero_usage()), + ); + } + }; + let Some(token) = self.tokens.read() else { + return failed( + FailureKind::Authentication, + "claude-code: the connector has no credential to give the child".to_string(), + Some(zero_usage()), + ); + }; + if !self.binary_matches_pin() { + return failure(Failure::never_reached( + FailureKind::Provider, + "claude-code: the claude binary does not match its pin", + )); + } + let Ok(prompt) = PromptFile::create(&self.config.prompt_dir, &request.instructions) else { + return failure(Failure::never_reached( + FailureKind::Provider, + "claude-code: the system prompt could not be staged", + )); + }; + let argv = argv(&self.config, request, &plan, prompt.path(), ephemeral); + let env = child_env(&self.config, request); + let deadline = Instant::now() + self.config.limits.deadline; + + let spawned = self.port.spawn( + &argv, + &env, + &self.config.work_dir, + stdin_bytes(plan.blocks()), + &token, + ); + let Ok(mut child) = spawned else { + return failure(Failure::never_reached( + FailureKind::Provider, + "claude-code: the child could not be spawned", + )); + }; + let finished = read_stream( + child.as_mut(), + deadline, + &request.model, + request.output_schema_json.as_deref(), + &self.quota, + ); + child.kill(); + child.wait(); + let tail = child.stderr_tail(); + if !tail.is_empty() { + self.sink.stderr_tail(&tail); + } + + match finished { + Ok(finished) => self.completed(request, caller, secret.as_bytes(), finished), + Err(failure_) => failure(failure_), + } + } + + fn completed( + &self, + request: &ProviderRequest, + caller: &str, + secret: &[u8], + finished: Finished, + ) -> (Vec, TransportOutcome) { + let mut payloads: Vec = finished + .texts + .iter() + .map(|text| TransportEventPayload::TextDelta { text: text.clone() }) + .collect(); + + let mut prefix = request.input.clone(); + if !finished.texts.is_empty() { + prefix.push(InputItem::AssistantText { + text: finished.texts.concat(), + }); + } + // A schema turn leaves the CLI's StructuredOutput tool exchange in its + // session, and a later turn without the schema runs with no tools: it + // must not resume that session. So a schema turn hands out no handle, + // and whatever follows it is seeded from the caller's transcript. + if request.output_schema_json.is_none() { + let handle = ClaudeCodeHandle::Ended { + session_id: finished.session_id.clone(), + instance: self.config.instance.clone(), + prefix_sha256: prefix_digest( + secret, + &Binding { + caller, + instructions: &request.instructions, + model: &request.model, + session_id: &finished.session_id, + instance: &self.config.instance, + items: &prefix, + }, + ), + }; + payloads.push(TransportEventPayload::ReasoningState { + state: handle.encode(), + }); + } + + let events = payloads + .into_iter() + .enumerate() + .map(|(sequence, payload)| TransportEvent { + sequence: sequence as u64, + payload, + }) + .collect(); + ( + events, + TransportOutcome::Completed { + provider_response_id: finished.message_id, + stop_reason: finished.stop_reason, + usage: finished.usage, + }, + ) + } +} + +impl ProviderBackend for ClaudeCodeBackend { + fn ready(&self) -> Result<(), &'static str> { + readiness( + &self.config, + self.tokens.as_ref(), + self.handle_secret.as_ref(), + ) + } + + fn provider(&self) -> &str { + PROVIDER + } + + fn execute(&self, call: &ProviderCall) -> TransportResult { + let (events, outcome) = self.run(call.request(), call.caller_runtime_id(), false); + call.transported(events, outcome) + } + + fn plan_quota(&self) -> PlanQuotaSnapshot { + self.quota.snapshot() + } +} + +fn read_stream( + child: &mut dyn RunningChild, + deadline: Instant, + model: &str, + schema: Option<&str>, + quota: &QuotaCell, +) -> Result { + let mut state = StreamState::new(model, schema, quota); + let finished = loop { + match child.read_line(deadline) { + StdoutRead::Line(line) => { + if let Step::Done(done) = state.feed(&line) { + break *done; + } + } + StdoutRead::Eof => { + return Err(Failure::cost_unknown( + FailureKind::Provider, + "claude-code: the stream ended without a result", + )); + } + StdoutRead::Timeout => { + return Err(Failure::cost_unknown( + FailureKind::Timeout, + "claude-code: the deadline passed before a result", + )); + } + StdoutRead::Breach => { + return Err(Failure::cost_unknown( + FailureKind::Provider, + "claude-code: the output passed its size cap", + )); + } + } + }; + // The result is in; give the child until its deadline to close stdout. + while matches!(child.read_line(deadline), StdoutRead::Line(_)) {} + finished +} + +fn binary_matches_pin(config: &ClaudeCodeConfig) -> bool { + let Ok(mut file) = File::open(&config.claude_binary) else { + return false; + }; + let mut hasher = Sha256::new(); + io::copy(&mut file, &mut hasher).is_ok() + && <[u8; 32]>::from(hasher.finalize()) == config.claude_binary_sha256 +} + +/// Whether the backend can take work, naming the first config field that +/// cannot be used. `config check` and `serve` both ask this. +pub(crate) fn readiness( + config: &ClaudeCodeConfig, + tokens: &dyn TokenSource, + handle_secret: &dyn TokenSource, +) -> Result<(), &'static str> { + if !config.work_dir.is_dir() { + return Err("work_dir"); + } + if !config.config_dir.is_dir() { + return Err("config_dir"); + } + if !config.prompt_dir.is_dir() { + return Err("prompt_dir"); + } + if !binary_matches_pin(config) { + return Err("claude_binary_sha256"); + } + // Last: outside the unit the credential is the one thing always absent, + // and it must not hide the faults the document itself has. + if usable_secret(handle_secret).is_none() { + return Err(handle_secret.missing()); + } + if tokens.read().is_none() { + return Err(tokens.missing()); + } + Ok(()) +} + +fn zero_usage() -> Usage { + Usage { + input_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + output_tokens: 0, + reasoning_tokens: None, + } +} + +fn failed( + kind: FailureKind, + message: String, + usage: Option, +) -> (Vec, TransportOutcome) { + ( + Vec::new(), + TransportOutcome::Failed { + kind, + message, + usage, + }, + ) +} + +fn failure(failure: Failure) -> (Vec, TransportOutcome) { + failed(failure.kind, failure.message.to_string(), failure.usage) +} + +#[cfg(test)] +mod tests; diff --git a/crates/claude-connector/src/backend/tests.rs b/crates/claude-connector/src/backend/tests.rs new file mode 100644 index 0000000..ecd98fa --- /dev/null +++ b/crates/claude-connector/src/backend/tests.rs @@ -0,0 +1,2792 @@ +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use model_connector::{ + CallerAdmission, PlanQuotaSnapshot, ReasoningEffort, StopReason, ToolDefinition, + TransportAdmission, TransportDisposition, TransportInvocation, TransportKey, TransportReceipt, + TransportService, decrypt_result, encrypt_invocation, +}; + +use super::*; +use crate::config::ClaudeCodeLimits; +use crate::handle::{FsSessionStore, SessionStore}; +use crate::launch::ChildEnv; + +const CANARY_TOKEN: &str = "canary-oauth-token-value"; +/// The variable the connector's own environment must never hand on. +const TOKEN_ENV: &str = "CLAUDE_CODE_OAUTH_TOKEN"; +const TOKEN_FD_ENV: &str = "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR"; +const MODEL: &str = "claude-haiku-5-5"; +const INSTANCE: &str = "persona-a"; +const SESSION: &str = "11111111-2222-3333-4444-555555555555"; +const HEX_SESSION: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; +const OTHER_MODEL: &str = "claude-opus-5"; +const FORK: &str = "66666666-7777-8888-9999-000000000000"; +const FRAME: usize = 1 << 20; + +// ---- fixtures, written by hand from the fields the probes observed ---- + +fn init_line(model: &str, session: &str) -> String { + format!( + r#"{{"type":"system","subtype":"init","session_id":"{session}","model":"{model}","tools":[],"apiKeySource":"none"}}"# + ) +} + +fn text_line(id: &str, text: &str) -> String { + format!( + r#"{{"type":"assistant","message":{{"id":"{id}","role":"assistant","content":[{{"type":"text","text":"{text}"}}],"stop_reason":null}},"session_id":"{SESSION}"}}"# + ) +} + +fn thinking_line(id: &str) -> String { + format!( + r#"{{"type":"assistant","message":{{"id":"{id}","role":"assistant","content":[{{"type":"thinking","thinking":"","signature":"sig"}}]}}}}"# + ) +} + +fn tool_use_line() -> String { + r#"{"type":"assistant","message":{"id":"msg_t","content":[{"type":"tool_use","id":"toolu_1","name":"x","input":{}}]}}"#.to_string() +} + +fn rate_limit_line(status: &str) -> String { + format!( + r#"{{"type":"rate_limit_event","rate_limit_info":{{"status":"{status}","rateLimitType":"five_hour"}}}}"# + ) +} + +const USAGE: &str = r#"{"claude-haiku-5-5":{"inputTokens":2,"outputTokens":4,"cacheReadInputTokens":7,"cacheCreationInputTokens":1043,"thinkingTokens":3,"costUSD":0.5}}"#; + +/// What a resumed session's `modelUsage` says: every turn so far. Every result +/// fixture carries it beside the turn's own `model_usage_since_last_result`, +/// so reading the wrong field shows in a count. +const CUMULATIVE: &str = r#"{"claude-haiku-5-5":{"inputTokens":900000,"outputTokens":900000,"cacheReadInputTokens":900000,"cacheCreationInputTokens":900000,"thinkingTokens":900000,"costUSD":99.0}}"#; + +fn result_line(stop_reason: &str, since_last: &str) -> String { + result_line_with(stop_reason, since_last, "") +} + +/// A success result with `extra` (a leading comma and fields) appended. +fn result_line_with(stop_reason: &str, since_last: &str, extra: &str) -> String { + format!( + r#"{{"type":"result","subtype":"success","is_error":false,"stop_reason":"{stop_reason}","session_id":"{SESSION}","total_cost_usd":99.0,"modelUsage":{CUMULATIVE},"model_usage_since_last_result":{since_last}{extra}}}"# + ) +} + +// The shapes `claude -p --json-schema` produced on the pinned CLI (2.1.312): +// the answer is a call of the CLI's own StructuredOutput tool, the result +// carries it as `structured_output` with stop_reason `tool_use`, and a +// `rate_limit_event` reports the plan's windows under `unifiedWindows`. +const SCHEMA: &str = r#"{"type":"object","properties":{"answer":{"type":"string"},"n":{"type":"integer"}},"required":["answer","n"],"additionalProperties":false}"#; +const STRUCTURED: &str = r#"{"answer":"say hello","n":0}"#; + +fn structured_call_line(name: &str) -> String { + format!( + r#"{{"type":"assistant","message":{{"id":"msg_s","role":"assistant","content":[{{"type":"tool_use","id":"toolu_01","name":"{name}","input":{STRUCTURED},"caller":{{"type":"direct"}}}}],"stop_reason":null}},"session_id":"{SESSION}"}}"# + ) +} + +fn structured_tool_result_line() -> String { + format!( + r#"{{"type":"user","message":{{"role":"user","content":[{{"tool_use_id":"toolu_01","type":"tool_result","content":"Structured output provided successfully"}}]}},"parent_tool_use_id":null,"session_id":"{SESSION}"}}"# + ) +} + +fn plan_rate_limit_line() -> String { + r#"{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":4102444800,"rateLimitType":"five_hour","overageStatus":"rejected","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0.08,"resetsAt":4102444800,"status":"allowed"},"seven_day":{"utilization":0.3,"resetsAt":4102531200,"status":"allowed"}}}}"#.to_string() +} + +fn structured_result_line() -> String { + result_line_with( + "tool_use", + USAGE, + &format!(r#","structured_output":{STRUCTURED},"result":"ignored""#), + ) +} + +fn structured_script() -> Script { + Script::lines(vec![ + init_line(MODEL, SESSION), + structured_call_line("StructuredOutput"), + structured_tool_result_line(), + plan_rate_limit_line(), + structured_result_line(), + ]) +} + +fn schema_request() -> ProviderRequest { + let mut request = request(vec![user("hello")]); + request.output_schema_json = Some(SCHEMA.to_string()); + request +} + +fn a_tool() -> ToolDefinition { + ToolDefinition { + name: "lookup".into(), + description: "d".into(), + parameters_json: "{}".into(), + } +} + +fn error_result_line(extra: &str) -> String { + format!( + r#"{{"type":"result","subtype":"error_during_execution","is_error":true,"session_id":"{SESSION}","total_cost_usd":99.0,"modelUsage":{CUMULATIVE},"model_usage_since_last_result":{USAGE}{extra}}}"# + ) +} + +fn ok_script(session: &str, text: &str) -> Script { + Script::lines(vec![ + init_line(MODEL, session), + text_line("msg_1", text), + rate_limit_line("allowed"), + result_line("end_turn", USAGE), + ]) +} + +fn usage( + input: u64, + cache_read: u64, + cache_write: u64, + output: u64, + reasoning: Option, +) -> Usage { + Usage { + input_tokens: input, + cache_read_tokens: cache_read, + cache_write_tokens: cache_write, + output_tokens: output, + reasoning_tokens: reasoning, + } +} + +fn user(text: &str) -> InputItem { + InputItem::UserText { text: text.into() } +} + +fn assistant(text: &str) -> InputItem { + InputItem::AssistantText { text: text.into() } +} + +fn request(input: Vec) -> ProviderRequest { + let mut request = ProviderRequest::new("req", "conv", MODEL, "Be terse.", 1024); + request.input = input; + request +} + +// ---- the scripted port: replays fixture lines and records what it was given ---- + +#[derive(Clone, Copy)] +enum Terminal { + Eof, + Timeout, + Breach, +} + +struct Script { + lines: Vec, + terminal: Terminal, +} + +impl Script { + fn lines(lines: Vec) -> Self { + Self { + lines, + terminal: Terminal::Eof, + } + } + + fn ending(mut self, terminal: Terminal) -> Self { + self.terminal = terminal; + self + } +} + +#[derive(Clone, Default)] +struct Seen { + argv: Vec, + env: Vec<(String, String)>, + cwd: PathBuf, + stdin: Vec, + /// The token the backend handed the port, which a real port puts on a pipe. + token: String, + /// The file `--system-prompt-file` named, as it was when the child started. + prompt_file: Option<(PathBuf, String)>, + /// Every deadline the backend passed to a read of this child. + deadlines: Arc>>, +} + +struct ScriptedPort { + scripts: Mutex>, + spawns: Arc, + seen: Arc>, + stderr: Vec, +} + +struct ScriptedChild { + lines: VecDeque, + terminal: Terminal, + stderr: Vec, + deadlines: Arc>>, +} + +impl ProcessPort for ScriptedPort { + fn spawn( + &self, + argv: &[String], + env: &ChildEnv, + cwd: &Path, + stdin: Vec, + token: &str, + ) -> io::Result> { + self.spawns.fetch_add(1, Ordering::SeqCst); + let deadlines = Arc::new(Mutex::new(Vec::new())); + *self.seen.lock().unwrap() = Seen { + prompt_file: arg_after(argv, "--system-prompt-file").map(|path| { + ( + PathBuf::from(path), + std::fs::read_to_string(path).expect("the prompt file exists at spawn"), + ) + }), + deadlines: deadlines.clone(), + argv: argv.to_vec(), + env: env + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + cwd: cwd.to_path_buf(), + stdin, + token: token.to_string(), + }; + let script = self + .scripts + .lock() + .unwrap() + .pop_front() + .expect("the test scripted no further spawn"); + Ok(Box::new(ScriptedChild { + lines: script.lines.into(), + terminal: script.terminal, + stderr: self.stderr.clone(), + deadlines, + })) + } +} + +impl RunningChild for ScriptedChild { + fn read_line(&mut self, deadline: Instant) -> StdoutRead { + self.deadlines.lock().unwrap().push(deadline); + match self.lines.pop_front() { + Some(line) => StdoutRead::Line(line.into_bytes()), + None => match self.terminal { + Terminal::Eof => StdoutRead::Eof, + Terminal::Timeout => StdoutRead::Timeout, + Terminal::Breach => StdoutRead::Breach, + }, + } + } + fn stderr_tail(&self) -> Vec { + self.stderr.clone() + } + fn kill(&mut self) {} + fn wait(&mut self) {} +} + +struct Sessions(Vec); + +impl SessionStore for Sessions { + fn exists(&self, session_id: &str) -> bool { + self.0.iter().any(|known| known == session_id) + } +} + +#[derive(Clone, Default)] +struct Sink(Arc>>); + +impl StderrSink for Sink { + fn stderr_tail(&self, tail: &[u8]) { + self.0.lock().unwrap().extend_from_slice(tail); + } +} + +/// The bytes of the stand-in `claude` the scripted rigs pin. +const PINNED: &[u8] = b"the pinned claude bytes"; + +fn pin_of(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() +} + +/// A directory holding a pinned `claude` file and an empty `prompts` directory. +fn sandbox() -> TempDir { + let dir = TempDir::new(); + std::fs::write(dir.path().join("claude"), PINNED).unwrap(); + std::fs::create_dir_all(dir.path().join("prompts")).unwrap(); + dir +} + +fn config(dir: &Path, instance: &str, limits: ClaudeCodeLimits) -> ClaudeCodeConfig { + ClaudeCodeConfig { + claude_binary: dir.join("claude"), + claude_binary_sha256: pin_of(PINNED), + work_dir: PathBuf::from("/work"), + config_dir: PathBuf::from("/cfg"), + prompt_dir: dir.join("prompts"), + token_credential: "claude-token".to_string(), + handle_secret_credential: "claude-handle-secret".to_string(), + instance: instance.to_string(), + limits, + } +} + +/// A token source with a fixed answer. +struct FixedToken(Option<&'static str>); + +impl TokenSource for FixedToken { + fn read(&self) -> Option> { + self.0.map(|token| Zeroizing::new(token.to_string())) + } +} + +fn canary_token() -> Box { + Box::new(FixedToken(Some(CANARY_TOKEN))) +} + +/// A handle secret source with a fixed answer; it names its own field. +struct FixedSecret(Option<&'static str>); + +impl TokenSource for FixedSecret { + fn read(&self) -> Option> { + self.0.map(|secret| Zeroizing::new(secret.to_string())) + } + + fn missing(&self) -> &'static str { + "handle_secret_credential" + } +} + +const SECRET: &str = "handle-secret-0000000000000000000000000000000000000000000000"; +const OTHER_SECRET: &str = "handle-secret-1111111111111111111111111111111111111111111111"; + +fn canary_secret() -> Box { + Box::new(FixedSecret(Some(SECRET))) +} + +fn limits() -> ClaudeCodeLimits { + ClaudeCodeLimits { + deadline: Duration::from_secs(30), + max_line_bytes: 1 << 20, + max_total_bytes: 1 << 24, + stderr_tail_bytes: 4096, + } +} + +struct Rig { + dir: TempDir, + backend: ClaudeCodeBackend, + spawns: Arc, + seen: Arc>, + sink: Sink, +} + +fn rig_for(instance: &str, scripts: Vec