From fdcfa063c7da6de1b0f3f21a677619cb2633bada Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 30 Oct 2026 00:11:53 +0100 Subject: [PATCH] connector-many-callers: one connector serves every Mind as its own caller; session handles are an HMAC under a connector secret over caller, session, instance, instructions, model and transcript prefix; config add-caller/add-lease-caller; one owner for distinct admissions (squash of eureka-body/connector-many-callers d1f25b5..a96b276; Soul s1-s2) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + README.md | 5 +- crates/claude-connector/Cargo.toml | 1 + crates/claude-connector/src/backend.rs | 83 ++- crates/claude-connector/src/backend/tests.rs | 296 ++++++++++- crates/claude-connector/src/config.rs | 3 + crates/claude-connector/src/daemon.rs | 92 ++-- crates/claude-connector/src/handle.rs | 69 ++- crates/claude-connector/src/lib.rs | 2 +- crates/claude-connector/src/main.rs | 264 ++++++++-- crates/claude-connector/src/quota.rs | 2 +- .../claude-connector/tests/config_document.rs | 474 +++++++++++++++++- crates/model-connector/src/serve.rs | 23 +- crates/model-connector/src/service.rs | 63 ++- 14 files changed, 1228 insertions(+), 150 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 524654e..5291d19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,6 +140,7 @@ version = "0.1.0" dependencies = [ "anyhow", "cultcache-rs", + "hmac", "libc", "model-connector", "rmp-serde", diff --git a/README.md b/README.md index 3a12abd..c2e23c7 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,9 @@ Crate `crates/claude-connector` (library only; the binary that wires it to `serv servers, no settings sources and the system prompt replaced. Tool-less turns only: a request with tools or a final tool result is refused before any spawn. A request's output schema goes to the CLI as `--json-schema` and the result's structured output is the one assistant text. -- Reasoning state is a session handle (`ClaudeCodeHandle`); a handle that cannot be proven to - match the request re-seeds from the caller's transcript, and every resume forks. +- Reasoning state is a session handle (`ClaudeCodeHandle`) authenticated with a connector secret + and bound to the caller it was minted for; a handle that cannot be proven to match the request + and its caller re-seeds from the caller's transcript, and every resume forks. - `ProcessPort` is the one OS seam (`StdProcessPort`, unix, kills the child's whole process group); `SessionStore` and `StderrSink` are the other ports. `ClaudeCodeConfig` is what the binary hands in; the credential is read from a systemd credential and reaches the child on a diff --git a/crates/claude-connector/Cargo.toml b/crates/claude-connector/Cargo.toml index 7f9b042..6592252 100644 --- a/crates/claude-connector/Cargo.toml +++ b/crates/claude-connector/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" [dependencies] anyhow = "1" +hmac = "0.12" cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a4eae9056152335662f1115f97bf35fb00ab6d23", package = "cultcache-rs" } model-connector = { path = "../model-connector", features = ["daemon"] } rmp-serde = "1" diff --git a/crates/claude-connector/src/backend.rs b/crates/claude-connector/src/backend.rs index 1963e1d..4833521 100644 --- a/crates/claude-connector/src/backend.rs +++ b/crates/claude-connector/src/backend.rs @@ -12,7 +12,9 @@ use sha2::{Digest, Sha256}; use zeroize::Zeroizing; use crate::config::{ClaudeCodeConfig, PROVIDER}; -use crate::handle::{ClaudeCodeHandle, SessionStore, plan, prefix_digest, stdin_bytes}; +use crate::handle::{ + Binding, ClaudeCodeHandle, MIN_SECRET_BYTES, SessionStore, plan, prefix_digest, stdin_bytes, +}; use crate::launch::{PromptFile, argv, child_env}; use crate::process::{ProcessPort, RunningChild, StdoutRead}; use crate::quota::QuotaCell; @@ -36,6 +38,8 @@ pub trait TokenSource: Send + Sync { pub struct CredentialTokenSource { directory: Option, name: String, + /// The configuration field `missing` names when the credential is absent. + field: &'static str, } impl CredentialTokenSource { @@ -46,13 +50,21 @@ impl CredentialTokenSource { Self { directory: std::env::var_os("CREDENTIALS_DIRECTORY").map(PathBuf::from), name: name.into(), + field: "token_credential", } } + /// The same source, naming `field` when its credential is absent. + pub fn for_field(mut self, field: &'static str) -> Self { + self.field = field; + self + } + pub fn in_directory(directory: impl Into, name: impl Into) -> Self { Self { directory: Some(directory.into()), name: name.into(), + field: "token_credential", } } } @@ -72,7 +84,7 @@ impl TokenSource for CredentialTokenSource { if self.directory.is_none() { "credentials_directory" } else { - "token_credential" + self.field } } } @@ -91,9 +103,17 @@ pub struct ClaudeCodeBackend { sessions: Box, sink: Box, tokens: Box, + handle_secret: Box, quota: Arc, } +/// The handle secret, or `None` when it is absent or shorter than a key. +fn usable_secret(source: &dyn TokenSource) -> Option> { + source + .read() + .filter(|secret| secret.len() >= MIN_SECRET_BYTES) +} + impl ClaudeCodeBackend { /// The backend over real processes and the CLI's real session store. #[cfg(unix)] @@ -105,7 +125,11 @@ impl ClaudeCodeBackend { let tokens = Box::new(CredentialTokenSource::from_environment( config.token_credential.clone(), )); - Self::with_ports(config, port, sessions, sink, tokens) + let handle_secret = Box::new( + CredentialTokenSource::from_environment(config.handle_secret_credential.clone()) + .for_field("handle_secret_credential"), + ); + Self::with_ports(config, port, sessions, sink, tokens, handle_secret) } pub fn with_ports( @@ -114,6 +138,7 @@ impl ClaudeCodeBackend { sessions: Box, sink: Box, tokens: Box, + handle_secret: Box, ) -> Self { Self { config, @@ -121,6 +146,7 @@ impl ClaudeCodeBackend { sessions, sink, tokens, + handle_secret, quota: Arc::new(QuotaCell::new()), } } @@ -136,8 +162,25 @@ impl ClaudeCodeBackend { binary_matches_pin(&self.config) } - fn run(&self, request: &ProviderRequest) -> (Vec, TransportOutcome) { - let plan = match plan(request, &self.config, self.sessions.as_ref()) { + fn run( + &self, + request: &ProviderRequest, + caller: &str, + ) -> (Vec, TransportOutcome) { + let Some(secret) = usable_secret(self.handle_secret.as_ref()) else { + return failed( + FailureKind::Authentication, + "claude-code: the connector has no handle secret".to_string(), + Some(zero_usage()), + ); + }; + let plan = match plan( + request, + &self.config, + secret.as_bytes(), + caller, + self.sessions.as_ref(), + ) { Ok(plan) => plan, Err(error) => { return failed( @@ -198,7 +241,7 @@ impl ClaudeCodeBackend { } match finished { - Ok(finished) => self.completed(request, finished), + Ok(finished) => self.completed(request, caller, secret.as_bytes(), finished), Err(failure_) => failure(failure_), } } @@ -206,6 +249,8 @@ impl ClaudeCodeBackend { fn completed( &self, request: &ProviderRequest, + caller: &str, + secret: &[u8], finished: Finished, ) -> (Vec, TransportOutcome) { let mut payloads: Vec = finished @@ -226,9 +271,19 @@ impl ClaudeCodeBackend { // and whatever follows it is seeded from the caller's transcript. if request.output_schema_json.is_none() { let handle = ClaudeCodeHandle::Ended { - session_id: finished.session_id, + session_id: finished.session_id.clone(), instance: self.config.instance.clone(), - prefix_sha256: prefix_digest(&request.instructions, &request.model, &prefix), + prefix_sha256: prefix_digest( + secret, + &Binding { + caller, + instructions: &request.instructions, + model: &request.model, + session_id: &finished.session_id, + instance: &self.config.instance, + items: &prefix, + }, + ), }; payloads.push(TransportEventPayload::ReasoningState { state: handle.encode(), @@ -256,7 +311,11 @@ impl ClaudeCodeBackend { impl ProviderBackend for ClaudeCodeBackend { fn ready(&self) -> Result<(), &'static str> { - readiness(&self.config, self.tokens.as_ref()) + readiness( + &self.config, + self.tokens.as_ref(), + self.handle_secret.as_ref(), + ) } fn provider(&self) -> &str { @@ -264,7 +323,7 @@ impl ProviderBackend for ClaudeCodeBackend { } fn execute(&self, call: &ProviderCall) -> TransportResult { - let (events, outcome) = self.run(call.request()); + let (events, outcome) = self.run(call.request(), call.caller_runtime_id()); call.transported(events, outcome) } @@ -327,6 +386,7 @@ fn binary_matches_pin(config: &ClaudeCodeConfig) -> bool { pub(crate) fn readiness( config: &ClaudeCodeConfig, tokens: &dyn TokenSource, + handle_secret: &dyn TokenSource, ) -> Result<(), &'static str> { if !config.work_dir.is_dir() { return Err("work_dir"); @@ -342,6 +402,9 @@ pub(crate) fn readiness( } // Last: outside the unit the credential is the one thing always absent, // and it must not hide the faults the document itself has. + if usable_secret(handle_secret).is_none() { + return Err(handle_secret.missing()); + } if tokens.read().is_none() { return Err(tokens.missing()); } diff --git a/crates/claude-connector/src/backend/tests.rs b/crates/claude-connector/src/backend/tests.rs index 2e3b993..fc2be99 100644 --- a/crates/claude-connector/src/backend/tests.rs +++ b/crates/claude-connector/src/backend/tests.rs @@ -333,6 +333,7 @@ fn config(dir: &Path, instance: &str, limits: ClaudeCodeLimits) -> ClaudeCodeCon config_dir: PathBuf::from("/cfg"), prompt_dir: dir.join("prompts"), token_credential: "claude-token".to_string(), + handle_secret_credential: "claude-handle-secret".to_string(), instance: instance.to_string(), limits, } @@ -351,6 +352,26 @@ fn canary_token() -> Box { Box::new(FixedToken(Some(CANARY_TOKEN))) } +/// A handle secret source with a fixed answer; it names its own field. +struct FixedSecret(Option<&'static str>); + +impl TokenSource for FixedSecret { + fn read(&self) -> Option> { + self.0.map(|secret| Zeroizing::new(secret.to_string())) + } + + fn missing(&self) -> &'static str { + "handle_secret_credential" + } +} + +const SECRET: &str = "handle-secret-0000000000000000000000000000000000000000000000"; +const OTHER_SECRET: &str = "handle-secret-1111111111111111111111111111111111111111111111"; + +fn canary_secret() -> Box { + Box::new(FixedSecret(Some(SECRET))) +} + fn limits() -> ClaudeCodeLimits { ClaudeCodeLimits { deadline: Duration::from_secs(30), @@ -369,6 +390,16 @@ struct Rig { } fn rig_for(instance: &str, scripts: Vec