diff --git a/.gitignore b/.gitignore index 65ddcaf1c4..9764c43b55 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,6 @@ identity.key # Helm dependency tarballs — regenerable from Chart.lock via `helm dependency build` deploy/charts/*/charts/*.tgz + +# Personal GUI-session launcher (see AGENTS.md); kept out of the repo. +run-dev.command diff --git a/AGENTS.md b/AGENTS.md index cb4843a9ef..df3c22796a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -485,6 +485,11 @@ reconnects preserve pending avatar verification work): - `resetAgentObserverStore()` — agent observer relay store - `resetActiveAgentTurnsStore()` — active agent turn timers - `resetAgentWorkingSignal()` — agent working indicator signal +- `resetSpawnerAttestationStore()` — pending spawner attestation prompts +- `resetSpawnerStatusStore()` — server-agent status rows +- `resetSpawnerDirectoryStore()` — announced spawner directory +- `resetTrustedSpawners()` — approved-spawner set (identity-scoped: a spawner + trusted under the old identity must not auto-sign under the new one) - `resetAvatarProfileSync()` — pending verified-avatar profile writes - `resetAvatarPresentations()` — avatar probes, previews, and Retry toasts - `resetSidebarRelayConnectionCardState()` — sidebar relay card dismiss state diff --git a/Cargo.lock b/Cargo.lock index 1a63b4f425..c7e28edc49 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" @@ -1215,6 +1258,7 @@ dependencies = [ "nostr", "serde", "serde_json", + "sha2 0.11.0", "thiserror 2.0.18", "uuid", ] @@ -1230,6 +1274,31 @@ 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", + "rustls", + "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 +2241,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 +3590,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 +3675,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/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index afec52305a..3196471385 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,57 @@ 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. +/// +/// Publishing an empty-content replacement is the signal to tear the agent +/// down. A NIP-09 deletion does not work here: it leaves nothing to fan out, so +/// a spawner that is already connected never learns the spec is gone. +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 applies no author gate: anyone may publish this kind, and a client +/// must therefore match a status to the spawner its own spec named rather than +/// trusting the kind alone. Nothing is granted by a status event — access comes +/// only from the owner's NIP-OA signature in a +/// [`KIND_SPAWNER_ATTESTATION`] exchange — so a forged one misreports a badge +/// and nothing more. +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 +486,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 +673,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 +875,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 +1022,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/Cargo.toml b/crates/buzz-sdk/Cargo.toml index da8fd0bd0e..eaffb02c4c 100644 --- a/crates/buzz-sdk/Cargo.toml +++ b/crates/buzz-sdk/Cargo.toml @@ -14,3 +14,4 @@ uuid = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +sha2 = { workspace = true } 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..932b8ea1cb --- /dev/null +++ b/crates/buzz-sdk/src/spawner.rs @@ -0,0 +1,1257 @@ +//! 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; + +/// Maximum byte length of an owner credential in a `CredentialUpdate` frame. +pub const MAX_CREDENTIAL_BYTES: usize = 512; + +// --------------------------------------------------------------------------- +// 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, + /// Providers/models this host can run, so a client scopes its picker to + /// what the server actually supports. Self-reported, like every field here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ai: Option>, +} + +/// One inference provider a spawner host can run, with its model ids. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpawnerAiProvider { + /// Provider id, e.g. "anthropic". + pub id: String, + /// Model ids this host can run for the provider. + #[serde(default)] + pub models: Vec, +} + +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, + /// Hash of the prompt material this agent is running with (see + /// [`PromptMaterial::hash`]), so a client can tell whether a pushed + /// 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> { + 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() + } + + /// Lowercase sha256 hex of this material's JSON serialization. + /// + /// 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(); + let mut h = Sha256::new(); + h.update(json.as_bytes()); + h.finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + } +} + +/// 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, + }, + /// 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 { .. } => "", + } + } + + /// 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 { .. } + | 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. + 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, + prompt_hash: None, + needs_credential: false, + }; + 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, + prompt_hash: None, + needs_credential: false, + }; + 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()) + ); + } + + fn sample_announcement() -> SpawnerAnnouncement { + 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), + ai: None, + } + } + + #[test] + fn announcement_round_trips_through_an_event() { + let keys = Keys::generate(); + let announcement = sample_announcement(); + let event = build_spawner_announcement(&announcement) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + assert_eq!(announcement_from_event(&event).unwrap(), announcement); + } + + #[test] + fn announcement_ai_catalog_round_trips() { + let mut a = sample_announcement(); + a.ai = Some(vec![SpawnerAiProvider { + id: "anthropic".into(), + models: vec!["claude-opus-5".into(), "claude-sonnet-5".into()], + }]); + let json = serde_json::to_string(&a).unwrap(); + let back: SpawnerAnnouncement = serde_json::from_str(&json).unwrap(); + assert_eq!(back.ai, a.ai); + // Old announcements without the field still parse. + let legacy: SpawnerAnnouncement = + serde_json::from_str(r#"{"name":"x","max_agents":1,"agents_running":0}"#).unwrap(); + assert!(legacy.ai.is_none()); + } + + #[test] + fn prompt_material_hash_is_stable_and_content_sensitive() { + let a = PromptMaterial { + model: Some("m1".into()), + ..Default::default() + }; + let b = PromptMaterial { + model: Some("m1".into()), + ..Default::default() + }; + let c = PromptMaterial { + model: Some("m2".into()), + ..Default::default() + }; + assert_eq!(a.hash(), b.hash()); + assert_ne!(a.hash(), c.hash()); + assert_eq!(a.hash().len(), 64); + } + + #[test] + fn status_prompt_hash_round_trips() { + let s = SpawnerAgentStatus { + phase: SpawnPhase::Running, + agent_pubkey: None, + spec_hash: None, + 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(); + assert_eq!(back.prompt_hash, s.prompt_hash); + } + + #[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, + ai: 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, + ai: 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 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 { + 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..99ea906f78 --- /dev/null +++ b/crates/buzz-spawner/Cargo.toml @@ -0,0 +1,42 @@ +[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" +# Explicit rustls dep with the ring provider, so the binary can install a +# process-level CryptoProvider. The workspace unifies both ring and aws-lc-rs +# features, leaving rustls unable to auto-select one; without an explicit +# install every wss:// relay connection panics. Same rationale as buzz-cli, +# buzz-acp, and buzz-admin. +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } + +[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..3b67c396bb --- /dev/null +++ b/crates/buzz-spawner/examples/owner_sim.rs @@ -0,0 +1,445 @@ +//! 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 +//! ``` +//! +//! 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}; + +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, + PromptMaterial, RespondTo, SpawnPhase, 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(); + // 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, + 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; + }; + // 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() + .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); + } + + 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, + 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, + verify_prompt_update: 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 verify_prompt_update = 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, + "--verify-prompt-update" => verify_prompt_update = true, + other => bail!("unknown argument {other}"), + } + } + + Ok(Self { + relay, + spawner: spawner.context("--spawner is required")?, + owner_nsec, + slug, + name, + disabled, + delete, + persona, + share_persona, + verify_prompt_update, + }) + } +} 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 new file mode 100644 index 0000000000..7b88ac04fb --- /dev/null +++ b/crates/buzz-spawner/src/attestation.rs @@ -0,0 +1,401 @@ +//! 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::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, + 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, + carried_team_instructions: 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..4dea2c10b6 --- /dev/null +++ b/crates/buzz-spawner/src/config.rs @@ -0,0 +1,266 @@ +//! Environment-driven configuration for the spawner daemon. + +use std::{path::PathBuf, time::Duration}; + +use anyhow::{bail, Context, Result}; +use buzz_sdk::spawner::SpawnerAiProvider; +use nostr::Keys; +use tracing::warn; + +/// 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)>, + /// AI providers/models this spawner advertises in its announcement, so a + /// picker can offer a menu instead of the operator having to know in + /// advance what to type. Parsed from `BUZZ_SPAWNER_AI_CATALOG`. + pub ai_catalog: Option>, +} + +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(), + )?, + ai_catalog: parse_ai_catalog(non_empty_env("BUZZ_SPAWNER_AI_CATALOG")), + }) + } +} + +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) +} + +/// Parse `BUZZ_SPAWNER_AI_CATALOG`, a JSON array of `SpawnerAiProvider` +/// advertised in this spawner's announcement. +/// +/// Malformed JSON is logged and dropped rather than failing startup — the +/// catalog is advertisement, not a correctness requirement, so a typo here +/// should not take the whole spawner down. +fn parse_ai_catalog(raw: Option) -> Option> { + let raw = raw?; + match serde_json::from_str(&raw) { + Ok(providers) => Some(providers), + Err(e) => { + warn!("BUZZ_SPAWNER_AI_CATALOG is not valid JSON: {e}"); + None + } + } +} + +#[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()); + } + + #[test] + fn ai_catalog_parses_from_env_json() { + let parsed = parse_ai_catalog(Some( + r#"[{"id":"anthropic","models":["claude-opus-5"]}]"#.into(), + )); + let providers = parsed.unwrap(); + assert_eq!(providers[0].id, "anthropic"); + assert_eq!(providers[0].models, vec!["claude-opus-5"]); + assert!(parse_ai_catalog(None).is_none()); + assert!(parse_ai_catalog(Some("not json".into())).is_none()); + } +} diff --git a/crates/buzz-spawner/src/container.rs b/crates/buzz-spawner/src/container.rs new file mode 100644 index 0000000000..89d70d2095 --- /dev/null +++ b/crates/buzz-spawner/src/container.rs @@ -0,0 +1,506 @@ +//! 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 = match self + .docker + .create_container( + Some(CreateContainerOptions { + name: Some(spec.name.clone()), + ..Default::default() + }), + body.clone(), + ) + .await + { + Ok(created) => created, + // The name can be squatted by a container a previous spawner + // The name can be squatted by a container we created and then lost + // track of (a crash between create and the store write) — self-heal + // by removing it, but only after the labels confirm it is an agent + // container belonging to this spawner. + Err(e) if is_name_conflict(&e) => { + self.remove_name_squatter(&spec.name, &spec.spawner_pubkey) + .await + .with_context(|| { + format!( + "container name {} is taken and could not be reclaimed", + spec.name + ) + })?; + self.docker + .create_container( + Some(CreateContainerOptions { + name: Some(spec.name.clone()), + ..Default::default() + }), + body, + ) + .await + .with_context(|| { + format!( + "failed to create container {} after reclaiming its name", + spec.name + ) + })? + } + Err(e) => { + return Err(e).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(()) + } + + /// Remove an agent container squatting on `name`. + /// + /// Two gates, both required. The container must carry the `com.buzz.agent` + /// label, so an operator's unrelated container of the same name is never + /// touched. And its `com.buzz.spawner` label must name *us* — a container + /// belonging to another spawner identity is left alone even though the name + /// blocks us, because that spawner may be alive and still serving it, and + /// force-removing a live agent to free a name is never the right trade. + /// + /// What that leaves is the case this exists for: a leftover from a crash + /// between `create` and the store write, under our own identity. Names + /// carry the spawner prefix (see [`AgentRecord::container_name`]), so a + /// conflict from any other identity means a genuine collision an operator + /// needs to see rather than something to silently resolve. + /// + /// The workspace volume is a named mount and survives the removal. + async fn remove_name_squatter(&self, name: &str, spawner_pubkey: &str) -> Result<()> { + use bollard::query_parameters::InspectContainerOptions; + + let squatter = self + .docker + .inspect_container(name, None::) + .await + .with_context(|| format!("failed to inspect conflicting container {name}"))?; + let labels = squatter.config.as_ref().and_then(|c| c.labels.as_ref()); + if !labels.is_some_and(|labels| labels.contains_key(AGENT_LABEL)) { + anyhow::bail!( + "conflicting container {name} does not carry the {AGENT_LABEL} label; \ + refusing to remove a container the spawner does not manage" + ); + } + let owner = labels + .and_then(|labels| labels.get(SPAWNER_LABEL)) + .map(String::as_str) + .unwrap_or_default(); + if owner != spawner_pubkey { + anyhow::bail!( + "conflicting container {name} belongs to spawner {owner}, not {spawner_pubkey}; \ + refusing to remove another spawner's agent to free the name" + ); + } + tracing::info!( + container = %name, + "removing our own orphaned agent container squatting on required name" + ); + self.remove_inner(name, None).await + } + + 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) + } +} + +/// True when Docker rejected a create because the container name is taken. +fn is_name_conflict(e: &bollard::errors::Error) -> bool { + matches!( + e, + bollard::errors::Error::DockerResponseServerError { + status_code: 409, + .. + } + ) +} + +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))); + } + + #[test] + fn name_conflict_matches_409_only() { + let conflict = bollard::errors::Error::DockerResponseServerError { + status_code: 409, + message: "Conflict. The container name is already in use".into(), + }; + assert!(is_name_conflict(&conflict)); + let not_found = bollard::errors::Error::DockerResponseServerError { + status_code: 404, + message: "no such container".into(), + }; + assert!(!is_name_conflict(¬_found)); + } +} 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/daemon.rs b/crates/buzz-spawner/src/daemon.rs new file mode 100644 index 0000000000..2b131baac0 --- /dev/null +++ b/crates/buzz-spawner/src/daemon.rs @@ -0,0 +1,1157 @@ +//! 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, + /// 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. + 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 credentials = crate::credentials::CredentialStore::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, + credentials, + 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::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, + .. + } = &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"); + 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. + r.spec_hash = None; + })?; + 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(); + 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 credentialed_owners: std::collections::HashSet = + self.credentials.owners().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, + credentialed_owners: &credentialed_owners, + }); + + 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. + // Running containers only: `list` reports stopped ones too, and counting + // those would advertise a host as full when the agents filling it are + // disabled or crashed, steering owners away from capacity that exists. + // A failure here is not fatal: discovery is a convenience, and an + // un-advertised spawner still works for anyone who knows its pubkey. + let running = containers.iter().filter(|c| c.running).count(); + if let Err(e) = self.announce(running).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), + ai: self.config.ai_catalog.clone(), + }; + 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( + &self.config.keys.public_key().to_hex(), + &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 + } + 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 + } + } + } + + 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, + carried_team_instructions: 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, + carried_team_instructions: 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 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()), + 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(), + }, + owner_credential.as_deref(), + ), + cpu_millis, + memory_mib, + volume_name: volume_name( + &self.config.keys.public_key().to_hex(), + &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(resolved_from_record(record, prompt)); + } + } + + 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 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, + }; + 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. +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 { + 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(spawner_pubkey: &str, owner_pubkey: &str, slug: &str) -> String { + format!( + "buzz-agent-{}-{}-{}", + &spawner_pubkey[..12.min(spawner_pubkey.len())], + &owner_pubkey[..12.min(owner_pubkey.len())], + slug + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn volume_names_are_scoped_per_owner() { + let spawner = "s".repeat(64); + assert_ne!( + volume_name(&spawner, &"a".repeat(64), "fizz"), + volume_name(&spawner, &"b".repeat(64), "fizz") + ); + } + + #[test] + fn volume_names_are_scoped_per_spawner() { + // Two spawners on one host must not share a workspace volume: deleting + // an agent purges its volume, and an unscoped name would wipe the other + // spawner's still-running agent out from under it. + let owner = "a".repeat(64); + assert_ne!( + volume_name(&"s".repeat(64), &owner, "fizz"), + volume_name(&"t".repeat(64), &owner, "fizz") + ); + } + + fn test_record(prompt: Option) -> AgentRecord { + AgentRecord { + slug: "fizz".into(), + owner_pubkey: "a".repeat(64), + agent_pubkey: "b".repeat(64), + private_key_nsec: String::new(), + auth_tag: None, + pending_nonce: None, + attestation_sent_at: None, + spec_hash: None, + prompt, + restart_count: 0, + last_failure_at: None, + carried_team_instructions: None, + } + } + + #[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 { + system_prompt: Some("be Fizz".into()), + team_instructions: None, + model: None, + provider: None, + }; + let record = test_record(Some(material.clone())); + assert_eq!(prompt_hash_for(Some(&record)), Some(material.hash())); + } + + #[test] + fn prompt_hash_for_absent_prompt_is_none() { + let record = test_record(None); + assert_eq!(prompt_hash_for(Some(&record)), None); + 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. + 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..0b5cd4db68 --- /dev/null +++ b/crates/buzz-spawner/src/env.rs @@ -0,0 +1,466 @@ +//! 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>, +} + +/// 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, + prompt: &ResolvedPrompt, + 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. + 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, + carried_team_instructions: 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, + None, + ); + + 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 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 + // 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, + None, + ); + + 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, + }, + 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, + None, + ); + 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, + }, + 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, + None, + ); + 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, + None, + ); + 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, + None, + ); + 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..3399ae6013 --- /dev/null +++ b/crates/buzz-spawner/src/lib.rs @@ -0,0 +1,51 @@ +#![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 credentials; +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..64287f290a --- /dev/null +++ b/crates/buzz-spawner/src/main.rs @@ -0,0 +1,51 @@ +//! `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(); + + // Install ring as the process-level rustls CryptoProvider, matching + // buzz-cli, buzz-acp, buzz-admin, and buzz-dev-mcp. + // + // Both ring and aws-lc-rs end up enabled through the workspace's unified + // feature set, and with two providers compiled in rustls refuses to guess. + // Without this the very first `wss://` connection panics inside rustls + // rather than returning an error — so a spawner pointed at a TLS relay + // crash-loops at startup and never reaches its own error handling. A `ws://` + // relay is unaffected, which is why local development did not catch it. + // + // `let _ =` is deliberate: a redundant install returns Err and is harmless. + let _ = rustls::crypto::ring::default_provider().install_default(); + + 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..284b9e9f89 --- /dev/null +++ b/crates/buzz-spawner/src/reconcile.rs @@ -0,0 +1,842 @@ +//! 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, + }, + /// 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. +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, + /// 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 +/// 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; + } + + // 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 { + // 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, + carried_team_instructions: 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, + } + } + + /// 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], + containers: &'a [ManagedContainer], + ) -> ReconcileInput<'a> { + ReconcileInput { + desired, + records, + containers, + now: 1_100, + attestation_timeout_secs: 600, + max_agents: 16, + desired_hydrated: true, + credentialed_owners: all_owners(), + } + } + + #[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 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) + .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..8803ea3ec9 --- /dev/null +++ b/crates/buzz-spawner/src/relay.rs @@ -0,0 +1,456 @@ +//! 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, VecDeque}, + 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); + +/// Cap on frames set aside while a one-shot query holds the socket. +const MAX_DEFERRED_FRAMES: usize = 256; + +/// 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, + /// Frames received while a one-shot query held the socket, replayed by + /// [`Self::next`] before anything new is read. + deferred: VecDeque, +} + +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(), + deferred: VecDeque::new(), + }; + relay.subscribe_all().await?; + Ok(relay) + } + + /// Reconnect after a transport failure, restoring both subscriptions. + pub async fn reconnect(&mut self) -> Result<()> { + // Anything deferred belonged to the socket that just died. Specs are + // replayed by the new subscription, and an ephemeral frame held across + // a disconnect answers a handshake round the reconnect has already + // outlived, so acting on it would only apply stale state. + self.deferred.clear(); + 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(()) + } + + /// Set a frame aside for [`Self::next`], dropping the oldest under flood. + fn defer(&mut self, msg: RelayMessage) { + defer_frame(&mut self.deferred, msg); + } + + /// Wait for the next actionable relay frame. + pub async fn next(&mut self) -> Result { + // Frames set aside during a one-shot query come first, so an + // attestation response that raced a persona fetch is still acted on. + let msg = match self.deferred.pop_front() { + Some(msg) => msg, + None => 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. + /// + /// Only reaches personas published `["shared","true"]`. Kind 30175 is + /// author-only otherwise, and the spawner authenticates as itself with no + /// NIP-OA tag (see [`Self::connect`]) — the owner delegation the relay + /// applies to attested readers covers the *agents* a spawner runs, never + /// the spawner. An unshared persona therefore has to arrive over the + /// encrypted attestation handshake instead; [`crate::daemon`] falls back to + /// that and says so when neither source has one. + 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 interleave with this + // one-shot query. They cannot be dropped: kind:24201 is + // ephemeral, so an attestation response that arrives while a + // persona query is in flight is gone for good, and the agent it + // answers for sits in pending_attestation until the handshake + // times out and the owner is asked to approve all over again. + // Defer them to the next `next()` call instead. + Ok(other) => { + self.defer(other); + 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) + } +} + +/// Push `msg` onto the deferral queue, evicting the oldest when full. +/// +/// Bounded because anyone on the relay can address frames at this spawner; an +/// unbounded queue would be a memory-growth lever for them. Eviction takes the +/// oldest, so a flood cannot push out a frame that just arrived. +fn defer_frame(deferred: &mut VecDeque, msg: RelayMessage) { + if deferred.len() >= MAX_DEFERRED_FRAMES { + deferred.pop_front(); + warn!("deferred-frame queue is full; dropping the oldest frame"); + } + deferred.push_back(msg); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn eose(id: &str) -> RelayMessage { + RelayMessage::Eose { + subscription_id: id.to_string(), + } + } + + fn id_of(msg: &RelayMessage) -> String { + match msg { + RelayMessage::Eose { subscription_id } => subscription_id.clone(), + _ => unreachable!("test only defers Eose frames"), + } + } + + #[test] + fn deferred_frames_replay_in_arrival_order() { + // An attestation response that raced a persona query must still be + // acted on, and in the order the relay sent it. + let mut deferred = VecDeque::new(); + defer_frame(&mut deferred, eose("first")); + defer_frame(&mut deferred, eose("second")); + + assert_eq!(id_of(&deferred.pop_front().unwrap()), "first"); + assert_eq!(id_of(&deferred.pop_front().unwrap()), "second"); + assert!(deferred.is_empty()); + } + + #[test] + fn deferral_is_bounded_and_evicts_the_oldest() { + // Anyone can address frames at this spawner, so the queue must not be + // an unbounded allocation lever — and the newest frame, which is the + // one most likely to still matter, must survive the flood. + let mut deferred = VecDeque::new(); + for i in 0..MAX_DEFERRED_FRAMES + 10 { + defer_frame(&mut deferred, eose(&i.to_string())); + } + + assert_eq!(deferred.len(), MAX_DEFERRED_FRAMES); + assert_eq!(id_of(deferred.front().unwrap()), "10"); + assert_eq!( + id_of(deferred.back().unwrap()), + (MAX_DEFERRED_FRAMES + 9).to_string() + ); + } +} diff --git a/crates/buzz-spawner/src/store.rs b/crates/buzz-spawner/src/store.rs new file mode 100644 index 0000000000..1feecbb18e --- /dev/null +++ b/crates/buzz-spawner/src/store.rs @@ -0,0 +1,336 @@ +//! 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, + /// 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, + /// 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, as created by `spawner_pubkey`. + /// + /// 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. + /// + /// Keyed on the *spawner* too, because labels are spawner-scoped but names + /// are global to the Docker daemon. Without it, two spawners sharing a host + /// collide the moment an owner moves an agent from one to the other: the + /// old spawner still manages the container while the new one needs the same + /// name, and reclaiming it would force-remove a live agent belonging to + /// somebody else — twice over, since the first spawner then does the same. + pub fn container_name(&self, spawner_pubkey: &str) -> String { + format!( + "buzz-agent-{}-{}-{}", + short_pubkey(spawner_pubkey), + short_pubkey(&self.owner_pubkey), + self.slug + ) + } +} + +/// First 12 hex characters of a pubkey, for use in Docker object names. +fn short_pubkey(pubkey: &str) -> &str { + &pubkey[..12.min(pubkey.len())] +} + +/// 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"); + 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(()) + } +} + +/// Create `path` mode 0600 and write `contents`. +/// +/// The mode is set at open rather than chmod-ed afterwards: this file holds +/// 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)] +pub(crate) fn write_private(path: &Path, contents: &str) -> Result<()> { + use std::io::Write; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + file.write_all(contents.as_bytes())?; + // An existing temp file keeps its old mode, so set it explicitly too. + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + file.sync_all()?; + Ok(()) +} + +#[cfg(not(unix))] +pub(crate) fn write_private(path: &Path, contents: &str) -> Result<()> { + std::fs::write(path, contents)?; + 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, + carried_team_instructions: 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 spawner = "s".repeat(64); + let a = record(&"a".repeat(64), "fizz"); + let b = record(&"c".repeat(64), "fizz"); + assert_ne!(a.container_name(&spawner), b.container_name(&spawner)); + } + + #[test] + fn container_names_are_scoped_per_spawner() { + // The mutual-destruction case: an owner moves an agent from spawner A + // to spawner B on the same host. Unscoped names collide, and whichever + // spawner loses the race force-removes the other's live container to + // free the name — then the first does the same on its next pass. + let rec = record(&"a".repeat(64), "fizz"); + assert_ne!( + rec.container_name(&"s".repeat(64)), + rec.container_name(&"t".repeat(64)) + ); + } + + #[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..d0f5e7d163 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -35,6 +35,24 @@ 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 +# BUZZ_SPAWNER_AI_CATALOG='[{"id":"anthropic","models":["claude-opus-5"]}]' + # 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..5c76a4d21e --- /dev/null +++ b/deploy/compose/compose.spawner.yml @@ -0,0 +1,90 @@ +# 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} + # How this spawner names itself in the desktop's picker. Without these, + # `Config::default_name` falls back to $HOSTNAME — which inside a + # container is its short ID, so the picker offers something like + # "8c6b9debbaf9" and the operator has no way to say otherwise. Left unset + # the fallback still applies, so this only ever improves on the default. + BUZZ_SPAWNER_NAME: ${BUZZ_SPAWNER_NAME:-} + BUZZ_SPAWNER_DESCRIPTION: ${BUZZ_SPAWNER_DESCRIPTION:-} + # Provider/model used when a spec names neither. The buzz-agent harness + # refuses to start without a provider, and a spec is allowed to omit it — + # a spawner has no global settings to fall back on, so the operator + # 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. + 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..0ead42eca7 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,8 @@ export default defineConfig({ name: "smoke", 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-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 7dcf615c00..2ff3cd4bb1 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1138,6 +1138,7 @@ dependencies = [ "nostr", "serde", "serde_json", + "sha2 0.11.0", "thiserror 2.0.18", "uuid", ] 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..976d910131 100644 --- a/desktop/src-tauri/src/commands/agent_settings.rs +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -5,8 +5,8 @@ use crate::{ app_state::AppState, managed_agents::{ build_managed_agent_summary, current_instance_id, find_managed_agent_mut, - load_managed_agents, load_personas, save_managed_agents, sync_managed_agent_processes, - ManagedAgentSummary, + load_managed_agents, load_personas, save_managed_agents, stop_managed_agent_process, + sync_managed_agent_processes, ManagedAgentSummary, }, util::now_iso, }; @@ -69,6 +69,78 @@ 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 *and* 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 be started again here. +/// Relocating also stops any process already running for that key, in the same +/// locked section: the flag alone only blocks *future* starts, and an agent +/// mid-run would otherwise keep answering alongside its server copy until it +/// happened to exit. +#[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 relocating = relocated_to_spawner.is_some(); + { + let record = find_managed_agent_mut(&mut records, &pubkey)?; + record.relocated_to_spawner = relocated_to_spawner; + record.updated_at = now_iso(); + if relocating { + // Stop before the flag is persisted rather than after, so a + // failure here aborts the whole hand-off instead of leaving a + // record that claims the agent moved while it is still running. + stop_managed_agent_process(&app, record, &mut runtimes)?; + } + } + if relocating { + state.clear_agent_session_caches(&pubkey); + } + + 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, + &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), + ) + }) + .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..5bbc096b53 --- /dev/null +++ b/desktop/src-tauri/src/commands/spawner.rs @@ -0,0 +1,687 @@ +//! 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: +//! +//! - 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 relocation key is looked up in this device's own store by that same +//! pubkey, so a spawner cannot name an arbitrary key and be handed it. +//! - The response is NIP-44 encrypted back to the same spawner that asked, so +//! an eavesdropper on the ephemeral kind:24201 stream gets ciphertext. +//! +//! ## Where the trust decision lives +//! +//! The consent prompt and the remembered set of approved spawners are the +//! renderer's (`features/agents/trustedSpawners.ts`), and the `trust` argument +//! here is that answer passed back in. This module does **not** re-derive it: +//! anything with IPC reach can therefore ask for a signature without a user +//! ever seeing a prompt, and the renderer is the boundary being relied on. That +//! is the same trust model as every other signing command in this app — the +//! webview is not treated as hostile — but it is worth stating plainly rather +//! than implying a backend check that does not exist. + +use nostr::JsonUtil; +use serde::{Deserialize, Serialize}; + +use buzz_sdk_pkg::spawner::{build_spawner_attestation, AttestationFrame, PromptMaterial}; + +use crate::app_state::AppState; +use crate::commands::agent_settings::set_managed_agent_relocated; +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), + } +} + +/// 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 hand-off relocated a locally-managed agent, meaning the + /// response carries that agent's secret key. + /// + /// The local copy is already marked relocated and stopped by the time this + /// returns. The caller needs the pubkey only to undo that — see + /// `set_managed_agent_relocated(pubkey, None)` — if publishing the event + /// fails and the spawner never receives the key. + #[serde(skip_serializing_if = "Option::is_none")] + pub relocated_agent_pubkey: Option, +} + +/// 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. +/// +/// # Why relocation is retired before the event is handed back +/// +/// A relocation response contains the agent's secret key, so the instant it is +/// published two runners hold one identity: both see every mention, both reply, +/// and the owner is billed twice per turn. Marking and stopping the local copy +/// here — not in a follow-up call from the renderer — means there is no window +/// where that has happened and this device has not yet been told. +/// +/// The residual risk is inverted rather than removed: if the publish then +/// fails, the agent is stopped here and never started there. That is the better +/// failure. It is visible (the agent is plainly stopped), it is recoverable by +/// clearing the flag, and it is quiet — whereas a split identity is silent and +/// costs money on every message until somebody notices. +#[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}"))?; + + // Retire the local copy before the caller can publish. If this fails the + // whole hand-off fails and the event is never returned, so the key stays on + // this device — the safe direction, since the alternative is handing out a + // key while the agent it belongs to keeps running here. + if let Some(relocated) = &relocated_agent_pubkey { + set_managed_agent_relocated(relocated.clone(), Some(spawner.to_hex()), app) + .await + .map_err(|e| { + format!("refusing to hand over the key: could not retire the local agent: {e}") + })?; + } + + Ok(SpawnerAttestationResponse { + event_json: event.as_json(), + relocated_agent_pubkey, + }) +} + +/// Output of [`send_spawner_prompt_update`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpawnerPromptUpdateOut { + /// The signed kind:24201 event to publish over the WebSocket. + pub event_json: String, + /// Content hash of the prompt material this update carries, for the + /// caller to confirm against what the spawner later reports applying. + pub prompt_hash: String, +} + +/// Build a signed, NIP-44-encrypted `PromptUpdate` frame addressed to +/// `spawner_pubkey`, so a running agent can be edited without minting a new +/// identity or re-running the attestation handshake. +fn build_prompt_update_event( + owner: &nostr::Keys, + spawner_pubkey: &str, + spec_slug: &str, + agent_pubkey: &str, + prompt: PromptMaterial, +) -> Result { + let spawner = nostr::PublicKey::from_hex(spawner_pubkey) + .map_err(|e| format!("invalid spawner pubkey: {e}"))?; + + let frame = AttestationFrame::PromptUpdate { + spec_slug: spec_slug.to_string(), + agent_pubkey: agent_pubkey.to_string(), + prompt: prompt.clone(), + }; + frame + .validate() + .map_err(|e| format!("malformed prompt update frame: {e}"))?; + + let plaintext = serde_json::to_string(&frame) + .map_err(|e| format!("failed to serialize prompt update 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 prompt update frame: {e}"))?; + + let event = build_spawner_attestation(&spawner.to_hex(), &ciphertext) + .map_err(|e| format!("failed to build prompt update event: {e}"))? + .sign_with_keys(owner) + .map_err(|e| format!("failed to sign prompt update event: {e}"))?; + + Ok(SpawnerPromptUpdateOut { + event_json: event.as_json(), + prompt_hash: prompt.hash(), + }) +} + +/// Build a signed prompt-update frame for a spawner-hosted agent, so its +/// system prompt / model / tool config can be edited in place. +/// +/// Returns the signed event rather than publishing it, for the same reason as +/// [`respond_to_spawner_attestation`]: kind 24201 is ephemeral and must go out +/// over the live WebSocket, not `POST /events`. +#[tauri::command] +pub async fn send_spawner_prompt_update( + state: tauri::State<'_, AppState>, + spawner_pubkey: String, + spec_slug: String, + agent_pubkey: String, + prompt: PromptMaterial, +) -> Result { + let keys = state.signing_keys()?; + 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, + 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_rejection_never_carries_a_key_or_a_tag() { + // The Untrusted arm is what runs when the user declines. It must not + // leak the agent's secret key, and it must not carry a signature that + // a spawner could treat as approval. + let frame = AttestationFrame::Reject { + spec_slug: "fizz-prod".into(), + agent_pubkey: Keys::generate().public_key().to_hex(), + nonce: "ab".repeat(ATTESTATION_NONCE_BYTES), + reason: Some("owner has not approved this spawner".into()), + }; + let json = serde_json::to_string(&frame).unwrap(); + + assert!(!json.contains("private_key_nsec")); + assert!(!json.contains("auth_tag")); + assert!(!json.contains("nsec1")); + } + + #[test] + fn a_relocation_response_carries_exactly_the_attested_identitys_key() { + // The spawner verifies this itself (evaluate_response rejects a key for + // a different agent), but the owner side must not build such a frame in + // the first place — the key is looked up by the pubkey in the decrypted + // request, so these two can never diverge. + let agent = Keys::generate(); + let owner = Keys::generate(); + let nsec = { + use nostr::nips::nip19::ToBech32; + agent.secret_key().to_bech32().unwrap() + }; + let auth_tag = + buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + + let frame = AttestationFrame::Response { + spec_slug: "fizz-prod".into(), + agent_pubkey: agent.public_key().to_hex(), + nonce: "ab".repeat(ATTESTATION_NONCE_BYTES), + auth_tag, + private_key_nsec: Some(nsec.clone()), + prompt: None, + }; + + let AttestationFrame::Response { + agent_pubkey, + private_key_nsec, + .. + } = &frame + else { + unreachable!("built a Response above") + }; + let delivered = Keys::parse(private_key_nsec.as_ref().unwrap()).unwrap(); + assert_eq!(&delivered.public_key().to_hex(), agent_pubkey); + } + + #[test] + fn prompt_update_round_trips_to_the_spawner() { + let owner = Keys::generate(); + let spawner = Keys::generate(); + let prompt = PromptMaterial { + model: Some("claude-opus-5".into()), + ..Default::default() + }; + let out = build_prompt_update_event( + &owner, + &spawner.public_key().to_hex(), + "honey", + &Keys::generate().public_key().to_hex(), + prompt.clone(), + ) + .unwrap(); + assert_eq!(out.prompt_hash, prompt.hash()); + // Spawner can decrypt and reads back the same frame. + let event: nostr::Event = serde_json::from_str(&out.event_json).unwrap(); + let plain = nostr::nips::nip44::decrypt( + spawner.secret_key(), + &owner.public_key(), + event.content.as_str(), + ) + .unwrap(); + let frame: AttestationFrame = serde_json::from_str(&plain).unwrap(); + match frame { + AttestationFrame::PromptUpdate { + spec_slug, + prompt: p, + .. + } => { + assert_eq!(spec_slug, "honey"); + assert_eq!(p, prompt); + } + other => panic!("wrong frame: {other:?}"), + } + } + + #[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(); + 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..4e5b670742 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -716,6 +716,11 @@ pub fn run() { get_media_proxy_port, fetch_link_preview_title, discover_acp_auth_methods, + 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, @@ -803,6 +808,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/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index 81c2611d5c..acf8d8908a 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -57,6 +57,7 @@ fn record( model: model.map(str::to_string), provider: provider.map(str::to_string), persona_source_version: None, + relocated_to_spawner: None, env_vars: BTreeMap::new(), start_on_app_launch: false, runtime_pid: 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/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index 27d3b0ce06..b75ff0e63f 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -25,6 +25,7 @@ fn sample_record() -> ManagedAgentRecord { model: None, provider: None, persona_source_version: None, + relocated_to_spawner: None, env_vars: BTreeMap::new(), start_on_app_launch: false, auto_restart_on_config_change: true, 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..0ba59be2d9 --- /dev/null +++ b/desktop/src/features/agents/spawnerAttestationStore.ts @@ -0,0 +1,340 @@ +import React from "react"; +import { toast } from "sonner"; + +import { getIdentity } from "@/shared/api/tauriIdentity"; +import { setManagedAgentRelocated } from "@/shared/api/tauriManagedAgents"; +import { + respondToSpawnerAttestation, + subscribeToSpawnerAttestations, +} from "@/shared/api/spawnerRelay"; +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"; + +/** + * 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, undoing the relocation if it never lands. + * + * By the time the signed event exists, Rust has already marked the local agent + * relocated and stopped it — atomically, under the store lock, before handing + * the event back. It has to be that way round: the response *contains* the + * agent's secret key, so any gap between publishing it and retiring the local + * copy is a window where two runners hold one identity, both answer every + * mention, and the owner pays twice. A window this side of an IPC boundary + * cannot be closed by ordering two calls carefully, only by not having it. + * + * That leaves the opposite failure to handle here: the key was never actually + * delivered, and the agent is now stopped everywhere. Clearing the flag puts it + * back exactly where it was, so the user can retry. + */ +async function publishAndRetireRelocated( + response: SpawnerAttestationResponse, +): Promise { + const relocated = response.relocatedAgentPubkey; + try { + await respondToSpawnerAttestation(response.event); + } catch (error) { + if (relocated) { + await rollBackRelocation(relocated); + } + throw error; + } +} + +/** + * Put a relocated agent back on this device after a failed publish. + * + * Best-effort by necessity — if this fails too, the agent is stopped and + * flagged as living on a server that never received its key, which no + * background retry can resolve. Say so loudly instead, and name the fix. + */ +async function rollBackRelocation(pubkey: string): Promise { + try { + await setManagedAgentRelocated(pubkey, null); + } catch (error) { + toast.error( + `The hand-off failed and this agent could not be moved back to this Mac: ${ + error instanceof Error ? error.message : "unknown error" + }. It is stopped everywhere until you move it back from the Agents screen.`, + { 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) { + 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 + // 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), + }), + ); + } 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), + }), + ); + // 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 { + // Cleared unconditionally. Keeping the settled promise around when no + // subscription was opened — no identity yet during onboarding, or a throw + // from getIdentity — made every later call return that same finished + // promise and report success, so attestation prompts never arrived again + // until the app restarted. A later call re-enters instead; the + // `unsubscribeRelay` check above is what keeps this idempotent. + 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/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/agents/spawnerDirectoryStore.ts b/desktop/src/features/agents/spawnerDirectoryStore.ts new file mode 100644 index 0000000000..f53883cda7 --- /dev/null +++ b/desktop/src/features/agents/spawnerDirectoryStore.ts @@ -0,0 +1,120 @@ +import React from "react"; + +import { + parseSpawnerAnnouncement, + subscribeToSpawnerAnnouncements, + type SpawnerAnnouncement, +} from "@/shared/api/spawnerRelay"; +import type { RelayEvent } from "@/shared/api/types"; +import { retryPendingSpawnerPromptUpdates } from "@/features/agents/spawnerPromptUpdateQueue"; + +/** + * 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(); + + // An announcement means this spawner is alive right now — resend anything + // that queued while it may have been unreachable. + void retryPendingSpawnerPromptUpdates(); +} + +/** 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/spawnerPromptUpdateQueue.test.mjs b/desktop/src/features/agents/spawnerPromptUpdateQueue.test.mjs new file mode 100644 index 0000000000..6556a778c4 --- /dev/null +++ b/desktop/src/features/agents/spawnerPromptUpdateQueue.test.mjs @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + findAckKey, + mergeHydrated, + 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", + key: "sp:ag", + promptHash: "h1", + queuedAt: 1, + }); + s = queueReducer(s, { + type: "enqueue", + key: "sp:ag", + promptHash: "h2", + queuedAt: 2, + }); + assert.equal(s.get("sp:ag").promptHash, "h2"); + assert.equal(s.size, 1); +}); + +test("matching ack clears, stale ack does not", () => { + const s = queueReducer(new Map(), { + type: "enqueue", + key: "sp:ag", + promptHash: "h2", + queuedAt: 2, + }); + assert.equal( + queueReducer(s, { type: "ack", key: "sp:ag", promptHash: "h1" }).size, + 1, + ); + assert.equal( + queueReducer(s, { type: "ack", key: "sp:ag", promptHash: "h2" }).size, + 0, + ); +}); + +test("ack for an unknown key is a no-op", () => { + const s = queueReducer(new Map(), { + type: "ack", + key: "sp:ag", + promptHash: "h1", + }); + assert.equal(s.size, 0); +}); + +test("ack with a nullish promptHash is a no-op", () => { + const s = queueReducer(new Map(), { + type: "enqueue", + key: "sp:ag", + promptHash: "h2", + queuedAt: 2, + }); + assert.equal( + queueReducer(s, { type: "ack", key: "sp:ag", promptHash: null }).size, + 1, + ); + assert.equal( + queueReducer(s, { type: "ack", key: "sp:ag", promptHash: undefined }).size, + 1, + ); +}); + +test("reset clears every pending entry", () => { + let s = queueReducer(new Map(), { + type: "enqueue", + key: "sp:ag", + promptHash: "h1", + queuedAt: 1, + }); + s = queueReducer(s, { type: "reset" }); + 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 + // 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 + // land — retrying here would force a needless repeat container restart. + const now = 1_000_000; + const entry = { promptHash: "h1", queuedAt: now, lastSentAt: now }; + assert.equal(shouldRetryPromptUpdate(entry, now + 1_000), false); +}); + +test("a delivered entry is retried once it has sat unacked past the floor", () => { + const now = 1_000_000; + const entry = { promptHash: "h1", queuedAt: now, lastSentAt: now }; + assert.equal(shouldRetryPromptUpdate(entry, now + 4 * 60 * 1000), true); +}); + +test("an entry whose last send failed is always retried", () => { + const now = 1_000_000; + const entry = { promptHash: "", queuedAt: now, lastSentAt: now }; + assert.equal(shouldRetryPromptUpdate(entry, now + 1), true); +}); + +test("ack still clears a delivered entry regardless of how recently it sent", () => { + const now = 1_000_000; + const s = queueReducer(new Map(), { + type: "enqueue", + key: "sp:ag", + promptHash: "h1", + queuedAt: now, + lastSentAt: now, + }); + assert.equal( + queueReducer(s, { type: "ack", key: "sp:ag", promptHash: "h1" }).size, + 0, + ); +}); diff --git a/desktop/src/features/agents/spawnerPromptUpdateQueue.ts b/desktop/src/features/agents/spawnerPromptUpdateQueue.ts new file mode 100644 index 0000000000..42cd900bb3 --- /dev/null +++ b/desktop/src/features/agents/spawnerPromptUpdateQueue.ts @@ -0,0 +1,402 @@ +import React from "react"; + +import { sendSpawnerPromptUpdate } from "@/shared/api/spawnerRelay"; +import type { SpawnerPromptMaterial } from "@/shared/api/tauriSpawner"; +import { + getCachedRelayOrigin, + subscribeRelayOrigin, +} from "@/shared/lib/mediaUrl"; + +/** + * Pending prompt updates for server-hosted agents, keyed by + * `":"`. + * + * A prompt edit is sent immediately, but the spawner only confirms it applied + * by echoing `prompt_hash` back on its next kind:30179 status — the WebSocket + * publish acknowledges delivery to the relay, not that the agent picked up the + * new prompt. Until that echo arrives (or the send itself fails), the entry + * stays here so the UI can show "pending" and so a spawner that was briefly + * offline gets the update resent once it is seen again. + * + * Module-level singleton, matching `spawnerStatusStore`/`spawnerAttestationStore`: + * this has to survive navigation and outlive any one component. + */ + +/** One prompt update awaiting confirmation. */ +export type QueueEntry = { + spawnerPubkey: string; + specSlug: string; + agentPubkey: string; + prompt: SpawnerPromptMaterial; + /** + * Hash of the last-sent prompt material, or `""` when the send itself + * failed and no hash was ever returned. An empty hash can never match a + * real `prompt_hash` echoed by a spawner, so the entry simply stays pending + * until `retryPendingSpawnerPromptUpdates` sends it again. + */ + promptHash: string; + queuedAt: number; + /** + * When the last send attempt for this entry was made (successful or not). + * Used to hold off resending a *delivered* entry — one with a non-empty + * `promptHash` — while its status ack is merely still in flight. Without + * this floor, every spawner announcement (which the Rust side republishes + * as part of its own reconcile loop after applying a prompt update) would + * retrigger a resend before the confirming status has had a chance to + * arrive, forcing a needless repeat container restart each time. + */ + lastSentAt: number; +}; + +export type QueueAction = + | ({ type: "enqueue"; key: string } & Omit) + | { type: "ack"; key: string; promptHash: string | null | undefined } + | { type: "reset" }; + +/** Pure reducer over the pending-queue map. Exported so tests avoid Tauri. */ +export function queueReducer( + state: ReadonlyMap, + action: QueueAction, +): ReadonlyMap { + switch (action.type) { + case "enqueue": { + const { type: _type, key, ...entry } = action; + const next = new Map(state); + next.set(key, entry as QueueEntry); + return next; + } + case "ack": { + if (!action.promptHash) return state; + const existing = state.get(action.key); + if (!existing || existing.promptHash !== action.promptHash) return state; + const next = new Map(state); + next.delete(action.key); + return next; + } + case "reset": + return state.size === 0 ? state : new Map(); + default: + return state; + } +} + +function queueKey(spawnerPubkey: string, agentPubkey: string): string { + return `${spawnerPubkey}:${agentPubkey}`; +} + +/** + * Minimum time a *delivered* (non-empty `promptHash`) entry must sit unacked + * before it is eligible for resend. Deliberately simple — no backoff curve — + * this only needs to outlast the gap between "send succeeds" and "spawner's + * next status publish echoes the hash back", which is normally seconds. + */ +const REDELIVER_FLOOR_MS = 3 * 60 * 1000; + +/** + * Whether a pending entry should be resent right now. + * + * A failed send (`promptHash === ""`) always qualifies — it never reached the + * spawner. A delivered entry only qualifies once it has been unacked longer + * than {@link REDELIVER_FLOOR_MS}, so a spawner's own reconcile-triggered + * re-announcement (which fires right after a successful send, before the + * confirming status can land) does not cause an immediate, needless resend. + */ +export function shouldRetryPromptUpdate( + entry: QueueEntry, + now: number, +): boolean { + if (!entry.promptHash) return true; + 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. 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(origin: string): string { + return `buzz:spawner-prompt-queue:${origin}`; +} + +const listeners = new Set<() => void>(); + +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(); + +/** + * 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) { + const origin = getCachedRelayOrigin(); + if (origin === null) return queue; + queue = mergeHydrated(readStored(origin), queue); + hydrated = true; + } + return queue; +} + +function readStored(origin: string): ReadonlyMap { + try { + 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(); + return new Map(Object.entries(parsed as Record)); + } catch { + return new Map(); + } +} + +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(origin), + JSON.stringify(Object.fromEntries(queue)), + ); + } catch { + // Keep the in-memory queue so this session still works. + } +} + +// 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; + queue = next; + persist(); + for (const listener of listeners) listener(); +} + +/** + * Enqueue and immediately send a prompt update. + * + * Latest-write-wins per `(spawnerPubkey, agentPubkey)`: a second edit before + * the first is confirmed simply replaces the pending entry, since only the + * newest prompt material matters. + * + * A send failure is swallowed here (no toast) — the entry is left pending so + * `retryPendingSpawnerPromptUpdates` can resend it once the spawner is next + * seen alive. + */ +export async function enqueueSpawnerPromptUpdate(input: { + spawnerPubkey: string; + specSlug: string; + agentPubkey: string; + prompt: SpawnerPromptMaterial; +}): Promise { + const key = queueKey(input.spawnerPubkey, input.agentPubkey); + const queuedAt = Date.now(); + + let promptHash = ""; + try { + promptHash = await sendSpawnerPromptUpdate(input); + } catch (error) { + console.debug( + "[spawner-prompt-queue] send failed, left pending for retry:", + error, + ); + } + + dispatch({ + type: "enqueue", + key, + spawnerPubkey: input.spawnerPubkey, + specSlug: input.specSlug, + agentPubkey: input.agentPubkey, + prompt: input.prompt, + promptHash, + queuedAt, + lastSentAt: Date.now(), + }); +} + +/** + * 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 + * hash from a since-superseded edit) leaves the entry pending. + */ +export function ackSpawnerPromptUpdate( + spawnerPubkey: string, + agentPubkey: string | null | undefined, + specSlug: string, + promptHash: string | null | undefined, +): void { + if (!promptHash) return; + const key = findAckKey(currentQueue(), spawnerPubkey, agentPubkey, specSlug); + if (key) dispatch({ type: "ack", key, promptHash }); +} + +/** + * Resend prompt updates that actually need it. + * + * Called when a spawner is seen alive again (status or announcement ingest), + * since a send that failed while it was unreachable never got a chance to + * land. Entries that were already delivered and are merely awaiting their + * status ack are skipped (see {@link shouldRetryPromptUpdate}) — the Rust + * side republishes its kind:10180 announcement as part of applying a prompt + * update, and resending on every one of those before the confirming status + * arrives would force a repeat container restart each time. + */ +export async function retryPendingSpawnerPromptUpdates(): Promise { + const now = Date.now(); + for (const [key, entry] of currentQueue()) { + if (!shouldRetryPromptUpdate(entry, now)) continue; + try { + const promptHash = await sendSpawnerPromptUpdate({ + spawnerPubkey: entry.spawnerPubkey, + specSlug: entry.specSlug, + agentPubkey: entry.agentPubkey, + prompt: entry.prompt, + }); + dispatch({ + type: "enqueue", + key, + ...entry, + promptHash, + lastSentAt: Date.now(), + }); + } catch (error) { + console.debug( + "[spawner-prompt-queue] retry send failed, left pending:", + error, + ); + } + } +} + +/** + * 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 { + hydrated = false; + if (queue.size === 0) return; + queue = new Map(); + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): ReadonlyMap { + return currentQueue(); +} + +function getServerSnapshot(): ReadonlyMap { + return EMPTY; +} + +/** + * 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; delivered: boolean; queuedAt: number } | null { + const snapshot = React.useSyncExternalStore( + subscribe, + getSnapshot, + getServerSnapshot, + ); + for (const entry of snapshot.values()) { + if (entry.agentPubkey === agentPubkey) { + 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 new file mode 100644 index 0000000000..3b84df179a --- /dev/null +++ b/desktop/src/features/agents/spawnerStatusStore.ts @@ -0,0 +1,171 @@ +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"; +import { ackSpawnerPromptUpdate } from "@/features/agents/spawnerPromptUpdateQueue"; + +/** + * 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(); + + // A `prompt_hash` echoed back on status is the spawner confirming it + // applied the last prompt sent for this agent — clear the pending entry. + // 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. */ +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 { + // Cleared unconditionally — see the same guard in + // `spawnerAttestationStore`. Retaining a settled promise from an attempt + // that opened no subscription (no identity yet, or a throw) wedged this + // store shut for the rest of the session. + 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.test.mjs b/desktop/src/features/agents/trustedSpawners.test.mjs new file mode 100644 index 0000000000..b306446f86 --- /dev/null +++ b/desktop/src/features/agents/trustedSpawners.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// The module reads localStorage at import time, so stub it first. +const store = new Map(); +globalThis.window = { + localStorage: { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, value), + }, +}; + +const { isSpawnerTrusted, resetTrustedSpawners, revokeSpawner, trustSpawner } = + await import("./trustedSpawners.ts"); + +const SPAWNER = "5c".repeat(32); +const OTHER = "7a".repeat(32); + +test("remembersAnApprovedSpawner", () => { + resetTrustedSpawners(); + assert.equal(isSpawnerTrusted(SPAWNER), false); + trustSpawner(SPAWNER); + assert.equal(isSpawnerTrusted(SPAWNER), true); + // Approving one spawner must not vouch for any other. + assert.equal(isSpawnerTrusted(OTHER), false); +}); + +test("ignoresValuesThatAreNotPubkeys", () => { + resetTrustedSpawners(); + trustSpawner("not-a-pubkey"); + assert.equal(isSpawnerTrusted("not-a-pubkey"), false); +}); + +test("matchesRegardlessOfCase", () => { + resetTrustedSpawners(); + trustSpawner(SPAWNER.toUpperCase()); + assert.equal(isSpawnerTrusted(SPAWNER), true); +}); + +test("revokeStopsAutoApproval", () => { + resetTrustedSpawners(); + trustSpawner(SPAWNER); + revokeSpawner(SPAWNER); + assert.equal(isSpawnerTrusted(SPAWNER), false); +}); + +test("resetClearsEveryDecisionAndThePersistedCopy", () => { + // The community-switch path calls this. A spawner trusted under the old + // identity must not auto-sign under the new one — and it must not come back + // from localStorage on the next read either. + resetTrustedSpawners(); + trustSpawner(SPAWNER); + trustSpawner(OTHER); + + resetTrustedSpawners(); + + assert.equal(isSpawnerTrusted(SPAWNER), false); + assert.equal(isSpawnerTrusted(OTHER), false); + assert.deepEqual(JSON.parse(store.get("buzz:trusted-spawners")), []); +}); 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/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 29cb48c643..1b707971b7 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -14,11 +14,10 @@ import { Dialog } from "@/shared/ui/dialog"; import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; import { AgentCreationPreview } from "./AgentCreationPreview"; -import { PersonaDropdownField } from "./PersonaDropdownField"; +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, @@ -38,6 +37,7 @@ import { BLOCK_BUILD_HIDDEN_PROVIDER_IDS, CUSTOM_PROVIDER_DROPDOWN_VALUE, computeLocalModeGate, + localModeGateSatisfiedForSubmit, formatRuntimeOptionLabel, getDefaultPersonaRuntime, getPersonaModelOptions, @@ -48,15 +48,9 @@ import { type PersonaDropdownOption, PERSONA_FIELD_CONTROL_CLASS, PERSONA_FIELD_SHELL_CLASS, - PERSONA_LABEL_OPTIONAL_CLASS, shouldClearKnownModelForSelectionScope, - sortPersonaRuntimes, } from "./agentConfigOptions"; -import { RequiredFieldLabel } from "./agentConfigControls"; -import { - modelDropdownOptions as buildModelDropdownOptions, - relayMeshModelPickerState, -} from "./relayMeshModelPicker"; +import { relayMeshModelPickerState } from "./relayMeshModelPicker"; import { selectionOnModelDropdownChange, selectionOnProviderDropdownChange, @@ -83,6 +77,20 @@ import { } from "./agentAiConfigurationPolicy"; import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; import { buildRuntimeModelProviderPayload } from "./agentDefinitionSubmitPayload"; +import { useServerAgents } from "../useServerAgents"; +import { slugFromName } from "../spawnerPreference"; +import { LlmProviderField } from "./LlmProviderField"; +import { + buildPersonaModelDropdownOptions, + buildPersonaRuntimeDropdown, + PersonaRuntimeWarning, +} from "./personaRuntimeDropdown"; +import { ServerModelField } from "./ServerModelField"; +import { ServerRunsOnBanner } from "./ServerRunsOnBanner"; +import { + useServerAgentEditContext, + withCurrentValueOption, +} from "./useServerAgentEditContext"; type AgentDefinitionDialogProps = { open: boolean; @@ -348,10 +356,8 @@ export function AgentDefinitionDialog({ }; if ("id" in initialValues) { - await onSubmit({ - id: initialValues.id, - ...baseInput, - }); + const result = await onSubmit({ id: initialValues.id, ...baseInput }); + await pushServerPromptUpdateAfterSubmit(serverContext, result, baseInput); return; } @@ -373,6 +379,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. @@ -429,7 +456,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. @@ -510,6 +542,7 @@ export function AgentDefinitionDialog({ ? effectiveProvider : "", selectedRuntime, + serverManaged: serverContext !== null, }); const staticModelOptions = getPersonaModelOptions(runtime, effectiveProvider); const runtimeModelOptions = getRuntimePersonaModelOptions(runtime); @@ -553,44 +586,16 @@ export function AgentDefinitionDialog({ const showCustomProviderInput = llmProviderFieldVisible && isCustomProviderEditing; const runtimeDropdownValue = runtime.trim() || NO_RUNTIME_DROPDOWN_VALUE; - const sortedRuntimes = React.useMemo( - () => sortPersonaRuntimes(runtimes), - [runtimes], - ); - const blankRuntimeOptionLabel = runtimesLoading - ? "Loading harnesses..." - : isCreateMode - ? "Choose a harness" - : "No preference (use app default)"; - const runtimeDropdownOptions: PersonaDropdownOption[] = [ - ...(!isCreateMode - ? [ - { - label: blankRuntimeOptionLabel, - value: NO_RUNTIME_DROPDOWN_VALUE, - }, - ] - : []), - ...sortedRuntimes.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 && - !runtimeDropdownOptions.some((option) => option.value === runtime) - ) { - runtimeDropdownOptions.push({ - label: `${runtime.trim()} (current)`, - value: runtime.trim(), - }); - } + const { + blankLabel: blankRuntimeOptionLabel, + options: runtimeDropdownOptions, + } = buildPersonaRuntimeDropdown({ + defaultRuntime, + isCreateMode, + runtime, + runtimes, + runtimesLoading, + }); const runtimeSummaryLabel = selectedRuntime ? formatRuntimeOptionLabel(selectedRuntime) : runtime.trim() || "Not configured"; @@ -603,32 +608,14 @@ export function AgentDefinitionDialog({ })), { label: "Custom provider...", value: CUSTOM_PROVIDER_DROPDOWN_VALUE }, ]; - const modelDropdownOptions: PersonaDropdownOption[] = - buildModelDropdownOptions({ - allowCustom: !isRelayMesh, - globalModel: undefined, - loading: modelDiscoveryLoading && discoveredModelOptions === null, - loadingValue: MODEL_DISCOVERY_LOADING_VALUE, - options: modelOptions, - }) - .filter( - (option) => isRelayMesh || option.value !== AUTO_MODEL_DROPDOWN_VALUE, - ) - .map((option) => - isRelayMesh && option.value === AUTO_MODEL_DROPDOWN_VALUE - ? { ...option, label: "Automatic" } - : option, - ); + const modelDropdownOptions = buildPersonaModelDropdownOptions({ + isRelayMesh, + loading: modelDiscoveryLoading && discoveredModelOptions === null, + loadingValue: MODEL_DISCOVERY_LOADING_VALUE, + options: modelOptions, + }); const previewLabel = displayName.trim() || "Agent name"; const previewAvatarUrl = avatarUrl.trim() || null; - const runtimeWarningText = selectedRuntime - ? runtimeAvailabilityWarning(selectedRuntime) - : null; - const runtimeWarning = runtimeWarningText ? ( -

