From 9a062f0846aa4e9b3ca7eeb203daba2b8b67176f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 29 Oct 2026 11:26:30 +0100 Subject: [PATCH 01/37] claude-connector: token from a systemd credential, pin before every spawn, prompts in prompt_dir, door set 401/403/429 Delete EnvLookup and the connector's own environment as a token source; a TokenSource port reads $CREDENTIALS_DIRECTORY/ at each use (claude-connector-token-credential). binary_matches_pin is the one pin check, used by ready() and before every spawn. PromptFile::create stages into the configured prompt_dir instead of temp_dir(). The zero-bill door set is exactly 401, 403 and 429 with explicitly empty since-last usage, pinned by a table over statuses and usage shapes. Soul s2's stderr-join probe is committed. The three price types gain serde derives so the price table can live in the config document. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 2 + crates/claude-connector/Cargo.toml | 3 + crates/claude-connector/src/backend.rs | 80 +++- crates/claude-connector/src/backend/tests.rs | 367 +++++++++++++++---- crates/claude-connector/src/config.rs | 6 + crates/claude-connector/src/launch.rs | 67 +++- crates/claude-connector/src/lib.rs | 2 +- crates/claude-connector/src/stream.rs | 4 +- crates/model-connector/src/service.rs | 6 +- 9 files changed, 442 insertions(+), 95 deletions(-) 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..454dad6 100644 --- a/crates/claude-connector/Cargo.toml +++ b/crates/claude-connector/Cargo.toml @@ -7,7 +7,10 @@ license.workspace = true repository.workspace = true publish = false + [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..2b27368 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,22 +96,19 @@ 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; @@ -86,14 +129,20 @@ 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 { + if !self.binary_matches_pin() { + return failure(Failure::unbilled( + 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::unbilled( FailureKind::Provider, "claude-code: the system prompt could not be staged", @@ -178,7 +227,8 @@ 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.config.prompt_dir.is_dir() + && self.tokens.read().is_some() && self.binary_matches_pin() } diff --git a/crates/claude-connector/src/backend/tests.rs b/crates/claude-connector/src/backend/tests.rs index 309e2d1..5a8939d 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