Skip to content

feat(agents): support agents whose harness you run yourself - #3738

Closed
vcolombo wants to merge 5 commits into
block:mainfrom
vcolombo:vcolombo/brainstorm-remote-hermes-agent
Closed

feat(agents): support agents whose harness you run yourself#3738
vcolombo wants to merge 5 commits into
block:mainfrom
vcolombo:vcolombo/brainstorm-remote-hermes-agent

Conversation

@vcolombo

Copy link
Copy Markdown

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:3825 do_connect dials out over WebSocket). The agent leg is local stdio NDJSON JSON-RPC and nothing else — AcpClient is bound to ChildStdin/ChildStdout (acp.rs:139-176), with no Transport trait to slot a network implementation into.

So the answer is to run buzz-acp next 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-acp declares the buzz CLI 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 the kind:0 profile — 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 existing BackendKind::Provider protocol 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) verifies BUZZ_AUTH_TAG locally via verify_auth_tag — no network, no owner discovery.

Notable implementation details

Only 4 Rust guard sites changed. External is non-Local and every non-Local branch that matters already pattern-matches Provider, so ~24 existing guards were correct as-is. The four that weren't:

Site Problem
runtime.rs:149 backend_agent_id is always None, so the provider mapping pinned External to not_deployed forever. Extracted to a pure, testable remote_backend_status.
agents.rs:1125 External fell into StartTarget::Provider; build_deploy_payload succeeds (it never checks backend) and hits the keyring, so a start that was never valid failed with a misleading key error. Now an exhaustive match, so the next variant breaks the build here.
agents.rs:1002, :1030 != Local tests 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 calls resolve_effective_harness_descriptor, so it omits the harness-definition env floor and runtime-metadata vars, and its own doc comment admits agent_args is pinned at create time. New external_env.rs reuses 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 in descriptor.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.
  • Conversely runtime_metadata_env_vars and baked_build_env are already in descriptor.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 the BUZZ_MANAGED_AGENT ownership 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-39 already 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 postureSecretRevealDialog renders privateKeyNsec verbatim on every create, and commands::identity::get_nsec exports 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_refusal fails closed on a keyring outage; the response type deliberately has no Debug derive; and the frontend copies NsecRevealRow — plain useState, never React Query, so the secret never enters the query cache, cleared on collapse and unmount behind a late-resolve guard.

render_env_file emits values unquoted on purpose: --env-file does 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 asserts lines().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:

  1. A third ungated start control. canBuzzControlManagedAgent covered the profile panel and members sidebar, but the agents-view card renders its own start button via AgentRuntimeAvatarControl — external agents offered a Start that the backend refuses. It now takes canStart and hides the affordance (errors stay reachable).
  2. The env block first landed in dead code. ManagedAgentRow is imported only by AgentGroupRows, 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 ci steps 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-testchannel_detail_page_test.dart "keeps follow mode off while a tall newest message stays visible". This is why a full just ci exits non-zero.
  • 5 integration E2E specs, 4 of which need a live relay.

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) → status external with a green PresenceDot, no Start button, no log pane.

Housekeeping notes for maintainers

  • commands/agents_archive.rs and shared/api/tauriAgentBackends.ts are pure extractions to satisfy the file-size ratchet, following the existing agents_deploy.rs precedent. No behavior change.
  • Design spec committed at docs/superpowers/specs/2026-07-30-external-agent-backend-design.md.
  • Unrelated, noticed while working: cargo can't fetch the private mesh-llm-client dep with its bundled libgit2 (git ls-remote works fine); every Rust command needed CARGO_NET_GIT_FETCH_WITH_CLI=true. Adding [net] git-fetch-with-cli = true to .cargo/config.toml would fix it for everyone. Not done here — unrelated to this feature.

vcolombo added 4 commits July 30, 2026 00:42
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>
@vcolombo
vcolombo requested a review from a team as a code owner July 30, 2026 13:35
Copilot AI review requested due to automatic review settings July 30, 2026 13:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 reports external instead 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.

Comment on lines +1 to +5
/**
* 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.
*/

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +43 to +49
if (isOpen) {
// Cancel any in-flight fetch before clearing state.
fetchCancelledRef.current = true;
setEnvFile(null);
setIsOpen(false);
return;
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 52 to 54
export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) {
if (agent.backend.type === "provider") {
return isManagedAgentActive(agent) ? "Shutdown" : "Deploy";

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 externalnull 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>
@vcolombo

Copy link
Copy Markdown
Author

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:

  1. remote_backend_status — the existing summary logic maps any non-Local backend through backend_agent_id.is_some(), so any backend without a deploy step reads not_deployed permanently. feat(desktop): support authorized external agent identities #3746 will hit this too.
  2. StartTarget in commands/agents.rs was a non-exhaustive if/else, so a new BackendKind silently routed into the provider deploy path and failed with a misleading keyring error rather than a clear refusal. Making it an exhaustive match means the next variant breaks the build at the right place.

Branch is preserved at vcolombo:vcolombo/brainstorm-remote-hermes-agent if any of it is useful. Happy to reopen against whatever direction #3748 settles on, or to fold the two fixes above into a separate focused PR if that would help #3746.

@vcolombo vcolombo closed this Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants