Skip to content

feat: add relay-native execution nodes - #4597

Draft
Onnokh wants to merge 31 commits into
block:mainfrom
Onnokh:feature/buzz-node
Draft

feat: add relay-native execution nodes#4597
Onnokh wants to merge 31 commits into
block:mainfrom
Onnokh:feature/buzz-node

Conversation

@Onnokh

@Onnokh Onnokh commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

First, great work on the remote-agent direction so far, especially the discussion and specification in #3748.

Over the past couple of days, I’ve been working on a relay-native execution-node approach, and it happened to converge with many of the same questions and constraints that emerged in the discussion around this PR and the related work.

This is intentionally an early exploration rather than a finished or settled architecture. I’m sharing it to make the direction concrete and see how well it fits with the Buzz vision and the provider model.

Summary

This PR introduces buzz-node: a standalone, relay-native execution node for running Buzz agents away from the desktop.

The goal is to let the same Buzz agent continue existing on remote or persistent infrastructure while preserving the normal Buzz model:

  • the agent identity remains on the relay;
  • the agent uses the same relay, history, channels, and desktop UI;
  • the machine is only an execution body;
  • runtime-specific details stay behind a substrate boundary.

This is intended to address the hosted and persistent agent direction discussed in #4174.

Motivation

Remote agents currently lead toward several separate solutions: provider-specific launchers, host-specific supervisors, custom control APIs, and one-off integrations for Docker, SSH, Kubernetes, and local processes.

That risks creating a different product model for every execution environment.

I’m exploring a shared execution-node model instead. A node is a relay-connected Buzz principal that can be paired with an owner, receive authorized lifecycle commands, run workloads through a local substrate, and publish status and receipts back through the relay.

The agent itself remains the same agent. Only its execution body changes.

Architecture

The lifecycle is divided into a few clear responsibilities:

  1. Desktop creates or selects a managed-agent identity using the existing agent model.

  2. An execution node is paired with the owner through the relay and receives its own persistent Nostr identity.

  3. Desktop publishes encrypted workload commands to the node through the relay. Commands include deployment, start, stop, restart, removal, and provider-authentication operations.

  4. The node authenticates the command, verifies the owner/node/relay binding, applies idempotency and sequencing checks, and reconciles the requested workload state.

  5. A runtime-neutral substrate starts the agent body. The initial substrates are local processes and Docker containers, using the same ACP/runtime contract used by Desktop.

  6. The node publishes encrypted receipts and safe workload status through the relay. Desktop uses those results to present the remote agent alongside other agents.

Node availability is represented through normal Buzz presence. The node announcement contains slower-moving identity, capability, pairing, and workload information; presence represents whether the node is currently connected.

Security and lifecycle boundaries

The node is deliberately relay-first:

  • Desktop does not maintain a second management connection to the host.
  • Commands and receipts are encrypted for their intended participants.
  • Pairing binds the node to an owner and relay authority.
  • Raw provider credentials are not included in workload configuration.
  • Credential material remains node-local.
  • Workload commands are bounded, sequenced, and idempotent.
  • The node persists its identity, pairing state, workload ledger, and command journal.
  • Runtime details such as Docker, process supervision, images, and environment setup stay outside the shared relay protocol.

The relay is the coordination plane. The node is the execution participant. The substrate only runs the requested workload.

Relationship to the remote-agent specification

This builds on the direction established by #3748:

  • the relay remains the post-deploy coordination plane;
  • the agent identity is independent of the machine running it;
  • execution environments are replaceable;
  • presence is used for conversational availability;
  • runtime and substrate details are kept behind a narrow boundary.

The part this PR explores further is the Buzz-specific substrate model discussed in the PR conversation: making the execution environment itself a relay-registered principal with an identity, pairing relationship, lifecycle protocol, and relay-visible status.

This is intended to complement the generic provider model rather than create a second agent model.

Current scope

