diff --git a/KNOWN_BUGS.md b/KNOWN_BUGS.md new file mode 100644 index 00000000000..742174db71c --- /dev/null +++ b/KNOWN_BUGS.md @@ -0,0 +1,45 @@ +# Known Bugs + +## personas-in-rooms — known defect, confuses agents + +**Status:** Rolled back. Do not re-land. +**Introduced by:** `a9217e6b1` — *feat(buzz-acp): channel-scoped personas* +**Rolled back to:** `924515dd8` — *docs: proposal — channel-scoped personas* (the +last commit before the persona code landed in `crates/buzz-acp/src/channel_tools.rs` ++ `pool.rs` and the desktop persona panel/commands). + +### What it did + +The "channel-scoped personas" feature auto-derived a Buzz channel's persona +(system prompt) from whatever persona file lived in the mapped Harbor room, +resolved per-channel at agent spawn time. + +### Why it's a bug + +It **confuses the agents**. A channel's system prompt stopped being a single, +owned, reviewable instruction set — it was silently overridden/replaced by +room state the agent could not see or reason about. Behavior became +inconsistent depending on room contents, and prompt provenance was opaque. + +### Remediation applied + +- Fork rolled back to `924515dd8`; built and installed (no persona code in + `buzz-acp` or `buzz-desktop`). +- Harbor room persona files relocated out of Harbor and staged as first-class + **actual agents** (owner-reviewed identities with explicit system prompts) + instead of room-derived persona overrides. +- All other Buzz installs on the machine removed; only the rolled-back build + remains. + +### Scope + +Only the **personas-in-rooms** surface is reverted. The fork's other addition — +**channel-scoped tools** (skills + MCP servers per channel via +`channel-tools.toml`) — is preserved; it predates `a9217e6b1` and does not +confuse agents. + +### Recommendation + +If per-channel instructions are needed, model them as **actual agents** +(owner-reviewed identities with explicit system prompts), never as +room-derived persona overrides. diff --git a/README.md b/README.md index 6aa3eeff0be..22e2266f519 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,14 @@ Apache 2.0

