diff --git a/Cargo.lock b/Cargo.lock index 34dec06..524654e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,6 +138,8 @@ dependencies = [ name = "claude-connector" version = "0.1.0" dependencies = [ + "anyhow", + "cultcache-rs", "libc", "model-connector", "rmp-serde", diff --git a/crates/claude-connector/Cargo.toml b/crates/claude-connector/Cargo.toml index a35bb4a..7f9b042 100644 --- a/crates/claude-connector/Cargo.toml +++ b/crates/claude-connector/Cargo.toml @@ -7,7 +7,13 @@ license.workspace = true repository.workspace = true publish = false +[[bin]] +name = "claude-connector" +path = "src/main.rs" + [dependencies] +anyhow = "1" +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"] } diff --git a/crates/claude-connector/src/backend.rs b/crates/claude-connector/src/backend.rs index ad3fb8c..c5a8f81 100644 --- a/crates/claude-connector/src/backend.rs +++ b/crates/claude-connector/src/backend.rs @@ -1,5 +1,6 @@ use std::fs::File; use std::io; +use std::path::PathBuf; use std::time::Instant; use model_connector::{ @@ -11,12 +12,54 @@ use zeroize::Zeroizing; use crate::config::{ClaudeCodeConfig, PROVIDER}; use crate::handle::{ClaudeCodeHandle, SessionStore, plan, prefix_digest, stdin_bytes}; -use crate::launch::{PromptFile, TOKEN_VAR, argv, child_env}; +use crate::launch::{PromptFile, argv, child_env}; use crate::process::{ProcessPort, RunningChild, StdoutRead}; use crate::stream::{Failure, Finished, Step, StreamState}; -/// How the backend reads its own environment. -type EnvLookup = Box Option + Send + Sync>; +/// 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>; +} + +/// 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, +} + +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(), + } + } + + pub fn in_directory(directory: impl Into, name: impl Into) -> Self { + Self { + directory: Some(directory.into()), + name: name.into(), + } + } +} + +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) + } +} /// 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. @@ -31,7 +74,7 @@ pub struct ClaudeCodeBackend { port: Box, sessions: Box, sink: Box, - env_lookup: EnvLookup, + tokens: Box, } impl ClaudeCodeBackend { @@ -42,7 +85,10 @@ impl ClaudeCodeBackend { let sessions = Box::new(crate::handle::FsSessionStore::new( config.config_dir.clone(), )); - Self::with_ports(config, port, sessions, sink) + let tokens = Box::new(CredentialTokenSource::from_environment( + config.token_credential.clone(), + )); + Self::with_ports(config, port, sessions, sink, tokens) } pub fn with_ports( @@ -50,29 +96,21 @@ impl ClaudeCodeBackend { port: Box, sessions: Box, sink: Box, + tokens: Box, ) -> Self { Self { config, port, sessions, sink, - env_lookup: Box::new(|name| std::env::var(name).ok()), + tokens, } } - fn token(&self) -> Option> { - (self.env_lookup)(TOKEN_VAR) - .filter(|value| !value.is_empty()) - .map(Zeroizing::new) - } - + /// The one pin check: `ready` and every spawn use it, and nothing caches + /// its answer. fn binary_matches_pin(&self) -> bool { - let Ok(mut file) = File::open(&self.config.claude_binary) else { - return false; - }; - let mut hasher = Sha256::new(); - io::copy(&mut file, &mut hasher).is_ok() - && <[u8; 32]>::from(hasher.finalize()) == self.config.claude_binary_sha256 + binary_matches_pin(&self.config) } fn run(&self, request: &ProviderRequest) -> (Vec, TransportOutcome) { @@ -86,15 +124,21 @@ impl ClaudeCodeBackend { ); } }; - let Some(token) = self.token() else { + 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()), ); }; - let Ok(prompt) = PromptFile::create(&request.instructions) else { - return failure(Failure::unbilled( + 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", )); @@ -110,7 +154,7 @@ impl ClaudeCodeBackend { stdin_bytes(plan.blocks()), ); let Ok(mut child) = spawned else { - return failure(Failure::unbilled( + return failure(Failure::never_reached( FailureKind::Provider, "claude-code: the child could not be spawned", )); @@ -175,11 +219,8 @@ impl ClaudeCodeBackend { } impl ProviderBackend for ClaudeCodeBackend { - fn ready(&self) -> bool { - self.config.work_dir.is_dir() - && self.config.config_dir.is_dir() - && self.token().is_some() - && self.binary_matches_pin() + fn ready(&self) -> Result<(), &'static str> { + readiness(&self.config, self.tokens.as_ref()) } fn provider(&self) -> &str { @@ -206,19 +247,19 @@ fn read_stream( } } StdoutRead::Eof => { - return Err(Failure::unbilled( + return Err(Failure::cost_unknown( FailureKind::Provider, "claude-code: the stream ended without a result", )); } StdoutRead::Timeout => { - return Err(Failure::unbilled( + return Err(Failure::cost_unknown( FailureKind::Timeout, "claude-code: the deadline passed before a result", )); } StdoutRead::Breach => { - return Err(Failure::unbilled( + return Err(Failure::cost_unknown( FailureKind::Provider, "claude-code: the output passed its size cap", )); @@ -230,6 +271,39 @@ fn read_stream( 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, +) -> 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 tokens.read().is_none() { + return Err("token_credential"); + } + if !binary_matches_pin(config) { + return Err("claude_binary_sha256"); + } + Ok(()) +} + fn zero_usage() -> Usage { Usage { input_tokens: 0, diff --git a/crates/claude-connector/src/backend/tests.rs b/crates/claude-connector/src/backend/tests.rs index 309e2d1..21a8c72 100644 --- a/crates/claude-connector/src/backend/tests.rs +++ b/crates/claude-connector/src/backend/tests.rs @@ -13,7 +13,7 @@ use model_connector::{ use super::*; use crate::config::ClaudeCodeLimits; use crate::handle::{FsSessionStore, SessionStore}; -use crate::launch::ChildEnv; +use crate::launch::{ChildEnv, TOKEN_VAR}; const CANARY_TOKEN: &str = "canary-oauth-token-value"; const MODEL: &str = "claude-haiku-5-5"; @@ -243,17 +243,47 @@ impl StderrSink for Sink { } } -fn config(instance: &str, limits: ClaudeCodeLimits) -> ClaudeCodeConfig { +/// 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: PathBuf::from("/opt/claude"), - claude_binary_sha256: [9; 32], + 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(), 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))) +} + fn limits() -> ClaudeCodeLimits { ClaudeCodeLimits { deadline: Duration::from_secs(30), @@ -263,12 +293,8 @@ fn limits() -> ClaudeCodeLimits { } } -fn with_canary_token(mut backend: ClaudeCodeBackend) -> ClaudeCodeBackend { - backend.env_lookup = Box::new(|name| (name == TOKEN_VAR).then(|| CANARY_TOKEN.to_string())); - backend -} - struct Rig { + dir: TempDir, backend: ClaudeCodeBackend, spawns: Arc, seen: Arc>, @@ -285,13 +311,16 @@ fn rig_for(instance: &str, scripts: Vec