This PR currently includes:

  • standalone buzz-node service;
  • persistent node identity and state;
  • Desktop pairing;
  • relay-native encrypted commands and receipts;
  • owner and relay-bound authorization;
  • durable workload reconciliation and idempotency;
  • Desktop execution-node discovery;
  • remote agent deployment and lifecycle controls;
  • process and Docker substrates;
  • provider-authentication handoff;
  • node presence and readiness reporting.

PR size

I recognize that this is a substantial PR with a broad surface area and a lot of change to review at once. The main review areas are approximately:

  • buzz-node and the implemented process/Docker substrates: ~6.8k lines
  • Shared execution protocol: ~1.9k lines
  • Desktop integration: ~2k lines
  • Execution and end-to-end tests: ~1.7k lines

Open questions

I’m sharing this implementation to discuss whether this is the right Buzz-specific layer for hosted and persistent agents:

  • Should buzz-node become the common relay-native execution model across substrates?
  • How should it relate to the generic provider contract from #3748?
  • Which parts should be standardized as relay protocol versus substrate-specific behavior?
  • Are the current pairing, lifecycle, status, and credential boundaries aligned with the broader Buzz vision?

Onnokh added 28 commits August 3, 2026 20:36
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
- enforce #![deny(unsafe_code)] and anyhow-based binary errors in buzz-node
- run pairing over the shared buzz-ws-client (OK-checked publishes,
  bounded waits) instead of hand-rolled tungstenite
- hot-reload owners.json in the run loop and re-announce on pairing
- publish standard kind-20001 presence heartbeats (60s) so node liveness
  rides the same lease as members and agents
- box WorkloadSpec in ExecutionCommand::Deploy (clippy large_enum_variant)
- make removal tombstones sequence-aware via a supersedes_removal echo:
  deliberate redeploys succeed, stale replays still conflict; legacy
  tombstone state migrates on load
- reject ephemeral execution kinds on HTTP POST /events with a clean 400
  (WebSocket-only), matching the gift-wrap/presence transport gate
- ignore the .buzz-node data dir (node identity key and pairing state)

Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
End-to-end coverage for kinds 30630/24201/24202: announcement acceptance,
NIP-33 replacement, and global readability; p-gated command/receipt fanout
reaching only the addressee; restricted/403 on unscoped REQ, COUNT, and
HTTP query shapes; fanout-only (never stored) semantics; and the clean 400
on HTTP publish of the WebSocket-only kinds.

Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
- swap an agent's backend from the instance edit dialog: teardown-then-
  adopt with serialized transitions, receipt-confirmed workload removal,
  and redeploy through the authoritative deploy path
- fix the BackendKind::ExecutionNode wire shape to camelCase nodeId (with
  a node_id alias for persisted records) — creation and summary reads were
  broken in both directions
- derive node availability from presence instead of a 2-minute
  announcement-staleness window
- replace the Run on select with a card picker: liveness dot
  (green/yellow/dimmed gray), radio semantics, bottom-aligned titles;
  drop the legacy/compatibility framing for provider backends
  (flag renamed to VITE_ENABLE_BACKEND_PROVIDERS)
- show a Runs on row in the agent profile runtime tab
- route profile-panel Edit to the instance dialog when a live instance
  exists (it was unreachable for persona-linked agents)
- dedupe execution-node control actions and share the run-on prefix
  constant; restore the rodio/PTT comment dropped in an earlier commit

Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Split the node's execution handling into a durable WorkloadLedger
(reconciliation bookkeeping: admitted specs, lifecycles, sequenced
removal tombstones) and an async Substrate seam with three
implementations:

- InertSubstrate: no-op bookkeeping, the library/test default
- ProcessSubstrate: supervises the sprig ACP harness as a child
  process with the desktop launcher's env contract; identity keys
  live only in memory and start fails closed after a node restart
- DockerSubstrate: runs agent bodies in containers where the
  container definition is the key store; deploys converge via
  rm-before-run, lifecycle survives node restarts, and exit
  watchers re-arm from labeled containers on boot

Ledger state mutates only after the substrate accepts an operation,
substrate failures surface as failed receipts, and bodies that exit
on their own are recorded (clean stop vs failure) and never
resurrected. LLM credentials come from a documented node-operator
env allowlist, never the workload spec.

