From 9f192f175825be4eeda517ba7265b80700828011 Mon Sep 17 00:00:00 2001 From: Vibherpunk <216434340+Vibherpunk@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:33:59 -0700 Subject: [PATCH 1/7] =?UTF-8?q?feat(desktop):=20channel=20tools=20panel=20?= =?UTF-8?q?=E2=80=94=20manage=20a=20channel's=20skills=20&=20MCP=20servers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a GUI, in the channel header, for viewing and adding the skills and MCP servers available to agents in a channel. A channel maps to a Harbor room (~/.buzz/channel-tools.toml); the room is the source of truth, enforced server-side by Harbor's gate. The panel is a window onto that model — it shells (in Rust) to the same 'harbor' binary buzz-acp uses, so panel, CLI, and enforcement all agree on one policy. - commands/channel_tools.rs: 5 Tauri commands wrapping harbor's audited verbs (channel-tools --json read; skills-list --json for the picker; skill-room-add / skill-install --room / mcp-add --room mutations). Runs in spawn_blocking over std::process (desktop crate has no tokio process feature), passes values as distinct argv, rejects flag-like inputs. - tauriChannelTools.ts: typed invoke wrappers. - ChannelToolsDialog.tsx: native-styled dialog — current skills/MCP list, add-existing-from-pool picker, install-new-from-path, add-MCP form; honest 'not Harbor-scoped' state for unmapped channels. - ChannelMembersBar: a 'Channel tools' button (inline + compact variants). Adds a skill to a channel first (via its room), which is exactly what makes it available in that channel. Verified: cargo check, tsc, biome, clippy, rustfmt all clean. Formatters run manually (repo lefthook needs 'just', unavailable). --- .../src-tauri/src/commands/channel_tools.rs | 207 +++++++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 5 + .../channels/ui/ChannelMembersBar.tsx | 34 +- .../channels/ui/ChannelToolsDialog.tsx | 513 ++++++++++++++++++ desktop/src/shared/api/tauriChannelTools.ts | 92 ++++ 6 files changed, 852 insertions(+), 1 deletion(-) create mode 100644 desktop/src-tauri/src/commands/channel_tools.rs create mode 100644 desktop/src/features/channels/ui/ChannelToolsDialog.tsx create mode 100644 desktop/src/shared/api/tauriChannelTools.ts 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..444afbaac9d --- /dev/null +++ b/desktop/src-tauri/src/commands/channel_tools.rs @@ -0,0 +1,207 @@ +//! 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) +} + +/// Grant an existing pool skill to this channel's room (additive). +#[tauri::command] +pub async fn channel_tools_add_existing_skill( + room: String, + skill: String, +) -> Result { + let room = safe_arg("room", &room)?; + let skill = safe_arg("skill", &skill)?; + 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) into the pool +/// and route it to this channel's room, so it is available here first. +#[tauri::command] +pub async fn channel_tools_add_new_skill( + room: String, + source: String, + name: Option, +) -> Result { + let room = safe_arg("room", &room)?; + let source = safe_arg("source", &source)?; + 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's room. +#[tauri::command] +pub async fn channel_tools_add_mcp( + room: String, + name: String, + command: String, + args: Option, +) -> Result { + let room = safe_arg("room", &room)?; + let name = safe_arg("name", &name)?; + let command = safe_arg("command", &command)?; + 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..4645b5fa091 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -714,6 +714,11 @@ 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, 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..adbc3cc4667 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 pool… +
+ ) : ( +
    + {candidates.length === 0 ? ( + No matching skills. + ) : ( + candidates.map((skill: PoolSkill) => ( +
  • +
    +
    {skill.name}
    +
    + {skill.room} +
    +
    + +
  • + )) + )} +
+ )} +
+ ) : ( + + 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's room. */ +function AddMcpControls({ + channelKey, + room, +}: { + channelKey: string; + room: 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( + room, + 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 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} +
+ ); +} + +function UnscopedNote() { + return ( +
+ + + This channel isn't scoped by Harbor, so its agents keep whatever + skills and extensions their harness is configured with. To manage tools + per channel, map it to a Harbor room in{" "} + + ~/.buzz/channel-tools.toml + + . + +
+ ); +} diff --git a/desktop/src/shared/api/tauriChannelTools.ts b/desktop/src/shared/api/tauriChannelTools.ts new file mode 100644 index 00000000000..4a8c89cd292 --- /dev/null +++ b/desktop/src/shared/api/tauriChannelTools.ts @@ -0,0 +1,92 @@ +/** + * 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"); +} + +/** Grant an existing pool skill to this channel's room (additive). */ +export function addExistingSkillToChannel( + room: string, + skill: string, +): Promise { + return invokeTauri("channel_tools_add_existing_skill", { + room, + skill, + }); +} + +/** Install a brand-new skill from a path and route it to this channel's room. */ +export function addNewSkillToChannel( + room: string, + source: string, + name?: string, +): Promise { + return invokeTauri("channel_tools_add_new_skill", { + room, + source, + name, + }); +} + +/** Add an MCP server to this channel's room. */ +export function addMcpToChannel( + room: string, + name: string, + command: string, + args?: string, +): Promise { + return invokeTauri("channel_tools_add_mcp", { + room, + name, + command, + args, + }); +} From edc1d76f51103fbb93a0ba8d2f1b853d4a277598 Mon Sep 17 00:00:00 2001 From: Vibherpunk <216434340+Vibherpunk@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:33:59 -0700 Subject: [PATCH 2/7] feat(desktop): scope channels on the fly; drop Harbor/room wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel tools panel now works on ANY channel, not just pre-mapped ones. Adding a skill or MCP server first scopes the channel on the fly (Rust ensure_channel_room shells to 'harbor channel-tools --map', which creates the channel's room + records the mapping), then adds the tool — so an unmapped channel like 'welcome-everyone' is a one-click scope-and-add instead of a dead end. The add commands now take a channel, never a room. User-facing copy speaks only in Buzz terms: removed the 'not scoped by Harbor / map it to a Harbor room in channel-tools.toml' dead-end note, the room-source badge on MCP servers, and the room subtitle in the skill picker; the panel now always shows the add controls. 'Tools' label added to the header button so it's discoverable. Verified: cargo check, clippy, rustfmt, tsc, biome all clean. --- .../src-tauri/src/commands/channel_tools.rs | 44 ++++++++++--- .../channels/ui/ChannelMembersBar.tsx | 3 +- .../channels/ui/ChannelToolsDialog.tsx | 66 +++++-------------- desktop/src/shared/api/tauriChannelTools.ts | 18 ++--- 4 files changed, 60 insertions(+), 71 deletions(-) diff --git a/desktop/src-tauri/src/commands/channel_tools.rs b/desktop/src-tauri/src/commands/channel_tools.rs index 444afbaac9d..a33be316bc0 100644 --- a/desktop/src-tauri/src/commands/channel_tools.rs +++ b/desktop/src-tauri/src/commands/channel_tools.rs @@ -130,14 +130,36 @@ pub async fn channel_tools_pool_skills() -> Result { json_output(out) } -/// Grant an existing pool skill to this channel's room (additive). +/// 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()) +} + +/// 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( - room: String, + channel: String, skill: String, ) -> Result { - let room = safe_arg("room", &room)?; + 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(), @@ -149,16 +171,17 @@ pub async fn channel_tools_add_existing_skill( text_output(out) } -/// Install a brand-new skill (from a directory or SKILL.md path) into the pool -/// and route it to this channel's room, so it is available here first. +/// 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( - room: String, + channel: String, source: String, name: Option, ) -> Result { - let room = safe_arg("room", &room)?; + 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(), @@ -176,17 +199,18 @@ pub async fn channel_tools_add_new_skill( text_output(out) } -/// Add an MCP server to this channel's room. +/// Add an MCP server to this channel. Scopes the channel on the fly if needed. #[tauri::command] pub async fn channel_tools_add_mcp( - room: String, + channel: String, name: String, command: String, args: Option, ) -> Result { - let room = safe_arg("room", &room)?; + 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(), diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index adbc3cc4667..8edecec639d 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -205,13 +205,14 @@ export function ChannelMembersBar({ Channel tools (skills & MCP) diff --git a/desktop/src/features/channels/ui/ChannelToolsDialog.tsx b/desktop/src/features/channels/ui/ChannelToolsDialog.tsx index d053a08fbe5..1bf2370c95f 100644 --- a/desktop/src/features/channels/ui/ChannelToolsDialog.tsx +++ b/desktop/src/features/channels/ui/ChannelToolsDialog.tsx @@ -61,7 +61,7 @@ export function ChannelToolsDialog({ Skills and MCP servers available to agents in{" "} {channelTitle}. - Anything added here is scoped to this channel first. + Anything you add is available in this channel first. @@ -78,8 +78,6 @@ export function ChannelToolsDialog({ : "Failed to load channel tools." } /> - ) : toolsQuery.data && !toolsQuery.data.scoped ? ( - ) : toolsQuery.data ? ( ) : null} @@ -95,8 +93,6 @@ function ChannelToolsBody({ channelKey: string; tools: ChannelTools; }) { - const room = tools.room; - return (
@@ -124,7 +120,7 @@ function ChannelToolsBody({ variant="outline" className="shrink-0 text-2xs text-amber-600" > - missing from pool + unavailable ) : null}
@@ -138,13 +134,7 @@ function ChannelToolsBody({ ))} )} - {room ? ( - - ) : null} +
@@ -165,14 +155,11 @@ function ChannelToolsBody({ {server.name} - - {server.source} - ))} )} - {room ? : null} +
); @@ -181,11 +168,9 @@ function ChannelToolsBody({ /** Add a skill — pick an existing pool skill, or install a new one from a path. */ function AddSkillControls({ channelKey, - room, present, }: { channelKey: string; - room: string; present: ChannelTools["skills"]; }) { const queryClient = useQueryClient(); @@ -207,14 +192,14 @@ function AddSkillControls({ queryClient.invalidateQueries({ queryKey: channelToolsKey(channelKey) }); const addExisting = useMutation({ - mutationFn: (skill: string) => addExistingSkillToChannel(room, skill), + mutationFn: (skill: string) => addExistingSkillToChannel(channelKey, skill), onSuccess: () => { void invalidate(); setFilter(""); }, }); const addNew = useMutation({ - mutationFn: (path: string) => addNewSkillToChannel(room, path), + mutationFn: (path: string) => addNewSkillToChannel(channelKey, path), onSuccess: () => { void invalidate(); setSource(""); @@ -278,7 +263,7 @@ function AddSkillControls({ {poolQuery.isLoading ? (
- Loading pool… + Loading skills…
) : (
    @@ -292,9 +277,11 @@ function AddSkillControls({ >
    {skill.name}
    -
    - {skill.room} -
    + {skill.description ? ( +
    + {skill.description} +
    + ) : null}
@@ -155,6 +184,13 @@ function ChannelToolsBody({ {server.name} + removeMcp.mutate(server.name)} + /> ))} @@ -441,6 +477,33 @@ function AddMcpControls({ channelKey }: { channelKey: string }) { ); } +function RemoveButton({ + label, + pending, + onClick, +}: { + label: string; + pending: boolean; + onClick: () => void; +}) { + return ( + + ); +} + function SectionHeading({ icon, title, diff --git a/desktop/src/shared/api/tauriChannelTools.ts b/desktop/src/shared/api/tauriChannelTools.ts index c7c19f4f4e8..7c56a7beb68 100644 --- a/desktop/src/shared/api/tauriChannelTools.ts +++ b/desktop/src/shared/api/tauriChannelTools.ts @@ -76,6 +76,22 @@ export function addNewSkillToChannel( }); } +/** 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, From b9c5a4651aab7044ddf350595fc44198e9e30419 Mon Sep 17 00:00:00 2001 From: Vibherpunk <216434340+Vibherpunk@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:11:48 -0700 Subject: [PATCH 4/7] feat(buzz-acp): channel-scoped tool policy (channel-tools.toml) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map a Buzz channel to a Harbor room (or explicit MCP servers) via a policy file (--channel-tools / BUZZ_ACP_CHANNEL_TOOLS). When a channel has an entry, its server list REPLACES the session's default MCP servers, so a scoped channel exposes exactly the listed tools/skills and nothing else; unscoped channels are unchanged. channel_tools.rs (ChannelTools::load/resolve → harbor mcp-server --room=) + config/lib/pool hooks. The desktop-side management panel is in desktop/. Pairs with harbor's channel-tools CLI (TDH-Labs/Harbor). --- crates/buzz-acp/src/channel_tools.rs | 271 +++++++++++++++++++++++++++ crates/buzz-acp/src/config.rs | 10 + crates/buzz-acp/src/lib.rs | 20 ++ crates/buzz-acp/src/pool.rs | 29 ++- 4 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 crates/buzz-acp/src/channel_tools.rs 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), From 05bcc72a0240ce3d7cbc99deab6cf235f7f3001e Mon Sep 17 00:00:00 2001 From: Vibherpunk <216434340+Vibherpunk@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:45:29 -0700 Subject: [PATCH 5/7] docs: explain what this fork adds (channel-scoped tools) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README note + docs/CHANNEL_SCOPED_TOOLS.md: what differs from upstream block/buzz (channel→Harbor-room scoping via buzz-acp + the desktop Channel tools panel), how it works, the exact files changed, and how to use it. Links the Harbor-side guide. --- README.md | 8 +++ docs/CHANNEL_SCOPED_TOOLS.md | 96 ++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 docs/CHANNEL_SCOPED_TOOLS.md 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/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. From 924515dd8ecce47f9cfc23c3e658084937bcc4dc Mon Sep 17 00:00:00 2001 From: Vibherpunk <216434340+Vibherpunk@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:36:41 -0700 Subject: [PATCH 6/7] =?UTF-8?q?docs:=20proposal=20=E2=80=94=20channel-scop?= =?UTF-8?q?ed=20personas=20(extends=20channel-tools)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design proposal (NOT built, NOT approved) to add optional persona/persona_file to ChannelToolsEntry, so one identity can serve different system prompts per channel — replacing (not additive), fatal-on-error, composes with tool-scoping. Genericized from an internal hand-off spec; no personal/fleet specifics. --- docs/CHANNEL_PERSONAS_PROPOSAL.md | 131 ++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 docs/CHANNEL_PERSONAS_PROPOSAL.md 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. From 4c72e5da93f87d1f2561f34292daba00f2e95407 Mon Sep 17 00:00:00 2001 From: Vibherpunk <216434340+Vibherpunk@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:44:13 -0700 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20KNOWN=5FBUGS=20=E2=80=94=20personas?= =?UTF-8?q?-in-rooms=20is=20a=20known=20defect=20(confuses=20agents),=20ro?= =?UTF-8?q?lled=20back=20to=20924515dd8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel-scoped-personas feature (a9217e6b1) auto-derived a channel persona from a Harbor room at spawn time. It confused agents by silently overriding the system prompt with room state they could not see, making prompt provenance opaque and behavior inconsistent. Rolled back to 924515dd8 (last commit before the persona code). Personas relocated out of Harbor and staged as actual owner-reviewed agents. Channel-scoped tools (skills/MCP) are preserved. --- KNOWN_BUGS.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 KNOWN_BUGS.md 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.