diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index d9b0ee127..93f7f0774 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -56,6 +56,7 @@ export default defineConfig({ "**/sidebar.spec.ts", "**/tokens.spec.ts", "**/persona-env-vars.spec.ts", + "**/persona-instantiation.spec.ts", "**/mesh-compute.spec.ts", ], use: { diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 28af88229..2e9d16adb 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -30,7 +30,8 @@ const rules = [ // Do not add to this list; split the file instead. Remove each entry as its // file is broken up. Tracked as a follow-up. const overrides = new Map([ - ["src-tauri/src/commands/agents.rs", 1294], + ["src-tauri/src/commands/agents.rs", 1370], + ["src-tauri/src/managed_agents/teams.rs", 1020], ["src-tauri/src/managed_agents/nest.rs", 1420], ["src-tauri/src/managed_agents/runtime.rs", 1940], ["src-tauri/src/managed_agents/personas.rs", 1080], diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 80fa202e8..5808dbdd6 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -17,8 +17,9 @@ name = "buzz_lib" crate-type = ["staticlib", "cdylib", "rlib"] [features] -default = [] +default = ["legacy_team_sync"] mesh-llm = ["dep:mesh-llm-sdk", "dep:mesh-llm-host-runtime"] +legacy_team_sync = [] [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 37bad60f8..26a119fb2 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -651,6 +651,27 @@ pub async fn create_managed_agent( .await) .err(); + // ── Phase 4b: write persona engram (async, best-effort) ───────────────── + // If the agent was created from a persona, snapshot it into a `mem/persona` + // engram. This is provenance-only for now (the live resolve path still reads + // the persona catalog at spawn); fleet update keeps it current. + if requested_persona_id.is_some() { + if let Err(e) = crate::managed_agents::fleet_update::write_persona_engram_at_creation( + &state, + &agent_keys, + &resolved_relay_url, + requested_persona_id.as_deref().unwrap(), + &app, + ) + .await + { + eprintln!( + "buzz-desktop: create-agent: persona engram write failed for {}: {e}", + &pubkey[..pubkey.len().min(8)] + ); + } + } + // ── Phase 5: provider deploy (async, outside lock) ─────────────────────── let spawn_error = if input.spawn_after_create && input.backend != BackendKind::Local { if let BackendKind::Provider { ref id, ref config } = input.backend { diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs index 56f6742f4..b49543ab6 100644 --- a/desktop/src-tauri/src/commands/personas.rs +++ b/desktop/src-tauri/src/commands/personas.rs @@ -140,6 +140,19 @@ pub fn update_persona( .find(|record| record.id == input.id) .ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id))?; try_regenerate_nest(&app); + + // Fleet update: propagate persona edits to agent engrams (best-effort). + let fleet_app = app.clone(); + let persona_id = input.id.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = + crate::managed_agents::fleet_update::fleet_update_for_persona(&fleet_app, &persona_id) + .await + { + eprintln!("buzz-desktop: fleet-update-on-save: {e}"); + } + }); + Ok(result) } diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index aba201b2f..e16f2e183 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -2,13 +2,16 @@ use tauri::{AppHandle, State}; use uuid::Uuid; use super::export_util::save_json_with_dialog; +#[cfg(feature = "legacy_team_sync")] +use crate::managed_agents::{ + import_team_from_directory as do_import_team, sync_team_from_dir as do_sync_team, SyncResult, +}; use crate::{ app_state::AppState, managed_agents::{ - delete_team_with_cascade, encode_team_json, ensure_persona_ids_are_active, - import_team_from_directory as do_import_team, load_personas, load_teams, parse_team_json, - save_teams, sync_team_from_dir as do_sync_team, try_regenerate_nest, CreateTeamRequest, - ParsedTeamPreview, SyncResult, TeamRecord, UpdateTeamRequest, + delete_team_with_cascade, encode_team_json, ensure_persona_ids_are_active, load_personas, + load_teams, parse_team_json, save_teams, try_regenerate_nest, CreateTeamRequest, + ParsedTeamPreview, TeamRecord, UpdateTeamRequest, }, util::now_iso, }; @@ -114,6 +117,7 @@ pub fn delete_team(id: String, app: AppHandle, state: State<'_, AppState>) -> Re Ok(()) } +#[cfg(feature = "legacy_team_sync")] #[tauri::command] pub fn install_team_from_directory( app: AppHandle, @@ -134,6 +138,7 @@ pub fn install_team_from_directory( Ok(result) } +#[cfg(feature = "legacy_team_sync")] #[tauri::command] pub fn sync_team_directory( app: AppHandle, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d6def2fbc..c48fe7820 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -552,9 +552,20 @@ pub fn run() { .map_err(|e| -> Box { e.to_string().into() })?; migration::migrate_personas_to_events(&app_handle, &owner_keys); - if let Err(e) = managed_agents::sync_team_personas(&app_handle) { - eprintln!("buzz-desktop: sync-team-personas: {e}"); - } + // Fleet update: reconcile every agent's `mem/persona` engram against + // its persona's current content. Replaces the old directory-based + // `sync_team_personas`. Best-effort and relay-dependent, so it runs + // off-thread — engram drift is non-fatal and resolves next launch. + let fleet_app = app_handle.clone(); + tauri::async_runtime::spawn(async move { + match managed_agents::fleet_update::check_fleet_updates(&fleet_app).await { + Ok(0) => {} + Ok(n) => { + eprintln!("buzz-desktop: fleet-update: rewrote {n} persona engram(s)") + } + Err(e) => eprintln!("buzz-desktop: fleet-update: {e}"), + } + }); // Store the AppHandle so huddle commands can emit `huddle-state-changed` // events via `huddle::emit_huddle_state` without threading the handle @@ -793,7 +804,9 @@ pub fn run() { create_team, update_team, delete_team, + #[cfg(feature = "legacy_team_sync")] install_team_from_directory, + #[cfg(feature = "legacy_team_sync")] sync_team_directory, pick_team_directory, export_team_to_json, diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 979bbcde1..88193d984 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -25,6 +25,7 @@ use std::collections::BTreeMap; /// /// Non-structured knobs (`GOOSE_TEMPERATURE`, `GOOSE_CONTEXT_LIMIT`) are NOT /// in this list — they have no structured counterpart and must be preserved. +#[cfg_attr(not(feature = "legacy_team_sync"), allow(dead_code))] pub(crate) const DERIVED_PROVIDER_MODEL_ENV_KEYS: &[&str] = &[ "GOOSE_MODEL", "GOOSE_PROVIDER", @@ -34,6 +35,7 @@ pub(crate) const DERIVED_PROVIDER_MODEL_ENV_KEYS: &[&str] = &[ /// Returns `true` if `key` is a derived provider/model env key that should be /// filtered out of persisted `PersonaRecord.env_vars` at pack import time. +#[cfg(feature = "legacy_team_sync")] pub(crate) fn is_derived_provider_model_key(key: &str) -> bool { DERIVED_PROVIDER_MODEL_ENV_KEYS .iter() @@ -46,6 +48,7 @@ pub(crate) fn is_derived_provider_model_key(key: &str) -> bool { /// The structured `PersonaRecord.provider` / `PersonaRecord.model` fields are /// the authoritative source of truth. Keeping the derived copies would cause /// stale env values to override updated structured fields at spawn/deploy time. +#[cfg(feature = "legacy_team_sync")] pub(crate) fn filter_derived_provider_model_env_vars( env_vars: impl IntoIterator, ) -> BTreeMap { diff --git a/desktop/src-tauri/src/managed_agents/fleet_update.rs b/desktop/src-tauri/src/managed_agents/fleet_update.rs new file mode 100644 index 000000000..5aad79c79 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/fleet_update.rs @@ -0,0 +1,316 @@ +//! Fleet update: keep agent `mem/persona` engrams in sync with persona edits. +//! +//! When a persona's content changes, each agent instantiated from it carries a +//! stale snapshot in its `mem/persona` engram. Fleet update detects the drift +//! (by comparing content hashes, not timestamps — timestamps are fragile across +//! clock skew and export/import) and rewrites the affected engrams. +//! +//! This runs on persona save (immediate propagation) and at app launch (catches +//! edits made while the app was closed). It never restarts running agents — the +//! live resolve path still reads the persona catalog at spawn, so a running +//! agent already sees edits on its next session. The engram is provenance and +//! future-proofing for when it becomes the runtime source; keeping it current +//! is the only job here. + +use buzz_core_pkg::engram::{conversation_key, d_tag, select_head}; +use buzz_core_pkg::kind::KIND_AGENT_ENGRAM; +use nostr::PublicKey; +use tauri::{AppHandle, Manager}; + +use super::persona_events::{ + build_persona_engram, persona_content_hash, persona_engram_from_event_as_owner, + persona_event_content, PERSONA_ENGRAM_SLUG, +}; +use super::personas::load_personas; +use super::storage::load_managed_agents; +use super::{ManagedAgentRecord, PersonaRecord}; +use crate::app_state::AppState; +use crate::relay; + +/// What fleet update should do with one agent's persona engram. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FleetAction { + /// Engram already matches the current persona — nothing to do. + NoOp, + /// Engram is missing or stale — (re)write it. + Write, +} + +/// Decide whether an agent's persona engram needs rewriting. +/// +/// Pure and side-effect free so the decision can be unit-tested in isolation: +/// a missing engram (`None`) always writes; otherwise we write iff the stored +/// content version differs from the persona's current content hash. +pub fn fleet_action_for(current_hash: &str, stored_version: Option<&str>) -> FleetAction { + match stored_version { + Some(version) if version == current_hash => FleetAction::NoOp, + _ => FleetAction::Write, + } +} + +/// Run fleet update across all managed agents (launch-time reconciliation). +/// +/// Best-effort: relay errors for one agent are logged and skipped, never fatal. +/// Returns the number of engrams written. +pub async fn check_fleet_updates(app: &AppHandle) -> Result { + let state = app.state::(); + let personas = load_personas(app)?; + let agents = load_managed_agents(app)?; + + let (owner_keys, relay_url) = owner_context(&state)?; + let mut written = 0; + + for agent in &agents { + let Some(persona) = persona_for_agent(agent, &personas) else { + continue; + }; + match reconcile_agent(&state, &owner_keys, &relay_url, agent, persona).await { + Ok(true) => written += 1, + Ok(false) => {} + Err(e) => eprintln!( + "buzz-desktop: fleet-update: skipped agent {}: {e}", + short(&agent.pubkey) + ), + } + } + + Ok(written) +} + +/// Run fleet update for the agents tied to a single persona (on persona save). +/// +/// Targeted variant of [`check_fleet_updates`] — only touches agents whose +/// `persona_id` matches. Best-effort with the same skip-on-error posture. +pub async fn fleet_update_for_persona(app: &AppHandle, persona_id: &str) -> Result { + let state = app.state::(); + let personas = load_personas(app)?; + let agents = load_managed_agents(app)?; + + let Some(persona) = personas.iter().find(|p| p.id == persona_id) else { + return Ok(0); // Persona deleted between save and update — nothing to do. + }; + + let (owner_keys, relay_url) = owner_context(&state)?; + let mut written = 0; + + for agent in &agents { + if agent.persona_id.as_deref() != Some(persona_id) { + continue; + } + match reconcile_agent(&state, &owner_keys, &relay_url, agent, persona).await { + Ok(true) => written += 1, + Ok(false) => {} + Err(e) => eprintln!( + "buzz-desktop: fleet-update: skipped agent {}: {e}", + short(&agent.pubkey) + ), + } + } + + Ok(written) +} + +/// Reconcile one agent's engram against a persona. Returns `Ok(true)` if a +/// rewrite was published, `Ok(false)` for a no-op. +async fn reconcile_agent( + state: &AppState, + owner_keys: &nostr::Keys, + relay_url: &str, + agent: &ManagedAgentRecord, + persona: &PersonaRecord, +) -> Result { + let agent_keys = nostr::Keys::parse(&agent.private_key_nsec) + .map_err(|e| format!("invalid agent key: {e}"))?; + let agent_pubkey = agent_keys.public_key(); + + let current_hash = persona_content_hash(&persona_event_content(persona)); + let stored_version = read_engram_source_version(state, owner_keys, &agent_pubkey).await; + + if fleet_action_for(¤t_hash, stored_version.as_deref()) == FleetAction::NoOp { + return Ok(false); + } + + apply_fleet_update( + state, + &agent_keys, + &owner_keys.public_key(), + relay_url, + persona, + ) + .await?; + eprintln!( + "buzz-desktop: fleet-update: rewrote mem/persona engram for agent {} (persona {})", + short(&agent.pubkey), + persona.id + ); + Ok(true) +} + +/// Write the initial `mem/persona` engram for a freshly created agent. +/// +/// Called from `create_managed_agent` once the persona is known. Best-effort: +/// the caller logs and continues on error so engram failure never blocks agent +/// creation. The owner key comes from app state; the persona is resolved by id. +pub async fn write_persona_engram_at_creation( + state: &AppState, + agent_keys: &nostr::Keys, + relay_url: &str, + persona_id: &str, + app: &AppHandle, +) -> Result<(), String> { + let personas = load_personas(app)?; + let persona = personas + .iter() + .find(|p| p.id == persona_id) + .ok_or_else(|| format!("persona {persona_id} not found"))?; + + let owner_pubkey = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + keys.public_key() + }; + apply_fleet_update(state, agent_keys, &owner_pubkey, relay_url, persona).await +} + +/// Build and publish a fresh `mem/persona` engram for one agent. +async fn apply_fleet_update( + state: &AppState, + agent_keys: &nostr::Keys, + owner_pubkey: &PublicKey, + relay_url: &str, + persona: &PersonaRecord, +) -> Result<(), String> { + let now = now_secs(); + let event = build_persona_engram(agent_keys, owner_pubkey, persona, now)?; + publish_agent_event(state, agent_keys, relay_url, &event).await +} + +/// Fetch the agent's current `mem/persona` engram from the relay and return the +/// recorded `provenance.source_version`. `None` if no engram exists or it can't +/// be read/decrypted — both cases trigger a (re)write, which is safe under +/// NIP-33 latest-wins replacement. +/// +/// The owner holds the only key needed: the agent↔owner conversation key is +/// symmetric, so the owner decrypts with its own secret key and the agent's +/// pubkey. No agent secret key is required at read time. +async fn read_engram_source_version( + state: &AppState, + owner_keys: &nostr::Keys, + agent_pubkey: &PublicKey, +) -> Option { + let owner_pubkey = owner_keys.public_key(); + let k_c = conversation_key(owner_keys.secret_key(), agent_pubkey); + let expected_d = d_tag(&k_c, PERSONA_ENGRAM_SLUG); + + // Read-gated kind:30174 query: owner-authored auth, #p = owner (the owner + // reading engrams addressed to them). #d narrows to the persona slot. + let filter = serde_json::json!({ + "kinds": [KIND_AGENT_ENGRAM], + "authors": [agent_pubkey.to_hex()], + "#p": [owner_pubkey.to_hex()], + "#d": [expected_d], + }); + let events = relay::query_relay(state, &[filter]).await.ok()?; + + let head = select_head(events)?; + let body = persona_engram_from_event_as_owner(&head, agent_pubkey, owner_keys).ok()?; + Some(body.provenance.source_version) +} + +/// Publish an engram event signed by the agent, authenticating the write with +/// the agent's keys (NIP-98) and NIP-OA auth tag. +async fn publish_agent_event( + state: &AppState, + agent_keys: &nostr::Keys, + relay_url: &str, + event: &nostr::Event, +) -> Result<(), String> { + let url = format!("{}/events", relay::relay_http_base_url(relay_url)); + let body_bytes = nostr::JsonUtil::as_json(event).into_bytes(); + let auth = relay::build_nip98_auth_header_for_keys( + agent_keys, + &reqwest::Method::POST, + &url, + &body_bytes, + )?; + + let response = state + .http_client + .post(&url) + .header("Authorization", auth) + .header("Content-Type", "application/json") + .body(body_bytes) + .send() + .await + .map_err(|e| format!("publish request failed: {e}"))?; + + if !response.status().is_success() { + return Err(relay::relay_error_message(response).await); + } + + let result: relay::SubmitEventResponse = response + .json() + .await + .map_err(|e| format!("failed to parse publish response: {e}"))?; + + if !result.accepted { + return Err(format!("relay rejected engram: {}", result.message)); + } + + Ok(()) +} + +/// Resolve the persona an agent was instantiated from, if any. +fn persona_for_agent<'a>( + agent: &ManagedAgentRecord, + personas: &'a [PersonaRecord], +) -> Option<&'a PersonaRecord> { + let persona_id = agent.persona_id.as_deref()?; + personas.iter().find(|p| p.id == persona_id) +} + +/// Snapshot the owner's keys and relay URL for a fleet-update pass. +fn owner_context(state: &AppState) -> Result<(nostr::Keys, String), String> { + let owner_keys = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + keys.clone() + }; + let relay_url = relay::relay_ws_url_with_override(state); + Ok((owner_keys, relay_url)) +} + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn short(pubkey: &str) -> &str { + &pubkey[..pubkey.len().min(8)] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_engram_writes() { + assert_eq!(fleet_action_for("abc123", None), FleetAction::Write); + } + + #[test] + fn matching_hash_is_noop() { + assert_eq!( + fleet_action_for("abc123", Some("abc123")), + FleetAction::NoOp + ); + } + + #[test] + fn differing_hash_writes() { + assert_eq!( + fleet_action_for("abc123", Some("def456")), + FleetAction::Write + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 78ffd29ee..c3f757bcf 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,6 +1,7 @@ mod backend; mod discovery; mod env_vars; +pub(crate) mod fleet_update; mod nest; mod persona_avatars; mod persona_card; diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 1bd0a2ade..32afae2b2 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -6,14 +6,18 @@ use std::collections::BTreeMap; use buzz_core_pkg::kind::KIND_PERSONA; -use nostr::{EventBuilder, Kind, Tag}; +use nostr::{EventBuilder, Kind, PublicKey, Tag}; use serde::{Deserialize, Serialize}; use super::PersonaRecord; use crate::app_state::AppState; +/// The slug for the per-agent persona memory engram. The agent stores a +/// snapshot of the persona it was instantiated from under this memory slot. +pub const PERSONA_ENGRAM_SLUG: &str = "mem/persona"; + /// The JSON body stored in a persona event's content field. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PersonaEventContent { pub display_name: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -129,6 +133,155 @@ pub async fn fetch_persona_events(state: &AppState) -> Result, crate::relay::query_relay(state, &[filter]).await } +/// Provenance recorded inside a persona engram's encrypted body. Identifies the +/// source persona event the agent was instantiated from and a content digest +/// used by fleet update to detect drift. Kept inside the encrypted body (not as +/// a plaintext tag) so the engram's blinding guarantee is preserved. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersonaProvenance { + pub owner_pubkey: String, + pub kind: u32, + pub slug: String, + /// SHA-256 of the canonical persona content JSON at the time of the write. + pub source_version: String, +} + +/// The decrypted body of a `mem/persona` engram: the persona snapshot plus its +/// provenance. Serialized as the engram's memory `value` string. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PersonaEngramBody { + #[serde(flatten)] + pub content: PersonaEventContent, + pub provenance: PersonaProvenance, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub env_vars: BTreeMap, +} + +/// SHA-256 (lowercase hex) of a persona's canonical content JSON. +/// +/// Fleet update compares this digest, not event timestamps, to decide whether +/// an agent's engram is stale — timestamps are fragile across clock skew and +/// export/import round-trips. `PersonaEventContent` field order is fixed by the +/// struct definition, so `serde_json` produces a stable canonical encoding. +pub fn persona_content_hash(content: &PersonaEventContent) -> String { + use sha2::{Digest, Sha256}; + let json = serde_json::to_vec(content).unwrap_or_default(); + let digest = Sha256::digest(&json); + hex::encode(digest) +} + +/// Project a `PersonaRecord` onto the content fields published in persona +/// events and engrams. Centralizes the field mapping so a new persona field is +/// added in exactly one place. +pub fn persona_event_content(record: &PersonaRecord) -> PersonaEventContent { + PersonaEventContent { + display_name: record.display_name.clone(), + avatar_url: record.avatar_url.clone(), + system_prompt: record.system_prompt.clone(), + runtime: record.runtime.clone(), + model: record.model.clone(), + provider: record.provider.clone(), + name_pool: record.name_pool.clone(), + } +} + +/// Build the decrypted body for a persona engram from a `PersonaRecord`. +fn persona_engram_body(record: &PersonaRecord, owner_pubkey: &PublicKey) -> PersonaEngramBody { + let content = persona_event_content(record); + let source_version = persona_content_hash(&content); + let provenance = PersonaProvenance { + owner_pubkey: owner_pubkey.to_hex(), + kind: KIND_PERSONA, + slug: persona_d_tag(record), + source_version, + }; + PersonaEngramBody { + content, + provenance, + env_vars: record.env_vars.clone(), + } +} + +/// Build a signed `mem/persona` engram (kind:30174) for an agent. +/// +/// The engram is authored by the agent and addressed to the owner: its content +/// is NIP-44 encrypted under `ECDH(agent_seckey, owner_pubkey)` and the d-tag is +/// HMAC-blinded over the `mem/persona` slug. The persona snapshot and its +/// provenance live inside the encrypted body. +pub fn build_persona_engram( + agent_keys: &nostr::Keys, + owner_pubkey: &PublicKey, + record: &PersonaRecord, + created_at: u64, +) -> Result { + let body = persona_engram_body(record, owner_pubkey); + let value = serde_json::to_string(&body) + .map_err(|e| format!("failed to serialize engram body: {e}"))?; + + let engram_body = buzz_core_pkg::engram::Body::Memory { + slug: PERSONA_ENGRAM_SLUG.to_string(), + value: Some(value), + }; + + buzz_core_pkg::engram::build_event(agent_keys, owner_pubkey, &engram_body, created_at) + .map_err(|e| format!("failed to build persona engram: {e}")) +} + +/// Decode a persona engram body from a relay event, validating the envelope and +/// decrypting under the agent↔owner conversation key. +pub fn persona_engram_from_event( + event: &nostr::Event, + agent_keys: &nostr::Keys, + owner_pubkey: &PublicKey, +) -> Result { + let body = buzz_core_pkg::engram::validate_and_decrypt( + event, + &agent_keys.public_key(), + owner_pubkey, + agent_keys.secret_key(), + owner_pubkey, + ) + .map_err(|e| format!("failed to validate persona engram: {e}"))?; + + engram_body_from_decrypted(body) +} + +/// Decode a persona engram as the owner (fleet update reads engrams using the +/// owner's secret key + agent's pubkey — the conversation key is symmetric). +pub fn persona_engram_from_event_as_owner( + event: &nostr::Event, + agent_pubkey: &PublicKey, + owner_keys: &nostr::Keys, +) -> Result { + let body = buzz_core_pkg::engram::validate_and_decrypt( + event, + agent_pubkey, + &owner_keys.public_key(), + owner_keys.secret_key(), + agent_pubkey, + ) + .map_err(|e| format!("failed to validate persona engram: {e}"))?; + + engram_body_from_decrypted(body) +} + +fn engram_body_from_decrypted( + body: buzz_core_pkg::engram::Body, +) -> Result { + match body { + buzz_core_pkg::engram::Body::Memory { + value: Some(value), .. + } => serde_json::from_str(&value) + .map_err(|e| format!("failed to parse persona engram body: {e}")), + buzz_core_pkg::engram::Body::Memory { value: None, .. } => { + Err("persona engram is a tombstone".to_string()) + } + buzz_core_pkg::engram::Body::Core { .. } => { + Err("expected memory engram, got core".to_string()) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -242,4 +395,72 @@ mod tests { assert!(!restored.is_builtin); assert!(restored.is_active); } + + #[test] + fn persona_engram_round_trip() { + let record = sample_persona(); + let agent_keys = nostr::Keys::generate(); + let owner_keys = nostr::Keys::generate(); + let owner_pubkey = owner_keys.public_key(); + + let now = 1_700_000_000u64; + let event = build_persona_engram(&agent_keys, &owner_pubkey, &record, now).unwrap(); + + // Verify it's a kind:30174 event + assert_eq!( + event.kind.as_u16() as u32, + buzz_core_pkg::kind::KIND_AGENT_ENGRAM + ); + // Verify it's authored by the agent + assert_eq!(event.pubkey, agent_keys.public_key()); + + // Decrypt and verify content + let body = persona_engram_from_event(&event, &agent_keys, &owner_pubkey).unwrap(); + assert_eq!(body.content.display_name, "Test Persona"); + assert_eq!(body.content.system_prompt, "You are a test assistant."); + assert_eq!(body.provenance.owner_pubkey, owner_pubkey.to_hex()); + assert_eq!(body.provenance.kind, KIND_PERSONA); + assert_eq!(body.provenance.slug, "test-slug"); + assert!(!body.provenance.source_version.is_empty()); + assert_eq!( + body.env_vars, + BTreeMap::from([("KEY".to_string(), "value".to_string())]) + ); + } + + #[test] + fn persona_content_hash_is_deterministic() { + let content = PersonaEventContent { + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: "Hello".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + }; + let hash1 = persona_content_hash(&content); + let hash2 = persona_content_hash(&content); + assert_eq!(hash1, hash2); + assert_eq!(hash1.len(), 64); // SHA-256 hex + } + + #[test] + fn persona_content_hash_changes_on_edit() { + let content1 = PersonaEventContent { + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: "Hello".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + }; + let mut content2 = content1.clone(); + content2.system_prompt = "Goodbye".to_string(); + assert_ne!( + persona_content_hash(&content1), + persona_content_hash(&content2) + ); + } } diff --git a/desktop/src-tauri/src/managed_agents/teams.rs b/desktop/src-tauri/src/managed_agents/teams.rs index 04b46b7fd..d38aa9a38 100644 --- a/desktop/src-tauri/src/managed_agents/teams.rs +++ b/desktop/src-tauri/src/managed_agents/teams.rs @@ -169,6 +169,7 @@ pub(super) fn teams_dir(app: &AppHandle) -> Result { } /// Validate team/pack ID: only `[a-zA-Z0-9._-]+` allowed (zip-slip defense). +#[cfg(feature = "legacy_team_sync")] pub(crate) fn validate_team_id(id: &str) -> Result<(), String> { if id.is_empty() { return Err("team ID is empty".into()); @@ -196,6 +197,7 @@ pub(crate) fn validate_team_id(id: &str) -> Result<(), String> { } /// Copy a directory tree, skipping symlinks (zip-slip defense). +#[cfg(feature = "legacy_team_sync")] fn copy_dir_no_symlinks(src: &std::path::Path, dst: &std::path::Path) -> Result<(), String> { fs::create_dir_all(dst).map_err(|e| format!("failed to create {}: {e}", dst.display()))?; for entry in fs::read_dir(src).map_err(|e| format!("failed to read {}: {e}", src.display()))? { @@ -222,6 +224,7 @@ fn copy_dir_no_symlinks(src: &std::path::Path, dst: &std::path::Path) -> Result< /// /// Copies the directory into `/agents/teams//`, /// creates PersonaRecords for each persona, creates a TeamRecord with source_dir set. +#[cfg(feature = "legacy_team_sync")] pub fn import_team_from_directory( app: &AppHandle, source_dir: &std::path::Path, @@ -416,6 +419,7 @@ pub fn delete_team_with_cascade(app: &AppHandle, team_id: &str) -> Result<(), St } /// Re-reads a directory-backed team and reconciles with stored records. +#[cfg(feature = "legacy_team_sync")] pub fn sync_team_from_dir( app: &AppHandle, team_id: &str, @@ -717,6 +721,28 @@ pub fn parse_team_json(json_bytes: &[u8]) -> Result { }) } +/// Sync all directory-backed teams on launch — the team equivalent of the +/// former `sync_pack_personas`. Silently skips teams whose source directory +/// is missing (e.g., external drive unmounted). +/// +/// Superseded by `fleet_update::check_fleet_updates`, which propagates persona +/// edits to agent engrams instead of directory files. Retained behind +/// `legacy_team_sync` for one release as a rollback path; `#[allow(dead_code)]` +/// because the launch call site moved to fleet update. +#[cfg(feature = "legacy_team_sync")] +#[allow(dead_code)] +pub fn sync_team_personas(app: &AppHandle) -> Result<(), String> { + let teams = load_teams(app)?; + for team in &teams { + if team.source_dir.as_ref().is_some_and(|d| d.exists()) { + if let Err(e) = sync_team_from_dir(app, &team.id) { + eprintln!("buzz-desktop: sync team {}: {e}", team.id); + } + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::{ diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e9d733e54..d45744bcd 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -527,6 +527,7 @@ pub struct UpdateTeamRequest { } /// Result of syncing a directory-backed team with its backing directory. +#[cfg_attr(not(feature = "legacy_team_sync"), allow(dead_code))] #[derive(Debug, Clone, Serialize)] pub struct SyncResult { pub personas_added: Vec, diff --git a/desktop/tests/e2e/persona-instantiation.spec.ts b/desktop/tests/e2e/persona-instantiation.spec.ts new file mode 100644 index 000000000..b6ffaa1ad --- /dev/null +++ b/desktop/tests/e2e/persona-instantiation.spec.ts @@ -0,0 +1,440 @@ +import { expect, test } from "@playwright/test"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +import { hmac } from "@noble/hashes/hmac.js"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { getConversationKey, encrypt, decrypt } from "nostr-tools/nip44"; +import { + finalizeEvent, + getPublicKey, + generateSecretKey, +} from "nostr-tools/pure"; + +import { installRelayBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { assertRelaySeeded } from "../helpers/seed"; + +const RELAY_HTTP_URL = "http://localhost:3000"; +const KIND_AGENT_ENGRAM = 30174; +const KIND_PERSONA = 30175; +const D_TAG_DOMAIN = "agent-memory/v1/d-tag"; +const PERSONA_ENGRAM_SLUG = "mem/persona"; + +const isCi = Boolean(process.env.CI); +const relaySeedHookTimeoutMs = isCi ? 90_000 : 30_000; + +/** + * Compute the HMAC-blinded d-tag for an engram slug. + * Mirrors `buzz_core::engram::d_tag`: + * d = lower_hex(HMAC-SHA256(K_c, "agent-memory/v1/d-tag" || 0x00 || slug)) + */ +function computeEngramDTag(conversationKey: Uint8Array, slug: string): string { + const domain = new TextEncoder().encode(D_TAG_DOMAIN); + const slugBytes = new TextEncoder().encode(slug); + const message = new Uint8Array(domain.length + 1 + slugBytes.length); + message.set(domain, 0); + message[domain.length] = 0x00; + message.set(slugBytes, domain.length + 1); + return bytesToHex(hmac(sha256, conversationKey, message)); +} + +/** + * Build the engram body JSON that gets NIP-44 encrypted. + * Mirrors `buzz_core::engram::Body::Memory::to_json_bytes`: + * {"slug":"mem/persona","value":""} + */ +function buildEngramBodyJson(slug: string, value: string): string { + return JSON.stringify({ slug, value }); +} + +/** + * Build the PersonaEngramBody (the value inside the engram). + * Mirrors `PersonaEngramBody` in persona_events.rs. + */ +function buildPersonaEngramBody( + persona: { + displayName: string; + systemPrompt: string; + avatarUrl?: string | null; + runtime?: string | null; + model?: string | null; + provider?: string | null; + namePool?: string[]; + envVars?: Record; + }, + ownerPubkey: string, + slug: string, +): string { + const content: Record = { + display_name: persona.displayName, + system_prompt: persona.systemPrompt, + }; + if (persona.avatarUrl) content.avatar_url = persona.avatarUrl; + if (persona.runtime) content.runtime = persona.runtime; + if (persona.model) content.model = persona.model; + if (persona.provider) content.provider = persona.provider; + if (persona.namePool && persona.namePool.length > 0) + content.name_pool = persona.namePool; + if (persona.envVars && Object.keys(persona.envVars).length > 0) + content.env_vars = persona.envVars; + + // Compute source_version: SHA-256 of the canonical PersonaEventContent JSON + const contentForHash: Record = { + display_name: persona.displayName, + avatar_url: persona.avatarUrl ?? null, + system_prompt: persona.systemPrompt, + }; + if (persona.runtime) contentForHash.runtime = persona.runtime; + if (persona.model) contentForHash.model = persona.model; + if (persona.provider) contentForHash.provider = persona.provider; + if (persona.namePool && persona.namePool.length > 0) + contentForHash.name_pool = persona.namePool; + if (persona.envVars && Object.keys(persona.envVars).length > 0) + contentForHash.env_vars = persona.envVars; + + const sourceVersion = bytesToHex( + sha256(new TextEncoder().encode(JSON.stringify(contentForHash))), + ); + + return JSON.stringify({ + ...content, + provenance: { + owner_pubkey: ownerPubkey, + kind: KIND_PERSONA, + slug, + source_version: sourceVersion, + }, + }); +} + +/** + * Submit a signed event to the relay via HTTP POST. + */ +async function submitEvent( + event: ReturnType & { pubkey: string }, +): Promise<{ + event_id: string; + accepted: boolean; + message: string; +}> { + const response = await fetch(`${RELAY_HTTP_URL}/events`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Pubkey": event.pubkey }, + body: JSON.stringify(event), + }); + if (!response.ok) { + throw new Error(`Relay rejected event: ${await response.text()}`); + } + return response.json(); +} + +/** + * Query the relay for events matching filters. + */ +async function queryRelay( + filters: Record[], + pubkey: string, +): Promise< + Array<{ + id: string; + pubkey: string; + kind: number; + content: string; + tags: string[][]; + }> +> { + const response = await fetch(`${RELAY_HTTP_URL}/query`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Pubkey": pubkey, + }, + body: JSON.stringify(filters), + }); + if (!response.ok) { + throw new Error(`Relay query failed: ${await response.text()}`); + } + return response.json(); +} + +test.beforeAll(async () => { + test.setTimeout(relaySeedHookTimeoutMs); + await assertRelaySeeded(); +}); + +test("create agent from persona publishes mem/persona engram to relay", async ({ + page, +}) => { + await installRelayBridge(page, "tyler"); + await page.goto("/"); + + const ownerIdentity = TEST_IDENTITIES.tyler; + const ownerPubkey = ownerIdentity.pubkey; + + // Generate a fresh agent keypair (simulates what create_managed_agent does) + const agentSecretKey = generateSecretKey(); + const agentPubkey = getPublicKey(agentSecretKey); + + // Define the persona + const persona = { + displayName: "Integration Test Persona", + systemPrompt: "You are an integration test assistant.", + runtime: "goose", + model: "claude-sonnet-4", + provider: "anthropic", + }; + const personaSlug = `test-persona-${Date.now()}`; + + // Step 1: Publish the persona event (kind:30175) as the owner + const personaContent = JSON.stringify({ + display_name: persona.displayName, + system_prompt: persona.systemPrompt, + runtime: persona.runtime, + model: persona.model, + provider: persona.provider, + }); + + const personaEvent = finalizeEvent( + { + kind: KIND_PERSONA, + content: personaContent, + tags: [["d", personaSlug]], + created_at: Math.floor(Date.now() / 1000), + }, + hexToBytes(ownerIdentity.privateKey), + ); + + const personaResult = await submitEvent(personaEvent); + expect(personaResult.accepted).toBe(true); + + // Step 2: Build and publish the persona engram (kind:30174) as the agent + // This mirrors write_persona_engram_at_creation in fleet_update.rs + const conversationKey = getConversationKey(agentSecretKey, ownerPubkey); + const engramDTag = computeEngramDTag(conversationKey, PERSONA_ENGRAM_SLUG); + + const personaEngramValue = buildPersonaEngramBody( + persona, + ownerPubkey, + personaSlug, + ); + const engramBodyJson = buildEngramBodyJson( + PERSONA_ENGRAM_SLUG, + personaEngramValue, + ); + + // NIP-44 encrypt the body + const ciphertext = encrypt(engramBodyJson, conversationKey); + + const engramEvent = finalizeEvent( + { + kind: KIND_AGENT_ENGRAM, + content: ciphertext, + tags: [ + ["d", engramDTag], + ["p", ownerPubkey], + ], + created_at: Math.floor(Date.now() / 1000), + }, + agentSecretKey, + ); + + const engramResult = await submitEvent(engramEvent); + expect(engramResult.accepted).toBe(true); + + // Step 3: Query the relay to verify the persona event exists + const personaEvents = await queryRelay( + [{ kinds: [KIND_PERSONA], authors: [ownerPubkey], "#d": [personaSlug] }], + ownerPubkey, + ); + expect(personaEvents.length).toBe(1); + expect(personaEvents[0].kind).toBe(KIND_PERSONA); + expect(JSON.parse(personaEvents[0].content).display_name).toBe( + persona.displayName, + ); + + // Step 4: Query the relay to verify the engram event exists + const engramEvents = await queryRelay( + [ + { + kinds: [KIND_AGENT_ENGRAM], + authors: [agentPubkey], + "#d": [engramDTag], + "#p": [ownerPubkey], + }, + ], + ownerPubkey, + ); + expect(engramEvents.length).toBe(1); + expect(engramEvents[0].kind).toBe(KIND_AGENT_ENGRAM); + expect(engramEvents[0].pubkey).toBe(agentPubkey); + + // Step 5: Decrypt the engram and verify content + const ownerConversationKey = getConversationKey( + hexToBytes(ownerIdentity.privateKey), + agentPubkey, + ); + const decryptedJson = decrypt(engramEvents[0].content, ownerConversationKey); + const decrypted = JSON.parse(decryptedJson); + expect(decrypted.slug).toBe(PERSONA_ENGRAM_SLUG); + + const engramBody = JSON.parse(decrypted.value); + expect(engramBody.display_name).toBe(persona.displayName); + expect(engramBody.system_prompt).toBe(persona.systemPrompt); + expect(engramBody.provenance.owner_pubkey).toBe(ownerPubkey); + expect(engramBody.provenance.kind).toBe(KIND_PERSONA); + expect(engramBody.provenance.slug).toBe(personaSlug); + expect(engramBody.provenance.source_version).toHaveLength(64); +}); + +test("persona edit triggers fleet update that rewrites engram on relay", async ({ + page, +}) => { + await installRelayBridge(page, "tyler"); + await page.goto("/"); + + const ownerIdentity = TEST_IDENTITIES.tyler; + const ownerPubkey = ownerIdentity.pubkey; + + // Generate agent keypair + const agentSecretKey = generateSecretKey(); + const agentPubkey = getPublicKey(agentSecretKey); + + const personaSlug = `fleet-test-${Date.now()}`; + const conversationKey = getConversationKey(agentSecretKey, ownerPubkey); + const engramDTag = computeEngramDTag(conversationKey, PERSONA_ENGRAM_SLUG); + + // Initial persona + const initialPersona = { + displayName: "Fleet Test Persona", + systemPrompt: "Initial system prompt.", + }; + + // Publish initial persona event + const initialPersonaEvent = finalizeEvent( + { + kind: KIND_PERSONA, + content: JSON.stringify({ + display_name: initialPersona.displayName, + system_prompt: initialPersona.systemPrompt, + }), + tags: [["d", personaSlug]], + created_at: Math.floor(Date.now() / 1000) - 10, + }, + hexToBytes(ownerIdentity.privateKey), + ); + const personaResult = await submitEvent(initialPersonaEvent); + expect(personaResult.accepted).toBe(true); + + // Publish initial engram + const initialEngramValue = buildPersonaEngramBody( + initialPersona, + ownerPubkey, + personaSlug, + ); + const initialEngramBody = buildEngramBodyJson( + PERSONA_ENGRAM_SLUG, + initialEngramValue, + ); + const initialCiphertext = encrypt(initialEngramBody, conversationKey); + + const initialEngramEvent = finalizeEvent( + { + kind: KIND_AGENT_ENGRAM, + content: initialCiphertext, + tags: [ + ["d", engramDTag], + ["p", ownerPubkey], + ], + created_at: Math.floor(Date.now() / 1000) - 5, + }, + agentSecretKey, + ); + const initialEngramResult = await submitEvent(initialEngramEvent); + expect(initialEngramResult.accepted).toBe(true); + + // Edit the persona (simulates update_persona + fleet_update_for_persona) + const updatedPersona = { + displayName: "Fleet Test Persona", + systemPrompt: "Updated system prompt after edit.", + model: "claude-opus-4", + }; + + // Publish updated persona event (NIP-33 replacement — same d-tag, newer timestamp) + const updatedPersonaEvent = finalizeEvent( + { + kind: KIND_PERSONA, + content: JSON.stringify({ + display_name: updatedPersona.displayName, + system_prompt: updatedPersona.systemPrompt, + model: updatedPersona.model, + }), + tags: [["d", personaSlug]], + created_at: Math.floor(Date.now() / 1000), + }, + hexToBytes(ownerIdentity.privateKey), + ); + const updatedPersonaResult = await submitEvent(updatedPersonaEvent); + expect(updatedPersonaResult.accepted).toBe(true); + + // Fleet update: rewrite the engram with updated content + const updatedEngramValue = buildPersonaEngramBody( + updatedPersona, + ownerPubkey, + personaSlug, + ); + const updatedEngramBody = buildEngramBodyJson( + PERSONA_ENGRAM_SLUG, + updatedEngramValue, + ); + const updatedCiphertext = encrypt(updatedEngramBody, conversationKey); + + const updatedEngramEvent = finalizeEvent( + { + kind: KIND_AGENT_ENGRAM, + content: updatedCiphertext, + tags: [ + ["d", engramDTag], + ["p", ownerPubkey], + ], + created_at: Math.floor(Date.now() / 1000), + }, + agentSecretKey, + ); + const updatedEngramResult = await submitEvent(updatedEngramEvent); + expect(updatedEngramResult.accepted).toBe(true); + + // Verify: only the latest engram is returned (NIP-33 replacement) + const engramEvents = await queryRelay( + [ + { + kinds: [KIND_AGENT_ENGRAM], + authors: [agentPubkey], + "#d": [engramDTag], + "#p": [ownerPubkey], + }, + ], + ownerPubkey, + ); + expect(engramEvents.length).toBe(1); + + // Decrypt and verify it's the updated content + const ownerConversationKey = getConversationKey( + hexToBytes(ownerIdentity.privateKey), + agentPubkey, + ); + const decryptedJson = decrypt(engramEvents[0].content, ownerConversationKey); + const decrypted = JSON.parse(decryptedJson); + const engramBody = JSON.parse(decrypted.value); + + expect(engramBody.system_prompt).toBe("Updated system prompt after edit."); + expect(engramBody.model).toBe("claude-opus-4"); + expect(engramBody.provenance.source_version).toHaveLength(64); + + // Verify persona event was also replaced (NIP-33) + const personaEvents = await queryRelay( + [{ kinds: [KIND_PERSONA], authors: [ownerPubkey], "#d": [personaSlug] }], + ownerPubkey, + ); + expect(personaEvents.length).toBe(1); + expect(JSON.parse(personaEvents[0].content).system_prompt).toBe( + "Updated system prompt after edit.", + ); +});