mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +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>
845 lines
33 KiB
Rust
845 lines
33 KiB
Rust
use serde::Deserialize;
|
|
use serde_json::{json, Value};
|
|
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt};
|
|
use tokio::sync::mpsc;
|
|
|
|
use crate::types::{ContentBlock, McpServerStdio};
|
|
|
|
pub const PARSE_ERROR: i32 = -32700;
|
|
pub const INVALID_REQUEST: i32 = -32600;
|
|
pub const METHOD_NOT_FOUND: i32 = -32601;
|
|
pub const INVALID_PARAMS: i32 = -32602;
|
|
|
|
pub enum WireMsg {
|
|
Notify(Value),
|
|
}
|
|
|
|
pub type WireSender = mpsc::Sender<WireMsg>;
|
|
|
|
#[derive(Debug)]
|
|
pub enum Inbound {
|
|
Request {
|
|
id: Value,
|
|
method: String,
|
|
params: Value,
|
|
},
|
|
Notification {
|
|
method: String,
|
|
params: Value,
|
|
},
|
|
/// A bare JSON-RPC response (id present, no method) — the client's answer
|
|
/// to a request buzz-agent issued. Today the only such request is
|
|
/// `session/request_permission`. `result` carries the JSON-RPC `result`
|
|
/// field ONLY when the frame is a structurally valid response — no `method`
|
|
/// member and exactly one of `result`/`error`. Any malformed shape (present
|
|
/// non-string `method`, both `result` and `error`, or neither) is normalized
|
|
/// to `Null` so a possibly-`selected` payload is never laundered into an
|
|
/// approval; every non-`selected` shape fails the broker's authorization
|
|
/// predicate and denies.
|
|
Response {
|
|
id: Value,
|
|
result: Value,
|
|
},
|
|
Invalid {
|
|
id: Value,
|
|
code: i32,
|
|
message: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct InitializeParams {
|
|
#[serde(rename = "protocolVersion")]
|
|
pub protocol_version: u32,
|
|
#[serde(default, rename = "clientCapabilities")]
|
|
pub _client_capabilities: Value,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SessionNewParams {
|
|
pub cwd: String,
|
|
#[serde(default)]
|
|
pub mcp_servers: Vec<McpServerStdio>,
|
|
#[serde(default)]
|
|
pub system_prompt: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SessionPromptParams {
|
|
pub session_id: String,
|
|
pub prompt: Vec<ContentBlock>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SessionCancelParams {
|
|
pub session_id: String,
|
|
}
|
|
|
|
/// Params for goose's non-standard `_goose/unstable/session/steer` request:
|
|
/// inject user input into the *currently active* prompt without starting a new
|
|
/// one. `expected_run_id` must match the run id buzz-agent advertised via
|
|
/// `params.update._meta.goose.activeRunId` on a `session/update`, so a steer
|
|
/// can't race a turn that already ended or hasn't started.
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SessionSteerParams {
|
|
pub session_id: String,
|
|
#[serde(default)]
|
|
pub prompt: Vec<ContentBlock>,
|
|
pub expected_run_id: String,
|
|
}
|
|
|
|
/// Params for `session/set_model`: override the active model for an existing
|
|
/// session without respawning. Applied immediately; subsequent prompts on this
|
|
/// session use `model_id` instead of the configured `BUZZ_AGENT_MODEL`.
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SessionSetModelParams {
|
|
pub session_id: String,
|
|
pub model_id: String,
|
|
}
|
|
|
|
pub fn classify(msg: &Value) -> Inbound {
|
|
if !msg.is_object() || msg.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
|
|
return Inbound::Invalid {
|
|
id: msg.get("id").cloned().unwrap_or(Value::Null),
|
|
code: INVALID_REQUEST,
|
|
message: "jsonrpc: missing or invalid version".into(),
|
|
};
|
|
}
|
|
let id = msg.get("id").cloned();
|
|
let method = msg.get("method").and_then(Value::as_str).map(str::to_owned);
|
|
let params = msg.get("params").cloned().unwrap_or(Value::Null);
|
|
|
|
match (method, id) {
|
|
(Some(m), Some(id)) => Inbound::Request {
|
|
id,
|
|
method: m,
|
|
params,
|
|
},
|
|
(Some(m), None) => Inbound::Notification { method: m, params },
|
|
// Bare responses (id present, no method) answer a request buzz-agent
|
|
// issued — today only `session/request_permission`. Route to the
|
|
// permission broker, which matches a live correlation id or ignores an
|
|
// unknown one. Forward the `result` ONLY when the frame is a
|
|
// structurally valid response — the exactly-one-of invariant: no
|
|
// `method` member at all, and `result` present with `error` absent. A
|
|
// present non-string `method` (which `as_str` above collapsed to
|
|
// `None`), both `result` and `error`, or neither is malformed; forward
|
|
// `Null` so the broker fails closed (deny) rather than laundering a
|
|
// possibly-`selected` payload into an approval.
|
|
(None, Some(id)) => {
|
|
let well_formed = msg.get("method").is_none()
|
|
&& msg.get("result").is_some()
|
|
&& msg.get("error").is_none();
|
|
let result = if well_formed {
|
|
msg.get("result").cloned().unwrap_or(Value::Null)
|
|
} else {
|
|
Value::Null
|
|
};
|
|
Inbound::Response { id, result }
|
|
}
|
|
(None, None) => Inbound::Invalid {
|
|
id: Value::Null,
|
|
code: INVALID_REQUEST,
|
|
message: "jsonrpc: missing method and id".into(),
|
|
},
|
|
}
|
|
}
|
|
|
|
/// `optionId`/`kind` of the single allow option offered on every
|
|
/// `session/request_permission`. buzz-acp's answering side selects the option
|
|
/// whose `kind == "allow_once"` (never by hardcoded `optionId`), and the
|
|
/// authorization predicate on this side requires the returned `optionId` to
|
|
/// equal exactly this value. Keeping option id and kind identical means both
|
|
/// sides agree without a separate lookup table.
|
|
pub const ALLOW_OPTION_ID: &str = "allow_once";
|
|
|
|
/// The two options offered on every permission request: allow-once and
|
|
/// reject-once. First cut ships only these (no session-scoped grant), so every
|
|
/// offered option is already in the desktop card's exact actionable allowlist.
|
|
fn permission_options() -> Value {
|
|
json!([
|
|
{ "optionId": ALLOW_OPTION_ID, "name": "Allow", "kind": ALLOW_OPTION_ID },
|
|
{ "optionId": "reject_once", "name": "Deny", "kind": "reject_once" },
|
|
])
|
|
}
|
|
|
|
/// Build `session/request_permission` params for the negotiated protocol
|
|
/// version. No hybrid shapes — the request must match exactly what the client
|
|
/// negotiated at `initialize`, or a strict client can reject it before policy
|
|
/// is applied.
|
|
///
|
|
/// - **v2** (what buzz-agent negotiates with current buzz-acp): tool context
|
|
/// lives under `subject: {type: "tool_call", toolCall}` with top-level
|
|
/// `title` and `options`.
|
|
/// - **v1** (still negotiated when a client requests it): the legacy shape with
|
|
/// `toolCall` (carrying `kind`) directly at the params level.
|
|
pub fn request_permission_params(
|
|
version: u32,
|
|
session_id: &str,
|
|
tool_call_id: &str,
|
|
title: &str,
|
|
raw_input: &Value,
|
|
) -> Value {
|
|
if version >= 2 {
|
|
json!({
|
|
"sessionId": session_id,
|
|
"title": title,
|
|
"subject": {
|
|
"type": "tool_call",
|
|
"toolCall": {
|
|
"toolCallId": tool_call_id,
|
|
"title": title,
|
|
"rawInput": raw_input,
|
|
},
|
|
},
|
|
"options": permission_options(),
|
|
})
|
|
} else {
|
|
json!({
|
|
"sessionId": session_id,
|
|
"toolCall": {
|
|
"toolCallId": tool_call_id,
|
|
"title": title,
|
|
"kind": "other",
|
|
"rawInput": raw_input,
|
|
},
|
|
"options": permission_options(),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Build an outbound JSON-RPC request `session/request_permission` frame.
|
|
pub fn request_permission(id: Value, params: Value) -> Value {
|
|
json!({
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"method": "session/request_permission",
|
|
"params": params,
|
|
})
|
|
}
|
|
|
|
pub fn ok(id: Value, result: Value) -> Value {
|
|
json!({ "jsonrpc": "2.0", "id": id, "result": result })
|
|
}
|
|
|
|
pub fn err(id: Value, code: i32, message: &str) -> Value {
|
|
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
|
|
}
|
|
|
|
pub fn session_update(sid: &str, update: Value) -> Value {
|
|
json!({
|
|
"jsonrpc": "2.0",
|
|
"method": "session/update",
|
|
"params": { "sessionId": sid, "update": update },
|
|
})
|
|
}
|
|
|
|
/// A `_goose/unstable/session/update` notification — the separate top-level
|
|
/// method goose uses for custom usage and status events. Used by buzz-agent
|
|
/// to emit the `usage_update` payload so buzz-acp's `UsageTracker` can treat
|
|
/// buzz-agent and goose symmetrically.
|
|
pub fn goose_session_update(sid: &str, update: Value) -> Value {
|
|
json!({
|
|
"jsonrpc": "2.0",
|
|
"method": "_goose/unstable/session/update",
|
|
"params": { "sessionId": sid, "update": update },
|
|
})
|
|
}
|
|
|
|
/// Build the `usage_update` payload for a `_goose/unstable/session/update`.
|
|
///
|
|
/// Shared by the two places that report usage — after each LLM round inside a
|
|
/// turn, and once more when the turn completes — so the wire shape cannot drift
|
|
/// between them. A consumer takes the high-water mark per session, so the
|
|
/// mid-turn payloads are supersets of each other and the final one wins; a
|
|
/// divergence in field names or units between the two call sites would instead
|
|
/// show up as tokens silently vanishing, which is the failure this reporting
|
|
/// exists to prevent.
|
|
///
|
|
/// All counts are SESSION-cumulative, matching goose, so buzz-acp's
|
|
/// `UsageTracker` can compute per-turn deltas symmetrically for both agents.
|
|
///
|
|
/// ## `_goose/unstable/session/update` contract (ACP)
|
|
///
|
|
/// | Field | Type | Semantics |
|
|
/// |---|---|---|
|
|
/// | `sessionUpdate` | `"usage_update"` | Discriminant |
|
|
/// | `used` | `u64` | `input + output`; context-usage proxy |
|
|
/// | `contextLimit` | `u64` | `0` — buzz-agent has no context limit tracking |
|
|
/// | `accumulatedInputTokens` | `u64?` | Session-cumulative inclusive input tokens; **absent** when overflow-poisoned |
|
|
/// | `accumulatedOutputTokens` | `u64?` | Session-cumulative output tokens; **absent** when overflow-poisoned |
|
|
/// | `accumulatedCachedInputTokens` | `u64?` | Session-cumulative cache-read tokens; **absent** when never observed (harness restart, goose, or first turn); `0` when provider confirmed no cache hits |
|
|
/// | `accumulatedCacheWriteTokens` | `u64?` | Session-cumulative cache-write tokens; **absent** when never observed (same rules as cached-read) |
|
|
/// | `accumulatedTotalTokens` | `u64?` | Session-cumulative provider total; absent unless every turn reported one |
|
|
/// | `model` | `string` | Effective model id |
|
|
///
|
|
/// **Absence vs explicit zero**: both cache fields are omitted (never `null`)
|
|
/// when the running session has never observed a value for them. An old-harness
|
|
/// consumer that does not recognise these fields ignores them cleanly; a new
|
|
/// consumer that receives them absent treats them as unknown, not zero.
|
|
///
|
|
/// **Overflow poison**: `accumulatedInputTokens` and `accumulatedOutputTokens`
|
|
/// are omitted (never `null`, never `u64::MAX`) when the session-cumulative sum
|
|
/// has overflowed. A consumer that receives them absent treats them as
|
|
/// incomplete, consistent with the `accumulatedTotalTokens` contract.
|
|
///
|
|
/// **Monotonic within a session**: each notification carries a cumulative
|
|
/// snapshot that can only increase. A consumer that takes the high-water mark
|
|
/// always ends up at the final value.
|
|
///
|
|
/// **Per-category independent**: a session where `accumulatedCacheWriteTokens`
|
|
/// is absent but `accumulatedCachedInputTokens` is present is valid — the two
|
|
/// categories are tracked independently. Either can become absent (e.g. after
|
|
/// a provider switch) without poisoning the other.
|
|
pub fn usage_update_payload(
|
|
accumulated_input_tokens: Option<u64>,
|
|
accumulated_output_tokens: Option<u64>,
|
|
accumulated_cached_input_tokens: Option<u64>,
|
|
accumulated_cache_write_tokens: Option<u64>,
|
|
accumulated_total: crate::types::TurnTotalState,
|
|
model: &str,
|
|
pricing_identity: Option<&crate::types::PricingIdentity>,
|
|
) -> Value {
|
|
let mut update = json!({
|
|
"sessionUpdate": "usage_update",
|
|
// used: total tokens as a context-usage proxy; saturate when either
|
|
// side is absent or poisoned (display-only, ACP treats it as dead code).
|
|
// contextLimit: 0 (buzz-agent has no context limit tracking).
|
|
"used": accumulated_input_tokens.unwrap_or(0).saturating_add(accumulated_output_tokens.unwrap_or(0)),
|
|
"contextLimit": 0u64,
|
|
"model": model,
|
|
});
|
|
// accumulatedInputTokens / accumulatedOutputTokens: omitted (never null,
|
|
// never u64::MAX) when the session-cumulative sum has overflowed.
|
|
if let Some(input) = accumulated_input_tokens {
|
|
update["accumulatedInputTokens"] = json!(input);
|
|
}
|
|
if let Some(output) = accumulated_output_tokens {
|
|
update["accumulatedOutputTokens"] = json!(output);
|
|
}
|
|
// accumulatedCachedInputTokens: a subset of accumulatedInputTokens, not an
|
|
// addition to it. Extends goose's usage_update shape; a consumer that does
|
|
// not know the field ignores it and prices exactly as it did before.
|
|
// Absent (never emitted as null) when the session has never observed a
|
|
// cached-input value — goose never emits it, so old-harness compat is clean.
|
|
if let Some(cached) = accumulated_cached_input_tokens {
|
|
update["accumulatedCachedInputTokens"] = json!(cached);
|
|
}
|
|
// accumulatedCacheWriteTokens: a subset of accumulatedInputTokens, not an
|
|
// addition to it. Absent when never observed (same provenance rules as
|
|
// accumulatedCachedInputTokens).
|
|
if let Some(written) = accumulated_cache_write_tokens {
|
|
update["accumulatedCacheWriteTokens"] = json!(written);
|
|
}
|
|
// Only when the cumulative is exactly known — never when Unseen (no total
|
|
// ever observed) or Unknown (at least one turn lacked a total). A goose
|
|
// consumer that doesn't recognise the field ignores it.
|
|
if let Some(total) = accumulated_total.exact_value() {
|
|
update["accumulatedTotalTokens"] = json!(total);
|
|
}
|
|
// pricingIdentity: present only when the publisher proved applicability.
|
|
// Absent (never null) when unproven — old consumers ignore the field.
|
|
if let Some(pi) = pricing_identity {
|
|
update["pricingIdentity"] = serde_json::to_value(pi).unwrap_or(serde_json::Value::Null);
|
|
}
|
|
update
|
|
}
|
|
|
|
/// A `session/update` notification carrying a `update._meta.goose.<key>` field.
|
|
/// Used to advertise `activeRunId` (so steer-capable clients can target the
|
|
/// in-flight run) and `queuedSteer` (so they can correlate an accepted steer
|
|
/// with the chunk that later picks it up) — matching goose's wire layout where
|
|
/// `_meta` is nested inside the `update` object (per the ACP `SessionInfoUpdate`
|
|
/// schema), not alongside it at the params level.
|
|
pub fn session_update_with_goose_meta(sid: &str, update: Value, goose_meta: Value) -> Value {
|
|
let mut update = update;
|
|
update["_meta"] = json!({ "goose": goose_meta });
|
|
json!({
|
|
"jsonrpc": "2.0",
|
|
"method": "session/update",
|
|
"params": {
|
|
"sessionId": sid,
|
|
"update": update,
|
|
},
|
|
})
|
|
}
|
|
|
|
pub async fn send(wire: &WireSender, msg: Value) {
|
|
let _ = send_checked(wire, msg).await;
|
|
}
|
|
|
|
/// Enqueue a frame, reporting whether the writer accepted it. Unlike mpsc's
|
|
/// non-blocking `try_send`, this awaits channel capacity; it fails only when
|
|
/// the writer task has dropped its receiver, which happens exactly when the
|
|
/// writer has exited because stdout is closed/broken. A frame that fails here
|
|
/// will never be written, so callers that correlate a response — the
|
|
/// permission broker — must fail closed immediately rather than wait out a
|
|
/// deadline for a reply that can never arrive.
|
|
pub async fn send_checked(wire: &WireSender, msg: Value) -> Result<(), ()> {
|
|
wire.send(WireMsg::Notify(msg)).await.map_err(|_| ())
|
|
}
|
|
|
|
pub async fn read_bounded_line<R: AsyncBufRead + Unpin>(
|
|
stdin: &mut R,
|
|
max: usize,
|
|
) -> std::io::Result<Option<String>> {
|
|
let mut buf: Vec<u8> = Vec::new();
|
|
loop {
|
|
let chunk = stdin.fill_buf().await?;
|
|
if chunk.is_empty() {
|
|
if !buf.is_empty() {
|
|
tracing::error!(
|
|
"io: unterminated frame at EOF ({} bytes dropped)",
|
|
buf.len()
|
|
);
|
|
}
|
|
return Ok(None);
|
|
}
|
|
let take = chunk
|
|
.iter()
|
|
.position(|b| *b == b'\n')
|
|
.map_or(chunk.len(), |i| i + 1);
|
|
if buf.len().saturating_add(take) > max {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidData,
|
|
format!("io: line exceeds max ({max} bytes)"),
|
|
));
|
|
}
|
|
buf.extend_from_slice(&chunk[..take]);
|
|
stdin.consume(take);
|
|
if buf.ends_with(b"\n") {
|
|
buf.pop();
|
|
if buf.ends_with(b"\r") {
|
|
buf.pop();
|
|
}
|
|
match String::from_utf8(buf) {
|
|
Ok(s) => return Ok(Some(s)),
|
|
Err(_) => {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidData,
|
|
"io: frame contains invalid UTF-8",
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn writer_task(rx: mpsc::Receiver<WireMsg>) {
|
|
write_frames(rx, tokio::io::stdout()).await;
|
|
}
|
|
|
|
/// Drain `rx`, writing each frame to `out` as a newline-terminated JSON line.
|
|
/// Generic over the sink so tests can inject an `AsyncWrite` that fails on
|
|
/// flush; production passes stdout.
|
|
///
|
|
/// Both `write_all` and `flush` failure are connection-fatal: they return,
|
|
/// dropping `rx` so `async_main`'s writer-death arm cancels every session.
|
|
/// Flush must be fatal too — a blocking stdout can report `Ok` from
|
|
/// `write_all` when it only schedules the underlying write and surface the
|
|
/// real error at `flush`, so ignoring flush failure would leave a dead stdout
|
|
/// undetected and strand any correlated ask waiting for a reply that can never
|
|
/// be written.
|
|
pub(crate) async fn write_frames<W: AsyncWrite + Unpin>(
|
|
mut rx: mpsc::Receiver<WireMsg>,
|
|
mut out: W,
|
|
) {
|
|
while let Some(msg) = rx.recv().await {
|
|
let WireMsg::Notify(v) = msg;
|
|
let mut s = match serde_json::to_string(&v) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
tracing::error!("io: serialize: {e}");
|
|
continue;
|
|
}
|
|
};
|
|
s.push('\n');
|
|
if out.write_all(s.as_bytes()).await.is_err() || out.flush().await.is_err() {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn session_new_params_deserializes_system_prompt() {
|
|
let json = serde_json::json!({
|
|
"cwd": "/tmp/test",
|
|
"mcpServers": [],
|
|
"systemPrompt": "You are a helpful agent."
|
|
});
|
|
let params: SessionNewParams = serde_json::from_value(json).unwrap();
|
|
assert_eq!(params.cwd, "/tmp/test");
|
|
assert_eq!(
|
|
params.system_prompt.as_deref(),
|
|
Some("You are a helpful agent.")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn session_new_params_system_prompt_defaults_to_none() {
|
|
let json = serde_json::json!({
|
|
"cwd": "/tmp/test",
|
|
"mcpServers": []
|
|
});
|
|
let params: SessionNewParams = serde_json::from_value(json).unwrap();
|
|
assert_eq!(params.cwd, "/tmp/test");
|
|
assert!(params.system_prompt.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn session_new_params_ignores_unknown_fields() {
|
|
// Backward compat: old agents with new harness — unknown fields are ignored.
|
|
let json = serde_json::json!({
|
|
"cwd": "/tmp/test",
|
|
"mcpServers": [],
|
|
"unknownField": "should be ignored"
|
|
});
|
|
let params: SessionNewParams = serde_json::from_value(json).unwrap();
|
|
assert_eq!(params.cwd, "/tmp/test");
|
|
assert!(params.system_prompt.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn session_new_params_empty_string_system_prompt() {
|
|
// An explicit empty string is distinct from absent — deserializes to Some("").
|
|
let json = serde_json::json!({
|
|
"cwd": "/tmp/test",
|
|
"mcpServers": [],
|
|
"systemPrompt": ""
|
|
});
|
|
let params: SessionNewParams = serde_json::from_value(json).unwrap();
|
|
assert_eq!(params.system_prompt, Some(String::new()));
|
|
}
|
|
|
|
// ── usage_update_payload: pricingIdentity emission ───────────────────────
|
|
|
|
fn make_pi(authority: &str, model: &str) -> crate::types::PricingIdentity {
|
|
crate::types::PricingIdentity {
|
|
authority: authority.to_string(),
|
|
model: model.to_string(),
|
|
cache_class: None,
|
|
}
|
|
}
|
|
|
|
/// When a proven identity is passed, the wire payload MUST include a
|
|
/// `pricingIdentity` object with camelCase keys, and it MUST NOT be null.
|
|
#[test]
|
|
fn usage_update_payload_includes_pricing_identity_when_proven() {
|
|
let pi = make_pi("api.anthropic.com", "claude-opus-4-5");
|
|
let payload = usage_update_payload(
|
|
Some(1000),
|
|
Some(200),
|
|
None,
|
|
None,
|
|
crate::types::TurnTotalState::Unseen,
|
|
"claude-opus-4-5",
|
|
Some(&pi),
|
|
);
|
|
|
|
let pi_wire = &payload["pricingIdentity"];
|
|
assert!(
|
|
!pi_wire.is_null(),
|
|
"pricingIdentity must be present when identity is proven"
|
|
);
|
|
assert_eq!(pi_wire["authority"], serde_json::json!("api.anthropic.com"));
|
|
assert_eq!(pi_wire["model"], serde_json::json!("claude-opus-4-5"));
|
|
// cacheClass absent when None (skip_serializing_if)
|
|
assert!(pi_wire.get("cacheClass").is_none() || pi_wire["cacheClass"].is_null());
|
|
}
|
|
|
|
/// When `pricing_identity` is `None` (unproven: custom endpoint, mixed
|
|
/// identities, etc.), the field MUST be absent from the wire payload.
|
|
/// It must never appear as `null`.
|
|
#[test]
|
|
fn usage_update_payload_omits_pricing_identity_when_absent() {
|
|
let payload = usage_update_payload(
|
|
Some(500),
|
|
Some(100),
|
|
None,
|
|
None,
|
|
crate::types::TurnTotalState::Unseen,
|
|
"some-model",
|
|
None, // no proven identity
|
|
);
|
|
|
|
assert!(
|
|
payload.get("pricingIdentity").is_none(),
|
|
"pricingIdentity must be absent (never null) when identity is unproven"
|
|
);
|
|
}
|
|
|
|
/// A custom endpoint (Databricks, corporate proxy) must not produce a
|
|
/// wire `pricingIdentity` — it must be absent.
|
|
///
|
|
/// This relies on the caller passing `None`; the payload builder must not
|
|
/// inject a default. This test documents the contract.
|
|
#[test]
|
|
fn usage_update_payload_no_pricing_identity_for_custom_endpoint() {
|
|
// Custom endpoint → caller passes None (pricing_authority returned None).
|
|
let payload = usage_update_payload(
|
|
Some(800),
|
|
Some(150),
|
|
Some(200),
|
|
None,
|
|
crate::types::TurnTotalState::Unseen,
|
|
"databricks-llama-4",
|
|
None,
|
|
);
|
|
|
|
assert!(
|
|
payload.get("pricingIdentity").is_none(),
|
|
"custom endpoint: pricingIdentity must not appear on the wire"
|
|
);
|
|
// Cache field must still be present (independent).
|
|
assert_eq!(
|
|
payload["accumulatedCachedInputTokens"],
|
|
serde_json::json!(200)
|
|
);
|
|
}
|
|
|
|
/// When input is overflow-poisoned (None), the wire payload must omit
|
|
/// `accumulatedInputTokens` entirely — never null, never u64::MAX.
|
|
#[test]
|
|
fn usage_update_payload_omits_input_when_poisoned() {
|
|
let payload = usage_update_payload(
|
|
None, // overflow-poisoned
|
|
Some(150),
|
|
None,
|
|
None,
|
|
crate::types::TurnTotalState::Unseen,
|
|
"model",
|
|
None,
|
|
);
|
|
assert!(
|
|
payload.get("accumulatedInputTokens").is_none(),
|
|
"poisoned accumulatedInputTokens must be absent, not null or MAX"
|
|
);
|
|
// output still present
|
|
assert_eq!(payload["accumulatedOutputTokens"], serde_json::json!(150));
|
|
}
|
|
|
|
/// When output is overflow-poisoned (None), the wire payload must omit
|
|
/// `accumulatedOutputTokens` entirely — never null, never u64::MAX.
|
|
#[test]
|
|
fn usage_update_payload_omits_output_when_poisoned() {
|
|
let payload = usage_update_payload(
|
|
Some(800),
|
|
None, // overflow-poisoned
|
|
None,
|
|
None,
|
|
crate::types::TurnTotalState::Unseen,
|
|
"model",
|
|
None,
|
|
);
|
|
assert!(
|
|
payload.get("accumulatedOutputTokens").is_none(),
|
|
"poisoned accumulatedOutputTokens must be absent, not null or MAX"
|
|
);
|
|
// input still present
|
|
assert_eq!(payload["accumulatedInputTokens"], serde_json::json!(800));
|
|
}
|
|
|
|
/// When both are present and exact, the wire payload emits both at their
|
|
/// values (unchanged goose-compatible behavior).
|
|
#[test]
|
|
fn usage_update_payload_emits_both_when_exact() {
|
|
let payload = usage_update_payload(
|
|
Some(1000),
|
|
Some(200),
|
|
None,
|
|
None,
|
|
crate::types::TurnTotalState::Unseen,
|
|
"model",
|
|
None,
|
|
);
|
|
assert_eq!(payload["accumulatedInputTokens"], serde_json::json!(1000));
|
|
assert_eq!(payload["accumulatedOutputTokens"], serde_json::json!(200));
|
|
}
|
|
|
|
// ── request_permission_params: version-aware wire shape ──────────────────
|
|
|
|
/// v2 (what buzz-agent negotiates with current buzz-acp): tool context is
|
|
/// nested under `subject: {type: "tool_call", toolCall}` with top-level
|
|
/// `title` and `options`, matching the ACP v2 `RequestPermissionRequest`.
|
|
#[test]
|
|
fn request_permission_params_v2_nests_tool_call_under_subject() {
|
|
let raw = json!({ "command": "ls" });
|
|
let p = request_permission_params(2, "ses_1", "fake__shell", "fake__shell", &raw);
|
|
|
|
assert_eq!(p["sessionId"], "ses_1");
|
|
assert_eq!(p["title"], "fake__shell");
|
|
assert_eq!(p["subject"]["type"], "tool_call");
|
|
assert_eq!(p["subject"]["toolCall"]["toolCallId"], "fake__shell");
|
|
assert_eq!(p["subject"]["toolCall"]["title"], "fake__shell");
|
|
assert_eq!(p["subject"]["toolCall"]["rawInput"], raw);
|
|
// No hybrid: v2 must NOT carry a top-level `toolCall`.
|
|
assert!(p.get("toolCall").is_none(), "v2 must not use the v1 shape");
|
|
assert_options(&p["options"]);
|
|
}
|
|
|
|
/// v1 (still negotiated when a client requests it): the legacy shape with
|
|
/// `toolCall` (carrying `kind`) directly at the params level, no `subject`.
|
|
#[test]
|
|
fn request_permission_params_v1_uses_legacy_top_level_tool_call() {
|
|
let raw = json!({ "command": "ls" });
|
|
let p = request_permission_params(1, "ses_1", "fake__shell", "fake__shell", &raw);
|
|
|
|
assert_eq!(p["sessionId"], "ses_1");
|
|
assert_eq!(p["toolCall"]["toolCallId"], "fake__shell");
|
|
assert_eq!(p["toolCall"]["title"], "fake__shell");
|
|
assert_eq!(p["toolCall"]["kind"], "other");
|
|
assert_eq!(p["toolCall"]["rawInput"], raw);
|
|
// No hybrid: v1 must NOT carry the v2 `subject` or top-level `title`.
|
|
assert!(p.get("subject").is_none(), "v1 must not use the v2 shape");
|
|
assert!(p.get("title").is_none(), "v1 has no top-level title");
|
|
assert_options(&p["options"]);
|
|
}
|
|
|
|
/// Both offered options are exactly allow-once and reject-once, with
|
|
/// `optionId == kind` so buzz-acp's `kind`-based selector and this side's
|
|
/// `optionId`-based predicate agree without a lookup table.
|
|
fn assert_options(options: &Value) {
|
|
let opts = options.as_array().expect("options is an array");
|
|
assert_eq!(opts.len(), 2, "first cut offers exactly two options");
|
|
assert_eq!(opts[0]["optionId"], ALLOW_OPTION_ID);
|
|
assert_eq!(opts[0]["kind"], ALLOW_OPTION_ID);
|
|
assert_eq!(opts[0]["name"], "Allow");
|
|
assert_eq!(opts[1]["optionId"], "reject_once");
|
|
assert_eq!(opts[1]["kind"], "reject_once");
|
|
assert_eq!(opts[1]["name"], "Deny");
|
|
}
|
|
|
|
/// The outbound frame wraps params in a JSON-RPC request whose id echoes
|
|
/// back verbatim so the broker can correlate the response.
|
|
#[test]
|
|
fn request_permission_frame_is_a_correlatable_jsonrpc_request() {
|
|
let params = request_permission_params(2, "ses_1", "t", "t", &json!({}));
|
|
let frame = request_permission(json!("perm-7"), params);
|
|
assert_eq!(frame["jsonrpc"], "2.0");
|
|
assert_eq!(frame["id"], "perm-7");
|
|
assert_eq!(frame["method"], "session/request_permission");
|
|
assert_eq!(frame["params"]["sessionId"], "ses_1");
|
|
}
|
|
|
|
// ── classify: bare responses route to the broker ─────────────────────────
|
|
|
|
/// A bare JSON-RPC response (id, no method) is the client's answer to a
|
|
/// request buzz-agent issued; it routes to the broker with its `result`.
|
|
#[test]
|
|
fn classify_bare_response_routes_to_broker() {
|
|
let msg = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": "perm-3",
|
|
"result": { "outcome": { "outcome": "selected", "optionId": ALLOW_OPTION_ID } },
|
|
});
|
|
match classify(&msg) {
|
|
Inbound::Response { id, result } => {
|
|
assert_eq!(id, json!("perm-3"));
|
|
assert_eq!(result["outcome"]["outcome"], "selected");
|
|
}
|
|
other => panic!("expected Response, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// A JSON-RPC error response (id, `error`, no `result`) still routes to the
|
|
/// broker but with `result == Null`, which the authorization predicate
|
|
/// fails closed. buzz-agent never leaves the waiter hanging on an error.
|
|
#[test]
|
|
fn classify_error_response_routes_with_null_result() {
|
|
let msg = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": "perm-3",
|
|
"error": { "code": -32601, "message": "method not found" },
|
|
});
|
|
match classify(&msg) {
|
|
Inbound::Response { id, result } => {
|
|
assert_eq!(id, json!("perm-3"));
|
|
assert_eq!(result, Value::Null, "error/absent result → Null → deny");
|
|
}
|
|
other => panic!("expected Response, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Carl's frame #1: a response carrying BOTH `result` and `error` is
|
|
/// structurally ambiguous and must NOT deliver the `result`, even when that
|
|
/// `result` is a well-formed `selected`/`allow_once` payload. The wire layer
|
|
/// normalizes it to `Null` so the broker denies instead of the frame
|
|
/// laundering an approval upstream of every fail-closed check.
|
|
#[test]
|
|
fn classify_response_with_both_result_and_error_denies() {
|
|
let msg = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": "perm-3",
|
|
"result": { "outcome": { "outcome": "selected", "optionId": ALLOW_OPTION_ID } },
|
|
"error": { "code": -32603, "message": "internal" },
|
|
});
|
|
match classify(&msg) {
|
|
Inbound::Response { id, result } => {
|
|
assert_eq!(id, json!("perm-3"));
|
|
assert_eq!(
|
|
result,
|
|
Value::Null,
|
|
"result+error is malformed → Null → deny, never forward the allow payload"
|
|
);
|
|
}
|
|
other => panic!("expected Response, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Carl's frame #2: a present but non-string `method` is NOT "method
|
|
/// absent". `as_str` collapses `method: 7` to `None`, which lands the frame
|
|
/// in the response arm, but it is not a valid response and must not forward
|
|
/// its `result` (a well-formed `selected` payload here). The structural
|
|
/// check sees the present `method` member and normalizes to `Null` → deny.
|
|
#[test]
|
|
fn classify_response_with_non_string_method_denies() {
|
|
let msg = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": "perm-3",
|
|
"method": 7,
|
|
"result": { "outcome": { "outcome": "selected", "optionId": ALLOW_OPTION_ID } },
|
|
});
|
|
match classify(&msg) {
|
|
Inbound::Response { id, result } => {
|
|
assert_eq!(id, json!("perm-3"));
|
|
assert_eq!(
|
|
result,
|
|
Value::Null,
|
|
"present non-string method → not a valid response → Null → deny"
|
|
);
|
|
}
|
|
other => panic!("expected Response, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
// ── send_checked: observable wire closure ────────────────────────────────
|
|
|
|
/// `send_checked` reports `Ok` while the writer's receiver is alive and
|
|
/// `Err` once it is gone (writer task exited on closed/broken stdout). This
|
|
/// is the contract the permission broker relies on to fail an undeliverable
|
|
/// ask closed immediately instead of waiting out its deadline for a reply
|
|
/// that can never be written.
|
|
#[tokio::test]
|
|
async fn send_checked_reports_closure_when_writer_gone() {
|
|
let (tx, rx) = mpsc::channel::<WireMsg>(4);
|
|
assert!(
|
|
send_checked(&tx, json!({ "ok": 1 })).await.is_ok(),
|
|
"send succeeds while the writer receiver is alive"
|
|
);
|
|
drop(rx); // writer exited → receiver dropped
|
|
assert!(
|
|
send_checked(&tx, json!({ "ok": 2 })).await.is_err(),
|
|
"send reports failure once the writer is gone"
|
|
);
|
|
}
|
|
}
|