diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index 3d011eb8ebb..f48fee059e8 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -291,7 +291,7 @@ Buzz Desktop supports registering any ACP-speaking agent tool as a selectable ru > **Note — OpenClaw:** `openclaw acp` is a Gateway-backed bridge; PATH availability shows "Available" even when the OpenClaw Gateway daemon is not running. This is expected tier-2 semantics (same class as a preset with unconfigured auth). The Gateway URL is configured via `OPENCLAW_GATEWAY_URL` (or the equivalent env var from OpenClaw's docs) — set it in the agent's **env vars** in Edit Agent, not in the definition env (the preset definition carries no env entries). Note that `openclaw acp` executes tools inside the Gateway daemon, not the Desktop process, so Desktop-injected `BUZZ_*` env vars do NOT reach the execution locus unless you also set them on the Gateway's own environment. -**Tier-3 — user custom harnesses**: JSON files in `/custom_harnesses/` that the user can create from the Settings UI or drop in directly. Each file describes one harness — no install scripts. +**Tier-3 — user custom harnesses**: JSON files in `/custom_harnesses/` that the user can create from the Settings UI or drop in directly. Each file describes one harness — no install scripts. Desktop mounts `buzz-dev-mcp` as an MCP sidecar at spawn so Buzz CLI auth reaches a sidecar shell the child ACP process cannot strip. ### Custom harness JSON schema diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 99dcc98145a..40977c9ca7a 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -145,7 +145,9 @@ pub async fn save_custom_harness( command: command_opt, binary_path, default_args, - mcp_command: None, + mcp_command: Some( + crate::managed_agents::custom_harnesses::CUSTOM_HARNESS_MCP_COMMAND.to_string(), + ), model_env_var: None, provider_env_var: None, thinking_env_var: None, diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 00a968f20f0..eb430b1a822 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -522,10 +522,17 @@ pub async fn create_managed_agent( // per-record field is never read at spawn time so user-supplied input // is silently discarded. Always sourcing from the catalog ensures // new agents pick up the correct value without any stored override. - let mcp_command = match crate::managed_agents::known_acp_runtime(&agent_command) { - Some(p) => p.mcp_command.unwrap_or("").to_string(), - None => String::new(), - }; + // Custom harnesses mount buzz-dev-mcp even when `runtime` is unset + // (create stores the harness command, not the catalog id). + let mcp_runtime_id = requested_persona_id + .as_deref() + .and_then(|pid| personas.iter().find(|p| p.id == pid)) + .and_then(|persona| persona.runtime.as_deref()) + .unwrap_or(""); + let mcp_command = + crate::managed_agents::resolve_harness_mcp_command(mcp_runtime_id, &agent_command) + .unwrap_or("") + .to_string(); let team_id = input .team_id diff --git a/desktop/src-tauri/src/managed_agents/custom_harnesses.rs b/desktop/src-tauri/src/managed_agents/custom_harnesses.rs index ba0448beaff..4cc6051b703 100644 --- a/desktop/src-tauri/src/managed_agents/custom_harnesses.rs +++ b/desktop/src-tauri/src/managed_agents/custom_harnesses.rs @@ -18,6 +18,14 @@ use std::path::Path; use serde::{Deserialize, Serialize}; +/// MCP sidecar mounted for every user-defined custom harness at spawn. +/// +/// `buzz-acp` injects Buzz CLI auth (`BUZZ_PRIVATE_KEY`) into this sidecar +/// during `session/new`, so a child ACP sandbox that strips `*_KEY` env still +/// reaches `buzz` through MCP. Presets and builtins keep their own catalog +/// `mcp_command` and are not rewritten here. +pub(crate) const CUSTOM_HARNESS_MCP_COMMAND: &str = "buzz-dev-mcp"; + /// Regex-equivalent predicate for a valid harness ID. /// /// IDs must match `[a-z0-9_][a-z0-9_-]*` — lowercase alphanumeric plus @@ -292,6 +300,46 @@ pub(crate) fn update_loaded_harness_registry(definitions: Vec *guard = arcs; } +fn is_preset_harness_id(id: &str) -> bool { + crate::managed_agents::discovery::preset_harness_ids() + .iter() + .any(|preset| *preset == id) +} + +/// True when `id` is a loaded **user** custom harness, not a preset or builtin. +pub(crate) fn is_loaded_custom_harness_id(id: &str) -> bool { + if id.is_empty() || is_preset_harness_id(id) { + return false; + } + lookup_loaded_harness_by_id(id).is_some() +} + +fn loaded_custom_matches_command(command: &str) -> bool { + if command.is_empty() { + return false; + } + let wanted = crate::managed_agents::normalize_command_identity(command); + let guard = match loaded_harness_registry().read() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + guard.iter().any(|def| { + !is_preset_harness_id(&def.id) + && (crate::managed_agents::normalize_command_identity(&def.command) == wanted + || def.id == command) + }) +} + +/// True when spawn should mount [`CUSTOM_HARNESS_MCP_COMMAND`]. +/// +/// Matches by catalog id **or** command because create stores the harness +/// command (`dsh`) and leaves `record.runtime` unset. +pub(crate) fn is_loaded_custom_harness(runtime_id: &str, command: &str) -> bool { + is_loaded_custom_harness_id(runtime_id) + || is_loaded_custom_harness_id(command) + || loaded_custom_matches_command(command) +} + /// Look up a loaded (non-builtin) harness by **id**. Returns `None` when the id /// is unknown. Uses `into_inner` to recover from a poisoned lock so a panic in /// one thread never permanently blocks all spawn attempts. @@ -1181,6 +1229,119 @@ mod tests { ); } + fn custom_def(id: &str, command: &str) -> HarnessDefinition { + HarnessDefinition { + id: id.to_string(), + label: id.to_string(), + command: command.to_string(), + args: vec![], + env: BTreeMap::new(), + install_instructions_url: String::new(), + install_hint: String::new(), + } + } + + /// Spawn matches a custom harness by command even when `record.runtime` is + /// unset — that is the create-time shape (command pin, no runtime id). + #[test] + fn loaded_custom_harness_matches_command_without_runtime_id() { + let _lock = registry_test_lock(); + let mut defs = crate::managed_agents::discovery::preset_harness_definitions(); + defs.push(custom_def("deepseek", "dsh")); + update_loaded_harness_registry(defs); + assert!( + is_loaded_custom_harness("", "dsh"), + "create stores the command, not the catalog id" + ); + assert!(is_loaded_custom_harness("deepseek", "something-else")); + assert!( + is_loaded_custom_harness("", "/opt/homebrew/bin/dsh"), + "absolute PATH pins must still match the catalog command" + ); + assert!( + !is_loaded_custom_harness("cursor", "cursor-agent"), + "presets must not count as custom even when present in the mixed registry" + ); + assert!(!is_loaded_custom_harness("", "dsh-missing")); + + assert_eq!( + crate::managed_agents::resolve_harness_mcp_command("", "dsh"), + Some(CUSTOM_HARNESS_MCP_COMMAND), + "unknown ACP command that is a loaded custom harness must mount the sidecar" + ); + assert_eq!( + crate::managed_agents::resolve_harness_mcp_command("deepseek", "dsh"), + Some(CUSTOM_HARNESS_MCP_COMMAND) + ); + assert_eq!( + crate::managed_agents::resolve_harness_mcp_command("cursor", "cursor-agent"), + None, + "presets stay sidecar-less" + ); + assert_eq!( + crate::managed_agents::resolve_harness_mcp_command("", "goose"), + None, + "builtin goose must keep catalog mcp_command (none), not the custom mount" + ); + assert_eq!( + crate::managed_agents::resolve_harness_mcp_command("", "buzz-agent"), + Some("buzz-dev-mcp"), + "builtin buzz-agent keeps its own catalog sidecar" + ); + assert_eq!( + crate::managed_agents::resolve_harness_mcp_command("", "not-a-harness"), + None, + "an unknown command with no custom definition must not grow a sidecar" + ); + + update_loaded_harness_registry(vec![]); + } + + /// Phase-3 catalog projection must set mcp_command directly: registry warm + /// happens after the custom-entry loop, so spawn lookup is not available yet. + #[test] + fn custom_catalog_entry_projects_buzz_dev_mcp_sidecar() { + use crate::managed_agents::discovery::discover_acp_runtimes_from; + use crate::managed_agents::HarnessSource; + + let _path_guard = crate::managed_agents::lock_path_mutex(); + let _lock = registry_test_lock(); + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("deepseek.json"), + r#"{ + "id": "deepseek", + "label": "DeepSeek", + "command": "dsh", + "args": ["--profile", "acp"] + }"#, + ) + .unwrap(); + + let entries = discover_acp_runtimes_from(Some(dir.path()), true); + let custom = entries + .iter() + .find(|e| e.id == "deepseek") + .expect("custom entry must appear in catalog"); + assert_eq!(custom.source, HarnessSource::Custom); + assert_eq!( + custom.mcp_command.as_deref(), + Some(CUSTOM_HARNESS_MCP_COMMAND), + "phase-3 catalog must project the sidecar without waiting on registry warm" + ); + + let cursor = entries + .iter() + .find(|e| e.id == "cursor") + .expect("cursor preset must exist"); + assert_eq!( + cursor.mcp_command, None, + "preset catalog entries stay sidecar-less" + ); + + update_loaded_harness_registry(vec![]); + } + // ── Legacy avatarUrl regression (F1) ───────────────────────────────────── /// A JSON file that contains a legacy `avatarUrl` field (from pre-BYOH code) diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index e4b87e7557a..86979ae4c89 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -157,6 +157,21 @@ pub(crate) fn known_acp_runtime_exact(id: &str) -> Option<&'static KnownAcpRunti KNOWN_ACP_RUNTIMES.iter().find(|p| p.id == id) } +/// MCP sidecar for spawn / catalog / snapshot. +/// +/// Builtins keep their `KnownAcpRuntime::mcp_command`. User custom harnesses +/// mount `buzz-dev-mcp`. Presets stay sidecar-less even though they share the +/// loaded-harness registry with customs. +pub(crate) fn resolve_harness_mcp_command(runtime_id: &str, command: &str) -> Option<&'static str> { + if let Some(runtime) = known_acp_runtime(command) { + return runtime.mcp_command; + } + if crate::managed_agents::custom_harnesses::is_loaded_custom_harness(runtime_id, command) { + return Some(crate::managed_agents::custom_harnesses::CUSTOM_HARNESS_MCP_COMMAND); + } + None +} + /// The agent command a freshly-created agent defaults to when the create /// request supplies none. Resolves the bundled `buzz-agent` from the catalog so /// the default cannot drift from the provider definition. Falls back to the id @@ -1177,9 +1192,12 @@ pub fn discover_acp_runtimes_from( command, binary_path, default_args, - // Custom harnesses are plain ACP — no MCP sidecar, no env-var - // model switching, no thinking knobs. - mcp_command: None, + // Custom harnesses mount buzz-dev-mcp so session/new can inject + // Buzz CLI auth into a sidecar the child ACP sandbox cannot + // strip. No env-var model switching or thinking knobs. + mcp_command: Some( + crate::managed_agents::custom_harnesses::CUSTOM_HARNESS_MCP_COMMAND.to_string(), + ), model_env_var: None, provider_env_var: None, thinking_env_var: None, diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 5b44f95de92..7beb6b23b7c 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -286,10 +286,12 @@ pub fn build_managed_agent_summary( env: Default::default(), } }); - let effective_mcp_command = known_acp_runtime(&descriptor.command) - .and_then(|r| r.mcp_command) - .unwrap_or("") - .to_string(); + let effective_mcp_command = crate::managed_agents::resolve_harness_mcp_command( + record.runtime.as_deref().unwrap_or(""), + &descriptor.command, + ) + .unwrap_or("") + .to_string(); Ok(ManagedAgentSummary { pubkey: record.pubkey.clone(), @@ -519,9 +521,11 @@ pub fn spawn_agent_child( .map_err(|error| format!("failed to clone log handle: {error}"))?; let resolved_acp_command = resolve_command(&record.acp_command) .ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?; - let effective_mcp_command = known_acp_runtime(effective_command) - .and_then(|r| r.mcp_command) - .unwrap_or(""); + let effective_mcp_command = crate::managed_agents::resolve_harness_mcp_command( + record.runtime.as_deref().unwrap_or(""), + effective_command, + ) + .unwrap_or(""); let resolved_mcp_command: Option = if effective_mcp_command.is_empty() { None } else { diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index b6af6bdc6da..1df355448c1 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -105,7 +105,8 @@ pub(crate) struct SpawnConfigSnapshot { /// The effective agent command the harness drives. pub command: String, pub args: Vec, - /// Catalog-derived from `command`; `""` when the runtime has none. + /// Catalog-derived sidecar (`buzz-dev-mcp` for builtins that ship one and + /// for every user custom harness); `""` when the runtime has none. pub mcp_command: String, /// Fully layered process env: baked floor -> runtime metadata -> /// definition -> global -> persona -> agent. @@ -197,10 +198,12 @@ impl SpawnConfigSnapshot { acp_command: record.acp_command.clone(), command: descriptor.command.clone(), args: descriptor.args.clone(), - mcp_command: known_acp_runtime(&descriptor.command) - .and_then(|runtime| runtime.mcp_command) - .unwrap_or("") - .to_string(), + mcp_command: crate::managed_agents::resolve_harness_mcp_command( + record.runtime.as_deref().unwrap_or(""), + &descriptor.command, + ) + .unwrap_or("") + .to_string(), // Effort has ONE representation in the snapshot: `effort_level` // below, always holding the projected effective value. The keys // stripped here mirror EXACTLY what the launch projection suppressed diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs index 0d0a6d963a6..fcdb634dd86 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs @@ -460,6 +460,39 @@ fn unchanged_session_policy_does_not_require_restart() { assert!(policy_transition_diff(&thread, &snapshot_under(AcpSessionPolicy::Thread)).is_empty()); } +#[test] +fn custom_harness_spawn_snapshot_mounts_buzz_dev_mcp() { + use crate::managed_agents::custom_harnesses::{ + registry_test_lock, update_loaded_harness_registry, HarnessDefinition, + }; + use std::collections::BTreeMap; + + let _lock = registry_test_lock(); + update_loaded_harness_registry(vec![HarnessDefinition { + id: "deepseek".into(), + label: "DeepSeek".into(), + command: "dsh".into(), + args: vec![], + env: BTreeMap::new(), + install_instructions_url: String::new(), + install_hint: String::new(), + }]); + + // Create stores a command pin and leaves `runtime` unset. + let mut rec = record(); + rec.agent_command_override = Some("dsh".into()); + rec.agent_command = "dsh".into(); + rec.runtime = None; + let canonical = snap(&rec); + assert_eq!( + canonical.get("mcp_command").and_then(|v| v.as_str()), + Some("buzz-dev-mcp"), + "spawn snapshot must record the sidecar so a restart actually injects it" + ); + + update_loaded_harness_registry(vec![]); +} + #[test] fn definitionless_instance_retains_its_stored_session_policy() { let mut instance = record(); diff --git a/desktop/src/testing/e2eBridgeCustomHarnesses.test.mjs b/desktop/src/testing/e2eBridgeCustomHarnesses.test.mjs index 0a0fb36a922..7954fbd09ee 100644 --- a/desktop/src/testing/e2eBridgeCustomHarnesses.test.mjs +++ b/desktop/src/testing/e2eBridgeCustomHarnesses.test.mjs @@ -53,6 +53,7 @@ describe("handleSaveCustomHarness", () => { assert.equal(entry.id, "my-rt"); assert.equal(entry.label, "My RT"); assert.equal(entry.source, "custom"); + assert.equal(entry.mcp_command, "buzz-dev-mcp"); }); it("stores the entry in mockCustomHarnesses", () => { diff --git a/desktop/src/testing/e2eBridgeCustomHarnesses.ts b/desktop/src/testing/e2eBridgeCustomHarnesses.ts index b72ecadd340..a2ed8262854 100644 --- a/desktop/src/testing/e2eBridgeCustomHarnesses.ts +++ b/desktop/src/testing/e2eBridgeCustomHarnesses.ts @@ -88,7 +88,7 @@ export function handleSaveCustomHarness(args: { command: def.command ?? null, binary_path: null, default_args: def.args ?? [], - mcp_command: null, + mcp_command: "buzz-dev-mcp", install_hint: def.installHint ?? "", install_instructions_url: def.installInstructionsUrl ?? "", can_auto_install: false,