- {runtimeWarningText} Visit Settings > Agents to set it up. -

- ) : null; const advancedFieldsTransition = shouldReduceMotion ? { duration: 0 } : ADVANCED_FIELDS_MOTION_TRANSITION; @@ -636,6 +623,7 @@ export function AgentDefinitionDialog({ React.useEffect(() => { if ( !open || + serverContext || !modelFieldVisible || isCustomModelEditing || !shouldClearKnownModelForSelectionScope({ @@ -656,6 +644,7 @@ export function AgentDefinitionDialog({ open, effectiveProvider, runtime, + serverContext, ]); const selection: RuntimeModelProviderSelection = { @@ -775,6 +764,14 @@ export function AgentDefinitionDialog({ />
+ {serverContext ? ( + + ) : null} +
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/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 601d57f95d..8673f2eb16 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -83,6 +83,17 @@ import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; import { resolveModelFieldStatusMessage } from "./agentConfigControls"; import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge"; import { showAgentProfileSyncWarning } from "./agentProfileSyncWarning"; +import { pushPrompt } from "./serverPromptUpdatePush"; +import { useServerAgents } from "../useServerAgents"; +import { slugFromName } from "../spawnerPreference"; +import { EditAgentRuntimeSection } from "./EditAgentRuntimeSection"; +import { LlmProviderField } from "./LlmProviderField"; +import { ServerModelField } from "./ServerModelField"; +import { ServerRunsOnBanner } from "./ServerRunsOnBanner"; +import { + useServerAgentEditContext, + withCurrentValueOption, +} from "./useServerAgentEditContext"; const ADVANCED_FIELDS_MOTION_TRANSITION = { duration: 0.18, @@ -386,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 @@ -412,6 +444,7 @@ export function AgentInstanceEditDialog({ open, provider: providerForDiscovery, selectedRuntime, + serverManaged: serverContext !== null, }); // D2: derive advancedRequiredEnvKeys for EnvVarsEditor display. @@ -446,6 +479,7 @@ export function AgentInstanceEditDialog({ React.useEffect(() => { if ( !open || + serverContext || isCustomModelEditing || !shouldClearKnownModelForSelectionScope({ model, @@ -465,6 +499,7 @@ export function AgentInstanceEditDialog({ providerForDiscovery, selectedRuntime, selectedRuntimeId, + serverContext, ]); const selection: RuntimeModelProviderSelection = { @@ -586,6 +621,7 @@ export function AgentInstanceEditDialog({ inheritHarness, agentCommand, requiredEnvKeyMissing, + serverManaged: serverContext !== null, }) && providerValid && !updateMutation.isPending && @@ -703,6 +739,7 @@ export function AgentInstanceEditDialog({ }; const result = await updateMutation.mutateAsync(input); + 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. @@ -884,6 +921,14 @@ export function AgentInstanceEditDialog({ )}
+ {serverContext ? ( + + ) : null} + {/* Agent name */}
- - {showCustomModelInput ? ( + {serverContext && !serverAi ? ( + + ) : ( + + )} + {!serverContext && showCustomModelInput ? (
) : null} - {modelStatusMessage ? ( + {!serverContext && modelStatusMessage ? (

{modelStatusMessage}

) : null}
- setAiDefaultsOpen(true)} - triggerRef={aiDefaultsTriggerRef} - explicitModel={inheritedSubmission.model ?? ""} - explicitProvider={inheritedSubmission.provider ?? ""} - inheritedModel={inheritedModelDefault} - inheritedProvider={inheritedProviderDefault} - /> + {serverContext ? ( +

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

+ ) : null} + + {/* 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} + /> + )} 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/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/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..eea39de7c8 --- /dev/null +++ b/desktop/src/features/agents/ui/ServerAgentsSection.tsx @@ -0,0 +1,481 @@ +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 { SectionHeader } from "@/shared/ui/PageHeader"; +import { + sameLocation, + setDefaultAgentLocation, + useDefaultAgentLocation, +} from "../agentLocation"; +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): { + 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(); + + // Keyed by the slug an agent's spec is published under, because that is the + // only name a ServerAgent carries — keying by display name would miss every + // persona whose name is not already its own slug ("Fizz" vs "fizz"), and a + // miss republishes the spec without its personaId. + const personaBySlug = React.useMemo(() => { + const map = new Map(); + for (const persona of personas) { + const slug = slugFromName(persona.displayName); + if (slug) map.set(slug, 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, + personaBySlug.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; +}) { + // 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 ( +
  • +
    +
    + {agent.slug} + {label} +
    + {agent.status.agentPubkey ? ( +

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

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

    + + {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 + {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 ( + + ); +} + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} 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..02404daec0 --- /dev/null +++ b/desktop/src/features/agents/ui/ServerRunsOnBanner.tsx @@ -0,0 +1,69 @@ +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, + pendingUpdate, +}: { + spawnerName: string; + runtime?: string | null; + /** A queued prompt edit awaiting the spawner's confirmation, or null. */ + pendingUpdate: { delivered: boolean } | null; +}) { + return ( +
    + + + Runs on {spawnerName} · Server + {runtime ? ( + · {runtime} + ) : null} + + {pendingUpdate ? ( + + ) : 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. + * + * 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, + delivered = true, +}: { + className?: string; + delivered?: boolean; +}) { + return ( + + {delivered ? "Update pending" : "Update pending — server offline"} + + ); +} 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/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/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index f24d6a41ff..94ddd4dbcf 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 + <> + {promptUpdatePending ? ( + + ) : null} + {agent?.personaOrphaned ? ( + + + Configuration missing + + ) : agent?.needsRestart ? ( + + + Restart required + + ) : null} + } /> ); @@ -368,6 +381,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 + <> + {promptUpdatePending ? ( + + ) : null} + {agent.personaOrphaned ? ( + + + Configuration missing + + ) : agent.needsRestart ? ( + + + Restart required + + ) : null} + } /> ); 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/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 `