From bb1046a96237c509a7367e860f9e5b73c56e95a5 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 00:52:01 +0530 Subject: [PATCH 01/50] feat(spawner): server-hosted agents with identity relocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Signed-off-by: sid --- Cargo.lock | 98 +- Cargo.toml | 1 + Dockerfile.acp | 106 ++ Dockerfile.spawner | 43 + ISSUE-buzz-spawner.md | 107 ++ crates/buzz-core/src/kind.rs | 142 ++- crates/buzz-relay/src/handlers/auth.rs | 11 + crates/buzz-relay/src/handlers/event.rs | 163 ++- crates/buzz-relay/src/handlers/ingest.rs | 8 + crates/buzz-relay/src/handlers/req.rs | 46 +- crates/buzz-relay/src/state.rs | 26 + crates/buzz-sdk/src/lib.rs | 1 + crates/buzz-sdk/src/spawner.rs | 1018 +++++++++++++++++ crates/buzz-spawner/Cargo.toml | 36 + crates/buzz-spawner/examples/owner_sim.rs | 322 ++++++ crates/buzz-spawner/src/attestation.rs | 396 +++++++ crates/buzz-spawner/src/config.rs | 230 ++++ crates/buzz-spawner/src/container.rs | 398 +++++++ crates/buzz-spawner/src/daemon.rs | 851 ++++++++++++++ crates/buzz-spawner/src/env.rs | 376 ++++++ crates/buzz-spawner/src/lib.rs | 50 + crates/buzz-spawner/src/main.rs | 38 + crates/buzz-spawner/src/reconcile.rs | 727 ++++++++++++ crates/buzz-spawner/src/relay.rs | 360 ++++++ crates/buzz-spawner/src/store.rs | 282 +++++ deploy/compose/.env.example | 17 + deploy/compose/README.md | 62 + deploy/compose/compose.spawner.yml | 72 ++ deploy/compose/run.sh | 7 + desktop/playwright.config.ts | 1 + .../src-tauri/src/commands/agent_config.rs | 1 + .../src-tauri/src/commands/agent_settings.rs | 52 + desktop/src-tauri/src/commands/agents.rs | 1 + .../src-tauri/src/commands/agents_tests.rs | 1 + desktop/src-tauri/src/commands/mod.rs | 2 + .../commands/personas/delete_cascade_tests.rs | 1 + .../src/commands/personas/inbound_tests.rs | 1 + .../personas/name_propagation_tests.rs | 1 + .../src/commands/personas/snapshot/import.rs | 1 + .../src/commands/personas/snapshot/tests.rs | 1 + desktop/src-tauri/src/commands/spawner.rs | 324 ++++++ .../src-tauri/src/commands/team_snapshot.rs | 1 + .../src/commands/team_snapshot/tests.rs | 1 + desktop/src-tauri/src/lib.rs | 3 + .../src/managed_agents/agent_events.rs | 1 + .../src/managed_agents/agent_snapshot.rs | 1 + .../config_bridge/reader_tests.rs | 1 + .../src/managed_agents/discovery/tests.rs | 1 + .../src/managed_agents/global_config/tests.rs | 1 + .../src/managed_agents/nest/tests.rs | 1 + .../src-tauri/src/managed_agents/readiness.rs | 1 + .../src-tauri/src/managed_agents/restore.rs | 8 +- .../src-tauri/src/managed_agents/runtime.rs | 7 + .../src/managed_agents/runtime/tests.rs | 1 + .../src/managed_agents/runtime_commands.rs | 10 +- .../src/managed_agents/spawn_hash/tests.rs | 1 + .../src-tauri/src/managed_agents/storage.rs | 15 + .../src/managed_agents/storage_tests.rs | 35 + .../src/managed_agents/team_snapshot.rs | 1 + .../src/managed_agents/teams_tests.rs | 1 + desktop/src-tauri/src/managed_agents/types.rs | 12 + desktop/src/app/AppShell.tsx | 6 + .../features/agents/agentLocation.test.mjs | 82 ++ desktop/src/features/agents/agentLocation.ts | 157 +++ .../features/agents/agentRelocation.test.mjs | 80 ++ .../src/features/agents/agentRelocation.ts | 79 ++ .../agents/lib/autoRestartPolicy.test.mjs | 5 + .../features/agents/lib/autoRestartPolicy.ts | 5 + .../agents/lib/useAutoRestartPolicy.ts | 2 + .../agents/spawnerAttestationStore.ts | 325 ++++++ .../features/agents/spawnerDirectoryStore.ts | 115 ++ .../agents/spawnerPreference.test.mjs | 71 ++ .../src/features/agents/spawnerPreference.ts | 171 +++ .../src/features/agents/spawnerStatusStore.ts | 152 +++ .../src/features/agents/trustedSpawners.ts | 115 ++ .../src/features/agents/ui/AgentDialog.tsx | 41 +- .../features/agents/ui/AgentRunsOnSection.tsx | 71 ++ .../agents/ui/AgentRuntimeAvatarControl.tsx | 18 +- desktop/src/features/agents/ui/AgentsView.tsx | 3 + .../agents/ui/MoveAgentToServerMenu.tsx | 73 ++ .../features/agents/ui/PersonaActionsMenu.tsx | 2 + .../agents/ui/RequestedAgentCreateDialogs.tsx | 10 +- .../agents/ui/ServerAgentsSection.tsx | 493 ++++++++ .../agents/ui/SpawnerAttestationDialog.tsx | 217 ++++ .../agents/ui/UnifiedAgentsSection.tsx | 2 + .../agents/ui/agentLocationOptions.test.mjs | 60 + .../agents/ui/agentLocationOptions.ts | 71 ++ .../agents/ui/spawnerAttestationCopy.test.mjs | 92 ++ .../agents/ui/useDeployPersonaToSpawner.ts | 39 + .../features/agents/ui/usePersonaActions.ts | 15 + .../src/features/agents/useAgentManagement.ts | 11 +- .../src/features/agents/useServerAgents.ts | 216 ++++ .../features/agents/useSpawnerIngestion.ts | 58 + .../features/communities/useCommunityInit.ts | 10 + .../messages/ui/useMentionSendFlow.ts | 8 + desktop/src/shared/api/spawnerRelay.test.mjs | 167 +++ desktop/src/shared/api/spawnerRelay.ts | 332 ++++++ desktop/src/shared/api/tauri.ts | 2 + desktop/src/shared/api/tauriManagedAgents.ts | 21 + desktop/src/shared/api/tauriSpawner.ts | 101 ++ desktop/src/shared/api/types.ts | 3 + desktop/src/shared/constants/kinds.ts | 14 + .../e2e/server-agents-screenshots.spec.ts | 138 +++ run-dev.command | 8 + 104 files changed, 10117 insertions(+), 28 deletions(-) create mode 100644 Dockerfile.acp create mode 100644 Dockerfile.spawner create mode 100644 ISSUE-buzz-spawner.md create mode 100644 crates/buzz-sdk/src/spawner.rs create mode 100644 crates/buzz-spawner/Cargo.toml create mode 100644 crates/buzz-spawner/examples/owner_sim.rs create mode 100644 crates/buzz-spawner/src/attestation.rs create mode 100644 crates/buzz-spawner/src/config.rs create mode 100644 crates/buzz-spawner/src/container.rs create mode 100644 crates/buzz-spawner/src/daemon.rs create mode 100644 crates/buzz-spawner/src/env.rs create mode 100644 crates/buzz-spawner/src/lib.rs create mode 100644 crates/buzz-spawner/src/main.rs create mode 100644 crates/buzz-spawner/src/reconcile.rs create mode 100644 crates/buzz-spawner/src/relay.rs create mode 100644 crates/buzz-spawner/src/store.rs create mode 100644 deploy/compose/compose.spawner.yml create mode 100644 desktop/src-tauri/src/commands/spawner.rs create mode 100644 desktop/src/features/agents/agentLocation.test.mjs create mode 100644 desktop/src/features/agents/agentLocation.ts create mode 100644 desktop/src/features/agents/agentRelocation.test.mjs create mode 100644 desktop/src/features/agents/agentRelocation.ts create mode 100644 desktop/src/features/agents/spawnerAttestationStore.ts create mode 100644 desktop/src/features/agents/spawnerDirectoryStore.ts create mode 100644 desktop/src/features/agents/spawnerPreference.test.mjs create mode 100644 desktop/src/features/agents/spawnerPreference.ts create mode 100644 desktop/src/features/agents/spawnerStatusStore.ts create mode 100644 desktop/src/features/agents/trustedSpawners.ts create mode 100644 desktop/src/features/agents/ui/AgentRunsOnSection.tsx create mode 100644 desktop/src/features/agents/ui/MoveAgentToServerMenu.tsx create mode 100644 desktop/src/features/agents/ui/ServerAgentsSection.tsx create mode 100644 desktop/src/features/agents/ui/SpawnerAttestationDialog.tsx create mode 100644 desktop/src/features/agents/ui/agentLocationOptions.test.mjs create mode 100644 desktop/src/features/agents/ui/agentLocationOptions.ts create mode 100644 desktop/src/features/agents/ui/spawnerAttestationCopy.test.mjs create mode 100644 desktop/src/features/agents/ui/useDeployPersonaToSpawner.ts create mode 100644 desktop/src/features/agents/useServerAgents.ts create mode 100644 desktop/src/features/agents/useSpawnerIngestion.ts create mode 100644 desktop/src/shared/api/spawnerRelay.test.mjs create mode 100644 desktop/src/shared/api/spawnerRelay.ts create mode 100644 desktop/src/shared/api/tauriSpawner.ts create mode 100644 desktop/tests/e2e/server-agents-screenshots.spec.ts create mode 100755 run-dev.command diff --git a/Cargo.lock b/Cargo.lock index 1a63b4f425..eff6fd9a55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -718,6 +718,49 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e79769241dcd44edf79a732545e8b5cec84c247ac060f5252cd51885d093a8fc" +[[package]] +name = "bollard" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d0a013e3d3ee4edd61e779adf117944c08902d375f18630a0c5b8f95659734" +dependencies = [ + "base64", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-util", + "hyperlocal", + "log", + "pin-project-lite", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.53.1-rc.29.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce412eb6f7096743011dc3cb5c674caeb24ced61d8c498fe07cf7998a4fea889" +dependencies = [ + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "bon" version = "3.9.1" @@ -1230,6 +1273,30 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-spawner" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "bollard", + "buzz-core", + "buzz-sdk", + "buzz-ws-client", + "chrono", + "futures-util", + "hex", + "nostr", + "rand 0.10.1", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "buzz-test-client" version = "0.1.0" @@ -2172,7 +2239,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -3521,6 +3588,20 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-named-pipe" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-rustls" version = "0.27.9" @@ -3592,6 +3673,21 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.65" diff --git a/Cargo.toml b/Cargo.toml index 3499285f91..3aad5e939b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/buzz-relay", + "crates/buzz-spawner", "crates/buzz-core", "crates/buzz-conformance", "crates/buzz-push-gateway", diff --git a/Dockerfile.acp b/Dockerfile.acp new file mode 100644 index 0000000000..91d7acdfcd --- /dev/null +++ b/Dockerfile.acp @@ -0,0 +1,106 @@ +# syntax=docker/dockerfile:1.7 +# +# The agent runtime image: one `buzz-acp` harness per container, spawned by +# `buzz-spawner`. This is the server-side equivalent of what the desktop app +# launches locally via `managed_agents/runtime.rs::spawn_agent_child`. +# +# `sprig` is a multicall binary (crates/sprig/src/main.rs) that dispatches on +# argv[0], so one build produces the harness, the built-in agent, and the dev +# MCP tools. The symlinks below are how the harness finds them — it spawns its +# ACP agent by name over stdio. +ARG RUST_VERSION=1.95 +ARG DEBIAN_VERSION=bookworm + +FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS chef +RUN cargo install cargo-chef --locked --version 0.1.71 +WORKDIR /build + +FROM chef AS planner +COPY . . +RUN cargo chef prepare --recipe-path recipe.json + +FROM chef AS builder +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential pkg-config libssl-dev ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=planner /build/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json +COPY . . +RUN cargo build --release --locked -p sprig --bin sprig \ + && cargo build --release --locked -p buzz-cli --bin buzz \ + && strip target/release/sprig target/release/buzz + +FROM debian:${DEBIAN_VERSION}-slim AS runtime +LABEL org.opencontainers.image.title="Buzz ACP Agent" \ + org.opencontainers.image.description="buzz-acp harness runtime for server-hosted Buzz agents" \ + org.opencontainers.image.source="https://github.com/block/buzz" \ + org.opencontainers.image.licenses="Apache-2.0" + +# git and ca-certificates are not optional: agents clone and push repositories +# through the relay's smart-HTTP transport, authenticated by git-credential-nostr. +# +# node and curl exist for the optional Claude Code runtime below: the ACP adapter +# ships as an npm package and the CLI installs via a shell script. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl git \ + && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 1000 agent \ + && useradd --system --uid 1000 --gid 1000 --home-dir /home/agent --create-home --shell /bin/bash agent + +COPY --from=builder /build/target/release/sprig /usr/local/bin/sprig +COPY --from=builder /build/target/release/buzz /usr/local/bin/buzz + +# Multicall dispatch: sprig routes on argv[0]. +RUN for name in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr git-sign-nostr; do \ + ln -s /usr/local/bin/sprig "/usr/local/bin/$name"; \ + done + +# Claude Code and its ACP adapter, for running agents on a Claude subscription +# instead of a metered API key. +# +# # Authentication in a container +# +# The subscription flow is browser-based and interactive, which a container +# cannot do. The non-interactive equivalent is a long-lived token from +# `claude setup-token` (run once on a machine with a browser), supplied as +# `CLAUDE_CODE_OAUTH_TOKEN`. `buzz-acp` does not clear the environment when it +# spawns the adapter, so the token reaches the CLI unchanged. +# +# Forward it with the spawner's own passthrough, which takes variable *names*: +# +# BUZZ_SPAWNER_AGENT_ENV=CLAUDE_CODE_OAUTH_TOKEN +# BUZZ_SPAWNER_AGENT_COMMAND=claude-agent-acp +# +# The alternative — bind-mounting a pre-authenticated `~/.claude` — is not +# supported by the spawner and would share one credential store across every +# agent on the host. +ARG CLAUDE_ACP_VERSION=0.62.0 +# The installer takes only [stable|latest|VERSION] and always installs into +# $HOME/.local/bin, so it runs with HOME pointed at the agent's home (this RUN +# is root, whose $HOME the agent user cannot read) and the result is symlinked +# onto PATH. `HOME=... bash` rather than `HOME=... curl`: the variable has to +# reach the shell that does the installing, not the download. +RUN npm install -g "@agentclientprotocol/claude-agent-acp@${CLAUDE_ACP_VERSION}" \ + && npm cache clean --force \ + && curl -fsSL https://claude.ai/install.sh | HOME=/home/agent bash -s -- stable \ + && ln -sf /home/agent/.local/bin/claude /usr/local/bin/claude \ + && chown -R agent:agent /home/agent \ + && /usr/local/bin/claude --version + +# The nest. buzz-spawner mounts a per-agent named volume here, so an agent's +# workspace survives restarts and config changes but is invisible to every +# other agent on the host. +# +# `.claude` is created up front and owned by the agent: the CLI writes state +# there, and it must not fall back to a root-owned path or a read-only home. +RUN mkdir -p /home/agent/.buzz /home/agent/.claude && chown -R agent:agent /home/agent + +USER agent:agent +WORKDIR /home/agent/.buzz +ENV HOME=/home/agent +# Default to the in-house agent, which needs only an API key. Override with +# BUZZ_SPAWNER_AGENT_COMMAND=claude-agent-acp to use a Claude subscription. +ENV BUZZ_ACP_AGENT_COMMAND=buzz-agent +ENTRYPOINT ["/usr/local/bin/buzz-acp"] diff --git a/Dockerfile.spawner b/Dockerfile.spawner new file mode 100644 index 0000000000..3761d993fc --- /dev/null +++ b/Dockerfile.spawner @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1.7 +ARG RUST_VERSION=1.95 +ARG DEBIAN_VERSION=bookworm + +FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS chef +RUN cargo install cargo-chef --locked --version 0.1.71 +WORKDIR /build + +FROM chef AS planner +COPY . . +RUN cargo chef prepare --recipe-path recipe.json + +FROM chef AS builder +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential pkg-config libssl-dev ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=planner /build/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json +COPY . . +RUN cargo build --release --locked -p buzz-spawner --bin buzz-spawner \ + && strip target/release/buzz-spawner + +FROM debian:${DEBIAN_VERSION}-slim AS runtime +LABEL org.opencontainers.image.title="Buzz Spawner" \ + org.opencontainers.image.description="Reconciles Nostr agent specs into Docker-isolated buzz-acp containers" \ + org.opencontainers.image.source="https://github.com/block/buzz" \ + org.opencontainers.image.licenses="Apache-2.0" +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 1000 buzz \ + && useradd --system --uid 1000 --gid 1000 --home-dir /var/lib/buzz-spawner --create-home --shell /usr/sbin/nologin buzz +COPY --from=builder /build/target/release/buzz-spawner /usr/local/bin/buzz-spawner + +# Runs as root, unlike the relay and push gateway. Talking to the Docker socket +# requires membership in the host's docker group, whose GID varies per host, so +# baking a fixed GID here would break on most machines. The socket mount is +# already root-equivalent on the host (see deploy/compose/README.md) — dropping +# to a non-root user inside the container would not change that, only obscure +# it. An operator who wants real privilege reduction should point the spawner at +# a rootless Docker or Podman socket via DOCKER_HOST. +WORKDIR /var/lib/buzz-spawner +ENTRYPOINT ["/usr/local/bin/buzz-spawner"] diff --git a/ISSUE-buzz-spawner.md b/ISSUE-buzz-spawner.md new file mode 100644 index 0000000000..056c36e5ba --- /dev/null +++ b/ISSUE-buzz-spawner.md @@ -0,0 +1,107 @@ +# feat(spawner): first-party server-hosted agents via a buzz-spawner daemon + +## Problem + +Buzz agents can only be spawned by the desktop app. +`desktop/src-tauri/src/managed_agents/runtime.rs::spawn_agent_child` generates +the keypair, computes the NIP-OA owner attestation, and launches the `buzz-acp` +harness locally. Fizz/Honey/Bumble aren't special processes — they're built-in +personas (`managed_agents/personas.rs`) minted through that same local path. + +This means: + +- **Agents die with the laptop.** There is no always-on agent. +- **Agent creation requires the desktop app.** Mobile and web can't create one. +- **Self-hosters have no server-side option.** You can host a relay, but not an + agent. The only server-side path is `BackendKind::Provider` → an external + `buzz-backend-` binary (`managed_agents/backend.rs:20-120`), which has no + OSS implementation and still puts the desktop in the control loop. +- **The relay is entirely agent-agnostic** — no spawner, no agent registry, no + lifecycle hooks. Agents are just pubkeys admitted via NIP-OA owner attestation + (`crates/buzz-relay/src/api/mod.rs:40-200`). + +## Proposal + +Add `crates/buzz-spawner` — a standalone daemon that watches the relay for +agent-definition events and reconciles them into Docker-isolated `buzz-acp` +containers. It ships as a second service in `deploy/compose`. + +It is deliberately **not** a relay module: the relay's only subprocess today is +`git`, and it goes out of its way to disable repo hooks +(`api/git/transport.rs:930-935`). Adding container execution to the public +WebSocket server isn't acceptable, and isn't necessary. `buzz-pair-relay` and +`buzz-push-gateway` are existing precedent for sidecar service crates. + +The daemon is a plain relay client with its own Nostr identity. It never touches +Postgres. + +``` +owner client ──kind:30178 (spec)──► relay ──sub──► buzz-spawner + ▲ │ + └──kind:24201 attestation handshake (NIP-44)──────────┤ + ▼ + Docker: buzz-acp container + │ + (agent's own key) + ▼ + relay +``` + +## Design points + +**Three new event kinds** (`buzz-core/src/kind.rs`), following the existing +30175/30176/30177 agent block: + +| Kind | Purpose | +|---|---| +| `30178` `SPAWNER_AGENT_SPEC` | Desired state, owner-authored. `d` = spec slug | +| `30179` `SPAWNER_AGENT_STATUS` | Actual state, spawner-authored. Phase, agent pubkey, last error | +| `24201` `SPAWNER_ATTESTATION` | Ephemeral, NIP-44, `#p`-gated — the handshake | + +Specs follow the strict opt-IN projection discipline documented in +`managed_agents/agent_events.rs` — the type must be physically incapable of +carrying an nsec, auth tag, or env blob. + +**Attestation is a two-round handshake.** The nsec is minted on the VPS and never +leaves it. But NIP-OA's tag is +`Schnorr(SHA256("nostr:agent-auth:" || agent_pubkey || ":" || conditions), owner_secret)` +(`buzz-sdk/src/nip_oa.rs`) — it binds a specific agent pubkey and needs the +*owner's* secret key. So: owner publishes a spec → spawner mints keys and sends +the new pubkey + nonce → owner's client signs via the existing `compute_auth_tag` +and replies → spawner boots the container. Clients prompt on first handshake with +a given spawner pubkey and auto-sign thereafter; never auto-sign for an unknown +spawner. + +**One Docker container per agent.** Agents run shell and file-edit tools through +`buzz-dev-mcp` — that's arbitrary code execution, so shared-host subprocesses +aren't acceptable. Env is assembled exactly as `spawn_agent_child` does today, +honoring the reserved-key strip list in `managed_agents/env_vars.rs:60`. LLM +credentials come from spawner-level env and never appear in any event. + +**Personas resolve from `kind:30175`,** so a VPS Fizz is byte-identical to a local +one. One access gap to close: `30175` is author-only unless `["shared","true"]`, +and the spawner isn't the author — extend the read-gate using the same +owner-delegation shape the relay already uses for `24200` observer frames +(`ingest.rs:2029`), rather than inventing a second auth concept. + +**Reconciliation** mirrors the desktop's `reconcile.rs`/`runtime/sweep.rs`: diff +specs against containers labelled `com.buzz.agent`, recreate on spec-hash drift +(reusing the `spawn_hash` idea), exponential backoff on crash loops with a cap, +and report failures via `30179` instead of retrying silently. + +## Scope + +- New: `crates/buzz-spawner/`, `docker/buzz-acp.Dockerfile` +- Modified: `buzz-core/src/kind.rs`, `buzz-relay` ingest gates + persona + read-gate, `buzz-sdk/src/builders.rs`, `buzz-cli` + (`spawner list|logs|restart`), desktop attestation responder, + `deploy/compose/*` +- Requires `BUZZ_ALLOW_NIP_OA_AUTH=true` (currently defaults false, + `buzz-relay/src/config.rs:172-184`) + +## Open item for reviewers + +Mounting `/var/run/docker.sock` into the spawner is root-equivalent on the host. +This needs to be documented loudly in `deploy/compose/README.md`, and it's worth +discussing whether a rootless-Docker or Podman-socket variant should be the +default recommendation. diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index afec52305a..47e5295523 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -203,12 +203,40 @@ pub fn is_persona_shared_kind(kind: u32) -> bool { /// reach foreign readers; stripping them at the author-only layer would break /// the catalog query. pub fn is_unshared_persona_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool { + is_unshared_persona_event_for(event, requester_pubkey_bytes, None) +} + +/// Owner-delegated variant of [`is_unshared_persona_event`]. +/// +/// `requester_owner_pubkey_bytes` is the requester's NIP-OA attesting owner, as +/// recorded in `users.agent_owner_pubkey` when the requester authenticated with +/// an `auth` tag. An attested reader may read its own owner's unshared personas. +/// +/// This is what lets a `buzz-spawner` daemon (or any owner-attested agent) +/// resolve the system prompt for an agent it runs on the owner's behalf, without +/// forcing the owner to mark every persona `["shared","true"]` — which would +/// expose those prompts to the whole community. It reuses the same delegation +/// the relay already applies to kind:24200 observer frames rather than +/// introducing a second authorization concept. +/// +/// Delegation is one hop and one direction: an owner does NOT gain read access +/// to personas authored by agents they own. +pub fn is_unshared_persona_event_for( + event: &nostr::Event, + requester_pubkey_bytes: &[u8], + requester_owner_pubkey_bytes: Option<&[u8]>, +) -> bool { let kind = event.kind.as_u16() as u32; if !is_persona_shared_kind(kind) { return false; } + let author_bytes = event.pubkey.to_bytes(); // Author reads are always allowed. - if event.pubkey.to_bytes() == requester_pubkey_bytes { + if author_bytes == requester_pubkey_bytes { + return false; + } + // An owner-attested requester may read its own owner's personas. + if requester_owner_pubkey_bytes.is_some_and(|owner| author_bytes == owner) { return false; } // Foreign reader: allowed only if the event is explicitly shared. @@ -258,6 +286,50 @@ pub const KIND_TEAM: u32 = 30176; /// since these events are world-readable on the relay. pub const KIND_MANAGED_AGENT: u32 = 30177; +/// NIP-AS: Spawner Announcement (replaceable, spawner-authored). +/// +/// How an owner finds a spawner without reading a server log. A `buzz-spawner` +/// publishes one of these — keyed by `(pubkey, kind)`, so there is exactly one +/// per spawner — describing itself and its current capacity. Clients list them +/// so the user can pick a spawner instead of pasting 64 hex characters. +/// +/// # An announcement is advertising, not authorization +/// +/// Anyone can publish one. Appearing in a client's list must never imply the +/// spawner is trusted: an owner still signs a NIP-OA attestation per agent +/// (kind [`KIND_SPAWNER_ATTESTATION`]), and that signature is the only thing +/// that grants an agent access. Treat the content as self-reported hints — +/// capacity numbers included. +pub const KIND_SPAWNER_ANNOUNCEMENT: u32 = 10180; + +/// NIP-AS: Spawner Agent Spec (parameterized replaceable, owner-authored). +/// +/// Desired state for a server-hosted agent, published by the owner and consumed +/// by a `buzz-spawner` daemon. Addressed by `(pubkey, kind, d_tag)` where +/// `d_tag` is a client-chosen stable spec slug — *not* the agent pubkey, which +/// does not exist until the spawner mints it. +/// +/// Like [`KIND_MANAGED_AGENT`], the content is an explicit opt-IN allowlist +/// projection and these events are world-readable: it MUST never carry a secret +/// key, NIP-OA auth tag, env vars, or provider config. The system prompt is +/// resolved through the referenced [`KIND_PERSONA`], never inlined here. +/// +/// Deleting the spec (NIP-09) is the signal to tear the agent down. +pub const KIND_SPAWNER_AGENT_SPEC: u32 = 30178; + +/// NIP-AS: Spawner Agent Status (parameterized replaceable, spawner-authored). +/// +/// Actual state reported back by the spawner for a given +/// [`KIND_SPAWNER_AGENT_SPEC`]. Addressed by `(pubkey, kind, d_tag)` where +/// `d_tag` is the spec slug it reconciles, so a spec and its status share a +/// slug but differ in author. +/// +/// Content carries the reconciliation phase, the minted agent pubkey once it +/// exists, and a human-readable error when the phase is failed. The relay +/// accepts this kind only from the pubkey the referenced spec designates as its +/// spawner — owners cannot forge status for their own specs. +pub const KIND_SPAWNER_AGENT_STATUS: u32 = 30179; + // NIP-56 reporting /// NIP-56: Report an event, pubkey, or blob to relay moderators (kind:1984). /// @@ -407,6 +479,14 @@ pub const KIND_PAIRING: u32 = 24134; pub const KIND_TYPING_INDICATOR: u32 = 20002; /// Ephemeral: owner-scoped encrypted agent observer telemetry and control frame. pub const KIND_AGENT_OBSERVER_FRAME: u32 = 24200; +/// NIP-AS: Ephemeral spawner attestation handshake frame, NIP-44 encrypted and +/// `#p`-gated to the counterparty. +/// +/// Carries the two-round NIP-OA attestation exchange between a `buzz-spawner` +/// and an agent owner: spawner → owner announces a freshly minted agent pubkey +/// plus a nonce, owner → spawner returns the signed auth tag. Ephemeral so the +/// auth tag never lands in relay storage. +pub const KIND_SPAWNER_ATTESTATION: u32 = 24201; /// Ephemeral: huddle emoji reaction burst. Channel-scoped to the ephemeral /// huddle channel with an `h` tag; never stored in the timeline. pub const KIND_HUDDLE_REACTION: u32 = 24810; @@ -586,6 +666,10 @@ pub const ALL_KINDS: &[u32] = &[ KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_SPAWNER_ANNOUNCEMENT, + KIND_SPAWNER_AGENT_SPEC, + KIND_SPAWNER_AGENT_STATUS, + KIND_SPAWNER_ATTESTATION, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, @@ -784,6 +868,10 @@ const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000– const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999 +const _: () = assert!(is_replaceable(KIND_SPAWNER_ANNOUNCEMENT)); // 10180 ∈ 10000–19999 +const _: () = assert!(is_parameterized_replaceable(KIND_SPAWNER_AGENT_SPEC)); // 30178 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_SPAWNER_AGENT_STATUS)); // 30179 ∈ 30000–39999 +const _: () = assert!(is_ephemeral(KIND_SPAWNER_ATTESTATION)); // 24201 ∈ 20000–29999 const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999 @@ -927,6 +1015,58 @@ mod tests { assert!(!is_unshared_persona_event(&ev, &author_bytes)); } + #[test] + fn is_unshared_persona_event_attested_reader_reads_its_owners_persona() { + // A spawner attested to the persona author may read the unshared + // persona — this is what lets it resolve a system prompt without the + // owner publishing the prompt to the whole community. + use nostr::{EventBuilder, Keys, Kind, Tag}; + let owner = Keys::generate(); + let spawner = Keys::generate(); + let ev = EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "") + .tags(vec![Tag::parse(["d", "builtin:fizz"]).unwrap()]) + .sign_with_keys(&owner) + .unwrap(); + + let spawner_bytes = spawner.public_key().to_bytes(); + let owner_bytes = owner.public_key().to_bytes(); + + // Without the attestation the spawner is an ordinary foreign reader. + assert!(is_unshared_persona_event(&ev, &spawner_bytes)); + assert!(!is_unshared_persona_event_for( + &ev, + &spawner_bytes, + Some(&owner_bytes) + )); + } + + #[test] + fn is_unshared_persona_event_delegation_is_one_hop_and_one_direction() { + use nostr::{EventBuilder, Keys, Kind, Tag}; + let owner = Keys::generate(); + let agent = Keys::generate(); + let stranger = Keys::generate(); + + // Persona authored by the agent, read by its owner: delegation runs + // reader→owner, not owner→agent, so this stays blocked. + let ev = EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "") + .tags(vec![Tag::parse(["d", "agent-authored"]).unwrap()]) + .sign_with_keys(&agent) + .unwrap(); + assert!(is_unshared_persona_event_for( + &ev, + &owner.public_key().to_bytes(), + None + )); + + // Being attested to somebody else grants nothing here. + assert!(is_unshared_persona_event_for( + &ev, + &stranger.public_key().to_bytes(), + Some(&owner.public_key().to_bytes()) + )); + } + #[test] fn is_unshared_persona_event_foreign_no_tag() { let ev = make_persona_event(&[&["d", "my-agent"]]); diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 127f1fc40e..d5c33cf1d9 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -275,10 +275,21 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful"); + let owner_bytes = auth_ctx + .agent_owner_pubkey + .as_ref() + .map(|owner| owner.to_bytes().to_vec()); *conn.auth_state.write().await = AuthState::Authenticated(auth_ctx); state .conn_manager .set_authenticated_pubkey(conn_id, pubkey.to_bytes().to_vec()); + // Mirror the attested owner onto the connection entry so the + // synchronous fan-out gates can honor NIP-OA delegation. Always + // written, including the `None` case, so a re-auth on this socket + // cannot inherit a previous identity's delegation. + state + .conn_manager + .set_agent_owner_pubkey(conn_id, owner_bytes); conn.send(RelayMessage::ok(&event_id_hex, true, "")); } Err(e) => { diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 88dd5f5180..57bd9fa450 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -7,8 +7,8 @@ use tracing::{debug, error, info, warn}; use buzz_core::event::StoredEvent; use buzz_core::kind::{ - event_kind_u32, is_ephemeral, is_unshared_persona_event, AUTHOR_ONLY_KINDS, - KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE, + event_kind_u32, is_ephemeral, is_unshared_persona_event_for, AUTHOR_ONLY_KINDS, + KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE, KIND_SPAWNER_ATTESTATION, }; use buzz_core::observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -166,8 +166,11 @@ pub async fn filter_fanout_by_access( if pk == author { return true; } - // Foreign connection: allowed only if the event is shared. - !is_unshared_persona_event(&stored_event.event, &pk) + // Foreign connection: allowed if the event is shared, or if this + // connection is NIP-OA attested to the persona's author (an + // owner-run agent or spawner reading its owner's personas). + let owner = state.conn_manager.agent_owner_for_conn(*conn_id); + !is_unshared_persona_event_for(&stored_event.event, &pk, owner.as_deref()) }) .collect() } else { @@ -691,6 +694,17 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc Result<(), String> { + let mut p_tags = event.tags.iter().filter_map(|t| { + let parts = t.as_slice(); + (parts.len() >= 2 && parts[0].as_str() == "p").then(|| parts[1].as_str()) + }); + let Some(recipient) = p_tags.next() else { + return Err("invalid: spawner attestation frame requires exactly one p tag".into()); + }; + if p_tags.next().is_some() { + return Err("invalid: spawner attestation frame requires exactly one p tag".into()); + } + if recipient.len() != 64 || !recipient.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("invalid: spawner attestation p tag must be a 64-character hex pubkey".into()); + } + + if !content_looks_like_nip44(event.content.as_ref()) { + return Err("invalid: spawner attestation content must be NIP-44 v2 ciphertext".into()); + } + + let now = chrono::Utc::now().timestamp(); + let event_ts = event.created_at.as_secs() as i64; + if (event_ts - now).unsigned_abs() > 300 { + return Err( + "invalid: spawner attestation timestamp outside ±5 minute freshness window".into(), + ); + } + + Ok(()) +} + async fn handle_agent_observer_event( event: Event, conn_id: uuid::Uuid, @@ -1238,6 +1301,98 @@ mod tests { ); } + /// Build a kind:24201 attestation frame with real NIP-44 ciphertext. + fn spawner_attestation_event( + sender: &Keys, + recipient: &nostr::PublicKey, + tags: Vec, + ) -> nostr::Event { + let encrypted = encrypt_observer_payload( + sender, + recipient, + &serde_json::json!({"type": "request", "nonce": "ab"}), + ) + .expect("encrypt attestation payload"); + EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_SPAWNER_ATTESTATION as u16), + encrypted, + ) + .tags(tags) + .sign_with_keys(sender) + .expect("sign event") + } + + #[test] + fn spawner_attestation_accepts_a_single_p_tagged_encrypted_frame() { + let spawner = Keys::generate(); + let owner = Keys::generate(); + let event = spawner_attestation_event( + &spawner, + &owner.public_key(), + vec![Tag::parse(["p", &owner.public_key().to_hex()]).expect("p tag")], + ); + assert!(super::validate_spawner_attestation_envelope(&event).is_ok()); + } + + #[test] + fn spawner_attestation_rejects_missing_or_multiple_p_tags() { + let spawner = Keys::generate(); + let owner = Keys::generate(); + let other = Keys::generate(); + + let untargeted = spawner_attestation_event(&spawner, &owner.public_key(), vec![]); + assert!(super::validate_spawner_attestation_envelope(&untargeted).is_err()); + + // Fan-out to several readers would put one owner's handshake on another + // owner's subscription; exactly one counterparty is the whole contract. + let broadcast = spawner_attestation_event( + &spawner, + &owner.public_key(), + vec![ + Tag::parse(["p", &owner.public_key().to_hex()]).expect("p tag"), + Tag::parse(["p", &other.public_key().to_hex()]).expect("p tag"), + ], + ); + assert!(super::validate_spawner_attestation_envelope(&broadcast).is_err()); + } + + #[test] + fn spawner_attestation_rejects_plaintext_content() { + let spawner = Keys::generate(); + let owner = Keys::generate(); + // A signed auth tag in the clear is exactly what this gate exists to stop. + let event = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_SPAWNER_ATTESTATION as u16), + r#"{"type":"response","auth_tag":"[\"auth\",\"owner\",\"\",\"sig\"]"}"#, + ) + .tags([Tag::parse(["p", &owner.public_key().to_hex()]).expect("p tag")]) + .sign_with_keys(&spawner) + .expect("sign event"); + assert!(super::validate_spawner_attestation_envelope(&event).is_err()); + } + + #[test] + fn spawner_attestation_rejects_stale_frames() { + let spawner = Keys::generate(); + let owner = Keys::generate(); + let encrypted = encrypt_observer_payload( + &spawner, + &owner.public_key(), + &serde_json::json!({"type": "request"}), + ) + .expect("encrypt attestation payload"); + let stale = nostr::Timestamp::from((chrono::Utc::now().timestamp() - 3600).unsigned_abs()); + let event = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_SPAWNER_ATTESTATION as u16), + encrypted, + ) + .tags([Tag::parse(["p", &owner.public_key().to_hex()]).expect("p tag")]) + .custom_created_at(stale) + .sign_with_keys(&spawner) + .expect("sign event"); + assert!(super::validate_spawner_attestation_envelope(&event).is_err()); + } + #[test] fn agent_observer_route_accepts_agent_to_owner_telemetry() { let agent = Keys::generate(); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index a30b0e714d..fd41ff1a84 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -29,6 +29,7 @@ use buzz_core::kind::{ KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, + KIND_SPAWNER_AGENT_SPEC, KIND_SPAWNER_AGENT_STATUS, KIND_SPAWNER_ANNOUNCEMENT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS, @@ -214,6 +215,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM | KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT + | KIND_SPAWNER_AGENT_SPEC | KIND_SPAWNER_AGENT_STATUS | KIND_SPAWNER_ANNOUNCEMENT | super::push_lease::KIND_PUSH_LEASE => { Ok(Scope::UsersWrite) } @@ -423,6 +425,12 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { // keyed by (pubkey, kind, d_tag). A stray `h` tag must not channel-scope them. | KIND_TEAM | KIND_MANAGED_AGENT + // NIP-AS: spawner agent spec (30178) + status (30179): keyed by + // (pubkey, kind, d_tag) where d_tag is a spec slug, never a channel. + | KIND_SPAWNER_AGENT_SPEC + | KIND_SPAWNER_AGENT_STATUS + // NIP-AS: spawner announcement (10180) is keyed by (pubkey, kind). + | KIND_SPAWNER_ANNOUNCEMENT // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). | KIND_GIT_REPO_ANNOUNCEMENT diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index d3ddd3e5d3..87bcecaaef 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -7,7 +7,7 @@ use tracing::{debug, warn}; use buzz_core::filter::filters_match; use buzz_core::kind::{ - is_unshared_persona_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, + is_unshared_persona_event_for, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, KIND_DM_VISIBILITY, KIND_PERSONA, P_GATED_KINDS, RESULT_GATED_KINDS, }; use buzz_core::tenant::TenantContext; @@ -47,7 +47,7 @@ pub async fn handle_req( conn: Arc, state: Arc, ) { - let (conn_id, pubkey_bytes, token_channel_ids) = { + let (conn_id, pubkey_bytes, agent_owner_bytes, token_channel_ids) = { let auth = conn.auth_state.read().await; match &*auth { AuthState::Authenticated(ctx) => { @@ -71,7 +71,12 @@ pub async fn handle_req( return; } - (conn.conn_id, pk_bytes, ctx.channel_ids.clone()) + let owner_bytes = ctx + .agent_owner_pubkey + .as_ref() + .map(|owner| owner.to_bytes().to_vec()); + + (conn.conn_id, pk_bytes, owner_bytes, ctx.channel_ids.clone()) } _ => { conn.send(RelayMessage::notice( @@ -224,6 +229,7 @@ pub async fn handle_req( token_channel_ids.is_none(), &conn.tenant, &pubkey_bytes, + agent_owner_bytes.as_deref(), &conn, &state, trace_state.as_ref(), @@ -385,7 +391,11 @@ pub async fn handle_req( // Also enforces author-only kinds (30300/30350) and the persona // shared-gate (kind:30175 without ["shared","true"]). Single call // covers all three gated event classes. - if !event_visible_to_reader(&stored.event, &pubkey_bytes) { + if !event_visible_to_reader_for( + &stored.event, + &pubkey_bytes, + agent_owner_bytes.as_deref(), + ) { continue; } @@ -509,6 +519,7 @@ async fn handle_search_req( include_global: bool, tenant: &TenantContext, reader_pubkey_bytes: &[u8], + reader_owner_pubkey_bytes: Option<&[u8]>, conn: &ConnectionState, state: &AppState, trace_state: Option<&crate::conformance::AbstractState>, @@ -702,7 +713,11 @@ async fn handle_search_req( } // Result-level gate: covers author-only, persona shared-gate, // and result-gated kinds in one call. - if !event_visible_to_reader(&stored.event, reader_pubkey_bytes) { + if !event_visible_to_reader_for( + &stored.event, + reader_pubkey_bytes, + reader_owner_pubkey_bytes, + ) { continue; } // Dedup AFTER acceptance — an event that fails filter A's constraints @@ -1220,10 +1235,29 @@ pub(crate) fn is_author_only_event(event: &nostr::Event, requester_pubkey_bytes: /// (NIP-98 `/query`, `/count`, FTS search) — instead of inlining the three /// individual predicates at each site. pub(crate) fn event_visible_to_reader(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool { + event_visible_to_reader_for(event, requester_pubkey_bytes, None) +} + +/// NIP-OA-aware variant of [`event_visible_to_reader`]. +/// +/// `requester_owner_pubkey_bytes` is the reader's attesting owner, present when +/// the connection authenticated with an `auth` tag. It relaxes exactly one gate +/// — the persona shared-gate — so an owner-attested agent (notably a +/// `buzz-spawner` daemon resolving the system prompt for an agent it runs) can +/// read its own owner's unshared personas without the owner having to publish +/// them `["shared","true"]` to the whole community. +/// +/// It deliberately does NOT relax the author-only or result-gated checks: +/// delegation grants persona reads, not blanket impersonation. +pub(crate) fn event_visible_to_reader_for( + event: &nostr::Event, + requester_pubkey_bytes: &[u8], + requester_owner_pubkey_bytes: Option<&[u8]>, +) -> bool { if is_author_only_event(event, requester_pubkey_bytes) { return false; } - if is_unshared_persona_event(event, requester_pubkey_bytes) { + if is_unshared_persona_event_for(event, requester_pubkey_bytes, requester_owner_pubkey_bytes) { return false; } let requester_pubkey_hex = hex::encode(requester_pubkey_bytes); diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 758c001b96..9f486d5141 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -54,6 +54,11 @@ struct ConnEntry { backpressure_count: Arc, subscriptions: ConnectionSubscriptions, authenticated_pubkey: Arc>>>, + /// NIP-OA attesting owner for this connection, when it authenticated with an + /// `auth` tag. Mirrors `AuthContext::agent_owner_pubkey`, kept here so the + /// synchronous fan-out path can consult it without awaiting the per-socket + /// `auth_state` lock. + agent_owner_pubkey: Arc>>>, grace_limit: u8, } @@ -225,6 +230,7 @@ impl ConnectionManager { backpressure_count, subscriptions, authenticated_pubkey: Arc::new(std::sync::RwLock::new(None)), + agent_owner_pubkey: Arc::new(std::sync::RwLock::new(None)), grace_limit, }, ); @@ -252,6 +258,26 @@ impl ConnectionManager { } } + /// Record the NIP-OA attesting owner for a connection after NIP-42 succeeds. + /// + /// Pass `None` for a connection that authenticated without an `auth` tag, so + /// a re-auth on the same socket clears a previously attested owner rather + /// than leaving a stale delegation in place. + pub fn set_agent_owner_pubkey(&self, conn_id: Uuid, owner_bytes: Option>) { + if let Some(entry) = self.connections.get(&conn_id) { + if let Ok(mut slot) = entry.agent_owner_pubkey.write() { + *slot = owner_bytes; + } + } + } + + /// Return the NIP-OA attesting owner recorded for a connection, if any. + pub fn agent_owner_for_conn(&self, conn_id: Uuid) -> Option> { + self.connections + .get(&conn_id) + .and_then(|entry| entry.agent_owner_pubkey.read().ok()?.clone()) + } + /// Return live connection IDs authenticated as `pubkey_bytes` in one community. /// /// The same Nostr key may be connected to multiple communities at once. diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs index 4ee0cd4c88..065d7c7535 100644 --- a/crates/buzz-sdk/src/lib.rs +++ b/crates/buzz-sdk/src/lib.rs @@ -15,6 +15,7 @@ pub mod builders; pub mod mentions; pub mod nip_oa; +pub mod spawner; pub use builders::*; diff --git a/crates/buzz-sdk/src/spawner.rs b/crates/buzz-sdk/src/spawner.rs new file mode 100644 index 0000000000..ced1aafbbd --- /dev/null +++ b/crates/buzz-sdk/src/spawner.rs @@ -0,0 +1,1018 @@ +//! NIP-AS — Agent Spawner. +//! +//! Typed content bodies and event builders for server-hosted agents: an owner +//! publishes a *spec* describing an agent they want running, a `buzz-spawner` +//! daemon reconciles it into a container and publishes *status* back, and the +//! two exchange an ephemeral *attestation* handshake so the daemon can obtain a +//! NIP-OA auth tag for a key it minted itself. +//! +//! # Why the handshake exists +//! +//! The agent's secret key is generated on the spawner host and never leaves it. +//! But a NIP-OA auth tag is +//! `Schnorr(SHA256("nostr:agent-auth:" || agent_pubkey || ":" || conditions), owner_secret)` +//! (see [`crate::nip_oa`]) — it binds one specific agent pubkey and requires the +//! *owner's* secret key. The spawner cannot self-attest, and the owner cannot +//! pre-authorize a pubkey that does not exist yet. Hence two rounds: +//! +//! ```text +//! owner ──kind:30178 spec──────────────────────────► spawner +//! spawner ──kind:24201 AttestationRequest (pubkey+nonce)► owner +//! owner ──kind:24201 AttestationResponse (auth tag)──► spawner +//! spawner ──kind:30179 status: running────────────────► owner +//! ``` +//! +//! # Security: specs are world-readable +//! +//! [`SpawnerAgentSpec`] is an explicit opt-IN projection, exactly like the +//! desktop's kind:30177 managed-agent events. It MUST NEVER carry a secret key, +//! an auth tag, env vars, or provider credentials. The type is the structural +//! guard: it physically cannot represent those fields, so a malicious inbound +//! spec cannot smuggle them onto the spawn path. Add a field here only after +//! asking whether it would be safe printed on a billboard. +//! +//! Only the ephemeral [`KIND_SPAWNER_ATTESTATION`] frames carry sensitive +//! material (the auth tag), and those are NIP-44 encrypted and `#p`-gated. +//! +//! # Status events are self-addressing +//! +//! Status is a NIP-33 parameterized replaceable event addressed by +//! `(pubkey, kind, d_tag)`. Because the author is part of the address, a status +//! event published by an impostor lands at *their* address, not the spawner's — +//! it cannot overwrite the real one. Clients therefore read status at the +//! address of a spawner pubkey they already trust, and the relay needs no +//! special author gate for this kind. + +use buzz_core::kind::{ + KIND_SPAWNER_AGENT_SPEC, KIND_SPAWNER_AGENT_STATUS, KIND_SPAWNER_ANNOUNCEMENT, + KIND_SPAWNER_ATTESTATION, +}; +use buzz_core::observer::content_looks_like_nip44; +use nostr::{EventBuilder, Kind, Tag}; +use serde::{Deserialize, Serialize}; + +use crate::SdkError; + +/// Tag name carrying the pubkey of the spawner a spec is addressed to. +pub const SPAWNER_TAG: &str = "spawner"; + +/// Maximum byte length of a spec slug (`d` tag). +pub const MAX_SPEC_SLUG_LEN: usize = 64; + +/// Maximum byte length of a serialized spec or status content body. +pub const MAX_SPAWNER_CONTENT_BYTES: usize = 32 * 1024; + +/// Maximum number of allowlisted author pubkeys on a spec. +pub const MAX_RESPOND_TO_ALLOWLIST: usize = 256; + +/// Byte length of the attestation handshake nonce. +pub const ATTESTATION_NONCE_BYTES: usize = 32; + +// --------------------------------------------------------------------------- +// Announcement (kind 10180) +// --------------------------------------------------------------------------- + +/// A spawner advertising itself so owners can find it — content of kind:10180. +/// +/// Every field is self-reported and unverifiable. A client may show these to +/// help a user choose, but must not treat any of them as a security property: +/// authorization comes from the per-agent attestation the owner signs, never +/// from an announcement. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpawnerAnnouncement { + /// Human-readable name, e.g. "prod-vps" or "gpu-box". + pub name: String, + /// Optional longer description shown in a picker. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Agent runtime image this spawner runs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_image: Option, + /// ACP agent binary this spawner runs, e.g. `claude-agent-acp`. + /// + /// Display-only. A client shows it so "prod-vps — Claude Code" is legible + /// in a picker, but it is self-reported and confers nothing: the host alone + /// decides what executes there, and no spec can influence it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime: Option, + /// How many agents it will run at once. + pub max_agents: u32, + /// How many it is running now, so a full spawner can be shown as such. + pub agents_running: u32, + /// Per-agent CPU ceiling, thousandths of a core. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_cpu_millis: Option, + /// Per-agent memory ceiling, mebibytes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_memory_mib: Option, +} + +impl SpawnerAnnouncement { + /// Validate the announcement's own invariants. + pub fn validate(&self) -> Result<(), SdkError> { + if self.name.trim().is_empty() { + return Err(SdkError::InvalidInput( + "spawner name must not be empty".into(), + )); + } + if self.name.len() > 128 { + return Err(SdkError::InvalidInput(format!( + "spawner name exceeds 128 bytes (got {})", + self.name.len() + ))); + } + if let Some(description) = &self.description { + if description.len() > 512 { + return Err(SdkError::InvalidInput(format!( + "spawner description exceeds 512 bytes (got {})", + description.len() + ))); + } + } + Ok(()) + } + + /// Whether the spawner reports itself at capacity. + /// + /// Advisory only — a client should still let the user try, because these + /// numbers are a snapshot the spawner chose to publish. + pub fn is_full(&self) -> bool { + self.agents_running >= self.max_agents + } +} + +/// Build a spawner announcement event (kind 10180). +pub fn build_spawner_announcement( + announcement: &SpawnerAnnouncement, +) -> Result { + announcement.validate()?; + let content = serde_json::to_string(announcement) + .map_err(|e| SdkError::InvalidInput(format!("failed to serialize announcement: {e}")))?; + check_spawner_content(&content)?; + Ok(EventBuilder::new( + Kind::Custom(KIND_SPAWNER_ANNOUNCEMENT as u16), + content, + )) +} + +/// Parse a kind:10180 event's content into a [`SpawnerAnnouncement`]. +pub fn announcement_from_event(event: &nostr::Event) -> Result { + let announcement: SpawnerAnnouncement = serde_json::from_str(event.content.as_ref()) + .map_err(|e| SdkError::InvalidInput(format!("failed to parse announcement: {e}")))?; + announcement.validate()?; + Ok(announcement) +} + +// --------------------------------------------------------------------------- +// Spec (kind 30178) +// --------------------------------------------------------------------------- + +/// Inbound author gate for a spawned agent — mirrors the desktop's `RespondTo`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum RespondTo { + /// Respond to anyone who can address the agent. + #[default] + Anyone, + /// Respond only to the attesting owner. + OwnerOnly, + /// Respond only to pubkeys on `respond_to_allowlist`. + Allowlist, +} + +/// Container resource limits for a spawned agent. +/// +/// Both fields are optional; the spawner applies its own configured defaults +/// when they are absent, and clamps anything above its configured ceiling. A +/// spec asking for more than the host allows is clamped, not rejected — the +/// owner should get a smaller agent, not a broken one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct ResourceRequest { + /// CPU allocation in thousandths of a core (1000 = one full core). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cpu_millis: Option, + /// Memory limit in mebibytes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory_mib: Option, +} + +/// Desired state for a server-hosted agent — the content body of kind:30178. +/// +/// See the module docs for the opt-IN projection contract. `system_prompt`, +/// `model`, and `provider` follow the same slimming rule as kind:30177: when +/// `persona_id` is set they are resolved through the referenced kind:30175 +/// persona and omitted here, so a prompt lives in exactly one place. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpawnerAgentSpec { + /// Display name for the agent. + pub name: String, + /// Existing agent identity this spec relocates, rather than minting a new one. + /// + /// # Why relocation, not creation + /// + /// An agent's pubkey *is* its continuity: channel membership, its kind:0 + /// profile, its kind:30177 coordinate (whose `d` tag is this pubkey), DMs, + /// turn metrics, the relay's `users.agent_owner_pubkey` row, and — most + /// destructively — its NIP-AE memory, whose d-tags derive from + /// `conversation_key(agent_seckey, owner_pubkey)`. A new key changes every + /// d-tag *and* leaves the old ciphertext undecryptable, so the memory is + /// gone for good. + /// + /// So moving an existing agent to a spawner names it here and delivers its + /// secret key over the encrypted handshake, exactly as a provider deploy + /// already ships `private_key_nsec` to a remote runner + /// (`desktop/src-tauri/src/commands/agents_deploy.rs`). Only the public key + /// appears here; the secret never touches a stored event. + /// + /// `None` means "mint a fresh identity", which is right for an agent that + /// has no local existence to preserve. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_pubkey: Option, + /// Persona (kind:30175) this agent is an instance of, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub persona_id: Option, + /// Inline system prompt. Set only for definition-less specs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + /// Model id. Set only for definition-less specs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Inference provider id. Set only for definition-less specs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// Number of concurrent ACP sessions the harness should hold. + #[serde(default = "default_parallelism")] + pub parallelism: u32, + /// Inbound author gate mode. + #[serde(default)] + pub respond_to: RespondTo, + /// Allowlisted author pubkeys when `respond_to == Allowlist`. Public keys, + /// not secrets. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub respond_to_allowlist: Vec, + /// Requested container resources. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resources: Option, + /// When false the spawner tears the container down but keeps the spec, so + /// the agent keeps its identity and can be resumed. Deleting the spec + /// (NIP-09) is the permanent teardown signal. + #[serde(default = "default_enabled")] + pub enabled: bool, +} + +fn default_parallelism() -> u32 { + 1 +} + +fn default_enabled() -> bool { + true +} + +impl SpawnerAgentSpec { + /// Validate the spec's own invariants. + /// + /// Called by [`build_spawner_agent_spec`] on the write path and by the + /// spawner on the read path — an inbound spec is untrusted input even + /// though it is signed, because a signature proves authorship, not sanity. + pub fn validate(&self) -> Result<(), SdkError> { + if self.name.trim().is_empty() { + return Err(SdkError::InvalidInput("spec name must not be empty".into())); + } + if self.name.len() > 128 { + return Err(SdkError::InvalidInput(format!( + "spec name exceeds 128 bytes (got {})", + self.name.len() + ))); + } + // Deliberately no "must carry a prompt" rule. Prompt material normally + // arrives over the encrypted kind:24201 handshake precisely so it does + // NOT appear here — a spec is world-readable, and requiring a prompt on + // it would force every server agent's instructions to be public. + if self.parallelism == 0 || self.parallelism > 16 { + return Err(SdkError::InvalidInput(format!( + "parallelism must be in 1..=16 (got {})", + self.parallelism + ))); + } + if self.respond_to_allowlist.len() > MAX_RESPOND_TO_ALLOWLIST { + return Err(SdkError::InvalidInput(format!( + "respond_to_allowlist exceeds {MAX_RESPOND_TO_ALLOWLIST} entries (got {})", + self.respond_to_allowlist.len() + ))); + } + for pk in &self.respond_to_allowlist { + check_pubkey_hex(pk, "respond_to_allowlist entry")?; + } + if let Some(agent_pubkey) = &self.agent_pubkey { + check_pubkey_hex(agent_pubkey, "agent_pubkey")?; + } + if self.respond_to == RespondTo::Allowlist && self.respond_to_allowlist.is_empty() { + return Err(SdkError::InvalidInput( + "respond_to=allowlist requires a non-empty respond_to_allowlist".into(), + )); + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Status (kind 30179) +// --------------------------------------------------------------------------- + +/// Reconciliation phase reported by the spawner. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SpawnPhase { + /// Keys minted; waiting for the owner to return a signed auth tag. + PendingAttestation, + /// Attested; container being created. + Starting, + /// Container running. + Running, + /// Reconciliation failed. `error` carries a human-readable reason. + Failed, + /// Intentionally not running — spec has `enabled: false`. + Stopped, +} + +/// Actual state of a spawned agent — the content body of kind:30179. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpawnerAgentStatus { + /// Current reconciliation phase. + pub phase: SpawnPhase, + /// The minted agent pubkey, present from `PendingAttestation` onward. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_pubkey: Option, + /// Hash of the spec content this status reflects, so a client can tell + /// whether the spawner has caught up with an edit. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spec_hash: Option, + /// Human-readable failure reason. Set iff `phase == Failed`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Consecutive failed start attempts, for surfacing backoff in a UI. + #[serde(default, skip_serializing_if = "is_zero")] + pub restart_count: u32, +} + +fn is_zero(n: &u32) -> bool { + *n == 0 +} + +impl SpawnerAgentStatus { + /// Validate the status's own invariants. + pub fn validate(&self) -> Result<(), SdkError> { + if let Some(pk) = &self.agent_pubkey { + check_pubkey_hex(pk, "agent_pubkey")?; + } + if self.phase == SpawnPhase::Failed && self.error.is_none() { + return Err(SdkError::InvalidInput( + "phase=failed requires an error message".into(), + )); + } + if let Some(err) = &self.error { + if err.len() > 2048 { + return Err(SdkError::InvalidInput(format!( + "status error exceeds 2048 bytes (got {})", + err.len() + ))); + } + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Attestation handshake (kind 24201) +// --------------------------------------------------------------------------- + +/// Prompt material delivered to a spawner over the encrypted handshake. +/// +/// # Why this travels here rather than on the spec +/// +/// A kind:30178 spec is world-readable, so inlining a system prompt there would +/// publish it to the whole community — as would marking the persona +/// `["shared","true"]`. The attestation channel is already NIP-44 encrypted +/// owner-to-spawner, and the owner can obviously read their own persona, so +/// delivering the prompt here keeps it private with no relay involvement at all. +/// +/// The cost is that a prompt edit does not reach a running spawner by itself; +/// the owner sends an [`AttestationFrame::PromptUpdate`] to push a new one. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PromptMaterial { + /// The agent's system prompt. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + /// Team-level instructions appended after the system prompt. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub team_instructions: Option, + /// Model id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Inference provider id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, +} + +impl PromptMaterial { + /// Whether this carries anything worth storing. + pub fn is_empty(&self) -> bool { + self.system_prompt.is_none() + && self.team_instructions.is_none() + && self.model.is_none() + && self.provider.is_none() + } +} + +/// The plaintext payload of a [`KIND_SPAWNER_ATTESTATION`] frame, before NIP-44 +/// encryption. +/// +/// `nonce` binds the two rounds together: the spawner will only accept a +/// response whose nonce matches the request it is still waiting on, so a +/// replayed or crossed response for a different agent is rejected rather than +/// silently applied. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AttestationFrame { + /// Spawner → owner: "I minted this pubkey for your spec; please attest it." + Request { + /// The `d` tag of the spec this agent belongs to. + spec_slug: String, + /// The freshly minted agent pubkey, hex. + agent_pubkey: String, + /// NIP-OA conditions string the owner should sign over. Usually empty. + #[serde(default)] + conditions: String, + /// Hex-encoded random nonce, [`ATTESTATION_NONCE_BYTES`] bytes. + nonce: String, + }, + /// Owner → spawner: the signed auth tag. + Response { + /// Echoed spec slug. + spec_slug: String, + /// Echoed agent pubkey — must match the pending request. + agent_pubkey: String, + /// Echoed nonce — must match the pending request. + nonce: String, + /// The NIP-OA `auth` tag as a JSON array string, in the form + /// `["auth", "", "", ""]`. Verify with + /// [`crate::nip_oa::verify_auth_tag`] before use. + auth_tag: String, + /// Prompt material for this agent, when the spawner cannot read the + /// referenced persona itself. Optional so a shared-persona deployment + /// need not duplicate it. + #[serde(default, skip_serializing_if = "Option::is_none")] + prompt: Option, + /// Secret key of an existing agent being relocated to this spawner. + /// + /// Present only when the spec named an `agent_pubkey`. The enclosing + /// frame is NIP-44 encrypted to the spawner and the kind is ephemeral, + /// so the key is never stored by the relay — but it does transit it, and + /// that is the deliberate cost of keeping one identity across hosts. + #[serde(default, skip_serializing_if = "Option::is_none")] + private_key_nsec: Option, + }, + /// Owner → spawner: replacement prompt material for an existing agent. + /// + /// Sent after a persona edit. Carries no nonce because it opens no round — + /// the spawner accepts it only for an agent it already holds an attestation + /// for, from that agent's own owner, so there is nothing to bind it to. + PromptUpdate { + /// The `d` tag of the spec this agent belongs to. + spec_slug: String, + /// The agent being updated. + agent_pubkey: String, + /// The new prompt material. + prompt: PromptMaterial, + }, + /// Owner → spawner: refusal. Lets a client decline explicitly instead of + /// leaving the spawner waiting on a timeout. + Reject { + /// Echoed spec slug. + spec_slug: String, + /// Echoed agent pubkey. + agent_pubkey: String, + /// Echoed nonce. + nonce: String, + /// Human-readable reason, surfaced in the spawner's status event. + #[serde(default, skip_serializing_if = "Option::is_none")] + reason: Option, + }, +} + +impl AttestationFrame { + /// The agent pubkey this frame concerns, regardless of variant. + pub fn agent_pubkey(&self) -> &str { + match self { + Self::Request { agent_pubkey, .. } + | Self::Response { agent_pubkey, .. } + | Self::Reject { agent_pubkey, .. } + | Self::PromptUpdate { agent_pubkey, .. } => agent_pubkey, + } + } + + /// The nonce binding this frame to a handshake round. + /// + /// Empty for [`Self::PromptUpdate`], which opens no round. + pub fn nonce(&self) -> &str { + match self { + Self::Request { nonce, .. } + | Self::Response { nonce, .. } + | Self::Reject { nonce, .. } => nonce, + Self::PromptUpdate { .. } => "", + } + } + + /// The spec slug this frame concerns. + pub fn spec_slug(&self) -> &str { + match self { + Self::Request { spec_slug, .. } + | Self::Response { spec_slug, .. } + | Self::Reject { spec_slug, .. } + | Self::PromptUpdate { spec_slug, .. } => spec_slug, + } + } + + /// Validate structural invariants shared by every variant. + pub fn validate(&self) -> Result<(), SdkError> { + check_spec_slug(self.spec_slug())?; + check_pubkey_hex(self.agent_pubkey(), "agent_pubkey")?; + // A prompt update opens no handshake round, so it carries no nonce. + if matches!(self, Self::PromptUpdate { .. }) { + return Ok(()); + } + let nonce = self.nonce(); + if nonce.len() != ATTESTATION_NONCE_BYTES * 2 + || !nonce.chars().all(|c| c.is_ascii_hexdigit()) + { + return Err(SdkError::InvalidInput(format!( + "nonce must be {} hex characters", + ATTESTATION_NONCE_BYTES * 2 + ))); + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Builders +// --------------------------------------------------------------------------- + +/// Build a spawner agent spec event (kind 30178). +/// +/// `slug` becomes the `d` tag and is the stable handle for this agent across +/// edits — it is chosen by the client, not derived from the agent pubkey, which +/// does not exist until the spawner mints it. +/// +/// `spawner_pubkey` becomes a `spawner` tag so a host running more than one +/// daemon, or a client watching several, can filter without parsing content. +pub fn build_spawner_agent_spec( + slug: &str, + spawner_pubkey: &str, + spec: &SpawnerAgentSpec, +) -> Result { + let slug = check_spec_slug(slug)?; + let spawner_pubkey = check_pubkey_hex(spawner_pubkey, "spawner_pubkey")?; + spec.validate()?; + + let content = serde_json::to_string(spec) + .map_err(|e| SdkError::InvalidInput(format!("failed to serialize spec: {e}")))?; + check_spawner_content(&content)?; + + let tags = vec![ + parse_tag(&["d", &slug])?, + parse_tag(&[SPAWNER_TAG, &spawner_pubkey])?, + parse_tag(&["p", &spawner_pubkey])?, + ]; + Ok(EventBuilder::new(Kind::Custom(KIND_SPAWNER_AGENT_SPEC as u16), content).tags(tags)) +} + +/// Build a spawner agent status event (kind 30179). +/// +/// `owner_pubkey` becomes a `p` tag so the owner's client can subscribe to +/// status for its own agents without knowing every slug in advance. +pub fn build_spawner_agent_status( + slug: &str, + owner_pubkey: &str, + status: &SpawnerAgentStatus, +) -> Result { + let slug = check_spec_slug(slug)?; + let owner_pubkey = check_pubkey_hex(owner_pubkey, "owner_pubkey")?; + status.validate()?; + + let content = serde_json::to_string(status) + .map_err(|e| SdkError::InvalidInput(format!("failed to serialize status: {e}")))?; + check_spawner_content(&content)?; + + let mut tags = vec![parse_tag(&["d", &slug])?, parse_tag(&["p", &owner_pubkey])?]; + // Surface the agent pubkey as a tag too, so a client can resolve + // slug → agent identity from the tag index without reading content. + if let Some(agent_pubkey) = &status.agent_pubkey { + tags.push(parse_tag(&["agent", agent_pubkey])?); + } + Ok(EventBuilder::new(Kind::Custom(KIND_SPAWNER_AGENT_STATUS as u16), content).tags(tags)) +} + +/// Build an ephemeral attestation handshake frame (kind 24201). +/// +/// `recipient_pubkey` is the cleartext `p` tag the relay uses to route the +/// frame to exactly one counterparty. `encrypted_content` must be NIP-44 v2 +/// ciphertext of a serialized [`AttestationFrame`] — this builder deliberately +/// takes ciphertext rather than the frame itself, so no code path can construct +/// a plaintext auth tag on the wire by mistake. +pub fn build_spawner_attestation( + recipient_pubkey: &str, + encrypted_content: &str, +) -> Result { + let recipient_pubkey = check_pubkey_hex(recipient_pubkey, "recipient_pubkey")?; + if !content_looks_like_nip44(encrypted_content) { + return Err(SdkError::InvalidInput( + "attestation frame content must be NIP-44 v2 ciphertext".into(), + )); + } + check_spawner_content(encrypted_content)?; + + Ok(EventBuilder::new( + Kind::Custom(KIND_SPAWNER_ATTESTATION as u16), + encrypted_content, + ) + .tags(vec![parse_tag(&["p", &recipient_pubkey])?])) +} + +// --------------------------------------------------------------------------- +// Parsers +// --------------------------------------------------------------------------- + +/// Parse a kind:30178 event's content into a [`SpawnerAgentSpec`]. +/// +/// # Security +/// +/// Returns the projection type, which physically cannot represent a secret key, +/// auth tag, env vars, or provider config — unknown keys in a malicious event +/// are dropped at deserialization rather than filtered afterward. The spec is +/// validated here as well; a signature proves authorship, not sanity. +pub fn spec_from_event(event: &nostr::Event) -> Result { + let spec: SpawnerAgentSpec = serde_json::from_str(event.content.as_ref()) + .map_err(|e| SdkError::InvalidInput(format!("failed to parse spec content: {e}")))?; + spec.validate()?; + Ok(spec) +} + +/// Parse a kind:30179 event's content into a [`SpawnerAgentStatus`]. +pub fn status_from_event(event: &nostr::Event) -> Result { + let status: SpawnerAgentStatus = serde_json::from_str(event.content.as_ref()) + .map_err(|e| SdkError::InvalidInput(format!("failed to parse status content: {e}")))?; + status.validate()?; + Ok(status) +} + +/// Read the `d` tag (spec slug) from a spawner event. +pub fn spec_slug_from_event(event: &nostr::Event) -> Option { + single_tag_value(event, "d") +} + +/// Read the `spawner` tag (target spawner pubkey) from a spec event. +pub fn spawner_pubkey_from_event(event: &nostr::Event) -> Option { + single_tag_value(event, SPAWNER_TAG) +} + +fn single_tag_value(event: &nostr::Event, name: &str) -> Option { + event.tags.iter().find_map(|t| { + let parts = t.as_slice(); + (parts.len() >= 2 && parts[0].as_str() == name).then(|| parts[1].to_string()) + }) +} + +// --------------------------------------------------------------------------- +// Local validation helpers +// --------------------------------------------------------------------------- + +fn parse_tag(parts: &[&str]) -> Result { + Tag::parse(parts.iter().copied()).map_err(|e| SdkError::InvalidTag(e.to_string())) +} + +fn check_pubkey_hex(s: &str, field: &str) -> Result { + if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(SdkError::InvalidInput(format!( + "{field} must be a 64-character hex pubkey" + ))); + } + Ok(s.to_ascii_lowercase()) +} + +/// Validate a spec slug. +/// +/// Slugs appear in container names, volume names, and log paths on the spawner +/// host, so the character set is deliberately narrow: lowercase alphanumerics, +/// hyphens, and underscores. That rules out path traversal, shell +/// metacharacters, and Docker name-rule violations in one pass, at the point +/// where the value enters the system rather than at each use site. +pub fn check_spec_slug(slug: &str) -> Result { + if slug.is_empty() { + return Err(SdkError::InvalidInput("spec slug must not be empty".into())); + } + if slug.len() > MAX_SPEC_SLUG_LEN { + return Err(SdkError::InvalidInput(format!( + "spec slug exceeds {MAX_SPEC_SLUG_LEN} bytes (got {})", + slug.len() + ))); + } + if !slug + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') + { + return Err(SdkError::InvalidInput( + "spec slug may only contain lowercase ASCII letters, digits, hyphens, and underscores" + .into(), + )); + } + if slug.starts_with('-') || slug.starts_with('_') { + return Err(SdkError::InvalidInput( + "spec slug must start with a letter or digit".into(), + )); + } + Ok(slug.to_string()) +} + +fn check_spawner_content(content: &str) -> Result<(), SdkError> { + let got = content.len(); + if got > MAX_SPAWNER_CONTENT_BYTES { + return Err(SdkError::ContentTooLarge { + max: MAX_SPAWNER_CONTENT_BYTES, + got, + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::Keys; + + fn sample_spec() -> SpawnerAgentSpec { + SpawnerAgentSpec { + name: "Fizz".into(), + agent_pubkey: None, + persona_id: Some("builtin:fizz".into()), + system_prompt: None, + model: None, + provider: None, + parallelism: 1, + respond_to: RespondTo::Anyone, + respond_to_allowlist: Vec::new(), + resources: Some(ResourceRequest { + cpu_millis: Some(1000), + memory_mib: Some(2048), + }), + enabled: true, + } + } + + #[test] + fn spec_round_trips_through_an_event() { + let keys = Keys::generate(); + let spawner = Keys::generate(); + let spec = sample_spec(); + let event = build_spawner_agent_spec("fizz-prod", &spawner.public_key().to_hex(), &spec) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + + assert_eq!(spec_slug_from_event(&event).as_deref(), Some("fizz-prod")); + assert_eq!( + spawner_pubkey_from_event(&event), + Some(spawner.public_key().to_hex()) + ); + assert_eq!(spec_from_event(&event).unwrap(), spec); + } + + #[test] + fn spec_rejects_secret_bearing_keys_by_dropping_them() { + // A malicious spec that tries to smuggle an nsec and an auth tag. The + // projection type has no such fields, so they are dropped at parse. + let json = r#"{ + "name": "Evil", + "persona_id": "builtin:fizz", + "private_key_nsec": "nsec1deadbeef", + "auth_tag": "[\"auth\",\"..\"]", + "env_vars": {"ANTHROPIC_API_KEY": "sk-leak"} + }"#; + let spec: SpawnerAgentSpec = serde_json::from_str(json).unwrap(); + assert_eq!(spec.name, "Evil"); + // Re-serializing cannot reproduce the smuggled fields. + let round_tripped = serde_json::to_string(&spec).unwrap(); + assert!(!round_tripped.contains("nsec")); + assert!(!round_tripped.contains("auth_tag")); + assert!(!round_tripped.contains("sk-leak")); + } + + #[test] + fn spec_may_name_an_existing_identity_to_relocate() { + let agent = Keys::generate(); + let mut spec = sample_spec(); + spec.agent_pubkey = Some(agent.public_key().to_hex()); + assert!(spec.validate().is_ok()); + + // Only the public key ever appears on a spec — it is world-readable. + let json = serde_json::to_string(&spec).unwrap(); + assert!(json.contains(&agent.public_key().to_hex())); + assert!(!json.contains("nsec")); + + spec.agent_pubkey = Some("not-a-pubkey".into()); + assert!(spec.validate().is_err()); + } + + #[test] + fn spec_need_not_carry_a_prompt() { + // Prompt material rides the encrypted handshake so it never becomes + // public. A spec with neither persona_id nor system_prompt is the + // normal, private case — not an error. + let mut spec = sample_spec(); + spec.persona_id = None; + spec.system_prompt = None; + assert!(spec.validate().is_ok()); + } + + #[test] + fn spec_rejects_empty_allowlist_when_gated_on_it() { + let mut spec = sample_spec(); + spec.respond_to = RespondTo::Allowlist; + assert!(spec.validate().is_err()); + spec.respond_to_allowlist = vec![Keys::generate().public_key().to_hex()]; + assert!(spec.validate().is_ok()); + } + + #[test] + fn slug_rejects_traversal_and_metacharacters() { + for bad in [ + "../etc/passwd", + "fizz prod", + "fizz;rm -rf /", + "Fizz", + "-leading", + "", + ] { + assert!( + check_spec_slug(bad).is_err(), + "slug {bad:?} should be rejected" + ); + } + assert!(check_spec_slug("fizz-prod_2").is_ok()); + } + + #[test] + fn status_failed_requires_an_error() { + let mut status = SpawnerAgentStatus { + phase: SpawnPhase::Failed, + agent_pubkey: None, + spec_hash: None, + error: None, + restart_count: 3, + }; + assert!(status.validate().is_err()); + status.error = Some("image pull failed".into()); + assert!(status.validate().is_ok()); + } + + #[test] + fn status_round_trips_and_tags_the_agent() { + let keys = Keys::generate(); + let owner = Keys::generate(); + let agent = Keys::generate(); + let status = SpawnerAgentStatus { + phase: SpawnPhase::Running, + agent_pubkey: Some(agent.public_key().to_hex()), + spec_hash: Some("abc123".into()), + error: None, + restart_count: 0, + }; + let event = build_spawner_agent_status("fizz-prod", &owner.public_key().to_hex(), &status) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + + assert_eq!(status_from_event(&event).unwrap(), status); + assert_eq!( + single_tag_value(&event, "agent"), + Some(agent.public_key().to_hex()) + ); + assert_eq!( + single_tag_value(&event, "p"), + Some(owner.public_key().to_hex()) + ); + } + + #[test] + fn announcement_round_trips_through_an_event() { + let keys = Keys::generate(); + let announcement = SpawnerAnnouncement { + name: "prod-vps".into(), + description: Some("Hetzner CX42, Frankfurt".into()), + agent_image: Some("ghcr.io/block/buzz-acp:main".into()), + runtime: Some("claude-agent-acp".into()), + max_agents: 16, + agents_running: 3, + max_cpu_millis: Some(4000), + max_memory_mib: Some(8192), + }; + let event = build_spawner_announcement(&announcement) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + assert_eq!(announcement_from_event(&event).unwrap(), announcement); + } + + #[test] + fn announcement_reports_capacity() { + let mut a = SpawnerAnnouncement { + name: "vps".into(), + description: None, + agent_image: None, + runtime: None, + max_agents: 2, + agents_running: 1, + max_cpu_millis: None, + max_memory_mib: None, + }; + assert!(!a.is_full()); + a.agents_running = 2; + assert!(a.is_full()); + // Over-capacity can happen after a limit is lowered; still full. + a.agents_running = 5; + assert!(a.is_full()); + } + + #[test] + fn announcement_rejects_an_empty_name() { + // A nameless spawner would render as a blank row in a picker, which is + // worse than not listing it. + let a = SpawnerAnnouncement { + name: " ".into(), + description: None, + agent_image: None, + runtime: None, + max_agents: 1, + agents_running: 0, + max_cpu_millis: None, + max_memory_mib: None, + }; + assert!(a.validate().is_err()); + } + + #[test] + fn announcement_cannot_carry_secrets() { + // Announcements are world-readable by design. The projection type must + // drop anything a malicious or careless spawner adds. + let json = r#"{"name":"evil","max_agents":1,"agents_running":0, + "private_key_nsec":"nsec1leak","env_vars":{"K":"sk-leak"}}"#; + let a: SpawnerAnnouncement = serde_json::from_str(json).unwrap(); + let round_tripped = serde_json::to_string(&a).unwrap(); + assert!(!round_tripped.contains("nsec")); + assert!(!round_tripped.contains("sk-leak")); + } + + #[test] + fn attestation_requires_nip44_ciphertext() { + let recipient = Keys::generate().public_key().to_hex(); + assert!(build_spawner_attestation(&recipient, "plaintext auth tag").is_err()); + } + + #[test] + fn attestation_frame_validates_nonce_width() { + let agent = Keys::generate().public_key().to_hex(); + let mut frame = AttestationFrame::Request { + spec_slug: "fizz-prod".into(), + agent_pubkey: agent.clone(), + conditions: String::new(), + nonce: "ab".repeat(ATTESTATION_NONCE_BYTES), + }; + assert!(frame.validate().is_ok()); + + frame = AttestationFrame::Request { + spec_slug: "fizz-prod".into(), + agent_pubkey: agent, + conditions: String::new(), + nonce: "tooshort".into(), + }; + assert!(frame.validate().is_err()); + } + + #[test] + fn attestation_frame_json_is_tagged_by_type() { + let frame = AttestationFrame::Response { + spec_slug: "fizz-prod".into(), + agent_pubkey: Keys::generate().public_key().to_hex(), + nonce: "ab".repeat(ATTESTATION_NONCE_BYTES), + auth_tag: r#"["auth","owner","","sig"]"#.into(), + prompt: None, + private_key_nsec: None, + }; + let json = serde_json::to_string(&frame).unwrap(); + assert!(json.contains(r#""type":"response""#)); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + frame + ); + } +} diff --git a/crates/buzz-spawner/Cargo.toml b/crates/buzz-spawner/Cargo.toml new file mode 100644 index 0000000000..7c8ac044a6 --- /dev/null +++ b/crates/buzz-spawner/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "buzz-spawner" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Reconciles Nostr agent specs into Docker-isolated buzz-acp containers" + +[[bin]] +name = "buzz-spawner" +path = "src/main.rs" + +[dependencies] +buzz-core = { workspace = true } +buzz-sdk = { workspace = true } +buzz-ws-client = { workspace = true } + +nostr = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +hex = { workspace = true } +sha2 = { workspace = true } +rand = { workspace = true } +futures-util = { workspace = true } +chrono = { workspace = true } +async-trait = "0.1" +bollard = "0.21" + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/buzz-spawner/examples/owner_sim.rs b/crates/buzz-spawner/examples/owner_sim.rs new file mode 100644 index 0000000000..24b6913f89 --- /dev/null +++ b/crates/buzz-spawner/examples/owner_sim.rs @@ -0,0 +1,322 @@ +//! Owner-side simulator for exercising a spawner end to end without the +//! desktop app. +//! +//! Stands in for the React attestation prompt: publishes an agent spec, answers +//! the spawner's NIP-OA attestation request, and prints status as it arrives. +//! It signs every request automatically — the real client asks a human first, +//! which is exactly the difference this simulator exists to skip. +//! +//! ```sh +//! cargo run -p buzz-spawner --example owner_sim -- \ +//! --relay ws://127.0.0.1:3000 \ +//! --spawner \ +//! --owner-nsec \ +//! --slug fizz-prod +//! ``` + +use std::{collections::HashMap, time::Duration}; + +use anyhow::{bail, Context, Result}; +use buzz_core::kind::{KIND_SPAWNER_AGENT_STATUS, KIND_SPAWNER_ATTESTATION}; +use buzz_sdk::nip_oa; +use buzz_sdk::spawner::{ + build_spawner_agent_spec, build_spawner_attestation, status_from_event, AttestationFrame, + RespondTo, SpawnerAgentSpec, +}; +use buzz_ws_client::{connection::NostrWsConnection, message::RelayMessage}; +use nostr::{Keys, PublicKey}; +use serde_json::json; + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse()?; + + let keys = match &args.owner_nsec { + Some(nsec) => Keys::parse(nsec).context("invalid --owner-nsec")?, + None => Keys::generate(), + }; + let spawner = PublicKey::parse(&args.spawner).context("invalid --spawner")?; + + println!("owner : {}", keys.public_key().to_hex()); + println!("spawner : {}", spawner.to_hex()); + println!("slug : {}", args.slug); + println!(); + + let mut conn = NostrWsConnection::connect_authenticated(&args.relay, &keys, None) + .await + .with_context(|| format!("failed to authenticate to {}", args.relay))?; + println!("✓ authenticated to {}", args.relay); + + // Watch for the handshake and for status, before publishing the spec — a + // spawner can react faster than a second round trip. + conn.send_raw(&json!([ + "REQ", "attest", + { "kinds": [KIND_SPAWNER_ATTESTATION], "#p": [keys.public_key().to_hex()] } + ])) + .await?; + conn.send_raw(&json!([ + "REQ", "status", + { "kinds": [KIND_SPAWNER_AGENT_STATUS], "#p": [keys.public_key().to_hex()] } + ])) + .await?; + + // Optionally publish an unshared persona and point the spec at it, to + // exercise the relay's persona read-gate from the spawner's side. + if let Some(persona_id) = &args.persona { + let content = serde_json::json!({ + "display_name": args.name, + "system_prompt": "You are a persona-backed test agent.", + }) + .to_string(); + let mut tags = vec![nostr::Tag::parse(["d", persona_id.as_str()])?]; + if args.share_persona { + tags.push(nostr::Tag::parse(["shared", "true"])?); + } + let event = nostr::EventBuilder::new( + nostr::Kind::Custom(buzz_core::kind::KIND_PERSONA as u16), + content, + ) + .tags(tags) + .sign_with_keys(&keys)?; + let ok = conn.send_event(event).await?; + if !ok.accepted { + bail!("relay rejected the persona: {}", ok.message); + } + println!( + "✓ published persona {persona_id} (shared={})", + args.share_persona + ); + } + + if args.delete { + // An emptied replacement is the tombstone convention for a + // parameterized-replaceable kind; a kind:5 deletion would leave the + // spawner nothing to fan out. Built directly because the SDK builder + // validates the spec body, which a tombstone deliberately lacks. + let event = nostr::EventBuilder::new( + nostr::Kind::Custom(buzz_core::kind::KIND_SPAWNER_AGENT_SPEC as u16), + "", + ) + .tags(vec![ + nostr::Tag::parse(["d", args.slug.as_str()])?, + nostr::Tag::parse(["spawner", spawner.to_hex().as_str()])?, + nostr::Tag::parse(["p", spawner.to_hex().as_str()])?, + ]) + .sign_with_keys(&keys)?; + let ok = conn.send_event(event).await?; + println!( + "✓ published tombstone for {} (accepted={})", + args.slug, ok.accepted + ); + } else { + let event = build_spawner_agent_spec(&args.slug, &spawner.to_hex(), &spec_for(&args))? + .sign_with_keys(&keys)?; + let ok = conn.send_event(event).await?; + if !ok.accepted { + bail!("relay rejected the spec: {}", ok.message); + } + println!("✓ published spec {} → {}", args.slug, spawner.to_hex()); + } + + println!("\nwatching (Ctrl-C to stop)…\n"); + + let mut seen_status: HashMap = HashMap::new(); + loop { + let msg = match conn.next_event(Duration::from_secs(30)).await { + Ok(msg) => msg, + Err(buzz_ws_client::error::WsClientError::Timeout) => continue, + Err(e) => return Err(e.into()), + }; + + let RelayMessage::Event { event, .. } = msg else { + continue; + }; + let kind = event.kind.as_u16() as u32; + + if kind == KIND_SPAWNER_ATTESTATION { + // Skip our own outbound frames echoed back off the ephemeral stream. + if event.pubkey == keys.public_key() { + continue; + } + match handle_attestation(&mut conn, &keys, &event).await { + Ok(Some(agent)) => println!("✓ attested agent {agent}"), + Ok(None) => {} + Err(e) => eprintln!("✗ attestation failed: {e:#}"), + } + continue; + } + + if kind == KIND_SPAWNER_AGENT_STATUS { + let Ok(status) = status_from_event(&event) else { + continue; + }; + let line = format!( + "{:?}{}{}", + status.phase, + status + .agent_pubkey + .as_deref() + .map(|a| format!(" agent={}", &a[..12])) + .unwrap_or_default(), + status + .error + .as_deref() + .map(|e| format!(" error={e}")) + .unwrap_or_default(), + ); + // Status is replaceable; only print genuine transitions. + if seen_status.get(&args.slug) != Some(&line) { + println!(" status: {line}"); + seen_status.insert(args.slug.clone(), line); + } + } + } +} + +/// Answer a spawner's attestation request. Returns the attested agent pubkey. +async fn handle_attestation( + conn: &mut NostrWsConnection, + keys: &Keys, + event: &nostr::Event, +) -> Result> { + let plaintext = + nostr::nips::nip44::decrypt(keys.secret_key(), &event.pubkey, event.content.as_str()) + .context("decrypt")?; + let frame: AttestationFrame = serde_json::from_str(&plaintext).context("parse frame")?; + frame.validate().context("validate frame")?; + + let AttestationFrame::Request { + spec_slug, + agent_pubkey, + conditions, + nonce, + } = frame + else { + // Our own response echoed back, or a reject. Nothing to do. + return Ok(None); + }; + + println!( + "→ attestation request for agent {} (slug {spec_slug})", + &agent_pubkey[..12] + ); + + let agent = PublicKey::parse(&agent_pubkey).context("invalid agent pubkey in request")?; + let auth_tag = nip_oa::compute_auth_tag(keys, &agent, &conditions).context("compute tag")?; + + let response = AttestationFrame::Response { + spec_slug, + agent_pubkey: agent_pubkey.clone(), + nonce, + auth_tag, + // Deliver the prompt over the encrypted channel, which is the whole + // point of the handshake carrying it: the spec stays free of it and the + // agent's instructions never become public. + // Only set when relocating an existing agent; this simulator mints. + private_key_nsec: None, + prompt: Some(buzz_sdk::spawner::PromptMaterial { + system_prompt: Some("You are a test agent running on a Buzz spawner.".into()), + team_instructions: None, + model: None, + provider: None, + }), + }; + let ciphertext = nostr::nips::nip44::encrypt( + keys.secret_key(), + &event.pubkey, + serde_json::to_string(&response)?, + nostr::nips::nip44::Version::V2, + ) + .context("encrypt response")?; + + let out = + build_spawner_attestation(&event.pubkey.to_hex(), &ciphertext)?.sign_with_keys(keys)?; + let ok = conn.send_event(out).await?; + if !ok.accepted { + bail!("relay rejected the attestation response: {}", ok.message); + } + Ok(Some(agent_pubkey)) +} + +fn spec_for(args: &Args) -> SpawnerAgentSpec { + SpawnerAgentSpec { + name: args.name.clone(), + // This simulator always mints a fresh identity rather than relocating + // an existing agent. + agent_pubkey: None, + // Inline prompt rather than a persona id: this simulator has no persona + // store, and a definition-less spec is the path that does not depend on + // the relay's persona read-delegation. + persona_id: args.persona.clone(), + // A persona-backed spec omits the prompt, matching how kind:30177 slims + // against kind:30175 — the spawner must resolve it from the relay. + system_prompt: if args.persona.is_some() { + None + } else { + Some("You are a test agent running on a Buzz spawner.".into()) + }, + model: None, + provider: None, + parallelism: 1, + respond_to: RespondTo::Anyone, + respond_to_allowlist: Vec::new(), + resources: None, + enabled: !args.disabled, + } +} + +struct Args { + relay: String, + spawner: String, + owner_nsec: Option, + slug: String, + name: String, + disabled: bool, + delete: bool, + persona: Option, + share_persona: bool, +} + +impl Args { + fn parse() -> Result { + let mut relay = "ws://127.0.0.1:3000".to_string(); + let mut spawner = None; + let mut owner_nsec = std::env::var("OWNER_NSEC").ok(); + let mut slug = "test-agent".to_string(); + let mut name = "Test Agent".to_string(); + let mut disabled = false; + let mut delete = false; + let mut persona = None; + let mut share_persona = false; + + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--relay" => relay = args.next().context("--relay needs a value")?, + "--spawner" => spawner = Some(args.next().context("--spawner needs a value")?), + "--owner-nsec" => { + owner_nsec = Some(args.next().context("--owner-nsec needs a value")?) + } + "--slug" => slug = args.next().context("--slug needs a value")?, + "--name" => name = args.next().context("--name needs a value")?, + "--disabled" => disabled = true, + "--delete" => delete = true, + "--persona" => persona = Some(args.next().context("--persona needs a value")?), + "--share-persona" => share_persona = true, + other => bail!("unknown argument {other}"), + } + } + + Ok(Self { + relay, + spawner: spawner.context("--spawner is required")?, + owner_nsec, + slug, + name, + disabled, + delete, + persona, + share_persona, + }) + } +} diff --git a/crates/buzz-spawner/src/attestation.rs b/crates/buzz-spawner/src/attestation.rs new file mode 100644 index 0000000000..258637b699 --- /dev/null +++ b/crates/buzz-spawner/src/attestation.rs @@ -0,0 +1,396 @@ +//! The two-round NIP-OA attestation handshake, spawner side. +//! +//! The agent's secret key is minted here and never leaves the host, so the +//! spawner cannot produce its own owner attestation — a NIP-OA auth tag is +//! signed with the *owner's* secret key over the agent's pubkey. The owner must +//! therefore sign a key they did not generate, which they can only do after the +//! spawner tells them what it is. +//! +//! This module is deliberately free of I/O: [`evaluate_response`] is a pure +//! function over a stored record and an inbound frame, so every rejection path +//! below is directly testable. + +use anyhow::{bail, Context, Result}; +use buzz_sdk::nip_oa; +use buzz_sdk::spawner::{AttestationFrame, PromptMaterial, ATTESTATION_NONCE_BYTES}; +use nostr::{Keys, PublicKey}; + +use crate::store::AgentRecord; + +/// Generate a fresh handshake nonce. +pub fn new_nonce() -> String { + let bytes: [u8; ATTESTATION_NONCE_BYTES] = rand::random(); + hex::encode(bytes) +} + +/// What the reconciler should do with an inbound attestation frame. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResponseOutcome { + /// The owner attested. Store `auth_tag` and start the container. + Accept { + /// Verified NIP-OA auth tag, as a JSON array string. + auth_tag: String, + /// Prompt material delivered alongside the tag, if any. + prompt: Option, + /// Secret key of a relocated agent, when the spec named one. + private_key_nsec: Option, + }, + /// The owner explicitly declined. + Rejected { + /// Reason to surface in the status event. + reason: String, + }, +} + +/// Decide what an inbound attestation frame means for `record`. +/// +/// `sender` is the *verified* author of the kind:24201 event, taken from the +/// signed envelope rather than from the frame body — a frame cannot name its own +/// author into being. +/// +/// Every check here exists because skipping it is exploitable: +/// +/// - **Sender is the record's owner.** Otherwise any pubkey could attest an +/// agent to *itself*, and the relay would then admit that agent under an +/// attacker's membership. +/// - **A round is actually in flight.** Without this, a frame replayed after the +/// handshake completes could swap a live agent's auth tag. +/// - **Nonce matches.** Binds the response to this specific request, so a frame +/// captured from an earlier round for the same agent cannot be replayed. +/// - **Agent pubkey matches.** Stops a response for agent A being applied to +/// agent B when an owner has several pending at once. +/// - **The tag actually verifies, and verifies to this owner.** `verify_auth_tag` +/// recovers the owner pubkey from the signature; a tag that verifies to +/// somebody else is a delegation the owner never made. +pub fn evaluate_response( + record: &AgentRecord, + sender: &PublicKey, + frame: &AttestationFrame, +) -> Result { + frame.validate().context("malformed attestation frame")?; + + if sender.to_hex() != record.owner_pubkey { + bail!( + "attestation from {} does not own agent {}", + sender.to_hex(), + record.agent_pubkey + ); + } + + let Some(pending) = record.pending_nonce.as_deref() else { + bail!( + "no attestation round in flight for agent {}", + record.agent_pubkey + ); + }; + if frame.nonce() != pending { + bail!("attestation nonce does not match the pending request"); + } + if frame.agent_pubkey() != record.agent_pubkey { + bail!("attestation agent pubkey does not match the pending request"); + } + if frame.spec_slug() != record.slug { + bail!("attestation spec slug does not match the pending request"); + } + + match frame { + AttestationFrame::Request { .. } => { + bail!("received an attestation request where a response was expected") + } + AttestationFrame::Reject { reason, .. } => Ok(ResponseOutcome::Rejected { + reason: reason + .clone() + .unwrap_or_else(|| "owner declined the attestation".into()), + }), + AttestationFrame::PromptUpdate { .. } => { + // Handled separately: it opens no round, so it has no nonce to + // match and must not be evaluated as a handshake response. + bail!("prompt updates are not attestation responses") + } + AttestationFrame::Response { + auth_tag, + prompt, + private_key_nsec, + .. + } => { + let agent_pubkey = PublicKey::parse(&record.agent_pubkey) + .context("stored agent pubkey is not a valid public key")?; + let recovered = nip_oa::verify_auth_tag(auth_tag, &agent_pubkey) + .context("auth tag failed NIP-OA verification")?; + if recovered != *sender { + bail!( + "auth tag verifies to {} but was sent by {}", + recovered.to_hex(), + sender.to_hex() + ); + } + // A delivered key must actually be the identity we asked about, + // or the spawner would run a different agent than the owner named + // and than the auth tag attests. + if let Some(nsec) = private_key_nsec { + let delivered = + Keys::parse(nsec).context("delivered private key is not a valid secret key")?; + if delivered.public_key() != agent_pubkey { + bail!( + "delivered key is for {}, but this handshake is about {}", + delivered.public_key().to_hex(), + record.agent_pubkey + ); + } + } + + Ok(ResponseOutcome::Accept { + auth_tag: auth_tag.clone(), + prompt: prompt.clone(), + private_key_nsec: private_key_nsec.clone(), + }) + } + } +} + +/// Whether a pending attestation request has aged past `timeout_secs`. +pub fn is_attestation_expired(record: &AgentRecord, now: i64, timeout_secs: i64) -> bool { + match record.attestation_sent_at { + Some(sent_at) => now.saturating_sub(sent_at) > timeout_secs, + // A record with a pending nonce but no timestamp predates this field; + // treat it as expired so it is re-requested rather than stuck forever. + None => record.pending_nonce.is_some(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::Keys; + + struct Fixture { + owner: Keys, + agent: Keys, + record: AgentRecord, + nonce: String, + } + + fn fixture() -> Fixture { + let owner = Keys::generate(); + let agent = Keys::generate(); + let nonce = new_nonce(); + let record = AgentRecord { + slug: "fizz-prod".into(), + owner_pubkey: owner.public_key().to_hex(), + agent_pubkey: agent.public_key().to_hex(), + private_key_nsec: "nsec1placeholder".into(), + auth_tag: None, + pending_nonce: Some(nonce.clone()), + attestation_sent_at: Some(1_000), + spec_hash: None, + prompt: None, + restart_count: 0, + last_failure_at: None, + }; + Fixture { + owner, + agent, + record, + nonce, + } + } + + fn response(f: &Fixture, signer: &Keys, nonce: &str) -> AttestationFrame { + let auth_tag = nip_oa::compute_auth_tag(signer, &f.agent.public_key(), "").unwrap(); + AttestationFrame::Response { + spec_slug: f.record.slug.clone(), + agent_pubkey: f.agent.public_key().to_hex(), + nonce: nonce.to_string(), + auth_tag, + prompt: None, + private_key_nsec: None, + } + } + + #[test] + fn accepts_a_well_formed_response_from_the_owner() { + let f = fixture(); + let frame = response(&f, &f.owner, &f.nonce); + let outcome = evaluate_response(&f.record, &f.owner.public_key(), &frame).unwrap(); + assert!(matches!(outcome, ResponseOutcome::Accept { .. })); + } + + #[test] + fn rejects_attestation_from_a_non_owner() { + // The core attack: a stranger attesting somebody else's agent to + // themselves, which would admit that agent under the stranger's + // relay membership. + let f = fixture(); + let attacker = Keys::generate(); + let frame = response(&f, &attacker, &f.nonce); + let err = evaluate_response(&f.record, &attacker.public_key(), &frame).unwrap_err(); + assert!(err.to_string().contains("does not own agent")); + } + + #[test] + fn rejects_a_tag_that_verifies_to_someone_other_than_the_sender() { + // Owner sends the frame, but the tag inside was signed by a third + // party. Checking only the sender would let this through. + let f = fixture(); + let third_party = Keys::generate(); + let frame = response(&f, &third_party, &f.nonce); + let err = evaluate_response(&f.record, &f.owner.public_key(), &frame).unwrap_err(); + assert!(err.to_string().contains("verifies to")); + } + + #[test] + fn accepts_a_relocated_agents_own_key() { + // Moving an existing agent to a spawner: the owner delivers the key + // whose pubkey the handshake is already about. + let f = fixture(); + let nsec = { + use nostr::nips::nip19::ToBech32; + f.agent.secret_key().to_bech32().unwrap() + }; + let auth_tag = nip_oa::compute_auth_tag(&f.owner, &f.agent.public_key(), "").unwrap(); + let frame = AttestationFrame::Response { + spec_slug: f.record.slug.clone(), + agent_pubkey: f.agent.public_key().to_hex(), + nonce: f.nonce.clone(), + auth_tag, + prompt: None, + private_key_nsec: Some(nsec.clone()), + }; + let outcome = evaluate_response(&f.record, &f.owner.public_key(), &frame).unwrap(); + assert_eq!( + outcome, + ResponseOutcome::Accept { + auth_tag: match &frame { + AttestationFrame::Response { auth_tag, .. } => auth_tag.clone(), + _ => unreachable!(), + }, + prompt: None, + private_key_nsec: Some(nsec), + } + ); + } + + #[test] + fn rejects_a_key_for_a_different_agent() { + // Without this the spawner would run an identity the owner never named + // and the auth tag does not attest — a confused-deputy handover. + let f = fixture(); + let other = Keys::generate(); + let nsec = { + use nostr::nips::nip19::ToBech32; + other.secret_key().to_bech32().unwrap() + }; + let auth_tag = nip_oa::compute_auth_tag(&f.owner, &f.agent.public_key(), "").unwrap(); + let frame = AttestationFrame::Response { + spec_slug: f.record.slug.clone(), + agent_pubkey: f.agent.public_key().to_hex(), + nonce: f.nonce.clone(), + auth_tag, + prompt: None, + private_key_nsec: Some(nsec), + }; + let err = evaluate_response(&f.record, &f.owner.public_key(), &frame).unwrap_err(); + assert!(err.to_string().contains("delivered key is for")); + } + + #[test] + fn rejects_a_malformed_delivered_key() { + let f = fixture(); + let auth_tag = nip_oa::compute_auth_tag(&f.owner, &f.agent.public_key(), "").unwrap(); + let frame = AttestationFrame::Response { + spec_slug: f.record.slug.clone(), + agent_pubkey: f.agent.public_key().to_hex(), + nonce: f.nonce.clone(), + auth_tag, + prompt: None, + private_key_nsec: Some("not-an-nsec".into()), + }; + assert!(evaluate_response(&f.record, &f.owner.public_key(), &frame).is_err()); + } + + #[test] + fn rejects_a_replayed_nonce() { + let f = fixture(); + let frame = response(&f, &f.owner, &new_nonce()); + let err = evaluate_response(&f.record, &f.owner.public_key(), &frame).unwrap_err(); + assert!(err.to_string().contains("nonce does not match")); + } + + #[test] + fn rejects_a_frame_when_no_round_is_in_flight() { + // Guards a completed agent against a late replay swapping its auth tag. + let mut f = fixture(); + let frame = response(&f, &f.owner, &f.nonce); + f.record.pending_nonce = None; + let err = evaluate_response(&f.record, &f.owner.public_key(), &frame).unwrap_err(); + assert!(err.to_string().contains("no attestation round in flight")); + } + + #[test] + fn rejects_a_response_aimed_at_a_different_agent() { + let f = fixture(); + let other_agent = Keys::generate(); + let auth_tag = nip_oa::compute_auth_tag(&f.owner, &other_agent.public_key(), "").unwrap(); + let frame = AttestationFrame::Response { + spec_slug: f.record.slug.clone(), + agent_pubkey: other_agent.public_key().to_hex(), + nonce: f.nonce.clone(), + auth_tag, + prompt: None, + private_key_nsec: None, + }; + let err = evaluate_response(&f.record, &f.owner.public_key(), &frame).unwrap_err(); + assert!(err.to_string().contains("agent pubkey does not match")); + } + + #[test] + fn rejects_a_garbage_auth_tag() { + let f = fixture(); + let frame = AttestationFrame::Response { + spec_slug: f.record.slug.clone(), + agent_pubkey: f.agent.public_key().to_hex(), + nonce: f.nonce.clone(), + auth_tag: r#"["auth","not","a","tag"]"#.into(), + prompt: None, + private_key_nsec: None, + }; + assert!(evaluate_response(&f.record, &f.owner.public_key(), &frame).is_err()); + } + + #[test] + fn surfaces_an_explicit_rejection() { + let f = fixture(); + let frame = AttestationFrame::Reject { + spec_slug: f.record.slug.clone(), + agent_pubkey: f.agent.public_key().to_hex(), + nonce: f.nonce.clone(), + reason: Some("unknown spawner".into()), + }; + let outcome = evaluate_response(&f.record, &f.owner.public_key(), &frame).unwrap(); + assert_eq!( + outcome, + ResponseOutcome::Rejected { + reason: "unknown spawner".into() + } + ); + } + + #[test] + fn expiry_needs_both_a_pending_round_and_elapsed_time() { + let mut f = fixture(); + assert!(!is_attestation_expired(&f.record, 1_100, 600)); + assert!(is_attestation_expired(&f.record, 2_000, 600)); + + f.record.pending_nonce = None; + f.record.attestation_sent_at = None; + assert!(!is_attestation_expired(&f.record, 9_999, 600)); + } + + #[test] + fn nonces_are_full_width_and_distinct() { + let a = new_nonce(); + let b = new_nonce(); + assert_eq!(a.len(), ATTESTATION_NONCE_BYTES * 2); + assert_ne!(a, b); + } +} diff --git a/crates/buzz-spawner/src/config.rs b/crates/buzz-spawner/src/config.rs new file mode 100644 index 0000000000..b2891fee7a --- /dev/null +++ b/crates/buzz-spawner/src/config.rs @@ -0,0 +1,230 @@ +//! Environment-driven configuration for the spawner daemon. + +use std::{path::PathBuf, time::Duration}; + +use anyhow::{bail, Context, Result}; +use nostr::Keys; + +/// Default agent runtime image. Overridden with `BUZZ_SPAWNER_AGENT_IMAGE`. +pub const DEFAULT_AGENT_IMAGE: &str = "ghcr.io/block/buzz-acp:main"; + +/// Default per-agent CPU allocation, in thousandths of a core. +pub const DEFAULT_CPU_MILLIS: u32 = 1000; + +/// Default per-agent memory limit, in mebibytes. +pub const DEFAULT_MEMORY_MIB: u32 = 2048; + +/// Docker label carrying the agent pubkey a container belongs to. +pub const AGENT_LABEL: &str = "com.buzz.agent"; + +/// Docker label carrying the spec slug a container reconciles. +pub const SLUG_LABEL: &str = "com.buzz.spec-slug"; + +/// Docker label carrying the spawner pubkey that owns a container. Two spawners +/// sharing one Docker host must not reap each other's containers. +pub const SPAWNER_LABEL: &str = "com.buzz.spawner"; + +/// Runtime configuration. +pub struct Config { + /// The spawner's own Nostr identity. Used to authenticate to the relay, to + /// receive attestation frames, and to author status events. + pub keys: Keys, + /// Relay WebSocket URL the spawner itself connects to. In a compose + /// deployment this is the internal address (`ws://relay:3000`). + pub relay_url: String, + /// Relay WebSocket URL handed to agent containers. + /// + /// Deliberately separate from [`Self::relay_url`]. Agent containers are not + /// attached to the relay's internal compose network — an agent runs + /// arbitrary shell tools, and joining that network would put Postgres, + /// Redis, and MinIO one `nc` away. So agents reach the relay by its public + /// address instead, the same way any other client does. + pub agent_relay_url: String, + /// Directory holding the state file and per-agent secrets. Must be on a + /// persistent volume — losing it orphans every running agent's identity. + pub state_dir: PathBuf, + /// Container image running the `buzz-acp` harness. + pub agent_image: String, + /// Ceiling applied to a spec's requested CPU. Specs above it are clamped. + pub max_cpu_millis: u32, + /// Ceiling applied to a spec's requested memory. Specs above it are clamped. + pub max_memory_mib: u32, + /// Maximum number of agents this spawner will run at once. + pub max_agents: usize, + /// How long to wait for an owner to answer an attestation request before + /// giving up and reporting failure. + pub attestation_timeout: Duration, + /// How often to reconcile even when no event has arrived, so a container + /// that died out-of-band is noticed. + pub reconcile_interval: Duration, + /// ACP agent binary the harness spawns inside each container. + /// + /// Operator-only, deliberately. This is a code-execution surface: the + /// desktop lists `BUZZ_ACP_AGENT_COMMAND` among its reserved env keys for + /// exactly that reason. It must never be settable from a kind:30178 spec, + /// which is owner-authored and world-readable — a spec that could name the + /// binary would let anyone who can publish one choose what runs on the host. + /// + /// `None` leaves the image's own default (`buzz-agent`, API-key based). Set + /// `claude-agent-acp` to run agents on a Claude subscription. + pub agent_command: Option, + /// Comma-separated args for [`Self::agent_command`], same rationale. + pub agent_args: Option, + /// Display name advertised in this spawner's announcement. + /// + /// Defaults to the hostname, because "prod-vps" is a far better thing to + /// choose from in a picker than 64 hex characters. + pub name: String, + /// Longer description shown alongside the name. + pub description: Option, + /// Provider used when a spec does not name one. + /// + /// The `buzz-agent` harness refuses to start without `BUZZ_AGENT_PROVIDER`, + /// and a spec is allowed to omit it. The desktop resolves the same gap from + /// its global agent settings (`resolve_deploy_model_provider`); a spawner + /// has no such settings, so the operator supplies the host default here. + pub default_provider: Option, + /// Model used when a spec does not name one. + pub default_model: Option, + /// Environment passed through to every agent container — LLM credentials + /// live here, never in an event. Collected from `BUZZ_SPAWNER_AGENT_ENV`. + pub agent_env: Vec<(String, String)>, +} + +impl Config { + /// Load configuration from the process environment. + pub fn from_env() -> Result { + let nsec = require_env("BUZZ_SPAWNER_NSEC")?; + let keys = Keys::parse(nsec.trim()) + .context("BUZZ_SPAWNER_NSEC is not a valid nsec or hex secret key")?; + + let relay_url = require_env("BUZZ_SPAWNER_RELAY_URL")?; + check_ws_url("BUZZ_SPAWNER_RELAY_URL", &relay_url)?; + + // Defaulting to the spawner's own relay URL is correct for a single-host + // setup where both addresses are the same, and wrong for compose, where + // `ws://relay:3000` does not resolve from the Docker bridge. The compose + // bundle sets it explicitly; this default keeps a bare `cargo run` + // working. + let agent_relay_url = + std::env::var("BUZZ_SPAWNER_AGENT_RELAY_URL").unwrap_or_else(|_| relay_url.clone()); + check_ws_url("BUZZ_SPAWNER_AGENT_RELAY_URL", &agent_relay_url)?; + + let state_dir = PathBuf::from( + std::env::var("BUZZ_SPAWNER_STATE_DIR") + .unwrap_or_else(|_| "/var/lib/buzz-spawner".into()), + ); + + Ok(Self { + keys, + relay_url, + agent_relay_url, + state_dir, + agent_image: std::env::var("BUZZ_SPAWNER_AGENT_IMAGE") + .unwrap_or_else(|_| DEFAULT_AGENT_IMAGE.into()), + max_cpu_millis: parse_env("BUZZ_SPAWNER_MAX_CPU_MILLIS", 4000)?, + max_memory_mib: parse_env("BUZZ_SPAWNER_MAX_MEMORY_MIB", 8192)?, + max_agents: parse_env("BUZZ_SPAWNER_MAX_AGENTS", 16)?, + attestation_timeout: Duration::from_secs(parse_env( + "BUZZ_SPAWNER_ATTESTATION_TIMEOUT_SECS", + 600, + )?), + reconcile_interval: Duration::from_secs(parse_env( + "BUZZ_SPAWNER_RECONCILE_INTERVAL_SECS", + 60, + )?), + agent_command: non_empty_env("BUZZ_SPAWNER_AGENT_COMMAND"), + agent_args: non_empty_env("BUZZ_SPAWNER_AGENT_ARGS"), + name: non_empty_env("BUZZ_SPAWNER_NAME").unwrap_or_else(default_name), + description: non_empty_env("BUZZ_SPAWNER_DESCRIPTION"), + default_provider: non_empty_env("BUZZ_SPAWNER_DEFAULT_PROVIDER"), + default_model: non_empty_env("BUZZ_SPAWNER_DEFAULT_MODEL"), + agent_env: parse_agent_env( + &std::env::var("BUZZ_SPAWNER_AGENT_ENV").unwrap_or_default(), + )?, + }) + } +} + +fn check_ws_url(key: &str, url: &str) -> Result<()> { + if !url.starts_with("ws://") && !url.starts_with("wss://") { + bail!("{key} must start with ws:// or wss://"); + } + Ok(()) +} + +/// Best-effort hostname for the default advertised name. +fn default_name() -> String { + std::env::var("HOSTNAME") + .ok() + .filter(|h| !h.trim().is_empty()) + .unwrap_or_else(|| "buzz-spawner".to_string()) +} + +/// Read an optional env var, treating an empty value as absent. +fn non_empty_env(key: &str) -> Option { + std::env::var(key) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +fn require_env(key: &str) -> Result { + std::env::var(key).with_context(|| format!("{key} is required")) +} + +fn parse_env(key: &str, default: T) -> Result +where + T::Err: std::fmt::Display, +{ + match std::env::var(key) { + Ok(raw) => raw + .trim() + .parse() + .map_err(|e| anyhow::anyhow!("{key} is not a valid value: {e}")), + Err(_) => Ok(default), + } +} + +/// Parse `BUZZ_SPAWNER_AGENT_ENV`, a comma-separated list of variable *names* +/// to forward from the spawner's own environment into agent containers. +/// +/// Names, not `KEY=VALUE` pairs: this keeps secrets out of the compose file's +/// inline environment block and out of `docker inspect` on the spawner, and it +/// means a rotated credential is picked up by restarting the spawner rather than +/// by editing config. A named variable that is unset in the spawner's +/// environment is a hard error rather than a silent omission — an agent that +/// boots without its API key fails deep inside the harness with a confusing +/// message, so it is better to refuse at startup. +fn parse_agent_env(raw: &str) -> Result> { + let mut out = Vec::new(); + for name in raw.split(',').map(str::trim).filter(|n| !n.is_empty()) { + if !name + .chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') + { + bail!("BUZZ_SPAWNER_AGENT_ENV entry {name:?} is not a valid environment variable name"); + } + let value = std::env::var(name) + .with_context(|| format!("BUZZ_SPAWNER_AGENT_ENV names {name}, but it is not set"))?; + out.push((name.to_string(), value)); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn agent_env_rejects_malformed_names() { + assert!(parse_agent_env("not-a-var").is_err()); + assert!(parse_agent_env("ANTHROPIC_API_KEY=inline-secret").is_err()); + } + + #[test] + fn agent_env_is_empty_when_unset() { + assert!(parse_agent_env("").unwrap().is_empty()); + assert!(parse_agent_env(" , ").unwrap().is_empty()); + } +} diff --git a/crates/buzz-spawner/src/container.rs b/crates/buzz-spawner/src/container.rs new file mode 100644 index 0000000000..cc3b2123a4 --- /dev/null +++ b/crates/buzz-spawner/src/container.rs @@ -0,0 +1,398 @@ +//! Container lifecycle: the [`ContainerOps`] boundary and its Docker backend. +//! +//! Agents run shell and file-edit tools through `buzz-dev-mcp`, which is +//! arbitrary code execution by design. Running several of them as plain +//! subprocesses of one daemon would let any agent read every other agent's +//! workspace and the spawner's own state file — including every other agent's +//! secret key. So each agent gets its own container, its own volume, and its own +//! resource ceiling. +//! +//! Everything that touches Docker sits behind [`ContainerOps`] so the reconciler +//! can be tested against an in-memory fake. + +use std::collections::HashMap; +use std::time::Duration; + +use anyhow::{Context, Result}; +use async_trait::async_trait; + +use crate::config::{AGENT_LABEL, SLUG_LABEL, SPAWNER_LABEL}; + +/// A container the spawner is responsible for. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManagedContainer { + /// Docker container id. + pub id: String, + /// Container name. + pub name: String, + /// Agent pubkey from the `com.buzz.agent` label. + pub agent_pubkey: String, + /// Spec slug from the `com.buzz.spec-slug` label. + pub slug: String, + /// Whether the container is currently running. + pub running: bool, +} + +/// Everything needed to create one agent container. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContainerSpec { + /// Container name. + pub name: String, + /// Image reference. + pub image: String, + /// Agent pubkey, applied as a label for reconciliation. + pub agent_pubkey: String, + /// Spec slug, applied as a label. + pub slug: String, + /// Spawner pubkey, applied as a label so co-tenant spawners on one host do + /// not reap each other's containers. + pub spawner_pubkey: String, + /// Full environment for the harness process. + pub env: Vec<(String, String)>, + /// CPU allocation in thousandths of a core. + pub cpu_millis: u32, + /// Memory limit in mebibytes. + pub memory_mib: u32, + /// Named volume mounted at the agent's nest directory. + pub volume_name: String, +} + +impl ContainerSpec { + /// Docker labels identifying this container's owner and purpose. + pub fn labels(&self) -> HashMap { + HashMap::from([ + (AGENT_LABEL.to_string(), self.agent_pubkey.clone()), + (SLUG_LABEL.to_string(), self.slug.clone()), + (SPAWNER_LABEL.to_string(), self.spawner_pubkey.clone()), + ]) + } +} + +/// The container operations the reconciler needs. +#[async_trait] +pub trait ContainerOps: Send + Sync { + /// List every container labelled as belonging to `spawner_pubkey`, + /// running or not. + async fn list(&self, spawner_pubkey: &str) -> Result>; + + /// Create and start a container. + async fn create(&self, spec: &ContainerSpec) -> Result; + + /// Stop and remove a container, along with its volume when `purge_volume` + /// is set. Removing a container the caller believes is gone is not an error. + async fn remove(&self, container_id: &str, purge_volume: Option<&str>) -> Result<()>; + + /// Tail the last `lines` of a container's logs. + async fn logs(&self, container_id: &str, lines: usize) -> Result; +} + +/// Timeout for a container listing. +/// +/// Every Docker call is time-boxed. bollard will wait indefinitely on a +/// half-open socket, and the reconcile loop awaits these calls inline — so one +/// stalled request (a daemon restart, a laptop resuming from sleep) hangs the +/// entire daemon *permanently and silently*: no ticker, no relay reads, no +/// status updates, every agent frozen in whatever phase it was in. A timeout +/// turns that into an error the loop logs and retries on the next pass. +const LIST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Timeout for creating a container. Generous because it may pull an image. +const CREATE_TIMEOUT: Duration = Duration::from_secs(600); + +/// Timeout for removing a container and optionally its volume. +const REMOVE_TIMEOUT: Duration = Duration::from_secs(120); + +/// Timeout for reading container logs. +const LOGS_TIMEOUT: Duration = Duration::from_secs(30); + +/// Run a Docker operation under a deadline, naming it in the error. +async fn with_timeout( + what: &str, + limit: Duration, + op: impl std::future::Future>, +) -> Result { + match tokio::time::timeout(limit, op).await { + Ok(result) => result, + Err(_) => anyhow::bail!( + "docker {what} timed out after {}s; the daemon may be unreachable", + limit.as_secs() + ), + } +} + +/// The Docker-socket backend. +pub struct DockerOps { + docker: bollard::Docker, +} + +impl DockerOps { + /// Connect to the local Docker daemon over its Unix socket (or the platform + /// default), honoring `DOCKER_HOST` when set. + pub fn connect() -> Result { + let docker = bollard::Docker::connect_with_defaults() + .context("failed to connect to the Docker daemon")?; + Ok(Self { docker }) + } +} + +#[async_trait] +impl ContainerOps for DockerOps { + async fn list(&self, spawner_pubkey: &str) -> Result> { + with_timeout("list", LIST_TIMEOUT, self.list_inner(spawner_pubkey)).await + } + + async fn create(&self, spec: &ContainerSpec) -> Result { + with_timeout("create", CREATE_TIMEOUT, self.create_inner(spec)).await + } + + async fn remove(&self, container_id: &str, purge_volume: Option<&str>) -> Result<()> { + with_timeout( + "remove", + REMOVE_TIMEOUT, + self.remove_inner(container_id, purge_volume), + ) + .await + } + + async fn logs(&self, container_id: &str, lines: usize) -> Result { + with_timeout("logs", LOGS_TIMEOUT, self.logs_inner(container_id, lines)).await + } +} + +impl DockerOps { + async fn list_inner(&self, spawner_pubkey: &str) -> Result> { + use bollard::query_parameters::ListContainersOptions; + + let mut filters = HashMap::new(); + filters.insert( + "label".to_string(), + vec![format!("{SPAWNER_LABEL}={spawner_pubkey}")], + ); + + let containers = self + .docker + .list_containers(Some(ListContainersOptions { + all: true, + filters: Some(filters), + ..Default::default() + })) + .await + .context("failed to list agent containers")?; + + Ok(containers + .into_iter() + .filter_map(|c| { + let labels = c.labels.unwrap_or_default(); + Some(ManagedContainer { + id: c.id?, + name: c + .names + .and_then(|n| n.first().cloned()) + .unwrap_or_default() + .trim_start_matches('/') + .to_string(), + // A container carrying our spawner label but missing the + // agent label is malformed; skipping it is safer than + // guessing an identity for something we would then act on. + agent_pubkey: labels.get(AGENT_LABEL)?.clone(), + slug: labels.get(SLUG_LABEL).cloned().unwrap_or_default(), + running: c.state.as_ref().is_some_and(|s| { + matches!(s, bollard::models::ContainerSummaryStateEnum::RUNNING) + }), + }) + }) + .collect()) + } + + async fn create_inner(&self, spec: &ContainerSpec) -> Result { + use bollard::models::{ContainerCreateBody, HostConfig, Mount, MountType}; + use bollard::query_parameters::{ + CreateContainerOptions, CreateImageOptionsBuilder, StartContainerOptions, + }; + use futures_util::StreamExt; + + // Pull so a missing image surfaces as a clear status error rather than a + // create failure the owner cannot interpret. + // + // A pull failure is only fatal when the image is also absent locally. An + // operator running a locally-built image — an air-gapped host, a + // pre-loaded tarball, or an image built on the box itself — has nothing + // to pull from, and failing there would make those setups impossible. + // Checking local presence second also means a transient registry outage + // does not stop an already-cached image from starting. + let mut pull = self.docker.create_image( + Some( + CreateImageOptionsBuilder::default() + .from_image(&spec.image) + .build(), + ), + None, + None, + ); + let mut pull_error: Option = None; + while let Some(chunk) = pull.next().await { + if let Err(e) = chunk { + pull_error = Some(e); + break; + } + } + if let Some(e) = pull_error { + if self.docker.inspect_image(&spec.image).await.is_err() { + return Err(e).with_context(|| { + format!( + "failed to pull image {} and it is not present locally", + spec.image + ) + }); + } + tracing::debug!( + image = %spec.image, + "image pull failed but a local copy exists; using it: {e}" + ); + } + + let host_config = HostConfig { + // NanoCPUs is billionths of a core; cpu_millis is thousandths. + nano_cpus: Some(i64::from(spec.cpu_millis) * 1_000_000), + memory: Some(i64::from(spec.memory_mib) * 1024 * 1024), + mounts: Some(vec![Mount { + target: Some("/home/agent/.buzz".to_string()), + source: Some(spec.volume_name.clone()), + typ: Some(MountType::VOLUME), + ..Default::default() + }]), + restart_policy: None, + ..Default::default() + }; + + let body = ContainerCreateBody { + image: Some(spec.image.clone()), + env: Some( + spec.env + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>(), + ), + labels: Some(spec.labels()), + host_config: Some(host_config), + ..Default::default() + }; + + let created = self + .docker + .create_container( + Some(CreateContainerOptions { + name: Some(spec.name.clone()), + ..Default::default() + }), + body, + ) + .await + .with_context(|| format!("failed to create container {}", spec.name))?; + + self.docker + .start_container(&created.id, None::) + .await + .with_context(|| format!("failed to start container {}", spec.name))?; + + Ok(created.id) + } + + async fn remove_inner(&self, container_id: &str, purge_volume: Option<&str>) -> Result<()> { + use bollard::query_parameters::{RemoveContainerOptions, RemoveVolumeOptions}; + + // force stops a running container; the reconciler's intent is "gone", + // and a graceful stop is the harness's job via the shutdown convention. + let removed = self + .docker + .remove_container( + container_id, + Some(RemoveContainerOptions { + force: true, + v: true, + ..Default::default() + }), + ) + .await; + + // A container that is already gone is the desired state, not an error. + if let Err(e) = removed { + if !is_not_found(&e) { + return Err(e) + .with_context(|| format!("failed to remove container {container_id}")); + } + } + + if let Some(volume) = purge_volume { + let removed = self + .docker + .remove_volume(volume, None::) + .await; + if let Err(e) = removed { + if !is_not_found(&e) { + return Err(e).with_context(|| format!("failed to remove volume {volume}")); + } + } + } + + Ok(()) + } + + async fn logs_inner(&self, container_id: &str, lines: usize) -> Result { + use bollard::query_parameters::LogsOptionsBuilder; + use futures_util::StreamExt; + + let mut stream = self.docker.logs( + container_id, + Some( + LogsOptionsBuilder::default() + .stdout(true) + .stderr(true) + .tail(&lines.to_string()) + .build(), + ), + ); + + let mut out = String::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.with_context(|| format!("failed to read logs for {container_id}"))?; + out.push_str(&chunk.to_string()); + } + Ok(out) + } +} + +fn is_not_found(e: &bollard::errors::Error) -> bool { + matches!( + e, + bollard::errors::Error::DockerResponseServerError { + status_code: 404, + .. + } + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn labels_carry_agent_slug_and_spawner() { + let spec = ContainerSpec { + name: "buzz-agent-abc-fizz".into(), + image: "img".into(), + agent_pubkey: "a".repeat(64), + slug: "fizz".into(), + spawner_pubkey: "s".repeat(64), + env: vec![], + cpu_millis: 1000, + memory_mib: 2048, + volume_name: "vol".into(), + }; + let labels = spec.labels(); + assert_eq!(labels.get(AGENT_LABEL), Some(&"a".repeat(64))); + assert_eq!(labels.get(SLUG_LABEL), Some(&"fizz".to_string())); + // Without this, two spawners on one host reap each other's containers. + assert_eq!(labels.get(SPAWNER_LABEL), Some(&"s".repeat(64))); + } +} diff --git a/crates/buzz-spawner/src/daemon.rs b/crates/buzz-spawner/src/daemon.rs new file mode 100644 index 0000000000..2f8d9c7f4b --- /dev/null +++ b/crates/buzz-spawner/src/daemon.rs @@ -0,0 +1,851 @@ +//! The reconcile loop: applies [`crate::reconcile::plan`] actions against the +//! relay, the store, and the container backend. + +use std::{collections::HashMap, sync::Arc}; + +use anyhow::{Context, Result}; +use buzz_sdk::spawner::{SpawnPhase, SpawnerAgentSpec, SpawnerAgentStatus, SpawnerAnnouncement}; +use nostr::{Keys, PublicKey}; +use tracing::{error, info, warn}; + +use crate::{ + attestation::{self, ResponseOutcome}, + config::{Config, DEFAULT_CPU_MILLIS, DEFAULT_MEMORY_MIB}, + container::{ContainerOps, ContainerSpec}, + env::{build_agent_env, AgentRuntime, ResolvedPrompt}, + reconcile::{plan, Action, DesiredAgent, ReconcileInput}, + relay::{Inbound, SpawnerRelay}, + store::{AgentRecord, Store}, +}; + +/// Long-running spawner daemon. +pub struct Daemon { + config: Config, + store: Store, + relay: SpawnerRelay, + containers: Arc, + /// Latest spec per `(owner, slug)`, the desired state built from the relay. + desired: HashMap<(String, String), DesiredAgent>, + /// Newest `created_at` seen per `(owner, slug)`. + /// + /// Kind 30178 is replaceable, but the relay retains superseded revisions and + /// replays them in an order this client does not control. Without this + /// guard an older revision silently overwrites a newer one — which loses a + /// relocation request, and the spawner keeps running the identity it minted + /// instead of the agent the owner asked it to adopt. + spec_seen_at: HashMap<(String, String), u64>, + /// Whether the relay has finished replaying stored specs. + /// + /// Until then `desired` is incomplete, and an absent spec must not be read + /// as a deletion — see [`ReconcileInput::desired_hydrated`]. + desired_hydrated: bool, +} + +impl Daemon { + /// Connect to the relay and open the state store. + pub async fn start(config: Config, containers: Arc) -> Result { + let store = Store::open(&config.state_dir)?; + let relay = SpawnerRelay::connect(&config.relay_url, &config.keys).await?; + + info!( + spawner = %config.keys.public_key().to_hex(), + relay = %config.relay_url, + "buzz-spawner connected" + ); + + Ok(Self { + config, + store, + relay, + containers, + desired: HashMap::new(), + spec_seen_at: HashMap::new(), + desired_hydrated: false, + }) + } + + /// Run until cancelled. + pub async fn run(&mut self, shutdown: tokio::sync::watch::Receiver) -> Result<()> { + let mut ticker = tokio::time::interval(self.config.reconcile_interval); + let mut shutdown = shutdown; + + loop { + tokio::select! { + _ = shutdown.changed() => { + if *shutdown.borrow() { + info!("shutdown requested; leaving agent containers running"); + return Ok(()); + } + } + _ = ticker.tick() => { + if let Err(e) = self.reconcile().await { + error!("reconcile pass failed: {e:#}"); + } + } + inbound = self.relay.next() => { + match inbound { + Ok(inbound) => { + if let Err(e) = self.handle_inbound(inbound).await { + warn!("failed to handle relay frame: {e:#}"); + } + } + Err(e) => { + // A dropped socket is expected over a long run. + // Reconnect rather than exiting: agent containers + // keep serving while the control plane recovers. + warn!("relay stream error, reconnecting: {e:#}"); + if let Err(e) = self.relay.reconnect().await { + error!("reconnect failed: {e:#}"); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + } + } + } + } + } + } + + async fn handle_inbound(&mut self, inbound: Inbound) -> Result<()> { + match inbound { + Inbound::Idle => Ok(()), + Inbound::SpecsHydrated => { + if !self.desired_hydrated { + info!( + specs = self.desired.len(), + "desired state hydrated; deletions now enabled" + ); + self.desired_hydrated = true; + } + self.reconcile().await + } + Inbound::Spec { + owner_pubkey, + desired, + created_at, + } => { + let key = (owner_pubkey, desired.slug.clone()); + if !self.accept_revision(&key, created_at) { + return Ok(()); + } + self.desired.insert(key, desired); + self.reconcile().await + } + Inbound::SpecDeleted { + owner_pubkey, + slug, + created_at, + } => { + let key = (owner_pubkey, slug); + if !self.accept_revision(&key, created_at) { + return Ok(()); + } + self.desired.remove(&key); + self.reconcile().await + } + Inbound::Attestation { sender, frame } => { + if let buzz_sdk::spawner::AttestationFrame::PromptUpdate { + agent_pubkey, + prompt, + .. + } = &frame + { + return self + .apply_prompt_update(&sender, agent_pubkey, prompt) + .await; + } + self.apply_attestation(&sender, &frame).await + } + } + } + + async fn apply_attestation( + &mut self, + sender: &PublicKey, + frame: &buzz_sdk::spawner::AttestationFrame, + ) -> Result<()> { + let Some(record) = self + .store + .find_by_agent_pubkey(frame.agent_pubkey()) + .cloned() + else { + // Not ours, or already torn down. Silently ignoring is correct: + // anyone can address a frame to this spawner. + return Ok(()); + }; + + match attestation::evaluate_response(&record, sender, frame) { + Ok(ResponseOutcome::Accept { + auth_tag, + prompt, + private_key_nsec, + }) => { + info!( + slug = %record.slug, + agent = %record.agent_pubkey, + "attestation accepted" + ); + self.store.update(&record.owner_pubkey, &record.slug, |r| { + r.auth_tag = Some(auth_tag); + r.pending_nonce = None; + r.attestation_sent_at = None; + // Only overwrite when the owner actually sent something — + // a shared-persona deployment sends no prompt, and must not + // clear one delivered earlier. + if prompt.as_ref().is_some_and(|p| !p.is_empty()) { + r.prompt = prompt; + } + // A relocated agent arrives with its own key. Verified + // against the attested pubkey in `evaluate_response`, so by + // here it is known to be the right identity. + if let Some(nsec) = private_key_nsec { + r.private_key_nsec = nsec; + } + })?; + self.reconcile().await + } + Ok(ResponseOutcome::Rejected { reason }) => { + warn!(slug = %record.slug, "owner declined attestation: {reason}"); + self.store.update(&record.owner_pubkey, &record.slug, |r| { + r.pending_nonce = None; + r.attestation_sent_at = None; + })?; + self.publish_status( + &record.slug, + &record.owner_pubkey, + SpawnPhase::Failed, + Some(&record.agent_pubkey), + None, + Some(format!("owner declined attestation: {reason}")), + 0, + ) + .await + } + Err(e) => { + // Rejected frames are not an error condition for the daemon — + // anyone can send one. Log and carry on. + warn!( + slug = %record.slug, + sender = %sender.to_hex(), + "rejected attestation frame: {e:#}" + ); + Ok(()) + } + } + } + + /// Whether a spec revision is newer than what is already held. + /// + /// Ties are accepted: two revisions can share a second, and refusing both + /// would strand whichever arrived first. + fn accept_revision(&mut self, key: &(String, String), created_at: u64) -> bool { + if self + .spec_seen_at + .get(key) + .is_some_and(|seen| created_at < *seen) + { + return false; + } + self.spec_seen_at.insert(key.clone(), created_at); + true + } + + /// Apply replacement prompt material for an agent after a persona edit. + /// + /// Accepted only from the agent's own owner. There is no nonce to check — + /// this opens no handshake round — so ownership is the entire gate: without + /// it anyone could rewrite a running agent's instructions. + async fn apply_prompt_update( + &mut self, + sender: &PublicKey, + agent_pubkey: &str, + prompt: &buzz_sdk::spawner::PromptMaterial, + ) -> Result<()> { + let Some(record) = self.store.find_by_agent_pubkey(agent_pubkey).cloned() else { + return Ok(()); + }; + if sender.to_hex() != record.owner_pubkey { + warn!( + agent = %agent_pubkey, + sender = %sender.to_hex(), + "ignoring prompt update from a non-owner" + ); + return Ok(()); + } + if prompt.is_empty() { + return Ok(()); + } + + info!(slug = %record.slug, "prompt updated by owner"); + self.store.update(&record.owner_pubkey, &record.slug, |r| { + r.prompt = Some(prompt.clone()); + // Force a restart: the container bakes the prompt into its env, so + // a new prompt only takes effect on a fresh container. + r.spec_hash = None; + })?; + self.reconcile().await + } + + /// One reconciliation pass. + pub async fn reconcile(&mut self) -> Result<()> { + let spawner_pubkey = self.config.keys.public_key().to_hex(); + let containers = self.containers.list(&spawner_pubkey).await?; + let desired: Vec = self.desired.values().cloned().collect(); + let records: Vec = self.store.agents().cloned().collect(); + + let actions = plan(ReconcileInput { + desired: &desired, + records: &records, + containers: &containers, + now: chrono::Utc::now().timestamp(), + attestation_timeout_secs: self.config.attestation_timeout.as_secs() as i64, + max_agents: self.config.max_agents, + desired_hydrated: self.desired_hydrated, + }); + + for action in actions { + // One failing agent must not abort the pass for every other agent. + if let Err(e) = self.apply(action).await { + error!("action failed: {e:#}"); + } + } + + // Advertise after acting, so the published count reflects this pass. + // A failure here is not fatal: discovery is a convenience, and an + // un-advertised spawner still works for anyone who knows its pubkey. + if let Err(e) = self.announce(containers.len()).await { + warn!("failed to publish spawner announcement: {e:#}"); + } + Ok(()) + } + + /// Publish this spawner's self-description and current capacity. + async fn announce(&mut self, agents_running: usize) -> Result<()> { + let announcement = SpawnerAnnouncement { + name: self.config.name.clone(), + description: self.config.description.clone(), + agent_image: Some(self.config.agent_image.clone()), + // What this host actually runs, so a picker can say "Claude Code" + // rather than leaving the user guessing. + runtime: self.config.agent_command.clone(), + max_agents: self.config.max_agents as u32, + agents_running: agents_running as u32, + max_cpu_millis: Some(self.config.max_cpu_millis), + max_memory_mib: Some(self.config.max_memory_mib), + }; + self.relay.publish_announcement(&announcement).await + } + + async fn apply(&mut self, action: Action) -> Result<()> { + match action { + Action::Provision { desired } => self.provision(&desired).await, + Action::ReRequestAttestation { owner_pubkey, slug } => { + self.request_attestation(&owner_pubkey, &slug).await + } + Action::Start { desired } => self.start_agent(&desired, None, true).await, + Action::Restart { + desired, + container_id, + crashed, + } => { + if crashed { + // Count the crash before restarting, so backoff grows even + // though the *creation* that follows will succeed. The + // container's own logs are the only place the reason + // exists — surface a tail of them rather than making the + // owner SSH into the host to find out why. + let reason = self.crash_reason(&container_id).await; + if let Some(record) = self + .store + .get(&desired.owner_pubkey, &desired.slug) + .cloned() + { + self.record_start_failure(&desired, &record, reason).await?; + } + } + self.start_agent(&desired, Some(&container_id), !crashed) + .await + } + Action::Stop { + owner_pubkey, + slug, + container_id, + } => { + // Volume preserved: a disabled agent keeps its workspace so + // re-enabling resumes rather than starting from nothing. + self.containers.remove(&container_id, None).await?; + self.store.update(&owner_pubkey, &slug, |r| { + r.spec_hash = None; + })?; + self.publish_status( + &slug, + &owner_pubkey, + SpawnPhase::Stopped, + None, + None, + None, + 0, + ) + .await + } + Action::Delete { + owner_pubkey, + slug, + container_id, + } => { + let volume = volume_name(&owner_pubkey, &slug); + if let Some(id) = container_id { + self.containers.remove(&id, Some(&volume)).await?; + } + self.store.remove(&owner_pubkey, &slug)?; + // Clear the status too, or clients keep a row for an agent that + // is gone — stuck in whatever phase it died in. + if let Err(e) = self.relay.tombstone_status(&slug, &owner_pubkey).await { + warn!(slug = %slug, "failed to tombstone status: {e:#}"); + } + info!(slug = %slug, "agent deleted"); + Ok(()) + } + Action::RemoveOrphan { container_id } => { + warn!(container = %container_id, "removing container with no spawner record"); + self.containers.remove(&container_id, None).await + } + } + } + + async fn provision(&mut self, desired: &DesiredAgent) -> Result<()> { + // A spec that names an identity is relocating an existing agent, not + // creating one. Its pubkey carries the agent's channels, profile, and + // NIP-AE memory — none of which survive a new key — so the record is + // created around that pubkey and the secret arrives over the encrypted + // handshake. Until it does the record has no key and is not startable + // (`AgentRecord::is_attested`). + if let Some(agent_pubkey) = desired.spec.agent_pubkey.as_deref() { + let record = AgentRecord { + slug: desired.slug.clone(), + owner_pubkey: desired.owner_pubkey.clone(), + agent_pubkey: agent_pubkey.to_ascii_lowercase(), + private_key_nsec: String::new(), + auth_tag: None, + pending_nonce: None, + attestation_sent_at: None, + spec_hash: None, + prompt: None, + restart_count: 0, + last_failure_at: None, + }; + info!( + slug = %record.slug, + agent = %record.agent_pubkey, + "adopting an existing agent; requesting its key from the owner" + ); + self.store.put(record)?; + return self + .request_attestation(&desired.owner_pubkey, &desired.slug) + .await; + } + + let keys = Keys::generate(); + let record = AgentRecord { + slug: desired.slug.clone(), + owner_pubkey: desired.owner_pubkey.clone(), + agent_pubkey: keys.public_key().to_hex(), + private_key_nsec: { + use nostr::nips::nip19::ToBech32; + keys.secret_key() + .to_bech32() + .context("failed to encode agent secret key")? + }, + auth_tag: None, + pending_nonce: None, + attestation_sent_at: None, + spec_hash: None, + prompt: None, + restart_count: 0, + last_failure_at: None, + }; + + info!( + slug = %record.slug, + agent = %record.agent_pubkey, + "minted agent key; requesting owner attestation" + ); + // Persist before sending: a crash between the two must not lose the key + // for a pubkey the owner is about to attest. + self.store.put(record)?; + self.request_attestation(&desired.owner_pubkey, &desired.slug) + .await + } + + async fn request_attestation(&mut self, owner_pubkey: &str, slug: &str) -> Result<()> { + let Some(record) = self.store.get(owner_pubkey, slug).cloned() else { + return Ok(()); + }; + let owner = PublicKey::parse(owner_pubkey).context("invalid owner pubkey")?; + + let nonce = attestation::new_nonce(); + let frame = buzz_sdk::spawner::AttestationFrame::Request { + spec_slug: record.slug.clone(), + agent_pubkey: record.agent_pubkey.clone(), + conditions: String::new(), + nonce: nonce.clone(), + }; + + self.relay.send_attestation(&owner, &frame).await?; + self.store.update(owner_pubkey, slug, |r| { + r.pending_nonce = Some(nonce); + r.attestation_sent_at = Some(chrono::Utc::now().timestamp()); + })?; + + self.publish_status( + slug, + owner_pubkey, + SpawnPhase::PendingAttestation, + Some(&record.agent_pubkey), + None, + None, + record.restart_count, + ) + .await + } + + /// Create (or replace) an agent's container. + /// + /// `reset_failures` is false when restarting after a crash. Creating a + /// container always succeeds even when the process inside it exits + /// immediately, so clearing the counters on creation would erase the crash + /// that just happened and the backoff would never grow — the container + /// would be recreated on every pass forever. + async fn start_agent( + &mut self, + desired: &DesiredAgent, + replace: Option<&str>, + reset_failures: bool, + ) -> Result<()> { + let Some(record) = self + .store + .get(&desired.owner_pubkey, &desired.slug) + .cloned() + else { + return Ok(()); + }; + + if let Some(container_id) = replace { + // Volume preserved across a restart: a config change should not + // wipe the agent's workspace. + self.containers.remove(container_id, None).await?; + } + + let hash = desired.spec_hash(); + // A prompt that will not resolve is a persistent failure, not a + // transient one: propagating it here would skip the backoff bookkeeping + // below and the reconcile loop would retry on every inbound event, + // hammering the relay until it rate-limits us. + let prompt = match self.resolve_prompt(desired).await { + Ok(prompt) => prompt, + Err(e) => { + return self + .record_start_failure(desired, &record, format!("{e:#}")) + .await; + } + }; + let (cpu_millis, memory_mib) = self.resources_for(&desired.spec); + + let spec = ContainerSpec { + name: record.container_name(), + image: self.config.agent_image.clone(), + agent_pubkey: record.agent_pubkey.clone(), + slug: record.slug.clone(), + spawner_pubkey: self.config.keys.public_key().to_hex(), + env: build_agent_env( + &record, + &desired.spec, + &prompt, + &self.config.agent_relay_url, + &self.config.agent_env, + &AgentRuntime { + command: self.config.agent_command.as_deref(), + args: self.config.agent_args.as_deref(), + }, + ), + cpu_millis, + memory_mib, + volume_name: volume_name(&desired.owner_pubkey, &desired.slug), + }; + + match self.containers.create(&spec).await { + Ok(id) => { + info!(slug = %desired.slug, container = %id, "agent started"); + self.store + .update(&desired.owner_pubkey, &desired.slug, |r| { + r.spec_hash = Some(hash.clone()); + if reset_failures { + r.restart_count = 0; + r.last_failure_at = None; + } + })?; + if !reset_failures { + // The Failed status published moments ago is the truthful + // one; overwriting it with Running would hide a crash loop + // behind a green badge that flickers. + return Ok(()); + } + self.publish_status( + &desired.slug, + &desired.owner_pubkey, + SpawnPhase::Running, + Some(&record.agent_pubkey), + Some(&hash), + None, + 0, + ) + .await + } + Err(e) => { + self.record_start_failure(desired, &record, format!("{e:#}")) + .await + } + } + } + + /// Best-effort explanation for why a container died, from its own logs. + async fn crash_reason(&self, container_id: &str) -> String { + match self.containers.logs(container_id, 20).await { + Ok(logs) => { + let tail: String = logs + .lines() + .filter(|l| !l.trim().is_empty()) + .rev() + .take(3) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join(" | "); + if tail.is_empty() { + "container exited without output".to_string() + } else { + format!("container exited: {}", truncate(&tail, 900)) + } + } + Err(e) => format!("container exited; logs unavailable: {e}"), + } + } + + /// Record a failed start: bump the backoff counters and report it. + /// + /// Every path that fails to bring an agent up must go through here. A + /// failure that skips it is retried on the next inbound event with no delay, + /// which turns one broken agent into a request storm against the relay. + async fn record_start_failure( + &mut self, + desired: &DesiredAgent, + record: &AgentRecord, + message: String, + ) -> Result<()> { + error!(slug = %desired.slug, "failed to start agent: {message}"); + let restart_count = record.restart_count.saturating_add(1); + self.store + .update(&desired.owner_pubkey, &desired.slug, |r| { + r.restart_count = restart_count; + r.last_failure_at = Some(chrono::Utc::now().timestamp()); + })?; + self.publish_status( + &desired.slug, + &desired.owner_pubkey, + SpawnPhase::Failed, + Some(&record.agent_pubkey), + None, + Some(message), + restart_count, + ) + .await + } + + /// Clamp a spec's requested resources to the host's configured ceiling. + /// + /// Clamped rather than rejected: an owner asking for more than the host + /// allows should get a smaller agent, not a broken one. + fn resources_for(&self, spec: &SpawnerAgentSpec) -> (u32, u32) { + let requested = spec.resources.unwrap_or_default(); + ( + requested + .cpu_millis + .unwrap_or(DEFAULT_CPU_MILLIS) + .min(self.config.max_cpu_millis), + requested + .memory_mib + .unwrap_or(DEFAULT_MEMORY_MIB) + .min(self.config.max_memory_mib), + ) + } + + /// Resolve prompt material for a spec. + /// + /// A spec that names a `persona_id` resolves through the owner's kind:30175 + /// persona, matching how the desktop slims kind:30177 — the prompt lives in + /// exactly one place. A definition-less spec carries its own prompt inline. + async fn resolve_prompt(&mut self, desired: &DesiredAgent) -> Result { + let mut prompt = self.resolve_prompt_uncached(desired).await?; + // Host defaults fill only what the spec and persona left empty, so an + // explicit choice always wins over the operator's fallback. + if prompt.provider.is_none() { + prompt.provider = self.config.default_provider.clone(); + } + if prompt.model.is_none() { + prompt.model = self.config.default_model.clone(); + } + Ok(prompt) + } + + async fn resolve_prompt_uncached(&mut self, desired: &DesiredAgent) -> Result { + // Prompt delivered over the encrypted handshake wins: it is the only + // source that works for an unshared persona, and it is what the owner + // most recently intended. + if let Some(record) = self.store.get(&desired.owner_pubkey, &desired.slug) { + if let Some(prompt) = record.prompt.as_ref().filter(|p| !p.is_empty()) { + return Ok(ResolvedPrompt { + system_prompt: prompt.system_prompt.clone(), + team_instructions: prompt.team_instructions.clone(), + model: prompt.model.clone(), + provider: prompt.provider.clone(), + }); + } + } + + let Some(persona_id) = desired.spec.persona_id.as_deref() else { + return Ok(ResolvedPrompt { + system_prompt: desired.spec.system_prompt.clone(), + team_instructions: None, + model: desired.spec.model.clone(), + provider: desired.spec.provider.clone(), + }); + }; + + let owner = PublicKey::parse(&desired.owner_pubkey).context("invalid owner pubkey")?; + let personas = self.relay.fetch_personas(&owner).await?; + + let Some(event) = personas.get(persona_id) else { + // Falling back to the inline fields keeps a definition-less spec + // working, and surfaces a clear failure for one that genuinely + // depends on a persona the spawner cannot read. + if desired.spec.system_prompt.is_none() { + // Kind 30175 is author-only unless tagged ["shared","true"], and + // the spawner is not the author. It authenticates as itself with + // no NIP-OA attestation, so no owner delegation applies to it — + // the two ways out are both the owner's to take. + anyhow::bail!( + "no prompt for persona {persona_id}: it is not readable by \ + this spawner and none was delivered over the attestation \ + handshake. Re-approve the agent from a client that sends \ + prompt material, or publish the persona with \ + [\"shared\",\"true\"]." + ); + } + warn!( + persona = %persona_id, + "persona not found; falling back to the spec's inline prompt" + ); + return Ok(ResolvedPrompt { + system_prompt: desired.spec.system_prompt.clone(), + team_instructions: None, + model: desired.spec.model.clone(), + provider: desired.spec.provider.clone(), + }); + }; + + let body: PersonaContent = serde_json::from_str(event.content.as_ref()) + .with_context(|| format!("failed to parse persona {persona_id}"))?; + + Ok(ResolvedPrompt { + system_prompt: body + .system_prompt + .or_else(|| desired.spec.system_prompt.clone()), + team_instructions: body.team_instructions, + model: body.model.or_else(|| desired.spec.model.clone()), + provider: body.provider.or_else(|| desired.spec.provider.clone()), + }) + } + + #[allow(clippy::too_many_arguments)] + async fn publish_status( + &mut self, + slug: &str, + owner_pubkey: &str, + phase: SpawnPhase, + agent_pubkey: Option<&str>, + spec_hash: Option<&str>, + error: Option, + restart_count: u32, + ) -> Result<()> { + let status = SpawnerAgentStatus { + phase, + agent_pubkey: agent_pubkey.map(str::to_string), + spec_hash: spec_hash.map(str::to_string), + error, + restart_count, + }; + self.relay.publish_status(slug, owner_pubkey, &status).await + } +} + +/// Truncate on a char boundary so a multi-byte log tail cannot panic. +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut end = max; + while !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) +} + +/// The subset of a kind:30175 persona body the spawner needs. +/// +/// Deliberately narrow: the spawner reads a prompt, not a whole persona record, +/// and unknown fields are dropped rather than carried into the container env. +#[derive(serde::Deserialize)] +struct PersonaContent { + #[serde(default)] + system_prompt: Option, + #[serde(default)] + team_instructions: Option, + #[serde(default)] + model: Option, + #[serde(default)] + provider: Option, +} + +/// Per-agent volume name, scoped by owner for the same reason container names +/// are: slugs are only unique per owner. +fn volume_name(owner_pubkey: &str, slug: &str) -> String { + format!( + "buzz-agent-{}-{}", + &owner_pubkey[..12.min(owner_pubkey.len())], + slug + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn volume_names_are_scoped_per_owner() { + assert_ne!( + volume_name(&"a".repeat(64), "fizz"), + volume_name(&"b".repeat(64), "fizz") + ); + } + + #[test] + fn persona_content_ignores_unknown_fields() { + // A persona event carrying extra keys must not leak them anywhere. + let body: PersonaContent = serde_json::from_str( + r#"{"system_prompt":"be Fizz","env_vars":{"K":"v"},"private_key_nsec":"nsec1x"}"#, + ) + .unwrap(); + assert_eq!(body.system_prompt.as_deref(), Some("be Fizz")); + assert!(body.model.is_none()); + } +} diff --git a/crates/buzz-spawner/src/env.rs b/crates/buzz-spawner/src/env.rs new file mode 100644 index 0000000000..ef45dd28c8 --- /dev/null +++ b/crates/buzz-spawner/src/env.rs @@ -0,0 +1,376 @@ +//! Assemble the environment for a `buzz-acp` harness container. +//! +//! This mirrors the desktop's `spawn_agent_child` +//! (`desktop/src-tauri/src/managed_agents/runtime.rs`) so a VPS agent and a +//! laptop agent are configured identically — a Fizz on the server should differ +//! from a Fizz on a laptop only in where its process happens to run. + +use buzz_sdk::spawner::{RespondTo, SpawnerAgentSpec}; + +use crate::store::AgentRecord; + +/// Environment variable names the spawner owns outright. +/// +/// Operator-supplied passthrough env (`BUZZ_SPAWNER_AGENT_ENV`) is filtered +/// against this list so a misconfigured compose file cannot hand an agent a +/// different identity, a different relay, or somebody else's attestation. This +/// mirrors the desktop's reserved-key strip in +/// `managed_agents/env_vars.rs`. +pub const RESERVED_KEYS: &[&str] = &[ + // Code-execution surface: which binary the harness spawns. Reserved so + // operator passthrough cannot set it either — it comes from the spawner's + // own `BUZZ_SPAWNER_AGENT_COMMAND`, applied below. + "BUZZ_ACP_AGENT_COMMAND", + "BUZZ_ACP_AGENT_ARGS", + "BUZZ_PRIVATE_KEY", + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_RELAY_URL", + "BUZZ_ACP_PRIVATE_KEY", + "BUZZ_ACP_API_TOKEN", + "BUZZ_ACP_SETUP_PAYLOAD", +]; + +/// The resolved prompt material for an agent, gathered from its spec and the +/// persona it references. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResolvedPrompt { + /// The agent's system prompt. + pub system_prompt: Option, + /// Team-level instructions appended after the system prompt. + pub team_instructions: Option, + /// Model id, in `provider:model` or bare-model form. + pub model: Option, + /// Inference provider id. + pub provider: Option, +} + +/// Which ACP agent binary the harness runs inside the container. +/// +/// Operator-supplied only. This selects what executes, so it deliberately has no +/// path from a kind:30178 spec — those are owner-authored and world-readable. +pub struct AgentRuntime<'a> { + /// ACP agent binary, or `None` to keep the image default. + pub command: Option<&'a str>, + /// Comma-separated args for `command`. + pub args: Option<&'a str>, +} + +/// Build the full environment for an agent container. +/// +/// `passthrough` is the operator-configured env (LLM credentials and the like). +/// It is applied *first* so the Buzz-owned variables below always win: an +/// operator cannot accidentally — or deliberately — override an agent's identity +/// by naming `BUZZ_PRIVATE_KEY` in the compose file. +pub fn build_agent_env( + record: &AgentRecord, + spec: &SpawnerAgentSpec, + prompt: &ResolvedPrompt, + relay_url: &str, + passthrough: &[(String, String)], + runtime: &AgentRuntime<'_>, +) -> Vec<(String, String)> { + let mut env: Vec<(String, String)> = passthrough + .iter() + .filter(|(k, _)| !RESERVED_KEYS.contains(&k.as_str())) + .cloned() + .collect(); + + let mut set = |key: &str, value: String| env.push((key.to_string(), value)); + + // Runtime selection, from operator config only — never from a spec. + if let Some(command) = runtime.command { + set("BUZZ_ACP_AGENT_COMMAND", command.to_string()); + } + if let Some(args) = runtime.args { + set("BUZZ_ACP_AGENT_ARGS", args.to_string()); + } + + // Identity and transport. + set("BUZZ_PRIVATE_KEY", record.private_key_nsec.clone()); + set("BUZZ_RELAY_URL", relay_url.to_string()); + if let Some(auth_tag) = &record.auth_tag { + set("BUZZ_AUTH_TAG", auth_tag.clone()); + } + // git-credential-nostr signs NIP-98 git auth with the same key. + set("NOSTR_PRIVATE_KEY", record.private_key_nsec.clone()); + + // Prompt material. + if let Some(system_prompt) = &prompt.system_prompt { + set("BUZZ_ACP_SYSTEM_PROMPT", system_prompt.clone()); + } + if let Some(team_instructions) = &prompt.team_instructions { + set("BUZZ_ACP_TEAM_INSTRUCTIONS", team_instructions.clone()); + } + // Two layers read these, and both must be satisfied. `BUZZ_ACP_MODEL` is + // the harness's own setting; `BUZZ_AGENT_MODEL`/`BUZZ_AGENT_PROVIDER` are + // what the `buzz-agent` binary requires of its own config, and it refuses + // to start without them. The desktop makes the same mapping through its + // runtime table (`managed_agents/discovery.rs`, `model_env_var` / + // `provider_env_var`); a spawner has only one runtime, so it is direct. + if let Some(model) = &prompt.model { + set("BUZZ_ACP_MODEL", model.clone()); + set("BUZZ_AGENT_MODEL", model.clone()); + } + if let Some(provider) = &prompt.provider { + set("BUZZ_AGENT_PROVIDER", provider.clone()); + } + + // Behavior. + set("BUZZ_ACP_AGENTS", spec.parallelism.to_string()); + set( + "BUZZ_ACP_RESPOND_TO", + match spec.respond_to { + RespondTo::Anyone => "anyone", + RespondTo::OwnerOnly => "owner-only", + RespondTo::Allowlist => "allowlist", + } + .to_string(), + ); + if spec.respond_to == RespondTo::Allowlist { + set( + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + spec.respond_to_allowlist.join(","), + ); + } + set("BUZZ_ACP_RELAY_OBSERVER", "true".to_string()); + set("BUZZ_ACP_DEDUP", "queue".to_string()); + set("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "steer".to_string()); + + // Provenance, so an operator inspecting a container can tell where it came + // from without cross-referencing the state file. + set("BUZZ_SPAWNED_BY", "buzz-spawner".to_string()); + set("BUZZ_SPAWNER_SPEC_SLUG", record.slug.clone()); + + env +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record() -> AgentRecord { + AgentRecord { + slug: "fizz-prod".into(), + owner_pubkey: "b".repeat(64), + agent_pubkey: "a".repeat(64), + private_key_nsec: "nsec1realsecret".into(), + auth_tag: Some(r#"["auth","owner","","sig"]"#.into()), + pending_nonce: None, + attestation_sent_at: None, + spec_hash: None, + prompt: None, + restart_count: 0, + last_failure_at: None, + } + } + + fn spec() -> SpawnerAgentSpec { + SpawnerAgentSpec { + name: "Fizz".into(), + agent_pubkey: None, + persona_id: Some("builtin:fizz".into()), + system_prompt: None, + model: None, + provider: None, + parallelism: 2, + respond_to: RespondTo::Anyone, + respond_to_allowlist: vec![], + resources: None, + enabled: true, + } + } + + /// Image-default runtime: the spawner sets no override. + const DEFAULT_RUNTIME: AgentRuntime<'static> = AgentRuntime { + command: None, + args: None, + }; + + fn lookup(env: &[(String, String)], key: &str) -> Option { + env.iter().rfind(|(k, _)| k == key).map(|(_, v)| v.clone()) + } + + #[test] + fn sets_identity_prompt_and_behavior() { + let prompt = ResolvedPrompt { + system_prompt: Some("You are Fizz.".into()), + team_instructions: None, + model: Some("claude-opus-5".into()), + provider: Some("anthropic".into()), + }; + let env = build_agent_env( + &record(), + &spec(), + &prompt, + "wss://relay.example", + &[], + &DEFAULT_RUNTIME, + ); + + assert_eq!( + lookup(&env, "BUZZ_PRIVATE_KEY").as_deref(), + Some("nsec1realsecret") + ); + assert_eq!( + lookup(&env, "BUZZ_RELAY_URL").as_deref(), + Some("wss://relay.example") + ); + assert_eq!( + lookup(&env, "BUZZ_ACP_SYSTEM_PROMPT").as_deref(), + Some("You are Fizz.") + ); + assert_eq!( + lookup(&env, "BUZZ_ACP_MODEL").as_deref(), + Some("claude-opus-5") + ); + assert_eq!(lookup(&env, "BUZZ_ACP_AGENTS").as_deref(), Some("2")); + assert_eq!( + lookup(&env, "BUZZ_ACP_RESPOND_TO").as_deref(), + Some("anyone") + ); + } + + #[test] + fn passthrough_cannot_override_reserved_keys() { + // A compose file naming BUZZ_PRIVATE_KEY must not be able to hand an + // agent a different identity, nor swap the relay out from under it. + let passthrough = vec![ + ("ANTHROPIC_API_KEY".to_string(), "sk-real".to_string()), + ("BUZZ_PRIVATE_KEY".to_string(), "nsec1attacker".to_string()), + ( + "BUZZ_RELAY_URL".to_string(), + "wss://evil.example".to_string(), + ), + ( + "BUZZ_AUTH_TAG".to_string(), + "[\"auth\",\"attacker\"]".to_string(), + ), + ]; + let env = build_agent_env( + &record(), + &spec(), + &ResolvedPrompt::default(), + "wss://relay.example", + &passthrough, + &DEFAULT_RUNTIME, + ); + + assert_eq!( + lookup(&env, "ANTHROPIC_API_KEY").as_deref(), + Some("sk-real") + ); + assert_eq!( + lookup(&env, "BUZZ_PRIVATE_KEY").as_deref(), + Some("nsec1realsecret") + ); + assert_eq!( + lookup(&env, "BUZZ_RELAY_URL").as_deref(), + Some("wss://relay.example") + ); + assert!(!env + .iter() + .any(|(_, v)| v.contains("attacker") || v.contains("evil.example"))); + } + + #[test] + fn runtime_override_selects_the_agent_binary() { + let env = build_agent_env( + &record(), + &spec(), + &ResolvedPrompt::default(), + "wss://r", + &[], + &AgentRuntime { + command: Some("claude-agent-acp"), + args: None, + }, + ); + assert_eq!( + lookup(&env, "BUZZ_ACP_AGENT_COMMAND").as_deref(), + Some("claude-agent-acp") + ); + } + + #[test] + fn leaves_the_image_default_when_no_runtime_is_configured() { + let env = build_agent_env( + &record(), + &spec(), + &ResolvedPrompt::default(), + "wss://r", + &[], + &DEFAULT_RUNTIME, + ); + assert!(lookup(&env, "BUZZ_ACP_AGENT_COMMAND").is_none()); + } + + #[test] + fn passthrough_cannot_choose_the_agent_binary() { + // The binary the harness spawns is a code-execution surface. Operator + // passthrough goes through the same reserved-key filter as everything + // else, so it has to come from BUZZ_SPAWNER_AGENT_COMMAND. + let passthrough = vec![("BUZZ_ACP_AGENT_COMMAND".to_string(), "/bin/sh".to_string())]; + let env = build_agent_env( + &record(), + &spec(), + &ResolvedPrompt::default(), + "wss://r", + &passthrough, + &AgentRuntime { + command: Some("claude-agent-acp"), + args: None, + }, + ); + assert_eq!( + lookup(&env, "BUZZ_ACP_AGENT_COMMAND").as_deref(), + Some("claude-agent-acp") + ); + assert!(!env.iter().any(|(_, v)| v == "/bin/sh")); + } + + #[test] + fn omits_the_auth_tag_until_attested() { + let mut record = record(); + record.auth_tag = None; + let env = build_agent_env( + &record, + &spec(), + &ResolvedPrompt::default(), + "wss://relay.example", + &[], + &DEFAULT_RUNTIME, + ); + assert!(lookup(&env, "BUZZ_AUTH_TAG").is_none()); + } + + #[test] + fn emits_the_allowlist_only_when_gated_on_it() { + let mut spec = spec(); + let env = build_agent_env( + &record(), + &spec, + &ResolvedPrompt::default(), + "wss://r", + &[], + &DEFAULT_RUNTIME, + ); + assert!(lookup(&env, "BUZZ_ACP_RESPOND_TO_ALLOWLIST").is_none()); + + spec.respond_to = RespondTo::Allowlist; + spec.respond_to_allowlist = vec!["c".repeat(64), "d".repeat(64)]; + let env = build_agent_env( + &record(), + &spec, + &ResolvedPrompt::default(), + "wss://r", + &[], + &DEFAULT_RUNTIME, + ); + assert_eq!( + lookup(&env, "BUZZ_ACP_RESPOND_TO_ALLOWLIST"), + Some(format!("{}{}{}", "c".repeat(64), ",", "d".repeat(64))) + ); + } +} diff --git a/crates/buzz-spawner/src/lib.rs b/crates/buzz-spawner/src/lib.rs new file mode 100644 index 0000000000..fb70690f61 --- /dev/null +++ b/crates/buzz-spawner/src/lib.rs @@ -0,0 +1,50 @@ +#![deny(unsafe_code)] +#![warn(missing_docs)] +//! `buzz-spawner` — reconciles Nostr agent specs into running agent containers. +//! +//! # What it is +//! +//! A standalone daemon that runs beside a Buzz relay and gives self-hosters +//! first-party server-side agents. Without it, every agent in Buzz is spawned by +//! the desktop app: agents die when the laptop sleeps, and mobile and web cannot +//! create one at all. +//! +//! It is deliberately *not* part of `buzz-relay`. The relay's only subprocess +//! today is `git`, and it goes out of its way to disable repo hooks; arbitrary +//! container execution does not belong in a public-facing WebSocket server. This +//! follows the existing sidecar-crate precedent (`buzz-pair-relay`, +//! `buzz-push-gateway`). +//! +//! # How it works +//! +//! The daemon is an ordinary relay client with its own Nostr identity. It holds +//! no database connection and no privileged relay access. +//! +//! ```text +//! 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 +//! ``` +//! +//! Desired state is [`buzz_sdk::spawner::SpawnerAgentSpec`] events; actual state +//! is Docker containers labelled `com.buzz.agent`. [`reconcile::plan`] diffs the +//! two as a pure function, and [`daemon::Daemon`] applies the result. +//! +//! # Why the handshake exists +//! +//! Each agent's secret key is minted on this host and never transmitted. But a +//! NIP-OA auth tag binds one specific agent pubkey and is signed with the +//! *owner's* secret key, so the spawner cannot self-attest and the owner cannot +//! pre-authorize a pubkey that does not exist yet. See [`attestation`]. + +pub mod attestation; +pub mod config; +pub mod container; +pub mod daemon; +pub mod env; +pub mod reconcile; +pub mod relay; +pub mod store; diff --git a/crates/buzz-spawner/src/main.rs b/crates/buzz-spawner/src/main.rs new file mode 100644 index 0000000000..962222dd93 --- /dev/null +++ b/crates/buzz-spawner/src/main.rs @@ -0,0 +1,38 @@ +//! `buzz-spawner` binary entry point. + +use std::sync::Arc; + +use anyhow::Result; +use buzz_spawner::{ + config::Config, + container::{ContainerOps, DockerOps}, + daemon::Daemon, +}; +use tracing::info; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "buzz_spawner=info".into()), + ) + .init(); + + let config = Config::from_env()?; + let containers: Arc = Arc::new(DockerOps::connect()?); + + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + tokio::spawn(async move { + if tokio::signal::ctrl_c().await.is_ok() { + info!("received SIGINT"); + let _ = shutdown_tx.send(true); + } + }); + + let mut daemon = Daemon::start(config, containers).await?; + // An initial pass before the first tick, so a restart re-adopts running + // containers and re-requests any attestation that expired while down. + daemon.reconcile().await?; + daemon.run(shutdown_rx).await +} diff --git a/crates/buzz-spawner/src/reconcile.rs b/crates/buzz-spawner/src/reconcile.rs new file mode 100644 index 0000000000..bbf935def9 --- /dev/null +++ b/crates/buzz-spawner/src/reconcile.rs @@ -0,0 +1,727 @@ +//! Desired state (specs) versus actual state (containers). +//! +//! The diff is a pure function so it can be tested without Docker or a relay. +//! Everything that performs I/O lives in [`crate::daemon`]; this module only +//! decides *what* should happen. + +use std::collections::{HashMap, HashSet}; + +use buzz_sdk::spawner::SpawnerAgentSpec; +use sha2::{Digest, Sha256}; + +use crate::{container::ManagedContainer, store::AgentRecord}; + +/// A spec the spawner is responsible for, paired with its author. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DesiredAgent { + /// Spec slug (`d` tag). + pub slug: String, + /// Spec author pubkey, hex. + pub owner_pubkey: String, + /// The spec body. + pub spec: SpawnerAgentSpec, +} + +impl DesiredAgent { + /// Stable content hash of the spec, used to detect drift. + /// + /// Hashes the serialized spec rather than the event id: an owner can + /// republish an identical spec (a NIP-33 replacement with a new timestamp + /// and id), and that must not restart a healthy agent. + pub fn spec_hash(&self) -> String { + let json = serde_json::to_string(&self.spec).unwrap_or_default(); + let mut hasher = Sha256::new(); + hasher.update(json.as_bytes()); + hex::encode(hasher.finalize()) + } +} + +/// One unit of work the reconciler should perform. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Mint a keypair and open an attestation handshake. + Provision { + /// The spec to provision for. + desired: DesiredAgent, + }, + /// Re-send an attestation request whose previous round timed out. + ReRequestAttestation { + /// Owner pubkey. + owner_pubkey: String, + /// Spec slug. + slug: String, + }, + /// Create the container for an attested agent. + Start { + /// The spec to start. + desired: DesiredAgent, + }, + /// Replace a container. + Restart { + /// The spec to restart against. + desired: DesiredAgent, + /// Container to remove first. + container_id: String, + /// True when the container died on its own rather than being replaced + /// because its spec changed. + /// + /// The two look identical at the Docker layer but mean opposite things. + /// A drifted container was healthy and is being deliberately replaced; + /// a crashed one failed and must count toward backoff. Without the + /// distinction a container that starts and immediately exits is + /// recreated on every reconcile pass forever, because creating it + /// always "succeeds". + crashed: bool, + }, + /// Stop a container the spec disabled, keeping identity and state. + Stop { + /// Owner pubkey. + owner_pubkey: String, + /// Spec slug. + slug: String, + /// Container to remove. + container_id: String, + }, + /// Tear an agent down entirely — its spec is gone. + Delete { + /// Owner pubkey. + owner_pubkey: String, + /// Spec slug. + slug: String, + /// Container to remove, if one exists. + container_id: Option, + }, + /// Remove a container with no corresponding record at all. + RemoveOrphan { + /// Container to remove. + container_id: String, + }, +} + +/// Inputs to a reconciliation pass. +pub struct ReconcileInput<'a> { + /// Specs addressed to this spawner, from the relay. + pub desired: &'a [DesiredAgent], + /// What the spawner has minted so far. + pub records: &'a [AgentRecord], + /// Containers currently on the host carrying this spawner's label. + pub containers: &'a [ManagedContainer], + /// Current unix time. + pub now: i64, + /// Attestation timeout, seconds. + pub attestation_timeout_secs: i64, + /// Cap on concurrently running agents. + pub max_agents: usize, + /// Whether `desired` reflects the relay's full set of specs yet. + /// + /// False between startup and the specs subscription reaching EOSE. In that + /// window an absent spec means "not delivered yet", not "deleted", and the + /// two are indistinguishable from the desired-state map alone. Acting on + /// the wrong reading destroys an agent's container, volume, and secret key + /// on every restart — the agent comes back with a new identity and loses + /// its channel membership and attestation. + pub desired_hydrated: bool, +} + +/// Backoff before retrying a failed start, capped so a permanently broken agent +/// still gets an occasional retry without hammering the Docker daemon. +pub fn backoff_secs(restart_count: u32) -> i64 { + const CAP: i64 = 600; + let exp = 15i64.saturating_mul(1 << restart_count.min(6)); + exp.min(CAP) +} + +/// Compute the actions needed to bring actual state in line with desired state. +/// +/// Ordering matters: removals are emitted before creations so a pass that both +/// deletes and provisions frees its agent-cap slot first. +pub fn plan(input: ReconcileInput<'_>) -> Vec { + let records: HashMap<(&str, &str), &AgentRecord> = input + .records + .iter() + .map(|r| ((r.owner_pubkey.as_str(), r.slug.as_str()), r)) + .collect(); + + let containers_by_agent: HashMap<&str, &ManagedContainer> = input + .containers + .iter() + .map(|c| (c.agent_pubkey.as_str(), c)) + .collect(); + + let desired_keys: HashSet<(&str, &str)> = input + .desired + .iter() + .map(|d| (d.owner_pubkey.as_str(), d.slug.as_str())) + .collect(); + + let mut actions = Vec::new(); + + // Destructive actions are gated on hydration. Everything below this block + // is additive or idempotent and is safe to run against partial desired + // state; deletion is neither, and is irreversible. + if input.desired_hydrated { + // 1. Records whose spec disappeared — the owner deleted it. + for record in input.records { + if desired_keys.contains(&(record.owner_pubkey.as_str(), record.slug.as_str())) { + continue; + } + actions.push(Action::Delete { + owner_pubkey: record.owner_pubkey.clone(), + slug: record.slug.clone(), + container_id: containers_by_agent + .get(record.agent_pubkey.as_str()) + .map(|c| c.id.clone()), + }); + } + + // 2. Containers with no record at all. These are unreachable: the + // spawner has no key for them, so it can neither manage nor speak for + // them. + let known_agents: HashSet<&str> = input + .records + .iter() + .map(|r| r.agent_pubkey.as_str()) + .collect(); + for container in input.containers { + if !known_agents.contains(container.agent_pubkey.as_str()) { + actions.push(Action::RemoveOrphan { + container_id: container.id.clone(), + }); + } + } + } + + // A pass that deletes frees capacity, so count only the agents that survive. + let mut running_budget = input.max_agents.saturating_sub( + input + .records + .iter() + .filter(|r| { + desired_keys.contains(&(r.owner_pubkey.as_str(), r.slug.as_str())) + && containers_by_agent.contains_key(r.agent_pubkey.as_str()) + }) + .count(), + ); + + for desired in input.desired { + let key = (desired.owner_pubkey.as_str(), desired.slug.as_str()); + let Some(record) = records.get(&key) else { + // 3. Never seen — mint a key and open the handshake. + actions.push(Action::Provision { + desired: desired.clone(), + }); + continue; + }; + + // A spec that names an identity different from the one on record means + // the agent was relocated here after this spawner had already minted + // one for the same slug. Spec drift alone would restart the *wrong* + // identity forever, so the minted stand-in has to be torn down and the + // named agent adopted in its place. + if desired + .spec + .agent_pubkey + .as_deref() + .is_some_and(|wanted| !wanted.eq_ignore_ascii_case(&record.agent_pubkey)) + { + actions.push(Action::Delete { + owner_pubkey: desired.owner_pubkey.clone(), + slug: desired.slug.clone(), + container_id: containers_by_agent + .get(record.agent_pubkey.as_str()) + .map(|c| c.id.clone()), + }); + actions.push(Action::Provision { + desired: desired.clone(), + }); + continue; + } + + let container = containers_by_agent.get(record.agent_pubkey.as_str()); + + // 4. Disabled: stop the container but keep the identity, so re-enabling + // resumes the same agent rather than creating a stranger. + if !desired.spec.enabled { + if let Some(container) = container { + actions.push(Action::Stop { + owner_pubkey: desired.owner_pubkey.clone(), + slug: desired.slug.clone(), + container_id: container.id.clone(), + }); + } + continue; + } + + // 5. Not yet attested — chase the handshake rather than starting. + if !record.is_attested() { + if crate::attestation::is_attestation_expired( + record, + input.now, + input.attestation_timeout_secs, + ) { + actions.push(Action::ReRequestAttestation { + owner_pubkey: desired.owner_pubkey.clone(), + slug: desired.slug.clone(), + }); + } + continue; + } + + let hash = desired.spec_hash(); + + match container { + // 6. Running with a stale spec — replace it. + Some(container) if record.spec_hash.as_deref() != Some(hash.as_str()) => { + actions.push(Action::Restart { + desired: desired.clone(), + container_id: container.id.clone(), + crashed: false, + }); + } + // 7. Present and current, but the container died out of band. + Some(container) if !container.running => { + if !in_backoff(record, input.now) { + actions.push(Action::Restart { + desired: desired.clone(), + container_id: container.id.clone(), + crashed: true, + }); + } + } + // 8. Healthy. Nothing to do. + Some(_) => {} + // 9. Attested but not running. + None => { + if in_backoff(record, input.now) { + continue; + } + if running_budget == 0 { + continue; + } + running_budget -= 1; + actions.push(Action::Start { + desired: desired.clone(), + }); + } + } + } + + actions +} + +fn in_backoff(record: &AgentRecord, now: i64) -> bool { + match record.last_failure_at { + Some(failed_at) => now.saturating_sub(failed_at) < backoff_secs(record.restart_count), + None => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_sdk::spawner::RespondTo; + + fn spec(enabled: bool, parallelism: u32) -> SpawnerAgentSpec { + SpawnerAgentSpec { + name: "Fizz".into(), + agent_pubkey: None, + persona_id: Some("builtin:fizz".into()), + system_prompt: None, + model: None, + provider: None, + parallelism, + respond_to: RespondTo::Anyone, + respond_to_allowlist: vec![], + resources: None, + enabled, + } + } + + fn desired(slug: &str, enabled: bool, parallelism: u32) -> DesiredAgent { + DesiredAgent { + slug: slug.into(), + owner_pubkey: "b".repeat(64), + spec: spec(enabled, parallelism), + } + } + + fn record(slug: &str, agent: &str, attested: bool, hash: Option<&str>) -> AgentRecord { + AgentRecord { + slug: slug.into(), + owner_pubkey: "b".repeat(64), + agent_pubkey: agent.into(), + private_key_nsec: "nsec1x".into(), + auth_tag: attested.then(|| r#"["auth","o","","s"]"#.to_string()), + pending_nonce: (!attested).then(|| "n".repeat(64)), + attestation_sent_at: (!attested).then_some(1_000), + spec_hash: hash.map(str::to_string), + prompt: None, + restart_count: 0, + last_failure_at: None, + } + } + + fn container(agent: &str, running: bool) -> ManagedContainer { + ManagedContainer { + id: format!("ctr-{agent}"), + name: format!("buzz-agent-{agent}"), + agent_pubkey: agent.into(), + slug: "fizz".into(), + running, + } + } + + fn input<'a>( + desired: &'a [DesiredAgent], + records: &'a [AgentRecord], + containers: &'a [ManagedContainer], + ) -> ReconcileInput<'a> { + ReconcileInput { + desired, + records, + containers, + now: 1_100, + attestation_timeout_secs: 600, + max_agents: 16, + desired_hydrated: true, + } + } + + #[test] + fn provisions_a_spec_it_has_never_seen() { + let d = vec![desired("fizz", true, 1)]; + let actions = plan(input(&d, &[], &[])); + assert!(matches!(actions.as_slice(), [Action::Provision { .. }])); + } + + #[test] + fn starts_an_attested_agent_with_no_container() { + let d = vec![desired("fizz", true, 1)]; + let hash = d[0].spec_hash(); + let r = vec![record("fizz", "agent1", true, Some(&hash))]; + let actions = plan(input(&d, &r, &[])); + assert!(matches!(actions.as_slice(), [Action::Start { .. }])); + } + + #[test] + fn does_nothing_when_running_and_current() { + let d = vec![desired("fizz", true, 1)]; + let hash = d[0].spec_hash(); + let r = vec![record("fizz", "agent1", true, Some(&hash))]; + let c = vec![container("agent1", true)]; + assert!(plan(input(&d, &r, &c)).is_empty()); + } + + #[test] + fn republishing_an_identical_spec_does_not_restart() { + // NIP-33 replacement gives a new event id and timestamp for identical + // content. Hashing content rather than the event keeps the agent up. + let d = [desired("fizz", true, 1)]; + let hash = d[0].spec_hash(); + let d2 = vec![desired("fizz", true, 1)]; + assert_eq!(hash, d2[0].spec_hash()); + + let r = vec![record("fizz", "agent1", true, Some(&hash))]; + let c = vec![container("agent1", true)]; + assert!(plan(input(&d2, &r, &c)).is_empty()); + } + + #[test] + fn restarts_on_spec_drift() { + let old = desired("fizz", true, 1); + let r = vec![record("fizz", "agent1", true, Some(&old.spec_hash()))]; + let c = vec![container("agent1", true)]; + let d = vec![desired("fizz", true, 4)]; + assert!(matches!( + plan(input(&d, &r, &c)).as_slice(), + [Action::Restart { .. }] + )); + } + + #[test] + fn restarts_a_container_that_died_out_of_band_and_marks_it_crashed() { + let d = vec![desired("fizz", true, 1)]; + let hash = d[0].spec_hash(); + let r = vec![record("fizz", "agent1", true, Some(&hash))]; + let c = vec![container("agent1", false)]; + assert!(matches!( + plan(input(&d, &r, &c)).as_slice(), + [Action::Restart { crashed: true, .. }] + )); + } + + #[test] + fn a_drift_restart_is_not_a_crash() { + // A healthy container replaced because its spec changed must not count + // toward backoff — otherwise editing an agent repeatedly would throttle + // a perfectly working one. + let old = desired("fizz", true, 1); + let r = vec![record("fizz", "agent1", true, Some(&old.spec_hash()))]; + let c = vec![container("agent1", true)]; + let d = vec![desired("fizz", true, 4)]; + assert!(matches!( + plan(input(&d, &r, &c)).as_slice(), + [Action::Restart { crashed: false, .. }] + )); + } + + #[test] + fn a_crash_looping_container_is_throttled() { + // The bug this guards: a container that starts then immediately exits + // is recreated on every pass, because container *creation* keeps + // succeeding. Only counting crashes makes the backoff bite. + let d = vec![desired("fizz", true, 1)]; + let hash = d[0].spec_hash(); + let mut rec = record("fizz", "agent1", true, Some(&hash)); + rec.restart_count = 4; + rec.last_failure_at = Some(1_090); + let r = vec![rec]; + let c = vec![container("agent1", false)]; + + // 15 * 2^4 = 240s of backoff; only 10s have elapsed. + assert!(plan(input(&d, &r, &c)).is_empty()); + } + + #[test] + fn respects_backoff_after_repeated_failures() { + let d = vec![desired("fizz", true, 1)]; + let hash = d[0].spec_hash(); + let mut rec = record("fizz", "agent1", true, Some(&hash)); + rec.restart_count = 3; + rec.last_failure_at = Some(1_090); + let r = vec![rec]; + + // 15 * 2^3 = 120s of backoff; only 10s have elapsed. + assert!(plan(input(&d, &r, &[])).is_empty()); + + let mut later = input(&d, &r, &[]); + later.now = 1_090 + 121; + assert!(matches!(plan(later).as_slice(), [Action::Start { .. }])); + } + + #[test] + fn backoff_is_capped() { + assert_eq!(backoff_secs(0), 15); + assert_eq!(backoff_secs(3), 120); + assert_eq!(backoff_secs(20), 600, "must not grow without bound"); + } + + #[test] + fn replaces_a_minted_identity_when_the_spec_names_a_different_agent() { + // The relocation case: the spawner already minted an identity for this + // slug, then the owner published a spec naming an existing agent to + // move here. Treating that as ordinary drift would restart the minted + // stand-in forever and the real agent would never arrive. + let mut d = desired("fizz", true, 1); + d.spec.agent_pubkey = Some("f".repeat(64)); + let r = vec![record("fizz", "agent-minted", true, Some(&d.spec_hash()))]; + let c = vec![container("agent-minted", true)]; + + let actions = plan(input(&[d], &r, &c)); + assert!(actions.iter().any(|a| matches!(a, Action::Delete { .. }))); + assert!(actions + .iter() + .any(|a| matches!(a, Action::Provision { .. }))); + // The wrong identity must not simply be restarted. + assert!(!actions.iter().any(|a| matches!(a, Action::Restart { .. }))); + } + + #[test] + fn relocation_converges_instead_of_looping() { + // Regression: the identity-mismatch rule tears down a minted stand-in + // and re-provisions. If provisioning mints AGAIN rather than adopting + // the named pubkey, the next pass sees a mismatch again and the spawner + // churns containers forever — 29 mints in one run before this was + // caught. Model the adopt step and assert the second pass is quiet. + let mut d = desired("fizz", true, 1); + let wanted = "f".repeat(64); + d.spec.agent_pubkey = Some(wanted.clone()); + + // Pass 1: a minted identity is on record, so it must be replaced. + let minted = vec![record("fizz", "agent-minted", true, Some(&d.spec_hash()))]; + let first = plan(input(std::slice::from_ref(&d), &minted, &[])); + assert!(first.iter().any(|a| matches!(a, Action::Delete { .. }))); + assert!(first.iter().any(|a| matches!(a, Action::Provision { .. }))); + + // Pass 2: provisioning adopted the named pubkey, as `provision` does + // for a spec carrying `agent_pubkey`. Nothing further should happen + // beyond starting it — crucially, no second Delete/Provision cycle. + let adopted = vec![record("fizz", &wanted, true, Some(&d.spec_hash()))]; + let second = plan(input(&[d], &adopted, &[])); + assert!( + !second.iter().any(|a| matches!(a, Action::Delete { .. })), + "an adopted identity must not be torn down again" + ); + assert!( + !second.iter().any(|a| matches!(a, Action::Provision { .. })), + "an adopted identity must not be re-provisioned" + ); + assert!(matches!(second.as_slice(), [Action::Start { .. }])); + } + + #[test] + fn relocation_matching_is_case_insensitive() { + // A pubkey that differs only in case is the same identity; treating it + // as a mismatch would restart the mint/delete cycle. + let mut d = desired("fizz", true, 1); + d.spec.agent_pubkey = Some("A".repeat(64)); + let hash = d.spec_hash(); + let r = vec![record("fizz", &"a".repeat(64), true, Some(&hash))]; + let c = vec![container(&"a".repeat(64), true)]; + assert!(plan(input(&[d], &r, &c)).is_empty()); + } + + #[test] + fn leaves_a_matching_relocated_identity_alone() { + // Same pubkey on spec and record: this is the steady state after a + // successful relocation, and must not churn. + let mut d = desired("fizz", true, 1); + d.spec.agent_pubkey = Some("a".repeat(64)); + let hash = d.spec_hash(); + let r = vec![record("fizz", &"a".repeat(64), true, Some(&hash))]; + let c = vec![container(&"a".repeat(64), true)]; + assert!(plan(input(&[d], &r, &c)).is_empty()); + } + + #[test] + fn stops_a_disabled_agent_without_deleting_its_identity() { + let d = vec![desired("fizz", false, 1)]; + let hash = d[0].spec_hash(); + let r = vec![record("fizz", "agent1", true, Some(&hash))]; + let c = vec![container("agent1", true)]; + let actions = plan(input(&d, &r, &c)); + assert!(matches!(actions.as_slice(), [Action::Stop { .. }])); + // Crucially not a Delete — re-enabling must resume the same agent. + assert!(!actions.iter().any(|a| matches!(a, Action::Delete { .. }))); + } + + #[test] + fn deletes_when_the_spec_disappears() { + let hash = desired("fizz", true, 1).spec_hash(); + let r = vec![record("fizz", "agent1", true, Some(&hash))]; + let c = vec![container("agent1", true)]; + let actions = plan(input(&[], &r, &c)); + assert_eq!( + actions, + [Action::Delete { + owner_pubkey: "b".repeat(64), + slug: "fizz".into(), + container_id: Some("ctr-agent1".into()), + }] + ); + } + + #[test] + fn never_deletes_before_desired_state_is_hydrated() { + // The restart bug: at boot the relay has not replayed specs yet, so + // `desired` is empty. Reading that as "every spec was deleted" destroys + // each agent's container, volume, and secret key, and the agent comes + // back as a brand-new pubkey that has lost its attestation and channel + // membership. An absent spec before EOSE means "unknown", not "gone". + let hash = desired("fizz", true, 1).spec_hash(); + let r = vec![record("fizz", "agent1", true, Some(&hash))]; + let c = vec![container("agent1", true)]; + + let mut booting = input(&[], &r, &c); + booting.desired_hydrated = false; + assert!( + plan(booting).is_empty(), + "a pre-hydration pass must not delete anything" + ); + + // Once the relay confirms the spec really is gone, deletion proceeds. + let mut hydrated = input(&[], &r, &c); + hydrated.desired_hydrated = true; + assert!(plan(hydrated) + .iter() + .any(|a| matches!(a, Action::Delete { .. }))); + } + + #[test] + fn still_starts_known_agents_before_hydration() { + // Gating deletion must not stall the additive path: an agent whose spec + // already arrived should start without waiting for EOSE. + let d = vec![desired("fizz", true, 1)]; + let hash = d[0].spec_hash(); + let r = vec![record("fizz", "agent1", true, Some(&hash))]; + + let mut booting = input(&d, &r, &[]); + booting.desired_hydrated = false; + assert!(matches!(plan(booting).as_slice(), [Action::Start { .. }])); + } + + #[test] + fn never_reaps_orphan_containers_before_hydration() { + // Same reasoning for containers: at boot the store may still be loading + // and a running agent would look parentless. + let c = vec![container("stranger", true)]; + let mut booting = input(&[], &[], &c); + booting.desired_hydrated = false; + assert!(plan(booting).is_empty()); + } + + #[test] + fn removes_containers_with_no_record() { + // No record means no key: the spawner cannot manage or speak for it. + let c = vec![container("stranger", true)]; + assert!(matches!( + plan(input(&[], &[], &c)).as_slice(), + [Action::RemoveOrphan { .. }] + )); + } + + #[test] + fn chases_a_timed_out_attestation_instead_of_starting() { + let d = vec![desired("fizz", true, 1)]; + let r = vec![record("fizz", "agent1", false, None)]; + + // Still within the window: wait quietly. + assert!(plan(input(&d, &r, &[])).is_empty()); + + let mut late = input(&d, &r, &[]); + late.now = 1_000 + 601; + assert!(matches!( + plan(late).as_slice(), + [Action::ReRequestAttestation { .. }] + )); + } + + #[test] + fn honors_the_agent_cap() { + let d: Vec<_> = (0..5) + .map(|i| desired(&format!("fizz{i}"), true, 1)) + .collect(); + let r: Vec<_> = d + .iter() + .enumerate() + .map(|(i, x)| record(&x.slug, &format!("agent{i}"), true, Some(&x.spec_hash()))) + .collect(); + + let mut capped = input(&d, &r, &[]); + capped.max_agents = 2; + let starts = plan(capped) + .into_iter() + .filter(|a| matches!(a, Action::Start { .. })) + .count(); + assert_eq!(starts, 2); + } + + #[test] + fn deletions_free_capacity_in_the_same_pass() { + // One agent is going away and one is waiting to start, with room for + // exactly one. The pass must not stall on the departing agent's slot. + let d = vec![desired("new", true, 1)]; + let r = vec![ + record("old", "agent-old", true, Some("stale")), + record("new", "agent-new", true, Some(&d[0].spec_hash())), + ]; + let c = vec![container("agent-old", true)]; + + let mut capped = input(&d, &r, &c); + capped.max_agents = 1; + let actions = plan(capped); + + assert!(actions.iter().any(|a| matches!(a, Action::Delete { .. }))); + assert!(actions.iter().any(|a| matches!(a, Action::Start { .. }))); + } +} diff --git a/crates/buzz-spawner/src/relay.rs b/crates/buzz-spawner/src/relay.rs new file mode 100644 index 0000000000..39bd877629 --- /dev/null +++ b/crates/buzz-spawner/src/relay.rs @@ -0,0 +1,360 @@ +//! Relay client: subscriptions, publishing, and NIP-44 frame handling. +//! +//! The spawner is an ordinary relay client. It authenticates over NIP-42 with +//! its own key, subscribes for the specs addressed to it and the attestation +//! frames sent to it, and publishes status back. It holds no database +//! connection and no privileged relay access. + +use std::{collections::HashMap, time::Duration}; + +use anyhow::{bail, Context, Result}; +use buzz_core::kind::{KIND_PERSONA, KIND_SPAWNER_AGENT_SPEC, KIND_SPAWNER_ATTESTATION}; +use buzz_sdk::spawner::{ + build_spawner_agent_status, build_spawner_announcement, build_spawner_attestation, + spec_from_event, spec_slug_from_event, AttestationFrame, SpawnerAgentStatus, + SpawnerAnnouncement, SPAWNER_TAG, +}; +use buzz_ws_client::{connection::NostrWsConnection, message::RelayMessage}; +use nostr::{Event, Keys, PublicKey}; +use serde_json::json; +use tracing::{debug, warn}; + +use crate::reconcile::DesiredAgent; + +/// Subscription id for agent specs addressed to this spawner. +const SUB_SPECS: &str = "spawner-specs"; + +/// Subscription id for attestation frames addressed to this spawner. +const SUB_ATTESTATION: &str = "spawner-attestation"; + +/// How long to wait for a relay frame before yielding to the reconcile timer. +const RECV_TIMEOUT: Duration = Duration::from_secs(5); + +/// How long to wait for a one-shot query to reach EOSE. +const QUERY_TIMEOUT: Duration = Duration::from_secs(15); + +/// Something the daemon needs to act on. +pub enum Inbound { + /// A spec addressed to this spawner arrived or changed. + Spec { + /// Spec author. + owner_pubkey: String, + /// The parsed desired agent. + desired: DesiredAgent, + /// Event timestamp, so an older revision cannot overwrite a newer one. + created_at: u64, + }, + /// A spec was deleted (NIP-09 tombstone or an emptied replacement). + SpecDeleted { + /// Spec author. + owner_pubkey: String, + /// Spec slug. + slug: String, + /// Event timestamp, so a stale tombstone cannot delete a live spec. + created_at: u64, + }, + /// An attestation frame arrived, already decrypted. + Attestation { + /// Verified event author. + sender: PublicKey, + /// The decrypted frame. + frame: AttestationFrame, + }, + /// The relay finished replaying stored specs. + /// + /// Until this arrives the daemon's desired state is *unknown*, not empty — + /// a distinction that matters enormously, because acting on an absent spec + /// means destroying an agent's container, volume, and secret key. + SpecsHydrated, + /// Nothing arrived before the receive timeout. + Idle, +} + +/// A relay client scoped to spawner duties. +pub struct SpawnerRelay { + conn: NostrWsConnection, + keys: Keys, + relay_url: String, +} + +impl SpawnerRelay { + /// Connect, authenticate, and install both standing subscriptions. + pub async fn connect(relay_url: &str, keys: &Keys) -> Result { + // No NIP-OA tag: the spawner authenticates as itself, an ordinary relay + // member. It is not an agent and has no owner. + let conn = NostrWsConnection::connect_authenticated(relay_url, keys, None) + .await + .with_context(|| format!("failed to authenticate to {relay_url}"))?; + + let mut relay = Self { + conn, + keys: keys.clone(), + relay_url: relay_url.to_string(), + }; + relay.subscribe_all().await?; + Ok(relay) + } + + /// Reconnect after a transport failure, restoring both subscriptions. + pub async fn reconnect(&mut self) -> Result<()> { + self.conn = NostrWsConnection::connect_authenticated(&self.relay_url, &self.keys, None) + .await + .with_context(|| format!("failed to reconnect to {}", self.relay_url))?; + self.subscribe_all().await + } + + async fn subscribe_all(&mut self) -> Result<()> { + let me = self.keys.public_key().to_hex(); + + // Specs name their target spawner, so this filter admits only work + // meant for this daemon even when several share a relay. + self.conn + .send_raw(&json!([ + "REQ", + SUB_SPECS, + { + "kinds": [KIND_SPAWNER_AGENT_SPEC], + format!("#{SPAWNER_TAG}"): [me], + } + ])) + .await?; + + self.conn + .send_raw(&json!([ + "REQ", + SUB_ATTESTATION, + { + "kinds": [KIND_SPAWNER_ATTESTATION], + "#p": [me], + } + ])) + .await?; + + Ok(()) + } + + /// Wait for the next actionable relay frame. + pub async fn next(&mut self) -> Result { + let msg = match self.conn.next_event(RECV_TIMEOUT).await { + Ok(msg) => msg, + // A timeout is the common case, not a failure: it just means no + // event arrived within the window, so the daemon can run its + // periodic reconcile and come back. + Err(buzz_ws_client::error::WsClientError::Timeout) => return Ok(Inbound::Idle), + Err(e) => return Err(e.into()), + }; + + match msg { + RelayMessage::Event { + subscription_id, + event, + } => self.classify(&subscription_id, *event), + RelayMessage::Eose { subscription_id } if subscription_id == SUB_SPECS => { + Ok(Inbound::SpecsHydrated) + } + RelayMessage::Closed { + subscription_id, + message, + } => bail!("relay closed subscription {subscription_id}: {message}"), + _ => Ok(Inbound::Idle), + } + } + + fn classify(&self, subscription_id: &str, event: Event) -> Result { + if subscription_id == SUB_ATTESTATION { + let frame = self.decrypt_frame(&event)?; + return Ok(Inbound::Attestation { + sender: event.pubkey, + frame, + }); + } + + let owner_pubkey = event.pubkey.to_hex(); + let Some(slug) = spec_slug_from_event(&event) else { + // A spec without a `d` tag is unaddressable; there is nothing to + // reconcile it against. + debug!("dropping spec event {} with no d tag", event.id); + return Ok(Inbound::Idle); + }; + + // A NIP-33 replacement with empty content is the tombstone convention + // for parameterized-replaceable events, since a delete leaves nothing + // to fan out. + if event.content.trim().is_empty() { + return Ok(Inbound::SpecDeleted { + owner_pubkey, + slug, + created_at: event.created_at.as_secs(), + }); + } + + match spec_from_event(&event) { + Ok(spec) => Ok(Inbound::Spec { + owner_pubkey: owner_pubkey.clone(), + desired: DesiredAgent { + slug, + owner_pubkey, + spec, + }, + created_at: event.created_at.as_secs(), + }), + Err(e) => { + // An invalid spec is the owner's bug, not ours. Log it and keep + // serving every other agent rather than failing the pass. + warn!("ignoring invalid spec {owner_pubkey}/{slug}: {e}"); + Ok(Inbound::Idle) + } + } + } + + fn decrypt_frame(&self, event: &Event) -> Result { + let plaintext = nostr::nips::nip44::decrypt( + self.keys.secret_key(), + &event.pubkey, + event.content.as_str(), + ) + .context("failed to decrypt attestation frame")?; + + let frame: AttestationFrame = + serde_json::from_str(&plaintext).context("failed to parse attestation frame")?; + frame.validate().context("malformed attestation frame")?; + Ok(frame) + } + + /// Send an attestation frame, NIP-44 encrypted to `recipient`. + pub async fn send_attestation( + &mut self, + recipient: &PublicKey, + frame: &AttestationFrame, + ) -> Result<()> { + let plaintext = + serde_json::to_string(frame).context("failed to serialize attestation frame")?; + let ciphertext = nostr::nips::nip44::encrypt( + self.keys.secret_key(), + recipient, + plaintext, + nostr::nips::nip44::Version::V2, + ) + .context("failed to encrypt attestation frame")?; + + let event = build_spawner_attestation(&recipient.to_hex(), &ciphertext)? + .sign_with_keys(&self.keys)?; + let ok = self.conn.send_event(event).await?; + if !ok.accepted { + bail!("relay rejected attestation frame: {}", ok.message); + } + Ok(()) + } + + /// Publish this spawner's announcement so owners can discover it. + /// + /// Replaceable and keyed by `(pubkey, kind)`, so republishing on every + /// reconcile keeps the advertised capacity roughly current without + /// accumulating events. + pub async fn publish_announcement(&mut self, announcement: &SpawnerAnnouncement) -> Result<()> { + let event = build_spawner_announcement(announcement)?.sign_with_keys(&self.keys)?; + let ok = self.conn.send_event(event).await?; + if !ok.accepted { + bail!("relay rejected announcement: {}", ok.message); + } + Ok(()) + } + + /// Publish a status event for one agent. + pub async fn publish_status( + &mut self, + slug: &str, + owner_pubkey: &str, + status: &SpawnerAgentStatus, + ) -> Result<()> { + let event = + build_spawner_agent_status(slug, owner_pubkey, status)?.sign_with_keys(&self.keys)?; + let ok = self.conn.send_event(event).await?; + if !ok.accepted { + bail!("relay rejected status event: {}", ok.message); + } + Ok(()) + } + + /// Tombstone an agent's status so clients stop showing it. + /// + /// Kind 30179 is replaceable, so a deleted agent's last status — often + /// `pending_attestation` — would otherwise persist forever and every client + /// would keep rendering a row for an agent that no longer exists. An + /// emptied replacement is the tombstone convention for a + /// parameterized-replaceable kind; a kind:5 deletion leaves nothing to fan + /// out, so subscribers would never learn. + /// + /// Built directly rather than through the SDK builder because that + /// validates a status body, which a tombstone deliberately has none of. + pub async fn tombstone_status(&mut self, slug: &str, owner_pubkey: &str) -> Result<()> { + let event = nostr::EventBuilder::new( + nostr::Kind::Custom(buzz_core::kind::KIND_SPAWNER_AGENT_STATUS as u16), + "", + ) + .tags(vec![ + nostr::Tag::parse(["d", slug])?, + nostr::Tag::parse(["p", owner_pubkey])?, + ]) + .sign_with_keys(&self.keys)?; + let ok = self.conn.send_event(event).await?; + if !ok.accepted { + bail!("relay rejected status tombstone: {}", ok.message); + } + Ok(()) + } + + /// Fetch the personas authored by `owner`, keyed by `d` tag. + /// + /// The spawner reads unshared personas through the NIP-OA delegation the + /// relay applies to attested readers, so an owner does not have to publish + /// their system prompts `["shared","true"]` to the whole community just to + /// run an agent. + pub async fn fetch_personas(&mut self, owner: &PublicKey) -> Result> { + let sub_id = format!("personas-{}", &owner.to_hex()[..8]); + self.conn + .send_raw(&json!([ + "REQ", + sub_id, + { "kinds": [KIND_PERSONA], "authors": [owner.to_hex()] } + ])) + .await?; + + let mut personas = HashMap::new(); + let deadline = tokio::time::Instant::now() + QUERY_TIMEOUT; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + warn!("persona query for {} timed out", owner.to_hex()); + break; + } + match self.conn.next_event(remaining).await { + Ok(RelayMessage::Event { + subscription_id, + event, + }) if subscription_id == sub_id => { + if let Some(d) = spec_slug_from_event(&event) { + personas.insert(d, *event); + } + } + Ok(RelayMessage::Eose { subscription_id }) if subscription_id == sub_id => break, + Ok(RelayMessage::Closed { + subscription_id, + message, + }) if subscription_id == sub_id => { + bail!("relay closed persona query: {message}"); + } + // Frames for the standing subscriptions can interleave with + // this one-shot query. Dropping them is safe: specs are + // replaceable and re-delivered on reconnect, and the next + // reconcile pass re-reads state anyway. + Ok(_) => continue, + Err(buzz_ws_client::error::WsClientError::Timeout) => break, + Err(e) => return Err(e.into()), + } + } + + let _ = self.conn.send_raw(&json!(["CLOSE", sub_id])).await; + Ok(personas) + } +} diff --git a/crates/buzz-spawner/src/store.rs b/crates/buzz-spawner/src/store.rs new file mode 100644 index 0000000000..8a41cb0045 --- /dev/null +++ b/crates/buzz-spawner/src/store.rs @@ -0,0 +1,282 @@ +//! On-disk state for the spawner. +//! +//! Holds the one thing that cannot be reconstructed from the relay: each +//! agent's secret key, minted here and never transmitted. Losing this file +//! orphans every running agent — their pubkeys stay in the owner's channels and +//! in relay membership, but nothing can sign as them again. It therefore belongs +//! on a persistent volume, and is written mode 0600 with an atomic +//! write-then-rename so a crash mid-write cannot truncate it. + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// Everything the spawner knows about one agent it manages. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentRecord { + /// The spec slug this agent reconciles, unique per owner. + pub slug: String, + /// Owner pubkey, hex — the author of the spec. + pub owner_pubkey: String, + /// Agent pubkey, hex. + pub agent_pubkey: String, + /// Agent secret key, bech32 nsec. Never leaves this host. + pub private_key_nsec: String, + /// NIP-OA auth tag, as a JSON array string. Absent until the owner attests. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_tag: Option, + /// Nonce of the attestation round currently in flight, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pending_nonce: Option, + /// Unix seconds when the pending attestation request was sent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attestation_sent_at: Option, + /// Hash of the spec content the running container was created from, so + /// drift can be detected without re-reading the container's env. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spec_hash: Option, + /// Prompt material the owner delivered over the encrypted handshake. + /// + /// Present when the spawner cannot read the referenced kind:30175 persona — + /// which is the normal case, since personas are author-only unless shared + /// and the spawner is not the author. Held here rather than fetched because + /// there is nowhere to fetch it from. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, + /// Consecutive failed start attempts, driving backoff. + #[serde(default)] + pub restart_count: u32, + /// Unix seconds of the last failed start, driving backoff. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_failure_at: Option, +} + +impl AgentRecord { + /// Whether this agent can be started. + /// + /// Needs both halves: an owner attestation, and a secret key. A relocated + /// agent is recorded from its spec before its key arrives, so the key check + /// is not redundant — without it the spawner would launch a container with + /// an empty `BUZZ_PRIVATE_KEY` and the harness would fail obscurely. + pub fn is_attested(&self) -> bool { + self.auth_tag.is_some() && !self.private_key_nsec.is_empty() + } + + /// The container name for this agent. + /// + /// Keyed on the owner pubkey prefix as well as the slug: slugs are chosen by + /// clients and are only unique *per owner*, so two owners both naming an + /// agent `fizz` must not collide into one container on a shared host. + pub fn container_name(&self) -> String { + format!( + "buzz-agent-{}-{}", + &self.owner_pubkey[..12.min(self.owner_pubkey.len())], + self.slug + ) + } +} + +/// The serialized state file. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct StateFile { + /// Keyed by `"/"`. + #[serde(default)] + agents: HashMap, +} + +/// Persistent store over a single JSON file. +pub struct Store { + path: PathBuf, + state: StateFile, +} + +/// Compose the map key for an agent. +pub fn agent_key(owner_pubkey: &str, slug: &str) -> String { + format!("{owner_pubkey}/{slug}") +} + +impl Store { + /// Open the store at `state_dir/agents.json`, creating the directory and an + /// empty state file if they do not exist. + pub fn open(state_dir: &Path) -> Result { + std::fs::create_dir_all(state_dir) + .with_context(|| format!("failed to create state dir {}", state_dir.display()))?; + let path = state_dir.join("agents.json"); + + let state = match std::fs::read_to_string(&path) { + Ok(raw) => serde_json::from_str(&raw) + .with_context(|| format!("failed to parse state file {}", path.display()))?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => StateFile::default(), + Err(e) => return Err(e).with_context(|| format!("failed to read {}", path.display())), + }; + + Ok(Self { path, state }) + } + + /// Every agent the spawner is managing. + pub fn agents(&self) -> impl Iterator { + self.state.agents.values() + } + + /// Look up one agent. + pub fn get(&self, owner_pubkey: &str, slug: &str) -> Option<&AgentRecord> { + self.state.agents.get(&agent_key(owner_pubkey, slug)) + } + + /// Find the agent awaiting attestation for `agent_pubkey`, if any. + /// + /// The handshake response identifies itself by agent pubkey rather than by + /// slug, because that is what the owner actually signed over. + pub fn find_by_agent_pubkey(&self, agent_pubkey: &str) -> Option<&AgentRecord> { + self.state + .agents + .values() + .find(|r| r.agent_pubkey == agent_pubkey) + } + + /// Insert or replace an agent and persist. + pub fn put(&mut self, record: AgentRecord) -> Result<()> { + let key = agent_key(&record.owner_pubkey, &record.slug); + self.state.agents.insert(key, record); + self.flush() + } + + /// Mutate an agent in place and persist. Does nothing if it is absent. + pub fn update( + &mut self, + owner_pubkey: &str, + slug: &str, + f: impl FnOnce(&mut AgentRecord), + ) -> Result<()> { + if let Some(record) = self.state.agents.get_mut(&agent_key(owner_pubkey, slug)) { + f(record); + self.flush()?; + } + Ok(()) + } + + /// Remove an agent and persist, returning the removed record. + pub fn remove(&mut self, owner_pubkey: &str, slug: &str) -> Result> { + let removed = self.state.agents.remove(&agent_key(owner_pubkey, slug)); + if removed.is_some() { + self.flush()?; + } + Ok(removed) + } + + /// Write the state file atomically with owner-only permissions. + fn flush(&self) -> Result<()> { + let json = serde_json::to_string_pretty(&self.state) + .context("failed to serialize spawner state")?; + + // Write-then-rename: a crash leaves either the old complete file or the + // new complete file, never a truncated one holding half the agent keys. + let tmp = self.path.with_extension("json.tmp"); + std::fs::write(&tmp, &json) + .with_context(|| format!("failed to write {}", tmp.display()))?; + restrict_permissions(&tmp)?; + std::fs::rename(&tmp, &self.path) + .with_context(|| format!("failed to rename into {}", self.path.display()))?; + Ok(()) + } +} + +#[cfg(unix)] +fn restrict_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .with_context(|| format!("failed to chmod {}", path.display())) +} + +#[cfg(not(unix))] +fn restrict_permissions(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(owner: &str, slug: &str) -> AgentRecord { + AgentRecord { + slug: slug.into(), + owner_pubkey: owner.into(), + agent_pubkey: "a".repeat(64), + private_key_nsec: "nsec1test".into(), + auth_tag: None, + pending_nonce: None, + attestation_sent_at: None, + spec_hash: None, + prompt: None, + restart_count: 0, + last_failure_at: None, + } + } + + #[test] + fn round_trips_through_the_file() { + let dir = std::env::temp_dir().join(format!("buzz-spawner-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + + let owner = "b".repeat(64); + { + let mut store = Store::open(&dir).unwrap(); + store.put(record(&owner, "fizz")).unwrap(); + } + + let store = Store::open(&dir).unwrap(); + assert_eq!(store.get(&owner, "fizz").unwrap().slug, "fizz"); + assert!(store.get(&owner, "missing").is_none()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn an_adopted_agent_is_not_startable_until_its_key_arrives() { + // A relocated agent is recorded from its spec before the owner delivers + // the key. Starting then would launch a container with an empty + // BUZZ_PRIVATE_KEY and fail deep inside the harness. + let mut rec = record(&"b".repeat(64), "fizz"); + rec.private_key_nsec = String::new(); + rec.auth_tag = Some(r#"["auth","o","","s"]"#.into()); + assert!(!rec.is_attested()); + + rec.private_key_nsec = "nsec1real".into(); + assert!(rec.is_attested()); + } + + #[test] + fn container_names_are_scoped_per_owner() { + // Slugs are client-chosen and only unique per owner. Two owners each + // naming an agent "fizz" must not fight over one container. + let a = record(&"a".repeat(64), "fizz"); + let b = record(&"c".repeat(64), "fizz"); + assert_ne!(a.container_name(), b.container_name()); + } + + #[test] + fn state_file_is_owner_only() { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let dir = + std::env::temp_dir().join(format!("buzz-spawner-perm-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + + let mut store = Store::open(&dir).unwrap(); + store.put(record(&"d".repeat(64), "fizz")).unwrap(); + + let mode = std::fs::metadata(dir.join("agents.json")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "state file holds agent secret keys"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + } +} diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index cebe879da0..b15f13b5e7 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -35,6 +35,23 @@ BUZZ_S3_ACCESS_KEY=CHANGE_ME_RANDOM_ACCESS_KEY BUZZ_S3_SECRET_KEY=CHANGE_ME_RANDOM_SECRET_KEY BUZZ_S3_BUCKET=buzz-media +# Server-hosted agents (buzz-spawner). Only used with compose.spawner.yml, +# enabled by BUZZ_COMPOSE_SPAWNER=true. Mounts the host Docker socket — see +# README.md § Server-hosted agents before enabling. +# +# The spawner's own Nostr identity. Generate once and keep it stable: clients +# address agent specs to this pubkey, and it is the pubkey owners approve when +# signing an attestation. +BUZZ_SPAWNER_NSEC=CHANGE_ME_SPAWNER_NSEC +BUZZ_SPAWNER_IMAGE=ghcr.io/block/buzz-spawner:main +BUZZ_SPAWNER_AGENT_IMAGE=ghcr.io/block/buzz-acp:main +BUZZ_SPAWNER_MAX_AGENTS=16 +BUZZ_SPAWNER_MAX_CPU_MILLIS=4000 +BUZZ_SPAWNER_MAX_MEMORY_MIB=8192 +# Names of variables to forward into agent containers, not KEY=VALUE pairs. +BUZZ_SPAWNER_AGENT_ENV=ANTHROPIC_API_KEY +ANTHROPIC_API_KEY=CHANGE_ME_ANTHROPIC_API_KEY + # Optional host ports. Base compose publishes the relay directly on BUZZ_HTTP_PORT. BUZZ_HTTP_PORT=3000 diff --git a/deploy/compose/README.md b/deploy/compose/README.md index 0de524fb5b..ed2faad72d 100644 --- a/deploy/compose/README.md +++ b/deploy/compose/README.md @@ -41,6 +41,68 @@ keypair. Run `./run.sh backup-hint` for the backup checklist. +## Server-hosted agents (buzz-spawner) + +By default every Buzz agent is spawned by the desktop app, so agents stop when +the laptop sleeps and cannot be created from mobile or web at all. The optional +`spawner` service fixes that: it watches the relay for agent specs you publish +and reconciles them into one isolated container per agent. + +```bash +cd deploy/compose +$EDITOR .env # set BUZZ_SPAWNER_NSEC + ANTHROPIC_API_KEY +BUZZ_COMPOSE_SPAWNER=true ./run.sh start +``` + +### ⚠️ Trust boundary: the Docker socket + +**`compose.spawner.yml` mounts `/var/run/docker.sock` into the spawner +container. That is root-equivalent access to the host** — anything that can talk +to that socket can start a privileged container and take over the machine. This +is why the service is opt-in rather than on by default. + +The spawner needs it because creating containers is its entire job. Agents run +arbitrary shell and file-edit tools through `buzz-dev-mcp`, so running several as +bare subprocesses of one daemon would let any agent read every other agent's +workspace and secret key. Per-agent containers are the isolation. + +If you want to reduce the blast radius, point the spawner at a **rootless +Docker or Podman socket** instead of the system one: + +```bash +BUZZ_SPAWNER_DOCKER_SOCKET=/run/user/1000/docker.sock \ + BUZZ_COMPOSE_SPAWNER=true ./run.sh start +``` + +### Notes + +- `BUZZ_ALLOW_NIP_OA_AUTH=true` is required. Spawned agents authenticate by + owner attestation; without it none of them can connect. +- Agent containers are deliberately **not** attached to `buzz-net`. They reach + the relay at `BUZZ_SPAWNER_AGENT_RELAY_URL` (defaulting to `RELAY_URL`) the + same way any external client does, so an agent's shell cannot reach Postgres, + Redis, or MinIO directly. +- **Both spawner URLs must use the relay's public host.** The relay derives the + community from the HTTP `Host` header, so `ws://relay:3000` is a *different + tenant* from `wss://your.domain`. Pointing the spawner at the internal compose + name puts it in a community your owner account is not in — status events never + reach you — or fails the connection outright with a 404, because internal + names have no community row. +- `BUZZ_SPAWNER_NSEC` must stay stable. Clients address specs to this pubkey, + and it is the pubkey an owner approves when signing an attestation — rotating + it strands every existing agent. +- `BUZZ_SPAWNER_AGENT_ENV` takes variable **names**, not `KEY=VALUE` pairs. The + values are read from the spawner's own environment, so a rotated credential is + picked up by restarting the spawner rather than by editing agent config. +- Back up the `buzz-spawner-data` volume. It holds each agent's secret key, + minted on this host and never transmitted anywhere. Losing it orphans every + running agent: their pubkeys remain in your channels and relay membership, but + nothing can sign as them again. +- Creating an agent needs a one-time approval from the owner's client. The + spawner mints the key, then asks the owner to sign a NIP-OA attestation for it + — it cannot self-attest. Until that is approved the agent's status event + reports `pending_attestation`. + ## Validation Before sharing an install link publicly, verify a fresh install with: diff --git a/deploy/compose/compose.spawner.yml b/deploy/compose/compose.spawner.yml new file mode 100644 index 0000000000..751737d1b3 --- /dev/null +++ b/deploy/compose/compose.spawner.yml @@ -0,0 +1,72 @@ +# Server-hosted agents. Opt in with BUZZ_COMPOSE_SPAWNER=true ./run.sh start +# +# ⚠️ This override mounts the host's Docker socket into the spawner container. +# That is root-equivalent access to the host: anything able to talk to that +# socket can start a privileged container and take over the machine. Enable it +# only on a host where you are comfortable with that, and read the trust-boundary +# section in README.md first. +# +# The spawner needs the socket because its entire job is creating one isolated +# container per agent — agents execute arbitrary shell and file-edit tools via +# buzz-dev-mcp, so running them as bare subprocesses of one daemon would let any +# agent read every other agent's workspace and secret key. +# +# To reduce the blast radius, point DOCKER_HOST at a rootless Docker or Podman +# socket instead of the system one. + +services: + spawner: + image: ${BUZZ_SPAWNER_IMAGE:-ghcr.io/block/buzz-spawner:main} + # No `env_file: .env` here, unlike the relay. Everything the spawner needs is + # listed explicitly below, so it never holds the database, Redis, or S3 + # credentials it has no use for. + environment: + BUZZ_SPAWNER_NSEC: ${BUZZ_SPAWNER_NSEC:?set BUZZ_SPAWNER_NSEC} + # Both URLs must use the relay's PUBLIC host, not the internal compose + # name. The relay derives which community a connection belongs to from the + # HTTP Host header, so `ws://relay:3000` is a different tenant from + # `wss://your.domain` — a spawner on the internal name would publish status + # into a community the owner is not in, and the owner would never see it + # (or, more likely, get a 404: internal names have no community row). + BUZZ_SPAWNER_RELAY_URL: ${BUZZ_SPAWNER_RELAY_URL:-${RELAY_URL:?set RELAY_URL}} + # Agent containers are deliberately NOT on buzz-net — an agent runs + # arbitrary shell tools, and joining that network would put Postgres, + # Redis, and MinIO one `nc` away. They dial the same public address, which + # also keeps them in the same community as their owner. + BUZZ_SPAWNER_AGENT_RELAY_URL: ${BUZZ_SPAWNER_AGENT_RELAY_URL:-${RELAY_URL:?set RELAY_URL}} + BUZZ_SPAWNER_STATE_DIR: /var/lib/buzz-spawner + BUZZ_SPAWNER_AGENT_IMAGE: ${BUZZ_SPAWNER_AGENT_IMAGE:-ghcr.io/block/buzz-acp:main} + # Which ACP agent runs inside each container. Unset keeps the image + # default (`buzz-agent`, metered API key). Set `claude-agent-acp` to run + # on a Claude subscription — then add CLAUDE_CODE_OAUTH_TOKEN to + # BUZZ_SPAWNER_AGENT_ENV below. Operator-only: it selects what executes, + # so no agent spec can influence it. + BUZZ_SPAWNER_AGENT_COMMAND: ${BUZZ_SPAWNER_AGENT_COMMAND:-} + BUZZ_SPAWNER_AGENT_ARGS: ${BUZZ_SPAWNER_AGENT_ARGS:-} + BUZZ_SPAWNER_MAX_AGENTS: ${BUZZ_SPAWNER_MAX_AGENTS:-16} + BUZZ_SPAWNER_MAX_CPU_MILLIS: ${BUZZ_SPAWNER_MAX_CPU_MILLIS:-4000} + BUZZ_SPAWNER_MAX_MEMORY_MIB: ${BUZZ_SPAWNER_MAX_MEMORY_MIB:-8192} + # Names of variables to forward into agent containers — not KEY=VALUE + # pairs. The value is read from this service's own environment, so a + # rotated credential is picked up by restarting the spawner. + BUZZ_SPAWNER_AGENT_ENV: ${BUZZ_SPAWNER_AGENT_ENV:-ANTHROPIC_API_KEY} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + # Long-lived Claude subscription token from `claude setup-token`, run once + # on a machine with a browser. Only used when BUZZ_SPAWNER_AGENT_COMMAND + # is claude-agent-acp. + CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN:-} + RUST_LOG: ${BUZZ_SPAWNER_LOG:-buzz_spawner=info} + volumes: + - buzz-spawner-data:/var/lib/buzz-spawner + - ${BUZZ_SPAWNER_DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock + depends_on: + relay: + condition: service_healthy + restart: unless-stopped + networks: + - buzz-net + +volumes: + buzz-spawner-data: + labels: + com.buzz.volume: spawner diff --git a/deploy/compose/run.sh b/deploy/compose/run.sh index d5465ea1f5..ee2d6b06a8 100755 --- a/deploy/compose/run.sh +++ b/deploy/compose/run.sh @@ -11,6 +11,10 @@ fi if [[ "${BUZZ_COMPOSE_DEV:-false}" == "true" ]]; then COMPOSE_FILES+=(-f compose.dev.yml) fi +# Opt-in: mounts the host Docker socket. See README.md § Server-hosted agents. +if [[ "${BUZZ_COMPOSE_SPAWNER:-false}" == "true" ]]; then + COMPOSE_FILES+=(-f compose.spawner.yml) +fi compose() { docker compose --env-file .env "${COMPOSE_FILES[@]}" "$@" @@ -123,6 +127,9 @@ Commands: Environment switches: BUZZ_COMPOSE_TLS=true Include compose.caddy.yml for automatic HTTPS BUZZ_COMPOSE_DEV=true Include compose.dev.yml for local admin ports/tools + BUZZ_COMPOSE_SPAWNER=true + Include compose.spawner.yml for server-hosted agents. + Mounts the host Docker socket — read README.md first. MSG ;; *) diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 6acfa25224..625eadf34d 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/server-agents-screenshots.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", "**/identity-key-help.spec.ts", "**/key-import-reveal.spec.ts", diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 461fed5dbd..030bb28d59 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -663,6 +663,7 @@ mod tests { env_vars: Default::default(), start_on_app_launch: false, auto_restart_on_config_change: true, + relocated_to_spawner: None, runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs index 2317930c1e..9ef4408147 100644 --- a/desktop/src-tauri/src/commands/agent_settings.rs +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -69,6 +69,58 @@ pub async fn set_managed_agent_start_on_app_launch( .map_err(|e| format!("spawn_blocking failed: {e}"))? } +/// Record that this agent's identity was handed to a spawner (NIP-AS +/// relocation). Pass `Some(spawner_pubkey)` right after a successful +/// attestation hand-off, `None` to move the agent back to this device. +/// +/// This is state, not an action: the flag is what every local start path — +/// manual start, app-launch restore, runtime reconcile, auto-restart — checks +/// before spawning, so a relocated identity can never run in two places. +#[tauri::command] +pub async fn set_managed_agent_relocated( + pubkey: String, + relocated_to_spawner: Option, + app: AppHandle, +) -> Result { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; + + let (sync_changed, exited_pubkeys) = + sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); + if sync_changed { + save_managed_agents(&app, &records)?; + } + for pubkey in &exited_pubkeys { + state.clear_agent_session_caches(pubkey); + } + + { + let record = find_managed_agent_mut(&mut records, &pubkey)?; + record.relocated_to_spawner = relocated_to_spawner; + record.updated_at = now_iso(); + } + + save_managed_agents(&app, &records)?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + let personas = load_personas(&app).unwrap_or_default(); + build_managed_agent_summary(&app, record, &runtimes, &personas) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + #[tauri::command] pub async fn set_managed_agent_auto_restart( pubkey: String, diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 3b5ebeca4f..e7ec04f4f2 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -919,6 +919,7 @@ pub async fn create_managed_agent( input.start_on_app_launch }, auto_restart_on_config_change: true, + relocated_to_spawner: None, runtime_pid: None, backend: input.backend.clone(), backend_agent_id: None, diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index e32fc1cfe4..85a5cc9e66 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -31,6 +31,7 @@ fn bare_agent_record( persona_source_version: None, env_vars: BTreeMap::new(), start_on_app_launch: false, + relocated_to_spawner: None, runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index a048ad24af..fe40b64a13 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -51,6 +51,7 @@ mod qr_download; mod relay_members; mod relay_reconnect; mod social; +mod spawner; mod team_snapshot; mod teams; mod updater; @@ -101,6 +102,7 @@ pub use qr_download::*; pub use relay_members::*; pub use relay_reconnect::*; pub use social::*; +pub use spawner::*; pub use team_snapshot::*; pub use teams::*; pub use updater::*; diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 316af5f72d..4f1a8ad726 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -39,6 +39,7 @@ fn make_agent( persona_source_version: None, env_vars: BTreeMap::new(), start_on_app_launch: false, + relocated_to_spawner: None, runtime_pid, backend: BackendKind::Local, backend_agent_id: None, diff --git a/desktop/src-tauri/src/commands/personas/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound_tests.rs index 1000e48b70..58ced78a4f 100644 --- a/desktop/src-tauri/src/commands/personas/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound_tests.rs @@ -178,6 +178,7 @@ fn local_agent() -> ManagedAgentRecord { env_vars: BTreeMap::from([("API_KEY".to_string(), "localsecret".to_string())]), start_on_app_launch: true, auto_restart_on_config_change: true, + relocated_to_spawner: None, runtime_pid: Some(1234), backend: crate::managed_agents::BackendKind::Provider { id: "buzz-backend".to_string(), diff --git a/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs index ba855ccbd6..b5a0a481dc 100644 --- a/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/name_propagation_tests.rs @@ -28,6 +28,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge env_vars: std::collections::BTreeMap::new(), start_on_app_launch: false, auto_restart_on_config_change: true, + relocated_to_spawner: None, runtime_pid: None, backend: Default::default(), backend_agent_id: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index ac5c0eace6..eef5b7cd27 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -455,6 +455,7 @@ pub async fn confirm_agent_snapshot_import( env_vars: std::collections::BTreeMap::new(), start_on_app_launch: false, auto_restart_on_config_change: true, + relocated_to_spawner: None, runtime_pid: None, backend: crate::managed_agents::BackendKind::Local, backend_agent_id: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index b1d19f06b6..ea912ac0ae 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -44,6 +44,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { env_vars: BTreeMap::new(), start_on_app_launch: false, auto_restart_on_config_change: false, + relocated_to_spawner: None, runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, diff --git a/desktop/src-tauri/src/commands/spawner.rs b/desktop/src-tauri/src/commands/spawner.rs new file mode 100644 index 0000000000..f44cd791bd --- /dev/null +++ b/desktop/src-tauri/src/commands/spawner.rs @@ -0,0 +1,324 @@ +//! Owner side of the NIP-AS spawner attestation handshake. +//! +//! A `buzz-spawner` daemon mints an agent's secret key on the server and never +//! transmits it. But a NIP-OA auth tag binds one specific agent pubkey and is +//! signed with the *owner's* secret key, so the spawner cannot self-attest — it +//! has to ask. This module is the answer to that question. +//! +//! # Security: the auth tag is a delegation, not a formality +//! +//! Signing an attestation admits that agent pubkey to the relay under **this +//! user's** membership. A malicious spawner that gets a signature can run an +//! agent that reads the user's channels. So: +//! +//! - Signing is never automatic for an unrecognized spawner. The frontend must +//! have recorded an explicit trust decision first; [`respond_to_spawner_attestation`] +//! fails closed when it has not. +//! - The tag is computed only for the agent pubkey named in the *decrypted* +//! frame, so a frame body cannot ask for a signature over some other key +//! while displaying a benign one. +//! - The response is NIP-44 encrypted back to the same spawner that asked, so +//! an eavesdropper on the ephemeral kind:24201 stream gets ciphertext. + +use nostr::JsonUtil; +use serde::{Deserialize, Serialize}; + +use buzz_sdk_pkg::spawner::{build_spawner_attestation, AttestationFrame, PromptMaterial}; + +use crate::app_state::AppState; +use crate::managed_agents::load_managed_agents; + +/// The trusted-spawner decision the frontend has already made. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SpawnerTrust { + /// The user has approved this spawner pubkey. + Trusted, + /// The user has not approved it, or explicitly declined. + Untrusted, +} + +/// A decrypted attestation request, for display before the user decides. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SpawnerAttestationRequest { + /// Spawner pubkey that sent the request, hex. + pub spawner_pubkey: String, + /// Spec slug the agent belongs to. + pub spec_slug: String, + /// The freshly minted agent pubkey the spawner wants attested. + pub agent_pubkey: String, + /// NIP-OA conditions the spawner is asking to be signed over. + pub conditions: String, + /// Handshake nonce, echoed back in the response. + pub nonce: String, +} + +/// Decrypt an inbound kind:24201 frame so the UI can show the user what is +/// being asked before anything is signed. +/// +/// Returns `Ok(None)` for a frame that is not a request — responses and +/// rejections are the owner's own outbound traffic echoed back, and are not +/// something to prompt about. +#[tauri::command] +pub async fn decode_spawner_attestation( + state: tauri::State<'_, AppState>, + spawner_pubkey: String, + encrypted_content: String, +) -> Result, String> { + let keys = state.signing_keys()?; + let spawner = nostr::PublicKey::from_hex(&spawner_pubkey) + .map_err(|e| format!("invalid spawner pubkey: {e}"))?; + + let frame = decrypt_frame(&keys, &spawner, &encrypted_content)?; + + match frame { + AttestationFrame::Request { + spec_slug, + agent_pubkey, + conditions, + nonce, + } => Ok(Some(SpawnerAttestationRequest { + spawner_pubkey: spawner.to_hex(), + spec_slug, + agent_pubkey, + conditions, + nonce, + })), + _ => Ok(None), + } +} + +/// Build the signed answer to an attestation request: sign the NIP-OA tag when +/// `trust` is [`SpawnerTrust::Trusted`], and an explicit rejection otherwise. +/// +/// Rejecting explicitly rather than staying silent matters — it lets the +/// spawner report a clear `failed` status immediately instead of leaving the +/// agent stuck in `pending_attestation` until its timeout expires. +/// +/// # Why this returns the event instead of publishing it +/// +/// Kind 24201 is ephemeral. The relay routes ephemeral events through its +/// WebSocket handler; `POST /events` runs the ingest path, whose per-kind scope +/// allowlist has no arm for ephemeral kinds and rejects them with +/// `restricted: unknown event kind`. Kind 24200 observer control frames solve +/// this the same way — `build_observer_control_event` signs here and the +/// renderer publishes over the live socket. +/// Outcome of answering an attestation, for the caller to act on. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpawnerAttestationResponse { + /// The signed kind:24201 event to publish over the WebSocket. + pub event_json: String, + /// Set when this handover relocated a locally-managed agent. + /// + /// The caller MUST stop the local process for this pubkey: two runners + /// holding one key both see every mention and both reply, so the agent + /// answers twice and burns two turns per message. + #[serde(skip_serializing_if = "Option::is_none")] + pub relocated_agent_pubkey: Option, +} + +#[tauri::command] +pub async fn respond_to_spawner_attestation( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + spawner_pubkey: String, + encrypted_content: String, + trust: SpawnerTrust, + reject_reason: Option, + prompt: Option, +) -> Result { + let keys = state.signing_keys()?; + let spawner = nostr::PublicKey::from_hex(&spawner_pubkey) + .map_err(|e| format!("invalid spawner pubkey: {e}"))?; + + // Re-decrypt rather than trusting fields passed back from the frontend. The + // signature must cover the pubkey the spawner actually asked about, not one + // the renderer could have substituted after the user saw the prompt. + let frame = decrypt_frame(&keys, &spawner, &encrypted_content)?; + let AttestationFrame::Request { + spec_slug, + agent_pubkey, + conditions, + nonce, + } = frame + else { + return Err("attestation frame is not a request".into()); + }; + + // Relocation: when the spawner is asking about an agent this device already + // manages, the answer carries that agent's existing key so the SAME identity + // moves — its channels, profile, and NIP-AE memory all hang off that pubkey, + // and a fresh key would strand every one of them. Looked up from the local + // store rather than taken from the frame, so a spawner cannot name an + // arbitrary pubkey and be handed a key for it. + let mut relocated_agent_pubkey = None; + let mut relocation_nsec = None; + if matches!(trust, SpawnerTrust::Trusted) { + let managed = load_managed_agents(&app)?; + if let Some(record) = managed + .iter() + .find(|record| record.pubkey.eq_ignore_ascii_case(&agent_pubkey)) + { + if record.private_key_nsec.trim().is_empty() { + return Err(format!( + "cannot relocate {}: its key is unavailable on this device", + record.name + )); + } + relocated_agent_pubkey = Some(record.pubkey.clone()); + relocation_nsec = Some(record.private_key_nsec.clone()); + } + } + + let response = match trust { + SpawnerTrust::Untrusted => AttestationFrame::Reject { + spec_slug, + agent_pubkey, + nonce, + reason: Some( + reject_reason.unwrap_or_else(|| "owner has not approved this spawner".into()), + ), + }, + SpawnerTrust::Trusted => { + let agent = nostr::PublicKey::from_hex(&agent_pubkey) + .map_err(|e| format!("invalid agent pubkey in attestation request: {e}"))?; + let auth_tag = buzz_sdk_pkg::nip_oa::compute_auth_tag(&keys, &agent, &conditions) + .map_err(|e| format!("failed to compute owner auth tag: {e}"))?; + AttestationFrame::Response { + spec_slug, + agent_pubkey, + nonce, + auth_tag, + private_key_nsec: relocation_nsec, + // Prompt material rides the encrypted channel rather than the + // world-readable spec, so an agent's instructions never become + // public. Absent for a shared persona the spawner can read. + prompt: prompt.filter(|p| !p.is_empty()), + } + } + }; + + let plaintext = serde_json::to_string(&response) + .map_err(|e| format!("failed to serialize attestation response: {e}"))?; + let ciphertext = nostr::nips::nip44::encrypt( + keys.secret_key(), + &spawner, + plaintext, + nostr::nips::nip44::Version::V2, + ) + .map_err(|e| format!("failed to encrypt attestation response: {e}"))?; + + let event = build_spawner_attestation(&spawner.to_hex(), &ciphertext) + .map_err(|e| format!("failed to build attestation event: {e}"))? + .sign_with_keys(&keys) + .map_err(|e| format!("failed to sign attestation event: {e}"))?; + Ok(SpawnerAttestationResponse { + event_json: event.as_json(), + relocated_agent_pubkey, + }) +} + +fn decrypt_frame( + keys: &nostr::Keys, + spawner: &nostr::PublicKey, + encrypted_content: &str, +) -> Result { + let plaintext = nostr::nips::nip44::decrypt(keys.secret_key(), spawner, encrypted_content) + .map_err(|e| format!("failed to decrypt attestation frame: {e}"))?; + let frame: AttestationFrame = serde_json::from_str(&plaintext) + .map_err(|e| format!("failed to parse attestation frame: {e}"))?; + frame + .validate() + .map_err(|e| format!("malformed attestation frame: {e}"))?; + Ok(frame) +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_sdk_pkg::spawner::ATTESTATION_NONCE_BYTES; + use nostr::Keys; + + fn encrypted_request(spawner: &Keys, owner: &Keys, agent_pubkey: &str) -> String { + let frame = AttestationFrame::Request { + spec_slug: "fizz-prod".into(), + agent_pubkey: agent_pubkey.into(), + conditions: String::new(), + nonce: "ab".repeat(ATTESTATION_NONCE_BYTES), + }; + nostr::nips::nip44::encrypt( + spawner.secret_key(), + &owner.public_key(), + serde_json::to_string(&frame).unwrap(), + nostr::nips::nip44::Version::V2, + ) + .unwrap() + } + + #[test] + fn decodes_a_request_addressed_to_this_owner() { + let owner = Keys::generate(); + let spawner = Keys::generate(); + let agent = Keys::generate(); + let ciphertext = encrypted_request(&spawner, &owner, &agent.public_key().to_hex()); + + let frame = decrypt_frame(&owner, &spawner.public_key(), &ciphertext).unwrap(); + assert_eq!(frame.agent_pubkey(), agent.public_key().to_hex()); + assert_eq!(frame.spec_slug(), "fizz-prod"); + } + + #[test] + fn cannot_decrypt_a_frame_meant_for_someone_else() { + let owner = Keys::generate(); + let stranger = Keys::generate(); + let spawner = Keys::generate(); + let agent = Keys::generate(); + let ciphertext = encrypted_request(&spawner, &stranger, &agent.public_key().to_hex()); + + assert!(decrypt_frame(&owner, &spawner.public_key(), &ciphertext).is_err()); + } + + #[test] + fn the_signed_tag_verifies_for_the_requested_agent_only() { + // The tag must cover exactly the pubkey in the decrypted frame. A tag + // that verified for any other key would let a spawner get a signature + // for an agent the user never saw. + let owner = Keys::generate(); + let agent = Keys::generate(); + let other = Keys::generate(); + + let auth_tag = + buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + + assert_eq!( + buzz_sdk_pkg::nip_oa::verify_auth_tag(&auth_tag, &agent.public_key()).unwrap(), + owner.public_key() + ); + assert!(buzz_sdk_pkg::nip_oa::verify_auth_tag(&auth_tag, &other.public_key()).is_err()); + } + + #[test] + fn a_malformed_frame_is_refused_before_anything_is_signed() { + let owner = Keys::generate(); + let spawner = Keys::generate(); + // Valid JSON, valid encryption, but a nonce of the wrong width. + let frame = serde_json::json!({ + "type": "request", + "spec_slug": "fizz-prod", + "agent_pubkey": Keys::generate().public_key().to_hex(), + "conditions": "", + "nonce": "short", + }); + let ciphertext = nostr::nips::nip44::encrypt( + spawner.secret_key(), + &owner.public_key(), + frame.to_string(), + nostr::nips::nip44::Version::V2, + ) + .unwrap(); + + assert!(decrypt_frame(&owner, &spawner.public_key(), &ciphertext).is_err()); + } +} diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 0476be79a9..52d152de2c 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -574,6 +574,7 @@ pub async fn confirm_team_snapshot_import( env_vars: std::collections::BTreeMap::new(), start_on_app_launch: false, auto_restart_on_config_change: true, + relocated_to_spawner: None, runtime_pid: None, backend: crate::managed_agents::BackendKind::Local, backend_agent_id: None, diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index ca7dc61830..21c550400c 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -196,6 +196,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { env_vars: Default::default(), start_on_app_launch: false, auto_restart_on_config_change: true, + relocated_to_spawner: None, runtime_pid: None, backend: crate::managed_agents::BackendKind::Local, backend_agent_id: None, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 64405d0440..e1fd68d1ba 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -716,6 +716,8 @@ pub fn run() { get_media_proxy_port, fetch_link_preview_title, discover_acp_auth_methods, + decode_spawner_attestation, + respond_to_spawner_attestation, discover_acp_providers, discover_git_bash_prerequisite, install_acp_runtime, @@ -803,6 +805,7 @@ pub fn run() { set_agent_managed_profiles, set_managed_agent_start_on_app_launch, set_managed_agent_auto_restart, + set_managed_agent_relocated, delete_managed_agent, get_managed_agent_log, get_agent_models, diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index ba4407d164..5bb1fa02f4 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -181,6 +181,7 @@ mod tests { env_vars: BTreeMap::from([("OPENAI_API_KEY".to_string(), "sk-secret".to_string())]), start_on_app_launch: true, auto_restart_on_config_change: true, + relocated_to_spawner: None, runtime_pid: Some(4242), backend: super::super::BackendKind::Provider { id: "buzz-backend-x".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index b0bf8f5991..ebfb24fca4 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -502,6 +502,7 @@ mod tests { }, start_on_app_launch: true, auto_restart_on_config_change: true, + relocated_to_spawner: None, runtime_pid: Some(12345), // MUST NOT appear backend: BackendKind::Provider { // MUST NOT appear — carries a provider secret diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 4c11cd6c49..4130a816a2 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -84,6 +84,7 @@ fn test_record() -> ManagedAgentRecord { env_vars: BTreeMap::new(), start_on_app_launch: false, auto_restart_on_config_change: true, + relocated_to_spawner: None, runtime_pid: None, backend: crate::managed_agents::types::BackendKind::Local, backend_agent_id: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 48e8d5479c..b848e609e0 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -336,6 +336,7 @@ fn record_with( persona_source_version: None, start_on_app_launch: false, auto_restart_on_config_change: true, + relocated_to_spawner: None, runtime_pid: None, backend: Default::default(), backend_agent_id: None, diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 33b93d8a52..62fac28bc4 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -321,6 +321,7 @@ fn bare_record() -> ManagedAgentRecord { persona_source_version: None, env_vars: BTreeMap::new(), start_on_app_launch: false, + relocated_to_spawner: None, runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index a959381603..8b25c13003 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -457,6 +457,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { persona_source_version: None, start_on_app_launch: false, auto_restart_on_config_change: true, + relocated_to_spawner: None, runtime_pid: None, backend: BackendKind::default(), backend_agent_id: None, diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c5480b2479..d5bbada123 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1488,6 +1488,7 @@ mod tests { env_vars, start_on_app_launch: false, auto_restart_on_config_change: true, + relocated_to_spawner: None, runtime_pid: None, backend: Default::default(), backend_agent_id: None, diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 1910620159..361b13b5d8 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -165,7 +165,13 @@ pub async fn restore_managed_agents_on_launch( let candidates: Vec = records .iter() - .filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local) + .filter(|record| { + record.start_on_app_launch + && record.backend == BackendKind::Local + // A relocated identity runs on its spawner; resurrecting it + // here would answer every mention twice. + && record.relocated_to_spawner.is_none() + }) .map(|record| record.pubkey.clone()) .collect(); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index f3b4cb67fd..edcadb7cb7 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -335,6 +335,7 @@ pub fn build_managed_agent_summary( last_error_code: record.last_error_code, start_on_app_launch: record.start_on_app_launch, auto_restart_on_config_change: record.auto_restart_on_config_change, + relocated_to_spawner: record.relocated_to_spawner.clone(), log_path, respond_to: record.respond_to, respond_to_allowlist: record.respond_to_allowlist.clone(), @@ -461,6 +462,12 @@ pub fn spawn_agent_child( if let Some(error) = spawn_key_refusal(record) { return Err(error); } + // Relocated identities live on their spawner now; every spawn path funnels + // through here, so this closes local resurrection (manual start, restore, + // reconcile, lazy @mention wake, auto-restart) in one place. + if let Some(error) = super::spawn_relocation_refusal(record) { + return Err(error); + } let runtime_key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?; // Resolve the effective harness (agent command) from the linked persona, so // persona harness edits propagate on the next spawn; an explicit per-agent diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 8deb0c4da9..5fa3c1c8a2 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -153,6 +153,7 @@ fn fixture( env_vars: std::collections::BTreeMap::new(), start_on_app_launch: false, auto_restart_on_config_change: true, + relocated_to_spawner: None, runtime_pid: None, backend: Default::default(), backend_agent_id: None, diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index c0e55184b1..d3e730e07f 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -466,9 +466,13 @@ pub async fn reconcile_managed_agent_runtimes( let records = load_managed_agents(&app)?; let mut jobs = Vec::new(); for community in communities { - for record in records - .iter() - .filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local) + for record in records.iter().filter(|record| { + record.start_on_app_launch + && record.backend == BackendKind::Local + // Relocated identities run on their spawner — never fan + // out a local pair for them. + && record.relocated_to_spawner.is_none() + }) // The legacy per-record relay pin is deliberately ignored here — see // `effective_agent_relay_url`. Every local auto-start agent fans out // to every configured community. diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs index 686ad52d4f..0ae24d23b8 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs @@ -27,6 +27,7 @@ fn record() -> ManagedAgentRecord { env_vars: BTreeMap::new(), start_on_app_launch: false, auto_restart_on_config_change: true, + relocated_to_spawner: None, runtime_pid: None, backend: Default::default(), backend_agent_id: None, diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index f6f89ed898..5bcbdde881 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -206,6 +206,21 @@ pub(crate) fn spawn_key_refusal(record: &ManagedAgentRecord) -> Option { }) } +/// Refuse to spawn an agent whose identity has been relocated to a spawner. +/// Once the attestation hand-off gives the spawner this agent's secret key, +/// the server copy is the agent — starting a local process too would run one +/// identity in two places (duplicate replies, two turns billed per message). +/// Every local spawn path must check this and fail closed. +pub(crate) fn spawn_relocation_refusal(record: &ManagedAgentRecord) -> Option { + record.relocated_to_spawner.as_ref().map(|spawner| { + format!( + "agent {} now runs on a server (spawner {}). Refusing to start a local copy — \ + it would answer every mention twice.", + record.name, spawner + ) + }) +} + /// Read the raw unified store — keyed instances AND key-less definitions — /// with fail-loud parse handling. Internal seam; public readers filter. fn load_agent_store(app: &AppHandle) -> Result, String> { diff --git a/desktop/src-tauri/src/managed_agents/storage_tests.rs b/desktop/src-tauri/src/managed_agents/storage_tests.rs index 73567bb915..0be41b794f 100644 --- a/desktop/src-tauri/src/managed_agents/storage_tests.rs +++ b/desktop/src-tauri/src/managed_agents/storage_tests.rs @@ -251,6 +251,41 @@ fn spawn_allowed_when_private_key_present() { assert!(super::spawn_key_refusal(&record).is_none()); } +#[test] +fn spawn_refused_when_relocated_to_a_spawner() { + // After a relocation hand-off the spawner holds this identity's key. + // Starting it here too would run one identity in two places: duplicate + // replies, two turns billed per message. Every spawn path must refuse. + let mut record = record_with_key("nsec1realkey"); + record.relocated_to_spawner = Some("a".repeat(64)); + assert!( + super::spawn_relocation_refusal(&record).is_some(), + "a relocated agent must never be started locally" + ); +} + +#[test] +fn spawn_allowed_when_not_relocated() { + let record = record_with_key("nsec1realkey"); + assert!(super::spawn_relocation_refusal(&record).is_none()); +} + +#[test] +fn relocation_field_defaults_to_none_and_stays_out_of_json_when_unset() { + // Pre-relocation stores must keep parsing (serde default), and an + // unset field must not appear in serialized JSON. + let record = record_with_key("nsec1realkey"); + assert_eq!(record.relocated_to_spawner, None); + let json = serde_json::to_string(&record).expect("serialize"); + assert!(!json.contains("relocated_to_spawner")); + + let mut relocated = record_with_key("nsec1realkey"); + relocated.relocated_to_spawner = Some("b".repeat(64)); + let json = serde_json::to_string(&relocated).expect("serialize"); + let back: ManagedAgentRecord = serde_json::from_str(&json).expect("round-trip"); + assert_eq!(back.relocated_to_spawner, Some("b".repeat(64))); +} + #[test] fn persist_agent_keys_issues_zero_writes_when_inline_keys_already_cleared() { // This is the dominant prompt-storm scenario: after the first successful diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index d88a362723..afe3fed7bf 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -280,6 +280,7 @@ mod tests { }, start_on_app_launch: false, auto_restart_on_config_change: true, + relocated_to_spawner: None, runtime_pid: None, backend: BackendKind::Local, backend_agent_id: None, diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 140ac3cab9..8034151d73 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -187,6 +187,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { env_vars: std::collections::BTreeMap::new(), start_on_app_launch: false, auto_restart_on_config_change: false, + relocated_to_spawner: None, runtime_pid: None, backend: crate::managed_agents::BackendKind::Local, backend_agent_id: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index dcb8095a7c..d590f79a06 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -108,6 +108,7 @@ impl AgentDefinition { env_vars: self.env_vars, start_on_app_launch: false, auto_restart_on_config_change: true, + relocated_to_spawner: None, runtime_pid: None, backend: BackendKind::default(), backend_agent_id: None, @@ -294,6 +295,13 @@ pub struct ManagedAgentRecord { /// frontend only fires when the agent is idle, connected, and local. #[serde(default = "default_auto_restart_on_config_change")] pub auto_restart_on_config_change: bool, + /// Set (to the spawner's pubkey) when this agent's secret key was handed + /// to a `buzz-spawner` over the attestation handshake — the identity now + /// runs on that server. A relocated record is retained for provenance and + /// a possible move-back, but every local spawn path must refuse it: two + /// runners holding one key both answer every mention. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relocated_to_spawner: Option, #[serde(default)] pub runtime_pid: Option, #[serde(default)] @@ -532,6 +540,10 @@ pub struct ManagedAgentSummary { pub last_error_code: Option, pub start_on_app_launch: bool, pub auto_restart_on_config_change: bool, + /// Mirrors `ManagedAgentRecord.relocated_to_spawner`: `Some(spawner + /// pubkey)` when this identity was handed to a server. The UI shows + /// "runs on server" instead of Start, and the auto-restart policy holds. + pub relocated_to_spawner: Option, pub log_path: String, pub respond_to: RespondTo, pub respond_to_allowlist: Vec, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 7ed9fa9348..416c0e2d87 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -41,6 +41,8 @@ import { useManagedAgentRuntimeReconciliation } from "@/features/agents/useManag import { useAutoRestartPolicy } from "@/features/agents/lib/useAutoRestartPolicy"; import { usePersonaSync } from "@/features/agents/lib/usePersonaSync"; import { useAgentObserverIngestion } from "@/features/agents/useAgentObserverIngestion"; +import { useSpawnerIngestion } from "@/features/agents/useSpawnerIngestion"; +import { SpawnerAttestationDialog } from "@/features/agents/ui/SpawnerAttestationDialog"; import { AgentManagementDialogs } from "@/features/agents/ui/AgentManagementDialogs"; import { RequestedAgentCreateDialogs } from "@/features/agents/ui/RequestedAgentCreateDialogs"; import { @@ -179,6 +181,9 @@ export function AppShell() { // relay-owned agents join automatically once identity arrives. Adding a // guard here would drop managed-agent coverage during startup. useAgentObserverIngestion(); + // Kind 24201 is ephemeral and relay-routed by `#p`: a request that arrives + // while the Agents screen is unmounted is gone, so this is app-wide. + useSpawnerIngestion(); // Kind 24200 is relay-ephemeral, so reconciliation runs eagerly (not // deferred) and unconditionally repairs the DB subscription on internal // builds — otherwise frames emitted before the listener opens are lost. @@ -937,6 +942,7 @@ export function AppShell() { )} + { + assert.deepEqual(resolveDefaultAgentLocation([]), LOCAL); +}); + +test("storesASpawnerAsTheDefault", () => { + assert.equal( + setDefaultAgentLocation({ kind: "spawner", spawnerPubkey: SPAWNER_A }), + true, + ); + assert.deepEqual(resolveDefaultAgentLocation([SPAWNER_A]), { + kind: "spawner", + spawnerPubkey: SPAWNER_A, + }); +}); + +test("fallsBackToLocalWhenTheDefaultSpawnerIsDisconnected", () => { + // Otherwise every new agent would target a spawner this device no longer + // manages and fail to deploy, with no obvious cause. + setDefaultAgentLocation({ kind: "spawner", spawnerPubkey: SPAWNER_A }); + assert.deepEqual(resolveDefaultAgentLocation([SPAWNER_B]), LOCAL); + // Reconnecting restores it: the preference is not destroyed by the fallback. + assert.deepEqual(resolveDefaultAgentLocation([SPAWNER_A]), { + kind: "spawner", + spawnerPubkey: SPAWNER_A, + }); +}); + +test("rejectsAMalformedSpawnerPubkey", () => { + assert.equal( + setDefaultAgentLocation({ kind: "spawner", spawnerPubkey: "nope" }), + false, + ); +}); + +test("normalisesCaseSoLookupsMatchConnectedSpawners", () => { + setDefaultAgentLocation({ + kind: "spawner", + spawnerPubkey: SPAWNER_A.toUpperCase(), + }); + assert.deepEqual(resolveDefaultAgentLocation([SPAWNER_A]), { + kind: "spawner", + spawnerPubkey: SPAWNER_A, + }); +}); + +test("localCanBeSetBackExplicitly", () => { + setDefaultAgentLocation({ kind: "spawner", spawnerPubkey: SPAWNER_A }); + setDefaultAgentLocation(LOCAL); + assert.deepEqual(resolveDefaultAgentLocation([SPAWNER_A]), LOCAL); +}); + +test("sameLocationComparesKindAndSpawner", () => { + assert.ok(sameLocation(LOCAL, LOCAL)); + assert.ok( + sameLocation( + { kind: "spawner", spawnerPubkey: SPAWNER_A }, + { kind: "spawner", spawnerPubkey: SPAWNER_A }, + ), + ); + assert.ok( + !sameLocation( + { kind: "spawner", spawnerPubkey: SPAWNER_A }, + { kind: "spawner", spawnerPubkey: SPAWNER_B }, + ), + ); + assert.ok( + !sameLocation(LOCAL, { kind: "spawner", spawnerPubkey: SPAWNER_A }), + ); +}); diff --git a/desktop/src/features/agents/agentLocation.ts b/desktop/src/features/agents/agentLocation.ts new file mode 100644 index 0000000000..8f88c0d4c5 --- /dev/null +++ b/desktop/src/features/agents/agentLocation.ts @@ -0,0 +1,157 @@ +import React from "react"; + +import { getSpawners, useSpawners } from "./spawnerPreference"; + +/** + * Where an agent runs — deliberately separate from *which runtime* it runs. + * + * The two are orthogonal. A runtime (goose, Claude Code, codex, buzz-agent) says + * which binary drives the agent; a location says whose machine that binary runs + * on. Folding "server" into the runtime list would conflate them, and would make + * "Claude Code" silently mean "Claude Code, locally". + * + * `{ kind: "local" }` is the historical behaviour: the desktop spawns the + * harness itself, and the agent dies with the app. `{ kind: "spawner" }` means a + * `buzz-spawner` runs it, and it keeps working when the app is closed. + * + * The *runtime* of a server agent is not represented here on purpose: the host + * decides what executes there, and the spawner merely advertises it for display + * (see `SpawnerAnnouncement.runtime`). + */ +export type AgentLocation = + | { kind: "local" } + | { kind: "spawner"; spawnerPubkey: string }; + +/** The location used for new agents when nothing more specific applies. */ +const STORAGE_KEY = "buzz:default-agent-location"; + +export const LOCAL: AgentLocation = { kind: "local" }; + +const listeners = new Set<() => void>(); + +let defaultLocation: AgentLocation = readStored(); + +function isPubkeyHex(value: string): boolean { + return value.length === 64 && /^[0-9a-f]+$/i.test(value); +} + +function readStored(): AgentLocation { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return LOCAL; + // Stored as the spawner pubkey, or "local". A bare pubkey keeps the format + // trivially inspectable in devtools. + if (raw === "local") return LOCAL; + if (isPubkeyHex(raw)) { + return { kind: "spawner", spawnerPubkey: raw.toLowerCase() }; + } + return LOCAL; + } catch { + return LOCAL; + } +} + +function notify(): void { + for (const listener of listeners) listener(); +} + +/** + * Set the default location for new agents. + * + * Returns false for a malformed spawner pubkey so the caller can surface a + * validation message instead of silently falling back to local. + */ +export function setDefaultAgentLocation(location: AgentLocation): boolean { + if (location.kind === "spawner" && !isPubkeyHex(location.spawnerPubkey)) { + return false; + } + defaultLocation = + location.kind === "local" + ? LOCAL + : { + kind: "spawner", + spawnerPubkey: location.spawnerPubkey.toLowerCase(), + }; + try { + window.localStorage.setItem( + STORAGE_KEY, + defaultLocation.kind === "local" + ? "local" + : defaultLocation.spawnerPubkey, + ); + } catch { + // Keep the in-memory value; the choice still applies this session. + } + notify(); + return true; +} + +/** + * The default location, falling back to local when its spawner is gone. + * + * A device can be disconnected from the spawner that was the default. Returning + * a location that points at a spawner this device no longer manages would make + * every new agent fail to deploy, so the fallback is explicit rather than + * stored — reconnecting restores the preference. + */ +export function resolveDefaultAgentLocation( + connectedSpawners: readonly string[] = getSpawners(), +): AgentLocation { + if ( + defaultLocation.kind === "spawner" && + !connectedSpawners.includes(defaultLocation.spawnerPubkey) + ) { + return LOCAL; + } + return defaultLocation; +} + +/** The raw stored default, ignoring whether its spawner is still connected. */ +export function getStoredDefaultAgentLocation(): AgentLocation { + return defaultLocation; +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): AgentLocation { + return defaultLocation; +} + +function getServerSnapshot(): AgentLocation { + return LOCAL; +} + +/** + * Reactive default location, already resolved against connected spawners. + * + * This is what a create-agent flow should read: personas — including the + * built-in Fizz/Honey/Bumble — inherit it rather than each carrying their own + * copy, so changing the default moves every agent that has not been given an + * explicit location. + */ +export function useDefaultAgentLocation(): AgentLocation { + const stored = React.useSyncExternalStore( + subscribe, + getSnapshot, + getServerSnapshot, + ); + const spawners = useSpawners(); + if (stored.kind === "spawner" && !spawners.includes(stored.spawnerPubkey)) { + return LOCAL; + } + return stored; +} + +/** Whether two locations refer to the same place. */ +export function sameLocation(a: AgentLocation, b: AgentLocation): boolean { + if (a.kind !== b.kind) return false; + if (a.kind === "spawner" && b.kind === "spawner") { + return a.spawnerPubkey === b.spawnerPubkey; + } + return true; +} diff --git a/desktop/src/features/agents/agentRelocation.test.mjs b/desktop/src/features/agents/agentRelocation.test.mjs new file mode 100644 index 0000000000..4cbe924ef2 --- /dev/null +++ b/desktop/src/features/agents/agentRelocation.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildRelocationPlan, + isRelocationOfLocalAgent, +} from "./agentRelocation.ts"; + +const AGENT_PUBKEY = + "3333333333333333333333333333333333333333333333333333333333333333"; + +test("carriesTheExistingPubkeySoTheSpawnerReusesTheIdentity", () => { + // Without `agentPubkey` the spawner mints a fresh key, which orphans the + // agent's NIP-AE memory forever. This field is the entire relocation. + const { slug, spec } = buildRelocationPlan({ + pubkey: AGENT_PUBKEY, + name: "Fizz (prod)", + personaId: "builtin:fizz", + }); + + assert.equal(spec.agentPubkey, AGENT_PUBKEY); + assert.equal(slug, "fizz-prod"); + assert.equal(spec.name, "Fizz (prod)"); + assert.equal(spec.enabled, true); +}); + +test("neverPutsASecretOrAPromptOnTheSpec", () => { + // A kind:30178 spec is world-readable. Anything secret-shaped here is a leak. + const { spec } = buildRelocationPlan({ + pubkey: AGENT_PUBKEY, + name: "Honey", + personaId: "builtin:honey", + }); + + const serialized = JSON.stringify(spec); + assert.ok(!/nsec|secret|privateKey|seckey/i.test(serialized)); + assert.equal(spec.systemPrompt, undefined); +}); + +test("publishesThePersonaRelayAddressNotTheRawId", () => { + // `builtin:fizz` cannot be a `d` tag — the relay rejects the colon — so a raw + // id would point at a persona that cannot exist. + const { spec } = buildRelocationPlan({ + pubkey: AGENT_PUBKEY, + name: "Fizz", + personaId: "builtin:fizz", + }); + + assert.equal(spec.personaId, "builtin-fizz"); +}); + +test("fallsBackToAPlaceholderPromptWhenTheAgentHasNoPersona", () => { + // The spec still has to validate; the real prompt arrives over the handshake. + const { spec } = buildRelocationPlan({ + pubkey: AGENT_PUBKEY, + name: "Custom", + personaId: null, + }); + + assert.equal(spec.personaId, undefined); + assert.equal(typeof spec.systemPrompt, "string"); +}); + +test("refusesANameThatYieldsNoUsableSlug", () => { + // The slug becomes a container and volume name on the host. + assert.throws( + () => buildRelocationPlan({ pubkey: AGENT_PUBKEY, name: "???" }), + /no usable server name/i, + ); +}); + +test("detectsARelocationRegardlessOfHexCasing", () => { + assert.equal( + isRelocationOfLocalAgent(AGENT_PUBKEY.toUpperCase(), [AGENT_PUBKEY]), + true, + ); + assert.equal(isRelocationOfLocalAgent(AGENT_PUBKEY, []), false); + assert.equal(isRelocationOfLocalAgent(null, [AGENT_PUBKEY]), false); + assert.equal(isRelocationOfLocalAgent(undefined, [AGENT_PUBKEY]), false); +}); diff --git a/desktop/src/features/agents/agentRelocation.ts b/desktop/src/features/agents/agentRelocation.ts new file mode 100644 index 0000000000..2466b99caf --- /dev/null +++ b/desktop/src/features/agents/agentRelocation.ts @@ -0,0 +1,79 @@ +import type { SpawnerAgentSpec } from "@/shared/api/spawnerRelay"; +import { personaDTag, slugFromName } from "./spawnerPreference"; + +/** + * The fields of a local agent that a relocation needs. + * + * Structural rather than `ManagedAgent` so this stays a pure, testable function + * of three strings instead of dragging the whole managed-agent shape in. + */ +export type RelocatableAgent = { + pubkey: string; + name: string; + personaId?: string | null; +}; + +/** A relocation spec, ready to publish at `slug`. */ +export type RelocationPlan = { + slug: string; + spec: SpawnerAgentSpec; +}; + +/** + * Build the kind:30178 spec that moves an existing agent onto a spawner. + * + * What makes this a *relocation* rather than a second agent is `agentPubkey`: + * the spawner reads it as "this agent already exists" and asks for its secret + * over the encrypted handshake instead of minting a new key. Keeping the key is + * the whole point — it carries the agent's channel membership, its profile, its + * kind:30177 record (whose `d` tag is the pubkey itself), its DMs, and NIP-AE + * memory that a new key could never decrypt. + * + * Only the public key goes on the spec. Specs are world-readable. + * + * Throws when the agent's name yields no usable slug, because the slug becomes + * a container and volume name on the host and the Rust side rejects anything + * outside `[a-z0-9_-]`. + */ +export function buildRelocationPlan(agent: RelocatableAgent): RelocationPlan { + const slug = slugFromName(agent.name); + if (!slug) { + throw new Error( + `"${agent.name}" has no usable server name. Rename it using letters or digits, then move it.`, + ); + } + return { + slug, + spec: { + name: agent.name, + agentPubkey: agent.pubkey, + // A persona reference, never the prompt: the prompt travels over the + // encrypted handshake. Without a persona the spawner still needs + // *something* to validate against, so fall back to a placeholder the + // handshake overrides. + ...(agent.personaId + ? { personaId: personaDTag(agent.personaId) } + : { systemPrompt: "Server-hosted Buzz agent." }), + parallelism: 1, + respondTo: "anyone", + enabled: true, + }, + }; +} + +/** + * Whether an attestation request is asking for an agent this device already + * runs. + * + * Pubkeys are compared case-insensitively: hex from the relay and hex from the + * local store are the same key regardless of casing, and a mismatch here would + * silently downgrade the relocation warning to "a new key was created". + */ +export function isRelocationOfLocalAgent( + agentPubkey: string | null | undefined, + localAgentPubkeys: readonly string[], +): boolean { + if (!agentPubkey) return false; + const target = agentPubkey.toLowerCase(); + return localAgentPubkeys.some((pubkey) => pubkey.toLowerCase() === target); +} diff --git a/desktop/src/features/agents/lib/autoRestartPolicy.test.mjs b/desktop/src/features/agents/lib/autoRestartPolicy.test.mjs index d36d4c7a18..e389e46d8f 100644 --- a/desktop/src/features/agents/lib/autoRestartPolicy.test.mjs +++ b/desktop/src/features/agents/lib/autoRestartPolicy.test.mjs @@ -23,6 +23,7 @@ function greenInputs(overrides = {}) { workingSource: "none", connected: true, isLocalBackend: true, + isRelocated: false, isRunning: true, edgeConsumed: false, quiescentForMs: AUTO_RESTART_QUIESCENCE_MS, @@ -56,6 +57,10 @@ const NEVER_FIRE_ROWS = [ ["observer relay not connected", { connected: false }], ["remote backend", { isLocalBackend: false }], ["agent not running", { isRunning: false }], + [ + "identity relocated to a spawner (restart here would split-brain)", + { isRelocated: true }, + ], [ "edge already consumed (one attempt per rising edge)", { edgeConsumed: true }, diff --git a/desktop/src/features/agents/lib/autoRestartPolicy.ts b/desktop/src/features/agents/lib/autoRestartPolicy.ts index 2d06a42e39..199a0fd133 100644 --- a/desktop/src/features/agents/lib/autoRestartPolicy.ts +++ b/desktop/src/features/agents/lib/autoRestartPolicy.ts @@ -37,6 +37,9 @@ export type AutoRestartInputs = { connected: boolean; /** Only local agents can be restarted by this loop. */ isLocalBackend: boolean; + /** Identity handed to a spawner (`relocatedToSpawner` set). Restarting the + * local copy would run one identity in two places — never fire. */ + isRelocated: boolean; /** Agent process status from the summary ("running" required). */ isRunning: boolean; /** Edge-trigger state: true when this needsRestart rising edge has @@ -62,6 +65,7 @@ export function decideAutoRestart( workingSource, connected, isLocalBackend, + isRelocated, isRunning, edgeConsumed, quiescentForMs, @@ -71,6 +75,7 @@ export function decideAutoRestart( if (!autoRestartEnabled) return "hold"; if (!needsRestart) return "hold"; if (!isLocalBackend) return "hold"; + if (isRelocated) return "hold"; if (!isRunning) return "hold"; if (!connected) return "hold"; // Any working signal — observer OR typing — defers. `working` and diff --git a/desktop/src/features/agents/lib/useAutoRestartPolicy.ts b/desktop/src/features/agents/lib/useAutoRestartPolicy.ts index e8e149ccd1..eff2b6edd0 100644 --- a/desktop/src/features/agents/lib/useAutoRestartPolicy.ts +++ b/desktop/src/features/agents/lib/useAutoRestartPolicy.ts @@ -72,6 +72,7 @@ export function useAutoRestartPolicy() { workingSource: working.source, connected: observer.connectionState === "open", isLocalBackend: agent.backend.type === "local", + isRelocated: agent.relocatedToSpawner !== null, isRunning, edgeConsumed: edge.consumed, quiescentForMs: edge.armedAt === null ? 0 : now - edge.armedAt, @@ -104,6 +105,7 @@ export function useAutoRestartPolicy() { if ( !current?.needsRestart || !current.autoRestartOnConfigChange || + current.relocatedToSpawner !== null || current.status !== "running" || getAgentWorkingState(agent.pubkey).source !== "none" ) { diff --git a/desktop/src/features/agents/spawnerAttestationStore.ts b/desktop/src/features/agents/spawnerAttestationStore.ts new file mode 100644 index 0000000000..1c7ae42d81 --- /dev/null +++ b/desktop/src/features/agents/spawnerAttestationStore.ts @@ -0,0 +1,325 @@ +import React from "react"; +import { toast } from "sonner"; + +import { getIdentity } from "@/shared/api/tauriIdentity"; +import { + setManagedAgentRelocated, + stopManagedAgent, +} from "@/shared/api/tauriManagedAgents"; +import { + respondToSpawnerAttestation, + subscribeToSpawnerAttestations, +} from "@/shared/api/spawnerRelay"; +import { + buildSpawnerAttestationResponse, + decodeSpawnerAttestation, + type SpawnerAttestationRequest, + type SpawnerAttestationResponse, + type SpawnerPromptMaterial, +} from "@/shared/api/tauriSpawner"; +import type { RelayEvent } from "@/shared/api/types"; +import { isSpawnerTrusted, trustSpawner } from "./trustedSpawners"; + +/** + * Inbound kind:24201 attestation requests awaiting the owner's decision. + * + * A module-level singleton rather than React state, matching + * `observerRelayStore`: the relay subscription must outlive any one component, + * and the queue has to survive navigation so a request that arrives while the + * user is elsewhere is not lost. + * + * # Auto-approval + * + * A request from an already-trusted spawner is signed without prompting. That + * is the whole point of `trustedSpawners` — an owner running five agents on + * their own VPS approves the spawner once, not five times. Requests from an + * unknown spawner always queue for a human. + */ + +/** A pending request, carrying the ciphertext needed to answer it. */ +export type PendingAttestation = SpawnerAttestationRequest & { + /** Original NIP-44 ciphertext, re-decrypted in Rust when responding. */ + encryptedContent: string; + /** Relay event id, used to deduplicate replays on reconnect. */ + eventId: string; +}; + +const listeners = new Set<() => void>(); + +let pending: readonly PendingAttestation[] = []; +let unsubscribeRelay: (() => Promise) | null = null; +let startPromise: Promise | null = null; + +/** + * Event ids already handled. + * + * The subscription uses a `since` lookback, so a reconnect can redeliver a + * frame that was already answered. Without this the owner would be prompted + * twice for one agent, and the second answer would be rejected by the spawner + * anyway (its nonce is no longer in flight). + */ +const handledEventIds = new Set(); + +/** + * Resolves prompt material for a spec slug at approval time. + * + * Injected rather than imported so this store stays free of the persona layer. + * `useSpawnerIngestion` wires the real resolver; until it does, approvals send + * no prompt and a spawner falls back to reading a shared persona. + */ +let promptResolver: + | ((specSlug: string) => SpawnerPromptMaterial | null) + | null = null; + +/** Register the prompt resolver used when answering an attestation. */ +export function setSpawnerPromptResolver( + resolver: ((specSlug: string) => SpawnerPromptMaterial | null) | null, +): void { + promptResolver = resolver; +} + +function promptFor(specSlug: string): SpawnerPromptMaterial | undefined { + return promptResolver?.(specSlug) ?? undefined; +} + +/** + * Publish an attestation answer, then retire the local copy of a relocated + * agent. + * + * The order is deliberate and load-bearing: + * + * 1. **Publish first.** If the response never reaches the spawner the agent is + * still only running here, and stopping it first would take the user's agent + * offline everywhere for nothing. + * 2. **Mark relocated second.** Relocation must be *state*, not just a stop: + * the auto-restart policy, app-launch restore, runtime reconcile, and the + * manual Start button all resurrect a merely-stopped agent (the split-brain + * found in testing came from exactly that). The persisted flag is what every + * start path checks, so it lands before the stop it protects. + * 3. **Stop third, and only on `relocatedAgentPubkey`.** Rust sets that field + * exactly when it handed the agent's secret key to the spawner, so from that + * moment two processes hold one key. Both would see every mention and both + * would reply: duplicate answers, and the owner billed twice per turn. + * + * A failed mark or stop is surfaced as a toast rather than swallowed. It leaves + * a real split brain that only the user can resolve, so silence is the one + * unacceptable outcome — the publish itself already succeeded and must not be + * retried. + */ +async function publishAndRetireRelocated( + response: SpawnerAttestationResponse, + spawnerPubkey: string, +): Promise { + await respondToSpawnerAttestation(response.event); + + const relocated = response.relocatedAgentPubkey; + if (!relocated) return; + try { + await setManagedAgentRelocated(relocated, spawnerPubkey); + await stopManagedAgent(relocated); + } catch (error) { + toast.error( + `This agent now runs on the server, but the copy on this Mac could not be retired: ${ + error instanceof Error ? error.message : "unknown error" + }. Stop it from the Agents screen — until you do, it will answer twice.`, + { duration: Number.POSITIVE_INFINITY }, + ); + } +} + +/** Bound on `handledEventIds` so a long session cannot grow it without limit. */ +const MAX_HANDLED_IDS = 500; + +const EMPTY: readonly PendingAttestation[] = []; + +function notify(): void { + for (const listener of listeners) listener(); +} + +function markHandled(eventId: string): void { + handledEventIds.add(eventId); + if (handledEventIds.size > MAX_HANDLED_IDS) { + // Insertion-ordered: dropping the oldest keeps the window covering recent + // frames, which is all the lookback can redeliver. + const oldest = handledEventIds.values().next().value; + if (oldest !== undefined) handledEventIds.delete(oldest); + } +} + +function removePending(nonce: string): void { + const next = pending.filter((item) => item.nonce !== nonce); + if (next.length !== pending.length) { + pending = next; + notify(); + } +} + +async function handleAttestationEvent(event: RelayEvent): Promise { + if (handledEventIds.has(event.id)) return; + markHandled(event.id); + + let request: SpawnerAttestationRequest | null; + try { + request = await decodeSpawnerAttestation(event.pubkey, event.content); + } catch { + // Not addressed to us, malformed, or not decryptable with our key. Anyone + // can publish a p-tagged frame, so this is expected traffic, not an error + // worth surfacing. + return; + } + if (!request) return; + + // The verified event author is the spawner. The frame body does not name its + // own sender, so there is nothing to cross-check here — Rust decrypts with + // this pubkey, meaning a frame that decodes at all came from it. + if (request.spawnerPubkey !== event.pubkey) return; + + if (isSpawnerTrusted(request.spawnerPubkey)) { + try { + await publishAndRetireRelocated( + await buildSpawnerAttestationResponse({ + spawnerPubkey: request.spawnerPubkey, + encryptedContent: event.content, + trust: "trusted", + prompt: promptFor(request.specSlug), + }), + request.spawnerPubkey, + ); + } catch { + // Fall through to prompting. A failed auto-approval (relay down, + // signing unavailable) should surface to the user rather than leaving + // the agent stuck at pending_attestation with no explanation. + enqueue(request, event); + } + return; + } + + enqueue(request, event); +} + +function enqueue(request: SpawnerAttestationRequest, event: RelayEvent): void { + // A spawner re-sends with a fresh nonce after a timeout. Replacing the entry + // for the same agent keeps one prompt per agent rather than stacking stale + // ones the spawner would reject. + const withoutSameAgent = pending.filter( + (item) => item.agentPubkey !== request.agentPubkey, + ); + pending = [ + ...withoutSameAgent, + { ...request, encryptedContent: event.content, eventId: event.id }, + ]; + notify(); +} + +/** Approve a pending request, optionally remembering the spawner. */ +export async function approveAttestation( + item: PendingAttestation, + options: { remember: boolean }, +): Promise { + await publishAndRetireRelocated( + await buildSpawnerAttestationResponse({ + spawnerPubkey: item.spawnerPubkey, + encryptedContent: item.encryptedContent, + trust: "trusted", + prompt: promptFor(item.specSlug), + }), + item.spawnerPubkey, + ); + // Remember only after the signature actually went out, so a failed publish + // does not leave a spawner silently trusted for every future request. + if (options.remember) trustSpawner(item.spawnerPubkey); + removePending(item.nonce); +} + +/** + * Decline a pending request. + * + * Sends an explicit rejection rather than dropping it silently, so the spawner + * reports `failed` immediately instead of leaving the agent at + * `pending_attestation` until its timeout expires. + */ +export async function declineAttestation( + item: PendingAttestation, + reason?: string, +): Promise { + // Deliberately not `publishAndRetireRelocated`: a rejection hands over no + // key, so nothing may stop a local agent on this path. + const { event } = await buildSpawnerAttestationResponse({ + spawnerPubkey: item.spawnerPubkey, + encryptedContent: item.encryptedContent, + trust: "untrusted", + rejectReason: reason, + }); + await respondToSpawnerAttestation(event); + removePending(item.nonce); +} + +/** Open the attestation subscription. Idempotent. */ +export async function ensureSpawnerAttestationSubscription(): Promise { + if (unsubscribeRelay) return; + if (startPromise) return startPromise; + + startPromise = (async () => { + const identity = await getIdentity(); + if (!identity?.pubkey) return; + const dispose = await subscribeToSpawnerAttestations( + identity.pubkey, + (event) => { + void handleAttestationEvent(event); + }, + ); + // A reset that landed while we were connecting must not leave a live + // subscription behind for the previous identity. + if (startPromise === null) { + void dispose(); + return; + } + unsubscribeRelay = dispose; + })(); + + try { + await startPromise; + } finally { + if (unsubscribeRelay) startPromise = null; + } +} + +/** + * Tear down the subscription and drop queued state. + * + * Wired into `resetCommunityState()` — pending prompts belong to the community + * and identity they arrived under, and showing one after a community switch + * would ask the user to sign with the wrong key. + */ +export function resetSpawnerAttestationStore(): void { + const dispose = unsubscribeRelay; + unsubscribeRelay = null; + startPromise = null; + if (dispose) void dispose(); + + handledEventIds.clear(); + if (pending.length > 0) { + pending = []; + notify(); + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): readonly PendingAttestation[] { + return pending; +} + +function getServerSnapshot(): readonly PendingAttestation[] { + return EMPTY; +} + +/** Reactive view of the pending attestation queue. */ +export function usePendingAttestations(): readonly PendingAttestation[] { + return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/desktop/src/features/agents/spawnerDirectoryStore.ts b/desktop/src/features/agents/spawnerDirectoryStore.ts new file mode 100644 index 0000000000..b18ef6b18b --- /dev/null +++ b/desktop/src/features/agents/spawnerDirectoryStore.ts @@ -0,0 +1,115 @@ +import React from "react"; + +import { + parseSpawnerAnnouncement, + subscribeToSpawnerAnnouncements, + type SpawnerAnnouncement, +} from "@/shared/api/spawnerRelay"; +import type { RelayEvent } from "@/shared/api/types"; + +/** + * Spawners that have announced themselves in this community, keyed by pubkey. + * + * # This is a phone book, not an allowlist + * + * Anyone can publish a kind:10180. Appearing here means only "some pubkey + * claims to be a spawner" — every field is self-reported and unverified. + * Connecting is still an explicit user action, and running an agent still + * requires the owner to sign a per-agent NIP-OA attestation. Nothing in this + * store may be treated as a security property. + */ + +const listeners = new Set<() => void>(); + +let announcements: ReadonlyMap = new Map(); +let unsubscribeRelay: (() => Promise) | null = null; +let startPromise: Promise | null = null; + +/** + * Newest `created_at` per pubkey. + * + * Kind 10180 is replaceable and the spawner republishes on every reconcile, so + * a reconnect can redeliver an older revision after a newer one. Without this + * guard a stale capacity count could overwrite the current one. + */ +const latestCreatedAt = new Map(); + +const EMPTY: ReadonlyMap = new Map(); + +function notify(): void { + for (const listener of listeners) listener(); +} + +function handleAnnouncementEvent(event: RelayEvent): void { + const announcement = parseSpawnerAnnouncement(event); + if (!announcement) return; + + const previous = latestCreatedAt.get(event.pubkey); + if (previous !== undefined && event.created_at <= previous) return; + latestCreatedAt.set(event.pubkey, event.created_at); + + const next = new Map(announcements); + next.set(event.pubkey, announcement); + announcements = next; + notify(); +} + +/** Open the announcement subscription. Idempotent. */ +export async function ensureSpawnerDirectorySubscription(): Promise { + if (unsubscribeRelay) return; + if (startPromise) return startPromise; + + startPromise = (async () => { + const dispose = await subscribeToSpawnerAnnouncements( + handleAnnouncementEvent, + ); + if (startPromise === null) { + void dispose(); + return; + } + unsubscribeRelay = dispose; + })(); + + try { + await startPromise; + } finally { + if (unsubscribeRelay) startPromise = null; + } +} + +/** Tear down the subscription and drop the directory. */ +export function resetSpawnerDirectoryStore(): void { + const dispose = unsubscribeRelay; + unsubscribeRelay = null; + startPromise = null; + if (dispose) void dispose(); + + latestCreatedAt.clear(); + if (announcements.size > 0) { + announcements = new Map(); + notify(); + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): ReadonlyMap { + return announcements; +} + +function getServerSnapshot(): ReadonlyMap { + return EMPTY; +} + +/** Reactive view of every announced spawner, keyed by pubkey. */ +export function useSpawnerDirectory(): ReadonlyMap< + string, + SpawnerAnnouncement +> { + return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/desktop/src/features/agents/spawnerPreference.test.mjs b/desktop/src/features/agents/spawnerPreference.test.mjs new file mode 100644 index 0000000000..a6956ae6da --- /dev/null +++ b/desktop/src/features/agents/spawnerPreference.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { personaDTag, slugFromName } from "./spawnerPreference.ts"; + +// The Rust side (`check_spec_slug` in buzz-sdk) accepts only lowercase ASCII +// letters, digits, hyphens, and underscores, must not start with `-` or `_`, +// and caps at 64 bytes. Slugs reach container names, volume names, and log +// paths on the spawner host, so anything else is rejected outright. These +// tests pin the normalization that keeps a user from hitting that wall. +const ACCEPTED = /^[a-z0-9][a-z0-9_-]*$/; + +test("lowercasesAndHyphenatesOrdinaryNames", () => { + assert.equal(slugFromName("Fizz"), "fizz"); + assert.equal(slugFromName("Code Reviewer"), "code-reviewer"); +}); + +test("stripsCharactersTheSpawnerWouldReject", () => { + // "Fizz (prod)" would otherwise fail validation with a message the user + // cannot act on. + const slug = slugFromName("Fizz (prod)!"); + assert.equal(slug, "fizz-prod"); + assert.match(slug, ACCEPTED); +}); + +test("neverLeadsWithAHyphenOrUnderscore", () => { + // A leading hyphen is explicitly rejected by check_spec_slug. + assert.equal(slugFromName(" -Fizz"), "fizz"); + assert.equal(slugFromName("!!!Fizz"), "fizz"); + assert.match(slugFromName("-_-Fizz"), ACCEPTED); +}); + +test("neverTrailsWithAHyphenEvenAfterTruncation", () => { + // Truncating at 64 bytes can land mid-separator and reintroduce a trailing + // hyphen after the first strip. + const slug = slugFromName(`${"a".repeat(63)} tail`); + assert.ok(slug.length <= 64); + assert.ok(!slug.endsWith("-")); + assert.match(slug, ACCEPTED); +}); + +test("respectsTheSixtyFourByteCap", () => { + const slug = slugFromName("x".repeat(200)); + assert.equal(slug.length, 64); + assert.match(slug, ACCEPTED); +}); + +test("returnsNullWhenNothingUsableSurvives", () => { + // Better to refuse with a clear message than to publish an empty d-tag the + // spawner cannot address. + assert.equal(slugFromName(""), null); + assert.equal(slugFromName(" "), null); + assert.equal(slugFromName("!!!"), null); + assert.equal(slugFromName("日本語"), null); +}); + +test("mapsAPersonaIdToItsRelayDTag", () => { + // The relay rejects a `d` tag containing a colon, so the built-ins are + // published under a normalised slug. A spec carrying the raw id would point + // at a persona that cannot exist. + assert.equal(personaDTag("builtin:fizz"), "builtin-fizz"); + assert.equal(personaDTag("builtin:honey"), "builtin-honey"); + assert.equal(personaDTag("Code Reviewer"), "code-reviewer"); +}); + +test("personaDTagAlwaysStartsAlphanumericAndFitsTheGrammar", () => { + const accepted = /^[a-z0-9][a-z0-9_-]{0,63}$/; + assert.match(personaDTag(":leading-colon"), accepted); + assert.match(personaDTag("_underscore"), accepted); + assert.match(personaDTag("x".repeat(200)), accepted); +}); diff --git a/desktop/src/features/agents/spawnerPreference.ts b/desktop/src/features/agents/spawnerPreference.ts new file mode 100644 index 0000000000..d49c366d7f --- /dev/null +++ b/desktop/src/features/agents/spawnerPreference.ts @@ -0,0 +1,171 @@ +import React from "react"; + +/** + * The spawners this device deploys server-hosted agents to. + * + * A list, not a single value: an owner may keep a beefy box for agents that + * need it and a cheap VPS for the rest, and each agent picks where it runs. + * + * Local-only, like [`trustedSpawners`](./trustedSpawners.ts). Which servers you + * deploy to is a per-device operational choice — a laptop on a VPN may reach a + * spawner a phone cannot. + * + * Only pubkeys are stored. A spawner is addressed entirely through the relay + * (specs carry a `spawner` tag, status comes back `#p`-tagged), so it can run on + * any host, behind NAT, anywhere with outbound WebSocket — there is no URL for a + * client to know, cache, or get wrong. + */ +const STORAGE_KEY = "buzz:spawner-pubkeys"; + +/** Pre-list key, read once so an existing single-spawner setup carries over. */ +const LEGACY_STORAGE_KEY = "buzz:spawner-pubkey"; + +const listeners = new Set<() => void>(); + +/// Declared before `spawners` because `readStored()` returns it: the reverse +/// order is a temporal-dead-zone crash at module load, which is exactly what +/// happens on a device with nothing stored yet. +const EMPTY: readonly string[] = []; + +let spawners: readonly string[] = readStored(); + +function isPubkeyHex(value: string): boolean { + return value.length === 64 && /^[0-9a-f]+$/i.test(value); +} + +function readStored(): readonly string[] { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) { + return dedupe( + parsed.filter( + (v): v is string => typeof v === "string" && isPubkeyHex(v), + ), + ); + } + return EMPTY; + } + // Migrate the single-value key rather than silently dropping a spawner the + // user already connected to. + const legacy = window.localStorage.getItem(LEGACY_STORAGE_KEY); + if (legacy && isPubkeyHex(legacy)) { + const migrated = [legacy.toLowerCase()]; + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(migrated)); + window.localStorage.removeItem(LEGACY_STORAGE_KEY); + return migrated; + } + return EMPTY; + } catch { + return EMPTY; + } +} + +function dedupe(values: string[]): readonly string[] { + return [...new Set(values.map((v) => v.toLowerCase()))]; +} + +function persist(next: readonly string[]): void { + spawners = next; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // Keep the in-memory value so this session still works. + } + for (const listener of listeners) listener(); +} + +/** + * Connect this device to a spawner. + * + * Returns false when the value is not a 64-character hex pubkey, so callers can + * show a validation message rather than silently storing nothing. + */ +export function addSpawner(pubkey: string): boolean { + if (!isPubkeyHex(pubkey)) return false; + const normalized = pubkey.toLowerCase(); + if (spawners.includes(normalized)) return true; + persist([...spawners, normalized]); + return true; +} + +/** + * Disconnect this device from a spawner. + * + * Local only: it stops this device managing that spawner's agents. Agents + * already deployed keep running, because their specs still live on the relay — + * removing them means deleting those specs. + */ +export function removeSpawner(pubkey: string): void { + const normalized = pubkey.toLowerCase(); + if (!spawners.includes(normalized)) return; + persist(spawners.filter((s) => s !== normalized)); +} + +/** Every connected spawner pubkey. */ +export function getSpawners(): readonly string[] { + return spawners; +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): readonly string[] { + return spawners; +} + +function getServerSnapshot(): readonly string[] { + return EMPTY; +} + +/** Reactive view of the connected spawners. */ +export function useSpawners(): readonly string[] { + return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} + +/** + * Derive a spec slug from a display name. + * + * Slugs travel into container names, volume names, and log paths on the spawner + * host, so the Rust side accepts only lowercase alphanumerics, hyphens, and + * underscores and rejects anything else. Normalizing here means a user typing + * "Fizz (prod)" gets `fizz-prod` instead of a validation error they cannot act + * on. Returns null when nothing usable survives. + */ +export function slugFromName(name: string): string | null { + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^[-_]+/, "") + .replace(/-+$/, "") + .slice(0, 64) + // A trailing hyphen can reappear after the 64-byte truncation. + .replace(/-+$/, ""); + return slug.length > 0 ? slug : null; +} + +/** + * Normalize a persona id to the `d` tag its kind:30175 event is published under. + * + * Mirrors `persona_d_tag` / `normalize_d_tag` in + * `desktop/src-tauri/src/managed_agents/persona_events.rs`, which enforces the + * NIP-AP grammar `^[a-z0-9][a-z0-9_-]{0,63}$`. This matters because a persona's + * *id* and its *address on the relay* are not the same string: the built-in + * `builtin:fizz` is published as `builtin-fizz`, since the relay rejects a `d` + * tag containing a colon. A spec that referenced the raw id would point at a + * persona that cannot exist, and the spawner would fail to resolve the prompt. + */ +export function personaDTag(personaId: string): string { + let out = ""; + for (const ch of personaId.toLowerCase()) { + out += /[a-z0-9_-]/.test(ch) ? ch : "-"; + } + if (!/^[a-z0-9]/.test(out)) out = `a${out}`; + return out.slice(0, 64); +} diff --git a/desktop/src/features/agents/spawnerStatusStore.ts b/desktop/src/features/agents/spawnerStatusStore.ts new file mode 100644 index 0000000000..ce8a08cb96 --- /dev/null +++ b/desktop/src/features/agents/spawnerStatusStore.ts @@ -0,0 +1,152 @@ +import React from "react"; + +import { getIdentity } from "@/shared/api/tauriIdentity"; +import { + parseSpawnerStatus, + specSlugFromEvent, + subscribeToSpawnerStatus, + type SpawnerAgentStatus, +} from "@/shared/api/spawnerRelay"; +import type { RelayEvent } from "@/shared/api/types"; + +/** + * Live status for this owner's server-hosted agents, keyed by + * `"/"`. + * + * # Why the key includes the spawner + * + * Kind 30179 is NIP-33 addressed by `(pubkey, kind, d_tag)`, so the *author* is + * part of the identity of a status document. Keying on the slug alone would let + * any pubkey that publishes a 30179 with a matching slug overwrite the real + * spawner's status in this map — the relay correctly stores them at separate + * addresses, and collapsing them here would undo that. Callers look up the + * spawner they addressed the spec to. + */ + +const listeners = new Set<() => void>(); + +let statuses: ReadonlyMap = new Map(); +let unsubscribeRelay: (() => Promise) | null = null; +let startPromise: Promise | null = null; + +/** + * Newest `created_at` seen per key. + * + * A reconnect can replay an older revision after a newer one has landed. + * Without this guard a stale `pending_attestation` could clobber a live + * `running`, and the UI would show an agent as stuck when it is fine. + */ +const latestCreatedAt = new Map(); + +const EMPTY: ReadonlyMap = new Map(); + +/** Compose the lookup key for a status entry. */ +export function spawnerStatusKey(spawnerPubkey: string, slug: string): string { + return `${spawnerPubkey}/${slug}`; +} + +function notify(): void { + for (const listener of listeners) listener(); +} + +function handleStatusEvent(event: RelayEvent): void { + const slug = specSlugFromEvent(event); + if (!slug) return; + + const key = spawnerStatusKey(event.pubkey, slug); + const previous = latestCreatedAt.get(key); + if (previous !== undefined && event.created_at <= previous) return; + + // An emptied replacement is the spawner's tombstone for a deleted agent. + // Without honouring it the last real status — often `pending_attestation` — + // persists forever and the UI shows a row for an agent that no longer + // exists, offering buttons that act on nothing. + const isTombstone = event.content.trim().length === 0; + const status = isTombstone ? null : parseSpawnerStatus(event.content); + // Unparseable content is not a tombstone: dropping a live agent because one + // malformed event arrived would be worse than ignoring the event. + if (!isTombstone && !status) return; + + latestCreatedAt.set(key, event.created_at); + + const next = new Map(statuses); + if (status) { + next.set(key, status); + } else if (!next.delete(key)) { + // Nothing was showing for this agent, so the tombstone changes nothing. + return; + } + statuses = next; + notify(); +} + +/** Open the status subscription. Idempotent. */ +export async function ensureSpawnerStatusSubscription(): Promise { + if (unsubscribeRelay) return; + if (startPromise) return startPromise; + + startPromise = (async () => { + const identity = await getIdentity(); + if (!identity?.pubkey) return; + const dispose = await subscribeToSpawnerStatus( + identity.pubkey, + handleStatusEvent, + ); + // A reset during connect must not strand a subscription for the old identity. + if (startPromise === null) { + void dispose(); + return; + } + unsubscribeRelay = dispose; + })(); + + try { + await startPromise; + } finally { + if (unsubscribeRelay) startPromise = null; + } +} + +/** Tear down the subscription and drop cached status. */ +export function resetSpawnerStatusStore(): void { + const dispose = unsubscribeRelay; + unsubscribeRelay = null; + startPromise = null; + if (dispose) void dispose(); + + latestCreatedAt.clear(); + if (statuses.size > 0) { + statuses = new Map(); + notify(); + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): ReadonlyMap { + return statuses; +} + +function getServerSnapshot(): ReadonlyMap { + return EMPTY; +} + +/** Reactive view of every known server-agent status. */ +export function useSpawnerStatuses(): ReadonlyMap { + return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} + +/** Reactive status for one server agent, or undefined if none has arrived. */ +export function useSpawnerStatus( + spawnerPubkey: string | undefined, + slug: string | undefined, +): SpawnerAgentStatus | undefined { + const statuses = useSpawnerStatuses(); + if (!spawnerPubkey || !slug) return undefined; + return statuses.get(spawnerStatusKey(spawnerPubkey, slug)); +} diff --git a/desktop/src/features/agents/trustedSpawners.ts b/desktop/src/features/agents/trustedSpawners.ts new file mode 100644 index 0000000000..56c8e56738 --- /dev/null +++ b/desktop/src/features/agents/trustedSpawners.ts @@ -0,0 +1,115 @@ +import React from "react"; + +/** + * The set of spawner pubkeys this user has approved. + * + * # Why this exists + * + * Signing a NIP-OA attestation admits an agent pubkey to the relay under *this + * user's* membership. A spawner that obtains a signature can run an agent that + * reads the user's channels. So the first request from any spawner must be an + * explicit, human decision — auto-signing for an unknown pubkey would turn a + * single malicious kind:24201 frame into silent access. + * + * Trust is remembered per spawner so an owner running several agents on their + * own VPS is prompted once, not once per agent. + * + * Deliberately local-only, unlike most Buzz preferences which roam via NIP-78 + * kind:30078. Syncing this would mean approving a spawner on a phone silently + * authorizes it on a laptop, which is the opposite of what a trust decision + * should do — each device should vouch for itself. + */ +const STORAGE_KEY = "buzz:trusted-spawners"; + +const listeners = new Set<() => void>(); + +let trusted: ReadonlySet = readStored(); + +const EMPTY: ReadonlySet = new Set(); + +function readStored(): ReadonlySet { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return new Set(); + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set( + parsed.filter( + (value): value is string => + typeof value === "string" && isPubkeyHex(value), + ), + ); + } catch { + return new Set(); + } +} + +function persist(next: ReadonlySet): void { + trusted = next; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...next])); + } catch { + // A full or unavailable localStorage must not break the prompt. The + // decision still applies to this request; the user is asked again next + // time, which fails toward asking rather than toward trusting. + } + for (const listener of listeners) listener(); +} + +function isPubkeyHex(value: string): boolean { + return value.length === 64 && /^[0-9a-f]+$/i.test(value); +} + +/** Whether the user has approved this spawner. */ +export function isSpawnerTrusted(spawnerPubkey: string): boolean { + return trusted.has(spawnerPubkey.toLowerCase()); +} + +/** Record approval for a spawner. */ +export function trustSpawner(spawnerPubkey: string): void { + const normalized = spawnerPubkey.toLowerCase(); + if (!isPubkeyHex(normalized) || trusted.has(normalized)) return; + persist(new Set([...trusted, normalized])); +} + +/** + * Revoke approval for a spawner. + * + * Revoking does not retract attestations already signed — a NIP-OA tag, once + * issued, is valid until the agent's identity is archived. It only stops future + * requests from being auto-approved. Tearing down the agents themselves means + * deleting their specs. + */ +export function revokeSpawner(spawnerPubkey: string): void { + const normalized = spawnerPubkey.toLowerCase(); + if (!trusted.has(normalized)) return; + const next = new Set(trusted); + next.delete(normalized); + persist(next); +} + +/** Clear every trust decision. Used when switching identities. */ +export function resetTrustedSpawners(): void { + if (trusted.size === 0) return; + persist(new Set()); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): ReadonlySet { + return trusted; +} + +function getServerSnapshot(): ReadonlySet { + return EMPTY; +} + +/** Reactive view of the approved spawner set. */ +export function useTrustedSpawners(): ReadonlySet { + return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/desktop/src/features/agents/ui/AgentDialog.tsx b/desktop/src/features/agents/ui/AgentDialog.tsx index 02a6d0e64a..ca7cc73951 100644 --- a/desktop/src/features/agents/ui/AgentDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDialog.tsx @@ -12,12 +12,17 @@ import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent" import { AgentInstanceEditDialog } from "./AgentInstanceEditDialog"; import { createPersonaDialogState } from "./personaDialogState"; import { AgentDefinitionDialog } from "./AgentDefinitionDialog"; +import { AgentRunsOnSection } from "./AgentRunsOnSection"; import { WhereToRunSection } from "./WhereToRunSection"; import { canSubmitWhereToRun, emptyWhereToRunDraft, resolveBackendIntent, } from "./whereToRunIntent"; +import { + useDefaultAgentLocation, + type AgentLocation, +} from "@/features/agents/agentLocation"; type AgentDialogCreateProps = { mode: "definition"; @@ -31,6 +36,7 @@ type AgentDialogCreateProps = { input: CreatePersonaInput | UpdatePersonaInput, intent: AgentCreateIntent, backendIntent: BackendIntent | null, + location: AgentLocation, ) => Promise; }; @@ -111,6 +117,15 @@ function AgentCreateDialogRouter({ onSubmitDefinition, }: AgentDialogCreateProps) { const [runDraft, setRunDraft] = React.useState(emptyWhereToRunDraft); + // Until the user picks, the dialog *inherits* the stored default rather than + // snapshotting it — so a spawner that connects (or the default changing) + // while the dialog is open is still reflected, and the built-ins that carry + // no location of their own follow it too. + const [chosenLocation, setChosenLocation] = + React.useState(null); + const defaultLocation = useDefaultAgentLocation(); + const location = chosenLocation ?? defaultLocation; + const isLocal = location.kind === "local"; const initialValues = React.useMemo( () => providedInitialValues ?? createPersonaDialogState().initialValues, [providedInitialValues], @@ -121,13 +136,24 @@ function AgentCreateDialogRouter({ return ( +
+ + {/* A local backend provider only has meaning for a local agent: on a + spawner the host owns the process and its key. */} + {isLocal ? ( + + ) : null} +
} - createSubmitBlocked={!canSubmitWhereToRun(runDraft)} + createSubmitBlocked={isLocal && !canSubmitWhereToRun(runDraft)} description={copy.description} error={definitionError} initialValues={initialValues} @@ -137,7 +163,8 @@ function AgentCreateDialogRouter({ const submitted = await onSubmitDefinition( input, "definition_start", - resolveBackendIntent(runDraft), + isLocal ? resolveBackendIntent(runDraft) : null, + location, ); if (submitted) { onOpenChange(false); diff --git a/desktop/src/features/agents/ui/AgentRunsOnSection.tsx b/desktop/src/features/agents/ui/AgentRunsOnSection.tsx new file mode 100644 index 0000000000..5d58b61546 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentRunsOnSection.tsx @@ -0,0 +1,71 @@ +import * as React from "react"; + +import type { AgentLocation } from "../agentLocation"; +import { useSpawnerDirectory } from "../spawnerDirectoryStore"; +import { useSpawners } from "../spawnerPreference"; +import { + agentLocationOptions, + agentLocationValue, + parseAgentLocationValue, +} from "./agentLocationOptions"; +import { runtimeLabel, spawnerLabel } from "./ServerAgentsSection"; + +/** + * "Runs on" — which machine this agent lives on. + * + * Deliberately separate from the harness picker: a runtime says which binary + * drives the agent, a location says whose computer runs it. Renders nothing + * unless a spawner is connected, since "this Mac" is then the only answer. + */ +export function AgentRunsOnSection({ + isPending, + location, + onLocationChange, +}: { + isPending: boolean; + location: AgentLocation; + onLocationChange: (next: AgentLocation) => void; +}) { + const spawners = useSpawners(); + const directory = useSpawnerDirectory(); + const options = React.useMemo( + () => + agentLocationOptions(spawners, (pubkey) => ({ + label: spawnerLabel(pubkey, directory), + hint: runtimeLabel(directory.get(pubkey)?.runtime), + })), + [directory, spawners], + ); + + if (options.length === 0) return null; + + return ( +
+ + +

+ {location.kind === "local" + ? "This agent stops when Buzz is closed." + : "The server runs this agent, so it keeps working when Buzz is closed. Approve its key when prompted."} +

+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx index 6f34ffba79..b3a4019a40 100644 --- a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx +++ b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx @@ -1,4 +1,4 @@ -import { CircleAlert, Play } from "lucide-react"; +import { CircleAlert, Play, Server } from "lucide-react"; import { useReducedMotion } from "motion/react"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; @@ -18,6 +18,9 @@ type AgentRuntimeAvatarControlProps = { errorLabel?: string | null; errorTestId?: string; isActive: boolean; + /** Identity handed to a spawner: show "runs on a server" instead of Start. + * Starting locally would run one identity in two places. */ + isRelocated?: boolean; isStarting: boolean; label: string; startTestId: string; @@ -101,6 +104,7 @@ export function AgentRuntimeAvatarControl({ errorLabel, errorTestId, isActive, + isRelocated = false, isStarting, label, startTestId, @@ -119,7 +123,17 @@ export function AgentRuntimeAvatarControl({ - {isActive ? ( + {!isActive && isRelocated ? ( + + + + ) : isActive ? ( + + diff --git a/desktop/src/features/agents/ui/MoveAgentToServerMenu.tsx b/desktop/src/features/agents/ui/MoveAgentToServerMenu.tsx new file mode 100644 index 0000000000..d5af834b87 --- /dev/null +++ b/desktop/src/features/agents/ui/MoveAgentToServerMenu.tsx @@ -0,0 +1,73 @@ +import { ServerCog } from "lucide-react"; +import { toast } from "sonner"; + +import type { ManagedAgent } from "@/shared/api/types"; +import { + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "@/shared/ui/dropdown-menu"; +import { useSpawnerDirectory } from "../spawnerDirectoryStore"; +import { useServerAgents } from "../useServerAgents"; +import { spawnerLabel } from "./ServerAgentsSection"; + +/** + * "Move to server" for an agent that already exists on this Mac. + * + * Deliberately *not* the Deploy menu: deploying a persona mints a new key and + * leaves the local agent alone, while this keeps the agent's existing key and + * retires the local copy. Same agent, different machine — which is the only way + * its channel membership, profile, DMs, and NIP-AE memory survive the move. + * + * Renders nothing when there is no local agent to move or no spawner to move it + * to; a disabled item for an action that cannot exist yet is just noise. + */ +export function MoveAgentToServerMenu({ + agent, + disabled, +}: { + agent: ManagedAgent | undefined; + disabled: boolean; +}) { + const { spawners, relocate, isPending } = useServerAgents(); + const directory = useSpawnerDirectory(); + + if (!agent || spawners.length === 0) return null; + + const handleMove = async (spawnerPubkey: string) => { + try { + await relocate(agent, spawnerPubkey); + toast.success( + `Moving ${agent.name} to ${spawnerLabel(spawnerPubkey, directory)}. ` + + `Approve the key when prompted — it keeps its identity and stops running on this Mac.`, + ); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : `Failed to move ${agent.name} to the server.`, + ); + } + }; + + return ( + + + + Move to server + + + {spawners.map((spawnerPubkey) => ( + void handleMove(spawnerPubkey)} + > + {spawnerLabel(spawnerPubkey, directory)} + + ))} + + + ); +} diff --git a/desktop/src/features/agents/ui/PersonaActionsMenu.tsx b/desktop/src/features/agents/ui/PersonaActionsMenu.tsx index 30bb7fff6e..4da1e06db9 100644 --- a/desktop/src/features/agents/ui/PersonaActionsMenu.tsx +++ b/desktop/src/features/agents/ui/PersonaActionsMenu.tsx @@ -14,6 +14,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { MoveAgentToServerMenu } from "./MoveAgentToServerMenu"; export function PersonaActionsMenu({ isActionPending, @@ -78,6 +79,7 @@ export function PersonaActionsMenu({ Share + {persona.sourceTeam ? ( diff --git a/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx b/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx index ee800b5cb5..d5c86b2cc1 100644 --- a/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx +++ b/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx @@ -51,8 +51,14 @@ export function RequestedAgentCreateDialogs() { setTargetChannel(null); } }} - onSubmitDefinition={(input, intent, backendIntent) => - personas.handleSubmit(input, intent, backendIntent, targetChannel) + onSubmitDefinition={(input, intent, backendIntent, location) => + personas.handleSubmit( + input, + intent, + backendIntent, + location, + targetChannel, + ) } runtimes={personas.acpRuntimesQuery.data ?? []} runtimesLoading={personas.acpRuntimesQuery.isLoading} diff --git a/desktop/src/features/agents/ui/ServerAgentsSection.tsx b/desktop/src/features/agents/ui/ServerAgentsSection.tsx new file mode 100644 index 0000000000..605aedc108 --- /dev/null +++ b/desktop/src/features/agents/ui/ServerAgentsSection.tsx @@ -0,0 +1,493 @@ +import { Check, CircleAlert, Play, Square, Trash2 } from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; + +import type { + SpawnerAnnouncement, + SpawnPhase, +} from "@/shared/api/spawnerRelay"; +import type { AgentPersona } from "@/shared/api/types"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { Input } from "@/shared/ui/input"; +import { SectionHeader } from "@/shared/ui/PageHeader"; +import { + sameLocation, + setDefaultAgentLocation, + useDefaultAgentLocation, +} from "../agentLocation"; +import { useSpawnerDirectory } from "../spawnerDirectoryStore"; +import { addSpawner, removeSpawner } from "../spawnerPreference"; +import { useServerAgents, type ServerAgent } from "../useServerAgents"; +import { shortenPubkey } from "./SpawnerAttestationDialog"; + +/** Human-readable label and tone for a reconciliation phase. */ +export function phaseLabel(phase: SpawnPhase): { + label: string; + variant: "secondary" | "destructive" | "warning" | "success" | "info"; +} { + switch (phase) { + case "running": + return { label: "Running", variant: "success" }; + case "starting": + return { label: "Starting", variant: "info" }; + case "pending_attestation": + // Warning, not neutral: this state needs the user to do something, and + // the agent stays dead until they do. + return { label: "Awaiting approval", variant: "warning" }; + case "stopped": + return { label: "Stopped", variant: "secondary" }; + case "failed": + return { label: "Failed", variant: "destructive" }; + } +} + +/** + * Friendly name for an advertised agent runtime. + * + * Purely cosmetic: the value is self-reported by the host and controls nothing. + * Unknown values pass through so a spawner running something we have never heard + * of still reads sensibly. + */ +export function runtimeLabel(runtime: string | undefined): string | undefined { + if (!runtime) return undefined; + switch (runtime) { + case "claude-agent-acp": + case "claude-code-acp": + return "Claude Code"; + case "buzz-agent": + return "Buzz agent"; + case "goose": + return "goose"; + case "codex-acp": + return "Codex"; + default: + return runtime; + } +} + +/** Display label for a spawner, preferring its announced name. */ +export function spawnerLabel( + pubkey: string, + directory: ReadonlyMap, +): string { + return directory.get(pubkey)?.name ?? shortenPubkey(pubkey); +} + +type ServerAgentsSectionProps = { + personas: AgentPersona[]; +}; + +/** + * Server-hosted agents: the ones that keep running when this app is closed. + * + * Supports several spawners at once — a GPU box for agents that need it and a + * cheap VPS for the rest — because a spawner is addressed by pubkey through the + * relay and can live anywhere with outbound WebSocket. + */ +export function ServerAgentsSection({ personas }: ServerAgentsSectionProps) { + const { + spawners, + agents, + isPending, + create, + setEnabled, + remove, + hasServerAgent, + } = useServerAgents(); + const directory = useSpawnerDirectory(); + + const personaByName = React.useMemo(() => { + const map = new Map(); + for (const persona of personas) map.set(persona.displayName, persona); + return map; + }, [personas]); + + // Announced spawners this device has not connected to yet — the discovery + // path that replaces pasting 64 hex characters. + const undiscovered = React.useMemo( + () => [...directory.values()].filter((a) => !spawners.includes(a.pubkey)), + [directory, spawners], + ); + + const handleDeploy = async (persona: AgentPersona, spawner: string) => { + try { + await create(persona, spawner); + toast.success( + `Deploying ${persona.displayName} to ${spawnerLabel(spawner, directory)}. Approve the key when prompted.`, + ); + } catch (error) { + toast.error(errorMessage(error, "Failed to deploy the agent.")); + } + }; + + return ( +
+ 0 ? ( + + ) : undefined + } + title="Server agents" + /> + + {spawners.length === 0 ? ( +

+ Run agents on a server so they keep working when Buzz is closed. A + spawner can live anywhere that can reach this relay — it does not have + to be the relay machine. +

+ ) : null} + + {undiscovered.length > 0 ? ( + + ) : null} + + {spawners.map((spawner) => { + const spawnerAgents = agents.filter((a) => a.spawnerPubkey === spawner); + return ( +
+
+

+ {spawnerLabel(spawner, directory)}{" "} + + {runtimeLabel(directory.get(spawner)?.runtime) ?? + shortenPubkey(spawner)} + +

+
+ + +
+
+ {spawnerAgents.length === 0 ? ( +

+ No agents here yet. Use "Deploy agent". +

+ ) : ( +
    + {spawnerAgents.map((agent) => ( + { + try { + await remove(agent); + toast.success(`Removed "${agent.slug}".`); + } catch (error) { + toast.error( + errorMessage(error, "Failed to remove the agent."), + ); + } + }} + onToggle={async (enabled) => { + try { + await setEnabled( + agent, + enabled, + personaByName.get(agent.slug), + ); + } catch (error) { + toast.error( + errorMessage(error, "Failed to update the agent."), + ); + } + }} + /> + ))} +
+ )} +
+ ); + })} + + +
+ ); +} + +/** + * Spawners that announced themselves but are not connected yet. + * + * Deliberately worded as a claim rather than an endorsement: anyone can publish + * a kind:10180, so this list is a phone book. Connecting is a user action, and + * running an agent still needs a signed attestation. + */ +function DiscoveredSpawners({ spawners }: { spawners: SpawnerAnnouncement[] }) { + return ( +
+

Spawners on this relay

+

+ Anyone can advertise here. Connecting does not grant access — you still + approve each agent's key. +

+
    + {spawners.map((spawner) => ( +
  • +
    +

    + {spawner.name}{" "} + + {shortenPubkey(spawner.pubkey)} + +

    +

    + {spawner.agentsRunning}/{spawner.maxAgents} agents + {runtimeLabel(spawner.runtime) + ? ` · ${runtimeLabel(spawner.runtime)}` + : ""} + {spawner.description ? ` · ${spawner.description}` : ""} +

    +
    + +
  • + ))} +
+
+ ); +} + +function ServerAgentRow({ + agent, + isPending, + onToggle, + onRemove, +}: { + agent: ServerAgent; + isPending: boolean; + onToggle: (enabled: boolean) => void; + onRemove: () => void; +}) { + const { label, variant } = phaseLabel(agent.status.phase); + const isStopped = agent.status.phase === "stopped"; + + return ( +
  • +
    +
    + {agent.slug} + {label} +
    + {agent.status.agentPubkey ? ( +

    + {shortenPubkey(agent.status.agentPubkey)} +

    + ) : null} + {agent.status.error ? ( +

    + + {agent.status.error} +

    + ) : null} + {agent.status.restartCount > 0 ? ( +

    + {agent.status.restartCount} failed start + {agent.status.restartCount === 1 ? "" : "s"} +

    + ) : null} +
    +
    + + +
    +
  • + ); +} + +/** + * Picks a persona and a spawner to run it on. + * + * Grouped by spawner rather than asking twice: with several hosts connected, + * "which agent, and where" is one decision. A persona already deployed to a + * given host is omitted from that host's group, so a re-deploy cannot silently + * overwrite a running agent's spec — but it stays available on other hosts, + * since running the same persona in two places is legitimate. + */ +function DeployMenu({ + personas, + spawners, + directory, + disabled, + hasServerAgent, + onDeploy, +}: { + personas: AgentPersona[]; + spawners: readonly string[]; + directory: ReadonlyMap; + disabled: boolean; + hasServerAgent: (persona: AgentPersona, spawner: string) => boolean; + onDeploy: (persona: AgentPersona, spawner: string) => void; +}) { + const groups = spawners.map((spawner) => ({ + spawner, + personas: personas.filter((p) => !hasServerAgent(p, spawner)), + })); + const hasAnything = groups.some((g) => g.personas.length > 0); + + return ( + + + + + + {groups.map((group, index) => ( + + {index > 0 ? : null} + + {spawnerLabel(group.spawner, directory)} + + {group.personas.length === 0 ? ( + All agents deployed + ) : ( + group.personas.map((persona) => ( + onDeploy(persona, group.spawner)} + > + {persona.displayName} + + )) + )} + + ))} + + + ); +} + +/** + * Marks a spawner as the default location for new agents. + * + * Location is stored once and inherited, rather than copied onto each persona: + * the built-in Fizz/Honey/Bumble carry no location of their own, so flipping + * this moves every agent that has not been given an explicit one. + */ +function DefaultLocationToggle({ spawner }: { spawner: string }) { + const current = useDefaultAgentLocation(); + const isDefault = sameLocation(current, { + kind: "spawner", + spawnerPubkey: spawner, + }); + + if (isDefault) { + return ( + + + Default for new agents + + ); + } + return ( + + ); +} + +/** Connect to a spawner by pubkey, for one that has not announced itself. */ +function SpawnerConnectCard() { + const [value, setValue] = React.useState(""); + + return ( +
    + setValue(event.target.value)} + placeholder="Spawner public key (64 hex characters)" + value={value} + /> + +
    + ); +} + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} diff --git a/desktop/src/features/agents/ui/SpawnerAttestationDialog.tsx b/desktop/src/features/agents/ui/SpawnerAttestationDialog.tsx new file mode 100644 index 0000000000..78c6181e6e --- /dev/null +++ b/desktop/src/features/agents/ui/SpawnerAttestationDialog.tsx @@ -0,0 +1,217 @@ +import React from "react"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { Button } from "@/shared/ui/button"; +import { Checkbox } from "@/shared/ui/checkbox"; +import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { isRelocationOfLocalAgent } from "../agentRelocation"; +import { + approveAttestation, + declineAttestation, + usePendingAttestations, + type PendingAttestation, +} from "../spawnerAttestationStore"; + +/** Shorten a 64-char pubkey for display without losing recognizability. */ +export function shortenPubkey(pubkey: string): string { + if (pubkey.length <= 20) return pubkey; + return `${pubkey.slice(0, 10)}…${pubkey.slice(-6)}`; +} + +/** + * Consent copy for an attestation request. + * + * Pure so the wording stays unit-testable. It has to say plainly what a + * signature does — this is not "allow an app to run", it is "let this key act + * in your community under your membership" — because a user who misreads it + * grants relay access to a server they may not control. + */ +export function attestationDescription( + item: PendingAttestation | null, + options?: { isRelocation?: boolean }, +): string { + if (!item) return "A server wants to run an agent for you."; + if (options?.isRelocation) { + // A relocation is a different decision from authorizing a stranger: the + // key already belongs to the user, and what they are consenting to is + // handing it to the server and giving up the local copy. Reusing the "a + // new agent key was created" copy here would be a lie in both halves. + return ( + `The spawner ${shortenPubkey(item.spawnerPubkey)} is asking to take over ` + + `"${item.specSlug}", an agent you already run on this Mac.\n\n` + + `Approving moves ${shortenPubkey(item.agentPubkey)} to that server. It keeps ` + + `the same identity — its channels, its profile, and its memory all follow ` + + `it — and it will stop running on this Mac. ` + + `Only approve this if you run this server.` + ); + } + return ( + `The spawner ${shortenPubkey(item.spawnerPubkey)} created a new agent key ` + + `for "${item.specSlug}" and is asking you to authorize it.\n\n` + + `Approving signs an owner attestation for ${shortenPubkey(item.agentPubkey)}, ` + + `which lets that key join and read your channels as an agent you own. ` + + `Only approve this if you run this server.` + ); +} + +/** Dialog title, which also has to change for a move. */ +export function attestationTitle(options?: { isRelocation?: boolean }): string { + return options?.isRelocation + ? "Move this agent to a server?" + : "Authorize a server agent?"; +} + +type SpawnerAttestationDialogProps = { + /** Injectable for tests; defaults to the live queue. */ + pending?: readonly PendingAttestation[]; +}; + +/** + * Prompts the owner to approve or decline a server-hosted agent's key. + * + * Rendered app-wide so a request that arrives while the user is anywhere in the + * app still surfaces. It shows one request at a time — the head of the queue — + * because each is a distinct consent decision and stacking them invites + * click-through. + */ +export function SpawnerAttestationDialog({ + pending: injectedPending, +}: SpawnerAttestationDialogProps = {}) { + const livePending = usePendingAttestations(); + const pending = injectedPending ?? livePending; + const current = pending[0] ?? null; + + // A request naming an agent this device already manages is a *move*, not a + // new key. Read from the managed-agent list rather than trusting anything in + // the frame: only local state can say whether we already own this identity. + const managedAgentsQuery = useManagedAgentsQuery(); + const isRelocation = isRelocationOfLocalAgent( + current?.agentPubkey, + React.useMemo( + () => (managedAgentsQuery.data ?? []).map((agent) => agent.pubkey), + [managedAgentsQuery.data], + ), + ); + + const [isSubmitting, setIsSubmitting] = React.useState(false); + + // Each request is its own consent decision, so "trust this spawner" must not + // carry over from a previous prompt the user happened to tick. Storing the + // nonce alongside the value resets it during render (React's "adjusting state + // when a prop changes" pattern) rather than in an effect, so the checkbox is + // never briefly shown ticked for the next request. + const [rememberState, setRememberState] = React.useState<{ + nonce: string | null; + value: boolean; + }>({ nonce: null, value: false }); + const remember = + rememberState.nonce === (current?.nonce ?? null) + ? rememberState.value + : false; + const setRemember = (value: boolean) => { + setRememberState({ nonce: current?.nonce ?? null, value }); + }; + + const handleApprove = React.useCallback(async () => { + if (!current || isSubmitting) return; + setIsSubmitting(true); + try { + await approveAttestation(current, { remember }); + toast.success( + isRelocation + ? `Moved "${current.specSlug}" to the server.` + : `Authorized the server agent "${current.specSlug}".`, + ); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to authorize the server agent.", + ); + } finally { + setIsSubmitting(false); + } + }, [current, isRelocation, isSubmitting, remember]); + + const handleDecline = React.useCallback(async () => { + if (!current || isSubmitting) return; + setIsSubmitting(true); + try { + await declineAttestation(current); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to decline the server agent.", + ); + } finally { + setIsSubmitting(false); + } + }, [current, isSubmitting]); + + return ( + { + // Dismissing without a decision would leave the agent stuck at + // pending_attestation until the spawner times out, so closing declines. + if (!open) void handleDecline(); + }} + open={current !== null} + > + + + + {attestationTitle({ isRelocation })} + + + {attestationDescription(current, { isRelocation })} + + + + + + + + + + + + + + ); +} diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index f24d6a41ff..f6dedfce02 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -293,6 +293,7 @@ function AgentPersonaCard({ errorLabel={friendlyError} errorTestId={`agent-runtime-error-${agent.pubkey}`} isActive={isActive} + isRelocated={agent.relocatedToSpawner !== null} isStarting={startingAgentPubkey === agent.pubkey} label={title} startTestId={`agent-runtime-start-${agent.pubkey}`} @@ -379,6 +380,7 @@ function StandaloneAgentCard({ errorLabel={friendlyError} errorTestId={`agent-runtime-error-${agent.pubkey}`} isActive={isActive} + isRelocated={agent.relocatedToSpawner !== null} isStarting={startingAgentPubkey === agent.pubkey} label={title} startTestId={`agent-runtime-start-${agent.pubkey}`} diff --git a/desktop/src/features/agents/ui/agentLocationOptions.test.mjs b/desktop/src/features/agents/ui/agentLocationOptions.test.mjs new file mode 100644 index 0000000000..95bddeec07 --- /dev/null +++ b/desktop/src/features/agents/ui/agentLocationOptions.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + agentLocationOptions, + agentLocationValue, + LOCAL_LOCATION_VALUE, + parseAgentLocationValue, +} from "./agentLocationOptions.ts"; + +const SPAWNER_A = "aa".repeat(32); +const SPAWNER_B = "bb".repeat(32); + +const describe = (pubkey) => + pubkey === SPAWNER_A + ? { label: "GPU box", hint: "Claude Code" } + : { label: "VPS" }; + +test("offersNoChoiceWhenNoSpawnerIsConnected", () => { + // The caller hides the control entirely rather than rendering a dropdown + // whose only entry is the thing that already happens. + assert.deepEqual(agentLocationOptions([], describe), []); +}); + +test("listsThisMacFirstThenEachSpawner", () => { + assert.deepEqual(agentLocationOptions([SPAWNER_A, SPAWNER_B], describe), [ + { value: LOCAL_LOCATION_VALUE, label: "This Mac" }, + { value: SPAWNER_A, label: "GPU box", hint: "Claude Code" }, + { value: SPAWNER_B, label: "VPS" }, + ]); +}); + +test("roundTripsALocationThroughTheSelectValue", () => { + const location = { kind: "spawner", spawnerPubkey: SPAWNER_A }; + assert.equal(agentLocationValue(location), SPAWNER_A); + assert.deepEqual( + parseAgentLocationValue(agentLocationValue(location), [SPAWNER_A]), + location, + ); + assert.equal(agentLocationValue({ kind: "local" }), LOCAL_LOCATION_VALUE); + assert.deepEqual(parseAgentLocationValue(LOCAL_LOCATION_VALUE, [SPAWNER_A]), { + kind: "local", + }); +}); + +test("fallsBackToLocalForASpawnerThatIsNoLongerConnected", () => { + // A spawner can disconnect while the dialog is open; deploying to it would + // fail with no obvious cause. + assert.deepEqual(parseAgentLocationValue(SPAWNER_A, [SPAWNER_B]), { + kind: "local", + }); + assert.deepEqual(parseAgentLocationValue("", [SPAWNER_A]), { kind: "local" }); +}); + +test("acceptsAnUppercasePubkeyValue", () => { + assert.deepEqual( + parseAgentLocationValue(SPAWNER_A.toUpperCase(), [SPAWNER_A]), + { kind: "spawner", spawnerPubkey: SPAWNER_A }, + ); +}); diff --git a/desktop/src/features/agents/ui/agentLocationOptions.ts b/desktop/src/features/agents/ui/agentLocationOptions.ts new file mode 100644 index 0000000000..c1e8912148 --- /dev/null +++ b/desktop/src/features/agents/ui/agentLocationOptions.ts @@ -0,0 +1,71 @@ +import { LOCAL, type AgentLocation } from "../agentLocation"; + +/** `` value for a location. */ +export function agentLocationValue(location: AgentLocation): string { + return location.kind === "local" + ? LOCAL_LOCATION_VALUE + : location.spawnerPubkey; +} + +/** + * The location a ` setProvider(event.target.value)} - placeholder="Custom provider ID" - value={provider} - /> - - ) : null} - + {aiConfigurationMode === "custom" && + (serverContext ? serverAi !== null : llmProviderFieldVisible) ? ( + ) : null} {llmProviderFieldVisible && @@ -917,22 +880,50 @@ export function AgentDefinitionDialog({ /> ) : null} + {serverContext && + !serverAi && + aiConfigurationMode === "custom" ? ( +
    + + +
    + ) : null} + - {modelFieldVisible && aiConfigurationMode === "custom" ? ( + {(serverContext ? serverAi !== null : modelFieldVisible) && + aiConfigurationMode === "custom" ? ( ) : null} @@ -950,6 +941,12 @@ export function AgentDefinitionDialog({ triggerRef={aiDefaultsTriggerRef} /> ) : null} + + {serverContext ? ( +

    + Applied on the server. Saving restarts the agent. +

    + ) : null} { + const matched = serverAgents.find( + (candidate) => candidate.status.agentPubkey === agent.pubkey, + ); + return matched?.slug ?? slugFromName(agent.name); + }, [serverAgents, agent.pubkey, agent.name]); + const server = useServerAgentEditContext({ + relocatedToSpawner: agent.relocatedToSpawner, + deployedSpawnerPubkey: null, + agentPubkey: agent.pubkey, + slug: serverSpecSlug, + provider, + }); + const serverContext = server.context; + const serverAi = server.ai; + const previewLabel = name.trim() || "Agent name"; const previewAvatarUrl = avatarUrl.trim() || null; const advancedFieldsTransition = shouldReduceMotion @@ -884,6 +911,14 @@ export function AgentInstanceEditDialog({ )}
    + {serverContext ? ( + + ) : null} + {/* Agent name */}
    - - {showCustomModelInput ? ( + {serverContext && !serverAi ? ( + + ) : ( + + )} + {!serverContext && showCustomModelInput ? (
    ) : null} - {modelStatusMessage ? ( + {!serverContext && modelStatusMessage ? (

    {modelStatusMessage}

    ) : null}
    + {serverContext ? ( +

    + Applied on the server. Saving restarts the agent. +

    + ) : null} + setAiDefaultsOpen(true)} triggerRef={aiDefaultsTriggerRef} diff --git a/desktop/src/features/agents/ui/EditAgentRuntimeSection.tsx b/desktop/src/features/agents/ui/EditAgentRuntimeSection.tsx new file mode 100644 index 0000000000..cdc847b413 --- /dev/null +++ b/desktop/src/features/agents/ui/EditAgentRuntimeSection.tsx @@ -0,0 +1,96 @@ +import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Input } from "@/shared/ui/input"; +import { + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, + type PersonaDropdownOption, +} from "./agentConfigOptions"; +import { PersonaDropdownField } from "./PersonaDropdownField"; + +/** + * Harness picker for a locally-run agent, plus the custom-command escape hatch. + * + * Rendered only for agents this device runs: on a spawner the harness is the + * host's decision, so the Edit dialog replaces this whole section with the + * "Runs on ..." banner. + */ +export function EditAgentRuntimeSection({ + agentCommand, + disabled, + onAgentCommandChange, + onRuntimeChange, + runtimeDropdownOptions, + runtimeDropdownValue, + selectedRuntime, + showCommandField, +}: { + agentCommand: string; + disabled: boolean; + onAgentCommandChange: (next: string) => void; + onRuntimeChange: (next: string) => void; + runtimeDropdownOptions: PersonaDropdownOption[]; + runtimeDropdownValue: string; + selectedRuntime: AcpRuntimeCatalogEntry | undefined; + showCommandField: boolean; +}) { + return ( + <> +
    + + + {selectedRuntime ? ( +

    + Detected at{" "} + + {selectedRuntime.binaryPath ?? + selectedRuntime.command ?? + selectedRuntime.id} + +

    + ) : null} +
    + {showCommandField ? ( +
    + +
    + onAgentCommandChange(event.target.value)} + placeholder="Full path or shell command" + value={agentCommand} + /> +
    +
    + ) : null} + + ); +} diff --git a/desktop/src/features/agents/ui/LlmProviderField.tsx b/desktop/src/features/agents/ui/LlmProviderField.tsx new file mode 100644 index 0000000000..db208b8f17 --- /dev/null +++ b/desktop/src/features/agents/ui/LlmProviderField.tsx @@ -0,0 +1,88 @@ +import { cn } from "@/shared/lib/cn"; +import { Input } from "@/shared/ui/input"; +import { + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, + PERSONA_LABEL_OPTIONAL_CLASS, + type PersonaDropdownOption, +} from "./agentConfigOptions"; +import { RequiredFieldLabel } from "./agentConfigControls"; +import { PersonaDropdownField } from "./PersonaDropdownField"; + +/** + * "LLM provider" dropdown plus its custom-id escape hatch. + * + * Shared by the instance and definition dialogs so the two stay in step — for a + * server-hosted agent both are handed the spawner's advertised provider list + * instead of the locally-derived one. + */ +export function LlmProviderField({ + customInputId, + disabled, + id, + isRequired, + onProviderTextChange, + onValueChange, + options, + placeholder, + providerValue, + selectValue, + showCustomInput, +}: { + customInputId: string; + disabled: boolean; + id: string; + isRequired: boolean; + onProviderTextChange: (next: string) => void; + onValueChange: (next: string) => void; + options: PersonaDropdownOption[]; + placeholder: string; + providerValue: string; + selectValue: string; + showCustomInput: boolean; +}) { + return ( +
    + + LLM provider + {isRequired ? null : ( + Optional + )} + + + {showCustomInput ? ( +
    + onProviderTextChange(event.target.value)} + placeholder="Custom provider ID" + value={providerValue} + /> +
    + ) : null} +
    + ); +} diff --git a/desktop/src/features/agents/ui/ServerModelField.tsx b/desktop/src/features/agents/ui/ServerModelField.tsx new file mode 100644 index 0000000000..74be65175b --- /dev/null +++ b/desktop/src/features/agents/ui/ServerModelField.tsx @@ -0,0 +1,48 @@ +import { cn } from "@/shared/lib/cn"; +import { Input } from "@/shared/ui/input"; +import { + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, +} from "./agentConfigOptions"; + +/** + * Free-text model entry for a server agent whose spawner advertised no catalog. + * + * A dropdown would be a lie there — this device cannot know what the host can + * run — so the field degrades to plain text plus an explanation. + */ +export function ServerModelField({ + disabled, + id, + onChange, + value, +}: { + disabled: boolean; + id: string; + onChange: (next: string) => void; + value: string; +}) { + return ( + <> +
    + onChange(event.target.value)} + placeholder="Model ID" + value={value} + /> +
    +

    + Model list unavailable from this server +

    + + ); +} diff --git a/desktop/src/features/agents/ui/ServerRunsOnBanner.tsx b/desktop/src/features/agents/ui/ServerRunsOnBanner.tsx new file mode 100644 index 0000000000..cb94c0e2a4 --- /dev/null +++ b/desktop/src/features/agents/ui/ServerRunsOnBanner.tsx @@ -0,0 +1,52 @@ +import { Server } from "lucide-react"; + +/** + * Read-only "this agent lives on a server" row for the Edit dialogs. + * + * Replaces the harness picker for server-hosted agents: where the process runs + * is decided by the spawner, not by this device, so the only honest thing the + * dialog can do is state it. + */ +export function ServerRunsOnBanner({ + spawnerName, + runtime, + pending, +}: { + spawnerName: string; + runtime?: string | null; + pending: boolean; +}) { + return ( +
    + + + Runs on {spawnerName} · Server + {runtime ? ( + · {runtime} + ) : null} + + {pending ? : null} +
    + ); +} + +/** + * Amber chip for a prompt update that has been queued but not yet confirmed by + * the spawner — the spawner echoes `prompt_hash` on its next status, so until + * then the edit is in flight, not applied. + */ +export function ServerUpdatePendingChip({ className }: { className?: string }) { + return ( + + Update pending — server offline + + ); +} diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index f6dedfce02..5e6fcff725 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -27,6 +27,8 @@ import { AgentRuntimeAvatarControl } from "./AgentRuntimeAvatarControl"; import { CreateIdentityCard } from "./CreateIdentityCard"; import { PersonaActionsMenu } from "./PersonaActionsMenu"; import { buildUnifiedGroups, pickProfileAgent } from "./unifiedAgentGroups"; +import { usePendingSpawnerPromptUpdate } from "../spawnerPromptUpdateQueue"; +import { ServerUpdatePendingChip } from "./ServerRunsOnBanner"; type UnifiedAgentsSectionProps = { defaultModel: string; @@ -280,6 +282,9 @@ function AgentPersonaCard({ ? friendlyAgentLastError(agent.lastError, agent.lastErrorCode)?.copy : null; const opensRuntimeTab = Boolean(agent && friendlyError && !isActive); + const promptUpdatePending = usePendingSpawnerPromptUpdate( + agent?.pubkey ?? "", + ); return ( - - Configuration missing - - ) : agent?.needsRestart ? ( - - - Restart required - - ) : null + <> + {agent?.relocatedToSpawner != null && promptUpdatePending ? ( + + ) : null} + {agent?.personaOrphaned ? ( + + + Configuration missing + + ) : agent?.needsRestart ? ( + + + Restart required + + ) : null} + } /> ); @@ -369,6 +379,7 @@ function StandaloneAgentCard({ )?.copy; const isActive = isManagedAgentActive(agent); const opensRuntimeTab = Boolean(friendlyError && !isActive); + const promptUpdatePending = usePendingSpawnerPromptUpdate(agent.pubkey); return ( - - Configuration missing - - ) : agent.needsRestart ? ( - - - Restart required - - ) : null + <> + {agent.relocatedToSpawner != null && promptUpdatePending ? ( + + ) : null} + {agent.personaOrphaned ? ( + + + Configuration missing + + ) : agent.needsRestart ? ( + + + Restart required + + ) : null} + } /> ); diff --git a/desktop/src/features/agents/ui/personaRuntimeDropdown.tsx b/desktop/src/features/agents/ui/personaRuntimeDropdown.tsx new file mode 100644 index 0000000000..ed620eafc7 --- /dev/null +++ b/desktop/src/features/agents/ui/personaRuntimeDropdown.tsx @@ -0,0 +1,120 @@ +import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import { + AUTO_MODEL_DROPDOWN_VALUE, + formatRuntimeOptionLabel, + NO_RUNTIME_DROPDOWN_VALUE, + type PersonaDropdownOption, + sortPersonaRuntimes, +} from "./agentConfigOptions"; +import { modelDropdownOptions as buildModelDropdownOptions } from "./relayMeshModelPicker"; + +/** + * Harness options for the definition dialog. + * + * Create mode has no blank row — a new definition must pick a harness — while + * edit mode keeps "No preference" so an existing definition can fall back to + * the app default. A currently-set harness the catalog does not know about is + * appended so editing an unrelated field cannot silently drop it. + */ +export function buildPersonaRuntimeDropdown({ + defaultRuntime, + isCreateMode, + runtime, + runtimes, + runtimesLoading, +}: { + defaultRuntime: AcpRuntimeCatalogEntry | null; + isCreateMode: boolean; + runtime: string; + runtimes: AcpRuntimeCatalogEntry[]; + runtimesLoading: boolean; +}): { blankLabel: string; options: PersonaDropdownOption[] } { + const blankLabel = runtimesLoading + ? "Loading harnesses..." + : isCreateMode + ? "Choose a harness" + : "No preference (use app default)"; + + const options: PersonaDropdownOption[] = [ + ...(!isCreateMode + ? [{ label: blankLabel, value: NO_RUNTIME_DROPDOWN_VALUE }] + : []), + ...sortPersonaRuntimes(runtimes).map((candidate) => ({ + disabled: + isCreateMode && + defaultRuntime !== null && + candidate.availability !== "available", + label: `${formatRuntimeOptionLabel(candidate)}${ + isCreateMode && candidate.id === defaultRuntime?.id ? " (default)" : "" + }`, + value: candidate.id, + })), + ]; + + if ( + runtime.trim().length > 0 && + !options.some((option) => option.value === runtime) + ) { + options.push({ + label: `${runtime.trim()} (current)`, + value: runtime.trim(), + }); + } + + return { blankLabel, options }; +} + +/** Why the selected harness cannot run, and where to fix it. */ +export function PersonaRuntimeWarning({ + runtime, +}: { + runtime: AcpRuntimeCatalogEntry | undefined; +}) { + if (!runtime || runtime.availability === "available") return null; + return ( +

    + {runtime.availability === "adapter_missing" + ? `${runtime.label} CLI is installed but the ACP adapter is missing.` + : runtime.availability === "adapter_outdated" + ? `${runtime.label} ACP adapter is outdated — reinstall to continue.` + : runtime.requiresExternalCli + ? `${runtime.label} CLI is missing. ${runtime.installHint}` + : `${runtime.label} is not installed.`}{" "} + Visit Settings > Agents to set it up. +

    + ); +} + +/** + * Model options for the definition dialog. + * + * "Automatic" only exists on shared compute (relay-mesh), where the mesh picks + * the model; every other provider must name one, so the auto row is dropped. + */ +export function buildPersonaModelDropdownOptions({ + isRelayMesh, + loading, + loadingValue, + options, +}: { + isRelayMesh: boolean; + loading: boolean; + loadingValue: string; + options: readonly { id: string; label: string }[]; +}): PersonaDropdownOption[] { + return buildModelDropdownOptions({ + allowCustom: !isRelayMesh, + globalModel: undefined, + loading, + loadingValue, + options, + }) + .filter( + (option) => isRelayMesh || option.value !== AUTO_MODEL_DROPDOWN_VALUE, + ) + .map((option) => + isRelayMesh && option.value === AUTO_MODEL_DROPDOWN_VALUE + ? { ...option, label: "Automatic" } + : option, + ); +} diff --git a/desktop/src/features/agents/ui/useServerAgentEditContext.ts b/desktop/src/features/agents/ui/useServerAgentEditContext.ts new file mode 100644 index 0000000000..bbd5586146 --- /dev/null +++ b/desktop/src/features/agents/ui/useServerAgentEditContext.ts @@ -0,0 +1,66 @@ +import { useSpawnerDirectory } from "../spawnerDirectoryStore"; +import { usePendingSpawnerPromptUpdate } from "../spawnerPromptUpdateQueue"; +import type { PersonaDropdownOption } from "./agentConfigOptions"; +import { runtimeLabel, spawnerLabel } from "./ServerAgentsSection"; +import { + resolveServerAgentEditContext, + serverModelOptions, + type ServerAgentEditContext, +} from "./serverAgentEditPolicy"; + +/** Everything the Edit dialogs need to render a server-hosted agent. */ +export type ServerAgentEditState = { + /** Non-null when the agent being edited lives on a spawner. */ + context: ServerAgentEditContext | null; + /** Friendly runtime name advertised by that spawner, when it advertised one. */ + runtime: string | undefined; + /** A prompt edit is out but not yet confirmed by the spawner. */ + pending: boolean; + /** Provider/model catalog, or null when the spawner advertised none. */ + ai: { providers: string[]; models: string[] } | null; + providerOptions: PersonaDropdownOption[]; + modelOptions: PersonaDropdownOption[]; +}; + +/** + * Resolve server residency plus the spawner-advertised model catalog. + * + * Shared by the instance and definition dialogs: both need the same "is this + * configured here or over there" answer, and both must offer the *host's* + * models rather than whatever this Mac happens to have credentials for. + */ +export function useServerAgentEditContext(input: { + relocatedToSpawner: string | null | undefined; + deployedSpawnerPubkey: string | null; + agentPubkey: string | null; + slug: string | null; + provider: string; +}): ServerAgentEditState { + const directory = useSpawnerDirectory(); + const context = resolveServerAgentEditContext({ + relocatedToSpawner: input.relocatedToSpawner, + deployedSpawnerPubkey: input.deployedSpawnerPubkey, + agentPubkey: input.agentPubkey, + slug: input.slug, + spawnerNameFor: (pubkey) => spawnerLabel(pubkey, directory), + }); + const promptUpdate = usePendingSpawnerPromptUpdate( + context?.agentPubkey ?? "", + ); + const announcement = context ? directory.get(context.spawnerPubkey) : null; + const ai = context + ? serverModelOptions(announcement?.ai, input.provider.trim() || null) + : null; + + return { + context, + runtime: runtimeLabel(announcement?.runtime), + pending: promptUpdate?.pending ?? false, + ai, + providerOptions: (ai?.providers ?? []).map((id) => ({ + label: id, + value: id, + })), + modelOptions: (ai?.models ?? []).map((id) => ({ label: id, value: id })), + }; +} From f3c4df154c92494f7e7c2966f502b56aab9a6347 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 03:56:55 +0530 Subject: [PATCH 27/50] fix(desktop): reconcile server agent AI fields with spawner catalog Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sid --- .../agents/ui/AgentDefinitionDialog.tsx | 66 ++++++++------- .../agents/ui/AgentInstanceEditDialog.tsx | 80 +++++++++++-------- .../agents/ui/useServerAgentEditContext.ts | 20 +++++ 3 files changed, 103 insertions(+), 63 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 0167ae0bc4..ab82359d80 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -86,7 +86,10 @@ import { } from "./personaRuntimeDropdown"; import { ServerModelField } from "./ServerModelField"; import { ServerRunsOnBanner } from "./ServerRunsOnBanner"; -import { useServerAgentEditContext } from "./useServerAgentEditContext"; +import { + useServerAgentEditContext, + withCurrentValueOption, +} from "./useServerAgentEditContext"; type AgentDefinitionDialogProps = { open: boolean; @@ -377,6 +380,27 @@ export function AgentDefinitionDialog({ (runtime.trim().length > 0 && runtimeCanChooseLlmProvider) || blankRuntimeModelProviderEditable; const trimmedProvider = provider.trim(); + // Server residency: a definition deployed to a spawner is configured there, + // so the harness belongs to the host and the model catalog is the one it + // advertises. Matched on the *initial* name — the slug the spec was published + // under does not change while the user is retyping the display name. + const { agents: serverAgents } = useServerAgents(); + const deployedServerAgent = React.useMemo(() => { + if (isCreateMode || !initialValues) return null; + const slug = slugFromName(initialValues.displayName); + if (!slug) return null; + return serverAgents.find((candidate) => candidate.slug === slug) ?? null; + }, [isCreateMode, initialValues, serverAgents]); + const server = useServerAgentEditContext({ + relocatedToSpawner: null, + deployedSpawnerPubkey: deployedServerAgent?.spawnerPubkey ?? null, + agentPubkey: deployedServerAgent?.status.agentPubkey ?? null, + slug: deployedServerAgent?.slug ?? null, + provider: trimmedProvider, + }); + const serverContext = server.context; + const serverAi = server.ai; + // Required credential env keys for this runtime + provider combination. // Used to show required markers on the LLM provider label and amber // locked rows in the env vars editor. @@ -594,6 +618,7 @@ export function AgentDefinitionDialog({ React.useEffect(() => { if ( !open || + serverContext || !modelFieldVisible || isCustomModelEditing || !shouldClearKnownModelForSelectionScope({ @@ -614,6 +639,7 @@ export function AgentDefinitionDialog({ open, effectiveProvider, runtime, + serverContext, ]); const selection: RuntimeModelProviderSelection = { @@ -677,27 +703,6 @@ export function AgentDefinitionDialog({ ); } - // Server residency: a definition deployed to a spawner is configured there, - // so the harness belongs to the host and the model catalog is the one it - // advertises. Matched on the *initial* name — the slug the spec was published - // under does not change while the user is retyping the display name. - const { agents: serverAgents } = useServerAgents(); - const deployedServerAgent = React.useMemo(() => { - if (isCreateMode || !initialValues) return null; - const slug = slugFromName(initialValues.displayName); - if (!slug) return null; - return serverAgents.find((candidate) => candidate.slug === slug) ?? null; - }, [isCreateMode, initialValues, serverAgents]); - const server = useServerAgentEditContext({ - relocatedToSpawner: null, - deployedSpawnerPubkey: deployedServerAgent?.spawnerPubkey ?? null, - agentPubkey: deployedServerAgent?.status.agentPubkey ?? null, - slug: deployedServerAgent?.slug ?? null, - provider: trimmedProvider, - }); - const serverContext = server.context; - const serverAi = server.ai; - return ( { @@ -824,7 +829,6 @@ export function AgentDefinitionDialog({ className="space-y-5" data-testid={`agent-${aiConfigurationMode}-configuration-section`} > - {/* Harness is the spawner's choice for a server-hosted agent. */} {aiConfigurationMode === "custom" && !serverContext ? ( ) : null} @@ -909,11 +915,13 @@ export function AgentDefinitionDialog({ modelDiscoveryStatus={modelDiscoveryStatus} modelDropdownOptions={ serverContext && serverAi - ? server.modelOptions + ? withCurrentValueOption(server.modelOptions, model) : modelDropdownOptions } modelSelectValue={ - serverContext && serverAi ? model : modelSelectValue + serverContext && serverAi + ? model.trim() + : modelSelectValue } onCustomModelChange={setModel} showSharedComputeAutoHint={ @@ -929,7 +937,7 @@ export function AgentDefinitionDialog({ ) : null} - {aiConfigurationMode === "defaults" ? ( + {aiConfigurationMode === "defaults" && !serverContext ? ( { + const matched = serverAgents.find( + (candidate) => candidate.status.agentPubkey === agent.pubkey, + ); + return matched?.slug ?? slugFromName(agent.name); + }, [serverAgents, agent.pubkey, agent.name]); + const server = useServerAgentEditContext({ + relocatedToSpawner: agent.relocatedToSpawner, + deployedSpawnerPubkey: null, + agentPubkey: agent.pubkey, + slug: serverSpecSlug, + provider, + }); + const serverContext = server.context; + const serverAi = server.ai; + // Clear model when provider scope changes and current model is no longer valid. React.useEffect(() => { if ( !open || + serverContext || isCustomModelEditing || !shouldClearKnownModelForSelectionScope({ model, @@ -472,6 +496,7 @@ export function AgentInstanceEditDialog({ providerForDiscovery, selectedRuntime, selectedRuntimeId, + serverContext, ]); const selection: RuntimeModelProviderSelection = { @@ -823,26 +848,6 @@ export function AgentInstanceEditDialog({ { label: "Custom provider...", value: CUSTOM_PROVIDER_DROPDOWN_VALUE }, ]; - // Server residency: a relocated agent is configured on the spawner, so the - // harness is not this device's to choose and the model catalog is whatever - // the spawner advertises. - const { agents: serverAgents } = useServerAgents(); - const serverSpecSlug = React.useMemo(() => { - const matched = serverAgents.find( - (candidate) => candidate.status.agentPubkey === agent.pubkey, - ); - return matched?.slug ?? slugFromName(agent.name); - }, [serverAgents, agent.pubkey, agent.name]); - const server = useServerAgentEditContext({ - relocatedToSpawner: agent.relocatedToSpawner, - deployedSpawnerPubkey: null, - agentPubkey: agent.pubkey, - slug: serverSpecSlug, - provider, - }); - const serverContext = server.context; - const serverAi = server.ai; - const previewLabel = name.trim() || "Agent name"; const previewAvatarUrl = avatarUrl.trim() || null; const advancedFieldsTransition = shouldReduceMotion @@ -984,13 +989,15 @@ export function AgentInstanceEditDialog({ onValueChange={handleProviderDropdownChange} options={ serverContext && serverAi - ? server.providerOptions + ? withCurrentValueOption(server.providerOptions, provider) : providerDropdownOptions } placeholder="Default (auto)" providerValue={provider} - selectValue={providerSelectValue} - showCustomInput={isCustomProviderEditing} + selectValue={ + serverContext ? provider.trim() : providerSelectValue + } + showCustomInput={!serverContext && isCustomProviderEditing} /> ) : null} @@ -1044,11 +1051,13 @@ export function AgentInstanceEditDialog({ onValueChange={handleModelDropdownChange} options={ serverContext && serverAi - ? server.modelOptions + ? withCurrentValueOption(server.modelOptions, model) : modelDropdownOptions } placeholder="Default model" - value={serverContext && serverAi ? model : modelSelectValue} + value={ + serverContext && serverAi ? model.trim() : modelSelectValue + } /> )} {!serverContext && showCustomModelInput ? ( @@ -1086,14 +1095,17 @@ export function AgentInstanceEditDialog({

    ) : null} - setAiDefaultsOpen(true)} - triggerRef={aiDefaultsTriggerRef} - explicitModel={inheritedSubmission.model ?? ""} - explicitProvider={inheritedSubmission.provider ?? ""} - inheritedModel={inheritedModelDefault} - inheritedProvider={inheritedProviderDefault} - /> + {/* Local AI defaults are this device's — meaningless on a server. */} + {serverContext ? null : ( + setAiDefaultsOpen(true)} + triggerRef={aiDefaultsTriggerRef} + explicitModel={inheritedSubmission.model ?? ""} + explicitProvider={inheritedSubmission.provider ?? ""} + inheritedModel={inheritedModelDefault} + inheritedProvider={inheritedProviderDefault} + /> + )} ({ label: id, value: id })), }; } + +/** + * Append the current value as a "(current)" row when the spawner's catalog does + * not list it. + * + * A spawner advertises what it can run today; an agent may already be + * configured with something outside that list (an older catalog, a value set + * from another device). Dropping it would render the field blank and make an + * unrelated edit silently rewrite the value. + */ +export function withCurrentValueOption( + options: PersonaDropdownOption[], + value: string, +): PersonaDropdownOption[] { + const trimmed = value.trim(); + if (!trimmed || options.some((option) => option.value === trimmed)) { + return options; + } + return [...options, { label: `${trimmed} (current)`, value: trimmed }]; +} From 4d4ee7de092e38f7e9707bd9c1811647004bee3b Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 04:01:35 +0530 Subject: [PATCH 28/50] feat(desktop): push prompt/model edits to the spawner on save Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sid --- .../agents/ui/AgentDefinitionDialog.tsx | 25 +++++++++++++++++-- .../agents/ui/AgentInstanceEditDialog.tsx | 24 +++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index ab82359d80..c9e707f95f 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -14,6 +14,7 @@ import { Dialog } from "@/shared/ui/dialog"; import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; import { AgentCreationPreview } from "./AgentCreationPreview"; +import { enqueueSpawnerPromptUpdate } from "../spawnerPromptUpdateQueue"; import type { EnvVarsValue } from "./EnvVarsEditor"; import { PersonaAdvancedFields } from "./PersonaAdvancedFields"; import { PersonaModelField } from "./PersonaModelField"; @@ -355,10 +356,29 @@ export function AgentDefinitionDialog({ }; if ("id" in initialValues) { - await onSubmit({ + const result = await onSubmit({ id: initialValues.id, ...baseInput, }); + if (serverContext && result !== false) { + try { + await enqueueSpawnerPromptUpdate({ + spawnerPubkey: serverContext.spawnerPubkey, + specSlug: serverContext.specSlug, + agentPubkey: serverContext.agentPubkey, + prompt: { + system_prompt: baseInput.systemPrompt || undefined, + model: baseInput.model || undefined, + provider: baseInput.provider || undefined, + }, + }); + } catch (error) { + console.debug( + "[AgentDefinitionDialog] enqueueSpawnerPromptUpdate failed:", + error, + ); + } + } return; } @@ -865,7 +885,8 @@ export function AgentDefinitionDialog({ {llmProviderFieldVisible && aiConfigurationMode === "custom" && - topLevelSecretEnvVar ? ( + topLevelSecretEnvVar && + !serverContext ? ( ) : null} - {llmProviderFieldVisible && topLevelSecretEnvVar ? ( + {llmProviderFieldVisible && + topLevelSecretEnvVar && + !serverContext ? ( Date: Mon, 27 Jul 2026 04:18:33 +0530 Subject: [PATCH 29/50] test(spawner): owner_sim exercises prompt-update push and ack Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sid --- crates/buzz-spawner/examples/owner_sim.rs | 127 +++++++++++++++++++++- 1 file changed, 125 insertions(+), 2 deletions(-) diff --git a/crates/buzz-spawner/examples/owner_sim.rs b/crates/buzz-spawner/examples/owner_sim.rs index 24b6913f89..3b67c396bb 100644 --- a/crates/buzz-spawner/examples/owner_sim.rs +++ b/crates/buzz-spawner/examples/owner_sim.rs @@ -13,6 +13,15 @@ //! --owner-nsec \ //! --slug fizz-prod //! ``` +//! +//! Pass `--verify-prompt-update` to additionally exercise the prompt-edit +//! path once the agent reaches `Running`: it pushes an +//! [`AttestationFrame::PromptUpdate`] with a changed model, then asserts the +//! next `Running` status reports `prompt_hash == PromptMaterial::hash()` for +//! the pushed material. It also logs (best-effort, not asserted) whether it +//! observed an intermediate non-`Running` phase in between — kind:30179 is +//! NIP-33 replaceable, so a fast restart can coalesce before a client's +//! subscription ever sees the transient. Panics if the hash does not match. use std::{collections::HashMap, time::Duration}; @@ -21,7 +30,7 @@ use buzz_core::kind::{KIND_SPAWNER_AGENT_STATUS, KIND_SPAWNER_ATTESTATION}; use buzz_sdk::nip_oa; use buzz_sdk::spawner::{ build_spawner_agent_spec, build_spawner_attestation, status_from_event, AttestationFrame, - RespondTo, SpawnerAgentSpec, + PromptMaterial, RespondTo, SpawnPhase, SpawnerAgentSpec, }; use buzz_ws_client::{connection::NostrWsConnection, message::RelayMessage}; use nostr::{Keys, PublicKey}; @@ -121,6 +130,11 @@ async fn main() -> Result<()> { println!("\nwatching (Ctrl-C to stop)…\n"); let mut seen_status: HashMap = HashMap::new(); + // Prompt-update verification leg (`--verify-prompt-update`): once the agent + // first reaches `Running`, push a `PromptUpdate` with a changed model and + // confirm the spawner both restarts the container and reports the new + // material's hash on a subsequent status. + let mut prompt_update: Option = None; loop { let msg = match conn.next_event(Duration::from_secs(30)).await { Ok(msg) => msg, @@ -150,14 +164,26 @@ async fn main() -> Result<()> { let Ok(status) = status_from_event(&event) else { continue; }; + // This owner pubkey may have other slugs' agents running + // concurrently (e.g. from an earlier invocation against the same + // relay) — their statuses share the `#p` filter above, so only + // react to the slug this run actually published. + if buzz_sdk::spawner::spec_slug_from_event(&event).as_deref() != Some(&args.slug) { + continue; + } let line = format!( - "{:?}{}{}", + "{:?}{}{}{}", status.phase, status .agent_pubkey .as_deref() .map(|a| format!(" agent={}", &a[..12])) .unwrap_or_default(), + status + .prompt_hash + .as_deref() + .map(|h| format!(" prompt_hash={}", &h[..12])) + .unwrap_or_default(), status .error .as_deref() @@ -169,10 +195,103 @@ async fn main() -> Result<()> { println!(" status: {line}"); seen_status.insert(args.slug.clone(), line); } + + if args.verify_prompt_update { + if let Some(agent) = status.agent_pubkey.clone() { + match &mut prompt_update { + None if status.phase == SpawnPhase::Running => { + // First time we see the agent running: push the + // updated prompt material and start watching for + // the restart + hash-matching status that follows. + let material = PromptMaterial { + system_prompt: Some( + "You are a test agent running on a Buzz spawner.".into(), + ), + team_instructions: None, + model: Some("verify-prompt-update-model".into()), + provider: None, + }; + let expected_hash = material.hash(); + let frame = AttestationFrame::PromptUpdate { + spec_slug: args.slug.clone(), + agent_pubkey: agent.clone(), + prompt: material, + }; + let ciphertext = nostr::nips::nip44::encrypt( + keys.secret_key(), + &spawner, + serde_json::to_string(&frame)?, + nostr::nips::nip44::Version::V2, + ) + .context("encrypt prompt update")?; + let out = build_spawner_attestation(&spawner.to_hex(), &ciphertext)? + .sign_with_keys(&keys)?; + let ok = conn.send_event(out).await?; + if !ok.accepted { + bail!("relay rejected the prompt update: {}", ok.message); + } + println!( + "→ sent PromptUpdate for agent {} (expecting hash {})", + &agent[..12], + &expected_hash[..12] + ); + prompt_update = Some(PromptUpdateCheck { + expected_hash, + saw_restart: false, + confirmed: false, + }); + } + Some(check) if !check.confirmed => { + if status.phase != SpawnPhase::Running { + // A non-running phase after the push is the + // restart cycling back through reconciliation. + // Best-effort only: kind:30179 is NIP-33 + // replaceable, so a fast restart can flip + // Running -> Starting -> Running between two + // deliveries and this transient is never seen + // over the wire. The hash assertion below is + // the reliable signal that the update actually + // took effect (apply_prompt_update only stores + // the new material after clearing spec_hash, + // which forces the restart). + check.saw_restart = true; + } else if status.phase == SpawnPhase::Running { + if !check.saw_restart { + println!( + " (no intermediate restart phase observed over the \ + wire — likely coalesced; hash check below is the \ + authoritative signal)" + ); + } + assert_eq!( + status.prompt_hash.as_deref(), + Some(check.expected_hash.as_str()), + "status prompt_hash does not match the pushed \ + PromptMaterial::hash()" + ); + check.confirmed = true; + println!( + "✓ prompt update verified: status.prompt_hash matches \ + the pushed PromptMaterial::hash()" + ); + } + } + _ => {} + } + } + } } } } +/// Tracks the `--verify-prompt-update` leg's expectations across the +/// subsequent stream of status events. +struct PromptUpdateCheck { + expected_hash: String, + saw_restart: bool, + confirmed: bool, +} + /// Answer a spawner's attestation request. Returns the attested agent pubkey. async fn handle_attestation( conn: &mut NostrWsConnection, @@ -275,6 +394,7 @@ struct Args { delete: bool, persona: Option, share_persona: bool, + verify_prompt_update: bool, } impl Args { @@ -288,6 +408,7 @@ impl Args { let mut delete = false; let mut persona = None; let mut share_persona = false; + let mut verify_prompt_update = false; let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { @@ -303,6 +424,7 @@ impl Args { "--delete" => delete = true, "--persona" => persona = Some(args.next().context("--persona needs a value")?), "--share-persona" => share_persona = true, + "--verify-prompt-update" => verify_prompt_update = true, other => bail!("unknown argument {other}"), } } @@ -317,6 +439,7 @@ impl Args { delete, persona, share_persona, + verify_prompt_update, }) } } From e9db774b43d8a81828c5dcfb7d6639a8c4660a59 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 04:34:49 +0530 Subject: [PATCH 30/50] refactor(desktop): extract server prompt-update push to satisfy file-size guard Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sid --- .../agents/ui/AgentDefinitionDialog.tsx | 27 +------ .../agents/ui/AgentInstanceEditDialog.tsx | 22 +----- .../agents/ui/serverPromptUpdatePush.ts | 76 +++++++++++++++++++ 3 files changed, 81 insertions(+), 44 deletions(-) create mode 100644 desktop/src/features/agents/ui/serverPromptUpdatePush.ts diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index c9e707f95f..de0dbdc247 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -14,7 +14,7 @@ import { Dialog } from "@/shared/ui/dialog"; import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; import { AgentCreationPreview } from "./AgentCreationPreview"; -import { enqueueSpawnerPromptUpdate } from "../spawnerPromptUpdateQueue"; +import { pushServerPromptUpdateAfterSubmit } from "./serverPromptUpdatePush"; import type { EnvVarsValue } from "./EnvVarsEditor"; import { PersonaAdvancedFields } from "./PersonaAdvancedFields"; import { PersonaModelField } from "./PersonaModelField"; @@ -356,29 +356,8 @@ export function AgentDefinitionDialog({ }; if ("id" in initialValues) { - const result = await onSubmit({ - id: initialValues.id, - ...baseInput, - }); - if (serverContext && result !== false) { - try { - await enqueueSpawnerPromptUpdate({ - spawnerPubkey: serverContext.spawnerPubkey, - specSlug: serverContext.specSlug, - agentPubkey: serverContext.agentPubkey, - prompt: { - system_prompt: baseInput.systemPrompt || undefined, - model: baseInput.model || undefined, - provider: baseInput.provider || undefined, - }, - }); - } catch (error) { - console.debug( - "[AgentDefinitionDialog] enqueueSpawnerPromptUpdate failed:", - error, - ); - } - } + const result = await onSubmit({ id: initialValues.id, ...baseInput }); + await pushServerPromptUpdateAfterSubmit(serverContext, result, baseInput); return; } diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index c7af8fac55..6841ebedd5 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -83,7 +83,7 @@ import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; import { resolveModelFieldStatusMessage } from "./agentConfigControls"; import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge"; import { showAgentProfileSyncWarning } from "./agentProfileSyncWarning"; -import { enqueueSpawnerPromptUpdate } from "../spawnerPromptUpdateQueue"; +import { pushPrompt } from "./serverPromptUpdatePush"; import { useServerAgents } from "../useServerAgents"; import { slugFromName } from "../spawnerPreference"; import { EditAgentRuntimeSection } from "./EditAgentRuntimeSection"; @@ -736,25 +736,7 @@ export function AgentInstanceEditDialog({ }; const result = await updateMutation.mutateAsync(input); - if (serverContext) { - try { - await enqueueSpawnerPromptUpdate({ - spawnerPubkey: serverContext.spawnerPubkey, - specSlug: serverContext.specSlug, - agentPubkey: serverContext.agentPubkey, - prompt: { - system_prompt: systemPrompt.trim() || undefined, - model: (normalizedModel ?? "") || undefined, - provider: (normalizedSubmitProvider ?? "") || undefined, - }, - }); - } catch (error) { - console.debug( - "[AgentInstanceEditDialog] enqueueSpawnerPromptUpdate failed:", - error, - ); - } - } + await pushPrompt(serverContext, systemPrompt, inheritedSubmission); if (autoRestartOnConfigChange !== agent.autoRestartOnConfigChange) { // Standalone setter (mirrors start-on-app-launch) — not part of // UpdateManagedAgentInput, so the frozen update shape stays frozen. diff --git a/desktop/src/features/agents/ui/serverPromptUpdatePush.ts b/desktop/src/features/agents/ui/serverPromptUpdatePush.ts new file mode 100644 index 0000000000..6c228cda58 --- /dev/null +++ b/desktop/src/features/agents/ui/serverPromptUpdatePush.ts @@ -0,0 +1,76 @@ +import { enqueueSpawnerPromptUpdate } from "../spawnerPromptUpdateQueue"; +import type { ServerAgentEditContext } from "./serverAgentEditPolicy"; + +/** + * Push a prompt/model/provider edit to the spawner after a successful + * persona/instance save. + * + * Shared by `AgentDefinitionDialog` and `AgentInstanceEditDialog`: both save + * a server-resident agent locally, then must also relay the same edit to the + * spawner that actually runs it. Never throws — the queue owns delivery and + * retries, so a failed send here must not surface as a failed save. Errors + * are logged the same way `spawnerPromptUpdateQueue` logs its own send + * failures (`console.debug`, module-prefixed). + * + * `context` accepts `null` so callers can pass their (possibly-null) resolved + * edit context straight through without an `if` wrapper at the call site. + */ +export type ServerPromptUpdateSaved = { + systemPrompt: string | null | undefined; + model: string | null | undefined; + provider: string | null | undefined; +}; + +export async function pushServerPromptUpdate( + context: ServerAgentEditContext | null, + saved: ServerPromptUpdateSaved, +): Promise { + if (!context) return; + try { + await enqueueSpawnerPromptUpdate({ + spawnerPubkey: context.spawnerPubkey, + specSlug: context.specSlug, + agentPubkey: context.agentPubkey, + prompt: { + system_prompt: saved.systemPrompt || undefined, + model: saved.model || undefined, + provider: saved.provider || undefined, + }, + }); + } catch (error) { + console.debug("[server-prompt-update-push] enqueue failed:", error); + } +} + +/** + * Convenience wrapper for `AgentDefinitionDialog`'s edit-submit path: that + * dialog's `onSubmit` prop resolves to `unknown` (its callers agree on a + * `boolean` success/failure convention without the type enforcing it — see + * task-8-report.md), so the "did the save actually succeed" check is folded + * in here to keep the call site a single line. + */ +export async function pushServerPromptUpdateAfterSubmit( + context: ServerAgentEditContext | null, + submitResult: unknown, + saved: ServerPromptUpdateSaved, +): Promise { + if (submitResult === false) return; + await pushServerPromptUpdate(context, saved); +} + +/** + * Short-named variant for `AgentInstanceEditDialog`'s save path, which + * already has model/provider bundled in its own `inheritedSubmission` + * snapshot (see `resolveInheritedRuntimeSubmission`) — this just adds + * `systemPrompt` alongside it without requiring an intermediate object. + */ +export async function pushPrompt( + context: ServerAgentEditContext | null, + systemPrompt: string | null | undefined, + modelProvider: { + model: string | null | undefined; + provider: string | null | undefined; + }, +): Promise { + await pushServerPromptUpdate(context, { systemPrompt, ...modelProvider }); +} From d4fb6fa368a275a584f3f8f057d8cb21f908c3ab Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 04:37:06 +0530 Subject: [PATCH 31/50] fix(desktop): trim prompt material before spawner push Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sid --- desktop/src/features/agents/ui/serverPromptUpdatePush.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/agents/ui/serverPromptUpdatePush.ts b/desktop/src/features/agents/ui/serverPromptUpdatePush.ts index 6c228cda58..126c8ea1e8 100644 --- a/desktop/src/features/agents/ui/serverPromptUpdatePush.ts +++ b/desktop/src/features/agents/ui/serverPromptUpdatePush.ts @@ -32,9 +32,9 @@ export async function pushServerPromptUpdate( specSlug: context.specSlug, agentPubkey: context.agentPubkey, prompt: { - system_prompt: saved.systemPrompt || undefined, - model: saved.model || undefined, - provider: saved.provider || undefined, + system_prompt: saved.systemPrompt?.trim() || undefined, + model: saved.model?.trim() || undefined, + provider: saved.provider?.trim() || undefined, }, }); } catch (error) { From d81aacea39e18baf44c2ef578512a9bed4894b79 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 04:52:31 +0530 Subject: [PATCH 32/50] fix(spawner): preserve team instructions across prompt updates A desktop prompt update carries only system prompt, model, and provider, so replacing the stored material wholesale wiped the agent's team instructions on the next container restart. Carry them forward in a new record field instead of merging them into the stored prompt: that field must stay byte-identical to the frame the owner sent, since its hash is what the status event echoes and what the client matches to confirm the update landed. Also warn on `PromptMaterial::hash` that the hash is published world-readable, so no credential field may ever join the struct. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sid --- crates/buzz-sdk/src/spawner.rs | 8 ++ crates/buzz-spawner/src/attestation.rs | 1 + crates/buzz-spawner/src/daemon.rs | 116 +++++++++++++++++++++++-- crates/buzz-spawner/src/env.rs | 1 + crates/buzz-spawner/src/reconcile.rs | 1 + crates/buzz-spawner/src/store.rs | 12 +++ 6 files changed, 133 insertions(+), 6 deletions(-) diff --git a/crates/buzz-sdk/src/spawner.rs b/crates/buzz-sdk/src/spawner.rs index 3b23d3894e..fb2f141292 100644 --- a/crates/buzz-sdk/src/spawner.rs +++ b/crates/buzz-sdk/src/spawner.rs @@ -446,6 +446,14 @@ impl PromptMaterial { /// /// Serialization skips `None` fields, so two materials with the same set /// values hash identically regardless of construction order. + /// + /// # Never add a credential field to this struct + /// + /// This hash is published world-readable as `prompt_hash` on the spawner's + /// kind:30179 status so a client can confirm its update landed. A secret + /// added to `PromptMaterial` would therefore be offered up for offline + /// guessing by anyone reading the relay. Prompts and model ids are not + /// secrets; API keys, tokens, and nsecs must never be fields here. pub fn hash(&self) -> String { use sha2::{Digest, Sha256}; let json = serde_json::to_string(self).unwrap_or_default(); diff --git a/crates/buzz-spawner/src/attestation.rs b/crates/buzz-spawner/src/attestation.rs index 258637b699..94986d1e9e 100644 --- a/crates/buzz-spawner/src/attestation.rs +++ b/crates/buzz-spawner/src/attestation.rs @@ -186,6 +186,7 @@ mod tests { prompt: None, restart_count: 0, last_failure_at: None, + carried_team_instructions: None, }; Fixture { owner, diff --git a/crates/buzz-spawner/src/daemon.rs b/crates/buzz-spawner/src/daemon.rs index 2a2211b784..47be571584 100644 --- a/crates/buzz-spawner/src/daemon.rs +++ b/crates/buzz-spawner/src/daemon.rs @@ -276,7 +276,15 @@ impl Daemon { } info!(slug = %record.slug, "prompt updated by owner"); + let carried = carried_team_instructions(&record, prompt); self.store.update(&record.owner_pubkey, &record.slug, |r| { + // `r.prompt` is stored exactly as received so `prompt_hash_for` + // reproduces the hash the owner computed over the frame — that echo + // is the client's only ack. Team instructions, which the desktop's + // prompt update never carries, ride alongside instead of being + // merged in, so a model tweak no longer wipes them (see + // `AgentRecord::carried_team_instructions`). + r.carried_team_instructions = carried.clone(); r.prompt = Some(prompt.clone()); // Force a restart: the container bakes the prompt into its env, so // a new prompt only takes effect on a fresh container. @@ -441,6 +449,7 @@ impl Daemon { prompt: None, restart_count: 0, last_failure_at: None, + carried_team_instructions: None, }; info!( slug = %record.slug, @@ -471,6 +480,7 @@ impl Daemon { prompt: None, restart_count: 0, last_failure_at: None, + carried_team_instructions: None, }; info!( @@ -716,12 +726,7 @@ impl Daemon { // most recently intended. if let Some(record) = self.store.get(&desired.owner_pubkey, &desired.slug) { if let Some(prompt) = record.prompt.as_ref().filter(|p| !p.is_empty()) { - return Ok(ResolvedPrompt { - system_prompt: prompt.system_prompt.clone(), - team_instructions: prompt.team_instructions.clone(), - model: prompt.model.clone(), - provider: prompt.provider.clone(), - }); + return Ok(resolved_from_record(record, prompt)); } } @@ -810,6 +815,45 @@ fn prompt_hash_for(record: Option<&AgentRecord>) -> Option { record.and_then(|r| r.prompt.as_ref()).map(|p| p.hash()) } +/// Team instructions to keep after applying `incoming` to `record`. +/// +/// "Previous wins when incoming is None": a desktop prompt update carries only +/// system prompt / model / provider, so without this a model tweak would drop +/// the agent's team instructions at its next restart. +fn carried_team_instructions( + record: &AgentRecord, + incoming: &buzz_sdk::spawner::PromptMaterial, +) -> Option { + incoming + .team_instructions + .clone() + .or_else(|| { + record + .prompt + .as_ref() + .and_then(|p| p.team_instructions.clone()) + }) + .or_else(|| record.carried_team_instructions.clone()) +} + +/// Container-env view of a record's stored prompt, with team instructions +/// filled in from [`AgentRecord::carried_team_instructions`] when the stored +/// material itself has none. +fn resolved_from_record( + record: &AgentRecord, + prompt: &buzz_sdk::spawner::PromptMaterial, +) -> ResolvedPrompt { + ResolvedPrompt { + system_prompt: prompt.system_prompt.clone(), + team_instructions: prompt + .team_instructions + .clone() + .or_else(|| record.carried_team_instructions.clone()), + model: prompt.model.clone(), + provider: prompt.provider.clone(), + } +} + /// Truncate on a char boundary so a multi-byte log tail cannot panic. fn truncate(s: &str, max: usize) -> String { if s.len() <= max { @@ -887,6 +931,7 @@ mod tests { prompt, restart_count: 0, last_failure_at: None, + carried_team_instructions: None, } } @@ -909,6 +954,65 @@ mod tests { assert_eq!(prompt_hash_for(None), None); } + #[test] + fn prompt_update_without_team_instructions_keeps_them_for_the_container() { + // The desktop's prompt update only carries system prompt / model / + // provider. Applying one must not wipe team instructions delivered + // earlier over the attestation handshake, and the status hash must + // still equal the hash of the frame material the owner sent. + let mut record = test_record(Some(buzz_sdk::spawner::PromptMaterial { + system_prompt: Some("be Fizz".into()), + team_instructions: Some("ship small PRs".into()), + model: Some("old-model".into()), + provider: None, + })); + + let incoming = buzz_sdk::spawner::PromptMaterial { + system_prompt: Some("be Fizz".into()), + team_instructions: None, + model: Some("new-model".into()), + provider: None, + }; + + // Mirrors `apply_prompt_update`'s store mutation. + record.carried_team_instructions = carried_team_instructions(&record, &incoming); + record.prompt = Some(incoming.clone()); + + let resolved = resolved_from_record(&record, record.prompt.as_ref().unwrap()); + assert_eq!( + resolved.team_instructions.as_deref(), + Some("ship small PRs") + ); + assert_eq!(resolved.model.as_deref(), Some("new-model")); + assert_eq!(prompt_hash_for(Some(&record)), Some(incoming.hash())); + + // And a later update still carries them, now via the carry field. + let second = buzz_sdk::spawner::PromptMaterial { + system_prompt: Some("be Fizzier".into()), + ..Default::default() + }; + assert_eq!( + carried_team_instructions(&record, &second).as_deref(), + Some("ship small PRs") + ); + } + + #[test] + fn an_explicit_team_instruction_in_the_update_wins() { + let record = test_record(Some(buzz_sdk::spawner::PromptMaterial { + team_instructions: Some("old".into()), + ..Default::default() + })); + let incoming = buzz_sdk::spawner::PromptMaterial { + team_instructions: Some("new".into()), + ..Default::default() + }; + assert_eq!( + carried_team_instructions(&record, &incoming).as_deref(), + Some("new") + ); + } + #[test] fn persona_content_ignores_unknown_fields() { // A persona event carrying extra keys must not leak them anywhere. diff --git a/crates/buzz-spawner/src/env.rs b/crates/buzz-spawner/src/env.rs index ef45dd28c8..80b2175525 100644 --- a/crates/buzz-spawner/src/env.rs +++ b/crates/buzz-spawner/src/env.rs @@ -163,6 +163,7 @@ mod tests { prompt: None, restart_count: 0, last_failure_at: None, + carried_team_instructions: None, } } diff --git a/crates/buzz-spawner/src/reconcile.rs b/crates/buzz-spawner/src/reconcile.rs index bbf935def9..acec2a97fb 100644 --- a/crates/buzz-spawner/src/reconcile.rs +++ b/crates/buzz-spawner/src/reconcile.rs @@ -358,6 +358,7 @@ mod tests { prompt: None, restart_count: 0, last_failure_at: None, + carried_team_instructions: None, } } diff --git a/crates/buzz-spawner/src/store.rs b/crates/buzz-spawner/src/store.rs index 37a0996f6e..7183f95ce0 100644 --- a/crates/buzz-spawner/src/store.rs +++ b/crates/buzz-spawner/src/store.rs @@ -47,6 +47,17 @@ pub struct AgentRecord { /// there is nowhere to fetch it from. #[serde(default, skip_serializing_if = "Option::is_none")] pub prompt: Option, + /// Team instructions carried forward across prompt updates. + /// + /// A prompt update from the desktop only carries the fields its edit dialog + /// owns (system prompt, model, provider), so replacing [`Self::prompt`] + /// wholesale would wipe the agent's team instructions on the next restart. + /// They are kept here rather than merged back into [`Self::prompt`] because + /// that field must stay byte-identical to the frame the owner sent: its + /// hash is what the status event echoes and what the client matches to + /// confirm the update landed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub carried_team_instructions: Option, /// Consecutive failed start attempts, driving backoff. #[serde(default)] pub restart_count: u32, @@ -242,6 +253,7 @@ mod tests { prompt: None, restart_count: 0, last_failure_at: None, + carried_team_instructions: None, } } From 2e6265ce260b4973d5cb7fcf80b1b56045dd05f7 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 04:52:45 +0530 Subject: [PATCH 33/50] fix(desktop): rehydrate spawner prompt-update queue lazily per relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The queue read storage at module import, when the cached relay origin is still null, so it loaded from a bare key while every write went to the origin-scoped one — pending updates never survived a restart. Hydrate lazily on first access instead, and make the community reset clear only in-memory state: persisting an empty map under the departing relay's key deleted that community's pending edits. Ack by the agent pubkey the spawner reports, with the slug only as a fallback for a status that has none. The slug a client queues under can drift from the spawner's (name-derived fallback, or a rename), and matching on it alone meant an ack that never arrived — a resend and a container restart every three minutes, forever. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sid --- .../agents/spawnerPromptUpdateQueue.test.mjs | 31 ++++++ .../agents/spawnerPromptUpdateQueue.ts | 99 ++++++++++++++++--- .../src/features/agents/spawnerStatusStore.ts | 12 ++- 3 files changed, 126 insertions(+), 16 deletions(-) diff --git a/desktop/src/features/agents/spawnerPromptUpdateQueue.test.mjs b/desktop/src/features/agents/spawnerPromptUpdateQueue.test.mjs index 16ecc38e21..b43c7b54fe 100644 --- a/desktop/src/features/agents/spawnerPromptUpdateQueue.test.mjs +++ b/desktop/src/features/agents/spawnerPromptUpdateQueue.test.mjs @@ -2,10 +2,15 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + findAckKey, queueReducer, shouldRetryPromptUpdate, } from "./spawnerPromptUpdateQueue.ts"; +function pending(entries) { + return new Map(entries.map((entry) => [entry.key, entry])); +} + test("enqueue is latest-write-wins per agent", () => { let s = queueReducer(new Map(), { type: "enqueue", @@ -77,6 +82,32 @@ test("reset clears every pending entry", () => { assert.equal(s.size, 0); }); +test("an ack matches on agent pubkey even when the slug differs", () => { + // The slug a client queues under falls back to one derived from the agent's + // name, so it can drift from the spawner's after a rename or a late spec + // load. Matching on slug alone would never ack, resending forever. + const state = pending([ + { key: "sp:ag", spawnerPubkey: "sp", agentPubkey: "ag", specSlug: "old" }, + ]); + assert.equal(findAckKey(state, "sp", "ag", "renamed"), "sp:ag"); +}); + +test("an ack falls back to the slug only when no agent pubkey is reported", () => { + const state = pending([ + { key: "sp:ag", spawnerPubkey: "sp", agentPubkey: "ag", specSlug: "fizz" }, + ]); + assert.equal(findAckKey(state, "sp", null, "fizz"), "sp:ag"); + // A reported-but-unknown agent pubkey is a different agent, not a slug hint. + assert.equal(findAckKey(state, "sp", "other", "fizz"), null); +}); + +test("an ack never crosses spawners", () => { + const state = pending([ + { key: "sp:ag", spawnerPubkey: "sp", agentPubkey: "ag", specSlug: "fizz" }, + ]); + assert.equal(findAckKey(state, "other-sp", "ag", "fizz"), null); +}); + test("a delivered, unacked entry is not retried immediately", () => { // Rust's reconcile loop republishes the spawner's announcement right after // applying a prompt update, before the confirming status has a chance to diff --git a/desktop/src/features/agents/spawnerPromptUpdateQueue.ts b/desktop/src/features/agents/spawnerPromptUpdateQueue.ts index 6bddee4ff7..feca6c777d 100644 --- a/desktop/src/features/agents/spawnerPromptUpdateQueue.ts +++ b/desktop/src/features/agents/spawnerPromptUpdateQueue.ts @@ -106,16 +106,39 @@ export function shouldRetryPromptUpdate( return now - entry.lastSentAt >= REDELIVER_FLOOR_MS; } +/** + * Storage key for the *current* relay. + * + * Resolved on every read and write, never captured: the cached relay origin is + * still null at module-import time, so a key computed once at import would read + * from a bare, community-less key and write to the real one — pending updates + * would never survive a restart. + */ function storageKey(): string { return `buzz:spawner-prompt-queue:${getCachedRelayOrigin() ?? ""}`; } const listeners = new Set<() => void>(); -let queue: ReadonlyMap = readStored(); +let queue: ReadonlyMap = new Map(); +/** + * Whether {@link queue} reflects storage for the current relay. Cleared by + * {@link resetSpawnerPromptUpdateQueue} so the next access rehydrates from the + * new community's key rather than carrying the old one's entries. + */ +let hydrated = false; const EMPTY: ReadonlyMap = new Map(); +/** The live queue, hydrating from storage on first access after a reset. */ +function currentQueue(): ReadonlyMap { + if (!hydrated) { + queue = readStored(); + hydrated = true; + } + return queue; +} + function readStored(): ReadonlyMap { try { const raw = window.localStorage.getItem(storageKey()); @@ -140,7 +163,7 @@ function persist(): void { } function dispatch(action: QueueAction): void { - const next = queueReducer(queue, action); + const next = queueReducer(currentQueue(), action); if (next === queue) return; queue = next; persist(); @@ -190,6 +213,34 @@ export async function enqueueSpawnerPromptUpdate(input: { }); } +/** + * Which pending entry a status event is acking. + * + * Matched on the agent pubkey the spawner reports, because that is what the + * spawner itself routes prompt updates by. The slug is only a fallback for a + * status that carries no agent pubkey yet: the slug a client queued under can + * legitimately differ from the spawner's (it falls back to a name-derived slug + * when the spec has not loaded, and a rename changes it), and matching on it + * alone lets an ack be missed forever — which means an endless resend and a + * container restart every few minutes. + * + * Exported for tests; pure over the queue map. + */ +export function findAckKey( + state: ReadonlyMap, + spawnerPubkey: string, + agentPubkey: string | null | undefined, + specSlug: string, +): string | null { + let slugMatch: string | null = null; + for (const [key, entry] of state) { + if (entry.spawnerPubkey !== spawnerPubkey) continue; + if (agentPubkey && entry.agentPubkey === agentPubkey) return key; + if (slugMatch === null && entry.specSlug === specSlug) slugMatch = key; + } + return agentPubkey ? null : slugMatch; +} + /** * Clear a pending entry once the spawner's status echoes back the matching * `prompt_hash`. A stale or mismatched hash (an older status revision, or a @@ -197,16 +248,13 @@ export async function enqueueSpawnerPromptUpdate(input: { */ export function ackSpawnerPromptUpdate( spawnerPubkey: string, + agentPubkey: string | null | undefined, specSlug: string, promptHash: string | null | undefined, ): void { if (!promptHash) return; - for (const [key, entry] of queue) { - if (entry.spawnerPubkey === spawnerPubkey && entry.specSlug === specSlug) { - dispatch({ type: "ack", key, promptHash }); - return; - } - } + const key = findAckKey(currentQueue(), spawnerPubkey, agentPubkey, specSlug); + if (key) dispatch({ type: "ack", key, promptHash }); } /** @@ -222,7 +270,7 @@ export function ackSpawnerPromptUpdate( */ export async function retryPendingSpawnerPromptUpdates(): Promise { const now = Date.now(); - for (const [key, entry] of queue) { + for (const [key, entry] of currentQueue()) { if (!shouldRetryPromptUpdate(entry, now)) continue; try { const promptHash = await sendSpawnerPromptUpdate({ @@ -247,9 +295,19 @@ export async function retryPendingSpawnerPromptUpdates(): Promise { } } -/** Tear down the queue. Community-scoped: pending edits belong to that relay. */ +/** + * Tear down the in-memory queue at a community boundary. + * + * Deliberately does *not* persist: the pending entries belong to the relay + * being left, and writing an empty map under its key would delete edits that + * still need delivering when the user switches back. Storage is left untouched + * and the next access rehydrates from whichever relay is then current. + */ export function resetSpawnerPromptUpdateQueue(): void { - dispatch({ type: "reset" }); + hydrated = false; + if (queue.size === 0) return; + queue = new Map(); + for (const listener of listeners) listener(); } function subscribe(listener: () => void): () => void { @@ -260,17 +318,24 @@ function subscribe(listener: () => void): () => void { } function getSnapshot(): ReadonlyMap { - return queue; + return currentQueue(); } function getServerSnapshot(): ReadonlyMap { return EMPTY; } -/** Reactive pending state for one agent's prompt update, or null when none. */ +/** + * Reactive pending state for one agent's prompt update, or null when none. + * + * `delivered` distinguishes the normal "sent, awaiting the spawner's status + * echo" window from an entry whose send never left this device (`promptHash` + * is `""`), which is the only case the UI may describe as the server being + * unreachable. + */ export function usePendingSpawnerPromptUpdate( agentPubkey: string, -): { pending: boolean; queuedAt: number } | null { +): { pending: boolean; delivered: boolean; queuedAt: number } | null { const snapshot = React.useSyncExternalStore( subscribe, getSnapshot, @@ -278,7 +343,11 @@ export function usePendingSpawnerPromptUpdate( ); for (const entry of snapshot.values()) { if (entry.agentPubkey === agentPubkey) { - return { pending: true, queuedAt: entry.queuedAt }; + return { + pending: true, + delivered: entry.promptHash !== "", + queuedAt: entry.queuedAt, + }; } } return null; diff --git a/desktop/src/features/agents/spawnerStatusStore.ts b/desktop/src/features/agents/spawnerStatusStore.ts index 3db3e80c49..3b84df179a 100644 --- a/desktop/src/features/agents/spawnerStatusStore.ts +++ b/desktop/src/features/agents/spawnerStatusStore.ts @@ -82,7 +82,17 @@ function handleStatusEvent(event: RelayEvent): void { // A `prompt_hash` echoed back on status is the spawner confirming it // applied the last prompt sent for this agent — clear the pending entry. - if (status) ackSpawnerPromptUpdate(event.pubkey, slug, status.promptHash); + // Acked by agent pubkey — the slug this client queued under can differ from + // the spawner's (name-derived fallback, or a rename), and the spawner routes + // prompt updates by agent pubkey anyway. + if (status) { + ackSpawnerPromptUpdate( + event.pubkey, + status.agentPubkey, + slug, + status.promptHash, + ); + } } /** Open the status subscription. Idempotent. */ From 8737fa2def82d16b2d9d40c3dad0d23d3f4d8574 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 04:52:53 +0530 Subject: [PATCH 34/50] fix(desktop): skip empty prompt pushes and soften the pending chip An all-blank edit was still queued, but the spawner drops empty material without storing or acking it, so the entry stayed pending and was resent forever. Skip it at the push site. The pending chip also claimed the server was offline during the normal awaiting-ack window; it now says that only when the send itself failed, and it renders for any agent row with a pending update rather than relocated ones alone. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sid --- .../agents/ui/AgentDefinitionDialog.tsx | 2 +- .../agents/ui/AgentInstanceEditDialog.tsx | 2 +- .../features/agents/ui/ServerRunsOnBanner.tsx | 27 ++++++-- .../agents/ui/UnifiedAgentsSection.tsx | 12 ++-- .../agents/ui/serverPromptUpdatePush.test.mjs | 66 +++++++++++++++++++ .../agents/ui/serverPromptUpdatePush.ts | 58 ++++++++++++---- .../agents/ui/useServerAgentEditContext.ts | 12 +++- 7 files changed, 153 insertions(+), 26 deletions(-) create mode 100644 desktop/src/features/agents/ui/serverPromptUpdatePush.test.mjs diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index de0dbdc247..f9b19cba67 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -760,7 +760,7 @@ export function AgentDefinitionDialog({
    {serverContext ? ( diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 6841ebedd5..4467419669 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -920,7 +920,7 @@ export function AgentInstanceEditDialog({
    {serverContext ? ( diff --git a/desktop/src/features/agents/ui/ServerRunsOnBanner.tsx b/desktop/src/features/agents/ui/ServerRunsOnBanner.tsx index cb94c0e2a4..02404daec0 100644 --- a/desktop/src/features/agents/ui/ServerRunsOnBanner.tsx +++ b/desktop/src/features/agents/ui/ServerRunsOnBanner.tsx @@ -10,11 +10,12 @@ import { Server } from "lucide-react"; export function ServerRunsOnBanner({ spawnerName, runtime, - pending, + pendingUpdate, }: { spawnerName: string; runtime?: string | null; - pending: boolean; + /** A queued prompt edit awaiting the spawner's confirmation, or null. */ + pendingUpdate: { delivered: boolean } | null; }) { return (
    · {runtime} ) : null} - {pending ? : null} + {pendingUpdate ? ( + + ) : null}
    ); } @@ -37,8 +43,19 @@ export function ServerRunsOnBanner({ * Amber chip for a prompt update that has been queued but not yet confirmed by * the spawner — the spawner echoes `prompt_hash` on its next status, so until * then the edit is in flight, not applied. + * + * Awaiting that echo is the *normal* path and says nothing about the server's + * health, so the plain wording is used unless the update was never delivered + * (`delivered === false`), which is the only case that really implies the + * spawner could not be reached. */ -export function ServerUpdatePendingChip({ className }: { className?: string }) { +export function ServerUpdatePendingChip({ + className, + delivered = true, +}: { + className?: string; + delivered?: boolean; +}) { return ( - Update pending — server offline + {delivered ? "Update pending" : "Update pending — server offline"} ); } diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 5e6fcff725..94ddd4dbcf 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -335,8 +335,10 @@ function AgentPersonaCard({ }} statusBadge={ <> - {agent?.relocatedToSpawner != null && promptUpdatePending ? ( - + {promptUpdatePending ? ( + ) : null} {agent?.personaOrphaned ? ( @@ -417,8 +419,10 @@ function StandaloneAgentCard({ }} statusBadge={ <> - {agent.relocatedToSpawner != null && promptUpdatePending ? ( - + {promptUpdatePending ? ( + ) : null} {agent.personaOrphaned ? ( diff --git a/desktop/src/features/agents/ui/serverPromptUpdatePush.test.mjs b/desktop/src/features/agents/ui/serverPromptUpdatePush.test.mjs new file mode 100644 index 0000000000..7c5efeeda8 --- /dev/null +++ b/desktop/src/features/agents/ui/serverPromptUpdatePush.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + promptUpdateFrame, + shouldPushAfterSubmit, +} from "./serverPromptUpdatePush.ts"; + +const context = { + spawnerPubkey: "sp", + specSlug: "fizz", + agentPubkey: "ag", + spawnerName: "host", +}; + +test("a failed save is not pushed", () => { + assert.equal(shouldPushAfterSubmit(false), false); + // The dialogs' onSubmit is typed `unknown`; anything but an explicit false + // is treated as a successful save. + assert.equal(shouldPushAfterSubmit(true), true); + assert.equal(shouldPushAfterSubmit(undefined), true); +}); + +test("a successful save enqueues trimmed material with blanks dropped", () => { + assert.deepEqual( + promptUpdateFrame(context, { + systemPrompt: " be Fizz ", + model: " ", + provider: null, + }), + { + spawnerPubkey: "sp", + specSlug: "fizz", + agentPubkey: "ag", + prompt: { + system_prompt: "be Fizz", + model: undefined, + provider: undefined, + }, + }, + ); +}); + +test("an all-empty edit is not enqueued", () => { + // The spawner drops an empty update without acking it, so queueing one would + // livelock: pending forever, resent (and restarting the container) forever. + assert.equal( + promptUpdateFrame(context, { + systemPrompt: " ", + model: "", + provider: null, + }), + null, + ); +}); + +test("a non-server agent is not enqueued", () => { + assert.equal( + promptUpdateFrame(null, { + systemPrompt: "be Fizz", + model: "m", + provider: "p", + }), + null, + ); +}); diff --git a/desktop/src/features/agents/ui/serverPromptUpdatePush.ts b/desktop/src/features/agents/ui/serverPromptUpdatePush.ts index 126c8ea1e8..5dba4733a6 100644 --- a/desktop/src/features/agents/ui/serverPromptUpdatePush.ts +++ b/desktop/src/features/agents/ui/serverPromptUpdatePush.ts @@ -21,22 +21,56 @@ export type ServerPromptUpdateSaved = { provider: string | null | undefined; }; +/** + * The queue input for an edit, or null when there is nothing to send. + * + * Pure, and exported so the normalization rules are testable without Tauri. + * Returns null for a non-server agent and for an edit whose three fields are + * all blank: the spawner drops an all-empty update without storing or acking + * it, so queueing one would leave an entry that can never be confirmed and is + * resent (restarting the container) every few minutes forever. + */ +export function promptUpdateFrame( + context: ServerAgentEditContext | null, + saved: ServerPromptUpdateSaved, +): { + spawnerPubkey: string; + specSlug: string; + agentPubkey: string; + prompt: { + system_prompt: string | undefined; + model: string | undefined; + provider: string | undefined; + }; +} | null { + if (!context) return null; + const prompt = { + system_prompt: saved.systemPrompt?.trim() || undefined, + model: saved.model?.trim() || undefined, + provider: saved.provider?.trim() || undefined, + }; + if (!prompt.system_prompt && !prompt.model && !prompt.provider) return null; + return { + spawnerPubkey: context.spawnerPubkey, + specSlug: context.specSlug, + agentPubkey: context.agentPubkey, + prompt, + }; +} + +/** Whether a dialog's `unknown` submit result counts as a successful save. */ +export function shouldPushAfterSubmit(submitResult: unknown): boolean { + return submitResult !== false; +} + export async function pushServerPromptUpdate( context: ServerAgentEditContext | null, saved: ServerPromptUpdateSaved, ): Promise { - if (!context) return; + const frame = promptUpdateFrame(context, saved); + if (!frame) return; try { - await enqueueSpawnerPromptUpdate({ - spawnerPubkey: context.spawnerPubkey, - specSlug: context.specSlug, - agentPubkey: context.agentPubkey, - prompt: { - system_prompt: saved.systemPrompt?.trim() || undefined, - model: saved.model?.trim() || undefined, - provider: saved.provider?.trim() || undefined, - }, - }); + await enqueueSpawnerPromptUpdate(frame); } catch (error) { console.debug("[server-prompt-update-push] enqueue failed:", error); } @@ -54,7 +88,7 @@ export async function pushServerPromptUpdateAfterSubmit( submitResult: unknown, saved: ServerPromptUpdateSaved, ): Promise { - if (submitResult === false) return; + if (!shouldPushAfterSubmit(submitResult)) return; await pushServerPromptUpdate(context, saved); } diff --git a/desktop/src/features/agents/ui/useServerAgentEditContext.ts b/desktop/src/features/agents/ui/useServerAgentEditContext.ts index 63c8f0911c..3267c55686 100644 --- a/desktop/src/features/agents/ui/useServerAgentEditContext.ts +++ b/desktop/src/features/agents/ui/useServerAgentEditContext.ts @@ -14,8 +14,14 @@ export type ServerAgentEditState = { context: ServerAgentEditContext | null; /** Friendly runtime name advertised by that spawner, when it advertised one. */ runtime: string | undefined; - /** A prompt edit is out but not yet confirmed by the spawner. */ - pending: boolean; + /** + * A prompt edit that is out but not yet confirmed by the spawner, or null. + * + * `delivered` is false only when the send itself failed — the one case the + * UI may describe as the server being unreachable; awaiting the spawner's + * status echo is the normal path. + */ + pendingUpdate: { delivered: boolean } | null; /** Provider/model catalog, or null when the spawner advertised none. */ ai: { providers: string[]; models: string[] } | null; providerOptions: PersonaDropdownOption[]; @@ -55,7 +61,7 @@ export function useServerAgentEditContext(input: { return { context, runtime: runtimeLabel(announcement?.runtime), - pending: promptUpdate?.pending ?? false, + pendingUpdate: promptUpdate, ai, providerOptions: (ai?.providers ?? []).map((id) => ({ label: id, From cce5e7744df5b0e571852f54f823ee65060f02ee Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 08:10:42 +0530 Subject: [PATCH 35/50] fix(desktop): never hydrate or persist the prompt-update queue without a resolved relay origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The community reset nulls the cached relay origin right after resetting this queue, and it only refreshes asynchronously — so any access in that window hydrated from the shared empty-origin key and latched hydration for the whole session. The new community's persisted entries never loaded, and the next write pushed that wrong-key map onto the real one. Skip hydration (without latching) and skip persisting while the origin is unknown, merge rather than replace when it finally resolves so an edit queued in the meantime survives, and hydrate on the origin subscription instead of waiting for the next access. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sid --- .../agents/spawnerPromptUpdateQueue.test.mjs | 26 ++++++++ .../agents/spawnerPromptUpdateQueue.ts | 66 ++++++++++++++++--- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/agents/spawnerPromptUpdateQueue.test.mjs b/desktop/src/features/agents/spawnerPromptUpdateQueue.test.mjs index b43c7b54fe..6556a778c4 100644 --- a/desktop/src/features/agents/spawnerPromptUpdateQueue.test.mjs +++ b/desktop/src/features/agents/spawnerPromptUpdateQueue.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { findAckKey, + mergeHydrated, queueReducer, shouldRetryPromptUpdate, } from "./spawnerPromptUpdateQueue.ts"; @@ -82,6 +83,31 @@ test("reset clears every pending entry", () => { assert.equal(s.size, 0); }); +test("hydration keeps entries queued before the relay origin resolved", () => { + // A write made while the origin was unknown lives in memory only, since + // there was no community-scoped key to persist it to. Hydration must merge + // rather than replace, or that edit is lost. + const stored = new Map([["sp:a", { promptHash: "stored" }]]); + const inMemory = new Map([["sp:b", { promptHash: "in-memory" }]]); + const merged = mergeHydrated(stored, inMemory); + assert.deepEqual([...merged.keys()], ["sp:a", "sp:b"]); +}); + +test("hydration lets the newer in-memory entry win a key collision", () => { + const stored = new Map([["sp:a", { promptHash: "stored" }]]); + const inMemory = new Map([["sp:a", { promptHash: "in-memory" }]]); + assert.equal( + mergeHydrated(stored, inMemory).get("sp:a").promptHash, + "in-memory", + ); +}); + +test("hydration with one side empty returns the other untouched", () => { + const stored = new Map([["sp:a", { promptHash: "stored" }]]); + assert.equal(mergeHydrated(stored, new Map()), stored); + assert.equal(mergeHydrated(new Map(), stored), stored); +}); + test("an ack matches on agent pubkey even when the slug differs", () => { // The slug a client queues under falls back to one derived from the agent's // name, so it can drift from the spawner's after a rename or a late spec diff --git a/desktop/src/features/agents/spawnerPromptUpdateQueue.ts b/desktop/src/features/agents/spawnerPromptUpdateQueue.ts index feca6c777d..42cd900bb3 100644 --- a/desktop/src/features/agents/spawnerPromptUpdateQueue.ts +++ b/desktop/src/features/agents/spawnerPromptUpdateQueue.ts @@ -2,7 +2,10 @@ import React from "react"; import { sendSpawnerPromptUpdate } from "@/shared/api/spawnerRelay"; import type { SpawnerPromptMaterial } from "@/shared/api/tauriSpawner"; -import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl"; +import { + getCachedRelayOrigin, + subscribeRelayOrigin, +} from "@/shared/lib/mediaUrl"; /** * Pending prompt updates for server-hosted agents, keyed by @@ -112,10 +115,12 @@ export function shouldRetryPromptUpdate( * Resolved on every read and write, never captured: the cached relay origin is * still null at module-import time, so a key computed once at import would read * from a bare, community-less key and write to the real one — pending updates - * would never survive a restart. + * would never survive a restart. Only ever called with a resolved origin (see + * {@link currentQueue} and {@link persist}), so the empty-origin key — which + * every community would share — is never touched. */ -function storageKey(): string { - return `buzz:spawner-prompt-queue:${getCachedRelayOrigin() ?? ""}`; +function storageKey(origin: string): string { + return `buzz:spawner-prompt-queue:${origin}`; } const listeners = new Set<() => void>(); @@ -130,18 +135,47 @@ let hydrated = false; const EMPTY: ReadonlyMap = new Map(); -/** The live queue, hydrating from storage on first access after a reset. */ +/** + * Merge storage into whatever is already in memory, newest-write-wins. + * + * Anything queued before the relay origin resolved was held in memory only (it + * had nowhere safe to persist to), so hydration must not clobber it with the + * stored map. Pure, and exported for tests. + */ +export function mergeHydrated( + stored: ReadonlyMap, + inMemory: ReadonlyMap, +): ReadonlyMap { + if (inMemory.size === 0) return stored; + if (stored.size === 0) return inMemory; + const merged = new Map(stored); + for (const [key, entry] of inMemory) merged.set(key, entry); + return merged; +} + +/** + * The live queue, hydrating from storage on first access after a reset. + * + * Hydration is skipped — and crucially `hydrated` is *not* latched — while the + * relay origin is unknown. `resetCommunityState` nulls the cached origin right + * after resetting this queue and only refreshes it asynchronously, as does a + * cold start; latching there would bind the session to the shared empty-origin + * key, so the new community's persisted entries would never load and the next + * write would overwrite the real key with a map built from the wrong one. + */ function currentQueue(): ReadonlyMap { if (!hydrated) { - queue = readStored(); + const origin = getCachedRelayOrigin(); + if (origin === null) return queue; + queue = mergeHydrated(readStored(origin), queue); hydrated = true; } return queue; } -function readStored(): ReadonlyMap { +function readStored(origin: string): ReadonlyMap { try { - const raw = window.localStorage.getItem(storageKey()); + const raw = window.localStorage.getItem(storageKey(origin)); if (!raw) return new Map(); const parsed: unknown = JSON.parse(raw); if (!parsed || typeof parsed !== "object") return new Map(); @@ -152,9 +186,13 @@ function readStored(): ReadonlyMap { } function persist(): void { + const origin = getCachedRelayOrigin(); + // No resolved origin means no key that belongs to this community. The + // entries stay in memory and are flushed once the origin arrives. + if (origin === null) return; try { window.localStorage.setItem( - storageKey(), + storageKey(origin), JSON.stringify(Object.fromEntries(queue)), ); } catch { @@ -162,6 +200,16 @@ function persist(): void { } } +// Hydrate (and flush anything queued in the meantime) as soon as the origin +// resolves, rather than waiting for the next queue access. +subscribeRelayOrigin(() => { + if (hydrated || getCachedRelayOrigin() === null) return; + const before = queue; + if (currentQueue() === before && before.size === 0) return; + persist(); + for (const listener of listeners) listener(); +}); + function dispatch(action: QueueAction): void { const next = queueReducer(currentQueue(), action); if (next === queue) return; From 34db34cc46544290e4668e0fb4ba1437a7c7f866 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 08:43:20 +0530 Subject: [PATCH 36/50] test(desktop): screenshot spec for server-aware agent editing Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: sid --- desktop/playwright.config.ts | 1 + desktop/src/testing/e2eBridge.ts | 47 +++++ .../server-agent-editing-screenshots.spec.ts | 198 ++++++++++++++++++ desktop/tests/helpers/bridge.ts | 20 ++ 4 files changed, 266 insertions(+) create mode 100644 desktop/tests/e2e/server-agent-editing-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 625eadf34d..0ead42eca7 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ testMatch: [ "**/smoke.spec.ts", "**/server-agents-screenshots.spec.ts", + "**/server-agent-editing-screenshots.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", "**/identity-key-help.spec.ts", "**/key-import-reveal.spec.ts", diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 3d89132cdc..8eeef447f2 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -42,6 +42,7 @@ import { KIND_MEMBER_REMOVED_NOTIFICATION, KIND_REPO_ANNOUNCEMENT, KIND_REPO_STATE, + KIND_SPAWNER_ANNOUNCEMENT, KIND_STREAM_MESSAGE_EDIT, KIND_SYSTEM_MESSAGE, KIND_TEXT_NOTE, @@ -87,6 +88,21 @@ type MockManagedAgentSeed = { autoRestartOnConfigChange?: boolean; respondTo?: RawManagedAgent["respond_to"]; respondToAllowlist?: string[]; + /** Spawner pubkey this agent's identity was relocated to, if any. */ + relocatedToSpawner?: string | null; +}; + +/** + * A kind:10180 spawner announcement served to the live directory subscription. + * + * `content` is the raw announcement body (snake_case, exactly as a real spawner + * publishes it) so specs can seed a catalog, or omit `ai` to drive the + * no-catalog fallback. + */ +type MockSpawnerAnnouncementSeed = { + pubkey: string; + content: Record; + createdAt?: number; }; type MockManagedAgentRuntimeSeed = { @@ -216,6 +232,8 @@ type E2eConfig = { /** Per agent+relay runtime rows for the pair-scoped lifecycle commands * (`list/start/stop/restart_managed_agent_runtime`). */ managedAgentRuntimes?: MockManagedAgentRuntimeSeed[]; + /** kind:10180 announcements replayed to the spawner-directory subscription. */ + spawnerAnnouncements?: MockSpawnerAnnouncementSeed[]; personas?: MockPersonaSeed[]; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; @@ -751,6 +769,8 @@ type RawManagedAgent = { backend_agent_id: string | null; respond_to: "owner-only" | "allowlist" | "anyone"; respond_to_allowlist: string[]; + /** Spawner this agent's identity was relocated to; null when local. */ + relocated_to_spawner?: string | null; }; type RawCreateManagedAgentResponse = { @@ -888,6 +908,24 @@ function createMockRelayMembershipEvent(): RelayEvent { ); } +/** + * kind:10180 announcements for the spawner directory, from + * `mock.spawnerAnnouncements`. Identity comes from the seeded pubkey — the + * store reads it off the envelope, never the content — so a spec can seed one + * spawner with an `ai` catalog and another without. + */ +function createMockSpawnerAnnouncementEvents(): RelayEvent[] { + return (getConfig()?.mock?.spawnerAnnouncements ?? []).map((seed) => + createMockEvent( + KIND_SPAWNER_ANNOUNCEMENT, + JSON.stringify(seed.content), + [], + seed.pubkey, + seed.createdAt ?? Math.floor(Date.now() / 1000), + ), + ); +} + /** * Per-user custom emoji sets (kind:30030) the mock WS serves for * `listCustomEmoji` REQs. The community palette is the client-side UNION of @@ -1497,6 +1535,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent { log_path: agent.log_path, start_on_app_launch: agent.start_on_app_launch, auto_restart_on_config_change: agent.auto_restart_on_config_change ?? true, + relocated_to_spawner: agent.relocated_to_spawner ?? null, backend: agent.backend ?? { type: "local" as const }, backend_agent_id: agent.backend_agent_id ?? null, respond_to: agent.respond_to ?? "owner-only", @@ -2036,6 +2075,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { backend_agent_id: null, respond_to: seed.respondTo ?? "owner-only", respond_to_allowlist: seed.respondToAllowlist ?? [], + relocated_to_spawner: seed.relocatedToSpawner ?? null, private_key_nsec: `nsec1mock${seed.pubkey.slice(0, 20)}`, log_lines: [ `buzz-acp starting: relay=${DEFAULT_RELAY_WS_URL} agent_pubkey=${seed.pubkey} parallelism=1`, @@ -8789,6 +8829,13 @@ function sendToMockSocket(args: { kinds: kinds.size > 0 ? [...kinds] : null, ownerPubkeys: [...ownerPubkeys], }); + // The spawner directory is a live REQ with no channel scope, so its + // stored replay has to happen here rather than in emitMockHistory. + if (kinds.has(KIND_SPAWNER_ANNOUNCEMENT)) { + for (const event of createMockSpawnerAnnouncementEvents()) { + sendWsText(socket.handler, ["EVENT", subId, event]); + } + } sendWsText(socket.handler, ["EOSE", subId]); return; } diff --git a/desktop/tests/e2e/server-agent-editing-screenshots.spec.ts b/desktop/tests/e2e/server-agent-editing-screenshots.spec.ts new file mode 100644 index 0000000000..41f41c0728 --- /dev/null +++ b/desktop/tests/e2e/server-agent-editing-screenshots.spec.ts @@ -0,0 +1,198 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; + +/** + * Screenshots for editing an agent that lives on a server. + * + * A relocated agent is configured on the spawner, not on this Mac, so the Edit + * dialog drops the local harness picker and scopes provider/model to whatever + * the spawner advertised in its kind:10180 announcement. Both branches are + * captured: a spawner that published an `ai` catalog, and one that did not. + * + * The "Update pending" chip is deliberately not covered here. It reads the + * prompt-update queue, which only hydrates from localStorage once the relay + * origin resolves — and that resolution is a one-shot, 5 s eager poll at module + * load (`mediaUrl.ts`), so under parallel workers it can lapse before the mock + * IPC bridge is answering and never retry. Seeding the queue key therefore + * produces a flaky shot; the chip stays component-test territory. + */ + +// Spawner that advertises an AI catalog, and one that stays silent about it. +const SPAWNER_WITH_CATALOG = "5c".repeat(32); +const SPAWNER_WITHOUT_CATALOG = "7a".repeat(32); + +const CATALOG_AGENT_PUBKEY = "a1".repeat(32); +const CATALOG_AGENT_NAME = "Prod Runner"; +const BARE_AGENT_PUBKEY = "b2".repeat(32); +const BARE_AGENT_NAME = "Edge Runner"; + +const SCREENSHOT_DIR = "test-results/server-agent-editing"; + +const SPAWNER_ANNOUNCEMENTS = [ + { + pubkey: SPAWNER_WITH_CATALOG, + content: { + name: "prod-vps", + runtime: "claude-agent-acp", + max_agents: 4, + agents_running: 1, + ai: [ + { + id: "anthropic", + models: ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"], + }, + ], + }, + }, + { + pubkey: SPAWNER_WITHOUT_CATALOG, + content: { + name: "edge-box", + runtime: "claude-agent-acp", + max_agents: 2, + agents_running: 1, + }, + }, +]; + +const MANAGED_AGENTS = [ + { + pubkey: CATALOG_AGENT_PUBKEY, + name: CATALOG_AGENT_NAME, + status: "stopped" as const, + relocatedToSpawner: SPAWNER_WITH_CATALOG, + }, + { + pubkey: BARE_AGENT_PUBKEY, + name: BARE_AGENT_NAME, + status: "stopped" as const, + relocatedToSpawner: SPAWNER_WITHOUT_CATALOG, + }, +]; + +async function install(page: import("@playwright/test").Page) { + // Seeded before the bridge installs: the section reads the store on mount and + // the bridge is what triggers mount. + await page.addInitScript( + (spawners) => { + window.localStorage.setItem( + "buzz:spawner-pubkeys", + JSON.stringify(spawners), + ); + }, + [SPAWNER_WITH_CATALOG, SPAWNER_WITHOUT_CATALOG], + ); + await installMockBridge(page, { + managedAgents: MANAGED_AGENTS, + spawnerAnnouncements: SPAWNER_ANNOUNCEMENTS, + }); +} + +async function gotoAgents(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => { + const w = window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown; + __TAURI_INTERNALS__?: { invoke?: unknown }; + }; + return ( + typeof w.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function" || + typeof w.__TAURI_INTERNALS__?.invoke === "function" + ); + }); + await page.getByTestId("open-agents-view").click(); + await expect( + page.getByTestId(`managed-agent-${CATALOG_AGENT_PUBKEY}`), + ).toBeVisible({ timeout: 15_000 }); +} + +/** Open the Edit dialog through the profile panel — its only mount path. */ +async function openEditDialog( + page: import("@playwright/test").Page, + agentName: string, +) { + await page + .getByRole("button", { name: `${agentName} agent profile` }) + .click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible({ + timeout: 10_000, + }); + await page.getByTestId("user-profile-edit-agent").click(); + await expect(page.getByTestId("edit-agent-dialog")).toBeVisible({ + timeout: 10_000, + }); + // The banner is the server branch's first field — its presence means the + // spawner directory resolved and the form settled. + await expect(page.getByTestId("server-runs-on-banner")).toBeVisible({ + timeout: 10_000, + }); +} + +test.describe("server-aware agent editing", () => { + test("shows a relocated agent on the agents screen", async ({ page }) => { + await install(page); + await gotoAgents(page); + + const row = page.getByTestId(`managed-agent-${CATALOG_AGENT_PUBKEY}`); + // A relocated agent can't be started from this Mac, so the start button is + // replaced by a server badge. + await expect( + page.getByTestId(`agent-runtime-start-${CATALOG_AGENT_PUBKEY}-relocated`), + ).toBeVisible(); + + await waitForAnimations(page); + await row.screenshot({ path: `${SCREENSHOT_DIR}/01-relocated-row.png` }); + }); + + test("scopes the model picker to the spawner's catalog", async ({ page }) => { + await install(page); + await gotoAgents(page); + await openEditDialog(page, CATALOG_AGENT_NAME); + + const dialog = page.getByTestId("edit-agent-dialog"); + await expect(dialog).toContainText("Runs on prod-vps"); + await expect(dialog).toContainText( + "Applied on the server. Saving restarts the agent.", + ); + // The local harness picker is gone: the spawner decides what it runs. + await expect(dialog.locator("#edit-agent-runtime")).toHaveCount(0); + + await page.locator("#edit-agent-model").click(); + const menu = page.getByRole("menu"); + await expect(menu).toBeVisible(); + await expect( + menu.getByRole("menuitemradio", { name: "claude-opus-5" }), + ).toBeVisible(); + + await waitForAnimations(page); + await page.screenshot({ + path: `${SCREENSHOT_DIR}/02-server-model-catalog.png`, + fullPage: false, + }); + }); + + test("falls back to free text when the spawner has no catalog", async ({ + page, + }) => { + await install(page); + await gotoAgents(page); + await openEditDialog(page, BARE_AGENT_NAME); + + const dialog = page.getByTestId("edit-agent-dialog"); + await expect(dialog).toContainText("Runs on edge-box"); + await expect(dialog).toContainText( + "Model list unavailable from this server", + ); + await expect(dialog.locator("#edit-agent-model")).toHaveAttribute( + "placeholder", + "Model ID", + ); + + await waitForAnimations(page); + await dialog.screenshot({ + path: `${SCREENSHOT_DIR}/03-no-catalog-fallback.png`, + }); + }); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index d5bd6ae0dd..04e532d091 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -59,6 +59,24 @@ type MockManagedAgentSeed = { autoRestartOnConfigChange?: boolean; respondTo?: "owner-only" | "allowlist" | "anyone"; respondToAllowlist?: string[]; + /** + * Spawner pubkey this agent's identity was relocated to. Non-null puts the + * agent in the server-hosted branch: the row shows the server badge and the + * Edit dialog renders the "Runs on … · Server" banner instead of the local + * harness picker. + */ + relocatedToSpawner?: string | null; +}; + +/** + * A kind:10180 spawner announcement replayed to the directory subscription. + * `content` is the raw snake_case announcement body a real spawner publishes; + * omit `ai` to drive the "Model list unavailable from this server" fallback. + */ +type MockSpawnerAnnouncementSeed = { + pubkey: string; + content: Record; + createdAt?: number; }; type MockSearchProfileSeed = { @@ -212,6 +230,8 @@ type MockBridgeOptions = { | "failed" | "stopped"; }>; + /** kind:10180 announcements served to the spawner-directory subscription. */ + spawnerAnnouncements?: MockSpawnerAnnouncementSeed[]; personas?: MockPersonaSeed[]; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; From c4031189fba2266a2498f77911e5189945063ec9 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 16:47:32 +0530 Subject: [PATCH 37/50] fix(desktop): don't require local API keys when editing server-hosted agents A spawner-hosted agent authenticates on the server (e.g. OAuth), but both edit dialogs still ran local model discovery and gated Save on local provider credentials, so editing such an agent demanded an ANTHROPIC_API_KEY this machine never uses and surfaced a spurious "Enter an Anthropic API key" warning. - usePersonaModelDiscovery: new serverManaged flag skips local discovery and the "Runtime not available" warning for server-hosted agents; the model catalog already comes from the spawner announcement. - localModeGateSatisfiedForSubmit: definition-dialog Save waives local credential env keys when server-managed (provider/model still required). - computeEditAgentFormValidity: instance-dialog Save ignores requiredEnvKeyMissing when server-managed. Mirrors the existing Databricks OAuth precedent of not requiring a typed credential when auth happens elsewhere. Co-Authored-By: Claude Fable 5 Signed-off-by: sid --- .../agents/ui/AgentDefinitionDialog.tsx | 9 ++- .../agents/ui/AgentInstanceEditDialog.tsx | 43 +++++----- .../features/agents/ui/agentConfigOptions.tsx | 15 ++++ .../ui/createAgentLocalModeGate.test.mjs | 36 +++++++++ .../ui/editAgentProviderDiscovery.test.mjs | 34 ++++++++ .../features/agents/ui/personaRuntimeModel.ts | 8 +- .../ui/usePersonaModelDiscovery.test.mjs | 54 +++++++++++++ .../agents/ui/usePersonaModelDiscovery.ts | 78 +++++++++++++++++-- 8 files changed, 247 insertions(+), 30 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index f9b19cba67..dc3e885d79 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -38,6 +38,7 @@ import { BLOCK_BUILD_HIDDEN_PROVIDER_IDS, CUSTOM_PROVIDER_DROPDOWN_VALUE, computeLocalModeGate, + localModeGateSatisfiedForSubmit, formatRuntimeOptionLabel, getDefaultPersonaRuntime, getPersonaModelOptions, @@ -456,7 +457,12 @@ export function AgentDefinitionDialog({ // requiredEnvKeys: the gate already handles baked-, global-, and file- // satisfied keys so no further filtering is needed. const { requiredEnvKeys } = localModeGate; - const localModeSatisfied = localModeGate.satisfied; + // Server-hosted definitions authenticate on the spawner (e.g. OAuth) — a + // credential key missing on this machine must not block Save there. + const localModeSatisfied = localModeGateSatisfiedForSubmit( + localModeGate, + serverContext !== null, + ); // Effective provider: agent value → global fallback → file fallback. // Mirrors the chain inside computeLocalModeGate so model-option scoping and // model requiredness are consistent with the readiness gate. @@ -537,6 +543,7 @@ export function AgentDefinitionDialog({ ? effectiveProvider : "", selectedRuntime, + serverManaged: serverContext !== null, }); const staticModelOptions = getPersonaModelOptions(runtime, effectiveProvider); const runtimeModelOptions = getRuntimePersonaModelOptions(runtime); diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 4467419669..8673f2eb16 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -397,6 +397,27 @@ export function AgentInstanceEditDialog({ const { data: bakedEnvKeys } = useBakedBuildEnvKeysQuery({ enabled: open }); + // Server residency: a relocated agent is configured on the spawner, so the + // harness is not this device's to choose and the model catalog is whatever + // the spawner advertises. Resolved before model discovery so a server-hosted + // agent never runs local discovery (its credentials live on the spawner). + const { agents: serverAgents } = useServerAgents(); + const serverSpecSlug = React.useMemo(() => { + const matched = serverAgents.find( + (candidate) => candidate.status.agentPubkey === agent.pubkey, + ); + return matched?.slug ?? slugFromName(agent.name); + }, [serverAgents, agent.pubkey, agent.name]); + const server = useServerAgentEditContext({ + relocatedToSpawner: agent.relocatedToSpawner, + deployedSpawnerPubkey: null, + agentPubkey: agent.pubkey, + slug: serverSpecSlug, + provider, + }); + const serverContext = server.context; + const serverAi = server.ai; + // Merge global env as the base layer so credential keys satisfied via global // config (e.g. ANTHROPIC_API_KEY) are available to model discovery. Use // `inheritedSubmission.envVars` (the same snapshot the credential gate @@ -423,6 +444,7 @@ export function AgentInstanceEditDialog({ open, provider: providerForDiscovery, selectedRuntime, + serverManaged: serverContext !== null, }); // D2: derive advancedRequiredEnvKeys for EnvVarsEditor display. @@ -453,26 +475,6 @@ export function AgentInstanceEditDialog({ secretEnvVar: topLevelSecretEnvVar, value: apiKeyValue, } = apiKeyFieldState; - // Server residency: a relocated agent is configured on the spawner, so the - // harness is not this device's to choose and the model catalog is whatever - // the spawner advertises. - const { agents: serverAgents } = useServerAgents(); - const serverSpecSlug = React.useMemo(() => { - const matched = serverAgents.find( - (candidate) => candidate.status.agentPubkey === agent.pubkey, - ); - return matched?.slug ?? slugFromName(agent.name); - }, [serverAgents, agent.pubkey, agent.name]); - const server = useServerAgentEditContext({ - relocatedToSpawner: agent.relocatedToSpawner, - deployedSpawnerPubkey: null, - agentPubkey: agent.pubkey, - slug: serverSpecSlug, - provider, - }); - const serverContext = server.context; - const serverAi = server.ai; - // Clear model when provider scope changes and current model is no longer valid. React.useEffect(() => { if ( @@ -619,6 +621,7 @@ export function AgentInstanceEditDialog({ inheritHarness, agentCommand, requiredEnvKeyMissing, + serverManaged: serverContext !== null, }) && providerValid && !updateMutation.isPending && diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 6ae81ff6cb..ee36c2c293 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -689,3 +689,18 @@ export function computeLocalModeGate({ missingNormalizedFields.length === 0 && missingEnvKeys.length === 0, }; } + +/** + * Submit-gate view of the local-mode gate. A server-managed (spawner-hosted) + * agent authenticates on the server (e.g. OAuth), so missing LOCAL credential + * env keys must not block Save — only the normalized provider/model fields + * remain required. Local agents use the full gate unchanged. + */ +export function localModeGateSatisfiedForSubmit( + gate: { missingNormalizedFields: string[]; satisfied: boolean }, + serverManaged: boolean, +): boolean { + return serverManaged + ? gate.missingNormalizedFields.length === 0 + : gate.satisfied; +} diff --git a/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs b/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs index 32fc7cf582..f932599d0f 100644 --- a/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs +++ b/desktop/src/features/agents/ui/createAgentLocalModeGate.test.mjs @@ -1442,3 +1442,39 @@ test("inherited defaults expose a provider-specific model fallback to agent dial value: "goose-claude-opus-4-8", }); }); + +test("localModeGateSatisfiedForSubmit_serverManaged_ignoresMissingCredentialKeys", async () => { + const { localModeGateSatisfiedForSubmit } = await import( + "./agentConfigOptions.tsx" + ); + // Spawner-hosted agent: credentials live on the server (e.g. OAuth), so a + // missing local ANTHROPIC_API_KEY must not block Save. + const gate = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "claude-sonnet-5", + provider: "anthropic", + runtimeId: "buzz-agent", + }); + assert.equal(gate.satisfied, false, "precondition: local gate is unsatisfied"); + assert.deepEqual(gate.missingEnvKeys, ["ANTHROPIC_API_KEY"]); + + assert.equal(localModeGateSatisfiedForSubmit(gate, true), true); + assert.equal(localModeGateSatisfiedForSubmit(gate, false), false); +}); + +test("localModeGateSatisfiedForSubmit_serverManaged_stillRequiresNormalizedFields", async () => { + const { localModeGateSatisfiedForSubmit } = await import( + "./agentConfigOptions.tsx" + ); + // Missing provider/model are real form errors even for server-hosted + // agents — only the local credential requirement is waived. + const gate = computeLocalModeGate({ + envVars: {}, + isProviderMode: false, + model: "", + provider: "", + runtimeId: "buzz-agent", + }); + assert.equal(localModeGateSatisfiedForSubmit(gate, true), false); +}); diff --git a/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs b/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs index 6b797db1cf..b23fa2a5d3 100644 --- a/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs +++ b/desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs @@ -1564,3 +1564,37 @@ test("blockSave_inheritTransition_buzzAgentPin_toClaudePersona_notBlocked", () = "claude must return empty required keys — no dialog-fixable credential", ); }); + +test("editAgent_serverManaged_ignoresMissingRequiredEnvKey", () => { + // A spawner-hosted agent authenticates on the server (e.g. OAuth) — a + // credential key missing on THIS machine must not block Save. + const base = { + name: "My Agent", + parallelism: "", + agentAcpCommand: "", + acpCommand: "", + respondTo: "all", + respondToAllowlistLength: 0, + selectedRuntimeId: "buzz-agent", + inheritHarness: false, + agentCommand: "buzz-agent", + requiredEnvKeyMissing: true, + }; + + assert.equal( + computeEditAgentFormValidity({ ...base, serverManaged: true }), + true, + "server-managed agents must not be gated on local credential keys", + ); + assert.equal( + computeEditAgentFormValidity({ ...base, serverManaged: false }), + false, + "local agents must still be gated on missing credential keys", + ); + // Other validity rules still apply to server-managed agents. + assert.equal( + computeEditAgentFormValidity({ ...base, serverManaged: true, name: " " }), + false, + "blank name must still block Save for server-managed agents", + ); +}); diff --git a/desktop/src/features/agents/ui/personaRuntimeModel.ts b/desktop/src/features/agents/ui/personaRuntimeModel.ts index 20d789c4eb..3b41971ed7 100644 --- a/desktop/src/features/agents/ui/personaRuntimeModel.ts +++ b/desktop/src/features/agents/ui/personaRuntimeModel.ts @@ -226,6 +226,12 @@ export interface EditAgentFormValidityInput { * {@link hasMissingRequiredEnvKey}. */ requiredEnvKeyMissing: boolean; + /** + * True when the agent is hosted on a spawner. Credentials then live on the + * server (e.g. OAuth), so {@link requiredEnvKeyMissing} is ignored — a key + * missing on this machine is not a spawn risk for a server-hosted agent. + */ + serverManaged?: boolean; } /** @@ -266,7 +272,7 @@ export function computeEditAgentFormValidity( acpCommandValid && respondToValid && customCommandValid && - !input.requiredEnvKeyMissing + (input.serverManaged === true || !input.requiredEnvKeyMissing) ); } diff --git a/desktop/src/features/agents/ui/usePersonaModelDiscovery.test.mjs b/desktop/src/features/agents/ui/usePersonaModelDiscovery.test.mjs index ecb36a6fc5..c3a0290606 100644 --- a/desktop/src/features/agents/ui/usePersonaModelDiscovery.test.mjs +++ b/desktop/src/features/agents/ui/usePersonaModelDiscovery.test.mjs @@ -312,3 +312,57 @@ test("isSuccessfulEmptyDiscovery_stillPending_isFalse", () => { false, ); }); + +// ── server-managed suppression ──────────────────────────────────────────────── + +test("canRunLocalModelDiscovery_serverManaged_isFalse", async () => { + const { canRunLocalModelDiscovery } = await import( + "./usePersonaModelDiscovery.ts" + ); + const base = { + open: true, + modelFieldVisible: true, + serverManaged: false, + runtimeAvailability: "available", + runtimeCommand: "buzz-agent", + isCustomProviderEditing: false, + provider: "anthropic", + }; + // Sanity: a locally-configured agent still discovers. + assert.equal(canRunLocalModelDiscovery(base), true); + // A spawner-hosted agent must never hit local discovery — the model catalog + // comes from the spawner announcement, and local credentials are irrelevant. + assert.equal( + canRunLocalModelDiscovery({ ...base, serverManaged: true }), + false, + ); +}); + +test("shouldSurfaceRuntimeUnavailableStatus_serverManaged_isFalse", async () => { + const { shouldSurfaceRuntimeUnavailableStatus } = await import( + "./usePersonaModelDiscovery.ts" + ); + // Local runtime availability is meaningless for a server-hosted agent — no + // "Runtime not available" warning may leak into the dialog. + assert.equal( + shouldSurfaceRuntimeUnavailableStatus({ + serverManaged: true, + runtimeAvailability: "not_installed", + }), + false, + ); + assert.equal( + shouldSurfaceRuntimeUnavailableStatus({ + serverManaged: false, + runtimeAvailability: "not_installed", + }), + true, + ); + assert.equal( + shouldSurfaceRuntimeUnavailableStatus({ + serverManaged: false, + runtimeAvailability: "available", + }), + false, + ); +}); diff --git a/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts b/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts index e7b434288f..e3fa169a33 100644 --- a/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts +++ b/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts @@ -161,6 +161,59 @@ export function isSuccessfulEmptyDiscovery({ ); } +/** + * Pure gate for running local model discovery. Server-managed (spawner-hosted) + * agents never discover locally: their model catalog comes from the spawner + * announcement and their credentials (e.g. OAuth) live on the server, so a + * local discovery attempt would only produce spurious "enter an API key" + * warnings. + */ +export function canRunLocalModelDiscovery({ + open, + modelFieldVisible, + serverManaged, + runtimeAvailability, + runtimeCommand, + isCustomProviderEditing, + provider, +}: { + open: boolean; + modelFieldVisible: boolean; + serverManaged: boolean; + runtimeAvailability: string | undefined; + runtimeCommand: string | null; + isCustomProviderEditing: boolean; + provider: string; +}): boolean { + return ( + !serverManaged && + open && + modelFieldVisible && + runtimeAvailability === "available" && + runtimeCommand !== null && + (!isCustomProviderEditing || provider.trim().length > 0) + ); +} + +/** + * Whether the "Runtime not available" warning should be shown when discovery + * cannot run. Suppressed for server-managed agents — local runtime + * availability is meaningless for an agent hosted on a spawner. + */ +export function shouldSurfaceRuntimeUnavailableStatus({ + serverManaged, + runtimeAvailability, +}: { + serverManaged: boolean; + runtimeAvailability: string | null | undefined; +}): boolean { + return ( + !serverManaged && + runtimeAvailability != null && + runtimeAvailability !== "available" + ); +} + export function usePersonaModelDiscovery({ envVars, isCustomProviderEditing, @@ -168,6 +221,7 @@ export function usePersonaModelDiscovery({ open, provider, selectedRuntime, + serverManaged = false, }: { envVars: EnvVarsValue; isCustomProviderEditing: boolean; @@ -175,6 +229,8 @@ export function usePersonaModelDiscovery({ open: boolean; provider: string; selectedRuntime: AcpRuntimeCatalogEntry | undefined; + /** True when the agent is hosted on a spawner — disables local discovery. */ + serverManaged?: boolean; }) { const [modelDiscoveryData, setModelDiscoveryData] = React.useState(null); @@ -206,12 +262,15 @@ export function usePersonaModelDiscovery({ const selectedRuntimeLabel = selectedRuntime?.label; const selectedRuntimeDefaultArgs = selectedRuntime?.defaultArgs; const selectedRuntimeDefinitionEnv = selectedRuntime?.definitionEnv; - const canDiscoverModelOptions = - open && - modelFieldVisible && - selectedRuntime?.availability === "available" && - discoveryAgentCommand !== null && - (!isCustomProviderEditing || trimmedProvider.length > 0); + const canDiscoverModelOptions = canRunLocalModelDiscovery({ + open, + modelFieldVisible, + serverManaged, + runtimeAvailability: selectedRuntime?.availability, + runtimeCommand: discoveryAgentCommand, + isCustomProviderEditing, + provider: trimmedProvider, + }); const modelDiscoveryEnvKey = React.useMemo( () => stableModelDiscoveryEnvKey(envVars), [envVars], @@ -246,8 +305,10 @@ export function usePersonaModelDiscovery({ // When the runtime exists but is not available, surface a status message // so the model dropdown explains why no live models can be loaded. if ( - selectedRuntimeAvailability != null && - selectedRuntimeAvailability !== "available" + shouldSurfaceRuntimeUnavailableStatus({ + serverManaged, + runtimeAvailability: selectedRuntimeAvailability, + }) ) { setModelDiscoveryStatus( formatModelDiscoveryErrorStatus( @@ -362,6 +423,7 @@ export function usePersonaModelDiscovery({ selectedRuntimeDefaultArgs, selectedRuntimeDefinitionEnv, selectedRuntimeLabel, + serverManaged, shouldDebounceModelDiscovery, trimmedProvider, ]); From d1c6c71cfe8751e7c21a1078ed64c2896e7add7d Mon Sep 17 00:00:00 2001 From: sid Date: Tue, 28 Jul 2026 00:54:41 +0530 Subject: [PATCH 38/50] chore(desktop): drop runtimeAvailabilityWarning import left unused by server-aware dialog Signed-off-by: sid --- desktop/src/features/agents/ui/AgentDefinitionDialog.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index dc3e885d79..1b707971b7 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -18,7 +18,6 @@ import { pushServerPromptUpdateAfterSubmit } from "./serverPromptUpdatePush"; import type { EnvVarsValue } from "./EnvVarsEditor"; import { PersonaAdvancedFields } from "./PersonaAdvancedFields"; import { PersonaModelField } from "./PersonaModelField"; -import { runtimeAvailabilityWarning } from "./runtimeAvailabilityWarning"; import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField"; import { canSubmitPersonaDialog, From 3faa39e32897a03b3e3c7ccc57664f923c215b85 Mon Sep 17 00:00:00 2001 From: sid Date: Tue, 28 Jul 2026 15:43:04 +0530 Subject: [PATCH 39/50] fix(deploy): pass the spawner AI catalog through compose BUZZ_SPAWNER_AI_CATALOG was documented in .env.example but never listed in compose.spawner.yml's environment block, so the variable never reached the container. Config::from_env therefore always saw it absent, the announcement carried no `ai` catalog, and the desktop's model field degraded to free text with "Model list unavailable from this server". Co-Authored-By: Claude Opus 5 Signed-off-by: sid (cherry picked from commit a1297ebd129c21415a482d0acebc331d153a4439) --- deploy/compose/compose.spawner.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/deploy/compose/compose.spawner.yml b/deploy/compose/compose.spawner.yml index 07d6d6ec8f..5c76a4d21e 100644 --- a/deploy/compose/compose.spawner.yml +++ b/deploy/compose/compose.spawner.yml @@ -59,6 +59,11 @@ services: # supplies the host default here. BUZZ_SPAWNER_DEFAULT_PROVIDER: ${BUZZ_SPAWNER_DEFAULT_PROVIDER:-} BUZZ_SPAWNER_DEFAULT_MODEL: ${BUZZ_SPAWNER_DEFAULT_MODEL:-} + # Providers/models advertised in the announcement, as a JSON array. Without + # it the desktop's model field degrades to free text ("Model list + # unavailable from this server") — a client cannot know what the host can + # run unless the host says so. + BUZZ_SPAWNER_AI_CATALOG: ${BUZZ_SPAWNER_AI_CATALOG:-} # Names of variables to forward into agent containers — not KEY=VALUE # pairs. The value is read from this service's own environment, so a # rotated credential is picked up by restarting the spawner. From 98506cdc224ebb76e37843d750a8fe9e8a318990 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 17:10:52 +0530 Subject: [PATCH 40/50] docs: design spec for per-owner server agent credentials Co-Authored-By: Claude Fable 5 Signed-off-by: sid --- ...r-owner-server-agent-credentials-design.md | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-27-per-owner-server-agent-credentials-design.md diff --git a/docs/superpowers/specs/2026-07-27-per-owner-server-agent-credentials-design.md b/docs/superpowers/specs/2026-07-27-per-owner-server-agent-credentials-design.md new file mode 100644 index 0000000000..163cce934c --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-per-owner-server-agent-credentials-design.md @@ -0,0 +1,83 @@ +# Per-owner Claude credential provisioning for server agents + +Date: 2026-07-27 +Status: Approved + +## Problem + +Server-hosted agents (buzz-spawner) currently receive one host-global set of +Anthropic credentials (`ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN` from the +spawner host's `.env` via `BUZZ_SPAWNER_AGENT_ENV` passthrough). Every user's +agents bill against the operator's key. Each user should instead provide their +own Claude OAuth token (or API key) via the desktop agents settings UI, and the +spawner should provision it into that user's agents. + +## Decisions + +- **Scope:** per-owner. One token per owner pubkey covers all server agents + that owner owns on a given spawner. +- **Transport:** new NIP-44-encrypted frame variants on the existing kind:24201 + attestation channel (ephemeral, owner ↔ spawner). Credentials never appear in + any public event, hash, or `PromptMaterial`. +- **UI placement:** a credential card in the Server Agents section of desktop + agent settings, one per connected spawner. +- **Apply timing:** on token save/change, the spawner restarts that owner's + running agents so the credential takes effect immediately. +- **No fallback (breaking):** server agents require an owner token. The + host-global Anthropic credential passthrough no longer applies to + Claude/Anthropic credentials. Agents without an owner token are held stopped + and surfaced as `needs_credential`. +- **Token types:** OAuth token or API key, classified by prefix: + `sk-ant-oat*` → `CLAUDE_CODE_OAUTH_TOKEN`, otherwise → `ANTHROPIC_API_KEY`. + +## 1. Protocol (crates/buzz-sdk/src/spawner.rs) + +- `AttestationFrame::CredentialUpdate { credential: String }` — owner → spawner. + Sets/replaces the owner's token; empty string clears it. +- `AttestationFrame::CredentialAck { accepted: bool, message: Option }` + — spawner → owner delivery confirmation. Deliberately no hash echo: unlike + prompt updates, credentials must never appear in any hash or public event. + +## 2. Spawner (crates/buzz-spawner) + +- **Storage:** new `credentials.json` in the state dir (0600, atomic write), + map of owner pubkey → token. Separate from `agents.json` so agent records + stay credential-free. +- **Env assembly (`env.rs`):** `build_agent_env` gains the owner credential and + injects the prefix-classified env var after operator passthrough, so the + owner token always wins over any host-global value. +- **No fallback:** if the owner has no stored token, the spawner does not start + (or stops) that owner's agents and publishes kind:30179 status with a new + `needs_credential: true` field. +- **On `CredentialUpdate`:** store the token, ack, then restart all running + agents owned by that pubkey and start any held in `needs_credential`. On + clear: stop the owner's agents and mark `needs_credential`. + +## 3. Desktop + +- **UI:** "Your Claude credential" card in `ServerAgentsSection.tsx`, one per + connected spawner. Password-type input (OAuth token or API key), Save/Clear, + status line ("Provisioned" after ack / "Not set — your server agents can't + run"). Write-only: never echoed back, never persisted on the desktop — the + spawner is the source of truth. Agents in `needs_credential` show a warning + badge in the server agents list and edit dialogs. +- **Transport:** mirror the prompt-update path — new Tauri command + `send_spawner_credential_update` (Rust builds/signs the kind:24201 event so + the token never sits in JS-visible storage), published over the WebSocket via + `spawnerRelay.ts`. No persistent queue: fire, await ack with a timeout, show + an error toast on failure. A queued plaintext credential on disk is exactly + what we don't want. + +## 4. Testing + +- Rust unit tests: frame round-trip encrypt/decrypt, prefix classification, + env assembly with/without owner token, daemon restart-on-update and + needs_credential behavior (existing daemon test patterns). +- Desktop: unit tests for the card's state machine; screenshot spec extending + `server-agent-editing-screenshots.spec.ts` for the card and the + `needs_credential` badge. + +## Out of scope (YAGNI) + +Non-Anthropic providers, per-agent overrides, token validation against +Anthropic's API, desktop keychain storage (nothing is persisted on desktop). From 2d9d90c42c80dcec798f584faa5f82866dbf9daa Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 20:13:46 +0530 Subject: [PATCH 41/50] feat(sdk): credential update/ack attestation frames and needs_credential status Co-Authored-By: Claude Fable 5 Signed-off-by: sid --- crates/buzz-sdk/src/spawner.rs | 140 +++++++++++++++++++++++++++++- crates/buzz-spawner/src/daemon.rs | 1 + 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/crates/buzz-sdk/src/spawner.rs b/crates/buzz-sdk/src/spawner.rs index fb2f141292..932b8ea1cb 100644 --- a/crates/buzz-sdk/src/spawner.rs +++ b/crates/buzz-sdk/src/spawner.rs @@ -68,6 +68,9 @@ pub const MAX_RESPOND_TO_ALLOWLIST: usize = 256; /// Byte length of the attestation handshake nonce. pub const ATTESTATION_NONCE_BYTES: usize = 32; +/// Maximum byte length of an owner credential in a `CredentialUpdate` frame. +pub const MAX_CREDENTIAL_BYTES: usize = 512; + // --------------------------------------------------------------------------- // Announcement (kind 10180) // --------------------------------------------------------------------------- @@ -372,12 +375,20 @@ pub struct SpawnerAgentStatus { /// prompt update has been applied. #[serde(default, skip_serializing_if = "Option::is_none")] pub prompt_hash: Option, + /// True when the agent is held stopped because its owner has not delivered + /// a provider credential (see [`AttestationFrame::CredentialUpdate`]). + #[serde(default, skip_serializing_if = "is_false")] + pub needs_credential: bool, } fn is_zero(n: &u32) -> bool { *n == 0 } +fn is_false(b: &bool) -> bool { + !*b +} + impl SpawnerAgentStatus { /// Validate the status's own invariants. pub fn validate(&self) -> Result<(), SdkError> { @@ -540,16 +551,38 @@ pub enum AttestationFrame { #[serde(default, skip_serializing_if = "Option::is_none")] reason: Option, }, + /// Owner → spawner: set or replace the owner's provider credential. + /// + /// Owner-scoped, not agent-scoped: one token covers every agent this owner + /// runs on the spawner. An empty string clears it. The token never appears + /// in any hash or public event — unlike prompt updates there is no + /// world-readable echo; delivery is confirmed by [`Self::CredentialAck`]. + CredentialUpdate { + /// The raw token (`sk-ant-oat…` OAuth token or `sk-ant-api…` API key). + /// Empty clears the stored credential. + credential: String, + }, + /// Spawner → owner: delivery confirmation for a `CredentialUpdate`. + CredentialAck { + /// Whether the update was stored. + accepted: bool, + /// Human-readable detail when `accepted` is false. + #[serde(default, skip_serializing_if = "Option::is_none")] + message: Option, + }, } impl AttestationFrame { /// The agent pubkey this frame concerns, regardless of variant. + /// + /// Empty for the owner-scoped credential variants, which concern no agent. pub fn agent_pubkey(&self) -> &str { match self { Self::Request { agent_pubkey, .. } | Self::Response { agent_pubkey, .. } | Self::Reject { agent_pubkey, .. } | Self::PromptUpdate { agent_pubkey, .. } => agent_pubkey, + Self::CredentialUpdate { .. } | Self::CredentialAck { .. } => "", } } @@ -561,22 +594,47 @@ impl AttestationFrame { Self::Request { nonce, .. } | Self::Response { nonce, .. } | Self::Reject { nonce, .. } => nonce, - Self::PromptUpdate { .. } => "", + Self::PromptUpdate { .. } + | Self::CredentialUpdate { .. } + | Self::CredentialAck { .. } => "", } } /// The spec slug this frame concerns. + /// + /// Empty for the owner-scoped credential variants. pub fn spec_slug(&self) -> &str { match self { Self::Request { spec_slug, .. } | Self::Response { spec_slug, .. } | Self::Reject { spec_slug, .. } | Self::PromptUpdate { spec_slug, .. } => spec_slug, + Self::CredentialUpdate { .. } | Self::CredentialAck { .. } => "", } } /// Validate structural invariants shared by every variant. pub fn validate(&self) -> Result<(), SdkError> { + match self { + Self::CredentialUpdate { credential } => { + // The error deliberately does not echo the credential. + if credential.len() > MAX_CREDENTIAL_BYTES { + return Err(SdkError::InvalidInput(format!( + "credential exceeds {MAX_CREDENTIAL_BYTES} bytes" + ))); + } + return Ok(()); + } + Self::CredentialAck { message, .. } => { + if message.as_ref().is_some_and(|m| m.len() > 2048) { + return Err(SdkError::InvalidInput( + "credential ack message exceeds 2048 bytes".into(), + )); + } + return Ok(()); + } + _ => {} + } check_spec_slug(self.spec_slug())?; check_pubkey_hex(self.agent_pubkey(), "agent_pubkey")?; // A prompt update opens no handshake round, so it carries no nonce. @@ -912,6 +970,7 @@ mod tests { error: None, restart_count: 3, prompt_hash: None, + needs_credential: false, }; assert!(status.validate().is_err()); status.error = Some("image pull failed".into()); @@ -930,6 +989,7 @@ mod tests { error: None, restart_count: 0, prompt_hash: None, + needs_credential: false, }; let event = build_spawner_agent_status("fizz-prod", &owner.public_key().to_hex(), &status) .unwrap() @@ -1016,6 +1076,7 @@ mod tests { error: None, restart_count: 0, prompt_hash: Some("ab".repeat(32)), + needs_credential: false, }; let back: SpawnerAgentStatus = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); @@ -1099,6 +1160,83 @@ mod tests { assert!(frame.validate().is_err()); } + #[test] + fn credential_update_round_trips_and_is_owner_scoped() { + let frame = AttestationFrame::CredentialUpdate { + credential: "sk-ant-oat01-abc".into(), + }; + assert!(frame.validate().is_ok()); + // Owner-scoped: no agent, no slug, no nonce. + assert_eq!(frame.agent_pubkey(), ""); + assert_eq!(frame.spec_slug(), ""); + assert_eq!(frame.nonce(), ""); + let json = serde_json::to_string(&frame).unwrap(); + assert!(json.contains(r#""type":"credential_update""#)); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + frame + ); + } + + #[test] + fn credential_update_may_be_empty_to_clear() { + let frame = AttestationFrame::CredentialUpdate { + credential: String::new(), + }; + assert!(frame.validate().is_ok()); + } + + #[test] + fn credential_update_rejects_oversized_tokens() { + let frame = AttestationFrame::CredentialUpdate { + credential: "x".repeat(MAX_CREDENTIAL_BYTES + 1), + }; + assert!(frame.validate().is_err()); + } + + #[test] + fn credential_ack_round_trips() { + let frame = AttestationFrame::CredentialAck { + accepted: true, + message: None, + }; + assert!(frame.validate().is_ok()); + let json = serde_json::to_string(&frame).unwrap(); + assert!(json.contains(r#""type":"credential_ack""#)); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + frame + ); + } + + #[test] + fn status_needs_credential_round_trips_and_defaults_false() { + let s = SpawnerAgentStatus { + phase: SpawnPhase::Stopped, + agent_pubkey: None, + spec_hash: None, + error: None, + restart_count: 0, + prompt_hash: None, + needs_credential: true, + }; + let json = serde_json::to_string(&s).unwrap(); + assert!(json.contains("needs_credential")); + let back: SpawnerAgentStatus = serde_json::from_str(&json).unwrap(); + assert!(back.needs_credential); + // Old events without the field still parse, and false is omitted. + let legacy: SpawnerAgentStatus = + serde_json::from_str(r#"{"phase":"running"}"#).unwrap(); + assert!(!legacy.needs_credential); + let quiet = SpawnerAgentStatus { + needs_credential: false, + ..s + }; + assert!(!serde_json::to_string(&quiet) + .unwrap() + .contains("needs_credential")); + } + #[test] fn attestation_frame_json_is_tagged_by_type() { let frame = AttestationFrame::Response { diff --git a/crates/buzz-spawner/src/daemon.rs b/crates/buzz-spawner/src/daemon.rs index 47be571584..64e058c377 100644 --- a/crates/buzz-spawner/src/daemon.rs +++ b/crates/buzz-spawner/src/daemon.rs @@ -803,6 +803,7 @@ impl Daemon { error, restart_count, prompt_hash, + needs_credential: false, }; self.relay.publish_status(slug, owner_pubkey, &status).await } From c2b12b6bedcd5f22e39302d3d25187f2cf546bd0 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 20:14:17 +0530 Subject: [PATCH 42/50] fix(spawner): handle credential frames in attestation match Co-Authored-By: Claude Fable 5 Signed-off-by: sid --- crates/buzz-spawner/examples/pubkey_of.rs | 7 +++ crates/buzz-spawner/examples/relay_dump.rs | 56 ++++++++++++++++++++++ crates/buzz-spawner/src/attestation.rs | 4 ++ 3 files changed, 67 insertions(+) create mode 100644 crates/buzz-spawner/examples/pubkey_of.rs create mode 100644 crates/buzz-spawner/examples/relay_dump.rs diff --git a/crates/buzz-spawner/examples/pubkey_of.rs b/crates/buzz-spawner/examples/pubkey_of.rs new file mode 100644 index 0000000000..bb75614a9a --- /dev/null +++ b/crates/buzz-spawner/examples/pubkey_of.rs @@ -0,0 +1,7 @@ +//! Print the hex public key for a secret key (hex or nsec). Dev utility. +fn main() -> anyhow::Result<()> { + let secret = std::env::args().nth(1).ok_or_else(|| anyhow::anyhow!("usage: pubkey_of "))?; + let keys = nostr::Keys::parse(&secret)?; + println!("{}", keys.public_key().to_hex()); + Ok(()) +} diff --git a/crates/buzz-spawner/examples/relay_dump.rs b/crates/buzz-spawner/examples/relay_dump.rs new file mode 100644 index 0000000000..ee4ba0fda6 --- /dev/null +++ b/crates/buzz-spawner/examples/relay_dump.rs @@ -0,0 +1,56 @@ +//! Dev utility: dump spawner-related events (kinds 10180/30178/30179) from a relay. +//! Usage: relay_dump +use std::time::Duration; + +use buzz_ws_client::{parse_relay_message, NostrWsConnection, RelayMessage}; +use serde_json::json; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let url = std::env::args().nth(1).expect("relay url"); + let secret = std::env::args().nth(2).expect("secret"); + let keys = nostr::Keys::parse(&secret)?; + eprintln!("me: {}", keys.public_key().to_hex()); + + let mut conn = NostrWsConnection::connect_authenticated(&url, &keys, None).await?; + let kinds: Vec = std::env::args() + .nth(3) + .unwrap_or_else(|| "10180,30178,30179".into()) + .split(',') + .filter_map(|k| k.trim().parse().ok()) + .collect(); + conn.send_raw(&json!(["REQ", "dump", {"kinds": kinds, "limit": 200}])) + .await?; + loop { + match conn.next_event(Duration::from_secs(5)).await { + Ok(RelayMessage::Event { event, .. }) => { + let d = event + .tags + .iter() + .find_map(|t| { + let s = t.clone().to_vec(); + (s.first().map(String::as_str) == Some("d")).then(|| s.get(1).cloned()) + }) + .flatten() + .unwrap_or_default(); + println!( + "kind={} author={} d={} created_at={} content={}", + event.kind, + event.pubkey.to_hex(), + d, + event.created_at, + &event.content + ); + } + Ok(RelayMessage::Eose { .. }) => break, + Ok(_) => {} + Err(e) => { + eprintln!("recv: {e}"); + break; + } + } + } + let _ = parse_relay_message; // keep import simple + conn.disconnect().await.ok(); + Ok(()) +} diff --git a/crates/buzz-spawner/src/attestation.rs b/crates/buzz-spawner/src/attestation.rs index 94986d1e9e..7b88ac04fb 100644 --- a/crates/buzz-spawner/src/attestation.rs +++ b/crates/buzz-spawner/src/attestation.rs @@ -107,6 +107,10 @@ pub fn evaluate_response( // match and must not be evaluated as a handshake response. bail!("prompt updates are not attestation responses") } + AttestationFrame::CredentialUpdate { .. } | AttestationFrame::CredentialAck { .. } => { + // Owner-scoped frames, routed in the daemon before this point. + bail!("credential frames are not attestation responses") + } AttestationFrame::Response { auth_tag, prompt, From 76fdce80b81f87ddfe2a4cfab21f7d99b8a55754 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 20:15:03 +0530 Subject: [PATCH 43/50] feat(spawner): per-owner credential store with prefix classification Co-Authored-By: Claude Fable 5 Signed-off-by: sid --- crates/buzz-spawner/src/credentials.rs | 139 +++++++++++++++++++++++++ crates/buzz-spawner/src/lib.rs | 1 + crates/buzz-spawner/src/store.rs | 4 +- 3 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 crates/buzz-spawner/src/credentials.rs diff --git a/crates/buzz-spawner/src/credentials.rs b/crates/buzz-spawner/src/credentials.rs new file mode 100644 index 0000000000..f131aa4591 --- /dev/null +++ b/crates/buzz-spawner/src/credentials.rs @@ -0,0 +1,139 @@ +//! Per-owner provider credentials, delivered over the encrypted kind:24201 +//! channel and held only on this host. +//! +//! Kept in a separate file from `agents.json` so agent records stay +//! credential-free: the two files have different lifecycles (a credential +//! outlives any one agent) and different blast radii when read or logged. + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// Which env var a token should be injected as, by prefix. +/// +/// Claude Code OAuth tokens are `sk-ant-oat…`; anything else is treated as an +/// Anthropic API key. Misclassifying is harmless-but-broken (the harness fails +/// to authenticate), never a leak. +pub fn credential_env_key(token: &str) -> &'static str { + if token.starts_with("sk-ant-oat") { + "CLAUDE_CODE_OAUTH_TOKEN" + } else { + "ANTHROPIC_API_KEY" + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct CredentialFile { + /// Keyed by owner pubkey, hex. + #[serde(default)] + credentials: HashMap, +} + +/// Persistent owner-pubkey → token store over `/credentials.json`. +pub struct CredentialStore { + path: PathBuf, + state: CredentialFile, +} + +impl CredentialStore { + /// Open the store, creating the directory if needed. + pub fn open(state_dir: &Path) -> Result { + std::fs::create_dir_all(state_dir) + .with_context(|| format!("failed to create state dir {}", state_dir.display()))?; + let path = state_dir.join("credentials.json"); + let state = match std::fs::read_to_string(&path) { + Ok(raw) => serde_json::from_str(&raw) + .with_context(|| format!("failed to parse {}", path.display()))?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => CredentialFile::default(), + Err(e) => return Err(e).with_context(|| format!("failed to read {}", path.display())), + }; + Ok(Self { path, state }) + } + + /// The stored token for an owner, if any. + pub fn get(&self, owner_pubkey: &str) -> Option<&str> { + self.state.credentials.get(owner_pubkey).map(String::as_str) + } + + /// Store or replace an owner's token and persist. + pub fn set(&mut self, owner_pubkey: &str, token: String) -> Result<()> { + self.state + .credentials + .insert(owner_pubkey.to_string(), token); + self.flush() + } + + /// Remove an owner's token and persist. Returns whether one existed. + pub fn remove(&mut self, owner_pubkey: &str) -> Result { + let existed = self.state.credentials.remove(owner_pubkey).is_some(); + if existed { + self.flush()?; + } + Ok(existed) + } + + /// Owners that currently have a token. + pub fn owners(&self) -> impl Iterator { + self.state.credentials.keys() + } + + /// Atomic 0600 write, same crash-safety rationale as `Store::flush`. + fn flush(&self) -> Result<()> { + let json = serde_json::to_string_pretty(&self.state) + .context("failed to serialize credential store")?; + let tmp = self.path.with_extension("json.tmp"); + crate::store::write_private(&tmp, &json) + .with_context(|| format!("failed to write {}", tmp.display()))?; + std::fs::rename(&tmp, &self.path) + .with_context(|| format!("failed to rename into {}", self.path.display()))?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_tokens_by_prefix() { + assert_eq!( + credential_env_key("sk-ant-oat01-xyz"), + "CLAUDE_CODE_OAUTH_TOKEN" + ); + assert_eq!(credential_env_key("sk-ant-api03-xyz"), "ANTHROPIC_API_KEY"); + assert_eq!(credential_env_key("something-else"), "ANTHROPIC_API_KEY"); + } + + #[test] + fn round_trips_through_the_file_with_owner_only_permissions() { + let dir = std::env::temp_dir().join(format!("buzz-cred-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let owner = "b".repeat(64); + { + let mut store = CredentialStore::open(&dir).unwrap(); + store.set(&owner, "sk-ant-oat01-abc".into()).unwrap(); + } + let mut store = CredentialStore::open(&dir).unwrap(); + assert_eq!(store.get(&owner), Some("sk-ant-oat01-abc")); + assert!(store.get("missing").is_none()); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(dir.join("credentials.json")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "credential file holds tokens"); + } + + assert!(store.remove(&owner).unwrap()); + assert!(!store.remove(&owner).unwrap()); + assert!(store.get(&owner).is_none()); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/crates/buzz-spawner/src/lib.rs b/crates/buzz-spawner/src/lib.rs index fb70690f61..3399ae6013 100644 --- a/crates/buzz-spawner/src/lib.rs +++ b/crates/buzz-spawner/src/lib.rs @@ -43,6 +43,7 @@ pub mod attestation; pub mod config; pub mod container; +pub mod credentials; pub mod daemon; pub mod env; pub mod reconcile; diff --git a/crates/buzz-spawner/src/store.rs b/crates/buzz-spawner/src/store.rs index 7183f95ce0..1feecbb18e 100644 --- a/crates/buzz-spawner/src/store.rs +++ b/crates/buzz-spawner/src/store.rs @@ -213,7 +213,7 @@ impl Store { /// every agent's secret key, and creating it under the process umask would /// leave a window — however brief — where anything on the host could read it. #[cfg(unix)] -fn write_private(path: &Path, contents: &str) -> Result<()> { +pub(crate) fn write_private(path: &Path, contents: &str) -> Result<()> { use std::io::Write; use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; @@ -231,7 +231,7 @@ fn write_private(path: &Path, contents: &str) -> Result<()> { } #[cfg(not(unix))] -fn write_private(path: &Path, contents: &str) -> Result<()> { +pub(crate) fn write_private(path: &Path, contents: &str) -> Result<()> { std::fs::write(path, contents)?; Ok(()) } From f81906d7522451c5fac183035713cd064ce1889c Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 20:16:02 +0530 Subject: [PATCH 44/50] feat(spawner): inject per-owner credential into agent env, displacing host-global keys Co-Authored-By: Claude Fable 5 Signed-off-by: sid --- crates/buzz-spawner/src/daemon.rs | 1 + crates/buzz-spawner/src/env.rs | 89 +++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/crates/buzz-spawner/src/daemon.rs b/crates/buzz-spawner/src/daemon.rs index 64e058c377..f2fd15f87c 100644 --- a/crates/buzz-spawner/src/daemon.rs +++ b/crates/buzz-spawner/src/daemon.rs @@ -585,6 +585,7 @@ impl Daemon { command: self.config.agent_command.as_deref(), args: self.config.agent_args.as_deref(), }, + None, ), cpu_millis, memory_mib, diff --git a/crates/buzz-spawner/src/env.rs b/crates/buzz-spawner/src/env.rs index 80b2175525..0b5cd4db68 100644 --- a/crates/buzz-spawner/src/env.rs +++ b/crates/buzz-spawner/src/env.rs @@ -57,12 +57,20 @@ pub struct AgentRuntime<'a> { pub args: Option<&'a str>, } +/// Env var names an owner-delivered credential displaces. +const OWNER_CREDENTIAL_KEYS: &[&str] = &["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"]; + /// Build the full environment for an agent container. /// /// `passthrough` is the operator-configured env (LLM credentials and the like). /// It is applied *first* so the Buzz-owned variables below always win: an /// operator cannot accidentally — or deliberately — override an agent's identity /// by naming `BUZZ_PRIVATE_KEY` in the compose file. +/// +/// `owner_credential` is the per-owner provider credential delivered over the +/// encrypted kind:24201 channel. When present it displaces any host-global +/// Anthropic credential in `passthrough` — each owner's agents bill against +/// their own token, never the operator's. pub fn build_agent_env( record: &AgentRecord, spec: &SpawnerAgentSpec, @@ -70,13 +78,24 @@ pub fn build_agent_env( relay_url: &str, passthrough: &[(String, String)], runtime: &AgentRuntime<'_>, + owner_credential: Option<&str>, ) -> Vec<(String, String)> { let mut env: Vec<(String, String)> = passthrough .iter() .filter(|(k, _)| !RESERVED_KEYS.contains(&k.as_str())) + .filter(|(k, _)| { + owner_credential.is_none() || !OWNER_CREDENTIAL_KEYS.contains(&k.as_str()) + }) .cloned() .collect(); + if let Some(token) = owner_credential { + env.push(( + crate::credentials::credential_env_key(token).to_string(), + token.to_string(), + )); + } + let mut set = |key: &str, value: String| env.push((key.to_string(), value)); // Runtime selection, from operator config only — never from a spec. @@ -208,6 +227,7 @@ mod tests { "wss://relay.example", &[], &DEFAULT_RUNTIME, + None, ); assert_eq!( @@ -233,6 +253,68 @@ mod tests { ); } + #[test] + fn owner_credential_wins_over_host_global_passthrough() { + let passthrough = vec![ + ("ANTHROPIC_API_KEY".to_string(), "sk-host-global".to_string()), + ( + "CLAUDE_CODE_OAUTH_TOKEN".to_string(), + "sk-host-oauth".to_string(), + ), + ("OTHER_VAR".to_string(), "kept".to_string()), + ]; + let env = build_agent_env( + &record(), + &spec(), + &ResolvedPrompt::default(), + "wss://r", + &passthrough, + &DEFAULT_RUNTIME, + Some("sk-ant-oat01-owner"), + ); + assert_eq!( + lookup(&env, "CLAUDE_CODE_OAUTH_TOKEN").as_deref(), + Some("sk-ant-oat01-owner") + ); + // Host-global Anthropic credentials are fully displaced, not shadowed. + assert!(lookup(&env, "ANTHROPIC_API_KEY").is_none()); + assert!(!env.iter().any(|(_, v)| v.starts_with("sk-host"))); + assert_eq!(lookup(&env, "OTHER_VAR").as_deref(), Some("kept")); + } + + #[test] + fn owner_api_key_is_injected_under_the_api_key_var() { + let env = build_agent_env( + &record(), + &spec(), + &ResolvedPrompt::default(), + "wss://r", + &[], + &DEFAULT_RUNTIME, + Some("sk-ant-api03-owner"), + ); + assert_eq!( + lookup(&env, "ANTHROPIC_API_KEY").as_deref(), + Some("sk-ant-api03-owner") + ); + assert!(lookup(&env, "CLAUDE_CODE_OAUTH_TOKEN").is_none()); + } + + #[test] + fn without_an_owner_credential_passthrough_flows_unchanged() { + let passthrough = vec![("ANTHROPIC_API_KEY".to_string(), "sk-host".to_string())]; + let env = build_agent_env( + &record(), + &spec(), + &ResolvedPrompt::default(), + "wss://r", + &passthrough, + &DEFAULT_RUNTIME, + None, + ); + assert_eq!(lookup(&env, "ANTHROPIC_API_KEY").as_deref(), Some("sk-host")); + } + #[test] fn passthrough_cannot_override_reserved_keys() { // A compose file naming BUZZ_PRIVATE_KEY must not be able to hand an @@ -256,6 +338,7 @@ mod tests { "wss://relay.example", &passthrough, &DEFAULT_RUNTIME, + None, ); assert_eq!( @@ -287,6 +370,7 @@ mod tests { command: Some("claude-agent-acp"), args: None, }, + None, ); assert_eq!( lookup(&env, "BUZZ_ACP_AGENT_COMMAND").as_deref(), @@ -303,6 +387,7 @@ mod tests { "wss://r", &[], &DEFAULT_RUNTIME, + None, ); assert!(lookup(&env, "BUZZ_ACP_AGENT_COMMAND").is_none()); } @@ -323,6 +408,7 @@ mod tests { command: Some("claude-agent-acp"), args: None, }, + None, ); assert_eq!( lookup(&env, "BUZZ_ACP_AGENT_COMMAND").as_deref(), @@ -342,6 +428,7 @@ mod tests { "wss://relay.example", &[], &DEFAULT_RUNTIME, + None, ); assert!(lookup(&env, "BUZZ_AUTH_TAG").is_none()); } @@ -356,6 +443,7 @@ mod tests { "wss://r", &[], &DEFAULT_RUNTIME, + None, ); assert!(lookup(&env, "BUZZ_ACP_RESPOND_TO_ALLOWLIST").is_none()); @@ -368,6 +456,7 @@ mod tests { "wss://r", &[], &DEFAULT_RUNTIME, + None, ); assert_eq!( lookup(&env, "BUZZ_ACP_RESPOND_TO_ALLOWLIST"), From 3c6d3be27620cc26b74930bf7accb905551a2d3f Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 20:18:21 +0530 Subject: [PATCH 45/50] feat(spawner): hold agents without owner credential; apply credential updates with ack and restart Co-Authored-By: Claude Fable 5 Signed-off-by: sid --- crates/buzz-spawner/src/daemon.rs | 133 ++++++++++++++++++++++++++- crates/buzz-spawner/src/reconcile.rs | 114 +++++++++++++++++++++++ 2 files changed, 245 insertions(+), 2 deletions(-) diff --git a/crates/buzz-spawner/src/daemon.rs b/crates/buzz-spawner/src/daemon.rs index f2fd15f87c..2b131baac0 100644 --- a/crates/buzz-spawner/src/daemon.rs +++ b/crates/buzz-spawner/src/daemon.rs @@ -22,6 +22,8 @@ use crate::{ pub struct Daemon { config: Config, store: Store, + /// Per-owner provider credentials, delivered over the encrypted handshake. + credentials: crate::credentials::CredentialStore, relay: SpawnerRelay, containers: Arc, /// Latest spec per `(owner, slug)`, the desired state built from the relay. @@ -45,6 +47,7 @@ impl Daemon { /// Connect to the relay and open the state store. pub async fn start(config: Config, containers: Arc) -> Result { let store = Store::open(&config.state_dir)?; + let credentials = crate::credentials::CredentialStore::open(&config.state_dir)?; let relay = SpawnerRelay::connect(&config.relay_url, &config.keys).await?; info!( @@ -56,6 +59,7 @@ impl Daemon { Ok(Self { config, store, + credentials, relay, containers, desired: HashMap::new(), @@ -143,6 +147,18 @@ impl Daemon { self.reconcile().await } Inbound::Attestation { sender, frame } => { + if let buzz_sdk::spawner::AttestationFrame::CredentialUpdate { credential } = + &frame + { + return self.apply_credential_update(&sender, credential).await; + } + if matches!( + &frame, + buzz_sdk::spawner::AttestationFrame::CredentialAck { .. } + ) { + // Our own outbound ack echoed off the ephemeral stream. + return Ok(()); + } if let buzz_sdk::spawner::AttestationFrame::PromptUpdate { agent_pubkey, prompt, @@ -293,6 +309,54 @@ impl Daemon { self.reconcile().await } + /// Store, replace, or clear the sender's provider credential, then restart + /// every agent that owner runs here so the change takes effect immediately. + /// + /// The sender IS the authorization: a kind:24201 frame is NIP-44 encrypted + /// to this spawner and the event signature proves who sent it, so the token + /// is filed under that verified pubkey and can only ever affect that + /// owner's own agents. + async fn apply_credential_update( + &mut self, + sender: &PublicKey, + credential: &str, + ) -> Result<()> { + let owner = sender.to_hex(); + let outcome: Result<()> = match normalized_credential(credential) { + Some(token) => self.credentials.set(&owner, token), + None => self.credentials.remove(&owner).map(|_| ()), + }; + + let ack = buzz_sdk::spawner::AttestationFrame::CredentialAck { + accepted: outcome.is_ok(), + message: outcome.as_ref().err().map(|e| format!("{e:#}")), + }; + if let Err(e) = self.relay.send_attestation(sender, &ack).await { + warn!(owner = %owner, "failed to send credential ack: {e:#}"); + } + outcome?; + + // Deliberately no token material in the log line. + info!(owner = %owner, "owner credential updated"); + + // Force-restart this owner's agents: clearing spec_hash makes the next + // reconcile pass see drift and replace each container (same mechanism + // as apply_prompt_update). With the credential removed, the same clear + // lets reconcile emit exactly one HoldForCredential per agent. + let slugs: Vec = self + .store + .agents() + .filter(|r| r.owner_pubkey == owner) + .map(|r| r.slug.clone()) + .collect(); + for slug in slugs { + self.store.update(&owner, &slug, |r| { + r.spec_hash = None; + })?; + } + self.reconcile().await + } + /// One reconciliation pass. pub async fn reconcile(&mut self) -> Result<()> { let spawner_pubkey = self.config.keys.public_key().to_hex(); @@ -300,6 +364,9 @@ impl Daemon { let desired: Vec = self.desired.values().cloned().collect(); let records: Vec = self.store.agents().cloned().collect(); + let credentialed_owners: std::collections::HashSet = + self.credentials.owners().cloned().collect(); + let actions = plan(ReconcileInput { desired: &desired, records: &records, @@ -308,6 +375,7 @@ impl Daemon { attestation_timeout_secs: self.config.attestation_timeout.as_secs() as i64, max_agents: self.config.max_agents, desired_hydrated: self.desired_hydrated, + credentialed_owners: &credentialed_owners, }); for action in actions { @@ -426,6 +494,26 @@ impl Daemon { warn!(container = %container_id, "removing container with no spawner record"); self.containers.remove(&container_id, None).await } + Action::HoldForCredential { + owner_pubkey, + slug, + container_id, + } => { + if let Some(id) = container_id { + // Volume preserved, identity preserved: this is a hold. + self.containers.remove(&id, None).await?; + } + self.store.update(&owner_pubkey, &slug, |r| { + r.spec_hash = None; + })?; + let agent_pubkey = self + .store + .get(&owner_pubkey, &slug) + .map(|r| r.agent_pubkey.clone()); + info!(slug = %slug, "agent held: owner has no credential"); + self.publish_needs_credential(&slug, &owner_pubkey, agent_pubkey.as_deref()) + .await + } } } @@ -568,6 +656,10 @@ impl Daemon { } }; let (cpu_millis, memory_mib) = self.resources_for(&desired.spec); + let owner_credential = self + .credentials + .get(&desired.owner_pubkey) + .map(str::to_string); let spec = ContainerSpec { name: record.container_name(&self.config.keys.public_key().to_hex()), @@ -585,7 +677,7 @@ impl Daemon { command: self.config.agent_command.as_deref(), args: self.config.agent_args.as_deref(), }, - None, + owner_credential.as_deref(), ), cpu_millis, memory_mib, @@ -798,18 +890,45 @@ impl Daemon { ) -> Result<()> { let prompt_hash = prompt_hash_for(self.store.get(owner_pubkey, slug)); let status = SpawnerAgentStatus { + needs_credential: false, phase, agent_pubkey: agent_pubkey.map(str::to_string), spec_hash: spec_hash.map(str::to_string), error, restart_count, prompt_hash, - needs_credential: false, + }; + self.relay.publish_status(slug, owner_pubkey, &status).await + } + + /// Publish a `stopped` status flagged `needs_credential`, so clients can + /// say *why* the agent is not running instead of showing a plain Stopped. + async fn publish_needs_credential( + &mut self, + slug: &str, + owner_pubkey: &str, + agent_pubkey: Option<&str>, + ) -> Result<()> { + let status = SpawnerAgentStatus { + phase: SpawnPhase::Stopped, + agent_pubkey: agent_pubkey.map(str::to_string), + spec_hash: None, + error: None, + restart_count: 0, + prompt_hash: prompt_hash_for(self.store.get(owner_pubkey, slug)), + needs_credential: true, }; self.relay.publish_status(slug, owner_pubkey, &status).await } } +/// Whether a credential update clears (empty/whitespace) or sets a token. +/// Split out so the trim rule is testable without a daemon. +fn normalized_credential(raw: &str) -> Option { + let trimmed = raw.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + /// Hash of a record's cached prompt material, if any, for `prompt_hash` on the /// published status. Split out as a pure function so it is testable without a /// running daemon. @@ -937,6 +1056,16 @@ mod tests { } } + #[test] + fn credential_normalization_trims_and_treats_blank_as_clear() { + assert_eq!( + normalized_credential(" sk-ant-oat01-x \n"), + Some("sk-ant-oat01-x".into()) + ); + assert_eq!(normalized_credential(""), None); + assert_eq!(normalized_credential(" "), None); + } + #[test] fn prompt_hash_for_present_prompt_matches_material_hash() { let material = buzz_sdk::spawner::PromptMaterial { diff --git a/crates/buzz-spawner/src/reconcile.rs b/crates/buzz-spawner/src/reconcile.rs index acec2a97fb..284b9e9f89 100644 --- a/crates/buzz-spawner/src/reconcile.rs +++ b/crates/buzz-spawner/src/reconcile.rs @@ -96,6 +96,17 @@ pub enum Action { /// Container to remove. container_id: String, }, + /// Tear down / decline to start an agent whose owner has delivered no + /// credential, and report `needs_credential`. Identity, record, and volume + /// are all preserved — this is a hold, not a delete. + HoldForCredential { + /// Owner pubkey. + owner_pubkey: String, + /// Spec slug. + slug: String, + /// Running container to remove, if one exists. + container_id: Option, + }, } /// Inputs to a reconciliation pass. @@ -121,6 +132,12 @@ pub struct ReconcileInput<'a> { /// on every restart — the agent comes back with a new identity and loses /// its channel membership and attestation. pub desired_hydrated: bool, + /// Owner pubkeys with a stored provider credential. + /// + /// An attested, enabled agent whose owner is absent here is held stopped + /// instead of started — server agents run on their owner's token, never + /// the operator's. + pub credentialed_owners: &'a HashSet, } /// Backoff before retrying a failed start, capped so a permanently broken agent @@ -267,6 +284,22 @@ pub fn plan(input: ReconcileInput<'_>) -> Vec { continue; } + // 5b. Attested but the owner has delivered no credential — hold rather + // than start. Emitted only on the transition (running container, or a + // record that still thinks it started something) so status is not + // republished on every pass. + if !input.credentialed_owners.contains(&desired.owner_pubkey) { + let container_id = container.map(|c| c.id.clone()); + if container_id.is_some() || record.spec_hash.is_some() { + actions.push(Action::HoldForCredential { + owner_pubkey: desired.owner_pubkey.clone(), + slug: desired.slug.clone(), + container_id, + }); + } + continue; + } + let hash = desired.spec_hash(); match container { @@ -372,6 +405,17 @@ mod tests { } } + /// Every test owner ("b"*64) has a credential unless a test says otherwise. + fn all_owners() -> &'static HashSet { + static SET: std::sync::OnceLock> = std::sync::OnceLock::new(); + SET.get_or_init(|| HashSet::from(["b".repeat(64)])) + } + + fn no_owners() -> &'static HashSet { + static SET: std::sync::OnceLock> = std::sync::OnceLock::new(); + SET.get_or_init(HashSet::new) + } + fn input<'a>( desired: &'a [DesiredAgent], records: &'a [AgentRecord], @@ -385,6 +429,7 @@ mod tests { attestation_timeout_secs: 600, max_agents: 16, desired_hydrated: true, + credentialed_owners: all_owners(), } } @@ -687,6 +732,75 @@ mod tests { )); } + #[test] + fn holds_an_attested_agent_whose_owner_has_no_credential() { + let d = vec![desired("fizz", true, 1)]; + let hash = d[0].spec_hash(); + let r = vec![record("fizz", "agent1", true, Some(&hash))]; + let mut uncred = input(&d, &r, &[]); + uncred.credentialed_owners = no_owners(); + assert_eq!( + plan(uncred), + [Action::HoldForCredential { + owner_pubkey: "b".repeat(64), + slug: "fizz".into(), + container_id: None, + }] + ); + } + + #[test] + fn stops_a_running_agent_when_its_owner_credential_is_cleared() { + let d = vec![desired("fizz", true, 1)]; + let hash = d[0].spec_hash(); + let r = vec![record("fizz", "agent1", true, Some(&hash))]; + let c = vec![container("agent1", true)]; + let mut uncred = input(&d, &r, &c); + uncred.credentialed_owners = no_owners(); + assert_eq!( + plan(uncred), + [Action::HoldForCredential { + owner_pubkey: "b".repeat(64), + slug: "fizz".into(), + container_id: Some("ctr-agent1".into()), + }] + ); + } + + #[test] + fn an_already_held_agent_is_not_re_held_every_pass() { + // spec_hash None + no container = the hold already happened; a second + // HoldForCredential would republish status on every reconcile tick. + let d = vec![desired("fizz", true, 1)]; + let r = vec![record("fizz", "agent1", true, None)]; + let mut uncred = input(&d, &r, &[]); + uncred.credentialed_owners = no_owners(); + assert!(plan(uncred).is_empty()); + } + + #[test] + fn credential_gate_does_not_block_attestation_or_teardown() { + // Attestation chasing still runs without a credential… + let d = vec![desired("fizz", true, 1)]; + let r = vec![record("fizz", "agent1", false, None)]; + let mut late = input(&d, &r, &[]); + late.credentialed_owners = no_owners(); + late.now = 1_000 + 601; + assert!(matches!( + plan(late).as_slice(), + [Action::ReRequestAttestation { .. }] + )); + // …and so does deletion of a removed spec. + let hash = desired("fizz", true, 1).spec_hash(); + let r = vec![record("fizz", "agent1", true, Some(&hash))]; + let c = vec![container("agent1", true)]; + let mut gone = input(&[], &r, &c); + gone.credentialed_owners = no_owners(); + assert!(plan(gone) + .iter() + .any(|a| matches!(a, Action::Delete { .. }))); + } + #[test] fn honors_the_agent_cap() { let d: Vec<_> = (0..5) From 248beb1cdcb35dc93438d457b102c8a9ece278cb Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 20:20:15 +0530 Subject: [PATCH 46/50] feat(desktop): tauri commands to sign credential updates and decode acks Co-Authored-By: Claude Fable 5 Signed-off-by: sid --- desktop/src-tauri/src/commands/spawner.rs | 158 ++++++++++++++++++++++ desktop/src-tauri/src/lib.rs | 2 + 2 files changed, 160 insertions(+) diff --git a/desktop/src-tauri/src/commands/spawner.rs b/desktop/src-tauri/src/commands/spawner.rs index 894be27bcc..5bbc096b53 100644 --- a/desktop/src-tauri/src/commands/spawner.rs +++ b/desktop/src-tauri/src/commands/spawner.rs @@ -332,6 +332,104 @@ pub async fn send_spawner_prompt_update( build_prompt_update_event(&keys, &spawner_pubkey, &spec_slug, &agent_pubkey, prompt) } +/// Output of [`send_spawner_credential_update`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpawnerCredentialUpdateOut { + /// The signed kind:24201 event to publish over the WebSocket. + pub event_json: String, +} + +/// A decoded `CredentialAck` frame from a spawner. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpawnerCredentialAck { + /// Whether the spawner stored the update. + pub accepted: bool, + /// Detail when it did not. + pub message: Option, +} + +/// Build a signed, NIP-44-encrypted `CredentialUpdate` frame. +/// +/// The token is encrypted here, in Rust, and the renderer only ever handles +/// the resulting ciphertext event — nothing persists it on this device. An +/// empty `credential` clears the spawner-side token. +fn build_credential_update_event( + owner: &nostr::Keys, + spawner_pubkey: &str, + credential: &str, +) -> Result { + let spawner = nostr::PublicKey::from_hex(spawner_pubkey) + .map_err(|e| format!("invalid spawner pubkey: {e}"))?; + + let frame = AttestationFrame::CredentialUpdate { + credential: credential.to_string(), + }; + frame + .validate() + .map_err(|e| format!("invalid credential update: {e}"))?; + + let plaintext = serde_json::to_string(&frame) + .map_err(|e| format!("failed to serialize credential frame: {e}"))?; + let ciphertext = nostr::nips::nip44::encrypt( + owner.secret_key(), + &spawner, + plaintext, + nostr::nips::nip44::Version::V2, + ) + .map_err(|e| format!("failed to encrypt credential frame: {e}"))?; + + let event = build_spawner_attestation(&spawner.to_hex(), &ciphertext) + .map_err(|e| format!("failed to build credential event: {e}"))? + .sign_with_keys(owner) + .map_err(|e| format!("failed to sign credential event: {e}"))?; + + Ok(SpawnerCredentialUpdateOut { + event_json: event.as_json(), + }) +} + +/// Decrypt a frame and return it only when it is a `CredentialAck`. +fn decode_credential_ack_frame( + keys: &nostr::Keys, + spawner: &nostr::PublicKey, + encrypted_content: &str, +) -> Result, String> { + match decrypt_frame(keys, spawner, encrypted_content)? { + AttestationFrame::CredentialAck { accepted, message } => { + Ok(Some(SpawnerCredentialAck { accepted, message })) + } + _ => Ok(None), + } +} + +/// Sign an owner credential update for a spawner. See +/// [`respond_to_spawner_attestation`] for why the event is returned rather +/// than published: kind 24201 is ephemeral and must go over the WebSocket. +#[tauri::command] +pub async fn send_spawner_credential_update( + state: tauri::State<'_, AppState>, + spawner_pubkey: String, + credential: String, +) -> Result { + let keys = state.signing_keys()?; + build_credential_update_event(&keys, &spawner_pubkey, &credential) +} + +/// Decode an inbound frame as a credential ack, `Ok(None)` for anything else. +#[tauri::command] +pub async fn decode_spawner_credential_ack( + state: tauri::State<'_, AppState>, + spawner_pubkey: String, + encrypted_content: String, +) -> Result, String> { + let keys = state.signing_keys()?; + let spawner = nostr::PublicKey::from_hex(&spawner_pubkey) + .map_err(|e| format!("invalid spawner pubkey: {e}"))?; + decode_credential_ack_frame(&keys, &spawner, &encrypted_content) +} + fn decrypt_frame( keys: &nostr::Keys, spawner: &nostr::PublicKey, @@ -504,6 +602,66 @@ mod tests { } } + #[test] + fn credential_update_round_trips_to_the_spawner() { + let owner = Keys::generate(); + let spawner = Keys::generate(); + let out = build_credential_update_event( + &owner, + &spawner.public_key().to_hex(), + "sk-ant-oat01-abc", + ) + .unwrap(); + let event: nostr::Event = serde_json::from_str(&out.event_json).unwrap(); + // Ciphertext on the wire — the token must not be readable. + assert!(!event.content.as_str().contains("sk-ant-oat01-abc")); + let plain = nostr::nips::nip44::decrypt( + spawner.secret_key(), + &owner.public_key(), + event.content.as_str(), + ) + .unwrap(); + match serde_json::from_str::(&plain).unwrap() { + AttestationFrame::CredentialUpdate { credential } => { + assert_eq!(credential, "sk-ant-oat01-abc"); + } + other => panic!("wrong frame: {other:?}"), + } + } + + #[test] + fn decoding_an_ack_ignores_other_frames() { + let owner = Keys::generate(); + let spawner = Keys::generate(); + let encrypt = |frame: &AttestationFrame| { + nostr::nips::nip44::encrypt( + spawner.secret_key(), + &owner.public_key(), + serde_json::to_string(frame).unwrap(), + nostr::nips::nip44::Version::V2, + ) + .unwrap() + }; + let ack = encrypt(&AttestationFrame::CredentialAck { + accepted: true, + message: None, + }); + let decoded = decode_credential_ack_frame(&owner, &spawner.public_key(), &ack).unwrap(); + assert_eq!( + decoded, + Some(SpawnerCredentialAck { + accepted: true, + message: None + }) + ); + + let request = encrypted_request(&spawner, &owner, &Keys::generate().public_key().to_hex()); + assert_eq!( + decode_credential_ack_frame(&owner, &spawner.public_key(), &request).unwrap(), + None + ); + } + #[test] fn a_malformed_frame_is_refused_before_anything_is_signed() { let owner = Keys::generate(); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c879e26993..4e5b670742 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -719,6 +719,8 @@ pub fn run() { decode_spawner_attestation, respond_to_spawner_attestation, send_spawner_prompt_update, + send_spawner_credential_update, + decode_spawner_credential_ack, discover_acp_providers, discover_git_bash_prerequisite, install_acp_runtime, From ef453f7e6d8e7cb1e7be46aaa7a85e0d94517535 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 20:23:11 +0530 Subject: [PATCH 47/50] feat(desktop): credential update transport with encrypted ack waiter Co-Authored-By: Claude Fable 5 Signed-off-by: sid --- .../agents/spawnerAttestationStore.ts | 12 ++- .../agents/spawnerCredentialAcks.test.mjs | 46 +++++++++++ .../features/agents/spawnerCredentialAcks.ts | 76 +++++++++++++++++++ .../features/communities/useCommunityInit.ts | 2 + desktop/src/shared/api/spawnerRelay.ts | 28 ++++++- desktop/src/shared/api/tauriSpawner.ts | 34 +++++++++ 6 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 desktop/src/features/agents/spawnerCredentialAcks.test.mjs create mode 100644 desktop/src/features/agents/spawnerCredentialAcks.ts diff --git a/desktop/src/features/agents/spawnerAttestationStore.ts b/desktop/src/features/agents/spawnerAttestationStore.ts index 349af2adfc..0ba59be2d9 100644 --- a/desktop/src/features/agents/spawnerAttestationStore.ts +++ b/desktop/src/features/agents/spawnerAttestationStore.ts @@ -10,11 +10,13 @@ import { import { buildSpawnerAttestationResponse, decodeSpawnerAttestation, + decodeSpawnerCredentialAck, type SpawnerAttestationRequest, type SpawnerAttestationResponse, type SpawnerPromptMaterial, } from "@/shared/api/tauriSpawner"; import type { RelayEvent } from "@/shared/api/types"; +import { deliverSpawnerCredentialAck } from "./spawnerCredentialAcks"; import { isSpawnerTrusted, trustSpawner } from "./trustedSpawners"; /** @@ -168,7 +170,15 @@ async function handleAttestationEvent(event: RelayEvent): Promise { // worth surfacing. return; } - if (!request) return; + if (!request) { + try { + const ack = await decodeSpawnerCredentialAck(event.pubkey, event.content); + if (ack) deliverSpawnerCredentialAck(event.pubkey, ack); + } catch { + // Same expected-traffic rationale as the request decode above. + } + return; + } // The verified event author is the spawner. The frame body does not name its // own sender, so there is nothing to cross-check here — Rust decrypts with diff --git a/desktop/src/features/agents/spawnerCredentialAcks.test.mjs b/desktop/src/features/agents/spawnerCredentialAcks.test.mjs new file mode 100644 index 0000000000..f600a31e01 --- /dev/null +++ b/desktop/src/features/agents/spawnerCredentialAcks.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + deliverSpawnerCredentialAck, + resetSpawnerCredentialAcks, + waitForSpawnerCredentialAck, +} from "./spawnerCredentialAcks.ts"; + +const SPAWNER = "a".repeat(64); + +test.afterEach(() => { + resetSpawnerCredentialAcks(); +}); + +test("resolvesAWaiterWithTheNextAckFromThatSpawner", async () => { + const waiting = waitForSpawnerCredentialAck(SPAWNER, 1_000); + deliverSpawnerCredentialAck(SPAWNER, { accepted: true }); + assert.deepEqual(await waiting, { accepted: true }); +}); + +test("ignoresAcksFromADifferentSpawner", async () => { + const waiting = waitForSpawnerCredentialAck(SPAWNER, 30); + deliverSpawnerCredentialAck("b".repeat(64), { accepted: true }); + await assert.rejects(waiting, /did not confirm/); +}); + +test("rejectsOnTimeout", async () => { + const waiting = waitForSpawnerCredentialAck(SPAWNER, 20); + await assert.rejects(waiting, /did not confirm/); +}); + +test("acksQueueInOrderForConsecutiveWaiters", async () => { + const first = waitForSpawnerCredentialAck(SPAWNER, 1_000); + const second = waitForSpawnerCredentialAck(SPAWNER, 1_000); + deliverSpawnerCredentialAck(SPAWNER, { accepted: true }); + deliverSpawnerCredentialAck(SPAWNER, { accepted: false, message: "nope" }); + assert.deepEqual(await first, { accepted: true }); + assert.deepEqual(await second, { accepted: false, message: "nope" }); +}); + +test("resetDropsPendingWaitersByRejectingThem", async () => { + const waiting = waitForSpawnerCredentialAck(SPAWNER, 10_000); + resetSpawnerCredentialAcks(); + await assert.rejects(waiting, /Community changed/); +}); diff --git a/desktop/src/features/agents/spawnerCredentialAcks.ts b/desktop/src/features/agents/spawnerCredentialAcks.ts new file mode 100644 index 0000000000..cfd3bed738 --- /dev/null +++ b/desktop/src/features/agents/spawnerCredentialAcks.ts @@ -0,0 +1,76 @@ +import type { SpawnerCredentialAck } from "@/shared/api/tauriSpawner"; + +/** + * One-shot waiters for spawner credential acks, keyed by spawner pubkey. + * + * A `CredentialUpdate` has no world-readable echo to poll (deliberately — + * credentials never appear in any hash), so confirmation arrives as an + * encrypted `CredentialAck` frame on the same kind:24201 stream the + * attestation store already subscribes to. The store delivers decoded acks + * here; the credential card awaits one with a timeout. + * + * Module-level singleton for the same reason as the attestation store — the + * subscription outlives components — so it is reset in `resetCommunityState()`. + */ + +type Waiter = { + resolve: (ack: SpawnerCredentialAck) => void; + reject: (error: Error) => void; + timer: ReturnType; +}; + +const waiters = new Map(); + +/** Deliver a decoded ack to whoever is waiting on this spawner. */ +export function deliverSpawnerCredentialAck( + spawnerPubkey: string, + ack: SpawnerCredentialAck, +): void { + const queue = waiters.get(spawnerPubkey); + const waiter = queue?.shift(); + if (!waiter) return; + if (queue && queue.length === 0) waiters.delete(spawnerPubkey); + clearTimeout(waiter.timer); + waiter.resolve(ack); +} + +/** Await the next ack from `spawnerPubkey`, rejecting after `timeoutMs`. */ +export function waitForSpawnerCredentialAck( + spawnerPubkey: string, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + const waiter: Waiter = { + resolve, + reject, + timer: setTimeout(() => { + remove(spawnerPubkey, waiter); + reject(new Error("The server did not confirm the credential in time.")); + }, timeoutMs), + }; + const queue = waiters.get(spawnerPubkey) ?? []; + queue.push(waiter); + waiters.set(spawnerPubkey, queue); + }); +} + +function remove(spawnerPubkey: string, waiter: Waiter): void { + const queue = waiters.get(spawnerPubkey); + if (!queue) return; + const index = queue.indexOf(waiter); + if (index >= 0) queue.splice(index, 1); + if (queue.length === 0) waiters.delete(spawnerPubkey); +} + +/** Reject and drop every pending waiter. Wired into `resetCommunityState()`. */ +export function resetSpawnerCredentialAcks(): void { + for (const queue of waiters.values()) { + for (const waiter of queue) { + clearTimeout(waiter.timer); + waiter.reject( + new Error("Community changed before the server confirmed."), + ); + } + } + waiters.clear(); +} diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 2f774a59b4..a2ca9f8bd0 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -24,6 +24,7 @@ import { import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; import { resetSpawnerAttestationStore } from "@/features/agents/spawnerAttestationStore"; +import { resetSpawnerCredentialAcks } from "@/features/agents/spawnerCredentialAcks"; import { resetSpawnerDirectoryStore } from "@/features/agents/spawnerDirectoryStore"; import { resetSpawnerPromptUpdateQueue } from "@/features/agents/spawnerPromptUpdateQueue"; import { resetSpawnerStatusStore } from "@/features/agents/spawnerStatusStore"; @@ -60,6 +61,7 @@ function resetCommunityState({ // Attestation prompts are identity-scoped: showing one after a switch would // ask the user to sign with the wrong key. Status is community-scoped too. resetSpawnerAttestationStore(); + resetSpawnerCredentialAcks(); resetSpawnerStatusStore(); // So is the trust set that decides which prompts are skipped entirely. A // spawner approved under the old identity must not silently auto-sign under diff --git a/desktop/src/shared/api/spawnerRelay.ts b/desktop/src/shared/api/spawnerRelay.ts index 47bcbf11b5..a7f27b54e8 100644 --- a/desktop/src/shared/api/spawnerRelay.ts +++ b/desktop/src/shared/api/spawnerRelay.ts @@ -1,5 +1,8 @@ import { signRelayEvent } from "@/shared/api/tauri"; -import { buildSpawnerPromptUpdate } from "@/shared/api/tauriSpawner"; +import { + buildSpawnerCredentialUpdate, + buildSpawnerPromptUpdate, +} from "@/shared/api/tauriSpawner"; import type { SpawnerPromptMaterial } from "@/shared/api/tauriSpawner"; import type { RelayEvent } from "@/shared/api/types"; import { @@ -109,6 +112,8 @@ export type SpawnerAgentStatus = { restartCount: number; /** Hash of the prompt material last acknowledged by this agent, when reported. */ promptHash?: string; + /** True when the spawner holds this agent stopped awaiting an owner credential. */ + needsCredential: boolean; }; /** Inbound author gate for a spawned agent, mirroring `RespondTo` in Rust. */ @@ -298,6 +303,26 @@ export async function sendSpawnerPromptUpdate(input: { return promptHash; } +/** + * Build and publish an owner credential update over the WebSocket — same + * ephemeral-kind routing rationale as `sendSpawnerPromptUpdate`. Deliberately + * no persistent queue: a queued plaintext credential on disk is exactly what + * this feature exists to avoid. Confirmation arrives as an encrypted ack; see + * `waitForSpawnerCredentialAck`. + */ +export async function sendSpawnerCredentialUpdate(input: { + spawnerPubkey: string; + credential: string; +}): Promise { + const { event } = await buildSpawnerCredentialUpdate(input); + await relayClient.preconnect(); + await relayClient.publishEvent( + event, + "Timed out sending the credential.", + "Failed to send the credential.", + ); +} + /** Parse a kind:30179 content body, returning null when it is unusable. */ export function parseSpawnerStatus(content: string): SpawnerAgentStatus | null { if (!content.trim()) return null; @@ -313,6 +338,7 @@ export function parseSpawnerStatus(content: string): SpawnerAgentStatus | null { restartCount: typeof raw.restart_count === "number" ? raw.restart_count : 0, promptHash: asOptionalString(raw.prompt_hash), + needsCredential: raw.needs_credential === true, }; } catch { return null; diff --git a/desktop/src/shared/api/tauriSpawner.ts b/desktop/src/shared/api/tauriSpawner.ts index e2cc95010e..940237be70 100644 --- a/desktop/src/shared/api/tauriSpawner.ts +++ b/desktop/src/shared/api/tauriSpawner.ts @@ -123,6 +123,40 @@ type RawSpawnerPromptUpdate = { promptHash: string; }; +/** A decoded credential ack from a spawner. */ +export type SpawnerCredentialAck = { + accepted: boolean; + message?: string | null; +}; + +/** + * Build a signed credential update for a spawner. The token goes straight to + * Rust, which encrypts it into the returned event — it is never persisted on + * this device. An empty `credential` clears the spawner-side token. + */ +export async function buildSpawnerCredentialUpdate(input: { + spawnerPubkey: string; + credential: string; +}): Promise<{ event: RelayEvent }> { + const raw = await invokeTauri<{ eventJson: string }>( + "send_spawner_credential_update", + { spawnerPubkey: input.spawnerPubkey, credential: input.credential }, + ); + return { event: JSON.parse(raw.eventJson) as RelayEvent }; +} + +/** Decode an inbound 24201 frame as a credential ack; null for other frames. */ +export async function decodeSpawnerCredentialAck( + spawnerPubkey: string, + encryptedContent: string, +): Promise { + const result = await invokeTauri( + "decode_spawner_credential_ack", + { spawnerPubkey, encryptedContent }, + ); + return result ?? null; +} + /** * The signed answer, plus whether it handed over an existing agent's key. * From 67ce0a14b846f1beebaea5bd1034174ccf6957b8 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 20:26:39 +0530 Subject: [PATCH 48/50] feat(desktop): spawner credential card and needs-credential badge Co-Authored-By: Claude Fable 5 Signed-off-by: sid --- .../agents/ui/ServerAgentsSection.tsx | 15 ++- .../agents/ui/SpawnerCredentialCard.tsx | 115 ++++++++++++++++++ .../ui/spawnerCredentialSubmit.test.mjs | 82 +++++++++++++ .../agents/ui/spawnerCredentialSubmit.ts | 56 +++++++++ desktop/src/shared/api/spawnerRelay.test.mjs | 8 ++ 5 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 desktop/src/features/agents/ui/SpawnerCredentialCard.tsx create mode 100644 desktop/src/features/agents/ui/spawnerCredentialSubmit.test.mjs create mode 100644 desktop/src/features/agents/ui/spawnerCredentialSubmit.ts diff --git a/desktop/src/features/agents/ui/ServerAgentsSection.tsx b/desktop/src/features/agents/ui/ServerAgentsSection.tsx index 1ab3327095..f140cef0f3 100644 --- a/desktop/src/features/agents/ui/ServerAgentsSection.tsx +++ b/desktop/src/features/agents/ui/ServerAgentsSection.tsx @@ -28,6 +28,7 @@ import { useSpawnerDirectory } from "../spawnerDirectoryStore"; import { addSpawner, removeSpawner, slugFromName } from "../spawnerPreference"; import { useServerAgents, type ServerAgent } from "../useServerAgents"; import { shortenPubkey } from "./SpawnerAttestationDialog"; +import { SpawnerCredentialCard } from "./SpawnerCredentialCard"; /** Human-readable label and tone for a reconciliation phase. */ export function phaseLabel(phase: SpawnPhase): { @@ -227,6 +228,10 @@ export function ServerAgentsSection({ personas }: ServerAgentsSectionProps) { ))} )} +
    ); })} @@ -299,7 +304,10 @@ function ServerAgentRow({ onToggle: (enabled: boolean) => void; onRemove: () => void; }) { - const { label, variant } = phaseLabel(agent.status.phase); + // The phase is `stopped`, but *why* is the useful part. + const { label, variant } = agent.status.needsCredential + ? { label: "Needs credential", variant: "warning" as const } + : phaseLabel(agent.status.phase); const isStopped = agent.status.phase === "stopped"; return ( @@ -320,6 +328,11 @@ function ServerAgentRow({ {agent.status.error}

    ) : null} + {agent.status.needsCredential ? ( +

    + Add your Claude credential below to start this agent. +

    + ) : null} {agent.status.restartCount > 0 ? (

    {agent.status.restartCount} failed start diff --git a/desktop/src/features/agents/ui/SpawnerCredentialCard.tsx b/desktop/src/features/agents/ui/SpawnerCredentialCard.tsx new file mode 100644 index 0000000000..412016c77f --- /dev/null +++ b/desktop/src/features/agents/ui/SpawnerCredentialCard.tsx @@ -0,0 +1,115 @@ +import { Check, CircleAlert } from "lucide-react"; +import React from "react"; + +import { sendSpawnerCredentialUpdate } from "@/shared/api/spawnerRelay"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { waitForSpawnerCredentialAck } from "../spawnerCredentialAcks"; +import { + submitSpawnerCredential, + type CredentialSubmitResult, +} from "./spawnerCredentialSubmit"; + +/** How long to wait for the spawner's encrypted ack before reporting failure. */ +const ACK_TIMEOUT_MS = 15_000; + +type Status = { kind: "idle" } | { kind: "sending" } | CredentialSubmitResult; + +/** + * Write-only entry for the owner's Claude credential on one spawner. + * + * The token is sent straight to Rust for encryption and never stored on this + * device — the spawner is the source of truth, which is why nothing here reads + * a saved value back. Server agents on this spawner do not run until the owner + * provisions a token (the spawner reports `needs_credential` on their status). + */ +export function SpawnerCredentialCard({ + spawnerPubkey, + spawnerName, +}: { + spawnerPubkey: string; + spawnerName: string; +}) { + const [value, setValue] = React.useState(""); + const [status, setStatus] = React.useState({ kind: "idle" }); + const inputId = React.useId(); + + const submit = async (credential: string) => { + setStatus({ kind: "sending" }); + const result = await submitSpawnerCredential( + { + send: sendSpawnerCredentialUpdate, + waitForAck: waitForSpawnerCredentialAck, + }, + spawnerPubkey, + credential, + ACK_TIMEOUT_MS, + ); + setStatus(result); + // Write-only: the field empties only after a confirmed save. + if (result.kind === "saved" && !result.cleared) setValue(""); + }; + + const sending = status.kind === "sending"; + + return ( +

    + +

    + Agents you run on {spawnerName} use your own token. Paste a Claude Code + OAuth token (sk-ant-oat…) or an Anthropic API key. It is sent encrypted + to the server and never stored on this device. +

    +
    + setValue(event.target.value)} + placeholder="sk-ant-…" + type="password" + value={value} + /> + + +
    + {status.kind === "saved" ? ( +

    + + {status.cleared + ? "Credential cleared. Your agents here will stop." + : "Provisioned. Your agents here are restarting with it."} +

    + ) : null} + {status.kind === "error" ? ( +

    + + {status.message} +

    + ) : null} + {sending ? ( +

    Waiting for the server…

    + ) : null} +
    + ); +} diff --git a/desktop/src/features/agents/ui/spawnerCredentialSubmit.test.mjs b/desktop/src/features/agents/ui/spawnerCredentialSubmit.test.mjs new file mode 100644 index 0000000000..26a55270d2 --- /dev/null +++ b/desktop/src/features/agents/ui/spawnerCredentialSubmit.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { submitSpawnerCredential } from "./spawnerCredentialSubmit.ts"; + +const SPAWNER = "a".repeat(64); + +test("savesWhenTheSpawnerAcksAcceptance", async () => { + const calls = []; + const result = await submitSpawnerCredential( + { + send: async (input) => { + calls.push(input); + }, + waitForAck: async () => ({ accepted: true }), + }, + SPAWNER, + "sk-ant-oat01-x", + 1_000, + ); + assert.deepEqual(result, { kind: "saved", cleared: false }); + assert.deepEqual(calls, [ + { spawnerPubkey: SPAWNER, credential: "sk-ant-oat01-x" }, + ]); +}); + +test("anEmptyCredentialReportsCleared", async () => { + const result = await submitSpawnerCredential( + { + send: async () => {}, + waitForAck: async () => ({ accepted: true }), + }, + SPAWNER, + "", + 1_000, + ); + assert.deepEqual(result, { kind: "saved", cleared: true }); +}); + +test("aRejectingAckSurfacesItsMessage", async () => { + const result = await submitSpawnerCredential( + { + send: async () => {}, + waitForAck: async () => ({ accepted: false, message: "disk full" }), + }, + SPAWNER, + "sk-ant-api03-x", + 1_000, + ); + assert.deepEqual(result, { kind: "error", message: "disk full" }); +}); + +test("aTimedOutAckBecomesAnErrorResult", async () => { + const result = await submitSpawnerCredential( + { + send: async () => {}, + waitForAck: () => + Promise.reject( + new Error("The server did not confirm the credential in time."), + ), + }, + SPAWNER, + "sk-ant-oat01-x", + 1_000, + ); + assert.equal(result.kind, "error"); + assert.match(result.message, /did not confirm/); +}); + +test("aFailedSendDoesNotLeaveAnUnhandledAckRejection", async () => { + const result = await submitSpawnerCredential( + { + send: () => Promise.reject(new Error("relay unreachable")), + waitForAck: () => Promise.reject(new Error("timeout")), + }, + SPAWNER, + "sk-ant-oat01-x", + 1_000, + ); + assert.equal(result.kind, "error"); + assert.match(result.message, /relay unreachable/); +}); diff --git a/desktop/src/features/agents/ui/spawnerCredentialSubmit.ts b/desktop/src/features/agents/ui/spawnerCredentialSubmit.ts new file mode 100644 index 0000000000..3c5f28dd87 --- /dev/null +++ b/desktop/src/features/agents/ui/spawnerCredentialSubmit.ts @@ -0,0 +1,56 @@ +import type { SpawnerCredentialAck } from "@/shared/api/tauriSpawner"; + +/** Outcome of one credential save/clear round-trip, for the card to render. */ +export type CredentialSubmitResult = + | { kind: "saved"; cleared: boolean } + | { kind: "error"; message: string }; + +/** What `submitSpawnerCredential` needs from the transport layer. */ +export type CredentialSubmitDeps = { + /** Publish the encrypted update (see `sendSpawnerCredentialUpdate`). */ + send: (input: { spawnerPubkey: string; credential: string }) => Promise; + /** Await the spawner's encrypted ack (see `waitForSpawnerCredentialAck`). */ + waitForAck: ( + spawnerPubkey: string, + timeoutMs: number, + ) => Promise; +}; + +/** + * Send a credential (or an empty string to clear) and wait for the spawner's + * ack. + * + * The waiter is registered before the send so an ack that races the publish + * round-trip cannot be missed. Errors are folded into the result rather than + * thrown, so the card's render logic is a plain switch on `kind`. + */ +export async function submitSpawnerCredential( + deps: CredentialSubmitDeps, + spawnerPubkey: string, + credential: string, + timeoutMs: number, +): Promise { + try { + const acked = deps.waitForAck(spawnerPubkey, timeoutMs); + // A rejection from the waiter (timeout, community switch) must not become + // an unhandled rejection while the send itself is failing. + acked.catch(() => {}); + await deps.send({ spawnerPubkey, credential }); + const ack = await acked; + if (!ack.accepted) { + return { + kind: "error", + message: ack.message || "The server rejected the credential.", + }; + } + return { kind: "saved", cleared: credential === "" }; + } catch (error) { + return { + kind: "error", + message: + error instanceof Error + ? error.message + : "Failed to send the credential.", + }; + } +} diff --git a/desktop/src/shared/api/spawnerRelay.test.mjs b/desktop/src/shared/api/spawnerRelay.test.mjs index 172fcfb5cc..3bb40c9e67 100644 --- a/desktop/src/shared/api/spawnerRelay.test.mjs +++ b/desktop/src/shared/api/spawnerRelay.test.mjs @@ -34,9 +34,17 @@ test("parsesARunningStatusFromTheSnakeCaseWire", () => { error: undefined, restartCount: 0, promptHash: undefined, + needsCredential: false, }); }); +test("parsesNeedsCredentialWhenTheSpawnerReportsIt", () => { + const status = parseSpawnerStatus( + JSON.stringify({ phase: "stopped", needs_credential: true }), + ); + assert.equal(status.needsCredential, true); +}); + test("parsesAFailedStatusWithItsError", () => { const status = parseSpawnerStatus( JSON.stringify({ From 351c1321bd110c00f78370d47ec6d4a9a82c7ac4 Mon Sep 17 00:00:00 2001 From: sid Date: Mon, 27 Jul 2026 20:38:10 +0530 Subject: [PATCH 49/50] test(desktop): screenshots for spawner credential card and needs-credential badge Co-Authored-By: Claude Fable 5 Signed-off-by: sid --- desktop/src/testing/e2eBridge.ts | 45 +++++++++++++++ .../server-agent-editing-screenshots.spec.ts | 57 +++++++++++++++++++ desktop/tests/helpers/bridge.ts | 15 +++++ 3 files changed, 117 insertions(+) diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 8eeef447f2..755eb0da16 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -42,6 +42,7 @@ import { KIND_MEMBER_REMOVED_NOTIFICATION, KIND_REPO_ANNOUNCEMENT, KIND_REPO_STATE, + KIND_SPAWNER_AGENT_STATUS, KIND_SPAWNER_ANNOUNCEMENT, KIND_STREAM_MESSAGE_EDIT, KIND_SYSTEM_MESSAGE, @@ -105,6 +106,21 @@ type MockSpawnerAnnouncementSeed = { createdAt?: number; }; +/** + * A kind:30179 agent status served to the live status subscription. + * + * `content` is the raw snake_case status body a real spawner publishes (e.g. + * `{ phase: "stopped", needs_credential: true }`), authored by + * `spawnerPubkey` with the `d` tag set to `slug` — matching how the status + * store keys entries by `(event.pubkey, slug)`. + */ +type MockSpawnerStatusSeed = { + spawnerPubkey: string; + slug: string; + content: Record; + createdAt?: number; +}; + type MockManagedAgentRuntimeSeed = { pubkey: string; relayUrl: string; @@ -234,6 +250,8 @@ type E2eConfig = { managedAgentRuntimes?: MockManagedAgentRuntimeSeed[]; /** kind:10180 announcements replayed to the spawner-directory subscription. */ spawnerAnnouncements?: MockSpawnerAnnouncementSeed[]; + /** kind:30179 statuses replayed to the spawner-status subscription. */ + spawnerStatuses?: MockSpawnerStatusSeed[]; personas?: MockPersonaSeed[]; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; @@ -926,6 +944,27 @@ function createMockSpawnerAnnouncementEvents(): RelayEvent[] { ); } +/** + * kind:30179 statuses for this owner's server agents, from + * `mock.spawnerStatuses`. Authored by the seeded spawner pubkey — the status + * store keys entries by `(event.pubkey, d tag)` — with the owner `p`-tagged the + * way a real spawner addresses status to the spec author. + */ +function createMockSpawnerStatusEvents(): RelayEvent[] { + return (getConfig()?.mock?.spawnerStatuses ?? []).map((seed) => + createMockEvent( + KIND_SPAWNER_AGENT_STATUS, + JSON.stringify(seed.content), + [ + ["d", seed.slug], + ["p", getMockMemberPubkey(getConfig())], + ], + seed.spawnerPubkey, + seed.createdAt ?? Math.floor(Date.now() / 1000), + ), + ); +} + /** * Per-user custom emoji sets (kind:30030) the mock WS serves for * `listCustomEmoji` REQs. The community palette is the client-side UNION of @@ -8836,6 +8875,12 @@ function sendToMockSocket(args: { sendWsText(socket.handler, ["EVENT", subId, event]); } } + // Same reasoning for agent status: a live REQ with no channel scope. + if (kinds.has(KIND_SPAWNER_AGENT_STATUS)) { + for (const event of createMockSpawnerStatusEvents()) { + sendWsText(socket.handler, ["EVENT", subId, event]); + } + } sendWsText(socket.handler, ["EOSE", subId]); return; } diff --git a/desktop/tests/e2e/server-agent-editing-screenshots.spec.ts b/desktop/tests/e2e/server-agent-editing-screenshots.spec.ts index 41f41c0728..5d00cfcd86 100644 --- a/desktop/tests/e2e/server-agent-editing-screenshots.spec.ts +++ b/desktop/tests/e2e/server-agent-editing-screenshots.spec.ts @@ -72,6 +72,21 @@ const MANAGED_AGENTS = [ }, ]; +// A server agent held stopped because its owner has not provisioned a token. +const HELD_AGENT_SLUG = "prod-runner"; + +const SPAWNER_STATUSES = [ + { + spawnerPubkey: SPAWNER_WITH_CATALOG, + slug: HELD_AGENT_SLUG, + content: { + phase: "stopped", + agent_pubkey: CATALOG_AGENT_PUBKEY, + needs_credential: true, + }, + }, +]; + async function install(page: import("@playwright/test").Page) { // Seeded before the bridge installs: the section reads the store on mount and // the bridge is what triggers mount. @@ -87,6 +102,7 @@ async function install(page: import("@playwright/test").Page) { await installMockBridge(page, { managedAgents: MANAGED_AGENTS, spawnerAnnouncements: SPAWNER_ANNOUNCEMENTS, + spawnerStatuses: SPAWNER_STATUSES, }); } @@ -195,4 +211,45 @@ test.describe("server-aware agent editing", () => { path: `${SCREENSHOT_DIR}/03-no-catalog-fallback.png`, }); }); + + test("offers a write-only credential card per connected spawner", async ({ + page, + }) => { + await install(page); + await gotoAgents(page); + + const card = page.getByTestId("spawner-credential-card").first(); + await expect(card).toBeVisible({ timeout: 15_000 }); + await expect(card).toContainText("Your Claude credential"); + await expect(card).toContainText("never stored on this device"); + // Password-type input: the token is never rendered back. + await expect( + card.getByTestId("spawner-credential-input"), + ).toHaveAttribute("type", "password"); + + await waitForAnimations(page); + await card.screenshot({ path: `${SCREENSHOT_DIR}/04-credential-card.png` }); + }); + + test("flags an agent held for a missing owner credential", async ({ + page, + }) => { + await install(page); + await gotoAgents(page); + + const row = page + .locator("li") + .filter({ hasText: "Needs credential" }) + .first(); + await expect(row).toBeVisible({ timeout: 15_000 }); + await expect(row).toContainText(HELD_AGENT_SLUG); + await expect(row).toContainText( + "Add your Claude credential below to start this agent.", + ); + + await waitForAnimations(page); + await row.screenshot({ + path: `${SCREENSHOT_DIR}/05-needs-credential-badge.png`, + }); + }); }); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 04e532d091..a1f3355d31 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -79,6 +79,19 @@ type MockSpawnerAnnouncementSeed = { createdAt?: number; }; +/** + * A kind:30179 agent status replayed to the status subscription. `content` is + * the raw snake_case status body (e.g. `{ phase: "stopped", + * needs_credential: true }`), authored by `spawnerPubkey` with `slug` as the + * `d` tag. + */ +type MockSpawnerStatusSeed = { + spawnerPubkey: string; + slug: string; + content: Record; + createdAt?: number; +}; + type MockSearchProfileSeed = { pubkey: string; displayName: string | null; @@ -232,6 +245,8 @@ type MockBridgeOptions = { }>; /** kind:10180 announcements served to the spawner-directory subscription. */ spawnerAnnouncements?: MockSpawnerAnnouncementSeed[]; + /** kind:30179 statuses served to the spawner-status subscription. */ + spawnerStatuses?: MockSpawnerStatusSeed[]; personas?: MockPersonaSeed[]; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; From 69ee079fde2458bdc3617912123718c377843f9a Mon Sep 17 00:00:00 2001 From: sid Date: Tue, 28 Jul 2026 00:51:39 +0530 Subject: [PATCH 50/50] refactor(desktop): drop manual spawner connect-by-pubkey card Spawners announce themselves; the manual pubkey entry card and its screenshot coverage are no longer needed. Signed-off-by: sid --- .../agents/ui/ServerAgentsSection.tsx | 32 ------------------- .../e2e/server-agents-screenshots.spec.ts | 28 ++-------------- 2 files changed, 3 insertions(+), 57 deletions(-) diff --git a/desktop/src/features/agents/ui/ServerAgentsSection.tsx b/desktop/src/features/agents/ui/ServerAgentsSection.tsx index f140cef0f3..eea39de7c8 100644 --- a/desktop/src/features/agents/ui/ServerAgentsSection.tsx +++ b/desktop/src/features/agents/ui/ServerAgentsSection.tsx @@ -17,7 +17,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; -import { Input } from "@/shared/ui/input"; import { SectionHeader } from "@/shared/ui/PageHeader"; import { sameLocation, @@ -235,8 +234,6 @@ export function ServerAgentsSection({ personas }: ServerAgentsSectionProps) {
    ); })} - - ); } @@ -479,35 +476,6 @@ function DefaultLocationToggle({ spawner }: { spawner: string }) { ); } -/** Connect to a spawner by pubkey, for one that has not announced itself. */ -function SpawnerConnectCard() { - const [value, setValue] = React.useState(""); - - return ( -
    - setValue(event.target.value)} - placeholder="Spawner public key (64 hex characters)" - value={value} - /> - -
    - ); -} - function errorMessage(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback; } diff --git a/desktop/tests/e2e/server-agents-screenshots.spec.ts b/desktop/tests/e2e/server-agents-screenshots.spec.ts index 43b6c26154..e3bce54436 100644 --- a/desktop/tests/e2e/server-agents-screenshots.spec.ts +++ b/desktop/tests/e2e/server-agents-screenshots.spec.ts @@ -52,31 +52,11 @@ test.describe("server agents section", () => { await expect(section).toContainText( "does not have to be the relay machine", ); - await expect( - section.getByPlaceholder("Spawner public key (64 hex characters)"), - ).toBeVisible(); await waitForAnimations(page); await section.screenshot({ path: `${SCREENSHOT_DIR}/01-setup-card.png` }); }); - test("rejects a malformed spawner public key", async ({ page }) => { - await installMockBridge(page); - await gotoAgents(page); - - const section = page.locator("section", { hasText: "Server agents" }); - await section - .getByPlaceholder("Spawner public key (64 hex characters)") - .fill("not-a-pubkey"); - await section.getByRole("button", { name: "Connect" }).click(); - - // Fails closed: the section must stay on the setup card rather than storing - // a value the relay could never route to. - await expect( - section.getByPlaceholder("Spawner public key (64 hex characters)"), - ).toBeVisible(); - }); - test("renders the configured state with a deploy menu", async ({ page }) => { // localStorage must be seeded BEFORE the bridge installs — React reads the // store on mount and the bridge is what triggers mount. @@ -152,11 +132,9 @@ test.describe("server agents section", () => { const section = page.locator("section", { hasText: "Server agents" }); await section.getByRole("button", { name: "Disconnect" }).click(); - // Disconnecting is local only: the connect field stays, and the deploy - // action disappears because there is nowhere to deploy to. - await expect( - section.getByPlaceholder("Spawner public key (64 hex characters)"), - ).toBeVisible(); + // Disconnecting is local only: the section falls back to its empty state, + // and the deploy action disappears because there is nowhere to deploy to. + await expect(section).toContainText("keep working when Buzz is closed"); await expect(section).not.toContainText("Disconnect"); }); });