From 69fa4c59b7e5d2b99ba63fe671871fd99adfe231 Mon Sep 17 00:00:00 2001 From: Kyle Zemek Date: Sun, 1 Nov 2026 07:22:16 -0800 Subject: [PATCH] feat(agent-command): let local agents discover advertised plugin tools A plugin can spawn the bundled helper with `--advertise` inside its existing app-held MCP connection; the helper saves that session to a same-user-private file. A local agent Buzz did not start (Claude Code, Codex, Pi, Nexus) runs the installed helper with no inherited connection and calls the newest advertised session that still answers, dropping stale ones. Inherited connections keep precedence. Fixes NEXUS-4115 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Kyle Zemek --- crates/agent-command/src/main.rs | 326 +++++++++++++++++++++--- crates/agent-command/tests/discovery.rs | 320 +++++++++++++++++++++++ docs/agents.md | 6 + docs/buzz-help.md | 7 + 4 files changed, 627 insertions(+), 32 deletions(-) create mode 100644 crates/agent-command/tests/discovery.rs diff --git a/crates/agent-command/src/main.rs b/crates/agent-command/src/main.rs index ba8604ee3b..7b719ec613 100644 --- a/crates/agent-command/src/main.rs +++ b/crates/agent-command/src/main.rs @@ -1,9 +1,17 @@ //! One initialized app-held MCP session, inherited by a command and its children. -//! No relay credentials, configuration files, or owner-identity fallback. +//! No relay credentials or owner-identity fallback. A plugin may also advertise +//! one of its sessions to the user's other local agents with `--advertise`; they +//! run this helper without an inherited session and it finds the advertisement. use reqwest::{header::HeaderMap, Client, Url}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use std::{collections::HashMap, path::Path, process::Command, time::Duration}; +use std::{ + collections::{HashMap, HashSet}, + io::Write, + path::{Path, PathBuf}, + process::Command, + time::Duration, +}; type Result = std::result::Result>; const CONNECTION: &str = "BUZZ_AGENT_CONNECTION"; @@ -23,7 +31,18 @@ struct Session { client: Client, url: Url, headers: HeaderMap, + /// Read from an advertisement, so its server may have closed since. + advertised: bool, } +/// The advertised port is not one this user's processes listen on. +#[derive(Debug)] +struct Unowned; +impl std::fmt::Display for Unowned { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("Could not confirm that this user's Buzz holds an advertised port") + } +} +impl std::error::Error for Unowned {} impl Session { fn open(connection: Connection) -> Result { let url = Url::parse(&connection.url)?; @@ -49,6 +68,7 @@ impl Session { .build()?, url, headers, + advertised: false, }) } async fn rpc(&mut self, method: &str, params: Value, notify: bool) -> Result { @@ -56,6 +76,15 @@ impl Session { if !notify { body["id"] = json!(1); } + // A closed server's port may since belong to another user's process. The host + // takes one request per connection, so each request checks just before it. + if self.advertised { + if let Some(owned) = own_listeners()? { + if !self.url.port().is_some_and(|port| owned.contains(&port)) { + return Err(Unowned.into()); + } + } + } let response = self .client .post(self.url.clone()) @@ -128,38 +157,241 @@ impl Session { .status()?; Ok(status.code().unwrap_or(1)) } - async fn call(&mut self, args: &[String]) -> Result { + async fn call(mut self, args: &[String]) -> Result { if !self.headers.contains_key("mcp-session-id") { return Err("Run buzz inside an agent command".into()); } let tools = self.rpc("tools/list", json!({}), false).await?; - if args == ["tools"] { - println!("{}", tools["tools"]); - return Ok(0); + run(vec![(self, tools)], args).await + } +} +/// Lists or calls the tools of `live` sessions, newest first; a name the newest +/// offers wins over the same name in another Buzz window. +async fn run(mut live: Vec<(Session, Value)>, args: &[String]) -> Result { + let mut names = HashSet::new(); + let catalog: Vec = live + .iter() + .flat_map(|(_, tools)| tools["tools"].as_array().cloned().unwrap_or_default()) + .filter(|tool| names.insert(tool["name"].to_string())) + .collect(); + if args == ["tools"] { + println!("{}", Value::Array(catalog)); + return Ok(0); + } + let (name, arguments) = arguments(args, &json!({ "tools": catalog }))?; + let (session, _) = live + .iter_mut() + .find(|(_, tools)| offers(tools, &name)) + .ok_or("Unknown agent tool")?; + let result = session + .rpc( + "tools/call", + json!({"name":name,"arguments":arguments}), + false, + ) + .await?; + if let Some(content) = result["content"].as_array() { + for block in content { + if let Some(text) = block["text"].as_str() { + println!("{text}"); + } } - let (name, arguments) = arguments(args, &tools)?; - let result = self - .rpc( - "tools/call", - json!({"name":name,"arguments":arguments}), - false, - ) - .await?; - if let Some(content) = result["content"].as_array() { - for block in content { - if let Some(text) = block["text"].as_str() { - println!("{text}"); + } + Ok(i32::from(result["isError"] == true)) +} +fn offers(tools: &Value, name: &str) -> bool { + tools["tools"] + .as_array() + .is_some_and(|tools| tools.iter().any(|tool| tool["name"] == name)) +} + +/// Same-user-private advertisements: the user's own agents may use them, other +/// users may not, and nothing here is an account key. +fn advertisements() -> Result { + let home = std::env::home_dir().ok_or("No home directory")?; + let directory = home.join(".buzz-foundation").join("agent-connections"); + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt}; + builder.mode(0o700); + builder.create(&directory)?; + let metadata = std::fs::symlink_metadata(&directory)?; + if !metadata.is_dir() || metadata.uid() != std::fs::metadata(&home)?.uid() { + return Err("The agent connection directory is not private".into()); + } + if metadata.mode() & 0o077 != 0 { + std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700))?; + } + } + #[cfg(not(unix))] + builder.create(&directory)?; + Ok(directory) +} +/// Publishes this inherited, already-initialized session; it lasts as long as the +/// plugin's server, and callers drop it once that stops answering. +fn advertise(connection: &str) -> Result { + let session = Session::open(serde_json::from_str(connection)?)?; + if !session.headers.contains_key("mcp-session-id") { + return Err("Advertise an app-held session from inside an agent command".into()); + } + let directory = advertisements()?; + // Written whole under a dot name, then renamed, so readers never see part of it. + let mut file = tempfile::Builder::new() + .prefix(".") + .rand_bytes(16) + .tempfile_in(&directory)?; + file.write_all(session.connection()?.as_bytes())?; + let name = file + .path() + .file_name() + .ok_or("No file name")? + .to_string_lossy()[1..] + .to_owned(); + file.persist(directory.join(format!("{name}.json")))?; + Ok(0) +} +/// Sessions plugins advertised that still answer, newest first, up to the first +/// that offers `wanted`. One whose server is gone is removed: a later server +/// never accepts its token. +async fn discovered(wanted: Option<&str>) -> Result> { + let directory = advertisements()?; + #[cfg(unix)] + let owner = std::os::unix::fs::MetadataExt::uid(&std::fs::metadata(&directory)?); + // Another caller may prune a file while this one lists them. + let mut files: Vec<_> = std::fs::read_dir(directory)? + .filter_map(|entry| { + let path = entry.ok()?.path(); + let metadata = std::fs::symlink_metadata(&path).ok()?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + // A directory once open to others may still hold their files. + if metadata.uid() != owner || metadata.mode() & 0o077 != 0 { + return None; + } + } + let name = path.file_name()?.to_str()?; + (metadata.is_file() && name.ends_with(".json") && !name.starts_with('.')) + .then_some((metadata.modified().ok()?, path)) + }) + .collect(); + files.sort_by_key(|(modified, _)| std::cmp::Reverse(*modified)); + let (mut live, mut unreachable) = (Vec::new(), None); + for (_, path) in files { + let Some(mut session) = std::fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .and_then(|connection| Session::open(connection).ok()) + .filter(|session| session.headers.contains_key("mcp-session-id")) + else { + continue; + }; + session.advertised = true; + match session.rpc("tools/list", json!({}), false).await { + Ok(tools) => { + // Older servers cannot win the call, so a stalled one need not delay it. + let found = wanted.is_some_and(|name| offers(&tools, name)); + live.push((session, tools)); + if found { + break; } } + Err(error) if error.is::() => { + // A bare connection sends no credentials; only its refusal proves the server closed. + let refused = match session.url.socket_addrs(|| None) { + Ok(addresses) => tokio::net::TcpStream::connect(&*addresses) + .await + .is_err_and(|error| error.kind() == std::io::ErrorKind::ConnectionRefused), + Err(_) => false, + }; + if refused { + let _ = std::fs::remove_file(&path); + } else { + unreachable = Some(error.to_string()); + } + } + Err(error) if gone(error.as_ref()) => { + let _ = std::fs::remove_file(&path); + } + Err(error) => { + let cause = std::iter::successors(Some(error.as_ref()), |error| error.source()); + unreachable = Some(format!( + "Could not reach Buzz: {}", + cause.last().unwrap_or(&*error) + )); + } } - Ok(i32::from(result["isError"] == true)) } + if live.is_empty() { + return Err(unreachable + .unwrap_or_else(|| "No running Buzz offers agent tools to this user's agents".into()) + .into()); + } + Ok(live) +} +/// Loopback ports a process of this user listens on, where the platform can tell. +#[cfg(target_os = "macos")] +fn own_listeners() -> Result>> { + // Without root, lsof sees only this user's processes. + let output = Command::new("/usr/sbin/lsof") + .args(["-nP", "-w", "-iTCP@127.0.0.1", "-sTCP:LISTEN", "-Fn"]) + .output() + .map_err(|error| format!("Could not check this user's listeners: {error}"))? + .stdout; + Ok(Some( + String::from_utf8_lossy(&output) + .lines() + .filter_map(|line| line.strip_prefix("n127.0.0.1:")?.parse().ok()) + .collect(), + )) +} +#[cfg(target_os = "linux")] +fn own_listeners() -> Result>> { + use std::os::unix::fs::MetadataExt; + let check = |error: std::io::Error| format!("Could not check this user's listeners: {error}"); + let uid = std::fs::metadata("/proc/self") + .map_err(check)? + .uid() + .to_string(); + let table = std::fs::read_to_string("/proc/net/tcp").map_err(check)?; + Ok(Some( + table + .lines() + .skip(1) + .filter_map(|line| { + let fields: Vec<_> = line.split_whitespace().collect(); + let (address, port) = fields.get(1)?.split_once(':')?; + let listening = address == "0100007F" && fields.get(3) == Some(&"0A"); + (listening && fields.get(7) == Some(&uid.as_str())) + .then(|| u16::from_str_radix(port, 16).ok())? + }) + .collect(), + )) +} +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +fn own_listeners() -> Result>> { + Ok(None) +} +/// Nothing listens on the server's port, or it no longer admits the session. A +/// connection the caller's own sandbox denied proves neither, so its file stays. +fn gone(error: &(dyn std::error::Error + 'static)) -> bool { + error + .downcast_ref::() + .and_then(reqwest::Error::status) + == Some(reqwest::StatusCode::NOT_FOUND) + || std::iter::successors(Some(error), |error| error.source()).any(|error| { + error + .downcast_ref::() + .is_some_and(|error| error.kind() == std::io::ErrorKind::ConnectionRefused) + }) } fn arguments(args: &[String], catalog: &Value) -> Result<(String, Value)> { let explicit = args.first().is_some_and(|arg| arg == "call"); - let (name, rest) = args[usize::from(explicit)..].split_first().ok_or( - "Usage: buzz tools | buzz --field value | buzz call ''", - )?; + let (name, rest) = args[usize::from(explicit)..] + .split_first() + .ok_or("Name a tool; --help shows the usage")?; let tool = catalog["tools"] .as_array() .and_then(|tools| tools.iter().find(|tool| tool["name"] == name.as_str())) @@ -214,19 +446,49 @@ fn main() { .into()); } - let connection = serde_json::from_str( - &std::env::var(CONNECTION).map_err(|_| "An app-held agent connection is required")?, - )?; - let mut session = Session::open(connection)?; + let inherited = std::env::var(CONNECTION).ok(); let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build()?; - if Path::new(&args[0]).file_stem().and_then(|s| s.to_str()) == Some("buzz") { - runtime.block_on(session.call(&args[1..])) - } else if args.get(1).is_some_and(|arg| arg == "--launch") { - runtime.block_on(session.launch(&args[2..])) - } else { - Err("Expected --launch command [arguments]".into()) + let required = || { + inherited + .as_deref() + .ok_or("An app-held agent connection is required") + }; + let buzz = Path::new(&args[0]).file_stem().and_then(|s| s.to_str()) == Some("buzz"); + match args.get(1).map(String::as_str) { + Some("--launch") if !buzz => runtime + .block_on(Session::open(serde_json::from_str(required()?)?)?.launch(&args[2..])), + Some("--advertise") if !buzz => advertise(required()?), + Some("--help" | "-h") | None => { + // An agent outside a command has no `buzz` alias; it keeps using this path. + let program = if buzz { + "buzz".to_owned() + } else if args[0].contains(char::is_whitespace) { + format!("'{}'", args[0]) + } else { + args[0].clone() + }; + println!( + "Usage: {program} tools | {program} --field value | {program} call ''" + ); + Ok(0) + } + _ => match &inherited { + Some(connection) => runtime + .block_on(Session::open(serde_json::from_str(connection)?)?.call(&args[1..])), + None => { + let args = &args[1..]; + let wanted = if args == ["tools"] { + None + } else { + args.get(usize::from(args[0] == "call")) + }; + runtime.block_on(async { + run(discovered(wanted.map(String::as_str)).await?, args).await + }) + } + }, } })(); match result { diff --git a/crates/agent-command/tests/discovery.rs b/crates/agent-command/tests/discovery.rs new file mode 100644 index 0000000000..a2b6ac8c76 --- /dev/null +++ b/crates/agent-command/tests/discovery.rs @@ -0,0 +1,320 @@ +#![cfg(unix)] +//! A fresh local agent finds a session a plugin advertised, with no inherited +//! connection, through the same helper the app ships. +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpListener; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::{Command, Output}; +use std::time::{Duration, SystemTime}; + +const SESSION: &str = "session-1"; + +/// A loopback MCP server like the app's: one bearer token, one session that +/// `initialize` claims, and 404 for anything it refuses. +fn server(label: &'static str, tools: &'static [&'static str]) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/mcp", listener.local_addr().unwrap()); + std::thread::spawn(move || { + for stream in listener.incoming() { + let mut stream = stream.unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut headers = Vec::new(); + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" { + break; + } + headers.push(line.trim_end().to_ascii_lowercase()); + } + let length = headers + .iter() + .find_map(|header| header.strip_prefix("content-length: ")) + .map_or(0, |length| length.parse().unwrap()); + let mut body = vec![0; length]; + reader.read_exact(&mut body).unwrap(); + let request: Value = serde_json::from_slice(&body).unwrap(); + let has = |header: &str| headers.iter().any(|line| line == header); + let method = request["method"].as_str().unwrap(); + let (status, reply) = if !has(&format!("authorization: bearer {label}-token")) { + ("404 Not Found", None) + } else if method == "initialize" { + ("200 OK", Some(json!({}))) + } else if !has(&format!("mcp-session-id: {SESSION}")) { + ("404 Not Found", None) + } else if method == "tools/list" { + let tools: Vec<_> = tools + .iter() + .map(|name| json!({"name": name, "inputSchema": {"properties": {"text": {"type": "string"}}}})) + .collect(); + ("200 OK", Some(json!({ "tools": tools }))) + } else if method == "tools/call" { + let text = format!( + "{label} {} {}", + request["params"]["name"], request["params"]["arguments"] + ); + ( + "200 OK", + Some(json!({"content": [{"type": "text", "text": text}]})), + ) + } else { + ("202 Accepted", None) + }; + let body = reply.map_or(String::new(), |result| { + json!({"jsonrpc": "2.0", "id": request["id"], "result": result}).to_string() + }); + let _ = write!( + stream, + "HTTP/1.1 {status}\r\nmcp-session-id: {SESSION}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + } + }); + url +} + +fn connection(url: &str, label: &str, session: bool) -> String { + let mut headers = + vec![json!({"name": "authorization", "value": format!("Bearer {label}-token")})]; + if session { + headers.push(json!({"name": "mcp-session-id", "value": SESSION})); + } + json!({ "url": url, "headers": headers }).to_string() +} + +fn helper(home: &Path, inherited: Option<&str>, args: &[&str]) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_buzz-agent-command")); + command + .args(args) + .env("HOME", home) + .env_remove("BUZZ_AGENT_CONNECTION"); + if let Some(connection) = inherited { + command.env("BUZZ_AGENT_CONNECTION", connection); + } + command.output().unwrap() +} + +fn stdout(output: &Output) -> String { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout.clone()).unwrap() +} + +/// Writes an advertisement as `mode`, as another writer might have. +fn plant(path: &Path, connection: String, mode: u32) { + std::fs::write(path, connection).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap(); +} + +fn advertised(home: &Path) -> Vec { + let directory = home.join(".buzz-foundation/agent-connections"); + let mut files: Vec<_> = std::fs::read_dir(directory) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect(); + files.sort(); + files +} + +#[test] +fn fresh_agents_find_advertised_sessions_and_drop_stale_ones() { + let home = tempfile::tempdir().unwrap(); + let home = home.path(); + assert!(!helper(home, None, &["tools"]).status.success()); + + // The app's path: `host.spawn` runs the helper with `--launch`, which claims + // the session and hands it to the bundled helper's `--advertise`. + let old = server("old", &["solo_list", "solo_create"]); + let helper_path = env!("CARGO_BIN_EXE_buzz-agent-command"); + stdout(&helper( + home, + Some(&connection(&old, "old", false)), + &["--launch", helper_path, "--advertise"], + )); + let [first] = advertised(home).try_into().unwrap(); + let saved = std::fs::read_to_string(&first).unwrap(); + assert!(saved.contains(SESSION)); + assert_eq!( + std::fs::metadata(&first).unwrap().permissions().mode() & 0o777, + 0o600 + ); + let directory = first.parent().unwrap(); + assert_eq!( + std::fs::metadata(directory).unwrap().permissions().mode() & 0o777, + 0o700 + ); + std::fs::File::options() + .write(true) + .open(&first) + .unwrap() + .set_modified(SystemTime::now() - Duration::from_secs(60)) + .unwrap(); + + // A second window advertises a newer session; one that stopped is stale. + let new = server("new", &["solo_list"]); + stdout(&helper( + home, + Some(&connection(&new, "new", true)), + &["--advertise"], + )); + let stale = directory.join("stale.json"); + let closed = TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap(); + plant( + &stale, + connection(&format!("http://{closed}/mcp"), "gone", true), + 0o600, + ); + std::fs::File::options() + .write(true) + .open(&stale) + .unwrap() + .set_modified(SystemTime::now() + Duration::from_secs(60)) + .unwrap(); + // A live server that no longer admits the saved session answers 404. + let revoked = directory.join("revoked.json"); + plant(&revoked, connection(&old, "revoked", true), 0o600); + // A file others could have written is never trusted. + let loose = directory.join("loose.json"); + let other = server("other", &["solo_list"]); + plant(&loose, connection(&other, "other", true), 0o644); + + let tools: Value = serde_json::from_str(&stdout(&helper(home, None, &["tools"]))).unwrap(); + let names: Vec<_> = tools + .as_array() + .unwrap() + .iter() + .map(|tool| tool["name"].clone()) + .collect(); + assert_eq!(names, [json!("solo_list"), json!("solo_create")]); + assert!(!stale.exists() && !revoked.exists() && loose.exists()); + assert_eq!(advertised(home).len(), 3); + + // An oldest server that would accept and never answer is not asked about a + // tool a newer server already offers. + let stalled = TcpListener::bind("127.0.0.1:0").unwrap(); + stalled.set_nonblocking(true).unwrap(); + let silent = directory.join("stalled.json"); + plant( + &silent, + connection( + &format!("http://{}/mcp", stalled.local_addr().unwrap()), + "stalled", + true, + ), + 0o600, + ); + std::fs::File::options() + .write(true) + .open(&silent) + .unwrap() + .set_modified(SystemTime::now() - Duration::from_secs(120)) + .unwrap(); + + // Newest window first; a tool only the older one offers still reaches it. + assert_eq!( + stdout(&helper(home, None, &["solo_list", "--text", "hi"])), + "new \"solo_list\" {\"text\":\"hi\"}\n" + ); + assert_eq!( + stdout(&helper( + home, + None, + &["call", "solo_create", "{\"text\":\"x\"}"] + )), + "old \"solo_create\" {\"text\":\"x\"}\n" + ); + assert_eq!( + stalled.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + std::fs::remove_file(silent).unwrap(); + + // An inherited session still wins over advertisements. + let own = server("own", &["solo_list"]); + assert_eq!( + stdout(&helper( + home, + Some(&connection(&own, "own", true)), + &["solo_list", "--text", "a"] + )), + "own \"solo_list\" {\"text\":\"a\"}\n" + ); + // Advertising needs an app-held session, never a bare token. + assert!(!helper( + home, + Some(&connection(&own, "own", false)), + &["--advertise"] + ) + .status + .success()); + assert!(!helper(home, None, &["--advertise"]).status.success()); + + // A port this user's processes do not hold on loopback, as when another user + // takes a closed server's port, never receives the saved credentials. A + // wildcard listener answers on loopback but is not a loopback listener. Kept + // here so a parallel test cannot take a port another case expects closed. + let taken = TcpListener::bind("0.0.0.0:0").unwrap(); + let port = taken.local_addr().unwrap().port(); + for file in advertised(home) { + std::fs::remove_file(file).unwrap(); + } + let advertisement = directory.join("taken.json"); + plant( + &advertisement, + connection(&format!("http://127.0.0.1:{port}/mcp"), "new", true), + 0o600, + ); + let output = helper(home, None, &["tools"]); + assert!(String::from_utf8_lossy(&output.stderr).contains("Could not confirm")); + assert!(advertisement.exists()); + let (mut stream, _) = taken.accept().unwrap(); + let mut received = Vec::new(); + stream.read_to_end(&mut received).unwrap(); + assert!(received.is_empty()); +} + +/// An agent whose own sandbox blocks loopback sees why, and leaves the +/// advertisement for agents that can reach it. +#[test] +#[cfg(target_os = "macos")] +fn a_sandbox_denied_connection_keeps_the_advertisement() { + let home = tempfile::tempdir().unwrap(); + let home = home.path(); + let live = server("live", &["solo_list"]); + stdout(&helper( + home, + Some(&connection(&live, "live", true)), + &["--advertise"], + )); + let denied = Command::new("/usr/bin/sandbox-exec") + .args([ + "-p", + "(version 1)(allow default)(deny network-outbound)", + env!("CARGO_BIN_EXE_buzz-agent-command"), + "tools", + ]) + .env("HOME", home) + .env_remove("BUZZ_AGENT_CONNECTION") + .output() + .unwrap(); + assert!(!denied.status.success()); + assert!(!String::from_utf8_lossy(&denied.stderr).contains("No running Buzz")); + assert_eq!(advertised(home).len(), 1); + assert!(stdout(&helper(home, None, &["tools"])).contains("solo_list")); +} + +#[test] +fn help_names_the_path_an_outside_agent_runs() { + let home = tempfile::tempdir().unwrap(); + let usage = stdout(&helper(home.path(), None, &["--help"])); + let path = env!("CARGO_BIN_EXE_buzz-agent-command"); + assert!(usage.contains(&format!("{path} tools")), "{usage}"); +} diff --git a/docs/agents.md b/docs/agents.md index 9e40ce8de4..010261e38f 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -1239,4 +1239,10 @@ An Attention source run receives `spawn(processId, { args, cwd, env, onStdout, o Source return or failure, cancellation, Attention off, agent stop, plugin unload, command exit or closing the MCP transport revokes command access. Successful queued output remains valid after the source returns. Killing a command uses the existing host process group/job cleanup for descendants. OS access is the same as the app user's. Buzz owns batching, queue retries and final output draining for its built-in command sources. Other source plugins retain their own producer policies. See the [executable scheduled-source example](../tests/fixtures/agent-command/README.md), including the scope of its fixture-based custody evidence. +## Tools for the user's other local agents + +A plugin can offer MCP tools to agents Buzz did not start, such as Claude Code, Codex, Pi or Nexus sessions. It serves a dedicated `host.serveMcp` server and declares the bundled helper as a process, for example `{ "id": "agent-access", "program": "buzz-agent-command", "bundled": true, "args": ["--advertise"] }`. `spawn("agent-access", { mcp: server })` lets the helper claim that server's one session and save it to `~/.buzz-foundation/agent-connections`, then exit. The plugin keeps the server open after the helper exits and spawns it once per server: a second spawn on the same server stops it. Every local agent of this user can then call that server's tools. On macOS and Linux the directory and files are private to the user and the helper ignores files others could have written; on Windows they rely on the profile's inherited permissions. The plugin handler must not assume a particular caller, and protected workers that must not reach these tools need that directory in `hiddenPaths`. + +An agent runs the installed helper by absolute path, with no inherited connection, configuration or token: `/Applications/Buzz.app/Contents/Resources/bin/buzz-agent-command tools` on macOS (inside the app's installed name, such as `Buzz Foundation.app`), `…\bin\buzz-agent-command.exe` on Windows and `/usr/lib/Buzz Foundation/bin/buzz-agent-command` on Linux. `tools`, ` --field value` and `call ''` behave as `buzz` does inside an agent command, and `--help` prints that usage with the path it was run as. The agent's own sandbox must allow loopback connections: Codex's read-only and workspace-write modes, for example, block them unless network access is enabled, and the helper then reports `Could not reach Buzz` and leaves the advertisement in place. The helper uses the newest advertised session that still answers and offers the tool, so a second Buzz window's tools are still reached; when two plugins offer the same tool name, the newer advertisement wins. A tool call stops asking older advertisements once it finds that session, so a stalled older server does not delay it; `tools` asks every advertisement. On macOS and Linux the helper sends a saved session only to a loopback port that one of this user's processes listens on, checked just before each request; it skips any other advertisement without sending it anything. The check is not bound to the connection itself, so a port that changes owner within that moment is not caught, and Windows has no such check. An advertisement lasts as long as its server. Closing the server, reloading the page, unloading the plugin or quitting Buzz ends it, and the next caller that asks it removes the file once its server refuses the connection or the session. An inherited `BUZZ_AGENT_CONNECTION` always takes precedence. + Native agent hook callbacks are described in [Agent hook callbacks](agent-tool-hooks.md). diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 54c6aa1f34..a71771ec46 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -102,6 +102,13 @@ the interface unless an available tool actually supports that action. - Plugins can add features. **Settings → Plugins → Manage plugins** controls them. Do not assume a particular optional plugin is on. +- Some plugins let the person's other agents on this computer, such as Claude + Code, Codex or Pi, use their tools while Buzz is open. Those agents run the + helper Buzz installs, for example + `/Applications/Buzz.app/Contents/Resources/bin/buzz-agent-command tools` on + macOS (inside the app's installed name, such as `Buzz Foundation.app`), with + no setup or token. Without such a plugin there are no tools to + offer; do not promise them. - **Attention**, when available, lets agents use Interests, watches, and timers to follow work. Its tab in an agent's profile shows what that agent is watching and whether Attention is on for this device. A mention can still wake an agent