feat(agents): support agents whose harness you run yourself - #3738
feat(agents): support agents whose harness you run yourself#3738vcolombo wants to merge 5 commits into
Conversation
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 <vcolombo@gmail.com>
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 <vcolombo@gmail.com>
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 <vcolombo@gmail.com>
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 <vcolombo@gmail.com>
There was a problem hiding this comment.
Pull request overview
Adds a new “external” managed-agent backend for cases where the user runs buzz-acp next to their agent on infrastructure Buzz doesn’t control (e.g., Docker/VPS). This fits into the desktop managed-agents system by extending the backend model, adding a secure env-block export path, and updating the UI to avoid offering control-plane actions Buzz can’t perform.
Changes:
- Introduces
BackendKind::External(Rust + TS types) and a remote-status mapping that reportsexternalinstead of provider-style deploy states. - Adds a new Tauri command and Rust env-block assembler to export a copy/pasteable container env file for external agents.
- Updates desktop UI + E2E coverage to (a) hide start/stop/log affordances for external agents and (b) surface the env-block reveal UI.
Reviewed changes
Copilot reviewed 34 out of 34 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/superpowers/specs/2026-07-30-external-agent-backend-design.md | Design spec for the external backend and env export approach. |
| desktop/tests/helpers/bridge.ts | Extends mock managed-agent seed types to include external. |
| desktop/tests/e2e/agents.spec.ts | Adds E2E test asserting no start control + env-block reveal behavior. |
| desktop/src/testing/e2eBridge.ts | Extends E2E bridge mocks to accept external backend/status and mocks env export. |
| desktop/src/shared/api/types.ts | Updates shared types to include external backend and status. |
| desktop/src/shared/api/tauriAgentBackends.ts | New API surface module for backend-provider discovery + external env export. |
| desktop/src/shared/api/tauri.ts | Removes backend-provider discovery functions (moved to tauriAgentBackends). |
| desktop/src/features/profile/ui/UserProfilePanelSections.tsx | Gates managed-agent primary action for external + adds env-block UI in Runtime tab. |
| desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx | Hides start/stop action menu item for external agents. |
| desktop/src/features/agents/ui/WhereToRunSection.tsx | Adds “Somewhere I run myself” option and prevents provider probing for external. |
| desktop/src/features/agents/ui/whereToRunIntent.ts | Resolves external run target and marks it as always-submittable (no probe). |
| desktop/src/features/agents/ui/whereToRunIntent.test.mjs | Tests external selection submit + intent resolution behavior. |
| desktop/src/features/agents/ui/UnifiedAgentsSection.tsx | Passes canStart into avatar control to hide start affordance for external. |
| desktop/src/features/agents/ui/SecretRevealDialog.tsx | Shows env-block (not raw nsec) on create for external agents. |
| desktop/src/features/agents/ui/ManagedAgentRow.tsx | Adds external display copy and env-block expansion (even if currently unused). |
| desktop/src/features/agents/ui/ExternalAgentEnvBlock.tsx | New reveal-and-copy UI that fetches the env block on demand. |
| desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx | Adds canStart prop to hide the start button for non-controllable agents. |
| desktop/src/features/agents/lib/managedAgentControlActions.ts | Adds canBuzzControlManagedAgent helper for external backend gating. |
| desktop/src/features/agents/lib/instanceInputForDefinition.ts | Extends backend intent modeling to include external. |
| desktop/src/features/agents/lib/instanceInputForDefinition.test.mjs | Tests that external intent carries harness commands but never spawns. |
| desktop/src/features/agents/hooks.ts | Routes backend-provider discovery through tauriAgentBackends. |
| desktop/src-tauri/src/managed_agents/types/tests.rs | Adds serde round-trip + defaulting tests for BackendKind::External. |
| desktop/src-tauri/src/managed_agents/types.rs | Adds BackendKind::External variant and docs. |
| desktop/src-tauri/src/managed_agents/runtime/metadata.rs | Extracts remote_backend_status() and covers external. |
| desktop/src-tauri/src/managed_agents/runtime.rs | Uses remote_backend_status() for non-local statuses. |
| desktop/src-tauri/src/managed_agents/mod.rs | Exposes new external_env module. |
| desktop/src-tauri/src/managed_agents/external_env.rs | New portable env assembly + env-file rendering for external agents. |
| desktop/src-tauri/src/lib.rs | Registers the new get_external_agent_env Tauri command. |
| desktop/src-tauri/src/commands/mod.rs | Adds new commands modules and exports. |
| desktop/src-tauri/src/commands/agents.rs | Fixes provider-vs-nonlocal guard sites; blocks external start; avoids extra reads for external. |
| desktop/src-tauri/src/commands/agents_tests.rs | Updates tests to use extracted archive helper and ensures mesh rejection for external. |
| desktop/src-tauri/src/commands/agents_external.rs | Implements get_external_agent_env command (backend-gated secret export). |
| desktop/src-tauri/src/commands/agents_archive.rs | Extracts archive-request building helper from agents.rs. |
| crates/buzz-acp/README.md | Documents how to run the harness yourself (external backend) and what the env block contains. |
Comments suppressed due to low confidence (1)
desktop/src/features/agents/ui/ExternalAgentEnvBlock.tsx:92
- The toggle button is disabled during loading (
disabled={isLoading}), but this also prevents the user from clicking “Hide” to cancel while the env fetch is in-flight. Since the component already has cancellation logic (fetchCancelledRef), it would be more consistent to allow hiding while open.
One option is to only disable while loading before opening (or remove the disabled state entirely).
<button
className="text-sm font-medium text-primary hover:underline"
disabled={isLoading}
onClick={handleToggle}
type="button"
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** | ||
| * 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. | ||
| */ |
There was a problem hiding this comment.
Correct, thanks — the comment was left over from the first attempt at this split. I did try re-exporting from tauri.ts, but this module imports invokeTauri from there, so re-exporting makes the two cyclic; the other tauri*.ts siblings all have consumers import them directly, so I followed that instead. Header now says so, and gives the reason.
Fixed in 79ff3ad.
| if (isOpen) { | ||
| // Cancel any in-flight fetch before clearing state. | ||
| fetchCancelledRef.current = true; | ||
| setEnvFile(null); | ||
| setIsOpen(false); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Good catch on the mechanism — the finally is gated on the same ref the cancel path sets, so isLoading would indeed stick.
One correction for the record: it is not reachable through the UI today. The toggle is disabled={isLoading}, so Hide cannot be clicked while a fetch is in flight. But that makes the invariant depend on a button’\s disabled state rather than on the state machine, which is not worth relying on — so I reset isLoading and loadError explicitly on hide, and left a comment saying why rather than leaving it looking like dead code.
Fixed in 79ff3ad.
| export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) { | ||
| if (agent.backend.type === "provider") { | ||
| return isManagedAgentActive(agent) ? "Shutdown" : "Deploy"; |
There was a problem hiding this comment.
Agreed, and this is the better version of the guard I added. Rather than adding an external branch that returns some placeholder string, the function now returns string | null and short-circuits on !canBuzzControlManagedAgent(agent) — there is no honest label for an agent Buzz does not run, and the backend refuses the matching command anyway.
That turns the footgun into a type error for any future caller instead of a convention they have to remember. Both existing call sites already gated, so the change was mechanical (?? undefined at the one site whose prop is string | undefined). Added a regression test covering external → null plus the local/provider labels so the short-circuit cannot silently swallow them.
Fixed in 79ff3ad.
- 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 <vcolombo@gmail.com>
|
Closing this — I opened it without doing the duplicate search CONTRIBUTING asks for, and the overlap is substantial enough that reviewing it now would waste your time. What I should have found first:
Two things from this branch that may be worth salvaging regardless of which direction wins, since both are independent of the approach:
Branch is preserved at |
Problem
Buzz only supports agents it can spawn. If your agent already runs somewhere else — a container on your own VPS, a remote box — there's no way to add it.
Why the harness moves, not the transport
The relay connection lives in
buzz-acp, not in the vendor agent (relay.rs:3825do_connectdials out over WebSocket). The agent leg is local stdio NDJSON JSON-RPC and nothing else —AcpClientis bound toChildStdin/ChildStdout(acp.rs:139-176), with noTransporttrait to slot a network implementation into.So the answer is to run
buzz-acpnext to the agent, not to reach the agent over the network. Exposing the agent's own API to the desktop wouldn't help anyway:buzz-acpdeclares thebuzzCLI as an MCP server and the agent spawns it (lib.rs:4179-4232), so tools execute wherever the agent runs either way — you'd get a WAN hop per JSON-RPC frame and no savings. The OpenClaw preset already documents this trap.What actually blocks the user is 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 (MembershipDecision::ViaOwner). A container cannot self-mint it.What this adds
BackendKind::External: Buzz mints the identity and publishes thekind:0profile — so the agent appears as a real community member before the container ever runs — then hands over a copy-pasteable env block. It never spawns, deploys, stops, or reads logs. Liveness comes from relay presence alone.Generic across all harnesses. Hermes is the first caller, not a special case.
Out of scope, deliberately: the provider-binary deploy path (
buzz-backend-ssh/-tailscale). The existingBackendKind::Providerprotocol already supports that with zero Rust changes — it's a follow-up.No changes to
crates/buzz-acp. Verified unnecessary:resolve_agent_owner(lib.rs:117-143) verifiesBUZZ_AUTH_TAGlocally viaverify_auth_tag— no network, no owner discovery.Notable implementation details
Only 4 Rust guard sites changed.
Externalis non-Local and every non-Local branch that matters already pattern-matchesProvider, so ~24 existing guards were correct as-is. The four that weren't:runtime.rs:149backend_agent_idis alwaysNone, so the provider mapping pinned External tonot_deployedforever. Extracted to a pure, testableremote_backend_status.agents.rs:1125StartTarget::Provider;build_deploy_payloadsucceeds (it never checks backend) and hits the keyring, so a start that was never valid failed with a misleading key error. Now an exhaustivematch, so the next variant breaks the build here.agents.rs:1002,:1030!= Localtests made External take a provider-only path (store lock + 3 disk reads for nothing).Env-block assembly deliberately does not reuse
build_deploy_payload. That builds provider-protocol JSON from user env layers only — it never callsresolve_effective_harness_descriptor, so it omits the harness-definition env floor and runtime-metadata vars, and its own doc comment admitsagent_argsis pinned at create time. Newexternal_env.rsreuses the authoritative resolvers instead, so the exported block and a local spawn of the same record cannot disagree.Two subtleties worth a reviewer's eye:
meta.default_env(e.g.GOOSE_MODE=auto) is spawn-only and not indescriptor.env— a naive "just use the descriptor" would silently drop it. Spawn applies it only when absent from the desktop's own env so an operator's shell can override; a container inherits nothing, so that guard inverts and the default is applied unconditionally.runtime_metadata_env_varsandbaked_build_envare already indescriptor.env, so copying those spawn-site writes would have been redundant.Host-specific values are excluded with a documented reason per group: absolute command paths (commands are emitted bare so the container resolves them on its own
PATH),PATH/RUST_LOG,GIT_CONFIG_*, and theBUZZ_MANAGED_AGENTownership stamps — emitting those last would make the orphan sweep try to reap the container.Accepted debt, flagged in-code: there are now two env assemblers that can drift. Mitigated with a cross-reference comment, the same discipline
readiness.rs:25-39already uses. Extracting a shared assembler out of a 500-line function that also creates log files and spawns was judged the larger risk. Worth revisiting at a third consumer.Security
Revealing an agent nsec to the frontend is the already-established posture —
SecretRevealDialogrendersprivateKeyNsecverbatim on every create, andcommands::identity::get_nsecexports the owner's nsec with no re-auth. This adds one thing: repeatability, which is required because the user rebuilds the container.Guards: the command refuses any backend but
External(without it this is a generic nsec-export endpoint for local agents, reachable by pubkey);spawn_key_refusalfails closed on a keyring outage; the response type deliberately has noDebugderive; and the frontend copiesNsecRevealRow— plainuseState, never React Query, so the secret never enters the query cache, cleared on collapse and unmount behind a late-resolve guard.render_env_fileemits values unquoted on purpose:--env-filedoes no quote or escape processing, so quoting would corrupt values rather than protect them. Safety comes from upstream POSIX key validation, which is why the test assertslines().count() == env.len()— that's the injection check.Two bugs the E2E test caught
Both are fixed here, and both are the reason the spec was worth writing:
canBuzzControlManagedAgentcovered the profile panel and members sidebar, but the agents-view card renders its own start button viaAgentRuntimeAvatarControl— external agents offered a Start that the backend refuses. It now takescanStartand hides the affordance (errors stay reachable).ManagedAgentRowis imported only byAgentGroupRows, which nothing imports. The live surface is the profile panel's Runtime tab, which is where it lives now. (Deleting those two dead files is a separate cleanup — not in this PR.)Verification
All 7
just cisteps pass:check,test-unit,desktop-test,desktop-build,desktop-tauri-check,desktop-tauri-test,web-build. Rust 1905 tests, frontend 3787, integration E2E 162 passed.Two pre-existing failures, not from this change — each confirmed by re-running on a stashed tree:
just mobile-test—channel_detail_page_test.dart"keeps follow mode off while a tall newest message stays visible". This is why a fulljust ciexits non-zero.End-to-end, manual: create with "Somewhere I run myself" → agent appears as a member before the container runs (proves profile publish is backend-independent) →
docker run --env-file→ relay accepts NIP-42 AUTH (success proves the auth tag verified) → statusexternalwith a green PresenceDot, no Start button, no log pane.Housekeeping notes for maintainers
commands/agents_archive.rsandshared/api/tauriAgentBackends.tsare pure extractions to satisfy the file-size ratchet, following the existingagents_deploy.rsprecedent. No behavior change.docs/superpowers/specs/2026-07-30-external-agent-backend-design.md.mesh-llm-clientdep with its bundled libgit2 (git ls-remoteworks fine); every Rust command neededCARGO_NET_GIT_FETCH_WITH_CLI=true. Adding[net] git-fetch-with-cli = trueto.cargo/config.tomlwould fix it for everyone. Not done here — unrelated to this feature.