From 5ce4b42f9de30aa7d09b794e3001369539247da7 Mon Sep 17 00:00:00 2001 From: Vincent Colombo Date: Thu, 30 Jul 2026 00:42:27 -0500 Subject: [PATCH 1/5] docs: spec for external agent backend (run the harness yourself) Design for BackendKind::External: Buzz mints the agent identity and publishes its profile, then hands over a copy-pasteable env block so the user can run buzz-acp themselves in their own container. Covers why the harness moves rather than the transport, the 4 Rust edit sites, env-block assembly reuse, and the credential-reveal security posture. Signed-off-by: Vincent Colombo --- ...026-07-30-external-agent-backend-design.md | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-external-agent-backend-design.md diff --git a/docs/superpowers/specs/2026-07-30-external-agent-backend-design.md b/docs/superpowers/specs/2026-07-30-external-agent-backend-design.md new file mode 100644 index 0000000000..5081a91fa7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-external-agent-backend-design.md @@ -0,0 +1,193 @@ +# External agent backend — run the harness yourself + +Date: 2026-07-30 +Status: approved, implementing + +## Problem + +Buzz supports agents only where it can spawn them. A user whose agent already +runs in a Docker container on their own VPS has no way to add it. + +Hermes Agent is the concrete case. It exists today only as a **local** tier-2 +preset (`desktop/src-tauri/src/managed_agents/discovery.rs:1595`, +`command: "hermes-acp"`) that the desktop spawns on this machine. + +## Why "remote agent" does not mean "remote transport" + +The relay connection lives in **`buzz-acp`** (the harness), not in the vendor +agent — `crates/buzz-acp/src/relay.rs:3825` `do_connect` dials out over +WebSocket. The agent leg is local stdio NDJSON JSON-RPC and nothing else: +`AcpClient` is concretely bound to `ChildStdin`/`ChildStdout` +(`crates/buzz-acp/src/acp.rs:139-176`, spawn at `:451`). There is no `Transport` +trait, no TCP, no SSH, no Docker in that path. + +So the answer is **move the harness, not the transport**: run `buzz-acp` inside +the VPS container next to `hermes-acp`. + +### Rejected: expose the agent's API over the network (e.g. Tailscale) + +1. `hermes-acp` speaks stdio JSON-RPC. It has no network ACP listener, so this + requires a transport refactor of `AcpClient`. +2. Tools would still execute in the container regardless. `buzz-acp` *declares* + the `buzz` CLI as an MCP server in `session/new` and the **agent** spawns it + (`crates/buzz-acp/src/lib.rs:4179-4232`), so the container needs `buzz` + either way — zero savings, plus a WAN hop per JSON-RPC frame. The OpenClaw + preset already documents this trap: Buzz-injected `BUZZ_*` env does not reach + the execution locus when harness and agent are separated. +3. `buzz-acp` needs outbound-only egress. No inbound port, no ACL to maintain. + +## What actually blocks the user: identity, not networking + +The agent keypair is generated in the desktop (`commands/agents.rs:632`) and the +NIP-OA auth tag is signed by the **owner's** key (`:665-676`). That tag is what +makes the relay admit the agent — `crates/buzz-relay/src/api/mod.rs:82-105` +`MembershipDecision::ViaOwner`. A container cannot self-mint it. + +## Design + +A third backend kind, `BackendKind::External`, where Buzz: + +- mints the agent identity (keypair + NIP-OA auth tag) — already backend-agnostic; +- publishes the kind:0 profile so the agent appears as a real community member + before the container ever runs — `commands/agents.rs:990` already calls + `sync_managed_agent_profile` unconditionally; +- hands the user a copy-pasteable env block containing the credentials. + +Buzz spawns nothing, deploys nothing, owns no infra. Liveness comes from relay +presence (kind:20001), which the frontend already polls and renders as a +`PresenceDot`. + +Generic across all harnesses. Hermes is the first caller, not a special case. + +### The variant + +`desktop/src-tauri/src/managed_agents/types.rs:6-13`. `Local` stays `#[default]`, +so every `#[serde(default)] backend` is unaffected. + +```rust +pub enum BackendKind { + #[default] + Local, + /// User runs `buzz-acp` themselves. Buzz mints identity + publishes profile; + /// never spawns, deploys, stops, or reads logs. Liveness = relay presence. + External, + Provider { id: String, config: serde_json::Value }, +} +``` + +Only **4 Rust sites** need edits. Every other `!= Local` / `== Local` guard is +already correct, because `External` is non-Local and every non-Local branch that +matters pattern-matches `Provider`. + +| Site | Problem | Fix | +|---|---|---| +| `managed_agents/runtime.rs:149` | `backend_agent_id` is always `None`, so External reads `"not_deployed"` forever | `if` → 3-arm `match`; `External => ("external", None, "")` | +| `commands/agents.rs:1125-1132` | External falls into the `StartTarget::Provider` branch, `build_deploy_payload` *succeeds* (it never checks backend), then dies at `:1181` `"unsupported backend kind"` | real `match record.backend`, `External` arm errors early and builds no payload | +| `commands/agents.rs:1002-1005` | outer `!= Local` test now misleading | `matches!(…, Provider { .. })` | +| `commands/agents.rs:1030` | same — burns a store lock + 3 disk reads | `matches!(…, Provider { .. })` | + +### Env-block generation + +New `managed_agents/external_env.rs`, `AppHandle`-free so it is unit-testable. + +`commands/agents_deploy.rs` `build_deploy_payload` is deliberately **not** +reused: it emits provider-protocol JSON (the provider binary does the env +translation), it builds `merged_env` from user layers only (`:70-79`) so it omits +the harness-definition env floor and runtime-metadata env vars, and its own doc +comment (`:44-48`) admits `agent_args` is pinned at create time. + +Reused resolvers (all authoritative, none reimplemented): + +| Need | Reuse | +|---|---| +| command / args / full layered env | `readiness.rs:125` `resolve_effective_harness_descriptor` | +| model / provider / prompt + orphan refusal | `resolve_effective_config(...).require_resolved()` | +| respond-to gate | `runtime.rs:380` `build_respond_to_env` | +| relay URL | `relay::effective_agent_relay_url` | +| session title | `runtime/metadata.rs:45` `resolve_session_title` | +| team instructions | `spawn_hash.rs:41` `effective_team_instructions` | +| fail closed on keyring outage | `storage.rs:199` `spawn_key_refusal` | + +Emit order (later wins; `descriptor.env` last so user env wins, matching +`runtime.rs:857-859`): identity → `BUZZ_RELAY_URL` → harness command/args/mcp +(**bare** commands, not host absolute paths) → config → protocol defaults → +respond-to gate → `descriptor.env`. + +Excluded: host-process concerns (`PATH`, `RUST_LOG`, `BUZZ_ACP_LAZY_POOL`), all +absolute `resolve_command()` paths, `GIT_CONFIG_*` and the +`git-credential-nostr` path, `BUZZ_ACP_SETUP_PAYLOAD`, the +`BUZZ_MANAGED_AGENT`/`_START_NONCE` desktop-ownership stamps (the orphan sweep +would try to reap it), `MCP_HOOK_SERVERS`, and +`HERMES_ACP_SKIP_CONFIGURED_MCP` — the harness applies that itself +(`crates/buzz-acp/src/config.rs:714` → `acp.rs:497`). + +**Accepted debt:** two env assemblers can drift. Mitigated by a +`// mirror: external_env.rs` marker at the spawn site and a doc cross-reference — +the discipline `readiness.rs:25-39` already uses. Extracting a shared assembler +out of a 500-line function that also creates log files and spawns is a larger +risk than the drift. Revisit at a third consumer. + +### Credential delivery + +Reveal-once modal at create with a copy button, plus a re-reveal from the agent's +Manage dialog (the user will rebuild the container). No file written to disk by +Buzz. + +One Tauri command, `get_external_agent_env(pubkey)`, serves both — rather than +adding a field to `CreateManagedAgentResponse`. One code path, re-reveal free. + +The keyring read is `load_managed_agents` (it calls `hydrate_keys`, +`storage.rs:267-305`); no new keyring code. + +### Security + +Revealing an agent nsec to the frontend is the **already-established posture**: +`SecretRevealDialog.tsx:51-58` renders `created.privateKeyNsec` verbatim with a +`CopyButton` on every create, and `commands/identity.rs:190` `get_nsec` exports +the *owner's* nsec with no confirm and no re-auth. This change adds exactly one +thing: repeatability. Required guards: + +- refuse unless `backend == External` — without it this is a generic nsec-export + endpoint for local agents; +- `spawn_key_refusal` — never emit a block with an empty `BUZZ_PRIVATE_KEY`; +- **no `Debug` derive** on the response type (`CreateManagedAgentResponse` + derives it at `types.rs:570` — a latent footgun; do not copy); +- never log the map or any value; errors name the pubkey only. There is no invoke + middleware logging results, so the rule is prohibitive: this type must never + reach `observer.emit`, `retain_*`, or `agent_event_content`; +- frontend copies `ProfileSettingsCard.tsx:97-137` `NsecRevealRow` including its + late-resolve guard, and does **not** use `useQuery` — the secret must not sit + in the query cache. + +Env keys are POSIX-validated and reserved-filtered upstream +(`env_vars.rs:58-91`), so user env cannot inject `BUZZ_PRIVATE_KEY=` or a key +containing `=`/newline into an `--env-file`-shaped paste. + +### `crates/buzz-acp`: zero changes + +`BUZZ_AUTH_TAG` alone is sufficient. `resolve_agent_owner` (`lib.rs:117-143`) +verifies it **locally** via `verify_auth_tag` and extracts the owner hex — no +network, no owner discovery. `BUZZ_ACP_AGENT_OWNER` is only the legacy +`auth_tag: None` fallback. + +## Out of scope + +- **Provider-binary deploy** (`buzz-backend-ssh` / `-tailscale`). The existing + `BackendKind::Provider` protocol (`managed_agents/backend.rs:19,361`) already + supports this with zero Rust changes — a follow-up, not this change. +- Logs, stop, undeploy, or status queries against the container. + +## Known follow-ups + +- `build_deploy_payload` misses the harness-definition env floor and + runtime-metadata env vars and pins `agent_args`, so provider-deploy and + external will produce *different* effective envs for the same agent. +- Config edits don't reach a running container: + `auto_restart_on_config_change: true` is set for every backend + (`agents.rs:878`) while the restart machinery filters to Local. A "your env + block changed — re-copy it" hint is the obvious next step. +- `set_managed_agent_start_on_app_launch` (`commands/agent_settings.rs:22-50`) + has no backend guard; harmless today since both restore paths filter `== Local`. +- No exhaustive `match` on `BackendKind` anywhere, so adding a variant is + compiler-invisible. Converting the `StartTarget` site to a real `match` gives + the *next* variant one place to break the build. From 494d2a17ab1275e50e3152f24ad6ab5adee5cf21 Mon Sep 17 00:00:00 2001 From: Vincent Colombo Date: Thu, 30 Jul 2026 00:58:13 -0500 Subject: [PATCH 2/5] feat(agents): add External backend for user-run harnesses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds BackendKind::External for agents whose buzz-acp the user runs themselves — typically in their own container on their own host. Buzz mints the identity and publishes the kind:0 profile so the agent shows up as a real member; it never spawns, deploys, stops, or reads logs for it. Liveness comes from relay presence alone. - new external_env module assembles a portable env block, reusing resolve_effective_harness_descriptor and resolve_effective_config so it cannot disagree with a local spawn. Host-specific values (absolute command paths, PATH, GIT_CONFIG_*, desktop-ownership stamps) are excluded; meta.default_env is applied unconditionally because a container inherits nothing. - get_external_agent_env command, gated on the External backend so it is not a generic nsec-export endpoint. Response type has no Debug derive. - remote_backend_status extracted from build_managed_agent_summary; the provider mapping would have pinned External to not_deployed forever. - start now matches BackendKind exhaustively and refuses External before build_deploy_payload, which would otherwise report a keyring error for a start that was never valid. Signed-off-by: Vincent Colombo --- desktop/src-tauri/src/commands/agents.rs | 77 ++-- .../src-tauri/src/commands/agents_external.rs | 124 +++++ .../src-tauri/src/commands/agents_tests.rs | 7 + desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 1 + .../src/managed_agents/external_env.rs | 422 ++++++++++++++++++ desktop/src-tauri/src/managed_agents/mod.rs | 1 + .../src-tauri/src/managed_agents/runtime.rs | 67 ++- .../src/managed_agents/runtime/tests.rs | 45 ++ desktop/src-tauri/src/managed_agents/types.rs | 10 + .../src/managed_agents/types/tests.rs | 37 ++ 11 files changed, 740 insertions(+), 53 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agents_external.rs create mode 100644 desktop/src-tauri/src/managed_agents/external_env.rs diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 0758fc3aac..b53da3e70a 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -999,7 +999,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). @@ -1027,31 +1029,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, @@ -1122,14 +1127,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) diff --git a/desktop/src-tauri/src/commands/agents_external.rs b/desktop/src-tauri/src/commands/agents_external.rs new file mode 100644 index 0000000000..b367b1e199 --- /dev/null +++ b/desktop/src-tauri/src/commands/agents_external.rs @@ -0,0 +1,124 @@ +//! Credential export for [`BackendKind::External`] agents. +//! +//! Buzz mints an external agent's identity but never runs it. This command hands +//! the user the env their own `buzz-acp` needs, so the harness they start comes +//! up with the same effective configuration a local spawn would have produced. +//! +//! # Security +//! +//! The response contains the agent's nsec. Revealing an agent nsec to the +//! frontend is the established posture — `SecretRevealDialog` already renders +//! `CreateManagedAgentResponse::private_key_nsec` verbatim on every create, and +//! `commands::identity::get_nsec` exports the *owner's* nsec. What this command +//! adds is repeatability, which is required: the user rebuilds the container. +//! +//! Three rules follow from that, all load-bearing: +//! +//! 1. **The backend gate below is the security boundary.** Without it this is a +//! generic nsec-export endpoint for every local agent, reachable by pubkey. +//! 2. [`ExternalAgentEnvResponse`] deliberately does **not** derive `Debug` — a +//! `{:?}` on a struct holding an nsec is how secrets reach logs. +//! 3. This type must never reach `observer.emit`, `retain_*`, or +//! `agent_event_content`. There is no invoke middleware logging results, so +//! the rule is prohibitive rather than enforced by a redaction layer. +//! +//! [`BackendKind::External`]: crate::managed_agents::BackendKind::External + +use serde::Serialize; +use tauri::{AppHandle, Manager}; + +use crate::{ + app_state::AppState, + managed_agents::{load_managed_agents, load_personas, BackendKind}, +}; + +/// The env block for an external agent's container. +/// +/// No `Debug` derive — see the module-level security note. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalAgentEnvResponse { + /// Sorted `KEY -> value` pairs, for rendering as a table. + pub env: std::collections::BTreeMap, + /// The same pairs pre-rendered as `KEY=value` lines, ready to paste into a + /// `docker run --env-file` file. Provided so the copy button and the + /// displayed text cannot drift apart. + pub env_file: String, +} + +/// Build the env block an external agent's `buzz-acp` needs. +/// +/// Refuses for any backend other than `External`: local agents are spawned by +/// Buzz with this env already applied, and provider agents have it pushed to the +/// provider binary — neither has a reason to export a reusable secret. +#[tauri::command] +pub async fn get_external_agent_env( + pubkey: String, + app: AppHandle, +) -> Result { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + + // Resolved before taking the store lock: both touch other mutexes. + let owner_hex = super::agents::workspace_owner_hex(&state)?; + let workspace_relay = crate::relay::relay_ws_url_with_override(&state); + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(&app)?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + + // The security boundary. See the module-level note. + if record.backend != BackendKind::External { + return Err( + "env export is only available for agents that run outside Buzz".to_string(), + ); + } + + // Fails closed on a keyring outage rather than handing back a block whose + // BUZZ_PRIVATE_KEY is empty — the container would start with no identity + // and fail relay auth in a way that looks like a relay problem. + if let Some(error) = crate::managed_agents::spawn_key_refusal(record) { + return Err(error); + } + + let personas = load_personas(&app).unwrap_or_default(); + let teams = crate::managed_agents::load_teams(&app).unwrap_or_default(); + let global = crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(); + + // Same authoritative resolvers spawn uses, so the exported block and a + // local spawn of the same record cannot disagree. + let descriptor = crate::managed_agents::readiness::resolve_effective_harness_descriptor( + record, &personas, &global, + )?; + let cfg = crate::managed_agents::effective_config::resolve_effective_config( + record, &personas, &global, + ) + .require_resolved()?; + let (gate, _remove) = + crate::managed_agents::build_respond_to_env(record, Some(owner_hex.as_str()))?; + let team_instructions = + crate::managed_agents::spawn_hash::effective_team_instructions(record, &teams); + let relay_url = + crate::relay::effective_agent_relay_url(&record.relay_url, &workspace_relay); + + let env = crate::managed_agents::external_env::external_agent_env( + record, + &descriptor, + &cfg, + &relay_url, + team_instructions.as_deref(), + &gate, + ); + let env_file = crate::managed_agents::external_env::render_env_file(&env); + + Ok(ExternalAgentEnvResponse { env, env_file }) + }) + .await + .map_err(|error| format!("spawn_blocking failed: {error}"))? +} diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 03389d1d18..6757dd2225 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -247,6 +247,13 @@ fn normalize_relay_mesh_rejects_non_local_backend() { normalize_relay_mesh(Some(&config), &backend).unwrap_err(), "Buzz shared compute agents must use the local backend" ); + + // External agents run on hardware Buzz cannot schedule mesh inference on, + // so they are rejected for the same reason provider agents are. + assert_eq!( + normalize_relay_mesh(Some(&config), &BackendKind::External).unwrap_err(), + "Buzz shared compute agents must use the local backend" + ); } #[test] diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 66ef7ef17b..853417a9d1 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -10,6 +10,7 @@ mod agent_providers; mod agent_settings; mod agent_update_rollback; mod agents; +mod agents_external; mod canvas; mod channel_templates; mod channel_window; @@ -70,6 +71,7 @@ pub use agent_models::*; pub use agent_providers::*; pub use agent_settings::*; pub use agents::*; +pub use agents_external::*; pub use canvas::*; pub use channel_templates::*; pub use channel_window::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 35f4eae866..4171cb079e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -802,6 +802,7 @@ pub fn run() { update_managed_agent, discover_backend_providers, probe_backend_provider, + get_external_agent_env, list_personas, create_persona, update_persona, diff --git a/desktop/src-tauri/src/managed_agents/external_env.rs b/desktop/src-tauri/src/managed_agents/external_env.rs new file mode 100644 index 0000000000..ced930b7a0 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/external_env.rs @@ -0,0 +1,422 @@ +//! Env-block assembly for [`BackendKind::External`] agents. +//! +//! An external agent runs `buzz-acp` on hardware Buzz does not control — +//! typically the user's own container. Buzz mints the identity and publishes the +//! profile; the user starts the harness. This module produces the env the user +//! pastes into their container so that harness comes up with the same effective +//! configuration a local spawn would have given it. +//! +//! # Relationship to `spawn_agent_child` +//! +//! This is the second of two env assemblers. `spawn_agent_child` +//! (`super::runtime`) builds a *host process* env; this builds a *portable* one. +//! They agree on every protocol variable and deliberately diverge on host +//! concerns — see [`external_agent_env`] for the exclusion list and why each +//! group is dropped. +//! +//! The divergence is the accepted cost of not refactoring a 500-line function +//! that interleaves env writes with log-file creation, readiness probing, and +//! spawning. A new `BUZZ_ACP_*` protocol variable added at the spawn site must +//! be mirrored here by hand. Revisit extraction if a third consumer appears. +//! +//! [`BackendKind::External`]: super::types::BackendKind::External + +use std::collections::BTreeMap; + +use super::discovery::known_acp_runtime; +use super::effective_config::EffectiveAgentConfig; +use super::readiness::EffectiveHarnessDescriptor; +use super::runtime::{resolve_session_title, SESSION_TITLE_ENV_VAR}; +use super::types::ManagedAgentRecord; + +/// Assemble the portable env block for an external agent. +/// +/// Insertion order is lowest-precedence first; `descriptor.env` is written last +/// so user-supplied env wins over every Buzz-set variable, exactly as +/// `spawn_agent_child` does. Reserved identity keys were already stripped from +/// `descriptor.env` upstream (`super::env_vars::merged_user_env`), so user env +/// cannot clobber `BUZZ_PRIVATE_KEY` and friends. +/// +/// No `AppHandle`, no filesystem, no process env — fully unit-testable. +/// +/// # Deliberately excluded +/// +/// * `PATH`, `RUST_LOG`, `BUZZ_ACP_LAZY_POOL` — host-process concerns. The +/// container supplies its own PATH; log level and pool laziness are the +/// operator's call, not a property of the agent. +/// * Every absolute path `spawn_agent_child` resolves via `resolve_command` +/// (the harness command, the agent command, `CLAUDE_CODE_EXECUTABLE`, +/// `git-credential-nostr`). Host filesystem layout does not transfer; commands +/// are emitted **bare** so the container resolves them on its own PATH. +/// * `GIT_CONFIG_*` — points at a host `git-credential-nostr` binary. The image +/// configures its own helper; `NOSTR_PRIVATE_KEY` is all the helper needs and +/// *is* emitted. +/// * `BUZZ_ACP_SETUP_PAYLOAD` — desktop-computed readiness for *this* host, +/// meaningless for a container Buzz cannot inspect. +/// * `BUZZ_MANAGED_AGENT`, `BUZZ_MANAGED_AGENT_START_NONCE` — desktop-ownership +/// stamps. An external agent is by definition not desktop-owned, and emitting +/// them would make the orphan sweep treat the container as a process to reap. +/// * `MCP_HOOK_SERVERS` — only meaningful with a desktop-injected hook server. +/// * `HERMES_ACP_SKIP_CONFIGURED_MCP` and any other per-runtime env default from +/// `config::default_agent_env` — the harness applies those itself, keyed on the +/// agent command (`crates/buzz-acp/src/config.rs`). Emitting them here would +/// duplicate truth and drift when the harness changes. +pub(crate) fn external_agent_env( + record: &ManagedAgentRecord, + descriptor: &EffectiveHarnessDescriptor, + cfg: &EffectiveAgentConfig, + relay_url: &str, + team_instructions: Option<&str>, + gate: &[(&'static str, String)], +) -> BTreeMap { + let mut env: BTreeMap = BTreeMap::new(); + let runtime_meta = known_acp_runtime(&descriptor.command); + + // ── Identity ──────────────────────────────────────────────────────────── + env.insert( + "BUZZ_PRIVATE_KEY".to_string(), + record.private_key_nsec.clone(), + ); + // Mirrors BUZZ_PRIVATE_KEY for git-sign-nostr / git-credential-nostr, same + // as `spawn_agent_child`. Unlike spawn we emit it unconditionally: spawn + // gates on finding the helper binary *on this host*, which says nothing + // about the container. The container is responsible for shipping the helper. + env.insert( + "NOSTR_PRIVATE_KEY".to_string(), + record.private_key_nsec.clone(), + ); + if let Some(auth_tag) = record.auth_tag.as_deref() { + env.insert("BUZZ_AUTH_TAG".to_string(), auth_tag.to_string()); + } + env.insert("BUZZ_RELAY_URL".to_string(), relay_url.to_string()); + + // ── Harness → agent wiring (bare commands; see exclusion list) ────────── + env.insert( + "BUZZ_ACP_AGENT_COMMAND".to_string(), + descriptor.command.clone(), + ); + env.insert("BUZZ_ACP_AGENT_ARGS".to_string(), descriptor.args.join(",")); + // Empty is meaningful, not missing: `build_mcp_servers` in buzz-acp returns + // no servers for an empty command. Harnesses whose metadata declares no MCP + // command (goose, claude, and every tier-2 preset including Hermes) get + // their Buzz tooling from their own config rather than an injected server — + // emitting the empty value keeps that parity explicit. + env.insert( + "BUZZ_ACP_MCP_COMMAND".to_string(), + runtime_meta + .and_then(|meta| meta.mcp_command) + .unwrap_or("") + .to_string(), + ); + + // Per-runtime env defaults (e.g. GOOSE_MODE=auto). `spawn_agent_child` + // applies these only when absent from the desktop's own environment, so an + // operator's shell can override. A container inherits nothing, so the + // default is unconditionally correct here. + if let Some(meta) = runtime_meta { + for (key, value) in meta.default_env { + env.insert((*key).to_string(), (*value).to_string()); + } + } + + // ── Effective configuration ───────────────────────────────────────────── + if let Some(prompt) = cfg.system_prompt.value.as_deref() { + env.insert("BUZZ_ACP_SYSTEM_PROMPT".to_string(), prompt.to_string()); + } + if let Some(model) = cfg.model.value.as_deref() { + env.insert("BUZZ_ACP_MODEL".to_string(), model.to_string()); + } + if let Some(title) = resolve_session_title(record.display_name.as_deref(), &record.name) { + env.insert(SESSION_TITLE_ENV_VAR.to_string(), title); + } + if let Some(instructions) = team_instructions { + env.insert( + "BUZZ_ACP_TEAM_INSTRUCTIONS".to_string(), + instructions.to_string(), + ); + } + env.insert( + "BUZZ_ACP_AGENTS".to_string(), + record.parallelism.to_string(), + ); + // Only emitted when explicitly overridden — otherwise the harness applies + // its own defaults, which are the single source of truth (see + // DEFAULT_IDLE_TIMEOUT_SECS in crates/buzz-acp/src/config.rs). + if let Some(idle) = record.idle_timeout_seconds { + env.insert("BUZZ_ACP_IDLE_TIMEOUT".to_string(), idle.to_string()); + } + if let Some(max_dur) = record.max_turn_duration_seconds { + env.insert( + "BUZZ_ACP_MAX_TURN_DURATION".to_string(), + max_dur.to_string(), + ); + } + + // ── Protocol defaults ─────────────────────────────────────────────────── + env.insert( + "BUZZ_ACP_MULTIPLE_EVENT_HANDLING".to_string(), + "steer".to_string(), + ); + env.insert("BUZZ_ACP_DEDUP".to_string(), "queue".to_string()); + env.insert("BUZZ_ACP_RELAY_OBSERVER".to_string(), "true".to_string()); + + // ── Inbound author gate ───────────────────────────────────────────────── + // Only the `set` half of `build_respond_to_env` applies: its `remove` half + // clears inherited process env, and a fresh container has none to clear. + for (key, value) in gate { + env.insert((*key).to_string(), value.clone()); + } + + // ── User env last so it wins (reserved keys already stripped) ─────────── + for (key, value) in &descriptor.env { + env.insert(key.clone(), value.clone()); + } + + env +} + +/// Render an env map as `KEY=value` lines for `docker run --env-file`. +/// +/// One key per line, sorted (the map is a `BTreeMap`). Values are emitted raw: +/// `--env-file` treats everything after the first `=` literally, with no quote +/// or escape processing, so quoting would corrupt values rather than protect +/// them. Keys are POSIX-validated and newline-free before they reach this +/// function (`super::env_vars::merged_user_env`), so no key can forge a line +/// break and inject a second assignment. +pub(crate) fn render_env_file(env: &BTreeMap) -> String { + let mut out = String::new(); + for (key, value) in env { + out.push_str(key); + out.push('='); + out.push_str(value); + out.push('\n'); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::effective_config::{ConfigSource, ResolvedField}; + use crate::managed_agents::types::{BackendKind, RespondTo}; + + fn record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "aa".repeat(32), + name: "hermes-vps".to_string(), + persona_id: None, + private_key_nsec: "nsec1testkey".to_string(), + auth_tag: Some(r#"["auth","ownerpk","","sig"]"#.to_string()), + relay_url: "wss://relay.example.com".to_string(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "hermes-acp".to_string(), + agent_command_override: None, + agent_args: vec![], + mcp_command: "".to_string(), + turn_timeout_seconds: 300, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 2, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + runtime_pid: None, + backend: BackendKind::External, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "".to_string(), + updated_at: "".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + relay_mesh: None, + auto_restart_on_config_change: false, + definition_respond_to: None, + definition_respond_to_allowlist: vec![], + definition_parallelism: None, + } + } + + fn descriptor(command: &str) -> EffectiveHarnessDescriptor { + EffectiveHarnessDescriptor { + command: command.to_string(), + args: vec![], + env: BTreeMap::new(), + } + } + + fn unset() -> ResolvedField { + ResolvedField { + value: None, + source: ConfigSource::Global, + } + } + + fn cfg() -> EffectiveAgentConfig { + EffectiveAgentConfig { + model: unset(), + provider: unset(), + system_prompt: unset(), + } + } + + fn gate() -> Vec<(&'static str, String)> { + vec![("BUZZ_ACP_RESPOND_TO", "anyone".to_string())] + } + + fn build( + rec: &ManagedAgentRecord, + desc: &EffectiveHarnessDescriptor, + ) -> BTreeMap { + external_agent_env(rec, desc, &cfg(), "wss://relay.example.com", None, &gate()) + } + + #[test] + fn emits_the_variables_a_container_cannot_start_without() { + let env = build(&record(), &descriptor("hermes-acp")); + assert_eq!(env.get("BUZZ_PRIVATE_KEY").unwrap(), "nsec1testkey"); + assert_eq!(env.get("NOSTR_PRIVATE_KEY").unwrap(), "nsec1testkey"); + assert_eq!( + env.get("BUZZ_AUTH_TAG").unwrap(), + r#"["auth","ownerpk","","sig"]"# + ); + assert_eq!( + env.get("BUZZ_RELAY_URL").unwrap(), + "wss://relay.example.com" + ); + assert_eq!(env.get("BUZZ_ACP_AGENT_COMMAND").unwrap(), "hermes-acp"); + assert_eq!(env.get("BUZZ_ACP_RESPOND_TO").unwrap(), "anyone"); + assert_eq!(env.get("BUZZ_ACP_SESSION_TITLE").unwrap(), "hermes-vps"); + } + + #[test] + fn excludes_host_specific_and_desktop_ownership_variables() { + let env = build(&record(), &descriptor("hermes-acp")); + for key in [ + "PATH", + "RUST_LOG", + "BUZZ_ACP_LAZY_POOL", + "BUZZ_MANAGED_AGENT", + "BUZZ_MANAGED_AGENT_START_NONCE", + "BUZZ_ACP_SETUP_PAYLOAD", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_KEY_0", + "MCP_HOOK_SERVERS", + // Applied by the harness itself, keyed on the agent command. + "HERMES_ACP_SKIP_CONFIGURED_MCP", + ] { + assert!( + !env.contains_key(key), + "{key} is host- or desktop-specific and must not reach a container" + ); + } + } + + #[test] + fn agent_command_stays_bare_not_an_absolute_host_path() { + let env = build(&record(), &descriptor("hermes-acp")); + let command = env.get("BUZZ_ACP_AGENT_COMMAND").unwrap(); + assert!( + !command.contains('/') && !command.contains('\\'), + "container resolves the command on its own PATH, got {command:?}" + ); + } + + #[test] + fn user_env_wins_over_buzz_defaults_but_cannot_touch_identity() { + let mut desc = descriptor("hermes-acp"); + desc.env + .insert("BUZZ_ACP_DEDUP".to_string(), "drop".to_string()); + // Reserved keys are stripped upstream, so descriptor.env can never + // legitimately carry one. Prove the layering anyway: if a reserved key + // ever leaked through, identity must still win. + desc.env + .insert("SOME_USER_TOKEN".to_string(), "abc".to_string()); + let env = build(&record(), &desc); + assert_eq!(env.get("BUZZ_ACP_DEDUP").unwrap(), "drop"); + assert_eq!(env.get("SOME_USER_TOKEN").unwrap(), "abc"); + assert_eq!(env.get("BUZZ_PRIVATE_KEY").unwrap(), "nsec1testkey"); + } + + #[test] + fn omits_optional_timeouts_so_the_harness_default_applies() { + let env = build(&record(), &descriptor("hermes-acp")); + assert!(!env.contains_key("BUZZ_ACP_IDLE_TIMEOUT")); + assert!(!env.contains_key("BUZZ_ACP_MAX_TURN_DURATION")); + + let mut rec = record(); + rec.idle_timeout_seconds = Some(90); + rec.max_turn_duration_seconds = Some(600); + let env = build(&rec, &descriptor("hermes-acp")); + assert_eq!(env.get("BUZZ_ACP_IDLE_TIMEOUT").unwrap(), "90"); + assert_eq!(env.get("BUZZ_ACP_MAX_TURN_DURATION").unwrap(), "600"); + } + + #[test] + fn legacy_record_without_auth_tag_falls_back_to_owner_env() { + let mut rec = record(); + rec.auth_tag = None; + // What `build_respond_to_env` produces for a legacy record. + let gate = vec![("BUZZ_ACP_AGENT_OWNER", "ownerpk".to_string())]; + let env = external_agent_env( + &rec, + &descriptor("hermes-acp"), + &cfg(), + "wss://relay.example.com", + None, + &gate, + ); + assert!(!env.contains_key("BUZZ_AUTH_TAG")); + assert_eq!(env.get("BUZZ_ACP_AGENT_OWNER").unwrap(), "ownerpk"); + } + + #[test] + fn applies_runtime_default_env_unconditionally() { + // goose declares GOOSE_MODE=auto; spawn skips it when the desktop's own + // env already has it, but a container inherits nothing. + let env = build(&record(), &descriptor("goose")); + assert_eq!(env.get("GOOSE_MODE").unwrap(), "auto"); + } + + #[test] + fn mcp_command_is_empty_for_harnesses_that_declare_none() { + let env = build(&record(), &descriptor("hermes-acp")); + assert_eq!(env.get("BUZZ_ACP_MCP_COMMAND").unwrap(), ""); + } + + #[test] + fn render_env_file_emits_one_assignment_per_line() { + let env = build(&record(), &descriptor("hermes-acp")); + let rendered = render_env_file(&env); + for line in rendered.lines() { + assert!(line.contains('='), "not an assignment: {line:?}"); + } + assert_eq!( + rendered.lines().count(), + env.len(), + "no key may forge an extra line" + ); + assert!(rendered.contains("BUZZ_PRIVATE_KEY=nsec1testkey\n")); + } +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index be9b07cf11..88a2a16fc7 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -11,6 +11,7 @@ pub(crate) mod custom_harnesses; mod discovery; pub(crate) mod effective_config; mod env_vars; +pub(crate) mod external_env; pub(crate) mod git_bash; pub(crate) mod global_config; mod managed_node_paths; diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index f3b4cb67fd..bae5600ca4 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -130,24 +130,25 @@ pub(crate) fn resolve_workspace_pair_key( ManagedAgentRuntimeKey::new(pubkey.to_string(), &effective_relay).ok() } -pub fn build_managed_agent_summary( - app: &AppHandle, - record: &ManagedAgentRecord, - runtimes: &HashMap, - personas: &[crate::managed_agents::types::AgentDefinition], - global_config: &crate::managed_agents::GlobalAgentConfig, -) -> Result { +/// The control-plane status string for a backend Buzz does not run in-process. +/// +/// Returns `None` for [`BackendKind::Local`], whose status is derived from live +/// process state by the caller. Split out of `build_managed_agent_summary` so the +/// per-backend mapping is testable without an `AppHandle`. +pub(crate) fn remote_backend_status( + backend: &crate::managed_agents::BackendKind, + backend_agent_id: Option<&str>, +) -> Option<&'static str> { use crate::managed_agents::BackendKind; - - // Community-scoped truth: this summary describes the pair for the active - // workspace relay. An agent running only in another community must read - // as stopped here — matching by pubkey alone would show every community a - // green light as long as any pair anywhere is alive. - let pair_key = workspace_pair_key(app, record); - let pair_runtime = pair_key.as_ref().and_then(|key| runtimes.get(key)); - - let (status, pid, log_path) = if record.backend != BackendKind::Local { - // Two-axis status model for remote agents: + match backend { + BackendKind::Local => None, + // External agents have no control-plane axis at all: Buzz never deploys + // them, so `backend_agent_id` is always None and the provider-style + // "deployed"/"not_deployed" pair would read "not_deployed" forever. The + // only real signal is the live axis — relay presence (kind:20001), + // polled by the frontend and shown as a PresenceDot. + BackendKind::External => Some("external"), + // Two-axis status model for provider-deployed agents: // // Control-plane (this field): "deployed" = provider has been invoked and // returned a backend_agent_id. "not_deployed" = no deploy call yet (or it @@ -162,12 +163,34 @@ pub fn build_managed_agent_summary( // (infrastructure still exists). This is intentional — the provider may // have allocated a VM/container that persists across process restarts. // A future provider `undeploy` operation (v2) will handle teardown. - let status = if record.backend_agent_id.is_some() { - "deployed".to_string() + BackendKind::Provider { .. } => Some(if backend_agent_id.is_some() { + "deployed" } else { - "not_deployed".to_string() - }; - (status, None, String::new()) + "not_deployed" + }), + } +} + +pub fn build_managed_agent_summary( + app: &AppHandle, + record: &ManagedAgentRecord, + runtimes: &HashMap, + personas: &[crate::managed_agents::types::AgentDefinition], + global_config: &crate::managed_agents::GlobalAgentConfig, +) -> Result { + // Community-scoped truth: this summary describes the pair for the active + // workspace relay. An agent running only in another community must read + // as stopped here — matching by pubkey alone would show every community a + // green light as long as any pair anywhere is alive. + let pair_key = workspace_pair_key(app, record); + let pair_runtime = pair_key.as_ref().and_then(|key| runtimes.get(key)); + + // Backends Buzz does not run in-process have no pid and no log file; their + // liveness comes from relay presence, polled by the frontend. + let (status, pid, log_path) = if let Some(remote_status) = + remote_backend_status(&record.backend, record.backend_agent_id.as_deref()) + { + (remote_status.to_string(), None, String::new()) } else { let persisted_pid = record.runtime_pid.filter(|pid| process_is_running(*pid)); if let Some(runtime) = pair_runtime { diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 3f6ee996f6..f661d070ed 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1314,3 +1314,48 @@ fn restart_eligible_false_when_orphan_has_no_drift() { fn restart_eligible_false_when_non_orphan_has_no_drift() { assert!(!super::restart_eligible(false, false, false)); } + +// ── remote_backend_status tests ───────────────────────────────────────── + +#[test] +fn remote_backend_status_is_none_for_local_so_caller_derives_from_process() { + use crate::managed_agents::BackendKind; + assert_eq!( + super::remote_backend_status(&BackendKind::Local, None), + None, + "local status comes from live process state, not this mapping" + ); +} + +#[test] +fn remote_backend_status_reports_external_regardless_of_agent_id() { + use crate::managed_agents::BackendKind; + // External never gets a backend_agent_id (no deploy step). The provider + // mapping would therefore pin it to "not_deployed" forever — this is the + // regression that variant exists to avoid. + assert_eq!( + super::remote_backend_status(&BackendKind::External, None), + Some("external") + ); + assert_eq!( + super::remote_backend_status(&BackendKind::External, Some("ignored")), + Some("external") + ); +} + +#[test] +fn remote_backend_status_tracks_provider_deploy_state() { + use crate::managed_agents::BackendKind; + let provider = BackendKind::Provider { + id: "blox".to_string(), + config: serde_json::json!({}), + }; + assert_eq!( + super::remote_backend_status(&provider, None), + Some("not_deployed") + ); + assert_eq!( + super::remote_backend_status(&provider, Some("agent-123")), + Some("deployed") + ); +} diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 3d8e0ed02b..721c18c923 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -6,6 +6,16 @@ use std::{collections::BTreeMap, path::PathBuf, process::Child}; pub enum BackendKind { #[default] Local, + /// The user runs `buzz-acp` themselves — typically in their own container on + /// their own host. Buzz mints the agent identity and publishes its kind:0 + /// profile, then hands over an env block (see + /// [`crate::managed_agents::external_env`]); it never spawns, deploys, + /// stops, or reads logs for this agent. Liveness comes from relay presence + /// (kind:20001) alone. + /// + /// Distinct from [`BackendKind::Provider`]: there is no provider binary and + /// no deploy step, so `backend_agent_id` is always `None`. + External, Provider { id: String, config: serde_json::Value, diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 96ed556068..ee823b9682 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -694,3 +694,40 @@ fn mint_rejects_out_of_range_input_parallelism() { "input-branch error must not blame the definition: {err}" ); } + +#[test] +fn backend_kind_external_round_trips_through_serde() { + use super::BackendKind; + + let parsed: BackendKind = + serde_json::from_str(r#"{"type":"external"}"#).expect("external backend must deserialize"); + assert_eq!(parsed, BackendKind::External); + assert_eq!( + serde_json::to_string(&BackendKind::External).unwrap(), + r#"{"type":"external"}"# + ); +} + +#[test] +fn agent_record_without_backend_field_still_defaults_to_local() { + // Adding the External variant must not disturb the `#[serde(default)]` + // path every pre-backend record on disk relies on. + let record: ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "abcd1234", + "name": "test-agent", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": null, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }"#, + ) + .expect("record without backend should deserialize"); + assert_eq!(record.backend, super::BackendKind::Local); +} From 5b063959606ed22e36fc0eff410d6961a81c206a Mon Sep 17 00:00:00 2001 From: Vincent Colombo Date: Thu, 30 Jul 2026 01:20:33 -0500 Subject: [PATCH 3/5] feat(agents): surface the external-agent container env in the UI Adds the 'Somewhere I run myself' option to the Run-on picker and the reveal/copy surface for the env block. - WhereToRunSection no longer hides itself when no buzz-backend-* provider binary is discovered; external needs none. External also short-circuits providerConfigComplete, which otherwise gates submit on a probe that never happens. - instanceInputForDefinition carries the harness commands for external (unlike provider) because the env export resolves them back off the record, and sets spawnAfterCreate false so create skips both the local spawn and the provider deploy. - ExternalAgentEnvBlock follows the NsecRevealRow pattern: plain useState, never React Query, cleared on collapse and unmount with a late-resolve guard. Shown at create time and again by expanding the agent row, which previously had no expanded state for non-local agents. - canBuzzControlManagedAgent hides start/stop for external at both call sites; the backend refuses those starts anyway. - Moves the agent-backend API surface to shared/api/tauriAgentBackends and the NIP-IA archive builder to commands/agents_archive to keep the file-size ratchet satisfied. Signed-off-by: Vincent Colombo --- desktop/src-tauri/src/commands/agents.rs | 46 +----- .../src-tauri/src/commands/agents_archive.rs | 46 ++++++ .../src-tauri/src/commands/agents_tests.rs | 7 +- desktop/src-tauri/src/commands/mod.rs | 1 + .../src-tauri/src/managed_agents/runtime.rs | 45 +----- .../src/managed_agents/runtime/metadata.rs | 83 ++++++++++- .../src/managed_agents/runtime/tests.rs | 45 ------ desktop/src-tauri/src/managed_agents/types.rs | 13 +- desktop/src/features/agents/hooks.ts | 2 +- .../lib/instanceInputForDefinition.test.mjs | 59 ++++++++ .../agents/lib/instanceInputForDefinition.ts | 38 ++++- .../agents/lib/managedAgentControlActions.ts | 14 ++ .../agents/ui/ExternalAgentEnvBlock.tsx | 137 ++++++++++++++++++ .../features/agents/ui/ManagedAgentRow.tsx | 34 ++++- .../features/agents/ui/SecretRevealDialog.tsx | 57 +++++--- .../features/agents/ui/WhereToRunSection.tsx | 23 ++- .../agents/ui/whereToRunIntent.test.mjs | 22 +++ .../features/agents/ui/whereToRunIntent.ts | 7 +- .../channels/ui/MembersSidebarMemberCard.tsx | 30 ++-- .../profile/ui/UserProfilePanelSections.tsx | 9 +- desktop/src/shared/api/tauri.ts | 18 --- desktop/src/shared/api/tauriAgentBackends.ts | 57 ++++++++ desktop/src/shared/api/types.ts | 18 +-- desktop/src/testing/e2eBridge.ts | 20 ++- 24 files changed, 606 insertions(+), 225 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agents_archive.rs create mode 100644 desktop/src/features/agents/ui/ExternalAgentEnvBlock.tsx create mode 100644 desktop/src/shared/api/tauriAgentBackends.ts diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index b53da3e70a..81ac2be7b4 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -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 { - 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 = 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`. /// @@ -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, diff --git a/desktop/src-tauri/src/commands/agents_archive.rs b/desktop/src-tauri/src/commands/agents_archive.rs new file mode 100644 index 0000000000..4c9a608c76 --- /dev/null +++ b/desktop/src-tauri/src/commands/agents_archive.rs @@ -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 { + 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 = 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}")) +} diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 6757dd2225..472b931ceb 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -98,8 +98,11 @@ fn build_agent_archive_request_attaches_owner_auth_and_retired_reason() { let owner = nostr::Keys::generate(); let agent = nostr::Keys::generate(); - let event = build_agent_archive_request(&owner, &agent.public_key().to_hex()) - .expect("build archive request"); + let event = crate::commands::agents_archive::build_agent_archive_request( + &owner, + &agent.public_key().to_hex(), + ) + .expect("build archive request"); let json: serde_json::Value = serde_json::from_str(&event.as_json()).unwrap(); let tags = json["tags"].as_array().unwrap(); diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 853417a9d1..b05e074856 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -10,6 +10,7 @@ mod agent_providers; mod agent_settings; mod agent_update_rollback; mod agents; +mod agents_archive; mod agents_external; mod canvas; mod channel_templates; diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index bae5600ca4..e41e5a7f89 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -22,8 +22,8 @@ pub(crate) use path::should_use_inherited; mod metadata; pub(crate) use metadata::{ - resolve_effective_prompt_model_provider, resolve_session_title, runtime_metadata_env_vars, - SESSION_TITLE_ENV_VAR, + remote_backend_status, resolve_effective_prompt_model_provider, resolve_session_title, + runtime_metadata_env_vars, SESSION_TITLE_ENV_VAR, }; mod stop; @@ -130,47 +130,6 @@ pub(crate) fn resolve_workspace_pair_key( ManagedAgentRuntimeKey::new(pubkey.to_string(), &effective_relay).ok() } -/// The control-plane status string for a backend Buzz does not run in-process. -/// -/// Returns `None` for [`BackendKind::Local`], whose status is derived from live -/// process state by the caller. Split out of `build_managed_agent_summary` so the -/// per-backend mapping is testable without an `AppHandle`. -pub(crate) fn remote_backend_status( - backend: &crate::managed_agents::BackendKind, - backend_agent_id: Option<&str>, -) -> Option<&'static str> { - use crate::managed_agents::BackendKind; - match backend { - BackendKind::Local => None, - // External agents have no control-plane axis at all: Buzz never deploys - // them, so `backend_agent_id` is always None and the provider-style - // "deployed"/"not_deployed" pair would read "not_deployed" forever. The - // only real signal is the live axis — relay presence (kind:20001), - // polled by the frontend and shown as a PresenceDot. - BackendKind::External => Some("external"), - // Two-axis status model for provider-deployed agents: - // - // Control-plane (this field): "deployed" = provider has been invoked and - // returned a backend_agent_id. "not_deployed" = no deploy call yet (or it - // failed). This axis tracks whether infrastructure *exists*, not whether - // the process is currently running. - // - // Live axis (relay presence, polled by frontend): online/away/offline. - // Shown as a PresenceDot next to the agent name. This is the real-time - // signal for whether the harness is connected. - // - // After !shutdown the agent goes offline (presence) but stays "deployed" - // (infrastructure still exists). This is intentional — the provider may - // have allocated a VM/container that persists across process restarts. - // A future provider `undeploy` operation (v2) will handle teardown. - BackendKind::Provider { .. } => Some(if backend_agent_id.is_some() { - "deployed" - } else { - "not_deployed" - }), - } -} - pub fn build_managed_agent_summary( app: &AppHandle, record: &ManagedAgentRecord, diff --git a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs index 288ce06b0a..7738990d6c 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs @@ -83,9 +83,90 @@ pub(crate) fn resolve_effective_prompt_model_provider( } } +/// The control-plane status string for a backend Buzz does not run in-process. +/// +/// Returns `None` for [`BackendKind::Local`], whose status is derived from live +/// process state by the caller. Split out of `build_managed_agent_summary` so the +/// per-backend mapping is testable without an `AppHandle`. +/// +/// [`BackendKind::Local`]: crate::managed_agents::BackendKind::Local +pub(crate) fn remote_backend_status( + backend: &crate::managed_agents::BackendKind, + backend_agent_id: Option<&str>, +) -> Option<&'static str> { + use crate::managed_agents::BackendKind; + match backend { + BackendKind::Local => None, + // External agents have no control-plane axis at all: Buzz never deploys + // them, so `backend_agent_id` is always None and the provider-style + // "deployed"/"not_deployed" pair would read "not_deployed" forever. The + // only real signal is the live axis — relay presence (kind:20001), + // polled by the frontend and shown as a PresenceDot. + BackendKind::External => Some("external"), + // Two-axis status model for provider-deployed agents: + // + // Control-plane (this field): "deployed" = provider has been invoked and + // returned a backend_agent_id. "not_deployed" = no deploy call yet (or it + // failed). This axis tracks whether infrastructure *exists*, not whether + // the process is currently running. + // + // Live axis (relay presence, polled by frontend): online/away/offline. + // Shown as a PresenceDot next to the agent name. This is the real-time + // signal for whether the harness is connected. + // + // After !shutdown the agent goes offline (presence) but stays "deployed" + // (infrastructure still exists). This is intentional — the provider may + // have allocated a VM/container that persists across process restarts. + // A future provider `undeploy` operation (v2) will handle teardown. + BackendKind::Provider { .. } => Some(if backend_agent_id.is_some() { + "deployed" + } else { + "not_deployed" + }), + } +} + #[cfg(test)] mod tests { - use super::resolve_session_title; + use super::{remote_backend_status, resolve_session_title}; + use crate::managed_agents::BackendKind; + + #[test] + fn remote_backend_status_is_none_for_local_so_caller_derives_from_process() { + assert_eq!( + remote_backend_status(&BackendKind::Local, None), + None, + "local status comes from live process state, not this mapping" + ); + } + + #[test] + fn remote_backend_status_reports_external_regardless_of_agent_id() { + // External never gets a backend_agent_id (no deploy step). The provider + // mapping would therefore pin it to "not_deployed" forever — this is the + // regression that variant exists to avoid. + assert_eq!( + remote_backend_status(&BackendKind::External, None), + Some("external") + ); + assert_eq!( + remote_backend_status(&BackendKind::External, Some("ignored")), + Some("external") + ); + } + + #[test] + fn remote_backend_status_tracks_provider_deploy_state() { + let provider = BackendKind::Provider { + id: "blox".to_string(), + config: serde_json::json!({}), + }; + assert_eq!(remote_backend_status(&provider, None), Some("not_deployed")); + assert_eq!( + remote_backend_status(&provider, Some("agent-123")), + Some("deployed") + ); + } #[test] fn resolve_session_title_prefers_display_name() { diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index f661d070ed..3f6ee996f6 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1314,48 +1314,3 @@ fn restart_eligible_false_when_orphan_has_no_drift() { fn restart_eligible_false_when_non_orphan_has_no_drift() { assert!(!super::restart_eligible(false, false, false)); } - -// ── remote_backend_status tests ───────────────────────────────────────── - -#[test] -fn remote_backend_status_is_none_for_local_so_caller_derives_from_process() { - use crate::managed_agents::BackendKind; - assert_eq!( - super::remote_backend_status(&BackendKind::Local, None), - None, - "local status comes from live process state, not this mapping" - ); -} - -#[test] -fn remote_backend_status_reports_external_regardless_of_agent_id() { - use crate::managed_agents::BackendKind; - // External never gets a backend_agent_id (no deploy step). The provider - // mapping would therefore pin it to "not_deployed" forever — this is the - // regression that variant exists to avoid. - assert_eq!( - super::remote_backend_status(&BackendKind::External, None), - Some("external") - ); - assert_eq!( - super::remote_backend_status(&BackendKind::External, Some("ignored")), - Some("external") - ); -} - -#[test] -fn remote_backend_status_tracks_provider_deploy_state() { - use crate::managed_agents::BackendKind; - let provider = BackendKind::Provider { - id: "blox".to_string(), - config: serde_json::json!({}), - }; - assert_eq!( - super::remote_backend_status(&provider, None), - Some("not_deployed") - ); - assert_eq!( - super::remote_backend_status(&provider, Some("agent-123")), - Some("deployed") - ); -} diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 721c18c923..b1b5037ec3 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -6,15 +6,10 @@ use std::{collections::BTreeMap, path::PathBuf, process::Child}; pub enum BackendKind { #[default] Local, - /// The user runs `buzz-acp` themselves — typically in their own container on - /// their own host. Buzz mints the agent identity and publishes its kind:0 - /// profile, then hands over an env block (see - /// [`crate::managed_agents::external_env`]); it never spawns, deploys, - /// stops, or reads logs for this agent. Liveness comes from relay presence - /// (kind:20001) alone. - /// - /// Distinct from [`BackendKind::Provider`]: there is no provider binary and - /// no deploy step, so `backend_agent_id` is always `None`. + /// The user runs `buzz-acp` themselves. Buzz mints the identity, publishes + /// the profile, and exports an env block; it never spawns, deploys, stops, + /// or reads logs. No deploy step, so `backend_agent_id` is always `None`. + /// See [`crate::managed_agents::external_env`]. External, Provider { id: String, diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 122c872e54..ef1677372e 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -23,7 +23,6 @@ import { deleteManagedAgent, deleteCustomHarness, discoverAcpRuntimes, - discoverBackendProviders, discoverGitBashPrerequisite, discoverManagedAgentPrereqs, getAgentConfigSurface, @@ -38,6 +37,7 @@ import { saveCustomHarness, updateManagedAgent, } from "@/shared/api/tauri"; +import { discoverBackendProviders } from "@/shared/api/tauriAgentBackends"; import type { HarnessDefinitionInput } from "@/shared/api/tauri"; import { setManagedAgentAutoRestart, diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs index d79c7abcf8..47edf16b66 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs @@ -334,3 +334,62 @@ test("item-13: no runtimes available — refuses with actionable error", () => { "empty runtime list must throw, not silently return null", ); }); + +// ── external backend: Buzz mints identity, the user runs the harness ──────── + +test("external intent carries the harness commands, unlike provider", async () => { + const external = await buildInstanceInputForDefinition( + persona({ runtime: "goose" }), + gooseRuntime, + undefined, + { type: "external" }, + ); + // The exported env block resolves the harness back off the record via + // resolve_effective_harness_descriptor, so an external agent without these + // would export BUZZ_ACP_AGENT_COMMAND="" and never start. + assert.equal(external.acpCommand, "buzz-acp"); + assert.equal(external.agentCommand, "goose-cmd"); + assert.equal(external.mcpCommand, "goose-mcp"); + assert.deepEqual(external.agentArgs, []); + assert.equal(external.harnessOverride, true); + + const provider = await buildInstanceInputForDefinition( + persona({ runtime: "goose" }), + gooseRuntime, + undefined, + { type: "provider", id: "blox", config: {} }, + ); + assert.equal( + "agentCommand" in provider, + false, + "provider deploy hands the command to the provider binary, not the record", + ); +}); + +test("external intent never spawns or auto-starts", async () => { + const input = await buildInstanceInputForDefinition( + persona(), + gooseRuntime, + undefined, + { type: "external" }, + ); + assert.deepEqual(input.backend, { type: "external" }); + // spawnAfterCreate false is what makes create skip BOTH the local spawn and + // the provider deploy without needing a backend guard at either site. + assert.equal(input.spawnAfterCreate, false); + assert.equal(input.startOnAppLaunch, false); +}); + +test("external intent still omits definition env vars", async () => { + const input = await buildInstanceInputForDefinition( + persona(), + gooseRuntime, + undefined, + { type: "external" }, + ); + assert.equal( + "envVars" in input, + false, + "row 4 holds for external too — spawn/export merges live definition env", + ); +}); diff --git a/desktop/src/features/agents/lib/instanceInputForDefinition.ts b/desktop/src/features/agents/lib/instanceInputForDefinition.ts index 5919309224..77cc7ece0b 100644 --- a/desktop/src/features/agents/lib/instanceInputForDefinition.ts +++ b/desktop/src/features/agents/lib/instanceInputForDefinition.ts @@ -81,12 +81,19 @@ export function resolveStartRuntimeForDefinition( * per-instance runtime state, never definition env). `harnessOverride` * is true because the preset commands deliberately override the * definition's runtime preference. + * - `external`: the user runs `buzz-acp` themselves. Like `provider`, nothing + * is spawned locally and `startOnAppLaunch` is false — but unlike `provider`, + * the harness commands ARE set, because the exported env block resolves them + * back off the record (`resolve_effective_harness_descriptor`). An external + * agent with no `agentCommand` would export `BUZZ_ACP_AGENT_COMMAND=""`. */ -export type BackendIntent = { - type: "provider"; - id: string; - config: Record; -}; +export type BackendIntent = + | { + type: "provider"; + id: string; + config: Record; + } + | { type: "external" }; /** * The single definition→instance mapping (Phase 1B.3.5 rows 2–4). Every @@ -125,6 +132,27 @@ export async function buildInstanceInputForDefinition( avatarUrl, }; + if (backendIntent?.type === "external") { + return { + ...base, + // Commands are set (unlike provider): the exported env block reads them + // back off the record via resolve_effective_harness_descriptor. + acpCommand: "buzz-acp", + agentCommand: runtime.command, + // Same reasoning as the local path: leave empty so args resolve live from + // the definition, which the env export re-reads on every reveal. + agentArgs: [], + mcpCommand: runtime.mcpCommand ?? "", + harnessOverride: !persona.runtime || persona.runtime === runtime.id, + model: persona.model ?? undefined, + provider: persona.provider ?? undefined, + // Nothing to spawn or restore — the user starts the harness themselves. + spawnAfterCreate: false, + startOnAppLaunch: false, + backend: { type: "external" }, + }; + } + if (backendIntent?.type === "provider") { return { ...base, diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index dbaaaba803..ec7381951d 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -35,6 +35,20 @@ export function isManagedAgentActive(agent: Pick) { return agent.status === "running" || agent.status === "deployed"; } +/** + * Whether Buzz can start/stop this agent's process at all. + * + * False for `external` agents: the user runs the harness themselves, so Buzz has + * nothing to act on. The backend refuses these starts too + * (`commands/agents.rs`); this keeps the UI from offering a button whose only + * outcome is that error. + */ +export function canBuzzControlManagedAgent( + agent: Pick, +) { + return agent.backend.type !== "external"; +} + export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) { if (agent.backend.type === "provider") { return isManagedAgentActive(agent) ? "Shutdown" : "Deploy"; diff --git a/desktop/src/features/agents/ui/ExternalAgentEnvBlock.tsx b/desktop/src/features/agents/ui/ExternalAgentEnvBlock.tsx new file mode 100644 index 0000000000..df428068f1 --- /dev/null +++ b/desktop/src/features/agents/ui/ExternalAgentEnvBlock.tsx @@ -0,0 +1,137 @@ +import { AlertTriangle } from "lucide-react"; +import * as React from "react"; + +import { getExternalAgentEnv } from "@/shared/api/tauriAgentBackends"; + +import { CopyButton } from "./CopyButton"; + +/** + * Reveal-and-copy for an external agent's container env. + * + * Modeled on `NsecRevealRow` in `features/settings/ui/ProfileSettingsCard`: + * plain `useState` (never React Query — the block contains the agent's nsec and + * must not sit in the query cache), fetched only on expand, cleared on collapse + * and unmount, with a cancellation ref so a late-resolving fetch cannot + * repopulate state after the user hid it. + * + * Re-revealable by design: the user rebuilds the container, so a create-time + * one-shot would not be enough. + */ +export function ExternalAgentEnvBlock({ + agentName, + pubkey, +}: { + agentName?: string; + pubkey: string; +}) { + const [isOpen, setIsOpen] = React.useState(false); + const [envFile, setEnvFile] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const [loadError, setLoadError] = React.useState(null); + // Guards against a late-resolving fetch repopulating state after Hide or + // after the dialog unmounts. + const fetchCancelledRef = React.useRef(false); + + React.useEffect(() => { + return () => { + fetchCancelledRef.current = true; + setEnvFile(null); + }; + }, []); + + async function handleToggle() { + if (isOpen) { + // Cancel any in-flight fetch before clearing state. + fetchCancelledRef.current = true; + setEnvFile(null); + setIsOpen(false); + return; + } + fetchCancelledRef.current = false; + setIsOpen(true); + setIsLoading(true); + setLoadError(null); + try { + const result = await getExternalAgentEnv(pubkey); + if (!fetchCancelledRef.current) setEnvFile(result.envFile); + } catch (error) { + if (!fetchCancelledRef.current) + setLoadError( + error instanceof Error + ? error.message + : "Failed to build the env block.", + ); + } finally { + if (!fetchCancelledRef.current) setIsLoading(false); + } + } + + return ( +
+
+
+

