From 6971822eee4b23bea453863045b2226ded2ad2d3 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 20:51:19 -0600 Subject: [PATCH] fix(agents): sync private managed config via relay Keep encrypted runnable configuration in an owner-scoped relay event while preserving local records as migration state. Validate inbound payloads before retention, expose fresh-device records through an ephemeral scoped overlay, and retain public/private heads and tombstones atomically. Co-authored-by: Carl Co-authored-by: Mongo <5c25403eab7271f9f94ddd4f2b270e8cac2c92e2c830c51877cca6ec974ffb3f@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut <68157ebd23b3897c1991015c3038658ea916200c67d3a54620b0754d1b92f6e0@buzz.block.builderlab.xyz> Signed-off-by: Wes --- crates/buzz-core/src/private_managed_agent.rs | 746 +++++++----------- crates/buzz-relay/src/handlers/ingest.rs | 31 +- desktop/src-tauri/src/app_state.rs | 20 +- desktop/src-tauri/src/commands/agents.rs | 67 +- desktop/src-tauri/src/commands/identity.rs | 13 + .../src/commands/personas/inbound.rs | 103 ++- .../personas/inbound/inbound_tests.rs | 109 +++ desktop/src-tauri/src/commands/workspace.rs | 13 + .../src/managed_agents/agent_events.rs | 85 +- desktop/src-tauri/src/managed_agents/mod.rs | 1 + .../managed_agents/private_config_overlay.rs | 378 +++++++++ .../src-tauri/src/managed_agents/reconcile.rs | 171 +++- .../src/managed_agents/reconcile/tests.rs | 154 ++++ .../agents/lib/usePersonaSync.test.mjs | 2 + .../src/features/agents/lib/usePersonaSync.ts | 2 + desktop/src/shared/constants/kinds.ts | 2 + 16 files changed, 1371 insertions(+), 526 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/private_config_overlay.rs diff --git a/crates/buzz-core/src/private_managed_agent.rs b/crates/buzz-core/src/private_managed_agent.rs index 180dd6fa0c..11fd681508 100644 --- a/crates/buzz-core/src/private_managed_agent.rs +++ b/crates/buzz-core/src/private_managed_agent.rs @@ -1,8 +1,8 @@ //! NIP-PMA private managed-agent wire codec. //! -//! This module defines and validates the inert wire format only. Relays must -//! not accept [`KIND_PRIVATE_MANAGED_AGENT`](crate::kind::KIND_PRIVATE_MANAGED_AGENT) -//! until the dedicated privacy and aggregate-CAS transactions are deployed. +//! This module defines and validates the owner-authored encrypted wire format. +//! Relays treat it as global owner data; Desktop performs all decryption and +//! device-specific runtime validation. use std::collections::{BTreeMap, HashSet}; use std::fmt; @@ -18,7 +18,7 @@ use serde_json::Value; use sha2::{Digest, Sha256}; use thiserror::Error; -use crate::kind::{KIND_MANAGED_AGENT, KIND_PERSONA, KIND_PRIVATE_MANAGED_AGENT}; +use crate::kind::KIND_PRIVATE_MANAGED_AGENT; /// Wire-format discriminator for decrypted private managed-agent payloads. pub const FORMAT: &str = "buzz-private-managed-agent"; @@ -63,65 +63,6 @@ pub enum Error { Sign, } -/// Authoritative lifecycle state repeated in the outer tags and ciphertext. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum State { - /// Runnable aggregate. - Active, - /// Anti-resurrection tombstone. - Deleted, -} - -impl State { - fn as_str(self) -> &'static str { - match self { - Self::Active => "active", - Self::Deleted => "deleted", - } - } -} - -/// Versioned signed-event recovery material for a bound public projection. -/// -/// Retaining the complete signed event makes reconstruction unambiguous: its -/// signature, ID, author, kind, coordinate, and exact content bytes can all be -/// checked without trusting replaceable-event history. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProjectionRecoveryV1 { - /// Recovery schema version. Version 1 stores one complete signed event. - pub version: u32, - /// Exact signed public projection event. - pub signed_event: Event, -} - -/// Complete definition projection binding and recovery material. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DefinitionBinding { - /// CAS-managed definition revision pinned by this aggregate. - pub revision: u64, - /// Exact signed kind:30175 event ID. - pub event_id: String, - /// Lowercase SHA-256 of the exact projection content bytes. - pub content_sha256: String, - /// Versioned signed event sufficient to reproduce the projection. - pub recovery: ProjectionRecoveryV1, -} - -/// Complete kind:30177 projection binding and recovery material. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct InstanceBinding { - /// Exact signed kind:30177 event ID. - pub event_id: String, - /// Lowercase SHA-256 of the exact projection content bytes. - pub content_sha256: String, - /// Versioned signed event sufficient to reproduce the projection. - pub recovery: ProjectionRecoveryV1, -} - /// Secret agent identity material. It never appears in public projections. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -144,14 +85,46 @@ impl fmt::Debug for PrivateIdentity { } /// Portable private runnable configuration. +/// +/// Forward-compatible: unknown JSON members authored by a newer Desktop are +/// preserved verbatim in [`PrivateConfig::extra`] rather than rejected, so an +/// older writer round-tripping this config cannot silently drop them. Known +/// members are still strictly typed; unknown members can never override a +/// known field (serde routes a matching key to the typed field first). #[derive(Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct PrivateConfig { - /// Explicit kind:30175 coordinate, when definition-backed. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub definition_coordinate: Option, /// Intended relay endpoint; validated again on each device before use. pub relay_url: String, + /// Unique agent handle (`ManagedAgentRecord.name`). Required for fresh-device + /// reconstruction. Non-empty. + pub name: String, + /// Stable definition/persona slug (`ManagedAgentRecord.persona_id`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub persona_id: Option, + /// Preferred ACP runtime id, e.g. `"goose"`/`"claude"`. `None` = inherit. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime: Option, + /// Desired LLM model id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// LLM inference provider. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// System prompt. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + /// Turn parallelism. `None` = the Desktop default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parallelism: Option, + /// Inbound author gate mode as the NIP-AP wire string + /// (`"owner-only"`/`"allowlist"`/`"anyone"`). Wire string, not the Desktop + /// `RespondTo` enum, so unknown future modes round-trip verbatim. `None` = + /// the Desktop default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub respond_to: Option, + /// Allowlist used when `respond_to == "allowlist"`; normalized lowercase hex. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub respond_to_allowlist: Vec, /// Explicit harness override; never launched without local validation. #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_command_override: Option, @@ -181,6 +154,14 @@ pub struct PrivateConfig { /// Versioned provider/definition relay-mesh marker. #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_mesh: Option, + /// Unknown JSON members preserved verbatim for forward compatibility. + /// + /// A newer Desktop may author config keys this version does not model; they + /// round-trip here untouched so an older writer never drops them. Never + /// contains a key that collides with a known field above (serde binds known + /// keys first). Core semantics must never depend on this map. + #[serde(flatten)] + pub extra: serde_json::Map, } impl fmt::Debug for PrivateConfig { @@ -192,23 +173,13 @@ impl fmt::Debug for PrivateConfig { } } -/// Fields present only when [`Payload::state`] is [`State::Active`]. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ActivePayload { - /// Exact definition projection binding. - pub definition: DefinitionBinding, - /// Exact public instance projection binding. - pub instance_projection: InstanceBinding, - /// Secret identity material. - pub identity: PrivateIdentity, - /// Private portable/device-validated configuration. - pub config: PrivateConfig, -} - /// Decrypted private managed-agent payload. +/// +/// Forward-compatible at the top level: unknown JSON members authored by a +/// newer Desktop round-trip verbatim in [`Payload::extra`] (see [`PrivateConfig`] +/// for the same guarantee on config). Known members remain strictly typed and +/// validated; an unknown member can never override a known field. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct Payload { /// Always [`FORMAT`]. pub format: String, @@ -218,24 +189,27 @@ pub struct Payload { pub agent_pubkey: String, /// Owner pubkey and signed event author. pub owner_pubkey: String, - /// Monotonic CAS generation. + /// Advisory monotonic generation (validated shape, never CAS-enforced). pub generation: u64, - /// Exact predecessor event ID; absent only for generation one. + /// Advisory predecessor event ID; absent exactly at generation one. #[serde(default, skip_serializing_if = "Option::is_none")] pub previous_event_id: Option, - /// Lifecycle state, repeated in the outer `state` tag. - pub state: State, /// RFC3339 bookkeeping timestamp; never used for conflict resolution. pub updated_at: String, - /// Required for active records and forbidden for tombstones. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub active: Option, - /// Required for tombstones and forbidden for active records. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub deleted_at: Option, + /// Secret identity material. + pub identity: PrivateIdentity, + /// Private portable/device-validated configuration. + pub config: PrivateConfig, /// Forward-compatible namespaced data. Core semantics must never depend on it. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, + /// Unknown top-level JSON members preserved verbatim for forward + /// compatibility. A newer Desktop may author payload keys this version does + /// not model; they round-trip here untouched so an older writer never drops + /// them. Never contains a key that collides with a known field above (serde + /// binds known keys first). Core semantics must never depend on this map. + #[serde(flatten)] + pub extra: serde_json::Map, } /// Validated public metadata from a private managed-agent event. @@ -245,12 +219,10 @@ pub struct Envelope { pub agent_pubkey: PublicKey, /// Owner pubkey from the signed event author. pub owner_pubkey: PublicKey, - /// CAS generation from `g`. + /// Advisory generation from `g` (validated shape, never CAS-enforced). pub generation: u64, - /// CAS predecessor from `prev`. + /// Advisory predecessor from `prev`. pub previous_event_id: Option, - /// Lifecycle state from `state`. - pub state: State, } /// Compute the lowercase SHA-256 binding for exact projection content bytes. @@ -280,7 +252,6 @@ pub fn validate_envelope(event: &Event, expected_owner: &PublicKey) -> Result Result &mut d, "g" => &mut g, "prev" => &mut prev, - "state" => &mut state, name => return Err(Error::InvalidEnvelope(format!("unexpected tag: {name}"))), }; if slot.replace(parts[1].clone()).is_some() { @@ -322,18 +292,11 @@ pub fn validate_envelope(event: &Event, expected_owner: &PublicKey) -> Result State::Active, - Some("deleted") => State::Deleted, - Some(_) => return Err(Error::InvalidEnvelope("invalid state tag".into())), - None => return Err(Error::InvalidEnvelope("missing state tag".into())), - }; Ok(Envelope { agent_pubkey, owner_pubkey, generation, previous_event_id, - state, }) } @@ -362,7 +325,6 @@ pub fn build_event(owner_keys: &Keys, payload: &Payload, created_at: u64) -> Res let mut tags = vec![ parse_tag(["d", payload.agent_pubkey.as_str()])?, parse_tag(["g", payload.generation.to_string().as_str()])?, - parse_tag(["state", payload.state.as_str()])?, ]; if let Some(previous) = payload.previous_event_id.as_deref() { tags.push(parse_tag(["prev", previous])?); @@ -396,7 +358,6 @@ pub fn validate_and_decrypt( if payload.agent_pubkey != envelope.agent_pubkey.to_hex() || payload.owner_pubkey != envelope.owner_pubkey.to_hex() || payload.generation != envelope.generation - || payload.state != envelope.state || payload.previous_event_id.as_deref() != envelope .previous_event_id @@ -420,7 +381,7 @@ pub fn validate_payload(payload: &Payload) -> Result<(), Error> { } let agent = parse_canonical_pubkey("agent_pubkey", &payload.agent_pubkey) .map_err(|e| Error::InvalidPayload(e.to_string()))?; - parse_canonical_pubkey("owner_pubkey", &payload.owner_pubkey) + let owner = parse_canonical_pubkey("owner_pubkey", &payload.owner_pubkey) .map_err(|e| Error::InvalidPayload(e.to_string()))?; validate_generation_and_prev(payload.generation, payload.previous_event_id.as_deref())?; parse_rfc3339("updated_at", &payload.updated_at)?; @@ -432,77 +393,36 @@ pub fn validate_payload(payload: &Payload) -> Result<(), Error> { } validate_value_size("extension", value)?; } - match payload.state { - State::Active => { - if payload.deleted_at.is_some() { - return Err(Error::InvalidPayload( - "active payload must not contain deleted_at".into(), - )); - } - let active = payload.active.as_ref().ok_or_else(|| { - Error::InvalidPayload("active payload missing active body".into()) - })?; - validate_active(active, &agent, &payload.owner_pubkey)?; - } - State::Deleted => { - if payload.active.is_some() { - return Err(Error::InvalidPayload( - "deleted payload must not contain active body".into(), - )); - } - parse_rfc3339( - "deleted_at", - payload.deleted_at.as_deref().ok_or_else(|| { - Error::InvalidPayload("deleted payload missing deleted_at".into()) - })?, - )?; - } - } + validate_identity_and_config(&payload.identity, &payload.config, &agent, &owner)?; Ok(()) } -fn validate_active( - active: &ActivePayload, +/// Validate the secret identity and portable config of a payload. +/// +/// The nsec must derive the payload's `agent_pubkey` (the `d` coordinate), and +/// the config's bounds must hold. This is the nsec→coordinate binding gate. +fn validate_identity_and_config( + identity: &PrivateIdentity, + config: &PrivateConfig, agent: &PublicKey, - owner_pubkey: &str, + owner: &PublicKey, ) -> Result<(), Error> { - if active.definition.revision == 0 || active.definition.revision > MAX_SAFE_GENERATION { - return Err(Error::InvalidPayload("invalid definition revision".into())); - } - let definition_d = - parse_definition_coordinate(active.config.definition_coordinate.as_deref(), owner_pubkey)?; - validate_binding( - "definition", - KIND_PERSONA, - owner_pubkey, - Some(&definition_d), - &active.definition.event_id, - &active.definition.content_sha256, - &active.definition.recovery, - )?; - validate_binding( - "instance_projection", - KIND_MANAGED_AGENT, - owner_pubkey, - Some(&agent.to_hex()), - &active.instance_projection.event_id, - &active.instance_projection.content_sha256, - &active.instance_projection.recovery, - )?; - let agent_keys = Keys::parse(active.identity.private_key_nsec.trim()) + let agent_keys = Keys::parse(identity.private_key_nsec.trim()) .map_err(|_| Error::InvalidPayload("invalid agent nsec".into()))?; if agent_keys.public_key() != *agent { return Err(Error::InvalidPayload( "agent nsec does not derive agent_pubkey".into(), )); } - if let Some(auth_tag) = &active.identity.auth_tag { - validate_auth_tag(auth_tag, owner_pubkey, agent)?; + if let Some(auth_tag) = &identity.auth_tag { + validate_auth_tag(auth_tag, &owner.to_hex(), agent)?; } - let config = &active.config; if config.relay_url.is_empty() || config.relay_url.len() > 4096 { return Err(Error::InvalidPayload("invalid relay_url length".into())); } + if config.name.is_empty() || config.name.len() > 4096 { + return Err(Error::InvalidPayload("invalid name length".into())); + } if config.agent_args.len() > MAX_AGENT_ARGS || config .agent_args @@ -566,82 +486,6 @@ fn validate_auth_tag(auth_tag: &str, expected_owner: &str, agent: &PublicKey) -> .map_err(|_| Error::InvalidPayload("invalid auth_tag signature".into())) } -fn parse_definition_coordinate( - coordinate: Option<&str>, - owner_pubkey: &str, -) -> Result { - let coordinate = coordinate.ok_or_else(|| { - Error::InvalidPayload("active payload missing definition_coordinate".into()) - })?; - let mut parts = coordinate.splitn(3, ':'); - let kind = parts.next(); - let owner = parts.next(); - let d = parts.next(); - if kind != Some("30175") || owner != Some(owner_pubkey) || d.is_none_or(str::is_empty) { - return Err(Error::InvalidPayload( - "definition_coordinate must be 30175::".into(), - )); - } - Ok(d.unwrap().to_owned()) -} - -fn validate_binding( - label: &str, - expected_kind: u32, - owner_pubkey: &str, - expected_d: Option<&str>, - event_id: &str, - hash: &str, - recovery: &ProjectionRecoveryV1, -) -> Result<(), Error> { - parse_event_id(label, event_id).map_err(|e| Error::InvalidPayload(e.to_string()))?; - parse_lower_hex_32(&format!("{label}.content_sha256"), hash) - .map_err(|e| Error::InvalidPayload(e.to_string()))?; - if recovery.version != 1 { - return Err(Error::InvalidPayload(format!( - "unsupported {label} recovery version" - ))); - } - let event = &recovery.signed_event; - if !event.verify_id() || !event.verify_signature() { - return Err(Error::InvalidPayload(format!( - "invalid {label} recovery event" - ))); - } - if event.id.to_hex() != event_id - || event.kind.as_u16() as u32 != expected_kind - || event.pubkey.to_hex() != owner_pubkey - || content_sha256(event.content.as_bytes()) != hash - { - return Err(Error::InvalidPayload(format!( - "{label} recovery does not match binding" - ))); - } - let d_tags: Vec<_> = event - .tags - .iter() - .filter_map(|tag| { - let parts = tag.as_slice(); - (parts.first().map(String::as_str) == Some("d")).then_some(parts) - }) - .collect(); - if d_tags.len() != 1 || d_tags[0].len() != 2 || d_tags[0][1].is_empty() { - return Err(Error::InvalidPayload(format!( - "{label} recovery must have exactly one non-empty d tag" - ))); - } - if expected_d.is_some_and(|expected| d_tags[0][1] != expected) { - return Err(Error::InvalidPayload(format!( - "{label} recovery has wrong coordinate" - ))); - } - validate_value_size( - label, - &serde_json::to_value(recovery) - .map_err(|_| Error::InvalidPayload(format!("invalid {label}")))?, - ) -} - fn validate_generation_and_prev(generation: u64, previous: Option<&str>) -> Result<(), Error> { if generation == 0 || generation > MAX_SAFE_GENERATION { return Err(Error::InvalidPayload( @@ -814,21 +658,9 @@ mod tests { .to_string() } + /// Minimal valid payload: the nsec derives `agent_pubkey`, generation 1, + /// required config fields present, no unknown members. fn payload(owner: &Keys, agent: &Keys) -> Payload { - let definition_event = EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "definition") - .tags(vec![Tag::parse(["d", "test-agent"]).unwrap()]) - .custom_created_at(nostr::Timestamp::from(1_785_780_000)) - .sign_with_keys(owner) - .unwrap(); - let instance_event = EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), "instance") - .tags(vec![Tag::parse([ - "d", - agent.public_key().to_hex().as_str(), - ]) - .unwrap()]) - .custom_created_at(nostr::Timestamp::from(1_785_780_000)) - .sign_with_keys(owner) - .unwrap(); Payload { format: FORMAT.into(), version: VERSION, @@ -836,50 +668,36 @@ mod tests { owner_pubkey: owner.public_key().to_hex(), generation: 1, previous_event_id: None, - state: State::Active, updated_at: "2026-08-03T18:00:00Z".into(), - active: Some(ActivePayload { - definition: DefinitionBinding { - revision: 1, - event_id: definition_event.id.to_hex(), - content_sha256: content_sha256(definition_event.content.as_bytes()), - recovery: ProjectionRecoveryV1 { - version: 1, - signed_event: definition_event, - }, - }, - instance_projection: InstanceBinding { - event_id: instance_event.id.to_hex(), - content_sha256: content_sha256(instance_event.content.as_bytes()), - recovery: ProjectionRecoveryV1 { - version: 1, - signed_event: instance_event, - }, - }, - identity: PrivateIdentity { - private_key_nsec: agent.secret_key().to_bech32().unwrap(), - auth_tag: None, - }, - config: PrivateConfig { - definition_coordinate: Some(format!( - "30175:{}:test-agent", - owner.public_key().to_hex() - )), - relay_url: "wss://relay.example".into(), - agent_command_override: None, - agent_args: vec![], - idle_timeout_seconds: Some(300), - max_turn_duration_seconds: None, - env_vars: BTreeMap::from([("SECRET".into(), "not-public".into())]), - backend: serde_json::json!({"type": "local"}), - backend_agent_id: None, - team_id: None, - persona_name_in_team: None, - relay_mesh: None, - }, - }), - deleted_at: None, + identity: PrivateIdentity { + private_key_nsec: agent.secret_key().to_bech32().unwrap(), + auth_tag: None, + }, + config: PrivateConfig { + relay_url: "wss://relay.example".into(), + name: "aphid".into(), + persona_id: Some("aphid-def".into()), + runtime: Some("goose".into()), + model: None, + provider: None, + system_prompt: Some("be terse".into()), + parallelism: Some(2), + respond_to: Some("owner-only".into()), + respond_to_allowlist: vec![], + agent_command_override: None, + agent_args: vec![], + idle_timeout_seconds: Some(300), + max_turn_duration_seconds: None, + env_vars: BTreeMap::from([("SECRET".into(), "not-public".into())]), + backend: serde_json::json!({"type": "local"}), + backend_agent_id: None, + team_id: None, + persona_name_in_team: None, + relay_mesh: None, + extra: serde_json::Map::new(), + }, extensions: BTreeMap::new(), + extra: serde_json::Map::new(), } } @@ -894,7 +712,7 @@ mod tests { assert_eq!(envelope.agent_pubkey, agent.public_key()); assert_eq!(envelope.owner_pubkey, owner.public_key()); assert_eq!(envelope.generation, 1); - assert_eq!(envelope.state, State::Active); + assert_eq!(envelope.previous_event_id, None); } #[test] @@ -902,16 +720,9 @@ mod tests { let owner = Keys::generate(); let agent = Keys::generate(); let mut candidate = payload(&owner, &agent); - let private_key_nsec = candidate - .active - .as_ref() - .unwrap() - .identity - .private_key_nsec - .clone(); - let active = candidate.active.as_mut().unwrap(); - active.identity.auth_tag = Some("secret-auth-tag".into()); - active.config.backend = serde_json::json!({"token": "secret-backend-token"}); + let private_key_nsec = candidate.identity.private_key_nsec.clone(); + candidate.identity.auth_tag = Some("secret-auth-tag".into()); + candidate.config.backend = serde_json::json!({"token": "secret-backend-token"}); let debug = format!("{candidate:?}"); assert!(debug.contains("")); @@ -921,6 +732,7 @@ mod tests { assert!(!debug.contains("secret-backend-token")); } + // (1) privacy / wrong-owner + tamper fail closed. #[test] fn wrong_owner_and_tampering_fail_closed() { let owner = Keys::generate(); @@ -940,62 +752,12 @@ mod tests { )); } - #[test] - fn duplicate_and_unknown_json_fields_are_rejected() { - let duplicate = br#"{"format":"a","format":"b"}"#; - assert!(matches!( - parse_strict_json(duplicate), - Err(Error::InvalidPayload(message)) if message.contains("duplicate key") - )); - - let owner = Keys::generate(); - let agent = Keys::generate(); - let mut value = serde_json::to_value(payload(&owner, &agent)).unwrap(); - value - .as_object_mut() - .unwrap() - .insert("surprise".into(), Value::Bool(true)); - let err = serde_json::from_value::(value).unwrap_err(); - assert!(err.to_string().contains("unknown field")); - } - - #[test] - fn auth_tag_must_be_unconditional_and_bound_to_owner_and_agent() { - let owner = Keys::generate(); - let agent = Keys::generate(); - let mut candidate = payload(&owner, &agent); - candidate.active.as_mut().unwrap().identity.auth_tag = Some(auth_tag(&owner, &agent)); - validate_payload(&candidate).unwrap(); - - candidate.active.as_mut().unwrap().identity.auth_tag = - Some(auth_tag(&Keys::generate(), &agent)); - assert!(validate_payload(&candidate).is_err()); - - candidate.active.as_mut().unwrap().identity.auth_tag = - Some(auth_tag(&owner, &Keys::generate())); - assert!(validate_payload(&candidate).is_err()); - - let mut self_attested = payload(&owner, &owner); - self_attested.active.as_mut().unwrap().identity.auth_tag = Some(auth_tag(&owner, &owner)); - assert!(matches!( - validate_payload(&self_attested), - Err(Error::InvalidPayload(message)) if message.contains("distinct agent key") - )); - - let valid = auth_tag(&owner, &agent); - let mut parts: Vec = serde_json::from_str(&valid).unwrap(); - parts[2] = "kind=9".into(); - candidate.active.as_mut().unwrap().identity.auth_tag = - Some(serde_json::to_string(&parts).unwrap()); - assert!(validate_payload(&candidate).is_err()); - } - + // (2) nsec -> pubkey binding: the identity nsec must derive agent_pubkey (d). #[test] fn active_identity_must_derive_coordinate() { let owner = Keys::generate(); let mut candidate = payload(&owner, &Keys::generate()); - candidate.active.as_mut().unwrap().identity.private_key_nsec = - Keys::generate().secret_key().to_bech32().unwrap(); + candidate.identity.private_key_nsec = Keys::generate().secret_key().to_bech32().unwrap(); assert!(matches!( validate_payload(&candidate), Err(Error::InvalidPayload(message)) if message.contains("does not derive") @@ -1003,23 +765,160 @@ mod tests { } #[test] - fn tombstone_requires_successor_shape() { + fn valid_owner_attestation_passes_and_binds_agent() { let owner = Keys::generate(); let agent = Keys::generate(); - let mut deleted = payload(&owner, &agent); - deleted.generation = 2; - deleted.previous_event_id = Some("33".repeat(32)); - deleted.state = State::Deleted; - deleted.active = None; - deleted.deleted_at = Some("2026-08-03T18:01:00Z".into()); - validate_payload(&deleted).unwrap(); + let mut candidate = payload(&owner, &agent); + // The owner signs an unconditional attestation over the AGENT key. + candidate.identity.auth_tag = Some(auth_tag(&owner, &agent)); + assert!(validate_payload(&candidate).is_ok()); - deleted.previous_event_id = None; - assert!(validate_payload(&deleted).is_err()); + // Round-trips end-to-end with the attestation intact. + let event = build_event(&owner, &candidate, 1_785_780_000).unwrap(); + let (_envelope, decoded) = validate_and_decrypt(&event, &owner).unwrap(); + assert_eq!(decoded, candidate); } #[test] - fn outer_tag_grammar_rejects_duplicates_and_noncanonical_generation() { + fn auth_tag_from_wrong_attestor_is_rejected() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut candidate = payload(&owner, &agent); + // A stranger (not the owner) signs the attestation: parts[1] is not the + // owner pubkey, so the attestation is rejected. + let stranger = Keys::generate(); + candidate.identity.auth_tag = Some(auth_tag(&stranger, &agent)); + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("auth_tag") + )); + } + + #[test] + fn auth_tag_signature_must_verify() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut candidate = payload(&owner, &agent); + // Correct owner in parts[1], but the signature is over a DIFFERENT agent + // key, so schnorr verification against this agent's preimage fails. + let other_agent = Keys::generate(); + let preimage = format!("nostr:agent-auth:{}:", other_agent.public_key().to_hex()); + let digest = Sha256::digest(preimage.as_bytes()); + let signature = owner.sign_schnorr(&Message::from_digest(digest.into())); + candidate.identity.auth_tag = Some( + serde_json::json!([ + "auth", + owner.public_key().to_hex(), + "", + signature.to_string() + ]) + .to_string(), + ); + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("signature") + )); + } + + // (3) unknown-field round-trip: unknown top-level + config members survive + // verbatim through serialize/deserialize and land in the `extra` maps. + #[test] + fn unknown_members_round_trip_verbatim() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut candidate = payload(&owner, &agent); + candidate + .extra + .insert("future_top".into(), serde_json::json!({"nested": [1, 2]})); + candidate + .config + .extra + .insert("future_cfg".into(), Value::String("keep-me".into())); + + let event = build_event(&owner, &candidate, 1_785_780_000).unwrap(); + let (_envelope, decoded) = validate_and_decrypt(&event, &owner).unwrap(); + assert_eq!(decoded, candidate); + assert_eq!( + decoded.extra.get("future_top"), + Some(&serde_json::json!({"nested": [1, 2]})) + ); + assert_eq!( + decoded.config.extra.get("future_cfg"), + Some(&Value::String("keep-me".into())) + ); + } + + // (3b) an unknown member can never override a known field: serde binds the + // typed field first, so a colliding key is impossible to smuggle into `extra`. + #[test] + fn unknown_member_cannot_override_known_field() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut value = serde_json::to_value(payload(&owner, &agent)).unwrap(); + // Inject a duplicate-looking known key into the config object; serde + // routes it to the typed `name`, NOT to `extra`. + value["config"]["name"] = Value::String("renamed".into()); + let decoded: Payload = serde_json::from_value(value).unwrap(); + assert_eq!(decoded.config.name, "renamed"); + assert!(!decoded.config.extra.contains_key("name")); + } + + // (4) required / null semantics: a missing required known field is rejected; + // duplicate JSON keys are rejected by the strict parser. + #[test] + fn required_fields_and_duplicate_keys() { + let owner = Keys::generate(); + let agent = Keys::generate(); + + // Missing required `config.name`. + let mut value = serde_json::to_value(payload(&owner, &agent)).unwrap(); + value["config"].as_object_mut().unwrap().remove("name"); + assert!(serde_json::from_value::(value).is_err()); + + // Duplicate top-level key rejected pre-deserialization. + let duplicate = br#"{"format":"a","format":"b"}"#; + assert!(matches!( + parse_strict_json(duplicate), + Err(Error::InvalidPayload(message)) if message.contains("duplicate key") + )); + + // Empty required `name` fails semantic validation. + let mut empty_name = payload(&owner, &agent); + empty_name.config.name = String::new(); + assert!(matches!( + validate_payload(&empty_name), + Err(Error::InvalidPayload(message)) if message.contains("invalid name length") + )); + } + + // gen/prev are a3 advisory metadata: shape is validated (gen1 XOR prev, + // canonical decimal, outer/inner equality) but ordering is never enforced. + #[test] + fn generation_prev_shape_is_validated_metadata() { + let owner = Keys::generate(); + let agent = Keys::generate(); + + // Higher generation with a well-formed prev round-trips fine — no head + // consult, no staleness rejection. + let mut successor = payload(&owner, &agent); + successor.generation = 7; + successor.previous_event_id = Some("33".repeat(32)); + let event = build_event(&owner, &successor, 1_785_780_001).unwrap(); + let (envelope, decoded) = validate_and_decrypt(&event, &owner).unwrap(); + assert_eq!(envelope.generation, 7); + assert_eq!(decoded.generation, 7); + + // prev present at generation 1 violates the shape rule. + let mut bad = payload(&owner, &agent); + bad.previous_event_id = Some("33".repeat(32)); + assert!(matches!( + validate_payload(&bad), + Err(Error::InvalidPayload(message)) if message.contains("absent exactly at generation 1") + )); + } + + #[test] + fn outer_tag_grammar_rejects_noncanonical_generation_and_stray_tags() { let owner = Keys::generate(); let agent = Keys::generate(); let body = payload(&owner, &agent); @@ -1030,102 +929,39 @@ mod tests { Version::V2, ) .unwrap(); - let event = EventBuilder::new(Kind::Custom(KIND_PRIVATE_MANAGED_AGENT as u16), ciphertext) + // Non-canonical generation "01". + let event = EventBuilder::new( + Kind::Custom(KIND_PRIVATE_MANAGED_AGENT as u16), + ciphertext.clone(), + ) + .tags(vec![ + Tag::parse(["d", agent.public_key().to_hex().as_str()]).unwrap(), + Tag::parse(["g", "01"]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + assert!(matches!( + validate_envelope(&event, &owner.public_key()), + Err(Error::InvalidEnvelope(message)) if message.contains("canonical decimal") + )); + + // A stray lifecycle `state` tag is now unexpected (lifecycle removed). + let stray = EventBuilder::new(Kind::Custom(KIND_PRIVATE_MANAGED_AGENT as u16), ciphertext) .tags(vec![ Tag::parse(["d", agent.public_key().to_hex().as_str()]).unwrap(), - Tag::parse(["g", "01"]).unwrap(), + Tag::parse(["g", "1"]).unwrap(), Tag::parse(["state", "active"]).unwrap(), ]) .sign_with_keys(&owner) .unwrap(); assert!(matches!( - validate_envelope(&event, &owner.public_key()), - Err(Error::InvalidEnvelope(message)) if message.contains("canonical decimal") + validate_envelope(&stray, &owner.public_key()), + Err(Error::InvalidEnvelope(message)) if message.contains("unexpected tag") )); } #[test] - fn projection_recovery_must_match_binding_and_coordinate() { - let owner = Keys::generate(); - let agent = Keys::generate(); - let mut candidate = payload(&owner, &agent); - let active = candidate.active.as_mut().unwrap(); - active.instance_projection.content_sha256 = content_sha256(b"wrong"); - assert!(matches!( - validate_payload(&candidate), - Err(Error::InvalidPayload(message)) if message.contains("does not match binding") - )); - - let mut candidate = payload(&owner, &agent); - candidate - .active - .as_mut() - .unwrap() - .config - .definition_coordinate = - Some(format!("30175:{}:wrong-slug", owner.public_key().to_hex())); - assert!(matches!( - validate_payload(&candidate), - Err(Error::InvalidPayload(message)) if message.contains("wrong coordinate") - )); - let mut candidate = payload(&owner, &agent); - candidate - .active - .as_mut() - .unwrap() - .definition - .recovery - .version = 2; - assert!(matches!( - validate_payload(&candidate), - Err(Error::InvalidPayload(message)) if message.contains("unsupported definition recovery version") - )); - - let mut candidate = payload(&owner, &agent); - candidate - .active - .as_mut() - .unwrap() - .definition - .recovery - .signed_event - .content - .push('!'); - assert!(matches!( - validate_payload(&candidate), - Err(Error::InvalidPayload(message)) if message.contains("invalid definition recovery event") - )); - - let mut candidate = payload(&owner, &agent); - let wrong_kind = EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), "definition") - .tags(vec![Tag::parse(["d", "test-agent"]).unwrap()]) - .sign_with_keys(&owner) - .unwrap(); - let definition = &mut candidate.active.as_mut().unwrap().definition; - definition.event_id = wrong_kind.id.to_hex(); - definition.content_sha256 = content_sha256(wrong_kind.content.as_bytes()); - definition.recovery.signed_event = wrong_kind; - assert!(matches!( - validate_payload(&candidate), - Err(Error::InvalidPayload(message)) if message.contains("does not match binding") - )); - - let mut candidate = payload(&owner, &agent); - let missing_d = EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "definition") - .sign_with_keys(&owner) - .unwrap(); - let definition = &mut candidate.active.as_mut().unwrap().definition; - definition.event_id = missing_d.id.to_hex(); - definition.content_sha256 = content_sha256(missing_d.content.as_bytes()); - definition.recovery.signed_event = missing_d; - assert!(matches!( - validate_payload(&candidate), - Err(Error::InvalidPayload(message)) if message.contains("exactly one non-empty d tag") - )); - } - - #[test] - fn projection_hash_fixture_is_stable() { + fn hash_fixture_is_stable() { assert_eq!( content_sha256(b"buzz-private-managed-agent-v1"), "c3ca1603249c95343fc1766ba58d075d6bdf0e57b375bef38738729b2022cc80" diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index cd9f20b5f4..55a468144c 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -28,12 +28,13 @@ use buzz_core::kind::{ KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, - KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, - KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, - KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, - RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, + KIND_PRIVATE_MANAGED_AGENT, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, + KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, + KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, + KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, + KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, + RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; @@ -263,7 +264,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM | KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT - | KIND_TEAM_CATALOG | super::push_lease::KIND_PUSH_LEASE => { + | KIND_PRIVATE_MANAGED_AGENT | KIND_TEAM_CATALOG | super::push_lease::KIND_PUSH_LEASE => { Ok(Scope::UsersWrite) } // NIP-AM: agent turn metrics are agent-authored global events (encrypted to owner). @@ -476,6 +477,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { // (pubkey, kind, d_tag). A stray `h` tag must not channel-scope them. | KIND_TEAM | KIND_MANAGED_AGENT + | KIND_PRIVATE_MANAGED_AGENT | KIND_TEAM_CATALOG // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). @@ -3338,15 +3340,14 @@ mod tests { } #[test] - fn private_managed_agent_kind_remains_rejected_until_atomic_ingest_exists() { - assert!( - required_scope_for_kind( - buzz_core::kind::KIND_PRIVATE_MANAGED_AGENT, - &make_dummy_event(), - ) - .is_err(), - "kind 30179 must not enter generic EVENT ingest before privacy and aggregate CAS deploy" + fn private_managed_agent_kind_is_owner_scoped_global_user_data() { + let event = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_PRIVATE_MANAGED_AGENT, &event), + Ok(Scope::UsersWrite) ); + assert!(is_global_only_kind(KIND_PRIVATE_MANAGED_AGENT)); + assert!(!requires_h_channel_scope(KIND_PRIVATE_MANAGED_AGENT)); } #[test] diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index fc90e6ab14..34f3a0713b 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -35,28 +35,27 @@ pub struct AppState { /// Workspace-provided relay URL override. Set by `apply_workspace` on app /// init and takes priority over env vars and compile-time defaults. pub relay_url_override: Mutex>, - /// Set during backend setup when managed agents are eligible for launch - /// restore. `apply_workspace` consumes it after installing the workspace - /// relay and identity, so agents never start against the fallback relay. + /// Set during backend setup when managed agents are eligible for launch restore. + /// `apply_workspace` consumes it after installing the workspace relay and + /// identity, so agents never start against the fallback relay. pub managed_agent_restore_pending: AtomicBool, - /// Whether desktop may repair managed-agent kind:0 profiles from its local - /// records. Disabled by the agent-managed profiles experiment so an agent's - /// own profile updates are not overwritten on start or restore. + /// Whether desktop may repair managed-agent kind:0 profiles from local records. + /// Disabled by the experiment so agent profile updates survive start/restore. pub managed_agent_profile_reconcile_enabled: AtomicBool, - /// Shared shutdown signal checked by launch-time agent restoration. + /// Shared shutdown signal for launch-time agent restoration. pub shutdown_started: AtomicBool, /// Serializes every managed-runtime transition that changes the protected /// PID set: spawn/register, adoption, stop, shutdown, and sweep snapshots. /// Never perform network I/O while holding this lock. pub managed_agent_runtime_transition: Mutex<()>, pub managed_agents_store_lock: Mutex<()>, + pub(crate) private_managed_agent_overlay: + Mutex, pub channel_templates_store_lock: Mutex<()>, pub managed_agent_processes: Mutex>, pub huddle_state: Mutex, pub huddle_audio: crate::huddle::tts_settings::HuddleAudioSettingsState, - /// Tauri app handle — stored after setup so huddle commands can emit - /// `huddle-state-changed` events without needing the handle threaded - /// through every call site. + /// Tauri handle for emitting huddle events. /// /// Set once during `setup()` in `lib.rs`; never cleared. pub app_handle: Mutex>, @@ -213,6 +212,7 @@ pub fn build_app_state() -> AppState { managed_agent_runtime_transition: Mutex::new(()), identity_mutation: Mutex::new(()), managed_agents_store_lock: Mutex::new(()), + private_managed_agent_overlay: Mutex::new(Default::default()), channel_templates_store_lock: Mutex::new(()), managed_agent_processes: Mutex::new(HashMap::new()), session_config_cache: Mutex::new(HashMap::new()), diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index dd61fc9398..d21aa56ad3 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -70,49 +70,12 @@ pub(super) fn retain_managed_agent_pending( /// `pending_sync = 1`. The `d_tag` is the agent's pubkey. Best-effort: a /// failure is logged and swallowed so a retention hiccup never blocks the /// disk-authoritative delete. -pub(super) fn tombstone_managed_agent_pending( +pub(crate) fn tombstone_managed_agent_pending( app: &AppHandle, state: &AppState, agent_pubkey: &str, ) { - use crate::managed_agents::{ - agent_events::build_agent_delete, - retention::{ - delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, - RetainedEvent, - }, - }; - use buzz_core_pkg::kind::KIND_MANAGED_AGENT; - use nostr::JsonUtil; - - const KIND_DELETE: u32 = 5; - - let result = (|| -> Result<(), String> { - let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; - let owner_pubkey = scope.owner_keys.public_key().to_hex(); - let event = build_agent_delete(agent_pubkey, &owner_pubkey)? - .sign_with_keys(&scope.owner_keys) - .map_err(|e| format!("failed to sign managed-agent tombstone: {e}"))?; - let conn = open_retention_db(&scope.db_path)?; - delete_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, agent_pubkey)?; - retain_event( - &conn, - &RetainedEvent { - kind: KIND_DELETE, - pubkey: owner_pubkey, - // Key by the target coordinate so cross-kind d-tag tombstones - // occupy distinct rows (F2c). - d_tag: tombstone_retention_d_tag(KIND_MANAGED_AGENT, agent_pubkey), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, - ) - })(); - if let Err(e) = result { - eprintln!("buzz-desktop: agent-tombstone: {e}"); - } + crate::managed_agents::agent_events::tombstone_managed_agent_pending(app, state, agent_pubkey); } /// Build and sign the NIP-IA `kind:9035` archive request enqueued when an @@ -543,6 +506,11 @@ pub async fn list_managed_agents(app: AppHandle) -> Result(); @@ -96,10 +98,17 @@ fn reconcile_inbound_persona_event_blocking( return reconcile_inbound_tombstone(&event, &arrival_relay_url, &app, &state); } - if !matches!(kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) { + if !matches!( + kind, + KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT | KIND_PRIVATE_MANAGED_AGENT + ) { return Ok(()); } + if kind == KIND_PRIVATE_MANAGED_AGENT { + return reconcile_inbound_private_managed_agent(&event, &arrival_relay_url, &app, &state); + } + // The d-tag identifies the record within its kind. Persona derives it from // the parsed record (`persona_d_tag`); team/agent carry it as the event's // d-tag directly. The persona is parsed once here and reused in the apply @@ -182,6 +191,80 @@ fn reconcile_inbound_persona_event_blocking( Ok(()) } +fn apply_inbound_private_managed_agent_event( + event: &nostr::Event, + owner_keys: &nostr::Keys, + conn: &rusqlite::Connection, + overlay: &mut crate::managed_agents::private_config_overlay::PrivateConfigOverlay, +) -> Result { + use crate::managed_agents::{ + private_config_overlay::PrivateConfigPatch, + retention::{retain_inbound_event, InboundOutcome, RetainedEvent}, + }; + use buzz_core_pkg::{kind::KIND_PRIVATE_MANAGED_AGENT, private_managed_agent}; + use nostr::JsonUtil; + + // The codec verifies signature/owner, decrypts, validates the nsec binding, + // and rejects malformed portable config before any local state changes. + let (_, payload) = private_managed_agent::validate_and_decrypt(event, owner_keys) + .map_err(|error| format!("invalid private managed-agent event: {error}"))?; + let d_tag = payload.agent_pubkey.clone(); + // Constructing the Desktop patch validates backend-specific fields without + // mutating the live overlay or retained head. + let patch = PrivateConfigPatch::from_payload(payload)?; + let outcome = retain_inbound_event( + conn, + &RetainedEvent { + kind: KIND_PRIVATE_MANAGED_AGENT, + pubkey: event.pubkey.to_hex(), + d_tag, + content: event.content.clone(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: false, + }, + )?; + if outcome == InboundOutcome::Applied { + overlay.insert_patch(patch); + } + Ok(outcome) +} + +fn reconcile_inbound_private_managed_agent( + event: &nostr::Event, + arrival_relay_url: &str, + app: &AppHandle, + state: &AppState, +) -> Result<(), String> { + use crate::managed_agents::retention::{open_retention_db, InboundOutcome}; + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let Some(scope) = + crate::managed_agents::retention::arrival_retention_scope(app, state, arrival_relay_url)? + else { + return Ok(()); + }; + + let mut overlay = state + .private_managed_agent_overlay + .lock() + .map_err(|error| error.to_string())?; + let conn = open_retention_db(&scope.db_path)?; + let outcome = + apply_inbound_private_managed_agent_event(event, &scope.owner_keys, &conn, &mut overlay)?; + if outcome == InboundOutcome::Skipped { + return Ok(()); + } + + drop(overlay); + try_regenerate_nest(app); + let _ = app.emit("agents-data-changed", ()); + Ok(()) +} + /// Parse an inbound wire event and enforce the signature gate. Everything /// downstream trusts `event.pubkey` (ownership routing, tombstone scoping, /// behavioral-quad application), so a forged pubkey must die here — the @@ -244,13 +327,18 @@ fn reconcile_inbound_tombstone( }, save_managed_agents, save_teams, }; - use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; + use buzz_core_pkg::kind::{ + KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_PRIVATE_MANAGED_AGENT, KIND_TEAM, + }; use nostr::JsonUtil; let Some((target_kind, target_d_tag)) = parse_deletion_coordinate(event) else { return Ok(()); // no routable coordinate — nothing to delete }; - if !matches!(target_kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) { + if !matches!( + target_kind, + KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT | KIND_PRIVATE_MANAGED_AGENT + ) { return Ok(()); // deletion for a kind we don't track locally } @@ -299,7 +387,12 @@ fn reconcile_inbound_tombstone( teams.retain(|record| record.id != target_d_tag); save_teams(app, &teams)?; } - KIND_MANAGED_AGENT => { + KIND_MANAGED_AGENT | KIND_PRIVATE_MANAGED_AGENT => { + state + .private_managed_agent_overlay + .lock() + .map_err(|error| error.to_string())? + .remove(&target_d_tag); let mut agents = load_managed_agents(app)?; agents.retain(|record| record.pubkey != target_d_tag); save_managed_agents(app, &agents)?; diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 1005a83432..4e8c4ac2c7 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -2,6 +2,7 @@ //! Extracted from the parent module to keep it under the file-size cap. use super::*; +use nostr::{JsonUtil, ToBech32}; use std::collections::BTreeMap; const UUID: &str = "11111111-2222-3333-4444-555555555555"; @@ -155,6 +156,114 @@ fn no_local_match_inserts_inbound_reusing_d_tag_as_id() { const AGENT_PUBKEY: &str = "agentpubkeyhex0000000000000000000000000000000000000000000000000000"; +fn private_agent_payload( + owner_keys: &nostr::Keys, + agent_keys: &nostr::Keys, + name: &str, + parallelism: u32, +) -> buzz_core_pkg::private_managed_agent::Payload { + use buzz_core_pkg::private_managed_agent::{ + Payload, PrivateConfig, PrivateIdentity, FORMAT, VERSION, + }; + + Payload { + format: FORMAT.into(), + version: VERSION, + agent_pubkey: agent_keys.public_key().to_hex(), + owner_pubkey: owner_keys.public_key().to_hex(), + generation: 1, + previous_event_id: None, + updated_at: "2026-08-06T00:00:00Z".into(), + identity: PrivateIdentity { + private_key_nsec: agent_keys.secret_key().to_bech32().unwrap(), + auth_tag: None, + }, + config: PrivateConfig { + relay_url: "wss://relay.example".into(), + name: name.into(), + persona_id: None, + runtime: Some("goose".into()), + model: None, + provider: None, + system_prompt: Some("relay prompt".into()), + parallelism: Some(parallelism), + respond_to: None, + respond_to_allowlist: vec![], + agent_command_override: None, + agent_args: vec![], + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + env_vars: BTreeMap::new(), + backend: serde_json::json!({"type":"local"}), + backend_agent_id: None, + team_id: None, + persona_name_in_team: None, + relay_mesh: None, + extra: serde_json::Map::new(), + }, + extensions: BTreeMap::new(), + extra: serde_json::Map::new(), + } +} + +#[test] +fn private_agent_inbound_rejects_before_retain_and_stale_event_preserves_overlay() { + use crate::managed_agents::{ + private_config_overlay::PrivateConfigOverlay, + retention::{get_retained_event, open_retention_db, InboundOutcome}, + }; + use buzz_core_pkg::{kind::KIND_PRIVATE_MANAGED_AGENT, private_managed_agent}; + use tempfile::TempDir; + + let dir = TempDir::new().unwrap(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + let owner_keys = nostr::Keys::generate(); + let agent_keys = nostr::Keys::generate(); + let pubkey = agent_keys.public_key().to_hex(); + let mut overlay = PrivateConfigOverlay::default(); + + let valid = private_agent_payload(&owner_keys, &agent_keys, "new", 4); + let newer_event = private_managed_agent::build_event(&owner_keys, &valid, 20).unwrap(); + assert_eq!( + apply_inbound_private_managed_agent_event(&newer_event, &owner_keys, &conn, &mut overlay,) + .unwrap(), + InboundOutcome::Applied + ); + assert_eq!(overlay.resolved_records(&[])[0].name, "new"); + + let mut malformed = private_agent_payload(&owner_keys, &agent_keys, "malformed", 4); + malformed.generation = 2; + malformed.previous_event_id = Some(newer_event.id.to_hex()); + malformed.config.backend = serde_json::json!({"type":"provider"}); + let malformed_event = private_managed_agent::build_event(&owner_keys, &malformed, 30).unwrap(); + assert!(apply_inbound_private_managed_agent_event( + &malformed_event, + &owner_keys, + &conn, + &mut overlay, + ) + .is_err()); + assert_eq!(overlay.resolved_records(&[])[0].name, "new"); + let retained = get_retained_event( + &conn, + KIND_PRIVATE_MANAGED_AGENT, + &owner_keys.public_key().to_hex(), + &pubkey, + ) + .unwrap() + .unwrap(); + assert_eq!(retained.raw_event, newer_event.as_json()); + + let stale = private_agent_payload(&owner_keys, &agent_keys, "stale", 2); + let stale_event = private_managed_agent::build_event(&owner_keys, &stale, 10).unwrap(); + assert_eq!( + apply_inbound_private_managed_agent_event(&stale_event, &owner_keys, &conn, &mut overlay,) + .unwrap(), + InboundOutcome::Skipped + ); + assert_eq!(overlay.resolved_records(&[])[0].name, "new"); +} + /// A local managed agent carrying every device-local secret that an inbound /// event must NEVER be able to overwrite. fn local_agent() -> ManagedAgentRecord { diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index aa88bfe39a..15516f4ca0 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -164,6 +164,18 @@ pub async fn apply_workspace( }; // ── Apply all state changes (nothing below can fail) ────────────────── + // Serialize the scope transition with inbound private-config handling. + // Inbound holds this lock from scope resolution through overlay insert, + // so a patch decrypted for the old workspace cannot land after this clear. + let _managed_agents_store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + state + .private_managed_agent_overlay + .lock() + .map_err(|e| e.to_string())? + .clear(); { let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?; *override_guard = Some(relay_url); @@ -176,6 +188,7 @@ pub async fn apply_workspace( let mut keys_guard = state.keys.lock().map_err(|e| e.to_string())?; *keys_guard = keys; } + drop(_managed_agents_store_guard); // Keep the backend-side reconcile guard aligned with the frontend // experiment before launch-time restore can spawn any agents. Missing diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 4a7b80079d..9a7d898944 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -145,8 +145,91 @@ pub fn managed_agent_content_from_event( /// event-id deletion path, leaving the parameterized-replaceable coordinate /// live. The coordinate delete removes the agent for every client and across /// reboots. +pub(crate) fn tombstone_managed_agent_pending( + app: &tauri::AppHandle, + state: &crate::app_state::AppState, + agent_pubkey: &str, +) { + use crate::managed_agents::retention::{ + delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, + RetainedEvent, + }; + use buzz_core_pkg::kind::{KIND_MANAGED_AGENT, KIND_PRIVATE_MANAGED_AGENT}; + use nostr::JsonUtil; + + const KIND_DELETE: u32 = 5; + let result = (|| -> Result<(), String> { + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let public_delete = build_agent_delete(agent_pubkey, &owner_pubkey)? + .sign_with_keys(&scope.owner_keys) + .map_err(|e| format!("failed to sign managed-agent tombstone: {e}"))?; + let private_delete = build_private_agent_delete(agent_pubkey, &owner_pubkey)? + .sign_with_keys(&scope.owner_keys) + .map_err(|e| format!("failed to sign private managed-agent tombstone: {e}"))?; + let conn = open_retention_db(&scope.db_path)?; + let transaction = conn + .unchecked_transaction() + .map_err(|error| format!("failed to begin agent deletion transaction: {error}"))?; + delete_retained_event( + &transaction, + KIND_MANAGED_AGENT, + &owner_pubkey, + agent_pubkey, + )?; + delete_retained_event( + &transaction, + KIND_PRIVATE_MANAGED_AGENT, + &owner_pubkey, + agent_pubkey, + )?; + for (target_kind, event) in [ + (KIND_MANAGED_AGENT, public_delete), + (KIND_PRIVATE_MANAGED_AGENT, private_delete), + ] { + retain_event( + &transaction, + &RetainedEvent { + kind: KIND_DELETE, + pubkey: owner_pubkey.clone(), + d_tag: tombstone_retention_d_tag(target_kind, agent_pubkey), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + )?; + } + transaction + .commit() + .map_err(|error| format!("failed to commit agent deletion transaction: {error}")) + })(); + if let Err(e) = result { + eprintln!("buzz-desktop: agent-tombstone: {e}"); + } +} + pub fn build_agent_delete(d_tag: &str, owner_pubkey_hex: &str) -> Result { - let coord = format!("{KIND_MANAGED_AGENT}:{owner_pubkey_hex}:{d_tag}"); + build_agent_delete_for_kind(KIND_MANAGED_AGENT, d_tag, owner_pubkey_hex) +} + +pub fn build_private_agent_delete( + d_tag: &str, + owner_pubkey_hex: &str, +) -> Result { + build_agent_delete_for_kind( + buzz_core_pkg::kind::KIND_PRIVATE_MANAGED_AGENT, + d_tag, + owner_pubkey_hex, + ) +} + +fn build_agent_delete_for_kind( + target_kind: u32, + d_tag: &str, + owner_pubkey_hex: &str, +) -> Result { + let coord = format!("{target_kind}:{owner_pubkey_hex}:{d_tag}"); let tag = Tag::parse(["a", coord.as_str()]).map_err(|e| format!("invalid a-tag: {e}"))?; Ok(EventBuilder::new(Kind::Custom(5), "").tags(vec![tag])) } diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index fe90ce430f..6322547306 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -22,6 +22,7 @@ pub(crate) mod parallelism; mod persona_avatars; pub(crate) mod persona_events; mod personas; +pub(crate) mod private_config_overlay; #[cfg(windows)] mod process_lifecycle; pub(crate) mod readiness; diff --git a/desktop/src-tauri/src/managed_agents/private_config_overlay.rs b/desktop/src-tauri/src/managed_agents/private_config_overlay.rs new file mode 100644 index 0000000000..2ff9f15e88 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/private_config_overlay.rs @@ -0,0 +1,378 @@ +use std::collections::{BTreeMap, HashMap}; + +use buzz_core_pkg::private_managed_agent::Payload; + +use super::{ + build_managed_agent_summary, load_personas, start_managed_agent_process, + validate_respond_to_allowlist, validate_user_env_keys, BackendKind, ManagedAgentRecord, + ManagedAgentSummary, RelayMeshConfig, RespondTo, DEFAULT_ACP_COMMAND, + DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, +}; + +#[derive(Clone)] +pub(crate) struct PrivateConfigPatch { + pubkey: String, + name: String, + private_key_nsec: String, + auth_tag: Option, + relay_url: String, + persona_id: Option, + runtime: Option, + model: Option, + provider: Option, + system_prompt: Option, + parallelism: u32, + respond_to: RespondTo, + respond_to_allowlist: Vec, + agent_command_override: Option, + agent_args: Vec, + idle_timeout_seconds: Option, + max_turn_duration_seconds: Option, + env_vars: BTreeMap, + backend: BackendKind, + backend_agent_id: Option, + team_id: Option, + persona_name_in_team: Option, + relay_mesh: Option, + updated_at: String, +} + +impl PrivateConfigPatch { + /// Convert a payload that has already passed the codec's + /// `validate_and_decrypt` gate. Callers must not feed unvalidated wire data. + pub(crate) fn from_payload(payload: Payload) -> Result { + let config = payload.config; + let backend = serde_json::from_value(config.backend) + .map_err(|error| format!("invalid private managed-agent backend: {error}"))?; + let relay_mesh = config + .relay_mesh + .map(serde_json::from_value) + .transpose() + .map_err(|error| format!("invalid private managed-agent relay_mesh: {error}"))?; + let respond_to = config + .respond_to + .as_deref() + .map(RespondTo::parse_wire) + .transpose()? + .unwrap_or_default(); + let respond_to_allowlist = validate_respond_to_allowlist(&config.respond_to_allowlist)?; + if respond_to == RespondTo::Allowlist && respond_to_allowlist.is_empty() { + return Err("private managed-agent allowlist mode requires at least one pubkey".into()); + } + validate_user_env_keys(&config.env_vars)?; + let parallelism = config.parallelism.unwrap_or(DEFAULT_AGENT_PARALLELISM); + if !(1..=32).contains(¶llelism) { + return Err("private managed-agent parallelism must be between 1 and 32".into()); + } + + Ok(Self { + pubkey: payload.agent_pubkey, + name: config.name, + private_key_nsec: payload.identity.private_key_nsec, + auth_tag: payload.identity.auth_tag, + relay_url: config.relay_url, + persona_id: config.persona_id, + runtime: config.runtime, + model: config.model, + provider: config.provider, + system_prompt: config.system_prompt, + parallelism, + respond_to, + respond_to_allowlist, + agent_command_override: config.agent_command_override, + agent_args: config.agent_args, + idle_timeout_seconds: config.idle_timeout_seconds, + max_turn_duration_seconds: config.max_turn_duration_seconds, + env_vars: config.env_vars, + backend, + backend_agent_id: config.backend_agent_id, + team_id: config.team_id, + persona_name_in_team: config.persona_name_in_team, + relay_mesh, + updated_at: payload.updated_at, + }) + } + + fn apply(&self, record: &mut ManagedAgentRecord) { + record.pubkey.clone_from(&self.pubkey); + record.name.clone_from(&self.name); + record.private_key_nsec.clone_from(&self.private_key_nsec); + record.auth_tag.clone_from(&self.auth_tag); + record.relay_url.clone_from(&self.relay_url); + record.persona_id.clone_from(&self.persona_id); + record.runtime.clone_from(&self.runtime); + record.model.clone_from(&self.model); + record.provider.clone_from(&self.provider); + record.system_prompt.clone_from(&self.system_prompt); + record.parallelism = self.parallelism; + record.respond_to = self.respond_to; + record + .respond_to_allowlist + .clone_from(&self.respond_to_allowlist); + record + .agent_command_override + .clone_from(&self.agent_command_override); + record.agent_args.clone_from(&self.agent_args); + record.idle_timeout_seconds = self.idle_timeout_seconds; + record.max_turn_duration_seconds = self.max_turn_duration_seconds; + record.env_vars.clone_from(&self.env_vars); + record.backend.clone_from(&self.backend); + record.backend_agent_id.clone_from(&self.backend_agent_id); + record.team_id.clone_from(&self.team_id); + record + .persona_name_in_team + .clone_from(&self.persona_name_in_team); + record.relay_mesh.clone_from(&self.relay_mesh); + record.updated_at.clone_from(&self.updated_at); + } + + fn fresh_record(&self) -> ManagedAgentRecord { + let mut record = ManagedAgentRecord { + pubkey: String::new(), + name: String::new(), + persona_id: None, + team_id: None, + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: None, + acp_command: DEFAULT_ACP_COMMAND.into(), + agent_command: String::new(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: DEFAULT_AGENT_PARALLELISM, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: self.updated_at.clone(), + updated_at: self.updated_at.clone(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::default(), + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: vec![], + definition_parallelism: None, + relay_mesh: None, + }; + self.apply(&mut record); + record + } +} + +#[derive(Default)] +pub(crate) struct PrivateConfigOverlay(HashMap); + +impl PrivateConfigOverlay { + #[cfg(test)] + pub(crate) fn insert(&mut self, payload: Payload) -> Result<(), String> { + let patch = PrivateConfigPatch::from_payload(payload)?; + self.0.insert(patch.pubkey.clone(), patch); + Ok(()) + } + + pub(crate) fn insert_patch(&mut self, patch: PrivateConfigPatch) { + self.0.insert(patch.pubkey.clone(), patch); + } + + pub(crate) fn clear(&mut self) { + self.0.clear(); + } + + pub(crate) fn remove(&mut self, pubkey: &str) { + self.0.remove(pubkey); + } + + pub(crate) fn contains(&self, pubkey: &str) -> bool { + self.0.contains_key(pubkey) + } + + pub(crate) fn resolved_record( + &self, + pubkey: &str, + local: &[ManagedAgentRecord], + ) -> Option { + let patch = self.0.get(pubkey)?; + let mut record = local + .iter() + .find(|record| record.pubkey == pubkey) + .cloned() + .unwrap_or_else(|| patch.fresh_record()); + patch.apply(&mut record); + Some(record) + } + + pub(crate) fn resolved_records(&self, local: &[ManagedAgentRecord]) -> Vec { + let mut resolved = local.to_vec(); + for record in &mut resolved { + if let Some(patch) = self.0.get(&record.pubkey) { + patch.apply(record); + } + } + let mut relay_only: Vec<_> = self + .0 + .values() + .filter(|patch| !local.iter().any(|record| record.pubkey == patch.pubkey)) + .map(PrivateConfigPatch::fresh_record) + .collect(); + relay_only.sort_by(|left, right| left.pubkey.cmp(&right.pubkey)); + resolved.extend(relay_only); + resolved + } +} + +pub(crate) fn start_relay_only_agent( + app: &tauri::AppHandle, + state: &crate::app_state::AppState, + pubkey: &str, + owner_hex: &str, + local_records: &[ManagedAgentRecord], +) -> Result { + let mut record = state + .private_managed_agent_overlay + .lock() + .map_err(|error| error.to_string())? + .resolved_record(pubkey, local_records) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + if local_records.iter().all(|local| local.pubkey != pubkey) { + record.persona_id = None; + } + if record.backend != BackendKind::Local { + return Err("relay-only provider agents cannot be started on this device".into()); + } + let personas = load_personas(app).unwrap_or_default(); + super::try_record_agent_command(&record, &personas) + .map_err(|error| super::user_facing_harness_error(&error))?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + start_managed_agent_process(app, &mut record, &mut runtimes, Some(owner_hex))?; + build_managed_agent_summary( + app, + &record, + &runtimes, + &personas, + &super::load_global_agent_config(app).unwrap_or_default(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core_pkg::private_managed_agent::{ + Payload, PrivateConfig, PrivateIdentity, FORMAT, VERSION, + }; + use serde_json::{json, Map}; + + fn payload(pubkey: &str, name: &str) -> Payload { + Payload { + format: FORMAT.into(), + version: VERSION, + agent_pubkey: pubkey.into(), + owner_pubkey: "11".repeat(32), + generation: 1, + previous_event_id: None, + updated_at: "2026-08-06T00:00:00Z".into(), + identity: PrivateIdentity { + private_key_nsec: "nsec-test".into(), + auth_tag: None, + }, + config: PrivateConfig { + relay_url: "wss://relay.example".into(), + name: name.into(), + persona_id: None, + runtime: Some("goose".into()), + model: Some("m".into()), + provider: None, + system_prompt: Some("relay prompt".into()), + parallelism: None, + respond_to: None, + respond_to_allowlist: vec![], + agent_command_override: None, + agent_args: vec![], + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + env_vars: BTreeMap::new(), + backend: json!({"type":"local"}), + backend_agent_id: None, + team_id: None, + persona_name_in_team: None, + relay_mesh: None, + extra: Map::new(), + }, + extensions: BTreeMap::new(), + extra: Map::new(), + } + } + + #[test] + fn resolves_overlay_and_relay_only_without_mutating_local() { + let mut overlay = PrivateConfigOverlay::default(); + overlay.insert(payload("aa", "relay local")).unwrap(); + overlay.insert(payload("bb", "relay only")).unwrap(); + let mut local = overlay.0["aa"].fresh_record(); + local.name = "disk".into(); + local.system_prompt = Some("disk prompt".into()); + let original = local.clone(); + + let resolved = overlay.resolved_records(std::slice::from_ref(&local)); + assert_eq!(resolved.len(), 2); + assert_eq!(resolved[0].name, "relay local"); + assert_eq!(resolved[0].system_prompt.as_deref(), Some("relay prompt")); + assert_eq!(resolved[1].pubkey, "bb"); + assert!(!resolved[1].start_on_app_launch); + assert_eq!(local, original); + } + + #[test] + fn rejected_patch_preserves_cached_value_and_clear_drops_scope() { + let mut overlay = PrivateConfigOverlay::default(); + overlay.insert(payload("aa", "valid")).unwrap(); + let mut invalid = payload("aa", "invalid"); + invalid.config.backend = json!({"type":"provider"}); + assert!(overlay.insert(invalid).is_err()); + assert_eq!(overlay.resolved_records(&[])[0].name, "valid"); + overlay.clear(); + assert!(overlay.resolved_records(&[]).is_empty()); + } + + #[test] + fn unresolved_harness_has_named_refusal() { + let mut patch = PrivateConfigPatch::from_payload(payload("aa", "agent")).unwrap(); + patch.runtime = Some("missing-custom-harness".into()); + let record = patch.fresh_record(); + let error = crate::managed_agents::try_record_agent_command(&record, &[]).unwrap_err(); + assert_eq!( + crate::managed_agents::dangling_harness_id(&error), + Some("missing-custom-harness") + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/reconcile.rs b/desktop/src-tauri/src/managed_agents/reconcile.rs index 90f05c5750..7d110c056d 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile.rs @@ -26,8 +26,12 @@ use super::{ retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, ManagedAgentRecord, }; -use buzz_core_pkg::kind::KIND_MANAGED_AGENT; +use buzz_core_pkg::{ + kind::{KIND_MANAGED_AGENT, KIND_PRIVATE_MANAGED_AGENT}, + private_managed_agent::{self, Payload, PrivateConfig, PrivateIdentity}, +}; use nostr::JsonUtil; +use std::collections::BTreeMap; /// Reconcile `managed-agents.json` into kind:30177 events in the retention /// store. Boot-time entry point, called from `event_sync::run_event_sync` @@ -126,6 +130,22 @@ pub(crate) fn retain_agent_record( conn: &rusqlite::Connection, keys: &nostr::Keys, record: &ManagedAgentRecord, +) -> Result { + let transaction = conn + .unchecked_transaction() + .map_err(|error| format!("failed to begin agent retention transaction: {error}"))?; + let public_changed = retain_public_agent_record(&transaction, keys, record)?; + let private_changed = retain_private_agent_record(&transaction, keys, record)?; + transaction + .commit() + .map_err(|error| format!("failed to commit agent retention transaction: {error}"))?; + Ok(public_changed || private_changed) +} + +fn retain_public_agent_record( + conn: &rusqlite::Connection, + keys: &nostr::Keys, + record: &ManagedAgentRecord, ) -> Result { let owner_pubkey = keys.public_key().to_hex(); let existing = get_retained_event(conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; @@ -165,5 +185,154 @@ pub(crate) fn retain_agent_record( Ok(true) } +fn retain_private_agent_record( + conn: &rusqlite::Connection, + keys: &nostr::Keys, + record: &ManagedAgentRecord, +) -> Result { + if record.private_key_nsec.is_empty() { + return Ok(false); + } + + let owner_pubkey = keys.public_key().to_hex(); + let existing = get_retained_event( + conn, + KIND_PRIVATE_MANAGED_AGENT, + &owner_pubkey, + &record.pubkey, + )?; + let previous_event = existing + .as_ref() + .and_then(|row| nostr::Event::from_json(&row.raw_event).ok()); + let generation = previous_event + .as_ref() + .and_then(event_generation) + .unwrap_or(0) + .checked_add(1) + .ok_or_else(|| format!("private config generation overflow for '{}'", record.name))?; + let previous_event_id = previous_event.as_ref().map(|event| event.id.to_hex()); + let created_at = monotonic_created_at(existing.as_ref().map(|row| row.created_at)); + let mut payload = + private_payload_from_record(record, &owner_pubkey, generation, previous_event_id)?; + + // Preserve fields authored by a newer client. This writer owns the known + // typed fields only; flatten/extension data must survive an older Desktop + // editing one known value. + let existing_payload = previous_event.as_ref().and_then(|existing_event| { + private_managed_agent::validate_and_decrypt(existing_event, keys) + .ok() + .map(|(_, payload)| payload) + }); + if let Some(existing_payload) = &existing_payload { + payload.extensions.clone_from(&existing_payload.extensions); + payload.extra.clone_from(&existing_payload.extra); + payload + .config + .extra + .clone_from(&existing_payload.config.extra); + } + + // NIP-44 encryption is randomized, so compare the validated plaintext + // payload rather than ciphertext. Metadata derived from the retained head + // changes only after a meaningful config mutation. + if existing_payload + .as_ref() + .is_some_and(|existing| private_payload_body_eq(existing, &payload)) + { + return Ok(false); + } + + let event = private_managed_agent::build_event(keys, &payload, created_at.as_secs()) + .map_err(|e| format!("failed to build private config for '{}': {e}", record.name))?; + + retain_event( + conn, + &RetainedEvent { + kind: KIND_PRIVATE_MANAGED_AGENT, + pubkey: owner_pubkey, + d_tag: record.pubkey.clone(), + content: event.content.clone(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + .map_err(|e| format!("failed to retain private config for '{}': {e}", record.name))?; + Ok(true) +} + +fn event_generation(event: &nostr::Event) -> Option { + event.tags.iter().find_map(|tag| { + let values = tag.as_slice(); + (values.first().map(String::as_str) == Some("g")) + .then(|| values.get(1)?.parse().ok()) + .flatten() + }) +} + +fn private_payload_from_record( + record: &ManagedAgentRecord, + owner_pubkey: &str, + generation: u64, + previous_event_id: Option, +) -> Result { + let backend = serde_json::to_value(&record.backend) + .map_err(|e| format!("failed to serialize backend for '{}': {e}", record.name))?; + let relay_mesh = record + .relay_mesh + .as_ref() + .map(serde_json::to_value) + .transpose() + .map_err(|e| format!("failed to serialize relay mesh for '{}': {e}", record.name))?; + + Ok(Payload { + format: private_managed_agent::FORMAT.to_string(), + version: private_managed_agent::VERSION, + agent_pubkey: record.pubkey.clone(), + owner_pubkey: owner_pubkey.to_string(), + generation, + previous_event_id, + updated_at: record.updated_at.clone(), + identity: PrivateIdentity { + private_key_nsec: record.private_key_nsec.clone(), + auth_tag: record.auth_tag.clone(), + }, + config: PrivateConfig { + relay_url: record.relay_url.clone(), + name: record.name.clone(), + persona_id: record.persona_id.clone(), + runtime: record.runtime.clone(), + model: record.model.clone(), + provider: record.provider.clone(), + system_prompt: record.system_prompt.clone(), + parallelism: Some(record.parallelism), + respond_to: Some(record.respond_to.as_str().to_string()), + respond_to_allowlist: record.respond_to_allowlist.clone(), + agent_command_override: record.agent_command_override.clone(), + agent_args: record.agent_args.clone(), + idle_timeout_seconds: record.idle_timeout_seconds, + max_turn_duration_seconds: record.max_turn_duration_seconds, + env_vars: record.env_vars.clone(), + backend, + backend_agent_id: record.backend_agent_id.clone(), + team_id: record.team_id.clone(), + persona_name_in_team: record.persona_name_in_team.clone(), + relay_mesh, + extra: serde_json::Map::new(), + }, + extensions: BTreeMap::new(), + extra: serde_json::Map::new(), + }) +} + +fn private_payload_body_eq(left: &Payload, right: &Payload) -> bool { + left.agent_pubkey == right.agent_pubkey + && left.owner_pubkey == right.owner_pubkey + && left.identity == right.identity + && left.config == right.config + && left.extensions == right.extensions + && left.extra == right.extra +} + #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs index c9269dbf00..69a03af3e9 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::managed_agents::retention::{get_pending_sync, get_retained_event, mark_synced}; +use nostr::ToBech32; use std::collections::BTreeMap; use tempfile::TempDir; @@ -34,6 +35,159 @@ fn write_store(dir: &TempDir, records: &[ManagedAgentRecord]) { .unwrap(); } +#[test] +fn private_config_conversion_encrypts_secrets_and_is_idempotent() { + let dir = TempDir::new().unwrap(); + let owner_keys = nostr::Keys::generate(); + let agent_keys = nostr::Keys::generate(); + let pubkey = agent_keys.public_key().to_hex(); + let mut record = sample_record(&pubkey, "private-agent"); + record.private_key_nsec = agent_keys.secret_key().to_bech32().unwrap(); + record.env_vars = BTreeMap::from([("API_TOKEN".to_string(), "very-secret".to_string())]); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + + assert!(retain_agent_record(&conn, &owner_keys, &record).unwrap()); + let row = get_retained_event( + &conn, + KIND_PRIVATE_MANAGED_AGENT, + &owner_keys.public_key().to_hex(), + &pubkey, + ) + .unwrap() + .unwrap(); + assert!(!row.raw_event.contains("very-secret")); + assert!(!row.raw_event.contains("nsec1")); + + let event = nostr::Event::from_json(&row.raw_event).unwrap(); + let (_, payload) = private_managed_agent::validate_and_decrypt(&event, &owner_keys).unwrap(); + assert_eq!(payload.config.name, "private-agent"); + assert_eq!(payload.config.env_vars["API_TOKEN"], "very-secret"); + assert_eq!(payload.generation, 1); + assert_eq!(payload.previous_event_id, None); + + mark_synced( + &conn, + row.kind, + &row.pubkey, + &row.d_tag, + row.created_at, + &row.content, + ) + .unwrap(); + assert!(!retain_agent_record(&conn, &owner_keys, &record).unwrap()); + assert!(get_pending_sync(&conn) + .unwrap() + .iter() + .all(|pending| pending.kind != KIND_PRIVATE_MANAGED_AGENT)); +} + +#[test] +fn private_config_preserves_unknown_fields_without_generation_churn() { + let dir = TempDir::new().unwrap(); + let owner_keys = nostr::Keys::generate(); + let agent_keys = nostr::Keys::generate(); + let pubkey = agent_keys.public_key().to_hex(); + let mut record = sample_record(&pubkey, "private-agent"); + record.private_key_nsec = agent_keys.secret_key().to_bech32().unwrap(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + + let mut newer_payload = + private_payload_from_record(&record, &owner_keys.public_key().to_hex(), 1, None).unwrap(); + newer_payload.extensions.insert( + "future.example:feature".into(), + serde_json::json!({"enabled": true}), + ); + newer_payload + .extra + .insert("future_top_level".into(), serde_json::json!([1, 2, 3])); + newer_payload + .config + .extra + .insert("future_config".into(), serde_json::json!({"mode": "new"})); + let first_event = private_managed_agent::build_event(&owner_keys, &newer_payload, 1).unwrap(); + retain_event( + &conn, + &RetainedEvent { + kind: KIND_PRIVATE_MANAGED_AGENT, + pubkey: owner_keys.public_key().to_hex(), + d_tag: pubkey.clone(), + content: first_event.content.clone(), + created_at: first_event.created_at.as_secs() as i64, + raw_event: first_event.as_json(), + pending_sync: false, + }, + ) + .unwrap(); + + record.system_prompt = Some("edited by an older client".into()); + assert!(retain_agent_record(&conn, &owner_keys, &record).unwrap()); + let row = get_retained_event( + &conn, + KIND_PRIVATE_MANAGED_AGENT, + &owner_keys.public_key().to_hex(), + &pubkey, + ) + .unwrap() + .unwrap(); + let rebuilt_event = nostr::Event::from_json(&row.raw_event).unwrap(); + let (_, rebuilt) = + private_managed_agent::validate_and_decrypt(&rebuilt_event, &owner_keys).unwrap(); + assert_eq!(rebuilt.generation, 2); + assert_eq!(rebuilt.previous_event_id, Some(first_event.id.to_hex())); + assert_eq!(rebuilt.extensions, newer_payload.extensions); + assert_eq!(rebuilt.extra, newer_payload.extra); + assert_eq!(rebuilt.config.extra, newer_payload.config.extra); + + assert!(!retain_agent_record(&conn, &owner_keys, &record).unwrap()); + let unchanged = get_retained_event( + &conn, + KIND_PRIVATE_MANAGED_AGENT, + &owner_keys.public_key().to_hex(), + &pubkey, + ) + .unwrap() + .unwrap(); + assert_eq!(unchanged.raw_event, row.raw_event); +} + +#[test] +fn private_config_change_advances_generation_and_links_previous_event() { + let dir = TempDir::new().unwrap(); + let owner_keys = nostr::Keys::generate(); + let agent_keys = nostr::Keys::generate(); + let pubkey = agent_keys.public_key().to_hex(); + let mut record = sample_record(&pubkey, "private-agent"); + record.private_key_nsec = agent_keys.secret_key().to_bech32().unwrap(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + + retain_agent_record(&conn, &owner_keys, &record).unwrap(); + let first = get_retained_event( + &conn, + KIND_PRIVATE_MANAGED_AGENT, + &owner_keys.public_key().to_hex(), + &pubkey, + ) + .unwrap() + .unwrap(); + let first_event = nostr::Event::from_json(&first.raw_event).unwrap(); + + record.env_vars.insert("TOKEN".into(), "rotated".into()); + retain_agent_record(&conn, &owner_keys, &record).unwrap(); + let second = get_retained_event( + &conn, + KIND_PRIVATE_MANAGED_AGENT, + &owner_keys.public_key().to_hex(), + &pubkey, + ) + .unwrap() + .unwrap(); + let second_event = nostr::Event::from_json(&second.raw_event).unwrap(); + let (_, payload) = + private_managed_agent::validate_and_decrypt(&second_event, &owner_keys).unwrap(); + assert_eq!(payload.generation, 2); + assert_eq!(payload.previous_event_id, Some(first_event.id.to_hex())); +} + #[test] fn missing_store_is_noop() { let dir = TempDir::new().unwrap(); diff --git a/desktop/src/features/agents/lib/usePersonaSync.test.mjs b/desktop/src/features/agents/lib/usePersonaSync.test.mjs index 0dc12ddfd1..f3d9b93acf 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.test.mjs +++ b/desktop/src/features/agents/lib/usePersonaSync.test.mjs @@ -6,6 +6,7 @@ import { KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, + KIND_PRIVATE_MANAGED_AGENT, KIND_TEAM, } from "@/shared/constants/kinds"; import { startPersonaSync } from "./usePersonaSync.ts"; @@ -14,6 +15,7 @@ const EXPECTED_KINDS = [ KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_PRIVATE_MANAGED_AGENT, KIND_DELETION, ]; diff --git a/desktop/src/features/agents/lib/usePersonaSync.ts b/desktop/src/features/agents/lib/usePersonaSync.ts index f18194c5c6..cc34adb45b 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.ts +++ b/desktop/src/features/agents/lib/usePersonaSync.ts @@ -7,6 +7,7 @@ import { KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, + KIND_PRIVATE_MANAGED_AGENT, KIND_TEAM, } from "@/shared/constants/kinds"; @@ -17,6 +18,7 @@ const PERSONA_SYNC_KINDS = [ KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_PRIVATE_MANAGED_AGENT, KIND_DELETION, ]; diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index f995a63596..0c3ea112c0 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -53,6 +53,8 @@ export const KIND_COMMUNITY_THEME = 30078; export const KIND_PERSONA = 30175; export const KIND_TEAM = 30176; export const KIND_MANAGED_AGENT = 30177; +// Owner-authored, owner-readable encrypted runnable configuration. +export const KIND_PRIVATE_MANAGED_AGENT = 30179; export const KIND_USER_STATUS = 30315; export const KIND_AGENT_OBSERVER_FRAME = 24200; export const KIND_AGENT_TURN_METRIC = 44200;