991 lines
33 KiB
Rust
991 lines
33 KiB
Rust
//! 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 std::time::Duration;
|
|
|
|
use claude_connector::{ClaudeConnectorConfig, ConfigError, load_config, write_config};
|
|
use cultcache_rs::{
|
|
CacheBackingStore, CultCache, CultCacheEnvelope, DatabaseEntry,
|
|
SingleFileMessagePackBackingStore,
|
|
};
|
|
use model_connector::{CallerConfig, MIN_PAYLOAD_BYTES, ModelPrice, Rates};
|
|
|
|
const CANARY: &str = "canary-9f3b7c1d";
|
|
const OPUS: &str = "claude-opus-5-1";
|
|
const HAIKU: &str = "claude-haiku-5-5";
|
|
|
|
/// An edit that should make the valid document refuse.
|
|
type Change = Box<dyn Fn(&mut ClaudeConnectorConfig)>;
|
|
|
|
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::<ClaudeConnectorConfig>()
|
|
.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::<Other>().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)
|
|
));
|
|
}
|
|
|
|
fn store_raw(path: &Path, key: &str, ty: &str, schema: Option<&str>, payload: Vec<u8>) {
|
|
let mut store = SingleFileMessagePackBackingStore::new(path);
|
|
store
|
|
.push(&CultCacheEnvelope {
|
|
key: key.to_string(),
|
|
r#type: ty.to_string(),
|
|
payload,
|
|
stored_at: "2026-10-29T00:00:00Z".to_string(),
|
|
schema_id: schema.map(str::to_string),
|
|
})
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn the_envelope_must_be_exactly_the_documents_key_type_schema_and_encoding() {
|
|
let scratch = Scratch::new();
|
|
let canonical = rmp_serde::to_vec(&valid()).unwrap();
|
|
let ty = ClaudeConnectorConfig::TYPE;
|
|
let mut trailing = canonical.clone();
|
|
trailing.push(0xc0);
|
|
|
|
let cases = vec![
|
|
("wrong key", "other", ty, Some(ty), canonical.clone()),
|
|
(
|
|
"wrong type",
|
|
"runtime",
|
|
"gamecult.other.v1",
|
|
Some(ty),
|
|
canonical.clone(),
|
|
),
|
|
(
|
|
"wrong schema",
|
|
"runtime",
|
|
ty,
|
|
Some("gamecult.other.v1"),
|
|
canonical.clone(),
|
|
),
|
|
("not canonical", "runtime", ty, Some(ty), trailing),
|
|
];
|
|
for (name, key, ty, schema, payload) in cases {
|
|
let path = scratch.path(&format!("{}.cc", name.replace(' ', "-")));
|
|
store_raw(&path, key, ty, schema, payload);
|
|
assert!(
|
|
matches!(load_config(&path), Err(ConfigError::Store)),
|
|
"{name}"
|
|
);
|
|
}
|
|
|
|
// The same bytes with every part right load.
|
|
let path = scratch.path("right.cc");
|
|
store_raw(&path, "runtime", ty, Some(ty), canonical);
|
|
assert_eq!(load_config(&path).unwrap(), valid());
|
|
}
|
|
|
|
#[test]
|
|
fn bounds_sit_exactly_where_the_connector_needs_them() {
|
|
let mut config = valid();
|
|
config.max_total_bytes = config.max_line_bytes;
|
|
config.validate().unwrap();
|
|
config.callers[0].max_payload_bytes = MIN_PAYLOAD_BYTES;
|
|
config.validate().unwrap();
|
|
config.callers[0].max_payload_bytes = MIN_PAYLOAD_BYTES - 1;
|
|
assert_eq!(refused(&config), "caller bounds");
|
|
|
|
for set in [
|
|
(|c: &mut ClaudeConnectorConfig| c.max_connections = 0) as fn(&mut ClaudeConnectorConfig),
|
|
|c| c.socket_timeout_ms = 0,
|
|
|c| c.max_expiry_skew_ms = 0,
|
|
|c| c.max_frame_bytes = 0,
|
|
] {
|
|
let mut config = valid();
|
|
set(&mut config);
|
|
assert_eq!(refused(&config), "serve limits");
|
|
}
|
|
let mut config = valid();
|
|
config.prices.insert("claude-".to_string(), price());
|
|
assert_eq!(refused(&config), "prices");
|
|
}
|
|
|
|
#[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()];
|
|
}),
|
|
),
|
|
(
|
|
"family only",
|
|
Box::new(|c| {
|
|
c.callers[0].allowed_models = vec!["claude-haiku".to_string()];
|
|
c.prices.insert("claude-haiku".to_string(), price());
|
|
}),
|
|
),
|
|
(
|
|
"latest alias",
|
|
Box::new(|c| {
|
|
c.callers[0].allowed_models = vec!["claude-sonnet-latest".to_string()];
|
|
c.prices.insert("claude-sonnet-latest".to_string(), price());
|
|
}),
|
|
),
|
|
(
|
|
"uppercase family",
|
|
Box::new(|c| {
|
|
c.callers[0].allowed_models = vec!["claude-Opus-5-1".to_string()];
|
|
c.prices.insert("claude-Opus-5-1".to_string(), price());
|
|
}),
|
|
),
|
|
(
|
|
"short date",
|
|
Box::new(|c| {
|
|
c.callers[0].allowed_models = vec!["claude-opus-5-1-2026".to_string()];
|
|
c.prices.insert("claude-opus-5-1-2026".to_string(), price());
|
|
}),
|
|
),
|
|
(
|
|
"trailing part",
|
|
Box::new(|c| {
|
|
c.callers[0].allowed_models = vec!["claude-opus-5-1-20260101-x".to_string()];
|
|
c.prices
|
|
.insert("claude-opus-5-1-20260101-x".to_string(), price());
|
|
}),
|
|
),
|
|
(
|
|
"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",
|
|
"allowed_models",
|
|
"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 a_priced_id_of_any_loose_form_is_allowed_and_the_price_table_decides() {
|
|
// Real ids come with a minor, without one, and with a date either way.
|
|
for id in [
|
|
"claude-opus-5-1-20260101",
|
|
"claude-opus-5",
|
|
"claude-opus-5-20260101",
|
|
"claude-sonnet-4-5",
|
|
] {
|
|
let mut config = valid();
|
|
config.callers[0].allowed_models = vec![id.to_string()];
|
|
config.prices.insert(id.to_string(), price());
|
|
config.validate().unwrap_or_else(|e| panic!("{id}: {e}"));
|
|
|
|
// The same id with no price is not on the known list.
|
|
config.prices.remove(id);
|
|
assert_eq!(refused(&config), "allowed_models", "{id}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_price_with_a_zero_billed_component_is_refused() {
|
|
type Zero = fn(&mut Rates);
|
|
let zeroed: Vec<(&str, Zero)> = vec![
|
|
("input", |r| r.input = 0),
|
|
("cache_write_short", |r| r.cache_write_short = 0),
|
|
("cache_write_long", |r| r.cache_write_long = 0),
|
|
("cache_read", |r| r.cache_read = 0),
|
|
("output", |r| r.output = 0),
|
|
];
|
|
for (name, zero) in &zeroed {
|
|
let mut config = valid();
|
|
zero(&mut config.prices.get_mut(HAIKU).unwrap().rates);
|
|
assert_eq!(refused(&config), "prices", "{name}");
|
|
|
|
let mut config = valid();
|
|
let mut long = price().rates;
|
|
zero(&mut long);
|
|
config.prices.get_mut(HAIKU).unwrap().long_context = Some(model_connector::LongContext {
|
|
above_prompt_tokens: 200_000,
|
|
rates: long,
|
|
});
|
|
assert_eq!(refused(&config), "prices", "long context {name}");
|
|
}
|
|
// A zero overhead is not a rate.
|
|
let mut config = valid();
|
|
config.prices.get_mut(HAIKU).unwrap().overhead_tokens = 0;
|
|
config.validate().unwrap();
|
|
}
|
|
|
|
#[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<Change> = 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 ----
|
|
|
|
/// How long any connector verb may take in these tests. `check` and `init`
|
|
/// return at once and `serve` is meant to refuse; a `serve` that starts
|
|
/// instead would run forever, so it fails a named test here, not the suite.
|
|
const VERB_DEADLINE: Duration = Duration::from_secs(20);
|
|
|
|
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);
|
|
}
|
|
run_within(command, VERB_DEADLINE).unwrap_or_else(|| {
|
|
panic!(
|
|
"`{}` was still running after {VERB_DEADLINE:?}: it started",
|
|
args.join(" ")
|
|
)
|
|
})
|
|
}
|
|
|
|
/// The command's output, or `None` when it outlived `deadline` and was killed.
|
|
fn run_within(mut command: Command, deadline: Duration) -> Option<Output> {
|
|
use std::process::Stdio;
|
|
use std::time::Instant;
|
|
|
|
let mut child = command
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.unwrap();
|
|
let started = Instant::now();
|
|
while child.try_wait().unwrap().is_none() {
|
|
if started.elapsed() > deadline {
|
|
child.kill().unwrap();
|
|
child.wait().unwrap();
|
|
return None;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
}
|
|
Some(child.wait_with_output().unwrap())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn a_process_past_its_deadline_is_killed_and_reported_as_none() {
|
|
let mut forever = Command::new("sleep");
|
|
forever.arg("60");
|
|
let started = std::time::Instant::now();
|
|
assert!(run_within(forever, Duration::from_millis(200)).is_none());
|
|
assert!(started.elapsed() < Duration::from_secs(10));
|
|
let mut quick = Command::new("true");
|
|
quick.env_clear();
|
|
assert!(
|
|
run_within(quick, Duration::from_secs(10))
|
|
.unwrap()
|
|
.status
|
|
.success()
|
|
);
|
|
}
|
|
|
|
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<String> {
|
|
let path = |name: &str| scratch.path(name).to_string_lossy().into_owned();
|
|
let mut flags: Vec<String> = [
|
|
"--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_writes_the_flags_into_the_document() {
|
|
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]);
|
|
}
|
|
|
|
/// A scratch directory in which `serve` would start: every directory, the
|
|
/// pinned binary, the connection key and the credential exist. Returns the
|
|
/// `config init` flags and the credential directory.
|
|
#[cfg(unix)]
|
|
fn prepare(scratch: &Scratch) -> (Vec<String>, PathBuf) {
|
|
use sha2::{Digest, Sha256};
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
for name in ["work", "home", "prompts", "credentials"] {
|
|
std::fs::create_dir_all(scratch.path(name)).unwrap();
|
|
}
|
|
std::fs::set_permissions(
|
|
scratch.path("prompts"),
|
|
std::fs::Permissions::from_mode(0o700),
|
|
)
|
|
.unwrap();
|
|
std::fs::write(scratch.path("credentials/claude-token"), CANARY).unwrap();
|
|
std::fs::write(scratch.path("caller.key"), "caller-secret").unwrap();
|
|
std::fs::write(scratch.path("claude"), b"pinned claude").unwrap();
|
|
let sha: String = Sha256::digest(b"pinned claude")
|
|
.iter()
|
|
.map(|byte| format!("{byte:02x}"))
|
|
.collect();
|
|
let mut flags = init_flags(scratch, &[]);
|
|
let at = flags
|
|
.iter()
|
|
.position(|flag| flag == "--claude-sha256")
|
|
.unwrap();
|
|
flags[at + 1] = sha;
|
|
(flags, scratch.path("credentials"))
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn check_and_serve(config: &Path, credentials: Option<&Path>) -> (Output, Output) {
|
|
let envs: Vec<(&str, &Path)> = credentials
|
|
.map(|dir| ("CREDENTIALS_DIRECTORY", dir))
|
|
.into_iter()
|
|
.collect();
|
|
let path = config.to_str().unwrap();
|
|
(
|
|
run(&["config", "check", path], &envs),
|
|
run(&["serve", "--config", path], &envs),
|
|
)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn check_passes_a_document_serve_can_start_and_reports_counts_only() {
|
|
let scratch = Scratch::new();
|
|
let (flags, credentials) = prepare(&scratch);
|
|
let made = init(&scratch, &flags);
|
|
assert!(made.status.success(), "{}", shown(&made));
|
|
let checked = run(
|
|
&[
|
|
"config",
|
|
"check",
|
|
scratch.path("connector.cc").to_str().unwrap(),
|
|
],
|
|
&[("CREDENTIALS_DIRECTORY", &credentials)],
|
|
);
|
|
assert!(checked.status.success(), "{}", shown(&checked));
|
|
let text = shown(&checked);
|
|
assert!(text.contains("callers: 1") && text.contains("allowed_models: 1"));
|
|
assert!(text.contains("prices: 1"));
|
|
for secret in ["persona-a", HAIKU, CANARY, "caller-secret"] {
|
|
assert!(!text.contains(secret), "{text}");
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn check_refuses_exactly_what_serve_refuses_and_names_the_field() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
type Break = fn(&Scratch, &Path);
|
|
let cases: Vec<(&str, &str, Break)> = vec![
|
|
("open prompt dir", "prompt directory", |s, _| {
|
|
std::fs::set_permissions(s.path("prompts"), std::fs::Permissions::from_mode(0o755))
|
|
.unwrap();
|
|
}),
|
|
("no key file", "connection_key_file", |s, _| {
|
|
std::fs::remove_file(s.path("caller.key")).unwrap();
|
|
}),
|
|
("binary off its pin", "claude_binary_sha256", |s, _| {
|
|
std::fs::write(s.path("claude"), b"other bytes").unwrap();
|
|
}),
|
|
("no credential", "token_credential", |_, credentials| {
|
|
std::fs::remove_file(credentials.join("claude-token")).unwrap();
|
|
}),
|
|
("no work dir", "work_dir", |s, _| {
|
|
std::fs::remove_dir(s.path("work")).unwrap();
|
|
}),
|
|
("no config dir", "config_dir", |s, _| {
|
|
std::fs::remove_dir(s.path("home")).unwrap();
|
|
}),
|
|
("frame too small", "max_frame_bytes", |s, _| {
|
|
let path = s.path("connector.cc");
|
|
let mut config = load_config(&path).unwrap();
|
|
config.max_frame_bytes = 4099;
|
|
write_config(&path, &config).unwrap();
|
|
}),
|
|
("too few connections", "connection capacity", |s, _| {
|
|
let path = s.path("connector.cc");
|
|
let mut config = load_config(&path).unwrap();
|
|
config.max_connections = 1;
|
|
write_config(&path, &config).unwrap();
|
|
}),
|
|
];
|
|
for (name, field, break_it) in cases {
|
|
let scratch = Scratch::new();
|
|
let (flags, credentials) = prepare(&scratch);
|
|
let made = init(&scratch, &flags);
|
|
assert!(made.status.success(), "{name}: {}", shown(&made));
|
|
break_it(&scratch, &credentials);
|
|
let (checked, served) = check_and_serve(&scratch.path("connector.cc"), Some(&credentials));
|
|
assert!(!checked.status.success(), "{name} check passed");
|
|
assert!(!served.status.success(), "{name} serve started");
|
|
assert!(
|
|
shown(&checked).contains(field),
|
|
"{name}: {}",
|
|
shown(&checked)
|
|
);
|
|
assert_eq!(shown(&checked), shown(&served), "{name}");
|
|
for secret in [CANARY, "caller-secret", "other bytes"] {
|
|
assert!(!shown(&checked).contains(secret), "{name}");
|
|
}
|
|
}
|
|
|
|
// No credential directory at all is the same refusal.
|
|
let scratch = Scratch::new();
|
|
let (flags, _) = prepare(&scratch);
|
|
assert!(init(&scratch, &flags).status.success());
|
|
let (checked, served) = check_and_serve(&scratch.path("connector.cc"), None);
|
|
assert!(!checked.status.success() && !served.status.success());
|
|
assert!(shown(&checked).contains("credentials_directory"));
|
|
assert!(!shown(&checked).contains("token_credential"));
|
|
assert_eq!(shown(&checked), shown(&served));
|
|
|
|
// A credential directory without the credential is the other fault, and
|
|
// the token is checked last: a broken pin is named first.
|
|
let scratch = Scratch::new();
|
|
let (flags, credentials) = prepare(&scratch);
|
|
assert!(init(&scratch, &flags).status.success());
|
|
std::fs::remove_file(credentials.join("claude-token")).unwrap();
|
|
std::fs::write(scratch.path("claude"), b"other bytes").unwrap();
|
|
let (checked, _) = check_and_serve(&scratch.path("connector.cc"), Some(&credentials));
|
|
assert!(
|
|
shown(&checked).contains("claude_binary_sha256"),
|
|
"{}",
|
|
shown(&checked)
|
|
);
|
|
std::fs::write(scratch.path("claude"), b"pinned claude").unwrap();
|
|
let (checked, _) = check_and_serve(&scratch.path("connector.cc"), Some(&credentials));
|
|
assert!(
|
|
shown(&checked).contains("token_credential"),
|
|
"{}",
|
|
shown(&checked)
|
|
);
|
|
assert!(!shown(&checked).contains("credentials_directory"));
|
|
}
|
|
|
|
#[test]
|
|
fn usage_says_check_runs_in_the_units_environment() {
|
|
let output = run(&["nonsense"], &[]);
|
|
assert!(!output.status.success());
|
|
assert!(
|
|
shown(&output).contains("unit's environment"),
|
|
"{}",
|
|
shown(&output)
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn serve_names_a_corrupt_replay_store_without_its_path() {
|
|
let scratch = Scratch::new();
|
|
let (mut flags, credentials) = prepare(&scratch);
|
|
let store = scratch.path(&format!("{CANARY}-store/replay.cc"));
|
|
std::fs::create_dir_all(store.parent().unwrap()).unwrap();
|
|
std::fs::write(&store, b"not a cultcache store").unwrap();
|
|
let at = flags
|
|
.iter()
|
|
.position(|flag| flag == "--replay-store")
|
|
.unwrap();
|
|
flags[at + 1] = store.to_string_lossy().into_owned();
|
|
assert!(init(&scratch, &flags).status.success());
|
|
let (_, served) = check_and_serve(&scratch.path("connector.cc"), Some(&credentials));
|
|
assert!(!served.status.success());
|
|
let text = shown(&served);
|
|
assert!(text.contains("replay_store"), "{text}");
|
|
assert!(
|
|
!text.contains(CANARY) && !text.contains("replay.cc"),
|
|
"{text}"
|
|
);
|
|
}
|
|
|
|
#[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),
|
|
("--claude-sha256", "abcd"),
|
|
("--price", CANARY),
|
|
("--instance", &spaced),
|
|
("--connection-key-epoch", CANARY),
|
|
("--allowed-model", CANARY),
|
|
];
|
|
let appended: Vec<Vec<&str>> = vec![
|
|
vec![CANARY, CANARY],
|
|
vec!["--listen"],
|
|
vec!["--listen", "127.0.0.1:1"],
|
|
];
|
|
let mut attempts: Vec<Vec<String>> = 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}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn a_private_regular_file_is_not_a_prompt_dir() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let scratch = Scratch::new();
|
|
let file = scratch.path("file");
|
|
std::fs::write(&file, "x").unwrap();
|
|
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
assert!(claude_connector::check_prompt_dir(&file).is_err());
|
|
assert!(claude_connector::check_prompt_dir(&scratch.path("absent")).is_err());
|
|
}
|