From b44c5a3b757fce1895bed83e6accd5e7bf8438f3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 29 Oct 2026 11:26:32 +0100 Subject: [PATCH] claude-connector: the binary over one typed CultCache config document gamecult.model_connector.claude_connector_config.v1 owns every bound and path the backend and serve loop run with: read as an exact single-record canonical snapshot, validated once for config init, config check and serve (loopback listen, absolute paths, one caller, canonical priced claude- models). Refusals name a field and never echo a value. serve refuses a prompt_dir that is not a directory owned by the process uid with mode 0700. Co-Authored-By: Claude Sonnet 5.5 --- crates/claude-connector/Cargo.toml | 3 + crates/claude-connector/src/daemon.rs | 318 ++++++++++ crates/claude-connector/src/lib.rs | 4 + crates/claude-connector/src/main.rs | 238 ++++++++ .../claude-connector/tests/config_document.rs | 571 ++++++++++++++++++ 5 files changed, 1134 insertions(+) create mode 100644 crates/claude-connector/src/daemon.rs create mode 100644 crates/claude-connector/src/main.rs create mode 100644 crates/claude-connector/tests/config_document.rs diff --git a/crates/claude-connector/Cargo.toml b/crates/claude-connector/Cargo.toml index 454dad6..7f9b042 100644 --- a/crates/claude-connector/Cargo.toml +++ b/crates/claude-connector/Cargo.toml @@ -7,6 +7,9 @@ license.workspace = true repository.workspace = true publish = false +[[bin]] +name = "claude-connector" +path = "src/main.rs" [dependencies] anyhow = "1" diff --git a/crates/claude-connector/src/daemon.rs b/crates/claude-connector/src/daemon.rs new file mode 100644 index 0000000..8debb4b --- /dev/null +++ b/crates/claude-connector/src/daemon.rs @@ -0,0 +1,318 @@ +//! The claude-connector's configuration document and its serve verb. One typed +//! CultCache record owns every bound and path the backend and the serve loop +//! run with; nothing in the connector's environment, flags or JSON can change +//! them. Validation errors name a field and never echo a value. + +use std::collections::{BTreeMap, BTreeSet}; +use std::net::{SocketAddr, TcpListener}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use cultcache_rs::{CultCache, DatabaseEntry, SingleFileMessagePackBackingStore}; +use model_connector::{ + CallerConfig, MIN_PAYLOAD_BYTES, ModelPrice, ServeError, ServeLimits, load_admissions, serve, +}; +use thiserror::Error; + +use crate::{ClaudeCodeBackend, ClaudeCodeConfig, ClaudeCodeLimits, StderrSink}; + +const CONFIG_KEY: &str = "runtime"; +const MODEL_PREFIX: &str = "claude-"; + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "gamecult.model_connector.claude_connector_config.v1")] +pub struct ClaudeConnectorConfig { + /// A loopback socket address. + #[cultcache(key = 0)] + pub listen: String, + #[cultcache(key = 1)] + pub replay_store: PathBuf, + #[cultcache(key = 2)] + pub claude_binary: PathBuf, + #[cultcache(key = 3)] + pub claude_binary_sha256: [u8; 32], + #[cultcache(key = 4)] + pub work_dir: PathBuf, + #[cultcache(key = 5)] + pub config_dir: PathBuf, + /// Where each call's system prompt is staged: a directory the connector + /// owns, mode 0700. + #[cultcache(key = 6)] + pub prompt_dir: PathBuf, + #[cultcache(key = 7)] + pub instance: String, + /// The name of the systemd credential that holds the child's token. + #[cultcache(key = 8)] + pub token_credential: String, + #[cultcache(key = 9)] + pub deadline_ms: u64, + #[cultcache(key = 10)] + pub max_line_bytes: usize, + #[cultcache(key = 11)] + pub max_total_bytes: usize, + #[cultcache(key = 12)] + pub stderr_tail_bytes: usize, + #[cultcache(key = 13)] + pub max_frame_bytes: usize, + #[cultcache(key = 14)] + pub max_connections: usize, + #[cultcache(key = 15)] + pub socket_timeout_ms: u64, + #[cultcache(key = 16)] + pub max_expiry_skew_ms: u64, + /// Exactly one caller until claude-handle-caller-binding lands. + #[cultcache(key = 17)] + pub callers: Vec, + /// What each allowed model costs; keys are canonical model ids. + #[cultcache(key = 18)] + pub prices: BTreeMap, +} + +/// A refusal. It names the field or stage that failed and nothing the +/// operator or the file typed. +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("invalid configuration field {0}")] + Invalid(&'static str), + #[error("the configuration store could not be read as a claude connector document")] + Store, + #[error("the prompt directory is not a directory owned by this process with mode 0700")] + PromptDir, + #[error("the listener could not be bound")] + Listen, + #[error(transparent)] + Serve(#[from] ServeError), +} + +impl ClaudeConnectorConfig { + /// The one validation: `config init`, `config check` and `serve` all pass + /// through it. Returns the listen address. + pub fn validate(&self) -> Result { + let listen = self + .listen + .parse::() + .ok() + .filter(|address| address.ip().is_loopback()) + .ok_or(ConfigError::Invalid("listen"))?; + let paths = [ + ("replay_store", &self.replay_store), + ("claude_binary", &self.claude_binary), + ("work_dir", &self.work_dir), + ("config_dir", &self.config_dir), + ("prompt_dir", &self.prompt_dir), + ]; + for (field, path) in paths { + if !path.is_absolute() { + return Err(ConfigError::Invalid(field)); + } + } + if self.claude_binary_sha256 == [0; 32] { + return Err(ConfigError::Invalid("claude_binary_sha256")); + } + if !is_trimmed_nonempty(&self.instance) { + return Err(ConfigError::Invalid("instance")); + } + if !is_credential_name(&self.token_credential) { + return Err(ConfigError::Invalid("token_credential")); + } + if self.deadline_ms == 0 + || self.max_line_bytes == 0 + || self.max_total_bytes < self.max_line_bytes + || self.stderr_tail_bytes == 0 + { + return Err(ConfigError::Invalid("child limits")); + } + if self.max_frame_bytes == 0 + || self.max_connections == 0 + || self.socket_timeout_ms == 0 + || self.max_expiry_skew_ms == 0 + { + return Err(ConfigError::Invalid("serve limits")); + } + let [caller] = self.callers.as_slice() else { + return Err(ConfigError::Invalid("callers")); + }; + if !is_trimmed_nonempty(&caller.caller_runtime_id) { + return Err(ConfigError::Invalid("caller_runtime_id")); + } + if !caller.connection_key_file.is_absolute() { + return Err(ConfigError::Invalid("connection_key_file")); + } + if caller.connection_key_epoch == 0 { + return Err(ConfigError::Invalid("connection_key_epoch")); + } + if caller.max_concurrent_requests == 0 + || caller.max_payload_bytes < MIN_PAYLOAD_BYTES + || caller.max_output_tokens == 0 + || caller.spend_ceiling_micro_usd == 0 + || caller.spend_window_ms == 0 + { + return Err(ConfigError::Invalid("caller bounds")); + } + if caller.allowed_models.is_empty() { + return Err(ConfigError::Invalid("allowed_models")); + } + let mut seen = BTreeSet::new(); + for model in &caller.allowed_models { + if !is_canonical_model(model) + || !seen.insert(model.as_str()) + || !self.prices.contains_key(model) + { + return Err(ConfigError::Invalid("allowed_models")); + } + } + if !self.prices.keys().all(|model| is_canonical_model(model)) { + return Err(ConfigError::Invalid("prices")); + } + Ok(listen) + } + + fn backend_config(&self) -> ClaudeCodeConfig { + ClaudeCodeConfig { + claude_binary: self.claude_binary.clone(), + claude_binary_sha256: self.claude_binary_sha256, + work_dir: self.work_dir.clone(), + config_dir: self.config_dir.clone(), + prompt_dir: self.prompt_dir.clone(), + token_credential: self.token_credential.clone(), + instance: self.instance.clone(), + limits: ClaudeCodeLimits { + deadline: Duration::from_millis(self.deadline_ms), + max_line_bytes: self.max_line_bytes, + max_total_bytes: self.max_total_bytes, + stderr_tail_bytes: self.stderr_tail_bytes, + }, + } + } + + fn serve_limits(&self) -> ServeLimits { + ServeLimits { + max_frame_bytes: self.max_frame_bytes, + max_connections: self.max_connections, + socket_timeout_ms: self.socket_timeout_ms, + max_expiry_skew_ms: self.max_expiry_skew_ms, + } + } +} + +fn is_trimmed_nonempty(text: &str) -> bool { + !text.is_empty() && text.trim() == text +} + +/// A model id the CLI echoes back unchanged: aliases are refused because the +/// stream's `init.model` must equal the requested model. +fn is_canonical_model(model: &str) -> bool { + model.starts_with(MODEL_PREFIX) + && model.len() > MODEL_PREFIX.len() + && model + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_')) +} + +/// One file name under `$CREDENTIALS_DIRECTORY`, never a path. +fn is_credential_name(name: &str) -> bool { + !name.is_empty() + && name != "." + && name != ".." + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_')) +} + +/// Reads the document without writing: a shared snapshot of exactly one +/// record, with the exact key, type and schema id, in canonical positional +/// MessagePack, then validated. +pub fn load_config(path: &Path) -> Result { + let store = SingleFileMessagePackBackingStore::new(path); + let config = store + .with_read_only_shared_snapshot(|entries| { + let [envelope] = entries.as_slice() else { + anyhow::bail!("not exactly one record"); + }; + if envelope.key != CONFIG_KEY + || envelope.r#type != ClaudeConnectorConfig::TYPE + || envelope.schema_id.as_deref() != Some(ClaudeConnectorConfig::TYPE) + { + anyhow::bail!("wrong typed envelope"); + } + let config: ClaudeConnectorConfig = rmp_serde::from_slice(&envelope.payload)?; + if rmp_serde::to_vec(&config)? != envelope.payload { + anyhow::bail!("not canonical positional MessagePack"); + } + Ok(config) + }) + .map_err(|_| ConfigError::Store)?; + config.validate()?; + Ok(config) +} + +/// Validates, then writes the document as the store's only record. +pub fn write_config(path: &Path, config: &ClaudeConnectorConfig) -> Result<(), ConfigError> { + config.validate()?; + let mut cache = CultCache::new(); + cache + .register_entry_type::() + .map_err(|_| ConfigError::Store)?; + cache + .add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)) + .map_err(|_| ConfigError::Store)?; + cache + .pull_all_backing_stores() + .map_err(|_| ConfigError::Store)?; + cache + .put(CONFIG_KEY, config) + .map_err(|_| ConfigError::Store)?; + Ok(()) +} + +/// The prompt directory holds every system prompt in flight: it must be a +/// real directory (not a link) that this process owns and nobody else can +/// enter. +#[cfg(unix)] +pub fn check_prompt_dir(dir: &Path) -> Result<(), ConfigError> { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let metadata = std::fs::symlink_metadata(dir).map_err(|_| ConfigError::PromptDir)?; + // SAFETY: geteuid has no preconditions and cannot fail. + let uid = unsafe { libc::geteuid() }; + if !metadata.is_dir() + || metadata.uid() != uid + || metadata.permissions().mode() & 0o7777 != 0o700 + { + return Err(ConfigError::PromptDir); + } + Ok(()) +} + +/// The child's stderr is the log's business by size only: its bytes are never +/// written anywhere the connector controls. +struct LogSink; + +impl StderrSink for LogSink { + fn stderr_tail(&self, tail: &[u8]) { + eprintln!("claude-connector: child stderr, {} bytes", tail.len()); + } +} + +/// Loads the document and serves until the listener fails. +#[cfg(unix)] +pub fn serve_config(path: &Path) -> Result<(), ConfigError> { + let config = load_config(path)?; + let listen = config.validate()?; + check_prompt_dir(&config.prompt_dir)?; + let admissions = load_admissions(&config.callers)?; + let backend = Arc::new(ClaudeCodeBackend::new( + config.backend_config(), + Box::new(LogSink), + )); + let listener = TcpListener::bind(listen).map_err(|_| ConfigError::Listen)?; + serve( + listener, + config.serve_limits(), + admissions, + config.prices.clone(), + &config.replay_store, + backend, + )?; + Ok(()) +} diff --git a/crates/claude-connector/src/lib.rs b/crates/claude-connector/src/lib.rs index a7de84f..2ca22bf 100644 --- a/crates/claude-connector/src/lib.rs +++ b/crates/claude-connector/src/lib.rs @@ -5,6 +5,7 @@ mod backend; mod config; +mod daemon; mod handle; mod launch; mod process; @@ -12,6 +13,9 @@ mod stream; pub use backend::{ClaudeCodeBackend, CredentialTokenSource, StderrSink, TokenSource}; pub use config::{ClaudeCodeConfig, ClaudeCodeLimits}; +pub use daemon::{ClaudeConnectorConfig, ConfigError, load_config, write_config}; +#[cfg(unix)] +pub use daemon::{check_prompt_dir, serve_config}; pub use handle::{FsSessionStore, SessionStore}; pub use launch::ChildEnv; #[cfg(unix)] diff --git a/crates/claude-connector/src/main.rs b/crates/claude-connector/src/main.rs new file mode 100644 index 0000000..f4b7004 --- /dev/null +++ b/crates/claude-connector/src/main.rs @@ -0,0 +1,238 @@ +//! The claude-connector binary. +//! +//! claude-connector serve --config PATH.cc +//! claude-connector config init PATH.cc --flag value ... +//! claude-connector config check PATH.cc +//! +//! `serve` takes its configuration from the typed document alone. The flags of +//! `config init` exist only to author that document. No message here echoes a +//! value that was typed or stored; refusals name a field or a flag. + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::process::ExitCode; + +use claude_connector::{ClaudeConnectorConfig, ConfigError, load_config, write_config}; +use model_connector::{CallerConfig, ModelPrice, Rates, frame_bytes_for_payload}; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let result = match args.as_slice() { + ["serve", "--config", path] => serve(path), + ["config", "init", path, flags @ ..] => init(path, flags), + ["config", "check", path] => check(path), + _ => Err(Failure::Usage), + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(failure) => { + eprintln!("claude-connector: {failure}"); + ExitCode::FAILURE + } + } +} + +#[derive(Debug, thiserror::Error)] +enum Failure { + #[error("usage: serve --config PATH | config init PATH --flag value ... | config check PATH")] + Usage, + #[error(transparent)] + Config(#[from] ConfigError), + #[error("flag {0} is missing")] + Missing(&'static str), + #[error("flag {0} is malformed")] + Malformed(&'static str), + #[error("a flag is unknown, repeated or has no value")] + Flags, +} + +#[cfg(unix)] +fn serve(path: &str) -> Result<(), Failure> { + Ok(claude_connector::serve_config(path.as_ref())?) +} + +#[cfg(not(unix))] +fn serve(_: &str) -> Result<(), Failure> { + Err(Failure::Usage) +} + +fn check(path: &str) -> Result<(), Failure> { + let config = load_config(path.as_ref())?; + println!("claude connector configuration is valid"); + println!("callers: {}", config.callers.len()); + println!("allowed_models: {}", config.callers[0].allowed_models.len()); + println!("prices: {}", config.prices.len()); + Ok(()) +} + +/// The flags of `config init`, each given at most once except the two lists. +struct Flags { + single: BTreeMap<&'static str, String>, + models: Vec, + prices: Vec, +} + +const SINGLE: [&str; 22] = [ + "--listen", + "--replay-store", + "--claude-binary", + "--claude-sha256", + "--work-dir", + "--config-dir", + "--prompt-dir", + "--instance", + "--token-credential", + "--caller-id", + "--connection-key-file", + "--connection-key-epoch", + "--spend-ceiling-micro-usd", + "--spend-window-ms", + "--deadline-ms", + "--max-line-bytes", + "--max-total-bytes", + "--stderr-tail-bytes", + "--max-concurrent-requests", + "--max-payload-bytes", + "--max-output-tokens", + "--socket-timeout-ms", +]; + +impl Flags { + fn parse(args: &[&str]) -> Result { + let mut flags = Flags { + single: BTreeMap::new(), + models: Vec::new(), + prices: Vec::new(), + }; + let mut args = args.iter(); + while let Some(&name) = args.next() { + let Some(&value) = args.next() else { + return Err(Failure::Flags); + }; + match name { + "--allowed-model" => flags.models.push(value.to_string()), + "--price" => flags.prices.push(value.to_string()), + _ => { + let Some(known) = SINGLE.iter().find(|known| **known == name) else { + return Err(Failure::Flags); + }; + if flags.single.insert(*known, value.to_string()).is_some() { + return Err(Failure::Flags); + } + } + } + } + Ok(flags) + } + + fn text(&self, name: &'static str) -> Result { + self.single.get(name).cloned().ok_or(Failure::Missing(name)) + } + + fn path(&self, name: &'static str) -> Result { + self.text(name).map(PathBuf::from) + } + + fn number(&self, name: &'static str) -> Result { + self.text(name)? + .parse() + .map_err(|_| Failure::Malformed(name)) + } + + fn number_or( + &self, + name: &'static str, + default: T, + ) -> Result { + match self.single.get(name) { + None => Ok(default), + Some(text) => text.parse().map_err(|_| Failure::Malformed(name)), + } + } +} + +fn sha256(hex: &str) -> Option<[u8; 32]> { + if hex.len() != 64 || !hex.is_ascii() { + return None; + } + let mut digest = [0_u8; 32]; + for (byte, pair) in digest.iter_mut().zip(hex.as_bytes().chunks(2)) { + *byte = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?; + } + Some(digest) +} + +/// MODEL:INPUT:OUTPUT:CACHE_WRITE:CACHE_READ:OVERHEAD, rates in micro-USD per +/// million tokens. The one cache-write rate prices both retentions. +fn price(text: &str) -> Option<(String, ModelPrice)> { + let mut parts = text.split(':'); + let model = parts.next()?.to_string(); + let mut next = || parts.next()?.parse::().ok(); + let (input, output, cache_write, cache_read, overhead_tokens) = + (next()?, next()?, next()?, next()?, next()?); + if parts.next().is_some() { + return None; + } + Some(( + model, + ModelPrice { + rates: Rates { + input, + cache_write_short: cache_write, + cache_write_long: cache_write, + cache_read, + output, + }, + long_context: None, + overhead_tokens, + }, + )) +} + +fn init(path: &str, args: &[&str]) -> Result<(), Failure> { + let flags = Flags::parse(args)?; + let max_payload_bytes: usize = flags.number_or("--max-payload-bytes", 1 << 20)?; + let max_concurrent_requests: usize = flags.number_or("--max-concurrent-requests", 4)?; + let mut prices = BTreeMap::new(); + for text in &flags.prices { + let (model, price) = price(text).ok_or(Failure::Malformed("--price"))?; + if prices.insert(model, price).is_some() { + return Err(Failure::Malformed("--price")); + } + } + let config = ClaudeConnectorConfig { + listen: flags.text("--listen")?, + replay_store: flags.path("--replay-store")?, + claude_binary: flags.path("--claude-binary")?, + claude_binary_sha256: sha256(&flags.text("--claude-sha256")?) + .ok_or(Failure::Malformed("--claude-sha256"))?, + work_dir: flags.path("--work-dir")?, + config_dir: flags.path("--config-dir")?, + prompt_dir: flags.path("--prompt-dir")?, + instance: flags.text("--instance")?, + token_credential: flags.text("--token-credential")?, + deadline_ms: flags.number_or("--deadline-ms", 600_000)?, + max_line_bytes: flags.number_or("--max-line-bytes", 4 << 20)?, + max_total_bytes: flags.number_or("--max-total-bytes", 64 << 20)?, + stderr_tail_bytes: flags.number_or("--stderr-tail-bytes", 8192)?, + max_frame_bytes: frame_bytes_for_payload(max_payload_bytes), + max_connections: max_concurrent_requests.max(8), + socket_timeout_ms: flags.number_or("--socket-timeout-ms", 10_000)?, + max_expiry_skew_ms: 300_000, + callers: vec![CallerConfig { + caller_runtime_id: flags.text("--caller-id")?, + connection_key_file: flags.path("--connection-key-file")?, + connection_key_epoch: flags.number("--connection-key-epoch")?, + allowed_models: flags.models.clone(), + max_concurrent_requests, + max_payload_bytes, + max_output_tokens: flags.number_or("--max-output-tokens", 32_768)?, + spend_ceiling_micro_usd: flags.number("--spend-ceiling-micro-usd")?, + spend_window_ms: flags.number("--spend-window-ms")?, + }], + prices, + }; + write_config(path.as_ref(), &config)?; + Ok(()) +} diff --git a/crates/claude-connector/tests/config_document.rs b/crates/claude-connector/tests/config_document.rs new file mode 100644 index 0000000..89f92bf --- /dev/null +++ b/crates/claude-connector/tests/config_document.rs @@ -0,0 +1,571 @@ +//! The claude-connector's configuration document and binary: what the +//! document accepts, what it refuses, and that no refusal or output echoes a +//! byte of a value that was typed, stored or read from a credential. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use claude_connector::{ClaudeConnectorConfig, ConfigError, load_config, write_config}; +use cultcache_rs::{CultCache, DatabaseEntry, SingleFileMessagePackBackingStore}; +use model_connector::{CallerConfig, ModelPrice, Rates}; + +const CANARY: &str = "canary-9f3b7c1d"; +const OPUS: &str = "claude-opus-5"; +const HAIKU: &str = "claude-haiku-5-5"; + +/// An edit that should make the valid document refuse. +type Change = Box; + +static SEQUENCE: AtomicUsize = AtomicUsize::new(0); + +struct Scratch(PathBuf); + +impl Scratch { + fn new() -> Self { + let dir = std::env::temp_dir().join(format!( + "claude-connector-config-{}-{}", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::SeqCst) + )); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn price() -> ModelPrice { + ModelPrice { + rates: Rates { + input: 3_000_000, + cache_write_short: 3_750_000, + cache_write_long: 3_750_000, + cache_read: 300_000, + output: 15_000_000, + }, + long_context: None, + overhead_tokens: 100, + } +} + +fn valid() -> ClaudeConnectorConfig { + ClaudeConnectorConfig { + listen: "127.0.0.1:7443".to_string(), + replay_store: "/var/lib/claude-connector/replay.cc".into(), + claude_binary: "/opt/claude/bin/claude".into(), + claude_binary_sha256: [7; 32], + work_dir: "/var/lib/claude-connector/work".into(), + config_dir: "/var/lib/claude-connector/home".into(), + prompt_dir: "/var/lib/claude-connector/prompts".into(), + instance: "persona-a".to_string(), + token_credential: "claude-token".to_string(), + deadline_ms: 600_000, + max_line_bytes: 4 << 20, + max_total_bytes: 64 << 20, + stderr_tail_bytes: 8192, + max_frame_bytes: 2 << 20, + max_connections: 8, + socket_timeout_ms: 10_000, + max_expiry_skew_ms: 300_000, + callers: vec![CallerConfig { + caller_runtime_id: "persona-a-caller".to_string(), + connection_key_file: "/etc/claude-connector/caller.key".into(), + connection_key_epoch: 1, + allowed_models: vec![HAIKU.to_string(), OPUS.to_string()], + max_concurrent_requests: 4, + max_payload_bytes: 1 << 20, + max_output_tokens: 32_768, + spend_ceiling_micro_usd: 50_000_000, + spend_window_ms: 3_600_000, + }], + prices: BTreeMap::from([(HAIKU.to_string(), price()), (OPUS.to_string(), price())]), + } +} + +fn refused(config: &ClaudeConnectorConfig) -> &'static str { + match config.validate() { + Err(ConfigError::Invalid(field)) => field, + other => panic!("expected a refusal naming a field, got {other:?}"), + } +} + +#[test] +fn config_round_trips_through_cultcache() { + let scratch = Scratch::new(); + let path = scratch.path("connector.cc"); + let config = valid(); + write_config(&path, &config).unwrap(); + assert_eq!(load_config(&path).unwrap(), config); +} + +#[test] +fn a_second_record_or_the_wrong_type_is_refused() { + let scratch = Scratch::new(); + let path = scratch.path("two.cc"); + let config = valid(); + write_config(&path, &config).unwrap(); + let mut cache = CultCache::new(); + cache + .register_entry_type::() + .unwrap(); + cache + .add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)) + .unwrap(); + cache.pull_all_backing_stores().unwrap(); + cache.put("extra", &config).unwrap(); + assert!(matches!(load_config(&path), Err(ConfigError::Store))); + + #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] + #[cultcache(type = "gamecult.other.document.v1")] + struct Other { + #[cultcache(key = 0)] + value: u32, + } + let other = scratch.path("other.cc"); + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache + .add_generic_backing_store(SingleFileMessagePackBackingStore::new(&other)) + .unwrap(); + cache.pull_all_backing_stores().unwrap(); + cache.put("runtime", &Other { value: 1 }).unwrap(); + assert!(matches!(load_config(&other), Err(ConfigError::Store))); + + assert!(matches!( + load_config(&scratch.path("absent.cc")), + Err(ConfigError::Store) + )); +} + +#[test] +fn a_write_refuses_an_invalid_document_and_leaves_no_file() { + let scratch = Scratch::new(); + let path = scratch.path("bad.cc"); + let mut config = valid(); + config.callers[0].allowed_models = vec!["opus".to_string()]; + assert!(write_config(&path, &config).is_err()); + assert!(!path.exists()); +} + +#[test] +fn config_refuses_aliases_unpriced_models_and_second_callers() { + let valid_config = valid(); + assert_eq!(valid_config.validate().unwrap().port(), 7443); + + let cases: Vec<(&str, Change)> = vec![ + ( + "alias allowed", + Box::new(|c| { + c.callers[0].allowed_models = vec!["opus".to_string()]; + }), + ), + ( + "alias price key", + Box::new(|c| { + c.prices.insert("sonnet".to_string(), price()); + }), + ), + ( + "bare prefix", + Box::new(|c| { + c.callers[0].allowed_models = vec!["claude-".to_string()]; + }), + ), + ( + "unpriced allowed", + Box::new(|c| { + c.prices.remove(OPUS); + }), + ), + ( + "duplicate allowed", + Box::new(|c| { + c.callers[0].allowed_models = vec![HAIKU.to_string(), HAIKU.to_string()]; + }), + ), + ( + "no allowed", + Box::new(|c| c.callers[0].allowed_models.clear()), + ), + ( + "second caller", + Box::new(|c| { + let mut second = c.callers[0].clone(); + second.caller_runtime_id = "other".to_string(); + c.callers.push(second); + }), + ), + ("no caller", Box::new(|c| c.callers.clear())), + ]; + let fields = [ + "allowed_models", + "prices", + "allowed_models", + "allowed_models", + "allowed_models", + "allowed_models", + "callers", + "callers", + ]; + for ((name, change), field) in cases.iter().zip(fields) { + let mut config = valid(); + change(&mut config); + assert_eq!(refused(&config), field, "{name}"); + } +} + +#[test] +fn config_refuses_exposed_listeners_relative_paths_and_empty_bounds() { + let cases: Vec<(&str, &str, Change)> = vec![ + ( + "wildcard listen", + "listen", + Box::new(|c| c.listen = "0.0.0.0:7443".into()), + ), + ( + "lan listen", + "listen", + Box::new(|c| c.listen = "192.168.1.9:7443".into()), + ), + ( + "hostname listen", + "listen", + Box::new(|c| c.listen = "localhost:7443".into()), + ), + ( + "replay", + "replay_store", + Box::new(|c| c.replay_store = "replay.cc".into()), + ), + ( + "binary", + "claude_binary", + Box::new(|c| c.claude_binary = "claude".into()), + ), + ("work", "work_dir", Box::new(|c| c.work_dir = "work".into())), + ( + "config", + "config_dir", + Box::new(|c| c.config_dir = "cfg".into()), + ), + ( + "prompts", + "prompt_dir", + Box::new(|c| c.prompt_dir = "prompts".into()), + ), + ( + "key file", + "connection_key_file", + Box::new(|c| { + c.callers[0].connection_key_file = "caller.key".into(); + }), + ), + ( + "zero pin", + "claude_binary_sha256", + Box::new(|c| c.claude_binary_sha256 = [0; 32]), + ), + ( + "blank instance", + "instance", + Box::new(|c| c.instance = " ".into()), + ), + ( + "credential path", + "token_credential", + Box::new(|c| c.token_credential = "a/b".into()), + ), + ( + "credential parent", + "token_credential", + Box::new(|c| c.token_credential = "..".into()), + ), + ( + "credential empty", + "token_credential", + Box::new(|c| c.token_credential = String::new()), + ), + ( + "zero deadline", + "child limits", + Box::new(|c| c.deadline_ms = 0), + ), + ( + "total below line", + "child limits", + Box::new(|c| c.max_total_bytes = c.max_line_bytes - 1), + ), + ( + "zero frame", + "serve limits", + Box::new(|c| c.max_frame_bytes = 0), + ), + ( + "zero epoch", + "connection_key_epoch", + Box::new(|c| c.callers[0].connection_key_epoch = 0), + ), + ( + "tiny payload", + "caller bounds", + Box::new(|c| c.callers[0].max_payload_bytes = 16), + ), + ( + "no ceiling", + "caller bounds", + Box::new(|c| c.callers[0].spend_ceiling_micro_usd = 0), + ), + ]; + for (name, field, change) in cases { + let mut config = valid(); + change(&mut config); + assert_eq!(refused(&config), field, "{name}"); + } +} + +#[test] +fn no_refusal_echoes_a_value() { + let canary_path: PathBuf = format!("{CANARY}/relative").into(); + let canaried: Vec = vec![ + Box::new(|c| c.listen = format!("{CANARY}:1")), + Box::new(|c| c.instance = format!(" {CANARY} ")), + Box::new(|c| c.token_credential = format!("{CANARY}/x")), + Box::new(|c| c.callers[0].allowed_models = vec![format!("{CANARY}-model")]), + Box::new(|c| { + c.prices.insert(format!("{CANARY}-alias"), price()); + }), + Box::new(|c| c.callers[0].caller_runtime_id = format!(" {CANARY}")), + Box::new({ + let path = canary_path.clone(); + move |c| c.replay_store = path.clone() + }), + Box::new({ + let path = canary_path.clone(); + move |c| c.callers[0].connection_key_file = path.clone() + }), + Box::new(|c| { + let mut second = c.callers[0].clone(); + second.caller_runtime_id = CANARY.to_string(); + c.callers.push(second); + }), + ]; + for change in canaried { + let mut config = valid(); + change(&mut config); + let error = config.validate().unwrap_err(); + for shown in [error.to_string(), format!("{error:?}")] { + assert!(!shown.contains(CANARY), "{shown}"); + } + let scratch = Scratch::new(); + let write = write_config(&scratch.path("x.cc"), &config).unwrap_err(); + assert!(!format!("{write} {write:?}").contains(CANARY)); + } +} + +// ---- the binary ---- + +fn run(args: &[&str], envs: &[(&str, &Path)]) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_claude-connector")); + command.args(args).env_clear(); + for (name, value) in envs { + command.env(name, value); + } + command.output().unwrap() +} + +fn shown(output: &Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +fn init_flags<'a>(scratch: &'a Scratch, extra: &[&'a str]) -> Vec { + let path = |name: &str| scratch.path(name).to_string_lossy().into_owned(); + let mut flags: Vec = [ + "--listen", + "127.0.0.1:0", + "--replay-store", + &path("replay.cc"), + "--claude-binary", + &path("claude"), + "--claude-sha256", + &"ab".repeat(32), + "--work-dir", + &path("work"), + "--config-dir", + &path("home"), + "--prompt-dir", + &path("prompts"), + "--instance", + "persona-a", + "--token-credential", + "claude-token", + "--caller-id", + "persona-a-caller", + "--connection-key-file", + &path("caller.key"), + "--connection-key-epoch", + "1", + "--spend-ceiling-micro-usd", + "50000000", + "--spend-window-ms", + "3600000", + "--allowed-model", + HAIKU, + "--price", + &format!("{HAIKU}:1000000:5000000:1250000:100000:100"), + ] + .map(String::from) + .to_vec(); + flags.extend(extra.iter().map(|flag| flag.to_string())); + flags +} + +fn init(scratch: &Scratch, flags: &[String]) -> Output { + let config = scratch.path("connector.cc"); + let mut args = vec!["config", "init", config.to_str().unwrap()]; + args.extend(flags.iter().map(String::as_str)); + run(&args, &[]) +} + +#[test] +fn config_init_then_check_reports_counts_only() { + let scratch = Scratch::new(); + let made = init(&scratch, &init_flags(&scratch, &[])); + assert!(made.status.success(), "{}", shown(&made)); + let config = load_config(&scratch.path("connector.cc")).unwrap(); + assert_eq!(config.callers[0].allowed_models, [HAIKU]); + assert_eq!(config.prices[HAIKU].rates.output, 5_000_000); + assert_eq!(config.prices[HAIKU].rates.cache_write_long, 1_250_000); + assert_eq!(config.prices[HAIKU].overhead_tokens, 100); + assert_eq!(config.claude_binary_sha256, [0xab; 32]); + + let checked = run( + &[ + "config", + "check", + scratch.path("connector.cc").to_str().unwrap(), + ], + &[], + ); + assert!(checked.status.success()); + let text = shown(&checked); + assert!(text.contains("callers: 1") && text.contains("allowed_models: 1")); + assert!(text.contains("prices: 1")); + assert!(!text.contains("persona-a") && !text.contains(HAIKU)); +} + +#[test] +fn the_binary_refuses_without_echoing_what_it_was_given() { + let spaced = format!(" {CANARY}"); + // Each pair replaces the value of an existing flag; the others are appended. + let replaced: Vec<(&str, &str)> = vec![ + ("--listen", CANARY), + ("--claude-sha256", CANARY), + ("--price", CANARY), + ("--instance", &spaced), + ("--connection-key-epoch", CANARY), + ("--allowed-model", CANARY), + ]; + let appended: Vec> = vec![ + vec![CANARY, CANARY], + vec!["--listen"], + vec!["--listen", "127.0.0.1:1"], + ]; + let mut attempts: Vec> = Vec::new(); + for (name, value) in replaced { + let scratch = Scratch::new(); + let mut flags = init_flags(&scratch, &[]); + let at = flags.iter().position(|flag| flag == name).unwrap(); + flags[at + 1] = value.to_string(); + attempts.push(flags); + } + for extra in appended { + let scratch = Scratch::new(); + attempts.push(init_flags(&scratch, &extra)); + } + for flags in attempts { + let scratch = Scratch::new(); + let made = init(&scratch, &flags); + assert!(!made.status.success(), "{flags:?}"); + assert!(!shown(&made).contains(CANARY), "{}", shown(&made)); + assert!(!scratch.path("connector.cc").exists()); + } + + // A missing flag is refused by name. + let scratch = Scratch::new(); + let mut flags = init_flags(&scratch, &[]); + let at = flags.iter().position(|f| f == "--instance").unwrap(); + flags.drain(at..at + 2); + let made = init(&scratch, &flags); + assert!(!made.status.success()); + assert!(shown(&made).contains("--instance")); +} + +#[cfg(unix)] +#[test] +fn serve_refuses_an_unsafe_prompt_dir_and_prints_no_credential() { + use std::os::unix::fs::{PermissionsExt, symlink}; + + let scratch = Scratch::new(); + let credentials = scratch.path("credentials"); + std::fs::create_dir_all(&credentials).unwrap(); + std::fs::write(credentials.join("claude-token"), CANARY).unwrap(); + std::fs::write(scratch.path("caller.key"), "caller-secret").unwrap(); + let made = init(&scratch, &init_flags(&scratch, &[])); + assert!(made.status.success(), "{}", shown(&made)); + let config = scratch.path("connector.cc"); + let serve = |config: &Path| { + run( + &["serve", "--config", config.to_str().unwrap()], + &[("CREDENTIALS_DIRECTORY", &credentials)], + ) + }; + + // No prompt directory, then one others can enter, then a link to a good one. + let prompts = scratch.path("prompts"); + let refused = serve(&config); + assert!(!refused.status.success()); + assert!( + shown(&refused).contains("prompt directory"), + "{}", + shown(&refused) + ); + std::fs::create_dir_all(&prompts).unwrap(); + std::fs::set_permissions(&prompts, std::fs::Permissions::from_mode(0o755)).unwrap(); + let open = serve(&config); + assert!( + shown(&open).contains("prompt directory"), + "{}", + shown(&open) + ); + assert!(claude_connector::check_prompt_dir(&prompts).is_err()); + std::fs::set_permissions(&prompts, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert!(claude_connector::check_prompt_dir(&prompts).is_ok()); + let linked = scratch.path("linked"); + symlink(&prompts, &linked).unwrap(); + assert!(claude_connector::check_prompt_dir(&linked).is_err()); + + // A safe directory but no claude binary: the backend is not ready, and + // nothing the credential holds is printed. + let not_ready = serve(&config); + assert!(!not_ready.status.success()); + for output in [&refused, &open, ¬_ready] { + let text = shown(output); + assert!( + !text.contains(CANARY) && !text.contains("caller-secret"), + "{text}" + ); + } +}