From c47597b4218f932fb976891dd4983edbc7dc8d73 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 07:56:08 +0000 Subject: [PATCH 1/3] 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()); +} From 42a157b049d97f356819bfdb967767f8f7bc9362 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 08:10:53 +0000 Subject: [PATCH 2/3] ask: guard key and system file reads, name stdin/stdout/connection causes, widen tests Key file: nonblocking open, regular file only, 1024-byte bound, refuse group/world-readable. --system-file capped at 64 KiB. Tests drive a forced tools request through the real serve loop and the real claude backend. Co-Authored-By: Claude Sonnet 5.5 --- crates/claude-connector/src/ask.rs | 87 ++++++- crates/claude-connector/src/main.rs | 6 +- crates/claude-connector/tests/ask.rs | 353 ++++++++++++++++++++++++++- 3 files changed, 434 insertions(+), 12 deletions(-) diff --git a/crates/claude-connector/src/ask.rs b/crates/claude-connector/src/ask.rs index 7ae24b8..ddd4e9f 100644 --- a/crates/claude-connector/src/ask.rs +++ b/crates/claude-connector/src/ask.rs @@ -24,6 +24,7 @@ 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, @@ -49,6 +50,26 @@ pub enum AskError { 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?)")] @@ -75,10 +96,57 @@ pub enum AskError { 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>, 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, AskError> { - let raw = Zeroizing::new(std::fs::read(path).map_err(|_| AskError::KeyUnreadable)?); + 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 { @@ -90,6 +158,15 @@ pub fn read_key(path: &Path) -> Result, AskError> { Ok(key) } +/// The instructions file: a regular file of at most [`MAX_SYSTEM_BYTES`] of text. +pub fn read_system_file(path: &Path) -> Result { + 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); @@ -182,7 +259,13 @@ pub fn ask(args: &AskArgs) -> Result { { AskError::Timeout } - ConnectorClientError::Connection(io) => AskError::Connection(io.kind()), + 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 => { diff --git a/crates/claude-connector/src/main.rs b/crates/claude-connector/src/main.rs index bf99246..783686f 100644 --- a/crates/claude-connector/src/main.rs +++ b/crates/claude-connector/src/main.rs @@ -125,8 +125,8 @@ fn run_ask(args: &[&str]) -> Result<(), Failure> { 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"))?; + .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")?, @@ -141,7 +141,7 @@ fn run_ask(args: &[&str]) -> Result<(), Failure> { let mut out = std::io::stdout(); out.write_all(answer.as_bytes()) .and_then(|()| out.flush()) - .map_err(|_| Failure::Malformed("stdout")) + .map_err(|_| AskError::StdoutWrite.into()) } /// The flags of one config verb: each single flag at most once, each list diff --git a/crates/claude-connector/tests/ask.rs b/crates/claude-connector/tests/ask.rs index 3f957ae..63af7f6 100644 --- a/crates/claude-connector/tests/ask.rs +++ b/crates/claude-connector/tests/ask.rs @@ -24,6 +24,8 @@ enum Script { Empty, Failed(&'static str), Sleep(Duration), + ToolCall, + Stop(StopReason), } struct Backend { @@ -53,9 +55,13 @@ impl ProviderBackend for Backend { 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: StopReason::EndTurn, + stop_reason: stop, usage: usage.clone(), }; match &self.script { @@ -69,6 +75,18 @@ impl ProviderBackend for Backend { 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) @@ -77,6 +95,12 @@ impl ProviderBackend for Backend { } } +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, @@ -93,7 +117,7 @@ fn rig(script: Script) -> Rig { )); std::fs::create_dir_all(&dir).unwrap(); let key_file = dir.join("caller.key"); - std::fs::write(&key_file, format!("{KEY}\n")).unwrap(); + 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 = @@ -216,7 +240,7 @@ 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(); + 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}"); @@ -237,7 +261,7 @@ fn an_unusable_key_file_errors_without_echoing_its_contents() { ]; for (name, bytes) in cases { let path = rig.dir.join(format!("{name}.key")); - std::fs::write(&path, &bytes).unwrap(); + write_key(&path, &bytes, 0o600); let mut args = rig.args(); args.key_file = path; let text = message(&ask(&args).unwrap_err()); @@ -254,7 +278,7 @@ fn an_unusable_key_file_errors_without_echoing_its_contents() { directory.key_file = rig.dir.clone(); assert!(matches!( ask(&directory).unwrap_err(), - AskError::KeyUnreadable + AskError::KeyNotRegular )); assert!(rig.seen().is_empty()); } @@ -302,10 +326,19 @@ fn a_dead_endpoint_is_a_connection_error() { let mut args = rig.args(); args.endpoint = listener.local_addr().unwrap(); drop(listener); - assert!(matches!(ask(&args).unwrap_err(), AskError::Connection(_))); + 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([ @@ -316,7 +349,7 @@ fn run_binary(rig: &Rig, stdin: &str, extra: &[&str]) -> std::process::Output { "--system", "Be brief.", ]) - .args(["--connection-key-file", rig.key_file.to_str().unwrap()]) + .args(["--connection-key-file", key_file.to_str().unwrap()]) .args(extra) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -330,6 +363,312 @@ fn run_binary(rig: &Rig, stdin: &str, extra: &[&str]) -> std::process::Output { 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); + impl ProcessPort for CountingPort { + fn spawn( + &self, + _: &[String], + _: &ChildEnv, + _: &std::path::Path, + _: Vec, + _: &str, + ) -> std::io::Result> { + 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> { + 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")); From 2a65c4e7a23252c99781f7e89df20894952cc237 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 08:14:44 +0000 Subject: [PATCH 3/3] ask: the binary reads --system-file through the capped reader main.rs still called read_to_string, leaving read_system_file dead. Binary-level tests cover a FIFO, /dev/zero, 64KiB+1 and exactly 64KiB. Co-Authored-By: Claude Sonnet 5.5 --- crates/claude-connector/src/main.rs | 4 +- crates/claude-connector/tests/ask.rs | 63 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/crates/claude-connector/src/main.rs b/crates/claude-connector/src/main.rs index 783686f..fcb3f72 100644 --- a/crates/claude-connector/src/main.rs +++ b/crates/claude-connector/src/main.rs @@ -102,9 +102,7 @@ fn run_ask(args: &[&str]) -> Result<(), Failure> { 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"))? - } + (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) { diff --git a/crates/claude-connector/tests/ask.rs b/crates/claude-connector/tests/ask.rs index 63af7f6..8f25429 100644 --- a/crates/claude-connector/tests/ask.rs +++ b/crates/claude-connector/tests/ask.rs @@ -704,3 +704,66 @@ fn the_binary_fails_loudly_with_one_stderr_line_and_empty_stdout() { } 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); +}