claude-connector: add ask, a no-tools completion CLI over ConnectorClient
Prompt on stdin, answer on stdout; no tools, cwd or endpoint options, key read from a file, one-line stderr cause on every failure. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
61cc59d9a0
commit
c47597b421
7 changed files with 714 additions and 4 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -144,6 +144,7 @@ dependencies = [
|
|||
"hmac",
|
||||
"libc",
|
||||
"model-connector",
|
||||
"rand_core 0.6.4",
|
||||
"rmp-serde",
|
||||
"rmpv",
|
||||
"serde",
|
||||
|
|
|
|||
24
README.md
24
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
253
crates/claude-connector/src/ask.rs
Normal file
253
crates/claude-connector/src/ask.rs
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
//! `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 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 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,
|
||||
}
|
||||
|
||||
/// 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 = Zeroizing::new(std::fs::read(path).map_err(|_| AskError::KeyUnreadable)?);
|
||||
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)
|
||||
}
|
||||
|
||||
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) => AskError::Connection(io.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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,70 @@ 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)) => {
|
||||
std::fs::read_to_string(path).map_err(|_| Failure::Malformed("--system-file"))?
|
||||
}
|
||||
_ => 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(|_| Failure::Malformed("stdin"))?;
|
||||
let prompt = String::from_utf8(raw).map_err(|_| Failure::Malformed("stdin"))?;
|
||||
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(|_| Failure::Malformed("stdout"))
|
||||
}
|
||||
|
||||
/// The flags of one config verb: each single flag at most once, each list
|
||||
/// flag any number of times.
|
||||
struct Flags {
|
||||
|
|
|
|||
367
crates/claude-connector/tests/ask.rs
Normal file
367
crates/claude-connector/tests/ask.rs
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
//! `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),
|
||||
}
|
||||
|
||||
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 completed = TransportOutcome::Completed {
|
||||
provider_response_id: None,
|
||||
stop_reason: StopReason::EndTurn,
|
||||
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::Sleep(d) => {
|
||||
thread::sleep(*d);
|
||||
call.transported(text("late"), completed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
std::fs::write(&key_file, format!("{KEY}\n")).unwrap();
|
||||
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");
|
||||
std::fs::write(&other, "some-other-connection-key-0123456789").unwrap();
|
||||
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"));
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
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::KeyUnreadable
|
||||
));
|
||||
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::Connection(_)));
|
||||
}
|
||||
|
||||
fn run_binary(rig: &Rig, 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", rig.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 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());
|
||||
}
|
||||
Loading…
Reference in a new issue