+> [!NOTE] +> **This is a fork of [`block/buzz`](https://github.com/block/buzz).** The one thing +> it adds is **channel-scoped tools**: map a channel to a [Harbor](https://github.com/TDH-Labs/Harbor) +> room so every agent working there is confined to exactly that room's skills and MCP +> tools — enforced server-side, managed from a **Channel tools** panel in the channel +> header. Everything else is unmodified upstream Buzz. +> **→ [What this fork adds](docs/CHANNEL_SCOPED_TOOLS.md)** +

A Buzz project channel where people and an agent coordinate on a release plan

diff --git a/crates/buzz-acp/src/channel_tools.rs b/crates/buzz-acp/src/channel_tools.rs new file mode 100644 index 00000000000..884980d82de --- /dev/null +++ b/crates/buzz-acp/src/channel_tools.rs @@ -0,0 +1,271 @@ +//! Channel-scoped tool policy: map channels to the MCP servers (tools/skills) +//! an agent may use when responding in that channel. +//! +//! Loaded from the TOML file named by `--channel-tools` / `BUZZ_ACP_CHANNEL_TOOLS`. +//! Keys are channel UUIDs or channel names (names match case-insensitively); +//! values declare either a harbor `room` (shorthand for +//! `harbor mcp-server --room=`) or an explicit `mcp` server list. +//! +//! Scoping is strict, not additive: when a channel has an entry, its server +//! list *replaces* the global `--mcp-command` default for sessions created on +//! that channel — and because the agent runtime skips its own configured +//! extensions whenever explicit MCP servers are passed, a scoped channel +//! exposes exactly the listed servers and nothing else. Channels without an +//! entry keep today's behavior. +//! +//! ```toml +//! # ~/.buzz/channel-tools.toml +//! harbor_command = "harbor" # default for `room` entries +//! +//! [channels.bookkeeping] # channel name… +//! room = "bookkeeping" +//! +//! [channels."6f8199d3-479f-4dce-8851-000a4b0a2586"] # …or channel UUID +//! room = "bookkeeping" +//! +//! [channels.devops] +//! room = "devops" +//! [[channels.devops.mcp]] # extra explicit servers are additive to `room` +//! name = "system-monitoring" +//! command = "/usr/local/bin/sysmon-mcp" +//! args = ["--stdio"] +//! ``` + +use std::collections::HashMap; +use std::path::Path; + +use uuid::Uuid; + +use crate::acp::{EnvVar, McpServer}; + +/// One channel's tool policy as written in TOML. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct ChannelToolsEntry { + /// Harbor room shorthand: expands to ` mcp-server --room=`. + #[serde(default)] + room: Option, + /// Per-entry harbor binary override. + #[serde(default)] + harbor_command: Option, + /// Explicit MCP servers for this channel (additive with `room`). + #[serde(default)] + mcp: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct McpToml { + name: String, + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: HashMap, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct ChannelToolsFile { + /// Default harbor binary for `room` entries. Defaults to `"harbor"` (PATH). + #[serde(default)] + harbor_command: Option, + #[serde(default)] + channels: HashMap, +} + +/// Resolved channel→MCP-server policy, keyed by UUID and by lowercased name. +#[derive(Debug, Default)] +pub struct ChannelTools { + by_uuid: HashMap>, + by_name: HashMap>, +} + +impl ChannelTools { + /// Load and validate a policy file. Errors are fatal by design: a tool + /// policy that fails to parse must never silently degrade to "no policy". + pub fn load(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("cannot read channel-tools file {}: {e}", path.display()))?; + let file: ChannelToolsFile = toml::from_str(&content) + .map_err(|e| format!("invalid channel-tools file {}: {e}", path.display()))?; + + let default_harbor = file.harbor_command.as_deref().unwrap_or("harbor"); + let mut tools = ChannelTools::default(); + + for (key, entry) in &file.channels { + let servers = entry.to_servers(key, default_harbor)?; + match key.parse::() { + Ok(uuid) => { + tools.by_uuid.insert(uuid, servers); + } + Err(_) => { + tools + .by_name + .insert(key.trim_start_matches('#').to_ascii_lowercase(), servers); + } + } + } + Ok(tools) + } + + /// Resolve the policy for a channel: UUID match first, then name match. + /// `None` means no entry — the caller keeps the global default. + pub fn resolve(&self, id: &Uuid, name: Option<&str>) -> Option<&Vec> { + self.by_uuid.get(id).or_else(|| { + name.and_then(|n| { + self.by_name + .get(&n.trim_start_matches('#').to_ascii_lowercase()) + }) + }) + } + + /// Number of channel entries in the policy (for startup logging). + pub fn entry_count(&self) -> usize { + self.by_uuid.len() + self.by_name.len() + } +} + +impl ChannelToolsEntry { + fn to_servers(&self, key: &str, default_harbor: &str) -> Result, String> { + if self.room.is_none() && self.mcp.is_empty() { + return Err(format!( + "channel-tools entry '{key}' declares neither `room` nor `mcp`" + )); + } + let mut servers = Vec::new(); + if let Some(room) = &self.room { + if room.trim().is_empty() { + return Err(format!("channel-tools entry '{key}' has an empty `room`")); + } + servers.push(McpServer { + name: format!("harbor-{room}"), + command: self + .harbor_command + .as_deref() + .unwrap_or(default_harbor) + .to_string(), + args: vec!["mcp-server".into(), format!("--room={room}")], + env: vec![], + }); + } + for m in &self.mcp { + servers.push(McpServer { + name: m.name.clone(), + command: m.command.clone(), + args: m.args.clone(), + env: m + .env + .iter() + .map(|(k, v)| EnvVar { + name: k.clone(), + value: v.clone(), + }) + .collect(), + }); + } + Ok(servers) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn load_str(s: &str) -> Result { + let dir = std::env::temp_dir().join(format!("chtools-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("{:x}.toml", md5ish(s))); + std::fs::write(&path, s).unwrap(); + ChannelTools::load(&path) + } + + fn md5ish(s: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + s.hash(&mut h); + h.finish() + } + + #[test] + fn room_entry_expands_to_harbor_server() { + let t = load_str("[channels.bookkeeping]\nroom = \"bookkeeping\"\n").unwrap(); + let id = Uuid::nil(); + let servers = t.resolve(&id, Some("bookkeeping")).unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].name, "harbor-bookkeeping"); + assert_eq!(servers[0].command, "harbor"); + assert_eq!(servers[0].args, vec!["mcp-server", "--room=bookkeeping"]); + } + + #[test] + fn uuid_key_matches_by_uuid_and_wins_over_name() { + let id = "6f8199d3-479f-4dce-8851-000a4b0a2586"; + let toml = format!( + "[channels.\"{id}\"]\nroom = \"legal\"\n[channels.general]\nroom = \"devops\"\n" + ); + let t = load_str(&toml).unwrap(); + let uuid: Uuid = id.parse().unwrap(); + let servers = t.resolve(&uuid, Some("general")).unwrap(); + assert_eq!(servers[0].args[1], "--room=legal"); + } + + #[test] + fn name_match_is_case_insensitive_and_hash_insensitive() { + let t = load_str("[channels.\"#BookKeeping\"]\nroom = \"bookkeeping\"\n").unwrap(); + assert!(t.resolve(&Uuid::nil(), Some("bookkeeping")).is_some()); + assert!(t.resolve(&Uuid::nil(), Some("#Bookkeeping")).is_some()); + assert!(t.resolve(&Uuid::nil(), Some("other")).is_none()); + } + + #[test] + fn unmatched_channel_resolves_none() { + let t = load_str("[channels.bookkeeping]\nroom = \"bookkeeping\"\n").unwrap(); + assert!(t.resolve(&Uuid::nil(), Some("marketing")).is_none()); + assert!(t.resolve(&Uuid::nil(), None).is_none()); + } + + #[test] + fn explicit_mcp_servers_carry_args_and_env() { + let t = load_str( + "[channels.devops]\nroom = \"devops\"\n[[channels.devops.mcp]]\nname = \"sysmon\"\ncommand = \"/bin/sysmon\"\nargs = [\"--stdio\"]\nenv = { FOO = \"bar\" }\n", + ) + .unwrap(); + let servers = t.resolve(&Uuid::nil(), Some("devops")).unwrap(); + assert_eq!(servers.len(), 2); + assert_eq!(servers[1].name, "sysmon"); + assert_eq!(servers[1].env[0].name, "FOO"); + assert_eq!(servers[1].env[0].value, "bar"); + } + + #[test] + fn custom_harbor_command_overrides_default() { + let t = load_str( + "harbor_command = \"/opt/harbor\"\n[channels.a]\nroom = \"x\"\n[channels.b]\nroom = \"y\"\nharbor_command = \"/special/harbor\"\n", + ) + .unwrap(); + assert_eq!( + t.resolve(&Uuid::nil(), Some("a")).unwrap()[0].command, + "/opt/harbor" + ); + assert_eq!( + t.resolve(&Uuid::nil(), Some("b")).unwrap()[0].command, + "/special/harbor" + ); + } + + #[test] + fn entry_without_room_or_mcp_is_rejected() { + assert!(load_str("[channels.empty]\n").is_err()); + } + + #[test] + fn empty_room_is_rejected() { + assert!(load_str("[channels.a]\nroom = \" \"\n").is_err()); + } + + #[test] + fn unknown_keys_are_rejected() { + assert!(load_str("[channels.a]\nroom = \"x\"\ntypo_key = 1\n").is_err()); + } +} diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index a38d6faa14b..96eb11924c5 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -341,6 +341,12 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_CONFIG", default_value = "./buzz-acp.toml")] pub config: PathBuf, + /// Channel-scoped tool policy file (TOML). Maps channels (by UUID or name) + /// to the MCP servers exposed to sessions on that channel; see + /// `channel_tools.rs` for the format. Unset = no per-channel scoping. + #[arg(long, env = "BUZZ_ACP_CHANNEL_TOOLS")] + pub channel_tools: Option, + #[arg(long, env = "BUZZ_ACP_DEDUP", default_value = "queue", value_enum)] pub dedup: DedupMode, @@ -510,6 +516,8 @@ pub struct Config { pub channels_override: Option>, pub no_mention_filter: bool, pub config_path: PathBuf, + /// Channel-scoped tool policy file; see `channel_tools.rs`. + pub channel_tools_path: Option, pub context_message_limit: u32, /// Maximum turns per session before proactive rotation. 0 = disabled. pub max_turns_per_session: u32, @@ -986,6 +994,7 @@ impl Config { channels_override: args.channels, no_mention_filter: args.no_mention_filter, config_path: args.config, + channel_tools_path: args.channel_tools, context_message_limit: args.context_message_limit, max_turns_per_session: args.max_turns_per_session, presence_enabled: !args.no_presence, @@ -1355,6 +1364,7 @@ mod tests { channels_override: None, no_mention_filter: false, config_path: PathBuf::from("./buzz-acp.toml"), + channel_tools_path: None, context_message_limit: 12, max_turns_per_session: 0, presence_enabled: true, diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 862732f4780..7d93fc88167 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1,6 +1,7 @@ #![deny(unsafe_code)] mod acp; +mod channel_tools; mod config; mod engram_fetch; mod filter; @@ -1478,9 +1479,26 @@ async fn tokio_main() -> Result<()> { ); } + // Channel-scoped tool policy: parse failures are fatal by design — a tool + // policy must never silently degrade to "no scoping". + let channel_tools = match config.channel_tools_path.as_deref() { + Some(path) => { + let tools = channel_tools::ChannelTools::load(path) + .map_err(|e| anyhow::anyhow!("channel-tools policy: {e}"))?; + tracing::info!( + policy = %path.display(), + entries = tools.entry_count(), + "channel-scoped tool policy loaded" + ); + tools + } + None => Default::default(), + }; + let base_prompt_content = config.base_prompt_content.take(); let ctx = Arc::new(PromptContext { mcp_servers: build_mcp_servers(&config), + channel_tools, initial_message: config.initial_message.clone(), idle_timeout: Duration::from_secs(config.idle_timeout_secs), max_turn_duration: Duration::from_secs(config.max_turn_duration_secs), @@ -4632,6 +4650,7 @@ mod build_mcp_servers_tests { channels_override: None, no_mention_filter: false, config_path: std::path::PathBuf::from("./buzz-acp.toml"), + channel_tools_path: None, context_message_limit: 12, max_turns_per_session: 0, presence_enabled: true, @@ -4798,6 +4817,7 @@ mod error_outcome_emission_tests { channels_override: None, no_mention_filter: false, config_path: std::path::PathBuf::from("./buzz-acp.toml"), + channel_tools_path: None, context_message_limit: 12, max_turns_per_session: 0, presence_enabled: true, diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index fb08096792c..b8b54acc951 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -429,6 +429,9 @@ pub enum PromptOutcome { /// into every task. pub struct PromptContext { pub mcp_servers: Vec, + /// Channel-scoped tool policy: channels with an entry replace + /// `mcp_servers` for sessions created on that channel. + pub channel_tools: crate::channel_tools::ChannelTools, pub initial_message: Option, pub idle_timeout: Duration, pub max_turn_duration: Duration, @@ -754,6 +757,7 @@ async fn create_session_and_apply_model( ctx: &PromptContext, agent_core: Option<&str>, agent_canvas: Option<&str>, + channel_id: Option<&Uuid>, ) -> Result { // Build base_prompt + system_prompt + agent core + canvas metadata into a // single prompt. Standard protocol-v2 agents receive it in `session/new`; @@ -773,11 +777,30 @@ async fn create_session_and_apply_model( agent_canvas, ); + // Channel-scoped tools: a channel with a policy entry replaces the global + // MCP set for its session. The agent runtime skips its own configured + // extensions whenever explicit servers are passed, so a scoped channel + // exposes exactly the listed servers. + let mcp_servers = channel_id + .and_then(|cid| { + let name = ctx.channel_info.get(cid).map(|i| i.name.as_str()); + ctx.channel_tools.resolve(cid, name).map(|servers| { + tracing::info!( + channel = %cid, + channel_name = name.unwrap_or("?"), + servers = servers.len(), + "channel-scoped tools applied to new session" + ); + servers.clone() + }) + }) + .unwrap_or_else(|| ctx.mcp_servers.clone()); + let resp = agent .acp .session_new_full( &ctx.cwd, - ctx.mcp_servers.clone(), + mcp_servers, session_new_system_prompt( is_goose, agent.protocol_version, @@ -1425,6 +1448,7 @@ pub async fn run_prompt_task( &ctx, agent_core.as_deref(), agent_canvas.as_deref(), + Some(cid), ) .await { @@ -1472,7 +1496,7 @@ pub async fn run_prompt_task( if let Some(sid) = &agent.state.heartbeat_session { (sid.clone(), false) } else { - match create_session_and_apply_model(&mut agent, &ctx, None, None).await { + match create_session_and_apply_model(&mut agent, &ctx, None, None, None).await { Ok(sid) => { tracing::info!( target: "pool::session", @@ -5237,6 +5261,7 @@ mod tests { use crate::relay::RestClient; PromptContext { mcp_servers: vec![], + channel_tools: Default::default(), initial_message: None, idle_timeout: Duration::from_secs(60), max_turn_duration: Duration::from_secs(120), diff --git a/desktop/src-tauri/src/commands/channel_tools.rs b/desktop/src-tauri/src/commands/channel_tools.rs new file mode 100644 index 00000000000..22b9d71b96f --- /dev/null +++ b/desktop/src-tauri/src/commands/channel_tools.rs @@ -0,0 +1,284 @@ +//! Channel-scoped tools GUI bridge. +//! +//! Harbor is the source of truth for which skills + MCP servers a channel +//! exposes: buzz-acp maps a channel to a Harbor *room* via +//! `~/.buzz/channel-tools.toml`, and the room's skill/MCP allowlist is enforced +//! server-side by Harbor's gate. These commands are the GUI's window onto that +//! model — they shell out to the same `harbor` binary buzz-acp uses, so the +//! panel, the CLI, and enforcement all read and write ONE policy rather than +//! three divergent notions of "what's in this channel". +//! +//! Read commands (`*_get`, `*_pool_skills`) return `harbor ... --json` verbatim +//! for the frontend to render. Mutations (`*_add_*`) wrap Harbor's own audited +//! verbs (`skill-install` / `skill-room-add` / `mcp-add --room`); Harbor applies +//! its confirm policy and token budget, so this layer never bypasses the gate. +//! +//! The desktop Tauri crate does not enable tokio's `process` feature, so every +//! subprocess runs inside `spawn_blocking` over synchronous `std::process`, +//! mirroring `agent_model_process.rs`. Values are passed as distinct argv +//! entries (never a shell string), and any value that could be reinterpreted as +//! a flag is rejected up front. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde_json::Value; + +use crate::managed_agents::login_shell_path; + +/// The channel-tools policy file buzz-acp reads (env override mirrors +/// buzz-acp's `BUZZ_ACP_CHANNEL_TOOLS`, else `~/.buzz/channel-tools.toml`). +fn policy_path() -> PathBuf { + if let Ok(p) = std::env::var("BUZZ_ACP_CHANNEL_TOOLS") { + if !p.trim().is_empty() { + return PathBuf::from(p); + } + } + let mut base = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + base.push(".buzz"); + base.push("channel-tools.toml"); + base +} + +/// Resolve the harbor binary the same way buzz-acp does: the policy's +/// top-level `harbor_command`, else `harbor` on PATH. +fn harbor_binary(policy: &Path) -> String { + if let Ok(text) = std::fs::read_to_string(policy) { + if let Ok(toml::Value::Table(table)) = text.parse::() { + if let Some(toml::Value::String(cmd)) = table.get("harbor_command") { + if !cmd.trim().is_empty() { + return cmd.clone(); + } + } + } + } + "harbor".to_string() +} + +/// Reject a value that is empty or could be reinterpreted as a CLI flag. Values +/// reach harbor as separate argv entries (no shell), so the only injection +/// vector is a leading `-` being parsed as an option — guard exactly that. +fn safe_arg(label: &str, value: &str) -> Result { + let v = value.trim(); + if v.is_empty() { + return Err(format!("{label} must not be empty")); + } + if v.starts_with('-') { + return Err(format!("{label} must not start with '-'")); + } + Ok(v.to_string()) +} + +/// Run `harbor ` with the login-shell PATH so the binary resolves, off +/// the async runtime (the desktop crate lacks tokio's `process` feature). +async fn run_harbor(args: Vec) -> Result { + tokio::task::spawn_blocking(move || { + let policy = policy_path(); + let binary = harbor_binary(&policy); + let mut cmd = Command::new(&binary); + if let Some(path) = login_shell_path() { + cmd.env("PATH", path); + } + cmd.args(&args); + cmd.output() + .map_err(|e| format!("failed to run '{binary}': {e}")) + }) + .await + .map_err(|e| format!("harbor task failed: {e}"))? +} + +/// Parse a read command's stdout as JSON, surfacing stderr on failure. +fn json_output(out: Output) -> Result { + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + return Err(format!("harbor {}: {}", out.status, stderr.trim())); + } + serde_json::from_slice(&out.stdout).map_err(|e| { + let preview = String::from_utf8_lossy(&out.stdout); + format!( + "harbor returned invalid JSON: {e} (got {} bytes)", + preview.len() + ) + }) +} + +/// Return a mutation's combined output, treating a non-zero exit as an error. +fn text_output(out: Output) -> Result { + let stdout = String::from_utf8_lossy(&out.stdout); + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + let combined = format!("{}\n{}", stdout.trim(), stderr.trim()); + return Err(combined.trim().to_string()); + } + Ok(stdout.trim().to_string()) +} + +/// Resolve one channel to the skills + MCP servers it exposes (its room's +/// allowlist). Shape: `{ channel, room, scoped, skills[], mcpServers[] }`. +#[tauri::command] +pub async fn channel_tools_get(channel: String) -> Result { + let channel = safe_arg("channel", &channel)?; + let out = run_harbor(vec!["channel-tools".into(), channel, "--json".into()]).await?; + json_output(out) +} + +/// Every skill in the Harbor pool (name/room/description), for the "add a skill +/// from elsewhere in Buzz" picker. +#[tauri::command] +pub async fn channel_tools_pool_skills() -> Result { + let out = run_harbor(vec!["skills-list".into(), "--json".into()]).await?; + json_output(out) +} + +/// Ensure a channel is scoped, returning the room backing it. Called before an +/// add so an unmapped channel becomes usable in one click — Harbor creates the +/// room and records the mapping. The room name is an internal detail; the GUI +/// only ever deals in channels. +async fn ensure_channel_room(channel: &str) -> Result { + let out = run_harbor(vec![ + "channel-tools".into(), + channel.to_string(), + "--map".into(), + "--json".into(), + ]) + .await?; + let value = json_output(out)?; + value + .get("room") + .and_then(|r| r.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| "could not scope this channel".to_string()) +} + +/// Resolve the room backing an already-scoped channel WITHOUT creating one +/// (read-only — used by the remove path, where scoping on the fly would be +/// wrong). Errors if the channel isn't scoped. +async fn channel_room(channel: &str) -> Result { + let out = run_harbor(vec![ + "channel-tools".into(), + channel.to_string(), + "--json".into(), + ]) + .await?; + let value = json_output(out)?; + value + .get("room") + .and_then(|r| r.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| "this channel has no tools to remove".to_string()) +} + +/// Remove a skill from this channel (unregister it from the channel's room). +#[tauri::command] +pub async fn channel_tools_remove_skill(channel: String, skill: String) -> Result { + let channel = safe_arg("channel", &channel)?; + let skill = safe_arg("skill", &skill)?; + let room = channel_room(&channel).await?; + let out = run_harbor(vec![ + "skill-remove".into(), + "--name".into(), + skill, + "--room".into(), + room, + "--yes".into(), + ]) + .await?; + text_output(out) +} + +/// Remove an MCP server from this channel. +#[tauri::command] +pub async fn channel_tools_remove_mcp(channel: String, name: String) -> Result { + let channel = safe_arg("channel", &channel)?; + let name = safe_arg("name", &name)?; + let room = channel_room(&channel).await?; + let out = run_harbor(vec![ + "mcp-remove".into(), + "--room".into(), + room, + "--name".into(), + name, + ]) + .await?; + text_output(out) +} + +/// Add an existing skill (from elsewhere in Buzz) to this channel. Scopes the +/// channel on the fly if it isn't already. +#[tauri::command] +pub async fn channel_tools_add_existing_skill( + channel: String, + skill: String, +) -> Result { + let channel = safe_arg("channel", &channel)?; + let skill = safe_arg("skill", &skill)?; + let room = ensure_channel_room(&channel).await?; + let out = run_harbor(vec![ + "skill-room-add".into(), + "--skill".into(), + skill, + "--room".into(), + room, + ]) + .await?; + text_output(out) +} + +/// Install a brand-new skill (from a directory or SKILL.md path) and make it +/// available in this channel first. Scopes the channel on the fly if needed. +#[tauri::command] +pub async fn channel_tools_add_new_skill( + channel: String, + source: String, + name: Option, +) -> Result { + let channel = safe_arg("channel", &channel)?; + let source = safe_arg("source", &source)?; + let room = ensure_channel_room(&channel).await?; + let mut args = vec![ + "skill-install".into(), + "--source".into(), + source, + "--room".into(), + room, + ]; + if let Some(name) = name { + if !name.trim().is_empty() { + args.push("--name".into()); + args.push(safe_arg("name", &name)?); + } + } + let out = run_harbor(args).await?; + text_output(out) +} + +/// Add an MCP server to this channel. Scopes the channel on the fly if needed. +#[tauri::command] +pub async fn channel_tools_add_mcp( + channel: String, + name: String, + command: String, + args: Option, +) -> Result { + let channel = safe_arg("channel", &channel)?; + let name = safe_arg("name", &name)?; + let command = safe_arg("command", &command)?; + let room = ensure_channel_room(&channel).await?; + let mut argv = vec![ + "mcp-add".into(), + "--room".into(), + room, + "--name".into(), + name, + "--command".into(), + command, + ]; + if let Some(extra) = args { + if !extra.trim().is_empty() { + argv.push("--args".into()); + argv.push(extra.trim().to_string()); + } + } + let out = run_harbor(argv).await?; + text_output(out) +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index cf4cdca91d7..f830cb240d4 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -11,6 +11,7 @@ mod agent_update_rollback; mod agents; mod canvas; mod channel_templates; +mod channel_tools; mod channel_window; mod channels; mod clipboard; @@ -67,6 +68,7 @@ pub use agent_settings::*; pub use agents::*; pub use canvas::*; pub use channel_templates::*; +pub use channel_tools::*; pub use channel_window::*; pub use channels::*; pub use clipboard::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 9017356ab55..f9b6f96e9fd 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -714,6 +714,13 @@ pub fn run() { get_channels, create_channel, ensure_starter_channels, + channel_tools_get, + channel_tools_pool_skills, + channel_tools_add_existing_skill, + channel_tools_add_new_skill, + channel_tools_add_mcp, + channel_tools_remove_skill, + channel_tools_remove_mcp, open_dm, hide_dm, get_channel_details, diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index 8e030ef2157..8edecec639d 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -1,4 +1,4 @@ -import { EllipsisVertical, Settings2, Users } from "lucide-react"; +import { Blocks, EllipsisVertical, Settings2, Users } from "lucide-react"; import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useHuddle } from "@/features/huddle"; @@ -23,6 +23,7 @@ import { DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { AddChannelBotDialog } from "./AddChannelBotDialog"; +import { ChannelToolsDialog } from "./ChannelToolsDialog"; type ChannelMembersBarProps = { channel: Channel; @@ -55,6 +56,7 @@ export function ChannelMembersBar({ }, [isAddBotOpenProp, onAddBotOpenChange], ); + const [isToolsOpen, setIsToolsOpen] = React.useState(false); const { startHuddle, isStarting: isStartingHuddle } = useHuddle(); const queryClient = useQueryClient(); const membersQuery = useChannelMembersQuery(channel.id); @@ -160,6 +162,13 @@ export function ChannelMembersBar({ {huddleIndicator} + setIsToolsOpen(true)} + > + + Channel tools + + + + + Channel tools (skills & MCP) + + + + + ); + } + + return ( +
+ {mode === "existing" ? ( + +
+ + setFilter(e.target.value)} + placeholder="Search skills elsewhere in Buzz…" + value={filter} + /> +
+ {poolQuery.isLoading ? ( +
+ Loading skills… +
+ ) : ( +
    + {candidates.length === 0 ? ( + No matching skills. + ) : ( + candidates.map((skill: PoolSkill) => ( +
  • +
    +
    {skill.name}
    + {skill.description ? ( +
    + {skill.description} +
    + ) : null} +
    + +
  • + )) + )} +
+ )} +
+ ) : ( + + setSource(e.target.value)} + placeholder="Path to a skill directory or SKILL.md" + value={source} + /> +
+ +
+
+ )} + + {(addExisting.error || addNew.error) && ( + + )} + + +
+ ); +} + +/** Add an MCP server (name + command) to the channel. */ +function AddMcpControls({ channelKey }: { channelKey: string }) { + const queryClient = useQueryClient(); + const [open, setOpen] = React.useState(false); + const [name, setName] = React.useState(""); + const [command, setCommand] = React.useState(""); + const [args, setArgs] = React.useState(""); + + const addMcp = useMutation({ + mutationFn: () => + addMcpToChannel( + channelKey, + name.trim(), + command.trim(), + args.trim() || undefined, + ), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: channelToolsKey(channelKey), + }); + setName(""); + setCommand(""); + setArgs(""); + setOpen(false); + }, + }); + + if (!open) { + return ( + + ); + } + + const canSubmit = + name.trim() !== "" && command.trim() !== "" && !addMcp.isPending; + + return ( +
+ setName(e.target.value)} + placeholder="Server name (e.g. grafana)" + value={name} + /> + setCommand(e.target.value)} + placeholder="Command (e.g. grafana-mcp)" + value={command} + /> + setArgs(e.target.value)} + placeholder="Args, comma-separated (optional)" + value={args} + /> + {addMcp.error ? ( + + ) : null} +
+ + +
+
+ ); +} + +function RemoveButton({ + label, + pending, + onClick, +}: { + label: string; + pending: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function SectionHeading({ + icon, + title, + count, +}: { + icon: React.ReactNode; + title: string; + count: number; +}) { + return ( +
+ {icon} + {title} + {count} +
+ ); +} + +function EmptyRow({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function ErrorNote({ message }: { message: string }) { + return ( +
+ + {message} +
+ ); +} diff --git a/desktop/src/shared/api/tauriChannelTools.ts b/desktop/src/shared/api/tauriChannelTools.ts new file mode 100644 index 00000000000..7c56a7beb68 --- /dev/null +++ b/desktop/src/shared/api/tauriChannelTools.ts @@ -0,0 +1,108 @@ +/** + * tauriChannelTools.ts — typed bridge to the `channel_tools_*` Rust commands. + * + * A channel maps to a Harbor *room*; the room is the source of truth for the + * skills and MCP servers that channel exposes, enforced server-side by Harbor's + * gate. These wrappers shell (in Rust) to the same `harbor` binary buzz-acp + * uses, so the GUI panel, the CLI, and enforcement all agree on one policy. + * + * Reads return `harbor ... --json` verbatim; mutations wrap Harbor's own + * audited verbs and return its stdout so the panel can surface what happened. + */ +import { invokeTauri } from "@/shared/api/tauri"; + +/** A skill exposed in a channel (via its room). `present:false` = room lists it but the pool lacks it. */ +export type ChannelSkill = { + name: string; + description: string; + present: boolean; +}; + +/** An MCP server exposed in a channel: room-configured, or declared inline in the policy. */ +export type ChannelMcpServer = { + name: string; + source: "room" | "explicit"; +}; + +/** The fully-resolved toolset a channel exposes (shape of `harbor channel-tools --json`). */ +export type ChannelTools = { + channel: string; + /** Null when the channel maps to no room. */ + room: string | null; + /** False when the channel has no policy entry (not Harbor-scoped). */ + scoped: boolean; + skills: ChannelSkill[]; + mcpServers: ChannelMcpServer[]; +}; + +/** One pool skill, for the "add a skill from elsewhere in Buzz" picker. */ +export type PoolSkill = { + name: string; + description: string; + room: string; +}; + +/** Resolve one channel to the skills + MCP servers it exposes. */ +export function getChannelTools(channel: string): Promise { + return invokeTauri("channel_tools_get", { channel }); +} + +/** Every skill in the Harbor pool (for selecting an existing one to add). */ +export function getPoolSkills(): Promise { + return invokeTauri("channel_tools_pool_skills"); +} + +/** Add an existing skill (from elsewhere in Buzz) to this channel. Scopes it on the fly. */ +export function addExistingSkillToChannel( + channel: string, + skill: string, +): Promise { + return invokeTauri("channel_tools_add_existing_skill", { + channel, + skill, + }); +} + +/** Install a brand-new skill from a path and make it available in this channel first. */ +export function addNewSkillToChannel( + channel: string, + source: string, + name?: string, +): Promise { + return invokeTauri("channel_tools_add_new_skill", { + channel, + source, + name, + }); +} + +/** Remove a skill from this channel (unregister it from the channel's room). */ +export function removeSkillFromChannel( + channel: string, + skill: string, +): Promise { + return invokeTauri("channel_tools_remove_skill", { channel, skill }); +} + +/** Remove an MCP server from this channel. */ +export function removeMcpFromChannel( + channel: string, + name: string, +): Promise { + return invokeTauri("channel_tools_remove_mcp", { channel, name }); +} + +/** Add an MCP server to this channel. Scopes it on the fly. */ +export function addMcpToChannel( + channel: string, + name: string, + command: string, + args?: string, +): Promise { + return invokeTauri("channel_tools_add_mcp", { + channel, + name, + command, + args, + }); +} diff --git a/docs/CHANNEL_PERSONAS_PROPOSAL.md b/docs/CHANNEL_PERSONAS_PROPOSAL.md new file mode 100644 index 00000000000..ca031afd42e --- /dev/null +++ b/docs/CHANNEL_PERSONAS_PROPOSAL.md @@ -0,0 +1,131 @@ +# Proposal: channel-scoped personas (extends `channel-tools.toml`) + +> **Status: proposal — not built, not approved for implementation.** This touches +> shared `buzz-acp` core session-creation logic; do not implement without explicit +> sign-off and the testing bar below. Companion to the channel-scoped *tools* +> feature (`crates/buzz-acp/src/channel_tools.rs` + the desktop panel). + +## Problem this solves + +Today one Buzz agent = one Nostr identity = one fixed `system_prompt` (set in +`managed-agents.json`), used everywhere that identity appears. Each identity also +gets a separate, persistent runtime process **per relay/community it's in** +(`ManagedAgentRuntimeKey{pubkey, relay_url}`), so process count scales with +identities × communities. + +Consolidating *personas* (writing fewer, broader prompts) would cut that overhead +— but at a real cost. A safety-conscious fleet depends on each domain having a +**narrow, non-diluted, individually-auditable prompt** with its own hard-coded +approval boundaries. Merging prompts risks blurring those boundaries — a real +regression for sensitive domains (e.g. finance), not just an efficiency tradeoff. + +This proposal decouples the two: keep narrow, separate, fully-independent prompts +— but let **one identity/process serve several of them**, swapping which one +applies based on which channel a session is created in. + +## Design: extend `ChannelToolsEntry`, don't build a parallel mechanism + +`channel_tools.rs` already resolves a per-channel policy (by UUID, then by name) +at session-creation time in `pool.rs`, strict/replacing, fatal on load error. +Extend the same struct and the same resolution path rather than adding a second, +independently-loaded config file that must stay in sync with the first. + +```rust +// channel_tools.rs — add one optional field to the existing entry +struct ChannelToolsEntry { + room: Option, + harbor_command: Option, + mcp: Vec, + persona: Option, // NEW: inline system_prompt override + persona_file: Option, // NEW: load from file instead (long prompts) + // mutually exclusive with `persona`; same validation pattern as + // `cron`/`interval` in buzz-workflow's Schedule trigger +} +``` + +```toml +# ~/.buzz/channel-tools.toml — same file, new optional key per entry +[channels.bookkeeping] +room = "bookkeeping" +persona_file = "~/.buzz/personas/accountant.md" + +[channels.marketing] +room = "marketing" +persona_file = "~/.buzz/personas/marketing.md" + +[channels.support] +room = "support" +# no persona key — falls back to this identity's base managed-agents.json +# system_prompt, exactly like today. Backward compatible by default. +``` + +## Semantics (non-negotiable in v1 — matches the tool-scoping philosophy exactly) + +- **Replacing, not additive.** A channel entry with `persona`/`persona_file` set + **fully replaces** the base `system_prompt` for sessions created in that channel + — not layered underneath, not appended, not merged. This is the load-bearing + safety property: each channel's behavior stays a complete, self-contained, + independently-reviewable prompt, identical in spirit to the separate-agent model. + Do **not** build an additive "base + specialization layer" version — it + reintroduces prompt-dilution risk and adds a fixed token cost to every turn (the + shared base is paid for even when irrelevant to the channel). +- **Fatal on load error.** Missing `persona_file`, malformed TOML, or both + `persona` and `persona_file` set → hard failure at startup, same as the existing + "never silently degrade to no policy" rule. A channel silently running the wrong + persona is worse than the process refusing to start. +- **No entry = today's behavior.** Channels without a persona key keep using the + identity's normal `managed-agents.json` prompt. Nothing changes for channels that + don't opt in. +- **Composes with existing tool-scoping.** `room`/`mcp` and `persona` can and should + appear together in one entry — one channel definition should describe everything + that channel does (tools *and* behavior), not require two separate config files. + +## Explicitly out of scope for v1 (real gaps — flag for a future spec) + +- **`respond_to`/`respond_to_allowlist` stay per-identity, not per-channel.** If one + channel's persona genuinely needs a different trust boundary than another channel + on the same shared identity, v1 doesn't solve that. Some identity splits may exist + *because* of a respond_to difference, not just a prompt difference. +- **Nostr profile (kind:0) stays one-per-identity.** A shared identity's display + name/avatar is the same across channels with different personas — a persona named + one thing can visually appear while behaving as another in a different channel. + Not fatal, but decide it explicitly rather than discover it live. +- **Template variables in `persona`** (channel name, date, etc.) — punt unless a + real need appears. + +## Migration path (once built and approved) + +1. Pick ONE low-stakes consolidation candidate first — two pure-advisory agents with + no action-capable surface, where the safety argument doesn't apply to either. +2. Move each one's exact, current `system_prompt` **verbatim** into its own + `persona_file`, unchanged — this migration moves *where the prompt lives*, not + *what it says*. +3. Point both channels' entries at the SAME underlying identity (retire one pubkey, + keep the other; repoint the retired one's channel memberships to the survivor). +4. Verify byte-for-byte behavior parity in the surviving channels before trimming + anything else. +5. Only after that pilot holds, consider wider consolidation — treat any agent with + a hard behavioral guarantee (e.g. "structurally cannot publish") as a "needs + care" case, not a clean first candidate. + +## Testing bar + +Mirror the tool-scoping suite exactly (unit tests + clippy clean). Cover: +resolution precedence (UUID over name), fatal-load-error paths, the +mutual-exclusivity check on `persona`/`persona_file`, and a real **integration test +proving two different channels on one identity produce genuinely different, +non-bleeding system prompts in two concurrent sessions** — cross-channel state +bleeding on a shared process is the one truly new risk versus the tool-scoping +precedent, and nothing built so far exercises it. + +## What this does and doesn't fix + +- **Fixes:** process/relay-pair overhead, *if* identities get consolidated using + this mechanism (necessary but not sufficient — someone still decides which + identities to merge and does the migration above). +- **Does not fix:** every identity getting a runtime pair on every known community + regardless of relevance. That's an orthogonal problem (relay-pair *scoping*, not + persona-*count*) needing its own investigation into the app's pair-discovery. +- **Does not change per-turn token cost** in the replacing design — each turn still + pays for one complete, non-diluted prompt. The savings are process/connection/ + memory overhead, not LLM API spend. diff --git a/docs/CHANNEL_SCOPED_TOOLS.md b/docs/CHANNEL_SCOPED_TOOLS.md new file mode 100644 index 00000000000..5597fb8aec1 --- /dev/null +++ b/docs/CHANNEL_SCOPED_TOOLS.md @@ -0,0 +1,96 @@ +# Channel-scoped tools (this fork's addition) + +> **What this fork adds over upstream [`block/buzz`](https://github.com/block/buzz):** +> the ability to scope an agent's skills and MCP tools **per channel**, so the +> *channel* — not the agent — is the unit of capability. Everything else in this +> repo is unmodified upstream Buzz. + +## The idea + +In stock Buzz, whatever skills and extensions an agent is configured with travel +with that agent into every channel — the same everywhere, ungoverned per channel. +This fork makes a **channel** map to a **[Harbor](https://github.com/TDH-Labs/Harbor) +room**; the room is the source of truth for the skills and MCP servers agents may +use when they work in that channel. Scope a channel once, and *every* agent that +responds there — now or later — is confined to exactly that toolset, enforced +server-side and audited. Nothing leaks in from the harness; nothing leaks across +channels. + +A `#legal` channel exposes the legal room's skills; a `#bookkeeping` channel +exposes the bookkeeping room's; an agent moving between them picks up each +channel's tools as it goes. Multiple agents in one channel all share that +channel's scope. + +## How it works + +A small policy file, `~/.buzz/channel-tools.toml`, maps each channel (by name or +UUID) to a Harbor room: + +```toml +harbor_command = "harbor" # binary used for `room` entries + +[channels.legal] +room = "legal" # → expands to: harbor mcp-server --room=legal + +[channels.bookkeeping] +room = "bookkeeping" +``` + +When `buzz-acp` creates a session for a channel, it looks the channel up in that +file and **replaces** the session's MCP servers with the channel's scope: + +``` +channel has an entry? → session gets exactly `harbor mcp-server --room=` +no entry → session keeps the harness's own default extensions +``` + +That single MCP server exposes the room's skills as `list_skills` / `read_skill`, +gated by room and token budget. The lookup keys on the **channel**, not the +agent — so five agents answering in `#legal` all get the `legal` room's skills, +and none can load a skill from another room. + +Because scoping happens where the agent *runs* (your machine's `buzz-acp` reading +your local policy), it applies in **any** community the agent works in — +self-hosted or Block-hosted. The relay only moves messages. + +## What changed vs. upstream + +**Enforcement — `crates/buzz-acp/`:** +| File | Change | +|------|--------| +| `src/channel_tools.rs` | **new** — `ChannelTools::load/resolve` reads the policy file and expands a `room` entry to `harbor mcp-server --room=` (or an explicit inline server list) | +| `src/config.rs` | `--channel-tools` flag / `BUZZ_ACP_CHANNEL_TOOLS` env → policy path | +| `src/lib.rs` | loads the policy at startup | +| `src/pool.rs` | the per-session MCP **replace** override | + +**Management GUI — `desktop/`:** +| File | Change | +|------|--------| +| `src-tauri/src/commands/channel_tools.rs` | **new** — Tauri commands that shell to `harbor` to read a channel's tools, add/remove skills & MCP servers, and scope a channel on the fly | +| `src/features/channels/ui/ChannelToolsDialog.tsx` | **new** — the "Channel tools" panel | +| `src/features/channels/ui/ChannelMembersBar.tsx` | the **Channel tools** button in the channel header | +| `src/shared/api/tauriChannelTools.ts` | **new** — the typed API bridge | + +## Using it + +1. **Install Harbor** (the control plane): see + [TDH-Labs/Harbor](https://github.com/TDH-Labs/Harbor). It provides the + `harbor` command this fork calls. +2. **Point `buzz-acp` at a policy file** via `--channel-tools ~/.buzz/channel-tools.toml` + (or `BUZZ_ACP_CHANNEL_TOOLS`). +3. **Manage per channel from the GUI:** open a channel → **Channel tools** in the + header → add or remove skills and MCP servers. The first add scopes the + channel automatically (no hand-edited config). Or use the CLI: + `harbor channel-tools --map`, `harbor channel-tools `. + +Full Harbor-side guide: **[Harbor → Buzz](https://github.com/TDH-Labs/Harbor/blob/main/docs/BUZZ.md)**. + +## Notes + +- **Scopes are keyed by channel name, globally on your machine.** A channel named + `legal` in two communities gets the same scope; key by UUID to differ them. +- **The scope replaces, it doesn't merge** — a scoped channel exposes exactly the + room's tools, so the room must contain everything that channel's work needs. +- Harbor is one provider of the scoped command; the policy format is + provider-agnostic (`harbor_command` plus an explicit `mcp` list are both + supported), which is the intended shape for upstreaming.