claude-connector: token from a systemd credential, pin before every spawn, prompts in prompt_dir, door set 401/403/429
Delete EnvLookup and the connector's own environment as a token source; a TokenSource port reads $CREDENTIALS_DIRECTORY/<token_credential> at each use (claude-connector-token-credential). binary_matches_pin is the one pin check, used by ready() and before every spawn. PromptFile::create stages into the configured prompt_dir instead of temp_dir(). The zero-bill door set is exactly 401, 403 and 429 with explicitly empty since-last usage, pinned by a table over statuses and usage shapes. Soul s2's stderr-join probe is committed. The three price types gain serde derives so the price table can live in the config document. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
200933f7cf
commit
9a062f0846
9 changed files with 442 additions and 95 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -138,6 +138,8 @@ dependencies = [
|
|||
name = "claude-connector"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cultcache-rs",
|
||||
"libc",
|
||||
"model-connector",
|
||||
"rmp-serde",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ license.workspace = true
|
|||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a4eae9056152335662f1115f97bf35fb00ab6d23", package = "cultcache-rs" }
|
||||
model-connector = { path = "../model-connector", features = ["daemon"] }
|
||||
rmp-serde = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use model_connector::{
|
||||
|
|
@ -11,12 +12,54 @@ use zeroize::Zeroizing;
|
|||
|
||||
use crate::config::{ClaudeCodeConfig, PROVIDER};
|
||||
use crate::handle::{ClaudeCodeHandle, SessionStore, plan, prefix_digest, stdin_bytes};
|
||||
use crate::launch::{PromptFile, TOKEN_VAR, argv, child_env};
|
||||
use crate::launch::{PromptFile, argv, child_env};
|
||||
use crate::process::{ProcessPort, RunningChild, StdoutRead};
|
||||
use crate::stream::{Failure, Finished, Step, StreamState};
|
||||
|
||||
/// How the backend reads its own environment.
|
||||
type EnvLookup = Box<dyn Fn(&str) -> Option<String> + Send + Sync>;
|
||||
/// Where the child's credential comes from, read afresh at every use. The
|
||||
/// connector's own environment is never a source (ruling
|
||||
/// claude-connector-token-credential).
|
||||
pub trait TokenSource: Send + Sync {
|
||||
/// The token, or `None` when there is none or it is empty.
|
||||
fn read(&self) -> Option<Zeroizing<String>>;
|
||||
}
|
||||
|
||||
/// A token in a file named by a systemd credential: the file
|
||||
/// `$CREDENTIALS_DIRECTORY/<name>`, read on every call so a rotated credential
|
||||
/// is picked up by the next spawn.
|
||||
pub struct CredentialTokenSource {
|
||||
directory: Option<PathBuf>,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl CredentialTokenSource {
|
||||
/// The credential `name` under the directory systemd names in
|
||||
/// `CREDENTIALS_DIRECTORY`; with no such variable the source has no token.
|
||||
/// The variable names a directory, never a token.
|
||||
pub fn from_environment(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
directory: std::env::var_os("CREDENTIALS_DIRECTORY").map(PathBuf::from),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn in_directory(directory: impl Into<PathBuf>, name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
directory: Some(directory.into()),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TokenSource for CredentialTokenSource {
|
||||
fn read(&self) -> Option<Zeroizing<String>> {
|
||||
let directory = self.directory.as_ref()?;
|
||||
let mut token = Zeroizing::new(std::fs::read_to_string(directory.join(&self.name)).ok()?);
|
||||
let trimmed = token.trim_end_matches(['\r', '\n']).len();
|
||||
token.truncate(trimmed);
|
||||
(!token.is_empty()).then_some(token)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the tail of a child's stderr goes. The backend never puts it in a
|
||||
/// result; the connector binary wires this to its log.
|
||||
|
|
@ -31,7 +74,7 @@ pub struct ClaudeCodeBackend {
|
|||
port: Box<dyn ProcessPort>,
|
||||
sessions: Box<dyn SessionStore>,
|
||||
sink: Box<dyn StderrSink>,
|
||||
env_lookup: EnvLookup,
|
||||
tokens: Box<dyn TokenSource>,
|
||||
}
|
||||
|
||||
impl ClaudeCodeBackend {
|
||||
|
|
@ -42,7 +85,10 @@ impl ClaudeCodeBackend {
|
|||
let sessions = Box::new(crate::handle::FsSessionStore::new(
|
||||
config.config_dir.clone(),
|
||||
));
|
||||
Self::with_ports(config, port, sessions, sink)
|
||||
let tokens = Box::new(CredentialTokenSource::from_environment(
|
||||
config.token_credential.clone(),
|
||||
));
|
||||
Self::with_ports(config, port, sessions, sink, tokens)
|
||||
}
|
||||
|
||||
pub fn with_ports(
|
||||
|
|
@ -50,22 +96,19 @@ impl ClaudeCodeBackend {
|
|||
port: Box<dyn ProcessPort>,
|
||||
sessions: Box<dyn SessionStore>,
|
||||
sink: Box<dyn StderrSink>,
|
||||
tokens: Box<dyn TokenSource>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
port,
|
||||
sessions,
|
||||
sink,
|
||||
env_lookup: Box::new(|name| std::env::var(name).ok()),
|
||||
tokens,
|
||||
}
|
||||
}
|
||||
|
||||
fn token(&self) -> Option<Zeroizing<String>> {
|
||||
(self.env_lookup)(TOKEN_VAR)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(Zeroizing::new)
|
||||
}
|
||||
|
||||
/// The one pin check: `ready` and every spawn use it, and nothing caches
|
||||
/// its answer.
|
||||
fn binary_matches_pin(&self) -> bool {
|
||||
let Ok(mut file) = File::open(&self.config.claude_binary) else {
|
||||
return false;
|
||||
|
|
@ -86,14 +129,20 @@ impl ClaudeCodeBackend {
|
|||
);
|
||||
}
|
||||
};
|
||||
let Some(token) = self.token() else {
|
||||
let Some(token) = self.tokens.read() else {
|
||||
return failed(
|
||||
FailureKind::Authentication,
|
||||
"claude-code: the connector has no credential to give the child".to_string(),
|
||||
Some(zero_usage()),
|
||||
);
|
||||
};
|
||||
let Ok(prompt) = PromptFile::create(&request.instructions) else {
|
||||
if !self.binary_matches_pin() {
|
||||
return failure(Failure::unbilled(
|
||||
FailureKind::Provider,
|
||||
"claude-code: the claude binary does not match its pin",
|
||||
));
|
||||
}
|
||||
let Ok(prompt) = PromptFile::create(&self.config.prompt_dir, &request.instructions) else {
|
||||
return failure(Failure::unbilled(
|
||||
FailureKind::Provider,
|
||||
"claude-code: the system prompt could not be staged",
|
||||
|
|
@ -178,7 +227,8 @@ impl ProviderBackend for ClaudeCodeBackend {
|
|||
fn ready(&self) -> bool {
|
||||
self.config.work_dir.is_dir()
|
||||
&& self.config.config_dir.is_dir()
|
||||
&& self.token().is_some()
|
||||
&& self.config.prompt_dir.is_dir()
|
||||
&& self.tokens.read().is_some()
|
||||
&& self.binary_matches_pin()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use model_connector::{
|
|||
use super::*;
|
||||
use crate::config::ClaudeCodeLimits;
|
||||
use crate::handle::{FsSessionStore, SessionStore};
|
||||
use crate::launch::ChildEnv;
|
||||
use crate::launch::{ChildEnv, TOKEN_VAR};
|
||||
|
||||
const CANARY_TOKEN: &str = "canary-oauth-token-value";
|
||||
const MODEL: &str = "claude-haiku-5-5";
|
||||
|
|
@ -243,17 +243,47 @@ impl StderrSink for Sink {
|
|||
}
|
||||
}
|
||||
|
||||
fn config(instance: &str, limits: ClaudeCodeLimits) -> ClaudeCodeConfig {
|
||||
/// The bytes of the stand-in `claude` the scripted rigs pin.
|
||||
const PINNED: &[u8] = b"the pinned claude bytes";
|
||||
|
||||
fn pin_of(bytes: &[u8]) -> [u8; 32] {
|
||||
Sha256::digest(bytes).into()
|
||||
}
|
||||
|
||||
/// A directory holding a pinned `claude` file and an empty `prompts` directory.
|
||||
fn sandbox() -> TempDir {
|
||||
let dir = TempDir::new();
|
||||
std::fs::write(dir.path().join("claude"), PINNED).unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("prompts")).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn config(dir: &Path, instance: &str, limits: ClaudeCodeLimits) -> ClaudeCodeConfig {
|
||||
ClaudeCodeConfig {
|
||||
claude_binary: PathBuf::from("/opt/claude"),
|
||||
claude_binary_sha256: [9; 32],
|
||||
claude_binary: dir.join("claude"),
|
||||
claude_binary_sha256: pin_of(PINNED),
|
||||
work_dir: PathBuf::from("/work"),
|
||||
config_dir: PathBuf::from("/cfg"),
|
||||
prompt_dir: dir.join("prompts"),
|
||||
token_credential: "claude-token".to_string(),
|
||||
instance: instance.to_string(),
|
||||
limits,
|
||||
}
|
||||
}
|
||||
|
||||
/// A token source with a fixed answer.
|
||||
struct FixedToken(Option<&'static str>);
|
||||
|
||||
impl TokenSource for FixedToken {
|
||||
fn read(&self) -> Option<Zeroizing<String>> {
|
||||
self.0.map(|token| Zeroizing::new(token.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn canary_token() -> Box<dyn TokenSource> {
|
||||
Box::new(FixedToken(Some(CANARY_TOKEN)))
|
||||
}
|
||||
|
||||
fn limits() -> ClaudeCodeLimits {
|
||||
ClaudeCodeLimits {
|
||||
deadline: Duration::from_secs(30),
|
||||
|
|
@ -263,12 +293,8 @@ fn limits() -> ClaudeCodeLimits {
|
|||
}
|
||||
}
|
||||
|
||||
fn with_canary_token(mut backend: ClaudeCodeBackend) -> ClaudeCodeBackend {
|
||||
backend.env_lookup = Box::new(|name| (name == TOKEN_VAR).then(|| CANARY_TOKEN.to_string()));
|
||||
backend
|
||||
}
|
||||
|
||||
struct Rig {
|
||||
dir: TempDir,
|
||||
backend: ClaudeCodeBackend,
|
||||
spawns: Arc<AtomicUsize>,
|
||||
seen: Arc<Mutex<Seen>>,
|
||||
|
|
@ -285,13 +311,16 @@ fn rig_for(instance: &str, scripts: Vec<Script>, sessions: &[&str], stderr: &[u8
|
|||
seen: seen.clone(),
|
||||
stderr: stderr.to_vec(),
|
||||
};
|
||||
let backend = with_canary_token(ClaudeCodeBackend::with_ports(
|
||||
config(instance, limits()),
|
||||
let dir = sandbox();
|
||||
let backend = ClaudeCodeBackend::with_ports(
|
||||
config(dir.path(), instance, limits()),
|
||||
Box::new(port),
|
||||
Box::new(Sessions(sessions.iter().map(|s| s.to_string()).collect())),
|
||||
Box::new(sink.clone()),
|
||||
));
|
||||
canary_token(),
|
||||
);
|
||||
Rig {
|
||||
dir,
|
||||
backend,
|
||||
spawns,
|
||||
seen,
|
||||
|
|
@ -443,7 +472,7 @@ fn argv_is_the_fixed_hygiene_set() {
|
|||
.expect("the prompt is passed as a file")
|
||||
.to_string();
|
||||
let expected: Vec<String> = [
|
||||
"/opt/claude",
|
||||
rig.dir.path().join("claude").to_str().unwrap(),
|
||||
"-p",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
|
|
@ -569,7 +598,7 @@ fn the_token_never_reaches_a_message_an_event_or_a_debug_form() {
|
|||
);
|
||||
|
||||
let env = child_env(
|
||||
&config(INSTANCE, limits()),
|
||||
&config(rig.dir.path(), INSTANCE, limits()),
|
||||
&request(vec![user("x")]),
|
||||
Zeroizing::new(CANARY_TOKEN.to_string()),
|
||||
);
|
||||
|
|
@ -580,59 +609,144 @@ fn the_token_never_reaches_a_message_an_event_or_a_debug_form() {
|
|||
|
||||
// ---- ready ----
|
||||
|
||||
/// A backend over a scripted port that never has a script: `ready` runs nothing.
|
||||
fn ready_backend(config: ClaudeCodeConfig, tokens: Box<dyn TokenSource>) -> ClaudeCodeBackend {
|
||||
ClaudeCodeBackend::with_ports(
|
||||
config,
|
||||
Box::new(ScriptedPort {
|
||||
scripts: Mutex::new(VecDeque::new()),
|
||||
spawns: Arc::new(AtomicUsize::new(0)),
|
||||
seen: Arc::new(Mutex::new(Seen::default())),
|
||||
stderr: Vec::new(),
|
||||
}),
|
||||
Box::new(Sessions(vec![])),
|
||||
Box::new(Sink::default()),
|
||||
tokens,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_checks_the_pinned_binary_digest() {
|
||||
let dir = TempDir::new();
|
||||
let dir = sandbox();
|
||||
let (work, cfg) = (dir.path().join("work"), dir.path().join("cfg"));
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
std::fs::create_dir_all(&cfg).unwrap();
|
||||
let binary = dir.path().join("claude");
|
||||
std::fs::write(&binary, b"the pinned bytes").unwrap();
|
||||
let pin: [u8; 32] = Sha256::digest(b"the pinned bytes").into();
|
||||
|
||||
let ready = |sha: [u8; 32], binary: &Path, work: &Path, token: bool| {
|
||||
let mut config = config(INSTANCE, limits());
|
||||
let ready = |sha: [u8; 32], binary: &Path, work: &Path, prompts: &Path, token: bool| {
|
||||
let mut config = config(dir.path(), INSTANCE, limits());
|
||||
config.claude_binary = binary.to_path_buf();
|
||||
config.claude_binary_sha256 = sha;
|
||||
config.work_dir = work.to_path_buf();
|
||||
config.config_dir = cfg.clone();
|
||||
let rig = rig(vec![]);
|
||||
let mut backend = ClaudeCodeBackend::with_ports(
|
||||
config,
|
||||
Box::new(ScriptedPort {
|
||||
scripts: Mutex::new(VecDeque::new()),
|
||||
spawns: rig.spawns.clone(),
|
||||
seen: rig.seen.clone(),
|
||||
stderr: Vec::new(),
|
||||
}),
|
||||
Box::new(Sessions(vec![])),
|
||||
Box::new(Sink::default()),
|
||||
);
|
||||
backend.env_lookup =
|
||||
Box::new(move |name| (token && name == TOKEN_VAR).then(|| "t".to_string()));
|
||||
let answer = backend.ready();
|
||||
assert_eq!(rig.spawns(), 0, "ready runs nothing");
|
||||
answer
|
||||
config.prompt_dir = prompts.to_path_buf();
|
||||
let tokens = Box::new(FixedToken(token.then_some("t")));
|
||||
ready_backend(config, tokens).ready()
|
||||
};
|
||||
let prompts = dir.path().join("prompts");
|
||||
let pin = pin_of(PINNED);
|
||||
|
||||
assert!(ready(pin, &binary, &work, true));
|
||||
assert!(!ready([0; 32], &binary, &work, true), "other pin");
|
||||
assert!(ready(pin, &binary, &work, &prompts, true));
|
||||
assert!(!ready([0; 32], &binary, &work, &prompts, true), "other pin");
|
||||
assert!(
|
||||
!ready(pin, &dir.path().join("absent"), &work, true),
|
||||
!ready(pin, &dir.path().join("absent"), &work, &prompts, true),
|
||||
"no binary"
|
||||
);
|
||||
assert!(
|
||||
!ready(pin, &binary, &dir.path().join("absent"), true),
|
||||
!ready(pin, &binary, &dir.path().join("absent"), &prompts, true),
|
||||
"no work dir"
|
||||
);
|
||||
assert!(!ready(pin, &binary, &work, false), "no token");
|
||||
assert!(
|
||||
!ready(pin, &binary, &work, &dir.path().join("absent"), true),
|
||||
"no prompt dir"
|
||||
);
|
||||
assert!(!ready(pin, &binary, &work, &prompts, false), "no token");
|
||||
std::fs::write(&binary, b"other bytes").unwrap();
|
||||
assert!(
|
||||
!ready(pin, &binary, &work, true),
|
||||
!ready(pin, &binary, &work, &prompts, true),
|
||||
"bytes differ from the pin"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_comes_from_the_credential_file_not_the_environment() {
|
||||
static CANARY_PARENT: std::sync::Once = std::sync::Once::new();
|
||||
CANARY_PARENT.call_once(|| {
|
||||
// SAFETY: set once to a value no code path reads; the test proves none does.
|
||||
unsafe { std::env::set_var(TOKEN_VAR, "canary-environment-token") };
|
||||
});
|
||||
let dir = sandbox();
|
||||
let credentials = dir.path().join("credentials");
|
||||
for sub in ["credentials", "work", "cfg"] {
|
||||
std::fs::create_dir_all(dir.path().join(sub)).unwrap();
|
||||
}
|
||||
let seen = Arc::new(Mutex::new(Seen::default()));
|
||||
let mut config = config(dir.path(), INSTANCE, limits());
|
||||
config.work_dir = dir.path().join("work");
|
||||
config.config_dir = dir.path().join("cfg");
|
||||
let backend = ClaudeCodeBackend::with_ports(
|
||||
config,
|
||||
Box::new(ScriptedPort {
|
||||
scripts: Mutex::new(vec![ok_script(SESSION, "x")].into()),
|
||||
spawns: Arc::new(AtomicUsize::new(0)),
|
||||
seen: seen.clone(),
|
||||
stderr: Vec::new(),
|
||||
}),
|
||||
Box::new(Sessions(vec![])),
|
||||
Box::new(Sink::default()),
|
||||
Box::new(CredentialTokenSource::in_directory(
|
||||
&credentials,
|
||||
"claude-token",
|
||||
)),
|
||||
);
|
||||
let mut door = Door::new();
|
||||
|
||||
// The variable is set and the file is absent: nothing to give the child.
|
||||
assert!(!backend.ready());
|
||||
let (claim, _) = door.begin(request(vec![user("hi")]));
|
||||
let (kind, message, usage) = failure_of(&backend.execute(claim.call()));
|
||||
assert_eq!(kind, FailureKind::Authentication);
|
||||
assert_eq!(usage, Some(usage_zero()));
|
||||
assert!(!message.contains("canary-environment-token"));
|
||||
assert!(seen.lock().unwrap().argv.is_empty(), "no child was spawned");
|
||||
|
||||
// The file is read afresh at the call, its trailing newline trimmed, and
|
||||
// the child's environment carries the file's value.
|
||||
std::fs::write(
|
||||
credentials.join("claude-token"),
|
||||
format!("{CANARY_TOKEN}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(backend.ready());
|
||||
let mut door = Door::new();
|
||||
let (claim, _) = door.begin(request(vec![user("hi")]));
|
||||
let result = backend.execute(claim.call());
|
||||
assert!(matches!(
|
||||
outcome(&result),
|
||||
TransportOutcome::Completed { .. }
|
||||
));
|
||||
let env = seen.lock().unwrap().env.clone();
|
||||
assert!(env.contains(&(TOKEN_VAR.to_string(), CANARY_TOKEN.to_string())));
|
||||
|
||||
// An empty credential is no token.
|
||||
std::fs::write(credentials.join("claude-token"), "\n").unwrap();
|
||||
assert!(!backend.ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_credential_is_no_token() {
|
||||
// A credential that is a directory, or a directory that is absent.
|
||||
let dir = sandbox();
|
||||
std::fs::create_dir_all(dir.path().join("claude-token")).unwrap();
|
||||
let source = CredentialTokenSource::in_directory(dir.path(), "claude-token");
|
||||
assert!(source.read().is_none());
|
||||
assert!(
|
||||
CredentialTokenSource::in_directory(dir.path().join("absent"), "x")
|
||||
.read()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
// ---- planning ----
|
||||
|
||||
#[test]
|
||||
|
|
@ -1224,7 +1338,7 @@ fn one_spawn_per_execute() {
|
|||
#[test]
|
||||
fn a_missing_token_fails_before_spawn() {
|
||||
let mut rig = rig(vec![ok_script(SESSION, "x")]);
|
||||
rig.backend.env_lookup = Box::new(|_| None);
|
||||
rig.backend.tokens = Box::new(FixedToken(None));
|
||||
let (kind, _, usage) = failure_of(&rig.run(request(vec![user("hi")])));
|
||||
assert_eq!(kind, FailureKind::Authentication);
|
||||
assert_eq!(usage, Some(usage_zero()));
|
||||
|
|
@ -1245,18 +1359,23 @@ fn a_port_that_cannot_spawn_is_a_provider_failure() {
|
|||
Err(io::Error::from(io::ErrorKind::NotFound))
|
||||
}
|
||||
}
|
||||
let backend = with_canary_token(ClaudeCodeBackend::with_ports(
|
||||
config(INSTANCE, limits()),
|
||||
let dir = sandbox();
|
||||
let backend = ClaudeCodeBackend::with_ports(
|
||||
config(dir.path(), INSTANCE, limits()),
|
||||
Box::new(Refusing),
|
||||
Box::new(Sessions(vec![])),
|
||||
Box::new(Sink::default()),
|
||||
));
|
||||
canary_token(),
|
||||
);
|
||||
let mut door = Door::new();
|
||||
let (claim, _) = door.begin(request(vec![user("hi")]));
|
||||
let (kind, message, usage) = failure_of(&backend.execute(claim.call()));
|
||||
assert_eq!(kind, FailureKind::Provider);
|
||||
assert_eq!(usage, None);
|
||||
assert!(!message.contains("/opt/claude"), "no path in a message");
|
||||
assert!(
|
||||
!message.contains(dir.path().to_str().unwrap()),
|
||||
"no path in a message"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1384,28 +1503,60 @@ fn usage_the_result_does_not_report_is_billed_the_reservation() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_at_the_door_is_billed_nothing() {
|
||||
// Ruling spend-bills-on-identity-not-validity: a bound result is charged
|
||||
// its true cost, and the provider bills nothing for a 401 or 403.
|
||||
// The shape is a real claude 2.1.312 capture with a bogus token.
|
||||
fn door_set_is_exactly_401_403_429() {
|
||||
// Ruling claude-code-plan-quota-fuse-2 with spend-bills-on-identity-not-
|
||||
// validity: a refusal at the door is charged nothing, for exactly the
|
||||
// statuses the provider cannot have billed (401, 403, 429) and only when
|
||||
// the result says its usage is explicitly empty. Every other shape is
|
||||
// billed the reservation. The 401 shape is a real claude 2.1.312 capture
|
||||
// with a bogus token.
|
||||
let retry = r#"{"type":"system","subtype":"api_retry","error":"authentication_failed"}"#;
|
||||
let synthetic = r#"{"type":"assistant","api_error_status":401,"error":"authentication_failed","message":{"id":"e48001b7-cf4f-49b1-9810-db8fd7f73c73","model":"<synthetic>","stop_reason":"stop_sequence","content":[{"type":"text","text":"Failed to authenticate. API Error: 401 OAuth access token is invalid."}]}}"#;
|
||||
for status in [401, 403] {
|
||||
let result_line = format!(
|
||||
r#"{{"type":"result","subtype":"success","is_error":true,"api_error_status":{status},"stop_reason":"stop_sequence","result":"Failed to authenticate.","modelUsage":{{}},"model_usage_since_last_result":{{}},"terminal_reason":"api_error"}}"#
|
||||
);
|
||||
let result = rig(vec![Script::lines(vec![
|
||||
init_line(MODEL, SESSION),
|
||||
retry.to_string(),
|
||||
retry.to_string(),
|
||||
synthetic.to_string(),
|
||||
result_line,
|
||||
])])
|
||||
.run(request(vec![user("hi")]));
|
||||
let (kind, _, usage) = failure_of(&result);
|
||||
assert_eq!(kind, FailureKind::Authentication, "{status}");
|
||||
assert_eq!(usage, Some(usage_zero()), "{status}");
|
||||
assert_eq!(parts(&result).1.cost_micro_usd, 0, "{status}");
|
||||
let reservation = reservation();
|
||||
let statuses: [Option<u16>; 9] = [
|
||||
Some(400),
|
||||
Some(401),
|
||||
Some(402),
|
||||
Some(403),
|
||||
Some(404),
|
||||
Some(429),
|
||||
Some(500),
|
||||
Some(529),
|
||||
None,
|
||||
];
|
||||
let shapes = [
|
||||
(Some("{}"), "empty"),
|
||||
(None, "absent"),
|
||||
(Some("null"), "null"),
|
||||
];
|
||||
for status in statuses {
|
||||
for (since_last, shape) in shapes {
|
||||
let status_field = status
|
||||
.map(|status| format!(r#""api_error_status":{status},"#))
|
||||
.unwrap_or_default();
|
||||
let usage_field = since_last
|
||||
.map(|usage| format!(r#","model_usage_since_last_result":{usage}"#))
|
||||
.unwrap_or_default();
|
||||
let result_line = format!(
|
||||
r#"{{"type":"result","subtype":"success","is_error":true,{status_field}"stop_reason":"stop_sequence","result":"Failed.","modelUsage":{CUMULATIVE}{usage_field},"terminal_reason":"api_error"}}"#
|
||||
);
|
||||
let result = rig(vec![Script::lines(vec![
|
||||
init_line(MODEL, SESSION),
|
||||
retry.to_string(),
|
||||
synthetic.to_string(),
|
||||
result_line,
|
||||
])])
|
||||
.run(request(vec![user("hi")]));
|
||||
let (_, _, usage) = failure_of(&result);
|
||||
let case = format!("{status:?} {shape}");
|
||||
if matches!(status, Some(401 | 403 | 429)) && shape == "empty" {
|
||||
assert_eq!(usage, Some(usage_zero()), "{case}");
|
||||
assert_eq!(parts(&result).1.cost_micro_usd, 0, "{case}");
|
||||
} else {
|
||||
assert_eq!(usage, None, "{case}");
|
||||
assert_eq!(parts(&result).1.cost_micro_usd, reservation, "{case}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1458,6 +1609,49 @@ fn the_prompt_file_is_removed_when_the_call_fails() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_file_lives_in_prompt_dir_exclusively() {
|
||||
let rig = rig(vec![ok_script(SESSION, "x")]);
|
||||
rig.run(request(vec![user("hi")]));
|
||||
let (path, _) = rig.seen().prompt_file.expect("a prompt file");
|
||||
assert_eq!(
|
||||
path.parent(),
|
||||
Some(rig.dir.path().join("prompts").as_path())
|
||||
);
|
||||
|
||||
// A prompt directory that cannot take the file fails staging: the failure
|
||||
// is the unbilled constructor's, and no child runs.
|
||||
let rig = rig_for(INSTANCE, vec![ok_script(SESSION, "x")], &[SESSION], b"");
|
||||
std::fs::remove_dir_all(rig.dir.path().join("prompts")).unwrap();
|
||||
let (kind, _, usage) = failure_of(&rig.run(request(vec![user("hi")])));
|
||||
assert_eq!((kind, usage), (FailureKind::Provider, None));
|
||||
assert_eq!(rig.spawns(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_is_checked_before_every_spawn() {
|
||||
let rig = rig(vec![ok_script(SESSION, "x"), ok_script(SESSION, "y")]);
|
||||
let binary = rig.dir.path().join("claude");
|
||||
assert!(rig.backend.binary_matches_pin());
|
||||
rig.run(request(vec![user("hi")]));
|
||||
assert_eq!(rig.spawns(), 1);
|
||||
|
||||
// The binary is swapped after readiness: the next call never spawns.
|
||||
std::fs::write(&binary, b"swapped bytes").unwrap();
|
||||
let (kind, message, usage) = failure_of(&rig.run(request(vec![user("hi")])));
|
||||
assert_eq!((kind, usage), (FailureKind::Provider, None));
|
||||
assert!(!message.contains(rig.dir.path().to_str().unwrap()));
|
||||
assert_eq!(rig.spawns(), 1, "no child for a binary off its pin");
|
||||
assert!(!rig.backend.binary_matches_pin());
|
||||
let staged = std::fs::read_dir(rig.dir.path().join("prompts")).unwrap();
|
||||
assert_eq!(staged.count(), 0, "no prompt was staged for it");
|
||||
|
||||
// Restoring the pinned bytes is enough: nothing remembers the verdict.
|
||||
std::fs::write(&binary, PINNED).unwrap();
|
||||
rig.run(request(vec![user("hi")]));
|
||||
assert_eq!(rig.spawns(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_provider_tag_is_claude_code() {
|
||||
assert_eq!(rig(vec![]).backend.provider(), "claude-code");
|
||||
|
|
@ -1540,12 +1734,21 @@ mod real {
|
|||
let binary = dir.path().join("claude");
|
||||
std::fs::write(&binary, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||||
std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut config = config(INSTANCE, limits);
|
||||
let prompts = dir.path().join("prompts");
|
||||
std::fs::create_dir_all(&prompts).unwrap();
|
||||
let mut config = config(dir.path(), INSTANCE, limits);
|
||||
config.claude_binary_sha256 = pin_of(&std::fs::read(&binary).unwrap());
|
||||
config.claude_binary = binary;
|
||||
config.work_dir = work;
|
||||
config.config_dir = cfg;
|
||||
let sink = Sink::default();
|
||||
let backend = with_canary_token(ClaudeCodeBackend::new(config, Box::new(sink.clone())));
|
||||
let backend = ClaudeCodeBackend::with_ports(
|
||||
config.clone(),
|
||||
Box::new(crate::process::StdProcessPort::new(config.limits)),
|
||||
Box::new(FsSessionStore::new(config.config_dir.clone())),
|
||||
Box::new(sink.clone()),
|
||||
canary_token(),
|
||||
);
|
||||
Fake {
|
||||
_guard: guard,
|
||||
dir,
|
||||
|
|
@ -1655,7 +1858,7 @@ mod real {
|
|||
leader.display()
|
||||
);
|
||||
let fake = fake(&body, limits());
|
||||
let config = config(INSTANCE, limits());
|
||||
let config = config(fake.dir.path(), INSTANCE, limits());
|
||||
let env = child_env(
|
||||
&config,
|
||||
&request(vec![user("x")]),
|
||||
|
|
@ -1801,6 +2004,11 @@ mod real {
|
|||
"600"
|
||||
);
|
||||
let path = std::fs::read_to_string(probe.path().join("path")).unwrap();
|
||||
assert_eq!(
|
||||
Path::new(path.trim()).parent(),
|
||||
Some(fake.dir.path().join("prompts").as_path()),
|
||||
"staged in the configured prompt directory"
|
||||
);
|
||||
assert!(!Path::new(path.trim()).exists(), "removed after the call");
|
||||
}
|
||||
|
||||
|
|
@ -1837,6 +2045,25 @@ mod real {
|
|||
assert!(sunk.trim_end().ends_with("END-OF-STDERR"), "{}", sunk.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stderr_join_survives_a_megabyte_after_result() {
|
||||
// Soul s2's probe: the result line first, then a megabyte of stderr
|
||||
// before the child exits. The call must wait for the stderr reader to
|
||||
// finish, or the tail handed to the sink is cut short.
|
||||
let body = format!(
|
||||
"{}\nhead -c 1000000 /dev/zero | tr '\\0' x >&2\necho END-OF-STDERR >&2",
|
||||
printf_lines(&good_lines())
|
||||
);
|
||||
let fake = fake(&body, limits());
|
||||
let result = fake.run(request(vec![user("hi")]));
|
||||
assert!(matches!(
|
||||
outcome(&result),
|
||||
TransportOutcome::Completed { .. }
|
||||
));
|
||||
let sunk = String::from_utf8(fake.sink.0.lock().unwrap().clone()).unwrap();
|
||||
assert!(sunk.trim_end().ends_with("END-OF-STDERR"), "{}", sunk.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_result_is_enough_even_if_the_child_keeps_stdout_open() {
|
||||
let body = format!("{}\nexec sleep 300", printf_lines(&good_lines()));
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ pub struct ClaudeCodeConfig {
|
|||
/// Passed to the child as `CLAUDE_CONFIG_DIR` and `HOME`; it holds the
|
||||
/// CLI's session store.
|
||||
pub config_dir: PathBuf,
|
||||
/// A directory the connector owns (mode 0700), where each call's system
|
||||
/// prompt is staged for the child.
|
||||
pub prompt_dir: PathBuf,
|
||||
/// The name of the systemd credential that holds the child's token, under
|
||||
/// `$CREDENTIALS_DIRECTORY`.
|
||||
pub token_credential: String,
|
||||
/// The connector instance id written into every handle.
|
||||
pub instance: String,
|
||||
pub limits: ClaudeCodeLimits,
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ use zeroize::Zeroizing;
|
|||
use crate::config::ClaudeCodeConfig;
|
||||
use crate::handle::Plan;
|
||||
|
||||
/// The one credential variable, copied by name from the connector's own
|
||||
/// environment into the child and nowhere else.
|
||||
/// The one credential variable: the child's environment is the only
|
||||
/// environment the token ever enters.
|
||||
pub(crate) const TOKEN_VAR: &str = "CLAUDE_CODE_OAUTH_TOKEN";
|
||||
const CHILD_PATH: &str = "/usr/local/bin:/usr/bin:/bin";
|
||||
|
||||
|
|
@ -62,13 +62,18 @@ pub(crate) struct PromptFile(PathBuf);
|
|||
static PROMPT_FILES: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
impl PromptFile {
|
||||
pub(crate) fn create(instructions: &str) -> io::Result<Self> {
|
||||
pub(crate) fn create(dir: &Path, instructions: &str) -> io::Result<Self> {
|
||||
let name = format!(
|
||||
"claude-connector-prompt-{}-{}",
|
||||
std::process::id(),
|
||||
PROMPT_FILES.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
let path = std::env::temp_dir().join(name);
|
||||
Self::create_at(dir.join(name), instructions)
|
||||
}
|
||||
|
||||
/// Creates `path` and nothing that already exists there: a file, a link
|
||||
/// (dangling or not) or a directory at the name fails, and is left alone.
|
||||
fn create_at(path: PathBuf, instructions: &str) -> io::Result<Self> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
|
|
@ -146,3 +151,57 @@ pub(crate) fn argv(
|
|||
}
|
||||
argv
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"claude-connector-launch-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prompt_is_created_private_and_removed_on_drop() {
|
||||
let dir = scratch("private");
|
||||
let prompt = PromptFile::create(&dir, "the prompt").unwrap();
|
||||
let path = prompt.path().to_path_buf();
|
||||
assert_eq!(path.parent(), Some(dir.as_path()));
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "the prompt");
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
drop(prompt);
|
||||
assert!(!path.exists());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_already_taken_is_never_written_or_removed() {
|
||||
let dir = scratch("taken");
|
||||
let victim = dir.join("victim");
|
||||
std::fs::write(&victim, "keep").unwrap();
|
||||
|
||||
let file = dir.join("file");
|
||||
std::fs::write(&file, "mine").unwrap();
|
||||
let link = dir.join("link");
|
||||
symlink(&victim, &link).unwrap();
|
||||
let dangling = dir.join("dangling");
|
||||
symlink(dir.join("nowhere"), &dangling).unwrap();
|
||||
|
||||
for taken in [&file, &link, &dangling] {
|
||||
assert!(PromptFile::create_at(taken.clone(), "the prompt").is_err());
|
||||
}
|
||||
assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep");
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "mine");
|
||||
assert!(!dir.join("nowhere").exists());
|
||||
assert!(link.exists() && dangling.symlink_metadata().is_ok());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ mod launch;
|
|||
mod process;
|
||||
mod stream;
|
||||
|
||||
pub use backend::{ClaudeCodeBackend, StderrSink};
|
||||
pub use backend::{ClaudeCodeBackend, CredentialTokenSource, StderrSink, TokenSource};
|
||||
pub use config::{ClaudeCodeConfig, ClaudeCodeLimits};
|
||||
pub use handle::{FsSessionStore, SessionStore};
|
||||
pub use launch::ChildEnv;
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ impl<'a> StreamState<'a> {
|
|||
let door_refusal = !success
|
||||
&& matches!(
|
||||
line.api_error_status.as_ref().and_then(Value::as_u64),
|
||||
Some(401 | 403)
|
||||
Some(401 | 403 | 429)
|
||||
);
|
||||
let usage = read_usage(line.model_usage_since_last_result, door_refusal);
|
||||
if !success {
|
||||
|
|
@ -258,7 +258,7 @@ fn fail(kind: FailureKind, message: &'static str) -> Step {
|
|||
/// What this turn consumed, summed over every model it used (harness side
|
||||
/// calls included), or `None` when the result does not say: the field absent,
|
||||
/// of another shape, or empty. An empty report is believed only for a refusal
|
||||
/// at the door (401 or 403), which the provider cannot have billed; elsewhere
|
||||
/// at the door (401, 403 or 429), which the provider cannot have billed; elsewhere
|
||||
/// the CLI also zeroes usage on crashes after real work. `None` is billed the
|
||||
/// reservation by the kit.
|
||||
fn read_usage(reported: Option<Value>, door_refusal: bool) -> Option<Usage> {
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ impl CallerAdmission {
|
|||
}
|
||||
|
||||
/// What one million tokens of each class cost, in micro-USD.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Rates {
|
||||
pub input: u64,
|
||||
pub cache_write_short: u64,
|
||||
|
|
@ -129,7 +129,7 @@ pub struct Rates {
|
|||
}
|
||||
|
||||
/// The rates a prompt strictly above `above_prompt_tokens` is priced at.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LongContext {
|
||||
pub above_prompt_tokens: u64,
|
||||
pub rates: Rates,
|
||||
|
|
@ -138,7 +138,7 @@ pub struct LongContext {
|
|||
/// One model's price. Explicit zero rates are admitted (a local model is
|
||||
/// free); a missing price is not. `overhead_tokens` is added to the byte
|
||||
/// bound of a request when its worst case is reserved.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ModelPrice {
|
||||
pub rates: Rates,
|
||||
pub long_context: Option<LongContext>,
|
||||
|
|
|
|||
Loading…
Reference in a new issue