feat: server-hosted agents via buzz-spawner, with identity relocation (1/4) - #3185
Open
siddhartpai wants to merge 14 commits into
Open
feat: server-hosted agents via buzz-spawner, with identity relocation (1/4)#3185siddhartpai wants to merge 14 commits into
siddhartpai wants to merge 14 commits into
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part 1 of a 4-PR stack splitting the server-agent work (supersedes the single mega-branch in #3113):
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.
crates/*— spawner daemon, SDK, relay, kindsdesktop/src— React UI and storesdesktop/src-tauri— Tauri bridgeThe 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.
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.mjsandtests/(+779), plusowner_sim.Skip — boilerplate:
Dockerfile.*,deploy/compose/*,Cargo.lock, new-crate scaffolding.Design notes a reviewer may want
ncaway.Review fixes already applied
A review pass on this branch found and fixed, in the commits after
bb1046a9:0600rather 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