claude-connector: the binary over one typed CultCache config document
gamecult.model_connector.claude_connector_config.v1 owns every bound and path the backend and serve loop run with: read as an exact single-record canonical snapshot, validated once for config init, config check and serve (loopback listen, absolute paths, one caller, canonical priced claude- models). Refusals name a field and never echo a value. serve refuses a prompt_dir that is not a directory owned by the process uid with mode 0700. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9a062f0846
commit
b44c5a3b75
5 changed files with 1134 additions and 0 deletions
|
|
@ -7,6 +7,9 @@ license.workspace = true
|
|||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "claude-connector"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
|
|
|
|||
318
crates/claude-connector/src/daemon.rs
Normal file
318
crates/claude-connector/src/daemon.rs
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
//! The claude-connector's configuration document and its serve verb. One typed
|
||||
//! CultCache record owns every bound and path the backend and the serve loop
|
||||
//! run with; nothing in the connector's environment, flags or JSON can change
|
||||
//! them. Validation errors name a field and never echo a value.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::net::{SocketAddr, TcpListener};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use cultcache_rs::{CultCache, DatabaseEntry, SingleFileMessagePackBackingStore};
|
||||
use model_connector::{
|
||||
CallerConfig, MIN_PAYLOAD_BYTES, ModelPrice, ServeError, ServeLimits, load_admissions, serve,
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{ClaudeCodeBackend, ClaudeCodeConfig, ClaudeCodeLimits, StderrSink};
|
||||
|
||||
const CONFIG_KEY: &str = "runtime";
|
||||
const MODEL_PREFIX: &str = "claude-";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "gamecult.model_connector.claude_connector_config.v1")]
|
||||
pub struct ClaudeConnectorConfig {
|
||||
/// A loopback socket address.
|
||||
#[cultcache(key = 0)]
|
||||
pub listen: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub replay_store: PathBuf,
|
||||
#[cultcache(key = 2)]
|
||||
pub claude_binary: PathBuf,
|
||||
#[cultcache(key = 3)]
|
||||
pub claude_binary_sha256: [u8; 32],
|
||||
#[cultcache(key = 4)]
|
||||
pub work_dir: PathBuf,
|
||||
#[cultcache(key = 5)]
|
||||
pub config_dir: PathBuf,
|
||||
/// Where each call's system prompt is staged: a directory the connector
|
||||
/// owns, mode 0700.
|
||||
#[cultcache(key = 6)]
|
||||
pub prompt_dir: PathBuf,
|
||||
#[cultcache(key = 7)]
|
||||
pub instance: String,
|
||||
/// The name of the systemd credential that holds the child's token.
|
||||
#[cultcache(key = 8)]
|
||||
pub token_credential: String,
|
||||
#[cultcache(key = 9)]
|
||||
pub deadline_ms: u64,
|
||||
#[cultcache(key = 10)]
|
||||
pub max_line_bytes: usize,
|
||||
#[cultcache(key = 11)]
|
||||
pub max_total_bytes: usize,
|
||||
#[cultcache(key = 12)]
|
||||
pub stderr_tail_bytes: usize,
|
||||
#[cultcache(key = 13)]
|
||||
pub max_frame_bytes: usize,
|
||||
#[cultcache(key = 14)]
|
||||
pub max_connections: usize,
|
||||
#[cultcache(key = 15)]
|
||||
pub socket_timeout_ms: u64,
|
||||
#[cultcache(key = 16)]
|
||||
pub max_expiry_skew_ms: u64,
|
||||
/// Exactly one caller until claude-handle-caller-binding lands.
|
||||
#[cultcache(key = 17)]
|
||||
pub callers: Vec<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,
|
||||
#[error(transparent)]
|
||||
Serve(#[from] ServeError),
|
||||
}
|
||||
|
||||
impl ClaudeConnectorConfig {
|
||||
/// The one validation: `config init`, `config check` and `serve` all pass
|
||||
/// through it. Returns the listen address.
|
||||
pub fn validate(&self) -> Result<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.keys().all(|model| is_canonical_model(model)) {
|
||||
return Err(ConfigError::Invalid("prices"));
|
||||
}
|
||||
Ok(listen)
|
||||
}
|
||||
|
||||
fn backend_config(&self) -> ClaudeCodeConfig {
|
||||
ClaudeCodeConfig {
|
||||
claude_binary: self.claude_binary.clone(),
|
||||
claude_binary_sha256: self.claude_binary_sha256,
|
||||
work_dir: self.work_dir.clone(),
|
||||
config_dir: self.config_dir.clone(),
|
||||
prompt_dir: self.prompt_dir.clone(),
|
||||
token_credential: self.token_credential.clone(),
|
||||
instance: self.instance.clone(),
|
||||
limits: ClaudeCodeLimits {
|
||||
deadline: Duration::from_millis(self.deadline_ms),
|
||||
max_line_bytes: self.max_line_bytes,
|
||||
max_total_bytes: self.max_total_bytes,
|
||||
stderr_tail_bytes: self.stderr_tail_bytes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_limits(&self) -> ServeLimits {
|
||||
ServeLimits {
|
||||
max_frame_bytes: self.max_frame_bytes,
|
||||
max_connections: self.max_connections,
|
||||
socket_timeout_ms: self.socket_timeout_ms,
|
||||
max_expiry_skew_ms: self.max_expiry_skew_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_trimmed_nonempty(text: &str) -> bool {
|
||||
!text.is_empty() && text.trim() == text
|
||||
}
|
||||
|
||||
/// A model id the CLI echoes back unchanged: aliases are refused because the
|
||||
/// stream's `init.model` must equal the requested model.
|
||||
fn is_canonical_model(model: &str) -> bool {
|
||||
model.starts_with(MODEL_PREFIX)
|
||||
&& model.len() > MODEL_PREFIX.len()
|
||||
&& model
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_'))
|
||||
}
|
||||
|
||||
/// One file name under `$CREDENTIALS_DIRECTORY`, never a path.
|
||||
fn is_credential_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name != "."
|
||||
&& name != ".."
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_'))
|
||||
}
|
||||
|
||||
/// Reads the document without writing: a shared snapshot of exactly one
|
||||
/// record, with the exact key, type and schema id, in canonical positional
|
||||
/// MessagePack, then validated.
|
||||
pub fn load_config(path: &Path) -> Result<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;
|
||||
|
||||
impl StderrSink for LogSink {
|
||||
fn stderr_tail(&self, tail: &[u8]) {
|
||||
eprintln!("claude-connector: child stderr, {} bytes", tail.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the document and serves until the listener fails.
|
||||
#[cfg(unix)]
|
||||
pub fn serve_config(path: &Path) -> Result<(), ConfigError> {
|
||||
let config = load_config(path)?;
|
||||
let listen = config.validate()?;
|
||||
check_prompt_dir(&config.prompt_dir)?;
|
||||
let admissions = load_admissions(&config.callers)?;
|
||||
let backend = Arc::new(ClaudeCodeBackend::new(
|
||||
config.backend_config(),
|
||||
Box::new(LogSink),
|
||||
));
|
||||
let listener = TcpListener::bind(listen).map_err(|_| ConfigError::Listen)?;
|
||||
serve(
|
||||
listener,
|
||||
config.serve_limits(),
|
||||
admissions,
|
||||
config.prices.clone(),
|
||||
&config.replay_store,
|
||||
backend,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
mod backend;
|
||||
mod config;
|
||||
mod daemon;
|
||||
mod handle;
|
||||
mod launch;
|
||||
mod process;
|
||||
|
|
@ -12,6 +13,9 @@ mod stream;
|
|||
|
||||
pub use backend::{ClaudeCodeBackend, CredentialTokenSource, StderrSink, TokenSource};
|
||||
pub use config::{ClaudeCodeConfig, ClaudeCodeLimits};
|
||||
pub use daemon::{ClaudeConnectorConfig, ConfigError, load_config, write_config};
|
||||
#[cfg(unix)]
|
||||
pub use daemon::{check_prompt_dir, serve_config};
|
||||
pub use handle::{FsSessionStore, SessionStore};
|
||||
pub use launch::ChildEnv;
|
||||
#[cfg(unix)]
|
||||
|
|
|
|||
238
crates/claude-connector/src/main.rs
Normal file
238
crates/claude-connector/src/main.rs
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
//! The claude-connector binary.
|
||||
//!
|
||||
//! claude-connector serve --config PATH.cc
|
||||
//! claude-connector config init PATH.cc --flag value ...
|
||||
//! claude-connector config check PATH.cc
|
||||
//!
|
||||
//! `serve` takes its configuration from the typed document alone. The flags of
|
||||
//! `config init` exist only to author that document. No message here echoes a
|
||||
//! value that was typed or stored; refusals name a field or a flag.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use claude_connector::{ClaudeConnectorConfig, ConfigError, load_config, write_config};
|
||||
use model_connector::{CallerConfig, ModelPrice, Rates, frame_bytes_for_payload};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<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())?;
|
||||
println!("claude connector configuration is valid");
|
||||
println!("callers: {}", config.callers.len());
|
||||
println!("allowed_models: {}", config.callers[0].allowed_models.len());
|
||||
println!("prices: {}", config.prices.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The flags of `config init`, each given at most once except the two lists.
|
||||
struct Flags {
|
||||
single: BTreeMap<&'static str, String>,
|
||||
models: Vec<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(())
|
||||
}
|
||||
571
crates/claude-connector/tests/config_document.rs
Normal file
571
crates/claude-connector/tests/config_document.rs
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
//! The claude-connector's configuration document and binary: what the
|
||||
//! document accepts, what it refuses, and that no refusal or output echoes a
|
||||
//! byte of a value that was typed, stored or read from a credential.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use claude_connector::{ClaudeConnectorConfig, ConfigError, load_config, write_config};
|
||||
use cultcache_rs::{CultCache, DatabaseEntry, SingleFileMessagePackBackingStore};
|
||||
use model_connector::{CallerConfig, ModelPrice, Rates};
|
||||
|
||||
const CANARY: &str = "canary-9f3b7c1d";
|
||||
const OPUS: &str = "claude-opus-5";
|
||||
const HAIKU: &str = "claude-haiku-5-5";
|
||||
|
||||
/// An edit that should make the valid document refuse.
|
||||
type Change = Box<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)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_refuses_an_invalid_document_and_leaves_no_file() {
|
||||
let scratch = Scratch::new();
|
||||
let path = scratch.path("bad.cc");
|
||||
let mut config = valid();
|
||||
config.callers[0].allowed_models = vec!["opus".to_string()];
|
||||
assert!(write_config(&path, &config).is_err());
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_refuses_aliases_unpriced_models_and_second_callers() {
|
||||
let valid_config = valid();
|
||||
assert_eq!(valid_config.validate().unwrap().port(), 7443);
|
||||
|
||||
let cases: Vec<(&str, Change)> = vec![
|
||||
(
|
||||
"alias allowed",
|
||||
Box::new(|c| {
|
||||
c.callers[0].allowed_models = vec!["opus".to_string()];
|
||||
}),
|
||||
),
|
||||
(
|
||||
"alias price key",
|
||||
Box::new(|c| {
|
||||
c.prices.insert("sonnet".to_string(), price());
|
||||
}),
|
||||
),
|
||||
(
|
||||
"bare prefix",
|
||||
Box::new(|c| {
|
||||
c.callers[0].allowed_models = vec!["claude-".to_string()];
|
||||
}),
|
||||
),
|
||||
(
|
||||
"unpriced allowed",
|
||||
Box::new(|c| {
|
||||
c.prices.remove(OPUS);
|
||||
}),
|
||||
),
|
||||
(
|
||||
"duplicate allowed",
|
||||
Box::new(|c| {
|
||||
c.callers[0].allowed_models = vec![HAIKU.to_string(), HAIKU.to_string()];
|
||||
}),
|
||||
),
|
||||
(
|
||||
"no allowed",
|
||||
Box::new(|c| c.callers[0].allowed_models.clear()),
|
||||
),
|
||||
(
|
||||
"second caller",
|
||||
Box::new(|c| {
|
||||
let mut second = c.callers[0].clone();
|
||||
second.caller_runtime_id = "other".to_string();
|
||||
c.callers.push(second);
|
||||
}),
|
||||
),
|
||||
("no caller", Box::new(|c| c.callers.clear())),
|
||||
];
|
||||
let fields = [
|
||||
"allowed_models",
|
||||
"prices",
|
||||
"allowed_models",
|
||||
"allowed_models",
|
||||
"allowed_models",
|
||||
"allowed_models",
|
||||
"callers",
|
||||
"callers",
|
||||
];
|
||||
for ((name, change), field) in cases.iter().zip(fields) {
|
||||
let mut config = valid();
|
||||
change(&mut config);
|
||||
assert_eq!(refused(&config), field, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_refuses_exposed_listeners_relative_paths_and_empty_bounds() {
|
||||
let cases: Vec<(&str, &str, Change)> = vec![
|
||||
(
|
||||
"wildcard listen",
|
||||
"listen",
|
||||
Box::new(|c| c.listen = "0.0.0.0:7443".into()),
|
||||
),
|
||||
(
|
||||
"lan listen",
|
||||
"listen",
|
||||
Box::new(|c| c.listen = "192.168.1.9:7443".into()),
|
||||
),
|
||||
(
|
||||
"hostname listen",
|
||||
"listen",
|
||||
Box::new(|c| c.listen = "localhost:7443".into()),
|
||||
),
|
||||
(
|
||||
"replay",
|
||||
"replay_store",
|
||||
Box::new(|c| c.replay_store = "replay.cc".into()),
|
||||
),
|
||||
(
|
||||
"binary",
|
||||
"claude_binary",
|
||||
Box::new(|c| c.claude_binary = "claude".into()),
|
||||
),
|
||||
("work", "work_dir", Box::new(|c| c.work_dir = "work".into())),
|
||||
(
|
||||
"config",
|
||||
"config_dir",
|
||||
Box::new(|c| c.config_dir = "cfg".into()),
|
||||
),
|
||||
(
|
||||
"prompts",
|
||||
"prompt_dir",
|
||||
Box::new(|c| c.prompt_dir = "prompts".into()),
|
||||
),
|
||||
(
|
||||
"key file",
|
||||
"connection_key_file",
|
||||
Box::new(|c| {
|
||||
c.callers[0].connection_key_file = "caller.key".into();
|
||||
}),
|
||||
),
|
||||
(
|
||||
"zero pin",
|
||||
"claude_binary_sha256",
|
||||
Box::new(|c| c.claude_binary_sha256 = [0; 32]),
|
||||
),
|
||||
(
|
||||
"blank instance",
|
||||
"instance",
|
||||
Box::new(|c| c.instance = " ".into()),
|
||||
),
|
||||
(
|
||||
"credential path",
|
||||
"token_credential",
|
||||
Box::new(|c| c.token_credential = "a/b".into()),
|
||||
),
|
||||
(
|
||||
"credential parent",
|
||||
"token_credential",
|
||||
Box::new(|c| c.token_credential = "..".into()),
|
||||
),
|
||||
(
|
||||
"credential empty",
|
||||
"token_credential",
|
||||
Box::new(|c| c.token_credential = String::new()),
|
||||
),
|
||||
(
|
||||
"zero deadline",
|
||||
"child limits",
|
||||
Box::new(|c| c.deadline_ms = 0),
|
||||
),
|
||||
(
|
||||
"total below line",
|
||||
"child limits",
|
||||
Box::new(|c| c.max_total_bytes = c.max_line_bytes - 1),
|
||||
),
|
||||
(
|
||||
"zero frame",
|
||||
"serve limits",
|
||||
Box::new(|c| c.max_frame_bytes = 0),
|
||||
),
|
||||
(
|
||||
"zero epoch",
|
||||
"connection_key_epoch",
|
||||
Box::new(|c| c.callers[0].connection_key_epoch = 0),
|
||||
),
|
||||
(
|
||||
"tiny payload",
|
||||
"caller bounds",
|
||||
Box::new(|c| c.callers[0].max_payload_bytes = 16),
|
||||
),
|
||||
(
|
||||
"no ceiling",
|
||||
"caller bounds",
|
||||
Box::new(|c| c.callers[0].spend_ceiling_micro_usd = 0),
|
||||
),
|
||||
];
|
||||
for (name, field, change) in cases {
|
||||
let mut config = valid();
|
||||
change(&mut config);
|
||||
assert_eq!(refused(&config), field, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_refusal_echoes_a_value() {
|
||||
let canary_path: PathBuf = format!("{CANARY}/relative").into();
|
||||
let canaried: Vec<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_then_check_reports_counts_only() {
|
||||
let scratch = Scratch::new();
|
||||
let made = init(&scratch, &init_flags(&scratch, &[]));
|
||||
assert!(made.status.success(), "{}", shown(&made));
|
||||
let config = load_config(&scratch.path("connector.cc")).unwrap();
|
||||
assert_eq!(config.callers[0].allowed_models, [HAIKU]);
|
||||
assert_eq!(config.prices[HAIKU].rates.output, 5_000_000);
|
||||
assert_eq!(config.prices[HAIKU].rates.cache_write_long, 1_250_000);
|
||||
assert_eq!(config.prices[HAIKU].overhead_tokens, 100);
|
||||
assert_eq!(config.claude_binary_sha256, [0xab; 32]);
|
||||
|
||||
let checked = run(
|
||||
&[
|
||||
"config",
|
||||
"check",
|
||||
scratch.path("connector.cc").to_str().unwrap(),
|
||||
],
|
||||
&[],
|
||||
);
|
||||
assert!(checked.status.success());
|
||||
let text = shown(&checked);
|
||||
assert!(text.contains("callers: 1") && text.contains("allowed_models: 1"));
|
||||
assert!(text.contains("prices: 1"));
|
||||
assert!(!text.contains("persona-a") && !text.contains(HAIKU));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_binary_refuses_without_echoing_what_it_was_given() {
|
||||
let spaced = format!(" {CANARY}");
|
||||
// Each pair replaces the value of an existing flag; the others are appended.
|
||||
let replaced: Vec<(&str, &str)> = vec![
|
||||
("--listen", CANARY),
|
||||
("--claude-sha256", CANARY),
|
||||
("--price", CANARY),
|
||||
("--instance", &spaced),
|
||||
("--connection-key-epoch", CANARY),
|
||||
("--allowed-model", CANARY),
|
||||
];
|
||||
let appended: Vec<Vec<&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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue