diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index b91216980..636a5bc2b 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -182,6 +182,21 @@ pub const KIND_TEAM: u32 = 30176; /// since these events are world-readable on the relay. pub const KIND_MANAGED_AGENT: u32 = 30177; +/// Buzz community agent catalog entry (parameterized replaceable, owner-authored). +/// +/// Addressed by `(pubkey, kind, d_tag)` where `d_tag` is the source persona's +/// stable local id. Unlike [`KIND_PERSONA`], readers intentionally query this +/// kind across every community member: a `published` head contains an explicit +/// public projection plus a verified same-relay agent-snapshot reference, while +/// an `unpublished` head removes that coordinate from discovery. +/// +/// Privacy contract: catalog entries and their referenced snapshots are +/// community-readable plaintext. Publishers MUST use an explicit allowlist +/// projection that excludes private keys, auth tags, environment variables, +/// machine-local runtime state, and response allowlists. Optional memory is +/// included only at the user-selected `none`, `core`, or `everything` level. +pub const KIND_PERSONA_CATALOG: u32 = 30178; + // NIP-56 reporting /// NIP-56: Report an event, pubkey, or blob to relay moderators (kind:1984). /// @@ -510,6 +525,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_PERSONA_CATALOG, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, @@ -708,6 +724,7 @@ const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000– const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA_CATALOG)); // 30178 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999 diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index ca529d1db..9c6b70593 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -27,9 +27,9 @@ use buzz_core::kind::{ KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, - KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, + KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PERSONA_CATALOG, KIND_PIN_LIST, + KIND_PRESENCE_UPDATE, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, + KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, @@ -200,7 +200,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::UsersWrite), KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite), KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM - | KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT + | KIND_EVENT_REMINDER | KIND_PERSONA | KIND_PERSONA_CATALOG | KIND_TEAM | KIND_MANAGED_AGENT | super::push_lease::KIND_PUSH_LEASE => { Ok(Scope::UsersWrite) } @@ -406,6 +406,9 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_AGENT_PROFILE // NIP-AP: persona definitions (30175): owner-authored, keyed by (pubkey, kind, d_tag). | KIND_PERSONA + // Community catalog publications (30178): owner-authored, but read + // across all members and keyed by the source persona's stable id. + | KIND_PERSONA_CATALOG // NIP-AP: team (30176) + managed-agent (30177) definitions: owner-authored, // keyed by (pubkey, kind, d_tag). A stray `h` tag must not channel-scope them. | KIND_TEAM @@ -1066,6 +1069,254 @@ fn validate_persona_envelope(event: &Event) -> Result<(), String> { Ok(()) } +/// Validate the public envelope and plaintext projection of a community catalog +/// entry before it can replace the current kind:30178 head. +/// +/// The relay deliberately rejects unknown fields here. This is stricter than +/// the private owner projection (`kind:30175`) because every catalog entry and +/// referenced snapshot is readable by the community. The explicit allowlist +/// prevents accidental additions such as env vars, auth tags, or response +/// allowlists from silently becoming public metadata. +fn validate_persona_catalog_envelope(event: &Event) -> Result<(), String> { + use serde_json::{Map, Value}; + + const MAX_SOURCE_ID_LEN: usize = 128; + const MAX_SOURCE_UPDATED_AT_LEN: usize = 64; + const MAX_AGENT_SNAPSHOT_JSON_BYTES: u64 = 5 * 1024 * 1024; + + fn one_tag<'a>(event: &'a Event, name: &str) -> Result<&'a str, String> { + let values: Vec<&str> = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.len() >= 2 && parts[0].as_str() == name).then(|| parts[1].as_str()) + }) + .collect(); + if values.len() != 1 { + return Err(format!( + "persona catalog event must have exactly one `{name}` tag (got {})", + values.len() + )); + } + Ok(values[0]) + } + + fn optional_one_tag<'a>(event: &'a Event, name: &str) -> Result, String> { + let values: Vec<&str> = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.len() >= 2 && parts[0].as_str() == name).then(|| parts[1].as_str()) + }) + .collect(); + if values.len() > 1 { + return Err(format!( + "persona catalog event must have at most one `{name}` tag (got {})", + values.len() + )); + } + Ok(values.first().copied()) + } + + fn object<'a>(value: &'a Value, label: &str) -> Result<&'a Map, String> { + value + .as_object() + .ok_or_else(|| format!("persona catalog `{label}` must be an object")) + } + + fn reject_unknown( + object: &Map, + allowed: &[&str], + label: &str, + ) -> Result<(), String> { + if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) { + return Err(format!( + "persona catalog `{label}` contains unsupported field `{key}`" + )); + } + Ok(()) + } + + fn required_string<'a>( + object: &'a Map, + key: &str, + label: &str, + ) -> Result<&'a str, String> { + object + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("persona catalog `{label}.{key}` must be a non-empty string")) + } + + fn optional_string_or_null( + object: &Map, + key: &str, + label: &str, + ) -> Result<(), String> { + if object + .get(key) + .is_some_and(|value| !value.is_null() && !value.is_string()) + { + return Err(format!( + "persona catalog `{label}.{key}` must be a string or null" + )); + } + Ok(()) + } + + let source_id = one_tag(event, "d")?; + if source_id.is_empty() + || source_id.len() > MAX_SOURCE_ID_LEN + || source_id.chars().any(char::is_control) + { + return Err(format!( + "persona catalog `d` tag must be 1-{MAX_SOURCE_ID_LEN} non-control characters" + )); + } + + let status = one_tag(event, "status")?; + if status != "published" && status != "unpublished" { + return Err("persona catalog `status` must be `published` or `unpublished`".to_string()); + } + let source_updated_at = one_tag(event, "source_updated_at")?; + if source_updated_at.is_empty() || source_updated_at.len() > MAX_SOURCE_UPDATED_AT_LEN { + return Err(format!( + "persona catalog `source_updated_at` must be 1-{MAX_SOURCE_UPDATED_AT_LEN} characters" + )); + } + let memory_tag = optional_one_tag(event, "memory")?; + + let parsed: Value = serde_json::from_str(&event.content) + .map_err(|_| "persona catalog content must be valid JSON".to_string())?; + let root = object(&parsed, "content")?; + + if root.get("format").and_then(Value::as_str) != Some("buzz-persona-catalog") + || root.get("version").and_then(Value::as_u64) != Some(1) + || root.get("status").and_then(Value::as_str) != Some(status) + || root.get("sourcePersonaId").and_then(Value::as_str) != Some(source_id) + || root.get("sourceUpdatedAt").and_then(Value::as_str) != Some(source_updated_at) + { + return Err("persona catalog content does not match its signed envelope".to_string()); + } + + if status == "unpublished" { + reject_unknown( + root, + &[ + "format", + "version", + "status", + "sourcePersonaId", + "sourceUpdatedAt", + ], + "content", + )?; + if memory_tag.is_some() { + return Err( + "unpublished persona catalog events must not carry a `memory` tag".to_string(), + ); + } + return Ok(()); + } + + reject_unknown( + root, + &[ + "format", + "version", + "status", + "sourcePersonaId", + "sourceUpdatedAt", + "memoryLevel", + "agent", + "snapshot", + ], + "content", + )?; + + let memory = memory_tag.ok_or_else(|| { + "published persona catalog events must carry exactly one `memory` tag".to_string() + })?; + if !matches!(memory, "none" | "core" | "everything") + || root.get("memoryLevel").and_then(Value::as_str) != Some(memory) + { + return Err( + "persona catalog memory level must be `none`, `core`, or `everything` and match content" + .to_string(), + ); + } + + let agent = object( + root.get("agent") + .ok_or_else(|| "persona catalog content is missing `agent`".to_string())?, + "agent", + )?; + reject_unknown( + agent, + &[ + "displayName", + "avatarUrl", + "systemPrompt", + "runtime", + "model", + "provider", + ], + "agent", + )?; + required_string(agent, "displayName", "agent")?; + if !agent.get("systemPrompt").is_some_and(Value::is_string) { + return Err("persona catalog `agent.systemPrompt` must be a string".to_string()); + } + for key in ["avatarUrl", "runtime", "model", "provider"] { + optional_string_or_null(agent, key, "agent")?; + } + + let snapshot = object( + root.get("snapshot") + .ok_or_else(|| "persona catalog content is missing `snapshot`".to_string())?, + "snapshot", + )?; + reject_unknown( + snapshot, + &["url", "sha256", "size", "type", "fileName"], + "snapshot", + )?; + required_string(snapshot, "url", "snapshot")?; + let sha256 = required_string(snapshot, "sha256", "snapshot")?; + if sha256.len() != 64 + || !sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err("persona catalog `snapshot.sha256` must be 64 lowercase hex chars".to_string()); + } + snapshot + .get("size") + .and_then(Value::as_u64) + .filter(|size| (1..=MAX_AGENT_SNAPSHOT_JSON_BYTES).contains(size)) + .ok_or_else(|| { + "persona catalog `snapshot.size` must be within the 5 MiB agent JSON limit".to_string() + })?; + if snapshot.get("type").and_then(Value::as_str) != Some("application/json") { + return Err("persona catalog snapshot must use `application/json`".to_string()); + } + let filename = required_string(snapshot, "fileName", "snapshot")?; + if !filename.to_ascii_lowercase().ends_with(".agent.json") + || filename.contains('/') + || filename.contains('\\') + { + return Err( + "persona catalog `snapshot.fileName` must be a plain `.agent.json` filename" + .to_string(), + ); + } + + Ok(()) +} + /// Validate that `content` is a syntactically plausible NIP-44 v2 ciphertext. /// /// Checks: @@ -2025,6 +2276,11 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + if kind_u32 == KIND_PERSONA_CATALOG { + validate_persona_catalog_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + // Track pre-created channel UUID for compensation on insert failure. let mut pre_created_channel: Option = None; @@ -2808,6 +3064,7 @@ mod tests { KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_PERSONA, + KIND_PERSONA_CATALOG, KIND_TEAM, KIND_MANAGED_AGENT, KIND_AGENT_TURN_METRIC, @@ -2893,6 +3150,17 @@ mod tests { assert!(!requires_h_channel_scope(KIND_PERSONA)); } + #[test] + fn persona_catalog_is_in_scope_allowlist_and_global_only() { + let dummy = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_PERSONA_CATALOG, &dummy).unwrap(), + Scope::UsersWrite, + ); + assert!(is_global_only_kind(KIND_PERSONA_CATALOG)); + assert!(!requires_h_channel_scope(KIND_PERSONA_CATALOG)); + } + #[test] fn team_and_managed_agent_are_in_scope_allowlist() { let dummy = make_dummy_event(); @@ -3534,6 +3802,89 @@ mod tests { assert!(err.contains("`d` tag"), "got: {err}"); } + fn make_persona_catalog(status: &str, memory: Option<&str>, content: &str) -> Event { + let mut tags = vec![ + vec!["d", "persona-1"], + vec!["status", status], + vec!["source_updated_at", "2026-07-23T00:00:00Z"], + ]; + if let Some(memory) = memory { + tags.push(vec!["memory", memory]); + } + let tag_refs: Vec> = tags; + let borrowed: Vec<&[&str]> = tag_refs.iter().map(Vec::as_slice).collect(); + make_event_with_tags(KIND_PERSONA_CATALOG, content, &borrowed) + } + + fn valid_published_catalog_content() -> String { + serde_json::json!({ + "format": "buzz-persona-catalog", + "version": 1, + "status": "published", + "sourcePersonaId": "persona-1", + "sourceUpdatedAt": "2026-07-23T00:00:00Z", + "memoryLevel": "none", + "agent": { + "displayName": "Reviewer", + "avatarUrl": null, + "systemPrompt": "Review changes.", + "runtime": "goose", + "model": "claude", + "provider": null + }, + "snapshot": { + "url": "https://relay.example/media/snapshot", + "sha256": "a".repeat(64), + "size": 512, + "type": "application/json", + "fileName": "reviewer.agent.json" + } + }) + .to_string() + } + + #[test] + fn persona_catalog_accepts_public_allowlist_projection() { + let content = valid_published_catalog_content(); + let event = make_persona_catalog("published", Some("none"), &content); + assert!(validate_persona_catalog_envelope(&event).is_ok()); + } + + #[test] + fn persona_catalog_rejects_secret_or_allowlist_fields() { + let mut content: serde_json::Value = + serde_json::from_str(&valid_published_catalog_content()).unwrap(); + content["agent"]["envVars"] = serde_json::json!({"ANTHROPIC_API_KEY": "secret"}); + let event = make_persona_catalog("published", Some("none"), &content.to_string()); + let error = validate_persona_catalog_envelope(&event).unwrap_err(); + assert!( + error.contains("unsupported field `envVars`"), + "got: {error}" + ); + } + + #[test] + fn persona_catalog_accepts_unpublished_replacement() { + let content = serde_json::json!({ + "format": "buzz-persona-catalog", + "version": 1, + "status": "unpublished", + "sourcePersonaId": "persona-1", + "sourceUpdatedAt": "2026-07-23T00:00:00Z" + }) + .to_string(); + let event = make_persona_catalog("unpublished", None, &content); + assert!(validate_persona_catalog_envelope(&event).is_ok()); + } + + #[test] + fn persona_catalog_rejects_memory_mismatch() { + let content = valid_published_catalog_content(); + let event = make_persona_catalog("published", Some("everything"), &content); + let error = validate_persona_catalog_envelope(&event).unwrap_err(); + assert!(error.contains("memory level"), "got: {error}"); + } + // ─── agent_turn_metric envelope tests ──────────────────────────────────── /// Build an event for kind:44200 with the given tags and content. diff --git a/desktop/src/features/agents/lib/catalog.test.mjs b/desktop/src/features/agents/lib/catalog.test.mjs index 59ad24c1d..62e809bdb 100644 --- a/desktop/src/features/agents/lib/catalog.test.mjs +++ b/desktop/src/features/agents/lib/catalog.test.mjs @@ -2,11 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - getCatalogPersonas, - getCatalogSelectionState, getLibraryPersonas, getPersonaLabelsById, - getPersonaLibraryState, isCatalogPersonaSelected, } from "./catalog.ts"; @@ -25,66 +22,6 @@ function createPersona(id, displayName, overrides = {}) { }; } -test("getCatalogPersonas hides built-ins and includes shared custom agents", () => { - const personas = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: false }), - createPersona("custom:builder", "Builder"), - ]; - - assert.deepEqual( - getCatalogPersonas(personas, new Set(["custom:builder"])).map( - (persona) => persona.id, - ), - ["custom:builder"], - ); -}); - -test("getCatalogSelectionState only selects shared custom agents", () => { - const personas = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }), - createPersona("custom:builder", "Builder"), - ]; - - const state = getCatalogSelectionState( - personas, - new Set(["builtin:fizz", "custom:builder"]), - ); - - assert.deepEqual( - state.catalogPersonas.map((persona) => persona.id), - ["custom:builder"], - ); - assert.deepEqual( - state.selectedCatalogPersonas.map((persona) => persona.id), - ["custom:builder"], - ); - assert.deepEqual( - state.unselectedCatalogPersonas.map((persona) => persona.id), - [], - ); -}); - -test("getCatalogPersonas keeps chooser order stable when selection changes", () => { - const inactive = [ - createPersona("custom:fizz", "Fizz", { isActive: false }), - createPersona("custom:reviewer", "Reviewer", { - isActive: true, - }), - ]; - const active = [ - createPersona("custom:fizz", "Fizz", { isActive: true }), - createPersona("custom:reviewer", "Reviewer", { - isActive: false, - }), - ]; - const shared = new Set(["custom:fizz", "custom:reviewer"]); - - assert.deepEqual( - getCatalogPersonas(inactive, shared).map((persona) => persona.id), - getCatalogPersonas(active, shared).map((persona) => persona.id), - ); -}); - test("isCatalogPersonaSelected treats active catalog personas as selected", () => { assert.equal( isCatalogPersonaSelected( @@ -122,28 +59,6 @@ test("getPersonaLabelsById keeps every returned persona addressable", () => { }); }); -test("getPersonaLibraryState keeps built-ins in the library but not the catalog", () => { - const personas = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }), - createPersona("custom:builder", "Builder"), - ]; - - const state = getPersonaLibraryState( - personas, - new Set(["builtin:fizz", "custom:builder"]), - ); - - assert.deepEqual( - state.libraryPersonas.map((persona) => persona.id), - ["builtin:fizz", "custom:builder"], - ); - assert.deepEqual( - state.catalogPersonas.map((persona) => persona.id), - ["custom:builder"], - ); - assert.equal(state.personaLabelsById["builtin:fizz"], "Fizz"); -}); - test("getLibraryPersonas keeps active custom personas even when catalog entries are similar", () => { const avatarUrl = "https://example.test/coordinator.png"; const personas = [ diff --git a/desktop/src/features/agents/lib/catalog.ts b/desktop/src/features/agents/lib/catalog.ts index 95cf12c2c..fabc0af87 100644 --- a/desktop/src/features/agents/lib/catalog.ts +++ b/desktop/src/features/agents/lib/catalog.ts @@ -1,17 +1,5 @@ import type { AgentPersona } from "@/shared/api/types"; -export type CatalogSelectionState = { - catalogPersonas: AgentPersona[]; - selectedCatalogPersonas: AgentPersona[]; - unselectedCatalogPersonas: AgentPersona[]; -}; - -export type PersonaLibraryState = { - catalogPersonas: AgentPersona[]; - libraryPersonas: AgentPersona[]; - personaLabelsById: Record; -}; - export function isPersonaActive(persona: AgentPersona) { return persona.isActive; } @@ -24,62 +12,12 @@ export function getLibraryPersonas(personas: readonly AgentPersona[]) { return getActivePersonas(personas); } -export function isPersonaVisibleInCatalog( - persona: AgentPersona, - sharedCatalogPersonaIds: ReadonlySet = new Set(), -) { - return !persona.isBuiltIn && sharedCatalogPersonaIds.has(persona.id); -} - -export function getCatalogPersonas( - personas: readonly AgentPersona[], - sharedCatalogPersonaIds: ReadonlySet = new Set(), -) { - return personas - .filter((persona) => - isPersonaVisibleInCatalog(persona, sharedCatalogPersonaIds), - ) - .sort((left, right) => left.displayName.localeCompare(right.displayName)); -} - export function isCatalogPersonaSelected(persona: AgentPersona) { return persona.isActive; } -export function getCatalogSelectionState( - personas: readonly AgentPersona[], - sharedCatalogPersonaIds: ReadonlySet = new Set(), -): CatalogSelectionState { - const catalogPersonas = getCatalogPersonas(personas, sharedCatalogPersonaIds); - - return { - catalogPersonas, - selectedCatalogPersonas: catalogPersonas.filter(isCatalogPersonaSelected), - unselectedCatalogPersonas: catalogPersonas.filter( - (persona) => !isCatalogPersonaSelected(persona), - ), - }; -} - export function getPersonaLabelsById(personas: readonly AgentPersona[]) { return Object.fromEntries( personas.map((persona) => [persona.id, persona.displayName]), ); } - -export function getPersonaLibraryState( - personas: readonly AgentPersona[], - sharedCatalogPersonaIds: ReadonlySet = new Set(), -): PersonaLibraryState { - const libraryPersonas = getLibraryPersonas(personas); - const { catalogPersonas } = getCatalogSelectionState( - personas, - sharedCatalogPersonaIds, - ); - - return { - catalogPersonas, - libraryPersonas, - personaLabelsById: getPersonaLabelsById(personas), - }; -} diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs new file mode 100644 index 000000000..dd04627e1 --- /dev/null +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + catalogPersonasFromPublications, + catalogPublicationsFromEvents, + sanitizeCatalogSnapshotBytes, +} from "./personaCatalogRelay.ts"; + +const ALICE = "a".repeat(64); +const BOB = "b".repeat(64); +const SNAPSHOT = { + url: `https://relay.example/media/${"c".repeat(64)}`, + sha256: "c".repeat(64), + size: 512, + type: "application/json", + fileName: "reviewer.agent.json", +}; + +function catalogEvent({ + createdAt, + id, + owner = ALICE, + sourcePersonaId = "reviewer", + status = "published", + memoryLevel = "none", +}) { + const sourceUpdatedAt = `2026-07-23T00:00:0${createdAt}.000Z`; + const content = + status === "published" + ? { + format: "buzz-persona-catalog", + version: 1, + status, + sourcePersonaId, + sourceUpdatedAt, + memoryLevel, + agent: { + displayName: "Relay Reviewer", + avatarUrl: null, + systemPrompt: "Review changes.", + runtime: "goose", + model: "claude", + provider: null, + }, + snapshot: SNAPSHOT, + } + : { + format: "buzz-persona-catalog", + version: 1, + status, + sourcePersonaId, + sourceUpdatedAt, + }; + return { + id, + pubkey: owner, + created_at: createdAt, + kind: 30178, + tags: [ + ["d", sourcePersonaId], + ["status", status], + ["source_updated_at", sourceUpdatedAt], + ...(status === "published" ? [["memory", memoryLevel]] : []), + ], + content: JSON.stringify(content), + sig: "sig", + }; +} + +test("a catalog publication from Alice is discoverable by Bob", () => { + const publications = catalogPublicationsFromEvents([ + catalogEvent({ createdAt: 1, id: "alice-reviewer" }), + ]); + const personas = catalogPersonasFromPublications(publications, [], BOB); + + assert.equal(personas.length, 1); + assert.equal(personas[0].displayName, "Relay Reviewer"); + assert.equal(personas[0].isActive, false); + assert.equal(personas[0].catalogSource.ownerPubkey, ALICE); + assert.equal(personas[0].catalogSource.isOwn, false); + assert.deepEqual(personas[0].catalogSource.snapshot, SNAPSHOT); +}); + +test("the newest unpublished head removes an older publication from discovery", () => { + const publications = catalogPublicationsFromEvents([ + catalogEvent({ createdAt: 1, id: "published" }), + catalogEvent({ createdAt: 2, id: "unpublished", status: "unpublished" }), + ]); + + assert.equal(publications.length, 1); + assert.equal(publications[0].status, "unpublished"); + assert.deepEqual(catalogPersonasFromPublications(publications, [], BOB), []); +}); + +test("catalog coordinates remain independent across authors", () => { + const publications = catalogPublicationsFromEvents([ + catalogEvent({ createdAt: 1, id: "alice", owner: ALICE }), + catalogEvent({ createdAt: 1, id: "bob", owner: BOB }), + ]); + + assert.equal(publications.length, 2); + assert.equal( + catalogPersonasFromPublications(publications, [], BOB).length, + 2, + ); +}); + +test("catalog snapshot sanitization strips secrets and response allowlists", () => { + const source = { + format: "buzz-agent-snapshot", + version: 1, + definition: { + name: "Reviewer", + systemPrompt: "Review changes.", + runtime: "goose", + model: "claude", + provider: "anthropic", + respondTo: "allowlist", + respondToAllowlist: [BOB], + namePool: ["Reviewer"], + envVars: { ANTHROPIC_API_KEY: "secret" }, + privateKeyNsec: "nsec-secret", + authTag: "auth-secret", + }, + profile: { displayName: "Reviewer" }, + memory: { level: "none", entries: [] }, + relayUrl: "wss://private.example", + }; + + const sanitized = JSON.parse( + new TextDecoder().decode( + Uint8Array.from( + sanitizeCatalogSnapshotBytes( + Array.from(new TextEncoder().encode(JSON.stringify(source))), + "none", + ), + ), + ), + ); + + assert.equal(sanitized.definition.systemPrompt, "Review changes."); + assert.equal(sanitized.definition.respondTo, "allowlist"); + assert.equal("respondToAllowlist" in sanitized.definition, false); + assert.equal("envVars" in sanitized.definition, false); + assert.equal("privateKeyNsec" in sanitized.definition, false); + assert.equal("authTag" in sanitized.definition, false); + assert.equal("relayUrl" in sanitized, false); +}); + +test("selected core memory survives the public allowlist projection", () => { + const source = { + format: "buzz-agent-snapshot", + version: 1, + definition: { name: "Reviewer", systemPrompt: "Review changes." }, + profile: { displayName: "Reviewer" }, + memory: { + level: "core", + entries: [{ slug: "core", body: "Prefers concise findings." }], + }, + }; + const sanitized = JSON.parse( + new TextDecoder().decode( + Uint8Array.from( + sanitizeCatalogSnapshotBytes( + Array.from(new TextEncoder().encode(JSON.stringify(source))), + "core", + ), + ), + ), + ); + + assert.deepEqual(sanitized.memory, source.memory); +}); + +test("catalog snapshot sanitization rejects memory beyond the selected level", () => { + const source = { + format: "buzz-agent-snapshot", + version: 1, + definition: { name: "Reviewer", systemPrompt: "Review changes." }, + profile: { displayName: "Reviewer" }, + memory: { + level: "core", + entries: [ + { slug: "core", body: "Public core instructions." }, + { slug: "mem/private", body: "Must not escape a core-only share." }, + ], + }, + }; + + assert.throws( + () => + sanitizeCatalogSnapshotBytes( + Array.from(new TextEncoder().encode(JSON.stringify(source))), + "core", + ), + /outside the selected sharing level/u, + ); +}); diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts new file mode 100644 index 000000000..4f3d36dbf --- /dev/null +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -0,0 +1,618 @@ +import { relayClient } from "@/shared/api/relayClient"; +import { signRelayEvent, uploadMediaBytes } from "@/shared/api/tauri"; +import { + encodeAgentSnapshotForSend, + type SnapshotMemoryLevel, +} from "@/shared/api/tauriPersonas"; +import type { + AgentPersona, + ManagedAgent, + RelayEvent, +} from "@/shared/api/types"; +import { KIND_PERSONA_CATALOG } from "@/shared/constants/kinds"; + +const CATALOG_FORMAT = "buzz-persona-catalog"; +const CATALOG_VERSION = 1; +const MAX_CATALOG_EVENTS = 1_000; +const MAX_SNAPSHOT_JSON_BYTES = 5 * 1024 * 1024; + +type CatalogStatus = "published" | "unpublished"; + +export type CatalogSnapshotReference = { + url: string; + sha256: string; + size: number; + type: "application/json"; + fileName: string; +}; + +export type CatalogPersonaShareLevel = "not-shared" | SnapshotMemoryLevel; + +type CatalogAgentProjection = { + displayName: string; + avatarUrl: string | null; + systemPrompt: string; + runtime: string | null; + model: string | null; + provider: string | null; +}; + +type PublishedCatalogContent = { + format: typeof CATALOG_FORMAT; + version: typeof CATALOG_VERSION; + status: "published"; + sourcePersonaId: string; + sourceUpdatedAt: string; + memoryLevel: SnapshotMemoryLevel; + agent: CatalogAgentProjection; + snapshot: CatalogSnapshotReference; +}; + +type UnpublishedCatalogContent = { + format: typeof CATALOG_FORMAT; + version: typeof CATALOG_VERSION; + status: "unpublished"; + sourcePersonaId: string; + sourceUpdatedAt: string; +}; + +type CatalogContent = PublishedCatalogContent | UnpublishedCatalogContent; + +export type PersonaCatalogPublication = { + eventId: string; + ownerPubkey: string; + sourcePersonaId: string; + sourceUpdatedAt: string; + createdAt: number; + status: CatalogStatus; + memoryLevel: SnapshotMemoryLevel | null; + agent: CatalogAgentProjection | null; + snapshot: CatalogSnapshotReference | null; +}; + +export type CatalogPersona = AgentPersona & { + catalogSource: { + eventId: string; + ownerPubkey: string; + isOwn: boolean; + sourcePersonaId: string; + sourceUpdatedAt: string; + memoryLevel: SnapshotMemoryLevel; + snapshot: CatalogSnapshotReference; + }; +}; + +type JsonObject = Record; + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringOrNull(value: unknown): string | null | undefined { + return typeof value === "string" || value === null ? value : undefined; +} + +function extractTag(event: RelayEvent, name: string): string | null { + const matches = event.tags.filter( + (tag) => tag[0] === name && typeof tag[1] === "string", + ); + return matches.length === 1 ? (matches[0]?.[1] ?? null) : null; +} + +function isMemoryLevel(value: unknown): value is SnapshotMemoryLevel { + return value === "none" || value === "core" || value === "everything"; +} + +function isSafeSnapshotReference( + value: unknown, +): value is CatalogSnapshotReference { + if (!isObject(value)) return false; + const fileName = value.fileName; + const sha256 = value.sha256; + const size = value.size; + const url = value.url; + return ( + typeof url === "string" && + url.length > 0 && + !/[\s()]/u.test(url) && + (() => { + try { + const parsed = new URL(url); + return parsed.protocol === "https:" || parsed.protocol === "http:"; + } catch { + return false; + } + })() && + typeof sha256 === "string" && + /^[0-9a-f]{64}$/u.test(sha256) && + typeof size === "number" && + Number.isSafeInteger(size) && + size > 0 && + size <= MAX_SNAPSHOT_JSON_BYTES && + value.type === "application/json" && + typeof fileName === "string" && + fileName.toLowerCase().endsWith(".agent.json") && + !fileName.includes("/") && + !fileName.includes("\\") + ); +} + +function parseCatalogContent(event: RelayEvent): CatalogContent | null { + let parsed: unknown; + try { + parsed = JSON.parse(event.content); + } catch { + return null; + } + if (!isObject(parsed)) return null; + + const sourcePersonaId = extractTag(event, "d"); + const status = extractTag(event, "status"); + const sourceUpdatedAt = extractTag(event, "source_updated_at"); + if ( + event.kind !== KIND_PERSONA_CATALOG || + parsed.format !== CATALOG_FORMAT || + parsed.version !== CATALOG_VERSION || + parsed.status !== status || + parsed.sourcePersonaId !== sourcePersonaId || + parsed.sourceUpdatedAt !== sourceUpdatedAt || + !sourcePersonaId || + !sourceUpdatedAt || + (status !== "published" && status !== "unpublished") + ) { + return null; + } + + if (status === "unpublished") { + return { + format: CATALOG_FORMAT, + version: CATALOG_VERSION, + status, + sourcePersonaId, + sourceUpdatedAt, + }; + } + + const memoryLevel = extractTag(event, "memory"); + if ( + !isMemoryLevel(memoryLevel) || + parsed.memoryLevel !== memoryLevel || + !isObject(parsed.agent) || + typeof parsed.agent.displayName !== "string" || + parsed.agent.displayName.trim().length === 0 || + typeof parsed.agent.systemPrompt !== "string" || + stringOrNull(parsed.agent.avatarUrl) === undefined || + stringOrNull(parsed.agent.runtime) === undefined || + stringOrNull(parsed.agent.model) === undefined || + stringOrNull(parsed.agent.provider) === undefined || + !isSafeSnapshotReference(parsed.snapshot) + ) { + return null; + } + + return { + format: CATALOG_FORMAT, + version: CATALOG_VERSION, + status, + sourcePersonaId, + sourceUpdatedAt, + memoryLevel, + agent: { + displayName: parsed.agent.displayName, + avatarUrl: parsed.agent.avatarUrl as string | null, + systemPrompt: parsed.agent.systemPrompt, + runtime: parsed.agent.runtime as string | null, + model: parsed.agent.model as string | null, + provider: parsed.agent.provider as string | null, + }, + snapshot: parsed.snapshot, + }; +} + +/** + * Collapse relay results to one latest head per `(author, sourcePersonaId)`. + * The relay already applies NIP-33 replacement, but doing this client-side + * makes discovery deterministic against older relays and test fixtures. + */ +export function catalogPublicationsFromEvents( + events: readonly RelayEvent[], +): PersonaCatalogPublication[] { + const sorted = [...events].sort( + (left, right) => + right.created_at - left.created_at || right.id.localeCompare(left.id), + ); + const seenCoordinates = new Set(); + const publications: PersonaCatalogPublication[] = []; + + for (const event of sorted) { + const sourcePersonaId = extractTag(event, "d"); + if (!sourcePersonaId) continue; + const coordinate = `${event.pubkey.toLowerCase()}:${sourcePersonaId}`; + if (seenCoordinates.has(coordinate)) continue; + seenCoordinates.add(coordinate); + + const content = parseCatalogContent(event); + if (!content) continue; + publications.push({ + eventId: event.id, + ownerPubkey: event.pubkey.toLowerCase(), + sourcePersonaId: content.sourcePersonaId, + sourceUpdatedAt: content.sourceUpdatedAt, + createdAt: event.created_at, + status: content.status, + memoryLevel: content.status === "published" ? content.memoryLevel : null, + agent: content.status === "published" ? content.agent : null, + snapshot: content.status === "published" ? content.snapshot : null, + }); + } + + return publications; +} + +export async function fetchPersonaCatalogPublications(): Promise< + PersonaCatalogPublication[] +> { + const events = await relayClient.fetchEvents({ + kinds: [KIND_PERSONA_CATALOG], + limit: MAX_CATALOG_EVENTS, + }); + return catalogPublicationsFromEvents(events); +} + +export function catalogPersonasFromPublications( + publications: readonly PersonaCatalogPublication[], + localPersonas: readonly AgentPersona[], + currentPubkey: string | null | undefined, +): CatalogPersona[] { + const normalizedCurrentPubkey = currentPubkey?.toLowerCase() ?? null; + return publications + .filter( + ( + publication, + ): publication is PersonaCatalogPublication & { + status: "published"; + memoryLevel: SnapshotMemoryLevel; + agent: CatalogAgentProjection; + snapshot: CatalogSnapshotReference; + } => + publication.status === "published" && + publication.memoryLevel !== null && + publication.agent !== null && + publication.snapshot !== null, + ) + .map((publication) => { + const ownLocalPersona = + publication.ownerPubkey === normalizedCurrentPubkey + ? localPersonas.find( + (persona) => persona.id === publication.sourcePersonaId, + ) + : undefined; + const persona: CatalogPersona = { + id: + publication.ownerPubkey === normalizedCurrentPubkey + ? publication.sourcePersonaId + : `catalog:${publication.ownerPubkey}:${publication.sourcePersonaId}`, + displayName: publication.agent.displayName, + avatarUrl: publication.agent.avatarUrl, + systemPrompt: publication.agent.systemPrompt, + runtime: publication.agent.runtime, + model: publication.agent.model, + provider: publication.agent.provider, + namePool: [], + isBuiltIn: false, + isActive: ownLocalPersona?.isActive ?? false, + sourceTeam: null, + envVars: {}, + respondTo: null, + respondToAllowlist: [], + parallelism: null, + createdAt: publication.sourceUpdatedAt, + updatedAt: publication.sourceUpdatedAt, + catalogSource: { + eventId: publication.eventId, + ownerPubkey: publication.ownerPubkey, + isOwn: publication.ownerPubkey === normalizedCurrentPubkey, + sourcePersonaId: publication.sourcePersonaId, + sourceUpdatedAt: publication.sourceUpdatedAt, + memoryLevel: publication.memoryLevel, + snapshot: publication.snapshot, + }, + }; + return persona; + }) + .sort((left, right) => left.displayName.localeCompare(right.displayName)); +} + +export function isCatalogPersona( + persona: AgentPersona, +): persona is CatalogPersona { + return "catalogSource" in persona && isObject(persona.catalogSource); +} + +export function ownCatalogPublication( + publications: readonly PersonaCatalogPublication[], + ownerPubkey: string | null | undefined, + sourcePersonaId: string, +): PersonaCatalogPublication | null { + if (!ownerPubkey) return null; + const normalizedOwner = ownerPubkey.toLowerCase(); + return ( + publications.find( + (publication) => + publication.ownerPubkey === normalizedOwner && + publication.sourcePersonaId === sourcePersonaId, + ) ?? null + ); +} + +function copyOptionalString( + source: JsonObject, + target: JsonObject, + key: string, +): void { + if (typeof source[key] === "string") target[key] = source[key]; +} + +function copyOptionalNumber( + source: JsonObject, + target: JsonObject, + key: string, +): void { + if ( + typeof source[key] === "number" && + Number.isFinite(source[key]) && + source[key] >= 0 + ) { + target[key] = source[key]; + } +} + +/** + * Rebuild a catalog snapshot from a strict public allowlist. + * + * The normal portable snapshot already excludes identity keys, auth tags, + * environment variables, relay URLs, commands, and runtime state. Catalog + * publication applies a second boundary: response allowlists are also removed + * because they disclose source-community pubkeys and are not portable. + */ +export function sanitizeCatalogSnapshotBytes( + fileBytes: readonly number[], + expectedMemoryLevel: SnapshotMemoryLevel, +): number[] { + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(Uint8Array.from(fileBytes))); + } catch { + throw new Error("Couldn’t prepare this agent for the community catalog."); + } + if ( + !isObject(parsed) || + parsed.format !== "buzz-agent-snapshot" || + parsed.version !== 1 || + !isObject(parsed.definition) || + !isObject(parsed.profile) || + !isObject(parsed.memory) || + parsed.memory.level !== expectedMemoryLevel || + !Array.isArray(parsed.memory.entries) + ) { + throw new Error("The generated agent snapshot is invalid."); + } + + const definition: JsonObject = {}; + copyOptionalString(parsed.definition, definition, "name"); + if (typeof parsed.definition.sourceIsBuiltIn === "boolean") { + definition.sourceIsBuiltIn = parsed.definition.sourceIsBuiltIn; + } + for (const key of [ + "systemPrompt", + "runtime", + "model", + "provider", + "respondTo", + ]) { + copyOptionalString(parsed.definition, definition, key); + } + for (const key of [ + "parallelism", + "idleTimeoutSeconds", + "maxTurnDurationSeconds", + ]) { + copyOptionalNumber(parsed.definition, definition, key); + } + if ( + Array.isArray(parsed.definition.namePool) && + parsed.definition.namePool.every((value) => typeof value === "string") + ) { + definition.namePool = [...parsed.definition.namePool]; + } + + const profile: JsonObject = {}; + for (const key of ["displayName", "about", "avatarDataUrl", "avatarUrl"]) { + copyOptionalString(parsed.profile, profile, key); + } + if ( + typeof profile.displayName !== "string" || + profile.displayName.length === 0 + ) { + throw new Error("The generated agent snapshot has no display name."); + } + + const entries = parsed.memory.entries.map((entry) => { + if ( + !isObject(entry) || + typeof entry.slug !== "string" || + entry.slug.length === 0 || + typeof entry.body !== "string" + ) { + throw new Error("The generated agent snapshot has invalid memory."); + } + return { slug: entry.slug, body: entry.body }; + }); + const duplicateSlugs = + new Set(entries.map((entry) => entry.slug)).size !== entries.length; + const invalidMemorySelection = + duplicateSlugs || + (expectedMemoryLevel === "none" && entries.length > 0) || + (expectedMemoryLevel === "core" && + (entries.length > 1 || entries.some((entry) => entry.slug !== "core"))) || + (expectedMemoryLevel === "everything" && + entries.some( + (entry) => entry.slug !== "core" && !entry.slug.startsWith("mem/"), + )); + if (invalidMemorySelection) { + throw new Error( + "The generated agent snapshot includes memory outside the selected sharing level.", + ); + } + + const sanitized = { + format: "buzz-agent-snapshot", + version: 1, + definition, + profile, + memory: { + level: expectedMemoryLevel, + entries, + }, + }; + const bytes = Array.from(new TextEncoder().encode(JSON.stringify(sanitized))); + if (bytes.length > MAX_SNAPSHOT_JSON_BYTES) { + throw new Error( + "This agent is too large for the community catalog. Share less memory and try again.", + ); + } + return bytes; +} + +function publicAvatarUrl(avatarUrl: string | null): string | null { + if (!avatarUrl || avatarUrl.startsWith("data:") || avatarUrl.length > 2_048) { + return null; + } + return avatarUrl; +} + +function monotonicCreatedAt(previousCreatedAt?: number | null): number { + return Math.max(Math.floor(Date.now() / 1_000), (previousCreatedAt ?? 0) + 1); +} + +function requirePublishedCatalogEvent( + event: RelayEvent, +): PersonaCatalogPublication { + const publication = catalogPublicationsFromEvents([event])[0]; + if (!publication) { + throw new Error("The relay returned an invalid catalog publication."); + } + return publication; +} + +export async function publishPersonaToCatalog(input: { + persona: AgentPersona; + memoryLevel: SnapshotMemoryLevel; + linkedAgentPubkey: string | null; + previousCreatedAt?: number | null; +}): Promise { + if (input.persona.isBuiltIn) { + throw new Error("Built-in agents can’t be published to the catalog."); + } + if (input.memoryLevel !== "none" && !input.linkedAgentPubkey) { + throw new Error("Start this agent before sharing its memory."); + } + + const encoded = await encodeAgentSnapshotForSend( + input.persona.id, + input.memoryLevel, + "json", + input.linkedAgentPubkey, + ); + const snapshotBytes = sanitizeCatalogSnapshotBytes( + encoded.fileBytes, + input.memoryLevel, + ); + const descriptor = await uploadMediaBytes(snapshotBytes, encoded.fileName); + if ( + !/^[0-9a-f]{64}$/u.test(descriptor.sha256) || + descriptor.size !== snapshotBytes.length || + !descriptor.url + ) { + throw new Error("The relay returned an invalid catalog snapshot receipt."); + } + + const content: PublishedCatalogContent = { + format: CATALOG_FORMAT, + version: CATALOG_VERSION, + status: "published", + sourcePersonaId: input.persona.id, + sourceUpdatedAt: input.persona.updatedAt, + memoryLevel: input.memoryLevel, + agent: { + displayName: input.persona.displayName, + avatarUrl: publicAvatarUrl(input.persona.avatarUrl), + systemPrompt: input.persona.systemPrompt, + runtime: input.persona.runtime, + model: input.persona.model, + provider: input.persona.provider, + }, + snapshot: { + url: descriptor.url, + sha256: descriptor.sha256, + size: descriptor.size, + type: "application/json", + fileName: encoded.fileName, + }, + }; + const event = await signRelayEvent({ + kind: KIND_PERSONA_CATALOG, + content: JSON.stringify(content), + createdAt: monotonicCreatedAt(input.previousCreatedAt), + tags: [ + ["d", input.persona.id], + ["status", "published"], + ["source_updated_at", input.persona.updatedAt], + ["memory", input.memoryLevel], + ], + }); + const published = await relayClient.publishEvent( + event, + "Timed out publishing this agent to the catalog.", + "Failed to publish this agent to the catalog.", + ); + return requirePublishedCatalogEvent(published); +} + +export async function unpublishPersonaFromCatalog(input: { + persona: AgentPersona; + previousCreatedAt?: number | null; +}): Promise { + const content: UnpublishedCatalogContent = { + format: CATALOG_FORMAT, + version: CATALOG_VERSION, + status: "unpublished", + sourcePersonaId: input.persona.id, + sourceUpdatedAt: input.persona.updatedAt, + }; + const event = await signRelayEvent({ + kind: KIND_PERSONA_CATALOG, + content: JSON.stringify(content), + createdAt: monotonicCreatedAt(input.previousCreatedAt), + tags: [ + ["d", input.persona.id], + ["status", "unpublished"], + ["source_updated_at", input.persona.updatedAt], + ], + }); + const published = await relayClient.publishEvent( + event, + "Timed out removing this agent from the catalog.", + "Failed to remove this agent from the catalog.", + ); + return requirePublishedCatalogEvent(published); +} + +export function linkedAgentPubkeyForPersona( + personaId: string, + managedAgents: readonly ManagedAgent[], +): string | null { + return ( + managedAgents.find((agent) => agent.personaId === personaId)?.pubkey ?? null + ); +} diff --git a/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs b/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs deleted file mode 100644 index c8a7fcd3e..000000000 --- a/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs +++ /dev/null @@ -1,153 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - readCatalogPersonaMemoryLevels, - readPublishedCatalogPersonaVersions, - readSharedCatalogPersonaIds, - writeCatalogPersonaMemoryLevels, - writePublishedCatalogPersonaVersions, - writeSharedCatalogPersonaIds, -} from "./personaCatalogVisibility.ts"; - -test("catalog visibility reads stored persona ids", () => { - const storage = { - getItem: () => JSON.stringify(["custom:analyst", 42, "custom:writer"]), - }; - - assert.deepEqual(readSharedCatalogPersonaIds(storage), [ - "custom:analyst", - "custom:writer", - ]); -}); - -test("catalog visibility tolerates unavailable and invalid storage", () => { - assert.deepEqual(readSharedCatalogPersonaIds(null), []); - assert.deepEqual( - readSharedCatalogPersonaIds({ getItem: () => "not-json" }), - [], - ); - assert.deepEqual(readSharedCatalogPersonaIds({ getItem: () => "{}" }), []); - assert.deepEqual( - readSharedCatalogPersonaIds({ - getItem: () => { - throw new Error("unavailable"); - }, - }), - [], - ); -}); - -test("catalog visibility persists persona ids without blocking on storage errors", () => { - let storedKey = ""; - let storedValue = ""; - writeSharedCatalogPersonaIds(["custom:analyst"], { - setItem: (key, value) => { - storedKey = key; - storedValue = value; - }, - }); - - assert.equal(storedKey, "buzz-persona-catalog-visibility-v1"); - assert.equal(storedValue, '["custom:analyst"]'); - assert.doesNotThrow(() => - writeSharedCatalogPersonaIds(["custom:analyst"], { - setItem: () => { - throw new Error("unavailable"); - }, - }), - ); -}); - -test("catalog memory levels read only supported snapshot levels", () => { - const storage = { - getItem: () => - JSON.stringify({ - "custom:analyst": "none", - "custom:writer": "core", - "custom:reviewer": "everything", - "custom:invalid": "secret", - }), - }; - - assert.deepEqual(readCatalogPersonaMemoryLevels(storage), { - "custom:analyst": "none", - "custom:writer": "core", - "custom:reviewer": "everything", - }); - assert.deepEqual(readCatalogPersonaMemoryLevels(null), {}); - assert.deepEqual(readCatalogPersonaMemoryLevels({ getItem: () => "[]" }), {}); -}); - -test("catalog memory levels persist without blocking on storage errors", () => { - let storedKey = ""; - let storedValue = ""; - writeCatalogPersonaMemoryLevels( - { "custom:analyst": "core" }, - { - setItem: (key, value) => { - storedKey = key; - storedValue = value; - }, - }, - ); - - assert.equal(storedKey, "buzz-persona-catalog-memory-levels-v1"); - assert.equal(storedValue, '{"custom:analyst":"core"}'); - assert.doesNotThrow(() => - writeCatalogPersonaMemoryLevels( - { "custom:analyst": "core" }, - { - setItem: () => { - throw new Error("unavailable"); - }, - }, - ), - ); -}); - -test("catalog publication versions read only string revisions", () => { - const storage = { - getItem: () => - JSON.stringify({ - "custom:analyst": "2026-07-22T00:00:00.000Z", - "custom:invalid": 42, - }), - }; - - assert.deepEqual(readPublishedCatalogPersonaVersions(storage), { - "custom:analyst": "2026-07-22T00:00:00.000Z", - }); - assert.deepEqual(readPublishedCatalogPersonaVersions(null), {}); - assert.deepEqual( - readPublishedCatalogPersonaVersions({ getItem: () => "[]" }), - {}, - ); -}); - -test("catalog publication versions persist without blocking on storage errors", () => { - let storedKey = ""; - let storedValue = ""; - writePublishedCatalogPersonaVersions( - { "custom:analyst": "2026-07-22T00:00:00.000Z" }, - { - setItem: (key, value) => { - storedKey = key; - storedValue = value; - }, - }, - ); - - assert.equal(storedKey, "buzz-persona-catalog-published-versions-v1"); - assert.equal(storedValue, '{"custom:analyst":"2026-07-22T00:00:00.000Z"}'); - assert.doesNotThrow(() => - writePublishedCatalogPersonaVersions( - { "custom:analyst": "2026-07-22T00:00:00.000Z" }, - { - setItem: () => { - throw new Error("unavailable"); - }, - }, - ), - ); -}); diff --git a/desktop/src/features/agents/lib/personaCatalogVisibility.ts b/desktop/src/features/agents/lib/personaCatalogVisibility.ts deleted file mode 100644 index 374dd06c6..000000000 --- a/desktop/src/features/agents/lib/personaCatalogVisibility.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { SnapshotMemoryLevel } from "@/shared/api/tauriPersonas"; - -const PERSONA_CATALOG_VISIBILITY_STORAGE_KEY = - "buzz-persona-catalog-visibility-v1"; -const PERSONA_CATALOG_PUBLISHED_VERSIONS_STORAGE_KEY = - "buzz-persona-catalog-published-versions-v1"; -const PERSONA_CATALOG_MEMORY_LEVELS_STORAGE_KEY = - "buzz-persona-catalog-memory-levels-v1"; - -export type PublishedCatalogPersonaVersions = Record; -export type CatalogPersonaMemoryLevels = Record; -export type CatalogPersonaShareLevel = "not-shared" | SnapshotMemoryLevel; - -const SNAPSHOT_MEMORY_LEVELS = new Set([ - "none", - "core", - "everything", -]); - -function resolveStorage( - storage: Pick | null | undefined, -): Pick | null { - if (storage !== undefined) return storage; - if (typeof window === "undefined") return null; - - try { - return window.localStorage; - } catch { - return null; - } -} - -export function readSharedCatalogPersonaIds( - storage?: Pick | null, -): string[] { - const targetStorage = resolveStorage(storage); - if (!targetStorage) return []; - - try { - const raw = targetStorage.getItem(PERSONA_CATALOG_VISIBILITY_STORAGE_KEY); - if (!raw) return []; - - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - - return parsed.filter((id): id is string => typeof id === "string"); - } catch { - return []; - } -} - -export function writeSharedCatalogPersonaIds( - ids: readonly string[], - storage?: Pick | null, -): void { - const targetStorage = resolveStorage(storage); - if (!targetStorage) return; - - try { - targetStorage.setItem( - PERSONA_CATALOG_VISIBILITY_STORAGE_KEY, - JSON.stringify(ids), - ); - } catch { - // Catalog visibility is a convenience setting and should not block sharing. - } -} - -export function readCatalogPersonaMemoryLevels( - storage?: Pick | null, -): CatalogPersonaMemoryLevels { - const targetStorage = resolveStorage(storage); - if (!targetStorage) return {}; - - try { - const raw = targetStorage.getItem( - PERSONA_CATALOG_MEMORY_LEVELS_STORAGE_KEY, - ); - if (!raw) return {}; - - const parsed: unknown = JSON.parse(raw); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return {}; - } - - return Object.fromEntries( - Object.entries(parsed).filter( - (entry): entry is [string, SnapshotMemoryLevel] => - typeof entry[1] === "string" && - SNAPSHOT_MEMORY_LEVELS.has(entry[1] as SnapshotMemoryLevel), - ), - ); - } catch { - return {}; - } -} - -export function writeCatalogPersonaMemoryLevels( - levels: Readonly, - storage?: Pick | null, -): void { - const targetStorage = resolveStorage(storage); - if (!targetStorage) return; - - try { - targetStorage.setItem( - PERSONA_CATALOG_MEMORY_LEVELS_STORAGE_KEY, - JSON.stringify(levels), - ); - } catch { - // Catalog publication state should not block sharing. - } -} - -export function readPublishedCatalogPersonaVersions( - storage?: Pick | null, -): PublishedCatalogPersonaVersions { - const targetStorage = resolveStorage(storage); - if (!targetStorage) return {}; - - try { - const raw = targetStorage.getItem( - PERSONA_CATALOG_PUBLISHED_VERSIONS_STORAGE_KEY, - ); - if (!raw) return {}; - - const parsed: unknown = JSON.parse(raw); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return {}; - } - - return Object.fromEntries( - Object.entries(parsed).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ), - ); - } catch { - return {}; - } -} - -export function writePublishedCatalogPersonaVersions( - versions: Readonly, - storage?: Pick | null, -): void { - const targetStorage = resolveStorage(storage); - if (!targetStorage) return; - - try { - targetStorage.setItem( - PERSONA_CATALOG_PUBLISHED_VERSIONS_STORAGE_KEY, - JSON.stringify(versions), - ); - } catch { - // Catalog publication state should not block sharing. - } -} diff --git a/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts new file mode 100644 index 000000000..890cd2d19 --- /dev/null +++ b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts @@ -0,0 +1,107 @@ +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + fetchPersonaCatalogPublications, + publishPersonaToCatalog, + unpublishPersonaFromCatalog, + type PersonaCatalogPublication, +} from "@/features/agents/lib/personaCatalogRelay"; +import { relayClient } from "@/shared/api/relayClient"; +import { KIND_PERSONA_CATALOG } from "@/shared/constants/kinds"; + +export function personaCatalogQueryKey(communityId: string | null) { + return ["persona-catalog", communityId] as const; +} + +export function usePersonaCatalogQuery(communityId: string | null) { + return useQuery({ + enabled: communityId !== null, + queryKey: personaCatalogQueryKey(communityId), + queryFn: fetchPersonaCatalogPublications, + staleTime: 30_000, + refetchInterval: 120_000, + }); +} + +export function usePersonaCatalogLiveUpdates(communityId: string | null): void { + const queryClient = useQueryClient(); + + React.useEffect(() => { + if (!communityId) return; + let disposed = false; + let dispose: (() => Promise) | null = null; + + void relayClient + .subscribeLive({ kinds: [KIND_PERSONA_CATALOG], limit: 0 }, () => { + void queryClient.invalidateQueries({ + queryKey: personaCatalogQueryKey(communityId), + }); + }) + .then((unsubscribe) => { + if (disposed) { + void unsubscribe(); + } else { + dispose = unsubscribe; + } + }) + .catch((error) => { + console.error( + "Failed to subscribe to the community agent catalog", + error, + ); + }); + + const unsubscribeReconnect = relayClient.subscribeToReconnects(() => { + void queryClient.invalidateQueries({ + queryKey: personaCatalogQueryKey(communityId), + }); + }); + + return () => { + disposed = true; + unsubscribeReconnect(); + if (dispose) void dispose(); + }; + }, [communityId, queryClient]); +} + +export function usePublishPersonaCatalogMutation(communityId: string | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: publishPersonaToCatalog, + onSuccess: (publication) => { + queryClient.setQueryData( + personaCatalogQueryKey(communityId), + (current) => [ + publication, + ...(current ?? []).filter( + (candidate) => + candidate.ownerPubkey !== publication.ownerPubkey || + candidate.sourcePersonaId !== publication.sourcePersonaId, + ), + ], + ); + }, + }); +} + +export function useUnpublishPersonaCatalogMutation(communityId: string | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: unpublishPersonaFromCatalog, + onSuccess: (publication) => { + queryClient.setQueryData( + personaCatalogQueryKey(communityId), + (current) => [ + publication, + ...(current ?? []).filter( + (candidate) => + candidate.ownerPubkey !== publication.ownerPubkey || + candidate.sourcePersonaId !== publication.sourcePersonaId, + ), + ], + ); + }, + }); +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index ce3f1ad07..def2bedc0 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -374,9 +374,10 @@ export function AgentsView() { onCatalogShareLevelChange={(shareLevel) => { const shareTarget = personas.personaToShare; if (!shareTarget) return; - personas.setPersonaCatalogShareLevel( + void personas.setPersonaCatalogShareLevel( shareTarget.persona, shareLevel, + shareTarget.linkedAgentPubkey, ); }} onExport={() => { @@ -393,7 +394,10 @@ export function AgentsView() { onPublishCatalogUpdates={() => { const shareTarget = personas.personaToShare; if (!shareTarget) return; - personas.publishPersonaCatalogUpdates(shareTarget.persona); + void personas.publishPersonaCatalogUpdates( + shareTarget.persona, + shareTarget.linkedAgentPubkey, + ); }} open={personas.personaToShare !== null} persona={personas.personaToShare.persona} @@ -442,8 +446,8 @@ export function AgentsView() { {personas.isCatalogDialogOpen ? ( { personas.clearFeedback("catalog"); }} diff --git a/desktop/src/features/agents/ui/PersonaAddedBy.tsx b/desktop/src/features/agents/ui/PersonaAddedBy.tsx index 66e5ee31f..3cdec2910 100644 --- a/desktop/src/features/agents/ui/PersonaAddedBy.tsx +++ b/desktop/src/features/agents/ui/PersonaAddedBy.tsx @@ -2,13 +2,17 @@ import { cn } from "@/shared/lib/cn"; type PersonaAddedByProps = { className?: string; + label?: string; }; -export function PersonaAddedBy({ className }: PersonaAddedByProps) { +export function PersonaAddedBy({ + className, + label = "You", +}: PersonaAddedByProps) { return (

