Skip to content

feat: server-hosted agents via buzz-spawner, with identity relocation (1/4) - #3185

Open
siddhartpai wants to merge 14 commits into
block:mainfrom
siddhartpai:spawner-server-agents
Open

feat: server-hosted agents via buzz-spawner, with identity relocation (1/4)#3185
siddhartpai wants to merge 14 commits into
block:mainfrom
siddhartpai:spawner-server-agents

Conversation

@siddhartpai

@siddhartpai siddhartpai commented Jul 27, 2026

Copy link
Copy Markdown

Part 1 of a 4-PR stack splitting the server-agent work (supersedes the single mega-branch in #3113):

  1. this PR — server-hosted agents, end to end
  2. feat: first-party server-agent prompt/model editing (2/4) #3186 — first-party prompt/model editing
  3. feat: per-owner credentials for server agents (3/4) #3187 — per-owner Claude credentials
  4. docs: self-hosted backend guide and deploy notes (4/4) #3188 — self-hosted guide + deploy notes

Each later PR's diff currently includes its predecessors (fork branches can't be PR bases upstream); review this one first — the later drafts shrink to their own commits as predecessors merge.


This is a full vertical slice, not a backend-only PR

Worth setting expectations up front, because the title undersells it: roughly a third of this diff is React.

Area Files Lines
crates/* — spawner daemon, SDK, relay, kinds 20 +5,749
desktop/src — React UI and stores 42 +3,622
desktop/src-tauri — Tauri bridge 33
Docker / compose / lockfile 7 +404

The UI is not separable polish. The desktop app is the only thing that can sign a NIP-OA attestation, so without it the spawner mints an agent key and then waits forever for an approval nothing can give. The consent flow is what makes the daemon usable at all, and it is the most security-sensitive code here.

What it does

An owner publishes a kind:30178 spec naming a spawner. The spawner mints an agent keypair, asks the owner to attest it over an encrypted kind:24201 handshake, and on approval runs the agent in its own Docker container. Existing local agents can instead be relocated — the same identity moves to the server, keeping its channels, profile, and memory.

owner   ──kind:30178 spec───────────────────────────►  spawner
spawner ──kind:24201 request (agent pubkey + nonce)─►  owner
owner   ──kind:24201 response (NIP-OA auth tag)─────►  spawner
spawner ──kind:30179 status: running────────────────►  owner
                       └──► Docker: one buzz-acp container per agent

Review map

Read line-by-line — signing, key handling, and consent:

  • desktop/src/features/agents/trustedSpawners.ts (+115) — which spawners may skip the prompt. Signing admits an agent to the relay under the user's membership.
  • desktop/src/features/agents/spawnerAttestationStore.ts (+330) — auto-approval, dedup, and the relocation hand-off ordering.
  • desktop/src/features/agents/ui/SpawnerAttestationDialog.tsx (+217) — what the user actually sees before approving.
  • desktop/src-tauri/src/commands/spawner.rs (+418) — decrypts the frame, signs the tag, and releases a relocated agent's secret key.
  • crates/buzz-spawner/src/attestation.rs (+396) — the spawner-side verification. Pure function; every rejection path is tested.

Read carefully — correctness of the control loop:

  • crates/buzz-spawner/src/reconcile.rs (+727) — desired vs actual state. Pure function.
  • crates/buzz-spawner/src/env.rs (+376) — what reaches an agent container, and the reserved-key filter that stops operator config overriding an agent's identity.
  • crates/buzz-spawner/src/store.rs (+324) — on-disk agent secret keys.
  • crates/buzz-relay/* (+244) — NIP-OA delegated persona reads.

Skim — behaviour is covered by tests: everything in *.test.mjs and tests/ (+779), plus owner_sim.

Skip — boilerplate: Dockerfile.*, deploy/compose/*, Cargo.lock, new-crate scaffolding.

Design notes a reviewer may want

  • Agent keys are minted on the server and never transmitted. A NIP-OA tag is signed with the owner's key over the agent's pubkey, so the spawner cannot self-attest — hence the two-round handshake.
  • Agent containers are kept off the relay's internal network. An agent runs arbitrary shell tools; joining that network would put Postgres, Redis, and MinIO one nc away.
  • System prompts travel over the encrypted handshake, never in the world-readable spec.
  • Deletion is gated on subscription hydration. Before EOSE an absent spec means "unknown", not "deleted" — reading it as deleted would destroy every agent's container, volume, and key on each restart.

Review fixes already applied

A review pass on this branch found and fixed, in the commits after bb1046a9:

  • Trusted spawners were never cleared on community switch — a spawner approved under one identity auto-signed under the next.
  • Container and volume names were not spawner-scoped, so two spawners on one host could force-remove each other's live agents in a loop.
  • Ephemeral attestation frames arriving during a persona query were dropped, stalling the handshake until timeout.
  • The relocation hand-off is now atomic: the local agent is marked relocated and stopped before its key is released, rather than in follow-up IPC calls that could be interrupted.
  • Docs that described guarantees the code did not implement (a relay author-gate on kind:30179, NIP-09 teardown, a backend trust check) now describe what actually happens.
  • The state file holding agent secret keys is opened 0600 rather than chmod-ed after creation.

Known gap, closed by #3187: a spawner currently accepts specs from any community member, so an operator's host-global API key is reachable by anyone who can publish. Don't deploy this to a shared community until the credentials PR lands.

🤖 Generated with Claude Code

siddhartpai and others added 3 commits July 28, 2026 00:52
buzz-spawner daemon runs agents in containers, attested via the
NIP-AS kind:24201 encrypted handshake. Agents can relocate from the
desktop to a spawner keeping their pubkey, channels, profile, and
NIP-AE memory.

Relocation is persisted as state, not just performed as an action:
the hand-off marks relocated_to_spawner on the ManagedAgentRecord
(publish -> mark -> stop), and every local start path — manual start,
app-launch restore, runtime reconcile, lazy @mention wake, and the
auto-restart policy — refuses a relocated identity. Without this the
auto-restart loop resurrected the local copy after hand-off, running
one identity in two places (duplicate replies, double-billed turns).

Mentions of a relocated agent skip the local wake entirely; the
server copy sees them through the relay. The Agents screen shows a
server badge instead of Start for relocated agents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
…in fixtures)

Signed-off-by: sid <siddhartpai@gmail.com>
…wner identity

Container names are owner+slug scoped, but list() only sees containers
labelled with the current spawner pubkey. A container created by a
previous identity (wiped state dir, re-minted nsec) is therefore
invisible to reconcile while still holding the name, and every create
for that slug fails with Docker 409 forever.

On a 409, inspect the squatter: if it carries the com.buzz.agent label
it is spawner-managed (whichever identity created it) — remove it and
retry the create once. Unlabelled containers are refused, so an
operator's unrelated container of the same name is never touched. The
workspace volume is a named mount and survives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: sid <siddhartpai@gmail.com>
personaByName was keyed by displayName but looked up with agent.slug, so
the persona was only found when the display name happened to equal its
slug. A miss made setEnabled republish the spec with a placeholder prompt
instead of its personaId, silently detaching the agent from its persona.

Signed-off-by: sid <siddhartpai@gmail.com>
Two failures, both of which made this spec fail at head:

- The screenshot clip (y=1200) fell entirely outside the 720px viewport,
  which Playwright rejects outright rather than cropping. Derive the clip
  from the menu's own bounding box and clamp it to the viewport.
- The mock bridge seeds no personas, so libraryPersonas was empty and the
  Deploy action was correctly disabled. Seed an active Fizz persona, which
  is what the menu assertion already expected to find.

Signed-off-by: sid <siddhartpai@gmail.com>
resetTrustedSpawners() was defined and documented as "used when switching
identities" but had no call site and was missing from resetCommunityState.
A spawner approved under one identity was therefore auto-approved under the
next: its kind:24201 frame skipped the consent prompt and was signed with a
key the user never vouched for it with, admitting its agent to that
identity's relay membership.

Also records the four spawner singletons this feature added in the
community-switch contract in AGENTS.md, which had drifted.

Signed-off-by: sid <siddhartpai@gmail.com>
Labels are spawner-scoped but Docker names are global to the daemon, so
two spawners sharing a host collided on buzz-agent-<owner>-<slug> the
moment an owner moved an agent between them. The loser of the create race
called remove_name_squatter, which removed any agent-labelled container
regardless of which spawner owned it — force-removing a live agent to free
a name, whereupon the other spawner did the same on its next pass.

Names now carry the spawner prefix, so distinct identities cannot collide
at all, and reclaim additionally refuses any container whose com.buzz.spawner
label is not ours. What remains reclaimable is the case it was written for:
our own leftover from a crash between create and the store write.

Volume names get the same treatment — deleting an agent purges its volume,
and an unscoped name would wipe a co-tenant's running workspace.

Signed-off-by: sid <siddhartpai@gmail.com>
…ead of dropping them

fetch_personas holds the socket while it waits for EOSE, and discarded
anything that was not part of its own subscription. The comment justified
that with specs being replaceable and re-delivered on reconnect — true for
specs, but kind:24201 is ephemeral. An owner's attestation response that
arrived during a persona query was gone for good, leaving the agent in
pending_attestation until the handshake timed out and the owner was asked
to approve all over again.

Interleaved frames now go on a bounded queue that next() drains first.
The bound matters because anyone on the relay can address frames here;
eviction takes the oldest so a flood cannot displace a fresh frame, and a
reconnect clears the queue since those frames belong to the dead socket.

Also corrects the fetch_personas doc, which claimed the spawner reads
unshared personas through NIP-OA delegation. It does not: it authenticates
with no auth tag, so the delegation covers the agents it runs, never
itself. The daemon's own error message already said so.

Signed-off-by: sid <siddhartpai@gmail.com>
The response to a relocation attestation contains the agent's secret key.
Marking the local copy relocated and stopping it was left to two follow-up
IPC calls from the renderer, after it had already published that response —
so any interruption in between (a crash, a navigation, a rejected call)
left the spawner running the identity while this device still considered
the agent local and startable. Both would then answer every mention, and
the owner would be billed twice per turn, silently.

Rust now does it atomically under the store lock before the signed event is
returned, and set_managed_agent_relocated stops any running process in the
same locked section rather than only blocking future starts. A failure
there aborts the hand-off, so the key never leaves.

That inverts the residual risk: a publish that fails now leaves the agent
stopped everywhere rather than running twice. The renderer rolls the flag
back when that happens, and says so loudly if even that fails — offline and
visible beats duplicated and silent.

Also corrects two docs: the module claimed signing 'fails closed' without a
recorded trust decision, which the backend never checked (the renderer owns
that boundary — now stated plainly), and the 'why this returns the event'
block had drifted onto the response struct, leaving the command undocumented.
Adds tests for the two branches that had none: that a rejection carries
neither key nor tag, and that a relocation delivers exactly the attested
identity's key.

Signed-off-by: sid <siddhartpai@gmail.com>
…ate-file mode

Three small corrections found in review:

- kind:30179 claimed the relay accepts status only from the spec's
  designated spawner. There is no such gate — say so, and say why it is
  survivable (a status grants nothing; access comes from the owner's NIP-OA
  signature) so a client does not trust the kind alone.
- kind:30178 named NIP-09 deletion as the teardown signal, but a deletion
  leaves nothing to fan out and a connected spawner never learns of it. The
  implemented signal is an empty-content replacement.
- The announcement counted stopped containers as running, advertising a host
  as full when the agents filling it were disabled or crashed.
- The state file holding every agent's secret key was created under the
  process umask and chmod-ed afterwards. Open it 0600 instead, closing the
  window where it was briefly world-readable.

Signed-off-by: sid <siddhartpai@gmail.com>
ISSUE-buzz-spawner.md was the issue draft this work started from, and
run-dev.command is a personal launcher for running the GUI app from an
Aqua session. Neither is part of the feature. The launcher is gitignored
rather than deleted so an existing local copy keeps working.

Signed-off-by: sid <siddhartpai@gmail.com>
…onour deploy failures

Three lower-severity findings from review:

- Both spawner subscription stores kept their in-flight promise when the
  attempt opened no subscription — no identity yet during onboarding, or a
  throw from getIdentity. Every later ensure...() then returned that settled
  promise and reported success, so attestation prompts and status updates
  never arrived again until the app restarted. Clear it unconditionally; the
  unsubscribeRelay check is what keeps the call idempotent.

- create() and relocate() published a kind:30178 spec without checking
  whether the slug was already in use on that spawner. The kind is addressed
  by (author, kind, d_tag), so a second publish replaces the first rather
  than failing: relocating a local "Fizz" silently reconfigured a running
  server agent of the same name, and tore it down when the identities
  differed. Both now refuse up front. DeployMenu already filtered this case,
  so its behaviour is unchanged.

- Both callers of deployToSpawner ignored its boolean result and reported
  success regardless, closing the dialog on a failed publish and leaving a
  transient toast as the only evidence.

Signed-off-by: sid <siddhartpai@gmail.com>
@siddhartpai siddhartpai changed the title feat(spawner): server-hosted agents with identity relocation (1/4) feat: server-hosted agents via buzz-spawner, with identity relocation (1/4) Jul 28, 2026
siddhartpai added a commit to siddhartpai/buzz that referenced this pull request Jul 28, 2026
Brings in the full 4-PR stack still under review upstream as block#3185-block#3188:

- buzz-spawner daemon: reconciles owner-published kind:30178 specs into
  per-agent Docker containers, with a two-round NIP-OA attestation handshake
- desktop: server-agent UI, attestation consent flow, identity relocation
- first-party prompt/model editing for server-hosted agents
- per-owner Claude credentials, replacing host-global API key passthrough
- self-hosted backend guide and deploy notes

Merged on the fork for building and testing; the upstream PRs remain open.
Includes 18 upstream commits that landed after the stack was branched — all
13 overlapping files auto-merged cleanly.

Signed-off-by: sid <siddhartpai@gmail.com>
The spawner panicked inside rustls on its first wss:// connection and
crash-looped at startup, never reaching its own error handling. The
workspace pulls in both ring and aws-lc-rs through unified features, so
rustls refuses to guess which provider to use and the process must install
one explicitly — as buzz-cli, buzz-acp, buzz-admin, and buzz-dev-mcp all
already do. The spawner was the only wss:// client that did not.

Found by deploying against a TLS relay. A ws:// relay is unaffected, which
is why local development and the test suite never hit it.

Signed-off-by: sid <siddhartpai@gmail.com>
compose.spawner.yml never forwarded BUZZ_SPAWNER_NAME, so Config::default_name
fell back to $HOSTNAME — which inside a container is its short ID. Every
containerised spawner therefore advertised something like "8c6b9debbaf9" in
the desktop's picker, and setting the documented variable had no effect at
all. BUZZ_SPAWNER_DESCRIPTION and the DEFAULT_PROVIDER/DEFAULT_MODEL host
fallbacks were missing for the same reason.

All four default to empty, and the config layer treats empty as absent, so
this only ever improves on the previous behaviour.

Signed-off-by: sid <siddhartpai@gmail.com>
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.

1 participant