From 6971822eee4b23bea453863045b2226ded2ad2d3 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 20:51:19 -0600 Subject: [PATCH 1/8] 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 180dd6fa0c3..11fd681508c 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 cd9f20b5f4a..55a468144c1 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 fc90e6ab14a..34f3a0713b0 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 dd61fc9398a..d21aa56ad37 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 1005a83432d..4e8c4ac2c7b 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 aa88bfe39ac..15516f4ca0a 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 4a7b80079d8..9a7d8989449 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 fe90ce430fd..6322547306e 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 00000000000..2ff9f15e88b --- /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 90f05c5750d..7d110c056d5 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 c9269dbf002..69a03af3e98 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 0dc12ddfd1e..f3d9b93acfc 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 f18194c5c6e..cc34adb45bb 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 f995a635968..0c3ea112c00 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/8] 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 d21aa56ad37..88b1b8047ec 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 00000000000..a08c496a741 --- /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 2ff9f15e88b..eb99810d2d3 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/8] 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 4704582372d..cd5709ffb66 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 00000000000..b437136b3f8 --- /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 88b1b8047ec..4a4857321f7 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 4e8c4ac2c7b..59ed14ab3a6 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 ed2472d54ea..b7cbea7828e 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 c60215ae4dd..6a989a89dcf 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 ee8e0d8b108..6829779406c 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 eb99810d2d3..68f5fa8c8f2 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 7d110c056d5..8a47aff6ac2 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 69a03af3e98..891afd53b9f 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 00000000000..a0e8499bd32 --- /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 7e97fa1f566..50cfaf8a015 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 652bb9b9ea8..c789c59c546 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/8] 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 8a47aff6ac2..2ddd3111908 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 a0e8499bd32..6a895c84b93 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/8] 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 4a4857321f7..a08f34116ba 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 68f5fa8c8f2..d61dc451152 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 891afd53b9f..55599ef2498 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 00000000000..1f696540c2b --- /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); + } +} From 692fdafa61c108bd7fb6c4249f17dcc63cd8ad5a Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Fri, 7 Aug 2026 12:10:56 -0400 Subject: [PATCH 6/8] fix(desktop): export/card resolves through the 30179 overlay; snapshot respond_to falls back to instance fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relay-hosted agents made two gaps in the export/card resolvers user-visible: - Gap B: a relay-only agent (30179 head, never started on this device) has no disk record, so materialize_snapshot_bytes / card_mint_key_status / mint_agent_card failed with "agent not found" until first START. - Gap A: a follower device exported the stale disk snapshot instead of the effective 30179 config it actually runs. Fold the private-config overlay onto the disk list at all three resolver sites — the identical resolution the agent list already uses (resolved_records: applies patches to disk records AND synthesizes relay-only entries). Card mint then also inherits the correct live kind:0 avatar for free, since its existing profile fetch runs after resolution. Also give build_snapshot's respond_to/allowlist the same instance fallback parallelism already has: an overlay-materialized record carries its enforced respond_to only on the instance fields, so without the fallback a relay-hosted agent exported respond_to=None while actually enforcing an allowlist — a shared agent that behaves differently than advertised. Mode and list travel together (definition mode -> definition list, instance fallback -> instance list, never mixed). Deferred as fidelity-not-function (tracked for the relay-hosted cutover): kind:0 avatar hydration for ordinary JSON/PNG manifests, name_pool. Verified: cargo test in desktop/src-tauri — 2283 passed, 0 failed; clippy --tests clean; fmt clean. Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- .../src-tauri/src/commands/personas/card.rs | 15 +++ .../src/commands/personas/snapshot.rs | 10 ++ .../src/commands/personas/snapshot/tests.rs | 114 ++++++++++++++++++ .../src/managed_agents/agent_snapshot.rs | 21 +++- .../managed_agents/agent_snapshot_tests.rs | 49 ++++++++ 5 files changed, 207 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index 14c7c196b2b..51cda34b8bb 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -493,7 +493,14 @@ pub fn card_mint_key_status( .lock() .map_err(|e| e.to_string())?; + // Overlay-fold (kind:30179) so the status reflects the agent's effective + // config — including relay-only agents with no local record yet. let instances = load_managed_agents(&app)?; + let instances = state + .private_managed_agent_overlay + .lock() + .map_err(|e| e.to_string())? + .resolved_records(&instances); let definitions = load_agent_definitions(&app)?; let (record, _) = resolve_from_lists(&id, &instances, &definitions)?; @@ -549,7 +556,15 @@ pub async fn mint_agent_card( .lock() .map_err(|e| e.to_string())?; + // Overlay-fold (kind:30179) — same resolution as snapshot export and + // the agent list: mint sees effective config, and a relay-only agent + // resolves before its first START on this device. let instances = load_managed_agents(&app)?; + let instances = state + .private_managed_agent_overlay + .lock() + .map_err(|e| e.to_string())? + .resolved_records(&instances); let definitions = load_agent_definitions(&app)?; let (record, is_definition) = resolve_from_lists(&id, &instances, &definitions).map(|(r, d)| (r.clone(), d))?; diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index e7bd1597e63..6d03f45a088 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -245,7 +245,17 @@ pub(crate) async fn materialize_snapshot_bytes( .lock() .map_err(|e| e.to_string())?; + // Fold the kind:30179 overlay onto the disk records (and synthesize + // relay-only entries) so export sees the EFFECTIVE config — the same + // resolution the agent list uses. Raw disk here would export stale + // values on a follower device and fail with "agent not found" for a + // relay-only agent that has never been started on this device. let instances = load_managed_agents(&app)?; + let instances = state + .private_managed_agent_overlay + .lock() + .map_err(|e| e.to_string())? + .resolved_records(&instances); let definitions = load_agent_definitions(&app)?; let (def_record, is_definition) = resolve_from_lists(&id, &instances, &definitions) .map(|(r, is_def)| (r.clone(), is_def))?; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index c453b09a9de..600e9608212 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -161,6 +161,120 @@ fn definition_slug_resolves_to_definition_and_linked_instance_is_valid_memory_so // ── Resolver edge cases ─────────────────────────────────────────────────── +// ── Overlay-fold resolution (kind:30179) ────────────────────────────────── +// +// Export resolves against `resolved_records(load_managed_agents(..))`, not +// raw disk. These tests pin the two behaviors that fold buys export: +// a relay-only agent resolves before its first START on this device, and a +// follower exports the effective relay config instead of stale disk values. + +#[cfg(not(target_os = "windows"))] +mod overlay_fold { + use super::*; + use crate::managed_agents::private_config_overlay::PrivateConfigOverlay; + use buzz_core_pkg::private_managed_agent::{ + Payload, PrivateConfig, PrivateIdentity, FORMAT, VERSION, + }; + use serde_json::{json, Map}; + + fn relay_payload(pubkey: &str, name: &str, prompt: &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-07T00: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("relay-model".into()), + provider: None, + system_prompt: Some(prompt.into()), + parallelism: None, + respond_to: Some("allowlist".into()), + respond_to_allowlist: vec!["ab".repeat(32)], + 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(), + } + } + + /// Gap B: a relay-only agent (30179 head, no disk record — never started + /// on this device) must resolve for export once the disk list is folded + /// through the overlay. Raw disk alone returns "agent not found". + #[test] + fn relay_only_agent_resolves_for_export_after_overlay_fold() { + let mut overlay = PrivateConfigOverlay::default(); + overlay + .insert(relay_payload("relay-only-pk", "Relay Only", "relay prompt")) + .unwrap(); + let disk: Vec = vec![]; + + // Raw disk: not found (the pre-fix behavior). + assert!(resolve_from_lists("relay-only-pk", &disk, &[]).is_err()); + + // Folded: resolves, with the relay config as the effective record. + let folded = overlay.resolved_records(&disk); + let (record, is_def) = resolve_from_lists("relay-only-pk", &folded, &[]).unwrap(); + assert!(!is_def); + assert_eq!(record.name, "Relay Only"); + assert_eq!(record.system_prompt.as_deref(), Some("relay prompt")); + } + + /// Gap A: a follower device with a stale disk record must export the + /// effective 30179 values, not the disk snapshot — and the exported + /// manifest must advertise the enforced respond_to via the instance + /// fallback (the overlay patch only populates instance fields). + #[test] + fn follower_exports_effective_relay_values_not_stale_disk() { + use crate::managed_agents::agent_snapshot::build_snapshot; + + let mut overlay = PrivateConfigOverlay::default(); + overlay + .insert(relay_payload("follower-pk", "Fresh Name", "fresh prompt")) + .unwrap(); + let mut stale = make_instance("follower-pk", "some-persona"); + stale.name = "Stale Name".into(); + stale.system_prompt = Some("stale prompt".into()); + + let folded = overlay.resolved_records(std::slice::from_ref(&stale)); + let (record, _) = resolve_from_lists("follower-pk", &folded, &[]).unwrap(); + assert_eq!(record.name, "Fresh Name"); + assert_eq!(record.system_prompt.as_deref(), Some("fresh prompt")); + assert_eq!(record.model.as_deref(), Some("relay-model")); + + let snapshot = build_snapshot(record, MemoryLevel::None, vec![], None); + assert_eq!( + snapshot.definition.system_prompt.as_deref(), + Some("fresh prompt") + ); + assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist")); + assert_eq!( + snapshot.definition.respond_to_allowlist, + vec!["ab".repeat(32)] + ); + } +} + #[test] fn resolve_by_pubkey_finds_keyed_instance() { let inst = make_instance("pubkey-xyz", "my-agent"); diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index 7c08e7095f6..e00e2628821 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -194,6 +194,23 @@ pub fn build_snapshot( // Use definition-level fields (respond_to, allowlist, parallelism) for // portability — instance-level equivalents are spawn-time snapshots and // would be stale. + // + // respond_to falls back to the instance fields when the definition-level + // mode is unset — same rationale as the parallelism fallback below. A + // record materialized from a kind:30179 relay head carries its effective + // respond_to/allowlist ONLY on the instance fields (the overlay patch + // never populates the definition ones), so without this fallback a + // relay-hosted agent would export `respond_to: None` while actually + // enforcing an allowlist. Mode and list travel together: a definition + // mode uses the definition list, the instance fallback uses the instance + // list — never a mix. + let (respond_to, respond_to_allowlist) = match record.definition_respond_to.clone() { + Some(mode) => (Some(mode), record.definition_respond_to_allowlist.clone()), + None => ( + Some(record.respond_to.as_str().to_string()), + record.respond_to_allowlist.clone(), + ), + }; let definition = AgentSnapshotDefinition { name: record .display_name @@ -205,8 +222,8 @@ pub fn build_snapshot( model: record.model.clone(), provider: record.provider.clone(), parallelism: record.definition_parallelism.or(Some(record.parallelism)), - respond_to: record.definition_respond_to.clone(), - respond_to_allowlist: record.definition_respond_to_allowlist.clone(), + respond_to, + respond_to_allowlist, name_pool: record.name_pool.clone(), idle_timeout_seconds: record.idle_timeout_seconds, max_turn_duration_seconds: record.max_turn_duration_seconds, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index b4492418e59..8712aacd4e9 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -540,6 +540,55 @@ fn definition_fields_present_in_snapshot() { assert!(!snapshot.definition.respond_to_allowlist.is_empty()); } +/// A definition-level mode must carry the DEFINITION list, never the +/// instance list — mode and list travel together (`minimal_record` has +/// distinct values on each level, so a mix-up is detectable). +#[test] +fn definition_respond_to_keeps_definition_list_not_instance_list() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist")); + assert_eq!(snapshot.definition.respond_to_allowlist, vec!["abc123def"]); +} + +/// A record materialized from a kind:30179 relay head carries its effective +/// respond_to/allowlist only on the INSTANCE fields. Export must fall back +/// to them (like parallelism already does) instead of advertising +/// `respond_to: None` while the agent enforces an allowlist. +#[test] +fn respond_to_falls_back_to_instance_fields_when_definition_unset() { + let mut record = minimal_record(); + record.definition_respond_to = None; + record.definition_respond_to_allowlist = vec![]; + record.respond_to = RespondTo::Allowlist; + record.respond_to_allowlist = vec!["ef".repeat(32)]; + + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist")); + assert_eq!( + snapshot.definition.respond_to_allowlist, + vec!["ef".repeat(32)] + ); +} + +/// The instance fallback exports the enforced default explicitly rather +/// than `None` — an owner-only agent advertises owner-only. +#[test] +fn respond_to_fallback_exports_explicit_owner_only_default() { + let mut record = minimal_record(); + record.definition_respond_to = None; + record.definition_respond_to_allowlist = vec![]; + record.respond_to = RespondTo::default(); + record.respond_to_allowlist = vec![]; + + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert_eq!( + snapshot.definition.respond_to.as_deref(), + Some("owner-only") + ); + assert!(snapshot.definition.respond_to_allowlist.is_empty()); +} + #[test] fn profile_fields_present_in_snapshot() { let record = minimal_record(); From 2719088822107646ada8a8de37ba9566e78db2ea Mon Sep 17 00:00:00 2001 From: Sami Date: Fri, 7 Aug 2026 14:35:21 -0400 Subject: [PATCH 7/8] test(desktop): guard that every export resolver folds the 30179 overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The behavioural tests added with the export/card overlay fold model the resolution by calling `resolved_records` / `resolve_from_lists` directly. They cannot reach the production call sites: all three live inside `#[tauri::command]` bodies needing a live `AppHandle` + `State`. Measured on the parent commit, one full-workspace run per mutant: delete the snapshot.rs fold -> SURVIVED (2283 passed, 0 failed) delete the card precheck fold -> SURVIVED (2283 passed, 0 failed) delete the card mint fold -> SURVIVED (2283 passed, 0 failed) revert the respond_to fallback -> killed (3 tests) mix definition mode + inst. list -> killed (1 test) So all three production folds were deletable with a green suite. The fallback and the mode/list-atomicity invariant were genuinely covered; only the wiring was not. Add an `include_str!` source guard with EXACT per-file call counts (snapshot.rs: 1, card.rs: 2) — same remedy as `write_site_resolve_guard` in private_config_overlay.rs. Exact rather than a lower bound: `>= 1` per file would not notice one card site losing its fold while the other gained a second one. A second test strips the searched string and asserts zero matches remain, so a typo in the pattern cannot make the guard pass vacuously. Re-ran all three mutants against the guard, deleting each fold statement outright so the mutant compiles: all three fail, each naming its own file, with the unmutated control passing and both sources restored byte-identical after each arm. The guard lives in a sibling `tests_export_resolver_guard.rs` rather than in `snapshot/tests.rs`, following the convention `tests_locked.rs` documents: `snapshot/tests.rs` is already over the 1000-line desktop ratchet, so a file already above the limit may not grow. This commit adds 3 lines to snapshot.rs (528 total) and one new 67-line file, so it clears the ratchet on its own; the two pre-existing ratchet rows on this branch are unchanged by it. Verified: cargo test --workspace in desktop/src-tauri — 2285 passed, 0 failed, 14 ignored; cargo clippy --workspace --all-targets -D warnings clean; cargo fmt --all --check clean. All at parent 692fdafa with `git rev-parse HEAD` checked in the same shell. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../src/commands/personas/snapshot.rs | 3 + .../snapshot/tests_export_resolver_guard.rs | 67 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 desktop/src-tauri/src/commands/personas/snapshot/tests_export_resolver_guard.rs diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index 6d03f45a088..13a58caed13 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -455,6 +455,9 @@ pub async fn encode_agent_snapshot_for_send( }) } +#[cfg(test)] +#[path = "snapshot/tests_export_resolver_guard.rs"] +mod export_resolver_guard; #[cfg(test)] mod fidelity_tests; #[cfg(test)] diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_export_resolver_guard.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_export_resolver_guard.rs new file mode 100644 index 00000000000..0150e91cfe5 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_export_resolver_guard.rs @@ -0,0 +1,67 @@ +//! Source guard: every export resolver folds the private-config overlay. +//! +//! Kept in a sibling file so `snapshot/tests.rs` stays under the 1000-line +//! gate; `#[path]`-included from there as a child module. +//! +//! The behavioural tests in `tests.rs` model the fold by calling +//! `resolved_records` / `resolve_from_lists` directly. They CANNOT reach the +//! production call sites: all three live inside `#[tauri::command]` bodies +//! needing a live `AppHandle` + `State`. Measured on the parent +//! commit — deleting any one of the three production folds leaves the full +//! workspace suite green (2283 passed, 0 failed). +//! +//! Same weakness, same remedy, as `write_site_resolve_guard` in +//! `private_config_overlay.rs`: assert the call exists at each site. + +/// The fold call every export resolver must make. A resolver that reads raw +/// disk instead fails with "agent not found" for a relay-only agent that has +/// never started on this device, and bakes stale disk values into the +/// exported manifest on a follower device. +const FOLD_CALL: &str = ".resolved_records(&instances)"; + +/// `(file, source, expected_fold_calls)` — every export resolver that turns +/// an id into a record destined for a snapshot manifest or a card. +fn sites() -> Vec<(&'static str, &'static str, usize)> { + vec![ + // `materialize_snapshot_bytes` — JSON + PNG + send-to-channel. + ( + "commands/personas/snapshot.rs", + include_str!("../snapshot.rs"), + 1, + ), + // Card precheck (`card_mint_key_status`) + mint resolver + // (`mint_agent_card`). Exact, not `>= 1`: a lower bound would not + // notice one site losing its fold while the other gained a second. + ("commands/personas/card.rs", include_str!("../card.rs"), 2), + ] +} + +#[test] +fn every_export_resolver_folds_the_private_config_overlay() { + for (file, source, expected) in sites() { + let found = source.matches(FOLD_CALL).count(); + assert_eq!( + found, expected, + "{file}: expected {expected} `{FOLD_CALL}` call(s), found {found}. \ + An export resolver that reads raw disk fails with \"agent not found\" \ + for a relay-only agent that has never started on this device, and \ + bakes stale disk values into the exported manifest on a follower." + ); + } +} + +/// The guard above is a substring count, so prove it can FAIL: a source with +/// the fold removed must not satisfy it. Without this, a typo in `FOLD_CALL` +/// would make every row pass vacuously. +#[test] +fn guard_detects_a_missing_overlay_fold() { + for (file, source, expected) in sites() { + let stripped = source.replace(FOLD_CALL, ".REMOVED(&instances)"); + assert_eq!( + stripped.matches(FOLD_CALL).count(), + 0, + "{file}: guard string never matched, so its {expected} expected \ + call(s) passed vacuously" + ); + } +} From 637a7966582be7fb88f8fa22af1a0e935ea35769 Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Fri, 7 Aug 2026 15:15:08 -0400 Subject: [PATCH 8/8] =?UTF-8?q?refactor(desktop):=20satisfy=20the=20file-s?= =?UTF-8?q?ize=20ratchet=20=E2=80=94=20extract=20the=20overlay=20fold=20in?= =?UTF-8?q?to=20one=20helper,=20move=20overlay=20tests=20to=20a=20sibling?= =?UTF-8?q?=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's Desktop Core ratchet is red at 692fdafa (Sami's finding): card.rs 1000->1015 and snapshot/tests.rs 963->1077, both pinned at the cap at the merge base, so neither may grow by a line. Two moves, no behavior change: - Extract the three inline overlay folds into one helper, `load_effective_managed_agents` (snapshot.rs), called at all three resolver sites. card.rs returns to 999 (< merge-base 1000); the helper's doc comment carries the rationale and the lock-order contract (managed_agents_store_lock outer, overlay inner, released before return). - Move the overlay_fold behavioural tests to a sibling snapshot/tests_overlay_fold.rs, #[path]-included per the tests_locked.rs convention. snapshot/tests.rs returns to 968 (< cap). The extraction changes what Sami's source guard must pin, so tests_export_resolver_guard.rs now asserts three legs: every resolver site calls the helper (exact per-file counts), no resolver file reads raw load_managed_agents outside the helper's own body (exact allowed counts), and the helper itself still performs the resolved_records fold — plus the vacuity self-test for every needle. Verified at this commit: ratchet passes vs merge-base (CHECK_FILE_SIZES_BASE=$(git merge-base origin/main HEAD)); full workspace suite 2387 passed / 0 failed / 15 ignored; clippy --workspace --all-targets -D warnings clean; fmt clean. Guard mutation arms all KILLED with sources restored byte-identical (cmp vs pinned copies): card mint bypasses helper, card precheck bypasses helper, snapshot resolver bypasses helper, helper degrades to plain load; unmutated control green. Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- .../src-tauri/src/commands/personas/card.rs | 27 +---- .../src/commands/personas/snapshot.rs | 36 ++++-- .../src/commands/personas/snapshot/tests.rs | 114 +----------------- .../snapshot/tests_export_resolver_guard.rs | 108 ++++++++++++----- .../personas/snapshot/tests_overlay_fold.rs | 114 ++++++++++++++++++ 5 files changed, 228 insertions(+), 171 deletions(-) create mode 100644 desktop/src-tauri/src/commands/personas/snapshot/tests_overlay_fold.rs diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index 51cda34b8bb..bf06eb1f071 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -29,8 +29,8 @@ use tauri::{AppHandle, State}; use super::super::export_util::save_bytes_with_dialog; use super::snapshot::{ - memory_entries_from_listing, parse_memory_level, resolve_from_lists, - validate_snapshot_encode_size, + load_effective_managed_agents, memory_entries_from_listing, parse_memory_level, + resolve_from_lists, validate_snapshot_encode_size, }; use crate::{ app_state::AppState, @@ -43,8 +43,8 @@ use crate::{ agent_snapshot_envelope::{ decrypt_envelope, encode_locked_snapshot_png, parse_chunk_payload, ChunkPayload, }, - load_agent_definitions, load_global_agent_config, load_managed_agents, load_personas, - save_global_agent_config, validate_global_config, + load_agent_definitions, load_global_agent_config, load_personas, save_global_agent_config, + validate_global_config, }, }; @@ -493,14 +493,7 @@ pub fn card_mint_key_status( .lock() .map_err(|e| e.to_string())?; - // Overlay-fold (kind:30179) so the status reflects the agent's effective - // config — including relay-only agents with no local record yet. - let instances = load_managed_agents(&app)?; - let instances = state - .private_managed_agent_overlay - .lock() - .map_err(|e| e.to_string())? - .resolved_records(&instances); + let instances = load_effective_managed_agents(&app, &state)?; let definitions = load_agent_definitions(&app)?; let (record, _) = resolve_from_lists(&id, &instances, &definitions)?; @@ -556,15 +549,7 @@ pub async fn mint_agent_card( .lock() .map_err(|e| e.to_string())?; - // Overlay-fold (kind:30179) — same resolution as snapshot export and - // the agent list: mint sees effective config, and a relay-only agent - // resolves before its first START on this device. - let instances = load_managed_agents(&app)?; - let instances = state - .private_managed_agent_overlay - .lock() - .map_err(|e| e.to_string())? - .resolved_records(&instances); + let instances = load_effective_managed_agents(&app, &state)?; let definitions = load_agent_definitions(&app)?; let (record, is_definition) = resolve_from_lists(&id, &instances, &definitions).map(|(r, d)| (r.clone(), d))?; diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index 13a58caed13..84fb706aaa9 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -56,6 +56,29 @@ pub(crate) fn resolve_from_lists<'a>( Err(format!("agent {id:?} not found")) } +/// Load the managed-agent instances with the kind:30179 private-config +/// overlay folded on — patching disk records and synthesizing relay-only +/// entries, the same resolution the agent list uses. +/// +/// Every export/card resolver must read instances through this helper, never +/// raw `load_managed_agents`: raw disk exports stale values on a follower +/// device and fails with "agent not found" for a relay-only agent that has +/// never been started on this device. +/// +/// Lock order: callers already hold `managed_agents_store_lock` (outer); +/// this takes only the overlay lock (inner) and releases it before returning. +pub(crate) fn load_effective_managed_agents( + app: &AppHandle, + state: &State<'_, AppState>, +) -> Result, String> { + let instances = load_managed_agents(app)?; + Ok(state + .private_managed_agent_overlay + .lock() + .map_err(|e| e.to_string())? + .resolved_records(&instances)) +} + /// Validate that `memory_source_pubkey` is an appropriate source for a /// memory-bearing snapshot export. /// @@ -246,16 +269,9 @@ pub(crate) async fn materialize_snapshot_bytes( .map_err(|e| e.to_string())?; // Fold the kind:30179 overlay onto the disk records (and synthesize - // relay-only entries) so export sees the EFFECTIVE config — the same - // resolution the agent list uses. Raw disk here would export stale - // values on a follower device and fail with "agent not found" for a - // relay-only agent that has never been started on this device. - let instances = load_managed_agents(&app)?; - let instances = state - .private_managed_agent_overlay - .lock() - .map_err(|e| e.to_string())? - .resolved_records(&instances); + // relay-only entries) so export sees the EFFECTIVE config — see + // `load_effective_managed_agents`. + let instances = load_effective_managed_agents(&app, &state)?; let definitions = load_agent_definitions(&app)?; let (def_record, is_definition) = resolve_from_lists(&id, &instances, &definitions) .map(|(r, is_def)| (r.clone(), is_def))?; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index 600e9608212..644970a9439 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -162,118 +162,10 @@ fn definition_slug_resolves_to_definition_and_linked_instance_is_valid_memory_so // ── Resolver edge cases ─────────────────────────────────────────────────── // ── Overlay-fold resolution (kind:30179) ────────────────────────────────── -// -// Export resolves against `resolved_records(load_managed_agents(..))`, not -// raw disk. These tests pin the two behaviors that fold buys export: -// a relay-only agent resolves before its first START on this device, and a -// follower exports the effective relay config instead of stale disk values. - +// Sibling file: keeps this file under the 1000-line gate. #[cfg(not(target_os = "windows"))] -mod overlay_fold { - use super::*; - use crate::managed_agents::private_config_overlay::PrivateConfigOverlay; - use buzz_core_pkg::private_managed_agent::{ - Payload, PrivateConfig, PrivateIdentity, FORMAT, VERSION, - }; - use serde_json::{json, Map}; - - fn relay_payload(pubkey: &str, name: &str, prompt: &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-07T00: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("relay-model".into()), - provider: None, - system_prompt: Some(prompt.into()), - parallelism: None, - respond_to: Some("allowlist".into()), - respond_to_allowlist: vec!["ab".repeat(32)], - 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(), - } - } - - /// Gap B: a relay-only agent (30179 head, no disk record — never started - /// on this device) must resolve for export once the disk list is folded - /// through the overlay. Raw disk alone returns "agent not found". - #[test] - fn relay_only_agent_resolves_for_export_after_overlay_fold() { - let mut overlay = PrivateConfigOverlay::default(); - overlay - .insert(relay_payload("relay-only-pk", "Relay Only", "relay prompt")) - .unwrap(); - let disk: Vec = vec![]; - - // Raw disk: not found (the pre-fix behavior). - assert!(resolve_from_lists("relay-only-pk", &disk, &[]).is_err()); - - // Folded: resolves, with the relay config as the effective record. - let folded = overlay.resolved_records(&disk); - let (record, is_def) = resolve_from_lists("relay-only-pk", &folded, &[]).unwrap(); - assert!(!is_def); - assert_eq!(record.name, "Relay Only"); - assert_eq!(record.system_prompt.as_deref(), Some("relay prompt")); - } - - /// Gap A: a follower device with a stale disk record must export the - /// effective 30179 values, not the disk snapshot — and the exported - /// manifest must advertise the enforced respond_to via the instance - /// fallback (the overlay patch only populates instance fields). - #[test] - fn follower_exports_effective_relay_values_not_stale_disk() { - use crate::managed_agents::agent_snapshot::build_snapshot; - - let mut overlay = PrivateConfigOverlay::default(); - overlay - .insert(relay_payload("follower-pk", "Fresh Name", "fresh prompt")) - .unwrap(); - let mut stale = make_instance("follower-pk", "some-persona"); - stale.name = "Stale Name".into(); - stale.system_prompt = Some("stale prompt".into()); - - let folded = overlay.resolved_records(std::slice::from_ref(&stale)); - let (record, _) = resolve_from_lists("follower-pk", &folded, &[]).unwrap(); - assert_eq!(record.name, "Fresh Name"); - assert_eq!(record.system_prompt.as_deref(), Some("fresh prompt")); - assert_eq!(record.model.as_deref(), Some("relay-model")); - - let snapshot = build_snapshot(record, MemoryLevel::None, vec![], None); - assert_eq!( - snapshot.definition.system_prompt.as_deref(), - Some("fresh prompt") - ); - assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist")); - assert_eq!( - snapshot.definition.respond_to_allowlist, - vec!["ab".repeat(32)] - ); - } -} +#[path = "tests_overlay_fold.rs"] +mod overlay_fold; #[test] fn resolve_by_pubkey_finds_keyed_instance() { diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_export_resolver_guard.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_export_resolver_guard.rs index 0150e91cfe5..65d0308b0c5 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests_export_resolver_guard.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_export_resolver_guard.rs @@ -1,67 +1,117 @@ //! Source guard: every export resolver folds the private-config overlay. //! //! Kept in a sibling file so `snapshot/tests.rs` stays under the 1000-line -//! gate; `#[path]`-included from there as a child module. +//! gate; `#[path]`-included from `snapshot.rs` as a child module. //! -//! The behavioural tests in `tests.rs` model the fold by calling -//! `resolved_records` / `resolve_from_lists` directly. They CANNOT reach the -//! production call sites: all three live inside `#[tauri::command]` bodies -//! needing a live `AppHandle` + `State`. Measured on the parent -//! commit — deleting any one of the three production folds leaves the full -//! workspace suite green (2283 passed, 0 failed). +//! The behavioural tests in `tests.rs`/`tests_overlay_fold.rs` model the fold +//! by calling `resolved_records` / `resolve_from_lists` directly. They CANNOT +//! reach the production call sites: all three live inside `#[tauri::command]` +//! bodies needing a live `AppHandle` + `State`. Measured before this +//! guard existed — deleting any one of the three production folds left the +//! full workspace suite green (2283 passed, 0 failed). //! //! Same weakness, same remedy, as `write_site_resolve_guard` in //! `private_config_overlay.rs`: assert the call exists at each site. +//! +//! The fold lives in one helper, `load_effective_managed_agents`, so the +//! guarded invariant has three legs: +//! 1. every resolver site calls the helper (exact count per file); +//! 2. no resolver file reads raw `load_managed_agents(` outside the helper; +//! 3. the helper itself still performs the fold. + +/// Call-shaped needle for the helper. A resolver that reads raw disk instead +/// fails with "agent not found" for a relay-only agent that has never started +/// on this device, and bakes stale disk values into the exported manifest on +/// a follower device. +const HELPER_CALL: &str = "load_effective_managed_agents(&app, &state)?"; -/// The fold call every export resolver must make. A resolver that reads raw -/// disk instead fails with "agent not found" for a relay-only agent that has -/// never started on this device, and bakes stale disk values into the -/// exported manifest on a follower device. +/// The fold inside the helper. const FOLD_CALL: &str = ".resolved_records(&instances)"; -/// `(file, source, expected_fold_calls)` — every export resolver that turns -/// an id into a record destined for a snapshot manifest or a card. -fn sites() -> Vec<(&'static str, &'static str, usize)> { +/// Raw-disk read. Exact counts per file: the only permitted occurrence is the +/// one inside the helper in `snapshot.rs`. +const RAW_LOAD_CALL: &str = "load_managed_agents("; + +/// `(file, source, expected_helper_calls, allowed_raw_loads)` — every export +/// resolver that turns an id into a record destined for a snapshot manifest +/// or a card. +fn sites() -> Vec<(&'static str, &'static str, usize, usize)> { vec![ - // `materialize_snapshot_bytes` — JSON + PNG + send-to-channel. + // `materialize_snapshot_bytes` (JSON + PNG + send-to-channel): 1 + // helper call. 1 raw load allowed: the helper's own body. ( "commands/personas/snapshot.rs", include_str!("../snapshot.rs"), 1, + 1, ), // Card precheck (`card_mint_key_status`) + mint resolver - // (`mint_agent_card`). Exact, not `>= 1`: a lower bound would not - // notice one site losing its fold while the other gained a second. - ("commands/personas/card.rs", include_str!("../card.rs"), 2), + // (`mint_agent_card`): 2 helper calls, 0 raw loads. Exact, not + // `>= 1`: a lower bound would not notice one site losing its call + // while the other gained a second. + ( + "commands/personas/card.rs", + include_str!("../card.rs"), + 2, + 0, + ), ] } #[test] fn every_export_resolver_folds_the_private_config_overlay() { - for (file, source, expected) in sites() { - let found = source.matches(FOLD_CALL).count(); + for (file, source, expected, allowed_raw) in sites() { + let found = source.matches(HELPER_CALL).count(); assert_eq!( found, expected, - "{file}: expected {expected} `{FOLD_CALL}` call(s), found {found}. \ + "{file}: expected {expected} `{HELPER_CALL}` call(s), found {found}. \ An export resolver that reads raw disk fails with \"agent not found\" \ for a relay-only agent that has never started on this device, and \ bakes stale disk values into the exported manifest on a follower." ); + let raw = source.matches(RAW_LOAD_CALL).count(); + assert_eq!( + raw, allowed_raw, + "{file}: expected {allowed_raw} raw `{RAW_LOAD_CALL}` call(s), found \ + {raw}. A resolver bypassing `load_effective_managed_agents` reads \ + raw disk and skips the kind:30179 overlay fold." + ); } } -/// The guard above is a substring count, so prove it can FAIL: a source with -/// the fold removed must not satisfy it. Without this, a typo in `FOLD_CALL` -/// would make every row pass vacuously. +/// The helper must actually fold — the resolver-site counts above would pass +/// vacuously if `load_effective_managed_agents` degraded to a plain load. +#[test] +fn the_helper_itself_performs_the_fold() { + let source = include_str!("../snapshot.rs"); + let found = source.matches(FOLD_CALL).count(); + assert_eq!( + found, 1, + "snapshot.rs: expected exactly 1 `{FOLD_CALL}` call (inside \ + `load_effective_managed_agents`), found {found}" + ); +} + +/// The guards above are substring counts, so prove they can FAIL: a source +/// with the calls removed must not satisfy them. Without this, a typo in a +/// needle would make every row pass vacuously. #[test] -fn guard_detects_a_missing_overlay_fold() { - for (file, source, expected) in sites() { - let stripped = source.replace(FOLD_CALL, ".REMOVED(&instances)"); +fn guard_detects_missing_calls() { + for (file, source, expected, _) in sites() { + let stripped = source.replace(HELPER_CALL, "REMOVED(&app, &state)?"); assert_eq!( - stripped.matches(FOLD_CALL).count(), + stripped.matches(HELPER_CALL).count(), 0, - "{file}: guard string never matched, so its {expected} expected \ + "{file}: helper needle never matched, so its {expected} expected \ call(s) passed vacuously" ); } + let source = include_str!("../snapshot.rs"); + let stripped = source.replace(FOLD_CALL, ".REMOVED(&instances)"); + assert_eq!( + stripped.matches(FOLD_CALL).count(), + 0, + "snapshot.rs: fold needle never matched, so the helper check passed \ + vacuously" + ); } diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_overlay_fold.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_overlay_fold.rs new file mode 100644 index 00000000000..97a4c65b930 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_overlay_fold.rs @@ -0,0 +1,114 @@ +//! Overlay-fold resolution (kind:30179) tests for export. +//! +//! Kept in a sibling file so `snapshot/tests.rs` stays under the +//! 1000-line gate; `#[path]`-included from there as a child module, +//! so `super::*` still resolves to the shared test helpers. +//! +//! Export resolves against `resolved_records(load_managed_agents(..))`, not +//! raw disk. These tests pin the two behaviors that fold buys export: +//! a relay-only agent resolves before its first START on this device, and a +//! follower exports the effective relay config instead of stale disk values. + +use super::*; +use crate::managed_agents::private_config_overlay::PrivateConfigOverlay; +use buzz_core_pkg::private_managed_agent::{ + Payload, PrivateConfig, PrivateIdentity, FORMAT, VERSION, +}; +use serde_json::{json, Map}; + +fn relay_payload(pubkey: &str, name: &str, prompt: &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-07T00: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("relay-model".into()), + provider: None, + system_prompt: Some(prompt.into()), + parallelism: None, + respond_to: Some("allowlist".into()), + respond_to_allowlist: vec!["ab".repeat(32)], + 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(), + } +} + +/// Gap B: a relay-only agent (30179 head, no disk record — never started +/// on this device) must resolve for export once the disk list is folded +/// through the overlay. Raw disk alone returns "agent not found". +#[test] +fn relay_only_agent_resolves_for_export_after_overlay_fold() { + let mut overlay = PrivateConfigOverlay::default(); + overlay + .insert(relay_payload("relay-only-pk", "Relay Only", "relay prompt")) + .unwrap(); + let disk: Vec = vec![]; + + // Raw disk: not found (the pre-fix behavior). + assert!(resolve_from_lists("relay-only-pk", &disk, &[]).is_err()); + + // Folded: resolves, with the relay config as the effective record. + let folded = overlay.resolved_records(&disk); + let (record, is_def) = resolve_from_lists("relay-only-pk", &folded, &[]).unwrap(); + assert!(!is_def); + assert_eq!(record.name, "Relay Only"); + assert_eq!(record.system_prompt.as_deref(), Some("relay prompt")); +} + +/// Gap A: a follower device with a stale disk record must export the +/// effective 30179 values, not the disk snapshot — and the exported +/// manifest must advertise the enforced respond_to via the instance +/// fallback (the overlay patch only populates instance fields). +#[test] +fn follower_exports_effective_relay_values_not_stale_disk() { + use crate::managed_agents::agent_snapshot::build_snapshot; + + let mut overlay = PrivateConfigOverlay::default(); + overlay + .insert(relay_payload("follower-pk", "Fresh Name", "fresh prompt")) + .unwrap(); + let mut stale = make_instance("follower-pk", "some-persona"); + stale.name = "Stale Name".into(); + stale.system_prompt = Some("stale prompt".into()); + + let folded = overlay.resolved_records(std::slice::from_ref(&stale)); + let (record, _) = resolve_from_lists("follower-pk", &folded, &[]).unwrap(); + assert_eq!(record.name, "Fresh Name"); + assert_eq!(record.system_prompt.as_deref(), Some("fresh prompt")); + assert_eq!(record.model.as_deref(), Some("relay-model")); + + let snapshot = build_snapshot(record, MemoryLevel::None, vec![], None); + assert_eq!( + snapshot.definition.system_prompt.as_deref(), + Some("fresh prompt") + ); + assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist")); + assert_eq!( + snapshot.definition.respond_to_allowlist, + vec!["ab".repeat(32)] + ); +}