Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67c7849cb6 | ||
|
|
85ed36d292 | ||
|
|
2f2f7a9db5 | ||
|
|
83c93b2bb8 | ||
|
|
b44c5a3b75 | ||
|
|
9a062f0846 |
14 changed files with 2273 additions and 134 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -138,6 +138,8 @@ dependencies = [
|
|||
name = "claude-connector"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cultcache-rs",
|
||||
"libc",
|
||||
"model-connector",
|
||||
"rmp-serde",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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<dyn Fn(&str) -> Option<String> + 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<Zeroizing<String>>;
|
||||
}
|
||||
|
||||
/// A token in a file named by a systemd credential: the file
|
||||
/// `$CREDENTIALS_DIRECTORY/<name>`, read on every call so a rotated credential
|
||||
/// is picked up by the next spawn.
|
||||
pub struct CredentialTokenSource {
|
||||
directory: Option<PathBuf>,
|
||||
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<String>) -> Self {
|
||||
Self {
|
||||
directory: std::env::var_os("CREDENTIALS_DIRECTORY").map(PathBuf::from),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn in_directory(directory: impl Into<PathBuf>, name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
directory: Some(directory.into()),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TokenSource for CredentialTokenSource {
|
||||
fn read(&self) -> Option<Zeroizing<String>> {
|
||||
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<dyn ProcessPort>,
|
||||
sessions: Box<dyn SessionStore>,
|
||||
sink: Box<dyn StderrSink>,
|
||||
env_lookup: EnvLookup,
|
||||
tokens: Box<dyn TokenSource>,
|
||||
}
|
||||
|
||||
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<dyn ProcessPort>,
|
||||
sessions: Box<dyn SessionStore>,
|
||||
sink: Box<dyn StderrSink>,
|
||||
tokens: Box<dyn TokenSource>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
port,
|
||||
sessions,
|
||||
sink,
|
||||
env_lookup: Box::new(|name| std::env::var(name).ok()),
|
||||
tokens,
|
||||
}
|
||||
}
|
||||
|
||||
fn token(&self) -> Option<Zeroizing<String>> {
|
||||
(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<TransportEvent>, 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,
|
||||
|
|
|
|||
|
|
@ -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<Zeroizing<String>> {
|
||||
self.0.map(|token| Zeroizing::new(token.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn canary_token() -> Box<dyn TokenSource> {
|
||||
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<AtomicUsize>,
|
||||
seen: Arc<Mutex<Seen>>,
|
||||
|
|
@ -285,13 +311,16 @@ fn rig_for(instance: &str, scripts: Vec<Script>, sessions: &[&str], stderr: &[u8
|
|||
seen: seen.clone(),
|
||||
stderr: stderr.to_vec(),
|
||||
};
|
||||
let backend = with_canary_token(ClaudeCodeBackend::with_ports(
|
||||
config(instance, limits()),
|
||||
let dir = sandbox();
|
||||
let backend = ClaudeCodeBackend::with_ports(
|
||||
config(dir.path(), instance, limits()),
|
||||
Box::new(port),
|
||||
Box::new(Sessions(sessions.iter().map(|s| s.to_string()).collect())),
|
||||
Box::new(sink.clone()),
|
||||
));
|
||||
canary_token(),
|
||||
);
|
||||
Rig {
|
||||
dir,
|
||||
backend,
|
||||
spawns,
|
||||
seen,
|
||||
|
|
@ -443,7 +472,7 @@ fn argv_is_the_fixed_hygiene_set() {
|
|||
.expect("the prompt is passed as a file")
|
||||
.to_string();
|
||||
let expected: Vec<String> = [
|
||||
"/opt/claude",
|
||||
rig.dir.path().join("claude").to_str().unwrap(),
|
||||
"-p",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
|
|
@ -569,7 +598,7 @@ fn the_token_never_reaches_a_message_an_event_or_a_debug_form() {
|
|||
);
|
||||
|
||||
let env = child_env(
|
||||
&config(INSTANCE, limits()),
|
||||
&config(rig.dir.path(), INSTANCE, limits()),
|
||||
&request(vec![user("x")]),
|
||||
Zeroizing::new(CANARY_TOKEN.to_string()),
|
||||
);
|
||||
|
|
@ -580,59 +609,163 @@ fn the_token_never_reaches_a_message_an_event_or_a_debug_form() {
|
|||
|
||||
// ---- ready ----
|
||||
|
||||
/// A backend over a scripted port that never has a script: `ready` runs nothing.
|
||||
fn ready_backend(config: ClaudeCodeConfig, tokens: Box<dyn TokenSource>) -> ClaudeCodeBackend {
|
||||
ClaudeCodeBackend::with_ports(
|
||||
config,
|
||||
Box::new(ScriptedPort {
|
||||
scripts: Mutex::new(VecDeque::new()),
|
||||
spawns: Arc::new(AtomicUsize::new(0)),
|
||||
seen: Arc::new(Mutex::new(Seen::default())),
|
||||
stderr: Vec::new(),
|
||||
}),
|
||||
Box::new(Sessions(vec![])),
|
||||
Box::new(Sink::default()),
|
||||
tokens,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_checks_the_pinned_binary_digest() {
|
||||
let dir = TempDir::new();
|
||||
let dir = sandbox();
|
||||
let (work, cfg) = (dir.path().join("work"), dir.path().join("cfg"));
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
std::fs::create_dir_all(&cfg).unwrap();
|
||||
let binary = dir.path().join("claude");
|
||||
std::fs::write(&binary, b"the pinned bytes").unwrap();
|
||||
let pin: [u8; 32] = Sha256::digest(b"the pinned bytes").into();
|
||||
|
||||
let ready = |sha: [u8; 32], binary: &Path, work: &Path, token: bool| {
|
||||
let mut config = config(INSTANCE, limits());
|
||||
let ready_field = |sha: [u8; 32], binary: &Path, work: &Path, prompts: &Path, token: bool| {
|
||||
let mut config = config(dir.path(), INSTANCE, limits());
|
||||
config.claude_binary = binary.to_path_buf();
|
||||
config.claude_binary_sha256 = sha;
|
||||
config.work_dir = work.to_path_buf();
|
||||
config.config_dir = cfg.clone();
|
||||
let rig = rig(vec![]);
|
||||
let mut backend = ClaudeCodeBackend::with_ports(
|
||||
config,
|
||||
Box::new(ScriptedPort {
|
||||
scripts: Mutex::new(VecDeque::new()),
|
||||
spawns: rig.spawns.clone(),
|
||||
seen: rig.seen.clone(),
|
||||
stderr: Vec::new(),
|
||||
}),
|
||||
Box::new(Sessions(vec![])),
|
||||
Box::new(Sink::default()),
|
||||
);
|
||||
backend.env_lookup =
|
||||
Box::new(move |name| (token && name == TOKEN_VAR).then(|| "t".to_string()));
|
||||
let answer = backend.ready();
|
||||
assert_eq!(rig.spawns(), 0, "ready runs nothing");
|
||||
answer
|
||||
config.prompt_dir = prompts.to_path_buf();
|
||||
let tokens = Box::new(FixedToken(token.then_some("t")));
|
||||
ready_backend(config, tokens).ready()
|
||||
};
|
||||
let ready = |sha: [u8; 32], binary: &Path, work: &Path, prompts: &Path, token: bool| {
|
||||
ready_field(sha, binary, work, prompts, token).is_ok()
|
||||
};
|
||||
let prompts = dir.path().join("prompts");
|
||||
let pin = pin_of(PINNED);
|
||||
assert_eq!(
|
||||
ready_field([0; 32], &binary, &work, &prompts, true),
|
||||
Err("claude_binary_sha256")
|
||||
);
|
||||
assert_eq!(
|
||||
ready_field(pin, &binary, &dir.path().join("absent"), &prompts, true),
|
||||
Err("work_dir")
|
||||
);
|
||||
assert_eq!(
|
||||
ready_field(pin, &binary, &work, &dir.path().join("absent"), true),
|
||||
Err("prompt_dir")
|
||||
);
|
||||
assert_eq!(
|
||||
ready_field(pin, &binary, &work, &prompts, false),
|
||||
Err("token_credential")
|
||||
);
|
||||
|
||||
assert!(ready(pin, &binary, &work, true));
|
||||
assert!(!ready([0; 32], &binary, &work, true), "other pin");
|
||||
assert!(ready(pin, &binary, &work, &prompts, true));
|
||||
assert!(!ready([0; 32], &binary, &work, &prompts, true), "other pin");
|
||||
assert!(
|
||||
!ready(pin, &dir.path().join("absent"), &work, true),
|
||||
!ready(pin, &dir.path().join("absent"), &work, &prompts, true),
|
||||
"no binary"
|
||||
);
|
||||
assert!(
|
||||
!ready(pin, &binary, &dir.path().join("absent"), true),
|
||||
!ready(pin, &binary, &dir.path().join("absent"), &prompts, true),
|
||||
"no work dir"
|
||||
);
|
||||
assert!(!ready(pin, &binary, &work, false), "no token");
|
||||
assert!(
|
||||
!ready(pin, &binary, &work, &dir.path().join("absent"), true),
|
||||
"no prompt dir"
|
||||
);
|
||||
assert!(!ready(pin, &binary, &work, &prompts, false), "no token");
|
||||
std::fs::write(&binary, b"other bytes").unwrap();
|
||||
assert!(
|
||||
!ready(pin, &binary, &work, true),
|
||||
!ready(pin, &binary, &work, &prompts, true),
|
||||
"bytes differ from the pin"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_comes_from_the_credential_file_not_the_environment() {
|
||||
static CANARY_PARENT: std::sync::Once = std::sync::Once::new();
|
||||
CANARY_PARENT.call_once(|| {
|
||||
// SAFETY: set once to a value no code path reads; the test proves none does.
|
||||
unsafe { std::env::set_var(TOKEN_VAR, "canary-environment-token") };
|
||||
});
|
||||
let dir = sandbox();
|
||||
let credentials = dir.path().join("credentials");
|
||||
for sub in ["credentials", "work", "cfg"] {
|
||||
std::fs::create_dir_all(dir.path().join(sub)).unwrap();
|
||||
}
|
||||
let seen = Arc::new(Mutex::new(Seen::default()));
|
||||
let mut config = config(dir.path(), INSTANCE, limits());
|
||||
config.work_dir = dir.path().join("work");
|
||||
config.config_dir = dir.path().join("cfg");
|
||||
let backend = ClaudeCodeBackend::with_ports(
|
||||
config,
|
||||
Box::new(ScriptedPort {
|
||||
scripts: Mutex::new(vec![ok_script(SESSION, "x")].into()),
|
||||
spawns: Arc::new(AtomicUsize::new(0)),
|
||||
seen: seen.clone(),
|
||||
stderr: Vec::new(),
|
||||
}),
|
||||
Box::new(Sessions(vec![])),
|
||||
Box::new(Sink::default()),
|
||||
Box::new(CredentialTokenSource::in_directory(
|
||||
&credentials,
|
||||
"claude-token",
|
||||
)),
|
||||
);
|
||||
let mut door = Door::new();
|
||||
|
||||
// The variable is set and the file is absent: nothing to give the child.
|
||||
assert_eq!(backend.ready(), Err("token_credential"));
|
||||
let (claim, _) = door.begin(request(vec![user("hi")]));
|
||||
let (kind, message, usage) = failure_of(&backend.execute(claim.call()));
|
||||
assert_eq!(kind, FailureKind::Authentication);
|
||||
assert_eq!(usage, Some(usage_zero()));
|
||||
assert!(!message.contains("canary-environment-token"));
|
||||
assert!(seen.lock().unwrap().argv.is_empty(), "no child was spawned");
|
||||
|
||||
// The file is read afresh at the call, its trailing newline trimmed, and
|
||||
// the child's environment carries the file's value.
|
||||
std::fs::write(
|
||||
credentials.join("claude-token"),
|
||||
format!("{CANARY_TOKEN}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(backend.ready(), Ok(()));
|
||||
let mut door = Door::new();
|
||||
let (claim, _) = door.begin(request(vec![user("hi")]));
|
||||
let result = backend.execute(claim.call());
|
||||
assert!(matches!(
|
||||
outcome(&result),
|
||||
TransportOutcome::Completed { .. }
|
||||
));
|
||||
let env = seen.lock().unwrap().env.clone();
|
||||
assert!(env.contains(&(TOKEN_VAR.to_string(), CANARY_TOKEN.to_string())));
|
||||
|
||||
// An empty credential is no token.
|
||||
std::fs::write(credentials.join("claude-token"), "\n").unwrap();
|
||||
assert_eq!(backend.ready(), Err("token_credential"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_credential_is_no_token() {
|
||||
// A credential that is a directory, or a directory that is absent.
|
||||
let dir = sandbox();
|
||||
std::fs::create_dir_all(dir.path().join("claude-token")).unwrap();
|
||||
let source = CredentialTokenSource::in_directory(dir.path(), "claude-token");
|
||||
assert!(source.read().is_none());
|
||||
assert!(
|
||||
CredentialTokenSource::in_directory(dir.path().join("absent"), "x")
|
||||
.read()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
// ---- planning ----
|
||||
|
||||
#[test]
|
||||
|
|
@ -1224,7 +1357,7 @@ fn one_spawn_per_execute() {
|
|||
#[test]
|
||||
fn a_missing_token_fails_before_spawn() {
|
||||
let mut rig = rig(vec![ok_script(SESSION, "x")]);
|
||||
rig.backend.env_lookup = Box::new(|_| None);
|
||||
rig.backend.tokens = Box::new(FixedToken(None));
|
||||
let (kind, _, usage) = failure_of(&rig.run(request(vec![user("hi")])));
|
||||
assert_eq!(kind, FailureKind::Authentication);
|
||||
assert_eq!(usage, Some(usage_zero()));
|
||||
|
|
@ -1245,18 +1378,29 @@ fn a_port_that_cannot_spawn_is_a_provider_failure() {
|
|||
Err(io::Error::from(io::ErrorKind::NotFound))
|
||||
}
|
||||
}
|
||||
let backend = with_canary_token(ClaudeCodeBackend::with_ports(
|
||||
config(INSTANCE, limits()),
|
||||
let dir = sandbox();
|
||||
let backend = ClaudeCodeBackend::with_ports(
|
||||
config(dir.path(), INSTANCE, limits()),
|
||||
Box::new(Refusing),
|
||||
Box::new(Sessions(vec![])),
|
||||
Box::new(Sink::default()),
|
||||
));
|
||||
canary_token(),
|
||||
);
|
||||
let mut door = Door::new();
|
||||
let (claim, _) = door.begin(request(vec![user("hi")]));
|
||||
let (kind, message, usage) = failure_of(&backend.execute(claim.call()));
|
||||
let result = backend.execute(claim.call());
|
||||
let (kind, message, usage) = failure_of(&result);
|
||||
assert_eq!(kind, FailureKind::Provider);
|
||||
assert_eq!(usage, None);
|
||||
assert!(!message.contains("/opt/claude"), "no path in a message");
|
||||
assert_eq!(
|
||||
usage,
|
||||
Some(usage_zero()),
|
||||
"a child that never ran bills nothing"
|
||||
);
|
||||
assert_eq!(parts(&result).1.cost_micro_usd, 0);
|
||||
assert!(
|
||||
!message.contains(dir.path().to_str().unwrap()),
|
||||
"no path in a message"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1384,28 +1528,60 @@ fn usage_the_result_does_not_report_is_billed_the_reservation() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_at_the_door_is_billed_nothing() {
|
||||
// Ruling spend-bills-on-identity-not-validity: a bound result is charged
|
||||
// its true cost, and the provider bills nothing for a 401 or 403.
|
||||
// The shape is a real claude 2.1.312 capture with a bogus token.
|
||||
fn door_set_is_exactly_401_403_429() {
|
||||
// Ruling claude-code-plan-quota-fuse-2 with spend-bills-on-identity-not-
|
||||
// validity: a refusal at the door is charged nothing, for exactly the
|
||||
// statuses the provider cannot have billed (401, 403, 429) and only when
|
||||
// the result says its usage is explicitly empty. Every other shape is
|
||||
// billed the reservation. The 401 shape is a real claude 2.1.312 capture
|
||||
// with a bogus token.
|
||||
let retry = r#"{"type":"system","subtype":"api_retry","error":"authentication_failed"}"#;
|
||||
let synthetic = r#"{"type":"assistant","api_error_status":401,"error":"authentication_failed","message":{"id":"e48001b7-cf4f-49b1-9810-db8fd7f73c73","model":"<synthetic>","stop_reason":"stop_sequence","content":[{"type":"text","text":"Failed to authenticate. API Error: 401 OAuth access token is invalid."}]}}"#;
|
||||
for status in [401, 403] {
|
||||
let result_line = format!(
|
||||
r#"{{"type":"result","subtype":"success","is_error":true,"api_error_status":{status},"stop_reason":"stop_sequence","result":"Failed to authenticate.","modelUsage":{{}},"model_usage_since_last_result":{{}},"terminal_reason":"api_error"}}"#
|
||||
);
|
||||
let result = rig(vec![Script::lines(vec![
|
||||
init_line(MODEL, SESSION),
|
||||
retry.to_string(),
|
||||
retry.to_string(),
|
||||
synthetic.to_string(),
|
||||
result_line,
|
||||
])])
|
||||
.run(request(vec![user("hi")]));
|
||||
let (kind, _, usage) = failure_of(&result);
|
||||
assert_eq!(kind, FailureKind::Authentication, "{status}");
|
||||
assert_eq!(usage, Some(usage_zero()), "{status}");
|
||||
assert_eq!(parts(&result).1.cost_micro_usd, 0, "{status}");
|
||||
let reservation = reservation();
|
||||
let statuses: [Option<u16>; 9] = [
|
||||
Some(400),
|
||||
Some(401),
|
||||
Some(402),
|
||||
Some(403),
|
||||
Some(404),
|
||||
Some(429),
|
||||
Some(500),
|
||||
Some(529),
|
||||
None,
|
||||
];
|
||||
let shapes = [
|
||||
(Some("{}"), "empty"),
|
||||
(None, "absent"),
|
||||
(Some("null"), "null"),
|
||||
];
|
||||
for status in statuses {
|
||||
for (since_last, shape) in shapes {
|
||||
let status_field = status
|
||||
.map(|status| format!(r#""api_error_status":{status},"#))
|
||||
.unwrap_or_default();
|
||||
let usage_field = since_last
|
||||
.map(|usage| format!(r#","model_usage_since_last_result":{usage}"#))
|
||||
.unwrap_or_default();
|
||||
let result_line = format!(
|
||||
r#"{{"type":"result","subtype":"success","is_error":true,{status_field}"stop_reason":"stop_sequence","result":"Failed.","modelUsage":{CUMULATIVE}{usage_field},"terminal_reason":"api_error"}}"#
|
||||
);
|
||||
let result = rig(vec![Script::lines(vec![
|
||||
init_line(MODEL, SESSION),
|
||||
retry.to_string(),
|
||||
synthetic.to_string(),
|
||||
result_line,
|
||||
])])
|
||||
.run(request(vec![user("hi")]));
|
||||
let (_, _, usage) = failure_of(&result);
|
||||
let case = format!("{status:?} {shape}");
|
||||
if matches!(status, Some(401 | 403 | 429)) && shape == "empty" {
|
||||
assert_eq!(usage, Some(usage_zero()), "{case}");
|
||||
assert_eq!(parts(&result).1.cost_micro_usd, 0, "{case}");
|
||||
} else {
|
||||
assert_eq!(usage, None, "{case}");
|
||||
assert_eq!(parts(&result).1.cost_micro_usd, reservation, "{case}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1458,6 +1634,57 @@ fn the_prompt_file_is_removed_when_the_call_fails() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_file_lives_in_prompt_dir_exclusively() {
|
||||
let rig = rig(vec![ok_script(SESSION, "x")]);
|
||||
rig.run(request(vec![user("hi")]));
|
||||
let (path, _) = rig.seen().prompt_file.expect("a prompt file");
|
||||
assert_eq!(
|
||||
path.parent(),
|
||||
Some(rig.dir.path().join("prompts").as_path())
|
||||
);
|
||||
|
||||
// A prompt directory that cannot take the file fails staging: nothing
|
||||
// reached the provider, so it bills nothing, and no child runs.
|
||||
let rig = rig_for(INSTANCE, vec![ok_script(SESSION, "x")], &[SESSION], b"");
|
||||
std::fs::remove_dir_all(rig.dir.path().join("prompts")).unwrap();
|
||||
let result = rig.run(request(vec![user("hi")]));
|
||||
let (kind, _, usage) = failure_of(&result);
|
||||
assert_eq!((kind, usage), (FailureKind::Provider, Some(usage_zero())));
|
||||
assert_eq!(parts(&result).1.cost_micro_usd, 0);
|
||||
assert_eq!(rig.spawns(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_is_checked_before_every_spawn() {
|
||||
let rig = rig(vec![ok_script(SESSION, "x"), ok_script(SESSION, "y")]);
|
||||
let binary = rig.dir.path().join("claude");
|
||||
assert!(rig.backend.binary_matches_pin());
|
||||
rig.run(request(vec![user("hi")]));
|
||||
assert_eq!(rig.spawns(), 1);
|
||||
|
||||
// The binary is swapped after readiness: the next call never spawns.
|
||||
std::fs::write(&binary, b"swapped bytes").unwrap();
|
||||
let result = rig.run(request(vec![user("hi")]));
|
||||
let (kind, message, usage) = failure_of(&result);
|
||||
assert_eq!((kind, usage), (FailureKind::Provider, Some(usage_zero())));
|
||||
assert_eq!(
|
||||
parts(&result).1.cost_micro_usd,
|
||||
0,
|
||||
"billed nothing, not the reservation"
|
||||
);
|
||||
assert!(!message.contains(rig.dir.path().to_str().unwrap()));
|
||||
assert_eq!(rig.spawns(), 1, "no child for a binary off its pin");
|
||||
assert!(!rig.backend.binary_matches_pin());
|
||||
let staged = std::fs::read_dir(rig.dir.path().join("prompts")).unwrap();
|
||||
assert_eq!(staged.count(), 0, "no prompt was staged for it");
|
||||
|
||||
// Restoring the pinned bytes is enough: nothing remembers the verdict.
|
||||
std::fs::write(&binary, PINNED).unwrap();
|
||||
rig.run(request(vec![user("hi")]));
|
||||
assert_eq!(rig.spawns(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_provider_tag_is_claude_code() {
|
||||
assert_eq!(rig(vec![]).backend.provider(), "claude-code");
|
||||
|
|
@ -1540,12 +1767,21 @@ mod real {
|
|||
let binary = dir.path().join("claude");
|
||||
std::fs::write(&binary, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||||
std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut config = config(INSTANCE, limits);
|
||||
let prompts = dir.path().join("prompts");
|
||||
std::fs::create_dir_all(&prompts).unwrap();
|
||||
let mut config = config(dir.path(), INSTANCE, limits);
|
||||
config.claude_binary_sha256 = pin_of(&std::fs::read(&binary).unwrap());
|
||||
config.claude_binary = binary;
|
||||
config.work_dir = work;
|
||||
config.config_dir = cfg;
|
||||
let sink = Sink::default();
|
||||
let backend = with_canary_token(ClaudeCodeBackend::new(config, Box::new(sink.clone())));
|
||||
let backend = ClaudeCodeBackend::with_ports(
|
||||
config.clone(),
|
||||
Box::new(crate::process::StdProcessPort::new(config.limits)),
|
||||
Box::new(FsSessionStore::new(config.config_dir.clone())),
|
||||
Box::new(sink.clone()),
|
||||
canary_token(),
|
||||
);
|
||||
Fake {
|
||||
_guard: guard,
|
||||
dir,
|
||||
|
|
@ -1655,7 +1891,7 @@ mod real {
|
|||
leader.display()
|
||||
);
|
||||
let fake = fake(&body, limits());
|
||||
let config = config(INSTANCE, limits());
|
||||
let config = config(fake.dir.path(), INSTANCE, limits());
|
||||
let env = child_env(
|
||||
&config,
|
||||
&request(vec![user("x")]),
|
||||
|
|
@ -1801,6 +2037,11 @@ mod real {
|
|||
"600"
|
||||
);
|
||||
let path = std::fs::read_to_string(probe.path().join("path")).unwrap();
|
||||
assert_eq!(
|
||||
Path::new(path.trim()).parent(),
|
||||
Some(fake.dir.path().join("prompts").as_path()),
|
||||
"staged in the configured prompt directory"
|
||||
);
|
||||
assert!(!Path::new(path.trim()).exists(), "removed after the call");
|
||||
}
|
||||
|
||||
|
|
@ -1837,6 +2078,25 @@ mod real {
|
|||
assert!(sunk.trim_end().ends_with("END-OF-STDERR"), "{}", sunk.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stderr_join_survives_a_megabyte_after_result() {
|
||||
// Soul s2's probe: the result line first, then a megabyte of stderr
|
||||
// before the child exits. The call must wait for the stderr reader to
|
||||
// finish, or the tail handed to the sink is cut short.
|
||||
let body = format!(
|
||||
"{}\nhead -c 1000000 /dev/zero | tr '\\0' x >&2\necho END-OF-STDERR >&2",
|
||||
printf_lines(&good_lines())
|
||||
);
|
||||
let fake = fake(&body, limits());
|
||||
let result = fake.run(request(vec![user("hi")]));
|
||||
assert!(matches!(
|
||||
outcome(&result),
|
||||
TransportOutcome::Completed { .. }
|
||||
));
|
||||
let sunk = String::from_utf8(fake.sink.0.lock().unwrap().clone()).unwrap();
|
||||
assert!(sunk.trim_end().ends_with("END-OF-STDERR"), "{}", sunk.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_result_is_enough_even_if_the_child_keeps_stdout_open() {
|
||||
let body = format!("{}\nexec sleep 300", printf_lines(&good_lines()));
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ pub struct ClaudeCodeConfig {
|
|||
/// Passed to the child as `CLAUDE_CONFIG_DIR` and `HOME`; it holds the
|
||||
/// CLI's session store.
|
||||
pub config_dir: PathBuf,
|
||||
/// A directory the connector owns (mode 0700), where each call's system
|
||||
/// prompt is staged for the child.
|
||||
pub prompt_dir: PathBuf,
|
||||
/// The name of the systemd credential that holds the child's token, under
|
||||
/// `$CREDENTIALS_DIRECTORY`.
|
||||
pub token_credential: String,
|
||||
/// The connector instance id written into every handle.
|
||||
pub instance: String,
|
||||
pub limits: ClaudeCodeLimits,
|
||||
|
|
|
|||
571
crates/claude-connector/src/daemon.rs
Normal file
571
crates/claude-connector/src/daemon.rs
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
//! 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::{
|
||||
CallerAdmission, CallerConfig, MIN_PAYLOAD_BYTES, ModelPrice, ServeError, ServeLimits,
|
||||
ServiceError, load_admissions, serve,
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::backend::readiness;
|
||||
use crate::{
|
||||
ClaudeCodeBackend, ClaudeCodeConfig, ClaudeCodeLimits, CredentialTokenSource, 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<CallerConfig>,
|
||||
/// What each allowed model costs; keys are canonical model ids.
|
||||
#[cultcache(key = 18)]
|
||||
pub prices: BTreeMap<String, ModelPrice>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// Something the document names exists but cannot be used now.
|
||||
#[error("the configured {0} cannot be used")]
|
||||
Unusable(&'static str),
|
||||
/// The serve loop failed after it started.
|
||||
#[error("the serve loop failed at {0}")]
|
||||
Serving(&'static str),
|
||||
}
|
||||
|
||||
/// The kit's serve errors carry stored paths, ids and numbers; none of that
|
||||
/// text is shown. Each maps to the configuration field or stage it names.
|
||||
impl From<ServeError> for ConfigError {
|
||||
fn from(error: ServeError) -> Self {
|
||||
match error {
|
||||
ServeError::InvalidConfig(field) | ServeError::BackendUnready(field) => {
|
||||
Self::Unusable(field)
|
||||
}
|
||||
ServeError::FrameBound { .. } => Self::Invalid("max_frame_bytes"),
|
||||
ServeError::ConnectionKey(_) => Self::Unusable("connection_key_file"),
|
||||
ServeError::Service(error) => match error {
|
||||
ServiceError::ReplayStore(_) => Self::Unusable("replay_store"),
|
||||
ServiceError::UnpricedModel(_) => Self::Invalid("prices"),
|
||||
ServiceError::InvalidAdmission
|
||||
| ServiceError::DuplicateCaller
|
||||
| ServiceError::SharedCallerKey => Self::Invalid("callers"),
|
||||
_ => Self::Serving("service"),
|
||||
},
|
||||
ServeError::ServicePoisoned => Self::Serving("service"),
|
||||
ServeError::Listen(_) => Self::Serving("listener"),
|
||||
ServeError::Thread(_) | ServeError::Connection(_) => Self::Serving("connection"),
|
||||
ServeError::FrameSize | ServeError::FrameEncoding => Self::Serving("frame"),
|
||||
ServeError::Clock => Self::Serving("clock"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClaudeConnectorConfig {
|
||||
/// The one validation: `config init`, `config check` and `serve` all pass
|
||||
/// through it. Returns the listen address.
|
||||
pub fn validate(&self) -> Result<SocketAddr, ConfigError> {
|
||||
let listen = self
|
||||
.listen
|
||||
.parse::<SocketAddr>()
|
||||
.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
|
||||
.iter()
|
||||
.all(|(model, price)| is_canonical_model(model) && is_fully_priced(price))
|
||||
{
|
||||
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: `claude-<family>-<major>-<minor>`
|
||||
/// with an optional `-YYYYMMDD` suffix, the family lowercase letters. Aliases
|
||||
/// and bare prefixes are refused because the stream's `init.model` must equal
|
||||
/// the requested model.
|
||||
fn is_canonical_model(model: &str) -> bool {
|
||||
let Some(rest) = model.strip_prefix(MODEL_PREFIX) else {
|
||||
return false;
|
||||
};
|
||||
let digits = |part: &str, most: usize| {
|
||||
(1..=most).contains(&part.len()) && part.bytes().all(|byte| byte.is_ascii_digit())
|
||||
};
|
||||
let mut parts = rest.split('-');
|
||||
let family = parts.next().unwrap_or("");
|
||||
let major = parts.next().unwrap_or("");
|
||||
let minor = parts.next().unwrap_or("");
|
||||
let date = parts.next();
|
||||
!family.is_empty()
|
||||
&& family.bytes().all(|byte| byte.is_ascii_lowercase())
|
||||
&& digits(major, 2)
|
||||
&& digits(minor, 2)
|
||||
&& date.is_none_or(|date| date.len() == 8 && digits(date, 8))
|
||||
&& parts.next().is_none()
|
||||
}
|
||||
|
||||
/// A price is a fuse only if every component it bills is nonzero: a zero rate
|
||||
/// lets a call cost nothing against the caller's ceiling.
|
||||
fn is_fully_priced(price: &ModelPrice) -> bool {
|
||||
let long = price.long_context.map(|long| long.rates);
|
||||
[Some(price.rates), long]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.all(|rates| {
|
||||
[
|
||||
rates.input,
|
||||
rates.cache_write_short,
|
||||
rates.cache_write_long,
|
||||
rates.cache_read,
|
||||
rates.output,
|
||||
]
|
||||
.iter()
|
||||
.all(|rate| *rate > 0)
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<ClaudeConnectorConfig, ConfigError> {
|
||||
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::<ClaudeConnectorConfig>()
|
||||
.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<W>(std::sync::Mutex<W>);
|
||||
|
||||
impl<W: std::io::Write + Send> StderrSink for LogSink<W> {
|
||||
fn stderr_tail(&self, tail: &[u8]) {
|
||||
if let Ok(mut log) = self.0.lock() {
|
||||
let _ = writeln!(log, "claude-connector: child stderr, {} bytes", tail.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything `serve` needs true before it binds, in one place so that
|
||||
/// `config check` passes exactly what `serve` will start: the prompt
|
||||
/// directory, the other directories, the credential, the pin, the connection
|
||||
/// key and the frame and connection bounds. Returns the admitted callers.
|
||||
fn readiness_of(config: &ClaudeConnectorConfig) -> Result<Vec<CallerAdmission>, ConfigError> {
|
||||
config.validate()?;
|
||||
#[cfg(unix)]
|
||||
check_prompt_dir(&config.prompt_dir)?;
|
||||
let admissions = load_admissions(&config.callers)?;
|
||||
config.serve_limits().validate(&admissions)?;
|
||||
let backend = config.backend_config();
|
||||
readiness(
|
||||
&backend,
|
||||
&CredentialTokenSource::from_environment(backend.token_credential.clone()),
|
||||
)
|
||||
.map_err(ConfigError::Unusable)?;
|
||||
Ok(admissions)
|
||||
}
|
||||
|
||||
/// Whether `serve` would start with this document and this environment.
|
||||
pub fn check_ready(config: &ClaudeConnectorConfig) -> Result<(), ConfigError> {
|
||||
readiness_of(config).map(drop)
|
||||
}
|
||||
|
||||
/// 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 admissions = readiness_of(&config)?;
|
||||
let listen = config.validate()?;
|
||||
let backend = Arc::new(ClaudeCodeBackend::new(
|
||||
config.backend_config(),
|
||||
Box::new(LogSink(std::sync::Mutex::new(std::io::stderr()))),
|
||||
));
|
||||
let listener = TcpListener::bind(listen).map_err(|_| ConfigError::Listen)?;
|
||||
serve(
|
||||
listener,
|
||||
config.serve_limits(),
|
||||
admissions,
|
||||
config.prices.clone(),
|
||||
&config.replay_store,
|
||||
backend,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A log that a test can read back.
|
||||
#[derive(Clone, Default)]
|
||||
struct Captured(Arc<Mutex<Vec<u8>>>);
|
||||
|
||||
impl std::io::Write for Captured {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_errors_name_a_field_or_stage_and_never_a_stored_value() {
|
||||
const CANARY: &str = "canary-path-8e2a";
|
||||
let io = || std::io::Error::other(CANARY);
|
||||
let cases: Vec<(ServeError, &str)> = vec![
|
||||
(ServeError::InvalidConfig("callers"), "callers"),
|
||||
(ServeError::BackendUnready("work_dir"), "work_dir"),
|
||||
(
|
||||
ServeError::FrameBound {
|
||||
max_frame_bytes: 4099,
|
||||
payload_bytes: 4096,
|
||||
needed_frame_bytes: 9999,
|
||||
},
|
||||
"max_frame_bytes",
|
||||
),
|
||||
(ServeError::ConnectionKey(io()), "connection_key_file"),
|
||||
(
|
||||
ServeError::Service(ServiceError::ReplayStore(format!("{CANARY}/replay.cc"))),
|
||||
"replay_store",
|
||||
),
|
||||
(
|
||||
ServeError::Service(ServiceError::UnpricedModel(CANARY.to_string())),
|
||||
"prices",
|
||||
),
|
||||
(
|
||||
ServeError::Service(ServiceError::InvalidAdmission),
|
||||
"callers",
|
||||
),
|
||||
(
|
||||
ServeError::Service(ServiceError::DuplicateCaller),
|
||||
"callers",
|
||||
),
|
||||
(
|
||||
ServeError::Service(ServiceError::SharedCallerKey),
|
||||
"callers",
|
||||
),
|
||||
(ServeError::Listen(io()), "listener"),
|
||||
];
|
||||
for (error, field) in cases {
|
||||
let shown = ConfigError::from(error);
|
||||
let text = format!("{shown} {shown:?}");
|
||||
assert!(text.contains(field), "{text}");
|
||||
for stored in [CANARY, "4099", "9999", "replay.cc"] {
|
||||
assert!(!text.contains(stored), "{text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_log_gets_the_size_of_the_childs_stderr_and_never_its_bytes() {
|
||||
let log = Captured::default();
|
||||
let sink = LogSink(Mutex::new(log.clone()));
|
||||
sink.stderr_tail(b"canary-stderr-text, 22 bytes");
|
||||
let written = String::from_utf8(log.0.lock().unwrap().clone()).unwrap();
|
||||
assert_eq!(
|
||||
written,
|
||||
"claude-connector: child stderr, 28 bytes
|
||||
"
|
||||
);
|
||||
assert!(!written.contains("canary"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod prompt_dir {
|
||||
use std::os::unix::fs::{PermissionsExt, chown, symlink};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::super::check_prompt_dir;
|
||||
|
||||
struct Dir(PathBuf);
|
||||
|
||||
impl Dir {
|
||||
fn new(name: &str) -> Self {
|
||||
let path = std::env::temp_dir()
|
||||
.join(format!("claude-prompt-dir-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
fn mode(&self, mode: u32) {
|
||||
std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(mode)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Dir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o700));
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_mode_0700_passes() {
|
||||
let dir = Dir::new("modes");
|
||||
dir.mode(0o700);
|
||||
assert!(check_prompt_dir(&dir.0).is_ok());
|
||||
// Each of these leaves the owner able to enter, or adds a bit
|
||||
// beyond the owner's three; all are refused.
|
||||
for mode in [
|
||||
0o500, 0o300, 0o100, 0o600, 0o770, 0o707, 0o750, 0o755, 0o1700, 0o2700, 0o4700,
|
||||
] {
|
||||
dir.mode(mode);
|
||||
assert!(check_prompt_dir(&dir.0).is_err(), "{mode:o}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_to_a_good_directory_is_refused() {
|
||||
let dir = Dir::new("link-target");
|
||||
dir.mode(0o700);
|
||||
let link =
|
||||
std::env::temp_dir().join(format!("claude-prompt-dir-{}-link", std::process::id()));
|
||||
let _ = std::fs::remove_file(&link);
|
||||
symlink(&dir.0, &link).unwrap();
|
||||
let refused = check_prompt_dir(&link).is_err();
|
||||
std::fs::remove_file(&link).unwrap();
|
||||
assert!(refused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_directory_owned_by_another_user_is_refused() {
|
||||
// SAFETY: geteuid has no preconditions and cannot fail.
|
||||
if unsafe { libc::geteuid() } != 0 {
|
||||
eprintln!("skipped: only root can give a directory away");
|
||||
return;
|
||||
}
|
||||
let dir = Dir::new("owner");
|
||||
dir.mode(0o700);
|
||||
assert!(check_prompt_dir(&dir.0).is_ok());
|
||||
chown(&dir.0, Some(1234), None).unwrap();
|
||||
assert!(check_prompt_dir(&dir.0).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,8 +14,8 @@ use zeroize::Zeroizing;
|
|||
use crate::config::ClaudeCodeConfig;
|
||||
use crate::handle::Plan;
|
||||
|
||||
/// The one credential variable, copied by name from the connector's own
|
||||
/// environment into the child and nowhere else.
|
||||
/// The one credential variable: the child's environment is the only
|
||||
/// environment the token ever enters.
|
||||
pub(crate) const TOKEN_VAR: &str = "CLAUDE_CODE_OAUTH_TOKEN";
|
||||
const CHILD_PATH: &str = "/usr/local/bin:/usr/bin:/bin";
|
||||
|
||||
|
|
@ -62,13 +62,18 @@ pub(crate) struct PromptFile(PathBuf);
|
|||
static PROMPT_FILES: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
impl PromptFile {
|
||||
pub(crate) fn create(instructions: &str) -> io::Result<Self> {
|
||||
pub(crate) fn create(dir: &Path, instructions: &str) -> io::Result<Self> {
|
||||
let name = format!(
|
||||
"claude-connector-prompt-{}-{}",
|
||||
std::process::id(),
|
||||
PROMPT_FILES.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
let path = std::env::temp_dir().join(name);
|
||||
Self::create_at(dir.join(name), instructions)
|
||||
}
|
||||
|
||||
/// Creates `path` and nothing that already exists there: a file, a link
|
||||
/// (dangling or not) or a directory at the name fails, and is left alone.
|
||||
fn create_at(path: PathBuf, instructions: &str) -> io::Result<Self> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
|
|
@ -146,3 +151,57 @@ pub(crate) fn argv(
|
|||
}
|
||||
argv
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"claude-connector-launch-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prompt_is_created_private_and_removed_on_drop() {
|
||||
let dir = scratch("private");
|
||||
let prompt = PromptFile::create(&dir, "the prompt").unwrap();
|
||||
let path = prompt.path().to_path_buf();
|
||||
assert_eq!(path.parent(), Some(dir.as_path()));
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "the prompt");
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
drop(prompt);
|
||||
assert!(!path.exists());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_already_taken_is_never_written_or_removed() {
|
||||
let dir = scratch("taken");
|
||||
let victim = dir.join("victim");
|
||||
std::fs::write(&victim, "keep").unwrap();
|
||||
|
||||
let file = dir.join("file");
|
||||
std::fs::write(&file, "mine").unwrap();
|
||||
let link = dir.join("link");
|
||||
symlink(&victim, &link).unwrap();
|
||||
let dangling = dir.join("dangling");
|
||||
symlink(dir.join("nowhere"), &dangling).unwrap();
|
||||
|
||||
for taken in [&file, &link, &dangling] {
|
||||
assert!(PromptFile::create_at(taken.clone(), "the prompt").is_err());
|
||||
}
|
||||
assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep");
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "mine");
|
||||
assert!(!dir.join("nowhere").exists());
|
||||
assert!(link.exists() && dangling.symlink_metadata().is_ok());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,17 @@
|
|||
|
||||
mod backend;
|
||||
mod config;
|
||||
mod daemon;
|
||||
mod handle;
|
||||
mod launch;
|
||||
mod process;
|
||||
mod stream;
|
||||
|
||||
pub use backend::{ClaudeCodeBackend, StderrSink};
|
||||
pub use backend::{ClaudeCodeBackend, CredentialTokenSource, StderrSink, TokenSource};
|
||||
pub use config::{ClaudeCodeConfig, ClaudeCodeLimits};
|
||||
pub use daemon::{ClaudeConnectorConfig, ConfigError, check_ready, 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)]
|
||||
|
|
|
|||
241
crates/claude-connector/src/main.rs
Normal file
241
crates/claude-connector/src/main.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
//! 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, check_ready, load_config, write_config,
|
||||
};
|
||||
use model_connector::{CallerConfig, ModelPrice, Rates, frame_bytes_for_payload};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = 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())?;
|
||||
check_ready(&config)?;
|
||||
println!("claude connector configuration is valid and ready to serve");
|
||||
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<String>,
|
||||
prices: Vec<String>,
|
||||
}
|
||||
|
||||
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<Self, Failure> {
|
||||
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<String, Failure> {
|
||||
self.single.get(name).cloned().ok_or(Failure::Missing(name))
|
||||
}
|
||||
|
||||
fn path(&self, name: &'static str) -> Result<PathBuf, Failure> {
|
||||
self.text(name).map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn number<T: std::str::FromStr>(&self, name: &'static str) -> Result<T, Failure> {
|
||||
self.text(name)?
|
||||
.parse()
|
||||
.map_err(|_| Failure::Malformed(name))
|
||||
}
|
||||
|
||||
fn number_or<T: std::str::FromStr>(
|
||||
&self,
|
||||
name: &'static str,
|
||||
default: T,
|
||||
) -> Result<T, Failure> {
|
||||
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::<u64>().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(())
|
||||
}
|
||||
|
|
@ -8,7 +8,8 @@ use serde::Deserialize;
|
|||
use serde_json::Value;
|
||||
|
||||
/// A call that cannot complete, with the stage that failed. `usage` is `None`
|
||||
/// unless a result event reported it.
|
||||
/// (the kit bills its reservation) unless a result event reported it or the
|
||||
/// call never reached the provider (zero).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Failure {
|
||||
pub kind: FailureKind,
|
||||
|
|
@ -17,13 +18,24 @@ pub(crate) struct Failure {
|
|||
}
|
||||
|
||||
impl Failure {
|
||||
pub(crate) fn unbilled(kind: FailureKind, message: &'static str) -> Self {
|
||||
/// A failure after the child ran: what the provider billed is unknown, so
|
||||
/// the kit bills its reservation.
|
||||
pub(crate) fn cost_unknown(kind: FailureKind, message: &'static str) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message,
|
||||
usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A failure before anything was sent to the provider: it bills nothing.
|
||||
pub(crate) fn never_reached(kind: FailureKind, message: &'static str) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message,
|
||||
usage: Some(sum_usage(std::iter::empty())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the stream said, once its result event arrived.
|
||||
|
|
@ -181,7 +193,7 @@ impl<'a> StreamState<'a> {
|
|||
let door_refusal = !success
|
||||
&& matches!(
|
||||
line.api_error_status.as_ref().and_then(Value::as_u64),
|
||||
Some(401 | 403)
|
||||
Some(401 | 403 | 429)
|
||||
);
|
||||
let usage = read_usage(line.model_usage_since_last_result, door_refusal);
|
||||
if !success {
|
||||
|
|
@ -252,13 +264,13 @@ impl<'a> StreamState<'a> {
|
|||
}
|
||||
|
||||
fn fail(kind: FailureKind, message: &'static str) -> Step {
|
||||
Step::Done(Box::new(Err(Failure::unbilled(kind, message))))
|
||||
Step::Done(Box::new(Err(Failure::cost_unknown(kind, message))))
|
||||
}
|
||||
|
||||
/// What this turn consumed, summed over every model it used (harness side
|
||||
/// calls included), or `None` when the result does not say: the field absent,
|
||||
/// of another shape, or empty. An empty report is believed only for a refusal
|
||||
/// at the door (401 or 403), which the provider cannot have billed; elsewhere
|
||||
/// at the door (401, 403 or 429), which the provider cannot have billed; elsewhere
|
||||
/// the CLI also zeroes usage on crashes after real work. `None` is billed the
|
||||
/// reservation by the kit.
|
||||
fn read_usage(reported: Option<Value>, door_refusal: bool) -> Option<Usage> {
|
||||
|
|
|
|||
902
crates/claude-connector/tests/config_document.rs
Normal file
902
crates/claude-connector/tests/config_document.rs
Normal file
|
|
@ -0,0 +1,902 @@
|
|||
//! 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::{
|
||||
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());
|
||||
}),
|
||||
),
|
||||
(
|
||||
"no minor",
|
||||
Box::new(|c| {
|
||||
c.callers[0].allowed_models = vec!["claude-opus-5".to_string()];
|
||||
c.prices.insert("claude-opus-5".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",
|
||||
"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_dated_canonical_id_is_allowed() {
|
||||
let dated = "claude-opus-5-1-20260101".to_string();
|
||||
let mut config = valid();
|
||||
config.callers[0].allowed_models = vec![dated.clone()];
|
||||
config.prices.insert(dated, price());
|
||||
config.validate().unwrap();
|
||||
}
|
||||
|
||||
#[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 ----
|
||||
|
||||
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<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("token_credential"));
|
||||
assert_eq!(shown(&checked), shown(&served));
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
|
@ -23,10 +23,11 @@ use crate::service::{
|
|||
/// The only provider seam of the connector kit. A connector binary implements
|
||||
/// it over one model provider; the kit names no provider.
|
||||
pub trait ProviderBackend: Send + Sync {
|
||||
/// Whether the provider can take work. `serve` consults it once, before it
|
||||
/// opens the replay store or accepts a connection; the request path does
|
||||
/// not consult it.
|
||||
fn ready(&self) -> bool;
|
||||
/// Whether the provider can take work; `Err` names the backend field that
|
||||
/// is not ready, never a value. `serve` consults it once, before it opens
|
||||
/// the replay store or accepts a connection; the request path does not
|
||||
/// consult it.
|
||||
fn ready(&self) -> Result<(), &'static str>;
|
||||
/// The tag of the provider this backend fronts. The service owns it from
|
||||
/// `serve` on: it names the provider on every receipt and decides which
|
||||
/// reasoning state is foreign.
|
||||
|
|
@ -59,7 +60,7 @@ impl ServeLimits {
|
|||
/// largest whose sealed frame, at its worst-case encoding, fits the frame.
|
||||
/// An admitted payload therefore always fits, and `complete` is given the
|
||||
/// same frame bound to fit every result in.
|
||||
fn validate(&self, admissions: &[CallerAdmission]) -> Result<(), ServeError> {
|
||||
pub fn validate(&self, admissions: &[CallerAdmission]) -> Result<(), ServeError> {
|
||||
if self.max_frame_bytes == 0
|
||||
|| self.max_frame_bytes > u32::MAX as usize
|
||||
|| self.max_connections == 0
|
||||
|
|
@ -171,9 +172,7 @@ pub fn serve(
|
|||
{
|
||||
return Err(ServeError::InvalidConfig("listener address"));
|
||||
}
|
||||
if !backend.ready() {
|
||||
return Err(ServeError::BackendUnready);
|
||||
}
|
||||
backend.ready().map_err(ServeError::BackendUnready)?;
|
||||
let service = Arc::new(Mutex::new(TransportService::open(
|
||||
replay_store,
|
||||
backend.provider(),
|
||||
|
|
@ -322,8 +321,8 @@ pub enum ServeError {
|
|||
ConnectionKey(#[source] std::io::Error),
|
||||
#[error(transparent)]
|
||||
Service(#[from] ServiceError),
|
||||
#[error("provider backend is not ready")]
|
||||
BackendUnready,
|
||||
#[error("provider backend is not ready: {0}")]
|
||||
BackendUnready(&'static str),
|
||||
#[error("connector service lock was poisoned")]
|
||||
ServicePoisoned,
|
||||
#[error("connector listener failed")]
|
||||
|
|
@ -379,9 +378,9 @@ mod tests {
|
|||
}
|
||||
|
||||
impl ProviderBackend for CountingBackend {
|
||||
fn ready(&self) -> bool {
|
||||
fn ready(&self) -> Result<(), &'static str> {
|
||||
self.ready_calls.fetch_add(1, Ordering::SeqCst);
|
||||
self.ready
|
||||
self.ready.then_some(()).ok_or("fixture")
|
||||
}
|
||||
|
||||
fn provider(&self) -> &str {
|
||||
|
|
@ -710,7 +709,10 @@ mod tests {
|
|||
&store.path,
|
||||
backend.clone(),
|
||||
);
|
||||
assert!(matches!(result, Some(Err(ServeError::BackendUnready))));
|
||||
assert!(matches!(
|
||||
result,
|
||||
Some(Err(ServeError::BackendUnready("fixture")))
|
||||
));
|
||||
assert_eq!(backend.executions.load(Ordering::SeqCst), 0);
|
||||
assert!(
|
||||
!store.path.exists(),
|
||||
|
|
@ -1016,8 +1018,8 @@ mod tests {
|
|||
}
|
||||
|
||||
impl ProviderBackend for SizedBackend {
|
||||
fn ready(&self) -> bool {
|
||||
true
|
||||
fn ready(&self) -> Result<(), &'static str> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn provider(&self) -> &str {
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ impl CallerAdmission {
|
|||
}
|
||||
|
||||
/// What one million tokens of each class cost, in micro-USD.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Rates {
|
||||
pub input: u64,
|
||||
pub cache_write_short: u64,
|
||||
|
|
@ -129,7 +129,7 @@ pub struct Rates {
|
|||
}
|
||||
|
||||
/// The rates a prompt strictly above `above_prompt_tokens` is priced at.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LongContext {
|
||||
pub above_prompt_tokens: u64,
|
||||
pub rates: Rates,
|
||||
|
|
@ -138,7 +138,7 @@ pub struct LongContext {
|
|||
/// One model's price. Explicit zero rates are admitted (a local model is
|
||||
/// free); a missing price is not. `overhead_tokens` is added to the byte
|
||||
/// bound of a request when its worst case is reserved.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ModelPrice {
|
||||
pub rates: Rates,
|
||||
pub long_context: Option<LongContext>,
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ impl RecordingBackend {
|
|||
}
|
||||
|
||||
impl ProviderBackend for RecordingBackend {
|
||||
fn ready(&self) -> bool {
|
||||
true
|
||||
fn ready(&self) -> Result<(), &'static str> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn provider(&self) -> &str {
|
||||
|
|
@ -291,8 +291,8 @@ struct TamperingBackend {
|
|||
}
|
||||
|
||||
impl ProviderBackend for TamperingBackend {
|
||||
fn ready(&self) -> bool {
|
||||
true
|
||||
fn ready(&self) -> Result<(), &'static str> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn provider(&self) -> &str {
|
||||
|
|
|
|||
Loading…
Reference in a new issue