diff --git a/crates/claude-connector/tests/fixtures/lease_v2/README.md b/crates/claude-connector/tests/fixtures/lease_v2/README.md new file mode 100644 index 0000000..3cc9766 --- /dev/null +++ b/crates/claude-connector/tests/fixtures/lease_v2/README.md @@ -0,0 +1,16 @@ +# lease_v2 fixtures + +The exact bytes of the claude lease protocol v2: each file is one whole frame (a u32 +big-endian length, then the named-MessagePack map `{kind, value}`). + +- `request.bin`: `claude_lease_request`. +- `refusal_unknown_caller.bin`, `refusal_expired.bin`, `refusal_busy.bin`, + `refusal_provider_rejected.bin`: `claude_lease_refusal`; `kind` is the variant name as a + PascalCase string. +- `grant.bin`: `claude_lease_grant` with `lease_id` and `plan_quota` carrying `five_hour` and + `seven_day` windows (utilization_permille, resets_at_unix_ms, status, observed_at_unix_ms). + On the wire the grant travels with one SCM_RIGHTS fd; the file holds the frame only. + +Huginn's lease client vendors these files from this repository and tests against the same +bytes. Generator: `tests/lease_wire_golden.rs`; run it with `LEASE_V2_REGENERATE=1`, then +review the diff: a changed byte is a wire change. diff --git a/crates/claude-connector/tests/lease.rs b/crates/claude-connector/tests/lease.rs index 1128a06..2938070 100644 --- a/crates/claude-connector/tests/lease.rs +++ b/crates/claude-connector/tests/lease.rs @@ -543,3 +543,99 @@ fn connections_past_the_limit_are_dropped_and_finished_ones_free_their_place() { drop(first); assert!(granted_ok(&ask_until_granted(&harness, 2))); } + +#[test] +fn a_lease_survives_many_polls_while_its_holder_and_invocation_stay() { + let harness = start(setup()); + let mut first = ask(&harness, 60_000); + assert!(granted_ok(&first)); + // Three polls later the lease still holds: a second invocation is Busy. + std::thread::sleep(Duration::from_millis(1_700)); + harness.enter(2); + let second = ask(&harness, 60_000); + assert_eq!(refusal_of(&second), Some(LeaseRefusalKind::Busy)); + // The holder talking does not end it either. + first._stream.write_all(b"x").unwrap(); + std::thread::sleep(Duration::from_millis(1_200)); + harness.enter(3); + assert_eq!( + refusal_of(&ask(&harness, 60_000)), + Some(LeaseRefusalKind::Busy) + ); +} + +#[test] +fn a_lease_ends_when_the_unit_runs_a_different_invocation() { + let harness = start(setup()); + let first = ask(&harness, 60_000); + assert!(granted_ok(&first)); + // The same unit, a new invocation: the one that leased is over. + harness.enter(2); + std::thread::sleep(Duration::from_millis(1_200)); + assert!(granted_ok(&ask(&harness, 60_000))); + drop(first); +} + +#[test] +fn a_lease_asks_the_system_about_its_holders_process_for_its_own_unit() { + let harness = start(setup()); + let first = ask(&harness, 60_000); + assert!(granted_ok(&first)); + std::thread::sleep(Duration::from_millis(1_200)); + let asked = harness.units.as_ref().unwrap().asked_about.lock().unwrap().clone(); + assert!(asked.len() >= 3, "the hold polled: {asked:?}"); + assert!(asked.iter().all(|(_, unit)| unit == UNIT)); +} + +#[test] +fn an_account_resolves_to_its_uid_and_a_missing_one_to_none() { + assert_eq!(claude_connector::user_uid("root"), Some(0)); + assert_eq!(claude_connector::user_uid("no-such-account-4c1f9a"), None); + assert_eq!(claude_connector::user_uid("bad\0name"), None); +} + +#[test] +fn a_token_up_to_one_atomic_pipe_write_is_lent_and_a_longer_one_is_not() { + let exact: &'static str = Box::leak("a".repeat(4096).into_boxed_str()); + let harness = start(Setup { + token: Some(exact), + ..setup() + }); + let mut answer = ask(&harness, 60_000); + assert!(granted_ok(&answer)); + let mut read = Vec::new(); + File::from(answer.fds.remove(0)) + .read_to_end(&mut read) + .unwrap(); + assert_eq!(read.len(), 4096); + + let long: &'static str = Box::leak("a".repeat(4097).into_boxed_str()); + let harness = start(Setup { + token: Some(long), + ..setup() + }); + let refused = ask(&harness, 60_000); + assert!(refused.message.is_none() && refused.fds.is_empty()); +} + +#[test] +fn a_request_trickled_past_the_socket_timeout_is_dropped_without_a_lease() { + let harness = start(setup()); + let mut stream = UnixStream::connect(&harness.socket).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(8))) + .unwrap(); + stream.write_all(&[0, 0]).unwrap(); + let started = Instant::now(); + let mut byte = [0_u8; 1]; + let outcome = stream.read(&mut byte); + // The listener gave up at its own two-second bound and closed the + // connection: end of file or a reset, never our own read timing out. + assert!( + matches!(outcome, Ok(0)) + || outcome.as_ref().is_err_and(|e| e.kind() == std::io::ErrorKind::ConnectionReset), + "{outcome:?}" + ); + assert!(started.elapsed() < Duration::from_secs(6)); + assert_eq!(harness.tokens.reads.load(Ordering::SeqCst), 0); +} diff --git a/crates/claude-connector/tests/lease_wire_golden.rs b/crates/claude-connector/tests/lease_wire_golden.rs new file mode 100644 index 0000000..e0edec2 --- /dev/null +++ b/crates/claude-connector/tests/lease_wire_golden.rs @@ -0,0 +1,95 @@ +//! Pins the bytes of the lease protocol v2: every frame a lease client sends +//! or receives. The fixtures in `fixtures/lease_v2` are the contract's bytes +//! for other implementations (Huginn's lease client vendors them and tests +//! against the same bytes); the types in `claude_connector::lease` own the +//! shape. Regenerate with `LEASE_V2_REGENERATE=1`, then review the diff. +#![cfg(target_os = "linux")] + +use std::path::{Path, PathBuf}; + +use claude_connector::{ + LeaseGranted, LeaseMessage, LeaseRefusalKind, LeaseRefused, LeaseRequest, decode_frame, + encode_frame, +}; +use model_connector::PlanQuota; + +const REGENERATE: &str = "LEASE_V2_REGENERATE"; + +fn fixtures() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/lease_v2") +} + +fn assert_frame(name: &str, message: &LeaseMessage) { + let path = fixtures().join(name); + let bytes = encode_frame(message); + if std::env::var_os(REGENERATE).is_some() { + std::fs::create_dir_all(fixtures()).unwrap(); + std::fs::write(&path, &bytes).unwrap(); + } + let committed = std::fs::read(&path).unwrap_or_else(|_| panic!("{name} is missing")); + assert_eq!(committed, bytes, "{name} is not what the types encode"); + assert_eq!(decode_frame(&committed).as_ref(), Some(message), "{name}"); +} + +#[test] +fn the_request_frame_is_pinned() { + assert_frame( + "request.bin", + &LeaseMessage::ClaudeLeaseRequest(LeaseRequest { + expires_at_unix_ms: 1_793_307_000_000, + }), + ); +} + +#[test] +fn every_refusal_frame_is_pinned_with_its_kind_as_a_pascal_case_string() { + let kinds = [ + ("refusal_unknown_caller.bin", LeaseRefusalKind::UnknownCaller, "UnknownCaller"), + ("refusal_expired.bin", LeaseRefusalKind::Expired, "Expired"), + ("refusal_busy.bin", LeaseRefusalKind::Busy, "Busy"), + ( + "refusal_provider_rejected.bin", + LeaseRefusalKind::ProviderRejected, + "ProviderRejected", + ), + ]; + for (name, kind, spelled) in kinds { + let message = LeaseMessage::ClaudeLeaseRefusal(LeaseRefused { + kind, + detail: "the reason".to_string(), + }); + assert_frame(name, &message); + // The kind travels as the variant's name, which other runtimes read. + let bytes = encode_frame(&message); + let value: rmpv::Value = rmp_serde::from_slice(&bytes[4..]).unwrap(); + let rmpv::Value::Map(outer) = value else { + panic!("a frame is a map") + }; + let rmpv::Value::Map(inner) = &outer[1].1 else { + panic!("the value is a map") + }; + assert_eq!(inner[0].0.as_str(), Some("kind")); + assert_eq!(inner[0].1.as_str(), Some(spelled)); + } +} + +#[test] +fn a_grant_with_both_usage_windows_is_pinned() { + let window = |name: &str, permille, status: &str, resets| PlanQuota { + window: name.to_string(), + utilization_permille: Some(permille), + resets_at_unix_ms: resets, + status: status.to_string(), + observed_at_unix_ms: 1_793_300_000_000, + }; + assert_frame( + "grant.bin", + &LeaseMessage::ClaudeLeaseGrant(LeaseGranted { + lease_id: "lease:0123456789abcdef0123456789abcdef".to_string(), + plan_quota: vec![ + window("five_hour", 420, "allowed", 1_793_307_000_000), + window("seven_day", 999, "allowed_warning", 1_793_800_000_000), + ], + }), + ); +}