From 6971822eee4b23bea453863045b2226ded2ad2d3 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 20:51:19 -0600 Subject: [PATCH 1/5] 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 | 710 +++++++----------- 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, 1353 insertions(+), 508 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,192 +752,216 @@ mod tests { )); } + // (2) nsec -> pubkey binding: the identity nsec must derive agent_pubkey (d). #[test] - fn duplicate_and_unknown_json_fields_are_rejected() { - let duplicate = br#"{"format":"a","format":"b"}"#; + fn active_identity_must_derive_coordinate() { + let owner = Keys::generate(); + let mut candidate = payload(&owner, &Keys::generate()); + candidate.identity.private_key_nsec = Keys::generate().secret_key().to_bech32().unwrap(); assert!(matches!( - parse_strict_json(duplicate), - Err(Error::InvalidPayload(message)) if message.contains("duplicate key") + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("does not derive") )); + } + #[test] + fn valid_owner_attestation_passes_and_binds_agent() { 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")); + 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()); + + // 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 auth_tag_must_be_unconditional_and_bound_to_owner_and_agent() { + fn auth_tag_from_wrong_attestor_is_rejected() { 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)); + // 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(&self_attested), - Err(Error::InvalidPayload(message)) if message.contains("distinct agent key") + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("auth_tag") )); - - 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()); } #[test] - fn active_identity_must_derive_coordinate() { + fn auth_tag_signature_must_verify() { 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(); + 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("does not derive") + 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 tombstone_requires_successor_shape() { + fn unknown_members_round_trip_verbatim() { 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); + candidate + .extra + .insert("future_top".into(), serde_json::json!({"nested": [1, 2]})); + candidate + .config + .extra + .insert("future_cfg".into(), Value::String("keep-me".into())); - deleted.previous_event_id = None; - assert!(validate_payload(&deleted).is_err()); + 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 outer_tag_grammar_rejects_duplicates_and_noncanonical_generation() { + fn unknown_member_cannot_override_known_field() { let owner = Keys::generate(); let agent = Keys::generate(); - let body = payload(&owner, &agent); - let ciphertext = nip44::encrypt( - owner.secret_key(), - &owner.public_key(), - serde_json::to_string(&body).unwrap(), - Version::V2, - ) - .unwrap(); - let event = 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(["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") - )); + 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 projection_recovery_must_match_binding_and_coordinate() { + fn required_fields_and_duplicate_keys() { 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())); + // 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!( - validate_payload(&candidate), - Err(Error::InvalidPayload(message)) if message.contains("wrong coordinate") + parse_strict_json(duplicate), + Err(Error::InvalidPayload(message)) if message.contains("duplicate key") )); - let mut candidate = payload(&owner, &agent); - candidate - .active - .as_mut() - .unwrap() - .definition - .recovery - .version = 2; + + // Empty required `name` fails semantic validation. + let mut empty_name = payload(&owner, &agent); + empty_name.config.name = String::new(); assert!(matches!( - validate_payload(&candidate), - Err(Error::InvalidPayload(message)) if message.contains("unsupported definition recovery version") + validate_payload(&empty_name), + Err(Error::InvalidPayload(message)) if message.contains("invalid name length") )); + } - let mut candidate = payload(&owner, &agent); - candidate - .active - .as_mut() - .unwrap() - .definition - .recovery - .signed_event - .content - .push('!'); + // 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(&candidate), - Err(Error::InvalidPayload(message)) if message.contains("invalid definition recovery event") + validate_payload(&bad), + Err(Error::InvalidPayload(message)) if message.contains("absent exactly at generation 1") )); + } - 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; + #[test] + fn outer_tag_grammar_rejects_noncanonical_generation_and_stray_tags() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let body = payload(&owner, &agent); + let ciphertext = nip44::encrypt( + owner.secret_key(), + &owner.public_key(), + serde_json::to_string(&body).unwrap(), + Version::V2, + ) + .unwrap(); + // 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_payload(&candidate), - Err(Error::InvalidPayload(message)) if message.contains("does not match binding") + validate_envelope(&event, &owner.public_key()), + Err(Error::InvalidEnvelope(message)) if message.contains("canonical decimal") )); - let mut candidate = payload(&owner, &agent); - let missing_d = EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "definition") + // 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", "1"]).unwrap(), + Tag::parse(["state", "active"]).unwrap(), + ]) .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") + validate_envelope(&stray, &owner.public_key()), + Err(Error::InvalidEnvelope(message)) if message.contains("unexpected 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; From a9b648b4481fd8dfe6da682a706fb43883b7709b Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 21:29:01 -0600 Subject: [PATCH 2/5] fix(agents): preserve managed overlay lifecycle Route disk-backed overlay agents through the established preflight, provider, profile, persistence, and runtime transition paths. Materialize fresh-device local records before start so stop, delete, and shutdown can manage them. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src-tauri/src/commands/agents.rs | 197 ++++++------------ .../src/commands/agents_lifecycle.rs | 110 ++++++++++ .../managed_agents/private_config_overlay.rs | 136 ++++++++---- 3 files changed, 271 insertions(+), 172 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agents_lifecycle.rs diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index d21aa56ad3..88b1b8047e 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -317,99 +317,6 @@ pub(super) async fn start_local_agent_pairs_with_preflight( ) } -pub(super) async fn start_local_agent_with_preflight( - app: &AppHandle, - state: &AppState, - pubkey: &str, - owner_hex: &str, - allow_fresh_create_start: bool, -) -> Result { - let record_snapshot = { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let records = load_managed_agents(app)?; - records - .iter() - .find(|record| record.pubkey == pubkey) - .cloned() - .ok_or_else(|| format!("agent {pubkey} not found"))? - }; - - if record_snapshot.backend != BackendKind::Local { - return Err(format!("agent {pubkey} is not a local agent")); - } - - // Preflight against the same resolution spawn uses — `resolve_effective_config` - // (definition → global fallback). A linked instance's own `provider`/`model`/ - // `relay_mesh` bytes never contribute: this reads the CURRENT definition - // directly, so a definition edit that flips `provider` to/from relay-mesh - // between saves is reflected here without needing a prospective re-snapshot; - // for a global-inherited blank definition, it also folds in the global - // default, which record-byte sniffing could never see. - let personas = load_personas(app).unwrap_or_default(); - let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); - let mesh_model_id = - crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id( - &record_snapshot, - &personas, - &global, - ); - ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?; - - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(app)?; - let mut runtimes = state - .managed_agent_processes - .lock() - .map_err(|e| e.to_string())?; - let record = find_managed_agent_mut(&mut records, pubkey)?; - if record.backend != BackendKind::Local { - return Err(format!("agent {pubkey} is no longer a local agent")); - } - // Re-snapshot the persona onto the record at every spawn so the agent always - // starts with the current persona config (system_prompt, model, provider, - // runtime). This clears the "out of date" drift badge without requiring a - // delete+recreate. See `apply_persona_snapshot` for the precedence and - // env-override self-heal rules. - // Load personas once: used for snapshot application below and summary build - // at the end — avoids a second disk read for the same file in the same call. - let personas = load_personas(app).unwrap_or_default(); - if let Some(persona_id) = record.persona_id.clone() { - match personas.iter().find(|p| p.id == persona_id) { - Some(persona) => { - crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); - record.updated_at = crate::util::now_iso(); - } - None => { - return Err( - crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR.to_string(), - ); - } - } - } - start_managed_agent_process(app, record, &mut runtimes, Some(owner_hex))?; - save_managed_agents(app, &records)?; - if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) { - retain_managed_agent_pending(app, state, saved_record); - } - let record = records - .iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - build_managed_agent_summary( - app, - record, - &runtimes, - &personas, - &crate::managed_agents::load_global_agent_config(app).unwrap_or_default(), - ) -} - /// Deploy an agent to a provider backend. Resolves the binary, calls deploy via /// spawn_blocking, and persists the result (backend_agent_id or last_error). /// @@ -1039,27 +946,11 @@ pub async fn start_managed_agent( // Snapshot the workspace owner pubkey for the legacy auth_tag fallback. // Read outside the records lock to keep lock ordering simple. let owner_hex = workspace_owner_hex(&state)?; - let local_records = { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - load_managed_agents(&app)? - }; - if state - .private_managed_agent_overlay - .lock() - .map_err(|error| error.to_string())? - .contains(&pubkey) - { - return crate::managed_agents::private_config_overlay::start_relay_only_agent( - &app, - &state, - &pubkey, - &owner_hex, - &local_records, - ); - } + // A fresh-device relay record needs a durable lifecycle anchor so stop, + // delete, runtime polling, and shutdown can find it. + crate::managed_agents::private_config_overlay::materialize_relay_only_agent( + &app, &state, &pubkey, + )?; enum StartTarget { Local, Provider { @@ -1091,14 +982,18 @@ pub async fn start_managed_agent( state.clear_agent_session_caches(pubkey); } - let record = find_managed_agent_mut(&mut records, &pubkey)?; + let disk_record = find_managed_agent_mut(&mut records, &pubkey)?; + let record = crate::managed_agents::private_config_overlay::resolved_local_record( + &state, + disk_record, + )?; // Resolve the effective harness for the avatar-fallback derivation in // profile reconcile (the create-time snapshot may be empty or stale for // a persona-inherited harness). let reconcile_personas = load_personas(&app).unwrap_or_default(); let reconcile_effective_command = - crate::managed_agents::record_agent_command(record, &reconcile_personas); + crate::managed_agents::record_agent_command(&record, &reconcile_personas); let reconcile = ProfileReconcileData { private_key_nsec: record.private_key_nsec.clone(), @@ -1117,7 +1012,7 @@ pub async fn start_managed_agent( StartTarget::Provider { backend: record.backend.clone(), cached_binary_path: record.provider_binary_path.clone(), - agent_json: build_deploy_payload(&app, &state, record)?, + agent_json: build_deploy_payload(&app, &state, &record)?, } }; @@ -1158,10 +1053,13 @@ pub async fn start_managed_agent( .iter() .find(|r| r.pubkey == pubkey) .ok_or_else(|| format!("agent {pubkey} not found"))?; + let record = crate::managed_agents::private_config_overlay::resolved_local_record( + &state, record, + )?; let personas = load_personas(&app).unwrap_or_default(); build_managed_agent_summary( &app, - record, + &record, &runtimes, &personas, &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), @@ -1209,6 +1107,10 @@ pub async fn stop_managed_agent( use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); + let _transition_guard = state + .managed_agent_runtime_transition + .lock() + .map_err(|error| error.to_string())?; let _store_guard = state .managed_agents_store_lock .lock() @@ -1228,28 +1130,34 @@ pub async fn stop_managed_agent( state.clear_agent_session_caches(pubkey); } - { - let record = find_managed_agent_mut(&mut records, &pubkey)?; + let resolved_record = { + let disk_record = find_managed_agent_mut(&mut records, &pubkey)?; + let mut resolved = + crate::managed_agents::private_config_overlay::resolved_local_record( + &state, + disk_record, + )?; // Remote agents are stopped via !shutdown @mention from the frontend, - // not via this backend command. Reject the call. - if record.backend != BackendKind::Local { + // not via this backend command. Reject using the relay-resolved backend. + if resolved.backend != BackendKind::Local { return Err( "remote agents are stopped via !shutdown message, not this command".to_string(), ); } // Pair-scoped: stops only the active workspace's pair; delete and // the config-restart flows still drain every pair. - stop_managed_agent_workspace_pair(&app, record, &mut runtimes)?; - } + stop_managed_agent_workspace_pair(&app, &mut resolved, &mut runtimes)?; + crate::managed_agents::private_config_overlay::copy_lifecycle_state( + disk_record, + &resolved, + ); + resolved + }; save_managed_agents(&app, &records)?; - let record = records - .iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; let personas = load_personas(&app).unwrap_or_default(); build_managed_agent_summary( &app, - record, + &resolved_record, &runtimes, &personas, &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), @@ -1270,6 +1178,10 @@ pub async fn delete_managed_agent( use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); + let _transition_guard = state + .managed_agent_runtime_transition + .lock() + .map_err(|error| error.to_string())?; { let _store_guard = state .managed_agents_store_lock @@ -1298,7 +1210,19 @@ pub async fn delete_managed_agent( // invariant — a buggy or compromised IPC caller cannot silently orphan a live // remote deployment. The frontend sends force_remote_delete: true only after // the user confirms the orphan warning. - if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) { + let resolved_record = + if let Some(record) = records.iter().find(|record| record.pubkey == pubkey) { + Some( + state + .private_managed_agent_overlay + .lock() + .map_err(|error| error.to_string())? + .resolve_local_record(record), + ) + } else { + None + }; + if let Some(record) = resolved_record.as_ref() { if record.backend != BackendKind::Local && record.backend_agent_id.is_some() && !force_remote_delete.unwrap_or(false) @@ -1310,8 +1234,8 @@ pub async fn delete_managed_agent( } } - if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { - stop_managed_agent_process(&app, record, &mut runtimes)?; + if let Some(mut record) = resolved_record { + stop_managed_agent_process(&app, &mut record, &mut runtimes)?; } state.clear_agent_session_caches(&pubkey); let initial_len = records.len(); @@ -1320,6 +1244,11 @@ pub async fn delete_managed_agent( return Err(format!("agent {pubkey} not found")); } save_managed_agents(&app, &records)?; + state + .private_managed_agent_overlay + .lock() + .map_err(|error| error.to_string())? + .remove(&pubkey); // Remove the agent's nsec from the keyring after the record is gone. crate::managed_agents::delete_agent_key(&pubkey); // Tombstone-after-validation: only reached past the deployed-remote @@ -1344,6 +1273,10 @@ pub async fn delete_managed_agent( // 2. Harness sees it, exits gracefully, sets presence to "offline" // 3. Desktop's existing presence polling sees "offline" — UI updates automatically // No backend Tauri command needed. Presence IS the status. +#[path = "agents_lifecycle.rs"] +mod lifecycle; +use lifecycle::start_local_agent_with_preflight; + #[path = "agents_deploy.rs"] mod deploy; pub(super) mod provider_access; diff --git a/desktop/src-tauri/src/commands/agents_lifecycle.rs b/desktop/src-tauri/src/commands/agents_lifecycle.rs new file mode 100644 index 0000000000..a08c496a74 --- /dev/null +++ b/desktop/src-tauri/src/commands/agents_lifecycle.rs @@ -0,0 +1,110 @@ +use super::*; + +pub(super) async fn start_local_agent_with_preflight( + app: &AppHandle, + state: &AppState, + pubkey: &str, + owner_hex: &str, + allow_fresh_create_start: bool, +) -> Result { + let record_snapshot = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = load_managed_agents(app)?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + crate::managed_agents::private_config_overlay::resolved_local_record(state, record)? + }; + + if record_snapshot.backend != BackendKind::Local { + return Err(format!("agent {pubkey} is not a local agent")); + } + + // Preflight against the same resolution spawn uses — `resolve_effective_config` + // (definition → global fallback). A linked instance's own `provider`/`model`/ + // `relay_mesh` bytes never contribute: this reads the CURRENT definition + // directly, so a definition edit that flips `provider` to/from relay-mesh + // between saves is reflected here without needing a prospective re-snapshot; + // for a global-inherited blank definition, it also folds in the global + // default, which record-byte sniffing could never see. + let personas = load_personas(app).unwrap_or_default(); + let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + let mesh_model_id = + crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id( + &record_snapshot, + &personas, + &global, + ); + ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?; + + let _transition_guard = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + if state + .shutdown_started + .load(std::sync::atomic::Ordering::Acquire) + { + return Err("desktop shutdown has started".into()); + } + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + let disk_record = find_managed_agent_mut(&mut records, pubkey)?; + let mut resolved_record = + crate::managed_agents::private_config_overlay::resolved_local_record(state, disk_record)?; + if resolved_record.backend != BackendKind::Local { + return Err(format!("agent {pubkey} is no longer a local agent")); + } + // Re-snapshot the persona onto the resolved spawn record at every start so + // local persona state retains its established precedence without writing + // relay-owned configuration into the device-local migration record. + // Load personas once: used for snapshot application below and summary build + // at the end — avoids a second disk read for the same file in the same call. + let personas = load_personas(app).unwrap_or_default(); + if let Some(persona_id) = resolved_record.persona_id.clone() { + match personas.iter().find(|p| p.id == persona_id) { + Some(persona) => { + crate::managed_agents::persona_events::apply_persona_snapshot( + &mut resolved_record, + persona, + ); + resolved_record.updated_at = crate::util::now_iso(); + } + None => { + return Err( + crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR.to_string(), + ); + } + } + } + start_managed_agent_process(app, &mut resolved_record, &mut runtimes, Some(owner_hex))?; + // Persist operational lifecycle metadata only. Relay-owned configuration + // remains an in-memory overlay and is never copied over device-local fields. + crate::managed_agents::private_config_overlay::copy_lifecycle_state( + disk_record, + &resolved_record, + ); + save_managed_agents(app, &records)?; + // Retain the relay-resolved configuration. The projection equality guard + // makes a runtime-only start a no-op, while avoiding resurrection of stale + // disk config when this device is following a newer relay snapshot. + retain_managed_agent_pending(app, state, &resolved_record); + build_managed_agent_summary( + app, + &resolved_record, + &runtimes, + &personas, + &crate::managed_agents::load_global_agent_config(app).unwrap_or_default(), + ) +} diff --git a/desktop/src-tauri/src/managed_agents/private_config_overlay.rs b/desktop/src-tauri/src/managed_agents/private_config_overlay.rs index 2ff9f15e88..eb99810d2d 100644 --- a/desktop/src-tauri/src/managed_agents/private_config_overlay.rs +++ b/desktop/src-tauri/src/managed_agents/private_config_overlay.rs @@ -3,10 +3,9 @@ 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, + RelayMeshConfig, RespondTo, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, + DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }; #[derive(Clone)] @@ -210,22 +209,26 @@ impl PrivateConfigOverlay { self.0.remove(pubkey); } - pub(crate) fn contains(&self, pubkey: &str) -> bool { - self.0.contains_key(pubkey) + pub(crate) fn resolve_local_record(&self, record: &ManagedAgentRecord) -> ManagedAgentRecord { + let mut resolved = record.clone(); + if let Some(patch) = self.0.get(&record.pubkey) { + patch.apply(&mut resolved); + } + resolved } - pub(crate) fn resolved_record( + pub(crate) fn materialize_relay_only_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); + if local.iter().any(|record| record.pubkey == pubkey) { + return None; + } + let mut record = self.0.get(pubkey)?.fresh_record(); + // Persona definitions are device-local. A fresh device can still run the + // complete relay snapshot, but must not bind it to an absent local persona. + record.persona_id = None; Some(record) } @@ -248,40 +251,66 @@ impl PrivateConfigOverlay { } } -pub(crate) fn start_relay_only_agent( +pub(crate) fn resolved_local_record( + state: &crate::app_state::AppState, + record: &ManagedAgentRecord, +) -> Result { + state + .private_managed_agent_overlay + .lock() + .map_err(|error| error.to_string()) + .map(|overlay| overlay.resolve_local_record(record)) +} + +pub(crate) fn copy_lifecycle_state( + destination: &mut ManagedAgentRecord, + source: &ManagedAgentRecord, +) { + destination.runtime_pid = source.runtime_pid; + destination + .last_started_at + .clone_from(&source.last_started_at); + destination + .last_stopped_at + .clone_from(&source.last_stopped_at); + destination.last_exit_code = source.last_exit_code; + destination.last_error.clone_from(&source.last_error); + destination.last_error_code = source.last_error_code; +} + +pub(crate) fn materialize_relay_only_agent( app: &tauri::AppHandle, state: &crate::app_state::AppState, pubkey: &str, - owner_hex: &str, - local_records: &[ManagedAgentRecord], -) -> Result { - let mut record = state +) -> Result<(), String> { + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|error| error.to_string())?; + if state + .shutdown_started + .load(std::sync::atomic::Ordering::Acquire) + { + return Err("desktop shutdown has started".into()); + } + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut records = super::load_managed_agents(app)?; + let relay_only = 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()); + .materialize_relay_only_record(pubkey, &records); + if let Some(record) = relay_only { + if record.backend != BackendKind::Local { + return Err("relay-only provider agents cannot be started on this device".into()); + } + records.push(record); + super::save_managed_agents(app, &records)?; } - 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(), - ) + Ok(()) } #[cfg(test)] @@ -352,6 +381,33 @@ mod tests { assert_eq!(local, original); } + #[test] + fn materializes_only_relay_only_record_and_preserves_disk_overlay() { + 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.private_key_nsec = "device-local-key".into(); + + let resolved = overlay.resolve_local_record(&local); + assert_eq!(resolved.name, "relay local"); + assert_eq!(resolved.private_key_nsec, "nsec-test"); + assert_eq!(local.name, "disk"); + assert_eq!(local.private_key_nsec, "device-local-key"); + assert!(overlay + .materialize_relay_only_record("aa", std::slice::from_ref(&local)) + .is_none()); + + let relay_only = overlay + .materialize_relay_only_record("bb", &[local]) + .unwrap(); + assert_eq!(relay_only.name, "relay only"); + assert_eq!(relay_only.private_key_nsec, "nsec-test"); + assert_eq!(relay_only.backend, BackendKind::Local); + assert!(relay_only.persona_id.is_none()); + } + #[test] fn rejected_patch_preserves_cached_value_and_clear_drops_scope() { let mut overlay = PrivateConfigOverlay::default(); From c80c4c17b047d7580d50ee5261897e0fb192bb2d Mon Sep 17 00:00:00 2001 From: Sami Date: Thu, 6 Aug 2026 11:45:37 -0400 Subject: [PATCH 3/5] fix(agents): persist relay config across restarts and stop stale republish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relay-primary managed-agent config (kind:30179) delivered config for exactly one launch and could overwrite a newer device's config with an audit-clean event. Three defects, each measured with a probe before being fixed. Overlay boot rehydration. `PrivateConfigOverlay` was in-memory only and its single writer fired only when an inbound 30179 was strictly newer than the retained row. On every second-and-later launch the backfill re-delivered the same event, retention deduped it to Skipped, and the overlay stayed empty for the whole session, so every read silently fell back to stale disk. Rebuild the overlay from the retained rows on the `run_event_sync` boot seam, which already runs post-identity-resolution with the resolved owner keys and scoped db path. Adds `get_retained_events_of_kind` (nothing read rows back by kind before). Boot publication on default keyring builds. `reconcile_agents_in_dir_at` read `managed-agents.json` raw, so on a default `system-keyring` build the nsec was keyring-resident and `retain_private_agent_record`'s empty-nsec skip fired for every untouched agent: zero 30179s published on first boot. Hydrate keys in the reconcile path. The doc comment asserted the bug ("keys are never needed here") and is corrected, so the next reader is not re-licensed to reintroduce it. Stale-disk republish at three write sites. `private_payload_from_record` serializes every config field, so retaining a disk-derived record on a device following a newer relay head republished the other fields from stale disk; `monotonic_created_at` then floored the write at head+1, so it won LWW, bumped the generation, and chained `prev` to the head it destroyed — a validly-chained successor indistinguishable from a legitimate edit. Resolve the overlay before the local mutation at `agent_models.rs` (edit), `personas/update.rs` (persona rename) and `agents.rs` (pair-start snapshot re-apply). Ordering is the fix at each site, and it differs per site, which is why this is not centralized in `retain_managed_agent_pending`: - edit: resolve before the user's patch, or the patch is discarded - rename: resolve for the payload only; the `name != old_display_name` gate must keep reading disk state, and name/display_name are re-applied after - pair-start: resolve before `apply_persona_snapshot`, so the definition quad stays definition-authoritative Tests. Regression coverage for all three defects plus three probes that pin the wrong fixes (centralized resolve discards edits; resolve-before-gate skips the rename; swapped pair-start ordering lets the overlay clobber the persona quad), each with positive and negative controls. `write_site_resolve_guard` is a source-level assertion, added because the behavioural tests cannot see the production wiring: every write site is inside a `#[tauri::command]` needing a live `AppHandle`, so the tests call `retain_agent_record` directly and stayed green with the production resolve deleted (measured: 2261 passed / 0 failed). The guard fails when a site loses its resolve or a new site is added without one, and carries its own vacuity control. File-size ratchet. `agent_models.rs` sat at the 1000-line cap before this change (1024 lines), so the ratchet allows it zero growth, and `reconcile/tests.rs` crossed the cap. Two verbatim moves, following the seams each file already uses: `normalize_agent_models` to `agent_models_normalize.rs` (`#[path]` submodule, like the databricks/ openrouter/discovery helpers) and the stale-republish test family to `reconcile/tests/stale_republish_tests.rs` (like `personas/update/name_propagation_tests.rs`). Both moved blocks are byte-identical to their pre-move bytes apart from one visibility line (`pub(super)` -> `pub(crate)`, required by E0364 on the re-export), and the write-site guard's deletion mutant was re-run after the move: still dead. Item 3 from the review (collapsing the 27-field `PrivateConfigPatch` mirror, ~169 deletable lines) is deliberately not in this commit; it is an overlay refactor and lands separately. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../src-tauri/src/commands/agent_models.rs | 95 +-- .../src/commands/agent_models_normalize.rs | 89 +++ desktop/src-tauri/src/commands/agents.rs | 13 + .../personas/inbound/inbound_tests.rs | 120 +++ .../src-tauri/src/commands/personas/update.rs | 21 +- .../personas/update/name_propagation_tests.rs | 79 ++ desktop/src-tauri/src/event_sync.rs | 39 + .../managed_agents/private_config_overlay.rs | 126 ++++ .../src-tauri/src/managed_agents/reconcile.rs | 21 +- .../src/managed_agents/reconcile/tests.rs | 2 + .../reconcile/tests/stale_republish_tests.rs | 704 ++++++++++++++++++ .../src-tauri/src/managed_agents/retention.rs | 37 + .../src-tauri/src/managed_agents/storage.rs | 2 +- 13 files changed, 1258 insertions(+), 90 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_models_normalize.rs create mode 100644 desktop/src-tauri/src/managed_agents/reconcile/tests/stale_republish_tests.rs diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 4704582372..cd5709ffb6 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -754,6 +754,17 @@ pub async fn update_managed_agent( } let record = find_managed_agent_mut(&mut records, &input.pubkey)?; + // Item 2: fold the relay-config overlay onto the disk record BEFORE + // applying the user's patch, so the edit is authored on top of the + // config this device is actually following. Without this, retaining + // the raw disk record republishes every OTHER field from stale disk + // and LWW makes that the new relay head. Ordering is load-bearing: + // resolving AFTER the patch would discard the user's edit instead. + if let Ok(resolved) = + crate::managed_agents::private_config_overlay::resolved_local_record(&state, record) + { + *record = resolved; + } let previous_record = record.clone(); let mut name_changed = false; @@ -937,87 +948,9 @@ pub async fn update_managed_agent( }) } -// ── Model normalization ─────────────────────────────────────────────────────── - -/// Normalize raw `buzz-acp models --json` output into a typed DTO for the frontend. -/// -/// Merges models from both ACP paths (stable configOptions + unstable SessionModelState), -/// deduplicates by ID (stable takes precedence), and returns a unified list. -pub(super) fn normalize_agent_models( - raw: &serde_json::Value, - persisted_model: Option, -) -> AgentModelsResponse { - let agent_name = raw["agent"]["name"] - .as_str() - .unwrap_or("unknown") - .to_string(); - let agent_version = raw["agent"]["version"] - .as_str() - .unwrap_or("unknown") - .to_string(); - - let mut models: Vec = Vec::new(); - let mut seen_ids: HashSet = HashSet::new(); - - // 1. Stable configOptions (preferred). Only entries with category "model" - // are model options — the CLI pre-filters, but we're defensive here. - if let Some(config_options) = raw["stable"]["configOptions"].as_array() { - for opt in config_options { - if opt.get("category").and_then(|c| c.as_str()) != Some("model") { - continue; - } - if let Some(options) = opt.get("options").and_then(|v| v.as_array()) { - for o in options { - if let Some(value) = o.get("value").and_then(|v| v.as_str()) { - if seen_ids.insert(value.to_string()) { - models.push(AgentModelInfo { - id: value.to_string(), - name: o - .get("displayName") - .and_then(|v| v.as_str()) - .map(str::to_string), - description: None, - }); - } - } - } - } - } - } - - // 2. Unstable availableModels (fallback — skip duplicates from stable). - let mut agent_default_model: Option = None; - if let Some(unstable) = raw.get("unstable") { - agent_default_model = unstable["currentModelId"].as_str().map(str::to_string); - if let Some(available) = unstable["availableModels"].as_array() { - for m in available { - if let Some(id) = m.get("modelId").and_then(|v| v.as_str()) { - if seen_ids.insert(id.to_string()) { - models.push(AgentModelInfo { - id: id.to_string(), - name: m.get("name").and_then(|v| v.as_str()).map(str::to_string), - description: m - .get("description") - .and_then(|v| v.as_str()) - .map(str::to_string), - }); - } - } - } - } - } - - let supports_switching = !models.is_empty(); - - AgentModelsResponse { - agent_name, - agent_version, - models, - agent_default_model, - selected_model: persisted_model, - supports_switching, - } -} +#[path = "agent_models_normalize.rs"] +mod normalize; +pub(super) use normalize::normalize_agent_models; #[cfg(test)] #[path = "agent_models_tests.rs"] diff --git a/desktop/src-tauri/src/commands/agent_models_normalize.rs b/desktop/src-tauri/src/commands/agent_models_normalize.rs new file mode 100644 index 0000000000..b437136b3f --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models_normalize.rs @@ -0,0 +1,89 @@ +//! Normalization of raw `buzz-acp models --json` output into the frontend DTO. +//! +//! Split out of `agent_models.rs` to keep that file inside the desktop +//! file-size ratchet; it is a pure transform with no shared state, so the +//! seam is the same one the discovery/provider helpers already use. + +use std::collections::HashSet; + +use crate::managed_agents::{AgentModelInfo, AgentModelsResponse}; + +/// Normalize raw `buzz-acp models --json` output into a typed DTO for the frontend. +/// +/// Merges models from both ACP paths (stable configOptions + unstable SessionModelState), +/// deduplicates by ID (stable takes precedence), and returns a unified list. +pub(crate) fn normalize_agent_models( + raw: &serde_json::Value, + persisted_model: Option, +) -> AgentModelsResponse { + let agent_name = raw["agent"]["name"] + .as_str() + .unwrap_or("unknown") + .to_string(); + let agent_version = raw["agent"]["version"] + .as_str() + .unwrap_or("unknown") + .to_string(); + + let mut models: Vec = Vec::new(); + let mut seen_ids: HashSet = HashSet::new(); + + // 1. Stable configOptions (preferred). Only entries with category "model" + // are model options — the CLI pre-filters, but we're defensive here. + if let Some(config_options) = raw["stable"]["configOptions"].as_array() { + for opt in config_options { + if opt.get("category").and_then(|c| c.as_str()) != Some("model") { + continue; + } + if let Some(options) = opt.get("options").and_then(|v| v.as_array()) { + for o in options { + if let Some(value) = o.get("value").and_then(|v| v.as_str()) { + if seen_ids.insert(value.to_string()) { + models.push(AgentModelInfo { + id: value.to_string(), + name: o + .get("displayName") + .and_then(|v| v.as_str()) + .map(str::to_string), + description: None, + }); + } + } + } + } + } + } + + // 2. Unstable availableModels (fallback — skip duplicates from stable). + let mut agent_default_model: Option = None; + if let Some(unstable) = raw.get("unstable") { + agent_default_model = unstable["currentModelId"].as_str().map(str::to_string); + if let Some(available) = unstable["availableModels"].as_array() { + for m in available { + if let Some(id) = m.get("modelId").and_then(|v| v.as_str()) { + if seen_ids.insert(id.to_string()) { + models.push(AgentModelInfo { + id: id.to_string(), + name: m.get("name").and_then(|v| v.as_str()).map(str::to_string), + description: m + .get("description") + .and_then(|v| v.as_str()) + .map(str::to_string), + }); + } + } + } + } + } + + let supports_switching = !models.is_empty(); + + AgentModelsResponse { + agent_name, + agent_version, + models, + agent_default_model, + selected_model: persisted_model, + supports_switching, + } +} diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 88b1b8047e..4a4857321f 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -264,6 +264,19 @@ pub(super) async fn start_local_agent_pairs_with_preflight( .map_err(|e| e.to_string())?; let mut records = load_managed_agents(app)?; let record = find_managed_agent_mut(&mut records, pubkey)?; + // Item 2: fold the relay-config overlay on BEFORE the persona snapshot + // re-apply. Without this, retaining the saved record below republishes + // every non-quad field (parallelism, env overrides, name) from stale + // disk over a newer relay head, and LWW makes that the new head. + // Ordering is load-bearing in the other direction here: resolving + // AFTER `apply_persona_snapshot` would let the overlay clobber the + // definition quad (system_prompt/model/provider/runtime), so the + // snapshot must land last to stay definition-authoritative. + if let Ok(resolved) = + crate::managed_agents::private_config_overlay::resolved_local_record(state, record) + { + *record = resolved; + } let personas = load_personas(app).unwrap_or_default(); if let Some(persona_id) = record.persona_id.clone() { if let Some(persona) = personas.iter().find(|persona| persona.id == persona_id) { 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 4e8c4ac2c7..59ed14ab3a 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -264,6 +264,77 @@ fn private_agent_inbound_rejects_before_retain_and_stale_event_preserves_overlay assert_eq!(overlay.resolved_records(&[])[0].name, "new"); } +/// SAMI PROBE: the retention DB survives a restart but the overlay does not. +/// On the next launch the backfill re-delivers the SAME event, which resolves +/// to `Skipped` against the retained row — so `insert_patch` never runs and the +/// overlay stays empty for the whole session. +#[test] +fn sami_probe_overlay_does_not_rehydrate_after_restart() { + use crate::managed_agents::{ + private_config_overlay::PrivateConfigOverlay, + retention::{open_retention_db, InboundOutcome}, + }; + use buzz_core_pkg::private_managed_agent; + use tempfile::TempDir; + + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("retention.db"); + let owner_keys = nostr::Keys::generate(); + let agent_keys = nostr::Keys::generate(); + + let payload = private_agent_payload(&owner_keys, &agent_keys, "relay name", 4); + let event = private_managed_agent::build_event(&owner_keys, &payload, 20).unwrap(); + + // ── Session 1: event arrives, overlay hydrates. ── + { + let conn = open_retention_db(&db_path).unwrap(); + let mut overlay = PrivateConfigOverlay::default(); + assert_eq!( + apply_inbound_private_managed_agent_event(&event, &owner_keys, &conn, &mut overlay) + .unwrap(), + InboundOutcome::Applied + ); + assert_eq!( + overlay.resolved_records(&[]).len(), + 1, + "control: overlay hydrates on first arrival" + ); + } + + // ── Session 2: same DB file, fresh in-memory overlay (app restart). ── + let conn = open_retention_db(&db_path).unwrap(); + let mut overlay = PrivateConfigOverlay::default(); + let outcome = + apply_inbound_private_managed_agent_event(&event, &owner_keys, &conn, &mut overlay) + .unwrap(); + assert_eq!( + outcome, + InboundOutcome::Skipped, + "re-delivered event is deduped against the retained row" + ); + assert!( + overlay.resolved_records(&[]).is_empty(), + "DEFECT: overlay is empty after restart — relay config silently unavailable" + ); + + // ── Positive control: the probe CAN observe hydration in session 2. ── + // A strictly-newer event is the only thing that repopulates the overlay. + let mut newer = private_agent_payload(&owner_keys, &agent_keys, "newer name", 4); + newer.generation = 2; + newer.previous_event_id = Some(event.id.to_hex()); + let newer_event = private_managed_agent::build_event(&owner_keys, &newer, 30).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, + "newer name", + "positive control: this harness observes hydration when it happens" + ); +} + /// A local managed agent carrying every device-local secret that an inbound /// event must NEVER be able to overwrite. fn local_agent() -> ManagedAgentRecord { @@ -782,3 +853,52 @@ fn inbound_gate_accepts_validly_signed_event() { let parsed = parse_verified_inbound_event(&event.as_json()).unwrap(); assert_eq!(parsed.pubkey, keys.public_key()); } + +/// Item-0 FIX verification: after a "restart" (same retention db, fresh +/// overlay), `hydrate_from_retention` repopulates the overlay from the durable +/// rows — so the resolve sites see relay config instead of stale disk. +#[test] +fn sami_fix_overlay_rehydrates_from_retention_after_restart() { + use crate::managed_agents::{ + private_config_overlay::{hydrate_from_retention, PrivateConfigOverlay}, + retention::open_retention_db, + }; + use buzz_core_pkg::private_managed_agent; + use tempfile::TempDir; + + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("retention.db"); + let owner_keys = nostr::Keys::generate(); + let agent_keys = nostr::Keys::generate(); + + let payload = private_agent_payload(&owner_keys, &agent_keys, "relay name", 4); + let event = private_managed_agent::build_event(&owner_keys, &payload, 20).unwrap(); + + // Session 1: the event lands and is retained durably. + { + let conn = open_retention_db(&db_path).unwrap(); + let mut overlay = PrivateConfigOverlay::default(); + apply_inbound_private_managed_agent_event(&event, &owner_keys, &conn, &mut overlay) + .unwrap(); + } + + // Session 2 (restart): hydrate straight from the retained rows — no + // inbound event required. + let conn = open_retention_db(&db_path).unwrap(); + let overlay = hydrate_from_retention(&conn, &owner_keys).unwrap(); + let resolved = overlay.resolved_records(&[]); + assert_eq!(resolved.len(), 1, "FIX: overlay rehydrates from retention"); + assert_eq!(resolved[0].name, "relay name"); + assert_eq!(resolved[0].parallelism, 4); + + // NEGATIVE CONTROL: a different owner's keys must hydrate NOTHING — proves + // the query is scoped by owner pubkey and not just returning every row. + let stranger = nostr::Keys::generate(); + assert!( + hydrate_from_retention(&conn, &stranger) + .unwrap() + .resolved_records(&[]) + .is_empty(), + "control: hydration is owner-scoped" + ); +} diff --git a/desktop/src-tauri/src/commands/personas/update.rs b/desktop/src-tauri/src/commands/personas/update.rs index ed2472d54e..b7cbea7828 100644 --- a/desktop/src-tauri/src/commands/personas/update.rs +++ b/desktop/src-tauri/src/commands/personas/update.rs @@ -211,7 +211,26 @@ pub(super) async fn update_persona_with( // Avatar-only edits are excluded — the avatar is not in the // projection, so retaining would be a guaranteed no-op. for record in records.iter().filter(|r| renamed.contains(&r.pubkey)) { - crate::commands::agents::retain_managed_agent_pending(&app, &state, record); + // Item 2: `private_payload_from_record` serializes EVERY + // config field, so retaining the raw disk record here + // republishes system_prompt/parallelism/env_vars from + // stale disk over a newer relay head. Fold the overlay + // on first, then re-apply the rename — the overlay's + // `apply` clobbers `name`, and resolving before the + // `name != old_display_name` gate above would instead + // make the rename skip records whose relay name already + // diverged. Disk stays untouched: it is the fallback, + // the relay is primary. + let mut resolved = + crate::managed_agents::private_config_overlay::resolved_local_record( + &state, record, + ) + .unwrap_or_else(|_| record.clone()); + resolved.name.clone_from(&record.name); + resolved.display_name.clone_from(&record.display_name); + crate::commands::agents::retain_managed_agent_pending( + &app, &state, &resolved, + ); } } diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index c60215ae4d..6a989a89dc 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -158,3 +158,82 @@ fn test_rename_renames_all_matching_instances_in_one_pass() { assert_eq!(records[1].name, "Duncan Idaho"); assert_eq!(records[2].name, "Birch", "pool-named instance untouched"); } + +/// SAMI PROBE (fidelity pin for `sami_probe_rename_republishes_nonname_fields_from_stale_disk` +/// in `managed_agents/reconcile/tests.rs`): that probe hand-mutates `name` and +/// `display_name` to stand in for this helper. If the helper ever touched a +/// third field, the probe's fixture would silently stop modelling production. +/// Assert the mutation surface is EXACTLY those two fields, by diffing a +/// serialized before/after. +#[test] +fn rename_helper_mutates_only_name_and_display_name() { + let mut records = vec![agent("persona-1", "Paul", Some("Paul"))]; + records[0].system_prompt = Some("disk prompt".into()); + records[0].parallelism = 7; + let before = serde_json::to_value(&records[0]).unwrap(); + + propagate_persona_name_rename(&mut records, "persona-1", "Paul", "Paul Atreides"); + + let after = serde_json::to_value(&records[0]).unwrap(); + let changed: Vec = before + .as_object() + .unwrap() + .keys() + .chain(after.as_object().unwrap().keys()) + .filter(|key| before.get(*key) != after.get(*key)) + .cloned() + .collect::>() + .into_iter() + .collect(); + + assert_eq!( + changed, + vec!["display_name".to_string(), "name".to_string()], + "rename must mutate exactly name + display_name; a wider surface \ + invalidates the stale-disk republish probe's fixture" + ); +} + +/// SAMI PROBE (hazard in the PROPOSED fix, not in the current code): the fix +/// for the stale-disk republish is "resolve the overlay before the write". At +/// this site the write is gated on `record.name == old_display_name`, and the +/// overlay REPLACES `record.name` with the relay's name. So resolving before +/// the gate can change which records the rename reaches. +/// +/// Models a following device whose relay head carries a name that no longer +/// equals the persona's old display_name (device A already renamed, or the +/// instance is pool-named on the relay). Resolve-first makes the rename SKIP +/// the record entirely — the intended write is lost, which is the same +/// silent-data-loss class as the centralized-resolve probe. +#[test] +fn sami_probe_resolve_before_rename_can_skip_the_intended_rename() { + // Disk name matches the old persona display_name, so production renames it. + let mut disk_only = vec![agent("persona-1", "Paul", Some("Paul"))]; + let renamed = + propagate_persona_name_rename(&mut disk_only, "persona-1", "Paul", "Paul Atreides"); + assert_eq!( + renamed.len(), + 1, + "control: against the DISK name the rename fires" + ); + assert_eq!(disk_only[0].name, "Paul Atreides"); + + // Same record after the overlay resolves a relay head whose name differs + // (device A already applied the rename). `apply()` clobbers `record.name`. + let mut overlay_resolved = vec![agent("persona-1", "Paul", Some("Paul"))]; + overlay_resolved[0].name = "Paul Atreides".to_string(); // what the overlay wrote + + let renamed_after_resolve = + propagate_persona_name_rename(&mut overlay_resolved, "persona-1", "Paul", "Paul Atreides"); + + assert!( + renamed_after_resolve.is_empty(), + "resolve-before-rename makes the gate miss: the record is NOT reported \ + as renamed, so update.rs never retains it and never syncs its relay \ + profile" + ); + // Benign here (the names already agree), but the gate is now driven by + // relay state rather than disk state — so the fix must resolve for the + // PAYLOAD without moving the `name != old_display_name` decision onto the + // resolved name. +} diff --git a/desktop/src-tauri/src/event_sync.rs b/desktop/src-tauri/src/event_sync.rs index ee8e0d8b10..6829779406 100644 --- a/desktop/src-tauri/src/event_sync.rs +++ b/desktop/src-tauri/src/event_sync.rs @@ -17,6 +17,45 @@ pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys, db_path: migrate_personas_to_events(app, owner_keys, db_path); migrate_teams_to_events(app, owner_keys, db_path); crate::managed_agents::reconcile::reconcile_agents_to_events(app, owner_keys, db_path); + hydrate_private_config_overlay(app, owner_keys, db_path); +} + +/// Rebuild the relay-config overlay from the retained kind:30179 rows. +/// +/// Runs on the same boot seam as the disk→event reconcile but in the other +/// direction (retention→memory). Without it the overlay is empty on every +/// second-and-later launch, because the backfill's re-delivered events dedupe +/// against their own retained rows and never reach `insert_patch`. Best-effort: +/// a failure leaves the overlay empty, which is exactly today's behavior. +fn hydrate_private_config_overlay( + app: &tauri::AppHandle, + owner_keys: &nostr::Keys, + db_path: &Path, +) { + use tauri::Manager; + + let result = (|| -> Result { + let conn = crate::managed_agents::retention::open_retention_db(db_path)?; + let hydrated = crate::managed_agents::private_config_overlay::hydrate_from_retention( + &conn, owner_keys, + )?; + let count = hydrated.len(); + let state = app.state::(); + *state + .private_managed_agent_overlay + .lock() + .map_err(|error| error.to_string())? = hydrated; + Ok(count) + })(); + match result { + Ok(0) => {} + Ok(count) => { + eprintln!( + "buzz-desktop: private-config-overlay: hydrated {count} agents from retention" + ) + } + Err(error) => eprintln!("buzz-desktop: private-config-overlay: {error}"), + } } /// Spawn the best-effort event reconcile off the synchronous Tauri setup path. diff --git a/desktop/src-tauri/src/managed_agents/private_config_overlay.rs b/desktop/src-tauri/src/managed_agents/private_config_overlay.rs index eb99810d2d..68f5fa8c8f 100644 --- a/desktop/src-tauri/src/managed_agents/private_config_overlay.rs +++ b/desktop/src-tauri/src/managed_agents/private_config_overlay.rs @@ -201,6 +201,10 @@ impl PrivateConfigOverlay { self.0.insert(patch.pubkey.clone(), patch); } + pub(crate) fn len(&self) -> usize { + self.0.len() + } + pub(crate) fn clear(&mut self) { self.0.clear(); } @@ -432,3 +436,125 @@ mod tests { ); } } + +/// Rebuild the in-memory overlay from the retained kind:30179 rows. +/// +/// The inbound path only calls `insert_patch` when `retain_inbound_event` +/// returns `Applied`, i.e. when the event is STRICTLY newer than the retained +/// row. After a restart the backfill re-delivers the same events, retention +/// dedupes them to `Skipped`, and the overlay would stay empty for the whole +/// session — every resolve site silently falling back to stale disk config. +/// Hydrating from the durable rows at boot makes relay-primary config survive +/// a restart. +/// +/// Best-effort per row: a row that fails to parse, decrypt, or validate is +/// skipped rather than failing the boot, matching the inbound path's +/// per-record reject. +pub(crate) fn hydrate_from_retention( + conn: &rusqlite::Connection, + owner_keys: &nostr::Keys, +) -> Result { + use buzz_core_pkg::{kind::KIND_PRIVATE_MANAGED_AGENT, private_managed_agent}; + use nostr::JsonUtil; + + let rows = crate::managed_agents::retention::get_retained_events_of_kind( + conn, + KIND_PRIVATE_MANAGED_AGENT, + &owner_keys.public_key().to_hex(), + )?; + + let mut overlay = PrivateConfigOverlay::default(); + for row in rows { + let Ok(event) = nostr::Event::from_json(&row.raw_event) else { + continue; + }; + let Ok((_, payload)) = private_managed_agent::validate_and_decrypt(&event, owner_keys) + else { + continue; + }; + if let Ok(patch) = PrivateConfigPatch::from_payload(payload) { + overlay.insert_patch(patch); + } + } + Ok(overlay) +} + +/// Guards that each known stale-disk-republish write site actually calls the +/// overlay resolve. The behavioural tests for these sites (in +/// `reconcile/tests.rs` and `personas/update/name_propagation_tests.rs`) can +/// only *model* the ordering: every site is inside a `#[tauri::command]` that +/// needs a live `AppHandle`, so they call `retain_agent_record` directly and +/// stay green even when the production call is deleted. Measured, not assumed: +/// removing the resolve from `agent_models.rs` left the full lib suite at +/// 2261 passed / 0 failed. This module is the only thing that fails when a +/// site loses its resolve — or when a NEW site is added without one. +/// +/// A source assertion is a weak instrument (it cannot see ordering, only +/// presence), so it is deliberately paired with the behavioural ordering tests +/// rather than replacing them. It exists because the alternative here is no +/// coverage at all. +#[cfg(test)] +mod write_site_resolve_guard { + /// `(file, source, expected_resolve_calls)` — every write site that + /// retains a managed-agent record derived from disk. + fn sites() -> Vec<(&'static str, &'static str, usize)> { + vec![ + ( + "commands/agent_models.rs", + include_str!("../commands/agent_models.rs"), + 1, + ), + // 4 = the 3 sites Carl already resolved correctly (start/stop/ + // delete, ~:999/:1069/:1149) plus the pair-start snapshot re-apply + // fixed here (~:276). The count is deliberately exact rather than + // `>= 1`: a lower bound would not notice a site losing its resolve + // while another gained one. + ( + "commands/agents.rs", + include_str!("../commands/agents.rs"), + 4, + ), + ( + "commands/personas/update.rs", + include_str!("../commands/personas/update.rs"), + 1, + ), + ] + } + + #[test] + fn every_stale_republish_write_site_resolves_the_overlay() { + for (file, source, expected) in sites() { + let found = source.matches("resolved_local_record(").count(); + assert_eq!( + found, expected, + "{file}: expected {expected} `resolved_local_record(` call(s), found {found}. \ + A write site that retains a disk-derived record without resolving the \ + relay overlay republishes stale config over a newer relay head as a \ + validly-chained successor event (see \ + `sami_probe_2b_stale_disk_republish_over_newer_relay_head`)." + ); + } + } + + /// The guard above is a substring count, so prove it can FAIL: a source + /// with the call removed must not satisfy it. Without this, a typo in the + /// searched string would make every row vacuously pass. + #[test] + fn guard_detects_a_missing_resolve_call() { + for (file, source, _) in sites() { + let stripped = source.replace("resolved_local_record(", "REMOVED("); + assert_eq!( + stripped.matches("resolved_local_record(").count(), + 0, + "{file}: negative control — the guard's search string must actually \ + match the production call, or the guard is vacuous" + ); + assert_ne!( + source.matches("resolved_local_record(").count(), + 0, + "{file}: positive control — the search string must be present at HEAD" + ); + } + } +} diff --git a/desktop/src-tauri/src/managed_agents/reconcile.rs b/desktop/src-tauri/src/managed_agents/reconcile.rs index 7d110c056d..8a47aff6ac 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile.rs @@ -60,13 +60,14 @@ pub(crate) fn reconcile_agents_to_events( /// Core reconcile logic, decoupled from the Tauri `AppHandle` for testing. /// -/// Reads `managed-agents.json` raw — no keyring hydration: the published +/// Reads `managed-agents.json` and hydrates keys from the keyring: the 30177 /// projection ([`super::agent_events::agent_event_content`]) is the opt-IN -/// no-secrets allowlist, so keys are never needed here. For each record it -/// compares the freshly built event's content against the retained row at -/// `(30177, owner, agent_pubkey)` and re-retains (marking `pending_sync = 1`) -/// only when the row is absent or its content differs — an unchanged agent -/// never churns `pending_sync`. +/// no-secrets allowlist and needs no keys, but the 30179 private-config +/// projection carries the agent nsec, which is keyring-resident on a default +/// build. For each record it compares the freshly built event's content against +/// the retained row at `(30177, owner, agent_pubkey)` and re-retains (marking +/// `pending_sync = 1`) only when the row is absent or its content differs — an +/// unchanged agent never churns `pending_sync`. /// /// Returns the number of agents (re)written to the retention store. #[cfg(test)] @@ -87,7 +88,7 @@ fn reconcile_agents_in_dir_at( let content = std::fs::read_to_string(&store_path) .map_err(|e| format!("failed to read managed-agents.json: {e}"))?; - let records: Vec = serde_json::from_str(&content).map_err(|e| { + let mut records: Vec = serde_json::from_str(&content).map_err(|e| { super::storage::backup_invalid_store(&store_path); format!("failed to parse managed-agents.json (preserved as .invalid): {e}") })?; @@ -96,6 +97,12 @@ fn reconcile_agents_in_dir_at( return Ok(0); } + // The 30179 private-config projection carries the agent nsec, which on a + // default `system-keyring` build lives in the keyring and NOT in the JSON. + // Without this, `retain_private_agent_record`'s empty-nsec skip fires for + // every untouched agent and boot reconcile publishes zero 30179s. + super::storage::hydrate_keys(&mut records); + let conn = open_retention_db(db_path).map_err(|e| format!("failed to open retention db: {e}"))?; diff --git a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs index 69a03af3e9..891afd53b9 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs @@ -554,3 +554,5 @@ fn retain_agent_record_is_noop_when_unchanged() { "no pending_sync churn for an unchanged record" ); } + +mod stale_republish_tests; diff --git a/desktop/src-tauri/src/managed_agents/reconcile/tests/stale_republish_tests.rs b/desktop/src-tauri/src/managed_agents/reconcile/tests/stale_republish_tests.rs new file mode 100644 index 0000000000..a0e8499bd3 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/reconcile/tests/stale_republish_tests.rs @@ -0,0 +1,704 @@ +//! Regression coverage for the stale-disk republish class (review item 2) and +//! the overlay-resolution ordering at each write site. +//! +//! Split out of `tests.rs` to stay inside the desktop file-size ratchet. These +//! share the parent module's fixtures (`sample_record`, `write_store`) via +//! `use super::*`, so they stay one `cargo test` away from the engine they pin. + +use super::*; + +/// SAMI PROBE (2b): device B holds a NEWER relay head in retention (inbound, +/// pending_sync=0). A local edit then rebuilds the payload from the STALE disk +/// record and retains it. Does the projection-equality guard or LWW stop the +/// stale fields from becoming the new relay head? +#[test] +fn sami_probe_2b_stale_disk_republish_over_newer_relay_head() { + 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 owner_hex = owner_keys.public_key().to_hex(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + + // Device B's disk record: stale on two fields. + let mut disk = sample_record(&pubkey, "stale-disk-name"); + disk.private_key_nsec = agent_keys.secret_key().to_bech32().unwrap(); + disk.system_prompt = Some("STALE disk prompt".into()); + disk.parallelism = 1; + + // Inbound relay head from device A: fresher config, gen 5, far-future + // created_at so LWW clearly favors it. + let mut fresh = disk.clone(); + fresh.name = "FRESH relay name".into(); + fresh.system_prompt = Some("FRESH relay prompt".into()); + fresh.parallelism = 16; + let head_created_at = nostr::Timestamp::now().as_secs() as i64 + 10_000; + let head_payload = + private_payload_from_record(&fresh, &owner_hex, 5, Some("aa".repeat(32))).unwrap(); + let head_event = + private_managed_agent::build_event(&owner_keys, &head_payload, head_created_at as u64) + .unwrap(); + // Exactly what the inbound path writes: pending_sync = 0. + crate::managed_agents::retention::retain_inbound_event( + &conn, + &RetainedEvent { + kind: KIND_PRIVATE_MANAGED_AGENT, + pubkey: owner_hex.clone(), + d_tag: pubkey.clone(), + content: head_event.content.clone(), + created_at: head_created_at, + raw_event: head_event.as_json(), + pending_sync: false, + }, + ) + .unwrap(); + + // A local edit on device B: update_managed_agent's tail — retain the DISK + // record. (`update_managed_agent` passes the just-saved disk record.) + let changed = retain_agent_record(&conn, &owner_keys, &disk).unwrap(); + + let row = get_retained_event(&conn, KIND_PRIVATE_MANAGED_AGENT, &owner_hex, &pubkey) + .unwrap() + .unwrap(); + let (_, republished) = private_managed_agent::validate_and_decrypt( + &nostr::Event::from_json(&row.raw_event).unwrap(), + &owner_keys, + ) + .unwrap(); + + // DEFECT: every stale disk field became the new relay head. + assert!(changed, "retain reported a change"); + assert_eq!(republished.config.name, "stale-disk-name"); + assert_eq!( + republished.config.system_prompt, + Some("STALE disk prompt".into()) + ); + assert_eq!(republished.config.parallelism, Some(1)); + // ...and it WINS LWW: monotonic_created_at bumped past the fresher head. + assert!( + row.created_at > head_created_at, + "stale republish must outrank the fresher head for the defect to matter" + ); + // ...and it is a VALIDLY CHAINED successor (gen 5 -> 6, prev = head id), + // so no peer can distinguish it from a legitimate edit. + assert_eq!(republished.generation, 6); + assert_eq!( + republished.previous_event_id, + Some(head_event.id.to_hex()), + "stale event chains cleanly off the head it clobbers" + ); + // ...and it is queued for publish, not merely local. + assert!( + get_pending_sync(&conn) + .unwrap() + .iter() + .any(|event| event.kind == KIND_PRIVATE_MANAGED_AGENT), + "the stale 30179 is enqueued for relay publish" + ); + + // POSITIVE CONTROL: the guard this probe claims is bypassed DOES fire when + // the input matches the head — proving the probe observes a real bypass and + // not a guard that never no-ops. Run against a PRISTINE head (a second db), + // because the stale write above already replaced the head in `conn`. Compare + // the private ROW, not `retain_agent_record`'s bool: that bool is + // `public_changed || private_changed`, so the fresh db's absent 30177 row + // would mask the private no-op. + let control_conn = open_retention_db(&dir.path().join("control.db")).unwrap(); + crate::managed_agents::retention::retain_inbound_event( + &control_conn, + &RetainedEvent { + kind: KIND_PRIVATE_MANAGED_AGENT, + pubkey: owner_hex.clone(), + d_tag: pubkey.clone(), + content: head_event.content.clone(), + created_at: head_created_at, + raw_event: head_event.as_json(), + pending_sync: false, + }, + ) + .unwrap(); + retain_agent_record(&control_conn, &owner_keys, &fresh).unwrap(); + let control_row = get_retained_event( + &control_conn, + KIND_PRIVATE_MANAGED_AGENT, + &owner_hex, + &pubkey, + ) + .unwrap() + .unwrap(); + assert_eq!( + control_row.raw_event, + head_event.as_json(), + "control: retaining the RESOLVED (relay-fresh) record against the same \ + head leaves the head untouched — so the defect above is the stale \ + input, not a guard that never fires" + ); +} + +/// SAMI PROBE (item 2 cost): would the CHEAP fix — resolve the overlay once +/// inside `retain_managed_agent_pending` instead of at each call site — be +/// correct? Simulates that fix at the EDIT site: user edits one field on disk, +/// helper resolves the overlay on top, then retains. +#[test] +fn sami_probe_resolve_in_helper_would_discard_user_edits() { + use crate::managed_agents::private_config_overlay::{PrivateConfigOverlay, PrivateConfigPatch}; + + let owner_keys = nostr::Keys::generate(); + let agent_keys = nostr::Keys::generate(); + let pubkey = agent_keys.public_key().to_hex(); + let owner_hex = owner_keys.public_key().to_hex(); + + // Relay head the overlay is following: parallelism 16, relay prompt. + let mut relay = sample_record(&pubkey, "relay-name"); + relay.private_key_nsec = agent_keys.secret_key().to_bech32().unwrap(); + relay.system_prompt = Some("relay prompt".into()); + relay.parallelism = 16; + let head_payload = private_payload_from_record(&relay, &owner_hex, 1, None).unwrap(); + let mut overlay = PrivateConfigOverlay::default(); + overlay.insert_patch(PrivateConfigPatch::from_payload(head_payload).unwrap()); + + // The user edits ONE field locally: parallelism 16 -> 2. This is the + // just-saved disk record `update_managed_agent` passes to the helper. + let mut edited = relay.clone(); + edited.parallelism = 2; + + // The "cheap fix": helper resolves the overlay onto the record it was + // handed, then retains that. + let resolved = overlay.resolve_local_record(&edited); + + assert_eq!( + resolved.parallelism, 16, + "the cheap one-place fix SILENTLY DISCARDS the user's edit: \ + parallelism went back to the relay's 16, not the edited 2" + ); + // Positive control: with no patch for this agent the edit survives, so the + // discard above is the overlay winning, not a broken fixture. + let empty = PrivateConfigOverlay::default(); + assert_eq!( + empty.resolve_local_record(&edited).parallelism, + 2, + "control: without an overlay patch the edit survives" + ); +} + +/// SAMI PROBE (settles Eva's contested third site, `personas/update.rs:214`): +/// on a device following a NEWER relay head, does a persona RENAME republish +/// the non-name config fields from stale disk? +/// +/// Eva traced that `private_payload_from_record` serializes every config field +/// from the disk record, so a name-only mutation should still clobber +/// system_prompt / parallelism / env_vars. I traced it as scoped-out ("folding +/// the overlay would fight the intended write"). Measured here rather than +/// argued: the rename mutates ONLY `name`/`display_name` (pinned faithful by +/// `rename_helper_mutates_only_name_and_display_name` in +/// `commands/personas/update/name_propagation_tests.rs`), then the retain fires +/// exactly as `update.rs:214` fires it. +#[test] +fn sami_probe_rename_republishes_nonname_fields_from_stale_disk() { + 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 owner_hex = owner_keys.public_key().to_hex(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + + // Device B's disk record, stale on the NON-name fields. Its `name` still + // equals the old persona display_name, which is what makes the rename + // propagate to it at all. + let mut disk = sample_record(&pubkey, "Paul"); + disk.display_name = Some("Paul".into()); + disk.private_key_nsec = agent_keys.secret_key().to_bech32().unwrap(); + disk.system_prompt = Some("STALE disk prompt".into()); + disk.parallelism = 1; + disk.env_vars + .insert("STALE_KEY".into(), "stale-value".into()); + + // Device A's newer relay head: same agent, fresher non-name config. + let mut fresh = disk.clone(); + fresh.system_prompt = Some("FRESH relay prompt".into()); + fresh.parallelism = 16; + fresh.env_vars.clear(); + fresh + .env_vars + .insert("FRESH_KEY".into(), "fresh-value".into()); + let head_created_at = nostr::Timestamp::now().as_secs() as i64 + 10_000; + let head_payload = + private_payload_from_record(&fresh, &owner_hex, 5, Some("aa".repeat(32))).unwrap(); + let head_event = + private_managed_agent::build_event(&owner_keys, &head_payload, head_created_at as u64) + .unwrap(); + crate::managed_agents::retention::retain_inbound_event( + &conn, + &RetainedEvent { + kind: KIND_PRIVATE_MANAGED_AGENT, + pubkey: owner_hex.clone(), + d_tag: pubkey.clone(), + content: head_event.content.clone(), + created_at: head_created_at, + raw_event: head_event.as_json(), + pending_sync: false, + }, + ) + .unwrap(); + + // The rename: `propagate_persona_name_rename` mutates name + display_name + // and NOTHING else, then `update.rs:214` retains that disk record. + let mut renamed = disk.clone(); + renamed.name = "Paul Atreides".into(); + renamed.display_name = Some("Paul Atreides".into()); + retain_agent_record(&conn, &owner_keys, &renamed).unwrap(); + + let row = get_retained_event(&conn, KIND_PRIVATE_MANAGED_AGENT, &owner_hex, &pubkey) + .unwrap() + .unwrap(); + let (_, republished) = private_managed_agent::validate_and_decrypt( + &nostr::Event::from_json(&row.raw_event).unwrap(), + &owner_keys, + ) + .unwrap(); + + // The intended write DID land. + assert_eq!(republished.config.name, "Paul Atreides"); + // EVA IS RIGHT: the non-name fields came back from STALE DISK, not the head. + assert_eq!( + republished.config.system_prompt, + Some("STALE disk prompt".into()), + "rename republished the stale disk prompt over the fresher relay head" + ); + assert_eq!(republished.config.parallelism, Some(1)); + assert_eq!( + republished + .config + .env_vars + .get("STALE_KEY") + .map(String::as_str), + Some("stale-value"), + "stale env var resurrected" + ); + assert!( + !republished.config.env_vars.contains_key("FRESH_KEY"), + "the head's env var was DROPPED, so this is a replace not a merge" + ); + // ...and it outranks the fresher head, chained cleanly: same clobber class + // as `agent_models.rs:867`. + assert!(row.created_at > head_created_at); + assert_eq!(republished.generation, 6); + assert_eq!(republished.previous_event_id, Some(head_event.id.to_hex())); + + // POSITIVE CONTROL: a rename applied to the RESOLVED (relay-fresh) record + // preserves every non-name field, so the defect above is the stale input, + // not something inherent to renaming. Pristine head in a second db, and + // compare the private ROW bytes (retain_agent_record's bool is + // public_changed || private_changed, so a fresh db's absent 30177 row makes + // it true regardless of the private outcome). + let control_conn = open_retention_db(&dir.path().join("control.db")).unwrap(); + crate::managed_agents::retention::retain_inbound_event( + &control_conn, + &RetainedEvent { + kind: KIND_PRIVATE_MANAGED_AGENT, + pubkey: owner_hex.clone(), + d_tag: pubkey.clone(), + content: head_event.content.clone(), + created_at: head_created_at, + raw_event: head_event.as_json(), + pending_sync: false, + }, + ) + .unwrap(); + let mut resolved_then_renamed = fresh.clone(); + resolved_then_renamed.name = "Paul Atreides".into(); + resolved_then_renamed.display_name = Some("Paul Atreides".into()); + retain_agent_record(&control_conn, &owner_keys, &resolved_then_renamed).unwrap(); + let control_row = get_retained_event( + &control_conn, + KIND_PRIVATE_MANAGED_AGENT, + &owner_hex, + &pubkey, + ) + .unwrap() + .unwrap(); + let (_, control) = private_managed_agent::validate_and_decrypt( + &nostr::Event::from_json(&control_row.raw_event).unwrap(), + &owner_keys, + ) + .unwrap(); + assert_eq!( + control.config.name, "Paul Atreides", + "control: rename landed" + ); + assert_eq!( + control.config.system_prompt, + Some("FRESH relay prompt".into()), + "control: resolve-then-rename preserves the head's prompt" + ); + assert_eq!(control.config.parallelism, Some(16)); + assert_eq!( + control.config.env_vars.get("FRESH_KEY").map(String::as_str), + Some("fresh-value"), + "control: resolve-then-rename preserves the head's env vars" + ); +} + +/// SAMI FIX VERIFICATION for the rename site (`personas/update.rs:214`): +/// the shipped shape is resolve-overlay → re-apply name/display_name → retain. +/// Assert that on the SAME fixture that produces the clobber above, this +/// ordering republishes the head's non-name fields while still landing the +/// rename. Mirrors the production expression exactly (`resolved.name` and +/// `resolved.display_name` re-copied from the renamed disk record). +#[test] +fn sami_fix_rename_over_resolved_record_preserves_relay_fields() { + use crate::managed_agents::private_config_overlay::{PrivateConfigOverlay, PrivateConfigPatch}; + + 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 owner_hex = owner_keys.public_key().to_hex(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + + let mut disk = sample_record(&pubkey, "Paul"); + disk.display_name = Some("Paul".into()); + disk.private_key_nsec = agent_keys.secret_key().to_bech32().unwrap(); + disk.system_prompt = Some("STALE disk prompt".into()); + disk.parallelism = 1; + disk.env_vars + .insert("STALE_KEY".into(), "stale-value".into()); + + let mut fresh = disk.clone(); + fresh.system_prompt = Some("FRESH relay prompt".into()); + fresh.parallelism = 16; + fresh.env_vars.clear(); + fresh + .env_vars + .insert("FRESH_KEY".into(), "fresh-value".into()); + let head_created_at = nostr::Timestamp::now().as_secs() as i64 + 10_000; + let head_payload = + private_payload_from_record(&fresh, &owner_hex, 5, Some("aa".repeat(32))).unwrap(); + let head_event = + private_managed_agent::build_event(&owner_keys, &head_payload, head_created_at as u64) + .unwrap(); + crate::managed_agents::retention::retain_inbound_event( + &conn, + &RetainedEvent { + kind: KIND_PRIVATE_MANAGED_AGENT, + pubkey: owner_hex.clone(), + d_tag: pubkey.clone(), + content: head_event.content.clone(), + created_at: head_created_at, + raw_event: head_event.as_json(), + pending_sync: false, + }, + ) + .unwrap(); + + // The overlay this device is following (what boot hydration installs). + let mut overlay = PrivateConfigOverlay::default(); + overlay.insert_patch(PrivateConfigPatch::from_payload(head_payload).unwrap()); + + // Production's renamed disk record... + let mut renamed = disk.clone(); + renamed.name = "Paul Atreides".into(); + renamed.display_name = Some("Paul Atreides".into()); + // ...then the FIX: resolve, re-apply the rename, retain. + let mut resolved = overlay.resolve_local_record(&renamed); + resolved.name.clone_from(&renamed.name); + resolved.display_name.clone_from(&renamed.display_name); + retain_agent_record(&conn, &owner_keys, &resolved).unwrap(); + + let row = get_retained_event(&conn, KIND_PRIVATE_MANAGED_AGENT, &owner_hex, &pubkey) + .unwrap() + .unwrap(); + let (_, published) = private_managed_agent::validate_and_decrypt( + &nostr::Event::from_json(&row.raw_event).unwrap(), + &owner_keys, + ) + .unwrap(); + + // The rename still lands (the fix must not eat the intended write). + assert_eq!(published.config.name, "Paul Atreides"); + // ...and every non-name field is now the RELAY head's, not stale disk. + assert_eq!( + published.config.system_prompt, + Some("FRESH relay prompt".into()), + "fix: the head's prompt survives the rename" + ); + assert_eq!(published.config.parallelism, Some(16)); + assert_eq!( + published + .config + .env_vars + .get("FRESH_KEY") + .map(String::as_str), + Some("fresh-value"), + "fix: the head's env var survives" + ); + assert!( + !published.config.env_vars.contains_key("STALE_KEY"), + "fix: the stale disk env var is NOT resurrected" + ); + + // NEGATIVE CONTROL: with no overlay patch (e.g. pre-hydration, or an agent + // the relay has never described) the same code path must fall through to + // the disk record unchanged — the fix must not blank config on a device + // that legitimately has no relay head to follow. + let empty = PrivateConfigOverlay::default(); + let mut fallback = empty.resolve_local_record(&renamed); + fallback.name.clone_from(&renamed.name); + fallback.display_name.clone_from(&renamed.display_name); + assert_eq!( + fallback.system_prompt, + Some("STALE disk prompt".into()), + "control: with no patch the disk value is preserved, not cleared" + ); + assert_eq!(fallback.parallelism, 1); + assert_eq!( + fallback.name, "Paul Atreides", + "control: rename still lands" + ); +} + +/// EVA PROBE (item 2, third-party audit of `agents.rs:276`): the +/// persona-snapshot re-apply in `start_local_agent_pairs_with_preflight` +/// loads the DISK record, calls `apply_persona_snapshot` (which overwrites +/// only the definition quad: system_prompt/model/provider/runtime), saves, +/// and retains. On a device following a NEWER relay head, do the NON-quad +/// fields (parallelism, env overrides, name...) republish from stale disk? +#[test] +fn eva_probe_pair_start_snapshot_reapply_republishes_stale_nonquad_fields() { + 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 owner_hex = owner_keys.public_key().to_hex(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + + // Device B's disk record: stale on non-quad fields. + let mut disk = sample_record(&pubkey, "stale-disk-name"); + disk.private_key_nsec = agent_keys.secret_key().to_bech32().unwrap(); + disk.parallelism = 1; + disk.env_vars = BTreeMap::from([("STALE_KEY".to_string(), "stale".to_string())]); + disk.persona_id = Some("test-persona".to_string()); + + // Fresher relay head from device A: gen 5, future created_at. + let mut fresh = disk.clone(); + fresh.name = "FRESH relay name".into(); + fresh.parallelism = 16; + fresh.env_vars = BTreeMap::from([("FRESH_KEY".to_string(), "fresh".to_string())]); + let head_created_at = nostr::Timestamp::now().as_secs() as i64 + 10_000; + let head_payload = + private_payload_from_record(&fresh, &owner_hex, 5, Some("aa".repeat(32))).unwrap(); + let head_event = + private_managed_agent::build_event(&owner_keys, &head_payload, head_created_at as u64) + .unwrap(); + crate::managed_agents::retention::retain_inbound_event( + &conn, + &crate::managed_agents::retention::RetainedEvent { + kind: KIND_PRIVATE_MANAGED_AGENT, + pubkey: owner_hex.clone(), + d_tag: pubkey.clone(), + content: head_event.content.clone(), + created_at: head_created_at, + raw_event: head_event.as_json(), + pending_sync: false, + }, + ) + .unwrap(); + + // The site's exact sequence (agents.rs:268-277): persona snapshot applied + // to the DISK record, then retain. The snapshot only touches the quad. + let persona = crate::managed_agents::AgentDefinition { + id: "test-persona".to_string(), + display_name: "Test Persona".to_string(), + avatar_url: None, + system_prompt: "Persona prompt.".to_string(), + runtime: Some("goose".to_string()), + model: Some("claude-opus-4".to_string()), + provider: Some("anthropic".to_string()), + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + }; + let mut site_record = disk.clone(); + crate::managed_agents::persona_events::apply_persona_snapshot(&mut site_record, &persona); + let changed = retain_agent_record(&conn, &owner_keys, &site_record).unwrap(); + + let row = get_retained_event(&conn, KIND_PRIVATE_MANAGED_AGENT, &owner_hex, &pubkey) + .unwrap() + .unwrap(); + let (_, republished) = private_managed_agent::validate_and_decrypt( + &nostr::Event::from_json(&row.raw_event).unwrap(), + &owner_keys, + ) + .unwrap(); + + // DEFECT (if these pass): non-quad stale fields became the new head. + assert!(changed, "retain reported a change"); + assert_eq!(republished.config.name, "stale-disk-name"); + assert_eq!(republished.config.parallelism, Some(1)); + assert!( + republished.config.env_vars.contains_key("STALE_KEY"), + "stale env override resurrected" + ); + assert!( + !republished.config.env_vars.contains_key("FRESH_KEY"), + "head's env dropped — replace, not merge" + ); + assert!( + row.created_at > head_created_at, + "stale write outranks head" + ); + assert_eq!(republished.generation, 6, "validly chained gen bump"); + assert_eq!( + republished.previous_event_id, + Some(head_event.id.to_hex()), + "chains cleanly off the head it clobbers" + ); +} + +/// SAMI FIX VERIFICATION for `agents.rs:276` (red-first probe above is Eva's). +/// The shipped shape is resolve-overlay → `apply_persona_snapshot` → retain. +/// Both halves of that ordering are asserted, because each direction has its +/// own failure mode: +/// * resolve BEFORE the snapshot → non-quad fields come from the relay head +/// (fixes the stale republish), and +/// * snapshot AFTER the resolve → the definition quad stays +/// definition-authoritative rather than being clobbered by the overlay. +/// +/// A test asserting only the first half would pass with the calls in the wrong +/// order, since the overlay also carries system_prompt/model/provider/runtime. +#[test] +fn sami_fix_pair_start_resolve_then_snapshot_keeps_quad_definition_authoritative() { + use crate::managed_agents::private_config_overlay::{PrivateConfigOverlay, PrivateConfigPatch}; + + 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 owner_hex = owner_keys.public_key().to_hex(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + + let mut disk = sample_record(&pubkey, "stale-disk-name"); + disk.private_key_nsec = agent_keys.secret_key().to_bech32().unwrap(); + disk.parallelism = 1; + disk.env_vars = BTreeMap::from([("STALE_KEY".to_string(), "stale".to_string())]); + disk.persona_id = Some("test-persona".to_string()); + + // Relay head: fresher non-quad fields AND a quad the persona disagrees + // with, so the two halves of the ordering are separable. + let mut fresh = disk.clone(); + fresh.name = "FRESH relay name".into(); + fresh.parallelism = 16; + fresh.env_vars = BTreeMap::from([("FRESH_KEY".to_string(), "fresh".to_string())]); + fresh.system_prompt = Some("RELAY prompt (must lose to the persona)".into()); + fresh.model = Some("relay-model".into()); + let head_created_at = nostr::Timestamp::now().as_secs() as i64 + 10_000; + let head_payload = + private_payload_from_record(&fresh, &owner_hex, 5, Some("aa".repeat(32))).unwrap(); + let head_event = + private_managed_agent::build_event(&owner_keys, &head_payload, head_created_at as u64) + .unwrap(); + crate::managed_agents::retention::retain_inbound_event( + &conn, + &crate::managed_agents::retention::RetainedEvent { + kind: KIND_PRIVATE_MANAGED_AGENT, + pubkey: owner_hex.clone(), + d_tag: pubkey.clone(), + content: head_event.content.clone(), + created_at: head_created_at, + raw_event: head_event.as_json(), + pending_sync: false, + }, + ) + .unwrap(); + + let mut overlay = PrivateConfigOverlay::default(); + overlay.insert_patch(PrivateConfigPatch::from_payload(head_payload).unwrap()); + + let persona = crate::managed_agents::AgentDefinition { + id: "test-persona".to_string(), + display_name: "Test Persona".to_string(), + avatar_url: None, + system_prompt: "PERSONA prompt.".to_string(), + runtime: Some("goose".to_string()), + model: Some("persona-model".to_string()), + provider: Some("anthropic".to_string()), + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2025-01-01T00:00:00Z".to_string(), + updated_at: "2025-01-01T00:00:00Z".to_string(), + }; + + // The FIXED site sequence: resolve, then snapshot, then retain. + let mut site_record = overlay.resolve_local_record(&disk); + crate::managed_agents::persona_events::apply_persona_snapshot(&mut site_record, &persona); + retain_agent_record(&conn, &owner_keys, &site_record).unwrap(); + + let row = get_retained_event(&conn, KIND_PRIVATE_MANAGED_AGENT, &owner_hex, &pubkey) + .unwrap() + .unwrap(); + let (_, published) = private_managed_agent::validate_and_decrypt( + &nostr::Event::from_json(&row.raw_event).unwrap(), + &owner_keys, + ) + .unwrap(); + + // HALF 1 — resolve-before: non-quad fields are the RELAY head's, not stale disk. + assert_eq!(published.config.name, "FRESH relay name"); + assert_eq!(published.config.parallelism, Some(16)); + assert!( + published.config.env_vars.contains_key("FRESH_KEY"), + "head's env override survives" + ); + assert!( + !published.config.env_vars.contains_key("STALE_KEY"), + "stale disk env override is NOT resurrected" + ); + + // HALF 2 — snapshot-after: the definition quad is the PERSONA's, not the + // overlay's. This is the assertion that fails if the two calls are swapped. + assert_eq!( + published.config.system_prompt, + Some("PERSONA prompt.".into()), + "definition quad stays definition-authoritative after the resolve" + ); + assert_eq!(published.config.model, Some("persona-model".into())); + + // NEGATIVE CONTROL: with no overlay patch the site must fall through to the + // disk record — the fix must not blank config on a device that has no relay + // head to follow. + let empty = PrivateConfigOverlay::default(); + let mut fallback = empty.resolve_local_record(&disk); + crate::managed_agents::persona_events::apply_persona_snapshot(&mut fallback, &persona); + assert_eq!( + fallback.parallelism, 1, + "control: with no patch the disk value is preserved, not cleared" + ); + assert!( + fallback.env_vars.contains_key("STALE_KEY"), + "control: disk env override preserved when there is no relay head" + ); + assert_eq!( + fallback.system_prompt, + Some("PERSONA prompt.".into()), + "control: quad still definition-authoritative" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 7e97fa1f56..50cfaf8a01 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -432,6 +432,43 @@ pub fn has_retained_personas(conn: &Connection, pubkey: &str) -> Result Result, String> { + let mut stmt = conn + .prepare( + "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync + FROM persona_events + WHERE kind = ?1 AND pubkey = ?2 + ORDER BY d_tag", + ) + .map_err(|e| format!("failed to prepare retained-kind query: {e}"))?; + + let rows = stmt + .query_map(params![kind, pubkey], |row| { + Ok(RetainedEvent { + kind: row.get(0)?, + pubkey: row.get(1)?, + d_tag: row.get(2)?, + content: row.get(3)?, + created_at: row.get(4)?, + raw_event: row.get(5)?, + pending_sync: row.get::<_, i32>(6)? != 0, + }) + }) + .map_err(|e| format!("failed to query retained events by kind: {e}"))?; + + rows.collect::, _>>() + .map_err(|e| format!("failed to read retained event row: {e}")) +} + /// Look up a single retained event by its coordinate. pub fn get_retained_event( conn: &Connection, diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 652bb9b9ea..c789c59c54 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -302,7 +302,7 @@ pub(crate) fn backup_invalid_store(path: &Path) { /// writes clean JSON and plaintext stops lingering on disk; if still /// unreachable, leave it inline. This makes the strip deterministic on the /// next reachable boot rather than waiting for a non-deterministic save. -fn hydrate_keys(records: &mut [ManagedAgentRecord]) { +pub(crate) fn hydrate_keys(records: &mut [ManagedAgentRecord]) { let Some(store) = agent_secret_store() else { return; }; From aa39d72aac57aeda49cb3c38db7c9c8ed4af24f1 Mon Sep 17 00:00:00 2001 From: Sami Date: Thu, 6 Aug 2026 13:19:49 -0400 Subject: [PATCH 4/5] fix(agents): stop boot reconcile republishing stale config over a newer head Boot reconcile is a fourth stale-disk republish site, in the same class as the three write sites fixed in the previous commit but worse: it fires at launch, unprompted, for every agent on a device that follows another device's config. `reconcile_agents_in_dir_at` reads `managed-agents.json` raw and cannot resolve the private-config overlay -- `hydrate_private_config_overlay` runs after this leg (`event_sync.rs:19-20`) and reads the rows this leg writes. Inbound kind:30179 updates the overlay and retention but never the JSON, so on a follower disk is stale by construction. Rebuilding the 30179 projection from disk then republishes every stale field over device A's newer head as a validly chained gen+1 successor, and `monotonic_created_at` floors it at head+1 so it wins LWW. Measured: gen 5 -> 6, `prev` = the clobbered head, `created_at` = head+1 against a head 10,000s in the future, `pending_sync` set, and every field (name, system_prompt, parallelism, env_vars) taken from stale disk. It also does not self-heal. A second boot is a clean no-op because disk now matches the head it wrote, but each new head device A publishes re-arms it: measured 16 -> 1, no-op, then 24 -> 1. The follower's disk wins every round and the user on A sees their edit silently revert. The previous commit's keyring hydration is what makes this reachable. Before it, `retain_private_agent_record`'s empty-nsec skip returned early for every keyring-resident record, so boot never built a 30179 at all -- the skip was incidentally protecting this path. Hydrating keys is still correct (an untouched agent must publish its first 30179 on a default build), but it exposed everything downstream of the guard it removed. A control arm with an absent nsec confirms the head survives, pinning the causal line. Fix: `retain_agent_record_at_boot` publishes the 30179 only when no retained head exists, and is used by boot reconcile alone. That keeps the requirement boot exists to serve -- an agent whose nsec lives in the keyring gets its FIRST private config published -- while leaving an existing head to the interactive edit paths, which resolve the overlay before retaining and so author from relay-fresh state. The kind:30177 identity leg is untouched, so the upgrade republish waves keep working. Resolving the overlay at boot instead was rejected and is pinned by a permanent wrong-fix probe: an offline local edit lives on disk and in an unflushed `pending_sync` 30179, so resolving disk through an overlay hydrated from the older head would discard it -- the centralized-resolve failure from the previous commit, with boot's blast radius. Tests (4): the fix verification asserts the head is byte-identical after boot and nothing is enqueued; two requirement-preservation arms (first 30179 still published when no head exists; 30177 still republishes when a private head is present) so the fix cannot be satisfied by never publishing at boot or by gating at the wrong level; and the wrong-fix probe. Three mutants, each killed by a different arm: gate deleted, gate inverted, gate applied to the whole record instead of the private leg. Mutants re-run after cargo fmt. Desktop lib suite 2360 passed / 0 failed / 15 ignored (--all-features); cargo fmt --check, cargo clippy --workspace --all-targets --all-features -D warnings, and the desktop file-size ratchet (against the CI base) all clean -- the ratchet verified live with a padding control that fails it. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../src-tauri/src/managed_agents/reconcile.rs | 47 +++- .../reconcile/tests/stale_republish_tests.rs | 221 ++++++++++++++++++ 2 files changed, 267 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/managed_agents/reconcile.rs b/desktop/src-tauri/src/managed_agents/reconcile.rs index 8a47aff6ac..2ddd311190 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile.rs @@ -115,7 +115,7 @@ fn reconcile_agents_in_dir_at( continue; } - if retain_agent_record(&conn, keys, record)? { + if retain_agent_record_at_boot(&conn, keys, record)? { reconciled += 1; } } @@ -123,6 +123,51 @@ fn reconcile_agents_in_dir_at( Ok(reconciled) } +/// Boot-only variant of [`retain_agent_record`]: reconciles the kind:30177 +/// identity record exactly as the interactive paths do, but publishes the +/// kind:30179 private config **only when no retained head exists**. +/// +/// Boot reads `managed-agents.json` raw — there is no overlay to resolve +/// against, because `hydrate_private_config_overlay` runs after this leg and +/// depends on the very rows written here. On a device that FOLLOWS another +/// device's config, disk is stale by construction (inbound 30179 updates the +/// overlay and retention, never the JSON), so rebuilding the 30179 projection +/// from disk republishes every stale field over a newer head as an audit-clean +/// gen+1 successor, and `monotonic_created_at` makes it win LWW. That fires at +/// launch, unprompted, and re-arms on every new head the follower receives. +/// +/// Restricting boot to the head-absent case keeps the requirement boot exists +/// to serve — an agent whose nsec lives in the keyring must get its FIRST +/// 30179 published — while leaving an existing head to the interactive edit +/// paths, which resolve the overlay before retaining and so author from +/// relay-fresh state. Every 30177 (no-secrets projection) behaves exactly as +/// before: the upgrade republish waves run on that kind, not this one. +fn retain_agent_record_at_boot( + 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_head = get_retained_event( + &transaction, + KIND_PRIVATE_MANAGED_AGENT, + &keys.public_key().to_hex(), + &record.pubkey, + )?; + let private_changed = if private_head.is_some() { + false + } else { + 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) +} + /// Retain `record`'s kind:30177 identity record, marking it `pending_sync` /// for the flush loop, when its projection differs from the retained head. /// Returns `Ok(true)` when a row was (re)written and `Ok(false)` when the diff --git a/desktop/src-tauri/src/managed_agents/reconcile/tests/stale_republish_tests.rs b/desktop/src-tauri/src/managed_agents/reconcile/tests/stale_republish_tests.rs index a0e8499bd3..6a895c84b9 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile/tests/stale_republish_tests.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile/tests/stale_republish_tests.rs @@ -702,3 +702,224 @@ fn sami_fix_pair_start_resolve_then_snapshot_keeps_quad_definition_authoritative "control: quad still definition-authoritative" ); } + +// ── Review item 5: boot reconcile as a stale-republish site ───────────────── +// +// `reconcile_agents_in_dir_at` reads `managed-agents.json` raw and cannot +// resolve the overlay: `hydrate_private_config_overlay` runs AFTER this leg +// (`event_sync.rs:19-20`) and reads the rows this leg writes. On a following +// device, disk is stale by construction, so rebuilding the 30179 from disk is +// the item-2 clobber with no user action at all. + +/// Builds the follower fixture: a stale disk store plus a NEWER inbound 30179 +/// head (`pending_sync = 0`, far-future `created_at`). Returns the head event. +fn seed_follower_with_newer_head( + dir: &TempDir, + owner_keys: &nostr::Keys, + disk: &ManagedAgentRecord, + fresh: &ManagedAgentRecord, + generation: u64, + created_at: i64, +) -> nostr::Event { + let owner_hex = owner_keys.public_key().to_hex(); + let payload = + private_payload_from_record(fresh, &owner_hex, generation, Some("aa".repeat(32))).unwrap(); + let event = + private_managed_agent::build_event(owner_keys, &payload, created_at as u64).unwrap(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + crate::managed_agents::retention::retain_inbound_event( + &conn, + &RetainedEvent { + kind: KIND_PRIVATE_MANAGED_AGENT, + pubkey: owner_hex, + d_tag: disk.pubkey.clone(), + content: event.content.clone(), + created_at, + raw_event: event.as_json(), + pending_sync: false, + }, + ) + .unwrap(); + event +} + +fn retained_private_row(dir: &TempDir, owner_keys: &nostr::Keys, pubkey: &str) -> RetainedEvent { + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + get_retained_event( + &conn, + KIND_PRIVATE_MANAGED_AGENT, + &owner_keys.public_key().to_hex(), + pubkey, + ) + .unwrap() + .unwrap() +} + +/// Item 5 FIX: boot reconcile must leave an existing 30179 head alone. +/// +/// Red-first against `retain_agent_record` at boot: the probe measured +/// `name="stale-disk-name"`, `parallelism=Some(1)`, gen 5→6, `prev` = the +/// clobbered head, `created_at` = head+1 (so it wins LWW), `pending_sync=true` +/// — every stale disk field published over device A's newer config at launch, +/// with no user action. +#[test] +fn boot_reconcile_leaves_existing_private_head_intact() { + 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 disk = sample_record(&pubkey, "stale-disk-name"); + disk.private_key_nsec = agent_keys.secret_key().to_bech32().unwrap(); + disk.system_prompt = Some("STALE disk prompt".into()); + disk.parallelism = 1; + disk.env_vars = BTreeMap::from([("STALE_KEY".to_string(), "stale".to_string())]); + write_store(&dir, &[disk.clone()]); + + let mut fresh = disk.clone(); + fresh.name = "FRESH relay name".into(); + fresh.system_prompt = Some("FRESH relay prompt".into()); + fresh.parallelism = 16; + fresh.env_vars = BTreeMap::from([("FRESH_KEY".to_string(), "fresh".to_string())]); + let head_created_at = nostr::Timestamp::now().as_secs() as i64 + 10_000; + let head_event = + seed_follower_with_newer_head(&dir, &owner_keys, &disk, &fresh, 5, head_created_at); + + // BOOT. No user action. + reconcile_agents_in_dir(dir.path(), &owner_keys).unwrap(); + + let row = retained_private_row(&dir, &owner_keys, &pubkey); + assert_eq!( + row.raw_event, + head_event.as_json(), + "boot reconcile must not rebuild the 30179 from stale disk over an \ + existing head — byte-identical, so no gen bump and no re-encryption" + ); + // Not merely equal-by-content: nothing was queued for publish either. + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + assert!( + get_pending_sync(&conn) + .unwrap() + .iter() + .all(|event| event.kind != KIND_PRIVATE_MANAGED_AGENT), + "no stale 30179 enqueued for relay publish" + ); +} + +/// The requirement item 1 exists to serve, preserved: an agent with NO retained +/// 30179 head still publishes its first one at boot. Without this arm the fix +/// above is satisfied by never publishing a 30179 at boot at all — which is the +/// item-1 bug restored. +#[test] +fn boot_reconcile_still_publishes_first_private_config() { + 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, "untouched-agent"); + record.private_key_nsec = agent_keys.secret_key().to_bech32().unwrap(); + record.parallelism = 7; + write_store(&dir, &[record]); + + assert_eq!(reconcile_agents_in_dir(dir.path(), &owner_keys).unwrap(), 1); + + let row = retained_private_row(&dir, &owner_keys, &pubkey); + let (_, payload) = private_managed_agent::validate_and_decrypt( + &nostr::Event::from_json(&row.raw_event).unwrap(), + &owner_keys, + ) + .unwrap(); + assert_eq!(payload.generation, 1); + assert_eq!(payload.previous_event_id, None); + assert_eq!(payload.config.parallelism, Some(7)); + assert!(row.pending_sync, "first 30179 is queued for publish"); +} + +/// The 30177 leg must be untouched by the 30179 gate: an edited record whose +/// PUBLIC projection changed still republishes at boot even though a private +/// head exists. This is what keeps the upgrade republish waves +/// (`slimming_republish_wave_is_one_time`) working, and it fails if the gate is +/// written at the wrong level (skipping the whole record instead of the 30179). +#[test] +fn boot_reconcile_still_republishes_public_projection_with_private_head_present() { + 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 disk = sample_record(&pubkey, "public-name-v2"); + disk.private_key_nsec = agent_keys.secret_key().to_bech32().unwrap(); + write_store(&dir, &[disk.clone()]); + + let mut fresh = disk.clone(); + fresh.parallelism = 16; + let head_created_at = nostr::Timestamp::now().as_secs() as i64 + 10_000; + seed_follower_with_newer_head(&dir, &owner_keys, &disk, &fresh, 5, head_created_at); + + assert_eq!( + reconcile_agents_in_dir(dir.path(), &owner_keys).unwrap(), + 1, + "the 30177 identity projection still reconciles at boot" + ); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + let public_row = get_retained_event( + &conn, + KIND_MANAGED_AGENT, + &owner_keys.public_key().to_hex(), + &pubkey, + ) + .unwrap() + .unwrap(); + assert!(public_row.content.contains("public-name-v2")); + assert!(public_row.pending_sync); +} + +/// WRONG-FIX PROBE (permanent): the tempting alternative is to resolve the +/// overlay inside boot reconcile (swapping the hydrate/reconcile order in +/// `run_event_sync`). It is wrong for the same reason the centralized resolve +/// was wrong at the edit site — but with a worse blast radius, because boot +/// touches EVERY agent rather than the one being edited. +/// +/// A local edit made while the relay was unreachable lives on disk AND in a +/// `pending_sync` 30179 that never flushed. Resolving disk through an overlay +/// hydrated from the last-known head would rebuild the payload from that older +/// head and discard the edit — at launch, silently, for every agent. +#[test] +fn probe_resolving_overlay_at_boot_would_discard_unflushed_local_edits() { + use crate::managed_agents::private_config_overlay::{PrivateConfigOverlay, PrivateConfigPatch}; + + let owner_keys = nostr::Keys::generate(); + let agent_keys = nostr::Keys::generate(); + let pubkey = agent_keys.public_key().to_hex(); + let owner_hex = owner_keys.public_key().to_hex(); + + // The last head this device saw, which is what a boot-time overlay would + // hydrate from. + let mut head = sample_record(&pubkey, "head-name"); + head.private_key_nsec = agent_keys.secret_key().to_bech32().unwrap(); + head.parallelism = 16; + let head_payload = private_payload_from_record(&head, &owner_hex, 3, None).unwrap(); + let mut overlay = PrivateConfigOverlay::default(); + overlay.insert_patch(PrivateConfigPatch::from_payload(head_payload).unwrap()); + + // The user's offline edit, on disk and not yet flushed to the relay. + let mut disk = head.clone(); + disk.parallelism = 2; + + assert_eq!( + overlay.resolve_local_record(&disk).parallelism, + 16, + "resolving at boot DISCARDS the unflushed local edit (2 -> 16); this is \ + why the fix is a head-presence gate, not a resolve" + ); + // Positive control: with no patch the edit survives, so the discard above + // is the overlay winning rather than a broken fixture. + assert_eq!( + PrivateConfigOverlay::default() + .resolve_local_record(&disk) + .parallelism, + 2, + "control: without an overlay patch the offline edit survives" + ); +} From 6f486e88fd05c36a9192d79749652515728a57f3 Mon Sep 17 00:00:00 2001 From: Sami Date: Thu, 6 Aug 2026 17:50:35 -0400 Subject: [PATCH 5/5] fix(agents): write self-authored config back to the private-config overlay The overlay only ever learned config from events this device RECEIVED. Both fill paths are inbound-only: `insert_patch` on an `Applied` inbound event (`personas/inbound.rs:228`) and boot hydration (`hydrate_from_retention`). Neither fires for an event this device authored -- the relay's echo of our own event dedupes to `Skipped` in `retain_inbound_event`, because the row is already retained. So the overlay stays pinned at the last received generation for the whole session. `update_managed_agent` resolves that patch onto the disk record, applies the user's edit, saves, and retains -- correct for ONE edit. The SECOND edit in the same session resolves the same stale patch onto the now fresher disk record, reverting the first edit, and publishes the reversion as an audit-clean gen+1 successor that wins LWW. The resolve result is written back to disk, so the revert is durable, not just in-flight. That is Max's live gate red on a single backend: gen 3 published parallelism 19, the following rename returned 17 and published 17 as gen 4. It stands independent of the Device-A/B attribution he retracted. Fix: after `retain_agent_record` commits, read the just-retained kind:30179 head back out of the same connection and `insert_patch` it. One seam -- `retain_managed_agent_pending` -- covers all five writers (create, settings, edit/rename, start, rename-rollback) with no per-caller copies and no new-writer trap. `reconcile.rs` is untouched: `retain_agent_record` takes `conn`+`keys` and threading `AppState` through it would drag the boot reconcile into the diff for nothing. No new lock edge: callers already hold `managed_agents_store_lock` and the overlay lock is taken under it, the same order `resolved_local_record` uses. Decode is factored into `patch_from_retained_row`, shared with boot hydration, so both learn config through exactly one path. Absorbing unconditionally (not gated on the retain reporting a change) keeps the overlay from ever running ahead of retention: every insert comes from a row read back out of the database. A missing or undecodable head leaves the current entry alone rather than clearing it. Coverage. `sami_second_edit_in_one_session_preserves_the_first_edit` runs the two-edit sequence through the real retention engine, seeding gen 2 via `retain_inbound_event` + `hydrate_from_retention` so the overlay is populated exactly as boot populates it. Its negative control runs the same sequence without the write-through and asserts the revert to 17, so the main assertion cannot pass vacuously. `absorb_retained_head_leaves_the_overlay_alone_when_ there_is_no_head` pins the no-clear contract with a positive control proving the same call DOES update on a present head. Both behavioural tests model the helper body -- every caller is inside a `#[tauri::command]` needing a live `AppHandle`, so deleting the production call leaves them green (the failure mode already documented on `write_site_resolve_guard`). Three source guards close that: exact call count of 1, source order retain-before-absorb, and a negative control proving the searched literals are load-bearing. Mutants, 4/4 killed: (1) `absorb_retained_head` body no-oped -> the red test fails with exactly the live symptom, `left: Some(17) right: Some(19)`; (2) production call deleted -> both source guards fail; (3) production call moved before the retain -> the order guard fails; (4) test helper's order swapped -> the behavioural test fails. Gate at this tree: desktop lib suite 2272 passed / 0 failed / 14 ignored, `cargo clippy --workspace --all-targets` clean, `cargo fmt --check` clean, `check-file-sizes` rc=0 with CHECK_FILE_SIZES_BASE pinned to the merge-base with origin/main. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- desktop/src-tauri/src/commands/agents.rs | 17 +- .../managed_agents/private_config_overlay.rs | 67 +++- .../src/managed_agents/reconcile/tests.rs | 1 + .../tests/self_authored_overlay_tests.rs | 297 ++++++++++++++++++ 4 files changed, 371 insertions(+), 11 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/reconcile/tests/self_authored_overlay_tests.rs diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 4a4857321f..a08f34116b 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -39,6 +39,16 @@ pub(super) fn workspace_owner_hex(state: &AppState) -> Result { /// only runtime fields produces an identical row and never re-enqueues a /// publish. Best-effort: a failure here is logged and swallowed so a retention /// hiccup never blocks the disk-authoritative write. +/// +/// Also writes the just-retained kind:30179 head back through to the in-memory +/// overlay. This is the ONLY path by which the overlay learns config this +/// device authored — inbound `insert_patch` never fires for our own event +/// (the relay echo dedupes to `Skipped`) and boot hydration runs once per +/// launch — so without it a second edit in the same session resolves the +/// stale patch onto the fresher disk record and publishes a silent revert of +/// the first edit. Overlay lock is taken UNDER `managed_agents_store_lock`, +/// which every caller already holds: the established order, same as +/// `resolved_local_record`. pub(super) fn retain_managed_agent_pending( app: &AppHandle, state: &AppState, @@ -52,7 +62,12 @@ pub(super) fn retain_managed_agent_pending( // Shared engine with the boot-time reconcile: projection content diff // (no republish for runtime-only churn) + monotonic created_at bump // past the retained head (NIP-AP step 3). - retain_agent_record(&conn, &scope.owner_keys, record).map(|_| ()) + retain_agent_record(&conn, &scope.owner_keys, record)?; + state + .private_managed_agent_overlay + .lock() + .map_err(|error| error.to_string())? + .absorb_retained_head(&conn, &scope.owner_keys, &record.pubkey) })(); if let Err(e) = result { eprintln!("buzz-desktop: agent-retain: {e}"); diff --git a/desktop/src-tauri/src/managed_agents/private_config_overlay.rs b/desktop/src-tauri/src/managed_agents/private_config_overlay.rs index 68f5fa8c8f..d61dc45115 100644 --- a/desktop/src-tauri/src/managed_agents/private_config_overlay.rs +++ b/desktop/src-tauri/src/managed_agents/private_config_overlay.rs @@ -213,6 +213,44 @@ impl PrivateConfigOverlay { self.0.remove(pubkey); } + /// Write-through for a SELF-AUTHORED retain: adopt the kind:30179 head this + /// device just wrote as the config it is following. + /// + /// Both existing fill paths are inbound-only — `insert_patch` on an + /// `Applied` inbound event (`personas/inbound.rs`) and boot hydration + /// below — and neither fires for an event this device authored: the + /// relay's echo of our own event dedupes to `Skipped` against the row we + /// already retained. So without this the overlay stays pinned at the last + /// *received* generation, and the NEXT edit resolves that stale patch on + /// top of the fresher disk record, silently reverting the previous edit + /// and publishing the reversion as a valid successor. + /// + /// Absorbing unconditionally (not only when the retain reported a change) + /// is safe and strictly convergent: the overlay is never ahead of + /// retention — every insert either comes from a row written in the same + /// step or is read back out of retention. A missing/undecodable head + /// leaves the current entry alone rather than clearing it. + pub(crate) fn absorb_retained_head( + &mut self, + conn: &rusqlite::Connection, + owner_keys: &nostr::Keys, + agent_pubkey: &str, + ) -> Result<(), String> { + let row = crate::managed_agents::retention::get_retained_event( + conn, + buzz_core_pkg::kind::KIND_PRIVATE_MANAGED_AGENT, + &owner_keys.public_key().to_hex(), + agent_pubkey, + )?; + if let Some(patch) = row + .as_ref() + .and_then(|row| patch_from_retained_row(&row.raw_event, owner_keys)) + { + self.insert_patch(patch); + } + Ok(()) + } + pub(crate) fn resolve_local_record(&self, record: &ManagedAgentRecord) -> ManagedAgentRecord { let mut resolved = record.clone(); if let Some(patch) = self.0.get(&record.pubkey) { @@ -437,6 +475,23 @@ mod tests { } } +/// Decode one retained kind:30179 row into a patch. Shared by boot hydration +/// and the self-authored write-through so both learn config through exactly +/// one decode path. Best-effort: a row that fails to parse, decrypt, or +/// validate yields `None` rather than an error, matching the inbound path's +/// per-record reject. +fn patch_from_retained_row( + raw_event: &str, + owner_keys: &nostr::Keys, +) -> Option { + use buzz_core_pkg::private_managed_agent; + use nostr::JsonUtil; + + let event = nostr::Event::from_json(raw_event).ok()?; + let (_, payload) = private_managed_agent::validate_and_decrypt(&event, owner_keys).ok()?; + PrivateConfigPatch::from_payload(payload).ok() +} + /// Rebuild the in-memory overlay from the retained kind:30179 rows. /// /// The inbound path only calls `insert_patch` when `retain_inbound_event` @@ -454,8 +509,7 @@ pub(crate) fn hydrate_from_retention( conn: &rusqlite::Connection, owner_keys: &nostr::Keys, ) -> Result { - use buzz_core_pkg::{kind::KIND_PRIVATE_MANAGED_AGENT, private_managed_agent}; - use nostr::JsonUtil; + use buzz_core_pkg::kind::KIND_PRIVATE_MANAGED_AGENT; let rows = crate::managed_agents::retention::get_retained_events_of_kind( conn, @@ -465,14 +519,7 @@ pub(crate) fn hydrate_from_retention( let mut overlay = PrivateConfigOverlay::default(); for row in rows { - let Ok(event) = nostr::Event::from_json(&row.raw_event) else { - continue; - }; - let Ok((_, payload)) = private_managed_agent::validate_and_decrypt(&event, owner_keys) - else { - continue; - }; - if let Ok(patch) = PrivateConfigPatch::from_payload(payload) { + if let Some(patch) = patch_from_retained_row(&row.raw_event, owner_keys) { overlay.insert_patch(patch); } } diff --git a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs index 891afd53b9..55599ef249 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs @@ -555,4 +555,5 @@ fn retain_agent_record_is_noop_when_unchanged() { ); } +mod self_authored_overlay_tests; mod stale_republish_tests; diff --git a/desktop/src-tauri/src/managed_agents/reconcile/tests/self_authored_overlay_tests.rs b/desktop/src-tauri/src/managed_agents/reconcile/tests/self_authored_overlay_tests.rs new file mode 100644 index 0000000000..1f696540c2 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/reconcile/tests/self_authored_overlay_tests.rs @@ -0,0 +1,297 @@ +//! Regression coverage for the SELF-AUTHORED overlay write-through (defect 5): +//! the overlay only ever learned config from events this device *received*, so +//! a second edit in the same session resolved a stale patch onto the fresher +//! disk record and published a silent revert of the first edit. +//! +//! Split out of `stale_republish_tests.rs` to stay inside the desktop +//! file-size ratchet. Shares the parent module's fixtures (`sample_record`, +//! `private_payload_from_record`, `retain_agent_record`) via `use super::*`. + +use super::*; +use crate::managed_agents::private_config_overlay::{hydrate_from_retention, PrivateConfigOverlay}; + +/// The production body of `retain_managed_agent_pending` +/// (`commands/agents.rs:42`) minus its `AppHandle`/`AppState` plumbing: retain, +/// then write the just-retained head through to the overlay. The command is a +/// `#[tauri::command]` descendant needing a live `AppHandle`, so this models +/// the ordering; `retain_managed_agent_pending_writes_through_to_the_overlay` +/// below pins that production actually calls it. +fn retain_and_absorb( + conn: &rusqlite::Connection, + overlay: &mut PrivateConfigOverlay, + owner_keys: &nostr::Keys, + record: &ManagedAgentRecord, +) { + retain_agent_record(conn, owner_keys, record).unwrap(); + overlay + .absorb_retained_head(conn, owner_keys, &record.pubkey) + .unwrap(); +} + +fn published_head( + conn: &rusqlite::Connection, + owner_keys: &nostr::Keys, + pubkey: &str, +) -> buzz_core_pkg::private_managed_agent::Payload { + let row = get_retained_event( + conn, + KIND_PRIVATE_MANAGED_AGENT, + &owner_keys.public_key().to_hex(), + pubkey, + ) + .unwrap() + .unwrap(); + let (_, payload) = private_managed_agent::validate_and_decrypt( + &nostr::Event::from_json(&row.raw_event).unwrap(), + owner_keys, + ) + .unwrap(); + payload +} + +/// SAMI RED-FIRST (defect 5, the live gate red Max captured): two edits in ONE +/// session on ONE device. The first edit publishes parallelism 19; the second +/// edit — a rename, touching no other field — must not take parallelism back +/// to the received head's 17. +/// +/// Discriminator is the PARALLELISM, not the name: Max retracted the Device-B +/// attribution (his broker had a single global native connection and no device +/// routing), so the name-reversion leg is unattributed. The +/// gen3-returns-19 → gen4-publishes-17 sequence is receipt-backed on a single +/// backend regardless of which app served it. +#[test] +fn sami_second_edit_in_one_session_preserves_the_first_edit() { + 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 owner_hex = owner_keys.public_key().to_hex(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + + // Gen 2: the head this device RECEIVED, parallelism 17. Seeded through the + // real inbound path so the overlay is hydrated exactly as boot hydrates it. + let mut received = sample_record(&pubkey, "Fizz Relay"); + received.private_key_nsec = agent_keys.secret_key().to_bech32().unwrap(); + received.parallelism = 17; + let received_created_at = nostr::Timestamp::now().as_secs() as i64; + let received_payload = + private_payload_from_record(&received, &owner_hex, 2, Some("aa".repeat(32))).unwrap(); + let received_event = private_managed_agent::build_event( + &owner_keys, + &received_payload, + received_created_at as u64, + ) + .unwrap(); + crate::managed_agents::retention::retain_inbound_event( + &conn, + &RetainedEvent { + kind: KIND_PRIVATE_MANAGED_AGENT, + pubkey: owner_hex.clone(), + d_tag: pubkey.clone(), + content: received_event.content.clone(), + created_at: received_created_at, + raw_event: received_event.as_json(), + pending_sync: false, + }, + ) + .unwrap(); + let mut overlay = hydrate_from_retention(&conn, &owner_keys).unwrap(); + assert_eq!(overlay.len(), 1, "fixture: the overlay follows gen 2"); + + // Disk agrees with the head at the start of the session. + let mut disk = received.clone(); + + // EDIT 1 — parallelism 17 -> 19. `update_managed_agent`'s shape: resolve + // the overlay onto the disk record, apply the user's patch, SAVE to disk, + // then retain. + let mut edited = overlay.resolve_local_record(&disk); + edited.parallelism = 19; + disk = edited.clone(); + retain_and_absorb(&conn, &mut overlay, &owner_keys, &disk); + + let after_edit = published_head(&conn, &owner_keys, &pubkey); + assert_eq!(after_edit.generation, 3); + assert_eq!( + after_edit.config.parallelism, + Some(19), + "edit 1 published the user's value" + ); + + // EDIT 2 — a rename in the SAME session, touching only `name`. Same shape. + let mut renamed = overlay.resolve_local_record(&disk); + renamed.name = "Fizz Relay Rename".into(); + disk = renamed.clone(); + retain_and_absorb(&conn, &mut overlay, &owner_keys, &disk); + + let after_rename = published_head(&conn, &owner_keys, &pubkey); + assert_eq!(after_rename.generation, 4); + // The intended write lands... + assert_eq!(after_rename.config.name, "Fizz Relay Rename"); + // ...and the FIRST edit survives it. Without the write-through this is + // Some(17): the overlay is still pinned at the received gen-2 patch, so + // resolving it onto disk reverts parallelism, and the revert publishes as + // an audit-clean gen-4 successor that wins LWW. + assert_eq!( + after_rename.config.parallelism, + Some(19), + "the rename reverted the previous edit's parallelism from the stale overlay" + ); + // The revert is durable, not just in-flight: it was written back to disk. + assert_eq!(disk.parallelism, 19, "the reverted value also reached disk"); + + // NEGATIVE CONTROL: the same sequence with the write-through omitted must + // FAIL to preserve the edit, or the assertion above is vacuous — it would + // pass on a fixture where the overlay never had a competing value at all. + let control_conn = open_retention_db(&dir.path().join("control.db")).unwrap(); + crate::managed_agents::retention::retain_inbound_event( + &control_conn, + &RetainedEvent { + kind: KIND_PRIVATE_MANAGED_AGENT, + pubkey: owner_hex.clone(), + d_tag: pubkey.clone(), + content: received_event.content.clone(), + created_at: received_created_at, + raw_event: received_event.as_json(), + pending_sync: false, + }, + ) + .unwrap(); + let control_overlay = hydrate_from_retention(&control_conn, &owner_keys).unwrap(); + let mut control_disk = control_overlay.resolve_local_record(&received); + control_disk.parallelism = 19; + retain_agent_record(&control_conn, &owner_keys, &control_disk).unwrap(); + let mut control_renamed = control_overlay.resolve_local_record(&control_disk); + control_renamed.name = "Fizz Relay Rename".into(); + retain_agent_record(&control_conn, &owner_keys, &control_renamed).unwrap(); + let control = published_head(&control_conn, &owner_keys, &pubkey); + assert_eq!( + control.config.parallelism, + Some(17), + "control: WITHOUT the write-through the same sequence reverts to the \ + received head's 17 — this is the defect the test above pins as fixed" + ); +} + +/// The write-through must never CLEAR what the overlay is following. A retain +/// that produced no private row (empty nsec — the keyring-only case) or a +/// coordinate with no head at all must leave the existing entry alone. +#[test] +fn absorb_retained_head_leaves_the_overlay_alone_when_there_is_no_head() { + 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 owner_hex = owner_keys.public_key().to_hex(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + + let mut record = sample_record(&pubkey, "followed"); + record.private_key_nsec = agent_keys.secret_key().to_bech32().unwrap(); + record.parallelism = 16; + let payload = private_payload_from_record(&record, &owner_hex, 1, None).unwrap(); + let event = private_managed_agent::build_event( + &owner_keys, + &payload, + nostr::Timestamp::now().as_secs(), + ) + .unwrap(); + crate::managed_agents::retention::retain_inbound_event( + &conn, + &RetainedEvent { + kind: KIND_PRIVATE_MANAGED_AGENT, + pubkey: owner_hex.clone(), + d_tag: pubkey.clone(), + content: event.content.clone(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: false, + }, + ) + .unwrap(); + let mut overlay = hydrate_from_retention(&conn, &owner_keys).unwrap(); + + // A DIFFERENT db with no rows at all: absorbing must not drop the entry. + let empty = open_retention_db(&dir.path().join("empty.db")).unwrap(); + overlay + .absorb_retained_head(&empty, &owner_keys, &pubkey) + .unwrap(); + assert_eq!( + overlay.len(), + 1, + "a missing head must not clear the overlay" + ); + let mut disk = sample_record(&pubkey, "disk-name"); + disk.parallelism = 1; + assert_eq!( + overlay.resolve_local_record(&disk).parallelism, + 16, + "the followed head is still applied after a no-op absorb" + ); + + // POSITIVE CONTROL: the same call against the db that DOES hold a newer + // head updates the overlay, so the no-op above is the absent row and not a + // broken instrument. + let mut newer = record.clone(); + newer.parallelism = 24; + retain_agent_record(&conn, &owner_keys, &newer).unwrap(); + overlay + .absorb_retained_head(&conn, &owner_keys, &pubkey) + .unwrap(); + assert_eq!( + overlay.resolve_local_record(&disk).parallelism, + 24, + "control: absorbing a present head DOES update the overlay" + ); +} + +/// Source guard for the seam. Both behavioural tests above model +/// `retain_managed_agent_pending`'s body — every caller is inside a +/// `#[tauri::command]` needing a live `AppHandle`, so deleting the production +/// write-through leaves them green. Same weakness, same remedy, as +/// `write_site_resolve_guard` in `private_config_overlay.rs`: assert the call +/// exists in the one helper that all five writers funnel through. +#[cfg(test)] +mod retain_managed_agent_pending_writes_through_to_the_overlay { + const AGENTS_RS: &str = include_str!("../../../commands/agents.rs"); + + /// Exactly one — the single seam. A second copy would mean a per-caller + /// write-through crept back in, which is the shape this fix replaced. + #[test] + fn agents_rs_absorbs_the_retained_head_exactly_once() { + assert_eq!( + AGENTS_RS.matches("absorb_retained_head(").count(), + 1, + "commands/agents.rs must write the just-retained 30179 head back to \ + the overlay exactly once, in `retain_managed_agent_pending`. Without \ + it the overlay stays pinned at the last RECEIVED generation and the \ + next edit republishes a revert of the previous one (see \ + `sami_second_edit_in_one_session_preserves_the_first_edit`)." + ); + } + + /// Positional, not semantic: the guard above is a substring count and + /// cannot see ordering. Absorbing BEFORE the retain would read the + /// previous head and reintroduce the defect one generation later, so pin + /// the source order too — the behavioural test pins the runtime ordering. + #[test] + fn the_absorb_follows_the_retain() { + let retain = AGENTS_RS + .find("retain_agent_record(&conn") + .expect("positive control: the retain call must be present"); + let absorb = AGENTS_RS + .find("absorb_retained_head(") + .expect("positive control: the absorb call must be present"); + assert!( + retain < absorb, + "the overlay must absorb the head AFTER the retain writes it" + ); + } + + /// Negative control: prove the searched strings are load-bearing. Without + /// this a typo in either literal makes both guards vacuous. + #[test] + fn the_guard_can_fail() { + let stripped = AGENTS_RS.replace("absorb_retained_head(", "REMOVED("); + assert_eq!(stripped.matches("absorb_retained_head(").count(), 0); + assert_ne!(AGENTS_RS.matches("retain_agent_record(&conn").count(), 0); + } +}