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-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 0758fc3aac..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, @@ -999,7 +957,9 @@ pub async fn create_managed_agent( .err(); // ── Phase 5: provider deploy (async, outside lock) ─────────────────────── - let spawn_error = if input.spawn_after_create && input.backend != BackendKind::Local { + let spawn_error = if input.spawn_after_create + && matches!(input.backend, BackendKind::Provider { .. }) + { if let BackendKind::Provider { ref id, ref config } = input.backend { // Read the saved record to build the deploy payload (record has the // canonical field values after Phase 3 normalization). @@ -1027,31 +987,34 @@ pub async fn create_managed_agent( }; // Rebuild summary if provider deploy may have updated backend_agent_id. - let final_agent = if input.backend != BackendKind::Local && spawn_error.is_none() { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let records = load_managed_agents(&app)?; - let runtimes = state - .managed_agent_processes - .lock() - .map_err(|e| e.to_string())?; - let record = records - .iter() - .find(|r| r.pubkey == pubkey) - .ok_or_else(|| "agent disappeared".to_string())?; - let personas = load_personas(&app).unwrap_or_default(); - build_managed_agent_summary( - &app, - record, - &runtimes, - &personas, - &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), - )? - } else { - agent - }; + // Only the provider path writes that field, so External skips the extra + // store lock and three disk reads. + let final_agent = + if matches!(input.backend, BackendKind::Provider { .. }) && spawn_error.is_none() { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = load_managed_agents(&app)?; + let runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + let record = records + .iter() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| "agent disappeared".to_string())?; + let personas = load_personas(&app).unwrap_or_default(); + build_managed_agent_summary( + &app, + record, + &runtimes, + &personas, + &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), + )? + } else { + agent + }; Ok(CreateManagedAgentResponse { agent: final_agent, @@ -1122,14 +1085,24 @@ pub async fn start_managed_agent( persona_id: record.persona_id.clone(), }; - let target = if record.backend == BackendKind::Local { - StartTarget::Local - } else { - StartTarget::Provider { + // Exhaustive on purpose: a new BackendKind must fail to compile here + // rather than fall through into the provider deploy path. + let target = match record.backend { + BackendKind::Local => StartTarget::Local, + // Buzz does not run external agents — the user does. Refuse before + // build_deploy_payload, which would otherwise hit the keyring and + // report a misleading key error for a start that was never valid. + BackendKind::External => { + return Err(format!( + "agent {pubkey} runs outside Buzz — start it where it lives, \ + then copy its env block from the agent's settings if needed" + )); + } + BackendKind::Provider { .. } => StartTarget::Provider { backend: record.backend.clone(), cached_binary_path: record.provider_binary_path.clone(), agent_json: build_deploy_payload(&app, &state, record)?, - } + }, }; (target, reconcile) 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_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..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(); @@ -247,6 +250,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..b05e074856 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -10,6 +10,8 @@ mod agent_providers; mod agent_settings; mod agent_update_rollback; mod agents; +mod agents_archive; +mod agents_external; mod canvas; mod channel_templates; mod channel_window; @@ -70,6 +72,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..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; @@ -137,8 +137,6 @@ pub fn build_managed_agent_summary( personas: &[crate::managed_agents::types::AgentDefinition], global_config: &crate::managed_agents::GlobalAgentConfig, ) -> Result { - 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 @@ -146,28 +144,12 @@ pub fn build_managed_agent_summary( 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: - // - // 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. - let status = if record.backend_agent_id.is_some() { - "deployed".to_string() - } else { - "not_deployed".to_string() - }; - (status, None, String::new()) + // 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/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/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 3d8e0ed02b..b1b5037ec3 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -6,6 +6,11 @@ use std::{collections::BTreeMap, path::PathBuf, process::Child}; pub enum BackendKind { #[default] Local, + /// 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, 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); +} 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.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 dbaaaba803..1dba1c0adf 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -35,7 +35,36 @@ export function isManagedAgentActive(agent: Pick) { return agent.status === "running" || agent.status === "deployed"; } -export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) { +/** + * 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"; +} + +/** + * 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/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/ExternalAgentEnvBlock.tsx b/desktop/src/features/agents/ui/ExternalAgentEnvBlock.tsx new file mode 100644 index 0000000000..70996ab10e --- /dev/null +++ b/desktop/src/features/agents/ui/ExternalAgentEnvBlock.tsx @@ -0,0 +1,144 @@ +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 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; + } + 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/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); - 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 ? (
) : null} + {isOwner === true && managedAgent?.backend.type === "external" ? ( + + ) : null} ) : null} {activeTab === "channels" ? ( diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index c57525480e..7adc41f27f 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -6,8 +6,6 @@ import { import type { AddChannelMembersInput, AddChannelMembersResult, - BackendProviderCandidate, - BackendProviderProbeResult, CanvasResponse, GetHomeFeedInput, HomeFeedResponse, @@ -1127,22 +1125,6 @@ export async function updateManagedAgent( }; } -// ── Backend provider discovery ──────────────────────────────────────────────── - -export async function discoverBackendProviders(): Promise< - BackendProviderCandidate[] -> { - 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..ac255b0626 --- /dev/null +++ b/desktop/src/shared/api/tauriAgentBackends.ts @@ -0,0 +1,61 @@ +/** + * Agent backend surface: where an agent's harness runs, and the API for the + * 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"; + +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], 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; 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.