mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
## Summary
buzz-agent now authorizes every LLM-issued MCP tool call through
`session/request_permission` before it executes, instead of running
tools unconditionally. The agent **always asks**; the client applies
`BUZZ_ACP_PERMISSION_POLICY` and answers. buzz-agent never reads the
policy — this keeps the policy decision on the client side, matching the
layering of the other ACP harnesses, and avoids duplicating policy logic
that would drift.
### Broker
A crate-local `PermissionBroker`
(`crates/buzz-agent/src/permission.rs`), owned by `App` for the
connection lifetime, owns the full request-correlation lifecycle:
- **Process-wide admission.** A global `Semaphore`
(`BUZZ_AGENT_MAX_PENDING_PERMISSIONS`, default 32, validated `>= 1`) is
acquired *before* any correlation entry is inserted. This is the
security bound: the per-turn tool semaphore is constructed fresh per
turn and `max_sessions` is unbounded by default, so neither bounds
simultaneously-outstanding asks process-wide.
- **Abort-safe cleanup.** A successful admission returns a
`PendingPermission` lease that owns the admission permit and the
correlation id. Its `Drop` synchronously removes the still-pending entry
and releases the slot, covering task abort/panic that bypasses the
normal `run_prompt` tail.
- **At-most-once resolution.** `deliver` claims (removes) the entry
*before* waking the waiter, so each id resolves once and a later lease
`Drop` is a no-op. Unknown/late ids are logged and dropped; only ids the
broker minted (`perm-<n>`) are recognized.
- **Single absolute deadline.** Admission, request enqueue, and response
wait all share one deadline (`BUZZ_AGENT_PERMISSION_TIMEOUT_SECS`,
default 330s, validated `>= 1`), so a saturated call cannot outlive one
timeout window even when a stalled writer blocks the enqueue.
Cancellation races inside every wait — resolution never depends on the
outer abort drain. A writer that dies mid-connection (stdout closed, or
a blocking write that only surfaces its error at flush) is
connection-fatal: it cancels all sessions, which resolves any ask
waiting on a reply that can never be written.
### Wire
`request_permission_params` (`crates/buzz-agent/src/wire.rs`) is
version-aware, keyed on the protocol version negotiated at `initialize`
and stored on `App` for the connection lifetime (never derived from a
later mutable session field). v2 nests the tool call under `subject:
{type: "tool_call", toolCall}` with top-level `title`/`options`; v1 uses
the legacy top-level `toolCall`. No hybrid shape. Both offered options
(`allow_once`, `reject_once`) carry `optionId == kind`, so the client's
`kind`-based selection and this side's `optionId`-based predicate agree
without a lookup table.
### Gate
In each spawned tool task (`crates/buzz-agent/src/agent.rs`) the
sequence is: acquire per-turn permit → argument-shape validation →
broker admission + request + wait → cancellation recheck →
`emit_in_progress` → `mcp.call`. Argument-shape validation is hoisted
out of `mcp.rs::do_call` into `validate_arg_shape` so a malformed
non-object argument is rejected locally without prompting for a call
that could never execute.
Authorization is fail-closed, stated once in `evaluate`: execute IFF
`outcome.outcome == "selected"` **and** the selected `optionId` equals
the offered allow option. Every other shape (reject, cancelled, JSON-RPC
error, malformed, unknown outcome, wrong/unknown option, timeout,
wire-channel closure) denies with a synthetic tool error, and the turn
continues.
### Scope
Only LLM-issued MCP calls are gated. The built-in `load_skill` tool and
`call_hooks` lifecycle calls (`_Stop`, `_PostCompact`) are exempt — they
are not model-issued. `readOnlyHint` is never treated as a security
boundary.
First cut ships `allow_once`/`reject_once` only; session-scoped grants
are deliberately out of scope.
### Related issue
Part of #4938. This PR and
[#5106](https://github.com/block/buzz/pull/5106) jointly implement the
feature: #5106 is the client-side policy engine and permission cards;
this PR is buzz-agent's asking side (`session/request_permission`).
Neither closes #4938 alone.
---------
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
1048 lines
45 KiB
Rust
1048 lines
45 KiB
Rust
//! `session/request_permission` broker.
|
|
//!
|
|
//! buzz-agent asks the client to authorize every LLM-issued MCP tool call
|
|
//! *before* executing it; the client applies `BUZZ_ACP_PERMISSION_POLICY` and
|
|
//! answers. The agent never reads the policy — it always asks, matching the
|
|
//! layering of every other ACP harness. This module owns the whole request
|
|
//! correlation lifecycle so the rest of the agent only sees a single
|
|
//! `Allowed`/`Denied`/`Cancelled` decision.
|
|
//!
|
|
//! ## Invariants
|
|
//!
|
|
//! - **Process-wide admission.** The broker owns a global [`Semaphore`]
|
|
//! (`BUZZ_AGENT_MAX_PENDING_PERMISSIONS`) acquired *before* any correlation
|
|
//! entry is inserted. The per-turn `execute_parallel` semaphore is fresh per
|
|
//! turn and sessions are unbounded by default, so only this global cap bounds
|
|
//! simultaneously outstanding asks process-wide.
|
|
//! - **Abort-safe cleanup.** A successful admission returns a
|
|
//! [`PendingPermission`] lease that owns the admission permit and the
|
|
//! correlation id. Its `Drop` synchronously removes the still-pending entry
|
|
//! and releases the slot, covering task abort/panic that bypasses the normal
|
|
//! `run_prompt` tail.
|
|
//! - **Claim-before-wake / at-most-once.** [`PermissionBroker::deliver`] removes
|
|
//! the entry *before* waking the waiter, so each id resolves at most once and
|
|
//! a later lease `Drop` is a harmless no-op.
|
|
//! - **Unknown/late ids ignored.** A response whose id is not a live entry is
|
|
//! logged and dropped.
|
|
//! - **Undeliverable asks are terminal.** If the output wire is closed when the
|
|
//! request is enqueued, [`PermissionBroker::request_permission`] fails closed
|
|
//! immediately (dropping the lease removes the entry and releases the permit)
|
|
//! rather than leaving a resident waiter to time out — a closed wire can never
|
|
//! carry the reply.
|
|
//! - **Single absolute deadline.** Admission wait and response wait share one
|
|
//! absolute deadline computed at gate entry, so a saturated call cannot live
|
|
//! for two full timeout windows.
|
|
//! - **Cancellation races inside the wait.** The waiter selects on the turn's
|
|
//! cancel receiver directly; resolution never depends on the outer abort
|
|
//! drain.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
use serde_json::Value;
|
|
use tokio::sync::{oneshot, watch, OwnedSemaphorePermit, Semaphore};
|
|
use tokio::time::Instant;
|
|
|
|
use crate::types::ToolCall;
|
|
use crate::wire::{self, WireSender, ALLOW_OPTION_ID};
|
|
|
|
/// Model-visible tool error when a call is not authorized. Rides the normal
|
|
/// tool-failure path so the turn continues, matching every other tool error.
|
|
pub const PERMISSION_DENIED_MSG: &str = "permission denied: the tool call was not authorized";
|
|
|
|
/// Model-visible tool error when the client never answers within the deadline.
|
|
pub const PERMISSION_TIMEOUT_MSG: &str =
|
|
"permission request timed out: the tool call was not authorized";
|
|
|
|
/// Model-visible tool error when the permission request cannot be delivered
|
|
/// because the output wire is closed. Terminal and immediate — no waiter is
|
|
/// left resident, since a closed wire can never carry a reply.
|
|
pub const PERMISSION_WIRE_CLOSED_MSG: &str =
|
|
"permission request undeliverable: the tool call was not authorized";
|
|
|
|
/// Outcome of asking the client to authorize one tool call.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum PermissionDecision {
|
|
/// The client selected the offered allow option — execute the tool.
|
|
Allowed,
|
|
/// Every non-authorizing shape (reject, cancelled outcome, JSON-RPC error,
|
|
/// malformed response, unknown outcome, wrong/unknown optionId, timeout,
|
|
/// wire-channel closure). Fails closed with the given model-visible reason;
|
|
/// the turn continues.
|
|
Denied(&'static str),
|
|
/// The turn was cancelled while admitting or waiting. No tool runs and the
|
|
/// caller propagates cancellation exactly as the existing cancel path does.
|
|
Cancelled,
|
|
}
|
|
|
|
/// Broker owned by `App` for the connection lifetime.
|
|
pub struct PermissionBroker {
|
|
/// Global admission cap. Acquired before any entry is inserted.
|
|
sem: Arc<Semaphore>,
|
|
/// Live correlation entries: outbound request id -> response sender.
|
|
pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Value>>>>,
|
|
/// Monotonic id allocator. Never reused within a process lifetime, so a
|
|
/// late response for a removed id can never collide with a fresh request.
|
|
next_id: AtomicU64,
|
|
/// Absolute deadline budget shared by admission + response wait.
|
|
timeout: Duration,
|
|
/// Test-only: invoked by the waiter the instant it observes a delivered
|
|
/// response, with `true` iff the entry was already claimed (removed) from
|
|
/// `pending` before the wake. Makes claim-before-wake ordering
|
|
/// mutation-sensitive — a wake-before-claim mutant reports `false`, which a
|
|
/// purely behavioral test cannot detect (the waiter reads the oneshot once
|
|
/// either way).
|
|
#[cfg(test)]
|
|
wake_observer: Mutex<Option<WakeObserver>>,
|
|
}
|
|
|
|
/// Test-only wake-boundary observer; see [`PermissionBroker::wake_observer`].
|
|
#[cfg(test)]
|
|
type WakeObserver = Arc<dyn Fn(bool) + Send + Sync>;
|
|
|
|
impl PermissionBroker {
|
|
/// `max_pending` is validated `>= 1` by config; `timeout` is injectable so
|
|
/// broker unit tests exercise the timeout/abort paths without a 330s wait.
|
|
pub fn new(max_pending: usize, timeout: Duration) -> Self {
|
|
Self {
|
|
sem: Arc::new(Semaphore::new(max_pending.max(1))),
|
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
|
next_id: AtomicU64::new(0),
|
|
timeout,
|
|
#[cfg(test)]
|
|
wake_observer: Mutex::new(None),
|
|
}
|
|
}
|
|
|
|
/// Test-only: register a callback the waiter fires the instant it observes a
|
|
/// delivered response, with `true` iff the correlation entry was already
|
|
/// claimed (removed) before the wake. Used to prove claim-before-wake
|
|
/// ordering in a way a wake-before-claim mutant cannot satisfy.
|
|
#[cfg(test)]
|
|
pub fn set_wake_observer(&self, observer: WakeObserver) {
|
|
*self.wake_observer.lock().unwrap() = Some(observer);
|
|
}
|
|
|
|
/// Test-only: fire the wake observer (if any) with the claimed-before-wake
|
|
/// status of `id`. Called synchronously by the waiter the moment it receives
|
|
/// its response, so the observed `pending` state is exactly the state at the
|
|
/// wake — deterministic in production (removal happens-before the send) and
|
|
/// violated by a wake-before-claim mutant.
|
|
#[cfg(test)]
|
|
fn observe_wake(&self, id: u64) {
|
|
let claimed = !self.pending.lock().unwrap().contains_key(&id);
|
|
let observer = self.wake_observer.lock().unwrap().clone();
|
|
if let Some(observer) = observer {
|
|
observer(claimed);
|
|
}
|
|
}
|
|
|
|
/// Number of live (unresolved, un-dropped) correlation entries. Test-only
|
|
/// observability for the drop-guard and delivery invariants.
|
|
#[cfg(test)]
|
|
pub fn pending_count(&self) -> usize {
|
|
self.pending.lock().unwrap().len()
|
|
}
|
|
|
|
/// Free admission slots. Test-only, so a test can prove a terminal path
|
|
/// (delivery, timeout, cancel, drop) actually released the capacity it
|
|
/// held rather than leaking it.
|
|
#[cfg(test)]
|
|
pub fn available_permits(&self) -> usize {
|
|
self.sem.available_permits()
|
|
}
|
|
|
|
/// Deliver a client response to its waiter. Claims (removes) the entry
|
|
/// before waking so the id resolves at most once; unknown/late ids are
|
|
/// logged and ignored. `result` is the JSON-RPC `result` field (or
|
|
/// `Value::Null` for an error/malformed response — every such shape fails
|
|
/// the authorization predicate and denies).
|
|
pub fn deliver(&self, id: &Value, result: Value) {
|
|
let Some(key) = parse_id(id) else {
|
|
tracing::debug!(target: "permission", "ignoring response with unrecognized id {id}");
|
|
return;
|
|
};
|
|
// Claim before wake: remove first, then send into the removed sender.
|
|
let sender = self.pending.lock().unwrap().remove(&key);
|
|
match sender {
|
|
Some(tx) => {
|
|
// The receiver may already be gone (waiter cancelled/timed out
|
|
// and dropped the lease); a failed send is a harmless no-op.
|
|
let _ = tx.send(result);
|
|
}
|
|
None => {
|
|
tracing::debug!(target: "permission", "ignoring unknown/late permission id {id}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ask the client to authorize `call`, returning the decision.
|
|
///
|
|
/// Sequence: acquire global admission (racing cancel + deadline) → insert
|
|
/// correlation entry (held by an abort-safe lease) → send the version-aware
|
|
/// request → wait for the response (racing cancel + deadline). One absolute
|
|
/// deadline bounds both waits.
|
|
pub async fn request_permission(
|
|
&self,
|
|
wire: &WireSender,
|
|
version: u32,
|
|
session_id: &str,
|
|
call: &ToolCall,
|
|
cancel: &mut watch::Receiver<bool>,
|
|
) -> PermissionDecision {
|
|
let deadline = Instant::now() + self.timeout;
|
|
|
|
// ── Admission ──────────────────────────────────────────────────────
|
|
// Early cancel check: watch::changed() only fires on NEW writes.
|
|
if *cancel.borrow() {
|
|
return PermissionDecision::Cancelled;
|
|
}
|
|
let permit = tokio::select! {
|
|
biased;
|
|
_ = cancel.changed() => return PermissionDecision::Cancelled,
|
|
_ = tokio::time::sleep_until(deadline) => {
|
|
return PermissionDecision::Denied(PERMISSION_TIMEOUT_MSG);
|
|
}
|
|
p = Arc::clone(&self.sem).acquire_owned() => match p {
|
|
Ok(p) => p,
|
|
// Semaphore is never closed in production; treat as fail-closed.
|
|
Err(_) => return PermissionDecision::Denied(PERMISSION_DENIED_MSG),
|
|
},
|
|
};
|
|
|
|
// Insert the correlation entry under the owned permit. The lease's Drop
|
|
// removes the entry + releases the slot on every exit path below,
|
|
// including task abort.
|
|
let mut lease = self.register(permit);
|
|
|
|
// ── Send the version-aware request ─────────────────────────────────
|
|
let params = wire::request_permission_params(
|
|
version,
|
|
session_id,
|
|
&call.provider_id,
|
|
&call.name,
|
|
&call.arguments,
|
|
);
|
|
// ── Send (deadline- and cancel-governed) ───────────────────────────
|
|
// Enqueue is the third phase under the single absolute deadline. A
|
|
// full-but-live channel makes `send_checked` wait for capacity; racing
|
|
// it against cancel + the deadline means a stalled writer cannot hold
|
|
// the ask (and its global permit) past the advertised deadline, and
|
|
// `session/cancel` resolves it promptly. On send error the wire is
|
|
// closed: fail closed at once — dropping `lease` removes the entry and
|
|
// releases the permit synchronously.
|
|
let request = wire::request_permission(lease.id_value.clone(), params);
|
|
tokio::select! {
|
|
biased;
|
|
_ = cancel.changed() => return PermissionDecision::Cancelled,
|
|
_ = tokio::time::sleep_until(deadline) => {
|
|
return PermissionDecision::Denied(PERMISSION_TIMEOUT_MSG);
|
|
}
|
|
r = wire::send_checked(wire, request) => {
|
|
if r.is_err() {
|
|
return PermissionDecision::Denied(PERMISSION_WIRE_CLOSED_MSG);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Response wait ──────────────────────────────────────────────────
|
|
if *cancel.borrow() {
|
|
return PermissionDecision::Cancelled;
|
|
}
|
|
#[cfg(test)]
|
|
let id = lease.id;
|
|
tokio::select! {
|
|
biased;
|
|
_ = cancel.changed() => PermissionDecision::Cancelled,
|
|
r = &mut lease.rx => match r {
|
|
Ok(result) => {
|
|
// The waiter observes delivery here. At this instant the
|
|
// entry must already be claimed (removed) — delivery removes
|
|
// before it sends. The observer is test-only and a no-op in
|
|
// production.
|
|
#[cfg(test)]
|
|
self.observe_wake(id);
|
|
evaluate(&result)
|
|
}
|
|
// Sender dropped without sending — should not happen (delivery
|
|
// always sends before drop); fail closed.
|
|
Err(_) => PermissionDecision::Denied(PERMISSION_DENIED_MSG),
|
|
},
|
|
_ = tokio::time::sleep_until(deadline) => {
|
|
PermissionDecision::Denied(PERMISSION_TIMEOUT_MSG)
|
|
}
|
|
}
|
|
// `lease` drops here: entry removed (no-op if delivered) + slot released.
|
|
}
|
|
|
|
/// Allocate an id, insert its response sender, and return the abort-safe
|
|
/// lease holding the receiver + owned permit.
|
|
fn register(&self, permit: OwnedSemaphorePermit) -> PendingPermission {
|
|
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
|
let (tx, rx) = oneshot::channel();
|
|
self.pending.lock().unwrap().insert(id, tx);
|
|
PendingPermission {
|
|
id,
|
|
id_value: Value::String(format!("perm-{id}")),
|
|
rx,
|
|
pending: Arc::clone(&self.pending),
|
|
_permit: permit,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Abort-safe correlation lease. Owns the admission permit and the correlation
|
|
/// id; its `Drop` synchronously removes the still-pending entry and releases
|
|
/// the slot. Delivery removes the entry first, so a later drop is a no-op.
|
|
struct PendingPermission {
|
|
id: u64,
|
|
id_value: Value,
|
|
rx: oneshot::Receiver<Value>,
|
|
pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Value>>>>,
|
|
_permit: OwnedSemaphorePermit,
|
|
}
|
|
|
|
impl Drop for PendingPermission {
|
|
fn drop(&mut self) {
|
|
// Synchronous, non-async removal — safe from a Drop and required for
|
|
// abort/panic paths. No-op if delivery already claimed the entry.
|
|
self.pending.lock().unwrap().remove(&self.id);
|
|
// `_permit` drops → global admission slot released.
|
|
}
|
|
}
|
|
|
|
/// The authorization predicate, stated once: execute IFF the client selected an
|
|
/// option AND the selected `optionId` equals exactly this request's offered
|
|
/// allow-option id. Every other shape fails closed.
|
|
fn evaluate(result: &Value) -> PermissionDecision {
|
|
let outcome = &result["outcome"];
|
|
if outcome["outcome"] == "selected" && outcome["optionId"].as_str() == Some(ALLOW_OPTION_ID) {
|
|
PermissionDecision::Allowed
|
|
} else {
|
|
PermissionDecision::Denied(PERMISSION_DENIED_MSG)
|
|
}
|
|
}
|
|
|
|
/// Recover the correlation key from an outbound request id echoed by the
|
|
/// client. Only ids we minted (`perm-<n>`, canonical decimal) are ours; a
|
|
/// noncanonical alias (`perm-01`, `perm-+0`, `perm-00`) or any other string is
|
|
/// a foreign/stale id and is ignored. Requiring an exact round-trip means only
|
|
/// the string the broker actually minted correlates — no alias is ever live.
|
|
fn parse_id(id: &Value) -> Option<u64> {
|
|
let s = id.as_str()?;
|
|
let n: u64 = s.strip_prefix("perm-")?.parse().ok()?;
|
|
(format!("perm-{n}") == s).then_some(n)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
use std::io;
|
|
use std::pin::Pin;
|
|
use std::task::{Context, Poll};
|
|
use tokio::io::AsyncWrite;
|
|
use tokio::sync::mpsc;
|
|
|
|
const LONG: Duration = Duration::from_secs(30);
|
|
const SHORT: Duration = Duration::from_millis(60);
|
|
|
|
fn tool_call() -> ToolCall {
|
|
ToolCall {
|
|
provider_id: "fake".into(),
|
|
name: "fake__shell".into(),
|
|
arguments: json!({ "command": "ls" }),
|
|
provider_extra: serde_json::Map::new(),
|
|
}
|
|
}
|
|
|
|
fn selected(option_id: &str) -> Value {
|
|
json!({ "outcome": { "outcome": "selected", "optionId": option_id } })
|
|
}
|
|
|
|
/// Pull the next outbound frame off the wire and return its JSON-RPC `id`.
|
|
/// Reading it also proves the request was registered and sent (delivery
|
|
/// only happens after `register`).
|
|
async fn next_request_id(rx: &mut mpsc::Receiver<wire::WireMsg>) -> Value {
|
|
let wire::WireMsg::Notify(v) = tokio::time::timeout(Duration::from_secs(1), rx.recv())
|
|
.await
|
|
.expect("a request frame")
|
|
.expect("wire open");
|
|
assert_eq!(v["method"], "session/request_permission");
|
|
v["id"].clone()
|
|
}
|
|
|
|
/// An `AsyncWrite` that accepts every write but fails on `flush`, modelling
|
|
/// Tokio's blocking stdout when the pipe has broken: `write_all` reports
|
|
/// `Ok` (the underlying blocking write is only scheduled) and the real
|
|
/// error surfaces at `flush`. Used to prove the writer treats flush failure
|
|
/// as connection-fatal.
|
|
struct FlushFailSink;
|
|
|
|
impl AsyncWrite for FlushFailSink {
|
|
fn poll_write(
|
|
self: Pin<&mut Self>,
|
|
_cx: &mut Context<'_>,
|
|
buf: &[u8],
|
|
) -> Poll<io::Result<usize>> {
|
|
Poll::Ready(Ok(buf.len()))
|
|
}
|
|
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
|
Poll::Ready(Err(io::Error::from(io::ErrorKind::BrokenPipe)))
|
|
}
|
|
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
|
Poll::Ready(Ok(()))
|
|
}
|
|
}
|
|
|
|
// ── Authorization predicate (fail-closed) ────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_selected_allow_option_authorizes() {
|
|
assert_eq!(
|
|
evaluate(&selected(ALLOW_OPTION_ID)),
|
|
PermissionDecision::Allowed
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_every_non_allow_shape_denies() {
|
|
// reject, wrong/unknown option id, unknown outcome, missing fields,
|
|
// empty object — the full adversarial set the predicate must reject.
|
|
let denied = [
|
|
selected("reject_once"),
|
|
selected("some_unknown_option"),
|
|
json!({ "outcome": { "outcome": "cancelled" } }),
|
|
json!({ "outcome": { "outcome": "selected" } }), // no optionId
|
|
json!({ "outcome": { "outcome": "banana", "optionId": ALLOW_OPTION_ID } }),
|
|
json!({ "outcome": {} }),
|
|
json!({}),
|
|
Value::Null,
|
|
];
|
|
for shape in denied {
|
|
assert_eq!(
|
|
evaluate(&shape),
|
|
PermissionDecision::Denied(PERMISSION_DENIED_MSG),
|
|
"shape must fail closed: {shape}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Id correlation ───────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_parse_id_accepts_only_minted_ids() {
|
|
assert_eq!(parse_id(&json!("perm-0")), Some(0));
|
|
assert_eq!(parse_id(&json!("perm-42")), Some(42));
|
|
assert_eq!(parse_id(&json!("perm-x")), None);
|
|
assert_eq!(parse_id(&json!("42")), None); // foreign numeric-string id
|
|
assert_eq!(parse_id(&json!(42)), None); // foreign numeric id
|
|
assert_eq!(parse_id(&Value::Null), None);
|
|
}
|
|
|
|
/// Noncanonical strings that `u64::parse` would otherwise accept as aliases
|
|
/// of a minted id must NOT correlate. Only the exact string the broker
|
|
/// minted (`format!("perm-{n}")`) is live; leading zeros, a sign, or
|
|
/// whitespace make the id foreign and it is ignored. Without the exact
|
|
/// round-trip check these would resolve live asks under ids the broker
|
|
/// never issued.
|
|
#[test]
|
|
fn test_parse_id_rejects_noncanonical_aliases() {
|
|
for alias in [
|
|
"perm-00", // extra leading zero
|
|
"perm-01", // leading zero
|
|
"perm-+0", // explicit sign
|
|
"perm-0x1", // hex
|
|
"perm- 1", // leading space
|
|
"perm-1 ", // trailing space
|
|
"perm-1_000", // digit separator
|
|
] {
|
|
assert_eq!(
|
|
parse_id(&json!(alias)),
|
|
None,
|
|
"alias must be foreign: {alias}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Delivery: exact allow / deny ──────────────────────────────────────────
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_deliver_allow_authorizes_and_frees_slot() {
|
|
let broker = Arc::new(PermissionBroker::new(4, LONG));
|
|
let (tx, mut rx) = mpsc::channel(8);
|
|
let (_cancel_tx, mut cancel_rx) = watch::channel(false);
|
|
|
|
let b = Arc::clone(&broker);
|
|
let call = tool_call();
|
|
let task = tokio::spawn(async move {
|
|
b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx)
|
|
.await
|
|
});
|
|
|
|
let id = next_request_id(&mut rx).await;
|
|
assert_eq!(broker.pending_count(), 1);
|
|
assert_eq!(broker.available_permits(), 3);
|
|
|
|
broker.deliver(&id, selected(ALLOW_OPTION_ID));
|
|
assert_eq!(task.await.unwrap(), PermissionDecision::Allowed);
|
|
assert_eq!(broker.pending_count(), 0, "entry claimed on delivery");
|
|
assert_eq!(
|
|
broker.available_permits(),
|
|
4,
|
|
"slot released after decision"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_deliver_reject_denies_and_frees_slot() {
|
|
let broker = Arc::new(PermissionBroker::new(4, LONG));
|
|
let (tx, mut rx) = mpsc::channel(8);
|
|
let (_cancel_tx, mut cancel_rx) = watch::channel(false);
|
|
|
|
let b = Arc::clone(&broker);
|
|
let call = tool_call();
|
|
let task = tokio::spawn(async move {
|
|
b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx)
|
|
.await
|
|
});
|
|
|
|
let id = next_request_id(&mut rx).await;
|
|
broker.deliver(&id, selected("reject_once"));
|
|
assert_eq!(
|
|
task.await.unwrap(),
|
|
PermissionDecision::Denied(PERMISSION_DENIED_MSG)
|
|
);
|
|
assert_eq!(broker.pending_count(), 0);
|
|
assert_eq!(broker.available_permits(), 4);
|
|
}
|
|
|
|
// ── Malformed response frames deny (Carl's review) ────────────────────────
|
|
|
|
/// Route Carl's frame through the real `classify` → `deliver` path against a
|
|
/// live waiter and assert the tool is denied. Delivers on the exact id the
|
|
/// broker minted, so the only reason the waiter denies is that `classify`
|
|
/// refused to forward the ambiguous/malformed `result`. `provider_id`
|
|
/// carries which shape is under test so a failure names the mutant.
|
|
async fn assert_malformed_frame_denies(provider_id: &str, frame: Value) {
|
|
let broker = Arc::new(PermissionBroker::new(4, LONG));
|
|
let (tx, mut rx) = mpsc::channel(8);
|
|
let (_cancel_tx, mut cancel_rx) = watch::channel(false);
|
|
|
|
let b = Arc::clone(&broker);
|
|
let call = tool_call();
|
|
let task = tokio::spawn(async move {
|
|
b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx)
|
|
.await
|
|
});
|
|
|
|
let id = next_request_id(&mut rx).await;
|
|
// Stamp the broker's minted id onto Carl's frame, then classify it
|
|
// exactly as the dispatch loop would before handing `result` to deliver.
|
|
let mut frame = frame;
|
|
frame["id"] = id.clone();
|
|
match crate::wire::classify(&frame) {
|
|
crate::wire::Inbound::Response { id, result } => broker.deliver(&id, result),
|
|
other => panic!("[{provider_id}] expected Response, got {other:?}"),
|
|
}
|
|
|
|
assert_eq!(
|
|
task.await.unwrap(),
|
|
PermissionDecision::Denied(PERMISSION_DENIED_MSG),
|
|
"[{provider_id}] malformed frame must deny, not authorize the tool",
|
|
);
|
|
assert_eq!(broker.pending_count(), 0);
|
|
assert_eq!(broker.available_permits(), 4);
|
|
}
|
|
|
|
/// Carl frame #1: `result` (well-formed `selected`/`allow_once`) AND `error`
|
|
/// both present. The tool must not run — the ambiguous frame denies.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_frame_with_result_and_error_denies_tool() {
|
|
assert_malformed_frame_denies(
|
|
"result+error",
|
|
json!({
|
|
"jsonrpc": "2.0",
|
|
"result": { "outcome": { "outcome": "selected", "optionId": ALLOW_OPTION_ID } },
|
|
"error": { "code": -32603, "message": "internal" },
|
|
}),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
/// Carl frame #2: present non-string `method: 7` alongside a well-formed
|
|
/// `selected` `result`. It is not a valid response — the tool must not run.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_frame_with_non_string_method_denies_tool() {
|
|
assert_malformed_frame_denies(
|
|
"non-string-method",
|
|
json!({
|
|
"jsonrpc": "2.0",
|
|
"method": 7,
|
|
"result": { "outcome": { "outcome": "selected", "optionId": ALLOW_OPTION_ID } },
|
|
}),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
// ── Stale / unknown id ignored ────────────────────────────────────────────
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_unknown_id_does_not_unblock_waiter() {
|
|
let broker = Arc::new(PermissionBroker::new(4, LONG));
|
|
let (tx, mut rx) = mpsc::channel(8);
|
|
let (_cancel_tx, mut cancel_rx) = watch::channel(false);
|
|
|
|
let b = Arc::clone(&broker);
|
|
let call = tool_call();
|
|
let task = tokio::spawn(async move {
|
|
b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx)
|
|
.await
|
|
});
|
|
|
|
let real_id = next_request_id(&mut rx).await;
|
|
// A stale/foreign id is dropped; the live entry survives.
|
|
broker.deliver(&json!("perm-999"), selected(ALLOW_OPTION_ID));
|
|
broker.deliver(&json!(1), selected(ALLOW_OPTION_ID));
|
|
broker.deliver(&Value::Null, selected(ALLOW_OPTION_ID));
|
|
assert_eq!(broker.pending_count(), 1, "waiter still pending");
|
|
|
|
// The correct id resolves it exactly once.
|
|
broker.deliver(&real_id, selected(ALLOW_OPTION_ID));
|
|
assert_eq!(task.await.unwrap(), PermissionDecision::Allowed);
|
|
assert_eq!(broker.pending_count(), 0);
|
|
}
|
|
|
|
// ── Timeout ───────────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_timeout_denies_and_removes_state() {
|
|
let broker = Arc::new(PermissionBroker::new(4, SHORT));
|
|
let (tx, _rx) = mpsc::channel(8);
|
|
let (_cancel_tx, mut cancel_rx) = watch::channel(false);
|
|
let call = tool_call();
|
|
|
|
// No delivery ever arrives: the shared deadline denies.
|
|
let decision = broker
|
|
.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx)
|
|
.await;
|
|
assert_eq!(decision, PermissionDecision::Denied(PERMISSION_TIMEOUT_MSG));
|
|
assert_eq!(
|
|
broker.pending_count(),
|
|
0,
|
|
"timeout removes correlation state"
|
|
);
|
|
assert_eq!(broker.available_permits(), 4, "timeout releases the slot");
|
|
}
|
|
|
|
// ── Undeliverable ask (closed wire) is terminal ───────────────────────────
|
|
|
|
/// When the output wire is closed, the ask can never be written and no
|
|
/// reply can ever arrive. `request_permission` must fail closed
|
|
/// *immediately* — denying with the wire-closed reason and leaving zero
|
|
/// pending entries and zero held permits — rather than registering an entry
|
|
/// that waits out the full deadline. Uses a LONG timeout so a wrong
|
|
/// implementation that waits the deadline would visibly hang the test far
|
|
/// past its own assertions.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_closed_wire_denies_immediately_without_leaking_state() {
|
|
let broker = Arc::new(PermissionBroker::new(4, LONG));
|
|
// Drop the receiver so every send fails: the writer is gone.
|
|
let (tx, rx) = mpsc::channel(8);
|
|
drop(rx);
|
|
let (_cancel_tx, mut cancel_rx) = watch::channel(false);
|
|
let call = tool_call();
|
|
|
|
// Bound the whole call: correct behavior returns at once; a regression
|
|
// that waits the deadline blows this timeout instead of hanging LONG.
|
|
let decision = tokio::time::timeout(
|
|
Duration::from_secs(2),
|
|
broker.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx),
|
|
)
|
|
.await
|
|
.expect("closed wire must deny immediately, not wait the deadline");
|
|
|
|
assert_eq!(
|
|
decision,
|
|
PermissionDecision::Denied(PERMISSION_WIRE_CLOSED_MSG)
|
|
);
|
|
assert_eq!(
|
|
broker.pending_count(),
|
|
0,
|
|
"undeliverable ask leaves no resident entry"
|
|
);
|
|
assert_eq!(
|
|
broker.available_permits(),
|
|
4,
|
|
"undeliverable ask releases its admission slot"
|
|
);
|
|
}
|
|
|
|
// ── Writer flush failure is connection-fatal ──────────────────────────────
|
|
|
|
/// A blocking stdout can report `Ok` from `write_all` (the underlying
|
|
/// blocking write is only scheduled) and surface the real error at `flush`.
|
|
/// The writer must therefore treat flush failure exactly like write failure
|
|
/// — return, dropping its receiver — so the connection supervisor observes
|
|
/// writer death and cancels every session (which resolves any waiting ask).
|
|
/// Modelled here: `write_frames` fed one frame over a sink that accepts the
|
|
/// write but fails flush must terminate promptly; a cancellation wired to
|
|
/// that termination (as `async_main`'s writer arm does) then unblocks a
|
|
/// registered permission waiter, leaving zero pending entries and all
|
|
/// permits free — not after the injected deadline.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_flush_failure_kills_writer_and_resolves_waiter() {
|
|
let broker = Arc::new(PermissionBroker::new(4, LONG));
|
|
// A live output channel; its frames are drained by `write_frames` into
|
|
// the flush-failing sink, modelling the real writer over a broken pipe.
|
|
let (wire_tx, wire_rx) = mpsc::channel(8);
|
|
let (cancel_tx, mut cancel_rx) = watch::channel(false);
|
|
|
|
// Supervisor: run the writer over the failing sink; when it returns
|
|
// (flush error → connection-fatal), propagate cancellation exactly like
|
|
// `async_main`'s writer-death arm.
|
|
let writer = tokio::spawn(async move {
|
|
wire::write_frames(wire_rx, FlushFailSink).await;
|
|
let _ = cancel_tx.send(true);
|
|
});
|
|
|
|
let b = Arc::clone(&broker);
|
|
let call = tool_call();
|
|
let task = tokio::spawn(async move {
|
|
b.request_permission(&wire_tx, 2, "ses_a", &call, &mut cancel_rx)
|
|
.await
|
|
});
|
|
|
|
// The ask registers and enqueues its frame; the writer accepts the
|
|
// write, fails the flush, returns, and the supervisor cancels. The
|
|
// waiter must resolve via that cancellation, not the LONG deadline.
|
|
let decision = tokio::time::timeout(Duration::from_secs(2), task)
|
|
.await
|
|
.expect("flush failure must cancel the waiter, not wait the deadline")
|
|
.unwrap();
|
|
|
|
writer.await.unwrap();
|
|
assert_eq!(decision, PermissionDecision::Cancelled);
|
|
assert_eq!(
|
|
broker.pending_count(),
|
|
0,
|
|
"writer death resolves the waiter and leaves no resident entry"
|
|
);
|
|
assert_eq!(
|
|
broker.available_permits(),
|
|
4,
|
|
"writer death releases the held admission slot"
|
|
);
|
|
}
|
|
|
|
// ── Enqueue backpressure is deadline- and cancel-governed ─────────────────
|
|
|
|
/// A full-but-live output channel makes `send_checked` wait for capacity.
|
|
/// The send phase races the single absolute deadline, so a stalled writer
|
|
/// cannot hold the ask (and its global permit) past the advertised
|
|
/// deadline: `request_permission` must return the timeout deny within the
|
|
/// deadline and leave zero pending entries and zero held permits. Uses a
|
|
/// SHORT deadline bounded by a longer outer timeout, so a regression that
|
|
/// waits forever on capacity blows the outer bound. This is a distinct seam
|
|
/// from the dropped-receiver test: here the receiver is alive but never
|
|
/// drains.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_full_channel_send_is_bounded_by_the_deadline() {
|
|
let broker = Arc::new(PermissionBroker::new(4, SHORT));
|
|
// Capacity-1 channel, prefilled and never drained: the next send blocks
|
|
// on capacity while the receiver stays alive (writer present, stalled).
|
|
let (tx, _rx) = mpsc::channel(1);
|
|
tx.send(wire::WireMsg::Notify(json!({ "fill": 1 })))
|
|
.await
|
|
.unwrap();
|
|
let (_cancel_tx, mut cancel_rx) = watch::channel(false);
|
|
let call = tool_call();
|
|
|
|
let decision = tokio::time::timeout(
|
|
Duration::from_secs(2),
|
|
broker.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx),
|
|
)
|
|
.await
|
|
.expect("a stalled writer must not hold the send past the deadline");
|
|
|
|
assert_eq!(
|
|
decision,
|
|
PermissionDecision::Denied(PERMISSION_TIMEOUT_MSG),
|
|
"a full-but-live channel resolves via the deadline, not the wire-closed path"
|
|
);
|
|
assert_eq!(
|
|
broker.pending_count(),
|
|
0,
|
|
"a timed-out enqueue leaves no resident entry"
|
|
);
|
|
assert_eq!(
|
|
broker.available_permits(),
|
|
4,
|
|
"a timed-out enqueue releases its admission slot"
|
|
);
|
|
}
|
|
|
|
/// Cancellation must also resolve an ask stuck enqueueing on a stalled
|
|
/// writer: with a full-but-live channel and a LONG deadline, a
|
|
/// `session/cancel` returns `Cancelled` promptly rather than waiting the
|
|
/// deadline out.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_cancel_resolves_a_blocked_enqueue() {
|
|
let broker = Arc::new(PermissionBroker::new(4, LONG));
|
|
let (tx, _rx) = mpsc::channel(1);
|
|
tx.send(wire::WireMsg::Notify(json!({ "fill": 1 })))
|
|
.await
|
|
.unwrap();
|
|
let (cancel_tx, mut cancel_rx) = watch::channel(false);
|
|
let b = Arc::clone(&broker);
|
|
let call = tool_call();
|
|
let task = tokio::spawn(async move {
|
|
b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx)
|
|
.await
|
|
});
|
|
|
|
// Give the task time to admit + block on the full channel, then cancel.
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
cancel_tx.send(true).unwrap();
|
|
let decision = tokio::time::timeout(Duration::from_secs(2), task)
|
|
.await
|
|
.expect("cancel must resolve a blocked enqueue promptly")
|
|
.unwrap();
|
|
assert_eq!(decision, PermissionDecision::Cancelled);
|
|
assert_eq!(broker.pending_count(), 0);
|
|
assert_eq!(broker.available_permits(), 4);
|
|
}
|
|
|
|
// ── Claim-before-wake ordering (mutation-sensitive) ───────────────────────
|
|
|
|
/// The waiter must observe the correlation entry already *claimed* (removed
|
|
/// from `pending`) at the instant it wakes with the delivered response —
|
|
/// `deliver` removes before it sends. The wake observer fires synchronously
|
|
/// inside the waiter's response arm, so it captures the exact `pending`
|
|
/// state at the wake. A wake-before-claim mutant (send first, remove after)
|
|
/// makes the observed state `false` and fails this assertion; the behavioral
|
|
/// delivery tests cannot detect that mutant because the waiter reads the
|
|
/// oneshot exactly once either way.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_waiter_observes_entry_claimed_before_wake() {
|
|
let broker = Arc::new(PermissionBroker::new(4, LONG));
|
|
let claimed_at_wake = Arc::new(Mutex::new(None::<bool>));
|
|
let sink = Arc::clone(&claimed_at_wake);
|
|
broker.set_wake_observer(Arc::new(move |claimed| {
|
|
*sink.lock().unwrap() = Some(claimed);
|
|
}));
|
|
|
|
let (tx, mut rx) = mpsc::channel(8);
|
|
let (_cancel_tx, mut cancel_rx) = watch::channel(false);
|
|
let b = Arc::clone(&broker);
|
|
let call = tool_call();
|
|
let task = tokio::spawn(async move {
|
|
b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx)
|
|
.await
|
|
});
|
|
|
|
let id = next_request_id(&mut rx).await;
|
|
broker.deliver(&id, selected(ALLOW_OPTION_ID));
|
|
assert_eq!(task.await.unwrap(), PermissionDecision::Allowed);
|
|
assert_eq!(
|
|
*claimed_at_wake.lock().unwrap(),
|
|
Some(true),
|
|
"entry must be claimed (removed) before the waiter is woken"
|
|
);
|
|
}
|
|
|
|
// ── Cancellation while waiting ───────────────────────────────────────────
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_cancel_while_waiting_returns_cancelled_and_removes_state() {
|
|
let broker = Arc::new(PermissionBroker::new(4, LONG));
|
|
let (tx, mut rx) = mpsc::channel(8);
|
|
let (cancel_tx, mut cancel_rx) = watch::channel(false);
|
|
|
|
let b = Arc::clone(&broker);
|
|
let call = tool_call();
|
|
let task = tokio::spawn(async move {
|
|
b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx)
|
|
.await
|
|
});
|
|
|
|
let _id = next_request_id(&mut rx).await;
|
|
assert_eq!(broker.pending_count(), 1);
|
|
cancel_tx.send(true).unwrap();
|
|
assert_eq!(task.await.unwrap(), PermissionDecision::Cancelled);
|
|
assert_eq!(broker.pending_count(), 0);
|
|
assert_eq!(broker.available_permits(), 4);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_precancelled_turn_never_sends_request() {
|
|
let broker = Arc::new(PermissionBroker::new(4, LONG));
|
|
let (tx, mut rx) = mpsc::channel(8);
|
|
let (_cancel_tx, mut cancel_rx) = watch::channel(true); // already cancelled
|
|
let call = tool_call();
|
|
|
|
let decision = broker
|
|
.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx)
|
|
.await;
|
|
assert_eq!(decision, PermissionDecision::Cancelled);
|
|
assert_eq!(broker.pending_count(), 0, "no entry inserted");
|
|
assert_eq!(broker.available_permits(), 4);
|
|
assert!(rx.try_recv().is_err(), "no request frame emitted");
|
|
}
|
|
|
|
// ── Abort-safe drop guard ─────────────────────────────────────────────────
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_abort_while_waiting_leaves_zero_pending_and_reusable_slot() {
|
|
let broker = Arc::new(PermissionBroker::new(1, LONG));
|
|
let (tx, mut rx) = mpsc::channel(8);
|
|
let (_cancel_tx, mut cancel_rx) = watch::channel(false);
|
|
|
|
let b = Arc::clone(&broker);
|
|
let call = tool_call();
|
|
let task = tokio::spawn(async move {
|
|
b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx)
|
|
.await
|
|
});
|
|
|
|
// Registered + sent → then hard-abort the task (bypasses every normal
|
|
// exit path). The lease's Drop must still run.
|
|
let _id = next_request_id(&mut rx).await;
|
|
assert_eq!(broker.pending_count(), 1);
|
|
assert_eq!(broker.available_permits(), 0);
|
|
task.abort();
|
|
let _ = task.await;
|
|
assert_eq!(
|
|
broker.pending_count(),
|
|
0,
|
|
"drop guard removed the entry on abort"
|
|
);
|
|
assert_eq!(
|
|
broker.available_permits(),
|
|
1,
|
|
"drop guard released the slot on abort"
|
|
);
|
|
}
|
|
|
|
// ── Process-wide admission cap across multiple sessions ───────────────────
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_admission_cap_bounds_entries_across_sessions() {
|
|
// Capacity 2, shared by all sessions. Three distinct sessions ask at
|
|
// once; only two can register/send while the cap is saturated. The
|
|
// third is admitted only after a slot frees. Frame ids arrive in
|
|
// nondeterministic order across tasks, so the test never maps a task
|
|
// handle to a specific id — it proves the bound structurally (frame
|
|
// count + pending_count) and that every task ultimately resolves.
|
|
let broker = Arc::new(PermissionBroker::new(2, LONG));
|
|
let (tx, mut rx) = mpsc::channel(8);
|
|
let (_c, cancel_rx) = watch::channel(false);
|
|
|
|
let spawn_req = |session: &'static str| {
|
|
let b = Arc::clone(&broker);
|
|
let tx = tx.clone();
|
|
let mut cancel = cancel_rx.clone();
|
|
let call = tool_call();
|
|
tokio::spawn(async move {
|
|
b.request_permission(&tx, 2, session, &call, &mut cancel)
|
|
.await
|
|
})
|
|
};
|
|
|
|
let tasks = [spawn_req("ses_a"), spawn_req("ses_b"), spawn_req("ses_c")];
|
|
|
|
// Only two frames appear while the cap is 2; the third is blocked in
|
|
// admission with no entry and no frame.
|
|
let id1 = next_request_id(&mut rx).await;
|
|
let id2 = next_request_id(&mut rx).await;
|
|
assert_eq!(broker.pending_count(), 2);
|
|
assert_eq!(broker.available_permits(), 0);
|
|
assert!(
|
|
tokio::time::timeout(SHORT, rx.recv()).await.is_err(),
|
|
"third session must not send a request while the cap is saturated"
|
|
);
|
|
assert_eq!(
|
|
broker.pending_count(),
|
|
2,
|
|
"cap holds: no third entry inserted"
|
|
);
|
|
|
|
// Free one slot → the third session is admitted and sends its frame.
|
|
broker.deliver(&id1, selected(ALLOW_OPTION_ID));
|
|
let id3 = next_request_id(&mut rx).await;
|
|
assert_eq!(broker.pending_count(), 2, "still bounded after churn");
|
|
|
|
// Resolve the two remaining live entries.
|
|
broker.deliver(&id2, selected(ALLOW_OPTION_ID));
|
|
broker.deliver(&id3, selected(ALLOW_OPTION_ID));
|
|
|
|
// Every session resolved to Allowed — none stranded or timed out.
|
|
for t in tasks {
|
|
assert_eq!(t.await.unwrap(), PermissionDecision::Allowed);
|
|
}
|
|
assert_eq!(broker.pending_count(), 0);
|
|
assert_eq!(broker.available_permits(), 2);
|
|
}
|
|
|
|
// ── Admission-phase cancel: fail-closed, zero entries ─────────────────────
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_cancel_during_admission_inserts_no_entry() {
|
|
// Saturate the single slot directly (test-module access to `sem`) so the
|
|
// request under test blocks in the admission phase, before any insert.
|
|
let broker = Arc::new(PermissionBroker::new(1, LONG));
|
|
let held = Arc::clone(&broker.sem).acquire_owned().await.unwrap();
|
|
assert_eq!(broker.available_permits(), 0);
|
|
|
|
let (tx, mut rx) = mpsc::channel(8);
|
|
let (cancel_tx, mut cancel_rx) = watch::channel(false);
|
|
let b = Arc::clone(&broker);
|
|
let call = tool_call();
|
|
let task = tokio::spawn(async move {
|
|
b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx)
|
|
.await
|
|
});
|
|
|
|
// It cannot proceed past admission: no entry, no frame.
|
|
assert!(tokio::time::timeout(SHORT, rx.recv()).await.is_err());
|
|
assert_eq!(broker.pending_count(), 0, "blocked before insert");
|
|
|
|
cancel_tx.send(true).unwrap();
|
|
assert_eq!(task.await.unwrap(), PermissionDecision::Cancelled);
|
|
assert_eq!(
|
|
broker.pending_count(),
|
|
0,
|
|
"cancel during admission inserts nothing"
|
|
);
|
|
drop(held);
|
|
assert_eq!(broker.available_permits(), 1);
|
|
}
|
|
|
|
// ── Admission-phase deadline: fail-closed deny, zero entries ──────────────
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_deadline_during_admission_denies_with_no_entry() {
|
|
let broker = Arc::new(PermissionBroker::new(1, SHORT));
|
|
let held = Arc::clone(&broker.sem).acquire_owned().await.unwrap();
|
|
|
|
let (tx, mut rx) = mpsc::channel(8);
|
|
let (_cancel_tx, mut cancel_rx) = watch::channel(false);
|
|
let call = tool_call();
|
|
|
|
// Slot never frees within the deadline → admission times out.
|
|
let decision = broker
|
|
.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx)
|
|
.await;
|
|
assert_eq!(decision, PermissionDecision::Denied(PERMISSION_TIMEOUT_MSG));
|
|
assert_eq!(
|
|
broker.pending_count(),
|
|
0,
|
|
"no entry inserted on admission timeout"
|
|
);
|
|
assert!(rx.try_recv().is_err(), "no request frame emitted");
|
|
drop(held);
|
|
}
|
|
}
|