From ac9f33d218d20f90fb991b84d500a8baece05e2b Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 27 Jul 2026 15:10:23 -0400 Subject: [PATCH] fix(desktop): scope an inbound persona event to the relay it arrived on The inbound reconcile resolved the retention scope at processing time from whichever workspace was active. A community switch while an event was in flight retained it into the new community's scoped store, attributing another community's agent to the wrong one. The subscription now forwards the relay it was opened on, and the reconcile resolves the write target from that arrival relay instead. A mismatch drops the event: it was already durable in its own community's store, whose next boot reconcile refetches it. Only inbound paths are arrival-scoped; owner authored publishes still target the active scope. Relay-URL normalization is shared with the path hash so "same relay" and "same database" cannot drift, and the arrival check is a pure function over RetentionScope so it is testable without an AppHandle. The reconcile moves to personas/inbound.rs, retiring mod.rs's file-size exception rather than raising it. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/scripts/check-file-sizes.mjs | 4 - .../src/commands/personas/inbound.rs | 450 ++++++++++++++++++ .../personas/{ => inbound}/inbound_tests.rs | 0 .../src-tauri/src/commands/personas/mod.rs | 414 +--------------- .../src-tauri/src/managed_agents/retention.rs | 81 +++- desktop/src/app/AppShell.tsx | 5 +- .../agents/lib/usePersonaSync.test.mjs | 52 +- .../src/features/agents/lib/usePersonaSync.ts | 41 +- desktop/src/shared/api/tauriPersonas.ts | 10 +- desktop/tests/e2e/persona-sync.spec.ts | 7 + 10 files changed, 633 insertions(+), 431 deletions(-) create mode 100644 desktop/src-tauri/src/commands/personas/inbound.rs rename desktop/src-tauri/src/commands/personas/{ => inbound}/inbound_tests.rs (100%) 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.