Merge pull request 'claude-connector ask: no-tools completion CLI' (#2) from eureka-substrate/notool-ask-cli into main

This commit is contained in:
Eureka 2026-11-11 08:15:48 +00:00
commit 9d0f4b400c
7 changed files with 1197 additions and 4 deletions

1
Cargo.lock generated
View file

@ -144,6 +144,7 @@ dependencies = [
"hmac",
"libc",
"model-connector",
"rand_core 0.6.4",
"rmp-serde",
"rmpv",
"serde",

View file

@ -9,8 +9,8 @@ publishes the plan's usage windows (`usage_readout`, `claude_usage_readout` on t
minds to read and decide their own burn. The one usage-derived refusal is the provider's own
(`ProviderRejected`), repeated until its reset.
Status: the `model-connector` crate and the `claude-connector` backend crate are live. No
connector binary exists yet (the Claude connector binary is first, then an OpenAI-compatible one).
Status: the `model-connector` crate and the `claude-connector` backend crate and binary are live
(`claude-connector serve|config|ask`); an OpenAI-compatible connector is not written.
## Entry points
@ -40,7 +40,7 @@ Crate `crates/model-connector`:
`ProviderBackend` trait. A connector binary implements `ProviderBackend` for its
provider and calls `serve`; the kit names no provider.
Crate `crates/claude-connector` (library only; the binary that wires it to `serve` is not written):
Crate `crates/claude-connector` (library and the `claude-connector` binary that wires it to `serve`):
- `ClaudeCodeBackend` implements `ProviderBackend` for provider `claude-code` by driving the
unmodified `claude` binary, one child per call, in print stream-json mode with no tools, no MCP
@ -56,6 +56,24 @@ Crate `crates/claude-connector` (library only; the binary that wires it to `serv
pipe (`CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR`), never in an environment. `QuotaCell` keeps the
plan windows the CLI's `rate_limit_event`s report, for the usage readout and the lease grant.
## Asking for one no-tools completion
`claude-connector ask` is a caller of a running connector for a Mind's Self run: the prompt is all
of stdin, the answer text is stdout.
```
claude-connector ask --caller-id ID --connection-key-file FILE --model MODEL (--system TEXT | --system-file FILE) [--endpoint HOST:PORT] [--effort low|medium|high|max] [--max-output-tokens N] [--timeout-secs N] < prompt > answer
```
Defaults: endpoint `127.0.0.1:4110`, 8192 output tokens, 300 s. The key is read from the file (the
same text the connector reads for the caller; never argv or environment). There is no tools flag,
no working-directory flag and no other endpoint: the request is built by `ProviderRequest::new`
(no tools) and the model child's cwd and environment stay the connector's. Any failure (refused,
over a cap, model not allowed, wrong key or caller, timeout, provider failure, empty answer, empty
stdin) exits nonzero with one `claude-connector: <cause>` line on stderr and nothing on stdout;
the line never carries the key, the prompt or the provider's message. Each run uses a fresh request
id. Code: `crates/claude-connector/src/ask.rs`; tests: `crates/claude-connector/tests/ask.rs`.
Frame arithmetic is public in `src/envelope.rs`, measured on the carriage frame: `frame_bytes_for_payload(n)` is the frame a
caller payload bound of `n` needs, `payload_bytes_for_frame(frame)` is the largest payload bound
a frame allows, and `MAX_IDENTITY_BYTES` (256) bounds caller ids, request ids and model names.

View file

@ -17,6 +17,7 @@ chrono = { version = "0.4", default-features = false }
hmac = "0.12"
cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a4eae9056152335662f1115f97bf35fb00ab6d23", package = "cultcache-rs" }
model-connector = { path = "../model-connector", features = ["daemon"] }
rand_core = { version = "0.6", features = ["getrandom"] }
rmp-serde = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View file

@ -0,0 +1,336 @@
//! `claude-connector ask`: one no-tools completion through a running connector.
//!
//! A thin caller over [`ConnectorClient`]. It builds a [`ProviderRequest`] with
//! `ProviderRequest::new`, which carries no tools, and has no field, flag or
//! path by which a caller could add one; the working directory and environment
//! of the model child belong to the connector. Every failure is an
//! [`AskError`] whose text names a cause and never echoes the key, the prompt
//! or the instructions.
use std::net::SocketAddr;
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use model_connector::{
ConnectorClient, ConnectorClientError, FailureKind, InputItem, ProviderRequest,
ReasoningEffort, StopReason, TransportDisposition, TransportEventPayload, TransportInvocation,
TransportOutcome, TransportResult, frame_bytes_for_payload, provider_request_sha256,
};
use rand_core::{OsRng, RngCore};
use zeroize::Zeroizing;
/// The loopback endpoint a claude connector listens on.
pub const DEFAULT_ENDPOINT: &str = "127.0.0.1:4110";
/// The payload bound the connector's own `config init` defaults to.
pub const PAYLOAD_BYTES: usize = 1 << 20;
const MAX_KEY_BYTES: usize = 1024;
pub const MAX_SYSTEM_BYTES: usize = 64 * 1024;
pub struct AskArgs {
pub endpoint: SocketAddr,
pub caller_id: String,
pub key_file: std::path::PathBuf,
pub model: String,
pub instructions: String,
pub effort: Option<ReasoningEffort>,
pub max_output_tokens: u32,
pub timeout: Duration,
pub prompt: String,
}
#[derive(Debug, thiserror::Error)]
pub enum AskError {
#[error("the prompt on stdin is empty")]
EmptyPrompt,
#[error("the prompt on stdin is larger than the {PAYLOAD_BYTES} byte payload bound")]
PromptTooLarge,
#[error("the instructions are empty")]
EmptyInstructions,
#[error("the connection key file cannot be read")]
KeyUnreadable,
#[error("the connection key file is not text")]
KeyEncoding,
#[error("the connection key file is not a regular file")]
KeyNotRegular,
#[error("the connection key file is readable by group or world (chmod 600)")]
KeyExposed,
#[error("the instructions file cannot be read as text from a regular file")]
SystemUnreadable,
#[error("the instructions file is larger than {MAX_SYSTEM_BYTES} bytes")]
SystemTooLarge,
#[error("standard input cannot be read")]
StdinRead,
#[error("standard input is not UTF-8 text")]
StdinNotText,
#[error("the answer cannot be written to standard output")]
StdoutWrite,
#[error("nothing is listening at the connector endpoint")]
NotListening,
#[error(
"the connector closed the connection without answering (key, caller or frame not accepted)"
)]
ClosedWithoutAnswer,
#[error("the connection key file is empty or longer than {MAX_KEY_BYTES} bytes")]
KeyLength,
#[error("the connection key is not usable as a key (surrounding whitespace?)")]
KeyUnusable,
#[error("the request is invalid for the connector contract")]
Request,
#[error("timed out waiting for the connector")]
Timeout,
#[error("the connector connection failed: {0}")]
Connection(std::io::ErrorKind),
#[error("the connector answer was not accepted (wrong key, caller or tampered frame): {0}")]
Transport(model_connector::ServiceError),
#[error("the request exceeded a frame bound")]
FrameSize,
#[error("the connector refused the request: {0:?}")]
Refused(model_connector::Refusal),
#[error("the provider failed: {0:?}")]
Failed(FailureKind),
#[error("the answer stopped as {0:?}, not a completed text")]
NotCompleted(StopReason),
#[error("the answer carried a tool call, which this command never requests")]
ToolCall,
#[error("the answer is empty")]
EmptyAnswer,
}
enum ReadFailure {
Unreadable,
NotRegular,
TooLarge,
Exposed,
}
/// Opens `path` without blocking (a FIFO must not hang the open), requires the
/// opened file to be a regular file (a symlink is followed and its target
/// judged), optionally refuses one readable by group or world, and reads at
/// most `cap` bytes, failing if there are more.
fn read_regular(path: &Path, cap: usize, private: bool) -> Result<Zeroizing<Vec<u8>>, ReadFailure> {
use std::io::Read;
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NONBLOCK);
}
let file = options.open(path).map_err(|_| ReadFailure::Unreadable)?;
let metadata = file.metadata().map_err(|_| ReadFailure::Unreadable)?;
if !metadata.is_file() {
return Err(ReadFailure::NotRegular);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if private && metadata.permissions().mode() & 0o077 != 0 {
return Err(ReadFailure::Exposed);
}
}
let mut bytes = Zeroizing::new(Vec::new());
file.take(cap as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|_| ReadFailure::Unreadable)?;
if bytes.len() > cap {
return Err(ReadFailure::TooLarge);
}
Ok(bytes)
}
/// Reads the key as the connector reads its own key file (the text without the
/// trailing line ending). The bytes are zeroized on drop and never shown.
pub fn read_key(path: &Path) -> Result<Zeroizing<String>, AskError> {
let raw = read_regular(path, MAX_KEY_BYTES, true).map_err(|failure| match failure {
ReadFailure::Unreadable => AskError::KeyUnreadable,
ReadFailure::NotRegular => AskError::KeyNotRegular,
ReadFailure::TooLarge => AskError::KeyLength,
ReadFailure::Exposed => AskError::KeyExposed,
})?;
let text = std::str::from_utf8(raw.as_slice()).map_err(|_| AskError::KeyEncoding)?;
let key = Zeroizing::new(text.trim_end_matches(['\r', '\n']).to_string());
if key.is_empty() || key.len() > MAX_KEY_BYTES {
return Err(AskError::KeyLength);
}
if key.trim() != key.as_str() {
return Err(AskError::KeyUnusable);
}
Ok(key)
}
/// The instructions file: a regular file of at most [`MAX_SYSTEM_BYTES`] of text.
pub fn read_system_file(path: &Path) -> Result<String, AskError> {
let raw = read_regular(path, MAX_SYSTEM_BYTES, false).map_err(|failure| match failure {
ReadFailure::TooLarge => AskError::SystemTooLarge,
_ => AskError::SystemUnreadable,
})?;
String::from_utf8(raw.to_vec()).map_err(|_| AskError::SystemUnreadable)
}
fn fresh_id(prefix: &str) -> String {
let mut nonce = [0_u8; 16];
OsRng.fill_bytes(&mut nonce);
let hex: String = nonce.iter().map(|b| format!("{b:02x}")).collect();
format!("{prefix}-{hex}")
}
/// The one invocation `ask` ever sends: instructions, one user text, no tools.
pub fn build_invocation(args: &AskArgs) -> Result<TransportInvocation, AskError> {
if args.prompt.trim().is_empty() {
return Err(AskError::EmptyPrompt);
}
if args.prompt.len() > PAYLOAD_BYTES {
return Err(AskError::PromptTooLarge);
}
if args.instructions.trim().is_empty() {
return Err(AskError::EmptyInstructions);
}
let mut request = ProviderRequest::new(
fresh_id("ask"),
fresh_id("ask-conversation"),
args.model.clone(),
args.instructions.clone(),
args.max_output_tokens,
);
request.input = vec![InputItem::UserText {
text: args.prompt.clone(),
}];
request.reasoning_effort = args.effort;
let digest = provider_request_sha256(&request).map_err(|_| AskError::Request)?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64);
let invocation =
TransportInvocation::new(args.caller_id.clone(), now + 120_000, digest, request)
.map_err(|_| AskError::Request)?;
invocation
.validate(now, 300_000)
.map_err(|_| AskError::Request)?;
Ok(invocation)
}
/// The answer text of a result, or the one cause it is not an answer.
pub fn answer_text(result: &TransportResult) -> Result<String, AskError> {
let (events, receipt) = match &result.disposition {
TransportDisposition::Refused(reason) => return Err(AskError::Refused(reason.clone())),
TransportDisposition::Transported { events, receipt } => (events, receipt),
};
match &receipt.outcome {
TransportOutcome::Failed { kind, .. } => return Err(AskError::Failed(*kind)),
TransportOutcome::Completed { stop_reason, .. } => {
if !matches!(stop_reason, StopReason::EndTurn | StopReason::StopSequence) {
return Err(AskError::NotCompleted(*stop_reason));
}
}
}
let mut text = String::new();
for event in events {
match &event.payload {
TransportEventPayload::TextDelta { text: delta } => text.push_str(delta),
TransportEventPayload::ToolCall { .. } => return Err(AskError::ToolCall),
TransportEventPayload::ReasoningState { .. } => {}
}
}
if text.trim().is_empty() {
return Err(AskError::EmptyAnswer);
}
Ok(text)
}
pub fn ask(args: &AskArgs) -> Result<String, AskError> {
let invocation = build_invocation(args)?;
let key = read_key(&args.key_file)?;
let client = ConnectorClient::new(
args.endpoint,
key.as_str(),
frame_bytes_for_payload(PAYLOAD_BYTES),
Some(args.timeout),
)
.map_err(|error| match error {
ConnectorClientError::Transport(_) => AskError::KeyUnusable,
_ => AskError::Request,
})?;
let result = client.execute(&invocation).map_err(|error| match error {
ConnectorClientError::Connection(io)
if matches!(
io.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) =>
{
AskError::Timeout
}
ConnectorClientError::Connection(io) => match io.kind() {
std::io::ErrorKind::ConnectionRefused => AskError::NotListening,
std::io::ErrorKind::UnexpectedEof
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::BrokenPipe => AskError::ClosedWithoutAnswer,
kind => AskError::Connection(kind),
},
ConnectorClientError::Transport(error) => AskError::Transport(error),
ConnectorClientError::FrameSize => AskError::FrameSize,
ConnectorClientError::InvalidConfig | ConnectorClientError::RequestIdTooLong => {
AskError::Request
}
})?;
answer_text(&result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ClaudeCodeConfig, ClaudeCodeLimits};
use crate::handle::{SessionStore, plan};
use model_connector::ToolDefinition;
struct NoSessions;
impl SessionStore for NoSessions {
fn exists(&self, _: &str) -> bool {
false
}
}
fn args() -> AskArgs {
AskArgs {
endpoint: DEFAULT_ENDPOINT.parse().unwrap(),
caller_id: "c".into(),
key_file: "/nonexistent".into(),
model: "m".into(),
instructions: "i".into(),
effort: None,
max_output_tokens: 16,
timeout: Duration::from_secs(1),
prompt: "p".into(),
}
}
/// The claude backend's own planner accepts what `ask` builds and refuses
/// the same request once a tool is forced into it.
#[test]
fn the_backend_planner_refuses_a_forced_tool_and_accepts_the_asked_request() {
let config = ClaudeCodeConfig {
claude_binary: "/claude".into(),
claude_binary_sha256: [0; 32],
work_dir: "/w".into(),
config_dir: "/c".into(),
prompt_dir: "/p".into(),
token_credential: "t".into(),
handle_secret_credential: "h".into(),
instance: "i".into(),
limits: ClaudeCodeLimits {
deadline: Duration::from_secs(1),
max_line_bytes: 1024,
max_total_bytes: 4096,
stderr_tail_bytes: 64,
},
};
let mut request = build_invocation(&args()).unwrap().request;
let secret = [7_u8; 32];
assert!(plan(&request, &config, &secret, "c", &NoSessions).is_ok());
request.tools.push(ToolDefinition {
name: "t".into(),
description: "d".into(),
parameters_json: "{}".into(),
});
assert!(plan(&request, &config, &secret, "c", &NoSessions).is_err());
}
}

View file

@ -3,6 +3,7 @@
//! stream-json in and out, with every tool, MCP server, settings source and
//! plugin switched off. A binary wires it to [`model_connector::serve`].
pub mod ask;
mod backend;
mod config;
mod daemon;

View file

@ -5,6 +5,7 @@
//! claude-connector config add-caller PATH.cc --caller-id ID --connection-key-file F ...
//! claude-connector config add-lease-caller PATH.cc --lease-user U --caller-id ID ...
//! claude-connector config check PATH.cc
//! claude-connector ask --caller-id ID --connection-key-file F --model M (--system TEXT | --system-file F) < prompt
//!
//! `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
@ -14,6 +15,7 @@ use std::collections::BTreeMap;
use std::path::PathBuf;
use std::process::ExitCode;
use claude_connector::ask::{self, AskArgs, AskError};
use claude_connector::{
ClaudeConnectorConfig, ConfigError, LeaseCallerConfig, MIN_SECRET_BYTES, check_ready,
load_config, write_config,
@ -29,6 +31,7 @@ fn main() -> ExitCode {
["config", "add-caller", path, flags @ ..] => add_caller(path, flags),
["config", "add-lease-caller", path, flags @ ..] => add_lease_caller(path, flags),
["config", "check", path] => check(path),
["ask", flags @ ..] => run_ask(flags),
_ => Err(Failure::Usage),
};
match result {
@ -43,7 +46,7 @@ fn main() -> ExitCode {
#[derive(Debug, thiserror::Error)]
enum Failure {
#[error(
"usage: serve --config PATH | config init PATH --flag value ... | config add-caller PATH --flag value ... | config add-lease-caller PATH --flag value ... | config check PATH (check reads the credentials from the environment it runs in: run it in the unit's environment)"
"usage: serve --config PATH | config init PATH --flag value ... | config add-caller PATH --flag value ... | config add-lease-caller PATH --flag value ... | config check PATH | ask --caller-id ID --connection-key-file FILE --model MODEL (--system TEXT | --system-file FILE) [--endpoint HOST:PORT] [--effort low|medium|high|max] [--max-output-tokens N] [--timeout-secs N] (prompt on stdin, answer on stdout) (check reads the credentials from the environment it runs in: run it in the unit's environment)"
)]
Usage,
#[error(transparent)]
@ -54,6 +57,8 @@ enum Failure {
Malformed(&'static str),
#[error("a flag is unknown, repeated or has no value")]
Flags,
#[error("{0}")]
Ask(#[from] AskError),
}
#[cfg(unix)]
@ -75,6 +80,68 @@ fn check(path: &str) -> Result<(), Failure> {
Ok(())
}
const ASK: [&str; 9] = [
"--endpoint",
"--caller-id",
"--connection-key-file",
"--model",
"--system",
"--system-file",
"--effort",
"--max-output-tokens",
"--timeout-secs",
];
/// One no-tools completion: the prompt is all of stdin, the answer is stdout.
fn run_ask(args: &[&str]) -> Result<(), Failure> {
use std::io::{Read, Write};
let flags = Flags::parse(args, &[&ASK], &[])?;
let instructions = match (
flags.single.get("--system"),
flags.single.get("--system-file"),
) {
(Some(text), None) => text.clone(),
(None, Some(path)) => ask::read_system_file(std::path::Path::new(path))?,
_ => return Err(Failure::Missing("--system or --system-file (exactly one)")),
};
let effort = match flags.single.get("--effort").map(String::as_str) {
None => None,
Some("low") => Some(model_connector::ReasoningEffort::Low),
Some("medium") => Some(model_connector::ReasoningEffort::Medium),
Some("high") => Some(model_connector::ReasoningEffort::High),
Some("max") => Some(model_connector::ReasoningEffort::Max),
Some(_) => return Err(Failure::Malformed("--effort")),
};
let endpoint = flags
.single
.get("--endpoint")
.map_or(ask::DEFAULT_ENDPOINT, String::as_str)
.parse()
.map_err(|_| Failure::Malformed("--endpoint"))?;
let mut raw = Vec::new();
std::io::stdin()
.take(ask::PAYLOAD_BYTES as u64 + 1)
.read_to_end(&mut raw)
.map_err(|_| AskError::StdinRead)?;
let prompt = String::from_utf8(raw).map_err(|_| AskError::StdinNotText)?;
let answer = ask::ask(&AskArgs {
endpoint,
caller_id: flags.text("--caller-id")?,
key_file: flags.path("--connection-key-file")?,
model: flags.text("--model")?,
instructions,
effort,
max_output_tokens: flags.number_or("--max-output-tokens", 8192)?,
timeout: std::time::Duration::from_secs(flags.number_or("--timeout-secs", 300)?),
prompt,
})?;
let mut out = std::io::stdout();
out.write_all(answer.as_bytes())
.and_then(|()| out.flush())
.map_err(|_| AskError::StdoutWrite.into())
}
/// The flags of one config verb: each single flag at most once, each list
/// flag any number of times.
struct Flags {

View file

@ -0,0 +1,769 @@
//! `claude-connector ask` against a real serve loop on loopback with a scripted
//! backend: the consumer side of the no-tools completion.
use std::net::{SocketAddr, TcpListener};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use claude_connector::ask::{AskArgs, AskError, ask, build_invocation};
use model_connector::*;
const CALLER: &str = "ask-caller";
const KEY: &str = "ask-test-key-0123456789-abcdefghijklmnop";
const MODEL: &str = "claude-haiku-5-5";
const FRAME: usize = 4 << 20;
/// What the scripted backend does with each call.
#[derive(Clone)]
enum Script {
Text(&'static str),
Empty,
Failed(&'static str),
Sleep(Duration),
ToolCall,
Stop(StopReason),
}
struct Backend {
script: Script,
seen: Mutex<Vec<ProviderRequest>>,
}
impl ProviderBackend for Backend {
fn ready(&self) -> Result<(), &'static str> {
Ok(())
}
fn provider(&self) -> &str {
"claude-code"
}
fn execute(&self, call: &ProviderCall) -> TransportResult {
self.seen.lock().unwrap().push(call.request().clone());
let usage = Usage {
input_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
output_tokens: 1,
reasoning_tokens: None,
};
let text = |t: &str| {
vec![TransportEvent {
sequence: 0,
payload: TransportEventPayload::TextDelta { text: t.into() },
}]
};
let stop = match &self.script {
Script::Stop(reason) => *reason,
_ => StopReason::EndTurn,
};
let completed = TransportOutcome::Completed {
provider_response_id: None,
stop_reason: stop,
usage: usage.clone(),
};
match &self.script {
Script::Text(t) => call.transported(text(t), completed),
Script::Empty => call.transported(Vec::new(), completed),
Script::Failed(message) => call.transported(
Vec::new(),
TransportOutcome::Failed {
kind: FailureKind::Provider,
message: message.to_string(),
usage: None,
},
),
Script::Stop(_) => call.transported(text("cut off"), completed),
Script::ToolCall => call.transported(
vec![TransportEvent {
sequence: 0,
payload: TransportEventPayload::ToolCall {
call_id: "c".into(),
name: "n".into(),
arguments: "{}".into(),
},
}],
completed,
),
Script::Sleep(d) => {
thread::sleep(*d);
call.transported(text("late"), completed)
}
}
}
}
fn write_key(path: &std::path::Path, bytes: &[u8], mode: u32) {
use std::os::unix::fs::PermissionsExt;
std::fs::write(path, bytes).unwrap();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
}
struct Rig {
endpoint: SocketAddr,
dir: PathBuf,
key_file: PathBuf,
backend: Arc<Backend>,
}
fn rig(script: Script) -> Rig {
static SEQ: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!(
"claude-connector-ask-{}-{}",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
let key_file = dir.join("caller.key");
write_key(&key_file, format!("{KEY}\n").as_bytes(), 0o600);
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let endpoint = listener.local_addr().unwrap();
let admission =
CallerAdmission::new(CALLER, KEY, 1, [MODEL.to_string()], 4, 1 << 20, 4_096).unwrap();
let limits = ServeLimits {
max_frame_bytes: FRAME,
max_connections: 8,
socket_timeout_ms: 5_000,
max_expiry_skew_ms: 300_000,
};
let backend = Arc::new(Backend {
script,
seen: Mutex::new(Vec::new()),
});
let served = backend.clone();
let store = dir.join("replay.cc");
thread::spawn(move || serve(listener, limits, vec![admission], &store, served));
Rig {
endpoint,
dir,
key_file,
backend,
}
}
impl Rig {
fn args(&self) -> AskArgs {
AskArgs {
endpoint: self.endpoint,
caller_id: CALLER.into(),
key_file: self.key_file.clone(),
model: MODEL.into(),
instructions: "Answer briefly.".into(),
effort: Some(ReasoningEffort::Low),
max_output_tokens: 1_024,
timeout: Duration::from_secs(5),
prompt: "SECRET-PROMPT-TEXT".into(),
}
}
fn seen(&self) -> Vec<ProviderRequest> {
self.backend.seen.lock().unwrap().clone()
}
}
fn message(error: &AskError) -> String {
error.to_string()
}
#[test]
fn success_returns_the_answer_text_and_the_backend_saw_a_toolless_request() {
let rig = rig(Script::Text("four"));
assert_eq!(ask(&rig.args()).unwrap(), "four");
let seen = rig.seen();
assert_eq!(seen.len(), 1);
assert!(seen[0].tools.is_empty());
assert_eq!(seen[0].model, MODEL);
assert_eq!(seen[0].instructions, "Answer briefly.");
assert_eq!(seen[0].reasoning_effort, Some(ReasoningEffort::Low));
assert_eq!(seen[0].max_output_tokens, 1_024);
assert_eq!(
seen[0].input,
vec![InputItem::UserText {
text: "SECRET-PROMPT-TEXT".into()
}]
);
}
#[test]
fn each_run_uses_a_fresh_request_id_so_replay_never_answers_a_new_question() {
let rig = rig(Script::Text("again"));
ask(&rig.args()).unwrap();
ask(&rig.args()).unwrap();
let seen = rig.seen();
assert_eq!(seen.len(), 2, "the provider ran once per run");
assert_ne!(seen[0].request_id, seen[1].request_id);
}
#[test]
fn the_cli_cannot_construct_a_request_with_tools() {
let rig = rig(Script::Text("x"));
let invocation = build_invocation(&rig.args()).unwrap();
assert!(invocation.request.tools.is_empty());
assert_eq!(invocation.request.tool_choice, ToolChoice::Auto);
assert!(
invocation
.request
.input
.iter()
.all(|item| matches!(item, InputItem::UserText { .. }))
);
}
#[test]
fn a_model_outside_allowed_models_is_refused_loudly() {
let rig = rig(Script::Text("x"));
let mut args = rig.args();
args.model = "claude-opus-5".into();
let error = ask(&args).unwrap_err();
assert!(
matches!(error, AskError::Refused(Refusal::ModelNotAllowed)),
"{error}"
);
assert!(rig.seen().is_empty());
}
#[test]
fn an_output_bound_over_the_callers_cap_is_refused() {
let rig = rig(Script::Text("x"));
let mut args = rig.args();
args.max_output_tokens = 4_097;
assert!(matches!(
ask(&args).unwrap_err(),
AskError::Refused(Refusal::OutputBound)
));
assert!(rig.seen().is_empty());
}
#[test]
fn a_wrong_key_or_caller_is_an_error_and_never_reaches_the_provider() {
let rig = rig(Script::Text("x"));
let mut wrong_key = rig.args();
let other = rig.dir.join("other.key");
write_key(&other, b"some-other-connection-key-0123456789", 0o600);
wrong_key.key_file = other;
let error = ask(&wrong_key).unwrap_err();
assert!(!message(&error).contains("some-other"), "{error}");
let mut wrong_caller = rig.args();
wrong_caller.caller_id = "someone-else".into();
ask(&wrong_caller).unwrap_err();
assert!(rig.seen().is_empty());
}
#[test]
fn an_unusable_key_file_errors_without_echoing_its_contents() {
let rig = rig(Script::Text("x"));
let cases: Vec<(&str, Vec<u8>)> = vec![
("empty", b"\n".to_vec()),
("long", vec![b'k'; 2_000]),
("binary", vec![0xff, 0xfe, 0xfd, 0x41]),
("padded", b" spaced-secret-value-padded ".to_vec()),
];
for (name, bytes) in cases {
let path = rig.dir.join(format!("{name}.key"));
write_key(&path, &bytes, 0o600);
let mut args = rig.args();
args.key_file = path;
let text = message(&ask(&args).unwrap_err());
assert!(!text.contains("spaced-secret"), "{name}: {text}");
assert!(!text.contains("kkkk"), "{name}: {text}");
}
let mut missing = rig.args();
missing.key_file = rig.dir.join("absent.key");
assert!(matches!(
ask(&missing).unwrap_err(),
AskError::KeyUnreadable
));
let mut directory = rig.args();
directory.key_file = rig.dir.clone();
assert!(matches!(
ask(&directory).unwrap_err(),
AskError::KeyNotRegular
));
assert!(rig.seen().is_empty());
}
#[test]
fn an_empty_answer_is_an_error() {
for script in [Script::Empty, Script::Text(" \n")] {
let rig = rig(script);
assert!(matches!(
ask(&rig.args()).unwrap_err(),
AskError::EmptyAnswer
));
}
}
#[test]
fn an_empty_prompt_is_an_error_before_any_connection() {
let rig = rig(Script::Text("x"));
let mut args = rig.args();
args.prompt = " \n\t".into();
assert!(matches!(ask(&args).unwrap_err(), AskError::EmptyPrompt));
assert!(rig.seen().is_empty());
}
#[test]
fn a_provider_failure_names_its_kind_and_not_its_message() {
let rig = rig(Script::Failed("stderr tail with SECRET-PROMPT-TEXT"));
let error = ask(&rig.args()).unwrap_err();
assert!(matches!(error, AskError::Failed(FailureKind::Provider)));
assert!(!message(&error).contains("SECRET"));
}
#[test]
fn a_connector_that_does_not_answer_in_time_is_a_timeout() {
let rig = rig(Script::Sleep(Duration::from_secs(3)));
let mut args = rig.args();
args.timeout = Duration::from_millis(300);
assert!(matches!(ask(&args).unwrap_err(), AskError::Timeout));
}
#[test]
fn a_dead_endpoint_is_a_connection_error() {
let rig = rig(Script::Text("x"));
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let mut args = rig.args();
args.endpoint = listener.local_addr().unwrap();
drop(listener);
assert!(matches!(ask(&args).unwrap_err(), AskError::NotListening));
}
fn run_binary(rig: &Rig, stdin: &str, extra: &[&str]) -> std::process::Output {
run_binary_with_key(rig, &rig.key_file, stdin, extra)
}
fn run_binary_with_key(
rig: &Rig,
key_file: &std::path::Path,
stdin: &str,
extra: &[&str],
) -> std::process::Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_claude-connector"))
.args(["ask", "--endpoint", &rig.endpoint.to_string()])
.args([
"--caller-id",
CALLER,
"--model",
MODEL,
"--system",
"Be brief.",
])
.args(["--connection-key-file", key_file.to_str().unwrap()])
.args(extra)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
{
use std::io::Write;
let _ = child.stdin.take().unwrap().write_all(stdin.as_bytes());
}
child.wait_with_output().unwrap()
}
#[test]
fn a_toolcall_in_the_answer_is_an_error() {
let rig = rig(Script::ToolCall);
assert!(matches!(ask(&rig.args()).unwrap_err(), AskError::ToolCall));
}
/// The rule: only a turn that ended (end of turn or a stop sequence) is an
/// answer; a truncated, refused or context-limited turn is an error even when
/// it carries text.
#[test]
fn a_turn_that_did_not_end_is_an_error() {
for reason in [
StopReason::MaxOutputTokens,
StopReason::Refusal,
StopReason::ContextWindow,
StopReason::ToolUse,
] {
let rig = rig(Script::Stop(reason));
assert!(
matches!(ask(&rig.args()).unwrap_err(), AskError::NotCompleted(r) if r == reason),
"{reason:?}"
);
}
let rig = rig(Script::Stop(StopReason::StopSequence));
assert_eq!(ask(&rig.args()).unwrap(), "cut off");
}
#[test]
fn a_fifo_key_file_errors_promptly_instead_of_hanging() {
let rig = rig(Script::Text("x"));
let fifo = rig.dir.join("fifo.key");
let path = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap();
assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0);
let mut args = rig.args();
args.key_file = fifo;
let (tx, rx) = std::sync::mpsc::channel();
thread::spawn(move || tx.send(ask(&args)).unwrap());
let error = rx
.recv_timeout(Duration::from_secs(3))
.expect("reading a FIFO key file must not hang")
.unwrap_err();
assert!(matches!(error, AskError::KeyNotRegular), "{error}");
}
#[test]
fn a_key_file_readable_by_group_or_world_is_refused_and_a_private_one_accepted() {
let rig = rig(Script::Text("fine"));
for mode in [0o644, 0o640, 0o604] {
let path = rig.dir.join(format!("mode-{mode:o}.key"));
write_key(&path, KEY.as_bytes(), mode);
let mut args = rig.args();
args.key_file = path;
let error = ask(&args).unwrap_err();
assert!(matches!(error, AskError::KeyExposed), "{mode:o}: {error}");
assert!(!message(&error).contains(KEY));
}
assert!(rig.seen().is_empty());
assert_eq!(ask(&rig.args()).unwrap(), "fine");
}
#[test]
fn a_symlinked_key_is_judged_by_its_target() {
let rig = rig(Script::Text("linked"));
let link = rig.dir.join("link.key");
std::os::unix::fs::symlink(&rig.key_file, &link).unwrap();
let mut args = rig.args();
args.key_file = link;
assert_eq!(ask(&args).unwrap(), "linked");
let dir_link = rig.dir.join("dir.key");
std::os::unix::fs::symlink(&rig.dir, &dir_link).unwrap();
args.key_file = dir_link;
assert!(matches!(ask(&args).unwrap_err(), AskError::KeyNotRegular));
}
#[test]
fn the_key_read_is_bounded_at_1024_bytes() {
let rig = rig(Script::Text("x"));
let path = rig.dir.join("big.key");
write_key(&path, &vec![b'k'; 1_025], 0o600);
assert!(matches!(
claude_connector::ask::read_key(&path).unwrap_err(),
AskError::KeyLength
));
write_key(&path, &vec![b'k'; 1_024], 0o600);
assert!(claude_connector::ask::read_key(&path).is_ok());
}
#[test]
fn the_instructions_file_is_capped_and_must_be_a_regular_file() {
use claude_connector::ask::{MAX_SYSTEM_BYTES, read_system_file};
let rig = rig(Script::Text("x"));
let path = rig.dir.join("system.txt");
std::fs::write(&path, vec![b's'; MAX_SYSTEM_BYTES]).unwrap();
assert_eq!(read_system_file(&path).unwrap().len(), MAX_SYSTEM_BYTES);
std::fs::write(&path, vec![b's'; MAX_SYSTEM_BYTES + 1]).unwrap();
assert!(matches!(
read_system_file(&path).unwrap_err(),
AskError::SystemTooLarge
));
assert!(matches!(
read_system_file(&rig.dir).unwrap_err(),
AskError::SystemUnreadable
));
}
#[test]
fn the_binary_never_prints_key_bytes_for_a_bad_key_file() {
let rig = rig(Script::Text("x"));
let cases: [(&str, &[u8], u32); 3] = [
("exposed", b"exposed-key-bytes-0123456789", 0o644),
("padded", b" padded-key-bytes-0123456789 ", 0o600),
("wrong", b"wrong-but-wellformed-key-0123456789", 0o600),
];
for (name, bytes, mode) in cases {
let path = rig.dir.join(format!("bin-{name}.key"));
write_key(&path, bytes, mode);
let out = run_binary_with_key(&rig, &path, "q", &[]);
assert!(!out.status.success(), "{name}");
let shown = [out.stdout.clone(), out.stderr.clone()].concat();
let shown = String::from_utf8_lossy(&shown);
assert!(
!shown.contains("key-bytes") && !shown.contains("wellformed"),
"{name}: {shown}"
);
assert!(out.stdout.is_empty());
}
let out = run_binary(&rig, "q", &["--system-file", "/nonexistent"]);
assert!(!out.status.success());
}
#[test]
fn stdin_and_stdout_failures_have_their_own_wording() {
let rig = rig(Script::Text("x"));
let mut child = Command::new(env!("CARGO_BIN_EXE_claude-connector"))
.args(["ask", "--endpoint", &rig.endpoint.to_string()])
.args(["--caller-id", CALLER, "--model", MODEL, "--system", "s"])
.args(["--connection-key-file", rig.key_file.to_str().unwrap()])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
{
use std::io::Write;
child
.stdin
.take()
.unwrap()
.write_all(&[0xff, 0xfe])
.unwrap();
}
let out = child.wait_with_output().unwrap();
let err = String::from_utf8(out.stderr).unwrap();
assert!(err.contains("not UTF-8"), "{err}");
assert!(!err.contains("flag"), "{err}");
}
/// What the wire returns for a tools-bearing request that gets past the CLI's
/// own construction (here forced by hand) when the real claude backend sits
/// behind the real serve loop: the service admits it, the backend's planner
/// refuses it, the caller receives a transported `Failed(InvalidRequest)`
/// result, and the claude child is never started.
#[test]
fn a_forced_tools_request_reaches_the_planner_is_refused_and_no_child_starts() {
use claude_connector::{
ChildEnv, ClaudeCodeBackend, ClaudeCodeConfig, ClaudeCodeLimits, ProcessPort, RunningChild,
SessionStore, StderrSink, TokenSource,
};
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
struct CountingPort(Arc<AtomicUsize>);
impl ProcessPort for CountingPort {
fn spawn(
&self,
_: &[String],
_: &ChildEnv,
_: &std::path::Path,
_: Vec<u8>,
_: &str,
) -> std::io::Result<Box<dyn RunningChild>> {
self.0.fetch_add(1, Ordering::SeqCst);
Err(std::io::Error::other("no child in this test"))
}
}
struct Fixed(&'static str);
impl TokenSource for Fixed {
fn read(&self) -> Option<Zeroizing<String>> {
Some(Zeroizing::new(self.0.to_string()))
}
}
struct NoSessions;
impl SessionStore for NoSessions {
fn exists(&self, _: &str) -> bool {
false
}
}
struct Quiet;
impl StderrSink for Quiet {
fn stderr_tail(&self, _: &[u8]) {}
}
let dir =
std::env::temp_dir().join(format!("claude-connector-ask-tools-{}", std::process::id()));
for sub in ["work", "config", "prompt"] {
std::fs::create_dir_all(dir.join(sub)).unwrap();
}
let binary = dir.join("claude");
std::fs::write(&binary, b"pretend claude").unwrap();
let spawns = Arc::new(AtomicUsize::new(0));
let backend = ClaudeCodeBackend::with_ports(
ClaudeCodeConfig {
claude_binary: binary,
claude_binary_sha256: Sha256::digest(b"pretend claude").into(),
work_dir: dir.join("work"),
config_dir: dir.join("config"),
prompt_dir: dir.join("prompt"),
token_credential: "t".into(),
handle_secret_credential: "h".into(),
instance: "i".into(),
limits: ClaudeCodeLimits {
deadline: Duration::from_secs(5),
max_line_bytes: 1 << 16,
max_total_bytes: 1 << 20,
stderr_tail_bytes: 64,
},
},
Box::new(CountingPort(spawns.clone())),
Box::new(NoSessions),
Box::new(Quiet),
Box::new(Fixed("token")),
Box::new(Fixed(
"handle-secret-0000000000000000000000000000000000000000",
)),
);
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let endpoint = listener.local_addr().unwrap();
let admission =
CallerAdmission::new(CALLER, KEY, 1, [MODEL.to_string()], 4, 1 << 20, 4_096).unwrap();
let limits = ServeLimits {
max_frame_bytes: FRAME,
max_connections: 8,
socket_timeout_ms: 5_000,
max_expiry_skew_ms: 300_000,
};
let store = dir.join("replay.cc");
thread::spawn(move || serve(listener, limits, vec![admission], &store, Arc::new(backend)));
let mut args = AskArgs {
endpoint,
caller_id: CALLER.into(),
key_file: dir.join("unused"),
model: MODEL.into(),
instructions: "i".into(),
effort: None,
max_output_tokens: 64,
timeout: Duration::from_secs(5),
prompt: "p".into(),
};
let mut forced = build_invocation(&args).unwrap().request;
forced.tools.push(ToolDefinition {
name: "lookup".into(),
description: "d".into(),
parameters_json: "{}".into(),
});
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let invocation = TransportInvocation::new(
CALLER,
now + 60_000,
provider_request_sha256(&forced).unwrap(),
forced,
)
.unwrap();
let client = ConnectorClient::new(endpoint, KEY, FRAME, Some(Duration::from_secs(5))).unwrap();
let result = client.execute(&invocation).unwrap();
let TransportDisposition::Transported { receipt, .. } = &result.disposition else {
panic!(
"expected the planner's failed result, got {:?}",
result.disposition
);
};
assert!(matches!(
receipt.outcome,
TransportOutcome::Failed {
kind: FailureKind::InvalidRequest,
..
}
));
assert_eq!(
spawns.load(Ordering::SeqCst),
0,
"the claude child must never start"
);
// The same CLI path with no tools would have reached the spawn.
args.key_file = dir.join("unused");
let clean = client.execute(&build_invocation(&args).unwrap()).unwrap();
assert!(matches!(
clean.disposition,
TransportDisposition::Transported { .. }
));
assert_eq!(spawns.load(Ordering::SeqCst), 1);
}
#[test]
fn the_binary_prints_the_answer_on_success() {
let rig = rig(Script::Text("pong"));
let out = run_binary(
&rig,
"ping",
&["--effort", "low", "--max-output-tokens", "1024"],
);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(String::from_utf8(out.stdout).unwrap(), "pong");
assert!(out.stderr.is_empty());
}
#[test]
fn the_binary_fails_loudly_with_one_stderr_line_and_empty_stdout() {
let rig = rig(Script::Text("x"));
let out = run_binary(&rig, "", &[]);
assert!(!out.status.success());
assert!(out.stdout.is_empty());
let err = String::from_utf8(out.stderr).unwrap();
assert_eq!(err.trim_end().lines().count(), 1, "{err}");
assert!(err.starts_with("claude-connector: "));
assert!(!err.contains(KEY));
// Tools, a working directory and a second endpoint are not flags.
for flag in ["--tools", "--cwd", "--work-dir"] {
let out = run_binary(&rig, "q", &[flag, "x"]);
assert!(!out.status.success());
assert!(out.stdout.is_empty());
}
assert!(rig.seen().is_empty());
}
/// Runs the binary with `--system-file` instead of `--system`, killing it if it
/// has not exited within five seconds (a hang is a failure, not a stuck suite).
fn run_with_system_file(rig: &Rig, file: &std::path::Path) -> std::process::Output {
use std::io::Write;
let mut child = Command::new(env!("CARGO_BIN_EXE_claude-connector"))
.args(["ask", "--endpoint", &rig.endpoint.to_string()])
.args(["--caller-id", CALLER, "--model", MODEL])
.args(["--max-output-tokens", "1024"])
.args(["--connection-key-file", rig.key_file.to_str().unwrap()])
.args(["--system-file", file.to_str().unwrap()])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let _ = child.stdin.take().unwrap().write_all(b"question");
let started = std::time::Instant::now();
while child.try_wait().unwrap().is_none() {
if started.elapsed() > Duration::from_secs(5) {
child.kill().unwrap();
let _ = child.wait();
panic!("the binary hung on its --system-file");
}
thread::sleep(Duration::from_millis(20));
}
child.wait_with_output().unwrap()
}
#[test]
fn the_binary_bounds_and_guards_its_system_file() {
use claude_connector::ask::MAX_SYSTEM_BYTES;
let rig = rig(Script::Text("accepted"));
let fifo = rig.dir.join("system.fifo");
let path = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap();
assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0);
let out = run_with_system_file(&rig, &fifo);
assert!(!out.status.success());
assert!(out.stdout.is_empty());
let zero = run_with_system_file(&rig, std::path::Path::new("/dev/zero"));
assert!(!zero.status.success());
assert!(zero.stdout.is_empty());
let file = rig.dir.join("system.txt");
std::fs::write(&file, vec![b's'; MAX_SYSTEM_BYTES + 1]).unwrap();
let over = run_with_system_file(&rig, &file);
assert!(!over.status.success());
assert!(over.stdout.is_empty());
assert!(String::from_utf8_lossy(&over.stderr).contains("larger than"));
assert!(rig.seen().is_empty());
std::fs::write(&file, vec![b's'; MAX_SYSTEM_BYTES]).unwrap();
let exact = run_with_system_file(&rig, &file);
assert!(
exact.status.success(),
"{}",
String::from_utf8_lossy(&exact.stderr)
);
assert_eq!(String::from_utf8(exact.stdout).unwrap(), "accepted");
assert_eq!(rig.seen()[0].instructions.len(), MAX_SYSTEM_BYTES);
}