From 94b0a6e83ad1ae508a5d530d2dfee5073d1dc778 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 12:23:12 -0600 Subject: [PATCH] desktop: reconstruct relay-canonical agents Subscribe to owner-private PMA heads, strictly decrypt and validate their bound projections, and reconstruct the portable managed-agent record on a fresh device without allowing unsolicited heads to overwrite LegacyOnly state. Carry the relay-mesh marker that the field classification already declares private-canonical. Co-authored-by: Carl Signed-off-by: Wes --- .../src/commands/personas/inbound.rs | 136 +++++++++++++++++- .../src/managed_agents/migration/mod.rs | 7 +- .../src/managed_agents/migration/tests.rs | 9 +- desktop/src-tauri/src/managed_agents/mod.rs | 2 +- .../agents/lib/usePersonaSync.test.mjs | 2 + .../src/features/agents/lib/usePersonaSync.ts | 2 + desktop/src/shared/constants/kinds.ts | 2 + 7 files changed, 155 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index d7ffecef2d..2f182ec280 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -77,7 +77,9 @@ fn reconcile_inbound_persona_event_blocking( save_managed_agents, save_teams, team_events::team_content_from_event, }; - 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 state = app.state::(); @@ -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,129 @@ fn reconcile_inbound_persona_event_blocking( Ok(()) } +fn reconcile_inbound_private_managed_agent( + event: &nostr::Event, + arrival_relay_url: &str, + app: &AppHandle, + state: &AppState, +) -> Result<(), String> { + use buzz_core_pkg::private_managed_agent::{self as pma, State}; + + let owner_keys = state.signing_keys()?; + if event.pubkey != owner_keys.public_key() { + return Ok(()); + } + if crate::managed_agents::retention::arrival_retention_scope(app, state, arrival_relay_url)? + .is_none() + { + return Ok(()); + } + + let (_, payload) = pma::validate_and_decrypt(event, &owner_keys) + .map_err(|error| format!("invalid private managed-agent aggregate: {error}"))?; + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut agents = crate::managed_agents::load_managed_agents(app)?; + let existing_index = agents + .iter() + .position(|record| record.pubkey == payload.agent_pubkey); + + if let Some(index) = existing_index { + let authority = agents[index].relay_authority.evidence(); + if authority.is_some_and(|evidence| { + payload.generation <= evidence.generation + || payload.previous_event_id.as_deref() != Some(&evidence.private_event_id) + }) { + return Ok(()); + } + // A local legacy record is still authoritative until its own migration + // persists verified evidence. Never replace it from an unsolicited head. + if authority.is_none() { + return Ok(()); + } + } + + match payload.state { + State::Deleted => { + if let Some(index) = existing_index { + agents.remove(index); + crate::managed_agents::delete_agent_key(&payload.agent_pubkey); + state.clear_agent_session_caches(&payload.agent_pubkey); + crate::managed_agents::save_managed_agents(app, &agents)?; + } + } + State::Active => { + let reconstructed = reconstruct_managed_agent_from_payload(&payload, event)?; + match existing_index { + Some(index) => agents[index] = reconstructed, + None => agents.push(reconstructed), + } + crate::managed_agents::save_managed_agents(app, &agents)?; + } + } + + try_regenerate_nest(app); + let _ = app.emit("agents-data-changed", ()); + Ok(()) +} + +fn reconstruct_managed_agent_from_payload( + payload: &buzz_core_pkg::private_managed_agent::Payload, + private_event: &nostr::Event, +) -> Result { + use crate::managed_agents::{ + agent_events::managed_agent_content_from_event, persona_events::persona_from_event, + RelayAuthority, RelayAuthorityEvidence, RelayMeshConfig, VersionedBackend, + }; + + let active = payload + .active + .as_ref() + .ok_or_else(|| "active private aggregate is missing active payload".to_string())?; + let definition_event = &active.definition.recovery.signed_event; + let instance_event = &active.instance_projection.recovery.signed_event; + let definition = persona_from_event(definition_event)?; + let instance = managed_agent_content_from_event(instance_event)?; + let backend: VersionedBackend = serde_json::from_value(active.config.backend.clone()) + .map_err(|error| format!("invalid private backend envelope: {error}"))?; + let relay_mesh = active + .config + .relay_mesh + .clone() + .map(serde_json::from_value::) + .transpose() + .map_err(|error| format!("invalid private relay mesh config: {error}"))?; + + let mut record = definition.into_agent_record(); + record.pubkey = payload.agent_pubkey.clone(); + record.name = instance.name; + record.persona_id = instance.persona_id; + record.private_key_nsec = active.identity.private_key_nsec.clone(); + record.auth_tag = active.identity.auth_tag.clone(); + record.relay_url = active.config.relay_url.clone(); + record.agent_command_override = active.config.agent_command_override.clone(); + record.agent_args = active.config.agent_args.clone(); + record.idle_timeout_seconds = active.config.idle_timeout_seconds; + record.max_turn_duration_seconds = active.config.max_turn_duration_seconds; + record.env_vars = active.config.env_vars.clone().into_iter().collect(); + record.backend = backend.backend; + record.backend_agent_id = active.config.backend_agent_id.clone(); + record.team_id = active.config.team_id.clone(); + record.persona_name_in_team = active.config.persona_name_in_team.clone(); + record.relay_mesh = relay_mesh; + record.parallelism = instance.parallelism; + record.respond_to = instance.respond_to; + record.respond_to_allowlist = instance.respond_to_allowlist; + record.updated_at = payload.updated_at.clone(); + record.relay_authority = RelayAuthority::relay_authoritative(RelayAuthorityEvidence { + generation: payload.generation, + private_event_id: private_event.id.to_hex(), + }); + Ok(record) +} + /// 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 diff --git a/desktop/src-tauri/src/managed_agents/migration/mod.rs b/desktop/src-tauri/src/managed_agents/migration/mod.rs index 4629be4ce5..938e151dd5 100644 --- a/desktop/src-tauri/src/managed_agents/migration/mod.rs +++ b/desktop/src-tauri/src/managed_agents/migration/mod.rs @@ -294,7 +294,12 @@ where 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: None, + relay_mesh: record + .relay_mesh + .as_ref() + .map(serde_json::to_value) + .transpose() + .map_err(|e| MigrationError::Codec(format!("relay mesh envelope: {e}")))?, }; let payload = Payload { diff --git a/desktop/src-tauri/src/managed_agents/migration/tests.rs b/desktop/src-tauri/src/managed_agents/migration/tests.rs index 592856f368..01b5a50ee0 100644 --- a/desktop/src-tauri/src/managed_agents/migration/tests.rs +++ b/desktop/src-tauri/src/managed_agents/migration/tests.rs @@ -94,7 +94,9 @@ fn fully_valid_record() -> ManagedAgentRecord { definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, - relay_mesh: None, + relay_mesh: Some(crate::managed_agents::RelayMeshConfig { + model_ref: "mesh/model".to_string(), + }), relay_authority: super::super::authority::RelayAuthority::legacy(), } } @@ -187,6 +189,11 @@ fn happy_round_trip_yields_promotion_evidence() { assert_eq!(evidence.generation, 1); assert_eq!(evidence.previous_event_id, None); assert_eq!(evidence.definition_revision, 7); + assert_eq!( + candidate.payload.active.as_ref().unwrap().config.relay_mesh, + Some(json!({ "model_ref": "mesh/model" })), + "relay mesh is private-canonical and must be carried", + ); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index e89c48704a..1c227b6e12 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -56,7 +56,7 @@ pub use backend::*; // solely by `authority`'s own tests via `super::*`, so they need no re-export // yet; the not-yet-built migration path will add one when it actually consumes // them. No blanket `#[allow(dead_code)]` — unused API stays unexported. -pub(crate) use authority::RelayAuthority; +pub(crate) use authority::{RelayAuthority, RelayAuthorityEvidence, VersionedBackend}; pub use discovery::*; pub use env_vars::*; #[cfg(windows)] diff --git a/desktop/src/features/agents/lib/usePersonaSync.test.mjs b/desktop/src/features/agents/lib/usePersonaSync.test.mjs index 0dc12ddfd1..f3d9b93acf 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.test.mjs +++ b/desktop/src/features/agents/lib/usePersonaSync.test.mjs @@ -6,6 +6,7 @@ import { KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, + KIND_PRIVATE_MANAGED_AGENT, KIND_TEAM, } from "@/shared/constants/kinds"; import { startPersonaSync } from "./usePersonaSync.ts"; @@ -14,6 +15,7 @@ const EXPECTED_KINDS = [ KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_PRIVATE_MANAGED_AGENT, KIND_DELETION, ]; diff --git a/desktop/src/features/agents/lib/usePersonaSync.ts b/desktop/src/features/agents/lib/usePersonaSync.ts index f18194c5c6..cc34adb45b 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.ts +++ b/desktop/src/features/agents/lib/usePersonaSync.ts @@ -7,6 +7,7 @@ import { KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, + KIND_PRIVATE_MANAGED_AGENT, KIND_TEAM, } from "@/shared/constants/kinds"; @@ -17,6 +18,7 @@ const PERSONA_SYNC_KINDS = [ KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_PRIVATE_MANAGED_AGENT, KIND_DELETION, ]; diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index f851e459af..c637b3b64d 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -52,6 +52,8 @@ export const KIND_CHANNEL_SORT = 30078; export const KIND_PERSONA = 30175; export const KIND_TEAM = 30176; export const KIND_MANAGED_AGENT = 30177; +/** Owner-encrypted relay-canonical managed-agent aggregate (NIP-PMA). */ +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;