Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<app-data>/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 `<app-data>/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

Expand Down
4 changes: 3 additions & 1 deletion desktop/src-tauri/src/commands/agent_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 11 additions & 4 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
161 changes: 161 additions & 0 deletions desktop/src-tauri/src/managed_agents/custom_harnesses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -292,6 +300,46 @@ pub(crate) fn update_loaded_harness_registry(definitions: Vec<HarnessDefinition>
*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.
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 21 additions & 3 deletions desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 11 additions & 7 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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<std::path::PathBuf> = if effective_mcp_command.is_empty() {
None
} else {
Expand Down
13 changes: 8 additions & 5 deletions desktop/src-tauri/src/managed_agents/spawn_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ pub(crate) struct SpawnConfigSnapshot {
/// The effective agent command the harness drives.
pub command: String,
pub args: Vec<String>,
/// 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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions desktop/src/testing/e2eBridgeCustomHarnesses.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/testing/e2eBridgeCustomHarnesses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading