From 50ee403a320b3e3483e4521a67aa872671e766e7 Mon Sep 17 00:00:00 2001 From: Junchao Yan Date: Wed, 12 Aug 2026 20:53:51 -0700 Subject: [PATCH 01/10] feat(waker): agent enrolment schema and owner trust anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of docs/waker-agent-enrolment.md / PLANS/BUZZ_WAKER_DESIGN.md §12: the pure, network-free half of replacing hand-edited WAKER_AGENTS_CONFIG_PATH JSON with credentials delivered over the relay. Adds crates/buzz-waker/src/enrolment.rs: - RosterBody/SignedRoster: which agent pubkeys are currently enrolled with an owner, republished in full on every add/remove/rotate. This is the discovery primitive that closes the design review's P2 finding — a single coordinate per (owner, waker) pair gets the same small-bounded-query completeness the bundle tap already has, instead of needing history pagination or a relay guarantee the non- replaceable kind-1059 envelope doesn't provide. - CredentialBody/SignedCredential: one agent's nsec/auth_tag, once the roster says that pubkey exists. Reuses the bundle tap's exact per-agent delivery shape. - parse_authorized_owners: WAKER_OWNER_PUBKEYS parsing/validation — the daemon-level trust anchor for a brand-new agent's first admission, before any per-agent FloorStore exists to pin an owner into. Mirrors main.rs's existing parse_owner_pubkey. Both signed types mirror bundle.rs's SignedLaunchBundle shape and verification order (identity before cryptography, cryptography before parsing) with their own domain separators, and redact secret fields from Debug the same way LaunchBundleBody/SignedLaunchBundle do. Wire I/O (a roster/credential tap mirroring bundle_feed.rs) and the dynamic per-agent supervisor main.rs needs to act on any of this are later phases — this crate has zero production callers of the new module yet, matching how bundle issuance itself shipped unwired before its own daemon-side plumbing landed. Testing: - cargo test -p buzz-waker: 216 lib tests + 8 main tests pass (13 new in enrolment::tests) - cargo clippy -p buzz-waker --all-targets -- -D warnings: clean - cargo fmt -p buzz-waker -- --check: clean - cargo doc -p buzz-waker --no-deps with -D warnings surfaces 4 pre-existing private-doc-link errors in bundle_feed.rs/cursor.rs/ feed.rs/attempt.rs, none in enrolment.rs; reproduces identically on main with this diff stashed out, and cargo doc is not part of just ci's gate set Signed-off-by: Junchao Yan --- crates/buzz-waker/src/enrolment.rs | 574 +++++++++++++++++++++++++++++ crates/buzz-waker/src/lib.rs | 12 + 2 files changed, 586 insertions(+) create mode 100644 crates/buzz-waker/src/enrolment.rs diff --git a/crates/buzz-waker/src/enrolment.rs b/crates/buzz-waker/src/enrolment.rs new file mode 100644 index 00000000000..f38bf2b9fdf --- /dev/null +++ b/crates/buzz-waker/src/enrolment.rs @@ -0,0 +1,574 @@ +//! Agent enrolment over the relay — replaces hand-editing +//! `WAKER_AGENTS_CONFIG_PATH` with credentials the desktop delivers directly. +//! `docs/waker-agent-enrolment.md` (design, approved) and +//! `PLANS/BUZZ_WAKER_DESIGN.md` §12 (build order). +//! +//! Two signed, NIP-44-encrypted-to-the-waker payloads travel the same +//! envelope [`bundle`](crate::bundle) already uses (`KIND_WAKER_BUNDLE_ENVELOPE`, +//! kind 1059 — portable, proven, not parameterized-replaceable): +//! +//! - [`SignedRoster`] / [`RosterBody`] — which agent pubkeys are currently +//! enrolled with this owner, republished in full on every add/remove/rotate. +//! Its whole job is *discovery*: unlike a launch bundle's `#p`, which is +//! always an already-known agent, the roster's `#p` is the waker's own +//! identity, shared across every agent one owner enrols — so a bounded +//! per-agent query cannot find it. One roster per (owner, waker) pair, at a +//! fixed coordinate, gives the same "small bounded query, take the newest" +//! completeness the bundle tap already has for free, without depending on +//! history-pagination or a relay guarantee kind 1059 doesn't provide (this +//! is what closed the design review's P2 finding — see the doc's Open +//! Questions and PLANS/BUZZ_WAKER_DESIGN.md §12). +//! - [`SignedCredential`] / [`CredentialBody`] — one agent's own `nsec` and +//! `auth_tag`, once the roster has said that pubkey exists. Delivery reuses +//! the bundle tap's exact per-agent shape (`authors`+`#p`, bounded, held +//! open live) — the only difference from a launch bundle is what the +//! ciphertext decrypts to. +//! +//! # Trust anchor +//! +//! Both payload types are signed by an owner and decrypted by the waker, so +//! encrypting to the waker's pubkey proves the *recipient*, never the +//! *sender* — a signature alone proves whichever key signed, not that the +//! signer is an operator the daemon should trust. [`FloorStore`](crate::floors::FloorStore) +//! already answers this for launch bundles by pinning `owner_pubkey` from +//! local config at enrolment (**G2**) rather than trusting whatever an +//! incoming bundle claims. A brand-new agent has no `FloorStore` yet — that's +//! exactly the moment a roster or credential first admits one — so there has +//! to be a trust anchor that exists *before* any per-agent floor does. +//! [`parse_authorized_owners`] is that anchor: `WAKER_OWNER_PUBKEYS`, a small +//! daemon-level allowlist of operator pubkeys (entries, not secrets — see the +//! design doc's Bootstrap section), checked before a roster or credential +//! payload's signature is trusted at all. Once an agent's `FloorStore` is +//! created from a first-admitted roster/credential entry, `owner_pubkey` gets +//! pinned into it exactly as it does today for bundles, and *that* pin — not +//! `WAKER_OWNER_PUBKEYS` — governs every later delivery for that agent. This +//! mirrors `ensure_owner_pin_matches` in `main.rs`, which already refuses to +//! run if a statically configured `owner_pubkey` disagrees with a pinned one. +//! +//! # What this module does not do (yet) +//! +//! Wire I/O — connecting, decrypting a live frame, and calling +//! [`SignedRoster::verify`] / [`SignedCredential::verify`] — is Phase 2 +//! (`roster_feed.rs`, not yet written). This module is the pure, +//! network-free half: parsing, signing, and verification, exactly the split +//! [`bundle`](crate::bundle) already keeps from [`bundle_feed`](crate::bundle_feed). + +use nostr::hashes::sha256::Hash as Sha256Hash; +use nostr::hashes::Hash as _; +use nostr::secp256k1::schnorr::Signature; +use nostr::secp256k1::{Keypair, Message, XOnlyPublicKey}; +use nostr::SECP256K1; +use serde::{Deserialize, Serialize}; + +use crate::decide::normalize_pubkey; + +/// Domain separator mixed into every roster digest. +/// +/// Distinct from [`crate::bundle::BUNDLE_DOMAIN`] and [`CREDENTIAL_DOMAIN`] +/// for the same reason that constant documents: the owner key signs several +/// different things in this codebase, and a signature valid for one must +/// never verify as another. +pub const ROSTER_DOMAIN: &[u8] = b"buzz-waker:enrolment-roster:v1\0"; + +/// Domain separator mixed into every credential digest. See [`ROSTER_DOMAIN`]. +pub const CREDENTIAL_DOMAIN: &[u8] = b"buzz-waker:enrolment-credential:v1\0"; + +/// What can go wrong turning received roster or credential bytes into a +/// trusted payload. Shared between both types: the verification shape +/// ([`SignedRoster::verify`], [`SignedCredential::verify`]) is identical — +/// only the domain separator and the body type differ. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum EnrolmentError { + /// The signer is not in the daemon's authorized-owner allowlist (fresh + /// discovery) or does not match the agent's already-pinned owner + /// (a `FloorStore` exists). Checked before cryptography — a valid + /// signature by the wrong key is exactly the attack this rejects, + /// matching [`crate::bundle::BundleError::WrongOwner`]'s own ordering. + #[error("enrolment signer {found} is not an authorized owner")] + UnauthorizedOwner { + /// The pubkey that actually signed. + found: String, + }, + + /// The owner pubkey is not 32 bytes of hex / not a valid x-only key. + #[error("malformed owner public key: {0}")] + MalformedOwnerKey(String), + + /// The signature is not 64 bytes of hex. + #[error("malformed signature: {0}")] + MalformedSignature(String), + + /// BIP-340 verification failed over the received body bytes. + #[error("enrolment signature verification failed")] + BadSignature, + + /// The signature verified but the body is not the expected shape. + #[error("enrolment body is malformed: {0}")] + MalformedBody(String), +} + +/// One agent's membership entry inside a [`RosterBody`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RosterEntry { + /// Hex pubkey of the enrolled agent. + pub agent_pubkey: String, + /// The [`CredentialBody::credential_version`] this roster entry expects + /// to be current for `agent_pubkey`. + /// + /// Carried here, not just on the credential itself, so a daemon that + /// already has an agent's credential cached can tell a stale cache from + /// a current one by comparing against the roster alone — without a + /// round-trip to the per-agent credential tap on every restart. + pub credential_version: u64, +} + +/// The signed content of an enrolment roster. +/// +/// One roster per (owner, waker) pair — see the module doc. Republished in +/// full on every add/remove/rotate; there is no delta representation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RosterBody { + /// Every agent currently enrolled with this owner, on this waker. + /// Absence is removal: an agent not listed here is not watched, exactly + /// as if it had never been enrolled — there is no separate roster-level + /// revoked flag because omission already says everything a flag would. + pub entries: Vec, + /// Monotonic issuance counter — the same shape as + /// [`crate::bundle::LaunchBundleBody::bundle_version`], gated the same + /// way once this crosses into wire I/O (Phase 2/3). + pub roster_version: u64, + /// Issuance time, unix seconds. Not an expiry — a roster does not lapse + /// the way a launch bundle does; it is kept current by republication. + pub issued_at: u64, +} + +/// A roster as it travels and rests: opaque bytes plus a signature. +/// +/// The body is not parsed — and must not be acted on — until +/// [`SignedRoster::verify`] returns, matching +/// [`crate::bundle::SignedLaunchBundle`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignedRoster { + /// The exact bytes that were signed, as a UTF-8 JSON string. + pub body_json: String, + /// Hex x-only pubkey of the signing owner. + pub owner_pubkey: String, + /// Hex BIP-340 signature over [`roster_digest`] of `body_json`. + pub sig: String, +} + +/// The digest a roster signature covers: `SHA-256(ROSTER_DOMAIN || body_json)`. +#[must_use] +pub fn roster_digest(body_json: &str) -> [u8; 32] { + let mut preimage = Vec::with_capacity(ROSTER_DOMAIN.len() + body_json.len()); + preimage.extend_from_slice(ROSTER_DOMAIN); + preimage.extend_from_slice(body_json.as_bytes()); + Sha256Hash::hash(&preimage).to_byte_array() +} + +impl SignedRoster { + /// Sign a body with the owner's keypair. + /// + /// # Errors + /// Returns [`EnrolmentError::MalformedBody`] if the body cannot be serialized. + pub fn sign(body: &RosterBody, owner: &Keypair) -> Result { + let body_json = serde_json::to_string(body) + .map_err(|e| EnrolmentError::MalformedBody(format!("could not serialize: {e}")))?; + let message = Message::from_digest(roster_digest(&body_json)); + let sig = SECP256K1.sign_schnorr(&message, owner); + let (xonly, _) = owner.x_only_public_key(); + Ok(Self { + body_json, + owner_pubkey: hex::encode(xonly.serialize()), + sig: hex::encode(sig.serialize()), + }) + } + + /// Verify against a set of authorized owners and return the trusted body. + /// + /// `authorized_owners` is either the daemon-level `WAKER_OWNER_PUBKEYS` + /// allowlist (no `FloorStore` for any listed agent exists yet) or a + /// single already-pinned owner from an existing agent's `FloorStore`, + /// passed as a one-element slice — both are "is the signer one of + /// these," so one function serves both callers. Entries are compared + /// case-insensitively; callers should still normalize + /// ([`crate::decide::normalize_pubkey`]) before comparing elsewhere. + /// + /// Order matches [`crate::bundle::SignedLaunchBundle::verify`]: identity + /// before cryptography, cryptography before parsing. + /// + /// # Errors + /// See [`EnrolmentError`] — every variant is a refusal to trust the roster. + pub fn verify(&self, authorized_owners: &[String]) -> Result { + let signer = normalize_pubkey(&self.owner_pubkey); + if !authorized_owners + .iter() + .any(|owner| normalize_pubkey(owner) == signer) + { + return Err(EnrolmentError::UnauthorizedOwner { found: signer }); + } + + let key_bytes = hex::decode(&self.owner_pubkey) + .map_err(|e| EnrolmentError::MalformedOwnerKey(e.to_string()))?; + let xonly = XOnlyPublicKey::from_slice(&key_bytes) + .map_err(|e| EnrolmentError::MalformedOwnerKey(e.to_string()))?; + + let sig_bytes = hex::decode(&self.sig) + .map_err(|e| EnrolmentError::MalformedSignature(e.to_string()))?; + let sig = Signature::from_slice(&sig_bytes) + .map_err(|e| EnrolmentError::MalformedSignature(e.to_string()))?; + + let message = Message::from_digest(roster_digest(&self.body_json)); + if SECP256K1.verify_schnorr(&sig, &message, &xonly).is_err() { + return Err(EnrolmentError::BadSignature); + } + + let body: RosterBody = serde_json::from_str(&self.body_json) + .map_err(|e| EnrolmentError::MalformedBody(e.to_string()))?; + + Ok(body) + } +} + +/// The signed content of one agent's delivered credential. +/// +/// `Debug` is implemented by hand and redacts [`Self::nsec`] — the whole +/// point of this type is carrying a private key, and a derived `Debug` would +/// print it into any log line, span field, or failed-assertion message. +/// Matches how [`crate::bundle::LaunchBundleBody`] redacts `agent_json`. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CredentialBody { + /// Hex pubkey of the agent this credential belongs to. + pub agent_pubkey: String, + /// The agent's own Nostr private key, hex or `nsec1...` — same shape + /// `WAKER_AGENTS_CONFIG_PATH`'s `AgentConfig::nsec` accepts today + /// (`main.rs`), so a daemon can be migrated one agent at a time without + /// the two paths disagreeing on format. + pub nsec: String, + /// Raw NIP-OA authorization tag, if this relay deployment requires one — + /// same shape and meaning as `AgentConfig::auth_tag` in `main.rs`. + #[serde(default)] + pub auth_tag: Option>, + /// Monotonic issuance counter for this agent's credential, gated the + /// same way [`crate::floors::FloorStore`] already gates bundle versions + /// once this crosses into wire I/O (Phase 2/3). Matched against the + /// roster's [`RosterEntry::credential_version`] for the same agent. + pub credential_version: u64, + /// Issuance time, unix seconds. + pub issued_at: u64, + /// When `true`, this delivery is a revocation, not a live credential — + /// same shape as [`crate::bundle::LaunchBundleBody::revoked`]. A + /// revoked credential's `nsec` is an unused placeholder from the + /// issuer; the receiving side must never act on it. + #[serde(default)] + pub revoked: bool, +} + +impl std::fmt::Debug for CredentialBody { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CredentialBody") + .field("agent_pubkey", &self.agent_pubkey) + .field("nsec", &"") + .field("auth_tag", &self.auth_tag) + .field("credential_version", &self.credential_version) + .field("issued_at", &self.issued_at) + .field("revoked", &self.revoked) + .finish() + } +} + +/// A credential as it travels and rests: opaque bytes plus a signature. +/// +/// `Debug` redacts [`Self::body_json`] for the same reason [`CredentialBody`] +/// redacts `nsec` — those bytes *are* the private key. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignedCredential { + /// The exact bytes that were signed, as a UTF-8 JSON string. + pub body_json: String, + /// Hex x-only pubkey of the signing owner. + pub owner_pubkey: String, + /// Hex BIP-340 signature over [`credential_digest`] of `body_json`. + pub sig: String, +} + +impl std::fmt::Debug for SignedCredential { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SignedCredential") + .field("body_json", &"") + .field("owner_pubkey", &self.owner_pubkey) + .field("sig", &self.sig) + .finish() + } +} + +/// The digest a credential signature covers: +/// `SHA-256(CREDENTIAL_DOMAIN || body_json)`. +#[must_use] +pub fn credential_digest(body_json: &str) -> [u8; 32] { + let mut preimage = Vec::with_capacity(CREDENTIAL_DOMAIN.len() + body_json.len()); + preimage.extend_from_slice(CREDENTIAL_DOMAIN); + preimage.extend_from_slice(body_json.as_bytes()); + Sha256Hash::hash(&preimage).to_byte_array() +} + +impl SignedCredential { + /// Sign a body with the owner's keypair. + /// + /// # Errors + /// Returns [`EnrolmentError::MalformedBody`] if the body cannot be serialized. + pub fn sign(body: &CredentialBody, owner: &Keypair) -> Result { + let body_json = serde_json::to_string(body) + .map_err(|e| EnrolmentError::MalformedBody(format!("could not serialize: {e}")))?; + let message = Message::from_digest(credential_digest(&body_json)); + let sig = SECP256K1.sign_schnorr(&message, owner); + let (xonly, _) = owner.x_only_public_key(); + Ok(Self { + body_json, + owner_pubkey: hex::encode(xonly.serialize()), + sig: hex::encode(sig.serialize()), + }) + } + + /// Verify against a set of authorized owners and return the trusted body. + /// See [`SignedRoster::verify`] — same shape, same ordering, same + /// authorized-owner argument convention. + /// + /// # Errors + /// See [`EnrolmentError`] — every variant is a refusal to trust the credential. + pub fn verify(&self, authorized_owners: &[String]) -> Result { + let signer = normalize_pubkey(&self.owner_pubkey); + if !authorized_owners + .iter() + .any(|owner| normalize_pubkey(owner) == signer) + { + return Err(EnrolmentError::UnauthorizedOwner { found: signer }); + } + + let key_bytes = hex::decode(&self.owner_pubkey) + .map_err(|e| EnrolmentError::MalformedOwnerKey(e.to_string()))?; + let xonly = XOnlyPublicKey::from_slice(&key_bytes) + .map_err(|e| EnrolmentError::MalformedOwnerKey(e.to_string()))?; + + let sig_bytes = hex::decode(&self.sig) + .map_err(|e| EnrolmentError::MalformedSignature(e.to_string()))?; + let sig = Signature::from_slice(&sig_bytes) + .map_err(|e| EnrolmentError::MalformedSignature(e.to_string()))?; + + let message = Message::from_digest(credential_digest(&self.body_json)); + if SECP256K1.verify_schnorr(&sig, &message, &xonly).is_err() { + return Err(EnrolmentError::BadSignature); + } + + let body: CredentialBody = serde_json::from_str(&self.body_json) + .map_err(|e| EnrolmentError::MalformedBody(e.to_string()))?; + + Ok(body) + } +} + +/// Parse `WAKER_OWNER_PUBKEYS` — a comma-separated list of hex owner +/// pubkeys this daemon will ever trust to enrol a *new* agent (one with no +/// `FloorStore` yet). See the module doc's Trust anchor section for why this +/// exists independently of any enrolment event's own claimed signer. +/// +/// Entries are trimmed, lowercased, and validated as real Nostr public keys +/// before being accepted — the same reasoning `main.rs`'s +/// `parse_owner_pubkey` already applies to a single configured +/// `owner_pubkey`: catch a typo at startup, not silently after a roster or +/// credential has already failed to verify against it. +/// +/// An empty or unset value is not an error here — a daemon that only ever +/// watches statically configured agents (`WAKER_AGENTS_CONFIG_PATH`) has no +/// need for this allowlist yet. It becomes a hard requirement once Phase +/// 2/3 wires roster/credential taps into `main.rs`, at which point *not* +/// setting it should refuse to enable enrolment rather than silently trust +/// nothing (or, worse, everything). +/// +/// # Errors +/// Any entry fails to parse as a hex Nostr public key. +pub fn parse_authorized_owners(raw: &str) -> anyhow::Result> { + let mut owners = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for entry in raw.split(',') { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + let normalized = normalize_pubkey(entry); + nostr::PublicKey::from_hex(&normalized) + .map_err(|e| anyhow::anyhow!("invalid pubkey in WAKER_OWNER_PUBKEYS: {e}"))?; + if seen.insert(normalized.clone()) { + owners.push(normalized); + } + } + Ok(owners) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::secp256k1::rand::rngs::OsRng; + + fn keypair() -> Keypair { + Keypair::new(SECP256K1, &mut OsRng) + } + + fn owner_hex(kp: &Keypair) -> String { + let (xonly, _) = kp.x_only_public_key(); + hex::encode(xonly.serialize()) + } + + fn roster_body(owner_agent: &str) -> RosterBody { + RosterBody { + entries: vec![RosterEntry { + agent_pubkey: owner_agent.to_string(), + credential_version: 1, + }], + roster_version: 1, + issued_at: 1_000, + } + } + + fn credential_body(agent_pubkey: &str) -> CredentialBody { + CredentialBody { + agent_pubkey: agent_pubkey.to_string(), + nsec: "nsec1thisisthefakeagentsigningkey".to_string(), + auth_tag: None, + credential_version: 1, + issued_at: 1_000, + revoked: false, + } + } + + #[test] + fn a_roster_signed_by_an_authorized_owner_verifies() { + let owner = keypair(); + let owner_hex = owner_hex(&owner); + let signed = SignedRoster::sign(&roster_body(&"a".repeat(64)), &owner).expect("signs"); + + let body = signed + .verify(&[owner_hex]) + .expect("authorized owner verifies"); + assert_eq!(body.roster_version, 1); + } + + #[test] + fn a_roster_signed_by_an_unauthorized_owner_is_refused() { + let owner = keypair(); + let other = keypair(); + let signed = SignedRoster::sign(&roster_body(&"a".repeat(64)), &owner).expect("signs"); + + let error = signed + .verify(&[owner_hex(&other)]) + .expect_err("unauthorized owner refused"); + assert!(matches!(error, EnrolmentError::UnauthorizedOwner { .. })); + } + + #[test] + fn owner_authorization_is_checked_before_the_signature() { + // A tampered body (bad signature) signed by an owner not on the + // allowlist must report UnauthorizedOwner, not BadSignature — + // identity before cryptography, matching SignedLaunchBundle::verify. + let owner = keypair(); + let mut signed = SignedRoster::sign(&roster_body(&"a".repeat(64)), &owner).expect("signs"); + signed.body_json = + serde_json::to_string(&roster_body(&"b".repeat(64))).expect("serializes"); + + let other = keypair(); + let error = signed + .verify(&[owner_hex(&other)]) + .expect_err("unauthorized and tampered is still reported as unauthorized"); + assert!(matches!(error, EnrolmentError::UnauthorizedOwner { .. })); + } + + #[test] + fn a_tampered_roster_body_fails_signature_verification() { + let owner = keypair(); + let mut signed = SignedRoster::sign(&roster_body(&"a".repeat(64)), &owner).expect("signs"); + signed.body_json = + serde_json::to_string(&roster_body(&"b".repeat(64))).expect("serializes"); + + let error = signed + .verify(&[owner_hex(&owner)]) + .expect_err("tampered body fails signature check"); + assert_eq!(error, EnrolmentError::BadSignature); + } + + #[test] + fn a_credential_signed_by_an_authorized_owner_verifies() { + let owner = keypair(); + let agent = "a".repeat(64); + let signed = SignedCredential::sign(&credential_body(&agent), &owner).expect("signs"); + + let body = signed + .verify(&[owner_hex(&owner)]) + .expect("authorized owner verifies"); + assert_eq!(body.agent_pubkey, agent); + assert_eq!(body.nsec, "nsec1thisisthefakeagentsigningkey"); + } + + #[test] + fn a_credential_signed_by_an_unauthorized_owner_is_refused() { + let owner = keypair(); + let other = keypair(); + let signed = + SignedCredential::sign(&credential_body(&"a".repeat(64)), &owner).expect("signs"); + + let error = signed + .verify(&[owner_hex(&other)]) + .expect_err("unauthorized owner refused"); + assert!(matches!(error, EnrolmentError::UnauthorizedOwner { .. })); + } + + #[test] + fn a_credential_debug_impl_redacts_the_nsec() { + let body = credential_body(&"a".repeat(64)); + let rendered = format!("{body:?}"); + assert!(!rendered.contains("nsec1thisisthefakeagentsigningkey")); + assert!(rendered.contains("")); + } + + #[test] + fn a_signed_credential_debug_impl_redacts_body_json() { + let owner = keypair(); + let signed = + SignedCredential::sign(&credential_body(&"a".repeat(64)), &owner).expect("signs"); + let rendered = format!("{signed:?}"); + assert!(!rendered.contains("nsec1thisisthefakeagentsigningkey")); + } + + #[test] + fn authorized_owners_parses_a_comma_separated_list() { + let a = "A".repeat(64); + let b = "b".repeat(64); + let owners = parse_authorized_owners(&format!(" {a} , {b} ")).expect("parses"); + assert_eq!(owners, vec!["a".repeat(64), "b".repeat(64)]); + } + + #[test] + fn authorized_owners_dedupes_case_insensitively() { + let a_upper = "A".repeat(64); + let a_lower = "a".repeat(64); + let owners = parse_authorized_owners(&format!("{a_upper},{a_lower}")).expect("parses"); + assert_eq!(owners, vec!["a".repeat(64)]); + } + + #[test] + fn authorized_owners_accepts_empty_input() { + let owners = parse_authorized_owners("").expect("empty is valid"); + assert!(owners.is_empty()); + } + + #[test] + fn authorized_owners_skips_blank_entries_between_commas() { + let a = "a".repeat(64); + let owners = parse_authorized_owners(&format!("{a},, ,")).expect("parses"); + assert_eq!(owners, vec![a]); + } + + #[test] + fn authorized_owners_rejects_a_malformed_pubkey() { + let error = parse_authorized_owners("not-a-key").unwrap_err(); + assert!(error.to_string().contains("invalid pubkey"), "{error}"); + } +} diff --git a/crates/buzz-waker/src/lib.rs b/crates/buzz-waker/src/lib.rs index a4c95b0da33..2b1a0d2c000 100644 --- a/crates/buzz-waker/src/lib.rs +++ b/crates/buzz-waker/src/lib.rs @@ -39,6 +39,13 @@ //! the reconnect ladder plus [`feed::step`], and spawns each admitted //! trigger's [`attempt::run_wake_attempt`] onto its own task so the loop //! keeps answering the relay's pings during a liveness proof. +//! - [`enrolment`] — agent enrolment over the relay: the pure schema/trust +//! half (roster + per-agent credential payloads, `WAKER_OWNER_PUBKEYS`) of +//! replacing hand-edited `WAKER_AGENTS_CONFIG_PATH` JSON. +//! `docs/waker-agent-enrolment.md` (design) and `PLANS/BUZZ_WAKER_DESIGN.md` +//! §12 (build order) — wire I/O (a `roster_feed`/credential tap mirroring +//! [`bundle_feed`]) and the dynamic per-agent supervisor `main.rs` needs to +//! act on it are later phases, not yet implemented. //! //! Each exists because of a specific review finding and carries the gate id //! (`G1`–`G4`) it discharges, so the reason is not lost. @@ -49,6 +56,7 @@ pub mod bundle_feed; pub mod cursor; pub mod decide; pub mod effects; +pub mod enrolment; pub mod feed; mod fence; pub mod floors; @@ -68,6 +76,10 @@ pub use decide::{ agent_responds_to_author, compute_wake_replay_floor, event_addresses_agent, is_covered_by_replay_floor, select_wake_candidates, RespondTo, TriggerEvent, WakeCandidate, }; +pub use enrolment::{ + parse_authorized_owners, CredentialBody, EnrolmentError, RosterBody, RosterEntry, + SignedCredential, SignedRoster, +}; pub use floors::{FloorError, FloorStore, Floors}; /// Seconds of overlap to subtract from the persisted cursor when re-issuing a From db91f8fa14f9059f03e8cd0057504e740fd18006 Mon Sep 17 00:00:00 2001 From: Junchao Yan Date: Wed, 12 Aug 2026 21:03:49 -0700 Subject: [PATCH 02/10] fix(waker): bind credential nsec to agent_pubkey, validate roster entries Addresses Alex's two review findings on PR #48: - [P1] SignedCredential::verify accepted any owner-signed {agent_pubkey, nsec} pair without proving the nsec actually derives agent_pubkey. A signed-but-self-inconsistent credential (agent_pubkey: A, nsec: key-for-B) would verify successfully, and later phases key durable state (floor, roster membership, state directory) by A while a spawned connection authenticates as B. Fixed by parsing nsec after signature verification and requiring its derived pubkey to equal the claimed agent_pubkey, mirroring the identical binding crates/buzz-core/src/private_managed_agent.rs already enforces (validate_active_definition). The check (and the new auth_tag shape check) is skipped for a revoked credential, since CredentialBody's own doc already establishes nsec/auth_tag as unused issuer placeholders there, same as LaunchBundleBody::revoked. - [P2] SignedRoster::verify accepted a roster with malformed agent pubkeys or the same agent listed more than once with disagreeing credential_version, leaving Phase 2/3's diff/fold order-dependent and letting an invalid coordinate reach durable state. Fixed by validating and normalizing the roster as one semantic unit after signature verification: every agent_pubkey must parse as a canonical Nostr public key, duplicates are refused outright, and a new MAX_ROSTER_ENTRIES (256, comfortably under the ~65KB practical NIP-44 ceiling bundle_feed.rs's own NIP44_CONTENT_LEN_RANGE already enforces) bounds the roster's serialized size. Testing: - cargo test -p buzz-waker: 224 lib tests + 8 main tests pass (9 new in enrolment::tests covering both findings) - cargo clippy -p buzz-waker --all-targets -- -D warnings: clean - cargo fmt -p buzz-waker -- --check: clean Signed-off-by: Junchao Yan --- crates/buzz-waker/src/enrolment.rs | 375 +++++++++++++++++++++++++++-- 1 file changed, 352 insertions(+), 23 deletions(-) diff --git a/crates/buzz-waker/src/enrolment.rs b/crates/buzz-waker/src/enrolment.rs index f38bf2b9fdf..7c793dd4996 100644 --- a/crates/buzz-waker/src/enrolment.rs +++ b/crates/buzz-waker/src/enrolment.rs @@ -57,11 +57,24 @@ use nostr::hashes::sha256::Hash as Sha256Hash; use nostr::hashes::Hash as _; use nostr::secp256k1::schnorr::Signature; use nostr::secp256k1::{Keypair, Message, XOnlyPublicKey}; -use nostr::SECP256K1; +use nostr::{Keys, Tag, SECP256K1}; use serde::{Deserialize, Serialize}; use crate::decide::normalize_pubkey; +/// Upper bound on a [`RosterBody`]'s entry count. +/// +/// A daemon-level review finding requires "the intended entry/serialized-size +/// bound" be enforced, not left implicit. Each entry is a 64-hex-char pubkey +/// plus a `u64` version — roughly 130 bytes of JSON including field names and +/// punctuation — so 256 entries is ~33KB of plaintext, comfortably under the +/// ~65KB practical NIP-44 ceiling `bundle_feed.rs`'s own +/// `NIP44_CONTENT_LEN_RANGE` (132..=87472 ciphertext chars) already enforces +/// for this codebase's kind-1059 envelope, after NIP-44/base64 expansion. +/// Generous for a household waker's real agent count; revisit if that stops +/// being true. +pub const MAX_ROSTER_ENTRIES: usize = 256; + /// Domain separator mixed into every roster digest. /// /// Distinct from [`crate::bundle::BUNDLE_DOMAIN`] and [`CREDENTIAL_DOMAIN`] @@ -105,6 +118,44 @@ pub enum EnrolmentError { /// The signature verified but the body is not the expected shape. #[error("enrolment body is malformed: {0}")] MalformedBody(String), + + /// A [`RosterEntry::agent_pubkey`] is not a canonical, parseable Nostr + /// public key, the roster names the same agent more than once, or the + /// roster carries more than [`MAX_ROSTER_ENTRIES`]. Any of these would + /// leave Phase 2/3's diff/fold order-dependent or key durable state by + /// an invalid coordinate, so they are refused here rather than left to + /// whichever caller happens to notice first. + #[error("invalid roster entry: {0}")] + InvalidRosterEntry(String), + + /// A [`CredentialBody::agent_pubkey`] is not a canonical, parseable + /// Nostr public key. + #[error("invalid credential agent_pubkey: {0}")] + InvalidCredentialAgentPubkey(String), + + /// [`CredentialBody::nsec`] does not parse as a Nostr private key. + /// Only checked for a live credential — see + /// [`SignedCredential::verify`]'s doc on why a revocation skips this. + #[error("malformed credential nsec: {0}")] + MalformedNsec(String), + + /// [`CredentialBody::nsec`] parses, but derives a different public key + /// than [`CredentialBody::agent_pubkey`] claims. An owner-valid + /// signature over a self-inconsistent body is still a refusal: later + /// phases key durable state (floor, roster membership, state + /// directory) by `agent_pubkey` while the delivered key would + /// authenticate connections as someone else entirely. + #[error("credential nsec does not derive the claimed agent_pubkey {claimed}")] + CredentialKeyMismatch { + /// The `agent_pubkey` the credential body claimed. + claimed: String, + }, + + /// [`CredentialBody::auth_tag`], if present, is not a well-formed Nostr + /// tag. Only checked for a live credential, same reasoning as + /// [`Self::MalformedNsec`]. + #[error("malformed credential auth_tag: {0}")] + MalformedAuthTag(String), } /// One agent's membership entry inside a [`RosterBody`]. @@ -226,8 +277,51 @@ impl SignedRoster { let body: RosterBody = serde_json::from_str(&self.body_json) .map_err(|e| EnrolmentError::MalformedBody(e.to_string()))?; - Ok(body) + validate_roster_body(body) + } +} + +/// Validate and normalize a parsed [`RosterBody`] as one semantic unit, +/// after signature verification has already established the owner is +/// trusted — a valid signature over an ambiguous or malformed roster is +/// still not safe for a caller to act on. +/// +/// # Errors +/// [`EnrolmentError::InvalidRosterEntry`] if the roster exceeds +/// [`MAX_ROSTER_ENTRIES`], names the same agent more than once (even with +/// matching `credential_version`s — a well-formed roster never needs to), +/// or contains an `agent_pubkey` that does not parse as a canonical Nostr +/// public key. +fn validate_roster_body(body: RosterBody) -> Result { + if body.entries.len() > MAX_ROSTER_ENTRIES { + return Err(EnrolmentError::InvalidRosterEntry(format!( + "{} entries exceeds the {MAX_ROSTER_ENTRIES} limit", + body.entries.len() + ))); + } + + let mut seen = std::collections::HashSet::with_capacity(body.entries.len()); + let mut normalized_entries = Vec::with_capacity(body.entries.len()); + for entry in body.entries { + let agent_pubkey = normalize_pubkey(&entry.agent_pubkey); + nostr::PublicKey::from_hex(&agent_pubkey).map_err(|e| { + EnrolmentError::InvalidRosterEntry(format!("malformed agent_pubkey: {e}")) + })?; + if !seen.insert(agent_pubkey.clone()) { + return Err(EnrolmentError::InvalidRosterEntry(format!( + "agent {agent_pubkey} listed more than once" + ))); + } + normalized_entries.push(RosterEntry { + agent_pubkey, + ..entry + }); } + + Ok(RosterBody { + entries: normalized_entries, + ..body + }) } /// The signed content of one agent's delivered credential. @@ -333,6 +427,25 @@ impl SignedCredential { /// See [`SignedRoster::verify`] — same shape, same ordering, same /// authorized-owner argument convention. /// + /// Beyond signature verification, this binds [`CredentialBody::nsec`] to + /// [`CredentialBody::agent_pubkey`]: an owner-valid signature over + /// `{agent_pubkey: A, nsec: key-for-B}` is a self-inconsistent body, not + /// a trustworthy one — later phases key durable state (floor, roster + /// membership, state directory, supervisor identity) by `agent_pubkey`, + /// so a caller that skipped this check would authenticate connections + /// as an entirely different agent than the one it believes it is + /// running. Matches the binding + /// `crates/buzz-core/src/private_managed_agent.rs`'s + /// `validate_active_definition` already enforces for the same shape of + /// data (`agent_keys.public_key() != *agent`). + /// + /// The nsec/auth_tag checks are skipped when [`CredentialBody::revoked`] + /// is `true` — [`CredentialBody`]'s own doc says those fields are unused + /// issuer placeholders for a revocation, mirroring + /// [`crate::bundle::LaunchBundleBody::revoked`]'s placeholder + /// `agent_json`/`provider`. `agent_pubkey` is still validated and + /// normalized either way, since a revocation is routed by it. + /// /// # Errors /// See [`EnrolmentError`] — every variant is a refusal to trust the credential. pub fn verify(&self, authorized_owners: &[String]) -> Result { @@ -362,10 +475,47 @@ impl SignedCredential { let body: CredentialBody = serde_json::from_str(&self.body_json) .map_err(|e| EnrolmentError::MalformedBody(e.to_string()))?; - Ok(body) + validate_credential_body(body) } } +/// Validate and normalize a parsed [`CredentialBody`] as one semantic unit, +/// after signature verification has already established the owner is +/// trusted — see [`SignedCredential::verify`]'s doc for why the nsec/ +/// auth_tag checks are conditioned on `revoked`. +/// +/// # Errors +/// [`EnrolmentError::InvalidCredentialAgentPubkey`] if `agent_pubkey` does +/// not parse as a canonical Nostr public key; for a non-revoked credential, +/// also [`EnrolmentError::MalformedNsec`], [`EnrolmentError::CredentialKeyMismatch`], +/// or [`EnrolmentError::MalformedAuthTag`]. +fn validate_credential_body(body: CredentialBody) -> Result { + let agent_pubkey = normalize_pubkey(&body.agent_pubkey); + nostr::PublicKey::from_hex(&agent_pubkey).map_err(|e| { + EnrolmentError::InvalidCredentialAgentPubkey(format!("malformed agent_pubkey: {e}")) + })?; + + if !body.revoked { + let agent_keys = Keys::parse(body.nsec.trim()) + .map_err(|e| EnrolmentError::MalformedNsec(e.to_string()))?; + let derived = normalize_pubkey(&agent_keys.public_key().to_hex()); + if derived != agent_pubkey { + return Err(EnrolmentError::CredentialKeyMismatch { + claimed: agent_pubkey, + }); + } + if let Some(auth_tag) = &body.auth_tag { + Tag::parse(auth_tag.clone()) + .map_err(|e| EnrolmentError::MalformedAuthTag(e.to_string()))?; + } + } + + Ok(CredentialBody { + agent_pubkey, + ..body + }) +} + /// Parse `WAKER_OWNER_PUBKEYS` — a comma-separated list of hex owner /// pubkeys this daemon will ever trust to enrol a *new* agent (one with no /// `FloorStore` yet). See the module doc's Trust anchor section for why this @@ -408,6 +558,7 @@ pub fn parse_authorized_owners(raw: &str) -> anyhow::Result> { mod tests { use super::*; use nostr::secp256k1::rand::rngs::OsRng; + use nostr::ToBech32; fn keypair() -> Keypair { Keypair::new(SECP256K1, &mut OsRng) @@ -418,10 +569,18 @@ mod tests { hex::encode(xonly.serialize()) } - fn roster_body(owner_agent: &str) -> RosterBody { + /// A pubkey suitable for a roster entry where no matching `nsec` is + /// needed — real generated key so `nostr::PublicKey::from_hex` accepts + /// it, unlike an arbitrary repeated-byte string (not every 32-byte + /// value is a valid secp256k1 x-only point). + fn agent_pubkey_hex() -> String { + Keys::generate().public_key().to_hex() + } + + fn roster_body(agent_pubkey: &str) -> RosterBody { RosterBody { entries: vec![RosterEntry { - agent_pubkey: owner_agent.to_string(), + agent_pubkey: agent_pubkey.to_string(), credential_version: 1, }], roster_version: 1, @@ -429,10 +588,13 @@ mod tests { } } - fn credential_body(agent_pubkey: &str) -> CredentialBody { + /// A credential whose `nsec` genuinely derives `agent_pubkey` — the + /// shape `SignedCredential::verify` now requires for a non-revoked + /// delivery. + fn credential_body(agent: &Keys) -> CredentialBody { CredentialBody { - agent_pubkey: agent_pubkey.to_string(), - nsec: "nsec1thisisthefakeagentsigningkey".to_string(), + agent_pubkey: agent.public_key().to_hex(), + nsec: agent.secret_key().to_bech32().expect("valid nsec"), auth_tag: None, credential_version: 1, issued_at: 1_000, @@ -440,11 +602,27 @@ mod tests { } } + fn revoked_credential_body(agent_pubkey: &str) -> CredentialBody { + CredentialBody { + agent_pubkey: agent_pubkey.to_string(), + // Unread by a revoked delivery, same convention + // `sign_and_retain_waker_bundle_at` uses for a revoked bundle's + // agent_json/provider placeholders — an empty nsec would fail + // Keys::parse, which is exactly why verify() must not attempt + // that parse for a revocation. + nsec: String::new(), + auth_tag: None, + credential_version: 2, + issued_at: 2_000, + revoked: true, + } + } + #[test] fn a_roster_signed_by_an_authorized_owner_verifies() { let owner = keypair(); let owner_hex = owner_hex(&owner); - let signed = SignedRoster::sign(&roster_body(&"a".repeat(64)), &owner).expect("signs"); + let signed = SignedRoster::sign(&roster_body(&agent_pubkey_hex()), &owner).expect("signs"); let body = signed .verify(&[owner_hex]) @@ -456,7 +634,7 @@ mod tests { fn a_roster_signed_by_an_unauthorized_owner_is_refused() { let owner = keypair(); let other = keypair(); - let signed = SignedRoster::sign(&roster_body(&"a".repeat(64)), &owner).expect("signs"); + let signed = SignedRoster::sign(&roster_body(&agent_pubkey_hex()), &owner).expect("signs"); let error = signed .verify(&[owner_hex(&other)]) @@ -470,9 +648,10 @@ mod tests { // allowlist must report UnauthorizedOwner, not BadSignature — // identity before cryptography, matching SignedLaunchBundle::verify. let owner = keypair(); - let mut signed = SignedRoster::sign(&roster_body(&"a".repeat(64)), &owner).expect("signs"); + let mut signed = + SignedRoster::sign(&roster_body(&agent_pubkey_hex()), &owner).expect("signs"); signed.body_json = - serde_json::to_string(&roster_body(&"b".repeat(64))).expect("serializes"); + serde_json::to_string(&roster_body(&agent_pubkey_hex())).expect("serializes"); let other = keypair(); let error = signed @@ -484,9 +663,10 @@ mod tests { #[test] fn a_tampered_roster_body_fails_signature_verification() { let owner = keypair(); - let mut signed = SignedRoster::sign(&roster_body(&"a".repeat(64)), &owner).expect("signs"); + let mut signed = + SignedRoster::sign(&roster_body(&agent_pubkey_hex()), &owner).expect("signs"); signed.body_json = - serde_json::to_string(&roster_body(&"b".repeat(64))).expect("serializes"); + serde_json::to_string(&roster_body(&agent_pubkey_hex())).expect("serializes"); let error = signed .verify(&[owner_hex(&owner)]) @@ -494,17 +674,85 @@ mod tests { assert_eq!(error, EnrolmentError::BadSignature); } + #[test] + fn a_roster_naming_the_same_agent_twice_is_refused() { + let owner = keypair(); + let agent = agent_pubkey_hex(); + let body = RosterBody { + entries: vec![ + RosterEntry { + agent_pubkey: agent.clone(), + credential_version: 1, + }, + RosterEntry { + agent_pubkey: agent, + credential_version: 1, + }, + ], + roster_version: 1, + issued_at: 1_000, + }; + let signed = SignedRoster::sign(&body, &owner).expect("signs"); + + let error = signed + .verify(&[owner_hex(&owner)]) + .expect_err("duplicate agent refused"); + assert!(matches!(error, EnrolmentError::InvalidRosterEntry(_))); + } + + #[test] + fn a_roster_with_a_malformed_agent_pubkey_is_refused() { + let owner = keypair(); + let signed = SignedRoster::sign(&roster_body("not-a-pubkey"), &owner).expect("signs"); + + let error = signed + .verify(&[owner_hex(&owner)]) + .expect_err("malformed pubkey refused"); + assert!(matches!(error, EnrolmentError::InvalidRosterEntry(_))); + } + + #[test] + fn a_roster_exceeding_the_entry_limit_is_refused() { + let owner = keypair(); + let entries = (0..=MAX_ROSTER_ENTRIES) + .map(|_| RosterEntry { + agent_pubkey: agent_pubkey_hex(), + credential_version: 1, + }) + .collect(); + let body = RosterBody { + entries, + roster_version: 1, + issued_at: 1_000, + }; + let signed = SignedRoster::sign(&body, &owner).expect("signs"); + + let error = signed + .verify(&[owner_hex(&owner)]) + .expect_err("over-limit roster refused"); + assert!(matches!(error, EnrolmentError::InvalidRosterEntry(_))); + } + + #[test] + fn a_roster_entry_pubkey_is_normalized_to_lowercase() { + let owner = keypair(); + let agent = agent_pubkey_hex().to_uppercase(); + let signed = SignedRoster::sign(&roster_body(&agent), &owner).expect("signs"); + + let body = signed.verify(&[owner_hex(&owner)]).expect("verifies"); + assert_eq!(body.entries[0].agent_pubkey, agent.to_lowercase()); + } + #[test] fn a_credential_signed_by_an_authorized_owner_verifies() { let owner = keypair(); - let agent = "a".repeat(64); + let agent = Keys::generate(); let signed = SignedCredential::sign(&credential_body(&agent), &owner).expect("signs"); let body = signed .verify(&[owner_hex(&owner)]) .expect("authorized owner verifies"); - assert_eq!(body.agent_pubkey, agent); - assert_eq!(body.nsec, "nsec1thisisthefakeagentsigningkey"); + assert_eq!(body.agent_pubkey, agent.public_key().to_hex()); } #[test] @@ -512,7 +760,7 @@ mod tests { let owner = keypair(); let other = keypair(); let signed = - SignedCredential::sign(&credential_body(&"a".repeat(64)), &owner).expect("signs"); + SignedCredential::sign(&credential_body(&Keys::generate()), &owner).expect("signs"); let error = signed .verify(&[owner_hex(&other)]) @@ -520,21 +768,102 @@ mod tests { assert!(matches!(error, EnrolmentError::UnauthorizedOwner { .. })); } + #[test] + fn a_credential_whose_nsec_derives_a_different_agent_is_refused() { + // An owner-valid signature over {agent_pubkey: A, nsec: key-for-B} + // must not verify — this is the P1 finding: later phases key + // durable state by agent_pubkey while a connection would + // authenticate as whatever the nsec actually derives. + let owner = keypair(); + let claimed_agent = Keys::generate(); + let mut body = credential_body(&Keys::generate()); + body.agent_pubkey = claimed_agent.public_key().to_hex(); + let signed = SignedCredential::sign(&body, &owner).expect("signs"); + + let error = signed + .verify(&[owner_hex(&owner)]) + .expect_err("mismatched nsec/agent_pubkey refused"); + assert!(matches!( + error, + EnrolmentError::CredentialKeyMismatch { .. } + )); + } + + #[test] + fn a_credential_with_an_unparseable_nsec_is_refused() { + let owner = keypair(); + let mut body = credential_body(&Keys::generate()); + body.nsec = "not-an-nsec".to_string(); + let signed = SignedCredential::sign(&body, &owner).expect("signs"); + + let error = signed + .verify(&[owner_hex(&owner)]) + .expect_err("unparseable nsec refused"); + assert!(matches!(error, EnrolmentError::MalformedNsec(_))); + } + + #[test] + fn a_credential_with_a_malformed_auth_tag_is_refused() { + let owner = keypair(); + let mut body = credential_body(&Keys::generate()); + body.auth_tag = Some(vec![]); + let signed = SignedCredential::sign(&body, &owner).expect("signs"); + + let error = signed + .verify(&[owner_hex(&owner)]) + .expect_err("malformed auth_tag refused"); + assert!(matches!(error, EnrolmentError::MalformedAuthTag(_))); + } + + #[test] + fn a_revoked_credential_skips_nsec_and_auth_tag_validation() { + // The issuer leaves nsec as an empty placeholder for a revocation — + // Keys::parse("") would fail, so verify() must not attempt it here. + let owner = keypair(); + let agent = agent_pubkey_hex(); + let signed = + SignedCredential::sign(&revoked_credential_body(&agent), &owner).expect("signs"); + + let body = signed + .verify(&[owner_hex(&owner)]) + .expect("revocation verifies despite placeholder nsec"); + assert!(body.revoked); + assert_eq!(body.agent_pubkey, agent); + } + + #[test] + fn a_revoked_credential_still_validates_its_agent_pubkey() { + let owner = keypair(); + let signed = SignedCredential::sign(&revoked_credential_body("not-a-pubkey"), &owner) + .expect("signs"); + + let error = signed + .verify(&[owner_hex(&owner)]) + .expect_err("malformed agent_pubkey refused even for a revocation"); + assert!(matches!( + error, + EnrolmentError::InvalidCredentialAgentPubkey(_) + )); + } + #[test] fn a_credential_debug_impl_redacts_the_nsec() { - let body = credential_body(&"a".repeat(64)); + let agent = Keys::generate(); + let body = credential_body(&agent); + let nsec = body.nsec.clone(); let rendered = format!("{body:?}"); - assert!(!rendered.contains("nsec1thisisthefakeagentsigningkey")); + assert!(!rendered.contains(&nsec)); assert!(rendered.contains("")); } #[test] fn a_signed_credential_debug_impl_redacts_body_json() { let owner = keypair(); - let signed = - SignedCredential::sign(&credential_body(&"a".repeat(64)), &owner).expect("signs"); + let agent = Keys::generate(); + let nsec = agent.secret_key().to_bech32().expect("valid nsec"); + let signed = SignedCredential::sign(&credential_body(&agent), &owner).expect("signs"); let rendered = format!("{signed:?}"); - assert!(!rendered.contains("nsec1thisisthefakeagentsigningkey")); + assert!(!rendered.contains(&nsec)); } #[test] From c549d54e53296fadd05eadc9a51fc6ea737b15e1 Mon Sep 17 00:00:00 2001 From: Junchao Yan Date: Wed, 12 Aug 2026 22:03:18 -0700 Subject: [PATCH 03/10] feat(waker): add typed per-provider credential schema to CredentialBody MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates PR #48's schema to match docs/waker-agent-enrolment.md's now- approved multi-tenant delta (PR #47): a live CredentialBody can carry the agent owner's provider deploy credential, so one daemon can deploy on behalf of several owners. Adds ProviderCredential, a typed per-provider enum (Sprites { sprite_token } today) rather than an arbitrary environment map — the design doc explains why: Command::env() doesn't distinguish a credential from any other process-control variable, so an unconstrained map would let a tenant set LD_PRELOAD/PATH/proxy flags in the trusted provider subprocess. Actually spawning from a sanitized environment (daemon-controlled baseline plus this schema) is deploy-wiring, a later phase — this is schema only, matching Phase 1's scope. Validation mirrors nsec/auth_tag: checked for a live credential, skipped for a revocation (an issuer's placeholder value must not need to be well-formed). Debug redacts provider_credential the same way it already redacts nsec. WAKER_OWNER_PUBKEYS's fail-closed contract (round-2 finding on PR #47) needs no code change here: main.rs has no call site for parse_authorized_owners yet (Phase 2/3 wires that in), and the existing doc comment already states the correct contract for when it does. cargo test -p buzz-waker: 38 lib + 8 main pass (8 new tests). clippy -D warnings and fmt --check clean. Signed-off-by: Junchao Yan --- crates/buzz-waker/src/enrolment.rs | 141 ++++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 5 deletions(-) diff --git a/crates/buzz-waker/src/enrolment.rs b/crates/buzz-waker/src/enrolment.rs index 7c793dd4996..18c6560dff9 100644 --- a/crates/buzz-waker/src/enrolment.rs +++ b/crates/buzz-waker/src/enrolment.rs @@ -156,6 +156,13 @@ pub enum EnrolmentError { /// [`Self::MalformedNsec`]. #[error("malformed credential auth_tag: {0}")] MalformedAuthTag(String), + + /// [`CredentialBody::provider_credential`], if present, has an + /// empty/blank value for a field [`ProviderCredential`]'s schema + /// requires. Only checked for a live credential, same reasoning as + /// [`Self::MalformedNsec`]. + #[error("invalid provider_credential: {0}")] + InvalidProviderCredential(String), } /// One agent's membership entry inside a [`RosterBody`]. @@ -324,12 +331,48 @@ fn validate_roster_body(body: RosterBody) -> Result }) } +/// A tenant's provider deploy credential, carried inside a live +/// [`CredentialBody`] so one daemon can deploy on behalf of several owners. +/// +/// Deliberately **not** an arbitrary `{String: String}` environment map — +/// `docs/waker-agent-enrolment.md`'s Per-owner provider credentials section +/// explains why: `Command::env()` doesn't distinguish a credential from any +/// other process-control variable, so an unconstrained map would let a +/// tenant set `LD_PRELOAD`, `LD_LIBRARY_PATH`, `PATH`, or a provider's own +/// proxy/TLS/debug flags in the trusted subprocess the daemon spawns on +/// every tenant's behalf. Each variant instead names exactly the narrow set +/// of variables that provider's own credential resolution reads — nothing +/// more, so there is nothing extra to inject. +/// +/// Spawning is a separate, later concern (deploy wiring, not this schema): +/// the doc specifies these tenant-controlled values as one of two inputs to +/// the eventual child environment, layered on top of a fixed +/// daemon-controlled runtime baseline (`HOME` for Sprites — see +/// `buzz-backend-sprites::credentials::resolve`) that tenant data can never +/// override. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "provider", rename_all = "snake_case")] +pub enum ProviderCredential { + /// Sprites: `credentials::resolve()` checks `SPRITE_TOKEN`, then the + /// `SPRITES_TOKEN` compatibility alias, then the keychain + /// (`crates/buzz-backend-sprites/src/credentials.rs`). Enrolment is a + /// new path with no existing callers to stay compatible with, so it + /// carries only the primary variable — no reason to also accept the + /// alias. + Sprites { + /// The tenant's own Sprites API token. Secret — never logged, never + /// in an error string, zeroized after use, same as [`CredentialBody::nsec`]. + sprite_token: String, + }, +} + /// The signed content of one agent's delivered credential. /// -/// `Debug` is implemented by hand and redacts [`Self::nsec`] — the whole -/// point of this type is carrying a private key, and a derived `Debug` would -/// print it into any log line, span field, or failed-assertion message. -/// Matches how [`crate::bundle::LaunchBundleBody`] redacts `agent_json`. +/// `Debug` is implemented by hand and redacts [`Self::nsec`] and +/// [`Self::provider_credential`] — the whole point of this type is carrying +/// private keys and tokens, and a derived `Debug` would print them into any +/// log line, span field, or failed-assertion message. Matches how +/// [`crate::bundle::LaunchBundleBody`] redacts `agent_json`. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CredentialBody { /// Hex pubkey of the agent this credential belongs to. @@ -343,6 +386,12 @@ pub struct CredentialBody { /// same shape and meaning as `AgentConfig::auth_tag` in `main.rs`. #[serde(default)] pub auth_tag: Option>, + /// This agent owner's provider deploy credential, if this waker deploys + /// on the owner's behalf rather than the daemon's own configured + /// provider path. Absent for an agent that doesn't need one. See + /// [`ProviderCredential`] for why this is a typed schema, not a map. + #[serde(default)] + pub provider_credential: Option, /// Monotonic issuance counter for this agent's credential, gated the /// same way [`crate::floors::FloorStore`] already gates bundle versions /// once this crosses into wire I/O (Phase 2/3). Matched against the @@ -364,6 +413,10 @@ impl std::fmt::Debug for CredentialBody { .field("agent_pubkey", &self.agent_pubkey) .field("nsec", &"") .field("auth_tag", &self.auth_tag) + .field( + "provider_credential", + &self.provider_credential.as_ref().map(|_| ""), + ) .field("credential_version", &self.credential_version) .field("issued_at", &self.issued_at) .field("revoked", &self.revoked) @@ -488,7 +541,7 @@ impl SignedCredential { /// [`EnrolmentError::InvalidCredentialAgentPubkey`] if `agent_pubkey` does /// not parse as a canonical Nostr public key; for a non-revoked credential, /// also [`EnrolmentError::MalformedNsec`], [`EnrolmentError::CredentialKeyMismatch`], -/// or [`EnrolmentError::MalformedAuthTag`]. +/// [`EnrolmentError::MalformedAuthTag`], or [`EnrolmentError::InvalidProviderCredential`]. fn validate_credential_body(body: CredentialBody) -> Result { let agent_pubkey = normalize_pubkey(&body.agent_pubkey); nostr::PublicKey::from_hex(&agent_pubkey).map_err(|e| { @@ -508,6 +561,13 @@ fn validate_credential_body(body: CredentialBody) -> Result")); } + #[test] + fn a_credential_debug_impl_redacts_the_provider_credential() { + let agent = Keys::generate(); + let mut body = credential_body(&agent); + body.provider_credential = Some(ProviderCredential::Sprites { + sprite_token: "tok-should-not-appear".to_string(), + }); + let rendered = format!("{body:?}"); + assert!(!rendered.contains("tok-should-not-appear")); + } + #[test] fn a_signed_credential_debug_impl_redacts_body_json() { let owner = keypair(); From 078727513aae0fc75a334d3a5959c51fc38616b7 Mon Sep 17 00:00:00 2001 From: Junchao Yan Date: Wed, 12 Aug 2026 22:09:12 -0700 Subject: [PATCH 04/10] fix(waker): redact ProviderCredential's own Debug output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Alex's REQUEST-CHANGES on PR #48 (c549d54e): ProviderCredential derived Debug, so formatting it directly — not nested inside CredentialBody, whose manual formatter only redacts the enclosing-body case — printed sprite_token verbatim. Any future error, assertion, or log line in deploy wiring that formats a bare ProviderCredential would have leaked the tenant's token. Replaces the derive with a manual redacting Debug, matching the existing pattern for CredentialBody and buzz-backend-sprites::Credential. CredentialBody's own formatter now just defers to it instead of a separate ad hoc redaction. Added a test that formats ProviderCredential directly, per the finding. cargo test -p buzz-waker: 230 lib + 8 main pass (1 new test). clippy -D warnings and fmt --check clean. Signed-off-by: Junchao Yan --- crates/buzz-waker/src/enrolment.rs | 40 ++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/crates/buzz-waker/src/enrolment.rs b/crates/buzz-waker/src/enrolment.rs index 18c6560dff9..dc4aaee9f2c 100644 --- a/crates/buzz-waker/src/enrolment.rs +++ b/crates/buzz-waker/src/enrolment.rs @@ -350,7 +350,14 @@ fn validate_roster_body(body: RosterBody) -> Result /// daemon-controlled runtime baseline (`HOME` for Sprites — see /// `buzz-backend-sprites::credentials::resolve`) that tenant data can never /// override. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// +/// `Debug` is implemented by hand and redacts every variant's secret field — +/// deriving it would print `sprite_token` verbatim any time this type is +/// formatted directly (an error path, an assertion, a future log line in +/// deploy wiring), not just when it's nested inside [`CredentialBody`]'s own +/// manual formatter. Matches [`CredentialBody`]'s and +/// `buzz-backend-sprites::Credential`'s existing pattern. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "provider", rename_all = "snake_case")] pub enum ProviderCredential { /// Sprites: `credentials::resolve()` checks `SPRITE_TOKEN`, then the @@ -366,6 +373,17 @@ pub enum ProviderCredential { }, } +impl std::fmt::Debug for ProviderCredential { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Sprites { .. } => f + .debug_struct("Sprites") + .field("sprite_token", &"") + .finish(), + } + } +} + /// The signed content of one agent's delivered credential. /// /// `Debug` is implemented by hand and redacts [`Self::nsec`] and @@ -413,10 +431,7 @@ impl std::fmt::Debug for CredentialBody { .field("agent_pubkey", &self.agent_pubkey) .field("nsec", &"") .field("auth_tag", &self.auth_tag) - .field( - "provider_credential", - &self.provider_credential.as_ref().map(|_| ""), - ) + .field("provider_credential", &self.provider_credential) .field("credential_version", &self.credential_version) .field("issued_at", &self.issued_at) .field("revoked", &self.revoked) @@ -987,6 +1002,21 @@ mod tests { assert!(!rendered.contains("tok-should-not-appear")); } + #[test] + fn provider_credentials_own_debug_impl_redacts_the_token() { + // Formatted directly, not nested inside CredentialBody — the P1 + // finding was that CredentialBody's manual formatter only protects + // the enclosing-body case, so an error/assert/log path formatting a + // bare ProviderCredential (e.g. later deploy wiring) would otherwise + // print sprite_token verbatim via the derived impl. + let credential = ProviderCredential::Sprites { + sprite_token: "tok-should-not-appear".to_string(), + }; + let rendered = format!("{credential:?}"); + assert!(!rendered.contains("tok-should-not-appear")); + assert!(rendered.contains("")); + } + #[test] fn a_signed_credential_debug_impl_redacts_body_json() { let owner = keypair(); From 008bcd60d2050de8eab7deed15b4da84d1ac192d Mon Sep 17 00:00:00 2001 From: Junchao Yan Date: Wed, 12 Aug 2026 22:26:14 -0700 Subject: [PATCH 05/10] feat(waker): roster tap + per-agent credential tap (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements build order step 2 from PLANS/BUZZ_WAKER_DESIGN.md §12: the wire I/O half of agent enrolment, on top of Phase 1's schema (PR #48). roster_feed.rs — one connection for the whole daemon, authenticated as the waker's own identity rather than any watched agent's: a roster's whole job is telling the daemon which agents exist before it can authenticate as any of them. RosterState tracks the latest roster *per owner* (WAKER_OWNER_PUBKEYS may list several, and one owner's roster says nothing about another's membership), applying a delivery only if its roster_version exceeds what's already tracked for that owner — correct regardless of relay delivery order, not just the common created_at DESC case. run_roster_tap refuses to open a connection at all when given no authorized owners, enforcing the design's fail-closed contract (docs/waker-agent-enrolment.md, approved) itself rather than trusting main.rs's future wiring alone. credential_feed.rs mirrors bundle_feed.rs closely — same connect/ backoff/idle-timeout loop, same decrypt-verify-admit split against a per-agent FloorStore (bundle_feed's NIP44_CONTENT_LEN_RANGE constant is now pub(crate) and reused directly rather than duplicated) — with the two changes the design calls for: it also connects and decrypts as the waker identity, since the daemon doesn't have the target agent's key yet, and the query adds #d pinned to the target agent's pubkey, since #p is now the waker's shared identity rather than agent-specific. Both taps re-check their own #d value per received frame (roster: the fixed sentinel; credential: the specific agent pubkey) as defense in depth against a filter bug crossing the two streams, per the design's own instruction. Not wired into main.rs — diffing RosterState against the daemon's watch list and spawning/cancelling credential taps is step 3, the dynamic supervisor, deliberately not started here. cargo test -p buzz-waker: 251 lib + 8 main pass (21 new tests). clippy -D warnings and fmt --check clean. Signed-off-by: Junchao Yan --- crates/buzz-waker/src/bundle_feed.rs | 6 +- crates/buzz-waker/src/credential_feed.rs | 746 +++++++++++++++++++++ crates/buzz-waker/src/lib.rs | 16 +- crates/buzz-waker/src/roster_feed.rs | 801 +++++++++++++++++++++++ 4 files changed, 1565 insertions(+), 4 deletions(-) create mode 100644 crates/buzz-waker/src/credential_feed.rs create mode 100644 crates/buzz-waker/src/roster_feed.rs diff --git a/crates/buzz-waker/src/bundle_feed.rs b/crates/buzz-waker/src/bundle_feed.rs index 48a07bffb93..28367f9031a 100644 --- a/crates/buzz-waker/src/bundle_feed.rs +++ b/crates/buzz-waker/src/bundle_feed.rs @@ -55,7 +55,11 @@ pub const BUNDLE_TAP_IDLE_TIMEOUT_SECS: u64 = 300; /// (`buzz_core::pairing::session`'s own NIP-AB validation applies the same /// range) — reject anything outside it before attempting decryption rather /// than handing an oversized or malformed string to the decryptor. -const NIP44_CONTENT_LEN_RANGE: std::ops::RangeInclusive = 132..=87472; +/// +/// `pub(crate)` so [`crate::roster_feed`] and [`crate::credential_feed`] (the +/// enrolment taps, which decrypt the same kind-1059 envelope) apply the exact +/// same bound rather than a second copy of this magic range. +pub(crate) const NIP44_CONTENT_LEN_RANGE: std::ops::RangeInclusive = 132..=87472; /// How many envelopes to ask for on subscribe. /// diff --git a/crates/buzz-waker/src/credential_feed.rs b/crates/buzz-waker/src/credential_feed.rs new file mode 100644 index 00000000000..880965096a4 --- /dev/null +++ b/crates/buzz-waker/src/credential_feed.rs @@ -0,0 +1,746 @@ +//! The per-agent credential-delivery tap — daemon-side counterpart to +//! [`crate::roster_feed`] (`docs/waker-agent-enrolment.md`, +//! `PLANS/BUZZ_WAKER_DESIGN.md` §12, build order step 2). +//! +//! Shape closely mirrors [`crate::bundle_feed`] — same connect/backoff/ +//! idle-timeout machinery, same decrypt-then-verify-then-admit split against +//! a per-agent [`FloorStore`] — with two deliberate differences the design +//! doc's Per-agent credential delivery section calls for: +//! +//! - **Connects and decrypts as the daemon's own waker identity**, not the +//! target agent's. A credential tap's whole purpose is delivering an +//! agent's own `nsec` to the daemon *before* the daemon has it — there is +//! no agent identity to authenticate as yet. [`crate::roster_feed`] shares +//! this same reasoning. +//! - **The query adds `#d` pinned to the target agent's pubkey.** A bundle +//! tap's `#p` alone disambiguates one agent from another because `#p` is +//! that agent's own identity; here `#p` is the waker's identity, shared +//! across every agent one owner enrols, so `#d` is what disambiguates. +//! [`credential_frame`] re-checks it per delivered frame as defense in +//! depth, the same reasoning [`crate::roster_feed::roster_frame`] applies +//! to its own fixed `#d`. +//! +//! # What this tap does *not* do +//! +//! Decide whether a brand-new agent should be trusted at all, or supervise +//! anything. The caller is responsible for constructing the +//! [`FloorStore`] this tap admits against — for an agent this daemon has +//! never seen, that means enrolling one with an owner pubkey already proven +//! against `WAKER_OWNER_PUBKEYS` (typically via an already-verified roster +//! entry), *before* calling [`run_credential_tap`]. This module only wires an +//! already-decided trust anchor to a live socket, exactly the split +//! [`crate::bundle_feed`] already keeps. + +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; + +use buzz_core::kind::KIND_WAKER_BUNDLE_ENVELOPE; +use buzz_ws_client::{NostrWsConnection, RelayMessage, WsClientError}; +use nostr::{Keys, Tag}; +use serde_json::{json, Value}; +use tokio_util::sync::CancellationToken; +use zeroize::Zeroize; + +use crate::bundle_feed::NIP44_CONTENT_LEN_RANGE; +use crate::decide::normalize_pubkey; +use crate::enrolment::{CredentialBody, SignedCredential}; +use crate::feed::reconnect_delay_ms; +use crate::floors::FloorStore; + +/// Subscription id for one agent's credential-delivery tap. Fixed, like every +/// other tap's own id — a reconnect replaces the old subscription rather than +/// piling up a fresh one. +pub const CREDENTIAL_TAP_SUBSCRIPTION_ID: &str = "buzz-waker-credential"; + +/// How long to wait for a frame before treating the tap connection as idle. +/// +/// Matches [`crate::bundle_feed::BUNDLE_TAP_IDLE_TIMEOUT_SECS`]'s own +/// reasoning: a credential is reissued on rotation or config change only, +/// never as a liveness ping. +pub const CREDENTIAL_TAP_IDLE_TIMEOUT_SECS: u64 = 300; + +/// How many envelopes to ask for on subscribe. Same value and reasoning as +/// [`crate::bundle_feed::BUNDLE_QUERY_LIMIT`] / [`crate::roster_feed::ROSTER_QUERY_LIMIT`]. +const CREDENTIAL_QUERY_LIMIT: u32 = 16; + +/// The REQ filter for one agent's credential tap: global, `authors` pinned to +/// the enrolment-pinned owner, `#p` pinned to the **waker's own** identity, +/// `#d` pinned to the target agent's pubkey. +#[must_use] +pub fn credential_filter(owner_pubkey: &str, waker_pubkey: &str, agent_pubkey: &str) -> Value { + json!({ + "kinds": [KIND_WAKER_BUNDLE_ENVELOPE], + "authors": [normalize_pubkey(owner_pubkey)], + "#p": [normalize_pubkey(waker_pubkey)], + "#d": [normalize_pubkey(agent_pubkey)], + "limit": CREDENTIAL_QUERY_LIMIT, + }) +} + +/// The REQ frame opening one agent's credential-delivery tap. +#[must_use] +pub fn credential_req(owner_pubkey: &str, waker_pubkey: &str, agent_pubkey: &str) -> Value { + json!([ + "REQ", + CREDENTIAL_TAP_SUBSCRIPTION_ID, + credential_filter(owner_pubkey, waker_pubkey, agent_pubkey) + ]) +} + +/// Shared, thread-safe cache of one agent's current admitted credential. +/// +/// Mirrors [`crate::bundle_feed::BundleState`]'s shape exactly, one instance +/// per watched agent — the tap task ([`run_credential_tap`]) owns the +/// connection and the [`FloorStore`], and writes here; everything else only +/// reads. +#[derive(Debug, Default)] +pub struct CredentialState { + inner: Mutex>>, +} + +impl CredentialState { + /// A tap with nothing admitted yet. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Recover from a poisoned lock rather than propagating it — a panic in + /// one reader must not permanently blind every future credential lookup + /// for this agent. + fn lock(&self) -> std::sync::MutexGuard<'_, Option>> { + self.inner.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Record a newly admitted credential as the current one. + pub fn set(&self, body: CredentialBody) { + *self.lock() = Some(Arc::new(body)); + } + + /// Drop whatever credential is currently held, in response to an + /// owner-signed revocation. + pub fn clear(&self) { + *self.lock() = None; + } + + /// The current admitted credential, if any has been delivered and + /// admitted on this daemon run yet. + #[must_use] + pub fn current(&self) -> Option> { + self.lock().clone() + } +} + +/// What one delivered relay message means for this tap. +/// +/// Mirrors [`crate::bundle_feed`]'s own `BundleFrame` convention: everything +/// this tap does not read collapses to [`CredentialFrame::Ignored`]. +#[derive(Debug, PartialEq, Eq)] +enum CredentialFrame { + /// A verified envelope delivery, authored by the pinned owner and tagged + /// for the target agent, carrying its raw (still-encrypted) content. + Delivered { ciphertext: String }, + /// An event on this subscription that failed signature verification. + Rejected { event_id: String, reason: String }, + /// This subscription was closed by the relay. + Closed { message: String }, + /// A frame for a subscription this tap did not open, an event whose + /// kind/`#d`/author doesn't match (should be excluded by the filter + /// already — checked again here as defense in depth), or a message type + /// this tap has no use for. + Ignored, +} + +/// Classify one relay message for `owner_pubkey`/`agent_pubkey`'s credential +/// tap. +/// +/// Re-checking `#d` against `agent_pubkey` here is what stops a misrouted or +/// replayed event for a *different* agent under the same owner+waker from +/// ever reaching the decrypt step — the roster's fixed sentinel can never +/// collide with a real agent pubkey (see [`crate::roster_feed`]'s module +/// doc), but two different agents' own `#d` values are both ordinary +/// canonical pubkeys and could otherwise be confused by a filter bug. +fn credential_frame( + owner_pubkey: &str, + agent_pubkey: &str, + message: RelayMessage, +) -> CredentialFrame { + match message { + RelayMessage::Event { + subscription_id, + event, + } if subscription_id == CREDENTIAL_TAP_SUBSCRIPTION_ID => { + if let Err(error) = buzz_core::verify_event(&event) { + return CredentialFrame::Rejected { + event_id: event.id.to_hex(), + reason: error.to_string(), + }; + } + if buzz_core::kind::event_kind_u32(&event) != KIND_WAKER_BUNDLE_ENVELOPE { + return CredentialFrame::Ignored; + } + if event.tags.identifier().map(normalize_pubkey) != Some(normalize_pubkey(agent_pubkey)) + { + return CredentialFrame::Ignored; + } + if normalize_pubkey(&event.pubkey.to_hex()) != normalize_pubkey(owner_pubkey) { + return CredentialFrame::Ignored; + } + CredentialFrame::Delivered { + ciphertext: event.content.clone(), + } + } + RelayMessage::Closed { + subscription_id, + message, + } if subscription_id == CREDENTIAL_TAP_SUBSCRIPTION_ID => { + CredentialFrame::Closed { message } + } + _ => CredentialFrame::Ignored, + } +} + +/// What a decrypted, verified delivery means for the tap's caller. +/// +/// Mirrors [`crate::bundle_feed`]'s own `BundleOutcome` — same three cases, +/// same reasoning for each, adapted to a credential's `credential_version`. +#[derive(Debug, PartialEq)] +enum CredentialOutcome { + /// A credential to hold as the current one. + Delivered(CredentialBody), + /// An owner-signed revocation. The revocation floor has already been + /// raised (durably, best-effort) by the time this is returned; the + /// caller's only remaining job is to drop whatever it was holding. + Revoked, + /// An owner-signed revocation whose version is below the version already + /// admitted — a later, still-valid reissue has already superseded it. + /// See [`crate::bundle_feed`]'s own `BundleOutcome::StaleRevocation` doc + /// for why the currently held credential must not be cleared here. + StaleRevocation, +} + +/// Decrypt, verify, and admit (or revoke) one delivered credential. +/// +/// `waker_keys` is the **daemon's own** keypair (the NIP-44 recipient); the +/// ciphertext was encrypted to it — not the target agent's own keys, which +/// this tap does not have yet. The sender side of the ECDH is `owner_pubkey` +/// — already confirmed to be the event's own `pubkey` by [`credential_frame`], +/// which is itself confirmed to be the relay-authenticated signer by +/// ordinary ingest (this kind gets no gift-wrap exemption). +/// +/// Order matches [`crate::bundle_feed::decrypt_verify_and_admit`]'s own doc: +/// decrypt (confidentiality) is not a trust decision; `verify` (against the +/// `FloorStore`-pinned owner) is. +/// +/// # Errors +/// A human-readable message on any failure — malformed/oversized ciphertext, +/// a decrypt failure, a parse failure, a failed inner signature check, or a +/// floor refusal (revoked/rolled-back version). Every path is a refusal to +/// admit, never a credential in the error text. +fn decrypt_verify_and_admit( + waker_keys: &Keys, + owner_pubkey: &str, + agent_pubkey: &str, + ciphertext: &str, + floor_store: &mut FloorStore, +) -> Result { + if !NIP44_CONTENT_LEN_RANGE.contains(&ciphertext.len()) { + return Err(format!( + "credential ciphertext outside the expected NIP-44 size range ({} chars)", + ciphertext.len() + )); + } + let owner_pk = nostr::PublicKey::from_hex(owner_pubkey) + .map_err(|error| format!("malformed owner pubkey: {error}"))?; + let mut plaintext = nostr::nips::nip44::decrypt(waker_keys.secret_key(), &owner_pk, ciphertext) + .map_err(|error| format!("NIP-44 decrypt failed: {error}"))?; + + let parsed: Result = serde_json::from_str(&plaintext); + plaintext.zeroize(); + let signed = parsed.map_err(|error| format!("malformed credential JSON: {error}"))?; + + let pinned_owner = floor_store + .pinned_owner() + .map_err(|error| format!("could not read pinned owner: {error}"))?; + let body = signed + .verify(&[pinned_owner]) + .map_err(|error| format!("credential verification failed: {error}"))?; + + // `verify` only checks the owner's signature over the body — it does + // not, and by design cannot, know which agent this tap instance is + // watching. An owner-signed credential whose `agent_pubkey` names a + // *different* agent must never reach `admit`: that would durably raise + // this agent's version floor for a credential that was never meant for + // it. Mirrors bundle_feed's own `agent_pubkey` check exactly, except the + // receiving identity here is the parameter, not `keys.public_key()` — + // this tap authenticates as the waker, not the agent it watches. + let target_agent = normalize_pubkey(agent_pubkey); + if normalize_pubkey(&body.agent_pubkey) != target_agent { + return Err(format!( + "credential targets agent {}, not the watched agent {target_agent}", + body.agent_pubkey + )); + } + + if body.revoked { + if let Err(error) = floor_store.raise_revocation_floor(body.credential_version) { + tracing::warn!( + agent = %target_agent, + %error, + "credential tap could not durably raise the revocation floor; revoking this run's cache anyway" + ); + } + + if body.credential_version < floor_store.snapshot().highest_accepted_version { + return Ok(CredentialOutcome::StaleRevocation); + } + return Ok(CredentialOutcome::Revoked); + } + + floor_store + .admit(body.credential_version) + .map_err(|error| format!("credential floor refused it: {error}"))?; + + Ok(CredentialOutcome::Delivered(body)) +} + +/// Run one agent's credential-delivery tap until `cancel` fires. +/// +/// Connects and authenticates as `waker_keys` — **not** `agent_pubkey`, see +/// the module doc — subscribes under [`CREDENTIAL_TAP_SUBSCRIPTION_ID`], and +/// folds every delivery into `state` via [`decrypt_verify_and_admit`]. +/// Reconnects on any transport error using the same ladder every other tap in +/// this daemon uses ([`reconnect_delay_ms`]). +/// +/// `floor_store` is owned by this task for its lifetime, same single-owner +/// shape [`crate::bundle_feed::run_bundle_tap`] uses for its own floor — the +/// caller is responsible for having already enrolled or opened it against a +/// trust anchor proven before this function is called (see the module doc). +/// +/// A malformed, undecryptable, or floor-refused delivery is logged and +/// skipped, not a reconnect. +#[allow(clippy::too_many_arguments)] +pub async fn run_credential_tap( + relay_url: &str, + waker_keys: &Keys, + auth_tag: Option<&Tag>, + owner_pubkey: &str, + agent_pubkey: &str, + floor_store: &mut FloorStore, + state: &CredentialState, + cancel: &CancellationToken, +) { + let waker_pubkey = waker_keys.public_key().to_hex(); + let owner_pubkey = normalize_pubkey(owner_pubkey); + let agent_pubkey = normalize_pubkey(agent_pubkey); + let mut consecutive_failures = 0u32; + + while !cancel.is_cancelled() { + if consecutive_failures > 0 { + let delay_ms = reconnect_delay_ms(consecutive_failures); + tokio::select! { + () = tokio::time::sleep(Duration::from_millis(delay_ms)) => {} + () = cancel.cancelled() => break, + } + } + + let connect = NostrWsConnection::connect_authenticated(relay_url, waker_keys, auth_tag); + let mut connection = tokio::select! { + result = connect => match result { + Ok(connection) => connection, + Err(error) => { + tracing::warn!(agent = %agent_pubkey, %error, "credential tap connect failed; backing off"); + consecutive_failures = consecutive_failures.saturating_add(1); + continue; + } + }, + () = cancel.cancelled() => break, + }; + + if let Err(error) = connection + .send_raw(&credential_req(&owner_pubkey, &waker_pubkey, &agent_pubkey)) + .await + { + tracing::warn!(agent = %agent_pubkey, %error, "credential tap subscribe failed; reconnecting"); + consecutive_failures = consecutive_failures.saturating_add(1); + continue; + } + consecutive_failures = 0; + + loop { + let next = tokio::select! { + result = connection.next_event(Duration::from_secs(CREDENTIAL_TAP_IDLE_TIMEOUT_SECS)) => result, + () = cancel.cancelled() => return, + }; + + match next { + Ok(message) => match credential_frame(&owner_pubkey, &agent_pubkey, message) { + CredentialFrame::Delivered { ciphertext } => { + match decrypt_verify_and_admit( + waker_keys, + &owner_pubkey, + &agent_pubkey, + &ciphertext, + floor_store, + ) { + Ok(CredentialOutcome::Delivered(body)) => { + tracing::info!( + agent = %agent_pubkey, + credential_version = body.credential_version, + "credential tap admitted a credential" + ); + state.set(body); + } + Ok(CredentialOutcome::Revoked) => { + tracing::info!( + agent = %agent_pubkey, + "credential tap received a revocation; clearing the cached credential" + ); + state.clear(); + } + Ok(CredentialOutcome::StaleRevocation) => { + tracing::info!( + agent = %agent_pubkey, + "credential tap received a revocation already superseded by a newer admitted credential; leaving the cache in place" + ); + } + Err(error) => { + tracing::warn!( + agent = %agent_pubkey, + %error, + "credential tap received a delivery it could not admit; ignoring" + ); + } + } + } + CredentialFrame::Rejected { event_id, reason } => { + tracing::warn!( + agent = %agent_pubkey, + event_id = %event_id, + %reason, + "credential tap received an event that failed verification; ignoring" + ); + } + CredentialFrame::Closed { message } => { + tracing::warn!( + agent = %agent_pubkey, + %message, + "credential tap subscription closed by relay; reconnecting" + ); + consecutive_failures = consecutive_failures.saturating_add(1); + break; + } + CredentialFrame::Ignored => {} + }, + Err(WsClientError::Timeout) => { + // A quiet tap is the normal case — see CREDENTIAL_TAP_IDLE_TIMEOUT_SECS. + } + Err(error) => { + tracing::warn!(agent = %agent_pubkey, %error, "credential tap connection lost; reconnecting"); + consecutive_failures = consecutive_failures.saturating_add(1); + break; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Kind, ToBech32}; + + fn credential_event(owner: &Keys, ciphertext: &str, agent_pubkey: &str) -> nostr::Event { + EventBuilder::new(Kind::Custom(KIND_WAKER_BUNDLE_ENVELOPE as u16), ciphertext) + .tags([ + Tag::parse(["d", agent_pubkey]).unwrap(), + Tag::parse(["p", &"w".repeat(64)]).unwrap(), + ]) + .sign_with_keys(owner) + .expect("sign") + } + + fn credential_body(agent_pubkey: &str, nsec: &str, credential_version: u64) -> CredentialBody { + CredentialBody { + agent_pubkey: agent_pubkey.to_string(), + nsec: nsec.to_string(), + auth_tag: None, + provider_credential: None, + credential_version, + issued_at: 1_000, + revoked: false, + } + } + + #[test] + fn the_query_pins_p_to_the_waker_and_d_to_the_agent() { + let owner_pubkey = "a".repeat(64); + let waker_pubkey = "b".repeat(64); + let agent_pubkey = "c".repeat(64); + let filter = credential_filter(&owner_pubkey, &waker_pubkey, &agent_pubkey); + + assert_eq!(filter["kinds"], json!([KIND_WAKER_BUNDLE_ENVELOPE])); + assert_eq!(filter["authors"], json!([owner_pubkey])); + assert_eq!( + filter["#p"], + json!([waker_pubkey]), + "#p must be the waker's own identity, not the agent's" + ); + assert_eq!(filter["#d"], json!([agent_pubkey])); + assert!( + filter["limit"].is_number(), + "the envelope is not replaceable, so the query must be bounded" + ); + } + + #[test] + fn a_verified_delivery_from_the_pinned_owner_tagged_for_this_agent_is_delivered() { + let owner = Keys::generate(); + let owner_pubkey = normalize_pubkey(&owner.public_key().to_hex()); + let agent_pubkey = "a".repeat(64); + let event = credential_event(&owner, "ciphertext-bytes", &agent_pubkey); + + let frame = credential_frame( + &owner_pubkey, + &agent_pubkey, + RelayMessage::Event { + subscription_id: CREDENTIAL_TAP_SUBSCRIPTION_ID.to_string(), + event: Box::new(event), + }, + ); + assert_eq!( + frame, + CredentialFrame::Delivered { + ciphertext: "ciphertext-bytes".to_string() + } + ); + } + + /// The whole reason `#d` re-checking exists for this tap: two different + /// agents under the same owner+waker must not be confused with each + /// other, unlike the roster's fixed sentinel which can never collide + /// with either. + #[test] + fn a_delivery_tagged_for_a_different_agent_is_ignored() { + let owner = Keys::generate(); + let owner_pubkey = normalize_pubkey(&owner.public_key().to_hex()); + let watched_agent = "a".repeat(64); + let other_agent = "b".repeat(64); + let event = credential_event(&owner, "ciphertext-bytes", &other_agent); + + let frame = credential_frame( + &owner_pubkey, + &watched_agent, + RelayMessage::Event { + subscription_id: CREDENTIAL_TAP_SUBSCRIPTION_ID.to_string(), + event: Box::new(event), + }, + ); + assert_eq!(frame, CredentialFrame::Ignored); + } + + #[test] + fn a_delivery_from_an_unpinned_signer_is_ignored() { + let owner = Keys::generate(); + let attacker = Keys::generate(); + let owner_pubkey = normalize_pubkey(&owner.public_key().to_hex()); + let agent_pubkey = "a".repeat(64); + let event = credential_event(&attacker, "ciphertext-bytes", &agent_pubkey); + + let frame = credential_frame( + &owner_pubkey, + &agent_pubkey, + RelayMessage::Event { + subscription_id: CREDENTIAL_TAP_SUBSCRIPTION_ID.to_string(), + event: Box::new(event), + }, + ); + assert_eq!(frame, CredentialFrame::Ignored); + } + + #[test] + fn a_frame_for_another_subscription_is_ignored() { + let owner = Keys::generate(); + let owner_pubkey = normalize_pubkey(&owner.public_key().to_hex()); + let agent_pubkey = "a".repeat(64); + let event = credential_event(&owner, "ciphertext-bytes", &agent_pubkey); + + let frame = credential_frame( + &owner_pubkey, + &agent_pubkey, + RelayMessage::Event { + subscription_id: "some-other-subscription".to_string(), + event: Box::new(event), + }, + ); + assert_eq!(frame, CredentialFrame::Ignored); + } + + #[test] + fn a_closed_frame_for_this_subscription_is_reported() { + let frame = credential_frame( + &"a".repeat(64), + &"b".repeat(64), + RelayMessage::Closed { + subscription_id: CREDENTIAL_TAP_SUBSCRIPTION_ID.to_string(), + message: "auth-required".to_string(), + }, + ); + assert_eq!( + frame, + CredentialFrame::Closed { + message: "auth-required".to_string() + } + ); + } + + #[test] + fn oversized_ciphertext_is_refused_before_any_decrypt_attempt() { + let waker = Keys::generate(); + let owner_pubkey = "a".repeat(64); + let agent_pubkey = "b".repeat(64); + let dir = tempfile::tempdir().unwrap(); + let mut floor_store = + FloorStore::enroll(dir.path().join("floor.json"), &owner_pubkey).unwrap(); + + let too_long = "x".repeat(NIP44_CONTENT_LEN_RANGE.end() + 1); + let error = decrypt_verify_and_admit( + &waker, + &owner_pubkey, + &agent_pubkey, + &too_long, + &mut floor_store, + ) + .unwrap_err(); + assert!(error.contains("size range"), "{error}"); + } + + #[test] + fn a_valid_round_trip_decrypts_verifies_and_admits() { + let owner = Keys::generate(); + let owner_pubkey = normalize_pubkey(&owner.public_key().to_hex()); + let waker = Keys::generate(); + let agent = Keys::generate(); + let dir = tempfile::tempdir().unwrap(); + let mut floor_store = + FloorStore::enroll(dir.path().join("floor.json"), &owner_pubkey).unwrap(); + + let body = credential_body( + &agent.public_key().to_hex(), + &agent.secret_key().to_bech32().unwrap(), + 1, + ); + let owner_keypair = + nostr::secp256k1::Keypair::from_secret_key(nostr::SECP256K1, owner.secret_key()); + let signed = SignedCredential::sign(&body, &owner_keypair).unwrap(); + let plaintext = serde_json::to_string(&signed).unwrap(); + let ciphertext = nostr::nips::nip44::encrypt( + owner.secret_key(), + &waker.public_key(), + &plaintext, + nostr::nips::nip44::Version::V2, + ) + .unwrap(); + + let admitted = decrypt_verify_and_admit( + &waker, + &owner_pubkey, + &agent.public_key().to_hex(), + &ciphertext, + &mut floor_store, + ) + .expect("round trip"); + assert_eq!( + admitted, + CredentialOutcome::Delivered( + signed + .verify(&[owner_pubkey]) + .expect("the same body the tap just admitted") + ) + ); + assert_eq!(floor_store.snapshot().highest_accepted_version, 1); + } + + #[test] + fn a_revocation_raises_the_floor_and_reports_revoked() { + let owner = Keys::generate(); + let owner_pubkey = normalize_pubkey(&owner.public_key().to_hex()); + let waker = Keys::generate(); + let agent = Keys::generate(); + let dir = tempfile::tempdir().unwrap(); + let mut floor_store = + FloorStore::enroll(dir.path().join("floor.json"), &owner_pubkey).unwrap(); + + let body = CredentialBody { + agent_pubkey: agent.public_key().to_hex(), + nsec: String::new(), + auth_tag: None, + provider_credential: None, + credential_version: 5, + issued_at: 0, + revoked: true, + }; + let owner_keypair = + nostr::secp256k1::Keypair::from_secret_key(nostr::SECP256K1, owner.secret_key()); + let signed = SignedCredential::sign(&body, &owner_keypair).unwrap(); + let plaintext = serde_json::to_string(&signed).unwrap(); + let ciphertext = nostr::nips::nip44::encrypt( + owner.secret_key(), + &waker.public_key(), + &plaintext, + nostr::nips::nip44::Version::V2, + ) + .unwrap(); + + let outcome = decrypt_verify_and_admit( + &waker, + &owner_pubkey, + &agent.public_key().to_hex(), + &ciphertext, + &mut floor_store, + ) + .expect("a revocation is not an error"); + assert_eq!(outcome, CredentialOutcome::Revoked); + assert_eq!(floor_store.snapshot().revocation_floor, 5); + } + + #[test] + fn a_credential_targeting_another_agent_is_refused_and_does_not_advance_the_floor() { + let owner = Keys::generate(); + let owner_pubkey = normalize_pubkey(&owner.public_key().to_hex()); + let waker = Keys::generate(); + let watched_agent = Keys::generate(); + let other_agent = Keys::generate(); + let dir = tempfile::tempdir().unwrap(); + let mut floor_store = + FloorStore::enroll(dir.path().join("floor.json"), &owner_pubkey).unwrap(); + + let body = credential_body( + &other_agent.public_key().to_hex(), + &other_agent.secret_key().to_bech32().unwrap(), + 1, + ); + let owner_keypair = + nostr::secp256k1::Keypair::from_secret_key(nostr::SECP256K1, owner.secret_key()); + let signed = SignedCredential::sign(&body, &owner_keypair).unwrap(); + let plaintext = serde_json::to_string(&signed).unwrap(); + let ciphertext = nostr::nips::nip44::encrypt( + owner.secret_key(), + &waker.public_key(), + &plaintext, + nostr::nips::nip44::Version::V2, + ) + .unwrap(); + + let error = decrypt_verify_and_admit( + &waker, + &owner_pubkey, + &watched_agent.public_key().to_hex(), + &ciphertext, + &mut floor_store, + ) + .expect_err("must be refused"); + assert!(error.contains("targets agent"), "{error}"); + assert_eq!(floor_store.snapshot().highest_accepted_version, 0); + } +} diff --git a/crates/buzz-waker/src/lib.rs b/crates/buzz-waker/src/lib.rs index 2b1a0d2c000..05c340ad650 100644 --- a/crates/buzz-waker/src/lib.rs +++ b/crates/buzz-waker/src/lib.rs @@ -42,10 +42,18 @@ //! - [`enrolment`] — agent enrolment over the relay: the pure schema/trust //! half (roster + per-agent credential payloads, `WAKER_OWNER_PUBKEYS`) of //! replacing hand-edited `WAKER_AGENTS_CONFIG_PATH` JSON. +//! - [`roster_feed`] — the **roster tap**: one connection for the whole +//! daemon, authenticated as its own waker identity, that discovers which +//! agent pubkeys each authorized owner currently enrols. +//! - [`credential_feed`] — the **per-agent credential tap**: mirrors +//! [`bundle_feed`]'s connect/backoff/idle-timeout shape, also authenticated +//! as the waker's own identity, decrypting and admitting one agent's +//! delivered `nsec` against a per-agent [`floors::FloorStore`]. //! `docs/waker-agent-enrolment.md` (design) and `PLANS/BUZZ_WAKER_DESIGN.md` -//! §12 (build order) — wire I/O (a `roster_feed`/credential tap mirroring -//! [`bundle_feed`]) and the dynamic per-agent supervisor `main.rs` needs to -//! act on it are later phases, not yet implemented. +//! §12 (build order) — the dynamic per-agent supervisor `main.rs` needs to +//! diff [`roster_feed::RosterState`] against the daemon's watch list and +//! spawn/cancel [`credential_feed::run_credential_tap`] instances is the +//! next phase, not yet implemented. //! //! Each exists because of a specific review finding and carries the gate id //! (`G1`–`G4`) it discharges, so the reason is not lost. @@ -53,6 +61,7 @@ pub mod attempt; pub mod bundle; pub mod bundle_feed; +pub mod credential_feed; pub mod cursor; pub mod decide; pub mod effects; @@ -62,6 +71,7 @@ mod fence; pub mod floors; pub mod presence_feed; pub mod relay_feed; +pub mod roster_feed; pub mod wake_loop; pub use attempt::{ diff --git a/crates/buzz-waker/src/roster_feed.rs b/crates/buzz-waker/src/roster_feed.rs new file mode 100644 index 00000000000..478eab46d15 --- /dev/null +++ b/crates/buzz-waker/src/roster_feed.rs @@ -0,0 +1,801 @@ +//! The roster tap — daemon-side discovery half of agent enrolment +//! (`docs/waker-agent-enrolment.md`, `PLANS/BUZZ_WAKER_DESIGN.md` §12, build +//! order step 2). +//! +//! One connection for the whole daemon, authenticated as **the waker's own +//! identity** (`WAKER_IDENTITY_NSEC`) — not per agent. That is the one place +//! this tap's shape genuinely diverges from [`crate::bundle_feed`]'s "one +//! connection per watched agent, authenticated as that agent": a roster's +//! whole job is telling the daemon which agents exist in the first place, so +//! there is no agent identity to authenticate as yet. [`crate::credential_feed`] +//! shares this same waker-identity connection shape for the same reason — +//! see its own module doc. +//! +//! # Discovery, not delivery +//! +//! [`crate::enrolment::RosterBody`] lists membership; it never carries a +//! secret. This tap's only job is producing the latest known roster per +//! owner in [`RosterState`] for a caller (the eventual dynamic supervisor, +//! `PLANS/BUZZ_WAKER_DESIGN.md` §12 build order step 3, not yet written) to +//! diff against the daemon's current watch list and act on. It does not +//! spawn, cancel, or otherwise supervise anything itself. +//! +//! # Multiple owners, one query +//! +//! Unlike a bundle or credential tap (pinned to one already-known owner), +//! this daemon may be configured with several owners in `WAKER_OWNER_PUBKEYS` +//! — each with their own roster, at the same fixed `d` coordinate but a +//! different `authors` entry. One REQ with `authors` set to the whole +//! authorized list covers all of them; [`RosterState`] then tracks each +//! owner's latest roster independently, keyed by owner pubkey, because one +//! owner's roster says nothing about another's membership. +//! +//! # Fail closed on an empty owner list +//! +//! `docs/waker-agent-enrolment.md`'s Two admission modes section (approved, +//! round 2): an empty `WAKER_OWNER_PUBKEYS` means enrolment is **disabled**, +//! not "open" — open mode has no owner-discovery mechanism and is not +//! implemented. [`run_roster_tap`] enforces this itself rather than trusting +//! its caller alone: given no authorized owners, it logs and returns without +//! ever opening a connection. A query with an empty `authors` filter would +//! at best match nothing and at worst behave relay-implementation-defined — +//! refusing before ever constructing one is the same defense-in-depth +//! reasoning the design doc already applies to the `#d` tag re-check below. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; + +use buzz_core::kind::KIND_WAKER_BUNDLE_ENVELOPE; +use buzz_ws_client::{NostrWsConnection, RelayMessage, WsClientError}; +use nostr::{Keys, Tag}; +use serde_json::{json, Value}; +use tokio_util::sync::CancellationToken; +use zeroize::Zeroize; + +use crate::bundle_feed::NIP44_CONTENT_LEN_RANGE; +use crate::decide::normalize_pubkey; +use crate::enrolment::{RosterBody, SignedRoster}; +use crate::feed::reconnect_delay_ms; + +/// Subscription id for the daemon's one roster tap. Fixed, like every other +/// tap's own id — a reconnect replaces the old subscription rather than +/// piling up a fresh one. +pub const ROSTER_TAP_SUBSCRIPTION_ID: &str = "buzz-waker-roster"; + +/// The fixed `d` tag every roster event carries — the public, collision-proof +/// discriminator `docs/waker-agent-enrolment.md`'s Discriminator section +/// settles on. A credential's own `d` is always a canonical 64-hex-char agent +/// pubkey, which can never equal this literal (it contains characters outside +/// `[0-9a-f]`), so the two streams cannot cross by construction — this tap +/// still re-checks it per frame in [`roster_frame`] as defense in depth +/// against a filter bug, the same reasoning already applied to +/// [`crate::bundle_feed`]'s `#p` check. +pub const ROSTER_D_TAG: &str = "waker-enrolment-roster"; + +/// How long to wait for a frame before treating the tap connection as idle. +/// +/// Matches [`crate::bundle_feed::BUNDLE_TAP_IDLE_TIMEOUT_SECS`]'s own +/// reasoning: a roster is republished only on add/remove/rotate, never as a +/// liveness ping, so long quiet stretches are the normal case. +pub const ROSTER_TAP_IDLE_TIMEOUT_SECS: u64 = 300; + +/// How many roster events to ask for per owner on subscribe. +/// +/// The envelope kind is not parameterized-replaceable (same reasoning as +/// [`crate::bundle_feed::BUNDLE_QUERY_LIMIT`]), so every reissue lands beside +/// its predecessors. Relays return history in `created_at DESC` order, so a +/// small `limit` still reliably returns the newest reissue first regardless +/// of how many older ones exist — this bound only needs to comfortably cover +/// one owner's recent reissue history, not their whole lifetime. Same value +/// as [`crate::bundle_feed::BUNDLE_QUERY_LIMIT`] for the same margin. +pub const ROSTER_QUERY_LIMIT: u32 = 16; + +/// The REQ filter for the daemon's roster tap: global, `authors` set to every +/// authorized owner, `#p` pinned to the waker's own identity, `#d` pinned to +/// the fixed roster coordinate. +/// +/// `#p` is not optional, mirroring [`crate::bundle_feed::bundle_filter`]'s +/// own doc: the relay refuses an envelope query that omits it. +#[must_use] +pub fn roster_filter(authorized_owners: &[String], waker_pubkey: &str) -> Value { + let authors: Vec = authorized_owners + .iter() + .map(|o| normalize_pubkey(o)) + .collect(); + json!({ + "kinds": [KIND_WAKER_BUNDLE_ENVELOPE], + "authors": authors, + "#p": [normalize_pubkey(waker_pubkey)], + "#d": [ROSTER_D_TAG], + "limit": ROSTER_QUERY_LIMIT, + }) +} + +/// The REQ frame opening the daemon's roster tap. +#[must_use] +pub fn roster_req(authorized_owners: &[String], waker_pubkey: &str) -> Value { + json!([ + "REQ", + ROSTER_TAP_SUBSCRIPTION_ID, + roster_filter(authorized_owners, waker_pubkey) + ]) +} + +/// Shared, thread-safe cache of the latest known roster per owner. +/// +/// Mirrors [`crate::bundle_feed::BundleState`]'s shape: the tap task +/// ([`run_roster_tap`]) owns the connection and writes here; everything else +/// only reads. Keyed by normalized owner pubkey, since one owner's roster +/// says nothing about another's membership — see the module doc. +#[derive(Debug, Default)] +pub struct RosterState { + inner: Mutex)>>, +} + +impl RosterState { + /// A tap with nothing tracked for any owner yet. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Recover from a poisoned lock rather than propagating it — a panic in + /// one reader must not permanently blind every future roster lookup. + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap)>> { + self.inner.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Record `body` as the current roster for `owner_pubkey` if its + /// `roster_version` is newer than whatever is already tracked for that + /// owner (or nothing is tracked yet). Returns whether it was applied. + /// + /// A relay's own `created_at DESC` history ordering usually delivers the + /// newest reissue first on reconnect, but nothing guarantees that for a + /// live delivery racing a reconnect's backfill — comparing + /// `roster_version` rather than trusting delivery order is what makes + /// this correct either way, the same reasoning + /// [`crate::floors::FloorStore::admit`] applies to a bundle version. + fn update_if_newer(&self, owner_pubkey: &str, body: RosterBody) -> bool { + let owner_pubkey = normalize_pubkey(owner_pubkey); + let mut map = self.lock(); + let should_apply = match map.get(&owner_pubkey) { + Some((current_version, _)) => body.roster_version > *current_version, + None => true, + }; + if should_apply { + map.insert(owner_pubkey, (body.roster_version, Arc::new(body))); + } + should_apply + } + + /// The current roster for one owner, if any has been delivered and + /// tracked on this daemon run yet. + #[must_use] + pub fn current(&self, owner_pubkey: &str) -> Option> { + self.lock() + .get(&normalize_pubkey(owner_pubkey)) + .map(|(_, body)| Arc::clone(body)) + } + + /// Every owner this daemon currently has a tracked roster for. + #[must_use] + pub fn known_owners(&self) -> Vec { + self.lock().keys().cloned().collect() + } +} + +/// What one delivered relay message means for this tap. +/// +/// Mirrors [`crate::bundle_feed`]'s own `BundleFrame` convention: everything +/// this tap does not read collapses to [`RosterFrame::Ignored`]. +#[derive(Debug, PartialEq, Eq)] +enum RosterFrame { + /// A verified envelope delivery, authored by one of the authorized + /// owners, carrying its raw (still-encrypted) content. + Delivered { + owner_pubkey: String, + ciphertext: String, + }, + /// An event on this subscription that failed signature verification. + Rejected { event_id: String, reason: String }, + /// This subscription was closed by the relay. + Closed { message: String }, + /// A frame for a subscription this tap did not open, an event whose + /// kind/`#d`/author doesn't match (should be excluded by the filter + /// already — checked again here as defense in depth), or a message type + /// this tap has no use for. + Ignored, +} + +/// Classify one relay message for the roster tap. +/// +/// Verification proves only that the stated author signed the event — it +/// does not prove the relay applied this subscription's filter. Re-checking +/// the `#d` tag and the author against `authorized_owners` here is what stops +/// a misrouted or replayed event from ever reaching the decrypt step, same +/// reasoning [`crate::bundle_feed::bundle_frame`] applies for its own tap. +fn roster_frame(authorized_owners: &[String], message: RelayMessage) -> RosterFrame { + match message { + RelayMessage::Event { + subscription_id, + event, + } if subscription_id == ROSTER_TAP_SUBSCRIPTION_ID => { + if let Err(error) = buzz_core::verify_event(&event) { + return RosterFrame::Rejected { + event_id: event.id.to_hex(), + reason: error.to_string(), + }; + } + if buzz_core::kind::event_kind_u32(&event) != KIND_WAKER_BUNDLE_ENVELOPE { + return RosterFrame::Ignored; + } + if event.tags.identifier() != Some(ROSTER_D_TAG) { + return RosterFrame::Ignored; + } + let signer = normalize_pubkey(&event.pubkey.to_hex()); + if !authorized_owners + .iter() + .any(|owner| normalize_pubkey(owner) == signer) + { + return RosterFrame::Ignored; + } + RosterFrame::Delivered { + owner_pubkey: signer, + ciphertext: event.content.clone(), + } + } + RelayMessage::Closed { + subscription_id, + message, + } if subscription_id == ROSTER_TAP_SUBSCRIPTION_ID => RosterFrame::Closed { message }, + _ => RosterFrame::Ignored, + } +} + +/// What a decrypted, verified roster delivery means for the tap's caller. +#[derive(Debug, PartialEq, Eq)] +enum RosterOutcome { + /// A newer roster than whatever was previously tracked for this owner — + /// now recorded in [`RosterState`]. + Updated(RosterBody), + /// A roster whose `roster_version` did not exceed what is already + /// tracked for this owner — a replay or a reconnect re-delivering + /// history. Left in place, not an error. + Stale, +} + +/// Decrypt, verify, and track one delivered roster. +/// +/// `waker_keys` is the daemon's own identity (the NIP-44 recipient); the +/// ciphertext was encrypted to it. The sender side of the ECDH is +/// `owner_pubkey` — already confirmed to be one of `authorized_owners` by +/// [`roster_frame`]'s pre-filter, though that check is defense in depth: the +/// actual trust decision is [`SignedRoster::verify`], over the *decrypted* +/// body's own signature, independent of which key the outer envelope +/// happened to arrive signed by. +/// +/// # Errors +/// A human-readable message on any failure — malformed/oversized ciphertext, +/// a decrypt failure, a parse failure, or a failed inner signature/roster +/// validation. Every path is a refusal to track, never a credential in the +/// error text (a roster carries none, but keeps the same contract as +/// [`crate::bundle_feed::decrypt_verify_and_admit`] for consistency). +fn decrypt_verify_and_track( + waker_keys: &Keys, + authorized_owners: &[String], + owner_pubkey: &str, + ciphertext: &str, + state: &RosterState, +) -> Result { + if !NIP44_CONTENT_LEN_RANGE.contains(&ciphertext.len()) { + return Err(format!( + "roster ciphertext outside the expected NIP-44 size range ({} chars)", + ciphertext.len() + )); + } + let owner_pk = nostr::PublicKey::from_hex(owner_pubkey) + .map_err(|error| format!("malformed owner pubkey: {error}"))?; + let mut plaintext = nostr::nips::nip44::decrypt(waker_keys.secret_key(), &owner_pk, ciphertext) + .map_err(|error| format!("NIP-44 decrypt failed: {error}"))?; + + let parsed: Result = serde_json::from_str(&plaintext); + plaintext.zeroize(); + let signed = parsed.map_err(|error| format!("malformed roster JSON: {error}"))?; + + let body = signed + .verify(authorized_owners) + .map_err(|error| format!("roster verification failed: {error}"))?; + + if state.update_if_newer(owner_pubkey, body.clone()) { + Ok(RosterOutcome::Updated(body)) + } else { + Ok(RosterOutcome::Stale) + } +} + +/// Run the daemon's roster tap until `cancel` fires. +/// +/// Connects and authenticates as `waker_keys` — **not** any watched agent's +/// identity, see the module doc — subscribes under +/// [`ROSTER_TAP_SUBSCRIPTION_ID`], and folds every delivery into `state` via +/// [`decrypt_verify_and_track`]. Reconnects on any transport error using the +/// same ladder every other tap in this daemon uses ([`reconnect_delay_ms`]). +/// +/// Refuses to run at all if `authorized_owners` is empty — see the module +/// doc's Fail closed section. This is a deliberate no-op, not an error: a +/// daemon started with enrolment disabled should log why once and return, +/// not busy-loop reconnecting a query that can never usefully match. +/// +/// A malformed, undecryptable, or verification-refused delivery is logged +/// and skipped, not a reconnect — an unauthorized or stale publisher must not +/// be able to knock this tap offline. +pub async fn run_roster_tap( + relay_url: &str, + waker_keys: &Keys, + auth_tag: Option<&Tag>, + authorized_owners: &[String], + state: &RosterState, + cancel: &CancellationToken, +) { + if authorized_owners.is_empty() { + tracing::warn!( + "roster tap has no authorized owners configured (WAKER_OWNER_PUBKEYS is empty); \ + enrolment is disabled — refusing to open a connection" + ); + return; + } + + let waker_pubkey = waker_keys.public_key().to_hex(); + let authorized_owners: Vec = authorized_owners + .iter() + .map(|owner| normalize_pubkey(owner)) + .collect(); + let mut consecutive_failures = 0u32; + + while !cancel.is_cancelled() { + if consecutive_failures > 0 { + let delay_ms = reconnect_delay_ms(consecutive_failures); + tokio::select! { + () = tokio::time::sleep(Duration::from_millis(delay_ms)) => {} + () = cancel.cancelled() => break, + } + } + + let connect = NostrWsConnection::connect_authenticated(relay_url, waker_keys, auth_tag); + let mut connection = tokio::select! { + result = connect => match result { + Ok(connection) => connection, + Err(error) => { + tracing::warn!(%error, "roster tap connect failed; backing off"); + consecutive_failures = consecutive_failures.saturating_add(1); + continue; + } + }, + () = cancel.cancelled() => break, + }; + + if let Err(error) = connection + .send_raw(&roster_req(&authorized_owners, &waker_pubkey)) + .await + { + tracing::warn!(%error, "roster tap subscribe failed; reconnecting"); + consecutive_failures = consecutive_failures.saturating_add(1); + continue; + } + consecutive_failures = 0; + + loop { + let next = tokio::select! { + result = connection.next_event(Duration::from_secs(ROSTER_TAP_IDLE_TIMEOUT_SECS)) => result, + () = cancel.cancelled() => return, + }; + + match next { + Ok(message) => match roster_frame(&authorized_owners, message) { + RosterFrame::Delivered { + owner_pubkey, + ciphertext, + } => { + match decrypt_verify_and_track( + waker_keys, + &authorized_owners, + &owner_pubkey, + &ciphertext, + state, + ) { + Ok(RosterOutcome::Updated(body)) => { + tracing::info!( + owner = %owner_pubkey, + roster_version = body.roster_version, + agent_count = body.entries.len(), + "roster tap tracked a newer roster" + ); + } + Ok(RosterOutcome::Stale) => { + tracing::info!( + owner = %owner_pubkey, + "roster tap received a roster no newer than the one already tracked; ignoring" + ); + } + Err(error) => { + tracing::warn!( + owner = %owner_pubkey, + %error, + "roster tap received a delivery it could not track; ignoring" + ); + } + } + } + RosterFrame::Rejected { event_id, reason } => { + tracing::warn!( + event_id = %event_id, + %reason, + "roster tap received an event that failed verification; ignoring" + ); + } + RosterFrame::Closed { message } => { + tracing::warn!( + %message, + "roster tap subscription closed by relay; reconnecting" + ); + consecutive_failures = consecutive_failures.saturating_add(1); + break; + } + RosterFrame::Ignored => {} + }, + Err(WsClientError::Timeout) => { + // A quiet tap is the normal case — see ROSTER_TAP_IDLE_TIMEOUT_SECS. + } + Err(error) => { + tracing::warn!(%error, "roster tap connection lost; reconnecting"); + consecutive_failures = consecutive_failures.saturating_add(1); + break; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::enrolment::RosterEntry; + use nostr::{EventBuilder, Kind}; + + fn roster_event(owner: &Keys, ciphertext: &str) -> nostr::Event { + EventBuilder::new(Kind::Custom(KIND_WAKER_BUNDLE_ENVELOPE as u16), ciphertext) + .tags([ + Tag::parse(["d", ROSTER_D_TAG]).unwrap(), + Tag::parse(["p", &"w".repeat(64)]).unwrap(), + ]) + .sign_with_keys(owner) + .expect("sign") + } + + fn roster_body(entries: Vec, roster_version: u64) -> RosterBody { + RosterBody { + entries, + roster_version, + issued_at: 1_000, + } + } + + #[test] + fn the_query_names_every_authorized_owner_and_the_fixed_roster_coordinate() { + let owner_a = "a".repeat(64); + let owner_b = "b".repeat(64); + let waker_pubkey = "c".repeat(64); + let filter = roster_filter(&[owner_a.clone(), owner_b.clone()], &waker_pubkey); + + assert_eq!(filter["kinds"], json!([KIND_WAKER_BUNDLE_ENVELOPE])); + assert_eq!(filter["authors"], json!([owner_a, owner_b])); + assert_eq!(filter["#p"], json!([waker_pubkey])); + assert_eq!(filter["#d"], json!([ROSTER_D_TAG])); + assert!( + filter["limit"].is_number(), + "the envelope is not replaceable, so the query must be bounded" + ); + } + + #[test] + fn a_verified_delivery_from_an_authorized_owner_is_delivered() { + let owner = Keys::generate(); + let owner_pubkey = normalize_pubkey(&owner.public_key().to_hex()); + let event = roster_event(&owner, "ciphertext-bytes"); + + let frame = roster_frame( + std::slice::from_ref(&owner_pubkey), + RelayMessage::Event { + subscription_id: ROSTER_TAP_SUBSCRIPTION_ID.to_string(), + event: Box::new(event), + }, + ); + assert_eq!( + frame, + RosterFrame::Delivered { + owner_pubkey, + ciphertext: "ciphertext-bytes".to_string() + } + ); + } + + #[test] + fn a_delivery_from_an_unauthorized_signer_is_ignored() { + let owner = Keys::generate(); + let other_authorized = Keys::generate(); + let event = roster_event(&owner, "ciphertext-bytes"); + + let frame = roster_frame( + &[normalize_pubkey(&other_authorized.public_key().to_hex())], + RelayMessage::Event { + subscription_id: ROSTER_TAP_SUBSCRIPTION_ID.to_string(), + event: Box::new(event), + }, + ); + assert_eq!(frame, RosterFrame::Ignored); + } + + /// The credential tap's per-agent `#d` (a canonical 64-hex agent pubkey) + /// can never collide with the roster's fixed sentinel — this proves the + /// roster side of that: an event tagged with an agent pubkey instead of + /// the sentinel must not be mistaken for a roster delivery. + #[test] + fn an_event_with_a_non_sentinel_d_tag_is_ignored() { + let owner = Keys::generate(); + let owner_pubkey = normalize_pubkey(&owner.public_key().to_hex()); + let event = EventBuilder::new( + Kind::Custom(KIND_WAKER_BUNDLE_ENVELOPE as u16), + "ciphertext-bytes", + ) + .tags([ + Tag::parse(["d", &"a".repeat(64)]).unwrap(), + Tag::parse(["p", &"w".repeat(64)]).unwrap(), + ]) + .sign_with_keys(&owner) + .expect("sign"); + + let frame = roster_frame( + &[owner_pubkey], + RelayMessage::Event { + subscription_id: ROSTER_TAP_SUBSCRIPTION_ID.to_string(), + event: Box::new(event), + }, + ); + assert_eq!(frame, RosterFrame::Ignored); + } + + #[test] + fn a_frame_for_another_subscription_is_ignored() { + let owner = Keys::generate(); + let owner_pubkey = normalize_pubkey(&owner.public_key().to_hex()); + let event = roster_event(&owner, "ciphertext-bytes"); + + let frame = roster_frame( + &[owner_pubkey], + RelayMessage::Event { + subscription_id: "some-other-subscription".to_string(), + event: Box::new(event), + }, + ); + assert_eq!(frame, RosterFrame::Ignored); + } + + #[test] + fn a_closed_frame_for_this_subscription_is_reported() { + let frame = roster_frame( + &["a".repeat(64)], + RelayMessage::Closed { + subscription_id: ROSTER_TAP_SUBSCRIPTION_ID.to_string(), + message: "auth-required".to_string(), + }, + ); + assert_eq!( + frame, + RosterFrame::Closed { + message: "auth-required".to_string() + } + ); + } + + #[test] + fn oversized_ciphertext_is_refused_before_any_decrypt_attempt() { + let waker = Keys::generate(); + let owner_pubkey = "a".repeat(64); + let state = RosterState::new(); + + let too_long = "x".repeat(NIP44_CONTENT_LEN_RANGE.end() + 1); + let error = decrypt_verify_and_track( + &waker, + std::slice::from_ref(&owner_pubkey), + &owner_pubkey, + &too_long, + &state, + ) + .unwrap_err(); + assert!(error.contains("size range"), "{error}"); + } + + #[test] + fn a_valid_round_trip_decrypts_verifies_and_tracks() { + let owner = Keys::generate(); + let owner_pubkey = normalize_pubkey(&owner.public_key().to_hex()); + let waker = Keys::generate(); + let state = RosterState::new(); + + let agent = Keys::generate().public_key().to_hex(); + let body = roster_body( + vec![RosterEntry { + agent_pubkey: agent, + credential_version: 1, + }], + 1, + ); + let owner_keypair = + nostr::secp256k1::Keypair::from_secret_key(nostr::SECP256K1, owner.secret_key()); + let signed = SignedRoster::sign(&body, &owner_keypair).unwrap(); + let plaintext = serde_json::to_string(&signed).unwrap(); + let ciphertext = nostr::nips::nip44::encrypt( + owner.secret_key(), + &waker.public_key(), + &plaintext, + nostr::nips::nip44::Version::V2, + ) + .unwrap(); + + let outcome = decrypt_verify_and_track( + &waker, + std::slice::from_ref(&owner_pubkey), + &owner_pubkey, + &ciphertext, + &state, + ) + .expect("round trip"); + assert!(matches!(outcome, RosterOutcome::Updated(_))); + assert_eq!( + state + .current(&owner_pubkey) + .expect("tracked") + .roster_version, + 1 + ); + } + + /// The headline case [`RosterState::update_if_newer`] exists for: a + /// reconnect re-delivering an older reissue must not clobber a version + /// already tracked from a live delivery that arrived first. + #[test] + fn a_lower_version_delivered_after_a_higher_one_is_reported_stale_and_does_not_regress() { + let owner = Keys::generate(); + let owner_pubkey = normalize_pubkey(&owner.public_key().to_hex()); + let waker = Keys::generate(); + let state = RosterState::new(); + let owner_keypair = + nostr::secp256k1::Keypair::from_secret_key(nostr::SECP256K1, owner.secret_key()); + + let encrypt = |body: &RosterBody| -> String { + let signed = SignedRoster::sign(body, &owner_keypair).unwrap(); + let plaintext = serde_json::to_string(&signed).unwrap(); + nostr::nips::nip44::encrypt( + owner.secret_key(), + &waker.public_key(), + &plaintext, + nostr::nips::nip44::Version::V2, + ) + .unwrap() + }; + + let v2 = roster_body(vec![], 2); + let v1 = roster_body(vec![], 1); + + let outcome = decrypt_verify_and_track( + &waker, + std::slice::from_ref(&owner_pubkey), + &owner_pubkey, + &encrypt(&v2), + &state, + ) + .expect("v2 tracks"); + assert!(matches!(outcome, RosterOutcome::Updated(_))); + + let outcome = decrypt_verify_and_track( + &waker, + std::slice::from_ref(&owner_pubkey), + &owner_pubkey, + &encrypt(&v1), + &state, + ) + .expect("v1 is not an error"); + assert_eq!(outcome, RosterOutcome::Stale); + assert_eq!( + state + .current(&owner_pubkey) + .expect("tracked") + .roster_version, + 2, + "the newer roster must remain tracked" + ); + } + + #[test] + fn each_owner_is_tracked_independently() { + let owner_a = Keys::generate(); + let owner_a_pubkey = normalize_pubkey(&owner_a.public_key().to_hex()); + let owner_b = Keys::generate(); + let owner_b_pubkey = normalize_pubkey(&owner_b.public_key().to_hex()); + let waker = Keys::generate(); + let state = RosterState::new(); + + let encrypt_for = |owner: &Keys, body: &RosterBody| -> String { + let owner_keypair = + nostr::secp256k1::Keypair::from_secret_key(nostr::SECP256K1, owner.secret_key()); + let signed = SignedRoster::sign(body, &owner_keypair).unwrap(); + let plaintext = serde_json::to_string(&signed).unwrap(); + nostr::nips::nip44::encrypt( + owner.secret_key(), + &waker.public_key(), + &plaintext, + nostr::nips::nip44::Version::V2, + ) + .unwrap() + }; + + let authorized = vec![owner_a_pubkey.clone(), owner_b_pubkey.clone()]; + decrypt_verify_and_track( + &waker, + &authorized, + &owner_a_pubkey, + &encrypt_for(&owner_a, &roster_body(vec![], 5)), + &state, + ) + .expect("owner a tracks"); + decrypt_verify_and_track( + &waker, + &authorized, + &owner_b_pubkey, + &encrypt_for(&owner_b, &roster_body(vec![], 1)), + &state, + ) + .expect("owner b tracks"); + + assert_eq!(state.current(&owner_a_pubkey).unwrap().roster_version, 5); + assert_eq!(state.current(&owner_b_pubkey).unwrap().roster_version, 1); + let mut owners = state.known_owners(); + owners.sort(); + let mut expected = vec![owner_a_pubkey, owner_b_pubkey]; + expected.sort(); + assert_eq!(owners, expected); + } + + #[test] + fn a_roster_signed_by_a_never_authorized_owner_is_refused_at_verify() { + // Bypasses the frame pre-filter to prove the actual trust decision + // (SignedRoster::verify) independently refuses an unauthorized + // signer too, not just the pre-filter. + let owner = Keys::generate(); + let owner_pubkey = normalize_pubkey(&owner.public_key().to_hex()); + let waker = Keys::generate(); + let state = RosterState::new(); + let owner_keypair = + nostr::secp256k1::Keypair::from_secret_key(nostr::SECP256K1, owner.secret_key()); + let signed = SignedRoster::sign(&roster_body(vec![], 1), &owner_keypair).unwrap(); + let plaintext = serde_json::to_string(&signed).unwrap(); + let ciphertext = nostr::nips::nip44::encrypt( + owner.secret_key(), + &waker.public_key(), + &plaintext, + nostr::nips::nip44::Version::V2, + ) + .unwrap(); + + let error = decrypt_verify_and_track( + &waker, + &["z".repeat(64)], + &owner_pubkey, + &ciphertext, + &state, + ) + .expect_err("unauthorized owner refused"); + assert!(error.contains("verification failed"), "{error}"); + } +} From 6c93cd10777d08f148f599c29cf0970efed825ed Mon Sep 17 00:00:00 2001 From: Junchao Yan Date: Wed, 12 Aug 2026 22:46:47 -0700 Subject: [PATCH 06/10] fix(waker): persist roster floor per owner, split multi-owner REQ filter Alex's Phase 2 review (PR #48, head 008bcd60) found two real gaps in the roster tap: - RosterState only remembered the highest roster_version seen this process's lifetime. Because the envelope kind isn't replaceable, a relay can replay an owner's old-but-still-validly-signed roster after a restart, resurrecting an agent a newer roster already removed. Each authorized owner now gets its own FloorStore (crate::floors), opened lazily under roster_floor_dir and re-read from disk on every delivery, so the anti-replay floor survives a restart the same way it already does for bundle versions. - The REQ filter combined every authorized owner into one authors array with a single limit, but a NIP-01 filter's limit applies to the whole filter, not once per author. A burst of reissues from one owner could crowd another owner's current roster out of the response entirely. roster_filters now builds one filter per owner (buzz-relay runs each filter in a multi-filter REQ as its own independently-limited query), still under one subscription. cargo test/clippy/fmt -p buzz-waker all clean. `just gate`'s only failure is an unrelated pre-existing file-size ratchet trip in desktop/src-tauri/src/managed_agents/runtime.rs, a file this diff never touches. Signed-off-by: Junchao Yan --- crates/buzz-waker/src/roster_feed.rs | 305 +++++++++++++++++++++++---- 1 file changed, 268 insertions(+), 37 deletions(-) diff --git a/crates/buzz-waker/src/roster_feed.rs b/crates/buzz-waker/src/roster_feed.rs index 478eab46d15..d14a98eea10 100644 --- a/crates/buzz-waker/src/roster_feed.rs +++ b/crates/buzz-waker/src/roster_feed.rs @@ -41,8 +41,22 @@ //! at best match nothing and at worst behave relay-implementation-defined — //! refusing before ever constructing one is the same defense-in-depth //! reasoning the design doc already applies to the `#d` tag re-check below. +//! +//! # Durable per-owner version floor +//! +//! [`RosterState`] alone only tracks the highest `roster_version` seen this +//! process lifetime. Because the envelope kind is not replaceable, an owner's +//! old reissues stay queryable forever, so a relay can replay one after a +//! restart and this tap would accept it — resurrecting an agent a newer +//! roster already removed. [`run_roster_tap`] closes that hole the same way +//! [`crate::floors::FloorStore`] already closes it for bundle versions +//! (**G2**): one [`FloorStore`] per owner, opened lazily under +//! `roster_floor_dir` and re-read from disk on every delivery, so the floor +//! survives a restart even though [`RosterState`] itself does not. +use std::collections::hash_map::Entry; use std::collections::HashMap; +use std::path::Path; use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; @@ -57,6 +71,7 @@ use crate::bundle_feed::NIP44_CONTENT_LEN_RANGE; use crate::decide::normalize_pubkey; use crate::enrolment::{RosterBody, SignedRoster}; use crate::feed::reconnect_delay_ms; +use crate::floors::{FloorError, FloorStore}; /// Subscription id for the daemon's one roster tap. Fixed, like every other /// tap's own id — a reconnect replaces the old subscription rather than @@ -91,35 +106,46 @@ pub const ROSTER_TAP_IDLE_TIMEOUT_SECS: u64 = 300; /// as [`crate::bundle_feed::BUNDLE_QUERY_LIMIT`] for the same margin. pub const ROSTER_QUERY_LIMIT: u32 = 16; -/// The REQ filter for the daemon's roster tap: global, `authors` set to every -/// authorized owner, `#p` pinned to the waker's own identity, `#d` pinned to -/// the fixed roster coordinate. +/// One REQ filter per authorized owner: same `#p`/`#d` pinning as before, but +/// `authors` narrowed to a single owner so each filter gets its own +/// [`ROSTER_QUERY_LIMIT`]. +/// +/// A single filter with every owner in `authors` would apply the limit once +/// across *all* of them combined — `buzz-relay`'s `handle_req` runs a +/// multi-filter REQ as one independent, independently-limited DB query per +/// filter (NIP-01 OR semantics), so a burst of reissues from one owner can +/// only ever crowd out that owner's own history, never another authorized +/// owner's latest roster. /// /// `#p` is not optional, mirroring [`crate::bundle_feed::bundle_filter`]'s /// own doc: the relay refuses an envelope query that omits it. #[must_use] -pub fn roster_filter(authorized_owners: &[String], waker_pubkey: &str) -> Value { - let authors: Vec = authorized_owners +pub fn roster_filters(authorized_owners: &[String], waker_pubkey: &str) -> Vec { + let waker_pubkey = normalize_pubkey(waker_pubkey); + authorized_owners .iter() - .map(|o| normalize_pubkey(o)) - .collect(); - json!({ - "kinds": [KIND_WAKER_BUNDLE_ENVELOPE], - "authors": authors, - "#p": [normalize_pubkey(waker_pubkey)], - "#d": [ROSTER_D_TAG], - "limit": ROSTER_QUERY_LIMIT, - }) + .map(|owner| { + json!({ + "kinds": [KIND_WAKER_BUNDLE_ENVELOPE], + "authors": [normalize_pubkey(owner)], + "#p": [waker_pubkey], + "#d": [ROSTER_D_TAG], + "limit": ROSTER_QUERY_LIMIT, + }) + }) + .collect() } -/// The REQ frame opening the daemon's roster tap. +/// The REQ frame opening the daemon's roster tap: one subscription, one +/// filter per authorized owner (see [`roster_filters`]). #[must_use] pub fn roster_req(authorized_owners: &[String], waker_pubkey: &str) -> Value { - json!([ - "REQ", - ROSTER_TAP_SUBSCRIPTION_ID, - roster_filter(authorized_owners, waker_pubkey) - ]) + let mut frame = vec![ + Value::String("REQ".to_string()), + Value::String(ROSTER_TAP_SUBSCRIPTION_ID.to_string()), + ]; + frame.extend(roster_filters(authorized_owners, waker_pubkey)); + Value::Array(frame) } /// Shared, thread-safe cache of the latest known roster per owner. @@ -257,15 +283,18 @@ fn roster_frame(authorized_owners: &[String], message: RelayMessage) -> RosterFr #[derive(Debug, PartialEq, Eq)] enum RosterOutcome { /// A newer roster than whatever was previously tracked for this owner — - /// now recorded in [`RosterState`]. + /// now recorded in [`RosterState`] and durably admitted by this owner's + /// [`FloorStore`]. Updated(RosterBody), /// A roster whose `roster_version` did not exceed what is already - /// tracked for this owner — a replay or a reconnect re-delivering - /// history. Left in place, not an error. + /// tracked for this owner — either this process's own [`RosterState`] + /// or, after a restart, the durable per-owner [`FloorStore`] floor. A + /// replay or a reconnect re-delivering history. Left in place, not an + /// error. Stale, } -/// Decrypt, verify, and track one delivered roster. +/// Decrypt, verify, durably admit, and track one delivered roster. /// /// `waker_keys` is the daemon's own identity (the NIP-44 recipient); the /// ciphertext was encrypted to it. The sender side of the ECDH is @@ -275,17 +304,26 @@ enum RosterOutcome { /// body's own signature, independent of which key the outer envelope /// happened to arrive signed by. /// +/// `floor_store` is `owner_pubkey`'s own durable version floor (see the +/// module doc's Durable per-owner version floor section) — checked and +/// advanced, under its own fence, before the delivery ever reaches +/// [`RosterState`]. This is what makes the anti-replay guarantee survive a +/// restart; [`RosterState`] alone only remembers for this process's +/// lifetime. +/// /// # Errors /// A human-readable message on any failure — malformed/oversized ciphertext, -/// a decrypt failure, a parse failure, or a failed inner signature/roster -/// validation. Every path is a refusal to track, never a credential in the -/// error text (a roster carries none, but keeps the same contract as +/// a decrypt failure, a parse failure, a failed inner signature/roster +/// validation, or a durable floor that could not be persisted. Every path is +/// a refusal to track, never a credential in the error text (a roster +/// carries none, but keeps the same contract as /// [`crate::bundle_feed::decrypt_verify_and_admit`] for consistency). fn decrypt_verify_and_track( waker_keys: &Keys, authorized_owners: &[String], owner_pubkey: &str, ciphertext: &str, + floor_store: &mut FloorStore, state: &RosterState, ) -> Result { if !NIP44_CONTENT_LEN_RANGE.contains(&ciphertext.len()) { @@ -307,6 +345,18 @@ fn decrypt_verify_and_track( .verify(authorized_owners) .map_err(|error| format!("roster verification failed: {error}"))?; + // Durable floor first: it is the only part of this that survives a + // restart, so it must gate `RosterState` rather than the other way + // round. `RolledBack` is exactly the replay-after-restart case this + // floor exists for — a stale delivery, not an error. + match floor_store.admit(body.roster_version) { + Ok(()) => {} + Err(FloorError::RolledBack { .. }) => return Ok(RosterOutcome::Stale), + Err(error) => { + return Err(format!("roster floor could not be advanced: {error}")); + } + } + if state.update_if_newer(owner_pubkey, body.clone()) { Ok(RosterOutcome::Updated(body)) } else { @@ -314,6 +364,26 @@ fn decrypt_verify_and_track( } } +/// Open `owner_pubkey`'s durable roster floor under `dir`, creating it at +/// version 0 the first time this daemon ever sees that owner. +/// +/// Unlike [`FloorStore::enroll`]'s use for bundle/credential state, a +/// missing file here is the ordinary cold-start case (this daemon has never +/// tracked a roster from this owner before) rather than a suspicious gap — +/// the owner's authenticity already comes from `WAKER_OWNER_PUBKEYS` and +/// [`SignedRoster::verify`], not from anything pinned in this file. Once +/// created, the file is fenced and read-before-decide exactly like every +/// other [`FloorStore`], so a version this daemon has already admitted +/// cannot be forgotten by a later restart. +fn open_or_create_owner_floor(dir: &Path, owner_pubkey: &str) -> Result { + let path = dir.join(format!("{owner_pubkey}.json")); + match FloorStore::open(&path) { + Ok(store) => Ok(store), + Err(FloorError::NotEnrolled { .. }) => FloorStore::enroll(&path, owner_pubkey), + Err(error) => Err(error), + } +} + /// Run the daemon's roster tap until `cancel` fires. /// /// Connects and authenticates as `waker_keys` — **not** any watched agent's @@ -327,14 +397,25 @@ fn decrypt_verify_and_track( /// daemon started with enrolment disabled should log why once and return, /// not busy-loop reconnecting a query that can never usefully match. /// +/// `roster_floor_dir` holds each authorized owner's durable version floor +/// (one file per owner, opened lazily — see the module doc's Durable +/// per-owner version floor section). Owned by this task for its lifetime, +/// the same single-writer shape [`crate::bundle_feed::run_bundle_tap`] uses +/// for its own `floor_store`. +/// /// A malformed, undecryptable, or verification-refused delivery is logged /// and skipped, not a reconnect — an unauthorized or stale publisher must not -/// be able to knock this tap offline. +/// be able to knock this tap offline. The same is true of an owner whose +/// durable floor this daemon cannot open or create (e.g. a permissions +/// problem under `roster_floor_dir`): that owner's deliveries are skipped +/// and logged, not treated as a reason to drop the whole connection. +#[allow(clippy::too_many_arguments)] pub async fn run_roster_tap( relay_url: &str, waker_keys: &Keys, auth_tag: Option<&Tag>, authorized_owners: &[String], + roster_floor_dir: &Path, state: &RosterState, cancel: &CancellationToken, ) { @@ -346,11 +427,21 @@ pub async fn run_roster_tap( return; } + if let Err(error) = std::fs::create_dir_all(roster_floor_dir) { + tracing::error!( + dir = %roster_floor_dir.display(), + %error, + "roster tap could not create its durable floor directory; refusing to run" + ); + return; + } + let waker_pubkey = waker_keys.public_key().to_hex(); let authorized_owners: Vec = authorized_owners .iter() .map(|owner| normalize_pubkey(owner)) .collect(); + let mut floors: HashMap = HashMap::new(); let mut consecutive_failures = 0u32; while !cancel.is_cancelled() { @@ -397,11 +488,28 @@ pub async fn run_roster_tap( owner_pubkey, ciphertext, } => { + let floor_store = match floors.entry(owner_pubkey.clone()) { + Entry::Occupied(entry) => entry.into_mut(), + Entry::Vacant(entry) => { + match open_or_create_owner_floor(roster_floor_dir, &owner_pubkey) { + Ok(store) => entry.insert(store), + Err(error) => { + tracing::warn!( + owner = %owner_pubkey, + %error, + "roster tap could not open this owner's durable floor; skipping delivery" + ); + continue; + } + } + } + }; match decrypt_verify_and_track( waker_keys, &authorized_owners, &owner_pubkey, &ciphertext, + floor_store, state, ) { Ok(RosterOutcome::Updated(body)) => { @@ -481,20 +589,51 @@ mod tests { } } + /// A fresh durable floor for one owner, backed by its own tempdir file — + /// mirrors [`open_or_create_owner_floor`] but lets a test hold the + /// `TempDir` so a later reopen in the same test simulates a restart. + fn test_floor(dir: &tempfile::TempDir, owner_pubkey: &str) -> FloorStore { + open_or_create_owner_floor(dir.path(), owner_pubkey).expect("open or create floor") + } + #[test] - fn the_query_names_every_authorized_owner_and_the_fixed_roster_coordinate() { + fn each_filter_names_one_owner_with_its_own_bounded_limit() { let owner_a = "a".repeat(64); let owner_b = "b".repeat(64); let waker_pubkey = "c".repeat(64); - let filter = roster_filter(&[owner_a.clone(), owner_b.clone()], &waker_pubkey); + let filters = roster_filters(&[owner_a.clone(), owner_b.clone()], &waker_pubkey); - assert_eq!(filter["kinds"], json!([KIND_WAKER_BUNDLE_ENVELOPE])); - assert_eq!(filter["authors"], json!([owner_a, owner_b])); - assert_eq!(filter["#p"], json!([waker_pubkey])); - assert_eq!(filter["#d"], json!([ROSTER_D_TAG])); - assert!( - filter["limit"].is_number(), - "the envelope is not replaceable, so the query must be bounded" + assert_eq!(filters.len(), 2, "one filter per authorized owner"); + for (filter, owner) in filters.iter().zip([&owner_a, &owner_b]) { + assert_eq!(filter["kinds"], json!([KIND_WAKER_BUNDLE_ENVELOPE])); + assert_eq!( + filter["authors"], + json!([owner]), + "each filter's authors must be exactly its own owner, not every owner" + ); + assert_eq!(filter["#p"], json!([waker_pubkey])); + assert_eq!(filter["#d"], json!([ROSTER_D_TAG])); + assert!( + filter["limit"].is_number(), + "the envelope is not replaceable, so every filter must be bounded" + ); + } + } + + #[test] + fn the_req_frame_carries_one_filter_per_owner_under_one_subscription() { + let owner_a = "a".repeat(64); + let owner_b = "b".repeat(64); + let waker_pubkey = "c".repeat(64); + let req = roster_req(&[owner_a, owner_b], &waker_pubkey); + let frame = req.as_array().expect("REQ is an array"); + + assert_eq!(frame[0], json!("REQ")); + assert_eq!(frame[1], json!(ROSTER_TAP_SUBSCRIPTION_ID)); + assert_eq!( + frame.len(), + 4, + "\"REQ\", subscription id, then one filter per owner" ); } @@ -603,6 +742,8 @@ mod tests { let waker = Keys::generate(); let owner_pubkey = "a".repeat(64); let state = RosterState::new(); + let dir = tempfile::tempdir().expect("tempdir"); + let mut floor = test_floor(&dir, &owner_pubkey); let too_long = "x".repeat(NIP44_CONTENT_LEN_RANGE.end() + 1); let error = decrypt_verify_and_track( @@ -610,6 +751,7 @@ mod tests { std::slice::from_ref(&owner_pubkey), &owner_pubkey, &too_long, + &mut floor, &state, ) .unwrap_err(); @@ -622,6 +764,8 @@ mod tests { let owner_pubkey = normalize_pubkey(&owner.public_key().to_hex()); let waker = Keys::generate(); let state = RosterState::new(); + let dir = tempfile::tempdir().expect("tempdir"); + let mut floor = test_floor(&dir, &owner_pubkey); let agent = Keys::generate().public_key().to_hex(); let body = roster_body( @@ -648,6 +792,7 @@ mod tests { std::slice::from_ref(&owner_pubkey), &owner_pubkey, &ciphertext, + &mut floor, &state, ) .expect("round trip"); @@ -670,6 +815,8 @@ mod tests { let owner_pubkey = normalize_pubkey(&owner.public_key().to_hex()); let waker = Keys::generate(); let state = RosterState::new(); + let dir = tempfile::tempdir().expect("tempdir"); + let mut floor = test_floor(&dir, &owner_pubkey); let owner_keypair = nostr::secp256k1::Keypair::from_secret_key(nostr::SECP256K1, owner.secret_key()); @@ -693,6 +840,7 @@ mod tests { std::slice::from_ref(&owner_pubkey), &owner_pubkey, &encrypt(&v2), + &mut floor, &state, ) .expect("v2 tracks"); @@ -703,6 +851,7 @@ mod tests { std::slice::from_ref(&owner_pubkey), &owner_pubkey, &encrypt(&v1), + &mut floor, &state, ) .expect("v1 is not an error"); @@ -717,6 +866,80 @@ mod tests { ); } + /// The P1 fix's headline case: [`RosterState`] alone forgets on restart, + /// but the durable per-owner floor must not. Simulates a restart by + /// dropping the in-memory `RosterState` and reopening the on-disk + /// [`FloorStore`] from the same path, then replays the old, still + /// validly-signed v1 roster a relay could still serve from history. + #[test] + fn a_replayed_older_roster_is_refused_after_a_simulated_restart() { + let owner = Keys::generate(); + let owner_pubkey = normalize_pubkey(&owner.public_key().to_hex()); + let waker = Keys::generate(); + let dir = tempfile::tempdir().expect("tempdir"); + let owner_keypair = + nostr::secp256k1::Keypair::from_secret_key(nostr::SECP256K1, owner.secret_key()); + + let encrypt = |body: &RosterBody| -> String { + let signed = SignedRoster::sign(body, &owner_keypair).unwrap(); + let plaintext = serde_json::to_string(&signed).unwrap(); + nostr::nips::nip44::encrypt( + owner.secret_key(), + &waker.public_key(), + &plaintext, + nostr::nips::nip44::Version::V2, + ) + .unwrap() + }; + + let v9 = roster_body(vec![], 9); + let v4 = roster_body(vec![], 4); + + { + let state = RosterState::new(); + let mut floor = test_floor(&dir, &owner_pubkey); + let outcome = decrypt_verify_and_track( + &waker, + std::slice::from_ref(&owner_pubkey), + &owner_pubkey, + &encrypt(&v9), + &mut floor, + &state, + ) + .expect("v9 tracks"); + assert!(matches!(outcome, RosterOutcome::Updated(_))); + } + + // Simulated restart: fresh RosterState (nothing tracked this + // process lifetime), floor reopened from disk. + let state = RosterState::new(); + let mut floor = test_floor(&dir, &owner_pubkey); + assert_eq!( + floor.snapshot().highest_accepted_version, + 9, + "the durable floor must have survived the simulated restart" + ); + + let outcome = decrypt_verify_and_track( + &waker, + std::slice::from_ref(&owner_pubkey), + &owner_pubkey, + &encrypt(&v4), + &mut floor, + &state, + ) + .expect("a replayed older roster is refused, not an error"); + assert_eq!( + outcome, + RosterOutcome::Stale, + "the durable floor must refuse the replay even though RosterState forgot it" + ); + assert!( + state.current(&owner_pubkey).is_none(), + "a refused delivery must never reach RosterState" + ); + } + #[test] fn each_owner_is_tracked_independently() { let owner_a = Keys::generate(); @@ -725,6 +948,9 @@ mod tests { let owner_b_pubkey = normalize_pubkey(&owner_b.public_key().to_hex()); let waker = Keys::generate(); let state = RosterState::new(); + let dir = tempfile::tempdir().expect("tempdir"); + let mut floor_a = test_floor(&dir, &owner_a_pubkey); + let mut floor_b = test_floor(&dir, &owner_b_pubkey); let encrypt_for = |owner: &Keys, body: &RosterBody| -> String { let owner_keypair = @@ -746,6 +972,7 @@ mod tests { &authorized, &owner_a_pubkey, &encrypt_for(&owner_a, &roster_body(vec![], 5)), + &mut floor_a, &state, ) .expect("owner a tracks"); @@ -754,6 +981,7 @@ mod tests { &authorized, &owner_b_pubkey, &encrypt_for(&owner_b, &roster_body(vec![], 1)), + &mut floor_b, &state, ) .expect("owner b tracks"); @@ -776,6 +1004,8 @@ mod tests { let owner_pubkey = normalize_pubkey(&owner.public_key().to_hex()); let waker = Keys::generate(); let state = RosterState::new(); + let dir = tempfile::tempdir().expect("tempdir"); + let mut floor = test_floor(&dir, &owner_pubkey); let owner_keypair = nostr::secp256k1::Keypair::from_secret_key(nostr::SECP256K1, owner.secret_key()); let signed = SignedRoster::sign(&roster_body(vec![], 1), &owner_keypair).unwrap(); @@ -793,6 +1023,7 @@ mod tests { &["z".repeat(64)], &owner_pubkey, &ciphertext, + &mut floor, &state, ) .expect_err("unauthorized owner refused"); From e9721c220a17d4f8385e8e2161b4883359937b86 Mon Sep 17 00:00:00 2001 From: Junchao Yan Date: Wed, 12 Aug 2026 22:54:26 -0700 Subject: [PATCH 07/10] fix(waker): batch roster REQ filters under the relay's 10-filter cap Alex's re-review of the durable-floor fix (PR #48, head 6c93cd107) found the P2 fix itself was incomplete: roster_filters emits one filter per authorized owner, but buzz-relay refuses any REQ with more than MAX_FILTERS_PER_REQ (10, advertised via NIP-11 as max_filters). An 11-owner configuration would open no roster subscription at all and recover no tenants' rosters. roster_req is replaced by roster_reqs, which chunks authorized_owners into batches of at most ROSTER_MAX_FILTERS_PER_REQ and opens one REQ frame per batch, each under its own roster_subscription_id (always suffixed, even for the common single-batch case, so roster_frame has one shape to match against instead of two). run_roster_tap now sends every batch's REQ after connecting and tracks the full set of subscription ids for frame routing; a failure partway through subscribing reconnects the whole connection rather than leaving a partial subscription set running. cargo test/clippy/fmt -p buzz-waker all clean (255 lib + 8 main, +2 new tests: one proving an 11th owner opens a second batch rather than being dropped or refusing to start, one proving a delivery on the second batch's subscription id is still recognized). Signed-off-by: Junchao Yan --- crates/buzz-waker/src/roster_feed.rs | 203 ++++++++++++++++++++++----- 1 file changed, 168 insertions(+), 35 deletions(-) diff --git a/crates/buzz-waker/src/roster_feed.rs b/crates/buzz-waker/src/roster_feed.rs index d14a98eea10..9bd15e3360c 100644 --- a/crates/buzz-waker/src/roster_feed.rs +++ b/crates/buzz-waker/src/roster_feed.rs @@ -20,13 +20,18 @@ //! diff against the daemon's current watch list and act on. It does not //! spawn, cancel, or otherwise supervise anything itself. //! -//! # Multiple owners, one query +//! # Multiple owners, several queries //! //! Unlike a bundle or credential tap (pinned to one already-known owner), //! this daemon may be configured with several owners in `WAKER_OWNER_PUBKEYS` //! — each with their own roster, at the same fixed `d` coordinate but a -//! different `authors` entry. One REQ with `authors` set to the whole -//! authorized list covers all of them; [`RosterState`] then tracks each +//! different `authors` entry. Each owner gets its own filter — see +//! [`roster_filters`]'s own doc for why one shared filter would apply the +//! query's `limit` across every owner combined rather than to each of them — +//! and [`roster_reqs`] batches those filters into REQ frames of at most +//! [`ROSTER_MAX_FILTERS_PER_REQ`], the relay's own per-REQ cap, opening +//! however many subscriptions that takes rather than ever emitting a REQ +//! the relay would refuse outright. [`RosterState`] then tracks each //! owner's latest roster independently, keyed by owner pubkey, because one //! owner's roster says nothing about another's membership. //! @@ -73,9 +78,11 @@ use crate::enrolment::{RosterBody, SignedRoster}; use crate::feed::reconnect_delay_ms; use crate::floors::{FloorError, FloorStore}; -/// Subscription id for the daemon's one roster tap. Fixed, like every other -/// tap's own id — a reconnect replaces the old subscription rather than -/// piling up a fresh one. +/// Base subscription id for the daemon's roster tap. Fixed, like every other +/// tap's own id — a reconnect replaces the old subscriptions rather than +/// piling up fresh ones. Never used bare as a wire subscription id itself: +/// every actual REQ carries [`roster_subscription_id`]'s batch-suffixed +/// form, even when there is only one batch — see that function's own doc. pub const ROSTER_TAP_SUBSCRIPTION_ID: &str = "buzz-waker-roster"; /// The fixed `d` tag every roster event carries — the public, collision-proof @@ -136,16 +143,54 @@ pub fn roster_filters(authorized_owners: &[String], waker_pubkey: &str) -> Vec Value { - let mut frame = vec![ - Value::String("REQ".to_string()), - Value::String(ROSTER_TAP_SUBSCRIPTION_ID.to_string()), - ]; - frame.extend(roster_filters(authorized_owners, waker_pubkey)); - Value::Array(frame) +pub fn roster_subscription_id(index: usize) -> String { + format!("{ROSTER_TAP_SUBSCRIPTION_ID}-{index}") +} + +/// The REQ frames opening the daemon's roster tap: `authorized_owners` +/// split into batches of at most [`ROSTER_MAX_FILTERS_PER_REQ`], one REQ +/// frame per batch under its own [`roster_subscription_id`]. +/// +/// A daemon with more authorized owners than one REQ can hold still needs +/// every owner's roster, not just the first ten — multiple subscriptions +/// on the same connection is how NIP-01 already supports "more filters +/// than one REQ can carry" (the same reason a client opens several +/// subscriptions rather than one giant one). Splitting here, rather than +/// silently dropping owners past the cap or refusing to start, is what +/// makes an 11-owner configuration behave the same as a 10-owner one. +#[must_use] +pub fn roster_reqs(authorized_owners: &[String], waker_pubkey: &str) -> Vec<(String, Value)> { + authorized_owners + .chunks(ROSTER_MAX_FILTERS_PER_REQ) + .enumerate() + .map(|(index, owners)| { + let subscription_id = roster_subscription_id(index); + let mut frame = vec![ + Value::String("REQ".to_string()), + Value::String(subscription_id.clone()), + ]; + frame.extend(roster_filters(owners, waker_pubkey)); + (subscription_id, Value::Array(frame)) + }) + .collect() } /// Shared, thread-safe cache of the latest known roster per owner. @@ -236,17 +281,26 @@ enum RosterFrame { /// Classify one relay message for the roster tap. /// +/// `subscription_ids` is every batch id this run of the tap opened (see +/// [`roster_reqs`]) — more than one once `authorized_owners` exceeds +/// [`ROSTER_MAX_FILTERS_PER_REQ`], so a single fixed id can no longer be +/// the test. +/// /// Verification proves only that the stated author signed the event — it /// does not prove the relay applied this subscription's filter. Re-checking /// the `#d` tag and the author against `authorized_owners` here is what stops /// a misrouted or replayed event from ever reaching the decrypt step, same /// reasoning [`crate::bundle_feed::bundle_frame`] applies for its own tap. -fn roster_frame(authorized_owners: &[String], message: RelayMessage) -> RosterFrame { +fn roster_frame( + authorized_owners: &[String], + subscription_ids: &[String], + message: RelayMessage, +) -> RosterFrame { match message { RelayMessage::Event { subscription_id, event, - } if subscription_id == ROSTER_TAP_SUBSCRIPTION_ID => { + } if subscription_ids.contains(&subscription_id) => { if let Err(error) = buzz_core::verify_event(&event) { return RosterFrame::Rejected { event_id: event.id.to_hex(), @@ -274,7 +328,7 @@ fn roster_frame(authorized_owners: &[String], message: RelayMessage) -> RosterFr RelayMessage::Closed { subscription_id, message, - } if subscription_id == ROSTER_TAP_SUBSCRIPTION_ID => RosterFrame::Closed { message }, + } if subscription_ids.contains(&subscription_id) => RosterFrame::Closed { message }, _ => RosterFrame::Ignored, } } @@ -387,10 +441,13 @@ fn open_or_create_owner_floor(dir: &Path, owner_pubkey: &str) -> Result break, }; - if let Err(error) = connection - .send_raw(&roster_req(&authorized_owners, &waker_pubkey)) - .await - { - tracing::warn!(%error, "roster tap subscribe failed; reconnecting"); - consecutive_failures = consecutive_failures.saturating_add(1); + let reqs = roster_reqs(&authorized_owners, &waker_pubkey); + let subscription_ids: Vec = reqs.iter().map(|(id, _)| id.clone()).collect(); + let mut subscribed = true; + for (_, req) in &reqs { + if let Err(error) = connection.send_raw(req).await { + tracing::warn!(%error, "roster tap subscribe failed; reconnecting"); + consecutive_failures = consecutive_failures.saturating_add(1); + subscribed = false; + break; + } + } + if !subscribed { continue; } consecutive_failures = 0; @@ -483,7 +546,7 @@ pub async fn run_roster_tap( }; match next { - Ok(message) => match roster_frame(&authorized_owners, message) { + Ok(message) => match roster_frame(&authorized_owners, &subscription_ids, message) { RosterFrame::Delivered { owner_pubkey, ciphertext, @@ -621,15 +684,19 @@ mod tests { } #[test] - fn the_req_frame_carries_one_filter_per_owner_under_one_subscription() { + fn one_owner_still_produces_exactly_one_req_under_a_suffixed_subscription_id() { let owner_a = "a".repeat(64); let owner_b = "b".repeat(64); let waker_pubkey = "c".repeat(64); - let req = roster_req(&[owner_a, owner_b], &waker_pubkey); + let reqs = roster_reqs(&[owner_a, owner_b], &waker_pubkey); + + assert_eq!(reqs.len(), 1, "two owners fit in one batch"); + let (subscription_id, req) = &reqs[0]; + assert_eq!(subscription_id, &roster_subscription_id(0)); let frame = req.as_array().expect("REQ is an array"); assert_eq!(frame[0], json!("REQ")); - assert_eq!(frame[1], json!(ROSTER_TAP_SUBSCRIPTION_ID)); + assert_eq!(frame[1], json!(subscription_id)); assert_eq!( frame.len(), 4, @@ -637,16 +704,75 @@ mod tests { ); } + #[test] + fn owners_past_the_relay_filter_cap_split_into_a_second_req() { + let waker_pubkey = "c".repeat(64); + let owners: Vec = (0..(ROSTER_MAX_FILTERS_PER_REQ + 1)) + .map(|i| format!("{i:064x}")) + .collect(); + + let reqs = roster_reqs(&owners, &waker_pubkey); + + assert_eq!( + reqs.len(), + 2, + "one owner over the cap must open a second subscription, not be dropped or refuse to start" + ); + let (first_id, first_req) = &reqs[0]; + let (second_id, second_req) = &reqs[1]; + assert_eq!(first_id, &roster_subscription_id(0)); + assert_eq!(second_id, &roster_subscription_id(1)); + assert_ne!(first_id, second_id); + + let first_filters = first_req.as_array().expect("array").len() - 2; + let second_filters = second_req.as_array().expect("array").len() - 2; + assert_eq!(first_filters, ROSTER_MAX_FILTERS_PER_REQ); + assert_eq!(second_filters, 1); + assert_eq!( + first_filters + second_filters, + owners.len(), + "every owner must appear in exactly one batch" + ); + } + #[test] fn a_verified_delivery_from_an_authorized_owner_is_delivered() { let owner = Keys::generate(); let owner_pubkey = normalize_pubkey(&owner.public_key().to_hex()); let event = roster_event(&owner, "ciphertext-bytes"); + let subscription_id = roster_subscription_id(0); + + let frame = roster_frame( + std::slice::from_ref(&owner_pubkey), + std::slice::from_ref(&subscription_id), + RelayMessage::Event { + subscription_id: subscription_id.clone(), + event: Box::new(event), + }, + ); + assert_eq!( + frame, + RosterFrame::Delivered { + owner_pubkey, + ciphertext: "ciphertext-bytes".to_string() + } + ); + } + + /// A daemon with owners split across two batches must still recognize a + /// delivery on the second batch's subscription id, not just the first. + #[test] + fn a_delivery_on_a_later_batchs_subscription_is_still_delivered() { + let owner = Keys::generate(); + let owner_pubkey = normalize_pubkey(&owner.public_key().to_hex()); + let event = roster_event(&owner, "ciphertext-bytes"); + let subscription_ids = vec![roster_subscription_id(0), roster_subscription_id(1)]; let frame = roster_frame( std::slice::from_ref(&owner_pubkey), + &subscription_ids, RelayMessage::Event { - subscription_id: ROSTER_TAP_SUBSCRIPTION_ID.to_string(), + subscription_id: roster_subscription_id(1), event: Box::new(event), }, ); @@ -664,11 +790,13 @@ mod tests { let owner = Keys::generate(); let other_authorized = Keys::generate(); let event = roster_event(&owner, "ciphertext-bytes"); + let subscription_id = roster_subscription_id(0); let frame = roster_frame( &[normalize_pubkey(&other_authorized.public_key().to_hex())], + std::slice::from_ref(&subscription_id), RelayMessage::Event { - subscription_id: ROSTER_TAP_SUBSCRIPTION_ID.to_string(), + subscription_id: subscription_id.clone(), event: Box::new(event), }, ); @@ -693,11 +821,13 @@ mod tests { ]) .sign_with_keys(&owner) .expect("sign"); + let subscription_id = roster_subscription_id(0); let frame = roster_frame( &[owner_pubkey], + std::slice::from_ref(&subscription_id), RelayMessage::Event { - subscription_id: ROSTER_TAP_SUBSCRIPTION_ID.to_string(), + subscription_id: subscription_id.clone(), event: Box::new(event), }, ); @@ -712,6 +842,7 @@ mod tests { let frame = roster_frame( &[owner_pubkey], + std::slice::from_ref(&roster_subscription_id(0)), RelayMessage::Event { subscription_id: "some-other-subscription".to_string(), event: Box::new(event), @@ -722,10 +853,12 @@ mod tests { #[test] fn a_closed_frame_for_this_subscription_is_reported() { + let subscription_id = roster_subscription_id(0); let frame = roster_frame( &["a".repeat(64)], + std::slice::from_ref(&subscription_id), RelayMessage::Closed { - subscription_id: ROSTER_TAP_SUBSCRIPTION_ID.to_string(), + subscription_id: subscription_id.clone(), message: "auth-required".to_string(), }, ); From 7b149cd48b3c0f336a682554035a56ae2e111027 Mon Sep 17 00:00:00 2001 From: Junchao Yan Date: Wed, 12 Aug 2026 23:21:56 -0700 Subject: [PATCH 08/10] feat(waker): dynamic per-agent supervisor (build order step 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacks on claude/waker-enrolment-schema (PR #48, approved, not yet merged) — needs its roster_feed/credential_feed taps. Extracts the per-agent spawn block (presence tap, bundle tap, wake loop) into spawn_agent_watch, called both by the static WAKER_AGENTS_CONFIG_PATH startup loop (unchanged behavior) and by a new reconciliation loop that diffs every authorized owner's roster against a supervised map, spawning a per-agent credential tap to fetch a newly-listed agent's nsec before it can be watched, then calling spawn_agent_watch once that credential arrives. A statically configured pubkey always wins a collision against a roster-discovered one and is never touched by the roster diff in either direction (compute_desired_roster_agents). Each watched agent's three tasks share one CancellationToken::child_token of the daemon's global token, tracked in a SupervisedAgent map keyed by pubkey. An unsolicited exit is classified (classify_exit): fatal for the whole daemon if the agent was statically configured (preserves today's exact behavior when WAKER_OWNER_PUBKEYS is unset), but tears down only that one agent if it was roster-discovered — a single tenant's agent misbehaving must not take down a daemon serving several. confirm_author_not_known_agent's baseline (this daemon's watch list) moves from a frozen Arc<[String]> snapshot taken once at startup to a new live WatchList (Arc>>) updated as agents are added or removed. A frozen snapshot would let a roster-added agent's own mention wake another agent undetected, defeating the no-agent-to-agent-wake-loop invariant the guard exists to enforce. New env vars, both optional: WAKER_OWNER_PUBKEYS (comma-separated authorized owners; empty/unset disables dynamic enrolment, matching parse_authorized_owners' existing fail-closed contract) and WAKER_IDENTITY_NSEC (required only when WAKER_OWNER_PUBKEYS is set — the roster/credential taps' own connecting identity, never a watched agent's). Deliberately deferred, not implemented this round: a WAKER_MAX_AGENTS capacity bound (open tuning value in the design doc, not part of this step's own build-order text) and reacting to a credential rotation/ revocation for an already-running dynamically watched agent (the credential tap keeps running and would log one, but only the first delivered credential bootstraps identity). WAKER_AGENTS_CONFIG_PATH still requires at least one entry — a pure roster-only daemon with zero static agents isn't possible yet. cargo test -p buzz-waker: 261 lib + 17 main pass (12 new: 6 for WatchList, 6 for compute_desired_roster_agents/classify_exit). clippy -D warnings and fmt --check clean. Signed-off-by: Junchao Yan --- crates/buzz-waker/src/effects.rs | 70 +- crates/buzz-waker/src/lib.rs | 11 +- crates/buzz-waker/src/main.rs | 968 ++++++++++++++++++++++++---- crates/buzz-waker/src/wake_loop.rs | 14 +- crates/buzz-waker/src/watch_list.rs | 126 ++++ 5 files changed, 1023 insertions(+), 166 deletions(-) create mode 100644 crates/buzz-waker/src/watch_list.rs diff --git a/crates/buzz-waker/src/effects.rs b/crates/buzz-waker/src/effects.rs index fb973b88f0d..aba4aa24ea4 100644 --- a/crates/buzz-waker/src/effects.rs +++ b/crates/buzz-waker/src/effects.rs @@ -45,13 +45,18 @@ //! `select_wake_candidates` filtered against can be minutes stale. The //! desktop's version re-checks the full managed-agent roster (local ∪ //! relay-registered). This daemon has no such roster — it only knows the -//! agents it was configured to watch — so its baseline is that watch list. -//! Documented in `PLANS/BUZZ_WAKER_DESIGN.md` as an accepted gap: an author -//! that is a *managed agent this daemon does not watch* is not caught here. -//! It is still caught by the synchronous baseline at admission time whenever -//! that baseline is populated the same way; the re-check only narrows the -//! window, and narrowing it to "this daemon's own agents" is strictly better -//! than not re-checking at all. +//! agents it was configured to watch — so its baseline is that watch list, +//! held in a [`crate::watch_list::WatchList`] and read live rather than +//! snapshotted, since the dynamic supervisor (`PLANS/BUZZ_WAKER_DESIGN.md` +//! §12 build order step 3) can add or remove a watched agent at any time — +//! see that module's own doc for why a frozen snapshot would let a +//! roster-added agent's mention wake another agent undetected. Still +//! documented as an accepted gap: an author that is a *managed agent this +//! daemon does not watch* is not caught here. It is still caught by the +//! synchronous baseline at admission time whenever that baseline is +//! populated the same way; the re-check only narrows the window, and +//! narrowing it to "this daemon's own agents" is strictly better than not +//! re-checking at all. use std::sync::Arc; @@ -59,6 +64,7 @@ use crate::attempt::{HeartbeatObservation, WakeEffects}; use crate::bundle::LaunchBundleBody; use crate::decide::normalize_pubkey; use crate::presence_feed::{PresenceError, PresenceState}; +use crate::watch_list::WatchList; use buzz_core::PresenceStatus; use tokio_util::sync::CancellationToken; @@ -138,10 +144,12 @@ pub struct RealWakeEffects { /// The presence tap shared with every attempt for this agent — one tap /// per watched agent, not per attempt. presence_state: Arc, - /// This daemon's full watch list, normalized. Used only by + /// This daemon's live watch list. Used only by /// `confirm_author_not_known_agent`; see the module note on why this is - /// the accepted baseline rather than a full managed-agent roster. - watch_list: Arc<[String]>, + /// the accepted baseline rather than a full managed-agent roster, and + /// `crate::watch_list`'s own doc on why it must be read live rather than + /// snapshotted once an agent can be added or removed at runtime. + watch_list: WatchList, /// The pubkey of the agent this attempt is scoped to — this daemon's own /// watched identity, never derived from the bundle. Compared against /// `bundle.agent_pubkey` before any deploy, so a bundle transport bug @@ -178,7 +186,7 @@ impl RealWakeEffects { #[allow(clippy::too_many_arguments)] pub fn new( presence_state: Arc, - watch_list: Arc<[String]>, + watch_list: WatchList, watched_agent_pubkey: &str, trigger_author: &str, trigger_created_at: u64, @@ -244,13 +252,13 @@ impl WakeEffects for RealWakeEffects { async fn confirm_author_not_known_agent(&self) -> Result { // `Ok(true)` means "confirmed not a known agent" — see the trait doc. - // Every entry in the watch list is, by definition, a known agent this - // daemon manages, so the author is clear exactly when it matches - // none of them. - Ok(!self - .watch_list - .iter() - .any(|watched| watched == &self.trigger_author)) + // Every member of the watch list is, by definition, a known agent + // this daemon manages, so the author is clear exactly when it is not + // currently a member. Read live (`WatchList::contains`), not from a + // snapshot taken when this attempt was constructed — the whole point + // of a fresh re-check is to catch a watch-list change since the + // synchronous baseline ran. + Ok(!self.watch_list.contains(&self.trigger_author)) } async fn start_managed_agent(&self) -> Result, Self::Error> { @@ -364,7 +372,7 @@ mod tests { #[allow(clippy::too_many_arguments)] fn effects_with( presence_state: Arc, - watch_list: Arc<[String]>, + watch_list: WatchList, watched_agent_pubkey: &str, trigger_author: &str, bundle: Option>, @@ -406,7 +414,7 @@ mod tests { async fn an_unresolved_presence_tap_reports_unavailable() { let effects = effects_with( state(), - Arc::from(vec![]), + WatchList::from(vec![]), "aa".repeat(32).as_str(), "aa".repeat(32).as_str(), None, @@ -427,7 +435,7 @@ mod tests { presence_state.observe("ev1", PresenceStatus::Online, 1_000); let effects = effects_with( presence_state, - Arc::from(vec![]), + WatchList::from(vec![]), "aa".repeat(32).as_str(), "aa".repeat(32).as_str(), None, @@ -443,7 +451,7 @@ mod tests { let watched = "bb".repeat(32); let effects = effects_with( state(), - Arc::from(vec![watched.clone()]), + WatchList::from(vec![watched.clone()]), "aa".repeat(32).as_str(), &watched, None, @@ -458,7 +466,7 @@ mod tests { async fn an_author_off_the_watch_list_is_confirmed_clear() { let effects = effects_with( state(), - Arc::from(vec!["bb".repeat(32)]), + WatchList::from(vec!["bb".repeat(32)]), "aa".repeat(32).as_str(), "cc".repeat(32).as_str(), None, @@ -474,7 +482,7 @@ mod tests { let watched = "BB".repeat(32); let effects = effects_with( state(), - Arc::from(vec![normalize_pubkey(&watched)]), + WatchList::from(vec![normalize_pubkey(&watched)]), "aa".repeat(32).as_str(), &watched, None, @@ -489,7 +497,7 @@ mod tests { async fn start_managed_agent_without_a_bundle_reports_no_bundle() { let effects = effects_with( state(), - Arc::from(vec![]), + WatchList::from(vec![]), "aa".repeat(32).as_str(), "aa".repeat(32).as_str(), None, @@ -511,7 +519,7 @@ mod tests { let bundle = bundle_for(&watched, u64::MAX); let effects = effects_with( state(), - Arc::from(vec![]), + WatchList::from(vec![]), &watched, "aa".repeat(32).as_str(), Some(bundle), @@ -534,7 +542,7 @@ mod tests { let bundle = bundle_for(&other_agent, u64::MAX); let effects = effects_with( state(), - Arc::from(vec![]), + WatchList::from(vec![]), &watched, "aa".repeat(32).as_str(), Some(bundle), @@ -562,7 +570,7 @@ mod tests { let bundle = bundle_for(&watched, 1); let effects = effects_with( state(), - Arc::from(vec![]), + WatchList::from(vec![]), &watched, "aa".repeat(32).as_str(), Some(bundle), @@ -590,7 +598,7 @@ mod tests { let called_clone = called.clone(); let effects = effects_with( state(), - Arc::from(vec![]), + WatchList::from(vec![]), "aa".repeat(32).as_str(), "aa".repeat(32).as_str(), None, @@ -607,7 +615,7 @@ mod tests { let cancel = CancellationToken::new(); let effects = effects_with( state(), - Arc::from(vec![]), + WatchList::from(vec![]), "aa".repeat(32).as_str(), "aa".repeat(32).as_str(), None, @@ -624,7 +632,7 @@ mod tests { let cancel = CancellationToken::new(); let effects = effects_with( state(), - Arc::from(vec![]), + WatchList::from(vec![]), "aa".repeat(32).as_str(), "aa".repeat(32).as_str(), None, diff --git a/crates/buzz-waker/src/lib.rs b/crates/buzz-waker/src/lib.rs index 05c340ad650..916dbae9e93 100644 --- a/crates/buzz-waker/src/lib.rs +++ b/crates/buzz-waker/src/lib.rs @@ -49,11 +49,11 @@ //! [`bundle_feed`]'s connect/backoff/idle-timeout shape, also authenticated //! as the waker's own identity, decrypting and admitting one agent's //! delivered `nsec` against a per-agent [`floors::FloorStore`]. -//! `docs/waker-agent-enrolment.md` (design) and `PLANS/BUZZ_WAKER_DESIGN.md` -//! §12 (build order) — the dynamic per-agent supervisor `main.rs` needs to -//! diff [`roster_feed::RosterState`] against the daemon's watch list and -//! spawn/cancel [`credential_feed::run_credential_tap`] instances is the -//! next phase, not yet implemented. +//! - [`watch_list`] — [`watch_list::WatchList`], this daemon's live known-agent +//! set. `main.rs`'s dynamic supervisor (`PLANS/BUZZ_WAKER_DESIGN.md` §12 +//! build order step 3) diffs [`roster_feed::RosterState`] against it, +//! spawning/cancelling [`credential_feed::run_credential_tap`] plus each +//! agent's presence/bundle/wake-loop tasks as the roster changes. //! //! Each exists because of a specific review finding and carries the gate id //! (`G1`–`G4`) it discharges, so the reason is not lost. @@ -73,6 +73,7 @@ pub mod presence_feed; pub mod relay_feed; pub mod roster_feed; pub mod wake_loop; +pub mod watch_list; pub use attempt::{ is_managed_agent_live, is_presumed_delivered_by_floor, is_wake_attempt_debounced, diff --git a/crates/buzz-waker/src/main.rs b/crates/buzz-waker/src/main.rs index 82773b946a1..9fcfe5a3665 100644 --- a/crates/buzz-waker/src/main.rs +++ b/crates/buzz-waker/src/main.rs @@ -4,31 +4,66 @@ //! `crates/buzz-relay/src/main.rs` and `crates/buzz-pair-relay/src/main.rs` //! for the pattern this follows. JSON-structured logs, graceful shutdown on //! SIGTERM/Ctrl+C via a shared [`CancellationToken`], and three tasks spawned -//! per configured agent: the mention-feed loop +//! per watched agent: the mention-feed loop //! ([`buzz_waker::wake_loop::run_wake_loop`]), the presence tap //! ([`buzz_waker::presence_feed::run_presence_tap`]), and the bundle-delivery //! tap ([`buzz_waker::bundle_feed::run_bundle_tap`]). //! +//! # Two ways an agent gets watched +//! +//! **Statically**, from `WAKER_AGENTS_CONFIG_PATH` — read once at startup, +//! same as before this module's dynamic supervisor (below) existed. +//! +//! **Dynamically**, via the roster tap +//! ([`buzz_waker::roster_feed::run_roster_tap`]), when `WAKER_OWNER_PUBKEYS` +//! is non-empty (`PLANS/BUZZ_WAKER_DESIGN.md` §12 build order step 3). This +//! daemon's own reconciliation loop diffs every authorized owner's current +//! roster against its own `supervised` map, spawns a per-agent credential tap +//! ([`buzz_waker::credential_feed::run_credential_tap`]) to fetch a +//! newly-listed agent's `nsec` before it can be watched at all, then calls +//! the same [`spawn_agent_watch`] a static agent uses once that credential +//! arrives — and cancels a previously roster-added agent's tasks the moment +//! it drops off every authorized owner's roster. A statically configured +//! agent always wins a pubkey collision against a roster-discovered one: see +//! [`compute_desired_roster_agents`]'s own doc. +//! //! # Configuration //! //! | Env var | Required | Meaning | //! |---|---|---| -//! | `WAKER_RELAY_URL` | yes | The relay every watched agent's mention feed, presence tap, and bundle tap connects to. | -//! | `WAKER_STATE_DIR` | yes | Base directory for durable per-agent state (`//{cursor,floor}.json`). Created if missing. | -//! | `WAKER_AGENTS_CONFIG_PATH` | yes | Path to a JSON file listing the agents to watch — see [`AgentConfig`]. | +//! | `WAKER_RELAY_URL` | yes | The relay every watched agent's mention feed, presence tap, and bundle tap connects to — also the relay the roster/credential taps below connect to. | +//! | `WAKER_STATE_DIR` | yes | Base directory for durable per-agent state (`//{cursor,floor,credential_floor}.json`) and the roster's own per-owner floors (`/roster-floors/.json`). Created if missing. | +//! | `WAKER_AGENTS_CONFIG_PATH` | yes | Path to a JSON file listing the agents to statically watch — see [`AgentConfig`]. | +//! | `WAKER_OWNER_PUBKEYS` | no | Comma-separated list of owner pubkeys this daemon discovers agents for dynamically. Empty or unset disables dynamic enrolment entirely — see [`buzz_waker::enrolment::parse_authorized_owners`]'s own fail-closed doc. | +//! | `WAKER_IDENTITY_NSEC` | only if `WAKER_OWNER_PUBKEYS` is set | This daemon's own Nostr identity — the roster and credential taps decrypt as this key, never as any watched agent's. | //! | `RUST_LOG` | no | `tracing-subscriber` env filter. Defaults to `buzz_waker=info`. | //! //! # What is still deliberately not here //! -//! Agent identities, the watch list, and each agent's owner pubkey (pinned +//! Every *statically* configured agent's identity and owner pubkey (pinned //! into its [`buzz_waker::floors::FloorStore`] on first run, **G2**) are read //! from local config, matching the ecosystem's existing agent-identity //! provisioning story — nothing about *that* pin can come from a delivered -//! bundle without defeating the pin's own purpose. +//! bundle without defeating the pin's own purpose. A dynamically discovered +//! agent's owner is instead the roster entry's own owner, already proven +//! against `WAKER_OWNER_PUBKEYS` before this daemon ever trusts it (see +//! [`buzz_waker::roster_feed`]'s module doc). +//! +//! Not implemented this round, and deliberately deferred rather than +//! guessed at: a `WAKER_MAX_AGENTS` total-capacity bound (recorded as an +//! open tuning value in the design doc's multi-tenant extension, not part +//! of this step's own build-order text) and reacting to a credential +//! *rotation* for an already-running dynamically watched agent (this +//! daemon's credential tap keeps running for that agent's whole lifetime +//! and would log a rotation or revocation, but nothing currently acts on it +//! — only the *first* delivered credential is used, to bootstrap that +//! agent's identity). +use std::collections::HashMap; use std::collections::HashSet; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::time::Duration; use nostr::{Keys, Tag}; use serde::Deserialize; @@ -37,10 +72,14 @@ use tokio_util::sync::CancellationToken; use tracing_subscriber::{fmt, prelude::*, EnvFilter}; use buzz_waker::bundle_feed::{run_bundle_tap, BundleState}; +use buzz_waker::credential_feed::{run_credential_tap, CredentialState}; use buzz_waker::decide::normalize_pubkey; -use buzz_waker::floors::FloorStore; +use buzz_waker::enrolment::{parse_authorized_owners, RosterEntry}; +use buzz_waker::floors::{FloorError, FloorStore}; use buzz_waker::presence_feed::{run_presence_tap, PresenceState}; +use buzz_waker::roster_feed::{run_roster_tap, RosterState}; use buzz_waker::wake_loop::{run_wake_loop, WakeLoopConfig}; +use buzz_waker::watch_list::WatchList; /// One watched agent, as read from `WAKER_AGENTS_CONFIG_PATH`. #[derive(Debug, Deserialize)] @@ -109,8 +148,8 @@ fn ensure_owner_pin_matches( if pinned_owner != configured_owner { anyhow::bail!( "floor store for {pubkey} is pinned to owner {pinned_owner}, but \ - WAKER_AGENTS_CONFIG_PATH now configures owner {configured_owner}; refusing \ - to run with disagreeing owners" + the configured owner is now {configured_owner}; refusing to run with \ + disagreeing owners" ); } Ok(()) @@ -169,6 +208,539 @@ async fn shutdown_signal() { } } +/// How a watched pubkey came to be watched — governs both dedup ordering +/// (config always wins a collision) and how loudly this daemon reacts to +/// one of its tasks exiting unsolicited: see [`classify_exit`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AgentSource { + /// Listed in `WAKER_AGENTS_CONFIG_PATH`. + Config, + /// Discovered via an authorized owner's roster. + Roster, +} + +/// This daemon's bookkeeping for one currently-watched (or being-bootstrapped) +/// pubkey. +struct SupervisedAgent { + /// Shared by every task this pubkey owns (credential tap, presence tap, + /// bundle tap, wake loop) — a [`CancellationToken::child_token`] of the + /// daemon's own global token. Cancelling it stops exactly this pubkey's + /// tasks, nothing else; the global token cancelling stops it too, along + /// with everything else, on shutdown. + cancel: CancellationToken, + source: AgentSource, + /// `Some` while a [`AgentSource::Roster`] agent is still waiting for its + /// first credential delivery — the reconciliation loop polls it each + /// tick and, once populated, spawns this agent's presence/bundle/wake + /// tasks and clears this field. Always `None` for [`AgentSource::Config`] + /// (which already has its `nsec` from local config) and for a + /// [`AgentSource::Roster`] agent whose bootstrap has already completed. + credential_bootstrap: Option>, + /// The owner that published this pubkey's roster entry. Unused for + /// [`AgentSource::Config`] (each config entry already carries its own + /// `owner_pubkey` separately, read once at spawn time). + owner_pubkey: String, +} + +/// Why one of this daemon's tasks finished, for the join-handling loop in +/// [`main`] to classify via [`classify_exit`]. +enum TaskExit { + /// One of a watched agent's three tasks (`"presence_tap"`, + /// `"bundle_tap"`, or `"wake_loop"`). + Agent { pubkey: String, task: &'static str }, + /// A [`AgentSource::Roster`] agent's credential tap, tracked separately + /// from `Agent` only so log lines name it correctly — it shares that + /// agent's own [`SupervisedAgent::cancel`] and is classified exactly the + /// same way. + CredentialTap { pubkey: String }, + /// A daemon-wide task with no per-agent scope: the roster tap. Expected + /// to run until the global token cancels; any other exit is fatal. + Component(&'static str), +} + +/// What an unsolicited task exit means for the daemon. +#[derive(Debug, PartialEq, Eq)] +enum ExitDisposition { + /// Already accounted for — the owning entry was already removed from + /// `supervised` (an earlier sibling's exit already tore this pubkey + /// down), or its own token was already cancelled deliberately. No + /// further action. + Expected, + /// This daemon cannot recover from this exit; stop everything. + FatalDaemon, + /// Tear down just this one roster-discovered agent's remaining tasks + /// and keep running everything else — a single tenant's agent + /// misbehaving must not take down a daemon serving several. + TearDownAgent, +} + +/// Classify one task's exit, given whether it was already-known-cancelled at +/// the moment it finished and which agent (if any) it belonged to. +/// +/// `was_cancelled = true` covers both a deliberate per-agent cancellation +/// (roster removed this pubkey) and a global shutdown (every child token +/// cancels transitively) — either way, the exit is expected, not a failure +/// this function needs to react to. +/// +/// A statically configured agent's unsolicited exit is fatal for the whole +/// daemon — the historical behavior, preserved exactly: before this +/// module's dynamic supervisor, *every* watched agent was config-sourced, +/// so this is also what makes today's default (no `WAKER_OWNER_PUBKEYS`) +/// behave identically to before this file changed. +/// +/// `source = None` only reaches this function as `(None, true)` from the +/// real call site in [`main`] — it derives `was_cancelled` via +/// `supervised.get(pubkey).map(|a| a.cancel.is_cancelled()).unwrap_or(true)`, +/// so a pubkey no longer in `supervised` (already torn down by an earlier +/// sibling task's exit) always reports `was_cancelled = true` and short +/// circuits above. `(None, false)` is therefore not reachable from `main` +/// today; it is still handled here, conservatively, as fatal rather than +/// `unreachable!` — a caller that ever changes that default should fail +/// loud, not silently swallow an exit this function has no real source for. +fn classify_exit(source: Option, was_cancelled: bool) -> ExitDisposition { + if was_cancelled { + return ExitDisposition::Expected; + } + match source { + None | Some(AgentSource::Config) => ExitDisposition::FatalDaemon, + Some(AgentSource::Roster) => ExitDisposition::TearDownAgent, + } +} + +/// One agent this daemon should be watching because an authorized owner's +/// roster lists it. +struct DesiredRosterAgent { + pubkey: String, + owner_pubkey: String, +} + +/// Diff every authorized owner's current roster into the set of pubkeys this +/// daemon should be watching dynamically. +/// +/// `rosters` is `(owner_pubkey, entries)` for every owner this daemon +/// currently has a tracked roster for (from +/// [`buzz_waker::roster_feed::RosterState`] — read at the call site, not +/// here, so this stays a plain function over data rather than needing a live +/// `RosterState` to unit test). `existing_sources` is a snapshot of +/// `main`'s own `supervised` map, pubkey to [`AgentSource`] — enough to +/// enforce the one dedup rule that matters: **a statically configured +/// pubkey is never touched by this diff**, in either direction. It is never +/// added again (it is already supervised) and never removed (removal below +/// only ever targets [`AgentSource::Roster`] entries) — see +/// `PLANS/BUZZ_WAKER_DESIGN.md` §12's own note that a broken enrolment path +/// must never be load-bearing for an agent an operator explicitly +/// configured. +/// +/// Returns the desired set plus every pubkey more than one authorized owner +/// claims in this pass — informational for the caller to log loudly (an +/// operator misconfiguration, not something this function can resolve on +/// its own); the first owner encountered wins that pubkey, deterministic +/// only in `rosters`' own iteration order. +fn compute_desired_roster_agents( + rosters: &[(String, Vec)], + existing_sources: &HashMap, +) -> (Vec, Vec) { + let mut desired = Vec::new(); + let mut claimed_by: HashMap = HashMap::new(); + let mut conflicts = Vec::new(); + + for (owner_pubkey, entries) in rosters { + for entry in entries { + let pubkey = normalize_pubkey(&entry.agent_pubkey); + if existing_sources.get(&pubkey) == Some(&AgentSource::Config) { + continue; + } + match claimed_by.get(&pubkey) { + Some(first_owner) if first_owner != owner_pubkey => { + conflicts.push(pubkey); + } + Some(_) => {} + None => { + claimed_by.insert(pubkey.clone(), owner_pubkey.clone()); + desired.push(DesiredRosterAgent { + pubkey, + owner_pubkey: owner_pubkey.clone(), + }); + } + } + } + } + + (desired, conflicts) +} + +/// Open (or, the first time this daemon has ever seen `pubkey` under this +/// exact floor path, enroll) a [`FloorStore`] pinned to `owner_pubkey`, and +/// refuse it if a previous pin disagrees. +/// +/// Shared by the bundle floor and the credential floor, and by both a +/// statically configured agent (whose owner never changes across restarts) +/// and a roster-discovered one (whose claimed owner is re-validated against +/// the pin on every reconciliation tick that touches it). +/// +/// # Errors +/// The store cannot be created/opened, or its pinned owner disagrees with +/// `owner_pubkey`. +fn open_pinned_floor_store(path: &Path, owner_pubkey: &str) -> anyhow::Result { + let store = match FloorStore::open(path) { + Ok(store) => store, + Err(FloorError::NotEnrolled { .. }) => { + FloorStore::enroll(path, owner_pubkey).map_err(|e| { + anyhow::anyhow!("could not enroll floor store at {}: {e}", path.display()) + })? + } + Err(e) => anyhow::bail!("could not open floor store at {}: {e}", path.display()), + }; + let pinned_owner = normalize_pubkey(&store.snapshot().owner_pubkey); + ensure_owner_pin_matches(&path.display().to_string(), &pinned_owner, owner_pubkey)?; + Ok(store) +} + +/// Spawn one agent's presence tap, bundle tap, and wake loop under `cancel`, +/// and register it in `watch_list`. +/// +/// The extracted "per-agent spawn block" +/// `PLANS/BUZZ_WAKER_DESIGN.md` §12 build order step 3 calls for — shared by +/// both a statically configured agent (called once per entry at startup) +/// and a roster-discovered one (called once its credential tap delivers a +/// first `nsec`), so there is exactly one place this wiring can drift. +/// +/// # Errors +/// The agent's state directory or bundle [`FloorStore`] cannot be +/// created/opened, or the store's pinned owner disagrees with +/// `owner_pubkey` — see [`open_pinned_floor_store`]. +#[allow(clippy::too_many_arguments)] +fn spawn_agent_watch( + relay_url: &str, + state_dir: &Path, + keys: &Keys, + auth_tag: Option<&Tag>, + owner_pubkey: &str, + watch_list: &WatchList, + cancel: CancellationToken, + tasks: &mut JoinSet, +) -> anyhow::Result<()> { + let pubkey = normalize_pubkey(&keys.public_key().to_hex()); + let agent_dir = state_dir.join(&pubkey); + std::fs::create_dir_all(&agent_dir) + .map_err(|e| anyhow::anyhow!("could not create state dir {}: {e}", agent_dir.display()))?; + let cursor_path = agent_dir.join("cursor.json"); + let floor_path = agent_dir.join("floor.json"); + + let mut floor_store = open_pinned_floor_store(&floor_path, owner_pubkey)?; + + let presence_state = Arc::new(PresenceState::new()); + let bundle_state = Arc::new(BundleState::new()); + + tracing::info!(agent = %pubkey, owner = %owner_pubkey, "buzz-waker: watching agent"); + + { + let relay_url = relay_url.to_string(); + let keys = keys.clone(); + let auth_tag = auth_tag.cloned(); + let presence_state = Arc::clone(&presence_state); + let cancel = cancel.clone(); + let pubkey = pubkey.clone(); + tasks.spawn(async move { + run_presence_tap( + &relay_url, + &keys, + auth_tag.as_ref(), + &presence_state, + &cancel, + ) + .await; + TaskExit::Agent { + pubkey, + task: "presence_tap", + } + }); + } + + { + let relay_url = relay_url.to_string(); + let keys = keys.clone(); + let auth_tag = auth_tag.cloned(); + let owner_pubkey = owner_pubkey.to_string(); + let bundle_state = Arc::clone(&bundle_state); + let cancel = cancel.clone(); + let pubkey = pubkey.clone(); + tasks.spawn(async move { + run_bundle_tap( + &relay_url, + &keys, + auth_tag.as_ref(), + &owner_pubkey, + &mut floor_store, + &bundle_state, + &cancel, + ) + .await; + TaskExit::Agent { + pubkey, + task: "bundle_tap", + } + }); + } + + { + let config = WakeLoopConfig { + relay_url: relay_url.to_string(), + keys: keys.clone(), + auth_tag: auth_tag.cloned(), + cursor_path, + presence_state, + watch_list: watch_list.clone(), + bundle_state, + }; + let cancel = cancel.clone(); + let pubkey = pubkey.clone(); + tasks.spawn(async move { + run_wake_loop(config, cancel).await; + TaskExit::Agent { + pubkey, + task: "wake_loop", + } + }); + } + + watch_list.insert(&pubkey); + Ok(()) +} + +/// How often the reconciliation loop re-diffs every authorized owner's +/// current [`RosterState`] against `supervised`. +/// +/// This only reads state this daemon already holds in memory (the roster +/// tap keeps `RosterState` current via its own live subscription, not +/// polling) and checks each pending agent's [`CredentialState`] — both +/// cheap — so a short interval costs nothing but stays far from a busy +/// loop. +const RECONCILE_INTERVAL: Duration = Duration::from_secs(5); + +/// One reconciliation pass: tear down roster-sourced agents no longer +/// listed anywhere, adopt newly-listed ones (spawning a credential tap for +/// each), and promote any pending agent whose credential has now arrived. +#[allow(clippy::too_many_arguments)] +fn reconcile_roster( + relay_url: &str, + state_dir: &Path, + waker_keys: &Keys, + authorized_owners: &[String], + roster_state: &RosterState, + supervised: &mut HashMap, + watch_list: &WatchList, + cancel: &CancellationToken, + tasks: &mut JoinSet, +) { + let rosters: Vec<(String, Vec)> = authorized_owners + .iter() + .filter_map(|owner| { + roster_state + .current(owner) + .map(|body| (owner.clone(), body.entries.clone())) + }) + .collect(); + + let existing_sources: HashMap = supervised + .iter() + .map(|(pubkey, agent)| (pubkey.clone(), agent.source)) + .collect(); + let (desired, conflicts) = compute_desired_roster_agents(&rosters, &existing_sources); + for pubkey in conflicts { + tracing::warn!( + agent = %pubkey, + "buzz-waker: more than one authorized owner's roster claims this agent; \ + the first one seen this pass wins, this is almost certainly a \ + misconfiguration" + ); + } + + let desired_pubkeys: HashSet<&str> = desired.iter().map(|d| d.pubkey.as_str()).collect(); + let to_remove: Vec = supervised + .iter() + .filter(|(pubkey, agent)| { + agent.source == AgentSource::Roster && !desired_pubkeys.contains(pubkey.as_str()) + }) + .map(|(pubkey, _)| pubkey.clone()) + .collect(); + for pubkey in to_remove { + if let Some(agent) = supervised.remove(&pubkey) { + agent.cancel.cancel(); + } + watch_list.remove(&pubkey); + tracing::info!( + agent = %pubkey, + "buzz-waker: no authorized owner's roster lists this agent anymore; \ + cancelling its watch tasks" + ); + } + + for desired_agent in &desired { + if supervised.contains_key(&desired_agent.pubkey) { + continue; + } + let agent_cancel = cancel.child_token(); + let agent_dir = state_dir.join(&desired_agent.pubkey); + if let Err(error) = std::fs::create_dir_all(&agent_dir) { + tracing::error!( + agent = %desired_agent.pubkey, + %error, + "buzz-waker: could not create state dir for a roster-discovered agent; skipping this pass" + ); + continue; + } + let credential_floor_path = agent_dir.join("credential_floor.json"); + let mut credential_floor_store = match open_pinned_floor_store( + &credential_floor_path, + &desired_agent.owner_pubkey, + ) { + Ok(store) => store, + Err(error) => { + tracing::error!( + agent = %desired_agent.pubkey, + %error, + "buzz-waker: could not open this agent's credential floor; skipping this pass" + ); + continue; + } + }; + + // Known the moment this daemon adopts the pubkey, ahead of the + // credential that proves this daemon can actually run it — see + // `crate::watch_list`'s own doc for why that ordering is the safe + // one for `confirm_author_not_known_agent`. + watch_list.insert(&desired_agent.pubkey); + + let credential_state = Arc::new(CredentialState::new()); + { + let relay_url = relay_url.to_string(); + let waker_keys = waker_keys.clone(); + let owner_pubkey = desired_agent.owner_pubkey.clone(); + let agent_pubkey = desired_agent.pubkey.clone(); + let credential_state = Arc::clone(&credential_state); + let tap_cancel = agent_cancel.clone(); + let pubkey_for_exit = desired_agent.pubkey.clone(); + tasks.spawn(async move { + run_credential_tap( + &relay_url, + &waker_keys, + None, + &owner_pubkey, + &agent_pubkey, + &mut credential_floor_store, + &credential_state, + &tap_cancel, + ) + .await; + TaskExit::CredentialTap { + pubkey: pubkey_for_exit, + } + }); + } + + tracing::info!( + agent = %desired_agent.pubkey, + owner = %desired_agent.owner_pubkey, + "buzz-waker: roster lists a new agent; waiting for its credential" + ); + supervised.insert( + desired_agent.pubkey.clone(), + SupervisedAgent { + cancel: agent_cancel, + source: AgentSource::Roster, + credential_bootstrap: Some(credential_state), + owner_pubkey: desired_agent.owner_pubkey.clone(), + }, + ); + } + + let ready: Vec = supervised + .iter() + .filter_map(|(pubkey, agent)| { + agent + .credential_bootstrap + .as_ref() + .and_then(|state| state.current()) + .map(|_| pubkey.clone()) + }) + .collect(); + for pubkey in ready { + let Some(agent) = supervised.get(&pubkey) else { + continue; + }; + let Some(body) = agent + .credential_bootstrap + .as_ref() + .and_then(|state| state.current()) + else { + continue; + }; + let agent_cancel = agent.cancel.clone(); + let owner_pubkey = agent.owner_pubkey.clone(); + + let keys = match Keys::parse(&body.nsec) { + Ok(keys) => keys, + Err(error) => { + tracing::error!( + agent = %pubkey, + %error, + "buzz-waker: delivered credential's nsec does not parse; tearing down this agent" + ); + if let Some(agent) = supervised.remove(&pubkey) { + agent.cancel.cancel(); + } + watch_list.remove(&pubkey); + continue; + } + }; + let auth_tag = match body.auth_tag.clone().map(Tag::parse).transpose() { + Ok(auth_tag) => auth_tag, + Err(error) => { + tracing::error!( + agent = %pubkey, + %error, + "buzz-waker: delivered credential's auth_tag does not parse; tearing down this agent" + ); + if let Some(agent) = supervised.remove(&pubkey) { + agent.cancel.cancel(); + } + watch_list.remove(&pubkey); + continue; + } + }; + + match spawn_agent_watch( + relay_url, + state_dir, + &keys, + auth_tag.as_ref(), + &owner_pubkey, + watch_list, + agent_cancel, + tasks, + ) { + Ok(()) => { + if let Some(agent) = supervised.get_mut(&pubkey) { + agent.credential_bootstrap = None; + } + tracing::info!(agent = %pubkey, "buzz-waker: roster-discovered agent's credential arrived; now watching it"); + } + Err(error) => { + tracing::error!( + agent = %pubkey, + %error, + "buzz-waker: could not start watching a roster-discovered agent; tearing it down" + ); + if let Some(agent) = supervised.remove(&pubkey) { + agent.cancel.cancel(); + } + watch_list.remove(&pubkey); + } + } + } +} + #[tokio::main] async fn main() -> anyhow::Result<()> { // Install the ring CryptoProvider before any wss:// feed opens. Both ring @@ -187,6 +759,19 @@ async fn main() -> anyhow::Result<()> { let relay_url = env_var("WAKER_RELAY_URL")?; let state_dir = PathBuf::from(env_var("WAKER_STATE_DIR")?); let agents_config_path = env_var("WAKER_AGENTS_CONFIG_PATH")?; + let authorized_owners = + parse_authorized_owners(&std::env::var("WAKER_OWNER_PUBKEYS").unwrap_or_default())?; + let waker_keys = if authorized_owners.is_empty() { + None + } else { + let nsec = env_var("WAKER_IDENTITY_NSEC").map_err(|_| { + anyhow::anyhow!( + "WAKER_OWNER_PUBKEYS is set but WAKER_IDENTITY_NSEC is not; the roster and \ + credential taps have no identity to connect as" + ) + })?; + Some(Keys::parse(&nsec).map_err(|e| anyhow::anyhow!("invalid WAKER_IDENTITY_NSEC: {e}"))?) + }; let agent_configs = load_agents(&agents_config_path)?; @@ -209,16 +794,6 @@ async fn main() -> anyhow::Result<()> { keys_by_agent.push((keys, auth_tag, owner_pubkey)); } - // This daemon's whole known-agent baseline — see `effects`'s module doc - // on why this is the accepted simplification for - // `confirm_author_not_known_agent` rather than the full managed-agent - // roster. - let watch_list: Arc<[String]> = keys_by_agent - .iter() - .map(|(keys, _, _)| normalize_pubkey(&keys.public_key().to_hex())) - .collect::>() - .into(); - std::fs::create_dir_all(&state_dir).map_err(|e| { anyhow::anyhow!( "could not create WAKER_STATE_DIR {}: {e}", @@ -227,114 +802,148 @@ async fn main() -> anyhow::Result<()> { })?; let cancel = CancellationToken::new(); - let mut tasks: JoinSet<(String, &'static str)> = JoinSet::new(); + let mut tasks: JoinSet = JoinSet::new(); + let watch_list = WatchList::new(); + let mut supervised: HashMap = HashMap::new(); for (keys, auth_tag, owner_pubkey) in keys_by_agent { let pubkey = normalize_pubkey(&keys.public_key().to_hex()); - let agent_dir = state_dir.join(&pubkey); - std::fs::create_dir_all(&agent_dir).map_err(|e| { - anyhow::anyhow!("could not create state dir {}: {e}", agent_dir.display()) - })?; - let cursor_path = agent_dir.join("cursor.json"); - let floor_path = agent_dir.join("floor.json"); - - let presence_state = Arc::new(PresenceState::new()); - let bundle_state = Arc::new(BundleState::new()); - - // Open-or-enroll, matching `CursorStore::open_or_start`'s idempotent - // shape: a fresh state dir enrolls fresh, an existing one re-opens - // its durable floors (G2) rather than resetting them. - let mut floor_store = match FloorStore::open(&floor_path) { - Ok(store) => store, - Err(buzz_waker::floors::FloorError::NotEnrolled { .. }) => { - FloorStore::enroll(&floor_path, &owner_pubkey).map_err(|e| { - anyhow::anyhow!("could not enroll floor store for {pubkey}: {e}") - })? - } - Err(e) => { - anyhow::bail!("could not open floor store for {pubkey}: {e}") - } - }; - - let pinned_owner = normalize_pubkey(&floor_store.snapshot().owner_pubkey); - ensure_owner_pin_matches(&pubkey, &pinned_owner, &owner_pubkey)?; - - tracing::info!(agent = %pubkey, owner = %owner_pubkey, "buzz-waker: watching agent"); + let agent_cancel = cancel.child_token(); + spawn_agent_watch( + &relay_url, + &state_dir, + &keys, + auth_tag.as_ref(), + &owner_pubkey, + &watch_list, + agent_cancel.clone(), + &mut tasks, + )?; + supervised.insert( + pubkey, + SupervisedAgent { + cancel: agent_cancel, + source: AgentSource::Config, + credential_bootstrap: None, + owner_pubkey, + }, + ); + } + let roster_state = if let Some(waker_keys) = &waker_keys { + let roster_state = Arc::new(RosterState::new()); + let roster_floor_dir = state_dir.join("roster-floors"); { let relay_url = relay_url.clone(); - let keys = keys.clone(); - let auth_tag = auth_tag.clone(); - let presence_state = Arc::clone(&presence_state); + let waker_keys = waker_keys.clone(); + let authorized_owners = authorized_owners.clone(); + let roster_state = Arc::clone(&roster_state); let cancel = cancel.clone(); - let pubkey = pubkey.clone(); tasks.spawn(async move { - run_presence_tap( + run_roster_tap( &relay_url, - &keys, - auth_tag.as_ref(), - &presence_state, + &waker_keys, + None, + &authorized_owners, + &roster_floor_dir, + &roster_state, &cancel, ) .await; - (pubkey, "presence_tap") + TaskExit::Component("roster_tap") }); } + Some(roster_state) + } else { + None + }; - { - let relay_url = relay_url.clone(); - let keys = keys.clone(); - let auth_tag = auth_tag.clone(); - let owner_pubkey = owner_pubkey.clone(); - let bundle_state = Arc::clone(&bundle_state); - let cancel = cancel.clone(); - let pubkey = pubkey.clone(); - tasks.spawn(async move { - run_bundle_tap( + // A watch task can finish on its own, outside the shutdown path: a + // corrupt cursor makes `run_wake_loop` return immediately, and any task + // can panic. This loop keeps running for the daemon's whole life (not + // just a one-shot race at startup) because reconciliation and per-agent + // teardown are now ongoing, ordinary events, not only something that + // happens once at shutdown. + let mut ticker = tokio::time::interval(RECONCILE_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let fatal: Option = loop { + tokio::select! { + () = shutdown_signal() => { + tracing::info!("buzz-waker: shutdown signal received; stopping"); + break None; + } + _ = ticker.tick(), if roster_state.is_some() => { + let (Some(waker_keys), Some(roster_state)) = (&waker_keys, &roster_state) else { + unreachable!("ticker only fires when roster_state is Some, which only happens alongside waker_keys"); + }; + reconcile_roster( &relay_url, - &keys, - auth_tag.as_ref(), - &owner_pubkey, - &mut floor_store, - &bundle_state, + &state_dir, + waker_keys, + &authorized_owners, + roster_state, + &mut supervised, + &watch_list, &cancel, - ) - .await; - (pubkey, "bundle_tap") - }); - } - - { - let config = WakeLoopConfig { - relay_url: relay_url.clone(), - keys, - auth_tag, - cursor_path, - presence_state, - watch_list: Arc::clone(&watch_list), - bundle_state, - }; - let cancel = cancel.clone(); - let pubkey = pubkey.clone(); - tasks.spawn(async move { - run_wake_loop(config, cancel).await; - (pubkey, "wake_loop") - }); - } - } - - // A watch task can also finish on its own, outside the shutdown path: a - // corrupt cursor makes `run_wake_loop` return immediately, and either - // task can panic. If that happens before `cancel` fires, the daemon must - // not keep running with that agent silently unwatched and reporting - // healthy — race the first such completion against the shutdown signal - // and treat an early one as fatal for the whole process. - let early_exit = tokio::select! { - () = shutdown_signal() => { - tracing::info!("buzz-waker: shutdown signal received; stopping"); - None + &mut tasks, + ); + } + result = tasks.join_next(), if !tasks.is_empty() => { + let Some(result) = result else { continue }; + match result { + Err(join_error) => { + break Some(anyhow::anyhow!( + "buzz-waker: a watch task panicked: {join_error}" + )); + } + Ok(TaskExit::Component(name)) => { + if !cancel.is_cancelled() { + break Some(anyhow::anyhow!( + "buzz-waker: component {name} exited before shutdown was requested" + )); + } + } + Ok(exit) => { + let pubkey = match &exit { + TaskExit::Agent { pubkey, .. } | TaskExit::CredentialTap { pubkey } => pubkey.clone(), + TaskExit::Component(_) => unreachable!("handled above"), + }; + let task_name: &'static str = match &exit { + TaskExit::Agent { task, .. } => task, + TaskExit::CredentialTap { .. } => "credential_tap", + TaskExit::Component(_) => unreachable!("handled above"), + }; + let was_cancelled = supervised + .get(&pubkey) + .map(|agent| agent.cancel.is_cancelled()) + .unwrap_or(true); + let source = supervised.get(&pubkey).map(|agent| agent.source); + match classify_exit(source, was_cancelled) { + ExitDisposition::Expected => {} + ExitDisposition::FatalDaemon => { + break Some(anyhow::anyhow!( + "buzz-waker: {task_name} for agent {pubkey} exited before \ + shutdown was requested; that agent stopped being watched — \ + treating as fatal rather than running silently degraded" + )); + } + ExitDisposition::TearDownAgent => { + tracing::error!( + agent = %pubkey, + task = %task_name, + "buzz-waker: a roster-discovered agent's task exited unexpectedly; \ + tearing down this agent only, daemon continues" + ); + if let Some(agent) = supervised.remove(&pubkey) { + agent.cancel.cancel(); + } + watch_list.remove(&pubkey); + } + } + } + } + } } - result = tasks.join_next() => Some(result), }; cancel.cancel(); @@ -347,20 +956,9 @@ async fn main() -> anyhow::Result<()> { tracing::info!("buzz-waker: shutdown complete"); - match early_exit { - Some(Some(Ok((pubkey, task)))) => { - anyhow::bail!( - "buzz-waker: {task} for agent {pubkey} exited before shutdown was requested; \ - that agent stopped being watched — treating as fatal rather than running \ - silently degraded" - ) - } - Some(Some(Err(error))) => { - anyhow::bail!( - "buzz-waker: a watch task panicked before shutdown was requested: {error}" - ) - } - Some(None) | None => Ok(()), + match fatal { + Some(error) => Err(error), + None => Ok(()), } } @@ -447,4 +1045,126 @@ mod tests { let agents = load_agents(path.to_str().expect("utf8 path")).expect("loads"); assert_eq!(agents.len(), 1); } + + fn entry(pubkey: &str) -> RosterEntry { + RosterEntry { + agent_pubkey: pubkey.to_string(), + credential_version: 1, + } + } + + #[test] + fn a_config_sourced_pubkey_is_never_desired_via_roster() { + let pubkey = "a".repeat(64); + let owner = "b".repeat(64); + let mut existing = HashMap::new(); + existing.insert(normalize_pubkey(&pubkey), AgentSource::Config); + + let (desired, conflicts) = + compute_desired_roster_agents(&[(owner, vec![entry(&pubkey)])], &existing); + + assert!(desired.is_empty()); + assert!(conflicts.is_empty()); + } + + #[test] + fn a_roster_only_pubkey_is_desired_under_its_owner() { + let pubkey = "a".repeat(64); + let owner = "b".repeat(64); + + let (desired, conflicts) = compute_desired_roster_agents( + &[(owner.clone(), vec![entry(&pubkey)])], + &HashMap::new(), + ); + + assert_eq!(desired.len(), 1); + assert_eq!(desired[0].pubkey, normalize_pubkey(&pubkey)); + assert_eq!(desired[0].owner_pubkey, owner); + assert!(conflicts.is_empty()); + } + + #[test] + fn an_already_roster_supervised_pubkey_is_still_desired_so_it_is_not_torn_down() { + let pubkey = "a".repeat(64); + let owner = "b".repeat(64); + let mut existing = HashMap::new(); + existing.insert(normalize_pubkey(&pubkey), AgentSource::Roster); + + let (desired, _) = + compute_desired_roster_agents(&[(owner, vec![entry(&pubkey)])], &existing); + + assert_eq!( + desired.len(), + 1, + "an existing roster-sourced agent still listed must remain desired" + ); + } + + #[test] + fn two_owners_claiming_the_same_pubkey_is_a_conflict_and_the_first_wins() { + let pubkey = "a".repeat(64); + let owner_a = "b".repeat(64); + let owner_b = "c".repeat(64); + + let (desired, conflicts) = compute_desired_roster_agents( + &[ + (owner_a.clone(), vec![entry(&pubkey)]), + (owner_b, vec![entry(&pubkey)]), + ], + &HashMap::new(), + ); + + assert_eq!(desired.len(), 1); + assert_eq!( + desired[0].owner_pubkey, owner_a, + "the first owner seen wins" + ); + assert_eq!(conflicts, vec![normalize_pubkey(&pubkey)]); + } + + #[test] + fn a_pubkey_missing_from_every_roster_is_not_desired() { + let (desired, conflicts) = compute_desired_roster_agents(&[], &HashMap::new()); + assert!(desired.is_empty()); + assert!(conflicts.is_empty()); + } + + #[test] + fn a_cancelled_exit_is_always_expected_regardless_of_source() { + assert_eq!( + classify_exit(Some(AgentSource::Config), true), + ExitDisposition::Expected + ); + assert_eq!( + classify_exit(Some(AgentSource::Roster), true), + ExitDisposition::Expected + ); + assert_eq!(classify_exit(None, true), ExitDisposition::Expected); + } + + #[test] + fn an_unsolicited_config_exit_is_fatal() { + assert_eq!( + classify_exit(Some(AgentSource::Config), false), + ExitDisposition::FatalDaemon + ); + } + + #[test] + fn an_unsolicited_exit_of_an_untracked_pubkey_is_fatal() { + // `None` means the exiting task's pubkey has no entry in + // `supervised` at all — never true for a real roster-sourced agent + // (removal always cancels first), so this can only be an + // accounting bug or a statically configured agent whose entry was + // somehow lost. Treated the same as `Config`: fatal. + assert_eq!(classify_exit(None, false), ExitDisposition::FatalDaemon); + } + + #[test] + fn an_unsolicited_roster_exit_tears_down_only_that_agent() { + assert_eq!( + classify_exit(Some(AgentSource::Roster), false), + ExitDisposition::TearDownAgent + ); + } } diff --git a/crates/buzz-waker/src/wake_loop.rs b/crates/buzz-waker/src/wake_loop.rs index 3744db3f8a5..5fa20521395 100644 --- a/crates/buzz-waker/src/wake_loop.rs +++ b/crates/buzz-waker/src/wake_loop.rs @@ -54,6 +54,7 @@ use crate::feed::{ }; use crate::presence_feed::PresenceState; use crate::relay_feed::RelayFeed; +use crate::watch_list::WatchList; /// Configuration for one agent's wake loop. #[derive(Clone)] @@ -71,10 +72,11 @@ pub struct WakeLoopConfig { pub cursor_path: PathBuf, /// The presence tap shared with every wake attempt for this agent. pub presence_state: Arc, - /// This daemon's full watch list, normalized — see `effects`'s module - /// doc for why this is the accepted `confirm_author_not_known_agent` - /// baseline. - pub watch_list: Arc<[String]>, + /// This daemon's live watch list — see `effects`'s module doc for why + /// this is the accepted `confirm_author_not_known_agent` baseline, and + /// `crate::watch_list`'s own doc for why it is read live rather than + /// snapshotted. + pub watch_list: WatchList, /// The live cache [`crate::bundle_feed::run_bundle_tap`] writes this /// agent's admitted bundle into. Read fresh at the moment each attempt is /// spawned (never captured once at loop-construction time) so a reissue @@ -339,7 +341,7 @@ pub async fn run_wake_loop(config: WakeLoopConfig, cancel: CancellationToken) { agent_pubkey.clone(), Arc::clone(&attempt_state), Arc::clone(&config.presence_state), - Arc::clone(&config.watch_list), + config.watch_list.clone(), config.bundle_state.current(), cancel.clone(), ); @@ -592,7 +594,7 @@ fn spawn_attempt( agent_pubkey: String, attempt_state: Arc, presence_state: Arc, - watch_list: Arc<[String]>, + watch_list: WatchList, bundle: Option>, cancel: CancellationToken, ) { diff --git a/crates/buzz-waker/src/watch_list.rs b/crates/buzz-waker/src/watch_list.rs new file mode 100644 index 00000000000..4d07d81e310 --- /dev/null +++ b/crates/buzz-waker/src/watch_list.rs @@ -0,0 +1,126 @@ +//! This daemon's own live known-agent set — `crate::effects`'s baseline for +//! `confirm_author_not_known_agent`, `PLANS/BUZZ_WAKER_DESIGN.md` §12 build +//! order step 3. +//! +//! Before the dynamic supervisor, this was a frozen `Arc<[String]>` snapshot +//! taken once at startup from `WAKER_AGENTS_CONFIG_PATH` — safe only because +//! the set never changed for the life of the process. Once agents can be +//! added or removed at runtime (a roster reissue), a frozen snapshot goes +//! stale: an agent added after startup would not be "known" to +//! `confirm_author_not_known_agent`, and a mention it authored could then +//! wake another agent — exactly the agent-to-agent wake loop that guard +//! exists to prevent (`crate::decide::select_wake_candidates`'s own doc). +//! [`WatchList`] fixes that by being read live rather than snapshotted: +//! every clone shares the same underlying set, so an `insert`/`remove` from +//! the supervisor is visible to every in-flight wake attempt's +//! `confirm_author_not_known_agent` check immediately. + +use std::collections::HashSet; +use std::sync::{Arc, Mutex, PoisonError}; + +use crate::decide::normalize_pubkey; + +/// A cheaply-cloneable, live-shared set of normalized agent pubkeys. +#[derive(Debug, Clone, Default)] +pub struct WatchList { + inner: Arc>>, +} + +impl WatchList { + /// An empty watch list. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Recover from a poisoned lock rather than propagating it — a panic in + /// one reader must not permanently blind every future watch-list check. + fn lock(&self) -> std::sync::MutexGuard<'_, HashSet> { + self.inner.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Add `pubkey` to the set. + pub fn insert(&self, pubkey: &str) { + self.lock().insert(normalize_pubkey(pubkey)); + } + + /// Remove `pubkey` from the set. A no-op if it was never present. + pub fn remove(&self, pubkey: &str) { + self.lock().remove(&normalize_pubkey(pubkey)); + } + + /// Whether `pubkey` is currently in the set, comparison + /// case/whitespace-insensitive (both sides normalized). + #[must_use] + pub fn contains(&self, pubkey: &str) -> bool { + self.lock().contains(&normalize_pubkey(pubkey)) + } +} + +impl From> for WatchList { + /// Build a watch list already populated with `pubkeys` — the shape + /// existing tests already construct a frozen `Arc<[String]>` with + /// (`Arc::from(vec![...])`), so callers only need to swap the type, not + /// the construction pattern. + fn from(pubkeys: Vec) -> Self { + let set = pubkeys.iter().map(|p| normalize_pubkey(p)).collect(); + Self { + inner: Arc::new(Mutex::new(set)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_fresh_list_is_empty() { + let list = WatchList::new(); + assert!(!list.contains(&"a".repeat(64))); + } + + #[test] + fn an_inserted_pubkey_is_found() { + let list = WatchList::new(); + let pubkey = "a".repeat(64); + list.insert(&pubkey); + assert!(list.contains(&pubkey)); + } + + #[test] + fn a_removed_pubkey_is_no_longer_found() { + let list = WatchList::new(); + let pubkey = "a".repeat(64); + list.insert(&pubkey); + list.remove(&pubkey); + assert!(!list.contains(&pubkey)); + } + + #[test] + fn membership_is_case_and_whitespace_insensitive() { + let list = WatchList::new(); + list.insert(" AA "); + assert!(list.contains("aa")); + } + + #[test] + fn clones_share_the_same_underlying_set() { + let list = WatchList::new(); + let clone = list.clone(); + let pubkey = "a".repeat(64); + + clone.insert(&pubkey); + + assert!( + list.contains(&pubkey), + "a clone must be a shared handle, not an independent copy" + ); + } + + #[test] + fn from_vec_normalizes_every_entry() { + let list = WatchList::from(vec![" AA ".to_string()]); + assert!(list.contains("aa")); + } +} From ef8172bf4aa56cde4664a16132b4fbe2478e418f Mon Sep 17 00:00:00 2001 From: Junchao Yan Date: Wed, 12 Aug 2026 23:43:26 -0700 Subject: [PATCH 09/10] fix(waker): gate credential version, thread provider creds, cap agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alex's review of the dynamic supervisor (PR #50, head 7b149cd48) found three real P1s: 1. RosterEntry.credential_version was discarded, and an agent was promoted the moment CredentialState held *any* delivery — a stale or replayed version could bootstrap the wrong identity, and once running, nothing ever re-checked a later rotation or revocation. Fixed: SupervisedAgent now tracks expected_credential_version and keeps its CredentialState wired for the agent's whole supervised lifetime, not just bootstrap. reconcile_roster gained two new passes: one tears down a still-listed roster agent (pending or already running) the moment the roster's own claimed version moves, so re-bootstrap starts fresh under the new version in the same tick; promotion (and a running agent's continued operation) now requires an exact version match against the live delivery, and a revocation (CredentialState flipping to None while running) tears the agent down too. 2. CredentialBody.provider_credential was decoded but never used — provider_deploy_pinned ran with no tenant environment, so a dynamically enrolled owner's deploy would use the daemon's own inherited credentials or fail outright. buzz-provider-deploy gained an additive env: Option<&HashMap> parameter on every entry point (None preserves every existing caller's behavior exactly, including desktop's), applied as an overlay via Command::envs — not isolation, and the module doc says so plainly, since no daemon-baseline spec for that ever existed anywhere in this codebase to reuse. ProviderCredential::to_env() derives the overlay (SPRITE_TOKEN today); RealWakeEffects/WakeLoopConfig/spawn_agent_watch now carry it from the delivered credential through to the actual deploy call. 3. Dynamic enrolment had no admission cap — an authorized owner's roster, however large, was adopted in full. New required-alongside- WAKER_IDENTITY_NSEC env var WAKER_MAX_AGENTS: a refuse-not-evict ceiling counting every supervised pubkey (config, pending, and running together), checked before each new roster adoption. cargo test -p buzz-waker -p buzz-provider-deploy: 262 lib + 18 main + 31 (provider-deploy, +2 new proving the env overlay actually reaches the child and overrides an inherited variable) all pass. clippy -D warnings and fmt --check clean across both crates plus desktop/src-tauri (checked and clippy'd standalone; backend.rs's two call sites needed a trailing None each). Signed-off-by: Junchao Yan --- crates/buzz-provider-deploy/src/lib.rs | 55 ++- crates/buzz-provider-deploy/src/tests.rs | 87 +++++ crates/buzz-waker/src/effects.rs | 17 + crates/buzz-waker/src/enrolment.rs | 33 ++ crates/buzz-waker/src/main.rs | 365 +++++++++++++----- crates/buzz-waker/src/wake_loop.rs | 7 + .../src-tauri/src/managed_agents/backend.rs | 2 + 7 files changed, 461 insertions(+), 105 deletions(-) diff --git a/crates/buzz-provider-deploy/src/lib.rs b/crates/buzz-provider-deploy/src/lib.rs index 0bc669e05db..2d9ba2c1837 100644 --- a/crates/buzz-provider-deploy/src/lib.rs +++ b/crates/buzz-provider-deploy/src/lib.rs @@ -16,8 +16,35 @@ //! desktop resolves `~/.buzz`; a headless daemon may have none) and, for a //! caller acting on a signed launch bundle, pinning the expected binary //! digest via [`provider_deploy_pinned`] — see that function's doc for why. +//! +//! # The child's environment +//! +//! Every entry point takes an `env: Option<&HashMap>`. `None` +//! (every caller before `buzz-waker`'s dynamic multi-tenant enrolment) +//! leaves the child's environment exactly as `std::process::Command` +//! defaults it — the parent process's own environment, untouched — so this +//! parameter changed no existing caller's behavior when it was added. +//! `Some(map)` layers `map`'s keys on top via +//! [`std::process::Command::envs`], overriding any inherited variable with +//! the same name. +//! +//! **This is an overlay, not isolation.** The child still inherits every +//! other variable from this process's own environment — this crate never +//! calls `env_clear`. For `buzz-waker`'s multi-tenant use (layering one +//! enrolled owner's provider credential, e.g. `SPRITE_TOKEN`, onto the +//! deploy call for that owner's agent) that is enough to fix "the wrong +//! credential is used"; it does not, on its own, stop that child process +//! from also seeing whatever else the daemon's own environment carries +//! (`WAKER_IDENTITY_NSEC` among them). Full isolation — an explicit, +//! daemon-controlled baseline the tenant credential is layered onto, with +//! the rest of the parent's environment cleared — is real future work, not +//! implemented here: the design doc's own reference to it predates any +//! concrete environment-injection mechanism existing anywhere in this +//! codebase (desktop's own provider calls never passed one either), so +//! there was no existing baseline definition to reuse or extend. use sha2::{Digest, Sha256}; +use std::collections::HashMap; use std::io::{BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::sync::mpsc; @@ -102,7 +129,8 @@ fn validate_provider_info(info: &serde_json::Value) -> Result<(), String> { /// `workdir` is the child's working directory (`None` inherits the caller's /// own CWD) — callers with a notion of a stable agent home (the desktop's /// `~/.buzz`) should resolve and pass it; a caller without one may pass -/// `None`. +/// `None`. `env` overlays the child's environment — see the module doc's +/// The child's environment section. /// /// Reader threads stream lines/chunks over channels so the caller can receive /// data as it arrives and time-box the wait. No `read_to_end` — if a provider @@ -119,6 +147,7 @@ pub fn invoke_provider( request: &serde_json::Value, timeout: Duration, workdir: Option<&Path>, + env: Option<&HashMap>, ) -> Result { let request_bytes = format!( "{}\n", @@ -129,6 +158,9 @@ pub fn invoke_provider( if let Some(workdir) = workdir { cmd.current_dir(workdir); } + if let Some(env) = env { + cmd.envs(env); + } configure_no_window(&mut cmd); let mut child = cmd .stdin(std::process::Stdio::piped()) @@ -563,7 +595,8 @@ fn stage_provider( /// Deploy through one immutable staged copy: negotiate protocol v1 before the /// secret-bearing request, then invoke deploy on those exact same bytes. /// -/// `workdir` is passed straight through to [`invoke_provider`] — see its doc. +/// `workdir` and `env` are passed straight through to [`invoke_provider`] — +/// see its doc. /// /// # Errors /// See [`invoke_provider`] and [`stage_provider`] — every path returns a @@ -573,8 +606,9 @@ pub fn provider_deploy( agent: &serde_json::Value, provider_config: &serde_json::Value, workdir: Option<&Path>, + env: Option<&HashMap>, ) -> Result { - deploy(binary, agent, provider_config, workdir, None) + deploy(binary, agent, provider_config, workdir, env, None) } /// Like [`provider_deploy`], but refuses to run unless the staged binary's @@ -595,11 +629,13 @@ pub fn provider_deploy( /// # Errors /// A digest mismatch is reported before any process is spawned. Otherwise /// see [`provider_deploy`]. +#[allow(clippy::too_many_arguments)] pub fn provider_deploy_pinned( binary: &Path, agent: &serde_json::Value, provider_config: &serde_json::Value, workdir: Option<&Path>, + env: Option<&HashMap>, expected_sha256_hex: &str, ) -> Result { deploy( @@ -607,15 +643,18 @@ pub fn provider_deploy_pinned( agent, provider_config, workdir, + env, Some(expected_sha256_hex), ) } +#[allow(clippy::too_many_arguments)] fn deploy( binary: &Path, agent: &serde_json::Value, provider_config: &serde_json::Value, workdir: Option<&Path>, + env: Option<&HashMap>, expected_sha256_hex: Option<&str>, ) -> Result { let (_directory, staged, digest, _execution_guard) = stage_provider(binary)?; @@ -633,7 +672,13 @@ fn deploy( "op": "info", "request_id": uuid::Uuid::new_v4().to_string(), }); - let info = invoke_provider(&staged, &info_request, Duration::from_secs(10), workdir)?; + let info = invoke_provider( + &staged, + &info_request, + Duration::from_secs(10), + workdir, + env, + )?; validate_provider_info(&info)?; let request = serde_json::json!({ @@ -642,7 +687,7 @@ fn deploy( "agent": agent, "provider_config": provider_config, }); - let resp = invoke_provider(&staged, &request, Duration::from_secs(600), workdir)?; + let resp = invoke_provider(&staged, &request, Duration::from_secs(600), workdir, env)?; let agent_id = resp["agent_id"] .as_str() .map(String::from) diff --git a/crates/buzz-provider-deploy/src/tests.rs b/crates/buzz-provider-deploy/src/tests.rs index 79dad73eb6f..582585cf700 100644 --- a/crates/buzz-provider-deploy/src/tests.rs +++ b/crates/buzz-provider-deploy/src/tests.rs @@ -162,6 +162,7 @@ esac"#, &serde_json::json!({}), &serde_json::json!({}), None, + None, ) .expect("staged deploy"); assert_eq!(outcome.agent_id, "remote-1"); @@ -208,6 +209,7 @@ esac"# &serde_json::json!({}), &serde_json::json!({}), None, + None, ) .expect("staged deploy"); assert_eq!(outcome.fresh_generation, parsed, "wire value {wire_value}"); @@ -257,6 +259,7 @@ esac"#, &serde_json::json!({}), &serde_json::json!({}), None, + None, ) .expect("deploy from immutable staged copy") .agent_id; @@ -304,6 +307,7 @@ esac"#, &serde_json::json!({}), &serde_json::json!({}), None, + None, ) .expect("deploy from immutable staged copy") .agent_id; @@ -342,6 +346,7 @@ esac"#, &serde_json::json!({"private_key_nsec": "nsec1must-not-cross"}), &serde_json::json!({}), None, + None, ) .unwrap_err(); assert!(error.contains("protocol version 2"), "{error}"); @@ -365,6 +370,7 @@ printf '%s\n' '{"ok":true,"version":"1.0.0"}'"#, &serde_json::json!({}), &serde_json::json!({}), None, + None, ) .unwrap_err(); assert!( @@ -391,6 +397,7 @@ fn provider_deploy_pinned_refuses_a_digest_mismatch_before_any_negotiation() { &serde_json::json!({"private_key_nsec": "nsec1must-not-cross"}), &serde_json::json!({}), None, + None, &"f".repeat(64), ) .unwrap_err(); @@ -424,12 +431,92 @@ esac"#, &serde_json::json!({}), &serde_json::json!({}), None, + None, &expected, ) .expect("digest matched, deploy proceeds"); assert_eq!(outcome.agent_id, "pinned-1"); } +/// The headline case `env` exists for: a caller's overlay must actually +/// reach the child process, not just be accepted and silently dropped. +#[cfg(unix)] +#[test] +fn provider_deploy_env_overlay_reaches_the_child_process() { + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + write_test_provider( + &provider, + r#"read request +case "$request" in + *\"op\":\"info\"*) printf '%s\n' '{"ok":true,"name":"test","version":"1.0.0","protocol_version":1,"description":"test provider","config_schema":{}}' ;; + *\"op\":\"deploy\"*) printf '{"ok":true,"agent_id":"%s"}\n' "$TEST_TENANT_TOKEN" ;; +esac"#, + ); + + let mut env = std::collections::HashMap::new(); + env.insert( + "TEST_TENANT_TOKEN".to_string(), + "sprt-tenant-abc".to_string(), + ); + + let outcome = provider_deploy( + &provider, + &serde_json::json!({}), + &serde_json::json!({}), + None, + Some(&env), + ) + .expect("deploy with an env overlay"); + assert_eq!( + outcome.agent_id, "sprt-tenant-abc", + "the child must see the overlaid variable" + ); +} + +/// `Some(map)` overrides an inherited variable of the same name — the +/// module doc's own contract, and the property `buzz-waker` relies on if a +/// baseline var and a tenant var ever collide. +#[cfg(unix)] +#[test] +fn provider_deploy_env_overlay_overrides_an_inherited_variable() { + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + write_test_provider( + &provider, + r#"read request +case "$request" in + *\"op\":\"info\"*) printf '%s\n' '{"ok":true,"name":"test","version":"1.0.0","protocol_version":1,"description":"test provider","config_schema":{}}' ;; + *\"op\":\"deploy\"*) printf '{"ok":true,"agent_id":"%s"}\n' "$TEST_TENANT_TOKEN" ;; +esac"#, + ); + + // SAFETY: test-only, single-threaded at this point in the process + // (no other test in this crate reads or writes this exact variable). + unsafe { + std::env::set_var("TEST_TENANT_TOKEN", "inherited-from-parent"); + } + let mut env = std::collections::HashMap::new(); + env.insert( + "TEST_TENANT_TOKEN".to_string(), + "overlaid-value".to_string(), + ); + + let outcome = provider_deploy( + &provider, + &serde_json::json!({}), + &serde_json::json!({}), + None, + Some(&env), + ) + .expect("deploy with an env overlay"); + assert_eq!(outcome.agent_id, "overlaid-value"); + + unsafe { + std::env::remove_var("TEST_TENANT_TOKEN"); + } +} + #[test] fn provider_info_requires_the_complete_flat_wire_shape() { let complete = serde_json::json!({ diff --git a/crates/buzz-waker/src/effects.rs b/crates/buzz-waker/src/effects.rs index aba4aa24ea4..93cb3977df1 100644 --- a/crates/buzz-waker/src/effects.rs +++ b/crates/buzz-waker/src/effects.rs @@ -58,6 +58,7 @@ //! narrowing it to "this daemon's own agents" is strictly better than not //! re-checking at all. +use std::collections::HashMap; use std::sync::Arc; use crate::attempt::{HeartbeatObservation, WakeEffects}; @@ -166,6 +167,17 @@ pub struct RealWakeEffects { /// This attempt's launch bundle, if one is available. `None` until /// bundle transport is wired into this daemon — see the module note. bundle: Option>, + /// This agent's own provider credential, if it has one — a dynamically + /// (roster-)enrolled agent's [`crate::enrolment::ProviderCredential`], + /// already converted to its wire-shape environment variables + /// ([`crate::enrolment::ProviderCredential::to_env`]) at credential + /// delivery time. `None` for a statically configured agent (no + /// per-tenant credential exists to overlay) and for a roster-enrolled + /// agent whose delivered credential didn't carry one. Layered onto the + /// deploy subprocess's environment — see + /// `buzz_provider_deploy`'s own module doc for what "layered" means + /// (an overlay on the inherited environment, not isolation from it). + provider_env: Option>>, /// Fires on daemon shutdown. Deliberately **not** tied to the mention /// feed's own connection lifecycle: a wake attempt does not touch that /// socket, so a feed reconnect must not cancel an attempt that is still @@ -191,6 +203,7 @@ impl RealWakeEffects { trigger_author: &str, trigger_created_at: u64, bundle: Option>, + provider_env: Option>>, cancel: CancellationToken, on_deployed: impl Fn() + Send + Sync + 'static, ) -> Self { @@ -201,6 +214,7 @@ impl RealWakeEffects { trigger_author: normalize_pubkey(trigger_author), trigger_created_at, bundle, + provider_env, cancel, on_deployed: Box::new(on_deployed), } @@ -336,6 +350,7 @@ impl WakeEffects for RealWakeEffects { serde_json::Value::String(self.trigger_created_at.to_string()); let provider_config = bundle.provider.provider_config.clone(); + let provider_env = self.provider_env.clone(); let outcome = tokio::task::spawn_blocking(move || { buzz_provider_deploy::provider_deploy_pinned( @@ -343,6 +358,7 @@ impl WakeEffects for RealWakeEffects { &agent_json, &provider_config, None, + provider_env.as_deref(), &expected_digest, ) }) @@ -386,6 +402,7 @@ mod tests { trigger_author, 1_000, bundle, + None, cancel, on_deployed, ) diff --git a/crates/buzz-waker/src/enrolment.rs b/crates/buzz-waker/src/enrolment.rs index dc4aaee9f2c..34cf02294dc 100644 --- a/crates/buzz-waker/src/enrolment.rs +++ b/crates/buzz-waker/src/enrolment.rs @@ -384,6 +384,26 @@ impl std::fmt::Debug for ProviderCredential { } } +impl ProviderCredential { + /// This credential as the environment variable(s) a provider subprocess + /// reads it from — `buzz-provider-deploy`'s `env` overlay param, laid + /// over the deployed process's environment for exactly one tenant's own + /// deploy call. One key per variant today + /// (`credentials::resolve()` in `crates/buzz-backend-sprites` for + /// `Sprites`'s own `SPRITE_TOKEN`), but returns a map rather than a + /// single pair since a future provider variant may need more than one. + #[must_use] + pub fn to_env(&self) -> std::collections::HashMap { + let mut env = std::collections::HashMap::new(); + match self { + Self::Sprites { sprite_token } => { + env.insert("SPRITE_TOKEN".to_string(), sprite_token.clone()); + } + } + env + } +} + /// The signed content of one agent's delivered credential. /// /// `Debug` is implemented by hand and redacts [`Self::nsec`] and @@ -1017,6 +1037,19 @@ mod tests { assert!(rendered.contains("")); } + #[test] + fn sprites_credential_maps_to_sprite_token_env() { + let credential = ProviderCredential::Sprites { + sprite_token: "sprt-real-value".to_string(), + }; + let env = credential.to_env(); + assert_eq!( + env.get("SPRITE_TOKEN"), + Some(&"sprt-real-value".to_string()) + ); + assert_eq!(env.len(), 1); + } + #[test] fn a_signed_credential_debug_impl_redacts_body_json() { let owner = keypair(); diff --git a/crates/buzz-waker/src/main.rs b/crates/buzz-waker/src/main.rs index 9fcfe5a3665..425d792761c 100644 --- a/crates/buzz-waker/src/main.rs +++ b/crates/buzz-waker/src/main.rs @@ -36,6 +36,7 @@ //! | `WAKER_AGENTS_CONFIG_PATH` | yes | Path to a JSON file listing the agents to statically watch — see [`AgentConfig`]. | //! | `WAKER_OWNER_PUBKEYS` | no | Comma-separated list of owner pubkeys this daemon discovers agents for dynamically. Empty or unset disables dynamic enrolment entirely — see [`buzz_waker::enrolment::parse_authorized_owners`]'s own fail-closed doc. | //! | `WAKER_IDENTITY_NSEC` | only if `WAKER_OWNER_PUBKEYS` is set | This daemon's own Nostr identity — the roster and credential taps decrypt as this key, never as any watched agent's. | +//! | `WAKER_MAX_AGENTS` | only if `WAKER_OWNER_PUBKEYS` is set | Total ceiling on supervised agents — config, pending, and running together — dynamic enrolment can ever push this daemon to. Refuse-not-evict: an authorized owner's roster past this ceiling is refused new admissions, never made to cancel an existing agent to make room. See [`reconcile_roster`]'s own doc. | //! | `RUST_LOG` | no | `tracing-subscriber` env filter. Defaults to `buzz_waker=info`. | //! //! # What is still deliberately not here @@ -49,15 +50,14 @@ //! against `WAKER_OWNER_PUBKEYS` before this daemon ever trusts it (see //! [`buzz_waker::roster_feed`]'s module doc). //! -//! Not implemented this round, and deliberately deferred rather than -//! guessed at: a `WAKER_MAX_AGENTS` total-capacity bound (recorded as an -//! open tuning value in the design doc's multi-tenant extension, not part -//! of this step's own build-order text) and reacting to a credential -//! *rotation* for an already-running dynamically watched agent (this -//! daemon's credential tap keeps running for that agent's whole lifetime -//! and would log a rotation or revocation, but nothing currently acts on it -//! — only the *first* delivered credential is used, to bootstrap that -//! agent's identity). +//! A dynamically watched agent's `provider_credential`, when its delivered +//! credential carries one, is layered onto that agent's own deploy +//! subprocess environment (`buzz_provider_deploy`'s `env` overlay) — but +//! only as an overlay on top of this daemon's own inherited environment, +//! not full isolation from it. See `buzz_provider_deploy`'s own module doc, +//! The child's environment section, for exactly what that does and does +//! not protect against; full isolation is real future work, not +//! implemented here. use std::collections::HashMap; use std::collections::HashSet; @@ -229,17 +229,34 @@ struct SupervisedAgent { /// with everything else, on shutdown. cancel: CancellationToken, source: AgentSource, - /// `Some` while a [`AgentSource::Roster`] agent is still waiting for its - /// first credential delivery — the reconciliation loop polls it each - /// tick and, once populated, spawns this agent's presence/bundle/wake - /// tasks and clears this field. Always `None` for [`AgentSource::Config`] - /// (which already has its `nsec` from local config) and for a - /// [`AgentSource::Roster`] agent whose bootstrap has already completed. - credential_bootstrap: Option>, /// The owner that published this pubkey's roster entry. Unused for /// [`AgentSource::Config`] (each config entry already carries its own /// `owner_pubkey` separately, read once at spawn time). owner_pubkey: String, + /// `Some` for [`AgentSource::Roster`] only — the credential tap's live + /// state, watched for this agent's **entire** supervised lifetime, not + /// just while bootstrapping. A rotation or revocation delivered after + /// this agent is already running has to be seen too, or the old + /// identity (and, for a revocation, a credential the owner explicitly + /// withdrew) would keep running indefinitely — the exact gap Alex's + /// review round caught. `None` for [`AgentSource::Config`]. + credential_state: Option>, + /// `Some` for [`AgentSource::Roster`] only — the `credential_version` + /// the *most recent* roster reconciliation observed for this pubkey. + /// Compared against `credential_state.current()`'s own + /// `credential_version` every tick: promotion requires an exact match + /// (a stale or not-yet-caught-up delivery must not start this agent), + /// and the roster diff tears this agent down the moment the roster's + /// own claimed version moves, so it re-bootstraps from the new one + /// rather than continuing to run under the old identity. `None` for + /// [`AgentSource::Config`], which has no roster-published version at + /// all. + expected_credential_version: Option, + /// Whether this agent's presence/bundle/wake tasks have been spawned. + /// `true` immediately for [`AgentSource::Config`]. `false` for a + /// [`AgentSource::Roster`] agent until its credential (at the expected + /// version) arrives and [`spawn_agent_watch`] succeeds. + running: bool, } /// Why one of this daemon's tasks finished, for the join-handling loop in @@ -312,6 +329,11 @@ fn classify_exit(source: Option, was_cancelled: bool) -> ExitDispos struct DesiredRosterAgent { pubkey: String, owner_pubkey: String, + /// The [`RosterEntry::credential_version`] the roster currently expects + /// — the exact version [`SupervisedAgent::expected_credential_version`] + /// must match before this agent is promoted, or must still match for + /// an already-running agent to keep running unchanged. + credential_version: u64, } /// Diff every authorized owner's current roster into the set of pubkeys this @@ -360,6 +382,7 @@ fn compute_desired_roster_agents( desired.push(DesiredRosterAgent { pubkey, owner_pubkey: owner_pubkey.clone(), + credential_version: entry.credential_version, }); } } @@ -401,9 +424,12 @@ fn open_pinned_floor_store(path: &Path, owner_pubkey: &str) -> anyhow::Result, owner_pubkey: &str, + provider_env: Option>>, watch_list: &WatchList, cancel: CancellationToken, tasks: &mut JoinSet, @@ -492,6 +519,7 @@ fn spawn_agent_watch( presence_state, watch_list: watch_list.clone(), bundle_state, + provider_env: provider_env.clone(), }; let cancel = cancel.clone(); let pubkey = pubkey.clone(); @@ -518,15 +546,45 @@ fn spawn_agent_watch( /// loop. const RECONCILE_INTERVAL: Duration = Duration::from_secs(5); -/// One reconciliation pass: tear down roster-sourced agents no longer -/// listed anywhere, adopt newly-listed ones (spawning a credential tap for -/// each), and promote any pending agent whose credential has now arrived. +/// Cancel and drop one roster-sourced agent's entry — shared by every +/// tear-down path in [`reconcile_roster`] (no longer listed, version +/// changed, revoked while running, or a spawn/parse failure) so each stays +/// a one-line call rather than a repeated three-statement block. +fn tear_down_roster_agent( + supervised: &mut HashMap, + watch_list: &WatchList, + pubkey: &str, +) { + if let Some(agent) = supervised.remove(pubkey) { + agent.cancel.cancel(); + } + watch_list.remove(pubkey); +} + +/// One reconciliation pass, in order: +/// +/// 1. Tear down [`AgentSource::Roster`] agents no longer listed by any +/// authorized owner's roster. +/// 2. Tear down [`AgentSource::Roster`] agents still listed but whose +/// roster-claimed `credential_version` has moved since the last pass — +/// pending or already running, so a rotation cancels the old identity +/// immediately rather than only affecting a not-yet-started one. +/// 3. Adopt newly-listed agents (spawn a credential tap for each), bounded +/// by `max_agents` counting every currently supervised pubkey +/// (config, pending, and running together) — see [`main`]'s own +/// `WAKER_MAX_AGENTS` doc. +/// 4. For every [`AgentSource::Roster`] agent (pending or running), +/// re-check its credential tap's live state against the version it is +/// expected to be at: promote a pending agent whose delivery now +/// matches exactly, tear down a running agent whose credential was +/// revoked (the tap's state going from `Some` to `None`). #[allow(clippy::too_many_arguments)] fn reconcile_roster( relay_url: &str, state_dir: &Path, waker_keys: &Keys, authorized_owners: &[String], + max_agents: usize, roster_state: &RosterState, supervised: &mut HashMap, watch_list: &WatchList, @@ -555,20 +613,21 @@ fn reconcile_roster( misconfiguration" ); } + let desired_versions: HashMap<&str, u64> = desired + .iter() + .map(|d| (d.pubkey.as_str(), d.credential_version)) + .collect(); - let desired_pubkeys: HashSet<&str> = desired.iter().map(|d| d.pubkey.as_str()).collect(); - let to_remove: Vec = supervised + // Pass 1: no longer listed anywhere. + let no_longer_listed: Vec = supervised .iter() .filter(|(pubkey, agent)| { - agent.source == AgentSource::Roster && !desired_pubkeys.contains(pubkey.as_str()) + agent.source == AgentSource::Roster && !desired_versions.contains_key(pubkey.as_str()) }) .map(|(pubkey, _)| pubkey.clone()) .collect(); - for pubkey in to_remove { - if let Some(agent) = supervised.remove(&pubkey) { - agent.cancel.cancel(); - } - watch_list.remove(&pubkey); + for pubkey in no_longer_listed { + tear_down_roster_agent(supervised, watch_list, &pubkey); tracing::info!( agent = %pubkey, "buzz-waker: no authorized owner's roster lists this agent anymore; \ @@ -576,10 +635,45 @@ fn reconcile_roster( ); } + // Pass 2: still listed, but the roster's own claimed version moved — + // pending or already running, tear down either way so re-adoption + // (pass 3, same tick) starts fresh under the new version rather than + // leaving the old identity running or a stale bootstrap in place. + let version_changed: Vec = supervised + .iter() + .filter(|(pubkey, agent)| { + agent.source == AgentSource::Roster + && desired_versions + .get(pubkey.as_str()) + .is_some_and(|&v| Some(v) != agent.expected_credential_version) + }) + .map(|(pubkey, _)| pubkey.clone()) + .collect(); + for pubkey in version_changed { + tear_down_roster_agent(supervised, watch_list, &pubkey); + tracing::info!( + agent = %pubkey, + "buzz-waker: roster's credential_version for this agent changed; \ + cancelling and re-bootstrapping from the new version" + ); + } + + // Pass 3: adopt anything not currently supervised (newly listed, or + // just torn down above for a version change), bounded by max_agents. for desired_agent in &desired { if supervised.contains_key(&desired_agent.pubkey) { continue; } + if supervised.len() >= max_agents { + tracing::warn!( + agent = %desired_agent.pubkey, + max_agents, + "buzz-waker: refusing to adopt this roster-discovered agent; \ + WAKER_MAX_AGENTS reached (refuse, not evict — an existing agent is never \ + cancelled to make room)" + ); + continue; + } let agent_cancel = cancel.child_token(); let agent_dir = state_dir.join(&desired_agent.pubkey); if let Err(error) = std::fs::create_dir_all(&agent_dir) { @@ -642,6 +736,7 @@ fn reconcile_roster( tracing::info!( agent = %desired_agent.pubkey, owner = %desired_agent.owner_pubkey, + credential_version = desired_agent.credential_version, "buzz-waker: roster lists a new agent; waiting for its credential" ); supervised.insert( @@ -649,93 +744,113 @@ fn reconcile_roster( SupervisedAgent { cancel: agent_cancel, source: AgentSource::Roster, - credential_bootstrap: Some(credential_state), owner_pubkey: desired_agent.owner_pubkey.clone(), + credential_state: Some(credential_state), + expected_credential_version: Some(desired_agent.credential_version), + running: false, }, ); } - let ready: Vec = supervised + // Pass 4: re-check every roster-sourced agent's credential tap against + // the version it is expected to be at — pending or already running. + let pubkeys_to_check: Vec = supervised .iter() - .filter_map(|(pubkey, agent)| { - agent - .credential_bootstrap - .as_ref() - .and_then(|state| state.current()) - .map(|_| pubkey.clone()) - }) + .filter(|(_, agent)| agent.credential_state.is_some()) + .map(|(pubkey, _)| pubkey.clone()) .collect(); - for pubkey in ready { + for pubkey in pubkeys_to_check { let Some(agent) = supervised.get(&pubkey) else { continue; }; - let Some(body) = agent - .credential_bootstrap - .as_ref() - .and_then(|state| state.current()) - else { + let Some(credential_state) = &agent.credential_state else { continue; }; - let agent_cancel = agent.cancel.clone(); + let expected_version = agent.expected_credential_version; + let running = agent.running; let owner_pubkey = agent.owner_pubkey.clone(); + let agent_cancel = agent.cancel.clone(); - let keys = match Keys::parse(&body.nsec) { - Ok(keys) => keys, - Err(error) => { - tracing::error!( - agent = %pubkey, - %error, - "buzz-waker: delivered credential's nsec does not parse; tearing down this agent" - ); - if let Some(agent) = supervised.remove(&pubkey) { - agent.cancel.cancel(); + match credential_state.current() { + None => { + if running { + tear_down_roster_agent(supervised, watch_list, &pubkey); + tracing::error!( + agent = %pubkey, + "buzz-waker: this agent's credential was revoked while running; \ + cancelling its watch tasks" + ); } - watch_list.remove(&pubkey); - continue; + // Not yet running: still waiting for the first delivery, + // nothing to do this tick. } - }; - let auth_tag = match body.auth_tag.clone().map(Tag::parse).transpose() { - Ok(auth_tag) => auth_tag, - Err(error) => { - tracing::error!( - agent = %pubkey, - %error, - "buzz-waker: delivered credential's auth_tag does not parse; tearing down this agent" - ); - if let Some(agent) = supervised.remove(&pubkey) { - agent.cancel.cancel(); + Some(body) if Some(body.credential_version) == expected_version => { + if running { + continue; // already running this exact version } - watch_list.remove(&pubkey); - continue; - } - }; - - match spawn_agent_watch( - relay_url, - state_dir, - &keys, - auth_tag.as_ref(), - &owner_pubkey, - watch_list, - agent_cancel, - tasks, - ) { - Ok(()) => { - if let Some(agent) = supervised.get_mut(&pubkey) { - agent.credential_bootstrap = None; + let keys = match Keys::parse(&body.nsec) { + Ok(keys) => keys, + Err(error) => { + tracing::error!( + agent = %pubkey, + %error, + "buzz-waker: delivered credential's nsec does not parse; tearing down this agent" + ); + tear_down_roster_agent(supervised, watch_list, &pubkey); + continue; + } + }; + let auth_tag = match body.auth_tag.clone().map(Tag::parse).transpose() { + Ok(auth_tag) => auth_tag, + Err(error) => { + tracing::error!( + agent = %pubkey, + %error, + "buzz-waker: delivered credential's auth_tag does not parse; tearing down this agent" + ); + tear_down_roster_agent(supervised, watch_list, &pubkey); + continue; + } + }; + let provider_env = body + .provider_credential + .as_ref() + .map(|credential| Arc::new(credential.to_env())); + + match spawn_agent_watch( + relay_url, + state_dir, + &keys, + auth_tag.as_ref(), + &owner_pubkey, + provider_env, + watch_list, + agent_cancel, + tasks, + ) { + Ok(()) => { + if let Some(agent) = supervised.get_mut(&pubkey) { + agent.running = true; + } + tracing::info!(agent = %pubkey, "buzz-waker: roster-discovered agent's credential arrived; now watching it"); + } + Err(error) => { + tracing::error!( + agent = %pubkey, + %error, + "buzz-waker: could not start watching a roster-discovered agent; tearing it down" + ); + tear_down_roster_agent(supervised, watch_list, &pubkey); + } } - tracing::info!(agent = %pubkey, "buzz-waker: roster-discovered agent's credential arrived; now watching it"); } - Err(error) => { - tracing::error!( - agent = %pubkey, - %error, - "buzz-waker: could not start watching a roster-discovered agent; tearing it down" - ); - if let Some(agent) = supervised.remove(&pubkey) { - agent.cancel.cancel(); - } - watch_list.remove(&pubkey); + Some(_stale_or_mismatched) => { + // A delivery that doesn't match the roster's current + // expectation — a lagging reconnect replay, or the + // credential simply hasn't caught up to a just-bumped + // roster yet. Left in place, not acted on; either the tap + // eventually delivers the right version (self-heals) or + // the roster catches up (pass 2 next tick). } } } @@ -772,6 +887,31 @@ async fn main() -> anyhow::Result<()> { })?; Some(Keys::parse(&nsec).map_err(|e| anyhow::anyhow!("invalid WAKER_IDENTITY_NSEC: {e}"))?) }; + // Required alongside WAKER_IDENTITY_NSEC, same reasoning: dynamic + // enrolment needs an explicit ceiling on how many agents an authorized + // owner's roster can cause this daemon to run — refuse-not-evict, per + // the approved multi-tenant design (`PLANS/BUZZ_WAKER_DESIGN.md` §12's + // multi-tenant extension). Counts every currently supervised pubkey — + // config, pending, and running together — not roster-sourced agents + // alone, so a daemon cannot be pushed past the operator's own stated + // ceiling regardless of source. + let max_agents: usize = if authorized_owners.is_empty() { + 0 + } else { + let raw = env_var("WAKER_MAX_AGENTS").map_err(|_| { + anyhow::anyhow!( + "WAKER_OWNER_PUBKEYS is set but WAKER_MAX_AGENTS is not; dynamic enrolment \ + needs an explicit total-agent ceiling" + ) + })?; + let parsed: usize = raw + .parse() + .map_err(|e| anyhow::anyhow!("invalid WAKER_MAX_AGENTS {raw:?}: {e}"))?; + if parsed == 0 { + anyhow::bail!("WAKER_MAX_AGENTS must be at least 1, got 0"); + } + parsed + }; let agent_configs = load_agents(&agents_config_path)?; @@ -815,6 +955,7 @@ async fn main() -> anyhow::Result<()> { &keys, auth_tag.as_ref(), &owner_pubkey, + None, &watch_list, agent_cancel.clone(), &mut tasks, @@ -824,8 +965,10 @@ async fn main() -> anyhow::Result<()> { SupervisedAgent { cancel: agent_cancel, source: AgentSource::Config, - credential_bootstrap: None, owner_pubkey, + credential_state: None, + expected_credential_version: None, + running: true, }, ); } @@ -881,6 +1024,7 @@ async fn main() -> anyhow::Result<()> { &state_dir, waker_keys, &authorized_owners, + max_agents, roster_state, &mut supervised, &watch_list, @@ -1047,9 +1191,13 @@ mod tests { } fn entry(pubkey: &str) -> RosterEntry { + entry_at_version(pubkey, 1) + } + + fn entry_at_version(pubkey: &str, credential_version: u64) -> RosterEntry { RosterEntry { agent_pubkey: pubkey.to_string(), - credential_version: 1, + credential_version, } } @@ -1080,9 +1228,26 @@ mod tests { assert_eq!(desired.len(), 1); assert_eq!(desired[0].pubkey, normalize_pubkey(&pubkey)); assert_eq!(desired[0].owner_pubkey, owner); + assert_eq!(desired[0].credential_version, 1); assert!(conflicts.is_empty()); } + #[test] + fn the_roster_entrys_credential_version_is_carried_through() { + let pubkey = "a".repeat(64); + let owner = "b".repeat(64); + + let (desired, _) = compute_desired_roster_agents( + &[(owner, vec![entry_at_version(&pubkey, 7)])], + &HashMap::new(), + ); + + assert_eq!( + desired[0].credential_version, 7, + "the exact version reconcile_roster gates promotion on must survive the fold" + ); + } + #[test] fn an_already_roster_supervised_pubkey_is_still_desired_so_it_is_not_torn_down() { let pubkey = "a".repeat(64); diff --git a/crates/buzz-waker/src/wake_loop.rs b/crates/buzz-waker/src/wake_loop.rs index 5fa20521395..83d74713c24 100644 --- a/crates/buzz-waker/src/wake_loop.rs +++ b/crates/buzz-waker/src/wake_loop.rs @@ -83,6 +83,10 @@ pub struct WakeLoopConfig { /// admitted mid-run takes effect on the very next wake, with no daemon /// restart required. pub bundle_state: Arc, + /// This agent's own provider credential's environment overlay, if it has + /// one — see `effects::RealWakeEffects`'s own doc for what this is and + /// why it's `None` for a statically configured agent. + pub provider_env: Option>>, } fn now_secs() -> u64 { @@ -343,6 +347,7 @@ pub async fn run_wake_loop(config: WakeLoopConfig, cancel: CancellationToken) { Arc::clone(&config.presence_state), config.watch_list.clone(), config.bundle_state.current(), + config.provider_env.clone(), cancel.clone(), ); } @@ -596,6 +601,7 @@ fn spawn_attempt( presence_state: Arc, watch_list: WatchList, bundle: Option>, + provider_env: Option>>, cancel: CancellationToken, ) { attempts.spawn(async move { @@ -609,6 +615,7 @@ fn spawn_attempt( &event.author, event.created_at, bundle, + provider_env, cancel, move || { tracing::info!( diff --git a/desktop/src-tauri/src/managed_agents/backend.rs b/desktop/src-tauri/src/managed_agents/backend.rs index c4a533c4a4c..45d5ff7bafd 100644 --- a/desktop/src-tauri/src/managed_agents/backend.rs +++ b/desktop/src-tauri/src/managed_agents/backend.rs @@ -23,6 +23,7 @@ pub fn invoke_provider( request, timeout, super::default_agent_workdir().as_deref(), + None, ) } @@ -41,6 +42,7 @@ pub fn provider_deploy( agent, provider_config, super::default_agent_workdir().as_deref(), + None, ) } From 7f4ca83519381c43553b8dc1b7c256ac484d477a Mon Sep 17 00:00:00 2001 From: Junchao Yan Date: Wed, 12 Aug 2026 23:59:50 -0700 Subject: [PATCH 10/10] fix(waker): fence task-exit generations, isolate provider env, cap static baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alex's re-review of PR #50 (head ef8172bf4) found three more real gaps in the dynamic supervisor: 1. A roster credential-version change tore down and re-adopted the same pubkey within one reconcile_roster pass. The predecessor generation's presence/bundle/wake/credential tasks kept running until their own cancellation propagated, and when they finally exited, the join loop looked up `supervised` by pubkey and found the *replacement* generation's fresh, uncancelled token — misclassifying an expected predecessor exit as the replacement's unsolicited failure and tearing the new generation down too, so rotation could never converge. Fixed: TaskExit now carries a clone of the exact CancellationToken the task was spawned under, captured at spawn time; the join loop checks that token's own is_cancelled() directly instead of re-deriving cancellation status from whatever currently occupies the pubkey in `supervised`. 2. The `env` overlay onto a tenant's deploy subprocess only ever added SPRITE_TOKEN on top of this daemon's own inherited environment — the child still saw WAKER_IDENTITY_NSEC and everything else the daemon's process carries. Since the tenant's own bundle authorizes which provider binary/digest runs, an unisolated child could read and exfiltrate secrets across the shared-daemon boundary. Fixed: buzz-provider-deploy now clears the child's environment before adding back a small fixed baseline (HOME, PATH — both required by buzz-backend-sprites's own credential resolution and provisioning code) plus the tenant's own credential, whenever `env` is `Some`. `None` (every non-multi-tenant caller, including desktop) is unaffected. 3. WAKER_MAX_AGENTS is documented as a total ceiling over config, pending, and running agents together, but reconcile_roster's refuse-not-evict check only ever guarded roster *additions* — a static WAKER_AGENTS_CONFIG_PATH baseline already past the ceiling started unchecked and stayed that way for the daemon's whole life. Fixed: ensure_static_agent_count_fits_cap fails startup before any agent is spawned or gets per-agent state on disk when the static baseline alone exceeds WAKER_MAX_AGENTS and dynamic enrolment is enabled. cargo test -p buzz-waker -p buzz-provider-deploy: 262 lib + 21 main (4 new) + 32 (1 new) pass. clippy -D warnings and fmt --check clean across both crates plus desktop/src-tauri (unaffected call sites verified). Co-Authored-By: Claude Sonnet 5 Signed-off-by: Junchao Yan --- crates/buzz-provider-deploy/src/lib.rs | 61 ++++++++---- crates/buzz-provider-deploy/src/tests.rs | 52 ++++++++++ crates/buzz-waker/src/effects.rs | 10 +- crates/buzz-waker/src/main.rs | 119 ++++++++++++++++++++--- 4 files changed, 204 insertions(+), 38 deletions(-) diff --git a/crates/buzz-provider-deploy/src/lib.rs b/crates/buzz-provider-deploy/src/lib.rs index 2d9ba2c1837..56b25b7cdf0 100644 --- a/crates/buzz-provider-deploy/src/lib.rs +++ b/crates/buzz-provider-deploy/src/lib.rs @@ -24,24 +24,26 @@ //! leaves the child's environment exactly as `std::process::Command` //! defaults it — the parent process's own environment, untouched — so this //! parameter changed no existing caller's behavior when it was added. -//! `Some(map)` layers `map`'s keys on top via -//! [`std::process::Command::envs`], overriding any inherited variable with -//! the same name. //! -//! **This is an overlay, not isolation.** The child still inherits every -//! other variable from this process's own environment — this crate never -//! calls `env_clear`. For `buzz-waker`'s multi-tenant use (layering one -//! enrolled owner's provider credential, e.g. `SPRITE_TOKEN`, onto the -//! deploy call for that owner's agent) that is enough to fix "the wrong -//! credential is used"; it does not, on its own, stop that child process -//! from also seeing whatever else the daemon's own environment carries -//! (`WAKER_IDENTITY_NSEC` among them). Full isolation — an explicit, -//! daemon-controlled baseline the tenant credential is layered onto, with -//! the rest of the parent's environment cleared — is real future work, not -//! implemented here: the design doc's own reference to it predates any -//! concrete environment-injection mechanism existing anywhere in this -//! codebase (desktop's own provider calls never passed one either), so -//! there was no existing baseline definition to reuse or extend. +//! **`Some(map)` is isolation, not an overlay.** The child's environment is +//! cleared ([`std::process::Command::env_clear`]), then +//! [`tenant_child_environment_baseline`] is applied (today: `HOME` — required +//! by `buzz-backend-sprites::credentials::resolve` before it even checks +//! `SPRITE_TOKEN`, for its keychain fallback and `~/.sprites` metadata dir — +//! and `PATH`, which that same binary's own provisioning code needs to +//! resolve `bash` by relative name), then `map`'s keys are layered on top, +//! overriding any baseline variable with the same name. This stops the +//! child from also seeing whatever else the daemon's own environment +//! carries (`WAKER_IDENTITY_NSEC`, another tenant's already-resolved +//! provider credential, proxy/TLS controls) — the gap the original overlay- +//! only version of this parameter left open, since in `buzz-waker`'s +//! multi-tenant model the tenant's own bundle authorizes which provider +//! binary/digest runs, so an unisolated child could otherwise read and +//! exfiltrate secrets across the shared-daemon boundary. +//! +//! `TENANT_BASELINE_VARS` is deliberately small and reviewed per addition, +//! not "whatever the parent happens to have" — growing it back to the full +//! parent environment would silently undo the isolation this exists for. use sha2::{Digest, Sha256}; use std::collections::HashMap; @@ -56,6 +58,27 @@ const STDERR_CAP: usize = 65536; const STDOUT_CAP: usize = 1_048_576; // 1 MB const PROVIDER_PROTOCOL_VERSION: u64 = 1; +/// Variables kept from this process's own environment when a tenant-scoped +/// `env` overlay is supplied — see the module doc's The child's environment +/// section for why each one is here. Provider-agnostic on purpose: this +/// crate has no notion of which provider binary it is invoking, so it keeps +/// the same small baseline regardless of provider, rather than branching on +/// provider identity. +const TENANT_BASELINE_VARS: &[&str] = &["HOME", "PATH"]; + +/// Build the fixed baseline applied under a tenant-scoped `env` overlay, +/// read fresh from this process's own environment (never from `env` itself). +fn tenant_child_environment_baseline() -> HashMap { + TENANT_BASELINE_VARS + .iter() + .filter_map(|&key| { + std::env::var(key) + .ok() + .map(|value| (key.to_string(), value)) + }) + .collect() +} + /// On Windows, a console-subsystem child gets a fresh, briefly-visible /// console window per invocation unless `CREATE_NO_WINDOW` is set. A pure /// no-op on non-Windows platforms, so callers can call this unconditionally. @@ -129,7 +152,7 @@ fn validate_provider_info(info: &serde_json::Value) -> Result<(), String> { /// `workdir` is the child's working directory (`None` inherits the caller's /// own CWD) — callers with a notion of a stable agent home (the desktop's /// `~/.buzz`) should resolve and pass it; a caller without one may pass -/// `None`. `env` overlays the child's environment — see the module doc's +/// `None`. `Some` isolates the child's environment — see the module doc's /// The child's environment section. /// /// Reader threads stream lines/chunks over channels so the caller can receive @@ -159,6 +182,8 @@ pub fn invoke_provider( cmd.current_dir(workdir); } if let Some(env) = env { + cmd.env_clear(); + cmd.envs(tenant_child_environment_baseline()); cmd.envs(env); } configure_no_window(&mut cmd); diff --git a/crates/buzz-provider-deploy/src/tests.rs b/crates/buzz-provider-deploy/src/tests.rs index 582585cf700..d3357fc49a8 100644 --- a/crates/buzz-provider-deploy/src/tests.rs +++ b/crates/buzz-provider-deploy/src/tests.rs @@ -517,6 +517,58 @@ esac"#, } } +/// A tenant-scoped `env` overlay must isolate the child from this process's +/// own environment, not merely overlay on top of it — an unrelated inherited +/// variable (standing in for a daemon secret like `WAKER_IDENTITY_NSEC` or +/// another tenant's already-resolved provider credential) must not reach the +/// child, even though it is never mentioned in `env` or in +/// `TENANT_BASELINE_VARS`. +#[cfg(unix)] +#[test] +fn provider_deploy_env_overlay_clears_unrelated_inherited_variables() { + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + write_test_provider( + &provider, + r#"read request +case "$request" in + *\"op\":\"info\"*) printf '%s\n' '{"ok":true,"name":"test","version":"1.0.0","protocol_version":1,"description":"test provider","config_schema":{}}' ;; + *\"op\":\"deploy\"*) + if [ -z "${TEST_SHOULD_NOT_LEAK:-}" ]; then + printf '{"ok":true,"agent_id":"cleared"}\n' + else + printf '{"ok":true,"agent_id":"leaked"}\n' + fi + ;; +esac"#, + ); + + // SAFETY: test-only, single-threaded at this point in the process + // (no other test in this crate reads or writes this exact variable). + unsafe { + std::env::set_var("TEST_SHOULD_NOT_LEAK", "daemon-secret"); + } + let env = std::collections::HashMap::new(); + + let outcome = provider_deploy( + &provider, + &serde_json::json!({}), + &serde_json::json!({}), + None, + Some(&env), + ) + .expect("deploy with an empty tenant env overlay"); + assert_eq!( + outcome.agent_id, "cleared", + "a variable inherited from this process but absent from both the tenant overlay \ + and TENANT_BASELINE_VARS must not reach the child" + ); + + unsafe { + std::env::remove_var("TEST_SHOULD_NOT_LEAK"); + } +} + #[test] fn provider_info_requires_the_complete_flat_wire_shape() { let complete = serde_json::json!({ diff --git a/crates/buzz-waker/src/effects.rs b/crates/buzz-waker/src/effects.rs index 93cb3977df1..4c873f9b3a4 100644 --- a/crates/buzz-waker/src/effects.rs +++ b/crates/buzz-waker/src/effects.rs @@ -172,11 +172,11 @@ pub struct RealWakeEffects { /// already converted to its wire-shape environment variables /// ([`crate::enrolment::ProviderCredential::to_env`]) at credential /// delivery time. `None` for a statically configured agent (no - /// per-tenant credential exists to overlay) and for a roster-enrolled - /// agent whose delivered credential didn't carry one. Layered onto the - /// deploy subprocess's environment — see - /// `buzz_provider_deploy`'s own module doc for what "layered" means - /// (an overlay on the inherited environment, not isolation from it). + /// per-tenant credential exists) and for a roster-enrolled agent whose + /// delivered credential didn't carry one. Passed as the deploy + /// subprocess's `env` — see `buzz_provider_deploy`'s own module doc for + /// what that does (isolates the child's environment, not an overlay on + /// this daemon's own). provider_env: Option>>, /// Fires on daemon shutdown. Deliberately **not** tied to the mention /// feed's own connection lifecycle: a wake attempt does not touch that diff --git a/crates/buzz-waker/src/main.rs b/crates/buzz-waker/src/main.rs index 425d792761c..5b51f965d0b 100644 --- a/crates/buzz-waker/src/main.rs +++ b/crates/buzz-waker/src/main.rs @@ -51,13 +51,12 @@ //! [`buzz_waker::roster_feed`]'s module doc). //! //! A dynamically watched agent's `provider_credential`, when its delivered -//! credential carries one, is layered onto that agent's own deploy -//! subprocess environment (`buzz_provider_deploy`'s `env` overlay) — but -//! only as an overlay on top of this daemon's own inherited environment, -//! not full isolation from it. See `buzz_provider_deploy`'s own module doc, -//! The child's environment section, for exactly what that does and does -//! not protect against; full isolation is real future work, not -//! implemented here. +//! credential carries one, becomes that agent's own deploy subprocess +//! environment (`buzz_provider_deploy`'s `env` parameter): the child's +//! environment is cleared and rebuilt from a small fixed baseline plus this +//! credential, not this daemon's own inherited environment. See +//! `buzz_provider_deploy`'s own module doc, The child's environment +//! section, for exactly what that baseline is and why. use std::collections::HashMap; use std::collections::HashSet; @@ -155,6 +154,36 @@ fn ensure_owner_pin_matches( Ok(()) } +/// `WAKER_MAX_AGENTS` is documented as a total ceiling over config, pending, +/// and running agents together, not just a dynamic-admission threshold — +/// [`reconcile_roster`]'s own refuse-not-evict check only ever guards +/// roster *additions* against it, so an oversized static baseline would +/// otherwise start unchecked and stay that way for the daemon's whole life. +/// Called once at startup, before any agent (static or otherwise) is +/// spawned or gets per-agent state on disk — the one place this can still +/// fail closed. A no-op when dynamic enrolment is disabled +/// (`authorized_owners` empty): `max_agents` is `0` in that case by +/// definition (see [`main`]'s own parsing), not a real ceiling to enforce. +/// +/// # Errors +/// Dynamic enrolment is enabled and `static_agent_count` alone already +/// exceeds `max_agents`. +fn ensure_static_agent_count_fits_cap( + static_agent_count: usize, + max_agents: usize, + dynamic_enrolment_enabled: bool, +) -> anyhow::Result<()> { + if dynamic_enrolment_enabled && static_agent_count > max_agents { + anyhow::bail!( + "WAKER_MAX_AGENTS={max_agents} but WAKER_AGENTS_CONFIG_PATH already lists \ + {static_agent_count} statically configured agents; WAKER_MAX_AGENTS is a total \ + ceiling over config, pending, and running agents together, so the static \ + baseline alone must fit within it" + ); + } + Ok(()) +} + fn env_var(name: &str) -> anyhow::Result { std::env::var(name).map_err(|_| anyhow::anyhow!("{name} is required but not set")) } @@ -264,12 +293,33 @@ struct SupervisedAgent { enum TaskExit { /// One of a watched agent's three tasks (`"presence_tap"`, /// `"bundle_tap"`, or `"wake_loop"`). - Agent { pubkey: String, task: &'static str }, + /// + /// `generation` is a clone of the exact [`SupervisedAgent::cancel`] + /// token this task was spawned under, captured at spawn time — not + /// re-derived from `supervised` at exit time. A roster version change + /// tears down and re-adopts the same pubkey within one + /// [`reconcile_roster`] pass, so by the time a *predecessor* + /// generation's task actually finishes, `supervised` already holds the + /// *replacement* generation's fresh, uncancelled token under that same + /// pubkey — looking it up by pubkey at exit time would misclassify the + /// predecessor's expected exit as the replacement's unsolicited one. + /// Checking this captured token's own `is_cancelled()` instead is + /// correct regardless of what currently occupies that pubkey, because + /// [`tear_down_roster_agent`] always cancels a generation's token + /// before anything replaces its `supervised` entry. + Agent { + pubkey: String, + task: &'static str, + generation: CancellationToken, + }, /// A [`AgentSource::Roster`] agent's credential tap, tracked separately /// from `Agent` only so log lines name it correctly — it shares that /// agent's own [`SupervisedAgent::cancel`] and is classified exactly the - /// same way. - CredentialTap { pubkey: String }, + /// same way, `generation` included for the same reason. + CredentialTap { + pubkey: String, + generation: CancellationToken, + }, /// A daemon-wide task with no per-agent scope: the roster tap. Expected /// to run until the global token cancels; any other exit is fatal. Component(&'static str), @@ -480,6 +530,7 @@ fn spawn_agent_watch( TaskExit::Agent { pubkey, task: "presence_tap", + generation: cancel, } }); } @@ -506,6 +557,7 @@ fn spawn_agent_watch( TaskExit::Agent { pubkey, task: "bundle_tap", + generation: cancel, } }); } @@ -522,12 +574,14 @@ fn spawn_agent_watch( provider_env: provider_env.clone(), }; let cancel = cancel.clone(); + let generation = cancel.clone(); let pubkey = pubkey.clone(); tasks.spawn(async move { run_wake_loop(config, cancel).await; TaskExit::Agent { pubkey, task: "wake_loop", + generation, } }); } @@ -729,6 +783,7 @@ fn reconcile_roster( .await; TaskExit::CredentialTap { pubkey: pubkey_for_exit, + generation: tap_cancel, } }); } @@ -934,6 +989,12 @@ async fn main() -> anyhow::Result<()> { keys_by_agent.push((keys, auth_tag, owner_pubkey)); } + ensure_static_agent_count_fits_cap( + keys_by_agent.len(), + max_agents, + !authorized_owners.is_empty(), + )?; + std::fs::create_dir_all(&state_dir).map_err(|e| { anyhow::anyhow!( "could not create WAKER_STATE_DIR {}: {e}", @@ -1049,7 +1110,7 @@ async fn main() -> anyhow::Result<()> { } Ok(exit) => { let pubkey = match &exit { - TaskExit::Agent { pubkey, .. } | TaskExit::CredentialTap { pubkey } => pubkey.clone(), + TaskExit::Agent { pubkey, .. } | TaskExit::CredentialTap { pubkey, .. } => pubkey.clone(), TaskExit::Component(_) => unreachable!("handled above"), }; let task_name: &'static str = match &exit { @@ -1057,10 +1118,19 @@ async fn main() -> anyhow::Result<()> { TaskExit::CredentialTap { .. } => "credential_tap", TaskExit::Component(_) => unreachable!("handled above"), }; - let was_cancelled = supervised - .get(&pubkey) - .map(|agent| agent.cancel.is_cancelled()) - .unwrap_or(true); + // Classify against the exact token this task was + // spawned under, not whatever `supervised` currently + // holds for this pubkey — a version change tears + // down and re-adopts the same pubkey within one + // `reconcile_roster` pass, so a predecessor + // generation's exit can land after `supervised` + // already holds the replacement's fresh, uncancelled + // token. See `TaskExit::Agent`'s own doc. + let was_cancelled = match &exit { + TaskExit::Agent { generation, .. } + | TaskExit::CredentialTap { generation, .. } => generation.is_cancelled(), + TaskExit::Component(_) => unreachable!("handled above"), + }; let source = supervised.get(&pubkey).map(|agent| agent.source); match classify_exit(source, was_cancelled) { ExitDisposition::Expected => {} @@ -1173,6 +1243,25 @@ mod tests { assert!(error.to_string().contains("disagreeing owners"), "{error}"); } + #[test] + fn a_static_baseline_within_the_cap_is_accepted() { + assert!(ensure_static_agent_count_fits_cap(10, 10, true).is_ok()); + assert!(ensure_static_agent_count_fits_cap(5, 10, true).is_ok()); + } + + #[test] + fn a_static_baseline_exceeding_the_cap_is_refused_when_dynamic_enrolment_is_enabled() { + let error = ensure_static_agent_count_fits_cap(20, 10, true).unwrap_err(); + let message = error.to_string(); + assert!(message.contains("WAKER_MAX_AGENTS=10"), "{message}"); + assert!(message.contains("20"), "{message}"); + } + + #[test] + fn a_static_baseline_exceeding_the_cap_is_ignored_when_dynamic_enrolment_is_disabled() { + assert!(ensure_static_agent_count_fits_cap(20, 0, false).is_ok()); + } + #[test] fn a_valid_agent_list_round_trips_through_load_agents() { let dir = tempfile::tempdir().expect("tempdir");