Dockerfile.agent builds the agent body image from sprig: a slim
default plus per-runtime variants (goose/claude/codex) layered with
the same official installers the desktop's auto-install uses,
resolved per workload as repository:runtime tags and failing closed
when a variant is not built.

Also enables the rustls ring feature buzz-test-client's mention bin
relies on (pre-existing standalone build failure).

Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Upstream block#4539 added a read-only saved-settings summary to the edit
dialog premised on backends being fixed at creation. This branch makes
backends swappable from that dialog, so the two features now compose:
the interactive Run on picker owns location and changes, and the
summary renders beneath it for provider agents only, showing the saved
config rows (namespace, image, limits) without the immutability copy.
summarizeRunOn learns the execution-node backend variant (no config
rows — node runtime details stay on the node), and the edit dialog
consumes upstream's applyProbeResult seam instead of the draft-keyed
probe effect it replaced.

Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
Align buzz-node with the remote-agents spec that landed upstream:

- Feed the harness inactivity reaper. New --inactivity-seconds /
  BUZZ_NODE_INACTIVITY_SECONDS (default 7200, mirroring the Kubernetes
  binding's schema default) sets BUZZ_ACP_EXIT_AFTER_INACTIVITY on the
  body. 0 is the blessed 'no bound' opt-in and omits the variable, since
  the harness default already means disabled. Remote bodies opt in, and
  a node is remote by definition.
- Default the Docker substrate to the digest-pinned published body image
  (ghcr.io/block/buzz-sprig), because the object holding an agent's key
  must not run a movable tag. A test asserts the pin still matches the
  Kubernetes backend's constant, so drift fails rather than ships.
- Resolve per-runtime images from a dedicated variant repository
  (--variant-image-repo, default buzz-agent) instead of retagging the
  base reference: goose/claude/codex images are local override images
  built by 'just agent-image <runtime>', never a fatter default. A
  missing image fails the deploy with the exact command to run, still
  checked before the previous container is removed.

Two conformance bugs found while auditing against the spec:

- A workload whose record carries no auth tag launched a body with no
  resolvable owner: unable to match an owner shutdown request and
  silently dropping every message under respond_to=owner-only. The node
  already holds a verified owner pubkey for every command, so it now
  supplies BUZZ_ACP_AGENT_OWNER when no attestation is present.
- Deploy reported success at launch rather than at start, so a body that
  died immediately (bad credential, missing runtime) still earned a
  succeeded receipt. Both substrates now confirm liveness within a
  bounded window before the terminal receipt, keeping the exit watcher
  as the after-the-fact correction. A failed confirmation leaves the
  container in place so its logs remain readable.

Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
@Onnokh

Onnokh commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Implementation walkthrough

I kept the PR description focused on the motivation and architectural direction. This comment captures the current implementation shape.

The Desktop integration is intended to preserve the existing agent model while making the execution body selectable. The agent configuration still shows the normal runtime and ACP command, while indicating that the agent runs on a connected Buzz execution node and remains owner-controlled.

Agent configuration

runtime-node

Selecting the execution body

The current Desktop flow allows the agent to run on this computer or on a connected execution node.

run-on

Relay protocol

The execution-node protocol currently uses:

  • kind 30630 for replaceable node announcements;
  • kind 20001 presence for live node availability;
  • encrypted kind 24201 commands addressed to a node;
  • encrypted kind 24202 receipts returned by the node.

The announcement contains the node identity, capabilities, pairing attestations, and safe workload status. Presence remains the availability signal rather than host telemetry.

Command lifecycle

Desktop sends encrypted commands through the relay. The node then:

  1. verifies the command envelope and expiry;
  2. verifies the owner, node, and relay binding;
  3. checks command idempotency and sequencing;
  4. reconciles the requested workload state;
  5. publishes a sequenced receipt.

The current command set covers deployment, start, stop, restart, removal, and provider authentication.

The workload projection intentionally contains only safe execution data. Runtime-specific infrastructure, container details, and raw credentials remain node-local.

Runtime boundary

buzz-node currently has two implemented substrates:

  • the process substrate launches and supervises buzz-acp bodies;
  • the Docker substrate launches agent bodies as containers and supervises them through the Docker runtime.

Both use the same relay, identity, runtime, and environment contract as Desktop. The node protocol does not need to know whether the body is a process or a container.

Identity and credentials

The agent identity remains the managed agent’s identity. The execution node has a separate persistent Nostr identity used for relay authentication, announcements, commands, and receipts.

Nodes are paired with owners through the relay. Provider credentials remain node-local and are referenced by workloads rather than transmitted as ordinary workload configuration.

The process substrate keeps the launch key in memory and therefore requires redeployment after a node restart. The Docker substrate uses the running container as the key store, allowing lifecycle operations to survive a node restart while still failing closed if the container is gone.

Desktop integration

Desktop discovers paired nodes through their announcements and derives their live availability from presence. A managed agent can then be assigned to an execution node while retaining the same identity, profile, channels, history, and agent-facing UI as a locally running agent.

The intention is that “remote” changes where the body runs, not what kind of agent it is.

I expect the protocol and substrate boundaries to evolve as this is reviewed. The main question I’m trying to answer with this implementation is whether this is the right relay-native execution layer to standardize across hosted and persistent substrates.

Onnokh added a commit to Onnokh/buzz that referenced this pull request Aug 3, 2026

Copy link
Copy Markdown

@Onnokh, I think the node is the better execution target. It keeps the agent identity stable and makes Run on a real product choice, so I want to adapt #4301 around it rather than build a competing remote path.

One boundary needs aligning before this grows. Merged #3748 now gives local and provider-backed agents one place to resolve the command, arguments, layered environment and launch policy. AgentWorkloadContext creates a second projection for nodes, so later Skills, MCP and workspace work would need to be wired twice and could drift.

Could WorkloadSpec carry that shared launch contract, plus a configuration version and node-local credential references? Then Run on changes where the agent runs, not which setup it gets. If that fits, I can adapt #4301 around it and help extract the shared type.

@Onnokh

Onnokh commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

@wolfyy970 That feels like the right next step to me. I agree that the node is the better execution target, and that “Run on” should change where the agent runs rather than which configuration it receives.

I think the convergence seam is the launch contract that Desktop already resolves today through EffectiveHarnessDescriptor and serializes into the provider launch block. The next step should be to promote that into a shared buzz-core type and carry it with the execution workload, together with a configuration version and node-local credential references.

I don’t want to add launch alongside another active configuration path. The goal should be for local spawn, provider deployment, and buzz-node to consume the same resolved command, arguments, environment, and launch policy. The process and Docker substrates would then only adapt that contract to their runtime environment instead of reconstructing it from runtime and AgentRuntimeSettings.

That should also let us remove the duplicate node-side runtime inference and the separate provider launch type, while keeping substrate-specific supervision and persistence where they belong.

I could draft the follow-up later today.

Promote the provider launch block into a shared buzz-core LaunchSpec
(versioned: command, args, mcp_command, layered env, policy_env, owner)
and make every execution path consume the same resolve:

- Desktop resolves the contract once (managed_agents::launch); the
  provider deploy block, execution-node deploys, and local spawn all
  consume it. The wire shape of the provider launch block is preserved;
  all additions (version, mcp_command, runtime metadata env, respond-to
  gate, steer/dedup) are additive.
- buzz-node substrates adapt the contract instead of reconstructing it
  from the runtime identifier: the node-side catalog shrinks to image
  variant + Claude CLI pointer, and the duplicate default_env/model_env/
  provider_env inference is deleted. Wire env is layered policy-under-
  user with a reserved-key strip for node-owned identity variables.
- AgentWorkloadContext slims to identity (key handoff, relay, auth tag,
  channel); AgentRuntimeSettings is gone — behavior travels in the
  contract. Provider credential names (shared PROVIDER_CREDENTIAL_ENV)
  are stripped at the node boundary and never persisted in the ledger.
- Local spawn keeps its inherited-environment semantics explicitly:
  ambient-wins for runtime default_env, clear-when-unset for
  policy-managed keys, and the caller-decided lazy-pool flag.
- spawn_config_hash digests the contract's merged effective view plus
  the few non-contract inputs, so the restart badge is literally 'the
  contract changed'. All 34 badge-behavior tests pass unmodified.

Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
@Onnokh

Onnokh commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

@wolfyy970

WorkloadSpec now carries a versioned LaunchSpec in buzz-core — the same block providers already consume, evolved additively (snake_case wire shape unchanged).

Desktop resolves it once; local spawn, provider deploy, and node deploy all consume the same contract. Run on changes where the agent runs, not which setup it gets.
Substrates only adapt (paths, images, supervision) — the node-side runtime inference and AgentRuntimeSettings are deleted.
Provider credentials are stripped at the node boundary (PROVIDER_CREDENTIAL_ENV) and stay node-local; credential_refs is the anchor for a secret-store follow-up.
The restart badge now hashes the contract itself.

For #4301, the seam is resolve_launch_spec → LaunchSpec. Happy to extract more into buzz-core if you need pieces it doesn't carry yet.

Copy link
Copy Markdown

Thanks. This is the shared execution boundary I was hoping for. I’ll rebase the Project-binding work onto LaunchSpec and flag any missing configuration fields as concrete follow-ups.

Copy link
Copy Markdown

@Onnokh, I compared the new contract with the Project-binding and portable-agent work. The remaining shared pieces I need are:

  • an immutable configuration revision or digest, separate from LaunchSpec.version, so updates and rollbacks identify the exact setup;
  • structured MCP server definitions, with credential references bound to the inputs they populate. The executing machine resolves those references locally, so raw credentials never travel;
  • Project and workspace context beyond the channel ID, without carrying machine-specific paths;
  • pinned Skill bundle references with a version or digest, materialized by the executing machine.

I want resolve_launch_spec to resolve the complete execution configuration once. Local spawn, providers and nodes should only supply paths, credentials and runtime packaging. An agent can then keep the same instructions, Skills and tools when Run on changes, while updates and rollbacks compare one exact configuration.

If you want to take these shared types in #4597 or a follow-up, I’ll build #4301 and #4588 against them. If some belong elsewhere, tell me where you think the boundary should sit and I’ll adapt.

Copy link
Copy Markdown

@tlongwell-block, I want to check this against the remote-agent direction that has already landed.

#3748 and #3924 make provider deployment the generic path and deliberately avoid a substrate management channel after deploy. #4597 adds a relay-addressable execution node with deploy, start, stop and restart commands. I can see that working as a Buzz-specific execution target, but it is a different management model.

I am building the portable configuration side in #4301: one resolved setup for instructions, Skills, Project-bound tools and updates, independent of where it runs. Should I treat LaunchSpec as the stable provider-neutral boundary and the node as another consumer if it lands, or is #4597 intended to evolve the provider model? That decision changes where I put the Project and rollout integration, so I would rather align it now.

Onnokh added 2 commits August 4, 2026 23:31
Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>

# Conflicts:
#	desktop/src-tauri/src/commands/agents_deploy.rs
#	desktop/src-tauri/src/managed_agents/runtime.rs
#	desktop/src-tauri/src/managed_agents/spawn_hash.rs
#	desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
SpawnConfigSnapshot::from_inputs re-derived mcp_command from the runtime
catalog — the last value the restart badge computed on its own instead of
taking from the resolved contract. It is now an input like every other
captured value: the spawn stamp passes launch.mcp_command (what actually
fed the body), and the prospective side uses the same shared derivation
(launch::effective_mcp_command) the contract resolver calls.

Signed-off-by: Onno Klein Hofmeijer <onnokh@hotmail.com>
@Onnokh
Onnokh force-pushed the feature/buzz-node branch from 49a0198 to 0660ab7 Compare August 4, 2026 21:32
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