Skip to content
Closed
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
70 changes: 70 additions & 0 deletions crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,10 @@ Each channel has at most one prompt in flight. Multiple channels can be processe

Buzz Desktop supports registering any ACP-speaking agent tool as a selectable runtime without a PR.

These tiers decide *which* agent runs, not *where*. Any tier can run locally, on a
provider backend, or on hardware you manage yourself — see
[Running the Harness Yourself](#running-the-harness-yourself-external-backend).

### How it works

**Tier-1 — compiled-in runtimes** (Goose, Claude Code, Codex, Buzz Agent): have auto-installers, auth probes, and first-class onboarding. Their IDs (`goose`, `claude`, `codex`, `buzz-agent`) are reserved and cannot be overridden.
Expand Down Expand Up @@ -320,6 +324,72 @@ To add a new runtime to the tier-2 gallery:

The built-in `BUILTIN_IDS` set (`goose`, `claude`, `codex`, `buzz-agent`, and all current preset ids) is the reserved namespace; every other id is available for custom harnesses.

## Running the Harness Yourself (external backend)

The BYOH tiers above decide *which* agent runs. They are orthogonal to *where*
the harness runs — any tier works with any backend.

When the agent already lives somewhere Buzz does not control (a container on your
own VPS, a remote box), pick **Run on → "Somewhere I run myself"** at agent
creation. Buzz then mints the agent's identity and publishes its `kind:0` profile
— so it appears as a real community member immediately — but never spawns,
deploys, stops, or reads logs for it. Its status reads `external`, and liveness
comes from relay presence alone.

Note the shape: **the harness moves, not the transport.** `buzz-acp` talks to its
agent over local stdio and dials the relay outbound over WebSocket, so you run
`buzz-acp` *next to* the agent and expose nothing inbound. There is no
network-ACP transport, and exposing the agent's own API to the desktop would not
help — tools execute wherever the agent runs either way.

### Three steps

1. Create the agent with **Run on → "Somewhere I run myself"**.
2. Copy the env block from the reveal dialog. It is available again later by
expanding the agent's row in the Agents list.
3. Save it as a file and start the container with `--env-file`.

```bash
docker run -d --restart unless-stopped --env-file hermes.env my-hermes-image buzz-acp
```

Prefer `--env-file` over `-e`: values passed with `-e` land in your shell history
and in `docker inspect`.

### What the image needs

| Binary | Why |
|---|---|
| `buzz-acp` | The harness. Holds the relay connection. |
| whatever `BUZZ_ACP_AGENT_COMMAND` names (e.g. `hermes-acp`) | The agent itself. |
| `buzz` | The CLI the agent calls for Buzz operations. |
| `git-credential-nostr` *(optional)* | Buzz-hosted git auth. Needs only `NOSTR_PRIVATE_KEY`, which the block sets. |

Outbound network access to your relay is required; no inbound ports are.

### The env block

Identity and wiring are always present: `BUZZ_PRIVATE_KEY`,
`NOSTR_PRIVATE_KEY`, `BUZZ_AUTH_TAG`, `BUZZ_RELAY_URL`,
`BUZZ_ACP_AGENT_COMMAND`, `BUZZ_ACP_AGENT_ARGS`, `BUZZ_ACP_MCP_COMMAND`,
`BUZZ_ACP_AGENTS`, the respond-to gate, and the protocol defaults. Prompt, model,
session title, team instructions, and the timeouts appear only when set — an
absent timeout means the harness applies its own default, which is the single
source of truth.

Commands are emitted **bare**, not as absolute paths, so the container resolves
them on its own `PATH`. Host-specific values are deliberately excluded: `PATH`,
`RUST_LOG`, `GIT_CONFIG_*`, and the desktop-ownership stamps. See
`external_env.rs` for the full exclusion list and the reasoning per group.

`BUZZ_AUTH_TAG` is what gets the agent admitted: the relay verifies it and grants
membership via its owner (`MembershipDecision::ViaOwner`), so the agent does not
need its own roster entry.

> **Config changes need a restart.** Editing this agent's model, prompt, or env
> in Buzz does not reach an already-running container. Re-copy the block and
> restart it.

## Using Any ACP Agent

The harness works with any agent that implements the [ACP spec](https://agentclientprotocol.com/) over stdio. The requirements are:
Expand Down
123 changes: 48 additions & 75 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,49 +115,6 @@ pub(super) fn tombstone_managed_agent_pending(
}
}

/// Build and sign the NIP-IA `kind:9035` archive request enqueued when an
/// agent is deleted. Pure given the keys — unit-testable without an
/// `AppHandle`. Reuses the same wire builder as the GUI's Archive action
/// (`events::build_archive_identity_request`); the machine-readable reason is
/// `retired` (NIP-IA suggested code for a deliberately decommissioned key).
///
/// The owner auth tag is minted locally from the same keys used to sign the
/// request, avoiding a network fetch while the managed-agent store lock is
/// held. The relay still independently verifies it against the agent's live
/// kind:0.
pub(super) fn build_agent_archive_request(
keys: &nostr::Keys,
agent_pubkey: &str,
) -> Result<nostr::Event, String> {
let auth_tag = if keys
.public_key()
.to_hex()
.eq_ignore_ascii_case(agent_pubkey)
{
None
} else {
let agent = nostr::PublicKey::from_hex(agent_pubkey)
.map_err(|e| format!("invalid agent pubkey: {e}"))?;
let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(keys, &agent, "")
.map_err(|e| format!("failed to build owner auth tag: {e}"))?;
let parts: Vec<String> = serde_json::from_str(&tag_json)
.map_err(|e| format!("failed to parse owner auth tag: {e}"))?;
Some(
<[String; 4]>::try_from(parts)
.map_err(|_| "owner auth tag must have four elements".to_string())?,
)
};
crate::events::build_archive_identity_request(
agent_pubkey,
"",
Some("retired"),
None,
auth_tag.as_ref(),
)?
.sign_with_keys(keys)
.map_err(|e| format!("failed to sign archive request: {e}"))
}

/// Enqueue a NIP-IA `kind:9035` archive request for a deleted agent, retained
/// next to its kind:5 tombstone with `pending_sync = 1`.
///
Expand All @@ -181,7 +138,8 @@ pub(super) fn archive_managed_agent_pending(app: &AppHandle, state: &AppState, a
let result = (|| -> Result<(), String> {
let scope = crate::managed_agents::retention::active_retention_scope(app, state)?;
let owner_pubkey = scope.owner_keys.public_key().to_hex();
let event = build_agent_archive_request(&scope.owner_keys, agent_pubkey)?;
let event =
super::agents_archive::build_agent_archive_request(&scope.owner_keys, agent_pubkey)?;
let conn = open_retention_db(&scope.db_path)?;
retain_event(
&conn,
Expand Down Expand Up @@ -999,7 +957,9 @@ pub async fn create_managed_agent(
.err();

// ── Phase 5: provider deploy (async, outside lock) ───────────────────────
let spawn_error = if input.spawn_after_create && input.backend != BackendKind::Local {
let spawn_error = if input.spawn_after_create
&& matches!(input.backend, BackendKind::Provider { .. })
{
if let BackendKind::Provider { ref id, ref config } = input.backend {
// Read the saved record to build the deploy payload (record has the
// canonical field values after Phase 3 normalization).
Expand Down Expand Up @@ -1027,31 +987,34 @@ pub async fn create_managed_agent(
};

// Rebuild summary if provider deploy may have updated backend_agent_id.
let final_agent = if input.backend != BackendKind::Local && spawn_error.is_none() {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let records = load_managed_agents(&app)?;
let runtimes = state
.managed_agent_processes
.lock()
.map_err(|e| e.to_string())?;
let record = records
.iter()
.find(|r| r.pubkey == pubkey)
.ok_or_else(|| "agent disappeared".to_string())?;
let personas = load_personas(&app).unwrap_or_default();
build_managed_agent_summary(
&app,
record,
&runtimes,
&personas,
&crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(),
)?
} else {
agent
};
// Only the provider path writes that field, so External skips the extra
// store lock and three disk reads.
let final_agent =
if matches!(input.backend, BackendKind::Provider { .. }) && spawn_error.is_none() {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let records = load_managed_agents(&app)?;
let runtimes = state
.managed_agent_processes
.lock()
.map_err(|e| e.to_string())?;
let record = records
.iter()
.find(|r| r.pubkey == pubkey)
.ok_or_else(|| "agent disappeared".to_string())?;
let personas = load_personas(&app).unwrap_or_default();
build_managed_agent_summary(
&app,
record,
&runtimes,
&personas,
&crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(),
)?
} else {
agent
};

Ok(CreateManagedAgentResponse {
agent: final_agent,
Expand Down Expand Up @@ -1122,14 +1085,24 @@ pub async fn start_managed_agent(
persona_id: record.persona_id.clone(),
};

let target = if record.backend == BackendKind::Local {
StartTarget::Local
} else {
StartTarget::Provider {
// Exhaustive on purpose: a new BackendKind must fail to compile here
// rather than fall through into the provider deploy path.
let target = match record.backend {
BackendKind::Local => StartTarget::Local,
// Buzz does not run external agents — the user does. Refuse before
// build_deploy_payload, which would otherwise hit the keyring and
// report a misleading key error for a start that was never valid.
BackendKind::External => {
return Err(format!(
"agent {pubkey} runs outside Buzz — start it where it lives, \
then copy its env block from the agent's settings if needed"
));
}
BackendKind::Provider { .. } => StartTarget::Provider {
backend: record.backend.clone(),
cached_binary_path: record.provider_binary_path.clone(),
agent_json: build_deploy_payload(&app, &state, record)?,
}
},
};

(target, reconcile)
Expand Down
46 changes: 46 additions & 0 deletions desktop/src-tauri/src/commands/agents_archive.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//! NIP-IA archive-request construction for deleted agents, split from
//! `agents.rs` (file-size guard). Pure given the keys, so the auth-tag and
//! reason-code wiring stays unit-testable without an `AppHandle`.

/// Build and sign the NIP-IA `kind:9035` archive request enqueued when an
/// agent is deleted. Pure given the keys — unit-testable without an
/// `AppHandle`. Reuses the same wire builder as the GUI's Archive action
/// (`events::build_archive_identity_request`); the machine-readable reason is
/// `retired` (NIP-IA suggested code for a deliberately decommissioned key).
///
/// The owner auth tag is minted locally from the same keys used to sign the
/// request, avoiding a network fetch while the managed-agent store lock is
/// held. The relay still independently verifies it against the agent's live
/// kind:0.
pub(super) fn build_agent_archive_request(
keys: &nostr::Keys,
agent_pubkey: &str,
) -> Result<nostr::Event, String> {
let auth_tag = if keys
.public_key()
.to_hex()
.eq_ignore_ascii_case(agent_pubkey)
{
None
} else {
let agent = nostr::PublicKey::from_hex(agent_pubkey)
.map_err(|e| format!("invalid agent pubkey: {e}"))?;
let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(keys, &agent, "")
.map_err(|e| format!("failed to build owner auth tag: {e}"))?;
let parts: Vec<String> = serde_json::from_str(&tag_json)
.map_err(|e| format!("failed to parse owner auth tag: {e}"))?;
Some(
<[String; 4]>::try_from(parts)
.map_err(|_| "owner auth tag must have four elements".to_string())?,
)
};
crate::events::build_archive_identity_request(
agent_pubkey,
"",
Some("retired"),
None,
auth_tag.as_ref(),
)?
.sign_with_keys(keys)
.map_err(|e| format!("failed to sign archive request: {e}"))
}
Loading