Added by{" "} - You + {label}

); } diff --git a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx index b70e20f4d..ba76d6e4e 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog"; +import { isCatalogPersona } from "@/features/agents/lib/personaCatalogRelay"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { AgentPersona } from "@/shared/api/types"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; @@ -288,7 +289,16 @@ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) {

{persona.displayName}

- {persona.isBuiltIn ? null : } + {persona.isBuiltIn ? null : ( + + )} diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index 1780f514a..c8e7d471b 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -12,7 +12,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { toast } from "sonner"; import { useEncodeAgentSnapshotForSendMutation } from "@/features/agents/hooks"; -import type { CatalogPersonaShareLevel } from "@/features/agents/lib/personaCatalogVisibility"; +import type { CatalogPersonaShareLevel } from "@/features/agents/lib/personaCatalogRelay"; import { useOpenDmMutation, useUpsertCachedChannel, @@ -740,6 +740,8 @@ export function PersonaShareDialog({ persona, }: PersonaShareDialogProps) { const encodeSnapshotMutation = useEncodeAgentSnapshotForSendMutation(); + const [pendingCatalogMemoryLevel, setPendingCatalogMemoryLevel] = + React.useState | null>(null); const catalogShareLevels = React.useMemo( () => [ { value: "not-shared", label: "Not shared" }, @@ -767,60 +769,107 @@ export function PersonaShareDialog({ ); return ( - - - - -
-

Share to catalog

-

- Let anyone in this community find and use a copy of this agent. -

-
-
- {catalogShareLevel !== "not-shared" && hasCatalogUpdates ? ( - + ) : null} + - Publish updates - - ) : null} - - onCatalogShareLevelChange( - nextValue as CatalogPersonaShareLevel, - ) - } - options={catalogShareLevels} - testId="persona-share-catalog-access" - value={catalogShareLevel} - /> -
- - ) - } - displayName={persona.displayName} - encodeSnapshot={encodeSnapshot} - hasMemoryOptions={linkedAgentPubkey !== null} - isPending={isPending} - onExport={onExport} - onOpenChange={onOpenChange} - onReset={encodeSnapshotMutation.reset} - open={open} - snapshotKind="agent" - testIdPrefix="persona-share" - /> + onValueChange={(nextValue) => { + const shareLevel = nextValue as CatalogPersonaShareLevel; + if (shareLevel === "core" || shareLevel === "everything") { + setPendingCatalogMemoryLevel(shareLevel); + } else { + onCatalogShareLevelChange(shareLevel); + } + }} + options={catalogShareLevels} + testId="persona-share-catalog-access" + value={catalogShareLevel} + /> + + + ) + } + displayName={persona.displayName} + encodeSnapshot={encodeSnapshot} + hasMemoryOptions={linkedAgentPubkey !== null} + isPending={isPending} + onExport={onExport} + onOpenChange={onOpenChange} + onReset={encodeSnapshotMutation.reset} + open={open} + snapshotKind="agent" + testIdPrefix="persona-share" + /> + { + if (!nextOpen) setPendingCatalogMemoryLevel(null); + }} + open={pendingCatalogMemoryLevel !== null} + > + + + + Publish memories to the catalog? + + + The selected memory will be stored as plaintext community data. + Anyone in this community can read it and keep a copy, even if you + stop sharing the agent later. + + + + + + + + + + + + + ); } diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 1d20e9f48..b62099848 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -17,17 +17,27 @@ import { type AgentSnapshotImportPreview, type AgentSnapshotImportResult, } from "@/features/agents/hooks"; -import { getPersonaLibraryState } from "@/features/agents/lib/catalog"; +import { + getLibraryPersonas, + getPersonaLabelsById, +} from "@/features/agents/lib/catalog"; import { type CatalogPersonaShareLevel, - readCatalogPersonaMemoryLevels, - readPublishedCatalogPersonaVersions, - readSharedCatalogPersonaIds, - writeCatalogPersonaMemoryLevels, - writePublishedCatalogPersonaVersions, - writeSharedCatalogPersonaIds, -} from "@/features/agents/lib/personaCatalogVisibility"; + catalogPersonasFromPublications, + isCatalogPersona, + linkedAgentPubkeyForPersona, + ownCatalogPublication, +} from "@/features/agents/lib/personaCatalogRelay"; +import { + usePersonaCatalogLiveUpdates, + usePersonaCatalogQuery, + usePublishPersonaCatalogMutation, + useUnpublishPersonaCatalogMutation, +} from "@/features/agents/lib/usePersonaCatalogRelay"; import { useCreatedAgentChannelAttachment } from "@/features/agents/useCreatedAgentChannelAttachment"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { fetchSnapshotBytes } from "@/shared/api/tauriMedia"; import type { SnapshotFormat, SnapshotMemoryLevel, @@ -59,7 +69,15 @@ type PersonaFeedbackSurface = "catalog" | "library"; export function usePersonaActions() { const queryClient = useQueryClient(); + const { activeCommunity } = useCommunities(); + const identityQuery = useIdentityQuery(); + const communityId = activeCommunity?.id ?? null; const personasQuery = usePersonasQuery(); + const catalogQuery = usePersonaCatalogQuery(communityId); + usePersonaCatalogLiveUpdates(communityId); + const publishCatalogMutation = usePublishPersonaCatalogMutation(communityId); + const unpublishCatalogMutation = + useUnpublishPersonaCatalogMutation(communityId); const [shouldLoadAcpRuntimes, setShouldLoadAcpRuntimes] = React.useState(false); const acpRuntimesQuery = useAcpRuntimesQuery({ @@ -96,13 +114,6 @@ export function usePersonaActions() { const [snapshotImportConfirmError, setSnapshotImportConfirmError] = React.useState(null); const [isCatalogDialogOpen, setIsCatalogDialogOpen] = React.useState(false); - const [sharedCatalogPersonaIds, setSharedCatalogPersonaIds] = React.useState< - string[] - >(readSharedCatalogPersonaIds); - const [catalogPersonaMemoryLevels, setCatalogPersonaMemoryLevels] = - React.useState(readCatalogPersonaMemoryLevels); - const [publishedCatalogPersonaVersions, setPublishedCatalogPersonaVersions] = - React.useState>(readPublishedCatalogPersonaVersions); const [personaNoticeMessage, setPersonaNoticeMessage] = React.useState< string | null >(null); @@ -116,15 +127,19 @@ export function usePersonaActions() { React.useState(false); const personas = personasQuery.data ?? []; - const sharedCatalogPersonaIdSet = React.useMemo( - () => - new Set( - sharedCatalogPersonaIds.filter( - (personaId) => catalogPersonaMemoryLevels[personaId] !== undefined, - ), - ), - [catalogPersonaMemoryLevels, sharedCatalogPersonaIds], - ); + const publications = catalogQuery.data ?? []; + const sharedCatalogPersonaIdSet = React.useMemo(() => { + const ownerPubkey = identityQuery.data?.pubkey?.toLowerCase(); + return new Set( + publications + .filter( + (publication) => + publication.ownerPubkey === ownerPubkey && + publication.status === "published", + ) + .map((publication) => publication.sourcePersonaId), + ); + }, [identityQuery.data?.pubkey, publications]); const availableRuntimes = React.useMemo( () => (acpRuntimesQuery.data ?? []).filter( @@ -133,9 +148,22 @@ export function usePersonaActions() { ), [acpRuntimesQuery.data], ); - const { catalogPersonas, libraryPersonas, personaLabelsById } = React.useMemo( - () => getPersonaLibraryState(personas, sharedCatalogPersonaIdSet), - [personas, sharedCatalogPersonaIdSet], + const catalogPersonas = React.useMemo( + () => + catalogPersonasFromPublications( + publications, + personas, + identityQuery.data?.pubkey, + ), + [identityQuery.data?.pubkey, personas, publications], + ); + const libraryPersonas = React.useMemo( + () => getLibraryPersonas(personas), + [personas], + ); + const personaLabelsById = React.useMemo( + () => getPersonaLabelsById(personas), + [personas], ); function clearFeedback( @@ -163,7 +191,7 @@ export function usePersonaActions() { if ("id" in input) { const updatedPersona = await updatePersonaMutation.mutateAsync(input); if (options?.publishCatalogUpdates) { - publishPersonaCatalogUpdates(updatedPersona); + await publishPersonaCatalogUpdates(updatedPersona); } setPersonaNoticeMessage(`Updated ${input.displayName}.`); } else { @@ -248,6 +276,17 @@ export function usePersonaActions() { async function handleDelete(persona: AgentPersona) { clearFeedback("library"); try { + const publication = ownCatalogPublication( + publications, + identityQuery.data?.pubkey, + persona.id, + ); + if (publication?.status === "published") { + await unpublishCatalogMutation.mutateAsync({ + persona, + previousCreatedAt: publication.createdAt, + }); + } await deletePersonaMutation.mutateAsync(persona.id); setPersonaNoticeMessage(`Deleted ${persona.displayName}.`); setPersonaToDelete(null); @@ -265,7 +304,47 @@ export function usePersonaActions() { ) { clearFeedback(surface); try { - await setPersonaActiveMutation.mutateAsync({ id: persona.id, active }); + if (active && isCatalogPersona(persona)) { + const isOwnPublication = + persona.catalogSource.ownerPubkey === + identityQuery.data?.pubkey?.toLowerCase(); + const ownLocalPersona = isOwnPublication + ? personas.find( + (candidate) => + candidate.id === persona.catalogSource.sourcePersonaId, + ) + : undefined; + + if (ownLocalPersona) { + await setPersonaActiveMutation.mutateAsync({ + id: ownLocalPersona.id, + active: true, + }); + } else { + const snapshot = persona.catalogSource.snapshot; + const fileBytes = await fetchSnapshotBytes({ + url: snapshot.url, + filename: snapshot.fileName, + expectedSha256: snapshot.sha256, + expectedSize: snapshot.size, + }); + const result = await confirmSnapshotImportMutation.mutateAsync({ + fileBytes, + // Catalog publication strips the source response allowlist, but + // fail closed at import too if a malicious entry bypassed it. + keepAllowlist: false, + }); + void queryClient.invalidateQueries({ queryKey: personasQueryKey }); + void queryClient.invalidateQueries({ + queryKey: managedAgentsQueryKey, + }); + void queryClient.invalidateQueries({ + queryKey: ["user-profile", result.newPubkey.toLowerCase()], + }); + } + } else { + await setPersonaActiveMutation.mutateAsync({ id: persona.id, active }); + } setPersonaNoticeMessage( active ? `Selected ${persona.displayName} for My Agents.` @@ -372,16 +451,6 @@ export function usePersonaActions() { linkedAgent: ManagedAgent | undefined, ) { clearFeedback("library"); - if ( - sharedCatalogPersonaIdSet.has(persona.id) && - publishedCatalogPersonaVersions[persona.id] === undefined - ) { - setPublishedCatalogPersonaVersions((current) => { - const next = { ...current, [persona.id]: persona.updatedAt }; - writePublishedCatalogPersonaVersions(next); - return next; - }); - } setPersonaToShare({ persona, linkedAgentPubkey: linkedAgent?.pubkey ?? null, @@ -424,69 +493,113 @@ export function usePersonaActions() { function getPersonaCatalogShareLevel( persona: AgentPersona, ): CatalogPersonaShareLevel { - if (!sharedCatalogPersonaIdSet.has(persona.id)) return "not-shared"; - return catalogPersonaMemoryLevels[persona.id] ?? "none"; + const publication = ownCatalogPublication( + publications, + identityQuery.data?.pubkey, + persona.id, + ); + if ( + publication?.status !== "published" || + publication.memoryLevel === null + ) { + return "not-shared"; + } + return publication.memoryLevel; } - function setPersonaCatalogShareLevel( + async function setPersonaCatalogShareLevel( persona: AgentPersona, shareLevel: CatalogPersonaShareLevel, - ) { + linkedAgentPubkey: string | null, + ): Promise { if (persona.isBuiltIn) return; - const visible = shareLevel !== "not-shared"; clearFeedback("library"); - setSharedCatalogPersonaIds((current) => { - const next = new Set(current); - if (visible) { - next.add(persona.id); + const publication = ownCatalogPublication( + publications, + identityQuery.data?.pubkey, + persona.id, + ); + try { + if (shareLevel === "not-shared") { + await unpublishCatalogMutation.mutateAsync({ + persona, + previousCreatedAt: publication?.createdAt, + }); + setPersonaNoticeMessage( + `${persona.displayName} is no longer discoverable in the community catalog.`, + ); } else { - next.delete(persona.id); + await publishCatalogMutation.mutateAsync({ + persona, + memoryLevel: shareLevel, + linkedAgentPubkey, + previousCreatedAt: publication?.createdAt, + }); + setPersonaNoticeMessage( + `Published ${persona.displayName} to the community catalog.`, + ); } - - const ids = Array.from(next); - writeSharedCatalogPersonaIds(ids); - return ids; - }); - setCatalogPersonaMemoryLevels((current) => { - const next = { ...current }; - if (shareLevel !== "not-shared") { - next[persona.id] = shareLevel; - } else { - delete next[persona.id]; - } - writeCatalogPersonaMemoryLevels(next); - return next; - }); - setPublishedCatalogPersonaVersions((current) => { - const next = { ...current }; - if (visible) { - next[persona.id] = persona.updatedAt; - } else { - delete next[persona.id]; - } - writePublishedCatalogPersonaVersions(next); - return next; - }); + } catch (error) { + setPersonaErrorMessage( + error instanceof Error + ? error.message + : "Failed to update catalog sharing.", + ); + } } function hasPersonaCatalogUpdates(persona: AgentPersona) { - const publishedVersion = publishedCatalogPersonaVersions[persona.id]; + const publication = ownCatalogPublication( + publications, + identityQuery.data?.pubkey, + persona.id, + ); return ( - sharedCatalogPersonaIdSet.has(persona.id) && - publishedVersion !== undefined && - publishedVersion !== persona.updatedAt + publication?.status === "published" && + publication.sourceUpdatedAt !== persona.updatedAt ); } - function publishPersonaCatalogUpdates(persona: AgentPersona) { - if (persona.isBuiltIn || !sharedCatalogPersonaIdSet.has(persona.id)) return; - - setPublishedCatalogPersonaVersions((current) => { - const next = { ...current, [persona.id]: persona.updatedAt }; - writePublishedCatalogPersonaVersions(next); - return next; - }); + async function publishPersonaCatalogUpdates( + persona: AgentPersona, + linkedAgentPubkey?: string | null, + ): Promise { + if (persona.isBuiltIn) return false; + const publication = ownCatalogPublication( + publications, + identityQuery.data?.pubkey, + persona.id, + ); + if ( + publication?.status !== "published" || + publication.memoryLevel === null + ) { + return false; + } + const managedAgents = + queryClient.getQueryData(managedAgentsQueryKey) ?? []; + try { + await publishCatalogMutation.mutateAsync({ + persona, + memoryLevel: publication.memoryLevel, + linkedAgentPubkey: + linkedAgentPubkey ?? + linkedAgentPubkeyForPersona(persona.id, managedAgents), + previousCreatedAt: publication.createdAt, + }); + setPersonaNoticeMessage( + `Published updates to ${persona.displayName} in the community catalog.`, + ); + return true; + } catch (error) { + setPersonaErrorMessage( + error instanceof Error + ? `${persona.displayName} was saved, but its catalog update failed: ${error.message}` + : `${persona.displayName} was saved, but its catalog update failed.`, + ); + return false; + } } const isPending = @@ -498,10 +611,13 @@ export function usePersonaActions() { setPersonaActiveMutation.isPending || exportAgentSnapshotMutation.isPending || previewSnapshotImportMutation.isPending || - confirmSnapshotImportMutation.isPending; + confirmSnapshotImportMutation.isPending || + publishCatalogMutation.isPending || + unpublishCatalogMutation.isPending; return { personasQuery, + catalogQuery, acpRuntimesQuery, createPersonaMutation, updatePersonaMutation, diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index ef3234f4c..ab8923b1b 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -52,6 +52,9 @@ export const KIND_CHANNEL_SORT = 30078; export const KIND_PERSONA = 30175; export const KIND_TEAM = 30176; export const KIND_MANAGED_AGENT = 30177; +// Community-visible catalog publications. Unlike KIND_PERSONA, clients query +// this kind across every author in the active community. +export const KIND_PERSONA_CATALOG = 30178; export const KIND_USER_STATUS = 30315; export const KIND_AGENT_OBSERVER_FRAME = 24200; export const KIND_AGENT_TURN_METRIC = 44200; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index ffcefee8a..a1cc8dae8 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -35,6 +35,7 @@ import { KIND_HUDDLE_STARTED, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, + KIND_PERSONA_CATALOG, KIND_REPO_ANNOUNCEMENT, KIND_REPO_STATE, KIND_STREAM_MESSAGE_EDIT, @@ -104,6 +105,7 @@ type MockPersonaSeed = { displayName: string; avatarUrl?: string | null; systemPrompt: string; + updatedAt?: string; isActive?: boolean; sourceTeam?: string | null; envVars?: Record; @@ -193,6 +195,8 @@ type E2eConfig = { * (`list/start/stop/restart_managed_agent_runtime`). */ managedAgentRuntimes?: MockManagedAgentRuntimeSeed[]; personas?: MockPersonaSeed[]; + /** Community catalog replaceable-event heads returned by relay queries. */ + personaCatalogEvents?: RelayEvent[]; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; agentListDelayMs?: number; @@ -2121,7 +2125,7 @@ function resetMockPersonas(config?: E2eConfig) { source_team: persona.sourceTeam ?? null, env_vars: { ...(persona.envVars ?? {}) }, created_at: now, - updated_at: now, + updated_at: persona.updatedAt ?? now, }); } } @@ -2716,6 +2720,7 @@ const mockChannels: MockChannel[] = [ const mockMessages = new Map(); const mockUserStatuses: RelayEvent[] = []; const mockReminderEvents: RelayEvent[] = []; +const mockPersonaCatalogEvents: RelayEvent[] = []; let mockRelayMembers: RawRelayMember[] = []; const mockSockets = new Map(); let mockWebsocketSendMutexWedged = false; @@ -2746,6 +2751,16 @@ function resetMockSaveSubscriptions(config: E2eConfig | undefined) { })); } +function resetMockPersonaCatalogEvents(config: E2eConfig | undefined) { + mockPersonaCatalogEvents.length = 0; + for (const event of config?.mock?.personaCatalogEvents ?? []) { + mockPersonaCatalogEvents.push({ + ...event, + tags: event.tags.map((tag) => [...tag]), + }); + } +} + // Mesh-compute mock state — TEST-ONLY. // // This entire module (e2eBridge.ts) is loaded only when `window.__BUZZ_E2E__` @@ -7235,6 +7250,9 @@ async function handleUpdatePersona(args: { applyMockPersonaBehavior(persona, args.input.behavior); persona.updated_at = new Date().toISOString(); + for (const callback of tauriEventListeners.get("agents-data-changed") ?? []) { + callback(); + } return { ...persona }; } @@ -7992,6 +8010,35 @@ async function resolveMockUploadDescriptors( ]; } +async function resolveMockUploadDescriptorForBytes( + args: { data: number[]; filename?: string | null }, + config: E2eConfig | undefined, +): Promise { + const configured = config?.mock?.uploadDescriptors; + if (configured !== undefined) { + const descriptors = await resolveMockUploadDescriptors(config); + const descriptor = descriptors[0]; + if (!descriptor) throw new Error("mock upload returned no descriptor"); + return descriptor; + } + + const bytes = Uint8Array.from(args.data); + const digest = await crypto.subtle.digest("SHA-256", bytes); + const sha256 = Array.from(new Uint8Array(digest), (value) => + value.toString(16).padStart(2, "0"), + ).join(""); + const filename = args.filename ?? "upload.bin"; + const isAgentJson = filename.toLowerCase().endsWith(".agent.json"); + return { + url: `https://mock.relay/media/${sha256}${isAgentJson ? ".json" : ".bin"}`, + sha256, + size: bytes.length, + type: isAgentJson ? "application/json" : "application/octet-stream", + uploaded: Math.floor(Date.now() / 1000), + filename, + }; +} + async function handleSendChannelMessage( args: { channelId: string; @@ -8679,6 +8726,19 @@ function sendToMockSocket(args: { return; } + if (filter.kinds?.includes(KIND_PERSONA_CATALOG)) { + const authors = filter.authors?.map((author) => author.toLowerCase()); + const sourceIds = filter["#d"]; + for (const event of mockPersonaCatalogEvents) { + if (authors && !authors.includes(event.pubkey.toLowerCase())) continue; + const sourceId = event.tags.find((tag) => tag[0] === "d")?.[1]; + if (sourceIds && (!sourceId || !sourceIds.includes(sourceId))) continue; + sendWsText(socket.handler, ["EVENT", subId, event]); + } + sendWsText(socket.handler, ["EOSE", subId]); + return; + } + // Project queries: NIP-34 kinds, or kind:1 comments scoped by repo `a` // tag (PR/issue discussions, approvals, review requests). if ( @@ -8775,6 +8835,31 @@ function sendToMockSocket(args: { return; } + if (event.kind === KIND_PERSONA_CATALOG) { + const sourceId = event.tags.find((tag) => tag[0] === "d")?.[1]; + if (!sourceId) { + sendWsText(socket.handler, [ + "OK", + event.id, + false, + "invalid: persona catalog event missing d tag.", + ]); + return; + } + const existingIndex = mockPersonaCatalogEvents.findIndex( + (candidate) => + candidate.pubkey.toLowerCase() === event.pubkey.toLowerCase() && + candidate.tags.some((tag) => tag[0] === "d" && tag[1] === sourceId), + ); + if (existingIndex >= 0) { + mockPersonaCatalogEvents.splice(existingIndex, 1); + } + mockPersonaCatalogEvents.push(event); + emitMockGlobalEvent(event); + sendWsText(socket.handler, ["OK", event.id, true, ""]); + return; + } + if (event.kind === 20001) { const status = event.content; if (status === "online" || status === "away" || status === "offline") { @@ -8893,6 +8978,7 @@ export function maybeInstallE2eTauriMocks() { resetMockWorkflows(); resetMockMesh(); resetMockUserStatuses(); + resetMockPersonaCatalogEvents(config); resetMockSaveSubscriptions(config); resetMockPendingCommunityDeepLinks(config); mockWebsocketSendMutexWedged = false; @@ -9969,8 +10055,8 @@ export function maybeInstallE2eTauriMocks() { // Specs assert invocation via __BUZZ_E2E_COMMANDS__. return true; case "encode_agent_snapshot_for_send": { - // Return a minimal PNG-shaped payload so the send flow can proceed - // through upload_media_bytes without a real Rust encode step. + // Return the requested wire format so both message sharing (PNG) and + // community catalog publication (JSON) exercise their real branches. // Optional encodeDelayMs lets specs observe the "preparing" phase before // the upload begins. const encodeDelayMs = activeConfig?.mock?.encodeDelayMs ?? 0; @@ -9979,6 +10065,45 @@ export function maybeInstallE2eTauriMocks() { window.setTimeout(resolve, encodeDelayMs), ); } + const input = payload as { + id: string; + memoryLevel: "none" | "core" | "everything"; + format: "json" | "png"; + }; + if (input.format === "json") { + const persona = mockPersonas.find( + (candidate) => candidate.id === input.id, + ); + const snapshot = { + format: "buzz-agent-snapshot", + version: 1, + definition: { + name: persona?.display_name ?? "E2E Agent", + sourceIsBuiltIn: persona?.is_builtin ?? false, + systemPrompt: persona?.system_prompt ?? "", + runtime: persona?.runtime ?? null, + model: persona?.model ?? null, + provider: persona?.provider ?? null, + respondToAllowlist: persona?.respond_to_allowlist ?? [], + namePool: persona?.name_pool ?? [], + }, + profile: { + displayName: persona?.display_name ?? "E2E Agent", + avatarUrl: persona?.avatar_url ?? null, + }, + memory: { + level: input.memoryLevel, + entries: [], + }, + }; + const fileBytes = Array.from( + new TextEncoder().encode(JSON.stringify(snapshot)), + ); + return { + fileBytes, + fileName: "e2e-agent.agent.json", + }; + } return { fileBytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], fileName: "e2e-agent.agent.png", @@ -10501,7 +10626,10 @@ export function maybeInstallE2eTauriMocks() { case "pick_and_upload_image": return (await resolveMockUploadDescriptors(activeConfig))[0] ?? null; case "upload_media_bytes": - return (await resolveMockUploadDescriptors(activeConfig))[0]; + return resolveMockUploadDescriptorForBytes( + payload as { data: number[]; filename?: string | null }, + activeConfig, + ); case "fetch_media_bytes": { // The real command fetches relay media through Rust reqwest and // replies with raw bytes (`tauri::ipc::Response` → ArrayBuffer). In diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index e7a0e073b..0dbe74a2d 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -1,8 +1,59 @@ import { expect, test } from "@playwright/test"; +import type { RelayEvent } from "@/shared/api/types"; + import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +const DEFAULT_MOCK_OWNER_PUBKEY = "deadbeef".repeat(8); + +function createCatalogEvent(input: { + ownerPubkey: string; + sourcePersonaId: string; + sourceUpdatedAt: string; + displayName: string; + systemPrompt: string; + createdAt?: number; +}): RelayEvent { + const snapshot = { + url: `https://relay.example/media/${"c".repeat(64)}`, + sha256: "c".repeat(64), + size: 512, + type: "application/json", + fileName: `${input.sourcePersonaId}.agent.json`, + } as const; + return { + id: "1".repeat(64), + pubkey: input.ownerPubkey, + created_at: input.createdAt ?? 1_721_750_400, + kind: 30178, + tags: [ + ["d", input.sourcePersonaId], + ["status", "published"], + ["source_updated_at", input.sourceUpdatedAt], + ["memory", "none"], + ], + content: JSON.stringify({ + format: "buzz-persona-catalog", + version: 1, + status: "published", + sourcePersonaId: input.sourcePersonaId, + sourceUpdatedAt: input.sourceUpdatedAt, + memoryLevel: "none", + agent: { + displayName: input.displayName, + avatarUrl: null, + systemPrompt: input.systemPrompt, + runtime: null, + model: null, + provider: null, + }, + snapshot, + }), + sig: "2".repeat(128), + }; +} + test.beforeEach(async ({ page }) => { await installMockBridge(page); }); @@ -1272,12 +1323,6 @@ test("custom personas share with people and keep export separate", async ({ test("custom personas can be shared to the relay catalog", async ({ page }) => { const personaId = "custom:catalog-analyst"; - await page.addInitScript((legacyPersonaId) => { - localStorage.setItem( - "buzz-persona-catalog-visibility-v1", - JSON.stringify([legacyPersonaId]), - ); - }, personaId); await installMockBridge(page, { globalAgentConfig: { env_vars: { ANTHROPIC_API_KEY: "sk-ant-test" }, @@ -1332,7 +1377,10 @@ This deliberately long fenced-code example must not establish the minimum width ).toBeVisible(); await expect(catalogSection).toContainText("Share to catalog"); await expect(catalogSection).toContainText( - "Let anyone in this community find and use a copy of this agent.", + "Anyone in this community can find and use a copy.", + ); + await expect(catalogSection).toContainText( + "Catalog data is plaintext; secrets and response allowlists are never included.", ); const [copyLinkButtonBox, catalogSectionBox, shareMainCardBox] = await Promise.all([ @@ -1430,37 +1478,6 @@ This deliberately long fenced-code example must not establish the minimum width .getByRole("button", { name: "Close" }) .click(); - await page.evaluate((id) => { - const storageKey = "buzz-persona-catalog-published-versions-v1"; - const publishedVersions = JSON.parse( - localStorage.getItem(storageKey) ?? "{}", - ) as Record; - publishedVersions[id] = "stale"; - localStorage.setItem(storageKey, JSON.stringify(publishedVersions)); - }, personaId); - await gotoApp(page); - await page.getByTestId("open-agents-view").click(); - - await page.getByLabel("Open actions for Catalog Analyst").click(); - await page.getByRole("menuitem", { name: "Share" }).click(); - await expect(catalogAccess).toHaveText("Agent only"); - await expect(publishCatalogUpdatesButton).toBeVisible(); - const [catalogAccessBox, publishCatalogUpdatesButtonBox] = await Promise.all([ - catalogAccess.boundingBox(), - publishCatalogUpdatesButton.boundingBox(), - ]); - expect( - (publishCatalogUpdatesButtonBox?.x ?? 0) + - (publishCatalogUpdatesButtonBox?.width ?? 0), - ).toBeLessThan(catalogAccessBox?.x ?? 0); - await publishCatalogUpdatesButton.click(); - await expect(publishCatalogUpdatesButton).toHaveCount(0); - await expect(catalogAccess).toHaveText("Agent only"); - await page - .getByTestId("persona-share-dialog") - .getByRole("button", { name: "Close" }) - .click(); - await page.getByLabel("Open actions for Catalog Analyst").click(); await page.getByRole("menuitem", { name: "Share" }).click(); await expect(catalogAccess).toHaveText("Agent only"); @@ -1480,6 +1497,103 @@ This deliberately long fenced-code example must not establish the minimum width ).toHaveCount(0); }); +test("catalog owners can publish local updates to an existing relay entry", async ({ + page, +}) => { + const personaId = "custom:catalog-updates"; + await installMockBridge(page, { + personas: [ + { + id: personaId, + displayName: "Catalog Updates", + systemPrompt: "The locally updated instructions.", + updatedAt: "2026-07-23T17:00:00.000Z", + }, + ], + personaCatalogEvents: [ + createCatalogEvent({ + ownerPubkey: DEFAULT_MOCK_OWNER_PUBKEY, + sourcePersonaId: personaId, + sourceUpdatedAt: "2026-07-23T16:00:00.000Z", + displayName: "Catalog Updates", + systemPrompt: "The previously published instructions.", + }), + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + await page.getByLabel("Open actions for Catalog Updates").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + const catalogAccess = page.getByTestId("persona-share-catalog-access"); + const publishButton = page.getByTestId( + "persona-share-publish-catalog-updates", + ); + await expect(catalogAccess).toHaveText("Agent only"); + await expect(publishButton).toBeVisible(); + const [catalogAccessBox, publishButtonBox] = await Promise.all([ + catalogAccess.boundingBox(), + publishButton.boundingBox(), + ]); + expect( + (publishButtonBox?.x ?? 0) + (publishButtonBox?.width ?? 0), + ).toBeLessThan(catalogAccessBox?.x ?? 0); + + await publishButton.click(); + await expect(publishButton).toHaveCount(0); + await expect(catalogAccess).toHaveText("Agent only"); +}); + +test("a community member can discover and add another member's catalog agent", async ({ + page, +}) => { + const personaId = "shared-reviewer"; + const remoteCatalogId = `catalog:${TEST_IDENTITIES.alice.pubkey}:${personaId}`; + await installMockBridge(page, { + personaCatalogEvents: [ + createCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + sourcePersonaId: personaId, + sourceUpdatedAt: "2026-07-23T16:00:00.000Z", + displayName: "Alice’s Reviewer", + systemPrompt: "Review changes for the whole community.", + }), + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await openPersonaCatalog(page); + + const remoteEntry = page.getByTestId( + `persona-catalog-list-item-${remoteCatalogId}`, + ); + await expect(remoteEntry).toContainText("Alice’s Reviewer"); + await remoteEntry.click(); + await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + "Added by Community member", + ); + + await page + .getByRole("button", { + name: "Add Alice’s Reviewer from Agent Catalog", + }) + .click(); + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_COMMANDS__?: string[]; + } + ).__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "confirm_agent_snapshot_import", + ).length ?? 0, + ), + ) + .toBe(1); +}); + test("share access controls include the selected memories", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 21f69776d..6459c322b 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -1,4 +1,5 @@ import type { Page } from "@playwright/test"; +import type { RelayEvent } from "@/shared/api/types"; import { FEATURE_OVERRIDES_STORAGE_KEY, PREVIEW_FEATURE_IDS } from "./features"; export const TEST_IDENTITIES = { @@ -87,6 +88,7 @@ type MockPersonaSeed = { displayName: string; avatarUrl?: string | null; systemPrompt: string; + updatedAt?: string; isActive?: boolean; sourceTeam?: string | null; envVars?: Record; @@ -210,6 +212,8 @@ type MockBridgeOptions = { | "stopped"; }>; personas?: MockPersonaSeed[]; + /** Community catalog replaceable-event heads returned by relay queries. */ + personaCatalogEvents?: RelayEvent[]; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; agentListDelayMs?: number;