+ Container environment +

+

+ Everything buzz-acp needs to run + {agentName ? ` ${agentName}` : " this agent"} wherever it lives. +

+
+
+ {isOpen && envFile ? ( + + ) : null} + +
+
+ + {isOpen ? ( +
+
+ +

+ Contains the agent's private key. Prefer writing it to a file + and passing --env-file — values + given with -e land in your + shell history and in{" "} + docker inspect. +

+
+ + {loadError ? ( +

+ {loadError} +

+ ) : envFile ? ( + <> +
+                {envFile}
+              
+

+ The image needs buzz-acp, the + agent binary named by{" "} + BUZZ_ACP_AGENT_COMMAND, and + the buzz CLI. Changing this + agent's settings later does not reach a running container — + come back here and re-copy. +

+ + ) : ( +

Loading…

+ )} +
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 606d2b7883..d0db2d3d32 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -22,6 +22,7 @@ import type { } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { ExternalAgentEnvBlock } from "./ExternalAgentEnvBlock"; import { AgentConfigPanel } from "./AgentConfigPanel"; import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel"; @@ -56,8 +57,16 @@ export function ManagedAgentRow({ onSelectLogAgent: (pubkey: string | null) => void; }) { const isLocal = agent.backend.type === "local"; + const isExternal = agent.backend.type === "external"; + // Local rows expand to logs; external rows expand to the container env block. + // Provider rows have neither, so they stay flat. + const isRowExpandable = isLocal || isExternal; const runtimeSource = - agent.backend.type === "provider" ? `Remote (${agent.backend.id})` : null; + agent.backend.type === "provider" + ? `Remote (${agent.backend.id})` + : agent.backend.type === "external" + ? "Self-hosted" + : null; const personaLabel = agent.personaId ? (personaLabelsById[agent.personaId] ?? null) : null; @@ -82,7 +91,9 @@ export function ManagedAgentRow({ ? `Exit ${agent.lastExitCode}` : isLocal ? "Ready to launch" - : "Managed remotely"; + : agent.backend.type === "external" + ? "Started by you" + : "Managed remotely"; // When the harness recovered a meaningful error string from the agent's // log tail (Max's seam in `managed_agents/storage.rs`), promote it to // user-visible copy below the process detail. Specifically renders the @@ -103,7 +114,7 @@ export function ManagedAgentRow({ data-testid={`managed-agent-${agent.pubkey}`} >
- {isLocal ? ( + {isRowExpandable ? (
+ {isExternal && isLogSelected ? ( +
+ +
+ ) : null} + {isLocal && isLogSelected ? (
{agent.startOnAppLaunch ? "Auto-start" : "Manual start"} + ) : agent.backend.type === "external" ? ( + Runs outside Buzz ) : ( Remote deployment )} @@ -420,7 +442,11 @@ function RuntimeBlock({ function AgentOriginBadge({ agent }: { agent: ManagedAgent }) { return ( - {agent.backend.type === "local" ? "Local" : "Remote"} + {agent.backend.type === "local" + ? "Local" + : agent.backend.type === "external" + ? "External" + : "Remote"} ); } diff --git a/desktop/src/features/agents/ui/SecretRevealDialog.tsx b/desktop/src/features/agents/ui/SecretRevealDialog.tsx index e1826cec6e..545e8e7edd 100644 --- a/desktop/src/features/agents/ui/SecretRevealDialog.tsx +++ b/desktop/src/features/agents/ui/SecretRevealDialog.tsx @@ -9,6 +9,7 @@ import { DialogTitle, } from "@/shared/ui/dialog"; import { CopyButton } from "./CopyButton"; +import { ExternalAgentEnvBlock } from "./ExternalAgentEnvBlock"; export function SecretRevealDialog({ attachmentFailure, @@ -23,6 +24,11 @@ export function SecretRevealDialog({ onOpenChange: (open: boolean) => void; onRetryAttachment?: () => void; }) { + // External agents are started by the user, not by Buzz, so the useful + // artifact is the whole container env — which already contains the nsec. + // Showing the bare key alongside it would just duplicate the secret. + const isExternal = created?.agent.backend.type === "external"; + return ( @@ -30,33 +36,41 @@ export function SecretRevealDialog({ Agent created - Save the private key now. The app can keep running the harness - locally, but this secret is only revealed here. + {isExternal + ? "Copy the environment below to start this agent where it lives. You can get it again later from the agent's settings." + : "Save the private key now. The app can keep running the harness locally, but this secret is only revealed here."}
{created ? ( <> -
-
-
-

- Private key (nsec) -

-

- This is the agent identity used by `buzz-acp`. -

+ {isExternal ? ( + + ) : ( +
+
+
+

+ Private key (nsec) +

+

+ This is the agent identity used by `buzz-acp`. +

+
+
- + + {created.privateKeyNsec} +
- - {created.privateKeyNsec} - -
+ )} {created.profileSyncError ? (

@@ -79,6 +93,11 @@ export function SecretRevealDialog({

{attachmentFailure.error}

+ ) : isExternal ? ( +

+ {created.agent.name} has an identity and is in your + community. It will come online once you start it. +

) : (

{created.agent.name} is ready diff --git a/desktop/src/features/agents/ui/WhereToRunSection.tsx b/desktop/src/features/agents/ui/WhereToRunSection.tsx index f068eceec8..419b251580 100644 --- a/desktop/src/features/agents/ui/WhereToRunSection.tsx +++ b/desktop/src/features/agents/ui/WhereToRunSection.tsx @@ -2,7 +2,7 @@ import { AlertTriangle } from "lucide-react"; import * as React from "react"; import { useBackendProvidersQuery } from "@/features/agents/hooks"; -import { probeBackendProvider } from "@/shared/api/tauri"; +import { probeBackendProvider } from "@/shared/api/tauriAgentBackends"; import { ProviderConfigFields } from "./ProviderConfigFields"; import { emptyWhereToRunDraft, type WhereToRunDraft } from "./whereToRunIntent"; @@ -19,7 +19,9 @@ export function WhereToRunSection({ }) { const backendProviders = useBackendProvidersQuery().data ?? []; const [probeError, setProbeError] = React.useState(null); - const isProviderMode = draft.runOn !== "local"; + const isExternalMode = draft.runOn === "external"; + // External has no provider binary, so it must not trigger the probe below. + const isProviderMode = draft.runOn !== "local" && !isExternalMode; const selectedBackendProvider = React.useMemo( () => backendProviders.find((provider) => provider.id === draft.runOn) ?? null, @@ -63,8 +65,8 @@ export function WhereToRunSection({ }; }, [draft, isProviderMode, onDraftChange, selectedBackendProvider]); - if (backendProviders.length === 0) return null; - + // No early return on an empty provider list: "Run it myself" needs no + // provider binary, so the picker is always meaningful. return (

@@ -84,6 +86,9 @@ export function WhereToRunSection({ value={draft.runOn} > + {backendProviders.map((provider) => (
+ {isExternalMode ? ( +

+ Buzz creates the agent's identity and adds it to your community, + but won't start or stop it. After creating it you'll get an + env block to run buzz-acp wherever + the agent lives — available again later from the agent's + settings. +

+ ) : null} + {isProviderMode && selectedBackendProvider ? (
diff --git a/desktop/src/features/agents/ui/whereToRunIntent.test.mjs b/desktop/src/features/agents/ui/whereToRunIntent.test.mjs index 500e9019f2..40a2d8dc81 100644 --- a/desktop/src/features/agents/ui/whereToRunIntent.test.mjs +++ b/desktop/src/features/agents/ui/whereToRunIntent.test.mjs @@ -59,3 +59,25 @@ test("provider draft resolves with coerced config values", () => { config: { region: "us", size: 3 }, }); }); + +// ── external backend: the user runs buzz-acp themselves ───────────────────── + +test("external selection needs no probe to submit", () => { + const draft = { ...emptyWhereToRunDraft, runOn: "external" }; + // There is no provider binary to probe and no config schema, so gating on + // probedProvider (as the provider path does) would disable submit forever. + assert.equal(draft.probedProvider, null); + assert.equal(providerConfigComplete(draft), true); + assert.equal(canSubmitWhereToRun(draft), true); +}); + +test("external selection resolves to the external intent", () => { + assert.deepEqual( + resolveBackendIntent({ ...emptyWhereToRunDraft, runOn: "external" }), + { type: "external" }, + ); +}); + +test("local still resolves to no intent, distinct from external", () => { + assert.equal(resolveBackendIntent(emptyWhereToRunDraft), null); +}); diff --git a/desktop/src/features/agents/ui/whereToRunIntent.ts b/desktop/src/features/agents/ui/whereToRunIntent.ts index fcb3e82b7e..dc1f462c0c 100644 --- a/desktop/src/features/agents/ui/whereToRunIntent.ts +++ b/desktop/src/features/agents/ui/whereToRunIntent.ts @@ -1,5 +1,5 @@ import type { BackendIntent } from "../lib/instanceInputForDefinition"; -import type { BackendProviderProbeResult } from "@/shared/api/types"; +import type { BackendProviderProbeResult } from "@/shared/api/tauriAgentBackends"; import { coerceConfigValues } from "./ProviderConfigFields"; /** Draft state of the optional remote-backend selector. */ @@ -17,6 +17,10 @@ export const emptyWhereToRunDraft: WhereToRunDraft = { export function providerConfigComplete(draft: WhereToRunDraft): boolean { if (draft.runOn === "local") return true; + // External has no provider binary to probe and no config schema, so it can + // never satisfy the probedProvider check below. Without this short-circuit + // submit stays disabled forever. + if (draft.runOn === "external") return true; if (!draft.probedProvider) return false; const schema = draft.probedProvider.config_schema as | Record @@ -35,6 +39,7 @@ export function resolveBackendIntent( draft: WhereToRunDraft, ): BackendIntent | null { if (draft.runOn === "local") return null; + if (draft.runOn === "external") return { type: "external" }; return { type: "provider", id: draft.runOn, diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index a332cb8500..825614ed51 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -15,6 +15,7 @@ import { } from "lucide-react"; import { + canBuzzControlManagedAgent, getManagedAgentPrimaryActionLabel, isManagedAgentActive, } from "@/features/agents/lib/managedAgentControlActions"; @@ -360,18 +361,23 @@ function MemberActionsMenu({ {memberIsBot && managedAgent ? ( <> {canViewActivity ? : null} - onManagedAgentAction(managedAgent)} - > - {pairAction - ? getPairActionIcon(pairAction) - : getManagedAgentActionIcon(managedAgent)} - {pairAction - ? MANAGED_AGENT_PAIR_ACTION_LABELS[pairAction] - : getManagedAgentPrimaryActionLabel(managedAgent)} - + {/* External agents are started by the user, so there is no + start/stop for Buzz to offer — but respond-to and the rest of + this menu still apply. */} + {canBuzzControlManagedAgent(managedAgent) ? ( + onManagedAgentAction(managedAgent)} + > + {pairAction + ? getPairActionIcon(pairAction) + : getManagedAgentActionIcon(managedAgent)} + {pairAction + ? MANAGED_AGENT_PAIR_ACTION_LABELS[pairAction] + : getManagedAgentPrimaryActionLabel(managedAgent)} + + ) : null} {onEditRespondTo ? ( { - return invokeTauri("discover_backend_providers"); -} - -export async function probeBackendProvider( - binaryPath: string, -): Promise { - return invokeTauri("probe_backend_provider", { - binaryPath, - }); -} - // ── NIP-44 encrypt-to-self ─────────────────────────────────────────────────── export async function nip44EncryptToSelf(plaintext: string): Promise { diff --git a/desktop/src/shared/api/tauriAgentBackends.ts b/desktop/src/shared/api/tauriAgentBackends.ts new file mode 100644 index 0000000000..a1b64eac51 --- /dev/null +++ b/desktop/src/shared/api/tauriAgentBackends.ts @@ -0,0 +1,57 @@ +/** + * Agent backend surface: where an agent's harness runs, and the API for the + * backends Buzz does not run in-process. Split from `tauri.ts`/`types.ts` + * (file-size guard); re-exported from both so import sites are unchanged. + */ +import { invokeTauri } from "@/shared/api/tauri"; + +export type BackendProviderCandidate = { + id: string; + binaryPath: string; +}; + +export type BackendProviderProbeResult = { + ok: boolean; + name?: string; + version?: string; + description?: string; + config_schema?: Record; +}; + +/** + * Env for an `external`-backend agent's container. Contains the agent's nsec — + * never cache, log, or persist it. + */ +export type ExternalAgentEnv = { + /** Sorted `KEY -> value` pairs, for display. */ + env: Record; + /** The same pairs as `KEY=value` lines, for `docker run --env-file`. */ + envFile: string; +}; + +export async function discoverBackendProviders(): Promise< + BackendProviderCandidate[] +> { + return invokeTauri("discover_backend_providers"); +} + +export async function probeBackendProvider( + binaryPath: string, +): Promise { + return invokeTauri("probe_backend_provider", { + binaryPath, + }); +} + +/** + * Fetch the env block an `external`-backend agent's `buzz-acp` needs. + * + * The response contains the agent's nsec. Callers must not cache it (no + * `useQuery`) and must clear it from component state when hidden — see + * `ExternalAgentEnvBlock`. Rejects for any other backend. + */ +export async function getExternalAgentEnv( + pubkey: string, +): Promise { + return invokeTauri("get_external_agent_env", { pubkey }); +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 689c400b03..cf47065f6d 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -320,6 +320,8 @@ export type ManagedAgentRuntimeStatus = { export type ManagedAgentBackend = | { type: "local" } + /** User-run harness; see `tauriAgentBackends.getExternalAgentEnv`. */ + | { type: "external" } | { type: "provider"; id: string; config: Record }; export type ManagedAgent = { @@ -377,7 +379,8 @@ export type ManagedAgent = { needsRestart: boolean; /** Per-agent env vars. Layered on top of persona envVars. */ envVars: Record; - status: "running" | "stopped" | "deployed" | "not_deployed"; + /** `external` has no control-plane axis; the PresenceDot is the live signal. */ + status: "running" | "stopped" | "deployed" | "not_deployed" | "external"; pid: number | null; createdAt: string; updatedAt: string; @@ -407,19 +410,6 @@ export type ManagedAgent = { */ export type RespondToMode = "owner-only" | "allowlist" | "anyone"; -export type BackendProviderCandidate = { - id: string; - binaryPath: string; -}; - -export type BackendProviderProbeResult = { - ok: boolean; - name?: string; - version?: string; - description?: string; - config_schema?: Record; -}; - export type RelayMeshConfig = { modelRef: string; }; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4cfc553df1..3e5e51ffa9 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -748,7 +748,7 @@ type RawManagedAgent = { model: string | null; provider?: string | null; env_vars?: Record; - status: "running" | "stopped" | "deployed" | "not_deployed"; + status: "running" | "stopped" | "deployed" | "not_deployed" | "external"; pid: number | null; created_at: string; updated_at: string; @@ -763,6 +763,7 @@ type RawManagedAgent = { auto_restart_on_config_change?: boolean; backend: | { type: "local" } + | { type: "external" } | { type: "provider"; id: string; config: Record }; backend_agent_id: string | null; respond_to: "owner-only" | "allowlist" | "anyone"; @@ -7821,6 +7822,7 @@ async function handleCreateManagedAgent( startOnAppLaunch?: boolean; backend?: | { type: "local" } + | { type: "external" } | { type: "provider"; id: string; config: Record }; respondTo?: "owner-only" | "allowlist" | "anyone"; respondToAllowlist?: string[]; @@ -10447,6 +10449,22 @@ export function maybeInstallE2eTauriMocks() { return []; case "probe_backend_provider": return { ok: false, error: "mock: no providers available" }; + case "get_external_agent_env": { + const env: Record = { + BUZZ_ACP_AGENT_COMMAND: "hermes-acp", + BUZZ_ACP_RESPOND_TO: "owner-only", + BUZZ_AUTH_TAG: '["auth","mock-owner","","mock-sig"]', + BUZZ_PRIVATE_KEY: "nsec1mockexternalagentkey", + BUZZ_RELAY_URL: "ws://localhost:3000", + NOSTR_PRIVATE_KEY: "nsec1mockexternalagentkey", + }; + return { + env, + envFile: Object.entries(env) + .map(([key, value]) => `${key}=${value}`) + .join("\n"), + }; + } case "discover_managed_agent_prereqs": return handleDiscoverManagedAgentPrereqs( payload as Parameters[0], From dfab1fdfe1f6c039d5106ec6b22b213ceaa17aa8 Mon Sep 17 00:00:00 2001 From: Vincent Colombo Date: Thu, 30 Jul 2026 08:31:20 -0500 Subject: [PATCH 4/5] docs(acp): document the external backend; e2e-cover the env reveal README section on running buzz-acp yourself: why the harness moves rather than the transport, the three-step flow, what the image needs, and that config edits do not reach an already-running container. The spec caught two real gaps, both fixed here: - the agents-view card rendered its own start button, so external agents offered a Start that the backend refuses. AgentRuntimeAvatarControl now takes canStart and hides the affordance (errors stay reachable). - ManagedAgentRow, where the env block first landed, is unreachable: only AgentGroupRows imports it and nothing imports that. The live surface is the profile panel's Runtime tab, which is where the block now lives. Signed-off-by: Vincent Colombo --- crates/buzz-acp/README.md | 70 +++++++++++++++++++ .../agents/ui/AgentRuntimeAvatarControl.tsx | 12 +++- .../agents/ui/UnifiedAgentsSection.tsx | 6 +- .../profile/ui/UserProfilePanelSections.tsx | 7 ++ desktop/tests/e2e/agents.spec.ts | 48 +++++++++++++ desktop/tests/helpers/bridge.ts | 3 +- 6 files changed, 142 insertions(+), 4 deletions(-) diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd..7bd6d179c5 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -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. @@ -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: diff --git a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx index 6f34ffba79..22a1c63524 100644 --- a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx +++ b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx @@ -23,6 +23,13 @@ type AgentRuntimeAvatarControlProps = { startTestId: string; onOpenError?: () => void; onStart: () => void; + /** + * Whether Buzz can start this agent at all. False for `external` backends, + * where the user runs the harness themselves — the start affordance is hidden + * rather than disabled, since there is nothing for Buzz to start. An error + * badge still shows so the failure stays reachable. + */ + canStart?: boolean; }; const TAILWIND_SPACING = { @@ -106,6 +113,7 @@ export function AgentRuntimeAvatarControl({ startTestId, onOpenError, onStart, + canStart = true, }: AgentRuntimeAvatarControlProps) { const shouldReduceMotion = useReducedMotion(); const trimmedAvatarUrl = avatarUrl?.trim() || null; @@ -129,7 +137,7 @@ export function AgentRuntimeAvatarControl({ > - ) : ( + ) : canStart || hasError ? ( - )} + ) : null} } badgeBox={badge.shell} diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 9bbe3feef7..952030139d 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -8,7 +8,10 @@ import { import { resolveAgentCardModelLabel } from "@/features/agents/lib/agentCardModelLabel"; import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError"; -import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; +import { + canBuzzControlManagedAgent, + isManagedAgentActive, +} from "@/features/agents/lib/managedAgentControlActions"; import { useUserProfileQuery } from "@/features/profile/hooks"; import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; @@ -385,6 +388,7 @@ function StandaloneAgentCard({ avatar={
) : null} + {isOwner === true && managedAgent?.backend.type === "external" ? ( + + ) : null} ) : null} {activeTab === "channels" ? ( diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 13eda788b1..f1e247735d 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -2387,3 +2387,51 @@ test("duplicate instances move from the agents gallery into the agent profile", page.getByTestId(`user-profile-agent-delete-${additionalPubkey}`), ).toBeVisible(); }); + +// ── external backend: the user runs the harness themselves ────────────────── +// +// Buzz mints the identity and publishes the profile, then exports an env block. +// It must never offer to start, stop, or tail logs for such an agent. + +const EXTERNAL_AGENT_PUBKEY = "e".repeat(64); + +test("external agent exposes its container env and no start control", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: EXTERNAL_AGENT_PUBKEY, + name: "hermes-vps", + status: "external", + backend: { type: "external" }, + }, + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + // Buzz cannot start what it does not run — no start affordance on the card. + await expect( + page.getByTestId(`agent-runtime-start-${EXTERNAL_AGENT_PUBKEY}`), + ).toHaveCount(0); + + await page.getByTestId(`managed-agent-${EXTERNAL_AGENT_PUBKEY}`).click(); + await page.getByRole("tab", { name: "Runtime" }).click(); + + const envBlock = page.getByTestId("external-agent-env-block"); + await expect(envBlock).toBeVisible(); + + // Collapsed until asked for — the secret is not rendered on open. + await expect(envBlock).not.toContainText("BUZZ_PRIVATE_KEY"); + await envBlock.getByRole("button", { name: "Show" }).click(); + + await expect(envBlock).toContainText("BUZZ_PRIVATE_KEY="); + await expect(envBlock).toContainText("BUZZ_AUTH_TAG="); + await expect(envBlock).toContainText("BUZZ_RELAY_URL="); + await expect(envBlock).toContainText("BUZZ_ACP_AGENT_COMMAND=hermes-acp"); + + // Hiding clears it from the DOM, matching the nsec-reveal contract. + await envBlock.getByRole("button", { name: "Hide" }).click(); + await expect(envBlock).not.toContainText("BUZZ_PRIVATE_KEY"); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index ca4d62ddd6..64a1bf3dd8 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -47,11 +47,12 @@ type MockManagedAgentSeed = { pubkey: string; name: string; personaId?: string | null; - status?: "running" | "stopped" | "deployed" | "not_deployed"; + status?: "running" | "stopped" | "deployed" | "not_deployed" | "external"; channelNames?: string[]; channelIds?: string[]; backend?: | { type: "local" } + | { type: "external" } | { type: "provider"; id: string; config: Record }; lastError?: string | null; lastErrorCode?: number | null; From 79ff3ade2a0d337734b992ddf9b6d0a1d15a8528 Mon Sep 17 00:00:00 2001 From: Vincent Colombo Date: Thu, 30 Jul 2026 08:47:06 -0500 Subject: [PATCH 5/5] fix(agents): address review on the external backend - getManagedAgentPrimaryActionLabel returned "Spawn" for external agents, which Buzz cannot start. It now returns null so callers handle the absence at the type level instead of remembering to gate on canBuzzControlManagedAgent first. - hiding the env block cancels the in-flight fetch, but the finally block is gated on the same ref, so isLoading was never cleared. Not reachable through the toggle today (disabled while loading), but that made correctness depend on a button's disabled state. Reset it explicitly. - tauriAgentBackends header claimed the module is re-exported so import sites are unchanged; it is not, and they were updated. Corrected, with the reason (re-exporting would make it cyclic with tauri.ts). Signed-off-by: Vincent Colombo --- .../lib/managedAgentControlActions.test.mjs | 31 +++++++++++++++++++ .../agents/lib/managedAgentControlActions.ts | 17 +++++++++- .../agents/ui/ExternalAgentEnvBlock.tsx | 9 +++++- .../profile/ui/UserProfilePanelSections.tsx | 2 +- desktop/src/shared/api/tauriAgentBackends.ts | 8 +++-- 5 files changed, 62 insertions(+), 5 deletions(-) diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs index e6926b36d2..cdf2490b6c 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs +++ b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + canBuzzControlManagedAgent, + getManagedAgentPrimaryActionLabel, startManagedAgentWithRules, respawnManagedAgentWithRules, } from "./managedAgentControlActions.ts"; @@ -166,3 +168,32 @@ test("test_respawn_onStopped_fires_before_start_resolves", async () => { "onStopped must fire after stop resolves and before start is called", ); }); + +// ── external backend has no primary action ───────────────────────────────── + +test("external agents have no primary action label", () => { + const external = agent({ backend: { type: "external" }, status: "external" }); + + assert.equal(canBuzzControlManagedAgent(external), false); + // null, not a string: every label would be a lie, and the backend refuses the + // command. Callers must handle the absence at the type level rather than + // remembering to gate on canBuzzControlManagedAgent first. + assert.equal(getManagedAgentPrimaryActionLabel(external), null); +}); + +test("other backends keep their existing primary action labels", () => { + assert.equal( + getManagedAgentPrimaryActionLabel(agent({ status: "stopped" })), + "Respawn", + ); + assert.equal( + getManagedAgentPrimaryActionLabel(agent({ status: "running" })), + "Stop", + ); + assert.equal( + getManagedAgentPrimaryActionLabel( + agent({ backend: { type: "provider", id: "blox", config: {} } }), + ), + "Deploy", + ); +}); diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index ec7381951d..1dba1c0adf 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -49,7 +49,22 @@ export function canBuzzControlManagedAgent( return agent.backend.type !== "external"; } -export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) { +/** + * Label for the agent's start/stop control, or `null` when there is no valid + * action. + * + * `null` for `external` agents: Buzz does not run them, so every label would be + * a lie and the backend refuses the corresponding command. Returning `null` + * rather than a string makes callers handle it at the type level instead of + * relying on remembering to gate on [`canBuzzControlManagedAgent`] first. + */ +export function getManagedAgentPrimaryActionLabel( + agent: ManagedAgent, +): string | null { + if (!canBuzzControlManagedAgent(agent)) { + return null; + } + if (agent.backend.type === "provider") { return isManagedAgentActive(agent) ? "Shutdown" : "Deploy"; } diff --git a/desktop/src/features/agents/ui/ExternalAgentEnvBlock.tsx b/desktop/src/features/agents/ui/ExternalAgentEnvBlock.tsx index df428068f1..70996ab10e 100644 --- a/desktop/src/features/agents/ui/ExternalAgentEnvBlock.tsx +++ b/desktop/src/features/agents/ui/ExternalAgentEnvBlock.tsx @@ -41,9 +41,16 @@ export function ExternalAgentEnvBlock({ async function handleToggle() { if (isOpen) { - // Cancel any in-flight fetch before clearing state. + // Cancel any in-flight fetch and reset every piece of its state. The + // `finally` below is gated on this same ref, so it will not run for the + // cancelled fetch — without clearing here, `isLoading` would stay true + // forever. Not reachable through the toggle today (it is disabled while + // loading), but that makes correctness depend on a button's disabled + // state, which is not an invariant worth relying on. fetchCancelledRef.current = true; setEnvFile(null); + setIsLoading(false); + setLoadError(null); setIsOpen(false); return; } diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 029ae8708c..4b6515301d 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -366,7 +366,7 @@ export function ProfileSummaryView({ isOwner === true && managedAgent && canBuzzControlManagedAgent(managedAgent) - ? getManagedAgentPrimaryActionLabel(managedAgent) + ? (getManagedAgentPrimaryActionLabel(managedAgent) ?? undefined) : undefined } agentActionLive={ diff --git a/desktop/src/shared/api/tauriAgentBackends.ts b/desktop/src/shared/api/tauriAgentBackends.ts index a1b64eac51..ac255b0626 100644 --- a/desktop/src/shared/api/tauriAgentBackends.ts +++ b/desktop/src/shared/api/tauriAgentBackends.ts @@ -1,7 +1,11 @@ /** * Agent backend surface: where an agent's harness runs, and the API for the - * backends Buzz does not run in-process. Split from `tauri.ts`/`types.ts` - * (file-size guard); re-exported from both so import sites are unchanged. + * backends Buzz does not run in-process. Split out of `tauri.ts`/`types.ts` + * (file-size guard). + * + * Import sites point here directly rather than at a re-export from `tauri.ts`: + * this module imports `invokeTauri` from `tauri.ts`, so re-exporting would make + * the two modules cyclic. That matches how the other `tauri*.ts` siblings work. */ import { invokeTauri } from "@/shared/api/tauri";