diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 8e445209a..04a4ce4ae 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -94,10 +94,6 @@ const overrides = new Map([ // 3-phase (stage/stop/commit) + commit_cascade_agents injectable helper for // retry-safety. Load-bearing reviewer-required change; queued to split. // Consolidation removed the legacy persona-card import/export codecs. - // Retired-in-place ratchet: the edit path moved to personas/update.rs and - // the create path to personas/create.rs, taking mod.rs from 1003 to 719. - // Kept as a ratchet so neither split can silently refill. - ["src-tauri/src/commands/personas/mod.rs", 719], // #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS // const + build_thread_replies_filter helper, mirroring the channel sibling so // the two p-gate filters can't drift) plus two guard unit tests. The file was diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs new file mode 100644 index 000000000..d7ffecef2 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -0,0 +1,450 @@ +//! Inbound relay → local store reconciliation for persona/team/managed-agent +//! projections and their NIP-09 tombstones. Extracted from the parent module to +//! keep it under the file-size cap. + +use tauri::{AppHandle, Emitter, Manager}; + +use crate::{ + app_state::AppState, + managed_agents::{ + agent_events::ManagedAgentEventContent, load_personas, persona_events::persona_d_tag, + save_personas, team_events::TeamEventContent, try_regenerate_nest, AgentDefinition, + ManagedAgentRecord, TeamRecord, + }, + util::now_iso, +}; + +#[cfg(test)] +mod inbound_tests; + +/// Apply an inbound kind:30175 persona event from the relay onto the local +/// store. The frontend's live subscription invokes this per event for our own +/// authored coordinate so Device B inherits Device A's edits. +/// +/// Retention is a sync channel that writes INTO `personas.json`, never an +/// authoritative read source — `load_personas` is untouched, so every agent +/// keeps resolving its persona by UUID and keeps its provider keys. +/// +/// MATCH KEY (single source of truth, both directions): an inbound event +/// matches the local record whose `persona_d_tag(record)` equals the event's +/// d-tag. Reusing the same derivation the outbound path uses guarantees the +/// inbound key can never drift from the outbound key — in particular, an +/// in-app persona (`source_team_persona_slug == None`) whose d-tag IS its +/// `id` matches its existing UUID row instead of minting a duplicate. +/// +/// On match: patch ONLY the projected fields; preserve local `id`, `env_vars`, +/// `source_team`, and `created_at`. On no match: insert the parsed record as-is +/// — `persona_from_event` already sets `id = d_tag`, so an in-app persona reuses +/// its d-tag as the id and a re-received event stays idempotent (no duplicate). +/// +/// The retention store decides whether the inbound event wins over a pending +/// local edit (`retain_inbound_event`): `personas.json` is only patched when the +/// retain reports [`InboundOutcome::Applied`], so an equal-second collision with +/// a pending local edit leaves the local record — and its queued publish — +/// untouched. +/// +/// `arrival_relay_url` is the relay the calling subscription is bound to. The +/// retention store this event belongs to is decided by the community that +/// DELIVERED it, not by whichever community happens to be active when the +/// reconcile runs — a workspace switch in flight would otherwise file community +/// A's event into community B's scoped database. An event whose arrival relay is +/// no longer the active scope is dropped: it was already durable in its own +/// community's store when it arrived there, and that community's next boot +/// reconcile refetches it. +#[tauri::command] +pub async fn reconcile_inbound_persona_event( + event_json: String, + arrival_relay_url: String, + app: AppHandle, +) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + reconcile_inbound_persona_event_blocking(event_json, arrival_relay_url, app) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +fn reconcile_inbound_persona_event_blocking( + event_json: String, + arrival_relay_url: String, + app: AppHandle, +) -> Result<(), String> { + use crate::managed_agents::{ + agent_events::managed_agent_content_from_event, + load_managed_agents, load_teams, + persona_events::persona_from_event, + retention::{open_retention_db, retain_inbound_event, InboundOutcome, RetainedEvent}, + 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 nostr::JsonUtil; + + let state = app.state::(); + let event = parse_verified_inbound_event(&event_json)?; + + // The live filter subscribes to 30175/30176/30177 (upserts) plus kind:5 + // (NIP-09 deletions). d-tags are NOT unique across kinds, so every path + // below dispatches on kind FIRST and only ever touches its own store — a + // cross-kind d-tag collision can never link a team to a persona or agent. + let kind = event.kind.as_u16() as u32; + + // kind:5 deletion: a tombstone removes the local record at the coordinate + // in its `a` tag (`::`). Handled before the + // upsert dispatch because its coordinate and retention key differ. + if kind == KIND_DELETION { + return reconcile_inbound_tombstone(&event, &arrival_relay_url, &app, &state); + } + + if !matches!(kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) { + return Ok(()); + } + + // 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 + // branch below — team/agent content is parsed in-branch since their d-tag + // comes from the event tag, not the content. + let inbound_persona = (kind == KIND_PERSONA) + .then(|| persona_from_event(&event)) + .transpose()?; + let d_tag = match &inbound_persona { + Some(persona) => persona_d_tag(persona), + None => event_d_tag(&event)?, + }; + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + + // Resolve inbound vs. any pending local edit before touching the store, in + // the scope the event ARRIVED on. A workspace switch since arrival leaves + // this event to its own community's store — dropping it here is what keeps + // community A's head out of community B's database. + let Some(scope) = crate::managed_agents::retention::arrival_retention_scope( + &app, + &state, + &arrival_relay_url, + )? + else { + return Ok(()); + }; + let conn = open_retention_db(&scope.db_path)?; + let outcome = retain_inbound_event( + &conn, + &RetainedEvent { + kind, + pubkey: event.pubkey.to_hex(), + d_tag: d_tag.clone(), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: false, + }, + )?; + if outcome == InboundOutcome::Skipped { + return Ok(()); + } + + match kind { + KIND_PERSONA => { + let mut personas = load_personas(&app)?; + // `inbound_persona` is `Some` for KIND_PERSONA (set above). + apply_inbound_persona( + &mut personas, + inbound_persona.expect("persona parsed above"), + ); + save_personas(&app, &personas)?; + } + KIND_TEAM => { + let mut teams = load_teams(&app)?; + apply_inbound_team(&mut teams, d_tag, team_content_from_event(&event)?); + save_teams(&app, &teams)?; + } + KIND_MANAGED_AGENT => { + let mut agents = load_managed_agents(&app)?; + apply_inbound_managed_agent( + &mut agents, + &d_tag, + managed_agent_content_from_event(&event)?, + ); + save_managed_agents(&app, &agents)?; + } + _ => unreachable!("kind gated above"), + } + try_regenerate_nest(&app); + + // Signal the live UI to refetch agents data — inbound relay events otherwise + // land on disk silently, leaving the Agents tab stale until restart. + 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 +/// TS-side owner filter reads the same attacker-controlled field and is no +/// defense. +fn parse_verified_inbound_event(event_json: &str) -> Result { + use nostr::JsonUtil; + let event = nostr::Event::from_json(event_json) + .map_err(|e| format!("failed to parse inbound event: {e}"))?; + event + .verify() + .map_err(|e| format!("inbound event failed signature verification: {e}"))?; + Ok(event) +} + +/// Parse a NIP-09 `a`-tag coordinate `::` into its +/// target kind and d-tag. Returns `None` if the tag is absent or malformed, so +/// the caller no-ops on a tombstone it can't route. +fn parse_deletion_coordinate(event: &nostr::Event) -> Option<(u32, String)> { + event.tags.iter().find_map(|tag| { + let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect(); + if values.first() != Some(&"a") { + return None; + } + let coord = values.get(1)?; + // `::` — d_tag may itself contain ':' so split at + // most twice and keep the remainder as the d_tag. + let mut parts = coord.splitn(3, ':'); + let kind: u32 = parts.next()?.parse().ok()?; + let owner = parts.next()?; + // NIP-09 scoping: only the record's author may tombstone it. The + // signature gate upstream proves `event.pubkey`; requiring the + // coordinate owner to match closes the other half — a validly + // signed kind:5 naming ANOTHER owner's coordinate must no-op. + if owner != event.pubkey.to_hex() { + return None; + } + let d_tag = parts.next()?; + Some((kind, d_tag.to_string())) + }) +} + +/// Apply an inbound kind:5 NIP-09 deletion: remove the local record at the +/// tombstone's target coordinate, scoped per-kind. Mirrors the upsert spine — +/// arrival-scoped retention resolution under the store lock, then a per-kind +/// store mutation — but removes rather than patches. Unknown/malformed +/// coordinates no-op, as does a tombstone whose arrival community is no longer +/// active. +fn reconcile_inbound_tombstone( + event: &nostr::Event, + arrival_relay_url: &str, + app: &AppHandle, + state: &AppState, +) -> Result<(), String> { + use crate::managed_agents::{ + load_managed_agents, load_teams, + retention::{ + open_retention_db, retain_inbound_event, tombstone_retention_d_tag, InboundOutcome, + RetainedEvent, + }, + save_managed_agents, save_teams, + }; + use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, 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) { + return Ok(()); // deletion for a kind we don't track locally + } + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + + // Resolve against the retained tombstone row (keyed by the target + // coordinate, F2c) so a re-received tombstone or one older than a pending + // local edit is a no-op. Scoped to the arrival community, so a workspace + // switch since arrival drops the tombstone instead of retaining it — and + // deleting a record — in the wrong community's store. + let Some(scope) = + crate::managed_agents::retention::arrival_retention_scope(app, state, arrival_relay_url)? + else { + return Ok(()); + }; + let conn = open_retention_db(&scope.db_path)?; + let outcome = retain_inbound_event( + &conn, + &RetainedEvent { + kind: KIND_DELETION, + pubkey: event.pubkey.to_hex(), + d_tag: tombstone_retention_d_tag(target_kind, &target_d_tag), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: false, + }, + )?; + if outcome == InboundOutcome::Skipped { + return Ok(()); + } + + // Remove the local record using the SAME per-kind match rule the apply fns + // use: persona by `persona_d_tag`, team by `id`, managed-agent by `pubkey`. + match target_kind { + KIND_PERSONA => { + let mut personas = load_personas(app)?; + personas.retain(|record| persona_d_tag(record) != target_d_tag); + save_personas(app, &personas)?; + } + KIND_TEAM => { + let mut teams = load_teams(app)?; + teams.retain(|record| record.id != target_d_tag); + save_teams(app, &teams)?; + } + KIND_MANAGED_AGENT => { + let mut agents = load_managed_agents(app)?; + agents.retain(|record| record.pubkey != target_d_tag); + save_managed_agents(app, &agents)?; + } + _ => unreachable!("target kind gated above"), + } + try_regenerate_nest(app); + + // Refresh the live UI on inbound deletion — a removal is as user-visible as + // an upsert and the Agents tab must drop the tombstoned record without restart. + let _ = app.emit("agents-data-changed", ()); + + Ok(()) +} + +/// Extract the `d` tag value from an event, the match key for team (= team id) +/// and managed-agent (= agent pubkey) inbound reconcile. +fn event_d_tag(event: &nostr::Event) -> Result { + event + .tags + .iter() + .find_map(|tag| { + let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect(); + (values.first() == Some(&"d")) + .then(|| values.get(1).map(|s| s.to_string())) + .flatten() + }) + .ok_or_else(|| "inbound event missing d-tag".to_string()) +} + +/// Merge a parsed inbound persona into the local set: patch the matching record +/// in place, or push it when none matches. +/// +/// The match key is `persona_d_tag` — the same derivation the outbound path +/// uses — so the inbound and outbound keys can never drift. On match, only the +/// projected fields are overwritten; local `id`, `env_vars`, `source_team`, and +/// `created_at` survive. On no match, the parsed record is inserted as-is; since +/// `persona_from_event` sets `id = d_tag`, an in-app persona reuses its d-tag as +/// the id and a re-received event stays idempotent (no duplicate row). +fn apply_inbound_persona(personas: &mut Vec, inbound: AgentDefinition) { + let d_tag = persona_d_tag(&inbound); + match personas + .iter_mut() + .find(|record| persona_d_tag(record) == d_tag) + { + Some(local) => { + local.display_name = inbound.display_name; + local.avatar_url = inbound.avatar_url; + local.system_prompt = inbound.system_prompt; + local.runtime = inbound.runtime; + local.model = inbound.model; + local.provider = inbound.provider; + local.name_pool = inbound.name_pool; + local.respond_to = inbound.respond_to; + local.respond_to_allowlist = inbound.respond_to_allowlist; + local.parallelism = inbound.parallelism; + local.shared = inbound.shared; + local.updated_at = inbound.updated_at; + } + None => personas.push(inbound), + } +} + +/// Merge an inbound kind:30177 managed-agent projection into the local set. +/// +/// Matches the local record whose `pubkey` equals the event's d-tag (the d-tag +/// IS the agent pubkey — see `build_agent_event`). On match, overwrite ONLY the +/// 10 projected fields; every secret (`private_key_nsec`, `auth_tag`, +/// `env_vars`, `backend`), the harness pins (`agent_command`, +/// `agent_command_override`), and all runtime/local fields are preserved +/// untouched. The projection type carries none of them, so they cannot be +/// reached here even if a foreign event tried to inject them. +/// +/// No match is a no-op: managed agents carry device-local secrets and are never +/// minted from a relay event — an agent that does not already exist locally has +/// no secret key to run with, so inserting a secretless shell would be useless +/// and misleading. This diverges from the persona path, which DOES insert on no +/// match (personas are secretless definitions). Flagged in the reconcile docs. +fn apply_inbound_managed_agent( + agents: &mut [ManagedAgentRecord], + d_tag: &str, + inbound: ManagedAgentEventContent, +) { + if let Some(local) = agents.iter_mut().find(|record| record.pubkey == d_tag) { + local.name = inbound.name; + // Mirror of the slimmed writer (agent_event_content): a + // definition-linked event omits the definition quad because those + // fields resolve through the kind:30175 definition — absent means + // "not carried", never "clear". Definition-less events still carry + // the quad and apply it unconditionally (including clears). + let definition_linked = inbound.persona_id.is_some(); + local.persona_id = inbound.persona_id; + if !definition_linked { + local.system_prompt = inbound.system_prompt; + local.model = inbound.model; + local.provider = inbound.provider; + local.persona_source_version = inbound.persona_source_version; + } + local.parallelism = inbound.parallelism; + local.respond_to = inbound.respond_to; + local.respond_to_allowlist = inbound.respond_to_allowlist; + } +} + +/// Merge an inbound kind:30176 team projection into the local set. +/// +/// Matches the local record whose `id` equals the event's d-tag (the d-tag IS +/// the team id — see `build_team_event`). On match, overwrite ONLY the three +/// shared fields (`name`, `description`, `persona_ids`); install-specific local +/// fields (`source_dir`, `is_symlink`, `symlink_target`, `is_builtin`, +/// `version`, `created_at`) are preserved. On no match, insert a fresh record +/// reusing the d-tag as the id so a re-received event stays idempotent — +/// symmetric to the persona path, since a team (like a persona) is a secretless +/// definition that another device may legitimately learn about from the relay. +fn apply_inbound_team(teams: &mut Vec, d_tag: String, inbound: TeamEventContent) { + match teams.iter_mut().find(|record| record.id == d_tag) { + Some(local) => { + local.name = inbound.name; + local.description = inbound.description; + // `None` means the event came from a client that predates + // always-publish — its true value is unknown, so preserve + // local. Only `Some` (including the explicit-clear variants) + // overwrites. See `TeamEventContent` for the wire rules. + if let Some(instructions) = inbound.instructions { + local.instructions = instructions; + } + if let Some(persona_ids) = inbound.persona_ids { + local.persona_ids = persona_ids; + } + } + None => teams.push(TeamRecord { + id: d_tag, + name: inbound.name, + description: inbound.description, + // Fresh insert has no local value to preserve; `None` from a + // pre-fix client simply means no known value. + instructions: inbound.instructions.unwrap_or_default(), + persona_ids: inbound.persona_ids.unwrap_or_default(), + is_builtin: false, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: now_iso(), + updated_at: now_iso(), + }), + } +} diff --git a/desktop/src-tauri/src/commands/personas/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs similarity index 100% rename from desktop/src-tauri/src/commands/personas/inbound_tests.rs rename to desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 26ff08f8b..66f7296a2 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -1,14 +1,12 @@ -use tauri::{AppHandle, Emitter, Manager}; +use tauri::AppHandle; use crate::{ app_state::AppState, managed_agents::{ - agent_events::ManagedAgentEventContent, current_instance_id, delete_agent_key, - load_managed_agents, load_personas, load_teams, persona_events::persona_d_tag, + current_instance_id, delete_agent_key, load_managed_agents, load_personas, load_teams, save_managed_agents, save_personas, stop_managed_agent_process, - sync_managed_agent_processes, team_events::TeamEventContent, try_regenerate_nest, - validate_persona_activation_change, validate_persona_deletion, AgentDefinition, - ManagedAgentRecord, TeamRecord, + sync_managed_agent_processes, try_regenerate_nest, validate_persona_activation_change, + validate_persona_deletion, AgentDefinition, ManagedAgentRecord, }, util::now_iso, }; @@ -38,6 +36,8 @@ pub use sharing::set_persona_shared; pub use sharing::update_persona_and_publish; mod update; pub use update::update_persona; +mod inbound; +pub use inbound::reconcile_inbound_persona_event; #[tauri::command] pub async fn list_personas(app: AppHandle) -> Result, String> { @@ -58,8 +58,6 @@ pub async fn list_personas(app: AppHandle) -> Result, Strin #[cfg(test)] mod delete_cascade_tests; -#[cfg(test)] -mod inbound_tests; /// Return pubkeys of every managed agent whose definition is the given persona. /// @@ -254,406 +252,6 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { .map_err(|e| format!("spawn_blocking failed: {e}"))? } -/// Apply an inbound kind:30175 persona event from the relay onto the local -/// store. The frontend's live subscription invokes this per event for our own -/// authored coordinate so Device B inherits Device A's edits. -/// -/// Retention is a sync channel that writes INTO `personas.json`, never an -/// authoritative read source — `load_personas` is untouched, so every agent -/// keeps resolving its persona by UUID and keeps its provider keys. -/// -/// MATCH KEY (single source of truth, both directions): an inbound event -/// matches the local record whose `persona_d_tag(record)` equals the event's -/// d-tag. Reusing the same derivation the outbound path uses guarantees the -/// inbound key can never drift from the outbound key — in particular, an -/// in-app persona (`source_team_persona_slug == None`) whose d-tag IS its -/// `id` matches its existing UUID row instead of minting a duplicate. -/// -/// On match: patch ONLY the projected fields; preserve local `id`, `env_vars`, -/// `source_team`, and `created_at`. On no match: insert the parsed record as-is -/// — `persona_from_event` already sets `id = d_tag`, so an in-app persona reuses -/// its d-tag as the id and a re-received event stays idempotent (no duplicate). -/// -/// The retention store decides whether the inbound event wins over a pending -/// local edit (`retain_inbound_event`): `personas.json` is only patched when the -/// retain reports [`InboundOutcome::Applied`], so an equal-second collision with -/// a pending local edit leaves the local record — and its queued publish — -/// untouched. -#[tauri::command] -pub async fn reconcile_inbound_persona_event( - event_json: String, - app: AppHandle, -) -> Result<(), String> { - tokio::task::spawn_blocking(move || reconcile_inbound_persona_event_blocking(event_json, app)) - .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? -} - -fn reconcile_inbound_persona_event_blocking( - event_json: String, - app: AppHandle, -) -> Result<(), String> { - use crate::managed_agents::{ - agent_events::managed_agent_content_from_event, - load_managed_agents, load_teams, - persona_events::persona_from_event, - retention::{open_retention_db, retain_inbound_event, InboundOutcome, RetainedEvent}, - 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 nostr::JsonUtil; - - let state = app.state::(); - let event = parse_verified_inbound_event(&event_json)?; - - // The live filter subscribes to 30175/30176/30177 (upserts) plus kind:5 - // (NIP-09 deletions). d-tags are NOT unique across kinds, so every path - // below dispatches on kind FIRST and only ever touches its own store — a - // cross-kind d-tag collision can never link a team to a persona or agent. - let kind = event.kind.as_u16() as u32; - - // kind:5 deletion: a tombstone removes the local record at the coordinate - // in its `a` tag (`::`). Handled before the - // upsert dispatch because its coordinate and retention key differ. - if kind == KIND_DELETION { - return reconcile_inbound_tombstone(&event, &app, &state); - } - - if !matches!(kind, KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT) { - return Ok(()); - } - - // 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 - // branch below — team/agent content is parsed in-branch since their d-tag - // comes from the event tag, not the content. - let inbound_persona = (kind == KIND_PERSONA) - .then(|| persona_from_event(&event)) - .transpose()?; - let d_tag = match &inbound_persona { - Some(persona) => persona_d_tag(persona), - None => event_d_tag(&event)?, - }; - - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - - // Resolve inbound vs. any pending local edit before touching the store. - let scope = crate::managed_agents::retention::active_retention_scope(&app, &state)?; - let conn = open_retention_db(&scope.db_path)?; - let outcome = retain_inbound_event( - &conn, - &RetainedEvent { - kind, - pubkey: event.pubkey.to_hex(), - d_tag: d_tag.clone(), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: false, - }, - )?; - if outcome == InboundOutcome::Skipped { - return Ok(()); - } - - match kind { - KIND_PERSONA => { - let mut personas = load_personas(&app)?; - // `inbound_persona` is `Some` for KIND_PERSONA (set above). - apply_inbound_persona( - &mut personas, - inbound_persona.expect("persona parsed above"), - ); - save_personas(&app, &personas)?; - } - KIND_TEAM => { - let mut teams = load_teams(&app)?; - apply_inbound_team(&mut teams, d_tag, team_content_from_event(&event)?); - save_teams(&app, &teams)?; - } - KIND_MANAGED_AGENT => { - let mut agents = load_managed_agents(&app)?; - apply_inbound_managed_agent( - &mut agents, - &d_tag, - managed_agent_content_from_event(&event)?, - ); - save_managed_agents(&app, &agents)?; - } - _ => unreachable!("kind gated above"), - } - try_regenerate_nest(&app); - - // Signal the live UI to refetch agents data — inbound relay events otherwise - // land on disk silently, leaving the Agents tab stale until restart. - 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 -/// TS-side owner filter reads the same attacker-controlled field and is no -/// defense. -fn parse_verified_inbound_event(event_json: &str) -> Result { - use nostr::JsonUtil; - let event = nostr::Event::from_json(event_json) - .map_err(|e| format!("failed to parse inbound event: {e}"))?; - event - .verify() - .map_err(|e| format!("inbound event failed signature verification: {e}"))?; - Ok(event) -} - -/// Parse a NIP-09 `a`-tag coordinate `::` into its -/// target kind and d-tag. Returns `None` if the tag is absent or malformed, so -/// the caller no-ops on a tombstone it can't route. -fn parse_deletion_coordinate(event: &nostr::Event) -> Option<(u32, String)> { - event.tags.iter().find_map(|tag| { - let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect(); - if values.first() != Some(&"a") { - return None; - } - let coord = values.get(1)?; - // `::` — d_tag may itself contain ':' so split at - // most twice and keep the remainder as the d_tag. - let mut parts = coord.splitn(3, ':'); - let kind: u32 = parts.next()?.parse().ok()?; - let owner = parts.next()?; - // NIP-09 scoping: only the record's author may tombstone it. The - // signature gate upstream proves `event.pubkey`; requiring the - // coordinate owner to match closes the other half — a validly - // signed kind:5 naming ANOTHER owner's coordinate must no-op. - if owner != event.pubkey.to_hex() { - return None; - } - let d_tag = parts.next()?; - Some((kind, d_tag.to_string())) - }) -} - -/// Apply an inbound kind:5 NIP-09 deletion: remove the local record at the -/// tombstone's target coordinate, scoped per-kind. Mirrors the upsert spine — -/// retention resolution under the store lock, then a per-kind store mutation — -/// but removes rather than patches. Unknown/malformed coordinates no-op. -fn reconcile_inbound_tombstone( - event: &nostr::Event, - app: &AppHandle, - state: &AppState, -) -> Result<(), String> { - use crate::managed_agents::{ - load_managed_agents, load_teams, - retention::{ - open_retention_db, retain_inbound_event, tombstone_retention_d_tag, InboundOutcome, - RetainedEvent, - }, - save_managed_agents, save_teams, - }; - use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, 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) { - return Ok(()); // deletion for a kind we don't track locally - } - - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - - // Resolve against the retained tombstone row (keyed by the target - // coordinate, F2c) so a re-received tombstone or one older than a pending - // local edit is a no-op. - let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; - let conn = open_retention_db(&scope.db_path)?; - let outcome = retain_inbound_event( - &conn, - &RetainedEvent { - kind: KIND_DELETION, - pubkey: event.pubkey.to_hex(), - d_tag: tombstone_retention_d_tag(target_kind, &target_d_tag), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: false, - }, - )?; - if outcome == InboundOutcome::Skipped { - return Ok(()); - } - - // Remove the local record using the SAME per-kind match rule the apply fns - // use: persona by `persona_d_tag`, team by `id`, managed-agent by `pubkey`. - match target_kind { - KIND_PERSONA => { - let mut personas = load_personas(app)?; - personas.retain(|record| persona_d_tag(record) != target_d_tag); - save_personas(app, &personas)?; - } - KIND_TEAM => { - let mut teams = load_teams(app)?; - teams.retain(|record| record.id != target_d_tag); - save_teams(app, &teams)?; - } - KIND_MANAGED_AGENT => { - let mut agents = load_managed_agents(app)?; - agents.retain(|record| record.pubkey != target_d_tag); - save_managed_agents(app, &agents)?; - } - _ => unreachable!("target kind gated above"), - } - try_regenerate_nest(app); - - // Refresh the live UI on inbound deletion — a removal is as user-visible as - // an upsert and the Agents tab must drop the tombstoned record without restart. - let _ = app.emit("agents-data-changed", ()); - - Ok(()) -} - -/// Extract the `d` tag value from an event, the match key for team (= team id) -/// and managed-agent (= agent pubkey) inbound reconcile. -fn event_d_tag(event: &nostr::Event) -> Result { - event - .tags - .iter() - .find_map(|tag| { - let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect(); - (values.first() == Some(&"d")) - .then(|| values.get(1).map(|s| s.to_string())) - .flatten() - }) - .ok_or_else(|| "inbound event missing d-tag".to_string()) -} - -/// Merge a parsed inbound persona into the local set: patch the matching record -/// in place, or push it when none matches. -/// -/// The match key is `persona_d_tag` — the same derivation the outbound path -/// uses — so the inbound and outbound keys can never drift. On match, only the -/// projected fields are overwritten; local `id`, `env_vars`, `source_team`, and -/// `created_at` survive. On no match, the parsed record is inserted as-is; since -/// `persona_from_event` sets `id = d_tag`, an in-app persona reuses its d-tag as -/// the id and a re-received event stays idempotent (no duplicate row). -fn apply_inbound_persona(personas: &mut Vec, inbound: AgentDefinition) { - let d_tag = persona_d_tag(&inbound); - match personas - .iter_mut() - .find(|record| persona_d_tag(record) == d_tag) - { - Some(local) => { - local.display_name = inbound.display_name; - local.avatar_url = inbound.avatar_url; - local.system_prompt = inbound.system_prompt; - local.runtime = inbound.runtime; - local.model = inbound.model; - local.provider = inbound.provider; - local.name_pool = inbound.name_pool; - local.respond_to = inbound.respond_to; - local.respond_to_allowlist = inbound.respond_to_allowlist; - local.parallelism = inbound.parallelism; - local.shared = inbound.shared; - local.updated_at = inbound.updated_at; - } - None => personas.push(inbound), - } -} - -/// Merge an inbound kind:30177 managed-agent projection into the local set. -/// -/// Matches the local record whose `pubkey` equals the event's d-tag (the d-tag -/// IS the agent pubkey — see `build_agent_event`). On match, overwrite ONLY the -/// 10 projected fields; every secret (`private_key_nsec`, `auth_tag`, -/// `env_vars`, `backend`), the harness pins (`agent_command`, -/// `agent_command_override`), and all runtime/local fields are preserved -/// untouched. The projection type carries none of them, so they cannot be -/// reached here even if a foreign event tried to inject them. -/// -/// No match is a no-op: managed agents carry device-local secrets and are never -/// minted from a relay event — an agent that does not already exist locally has -/// no secret key to run with, so inserting a secretless shell would be useless -/// and misleading. This diverges from the persona path, which DOES insert on no -/// match (personas are secretless definitions). Flagged in the reconcile docs. -fn apply_inbound_managed_agent( - agents: &mut [ManagedAgentRecord], - d_tag: &str, - inbound: ManagedAgentEventContent, -) { - if let Some(local) = agents.iter_mut().find(|record| record.pubkey == d_tag) { - local.name = inbound.name; - // Mirror of the slimmed writer (agent_event_content): a - // definition-linked event omits the definition quad because those - // fields resolve through the kind:30175 definition — absent means - // "not carried", never "clear". Definition-less events still carry - // the quad and apply it unconditionally (including clears). - let definition_linked = inbound.persona_id.is_some(); - local.persona_id = inbound.persona_id; - if !definition_linked { - local.system_prompt = inbound.system_prompt; - local.model = inbound.model; - local.provider = inbound.provider; - local.persona_source_version = inbound.persona_source_version; - } - local.parallelism = inbound.parallelism; - local.respond_to = inbound.respond_to; - local.respond_to_allowlist = inbound.respond_to_allowlist; - } -} - -/// Merge an inbound kind:30176 team projection into the local set. -/// -/// Matches the local record whose `id` equals the event's d-tag (the d-tag IS -/// the team id — see `build_team_event`). On match, overwrite ONLY the three -/// shared fields (`name`, `description`, `persona_ids`); install-specific local -/// fields (`source_dir`, `is_symlink`, `symlink_target`, `is_builtin`, -/// `version`, `created_at`) are preserved. On no match, insert a fresh record -/// reusing the d-tag as the id so a re-received event stays idempotent — -/// symmetric to the persona path, since a team (like a persona) is a secretless -/// definition that another device may legitimately learn about from the relay. -fn apply_inbound_team(teams: &mut Vec, d_tag: String, inbound: TeamEventContent) { - match teams.iter_mut().find(|record| record.id == d_tag) { - Some(local) => { - local.name = inbound.name; - local.description = inbound.description; - // `None` means the event came from a client that predates - // always-publish — its true value is unknown, so preserve - // local. Only `Some` (including the explicit-clear variants) - // overwrites. See `TeamEventContent` for the wire rules. - if let Some(instructions) = inbound.instructions { - local.instructions = instructions; - } - if let Some(persona_ids) = inbound.persona_ids { - local.persona_ids = persona_ids; - } - } - None => teams.push(TeamRecord { - id: d_tag, - name: inbound.name, - description: inbound.description, - // Fresh insert has no local value to preserve; `None` from a - // pre-fix client simply means no known value. - instructions: inbound.instructions.unwrap_or_default(), - persona_ids: inbound.persona_ids.unwrap_or_default(), - is_builtin: false, - source_dir: None, - is_symlink: false, - symlink_target: None, - version: None, - created_at: now_iso(), - updated_at: now_iso(), - }), - } -} - #[tauri::command] pub async fn set_persona_active( id: String, diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index c7ba2efc3..7e97fa1f5 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -29,12 +29,35 @@ pub struct RetentionScope { pub owner_keys: nostr::Keys, } +/// Decide whether `scope` — the workspace's active retention scope — is the one +/// that owns an event delivered by `arrival_relay_url`. +/// +/// Inbound reconcile resolves its retention database when it PROCESSES an event, +/// while the event belongs to the community that DELIVERED it. `None` means a +/// workspace switch happened in between and the caller must drop the event +/// rather than file community A's event into community B's store. +/// +/// The comparison goes through the same normalization +/// [`scoped_retention_db_path`] hashes, so "same relay" can never disagree with +/// "same database". +pub fn scope_for_arrival(scope: RetentionScope, arrival_relay_url: &str) -> Option { + let same_scope = + normalized_relay_scope(&scope.relay_url) == normalized_relay_scope(arrival_relay_url); + same_scope.then_some(scope) +} + +/// Relay-URL form that identifies a retention scope: equivalent workspace URLs +/// (surrounding space, trailing slash) must resolve to one scope. +fn normalized_relay_scope(relay_url: &str) -> &str { + relay_url.trim().trim_end_matches('/') +} + /// Resolve the retention database path for a relay + owner pair. /// /// The normalized scope is hashed so relay URLs never become path components. /// Trimming a trailing slash keeps equivalent workspace URLs on one scope. pub fn scoped_retention_db_path(base_dir: &Path, relay_url: &str, owner_pubkey: &str) -> PathBuf { - let normalized_relay = relay_url.trim().trim_end_matches('/'); + let normalized_relay = normalized_relay_scope(relay_url); let mut hasher = Sha256::new(); hasher.update(owner_pubkey.trim().to_ascii_lowercase().as_bytes()); hasher.update(b"\0"); @@ -65,6 +88,24 @@ pub fn active_retention_scope(app: &AppHandle, state: &AppState) -> Result Result, String> { + Ok(scope_for_arrival( + active_retention_scope(app, state)?, + arrival_relay_url, + )) +} + /// A retained persona event row. #[derive(Debug, Clone)] pub struct RetainedEvent { @@ -443,6 +484,44 @@ mod tests { ); } + #[test] + fn test_arrival_relay_matching_agrees_with_database_identity() { + let base = Path::new("/tmp/buzz-retention-test"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let scope = |relay: &str| RetentionScope { + db_path: scoped_retention_db_path(base, relay, &owner), + relay_url: relay.to_string(), + owner_keys: keys.clone(), + }; + let community_a = scoped_retention_db_path(base, "wss://a.example", &owner); + + // "Same relay" and "same database" must never disagree: every URL the + // match accepts has to hash to the scope's own db path, and every URL it + // rejects has to hash somewhere else. + for equivalent in ["wss://a.example", "wss://a.example/", " wss://a.example "] { + assert_eq!( + scope_for_arrival(scope("wss://a.example"), equivalent).map(|scope| scope.db_path), + Some(community_a.clone()), + "{equivalent}" + ); + assert_eq!( + scoped_retention_db_path(base, equivalent, &owner), + community_a, + "{equivalent}" + ); + } + + assert!( + scope_for_arrival(scope("wss://b.example"), "wss://a.example").is_none(), + "an event from community A must not be filed while community B is active" + ); + assert_ne!( + scoped_retention_db_path(base, "wss://b.example", &owner), + community_a + ); + } + #[test] fn concurrent_open_waits_for_initialization_lock() { let dir = tempfile::tempdir().unwrap(); diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 7ed9fa934..802f46465 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -167,7 +167,10 @@ export function AppShell() { const { starredChannelIds, starChannel, unstarChannel } = useChannelStars( identityQuery.data?.pubkey, ); - usePersonaSync(identityQuery.data?.pubkey); + usePersonaSync( + identityQuery.data?.pubkey, + communitiesHook.activeCommunity?.relayUrl, + ); useAgentsDataRefresh(); // Chunk F: auto-restart drifted idle agents (per-agent opt-out, default ON). useAutoRestartPolicy(); diff --git a/desktop/src/features/agents/lib/usePersonaSync.test.mjs b/desktop/src/features/agents/lib/usePersonaSync.test.mjs index a1cbbf93f..0dc12ddfd 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.test.mjs +++ b/desktop/src/features/agents/lib/usePersonaSync.test.mjs @@ -35,7 +35,7 @@ test("startPersonaSync backfills history including the deletion kind", () => { return Promise.resolve(() => Promise.resolve()); }); - startPersonaSync("owner-pubkey", () => false); + startPersonaSync("owner-pubkey", "wss://relay.example", () => false); assert.equal(fetchCalls.length, 1, "must do exactly one backfill fetch"); assert.deepEqual( @@ -58,3 +58,53 @@ test("startPersonaSync backfills history including the deletion kind", () => { mock.reset(); }); + +// Regression guard for the arrival-scope fix (F6): the reconcile must carry the +// relay this subscription was opened on, NOT whichever community happens to be +// active when the reconcile runs. Without the forwarded URL the backend falls +// back to the active workspace and an in-flight event lands in the wrong +// community's scoped retention store on a mid-flight switch. +test("startPersonaSync forwards its own relay as the event arrival relay", async () => { + const invokes = []; + // @tauri-apps/api/core reads `window.__TAURI_INTERNALS__.invoke`. + globalThis.window = { + __TAURI_INTERNALS__: { + invoke: (cmd, args) => { + invokes.push({ cmd, args }); + return Promise.resolve(); + }, + }, + }; + + const ownEvent = { id: "e1", pubkey: "owner-pubkey", kind: KIND_PERSONA }; + const foreignEvent = { id: "e2", pubkey: "someone-else", kind: KIND_PERSONA }; + + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([ownEvent, foreignEvent]), + ); + mock.method(relayClient, "subscribeLive", () => + Promise.resolve(() => Promise.resolve()), + ); + + startPersonaSync("owner-pubkey", "wss://community-a.example", () => false); + // Let the backfill promise chain and the reconcile invoke settle. + await new Promise((resolve) => setImmediate(resolve)); + + const reconciles = invokes.filter( + (call) => call.cmd === "reconcile_inbound_persona_event", + ); + assert.equal( + reconciles.length, + 1, + "only the subscribed author's event reconciles", + ); + assert.equal( + reconciles[0].args.arrivalRelayUrl, + "wss://community-a.example", + "reconcile must carry the subscription's relay as the arrival relay", + ); + assert.equal(JSON.parse(reconciles[0].args.eventJson).id, "e1"); + + mock.reset(); + delete globalThis.window; +}); diff --git a/desktop/src/features/agents/lib/usePersonaSync.ts b/desktop/src/features/agents/lib/usePersonaSync.ts index e713ed71d..f18194c5c 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.ts +++ b/desktop/src/features/agents/lib/usePersonaSync.ts @@ -20,19 +20,28 @@ const PERSONA_SYNC_KINDS = [ KIND_DELETION, ]; -// Start the persona/team/agent/deletion sync for `pubkey`: one-shot backfill -// of existing heads + tombstones, then a live subscription. Returns a disposer -// that closes the live subscription. Extracted from the hook so the wiring is -// unit-testable without a React renderer (see `usePersonaSync.test.mjs`). +// Start the persona/team/agent/deletion sync for `pubkey` on `relayUrl`: +// one-shot backfill of existing heads + tombstones, then a live subscription. +// Returns a disposer that closes the live subscription. Extracted from the hook +// so the wiring is unit-testable without a React renderer (see +// `usePersonaSync.test.mjs`). +// +// `relayUrl` is the community this subscription is bound to, and every reconcile +// carries it as the event's arrival relay. Capturing it here — rather than +// letting the backend read whichever workspace is active when the reconcile runs +// — is what keeps an in-flight event out of the next community's scoped store. export function startPersonaSync( pubkey: string, + relayUrl: string, onCancelled: () => boolean, ): () => Promise { const reconcile = (event: RelayEvent) => { if (event.pubkey !== pubkey) return; - void reconcileInboundPersonaEvent(JSON.stringify(event)).catch((error) => { - console.warn("[usePersonaSync] reconcile failed:", error); - }); + void reconcileInboundPersonaEvent(JSON.stringify(event), relayUrl).catch( + (error) => { + console.warn("[usePersonaSync] reconcile failed:", error); + }, + ); }; // One-shot backfill of existing heads + tombstones (closes the fresh-start @@ -68,23 +77,27 @@ export function startPersonaSync( // Subscribes to this device's own persona/team/agent projection + deletion // events and patches each into the local store. The subscription is keyed on -// the active pubkey: an identity switch re-runs the effect, whose cleanup -// closes the old subscription before a new one opens on the new pubkey's -// filter — so no stale-coordinate subscription survives. +// the active pubkey and relay: an identity or community switch re-runs the +// effect, whose cleanup closes the old subscription before a new one opens on +// the new filter — so no stale-coordinate subscription survives, and every +// reconcile is attributed to the community it was subscribed to. // // A fresh device that comes online AFTER another already published gets no // history from a live-only subscription: relayClient's replayLiveSubscriptions // only replays from a since-cursor that is undefined until the first live // event arrives. So `startPersonaSync` does an explicit one-shot history fetch // up front and feeds each event through the same reconcile path. -export function usePersonaSync(pubkey: string | undefined): void { +export function usePersonaSync( + pubkey: string | undefined, + relayUrl: string | undefined, +): void { React.useEffect(() => { - if (!pubkey) return; + if (!pubkey || !relayUrl) return; let cancelled = false; - const dispose = startPersonaSync(pubkey, () => cancelled); + const dispose = startPersonaSync(pubkey, relayUrl, () => cancelled); return () => { cancelled = true; void dispose(); }; - }, [pubkey]); + }, [pubkey, relayUrl]); } diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index 9f953eecd..623c49075 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -263,9 +263,15 @@ export async function confirmAgentSnapshotImport( // Patches a single inbound persona/team/agent projection event into the local // store (personas.json). The backend resolves the match key and the -// pending-edit race; the frontend only forwards the raw Nostr event JSON. +// pending-edit race; the frontend forwards the raw Nostr event JSON plus the +// relay it arrived on, so a workspace switch mid-flight cannot retain the event +// into the newly active community's scoped store. export async function reconcileInboundPersonaEvent( eventJson: string, + arrivalRelayUrl: string, ): Promise { - await invokeTauri("reconcile_inbound_persona_event", { eventJson }); + await invokeTauri("reconcile_inbound_persona_event", { + eventJson, + arrivalRelayUrl, + }); } diff --git a/desktop/tests/e2e/persona-sync.spec.ts b/desktop/tests/e2e/persona-sync.spec.ts index 5dfa7e1a1..84b24f7eb 100644 --- a/desktop/tests/e2e/persona-sync.spec.ts +++ b/desktop/tests/e2e/persona-sync.spec.ts @@ -14,6 +14,10 @@ const TYLER_PUBKEY = const D_TAG = "sync-test-persona"; const KIND_PERSONA = 30175; const KIND_DELETION = 5; +// The command scopes an inbound event to the community it arrived on. Under the +// mock bridge the app subscribes on e2eBridge's DEFAULT_RELAY_WS_URL, so that is +// the arrival relay these direct invocations stand in for. +const ARRIVAL_RELAY_URL = "ws://localhost:3000"; test.beforeEach(async ({ page }) => { await installMockBridge(page); @@ -139,6 +143,7 @@ test("upsert round-trip: reconcile_inbound_persona_event writes record and emits // Drive the inbound reconcile path. await invokeTauri(page, "reconcile_inbound_persona_event", { eventJson: JSON.stringify(personaEvent), + arrivalRelayUrl: ARRIVAL_RELAY_URL, }); // Assert the record landed on disk. @@ -176,6 +181,7 @@ test("tombstone round-trip: reconcile_inbound_persona_event removes record and e await invokeTauri(page, "reconcile_inbound_persona_event", { eventJson: JSON.stringify(personaEvent), + arrivalRelayUrl: ARRIVAL_RELAY_URL, }); // Step 2: confirm it landed. @@ -202,6 +208,7 @@ test("tombstone round-trip: reconcile_inbound_persona_event removes record and e await invokeTauri(page, "reconcile_inbound_persona_event", { eventJson: JSON.stringify(tombstoneEvent), + arrivalRelayUrl: ARRIVAL_RELAY_URL, }); // Step 4: assert the record is gone.