From c47597b4218f932fb976891dd4983edbc7dc8d73 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 07:56:08 +0000 Subject: [PATCH] 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 --- Cargo.lock | 1 + README.md | 24 +- crates/claude-connector/Cargo.toml | 1 + crates/claude-connector/src/ask.rs | 253 ++++++++++++++++++ crates/claude-connector/src/lib.rs | 1 + crates/claude-connector/src/main.rs | 71 +++++- crates/claude-connector/tests/ask.rs | 367 +++++++++++++++++++++++++++ 7 files changed, 714 insertions(+), 4 deletions(-) create mode 100644 crates/claude-connector/src/ask.rs create mode 100644 crates/claude-connector/tests/ask.rs diff --git a/Cargo.lock b/Cargo.lock index 3709c14..996b5fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,6 +144,7 @@ dependencies = [ "hmac", "libc", "model-connector", + "rand_core 0.6.4", "rmp-serde", "rmpv", "serde", diff --git a/README.md b/README.md index 7ffd262..cf5cd4f 100644 --- a/README.md +++ b/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: ` 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. diff --git a/crates/claude-connector/Cargo.toml b/crates/claude-connector/Cargo.toml index 6cb9e45..3b1d113 100644 --- a/crates/claude-connector/Cargo.toml +++ b/crates/claude-connector/Cargo.toml @@ -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" diff --git a/crates/claude-connector/src/ask.rs b/crates/claude-connector/src/ask.rs new file mode 100644 index 0000000..7ae24b8 --- /dev/null +++ b/crates/claude-connector/src/ask.rs @@ -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, + 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, 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 { + 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 { + 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 { + 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()); + } +} diff --git a/crates/claude-connector/src/lib.rs b/crates/claude-connector/src/lib.rs index 88de37b..6000a4e 100644 --- a/crates/claude-connector/src/lib.rs +++ b/crates/claude-connector/src/lib.rs @@ -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; diff --git a/crates/claude-connector/src/main.rs b/crates/claude-connector/src/main.rs index 273a4a1..bf99246 100644 --- a/crates/claude-connector/src/main.rs +++ b/crates/claude-connector/src/main.rs @@ -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 { diff --git a/crates/claude-connector/tests/ask.rs b/crates/claude-connector/tests/ask.rs new file mode 100644 index 0000000..3f957ae --- /dev/null +++ b/crates/claude-connector/tests/ask.rs @@ -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>, +} + +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, +} + +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 { + 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)> = 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()); +}