mirror of
https://github.com/block/buzz.git
synced 2026-11-13 12:51:34 +00:00
Adopts main's tool-call permission gate (#5712) onto the goose loop. The conflicts were semantic, not textual: #5712 landed +2494/-299 inside `crates/buzz-agent`, the crate this branch rewrote, so five of main's files (`llm.rs`, `mcp.rs`, `fake_llm.rs`, `regressions.rs`, and main's `agent.rs` loop) no longer exist here and their share of the feature had to be re-expressed against goose. Taken from main unchanged: `permission.rs` (the broker), `wire.rs`'s `session/request_permission` shapes, `Inbound::Response`, `send_checked`, and `write_frames`. Re-expressed: * The gate moved from main's `RunCtx::execute_parallel` to `tools::run_one`, which is where this branch dispatches model-issued calls. It runs after the `tool_call` announcement and before `Agent::dispatch_tool_call`, so a denied call still reaches a terminal wire state. * The broker takes goose's `CancellationToken` instead of a `watch::Receiver<bool>`, and an `AskSubject` instead of buzz's deleted `ToolCall` type. * Argument-shape validation is dropped: it guarded main's `mcp.rs`, and goose's `CallToolRequestParams::arguments` is already `Option<Map>`, so a non-object cannot be represented. * `max_pending_permissions` / `permission_timeout` follow this crate's `env_parse`-with-default style rather than main's validated `parse_env`. Two behaviour notes worth review: * `load_skill` is now **gated**, where main exempted it as an in-process built-in. Under goose it is a real tool on the skills platform extension, dispatched through the same path as any MCP tool. Exempting it would need an allowlist keyed on tool *name* — which an untrusted MCP server controls, and could register its own `load_skill` to inherit. Test renamed accordingly. * Connection teardown already cancels every session (`serve`'s shutdown), so main's `cancel_all_sessions` plus writer-death select arm had no separate work to do here; the broker's wire-closed path covers the rest. Test harness changes: * `tests/common/mod.rs`'s fake LLM now answers SSE. goose's openai-compatible provider requests `stream: true` and parses `chat.completion.chunk`; against main's plain `chat.completion` body every turn ended `end_turn` with no tool call and no ask, which reads as a broken gate rather than a broken fixture. Its `/models` lookup is also answered without popping the canned queue. * `tests/approve/mod.rs` auto-approves for the nine suites whose subject is not the boundary, selecting by option `kind` rather than a hardcoded `optionId`. * `cancel.rs` now answers the ask and waits for `FAKE_MCP_CALL_RECEIVED` before cancelling. Without that the cancel resolved the *permission wait*, the tool was never dispatched, and `cancel_propagates_notifications_cancelled_to_mcp` failed — a test named "cancel mid tool call" was exercising "cancel before tool call". Verified at this tree: `buzz-agent` full suite 111 unit + 36 integration (including all 9 boundary tests) pass, `clippy --all-targets -D warnings` clean, `cargo fmt --check` clean, `cargo build --workspace` clean, and both `Cargo.lock` files resolve `--locked` (8 goose deps in root, 0 in desktop). Co-authored-by: Michael Neale <michael.neale@gmail.com> Signed-off-by: Michael Neale <michael.neale@gmail.com>
396 lines
14 KiB
Rust
396 lines
14 KiB
Rust
//! End-to-end against the **real** `buzz-dev-mcp`, not the fake.
|
|
//!
|
|
//! Everything else in this crate's test suite uses `fake-mcp`, which answers
|
|
//! whatever the test tells it to. That proves our plumbing but not that it
|
|
//! matches the server buzz actually ships. This test drives the real binary:
|
|
//! real tool names, real schemas, real in-process todo state, real
|
|
//! `_Stop`/`_PostCompact` semantics.
|
|
//!
|
|
//! The scenario is the one buzz-agent's `_Stop` veto exists for: the model
|
|
//! records an open todo item and then tries to stop. It must not be allowed
|
|
//! to.
|
|
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::net::TcpListener;
|
|
use std::path::PathBuf;
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::{mpsc, Arc};
|
|
use std::time::Duration;
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
mod approve;
|
|
|
|
/// Locate the real `buzz-dev-mcp` binary in the workspace target dir.
|
|
///
|
|
/// It is a separate binary crate, so `CARGO_BIN_EXE_` is unavailable here and
|
|
/// we resolve it by walking up to the workspace target dir.
|
|
fn buzz_dev_mcp() -> PathBuf {
|
|
let mut dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
loop {
|
|
for profile in ["debug", "release"] {
|
|
let candidate = dir.join("target").join(profile).join("buzz-dev-mcp");
|
|
if candidate.is_file() {
|
|
return candidate;
|
|
}
|
|
}
|
|
if !dir.pop() {
|
|
break;
|
|
}
|
|
}
|
|
// Fail loudly rather than skipping. buzz-dev-mcp is a workspace sibling
|
|
// that `just ci` always builds, so a miss means the search is wrong -- and
|
|
// a silent skip hides exactly the breakage this test exists to catch.
|
|
panic!(
|
|
"buzz-dev-mcp not found searching upward from {}. Run `cargo build -p buzz-dev-mcp`.",
|
|
env!("CARGO_MANIFEST_DIR")
|
|
);
|
|
}
|
|
|
|
/// Provider that drives a scripted conversation:
|
|
/// generation 1 → call `todo` to record one open item
|
|
/// generation 2+ → plain text (i.e. "I'm done")
|
|
///
|
|
/// Returns every request body so the test can see what the model was shown.
|
|
fn spawn_scripted_provider() -> (String, mpsc::Receiver<Value>, Arc<AtomicUsize>) {
|
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
|
|
let addr = listener.local_addr().expect("addr");
|
|
let (tx, rx) = mpsc::channel();
|
|
let calls = Arc::new(AtomicUsize::new(0));
|
|
let counter = calls.clone();
|
|
|
|
std::thread::spawn(move || {
|
|
for stream in listener.incoming() {
|
|
let Ok(mut stream) = stream else { continue };
|
|
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
|
|
|
|
let mut request_line = String::new();
|
|
if reader.read_line(&mut request_line).is_err() {
|
|
continue;
|
|
}
|
|
let mut content_length = 0usize;
|
|
loop {
|
|
let mut line = String::new();
|
|
if reader.read_line(&mut line).is_err() {
|
|
break;
|
|
}
|
|
let t = line.trim_end();
|
|
if t.is_empty() {
|
|
break;
|
|
}
|
|
if let Some(v) = t
|
|
.strip_prefix("content-length: ")
|
|
.or_else(|| t.strip_prefix("Content-Length: "))
|
|
{
|
|
content_length = v.parse().unwrap_or(0);
|
|
}
|
|
}
|
|
let mut body = vec![0u8; content_length];
|
|
use std::io::Read;
|
|
let _ = reader.read_exact(&mut body);
|
|
|
|
let respond = |stream: &mut std::net::TcpStream, ct: &str, payload: String| {
|
|
let _ = stream.write_all(
|
|
format!(
|
|
"HTTP/1.1 200 OK\r\ncontent-type: {ct}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
|
payload.len(),
|
|
payload
|
|
)
|
|
.as_bytes(),
|
|
);
|
|
let _ = stream.flush();
|
|
};
|
|
|
|
if request_line.contains("/models") {
|
|
respond(
|
|
&mut stream,
|
|
"application/json",
|
|
json!({ "object": "list", "data": [] }).to_string(),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if let Ok(req) = serde_json::from_slice::<Value>(&body) {
|
|
let _ = tx.send(req);
|
|
}
|
|
let n = counter.fetch_add(1, Ordering::SeqCst) + 1;
|
|
|
|
let usage = json!({ "prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7 });
|
|
let sse = if n == 1 {
|
|
// Record one OPEN todo item via the real `todo` tool.
|
|
let args =
|
|
json!({ "todos": [{ "text": "finish the work", "done": false }] }).to_string();
|
|
let call = json!({
|
|
"id": "c", "object": "chat.completion.chunk", "created": 1,
|
|
"model": "fake-model",
|
|
"choices": [{
|
|
"index": 0,
|
|
"delta": {
|
|
"role": "assistant",
|
|
"tool_calls": [{
|
|
"index": 0,
|
|
"id": "call_1",
|
|
"type": "function",
|
|
"function": { "name": "devtools__todo", "arguments": args },
|
|
}],
|
|
},
|
|
"finish_reason": null,
|
|
}],
|
|
});
|
|
let done = json!({
|
|
"id": "c", "object": "chat.completion.chunk", "created": 1,
|
|
"model": "fake-model",
|
|
"choices": [{ "index": 0, "delta": {}, "finish_reason": "tool_calls" }],
|
|
"usage": usage,
|
|
});
|
|
format!("data: {call}\n\ndata: {done}\n\ndata: [DONE]\n\n")
|
|
} else {
|
|
let text = json!({
|
|
"id": "c", "object": "chat.completion.chunk", "created": 1,
|
|
"model": "fake-model",
|
|
"choices": [{
|
|
"index": 0,
|
|
"delta": { "role": "assistant", "content": "All finished!" },
|
|
"finish_reason": null,
|
|
}],
|
|
});
|
|
let done = json!({
|
|
"id": "c", "object": "chat.completion.chunk", "created": 1,
|
|
"model": "fake-model",
|
|
"choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }],
|
|
"usage": usage,
|
|
});
|
|
format!("data: {text}\n\ndata: {done}\n\ndata: [DONE]\n\n")
|
|
};
|
|
|
|
respond(&mut stream, "text/event-stream", sse);
|
|
}
|
|
});
|
|
|
|
(format!("http://{addr}"), rx, calls)
|
|
}
|
|
|
|
struct Harness {
|
|
child: Child,
|
|
stdin: std::process::ChildStdin,
|
|
stdout: BufReader<std::process::ChildStdout>,
|
|
next_id: i64,
|
|
}
|
|
|
|
impl Harness {
|
|
fn start(base_url: &str, home: &std::path::Path) -> Self {
|
|
let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-agent"))
|
|
.env("BUZZ_AGENT_PROVIDER", "openai-compat")
|
|
.env("BUZZ_AGENT_MODEL", "fake-model")
|
|
.env("OPENAI_COMPAT_API_KEY", "k")
|
|
.env("OPENAI_COMPAT_BASE_URL", base_url)
|
|
.env("HOME", home)
|
|
.env("XDG_CONFIG_HOME", home.join("config"))
|
|
.env("XDG_DATA_HOME", home.join("data"))
|
|
.env("GOOSE_DISABLE_KEYRING", "1")
|
|
.env("RUST_LOG", "warn")
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::inherit())
|
|
.spawn()
|
|
.expect("spawn");
|
|
let stdin = child.stdin.take().expect("stdin");
|
|
let stdout = BufReader::new(child.stdout.take().expect("stdout"));
|
|
Self {
|
|
child,
|
|
stdin,
|
|
stdout,
|
|
next_id: 0,
|
|
}
|
|
}
|
|
|
|
fn request(&mut self, method: &str, params: Value) -> i64 {
|
|
self.next_id += 1;
|
|
let id = self.next_id;
|
|
writeln!(
|
|
self.stdin,
|
|
"{}",
|
|
json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })
|
|
)
|
|
.expect("write");
|
|
self.stdin.flush().expect("flush");
|
|
id
|
|
}
|
|
|
|
fn await_response(&mut self, id: i64) -> (Value, Vec<Value>) {
|
|
let mut notifications = Vec::new();
|
|
loop {
|
|
let mut line = String::new();
|
|
let n = self.stdout.read_line(&mut line).expect("read");
|
|
assert_ne!(n, 0, "agent closed stdout awaiting id={id}");
|
|
let Ok(msg) = serde_json::from_str::<Value>(&line) else {
|
|
continue;
|
|
};
|
|
// Answer the authorization gate so the tool under test actually
|
|
// runs. This suite's subject is not the permission boundary (see
|
|
// `permission_boundary.rs`), so approval is automatic.
|
|
if approve::is_permission_request(&msg) {
|
|
let response = approve::approve(&msg);
|
|
writeln!(self.stdin, "{response}").expect("write approval");
|
|
self.stdin.flush().expect("flush approval");
|
|
continue;
|
|
}
|
|
if msg.get("id").and_then(Value::as_i64) == Some(id) {
|
|
return (msg, notifications);
|
|
}
|
|
if msg.get("method").is_some() {
|
|
notifications.push(msg);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for Harness {
|
|
fn drop(&mut self) {
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn real_dev_mcp_stop_hook_blocks_end_of_turn() {
|
|
let mcp = buzz_dev_mcp();
|
|
|
|
let (base_url, requests, calls) = spawn_scripted_provider();
|
|
let home = tempfile::tempdir().expect("home");
|
|
let cwd = tempfile::tempdir().expect("cwd");
|
|
let mut h = Harness::start(&base_url, home.path());
|
|
|
|
let id = h.request("initialize", json!({ "protocolVersion": 2 }));
|
|
let _ = h.await_response(id);
|
|
|
|
let id = h.request(
|
|
"session/new",
|
|
json!({
|
|
"cwd": cwd.path().to_str().unwrap(),
|
|
"mcpServers": [{
|
|
"name": "devtools",
|
|
"command": mcp.to_str().unwrap(),
|
|
"args": [],
|
|
"env": [],
|
|
}],
|
|
}),
|
|
);
|
|
let (resp, _) = h.await_response(id);
|
|
let session_id = resp["result"]["sessionId"]
|
|
.as_str()
|
|
.unwrap_or_else(|| panic!("session/new failed against real buzz-dev-mcp: {resp}"))
|
|
.to_string();
|
|
|
|
let id = h.request(
|
|
"session/prompt",
|
|
json!({
|
|
"sessionId": session_id,
|
|
"prompt": [{ "type": "text", "text": "do the work" }],
|
|
}),
|
|
);
|
|
let (resp, notifications) = h.await_response(id);
|
|
assert_eq!(
|
|
resp["result"]["stopReason"], "end_turn",
|
|
"turn should end cleanly once the veto budget is spent: {resp}"
|
|
);
|
|
|
|
// The real `todo` tool must have run.
|
|
let todo_ran = notifications.iter().any(|n| {
|
|
n["params"]["update"]["sessionUpdate"] == "tool_call"
|
|
&& n["params"]["update"]["title"]
|
|
.as_str()
|
|
.is_some_and(|t| t.contains("todo"))
|
|
});
|
|
assert!(
|
|
todo_ran,
|
|
"the real `todo` tool never ran: {notifications:#?}"
|
|
);
|
|
|
|
let mut seen = Vec::new();
|
|
while let Ok(req) = requests.recv_timeout(Duration::from_secs(5)) {
|
|
seen.push(req);
|
|
}
|
|
|
|
// The model tried to stop at generation 2 with an item still open. The
|
|
// real `_Stop` hook must have objected, forcing further generations.
|
|
let n = calls.load(Ordering::SeqCst);
|
|
assert!(
|
|
n >= 3,
|
|
"real _Stop hook did not block end-of-turn: only {n} generations \
|
|
(expected the todo item to force at least one more)"
|
|
);
|
|
|
|
// And the objection the real server produces must have reached the model.
|
|
let all = seen
|
|
.iter()
|
|
.map(|r| r["messages"].to_string())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(
|
|
all.contains("open todo items"),
|
|
"buzz-dev-mcp's objection text never reached the model.\nsaw:\n{all}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn real_dev_mcp_advertises_its_tools() {
|
|
let mcp = buzz_dev_mcp();
|
|
|
|
let (base_url, requests, _) = spawn_scripted_provider();
|
|
let home = tempfile::tempdir().expect("home");
|
|
let cwd = tempfile::tempdir().expect("cwd");
|
|
let mut h = Harness::start(&base_url, home.path());
|
|
|
|
let id = h.request("initialize", json!({ "protocolVersion": 2 }));
|
|
let _ = h.await_response(id);
|
|
|
|
let id = h.request(
|
|
"session/new",
|
|
json!({
|
|
"cwd": cwd.path().to_str().unwrap(),
|
|
"mcpServers": [{
|
|
"name": "devtools",
|
|
"command": mcp.to_str().unwrap(),
|
|
"args": [],
|
|
"env": [],
|
|
}],
|
|
}),
|
|
);
|
|
let (resp, _) = h.await_response(id);
|
|
let session_id = resp["result"]["sessionId"].as_str().unwrap().to_string();
|
|
|
|
let id = h.request(
|
|
"session/prompt",
|
|
json!({ "sessionId": session_id, "prompt": [{ "type": "text", "text": "hi" }] }),
|
|
);
|
|
let _ = h.await_response(id);
|
|
|
|
let first = requests
|
|
.recv_timeout(Duration::from_secs(10))
|
|
.expect("no provider request");
|
|
let tools = first["tools"].to_string();
|
|
|
|
// The shipped tool surface, prefixed by extension name.
|
|
for tool in ["shell", "read_file", "str_replace", "todo"] {
|
|
assert!(
|
|
tools.contains(&format!("devtools__{tool}")),
|
|
"real buzz-dev-mcp tool `{tool}` was not advertised to the model"
|
|
);
|
|
}
|
|
|
|
// Known deviation from buzz-agent, asserted so it cannot regress silently:
|
|
// goose's allowlist gates advertising and dispatch together, so the hooks
|
|
// stay visible and we instruct the model not to call them instead.
|
|
assert!(
|
|
tools.contains("devtools___Stop"),
|
|
"expected _Stop to remain visible (see build_agent's KNOWN DEVIATION)"
|
|
);
|
|
let system = first["messages"][0]["content"].as_str().unwrap_or_default();
|
|
assert!(
|
|
system.contains("lifecycle hooks"),
|
|
"hook guidance missing from the system prompt: {system}"
|
|
);
|
|
}
|