diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 2894a078d0d..00ce08d93b5 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -159,6 +159,7 @@ export default defineConfig({ "**/huddle-transcription.spec.ts", "**/agent-numeric-tuning.spec.ts", "**/needs-restart-screenshots.spec.ts", + "**/team-catalog-screenshots.spec.ts", ], use: { ...devices["Desktop Chrome"], @@ -180,6 +181,7 @@ export default defineConfig({ "**/persona-env-vars.spec.ts", "**/persona-sync.spec.ts", "**/team-snapshot.spec.ts", + "**/team-catalog.spec.ts", "**/agents-everywhere.live.spec.ts", "**/relay-restart.live.spec.ts", "**/parity-ancestor-island.spec.ts", diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f2b196c41d9..2dde312d779 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -41,6 +41,7 @@ mod relay_admission; mod reset; mod secret_store; mod shutdown; +mod team_catalog; mod templates; mod terminal_runtime; #[cfg_attr(not(test), allow(dead_code))] @@ -720,6 +721,7 @@ pub fn run() { discover_backend_providers, probe_backend_provider, persona_catalog::fetch_persona_catalog, + team_catalog::fetch_team_catalog, unread_catch_up::unread_catch_up, observed_unread::observed_unread_open_scope, observed_unread::observed_unread_ingest, diff --git a/desktop/src-tauri/src/team_catalog.rs b/desktop/src-tauri/src/team_catalog.rs new file mode 100644 index 00000000000..fe579e994c1 --- /dev/null +++ b/desktop/src-tauri/src/team_catalog.rs @@ -0,0 +1,290 @@ +//! Native team-catalog fetch and trust-boundary projection. +//! +//! The renderer owns presentation/linkage to local teams. Relay paging, +//! signature verification, NIP-33 head selection, and untrusted-content +//! parsing stay here — structurally the persona-catalog equivalent +//! (`persona_catalog.rs`) with kind 30178 and the team content parser swapped +//! in, so a catalog refresh crosses IPC once and never verifies a signature on +//! the webview thread. +//! +//! Content parsing reuses `managed_agents::team_catalog::team_catalog_content_from_event` +//! — the same all-or-nothing parse `add_team_from_catalog` re-runs at add time, +//! so a head this command projects is exactly a head the backend will accept. + +use std::{collections::HashMap, time::Duration}; + +use buzz_core_pkg::kind::{event_is_shared, KIND_TEAM_CATALOG}; +use nostr::Event; +use serde::Serialize; +use tauri::State; + +use crate::{ + app_state::AppState, + managed_agents::team_catalog::{team_catalog_content_from_event, TeamCatalogContent}, + native_relay_client::NativeRelayClient, +}; + +const CATALOG_PAGE_SIZE: usize = 500; +const MAX_CATALOG_PAGES: usize = 40; +const PAGE_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TeamCatalogPublication { + event_id: String, + owner_pubkey: String, + team_d_tag: String, + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + instructions: Option, + members: Vec, +} + +#[derive(Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct TeamCatalogMemberProjection { + member_key: String, + display_name: String, + system_prompt: String, + avatar_url: Option, + runtime: Option, + model: Option, + provider: Option, +} + +/// Fetches the active community's relay-confirmed team catalog. +/// +/// The command accepts no relay or identity input: both are snapshotted from +/// `AppState`, then checked again before return so an in-flight old-community +/// response cannot populate the new community's query cache. +#[tauri::command] +pub(crate) async fn fetch_team_catalog( + state: State<'_, AppState>, + relay_client: State<'_, NativeRelayClient>, +) -> Result, String> { + let keys = state.signing_keys()?; + let owner = keys.public_key().to_hex(); + let relay_url = crate::relay::relay_ws_url_with_override(&state); + let session = relay_client.session(relay_url.clone(), keys).await; + let mut by_id = HashMap::new(); + let mut until = None; + + for _ in 0..MAX_CATALOG_PAGES { + let mut filter = serde_json::json!({ + "kinds": [KIND_TEAM_CATALOG], + "limit": CATALOG_PAGE_SIZE, + }); + if let Some(until) = until { + filter["until"] = serde_json::json!(until); + } + let page = session.fetch_events(filter, PAGE_TIMEOUT).await?; + let page_len = page.len(); + // Schnorr verification is CPU-bound. Keep the complete page off the + // async executor (and therefore off Tauri command scheduling). + let verified = tauri::async_runtime::spawn_blocking(move || verify_page(page)) + .await + .map_err(|error| format!("catalog signature verification failed: {error}"))?; + + let progress = merge_verified_page(&mut by_id, page_len, until, verified); + match progress { + PageProgress::Done => break, + PageProgress::Next(next_until) => until = Some(next_until), + // The relay filter exposes no `(created_at, id)` cursor to page + // within a second, so more than one page at the boundary second + // cannot be paged past. Fail loudly rather than project a truncated + // catalog as complete — the browse dialog surfaces this as an error + // instead of silently dropping every older team. + PageProgress::DenseBoundary(second) => { + return Err(format!( + "team catalog has more than one page of events at created_at {second}; \ + the time-only relay cursor cannot page past it" + )); + } + // A full page with no verifiable events cannot advance the cursor on + // trusted data. Advancing on the wire timestamp would let one forged + // `created_at` warp the cursor past — and silently drop — every valid + // team below it, so fail loudly instead. + PageProgress::NoVerifiedEvents => { + return Err( + "team catalog returned a full page with no verifiable events; \ + cannot safely advance the cursor" + .to_string(), + ); + } + } + } + + let current_keys = state.signing_keys()?; + if current_keys.public_key().to_hex() != owner + || crate::relay::relay_ws_url_with_override(&state) != relay_url + { + return Err("team catalog scope changed while fetching".to_string()); + } + + Ok(publications_from_verified_events( + by_id.into_values().collect(), + )) +} + +#[derive(Debug, PartialEq)] +enum PageProgress { + Done, + Next(u64), + /// A full page whose oldest verified timestamp cannot drop the inclusive + /// `until` cursor: more than one page of events shares this second, and the + /// relay filter has no `(created_at, id)` cursor to escape it. + DenseBoundary(u64), + /// A full page with no verifiable events. The cursor can only advance on + /// trusted timestamps, so there is nothing safe to page with. + NoVerifiedEvents, +} + +/// Retain only events whose Schnorr signature verifies. This is the single +/// trust gate for a relay page: paging, head selection, and content parsing all +/// run on its output, so a forged or tampered event never influences the +/// cursor or the projected catalog. Shared with the paging regression so the +/// test drives the exact seam production does, not a stubbed result. +fn verify_page(page: Vec) -> Vec { + page.into_iter() + .filter(|event| event.verify().is_ok()) + .collect() +} + +fn merge_verified_page( + by_id: &mut HashMap, + wire_page_len: usize, + until: Option, + verified: Vec, +) -> PageProgress { + // The oldest *verified* timestamp is the only value safe to page with: an + // unverifiable event must never control the cursor, or one forged + // `created_at` (e.g. 0) would warp `until` past — and silently drop — every + // valid team below it. Captured before the page is drained into `by_id`. + let verified_oldest = verified + .iter() + .map(|event| event.created_at.as_secs()) + .min(); + + for event in verified { + by_id.insert(event.id.to_hex(), event); + } + + // A short page is the end of the catalog. + if wire_page_len < CATALOG_PAGE_SIZE { + return PageProgress::Done; + } + + // A full page must advance on a verified timestamp. With none, the cursor + // cannot move safely — fail loudly rather than trust the wire or complete. + let Some(oldest) = verified_oldest else { + return PageProgress::NoVerifiedEvents; + }; + // When the oldest verified timestamp cannot drop below the current inclusive + // `until`, the page is stuck at a dense boundary second: silently stopping + // would drop every older team and falsely report the catalog exhausted. + if until.is_some_and(|until| oldest >= until) { + return PageProgress::DenseBoundary(oldest); + } + PageProgress::Next(oldest) +} + +fn publications_from_verified_events(mut events: Vec) -> Vec { + events.sort_by(|left, right| { + right + .created_at + .cmp(&left.created_at) + .then_with(|| left.id.cmp(&right.id)) + }); + let mut claimed = std::collections::HashSet::new(); + let mut publications = Vec::new(); + + for event in events { + if event.kind.as_u16() as u32 != KIND_TEAM_CATALOG { + continue; + } + let Some(team_d_tag) = single_tag(&event, "d") else { + continue; + }; + if team_d_tag.is_empty() { + continue; + } + let owner_pubkey = event.pubkey.to_hex().to_ascii_lowercase(); + let coordinate = (owner_pubkey.clone(), team_d_tag.clone()); + if !claimed.insert(coordinate) { + continue; + } + + // Claim happens before visibility or parsing. A valid newest unshared + // or malformed head is still the NIP-33 head and must not resurrect an + // older shared definition. + if !event_is_shared(&event) { + continue; + } + // All-or-nothing parse, identical to the add-time re-fetch: a team with + // any invalid member cannot be adopted, so a partial projection would + // only offer an un-addable entry. + let Ok(content) = team_catalog_content_from_event(&event) else { + continue; + }; + publications.push(publication( + event.id.to_hex(), + owner_pubkey, + team_d_tag, + content, + )); + } + publications +} + +fn publication( + event_id: String, + owner_pubkey: String, + team_d_tag: String, + content: TeamCatalogContent, +) -> TeamCatalogPublication { + TeamCatalogPublication { + event_id, + owner_pubkey, + team_d_tag, + name: content.name, + description: content.description, + instructions: content.instructions, + members: content + .members + .into_iter() + .map(|member| TeamCatalogMemberProjection { + member_key: member.member_key, + display_name: member.display_name, + system_prompt: member.system_prompt.unwrap_or_default(), + avatar_url: member.avatar_url, + runtime: member.runtime, + model: member.model, + provider: member.provider, + }) + .collect(), + } +} + +/// A tag's value, but only when the event carries exactly one of that tag. +/// +/// Ambiguity is absence: the relay admits exactly one bounded `d` tag, so a +/// multi-`d` event is malformed and picking the first would resolve a +/// different coordinate than the publisher addressed. +fn single_tag(event: &Event, name: &str) -> Option { + let matches = event + .tags + .iter() + .filter_map(|tag| { + let values = tag.as_slice(); + (values.len() >= 2 && values.first().is_some_and(|value| value == name)) + .then(|| values[1].clone()) + }) + .collect::>(); + (matches.len() == 1).then(|| matches[0].clone()) +} + +#[cfg(test)] +#[path = "team_catalog_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/team_catalog_tests.rs b/desktop/src-tauri/src/team_catalog_tests.rs new file mode 100644 index 00000000000..8e85815ea4e --- /dev/null +++ b/desktop/src-tauri/src/team_catalog_tests.rs @@ -0,0 +1,281 @@ +use super::*; +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; +use serde_json::{json, Value}; + +fn event(keys: &Keys, created_at: u64, d_tag: &str, shared: bool, content: Value) -> Event { + let mut tags = vec![Tag::parse(["d", d_tag]).unwrap()]; + if shared { + tags.push(Tag::parse(["shared", "true"]).unwrap()); + } + EventBuilder::new(Kind::Custom(KIND_TEAM_CATALOG as u16), content.to_string()) + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(keys) + .unwrap() +} + +fn valid_content(name: &str) -> Value { + json!({ + "v": 1, + "name": name, + "description": "A crew.", + "instructions": "Ship it.", + "members": [{ + "member_key": "a".repeat(64), + "display_name": "Reviewer", + "system_prompt": "Review changes.", + "avatar_url": "https://relay.example/avatar.png", + "runtime": "goose", + "model": "claude", + "name_pool": ["Reviewer"], + "respond_to": "owner-only", + "parallelism": 4 + }] + }) +} + +#[test] +fn paging_advances_on_verified_oldest_and_stops_on_short_pages() { + let keys = Keys::generate(); + let newest = event(&keys, 9, "newest", true, valid_content("Newest")); + let oldest = event(&keys, 4, "oldest", true, valid_content("Oldest")); + let mut by_id = HashMap::new(); + + // First full page: no cursor yet, so the oldest verified timestamp (4) + // becomes the next inclusive `until`. + assert_eq!( + merge_verified_page( + &mut by_id, + CATALOG_PAGE_SIZE, + None, + vec![newest.clone(), oldest.clone()] + ), + PageProgress::Next(4) + ); + + // A short page ends the catalog regardless of its timestamps. + let short = event(&keys, 1, "short", true, valid_content("Short")); + assert_eq!( + merge_verified_page(&mut by_id, CATALOG_PAGE_SIZE - 1, Some(4), vec![short]), + PageProgress::Done + ); + + // A short page with no verified events is still the end of the catalog: + // NoVerifiedEvents only fires on a *full* page. + assert_eq!( + merge_verified_page( + &mut HashMap::new(), + CATALOG_PAGE_SIZE - 1, + Some(4), + Vec::new() + ), + PageProgress::Done + ); +} + +#[test] +fn full_page_stuck_at_boundary_second_reports_dense_not_done() { + // Regression for Carl blocker 2: a full page whose oldest verified timestamp + // ties the inclusive `until` cursor cannot be paged past (the relay filter + // has no sub-second cursor). It must report DenseBoundary, not silently + // complete and drop every older team. + let keys = Keys::generate(); + let a = event(&keys, 7, "a", true, valid_content("A")); + let b = event(&keys, 7, "b", true, valid_content("B")); + let mut by_id = HashMap::new(); + + // Under a cursor of 7, a full page whose oldest is also 7 is dense. + assert_eq!( + merge_verified_page(&mut by_id, CATALOG_PAGE_SIZE, Some(7), vec![a, b]), + PageProgress::DenseBoundary(7) + ); +} + +#[test] +fn mixed_page_advances_on_verified_oldest_ignoring_older_unverifiable_event() { + // An attacker-controlled relay page can carry a forged event with + // `created_at = 0` alongside genuinely newer valid teams. Drive the exact + // production trust gate: the raw page `[valid@9, valid@4, forged@0]` goes + // through `verify_page` (the same helper `fetch_team_catalog` calls), which + // drops the tampered event before it can reach paging. The cursor then + // advances on the oldest *verified* timestamp (4), never the forged wire + // timestamp (0) — advancing to 0 would skip every valid team between the + // verified floor and zero. Constructing the forged event here rather than + // stubbing `verify_page`'s output means a desync of the raw-page + // verification/cursor plumbing would fail this test. + let keys = Keys::generate(); + let newest = event(&keys, 9, "newest", true, valid_content("Newest")); + let oldest = event(&keys, 4, "oldest", true, valid_content("Oldest")); + // Sign at 0, then tamper the content so the signature no longer matches. + let mut forged = event(&keys, 0, "forged", true, valid_content("Forged")); + forged.content = valid_content("Tampered").to_string(); + + let verified = verify_page(vec![newest.clone(), oldest.clone(), forged]); + // The forged event is gone; only the two genuinely signed events survive. + assert_eq!(verified.len(), 2); + + let mut by_id = HashMap::new(); + assert_eq!( + merge_verified_page(&mut by_id, CATALOG_PAGE_SIZE, None, verified), + PageProgress::Next(4) + ); +} + +#[test] +fn full_page_of_unverifiable_events_errors_rather_than_advancing() { + // A full wire page whose events all fail verification leaves the verified + // set empty. The cursor can only move on trusted timestamps, so this must + // report NoVerifiedEvents (a loud error at the call site), never Done or + // Next — advancing on the untrusted wire would let a forged `created_at` + // silently drop every valid team below it. Drive the real `verify_page` + // seam: a tampered event at `created_at = 0` is dropped, leaving nothing to + // page with even though the wire page was full. + let keys = Keys::generate(); + let mut forged = event(&keys, 0, "forged", true, valid_content("Forged")); + forged.content = valid_content("Tampered").to_string(); + + let verified = verify_page(vec![forged]); + assert!(verified.is_empty()); + + let mut by_id = HashMap::new(); + assert_eq!( + merge_verified_page(&mut by_id, CATALOG_PAGE_SIZE, Some(9), verified), + PageProgress::NoVerifiedEvents + ); +} + +#[test] +fn forged_newest_head_is_dropped_before_it_can_claim_the_coordinate() { + let keys = Keys::generate(); + let older = event(&keys, 1, "crew", true, valid_content("Older")); + let mut forged = event(&keys, 2, "crew", true, valid_content("Forged")); + forged.content = valid_content("Tampered").to_string(); + + let verified = [older.clone(), forged] + .into_iter() + .filter(|candidate| candidate.verify().is_ok()) + .collect(); + let publications = publications_from_verified_events(verified); + assert_eq!(publications.len(), 1); + assert_eq!(publications[0].event_id, older.id.to_hex()); + assert_eq!(publications[0].name, "Older"); +} + +#[test] +fn valid_newest_head_claims_before_visibility_and_content_parsing() { + let keys = Keys::generate(); + for newest in [ + event(&keys, 2, "crew", false, valid_content("Unshared")), + event(&keys, 2, "crew", true, json!({"v": 1})), + ] { + let older = event(&keys, 1, "crew", true, valid_content("Older")); + assert!(publications_from_verified_events(vec![older, newest]).is_empty()); + } +} + +#[test] +fn equal_heads_use_lowest_event_id_and_authors_are_independent() { + let alice = Keys::generate(); + let bob = Keys::generate(); + let shared = event(&alice, 1, "crew", true, valid_content("Shared")); + let unshared = event(&alice, 1, "crew", false, valid_content("Hidden")); + let bob_head = event(&bob, 1, "crew", true, valid_content("Bob")); + let expected_alice = if shared.id < unshared.id { 1 } else { 0 }; + + let publications = publications_from_verified_events(vec![shared, unshared, bob_head]); + assert_eq!(publications.len(), expected_alice + 1); +} + +#[test] +fn all_or_nothing_parse_drops_a_team_with_any_invalid_member() { + let keys = Keys::generate(); + // parallelism 999 is out of the 1..=32 range validate_member enforces, so + // the whole projection fails to parse and the team is not offered. + let mut invalid = valid_content("Broken"); + invalid["members"][0]["parallelism"] = json!(999); + let head = event(&keys, 1, "crew", true, invalid); + assert!(publications_from_verified_events(vec![head]).is_empty()); +} + +#[test] +fn projection_flattens_members_and_defaults_absent_system_prompt() { + let keys = Keys::generate(); + let mut content = valid_content("Crew"); + // A member whose system_prompt is absent must project as an empty string, + // not be dropped — mirrors the renderer's `?? ""`. + content["members"][0] + .as_object_mut() + .unwrap() + .remove("system_prompt"); + let head = event(&keys, 1, "crew", true, content); + + let publications = publications_from_verified_events(vec![head]); + assert_eq!(publications.len(), 1); + let member = &publications[0].members[0]; + assert_eq!(member.display_name, "Reviewer"); + assert_eq!(member.system_prompt, ""); + assert_eq!(member.model.as_deref(), Some("claude")); +} + +#[test] +fn multi_d_and_empty_d_heads_are_rejected() { + let keys = Keys::generate(); + let empty_d = event(&keys, 1, "", true, valid_content("Empty")); + assert!(publications_from_verified_events(vec![empty_d]).is_empty()); + + let multi_d = EventBuilder::new( + Kind::Custom(KIND_TEAM_CATALOG as u16), + valid_content("Multi").to_string(), + ) + .tags([ + Tag::parse(["d", "crew"]).unwrap(), + Tag::parse(["d", "other"]).unwrap(), + Tag::parse(["shared", "true"]).unwrap(), + ]) + .custom_created_at(Timestamp::from(1)) + .sign_with_keys(&keys) + .unwrap(); + assert!(publications_from_verified_events(vec![multi_d]).is_empty()); +} + +/// Pins the serialized DTO output against the renderer's catalog contract. +/// The Tauri generic is only a TypeScript assertion; serde's bytes are the +/// actual boundary, so compare the value with an absent optional field. +#[test] +fn serialized_catalog_matches_the_typescript_contract() { + let publication = TeamCatalogPublication { + event_id: "ev1".into(), + owner_pubkey: "owner".into(), + team_d_tag: "team-1".into(), + name: "Crew".into(), + description: Some("A crew.".into()), + instructions: None, + members: vec![TeamCatalogMemberProjection { + member_key: "k1".into(), + display_name: "Ada".into(), + system_prompt: "be kind".into(), + avatar_url: Some("https://example.com/a.png".into()), + runtime: Some("acp".into()), + model: None, + provider: Some("p1".into()), + }], + }; + let actual = serde_json::to_value(vec![publication]).unwrap(); + let expected = serde_json::json!([{ + "eventId": "ev1", + "ownerPubkey": "owner", + "teamDTag": "team-1", + "name": "Crew", + "description": "A crew.", + "members": [{ + "memberKey": "k1", + "displayName": "Ada", + "systemPrompt": "be kind", + "avatarUrl": "https://example.com/a.png", + "runtime": "acp", + "model": null, + "provider": "p1", + }], + }]); + assert_eq!(actual, expected); +} diff --git a/desktop/src/features/agents/lib/teamCatalogRelay.test.mjs b/desktop/src/features/agents/lib/teamCatalogRelay.test.mjs new file mode 100644 index 00000000000..7c5bd9771af --- /dev/null +++ b/desktop/src/features/agents/lib/teamCatalogRelay.test.mjs @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { catalogTeamsFromPublications } from "./teamCatalogRelay.ts"; + +const ALICE = "a".repeat(64); +const BOB = "b".repeat(64); + +// Relay paging, signature verification, head selection, and content parsing +// now live in `team_catalog.rs` and are covered by `team_catalog_tests.rs`. +// This suite exercises only the renderer's remaining job: linking a verified +// publication to a local team and deciding ownership and sort order. + +function publication(overrides = {}) { + return { + eventId: "event-1", + ownerPubkey: ALICE, + teamDTag: "squad", + name: "Review Squad", + description: null, + instructions: null, + members: [ + { + memberKey: "reviewer", + displayName: "Relay Reviewer", + systemPrompt: "Review changes.", + avatarUrl: null, + runtime: "goose", + model: "claude", + provider: null, + }, + ], + ...overrides, + }; +} + +function localTeam(overrides = {}) { + return { + id: "local-1", + name: "Review Squad", + description: null, + instructions: null, + personaIds: [], + isBuiltin: false, + shared: false, + catalogSource: null, + sourceDir: null, + isSymlink: false, + symlinkTarget: null, + version: null, + createdAt: "2026-07-30T00:00:00.000Z", + updatedAt: "2026-07-30T00:00:00.000Z", + ...overrides, + }; +} + +test("test_own_publication_resolves_to_the_local_team_by_id", () => { + const own = localTeam({ id: "squad", shared: true }); + + const teams = catalogTeamsFromPublications([publication()], [own], ALICE); + + assert.equal(teams[0].isOwn, true); + assert.equal(teams[0].localTeam.id, "squad"); +}); + +// The duplicate-add bug: a copy carries a fresh local id, so only the stored +// coordinate links it back to the publication it came from. +test("test_added_foreign_entry_resolves_to_its_local_copy", () => { + const copy = localTeam({ + id: "a-fresh-uuid", + catalogSource: { ownerPubkey: ALICE, teamDTag: "squad" }, + }); + + const teams = catalogTeamsFromPublications([publication()], [copy], BOB); + + assert.equal(teams[0].isOwn, false); + assert.equal(teams[0].localTeam.id, "a-fresh-uuid"); +}); + +test("test_foreign_entry_with_no_local_copy_has_no_local_team", () => { + // A same-named local team with no provenance is a different team. + const unrelated = localTeam({ id: "unrelated" }); + + const teams = catalogTeamsFromPublications([publication()], [unrelated], BOB); + + assert.equal(teams[0].localTeam, null); +}); + +// Provenance is per-owner: the same d-tag under a different publisher is a +// different team, so a copy of Alice's must not mask Bob's entry. +test("test_catalog_source_match_is_scoped_to_the_publishing_owner", () => { + const copyOfAlices = localTeam({ + id: "copy-of-alices", + catalogSource: { ownerPubkey: ALICE, teamDTag: "squad" }, + }); + + const teams = catalogTeamsFromPublications( + [publication({ ownerPubkey: BOB, teamDTag: "bob-team" })], + [copyOfAlices], + ALICE, + ); + + assert.equal(teams[0].localTeam, null); +}); + +// An own team's `d`-tag is its local id, so an id match under another +// publisher's coordinate must not read as already-added. +test("test_local_id_match_under_a_foreign_owner_is_not_a_local_copy", () => { + const sameId = localTeam({ id: "squad" }); + + const teams = catalogTeamsFromPublications( + [publication({ ownerPubkey: BOB, teamDTag: "squad" })], + [sameId], + ALICE, + ); + + assert.equal(teams[0].isOwn, false); + assert.equal(teams[0].localTeam, null); +}); + +test("test_identity_pubkey_case_does_not_change_ownership", () => { + const teams = catalogTeamsFromPublications( + [publication()], + [], + ALICE.toUpperCase(), + ); + + assert.equal(teams[0].isOwn, true); +}); + +test("test_catalog_entries_are_sorted_by_name", () => { + const teams = catalogTeamsFromPublications( + [ + publication({ teamDTag: "zed", name: "Zed Squad" }), + publication({ teamDTag: "ace", name: "Ace Squad" }), + ], + [], + BOB, + ); + + assert.deepEqual( + teams.map((team) => team.name), + ["Ace Squad", "Zed Squad"], + ); +}); diff --git a/desktop/src/features/agents/lib/teamCatalogRelay.ts b/desktop/src/features/agents/lib/teamCatalogRelay.ts new file mode 100644 index 00000000000..51f0cffa199 --- /dev/null +++ b/desktop/src/features/agents/lib/teamCatalogRelay.ts @@ -0,0 +1,112 @@ +import { invokeTauri } from "@/shared/api/tauri"; +import type { AgentTeam } from "@/shared/api/types"; + +/** + * Presentation and local-linkage for the kind:30178 team catalog. + * + * Relay paging, signature verification, NIP-33 head selection, and untrusted + * content parsing live natively in `team_catalog.rs`; this module only shapes + * the verified projection for display and decides whether "Add" is offered. + * + * The projection is self-contained by design: every member's safe definition + * is embedded, so a published team renders without resolving anything in the + * publisher's namespace. `memberKey` is an opaque label here and never a + * kind:30175 coordinate — the publisher may never have shared that member + * individually. + * + * Adding is NOT done from this data. The frontend passes only the coordinate to + * `add_team_from_catalog`, which re-fetches and re-verifies the head backend + * side; what is shaped here is for display only. + */ + +/** + * Whether the current identity may share this team to the catalog, and at what + * level. `"none"` means shared with no memories attached; the team dialog + * renders it through `SnapshotOptionMenu`. Mirrors `CatalogPersonaShareLevel`. + */ +export type CatalogTeamShareLevel = "not-shared" | "none"; + +export type CatalogTeamMember = { + memberKey: string; + displayName: string; + systemPrompt: string; + avatarUrl: string | null; + runtime: string | null; + model: string | null; + provider: string | null; +}; + +export type TeamCatalogPublication = { + /** The head event this projection was built from. Passed to the backend so + * it can reject an add whose head moved since the dialog opened. */ + eventId: string; + ownerPubkey: string; + teamDTag: string; + name: string; + description: string | null; + instructions: string | null; + members: CatalogTeamMember[]; +}; + +export type CatalogTeam = TeamCatalogPublication & { + isOwn: boolean; + /** The local team already copied from this publication, if any. */ + localTeam: AgentTeam | null; +}; + +/** + * Fetch the active community's team catalog through the shared native relay + * session. Relay scoping, paging, signature verification, and head selection + * are native; this boundary intentionally accepts no caller-supplied relay or + * identity. + */ +export function fetchTeamCatalogPublications(): Promise< + TeamCatalogPublication[] +> { + return invokeTauri("fetch_team_catalog"); +} + +/** + * The local team backing a catalog entry, if the user already has it. + * + * An own publication is found by id — its `d`-tag *is* the local team id. A + * copy of another owner's entry carries a fresh local id instead, so the only + * link back is the `catalogSource` coordinate stored on the copy. Matching on + * that coordinate is what stops the catalog from offering "Add" for an entry + * the user already added, which would mint a second copy. + */ +export function findLocalTeamForCatalogEntry( + localTeams: readonly AgentTeam[], + publication: TeamCatalogPublication, + isOwn: boolean, +): AgentTeam | null { + if (isOwn) { + return localTeams.find((team) => team.id === publication.teamDTag) ?? null; + } + return ( + localTeams.find( + (team) => + team.catalogSource?.ownerPubkey === publication.ownerPubkey && + team.catalogSource?.teamDTag === publication.teamDTag, + ) ?? null + ); +} + +export function catalogTeamsFromPublications( + publications: readonly TeamCatalogPublication[], + localTeams: readonly AgentTeam[], + currentPubkey: string | null | undefined, +): CatalogTeam[] { + const normalizedCurrentPubkey = currentPubkey?.toLowerCase() ?? null; + + return publications + .map((publication) => { + const isOwn = publication.ownerPubkey === normalizedCurrentPubkey; + return { + ...publication, + isOwn, + localTeam: findLocalTeamForCatalogEntry(localTeams, publication, isOwn), + }; + }) + .sort((left, right) => left.name.localeCompare(right.name)); +} diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts index b086f12a9c4..2400e882813 100644 --- a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts +++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts @@ -1,6 +1,7 @@ import { listen } from "@tauri-apps/api/event"; import { useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; +import { toast } from "sonner"; import { managedAgentsQueryKey, @@ -8,6 +9,7 @@ import { teamsQueryKey, } from "@/features/agents/hooks"; import { managedAgentRuntimesQueryKey } from "@/features/agents/managedAgentRuntimeHooks"; +import { teamAutoRetractedNotice } from "@/features/agents/ui/teamLibraryCopy"; export const LOCAL_AGENT_DATA_QUERY_KEYS = [ personasQueryKey, @@ -44,10 +46,26 @@ export function useAgentsDataRefresh(): void { }, COALESCE_MS); }); + // Typed notice for automatic team catalog retractions (I4): the boot + // reconcile detected a shared team that can no longer be projected and + // tombstoned it. Show a toast so the owner is not left wondering why + // their share toggle changed. + const unlistenRetracted = listen<{ + teamName: string; + reason: string; + }>("team-catalog-auto-retracted", (event) => { + toast.warning( + teamAutoRetractedNotice(event.payload.teamName, event.payload.reason), + ); + // Invalidate team queries so the share toggle reflects the retraction. + void queryClient.invalidateQueries({ queryKey: teamsQueryKey }); + }); + return () => { if (timer !== undefined) clearTimeout(timer); void unlisten.then((fn) => fn()); void unlistenRuntime.then((fn) => fn()); + void unlistenRetracted.then((fn) => fn()); }; }, [queryClient]); } diff --git a/desktop/src/features/agents/lib/usePersonaSync.test.mjs b/desktop/src/features/agents/lib/usePersonaSync.test.mjs index cfc0d901c3a..62be9996487 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.test.mjs +++ b/desktop/src/features/agents/lib/usePersonaSync.test.mjs @@ -7,9 +7,12 @@ import { KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM, + KIND_TEAM_CATALOG, } from "@/shared/constants/kinds"; import { coalesceManagedAgentBackfill, + orderCatalogHeadsLast, + PersonaHistoryDenseBoundaryError, startPersonaSync, } from "./usePersonaSync.ts"; @@ -17,6 +20,7 @@ const EXPECTED_KINDS = [ KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_TEAM_CATALOG, KIND_DELETION, ]; @@ -67,6 +71,42 @@ test("startup backfill keeps only the newest managed-agent head per coordinate", ); }); +// Regression guard for the fresh-device backfill ordering defect (Carl r10 P1, +// finding 2): the relay serves history newest-first, so a freshly shared 30178 +// head arrives before the 30176 team and 30175 personas it projects. Dispatched +// in that order, the inbound team refresh runs before device B's personas +// hydrate — member resolution fails and the owner's valid shared head is purged +// plus falsely tombstoned. `orderCatalogHeadsLast` MUST defer every catalog head +// past its constituents while preserving relay order within each group. +test("orderCatalogHeadsLast defers catalog heads past all constituents", () => { + const catalog = event({ id: "cat", kind: KIND_TEAM_CATALOG, createdAt: 30 }); + const team = event({ id: "team", kind: KIND_TEAM, createdAt: 20 }); + const persona = event({ id: "p1", kind: KIND_PERSONA, createdAt: 10 }); + const deletion = event({ id: "del", kind: KIND_DELETION, createdAt: 5 }); + + assert.deepEqual( + // Relay newest-first order: catalog head lands before its constituents. + orderCatalogHeadsLast([catalog, team, persona, deletion]).map( + ({ id }) => id, + ), + ["team", "p1", "del", "cat"], + "constituents dispatch first; the catalog head is deferred to the end", + ); +}); + +test("orderCatalogHeadsLast preserves relay order within each group", () => { + const catA = event({ id: "cat-a", kind: KIND_TEAM_CATALOG, createdAt: 40 }); + const catB = event({ id: "cat-b", kind: KIND_TEAM_CATALOG, createdAt: 30 }); + const teamA = event({ id: "team-a", kind: KIND_TEAM, createdAt: 20 }); + const teamB = event({ id: "team-b", kind: KIND_TEAM, createdAt: 10 }); + + assert.deepEqual( + orderCatalogHeadsLast([catA, teamA, catB, teamB]).map(({ id }) => id), + ["team-a", "team-b", "cat-a", "cat-b"], + "a stable partition keeps newest-wins order inside constituents and heads", + ); +}); + // Regression guard for the fresh-start backfill gap (F3): a device that comes // online AFTER another published gets zero history from a live-only `limit: 0` // subscription, because reconnect-replay's since-cursor is undefined until the @@ -87,7 +127,7 @@ test("startPersonaSync backfills history including the deletion kind", () => { startPersonaSync("owner-pubkey", "wss://relay.example", () => false); - assert.equal(fetchCalls.length, 1, "must do exactly one backfill fetch"); + assert.equal(fetchCalls.length, 1, "empty first page exhausts in one fetch"); assert.deepEqual( fetchCalls[0].kinds, EXPECTED_KINDS, @@ -98,6 +138,7 @@ test("startPersonaSync backfills history including the deletion kind", () => { "backfill must request a positive limit — limit:0 returns no history", ); assert.deepEqual(fetchCalls[0].authors, ["owner-pubkey"]); + assert.equal(fetchCalls[0].until, undefined, "first page carries no cursor"); assert.equal(liveCalls.length, 1); assert.deepEqual( @@ -109,6 +150,151 @@ test("startPersonaSync backfills history including the deletion kind", () => { mock.reset(); }); +// Regression guard for Thufir r10 P2 finding 2 (hydration boundary). The history +// fetch and the live subscription start concurrently into ONE reconcile chain. A +// live/replayed 30178 catalog head that arrives BEFORE the backfill has +// reconciled its 30175/30176 constituents drives the inbound team refresh against +// an unhydrated persona store — member resolution fails and the owner's valid +// witness is purged plus falsely tombstoned. `startPersonaSync` MUST buffer live +// events until the ordered backfill is dispatched, then drain them. Removing the +// buffer dispatches the live head first and turns this RED. +test("startPersonaSync buffers live catalog heads until the backfill hydrates constituents", async () => { + const invokedIds = []; + globalThis.window = { + __TAURI_INTERNALS__: { + invoke: (_cmd, args) => { + invokedIds.push(JSON.parse(args.eventJson).id); + return Promise.resolve(); + }, + }, + }; + + let resolveBackfill; + const backfill = new Promise((resolve) => { + resolveBackfill = resolve; + }); + mock.method(relayClient, "fetchEvents", () => backfill); + let onEvent; + mock.method(relayClient, "subscribeLive", (_filter, listener) => { + onEvent = listener; + return Promise.resolve(() => Promise.resolve()); + }); + + startPersonaSync("owner-pubkey", "wss://relay.example", () => false); + await new Promise((resolve) => setImmediate(resolve)); + + // A freshly shared catalog head arrives live before the history resolves. + onEvent(event({ id: "cat-live", kind: KIND_TEAM_CATALOG, createdAt: 100 })); + // The delayed backfill returns the constituents (relay newest-first). + resolveBackfill([ + event({ id: "team", kind: KIND_TEAM, createdAt: 90 }), + event({ id: "persona", kind: KIND_PERSONA, createdAt: 80 }), + ]); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual( + invokedIds, + ["team", "persona", "cat-live"], + "constituents hydrate first; the buffered live catalog head drains last", + ); + + mock.reset(); + delete globalThis.window; +}); + +// Regression guard for Thufir r10 P2 finding 3 (capped page). The relay clamps a +// REQ to `max_limit` and serves newest-first, so a large owner's full history +// exceeds one 500-event page: a newer 30178/30176 can land in-page while an older +// required 30175 constituent falls beyond it. `startPersonaSync` MUST page to +// exhaustion via the `until` cursor so every constituent hydrates before the +// catalog head. Removing pagination leaves the required persona unfetched. +test("startPersonaSync pages history to exhaustion so an out-of-page constituent hydrates before the catalog head", async () => { + const invokedIds = []; + globalThis.window = { + __TAURI_INTERNALS__: { + invoke: (_cmd, args) => { + invokedIds.push(JSON.parse(args.eventJson).id); + return Promise.resolve(); + }, + }, + }; + + // Page 1 (newest-first): the catalog head, the team, and 498 filler personas — + // a full page whose oldest event is created_at 501. + const page1 = [ + event({ id: "cat", kind: KIND_TEAM_CATALOG, createdAt: 1000 }), + event({ id: "team", kind: KIND_TEAM, createdAt: 999 }), + ]; + for (let i = 0; i < 498; i += 1) { + page1.push( + event({ + id: `filler-${i}`, + kind: KIND_PERSONA, + createdAt: 998 - i, + dTag: `filler-${i}`, + }), + ); + } + // Page 2: the boundary event re-returned by the inclusive `until`, plus the + // required older persona that fell outside page 1. + const boundary = page1[page1.length - 1]; + const page2 = [ + boundary, + event({ + id: "req-persona", + kind: KIND_PERSONA, + createdAt: 100, + dTag: "req-persona", + }), + ]; + + const fetchCalls = []; + mock.method(relayClient, "fetchEvents", (filter) => { + fetchCalls.push(filter); + return Promise.resolve(filter.until === undefined ? page1 : page2); + }); + mock.method(relayClient, "subscribeLive", () => + Promise.resolve(() => Promise.resolve()), + ); + + startPersonaSync("owner-pubkey", "wss://relay.example", () => false); + for (let i = 0; i < 5; i += 1) + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal( + fetchCalls.length, + 2, + "a full first page triggers a second page", + ); + assert.equal( + fetchCalls[1].until, + 501, + "the second page is cursored on the oldest event", + ); + assert.ok( + invokedIds.includes("req-persona"), + "the out-of-page constituent must be fetched and reconciled", + ); + assert.ok( + invokedIds.indexOf("req-persona") < invokedIds.indexOf("cat"), + "the required persona hydrates before the catalog head", + ); + assert.equal( + invokedIds[invokedIds.length - 1], + "cat", + "the catalog head reconciles last, after every constituent", + ); + assert.equal( + invokedIds.filter((id) => id === boundary.id).length, + 1, + "the inclusive-cursor boundary event is deduped, not reconciled twice", + ); + + mock.reset(); + delete globalThis.window; +}); + // 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 @@ -159,6 +345,353 @@ test("startPersonaSync forwards its own relay as the event arrival relay", async delete globalThis.window; }); +// Regression guard for Will r10 P3 finding 1 (dense-boundary pagination). The WS +// filter exposes only a time-only `until` cursor, so when MORE than one page of +// events shares the oldest boundary second the cursor cannot advance: page 2 +// returns the same slice, and older events (a required 30175) are unreachable. +// `fetchOwnerHistoryToExhaustion` MUST throw `PersonaHistoryDenseBoundaryError` +// rather than treat the unadvanceable page as exhaustion. The pipeline then +// enters degraded-live and DROPS catalog heads (their constituents never +// hydrated). Reverting to time-only `added === 0` termination silently completes +// backfill as if exhaustive: the live catalog head is reconciled (the purge +// path) instead of dropped, turning this RED. +test("startPersonaSync fails loudly on a dense boundary and degrades to catalog-dropping live sync", async () => { + const invokedIds = []; + globalThis.window = { + __TAURI_INTERNALS__: { + invoke: (_cmd, args) => { + invokedIds.push(JSON.parse(args.eventJson).id); + return Promise.resolve(); + }, + }, + }; + + // 500 events all sharing created_at 100 — a full page whose oldest cannot + // advance the cursor. The required older persona at 50 is unreachable behind + // the dense second. The relay re-returns the same slice for `until: 100`. + const densePage = []; + for (let i = 0; i < 500; i += 1) + densePage.push( + event({ + id: `dense-${i}`, + kind: KIND_PERSONA, + createdAt: 100, + dTag: `d-${i}`, + }), + ); + + const fetchCalls = []; + mock.method(relayClient, "fetchEvents", (filter) => { + fetchCalls.push(filter); + return Promise.resolve(densePage); + }); + let onEvent; + mock.method(relayClient, "subscribeLive", (_filter, listener) => { + onEvent = listener; + return Promise.resolve(() => Promise.resolve()); + }); + + startPersonaSync("owner-pubkey", "wss://relay.example", () => false); + for (let i = 0; i < 5; i += 1) + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal( + fetchCalls.length, + 2, + "a full first page pages once more, then the dense second aborts fetching", + ); + assert.equal( + fetchCalls[1].until, + 100, + "the second page is cursored on the dense second", + ); + + // Degraded-live: the whole catalog dependency set is dropped (30178 head and + // its 30175/30176 constituents), but a 30177 runtime-policy event still + // reconciles — the subscription is not inert. + onEvent(event({ id: "live-cat", kind: KIND_TEAM_CATALOG, createdAt: 200 })); + onEvent( + event({ id: "live-team", kind: KIND_TEAM, createdAt: 201, dTag: "t1" }), + ); + onEvent( + event({ + id: "live-agent", + kind: KIND_MANAGED_AGENT, + createdAt: 202, + dTag: "a1", + }), + ); + for (let i = 0; i < 3; i += 1) + await new Promise((resolve) => setImmediate(resolve)); + + assert.ok( + !invokedIds.includes("live-cat"), + "the live catalog head is dropped in degraded mode, not reconciled", + ); + assert.ok( + !invokedIds.includes("live-team"), + "a live team edit is dropped in degraded mode — it could drive a refresh against an unhydrated store", + ); + assert.ok( + invokedIds.includes("live-agent"), + "a live 30177 runtime-policy event still reconciles — degraded sync is not inert", + ); + + mock.reset(); + delete globalThis.window; +}); + +// Regression guard for Thufir r10-delta finding (degraded-live false unshare). +// A witness-holding device that drops back to degraded-live still receives live +// team/persona edits. Live delivery is newest-first, so a 30176 team edit that +// ADDS a new persona reaches the backend BEFORE that persona's 30175. The +// backend's KIND_TEAM arm unconditionally refreshes the catalog head after a +// save; against the retained witness the new member cannot resolve, so it purges +// the valid witness and queues a false tombstone — the OLD constituents on disk +// don't cover a NEW member. Degraded mode MUST drop the whole catalog dependency +// set (30175/30176 + kind-5 deletions targeting them), not just 30178, so the +// backend never sees the un-hydrated edit. Narrowing the gate back to 30178-only +// dispatches the 30176 to the backend and turns this RED. +test("degraded-live drops a team edit adding a new persona so a witness is not falsely tombstoned", async () => { + const invokedIds = []; + globalThis.window = { + __TAURI_INTERNALS__: { + invoke: (_cmd, args) => { + invokedIds.push(JSON.parse(args.eventJson).id); + return Promise.resolve(); + }, + }, + }; + + // Dense history forces degraded-live on a device that already holds a witness. + const densePage = []; + for (let i = 0; i < 500; i += 1) + densePage.push( + event({ + id: `dense-${i}`, + kind: KIND_PERSONA, + createdAt: 100, + dTag: `d-${i}`, + }), + ); + mock.method(relayClient, "fetchEvents", () => Promise.resolve(densePage)); + let onEvent; + mock.method(relayClient, "subscribeLive", (_filter, listener) => { + onEvent = listener; + return Promise.resolve(() => Promise.resolve()); + }); + + startPersonaSync("owner-pubkey", "wss://relay.example", () => false); + for (let i = 0; i < 5; i += 1) + await new Promise((resolve) => setImmediate(resolve)); + + // Newest-first: the team edit adding P2 arrives before P2's own persona event. + onEvent( + event({ id: "team-adds-p2", kind: KIND_TEAM, createdAt: 201, dTag: "t1" }), + ); + onEvent( + event({ + id: "new-persona-p2", + kind: KIND_PERSONA, + createdAt: 200, + dTag: "p2", + }), + ); + for (let i = 0; i < 3; i += 1) + await new Promise((resolve) => setImmediate(resolve)); + + assert.ok( + !invokedIds.includes("team-adds-p2"), + "the team edit is dropped — the backend never refreshes against an unresolvable new member, so the witness survives", + ); + assert.ok( + !invokedIds.includes("new-persona-p2"), + "the new persona is dropped too — a lone 30175 cannot complete the dependency set in degraded mode", + ); + + mock.reset(); + delete globalThis.window; +}); + +// Regression guard for Thufir r11 finding (degraded gate vs Rust router). A +// kind-5 deletion is classified by scanning ALL `a` tags, because Rust's +// `parse_deletion_coordinate` find_maps across every tag and routes the first +// signer-owned dependency coordinate. A valid kind-5 can carry a malformed or +// foreign first `a` tag ahead of an owned 30176 coordinate: reading only the +// first tag returns null and dispatches it, but Rust skips the bad first tag, +// routes the owned 30176, deletes the team, and fires the destructive catalog +// refresh this gate exists to suppress. Degraded mode MUST hold the deletion +// whenever ANY parseable `a` tag names a dependency kind. Narrowing the +// classifier back to the first `a` tag dispatches this deletion and turns RED. +test("degraded-live drops a kind-5 whose owned dependency `a` tag is not first", async () => { + const invokedIds = []; + globalThis.window = { + __TAURI_INTERNALS__: { + invoke: (_cmd, args) => { + invokedIds.push(JSON.parse(args.eventJson).id); + return Promise.resolve(); + }, + }, + }; + + const densePage = []; + for (let i = 0; i < 500; i += 1) + densePage.push( + event({ + id: `dense-${i}`, + kind: KIND_PERSONA, + createdAt: 100, + dTag: `d-${i}`, + }), + ); + mock.method(relayClient, "fetchEvents", () => Promise.resolve(densePage)); + let onEvent; + mock.method(relayClient, "subscribeLive", (_filter, listener) => { + onEvent = listener; + return Promise.resolve(() => Promise.resolve()); + }); + + startPersonaSync("owner-pubkey", "wss://relay.example", () => false); + for (let i = 0; i < 5; i += 1) + await new Promise((resolve) => setImmediate(resolve)); + + // Malformed first `a` tag, then an owned 30176 team coordinate — exactly what + // Rust routes past the bad first tag into a destructive team deletion. + const deletion = event({ + id: "del-team-second-tag", + kind: KIND_DELETION, + createdAt: 201, + dTag: null, + }); + deletion.tags = [ + ["a", "not-a-coordinate"], + ["a", `${KIND_TEAM}:owner-pubkey:t1`], + ]; + onEvent(deletion); + for (let i = 0; i < 3; i += 1) + await new Promise((resolve) => setImmediate(resolve)); + + assert.ok( + !invokedIds.includes("del-team-second-tag"), + "the deletion is dropped — Rust would route its owned 30176 tag into a destructive refresh, so degraded mode must hold it", + ); + + mock.reset(); + delete globalThis.window; +}); + +// Regression guard for Will r10 P3 finding 2 (backfill rejection stranding live +// sync). A transient history-fetch rejection must not leave the subscription +// permanently unhydrated with `liveBuffer` accumulating forever. The backfill +// MUST retry with bounded backoff; on success the buffer drains and live events +// reconcile. Restoring a log-only `.catch` (no retry, `hydrated` never set) +// leaves the buffered live event unreconciled, turning this RED. +test("startPersonaSync retries a transient backfill rejection so a buffered live event still reconciles", async () => { + mock.timers.enable({ apis: ["setTimeout"] }); + const invokedIds = []; + globalThis.window = { + __TAURI_INTERNALS__: { + invoke: (_cmd, args) => { + invokedIds.push(JSON.parse(args.eventJson).id); + return Promise.resolve(); + }, + }, + }; + + let attempts = 0; + mock.method(relayClient, "fetchEvents", () => { + attempts += 1; + // Fail the first two attempts transiently, then succeed with empty history. + return attempts < 3 + ? Promise.reject(new Error("relay unreachable")) + : Promise.resolve([]); + }); + let onEvent; + mock.method(relayClient, "subscribeLive", (_filter, listener) => { + onEvent = listener; + return Promise.resolve(() => Promise.resolve()); + }); + + startPersonaSync("owner-pubkey", "wss://relay.example", () => false); + await new Promise((resolve) => setImmediate(resolve)); + + // A live event arrives while backfill is still failing — it must buffer, not + // be lost. + onEvent( + event({ + id: "live-persona", + kind: KIND_PERSONA, + createdAt: 300, + dTag: "p1", + }), + ); + + // Drive the bounded backoff timers (500ms, then 1000ms) to the retry that + // succeeds, flushing the promise chain between ticks. + for (let i = 0; i < 6; i += 1) { + mock.timers.tick(2_000); + await new Promise((resolve) => setImmediate(resolve)); + } + + assert.equal(attempts, 3, "backfill retried until it succeeded"); + assert.ok( + invokedIds.includes("live-persona"), + "the buffered live event reconciles after the retry hydrates — not stranded", + ); + + mock.reset(); + mock.timers.reset(); + delete globalThis.window; +}); + +// `PersonaHistoryDenseBoundaryError` is deterministic (a dense second cannot +// clear on retry), so the pipeline must NOT retry it — it goes straight to +// degraded-live. Guards against a future refactor that lumps it in with +// transient rejections and burns three fetch attempts on an unrecoverable state. +test("startPersonaSync does not retry a dense-boundary error", async () => { + globalThis.window = { + __TAURI_INTERNALS__: { invoke: () => Promise.resolve() }, + }; + const densePage = []; + for (let i = 0; i < 500; i += 1) + densePage.push( + event({ + id: `d-${i}`, + kind: KIND_PERSONA, + createdAt: 100, + dTag: `x-${i}`, + }), + ); + const fetchCalls = []; + mock.method(relayClient, "fetchEvents", (filter) => { + fetchCalls.push(filter); + return Promise.resolve(densePage); + }); + mock.method(relayClient, "subscribeLive", () => + Promise.resolve(() => Promise.resolve()), + ); + + startPersonaSync("owner-pubkey", "wss://relay.example", () => false); + for (let i = 0; i < 5; i += 1) + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal( + fetchCalls.length, + 2, + "page 1 (full) + page 2 (dense) then abort — no retry attempts", + ); + + mock.reset(); + delete globalThis.window; +}); + +test("PersonaHistoryDenseBoundaryError names the boundary second", () => { + const error = new PersonaHistoryDenseBoundaryError(42); + assert.ok(error instanceof Error); + assert.equal(error.name, "PersonaHistoryDenseBoundaryError"); + assert.match(error.message, /42/); +}); + test("startPersonaSync serializes inbound reconciliation in relay order", async () => { const resolvers = []; const invokedIds = []; diff --git a/desktop/src/features/agents/lib/usePersonaSync.ts b/desktop/src/features/agents/lib/usePersonaSync.ts index 57d33089a9b..e96f06348af 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.ts +++ b/desktop/src/features/agents/lib/usePersonaSync.ts @@ -8,18 +8,59 @@ import { KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM, + KIND_TEAM_CATALOG, } from "@/shared/constants/kinds"; -// Persona/team/managed-agent projections (upserts) plus kind:5 NIP-09 -// deletions, so a tombstone published by another device also removes the -// local record here. +// Persona/team/managed-agent projections (upserts), the owner's own team +// catalog heads (30178), plus kind:5 NIP-09 deletions, so a tombstone published +// by another device also removes the local record here. +// +// The 30178 head has no local record — it is retained only as this device's +// publication witness, so a second device's boot reconcile and interactive +// refresh have a row to supersede or retract. Without it, device B never learns +// device A published, and B's later edit or delete cannot update A's +// discoverable catalog entry. const PERSONA_SYNC_KINDS = [ KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_TEAM_CATALOG, KIND_DELETION, ]; +// One history page. The relay clamps a REQ `limit` to its advertised +// `max_limit` (1000; `crates/buzz-db` `DEFAULT_MAX_PAGE_LIMIT`), so a single +// query can never return an owner's complete history once it exceeds the page — +// `startPersonaSync` pages to exhaustion (see `fetchOwnerHistoryToExhaustion`). +const PERSONA_HISTORY_PAGE_LIMIT = 500; + +// Bounded retry for a transient backfill failure. A deterministic +// dense-boundary rejection is NOT retried (a retry cannot clear a genuinely +// dense second); only network/transport rejections are. After the last attempt +// the pipeline falls to degraded-live rather than looping forever. +const BACKFILL_MAX_ATTEMPTS = 3; +const BACKFILL_RETRY_BASE_DELAY_MS = 500; + +// Thrown when `fetchOwnerHistoryToExhaustion` reaches a full page whose oldest +// event cannot advance the time-only cursor: more than one page of events share +// the boundary second, and the WS filter has no `(created_at, id)` cursor to +// escape it. Distinct from a transport error so the caller fails loudly into +// degraded-live instead of silently proceeding with partial history. +export class PersonaHistoryDenseBoundaryError extends Error { + constructor(boundarySecond: number) { + super( + `owner history has >1 page of events at created_at ${boundarySecond}; ` + + `the time-only relay cursor cannot page past it`, + ); + this.name = "PersonaHistoryDenseBoundaryError"; + } +} + +async function backfillBackoff(attempt: number): Promise { + const ms = BACKFILL_RETRY_BASE_DELAY_MS * 2 ** attempt; + await new Promise((resolve) => setTimeout(resolve, ms)); +} + function eventDTag(event: RelayEvent): string | null { return event.tags.find((tag) => tag[0] === "d")?.[1] ?? null; } @@ -31,6 +72,38 @@ function eventIsNewer(candidate: RelayEvent, current: RelayEvent): boolean { ); } +// The catalog dependency set: the 30178 head and the 30175/30176 coordinates a +// team-catalog refresh resolves against. Degraded-live drops every live event +// that could drive (or destructively re-trigger) a catalog refresh against an +// unhydrated store — see `dispatchLive`. A kind-5 deletion is held whenever ANY +// parseable `a` tag names a dependency coordinate (`::`): +// Rust's `parse_deletion_coordinate` scans all `a` tags and routes the first +// signer-owned dependency target, so classifying on the first tag alone would +// dispatch a deletion that Rust still routes destructively (a foreign/malformed +// first tag ahead of an owned 30176). We do not duplicate Rust's signer/owner +// validation — false-positive holding during an abnormal self-healing state is +// safer than letting a Rust-routable destructive tombstone through. +const CATALOG_DEPENDENCY_KINDS: ReadonlySet = new Set([ + KIND_PERSONA, + KIND_TEAM, + KIND_TEAM_CATALOG, +]); + +function deletionTargetsDependency(event: RelayEvent): boolean { + return event.tags.some((tag) => { + if (tag[0] !== "a" || !tag[1]) return false; + const kind = Number.parseInt(tag[1].split(":", 1)[0], 10); + return !Number.isNaN(kind) && CATALOG_DEPENDENCY_KINDS.has(kind); + }); +} + +function isCatalogDependencyEvent(event: RelayEvent): boolean { + if (event.kind === KIND_DELETION) { + return deletionTargetsDependency(event); + } + return CATALOG_DEPENDENCY_KINDS.has(event.kind); +} + /** * Keep only the NIP-33 head for each managed-agent coordinate in a startup * backfill. Applying historical policy revisions one by one can stop and start @@ -62,8 +135,94 @@ export function coalesceManagedAgentBackfill( }); } +/** + * Dispatch owner catalog heads (30178) AFTER their constituents within the + * complete hydration batch. A freshly shared 30178 head is typically newer than + * the 30176 team and 30175 personas it projects, so relay newest-first order + * places it first. Reconciling in that raw order lets the inbound team refresh + * run while device B's personas have not hydrated: member resolution fails, and + * the resolution-failure arm purges the just-retained witness and queues a + * dominating false tombstone — deleting the owner's valid catalog entry on + * ordinary first sync. + * + * Deferring only the 30178 heads to the end of the batch preserves newest-wins + * within every other coordinate (order among non-catalog events is untouched) + * while guaranteeing the constituents are all applied before any catalog + * refresh could fire. A stable partition keeps relay order within each group. + * This is only sound over a COMPLETE batch — `startPersonaSync` pages history to + * exhaustion and buffers concurrent live events so every constituent is present + * before the partition runs. + */ +export function orderCatalogHeadsLast( + events: readonly RelayEvent[], +): RelayEvent[] { + const constituents = events.filter( + (event) => event.kind !== KIND_TEAM_CATALOG, + ); + const catalogHeads = events.filter( + (event) => event.kind === KIND_TEAM_CATALOG, + ); + return [...constituents, ...catalogHeads]; +} + +// Fetch the owner's complete persona/team/agent/deletion history, paging past +// the relay's per-query `max_limit` clamp. The relay serves each REQ newest-first +// (`created_at DESC, id ASC`) and clamps `limit` to its advertised ceiling, so a +// single 500-event query cannot return a large owner's full history: a newer +// 30178/30176 could land in-page while an older required 30175 constituent falls +// outside it, and the ordered-last partition can only reorder what came back. +// Paging with the `until` time cursor (the only cursor the WS REQ filter exposes — +// the DB `before_id` keyset is REST-only) walks the full window to exhaustion. +// +// `until` is inclusive (`created_at <= until`), so each page re-returns the rows +// at the boundary second; `seen` dedupes them. A page shorter than the limit means +// the window is exhausted. +// +// A FULL page whose oldest event does not advance the cursor below the current +// `until` means more than one page of events share that boundary second. The WS +// filter exposes no `(created_at, id)` cursor to escape a dense second, so +// silently stopping there would drop every older event — including a required +// 30175 constituent — and leave hydration falsely "complete". Fail loudly with +// `PersonaHistoryDenseBoundaryError` so the caller degrades explicitly rather +// than projecting partial history as exhaustive. +async function fetchOwnerHistoryToExhaustion( + pubkey: string, +): Promise { + const collected: RelayEvent[] = []; + const seen = new Set(); + let until: number | undefined; + + for (;;) { + const page = await relayClient.fetchEvents({ + kinds: PERSONA_SYNC_KINDS, + authors: [pubkey], + limit: PERSONA_HISTORY_PAGE_LIMIT, + ...(until === undefined ? {} : { until }), + }); + + let added = 0; + let oldest = Number.POSITIVE_INFINITY; + for (const event of page) { + if (event.created_at < oldest) oldest = event.created_at; + if (seen.has(event.id)) continue; + seen.add(event.id); + collected.push(event); + added += 1; + } + + if (page.length < PERSONA_HISTORY_PAGE_LIMIT) break; + // A full page that cannot lower the cursor is an unadvanceable dense second. + if (until !== undefined && oldest >= until) + throw new PersonaHistoryDenseBoundaryError(oldest); + if (added === 0) throw new PersonaHistoryDenseBoundaryError(oldest); + until = oldest; + } + + return collected; +} + // Start the persona/team/agent/deletion sync for `pubkey` on `relayUrl`: -// one-shot backfill of existing heads + tombstones, then a live subscription. +// exhaustive 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`). @@ -72,12 +231,32 @@ export function coalesceManagedAgentBackfill( // 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. +// +// HYDRATION BOUNDARY. The history fetch and the live subscription start +// concurrently and feed one reconcile chain. A live/replayed 30178 that arrives +// before the backfill has reconciled its 30175/30176 constituents reproduces the +// false-tombstone purge (member resolution fails against an unhydrated store). +// Live events are therefore BUFFERED until the complete, dependency-ordered +// backfill has been dispatched, then drained in arrival order. Setting `hydrated` +// and draining the buffer are synchronous and uninterrupted, so no live event can +// slip past the boundary. Steady-state live events (after hydration) reconcile +// immediately. +// +// FAILURE POLICY. A transient backfill fetch failure is retried with bounded +// backoff. If backfill cannot complete (retries exhausted, or a deterministic +// dense-boundary error), the pipeline enters DEGRADED-LIVE rather than leaving +// the subscription permanently inert: the boundary still opens so buffered and +// future live events keep reconciling, but the whole catalog dependency set +// (30178, its 30175/30176 constituents, and kind-5 deletions targeting them) is +// dropped because those events could drive a catalog refresh against an +// unhydrated store. 30177 runtime policy stays live. Degraded state self-heals +// on the next effect re-run. export function startPersonaSync( pubkey: string, relayUrl: string, onCancelled: () => boolean, ): () => Promise { - // Reconcile in relay order. Managed-agent reconciliation can await a remote + // Reconcile in dispatch order. Managed-agent reconciliation can await a remote // provider deployment after releasing the local store lock; firing commands // independently lets an older broad policy finish after a newer restrictive // one. One chain per owner/relay subscription makes the newest event the last @@ -92,24 +271,112 @@ export function startPersonaSync( }); }; - // One-shot backfill of existing heads + tombstones (closes the fresh-start - // gap that live-only subscription + reconnect-replay cannot recover). - void relayClient - .fetchEvents({ kinds: PERSONA_SYNC_KINDS, authors: [pubkey], limit: 500 }) - .then((events) => { - if (onCancelled()) return; - for (const event of coalesceManagedAgentBackfill(events)) - reconcile(event); - }) - .catch((error) => { - console.warn("[usePersonaSync] backfill failed:", error); - }); + // Live events that arrive before the backfill finishes hydrating are held + // here and drained once the constituents are reconciled. `hydrated` opens the + // boundary; `degraded` records that hydration ended in failure rather than a + // complete backfill (see the backfill runner below). + let hydrated = false; + let degraded = false; + + // Dispatch a live (post-boundary or drained-buffer) event. In DEGRADED mode + // the owner's constituents were never fully hydrated, so the whole catalog + // dependency set is dropped: the 30178 head itself, its 30175/30176 + // constituents, and any kind-5 deletion targeting one of those coordinates. + // + // Dropping the 30178 head alone is not enough. The backend's KIND_TEAM / + // KIND_PERSONA inbound arms unconditionally call `refresh_team_catalog_head` + // after a save (inbound.rs), and live delivery is newest-first — so a 30176 + // team edit that ADDS a new persona reaches Rust before that persona's 30175. + // Against a retained witness the refresh cannot resolve the new member, purges + // the valid witness, and queues a dominating false tombstone. Holding the + // prior hydration's constituents on disk only proves the OLD revision is + // resolvable; it says nothing about a NEW member. A kind-5 deletion of a + // dependency is likewise destructive — it intentionally triggers 30178 + // tombstoning — and cannot safely establish final state on incomplete history. + // + // 30177 (managed-agent runtime policy) stays live: it drives no catalog + // refresh, so it cannot reproduce the purge, and runtime control should keep + // working while degraded. Degraded mode is an explicit self-healing abnormal + // state — the full backfill retries on the next effect re-run (restart, or an + // identity/community switch) — so a degraded device staying stale on + // team/persona edits until self-heal is the correct trade against destroying + // valid shared state. + const dispatchLive = (event: RelayEvent) => { + if (degraded && isCatalogDependencyEvent(event)) return; + reconcile(event); + }; + + const liveBuffer: RelayEvent[] = []; + const onLiveEvent = (event: RelayEvent) => { + if (event.pubkey !== pubkey) return; + if (hydrated) { + dispatchLive(event); + } else { + liveBuffer.push(event); + } + }; + + // Open the hydration boundary and drain buffered live events. Setting + // `hydrated` and draining are synchronous and uninterrupted, so no live event + // can slip past the boundary. `degraded` must be set before this runs so the + // drain applies the same catalog-drop policy as future live events. + const openBoundaryAndDrain = () => { + hydrated = true; + for (const event of liveBuffer) dispatchLive(event); + liveBuffer.length = 0; + }; + + // Exhaustive one-shot backfill (closes the fresh-start gap that live-only + // subscription + reconnect-replay cannot recover). Coalesce managed-agent + // revisions, defer 30178 catalog heads past their constituents, dispatch the + // ordered batch, THEN open the hydration boundary and drain buffered live + // events — otherwise a fresh device retracts the owner's valid shared head. + // + // A transient fetch failure is retried with bounded backoff; a deterministic + // `PersonaHistoryDenseBoundaryError` is NOT retried (a dense second cannot + // clear on retry). When every attempt fails the pipeline transitions to + // degraded-live rather than leaving the subscription permanently inert: + // `hydrated` still opens so buffered and future live events keep reconciling, + // with catalog heads dropped (see `dispatchLive`). + const runBackfill = async () => { + for (let attempt = 0; attempt < BACKFILL_MAX_ATTEMPTS; attempt += 1) { + try { + const events = await fetchOwnerHistoryToExhaustion(pubkey); + if (onCancelled()) return; + for (const event of orderCatalogHeadsLast( + coalesceManagedAgentBackfill(events), + )) + reconcile(event); + openBoundaryAndDrain(); + return; + } catch (error) { + if (onCancelled()) return; + const transient = !(error instanceof PersonaHistoryDenseBoundaryError); + if (transient && attempt < BACKFILL_MAX_ATTEMPTS - 1) { + console.warn( + `[usePersonaSync] backfill attempt ${attempt + 1} failed, retrying:`, + error, + ); + await backfillBackoff(attempt); + continue; + } + console.warn( + "[usePersonaSync] backfill failed; entering degraded-live sync:", + error, + ); + degraded = true; + openBoundaryAndDrain(); + return; + } + } + }; + void runBackfill(); let unsub: (() => Promise) | null = null; void relayClient .subscribeLive( { kinds: PERSONA_SYNC_KINDS, authors: [pubkey], limit: 0 }, - reconcile, + onLiveEvent, ) .then((dispose) => { if (onCancelled()) { diff --git a/desktop/src/features/agents/lib/useTeamCatalogRelay.ts b/desktop/src/features/agents/lib/useTeamCatalogRelay.ts new file mode 100644 index 00000000000..318e7ad85bf --- /dev/null +++ b/desktop/src/features/agents/lib/useTeamCatalogRelay.ts @@ -0,0 +1,118 @@ +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + fetchTeamCatalogPublications, + type TeamCatalogPublication, +} from "@/features/agents/lib/teamCatalogRelay"; +import { personasQueryKey, teamsQueryKey } from "@/features/agents/hooks"; +import { relayClient } from "@/shared/api/relayClient"; +import { addTeamFromCatalog, setTeamShared } from "@/shared/api/tauriTeams"; +import type { + AgentTeam, + TeamCatalogSourceCoordinate, +} from "@/shared/api/types"; +import { KIND_TEAM_CATALOG } from "@/shared/constants/kinds"; + +/** + * Team catalog reads and writes, keyed by community. + * + * Structurally the persona equivalent (`usePersonaCatalogRelay`) with the + * kind and command swapped. Adding is the one genuine difference: it writes + * personas as well as teams, so it invalidates both stores. + */ + +export function teamCatalogQueryKey(communityId: string | null) { + return ["team-catalog", communityId] as const; +} + +export function useTeamCatalogQuery(communityId: string | null) { + return useQuery({ + enabled: communityId !== null, + queryKey: teamCatalogQueryKey(communityId), + queryFn: fetchTeamCatalogPublications, + staleTime: 30_000, + refetchInterval: 120_000, + }); +} + +export function useTeamCatalogLiveUpdates(communityId: string | null): void { + const queryClient = useQueryClient(); + + React.useEffect(() => { + if (!communityId) return; + let disposed = false; + let dispose: (() => Promise) | null = null; + + const invalidate = () => { + void queryClient.invalidateQueries({ + queryKey: teamCatalogQueryKey(communityId), + }); + }; + + void relayClient + .subscribeLive({ kinds: [KIND_TEAM_CATALOG], limit: 0 }, invalidate) + .then((unsubscribe) => { + if (disposed) { + void unsubscribe(); + } else { + dispose = unsubscribe; + } + }) + .catch((error) => { + console.error( + "Couldn’t subscribe to the community team catalog", + error, + ); + }); + + const unsubscribeReconnect = relayClient.subscribeToReconnects(invalidate); + + return () => { + disposed = true; + unsubscribeReconnect(); + if (dispose) void dispose(); + }; + }, [communityId, queryClient]); +} + +export function useSetTeamCatalogSharedMutation(communityId: string | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, shared }: { id: string; shared: boolean }) => + setTeamShared(id, shared), + onSuccess: (result) => { + queryClient.setQueryData( + teamsQueryKey, + (current) => + current?.map((team) => + team.id === result.team.id ? result.team : team, + ) ?? [result.team], + ); + void queryClient.invalidateQueries({ + queryKey: teamCatalogQueryKey(communityId), + }); + }, + }); +} + +/** + * Add a published team, then refresh both stores. + * + * The command copies every member as a local persona, so leaving the persona + * query stale would show the new team with members the agents list does not + * know about yet. + */ +export function useAddTeamFromCatalogMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (source: TeamCatalogSourceCoordinate & { eventId: string }) => + addTeamFromCatalog(source), + onSettled: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: teamsQueryKey }), + queryClient.invalidateQueries({ queryKey: personasQueryKey }), + ]); + }, + }); +} diff --git a/desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx b/desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx index 50109143cdc..509f2187378 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx @@ -4,11 +4,13 @@ export function AgentDefinitionMetadata({ className, isBuiltIn, model, + provider, runtime, }: { className?: string; isBuiltIn: boolean; model: string | null; + provider?: string | null; runtime: string | null; }) { const items = [ @@ -24,6 +26,9 @@ export function AgentDefinitionMetadata({ label: "Preferred runtime", value: runtime ?? "Use app default", }, + ...(provider !== undefined + ? [{ label: "Preferred provider", value: provider ?? "Use app default" }] + : []), ]; return ( @@ -31,7 +36,12 @@ export function AgentDefinitionMetadata({ className={cn("rounded-lg border border-border/70 bg-card/70", className)} data-testid="agent-definition-metadata" > -
+
3 ? "sm:grid-cols-4" : "sm:grid-cols-3", + )} + > {items.map((item, index) => (
(null); const [isAiDefaultsOpen, setIsAiDefaultsOpen] = React.useState(false); - function openUnifiedCatalog() { - personas.prepareCreate(); - personas.openCatalog(); - } - function openAiDefaults(trigger: HTMLButtonElement | null) { aiDefaultsTriggerRef.current = trigger; setIsAiDefaultsOpen(true); @@ -81,6 +76,21 @@ export function AgentsView() { }, ); + // Parent-owned unified catalog state per Thufir's corrective: + // - discriminated launch target so both sections refetch on open + // - one close owner via this boolean + const [catalogLaunchTarget, setCatalogLaunchTarget] = React.useState< + "agents" | "teams" | null + >(null); + + function openCommunityCatalog(target: "agents" | "teams") { + personas.clearFeedback("catalog"); + personas.prepareCreate(); + void personas.catalogQuery.refetch(); + void teamActions.catalogQuery.refetch(); + setCatalogLaunchTarget(target); + } + const isActionPending = agents.isPending || personas.isPending || @@ -257,7 +267,7 @@ export function AgentsView() { } isPersonasLoading={personas.personasQuery.isLoading} isPersonasPending={personas.isPending} - onOpenCatalog={openUnifiedCatalog} + onOpenCatalog={() => openCommunityCatalog("agents")} onDuplicatePersona={personas.openDuplicate} onEditPersona={personas.openEdit} onSharePersona={personas.openShare} @@ -284,6 +294,7 @@ export function AgentsView() { onDuplicate={teamActions.openDuplicateDialog} onEdit={teamActions.openEditDialog} onAddToChannel={teamActions.setTeamToAddToChannel} + onDiscover={() => openCommunityCatalog("teams")} onShare={teamActions.openShare} onImport={() => { teamImportInputRef.current?.click(); @@ -452,8 +463,8 @@ export function AgentsView() { }} /> ) : null} - {personas.isCatalogDialogOpen ? ( - ( )} - error={ + // Persona side + personas={personas.catalogPersonas} + personasError={ personas.catalogQuery.error instanceof Error ? personas.catalogQuery.error : null } + personasLoading={personas.catalogQuery.isLoading} + personasPending={personas.isPending} feedbackErrorMessage={ personas.personaFeedbackSurface === "catalog" ? personas.personaErrorMessage @@ -495,15 +510,12 @@ export function AgentsView() { ? personas.personaNoticeMessage : null } - isLoading={personas.catalogQuery.isLoading} - isPending={personas.isPending} onClearFeedback={() => { personas.clearFeedback("catalog"); }} onImportFile={(fileBytes, fileName) => { void personas.handleImportSnapshotFile(fileBytes, fileName); }} - onOpenChange={personas.setIsCatalogDialogOpen} onSelectPersona={async (persona, active) => { const addedPersona = await personas.handleSetActive( persona, @@ -512,11 +524,29 @@ export function AgentsView() { ); if (!active || !addedPersona) return; - personas.setIsCatalogDialogOpen(false); + setCatalogLaunchTarget(null); openPersonaProfilePanel?.(addedPersona); }} - open={personas.isCatalogDialogOpen} - personas={personas.catalogPersonas} + // Team side + teams={teamActions.catalogTeams} + teamsError={ + teamActions.catalogQuery.error instanceof Error + ? teamActions.catalogQuery.error + : null + } + teamsLoading={teamActions.catalogQuery.isLoading} + teamsAdding={teamActions.isAddingFromCatalog} + onAddTeam={(team) => { + void teamActions.handleAddTeamFromCatalog(team, () => + setCatalogLaunchTarget(null), + ); + }} + // Dialog + open={catalogLaunchTarget !== null} + preferSection={catalogLaunchTarget} + onOpenChange={(open) => { + if (!open) setCatalogLaunchTarget(null); + }} /> ) : null} {teamActions.teamDialogState ? ( @@ -576,11 +606,23 @@ export function AgentsView() { ) : null} {teamActions.teamToShare ? ( { + if (teamActions.teamToShare) { + void teamActions.setTeamCatalogShareLevel( + teamActions.teamToShare, + shareLevel, + ); + } + }} onExport={() => { if (teamActions.teamToShare) { const team = teamActions.teamToShare; diff --git a/desktop/src/features/agents/ui/CommunityCatalogDialog.tsx b/desktop/src/features/agents/ui/CommunityCatalogDialog.tsx new file mode 100644 index 00000000000..44c334828d5 --- /dev/null +++ b/desktop/src/features/agents/ui/CommunityCatalogDialog.tsx @@ -0,0 +1,890 @@ +import * as React from "react"; +import { ChevronDown, Plus, Upload } from "lucide-react"; + +import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog"; +import { isCatalogPersona } from "@/features/agents/lib/personaCatalogRelay"; +import type { CatalogTeam } from "@/features/agents/lib/teamCatalogRelay"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import type { AgentPersona } from "@/shared/api/types"; +import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; +import { cn } from "@/shared/lib/cn"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { Button } from "@/shared/ui/button"; +import { Dialog } from "@/shared/ui/dialog"; +import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; +import { Skeleton } from "@/shared/ui/skeleton"; + +import agentOutlineUrl from "../assets/agent-outline.svg"; +import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata"; +import { PersonaAddedBy } from "./PersonaAddedBy"; +import { resolveCatalogOwnerLabel } from "./catalogOwnerLabel"; + +// ── Type-tagged selection keys ──────────────────────────────────────────────── + +// The detail pane is a single-select surface across four kinds of content: +// the "create" and "import" panes carried from the unified add-agent dialog +// (#5015), plus a persona or team browsed from the community catalog. Persona +// IDs and team coordinates are prefixed so they cannot collide with each other +// or with the fixed "create"/"import" keys. +type CatalogSelectionKey = + | { kind: "persona"; id: string } + | { kind: "team"; key: string }; + +function teamSelectionKey(team: CatalogTeam): string { + return `${team.ownerPubkey}:${team.teamDTag}`; +} + +function encodeKey(k: CatalogSelectionKey): string { + return k.kind === "persona" ? `p:${k.id}` : `t:${k.key}`; +} + +function personaKey(persona: AgentPersona): string { + return encodeKey({ kind: "persona", id: persona.id }); +} + +function teamKey(team: CatalogTeam): string { + return encodeKey({ kind: "team", key: teamSelectionKey(team) }); +} + +// ── Props ───────────────────────────────────────────────────────────────────── + +type CommunityCatalogDialogProps = { + // Create pane (unified add-agent flow, #5015). Rendered when the "create" + // navigation item is active. The render-prop reports dirty state so the + // dialog can guard navigation away from an in-progress draft. + createContent: (controls: { + onDirtyChange: (dirty: boolean) => void; + onRequestClose: () => void; + }) => React.ReactNode; + onImportFile: (fileBytes: number[], fileName: string) => void; + + // Persona side + personas: AgentPersona[]; + personasError: Error | null; + personasLoading: boolean; + personasPending: boolean; + feedbackErrorMessage: string | null; + feedbackNoticeMessage: string | null; + onClearFeedback: () => void; + onSelectPersona: (persona: AgentPersona, active: boolean) => void; + + // Team side + teams: CatalogTeam[]; + teamsError: Error | null; + teamsLoading: boolean; + teamsAdding: boolean; + onAddTeam: (team: CatalogTeam) => void; + + // Dialog + open: boolean; + preferSection: "agents" | "teams"; + onOpenChange: (open: boolean) => void; +}; + +type PendingNavigation = + | { type: "close" } + | { type: "selection"; selection: string }; + +// ── CommunityCatalogDialog ──────────────────────────────────────────────────── + +export function CommunityCatalogDialog({ + createContent, + onImportFile, + personas, + personasError, + personasLoading, + personasPending, + feedbackErrorMessage, + feedbackNoticeMessage, + onClearFeedback, + onSelectPersona, + teams, + teamsError, + teamsLoading, + teamsAdding, + onAddTeam, + open, + preferSection, + onOpenChange, +}: CommunityCatalogDialogProps) { + const contentRef = React.useRef(null); + const fileInputRef = React.useRef(null); + const dragDepthRef = React.useRef(0); + const createDirtyRef = React.useRef(false); + const didInitRef = React.useRef(false); + const [isDragOver, setIsDragOver] = React.useState(false); + const [pendingNavigation, setPendingNavigation] = + React.useState(null); + + // Active detail selection. Defaults to the create pane (the unified + // add-agent entry point); a teams-launch may switch to the first team once + // the teams query settles. + const [selection, setSelection] = React.useState("create"); + const [userHasSelected, setUserHasSelected] = React.useState(false); + + const firstTeamKey = teams.length > 0 ? teamKey(teams[0]) : null; + + // Reset to a fresh create-pane state each time the dialog opens. + React.useEffect(() => { + if (open) { + createDirtyRef.current = false; + didInitRef.current = false; + setUserHasSelected(false); + setSelection("create"); + setPendingNavigation(null); + dragDepthRef.current = 0; + setIsDragOver(false); + } + }, [open]); + + // Teams-launch: once the teams query settles, select the first team so the + // catalog opens on browsable content rather than the create pane. Runs at + // most once per open and never overrides an explicit user navigation. + React.useEffect(() => { + if (!open || didInitRef.current || userHasSelected) return; + if (preferSection !== "teams") { + didInitRef.current = true; + return; + } + if (teamsLoading) return; + didInitRef.current = true; + if (firstTeamKey) setSelection(firstTeamKey); + }, [open, preferSection, teamsLoading, firstTeamKey, userHasSelected]); + + // Drop a selected catalog item that a live refresh retracted so the detail + // pane never points at something that no longer exists. + React.useEffect(() => { + if (selection.startsWith("p:")) { + const id = selection.slice(2); + if (!personas.some((p) => p.id === id)) setSelection("create"); + } else if (selection.startsWith("t:")) { + const key = selection.slice(2); + if (!teams.some((t) => teamSelectionKey(t) === key)) + setSelection("create"); + } + }, [personas, teams, selection]); + + useFeedbackToasts(feedbackNoticeMessage, feedbackErrorMessage); + + // Resolve current selection. + const selectedPersona = selection.startsWith("p:") + ? (personas.find((p) => p.id === selection.slice(2)) ?? null) + : null; + const selectedTeamKey = selection.startsWith("t:") + ? selection.slice(2) + : null; + const selectedTeam = selectedTeamKey + ? (teams.find((t) => teamSelectionKey(t) === selectedTeamKey) ?? null) + : null; + + const isCreateSelected = selection === "create"; + const isImportSelected = selection === "import"; + + const selectedPersonaIsActive = selectedPersona + ? isCatalogPersonaSelected(selectedPersona) + : false; + const selectedTeamIsAdded = selectedTeam?.localTeam != null; + + const bothEmpty = + !personasLoading && + !teamsLoading && + personas.length === 0 && + teams.length === 0; + const noError = !personasError && !teamsError; + + const handleCreateDirtyChange = React.useCallback((dirty: boolean) => { + createDirtyRef.current = dirty; + }, []); + + function requestSelection(nextSelection: string) { + if ( + isCreateSelected && + nextSelection !== "create" && + createDirtyRef.current + ) { + setPendingNavigation({ type: "selection", selection: nextSelection }); + return; + } + setSelection(nextSelection); + } + + function requestClose() { + if (isCreateSelected && createDirtyRef.current) { + setPendingNavigation({ type: "close" }); + return; + } + onOpenChange(false); + } + + function discardChangesAndNavigate() { + const navigation = pendingNavigation; + createDirtyRef.current = false; + setPendingNavigation(null); + if (navigation?.type === "selection") { + setSelection(navigation.selection); + } else if (navigation?.type === "close") { + onOpenChange(false); + } + } + + function handleUseAgent() { + if (!selectedPersona || selectedPersonaIsActive) return; + onClearFeedback(); + onSelectPersona(selectedPersona, true); + } + + function handleAddTeam() { + if (!selectedTeam || selectedTeamIsAdded) return; + onAddTeam(selectedTeam); + } + + React.useEffect(() => { + if (!isImportSelected) { + dragDepthRef.current = 0; + setIsDragOver(false); + } + }, [isImportSelected]); + + function hasFiles(event: React.DragEvent) { + return event.dataTransfer.types.includes("Files"); + } + + function isAgentSnapshot(file: File) { + const lowerName = file.name.toLowerCase(); + return ( + lowerName.endsWith(".agent.json") || lowerName.endsWith(".agent.png") + ); + } + + async function importFile(file: File) { + if (!isAgentSnapshot(file)) return; + const buffer = await file.arrayBuffer(); + onOpenChange(false); + onImportFile(Array.from(new Uint8Array(buffer)), file.name); + } + + return ( + <> + { + if (!nextOpen && personasPending) return; + if (!nextOpen) { + requestClose(); + return; + } + onOpenChange(true); + }} + open={open} + > + { + event.preventDefault(); + contentRef.current?.focus(); + }} + ref={contentRef} + scrollAreaClassName="flex min-h-0 overflow-hidden px-0" + scrollAreaTestId="community-catalog-dialog-body" + tabIndex={-1} + title="Add agent" + onDragEnter={(event) => { + if (!isImportSelected || !hasFiles(event)) return; + event.preventDefault(); + dragDepthRef.current += 1; + setIsDragOver(true); + }} + onDragLeave={(event) => { + if (!isImportSelected) return; + event.preventDefault(); + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setIsDragOver(false); + }} + onDragOver={(event) => { + if (!isImportSelected || !hasFiles(event)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }} + onDrop={(event) => { + if (!isImportSelected || !hasFiles(event)) return; + event.preventDefault(); + dragDepthRef.current = 0; + setIsDragOver(false); + const file = event.dataTransfer.files[0]; + if (file) void importFile(file); + }} + > +
+ {isImportSelected && isDragOver ? ( +
+

+ Drop .agent.json or .agent.png to import +

+
+ ) : null} + + {/* Sidebar navigation + catalog lists */} +
+
+
+ } + isCurrent={isCreateSelected} + label="Create agent" + onClick={() => requestSelection("create")} + testId="agent-catalog-create" + /> + } + isCurrent={isImportSelected} + label="Import" + onClick={() => requestSelection("import")} + testId="agent-catalog-import" + /> +
+ +
+ + {personasLoading ? : null} + + {!personasLoading && personas.length > 0 ? ( +
+

+ Agents +

+
+ {personas.map((persona) => { + const key = personaKey(persona); + const isCurrent = key === selection; + return ( + + ); + })} +
+
+ ) : null} + + {teamsLoading ? : null} + + {!teamsLoading && teams.length > 0 ? ( +
+

+ Teams +

+
+ {teams.map((team) => { + const key = teamKey(team); + const isCurrent = key === selection; + return ( + + ); + })} +
+
+ ) : null} + + {bothEmpty && noError ? : null} +
+
+ + {/* Detail pane */} +
+ {isCreateSelected + ? createContent({ + onDirtyChange: handleCreateDirtyChange, + onRequestClose: requestClose, + }) + : null} + + {isImportSelected ? ( + fileInputRef.current?.click()} + /> + ) : null} + + {selectedPersona || selectedTeam ? ( + <> +
+ {selectedPersona ? ( + + ) : null} + {selectedTeam ? ( + + ) : null} +
+ +
+ {selectedPersona ? ( + + ) : selectedTeam ? ( + + ) : null} +
+ + ) : null} + + {/* Per-section errors — only blank the section that failed */} + {personasError ? ( +

+ {personasError.message} +

+ ) : null} + {teamsError ? ( +

+ {teamsError.message} +

+ ) : null} +
+
+ + { + const file = event.target.files?.[0]; + if (file) void importFile(file); + event.target.value = ""; + }} + ref={fileInputRef} + type="file" + /> + +
+ + { + if (!nextOpen) setPendingNavigation(null); + }} + open={pendingNavigation !== null} + > + + + Discard agent changes? + + Your changes to this agent will be lost. + + + + Keep editing + + + + + + + + ); +} + +// ── Navigation button ───────────────────────────────────────────────────────── + +function CatalogNavigationButton({ + icon, + isCurrent, + label, + onClick, + testId, +}: { + icon: React.ReactNode; + isCurrent: boolean; + label: string; + onClick: () => void; + testId: string; +}) { + return ( + + ); +} + +// ── Import pane ─────────────────────────────────────────────────────────────── + +function ImportAgentPane({ onImport }: { onImport: () => void }) { + return ( + + ); +} + +// ── Empty state ─────────────────────────────────────────────────────────────── + +function CatalogEmptyState() { + return ( +
+ +

+ Nothing shared yet +

+

+ Shared agents and teams will appear here. +

+
+ ); +} + +// ── Skeleton loaders ────────────────────────────────────────────────────────── + +function CatalogListSkeleton() { + return ( +
+ {["first", "second", "third", "fourth", "fifth"].map((key) => ( +
+ + +
+ ))} +
+ ); +} + +// ── Persona detail ──────────────────────────────────────────────────────────── + +/** + * Security review surface for instructions that will execute verbatim. + * + * Do not replace this with the chat Markdown renderer: Markdown intentionally + * hides spoiler bodies, link destinations, and image sources, so the reviewed + * text would differ from the system prompt sent to the agent. + */ +export function AgentInstructionReview({ + instructions, +}: { + instructions: string; +}) { + return ( +
+      {instructions || "No instructions included."}
+    
+ ); +} + +function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) { + const isCommunityEntry = + isCatalogPersona(persona) && !persona.catalogSource.isOwn; + const ownerPubkey = isCommunityEntry + ? persona.catalogSource.ownerPubkey + : undefined; + const ownerBatchQuery = useUsersBatchQuery(ownerPubkey ? [ownerPubkey] : [], { + enabled: !!ownerPubkey, + }); + + let addedByLabel: string; + if (!isCommunityEntry) { + addedByLabel = "You"; + } else { + const summary = ownerPubkey + ? ownerBatchQuery.data?.profiles[ownerPubkey.toLowerCase()] + : undefined; + addedByLabel = resolveCatalogOwnerLabel(summary); + } + + return ( +
+
+ +
+

+ {persona.displayName} +

+ {persona.isBuiltIn ? null : ( + + )} +
+
+ + + +
+

+ Agent instructions +

+ +
+
+ ); +} + +// ── Team detail ─────────────────────────────────────────────────────────────── + +function TeamCatalogDetail({ team }: { team: CatalogTeam }) { + const ownerPubkey = team.isOwn ? undefined : team.ownerPubkey; + const ownerBatchQuery = useUsersBatchQuery(ownerPubkey ? [ownerPubkey] : [], { + enabled: !!ownerPubkey, + }); + + let addedByLabel: string; + if (team.isOwn) { + addedByLabel = "You"; + } else { + const summary = ownerPubkey + ? ownerBatchQuery.data?.profiles[ownerPubkey.toLowerCase()] + : undefined; + addedByLabel = resolveCatalogOwnerLabel(summary); + } + + const hasInstructions = + team.instructions !== null && team.instructions.trim().length > 0; + + return ( +
+
+

+ {team.name} +

+ + {team.description ? ( +

+ {team.description} +

+ ) : null} +
+ + {hasInstructions ? ( +
+

+ Team instructions +

+ +
+ ) : null} + +
+

+ {team.members.length}{" "} + {team.members.length === 1 ? "member" : "members"} +

+
    + {team.members.map((member) => ( + + ))} +
+
+
+ ); +} + +type TeamCatalogMemberRowProps = { + member: CatalogTeam["members"][number]; +}; + +function TeamCatalogMemberRow({ member }: TeamCatalogMemberRowProps) { + const [expanded, setExpanded] = React.useState(false); + + return ( +
  • + + + {expanded ? ( +
    + +
    +

    + Agent instructions +

    + {member.systemPrompt.trim().length > 0 ? ( + + ) : ( +

    + No instructions +

    + )} +
    +
    + ) : null} +
  • + ); +} diff --git a/desktop/src/features/agents/ui/CommunityCatalogDialogAvatarLeak.test.mjs b/desktop/src/features/agents/ui/CommunityCatalogDialogAvatarLeak.test.mjs new file mode 100644 index 00000000000..73e867478a0 --- /dev/null +++ b/desktop/src/features/agents/ui/CommunityCatalogDialogAvatarLeak.test.mjs @@ -0,0 +1,331 @@ +/** + * Catalog-browse wiring regression for the publisher-avatar IP leak. + * + * `ProfileAvatarUntrusted.test.mjs` proves the component guard in isolation, + * but it never renders `CommunityCatalogDialog` — so deleting `untrusted` from + * any of the three browse sites (persona sidebar row, persona detail header, + * team member row) would leave that test green while restoring the exact leak + * Carl flagged: opening Discover Teams fires image requests at up to 64 + * publisher-controlled hosts, handing the viewer's IP and browse timing away. + * + * This test mounts the real dialog with publisher URLs on every avatar-bearing + * projection, drives selection through all three sites, and asserts zero + * HTTP(S) `Image.src` assignments — the actual network trigger Radix fires. + * Removing `untrusted` from any single site turns it RED. + */ + +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +// Radix's AvatarImage probes load status by assigning `.src` on a detached +// `new window.Image()`; that assignment is the network request. Spy on it so +// the test observes the fetch itself rather than post-load DOM (which never +// mounts under jsdom because the probe never fires `load`). +const imageSrcAssignments = []; + +class SpyImage { + constructor() { + this.complete = false; + this.naturalWidth = 0; + this._src = ""; + } + addEventListener() {} + removeEventListener() {} + set src(value) { + this._src = value; + imageSrcAssignments.push(value); + } + get src() { + return this._src; + } +} + +Object.assign(globalThis, { + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + MutationObserver: dom.window.MutationObserver, + ResizeObserver: class { + observe() {} + unobserve() {} + disconnect() {} + }, + document: dom.window.document, + localStorage: dom.window.localStorage, + self: dom.window, + window: dom.window, +}); +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, +}); +dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); +globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; +dom.window.ResizeObserver = globalThis.ResizeObserver; +dom.window.matchMedia ??= (query) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, +}); +globalThis.matchMedia = dom.window.matchMedia; +// Radix Dialog's focus/dismiss machinery references many DOM globals without a +// window. prefix; copy them in bulk to avoid per-global whack-a-mole. +for (const key of Object.getOwnPropertyNames(dom.window)) { + if ( + !(key in globalThis) && + (key.startsWith("HTML") || + key.startsWith("SVG") || + key.startsWith("CSS") || + [ + "Node", + "NodeFilter", + "NodeList", + "NamedNodeMap", + "Event", + "CustomEvent", + "MouseEvent", + "KeyboardEvent", + "FocusEvent", + "InputEvent", + "PointerEvent", + "TouchEvent", + "WheelEvent", + "EventTarget", + "Text", + "Comment", + "DocumentFragment", + "Range", + "Selection", + "getComputedStyle", + "IntersectionObserver", + "ResizeObserver", + ].includes(key)) + ) { + const val = dom.window[key]; + if (val !== undefined) globalThis[key] = val; + } +} +globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window); + +// Radix DismissableLayer/FocusScope dispatch plain objects; JSDOM's strict +// Event validation throws on them. Drop non-Event objects so the dialog renders +// without throwing from effects; real Event delivery is unaffected. +const _origDispatch = dom.window.EventTarget.prototype.dispatchEvent; +dom.window.EventTarget.prototype.dispatchEvent = function (event) { + if (!(event instanceof dom.window.Event)) return false; + return _origDispatch.call(this, event); +}; +globalThis.EventTarget = dom.window.EventTarget; + +dom.window.Image = SpyImage; +globalThis.Image = SpyImage; + +// The owner-label batch query would cross the Tauri IPC boundary; resolve it to +// an empty profile set so the detail panes render without an unmocked reject. +globalThis.__TAURI_INTERNALS__ = { + invoke: (command) => { + if (command === "get_users_batch") { + return Promise.resolve({ profiles: {}, missing: [] }); + } + return Promise.reject(new Error(`unmocked: ${command}`)); + }, + transformCallback: () => 1, +}; +dom.window.__TAURI_INTERNALS__ = globalThis.__TAURI_INTERNALS__; + +let React; +let act; +let createRoot; +let QueryClient; +let QueryClientProvider; +let CommunitiesProvider; +let TooltipProvider; +let ThemeProvider; +let CommunityCatalogDialog; + +before(async () => { + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ CommunitiesProvider } = await import( + "@/features/communities/useCommunities.tsx" + )); + ({ TooltipProvider } = await import("@/shared/ui/tooltip.tsx")); + ({ ThemeProvider } = await import("@/shared/theme/ThemeProvider.tsx")); + ({ CommunityCatalogDialog } = await import("./CommunityCatalogDialog.tsx")); +}); + +afterEach(() => { + imageSrcAssignments.length = 0; +}); + +after(() => dom.window.close()); + +// Distinct publisher hosts per site so a RED assertion names the leaking one. +const PERSONA_AVATAR = "https://persona.attacker.example/beacon.png"; +const MEMBER_AVATAR = "https://member.attacker.example/beacon.png"; + +const networkAssignments = () => + imageSrcAssignments.filter((src) => /^https?:/i.test(src)); + +function catalogPersona() { + return { + id: "persona-1", + displayName: "Mallory", + avatarUrl: PERSONA_AVATAR, + systemPrompt: "Do things.", + runtime: "goose", + model: "claude", + provider: null, + namePool: [], + isBuiltIn: false, + isActive: false, + shared: true, + envVars: {}, + respondTo: null, + respondToAllowlist: [], + parallelism: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + // Marks a foreign catalog entry so PersonaCatalogDetail resolves an owner + // label — exercises the detail header (site 735) as a browsed row. + catalogSource: { ownerPubkey: "a".repeat(64), teamDTag: "", isOwn: false }, + }; +} + +function catalogTeam() { + return { + eventId: "ev-1", + ownerPubkey: "b".repeat(64), + teamDTag: "crew", + name: "Crew", + description: "A crew.", + instructions: null, + members: [ + { + memberKey: "m-1", + displayName: "Eve", + systemPrompt: "Review.", + avatarUrl: MEMBER_AVATAR, + runtime: "goose", + model: "claude", + provider: null, + }, + ], + isOwn: false, + localTeam: null, + }; +} + +async function mountDialog() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + + const tree = () => + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + ThemeProvider, + null, + React.createElement( + CommunitiesProvider, + null, + React.createElement( + TooltipProvider, + null, + React.createElement(CommunityCatalogDialog, { + createContent: () => React.createElement("div", null, "create"), + onImportFile: () => {}, + personas: [catalogPersona()], + personasError: null, + personasLoading: false, + personasPending: false, + feedbackErrorMessage: null, + feedbackNoticeMessage: null, + onClearFeedback: () => {}, + onSelectPersona: () => {}, + teams: [catalogTeam()], + teamsError: null, + teamsLoading: false, + teamsAdding: false, + onAddTeam: () => {}, + open: true, + // "agents" so the dialog does not auto-select the first team; the + // test drives each selection explicitly. + preferSection: "agents", + onOpenChange: () => {}, + }), + ), + ), + ), + ); + + await act(async () => { + root.render(tree()); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + return { root, container, client }; +} + +async function clickTestId(testId) { + const el = dom.window.document.querySelector(`[data-testid="${testId}"]`); + assert.ok(el, `expected element ${testId}`); + await act(async () => { + el.dispatchEvent( + new dom.window.MouseEvent("click", { bubbles: true, cancelable: true }), + ); + await new Promise((r) => setTimeout(r, 0)); + }); +} + +test("catalog browse fires no publisher image request across all three avatar sites", async () => { + const { root, container, client } = await mountDialog(); + + // Site 1 — persona sidebar row is rendered on open. + assert.deepEqual( + networkAssignments(), + [], + "persona sidebar avatar leaked a network request", + ); + + // Site 2 — persona detail header. + await clickTestId("community-catalog-agent-persona-1"); + assert.deepEqual( + networkAssignments(), + [], + "persona detail avatar leaked a network request", + ); + + // Site 3 — team member row (avatar is in the always-visible expander button). + await clickTestId(`community-catalog-team-${"b".repeat(64)}:crew`); + assert.deepEqual( + networkAssignments(), + [], + "team member avatar leaked a network request", + ); + + await act(async () => { + root.unmount(); + }); + container.remove(); + client.clear(); +}); diff --git a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx deleted file mode 100644 index d2791b480a0..00000000000 --- a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx +++ /dev/null @@ -1,634 +0,0 @@ -import * as React from "react"; -import { Plus, Upload } from "lucide-react"; - -import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog"; -import { isCatalogPersona } from "@/features/agents/lib/personaCatalogRelay"; -import { useUsersBatchQuery } from "@/features/profile/hooks"; -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; -import type { AgentPersona } from "@/shared/api/types"; -import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; -import { cn } from "@/shared/lib/cn"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/shared/ui/alert-dialog"; -import { Button } from "@/shared/ui/button"; -import { Dialog } from "@/shared/ui/dialog"; -import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; -import { Skeleton } from "@/shared/ui/skeleton"; - -import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata"; -import { PersonaAddedBy } from "./PersonaAddedBy"; -import { personaCatalogCopy } from "./personaLibraryCopy"; - -type PersonaCatalogDialogProps = { - createContent: (controls: { - onDirtyChange: (dirty: boolean) => void; - onRequestClose: () => void; - }) => React.ReactNode; - error: Error | null; - feedbackErrorMessage: string | null; - feedbackNoticeMessage: string | null; - isLoading: boolean; - isPending: boolean; - onClearFeedback: () => void; - onImportFile: (fileBytes: number[], fileName: string) => void; - onOpenChange: (open: boolean) => void; - onSelectPersona: (persona: AgentPersona, active: boolean) => void; - open: boolean; - personas: AgentPersona[]; -}; - -type PendingNavigation = - | { type: "close" } - | { type: "selection"; selection: string }; -export function PersonaCatalogDialog({ - createContent, - error, - feedbackErrorMessage, - feedbackNoticeMessage, - isLoading, - isPending, - onClearFeedback, - onImportFile, - onOpenChange, - onSelectPersona, - open, - personas, -}: PersonaCatalogDialogProps) { - const contentRef = React.useRef(null); - const fileInputRef = React.useRef(null); - const dragDepthRef = React.useRef(0); - const createDirtyRef = React.useRef(false); - const [isDragOver, setIsDragOver] = React.useState(false); - const [pendingNavigation, setPendingNavigation] = - React.useState(null); - const [selection, setSelection] = React.useState("create"); - const selectedPersonaId = selection.startsWith("persona:") - ? selection.slice("persona:".length) - : null; - const selectedPersona = React.useMemo(() => { - if (!selectedPersonaId) { - return null; - } - - return personas.find((persona) => persona.id === selectedPersonaId) ?? null; - }, [personas, selectedPersonaId]); - - React.useEffect(() => { - if (open) { - createDirtyRef.current = false; - setSelection("create"); - setPendingNavigation(null); - dragDepthRef.current = 0; - setIsDragOver(false); - } - }, [open]); - - React.useEffect(() => { - if ( - selectedPersonaId && - !personas.some((persona) => persona.id === selectedPersonaId) - ) { - setSelection("create"); - } - }, [personas, selectedPersonaId]); - - useFeedbackToasts(feedbackNoticeMessage, feedbackErrorMessage); - - const selectedPersonaIsActive = selectedPersona - ? isCatalogPersonaSelected(selectedPersona) - : false; - - const handleUseSelectedPersona = () => { - if (!selectedPersona || selectedPersonaIsActive) { - return; - } - - onClearFeedback(); - onSelectPersona(selectedPersona, true); - }; - - const isImportSelected = selection === "import"; - const handleCreateDirtyChange = React.useCallback((dirty: boolean) => { - createDirtyRef.current = dirty; - }, []); - - function requestSelection(nextSelection: string) { - if ( - selection === "create" && - nextSelection !== "create" && - createDirtyRef.current - ) { - setPendingNavigation({ - type: "selection", - selection: nextSelection, - }); - return; - } - setSelection(nextSelection); - } - - function requestClose() { - if (selection === "create" && createDirtyRef.current) { - setPendingNavigation({ type: "close" }); - return; - } - onOpenChange(false); - } - - function discardChangesAndNavigate() { - const navigation = pendingNavigation; - createDirtyRef.current = false; - setPendingNavigation(null); - if (navigation?.type === "selection") { - setSelection(navigation.selection); - } else if (navigation?.type === "close") { - onOpenChange(false); - } - } - - React.useEffect(() => { - if (!isImportSelected) { - dragDepthRef.current = 0; - setIsDragOver(false); - } - }, [isImportSelected]); - - function hasFiles(event: React.DragEvent) { - return event.dataTransfer.types.includes("Files"); - } - - function isAgentSnapshot(file: File) { - const lowerName = file.name.toLowerCase(); - return ( - lowerName.endsWith(".agent.json") || lowerName.endsWith(".agent.png") - ); - } - - async function importFile(file: File) { - if (!isAgentSnapshot(file)) return; - const buffer = await file.arrayBuffer(); - onOpenChange(false); - onImportFile(Array.from(new Uint8Array(buffer)), file.name); - } - - return ( - <> - { - if (!nextOpen && isPending) return; - if (!nextOpen) { - requestClose(); - return; - } - onOpenChange(true); - }} - open={open} - > - { - event.preventDefault(); - contentRef.current?.focus(); - }} - ref={contentRef} - scrollAreaClassName="flex min-h-0 overflow-hidden px-0" - scrollAreaTestId="persona-catalog-dialog-body" - tabIndex={-1} - title={personaCatalogCopy.dialogTitle} - onDragEnter={(event) => { - if (!isImportSelected || !hasFiles(event)) return; - event.preventDefault(); - dragDepthRef.current += 1; - setIsDragOver(true); - }} - onDragLeave={(event) => { - if (!isImportSelected) return; - event.preventDefault(); - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); - if (dragDepthRef.current === 0) setIsDragOver(false); - }} - onDragOver={(event) => { - if (!isImportSelected || !hasFiles(event)) return; - event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - }} - onDrop={(event) => { - if (!isImportSelected || !hasFiles(event)) return; - event.preventDefault(); - dragDepthRef.current = 0; - setIsDragOver(false); - const file = event.dataTransfer.files[0]; - if (file) void importFile(file); - }} - > - fileInputRef.current?.click()} - isSelectedPersonaActive={selectedPersonaIsActive} - onUsePersona={handleUseSelectedPersona} - onSelectionChange={requestSelection} - personas={personas} - selection={selection} - selectedPersona={selectedPersona} - selectedPersonaId={selectedPersona?.id ?? null} - /> - { - const file = event.target.files?.[0]; - if (file) void importFile(file); - event.target.value = ""; - }} - ref={fileInputRef} - type="file" - /> - - - - { - if (!nextOpen) setPendingNavigation(null); - }} - open={pendingNavigation !== null} - > - - - Discard agent changes? - - Your changes to this agent will be lost. - - - - Keep editing - - - - - - - - ); -} - -type PersonaCatalogChooserProps = { - createContent: React.ReactNode; - error: Error | null; - isDragOver: boolean; - isLoading: boolean; - isPending: boolean; - isSelectedPersonaActive: boolean; - onImport: () => void; - onUsePersona: () => void; - onSelectionChange: (selection: string) => void; - personas: AgentPersona[]; - selection: string; - selectedPersona: AgentPersona | null; - selectedPersonaId: string | null; -}; - -function PersonaCatalogChooser({ - createContent, - error, - isDragOver, - isLoading, - isPending, - isSelectedPersonaActive, - onImport, - onUsePersona, - onSelectionChange, - personas, - selection, - selectedPersona, - selectedPersonaId, -}: PersonaCatalogChooserProps) { - return ( -
    - {selection === "import" && isDragOver ? ( -
    -

    - Drop .agent.json or .agent.png to import -

    -
    - ) : null} -
    -
    -
    - } - isCurrent={selection === "create"} - label="Create agent" - onClick={() => onSelectionChange("create")} - testId="agent-catalog-create" - /> - } - isCurrent={selection === "import"} - label="Import" - onClick={() => onSelectionChange("import")} - testId="agent-catalog-import" - /> -
    - -
    - - {isLoading ? : null} - - {!isLoading && personas.length > 0 ? ( -
    - {personas.map((persona) => { - const isCurrent = persona.id === selectedPersonaId; - - return ( - - ); - })} -
    - ) : null} - {!isLoading && personas.length === 0 && !error ? ( -

    - No shared agents -

    - ) : null} -
    -
    - -
    - {selection === "create" ? createContent : null} - {selection === "import" ? ( - - ) : null} - {selectedPersona ? ( - <> -
    - -
    -
    - -
    - - ) : null} - {selection.startsWith("persona:") && isLoading ? ( -
    - -
    - ) : null} - {error ? ( -

    - {error.message} -

    - ) : null} -
    -
    - ); -} - -function CatalogNavigationButton({ - icon, - isCurrent, - label, - onClick, - testId, -}: { - icon: React.ReactNode; - isCurrent: boolean; - label: string; - onClick: () => void; - testId: string; -}) { - return ( - - ); -} - -function ImportAgentPane({ onImport }: { onImport: () => void }) { - return ( - - ); -} - -/** - * Derives the "Added by" label for a catalog entry from a resolved profile - * summary. Prefers `displayName`, falls back to `name`, then to the default - * "Community member" string when both are absent, null, or whitespace-only. - */ -export function resolveCatalogOwnerLabel( - summary: - | { displayName?: string | null; name?: string | null } - | null - | undefined, -): string { - return ( - summary?.displayName?.trim() || summary?.name?.trim() || "Community member" - ); -} - -/** - * Security review surface for instructions that will execute verbatim. - * - * Do not replace this with the chat Markdown renderer: Markdown intentionally - * hides spoiler bodies, link destinations, and image sources, so the reviewed - * text would differ from the system prompt sent to the agent. - */ -export function AgentInstructionReview({ - instructions, -}: { - instructions: string; -}) { - return ( -
    -      {instructions || "No instructions included."}
    -    
    - ); -} - -function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) { - const isCommunityEntry = - isCatalogPersona(persona) && !persona.catalogSource.isOwn; - const ownerPubkey = isCommunityEntry - ? persona.catalogSource.ownerPubkey - : undefined; - const ownerBatchQuery = useUsersBatchQuery(ownerPubkey ? [ownerPubkey] : [], { - enabled: !!ownerPubkey, - }); - - let addedByLabel: string; - if (!isCommunityEntry) { - addedByLabel = "You"; - } else { - const summary = ownerPubkey - ? ownerBatchQuery.data?.profiles[ownerPubkey.toLowerCase()] - : undefined; - addedByLabel = resolveCatalogOwnerLabel(summary); - } - - return ( -
    -
    - -
    -

    - {persona.displayName} -

    - {persona.isBuiltIn ? null : ( - - )} -
    -
    - - - -
    -

    - Agent instructions -

    - -
    -
    - ); -} - -function PersonaCatalogListSkeleton() { - return ( -
    - {["first", "second", "third", "fourth", "fifth"].map((key) => ( -
    - - -
    - ))} -
    - ); -} - -function PersonaCatalogDetailSkeleton() { - return ( -
    -
    - - -
    -
    - - - -
    - -
    - ); -} diff --git a/desktop/src/features/agents/ui/TeamShareDialog.tsx b/desktop/src/features/agents/ui/TeamShareDialog.tsx index 179af32b6a5..5328aa7b1b3 100644 --- a/desktop/src/features/agents/ui/TeamShareDialog.tsx +++ b/desktop/src/features/agents/ui/TeamShareDialog.tsx @@ -1,12 +1,18 @@ import * as React from "react"; +import { BookUser } from "lucide-react"; +import type { CatalogTeamShareLevel } from "@/features/agents/lib/teamCatalogRelay"; import { encodeTeamSnapshotForSend } from "@/shared/api/tauriTeams"; import type { AgentTeam } from "@/shared/api/types"; +import { Switch } from "@/shared/ui/switch"; import { SnapshotShareDialog } from "./PersonaShareDialog"; +import { teamCatalogCopy } from "./teamLibraryCopy"; type TeamShareDialogProps = { + catalogShareLevel: CatalogTeamShareLevel; isPending: boolean; + onCatalogShareLevelChange: (shareLevel: CatalogTeamShareLevel) => void; onExport: () => void; onOpenChange: (open: boolean) => void; open: boolean; @@ -14,7 +20,9 @@ type TeamShareDialogProps = { }; export function TeamShareDialog({ + catalogShareLevel, isPending, + onCatalogShareLevelChange, onExport, onOpenChange, open, @@ -28,6 +36,37 @@ export function TeamShareDialog({ return ( + +
    +

    + {teamCatalogCopy.shareTitle} +

    +

    + {teamCatalogCopy.shareDescription} +

    +
    + + onCatalogShareLevelChange(checked ? "none" : "not-shared") + } + style={{ cursor: "default" }} + /> + + ) + } displayName={team.name} encodeSnapshot={encodeSnapshot} hasMemoryOptions diff --git a/desktop/src/features/agents/ui/TeamsSection.tsx b/desktop/src/features/agents/ui/TeamsSection.tsx index 986c9bdc26b..debaf3206db 100644 --- a/desktop/src/features/agents/ui/TeamsSection.tsx +++ b/desktop/src/features/agents/ui/TeamsSection.tsx @@ -21,6 +21,7 @@ import { SectionHeader } from "@/shared/ui/PageHeader"; import { CreateIdentityCard } from "./CreateIdentityCard"; import { TeamIdentityCard } from "./TeamIdentityCard"; import { IDENTITY_CARD_GRID_CLASS } from "./UnifiedAgentsSection"; +import { teamCatalogCopy } from "./teamLibraryCopy"; const TEAM_CARD_COLUMN_CLASS = "w-full"; @@ -36,6 +37,7 @@ type TeamsSectionProps = { onDelete: (team: AgentTeam) => void; onAddToChannel: (team: AgentTeam) => void; onShare: (team: AgentTeam) => void; + onDiscover: () => void; onImport: () => void; }; @@ -51,6 +53,7 @@ export function TeamsSection({ onDelete, onAddToChannel, onShare, + onDiscover, onImport, }: TeamsSectionProps) { return ( @@ -87,6 +90,7 @@ export function TeamsSection({ {teams.map((team) => { @@ -191,10 +195,12 @@ export function TeamsSection({ function NewTeamCard({ isPending, onCreate, + onDiscover, onImport, }: { isPending: boolean; onCreate: () => void; + onDiscover: () => void; onImport: () => void; }) { return ( @@ -209,6 +215,13 @@ function NewTeamCard({ Create team + + {teamCatalogCopy.chooseFromCatalog} + Import diff --git a/desktop/src/features/agents/ui/catalogOwnerLabel.ts b/desktop/src/features/agents/ui/catalogOwnerLabel.ts new file mode 100644 index 00000000000..aad1d2d0c98 --- /dev/null +++ b/desktop/src/features/agents/ui/catalogOwnerLabel.ts @@ -0,0 +1,15 @@ +/** + * Derives the "Added by" label for a catalog entry from a resolved profile + * summary. Prefers `displayName`, falls back to `name`, then to the default + * "Community member" string when both are absent, null, or whitespace-only. + */ +export function resolveCatalogOwnerLabel( + summary: + | { displayName?: string | null; name?: string | null } + | null + | undefined, +): string { + return ( + summary?.displayName?.trim() || summary?.name?.trim() || "Community member" + ); +} diff --git a/desktop/src/features/agents/ui/personaCatalogOwnerLabel.test.mjs b/desktop/src/features/agents/ui/personaCatalogOwnerLabel.test.mjs index 0022be3d381..5fe8aadd88a 100644 --- a/desktop/src/features/agents/ui/personaCatalogOwnerLabel.test.mjs +++ b/desktop/src/features/agents/ui/personaCatalogOwnerLabel.test.mjs @@ -3,10 +3,8 @@ import test from "node:test"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { - AgentInstructionReview, - resolveCatalogOwnerLabel, -} from "./PersonaCatalogDialog.tsx"; +import { AgentInstructionReview } from "./CommunityCatalogDialog.tsx"; +import { resolveCatalogOwnerLabel } from "./catalogOwnerLabel.ts"; // ── null / undefined summary ────────────────────────────────────────────────── diff --git a/desktop/src/features/agents/ui/teamLibraryCopy.ts b/desktop/src/features/agents/ui/teamLibraryCopy.ts new file mode 100644 index 00000000000..9c32a829a0f --- /dev/null +++ b/desktop/src/features/agents/ui/teamLibraryCopy.ts @@ -0,0 +1,51 @@ +export const teamCatalogCopy = { + chooseFromCatalog: "Choose from catalog", + dialogTitle: "Team Catalog", + dialogDescription: "Browse teams shared to this relay.", + emptyCatalogTitle: "No teams are being shared", + emptyCatalogDescription: "Shared teams will appear here.", + addAction: "Add team", + addedAction: "Added to my teams", + addingAction: "Adding…", + shareTitle: "Share to catalog", + shareDescription: + "Anyone in this community can find and add a copy of this team. Both the team instructions and every member’s instructions are shared as plaintext. Memories and secrets aren’t included.", +} as const; + +/** + * The warning notice shown when the backend automatically queues a retraction + * for a shared team that can no longer be projected. + * + * "Queued" is accurate — the tombstone has been enqueued for the flush loop + * but the relay head may still be discoverable until the flush succeeds. + * Using "queued for removal" rather than "was removed" avoids a false claim + * that the catalog has already changed. + */ +export function teamAutoRetractedNotice( + teamName: string, + reason: string, +): string { + return `"${teamName}" has been queued for removal from the community catalog because it can no longer be projected: ${reason}`; +} + +/** + * The result message for a share toggle. + * + * `queued` is not a failure: the head is durably enqueued and the flush loop + * will publish it, so the copy promises eventual visibility rather than + * claiming the catalog already changed. + */ +export function teamShareNotice( + teamName: string, + shared: boolean, + publicationStatus: "published" | "queued", +): string { + if (publicationStatus === "queued") { + return shared + ? `Sharing ${teamName} is queued. It will appear after the relay accepts the update.` + : `Removing ${teamName} is queued. It may remain discoverable until the relay accepts the update.`; + } + return shared + ? `Published ${teamName} to the community catalog.` + : `${teamName} is no longer discoverable in the community catalog.`; +} diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index b4ef5afb6c8..268d336eaa5 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -115,7 +115,6 @@ export function usePersonaActions() { React.useState(null); const [snapshotImportConfirmError, setSnapshotImportConfirmError] = React.useState(null); - const [isCatalogDialogOpen, setIsCatalogDialogOpen] = React.useState(false); const [personaNoticeMessage, setPersonaNoticeMessage] = React.useState< string | null >(null); @@ -439,12 +438,6 @@ export function usePersonaActions() { setPersonaDialogState(duplicatePersonaDialogState(persona)); } - function openCatalog() { - clearFeedback("catalog"); - void catalogQuery.refetch(); - setIsCatalogDialogOpen(true); - } - function openDelete(persona: AgentPersona) { clearFeedback("library"); setPersonaToDelete(persona); @@ -585,8 +578,6 @@ export function usePersonaActions() { setPersonaToDelete, personaToShare, setPersonaToShare, - isCatalogDialogOpen, - setIsCatalogDialogOpen, personaNoticeMessage, personaErrorMessage, personaFeedbackSurface, @@ -597,7 +588,6 @@ export function usePersonaActions() { prepareCreate, openEdit, openDuplicate, - openCatalog, openDelete, openShare, personaToExportSnapshot, diff --git a/desktop/src/features/agents/ui/useTeamActions.ts b/desktop/src/features/agents/ui/useTeamActions.ts index 3652acf9541..2e838c0f523 100644 --- a/desktop/src/features/agents/ui/useTeamActions.ts +++ b/desktop/src/features/agents/ui/useTeamActions.ts @@ -10,7 +10,20 @@ import { useTeamsQuery, useUpdateTeamMutation, } from "@/features/agents/hooks"; +import type { CatalogTeamShareLevel } from "@/features/agents/lib/teamCatalogRelay"; +import { + catalogTeamsFromPublications, + type CatalogTeam, +} from "@/features/agents/lib/teamCatalogRelay"; +import { + useAddTeamFromCatalogMutation, + useSetTeamCatalogSharedMutation, + useTeamCatalogLiveUpdates, + useTeamCatalogQuery, +} from "@/features/agents/lib/useTeamCatalogRelay"; +import { useCommunities } from "@/features/communities/useCommunities"; import type { CreateChannelManagedAgentsResult } from "@/features/agents/channelAgents"; +import { useIdentityQuery } from "@/shared/api/hooks"; import { deletePersona } from "@/shared/api/tauriPersonas"; import { confirmTeamSnapshotImport, @@ -28,6 +41,7 @@ import type { UpdateTeamInput, } from "@/shared/api/types"; import { deriveImportToast } from "./teamSnapshotImport.lib"; +import { teamShareNotice } from "./teamLibraryCopy"; type TeamDialogState = { description: string; @@ -51,7 +65,14 @@ export function useTeamActions( refetch: RefetchCallbacks, ) { const queryClient = useQueryClient(); + const { activeCommunity } = useCommunities(); + const identityQuery = useIdentityQuery(); + const communityId = activeCommunity?.id ?? null; const teamsQuery = useTeamsQuery(); + const catalogQuery = useTeamCatalogQuery(communityId); + useTeamCatalogLiveUpdates(communityId); + const setCatalogSharedMutation = useSetTeamCatalogSharedMutation(communityId); + const addTeamFromCatalogMutation = useAddTeamFromCatalogMutation(); const createTeamMutation = useCreateTeamMutation(); const updateTeamMutation = useUpdateTeamMutation(); const deleteTeamMutation = useDeleteTeamMutation(); @@ -103,6 +124,16 @@ export function useTeamActions( }); const teams = teamsQuery.data ?? []; + const publications = catalogQuery.data ?? []; + const catalogTeams = React.useMemo( + () => + catalogTeamsFromPublications( + publications, + teams, + identityQuery.data?.pubkey, + ), + [identityQuery.data?.pubkey, publications, teams], + ); async function handleTeamSubmit(input: CreateTeamInput | UpdateTeamInput) { actions.setActionNoticeMessage(null); @@ -233,6 +264,73 @@ export function useTeamActions( setTeamToShare(team); } + function getTeamCatalogShareLevel(team: AgentTeam): CatalogTeamShareLevel { + return team.shared ? "none" : "not-shared"; + } + + async function setTeamCatalogShareLevel( + team: AgentTeam, + shareLevel: CatalogTeamShareLevel, + ): Promise { + if (team.isBuiltin) return; + + actions.setActionNoticeMessage(null); + actions.setActionErrorMessage(null); + const shared = shareLevel !== "not-shared"; + try { + const result = await setCatalogSharedMutation.mutateAsync({ + id: team.id, + shared, + }); + // The open dialog holds its own copy of the team, so re-point it at the + // returned record — otherwise the toggle snaps back to its old value. + setTeamToShare((current) => + current?.id === result.team.id ? result.team : current, + ); + actions.setActionNoticeMessage( + teamShareNotice(team.name, shared, result.publicationStatus), + ); + } catch (error) { + actions.setActionErrorMessage( + error instanceof Error + ? error.message + : `Failed to ${shared ? "share" : "unshare"} team.`, + ); + } + } + + /** + * Add a published team. + * + * Only the coordinate is sent; the backend re-verifies the head, so an entry + * retracted or republished while the dialog sat open fails loudly here + * rather than copying a stale projection. + */ + async function handleAddTeamFromCatalog( + team: CatalogTeam, + onSuccess?: () => void, + ): Promise { + actions.setActionNoticeMessage(null); + actions.setActionErrorMessage(null); + try { + const result = await addTeamFromCatalogMutation.mutateAsync({ + ownerPubkey: team.ownerPubkey, + teamDTag: team.teamDTag, + eventId: team.eventId, + }); + actions.setActionNoticeMessage( + result.alreadyPresent + ? `${result.team.name} is already in your teams.` + : `Added ${result.team.name} to your teams.`, + ); + onSuccess?.(); + } catch (error) { + actions.setActionErrorMessage( + error instanceof Error ? error.message : "Failed to add team.", + ); + } + } + function handleExportTeamSnapshot( team: AgentTeam, memoryLevel: SnapshotMemoryLevel, @@ -317,6 +415,10 @@ export function useTeamActions( return { teams, teamsQuery, + catalogQuery, + catalogTeams, + isAddingFromCatalog: addTeamFromCatalogMutation.isPending, + isCatalogSharePending: setCatalogSharedMutation.isPending, createTeamMutation, updateTeamMutation, deleteTeamMutation, @@ -344,6 +446,9 @@ export function useTeamActions( openEditDialog, openExportSnapshot, openShare, + getTeamCatalogShareLevel, + setTeamCatalogShareLevel, + handleAddTeamFromCatalog, handleExportTeamSnapshot, handleImportTeamSnapshotFile, handleConfirmTeamSnapshotImport, diff --git a/desktop/src/features/profile/ui/ProfileAvatar.tsx b/desktop/src/features/profile/ui/ProfileAvatar.tsx index 3153fb4be1c..106016a8f8e 100644 --- a/desktop/src/features/profile/ui/ProfileAvatar.tsx +++ b/desktop/src/features/profile/ui/ProfileAvatar.tsx @@ -9,6 +9,16 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { Avatar, AvatarFallback, AvatarImage } from "@/shared/ui/avatar"; import { Spinner } from "@/shared/ui/spinner"; +/** + * A `data:` URL is inlined bytes — rendering it makes no network request, so an + * untrusted publisher cannot use it to observe the viewer's IP or browse + * timing. Every other scheme (`http(s):`, `blob:`, relative) can reach the + * network and is suppressed under `untrusted`. + */ +function isInlineDataUrl(url: string): boolean { + return /^data:/i.test(url); +} + type ProfileAvatarProps = { avatarUrl: string | null; avatarDataUrl?: string | null; @@ -18,6 +28,17 @@ type ProfileAvatarProps = { imageClassName?: string; plain?: boolean; testId?: string; + /** + * Suppress every network image request for a publisher-controlled avatar + * URL, rendering the initials/icon placeholder instead. Community-catalog + * browse projects avatar URLs straight from untrusted publications; loading + * them would hand the viewer's IP and browse timing to up to 64 attacker- + * chosen hosts before the user adds anything. Inline `data:` avatars (e.g. + * emoji avatars, and a trusted locally cached `avatarDataUrl`) carry no + * network origin, so they still render — only network-capable schemes are + * blocked. + */ + untrusted?: boolean; }; export function ProfileAvatar({ @@ -29,6 +50,7 @@ export function ProfileAvatar({ imageClassName, plain = false, testId, + untrusted = false, }: ProfileAvatarProps) { const initials = getInitials(label); const presentation = useAvatarPresentation(avatarUrl); @@ -45,8 +67,19 @@ export function ProfileAvatar({ : presentedAvatarUrl; // Compute the live (proxied) source. Failures are tracked per resolved URL so - // the poster and hover animation can recover independently. - const liveSrc = baseUrl ? rewriteRelayUrl(baseUrl) : null; + // the poster and hover animation can recover independently. Under `untrusted` + // (publisher-controlled catalog browse) only an inline `data:` URL renders — + // it carries no network origin, so it can't leak the viewer's IP; every + // network-capable scheme is suppressed to the placeholder. This keeps emoji + // avatars (persisted as inline `data:image/svg+xml`) visible while blocking + // the up-to-64 attacker-chosen host fetches Carl flagged. + const liveSrc = !baseUrl + ? null + : untrusted + ? isInlineDataUrl(baseUrl) + ? baseUrl + : null + : rewriteRelayUrl(baseUrl); const [failedSrc, setFailedSrc] = React.useState(null); const liveFailed = liveSrc !== null && failedSrc === liveSrc; diff --git a/desktop/src/features/profile/ui/ProfileAvatarUntrusted.test.mjs b/desktop/src/features/profile/ui/ProfileAvatarUntrusted.test.mjs new file mode 100644 index 00000000000..a090bd4a4de --- /dev/null +++ b/desktop/src/features/profile/ui/ProfileAvatarUntrusted.test.mjs @@ -0,0 +1,136 @@ +/** + * Untrusted catalog-browse avatars must never fire a network image request. + * + * The community catalog projects publisher-controlled `avatarUrl` values into + * member/persona rows. Radix's `AvatarImage` resolves its loading status by + * assigning `image.src` on a `new window.Image()`, so a browsed row would fetch + * up to 64 attacker-chosen hosts — handing the viewer's IP and browse timing to + * publishers — before the user adds anything. `referrerPolicy` does not stop the + * request itself. `ProfileAvatar untrusted` must render the initials/icon + * placeholder with zero remote fetch; a trusted local `avatarDataUrl` still + * renders because it carries no network origin. + */ + +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +// Radix builds a detached `new window.Image()` and assigns `.src` to probe +// load status; that assignment is the actual network request. Spy on it so the +// test observes the fetch rather than post-load DOM (which never mounts in +// jsdom because the probe never fires `load`). +const imageSrcAssignments = []; + +class SpyImage { + constructor() { + this.complete = false; + this.naturalWidth = 0; + this._src = ""; + } + addEventListener() {} + removeEventListener() {} + set src(value) { + this._src = value; + imageSrcAssignments.push(value); + } + get src() { + return this._src; + } +} + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + dom.window.Image = SpyImage; + globalThis.Image = SpyImage; +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); + imageSrcAssignments.length = 0; +}); + +after(() => dom.window.close()); + +let React; +let render; +let act; +let ProfileAvatar; + +before(async () => { + React = (await import("react")).default; + ({ render, act } = await import("@testing-library/react")); + ({ ProfileAvatar } = await import("./ProfileAvatar.tsx")); +}); + +const PUBLISHER_URL = "https://attacker.example/beacon.png"; + +const networkAssignments = () => + imageSrcAssignments.filter((src) => /^https?:/i.test(src)); + +async function renderAvatar(props) { + await act(async () => { + render(React.createElement(ProfileAvatar, props)); + }); +} + +test("untrusted avatar fires no network image request", async () => { + await renderAvatar({ + avatarUrl: PUBLISHER_URL, + label: "Mallory", + untrusted: true, + }); + + assert.deepEqual(networkAssignments(), []); +}); + +test("a trusted avatar still fetches the publisher URL", async () => { + // Reversal witness: the guard is what suppresses the fetch. Without + // `untrusted`, the same URL is requested — the exact leak Carl flagged. + await renderAvatar({ avatarUrl: PUBLISHER_URL, label: "Mallory" }); + + assert.deepEqual(networkAssignments(), [PUBLISHER_URL]); +}); + +test("untrusted avatar still renders a locally cached data URL", async () => { + // A trusted, locally cached data URL carries no network origin, so it must + // keep rendering even while the remote fetch is blocked. + const dataUrl = "data:image/png;base64,AA"; + await renderAvatar({ + avatarUrl: PUBLISHER_URL, + avatarDataUrl: dataUrl, + label: "Mallory", + untrusted: true, + }); + + assert.deepEqual(networkAssignments(), []); + assert.deepEqual(imageSrcAssignments, [dataUrl]); +}); + +test("untrusted avatar renders an inline data: avatarUrl (emoji avatar)", async () => { + // Emoji avatars persist as an inline `data:image/svg+xml` value in + // `avatarUrl`, not a hosted URL. Blocking it isn't privacy — a `data:` URL + // makes zero network requests — it's a regression that drops every emoji + // avatar in catalog browse to initials. Under `untrusted`, an inline `data:` + // scheme must still render. + const emojiDataUrl = + "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'/%3E"; + await renderAvatar({ + avatarUrl: emojiDataUrl, + label: "Mallory", + untrusted: true, + }); + + assert.deepEqual(networkAssignments(), []); + assert.deepEqual(imageSrcAssignments, [emojiDataUrl]); +}); diff --git a/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx b/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx index 229941ec50c..98a1df20187 100644 --- a/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx +++ b/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx @@ -47,8 +47,8 @@ import { const CUSTOM_ENTRY_ID = "\u0000custom"; /** - * "Add runtimes" — master-detail catalog dialog, modeled on the Agent - * Catalog (PersonaCatalogDialog): searchable left chooser, right detail pane + * "Add runtimes" — master-detail catalog dialog, modeled on the Community + * Catalog (CommunityCatalogDialog): searchable left chooser, right detail pane * with one neutral vendor-sourced sentence, operational setup state, and * technical details, plus a primary Install / setup-guide CTA pinned in a * bottom action bar (same position as the custom-harness Save button). diff --git a/desktop/src/shared/api/tauriTeams.ts b/desktop/src/shared/api/tauriTeams.ts index a2c7fdf1d72..e83201249c2 100644 --- a/desktop/src/shared/api/tauriTeams.ts +++ b/desktop/src/shared/api/tauriTeams.ts @@ -2,9 +2,16 @@ import { invokeTauri } from "@/shared/api/tauri"; import type { AgentTeam, CreateTeamInput, + TeamCatalogSourceCoordinate, UpdateTeamInput, } from "@/shared/api/types"; +/** Wire shape of `TeamCatalogSource` — snake_case, like its parent record. */ +type RawTeamCatalogSource = { + owner_pubkey: string; + team_d_tag: string; +}; + type RawTeam = { id: string; name: string; @@ -12,6 +19,8 @@ type RawTeam = { instructions?: string | null; persona_ids: string[]; is_builtin?: boolean; + shared?: boolean; + catalog_source?: RawTeamCatalogSource | null; source_dir?: string | null; is_symlink?: boolean; symlink_target?: string | null; @@ -20,6 +29,14 @@ type RawTeam = { updated_at: string; }; +function fromRawCatalogSource( + source: RawTeamCatalogSource | null | undefined, +): TeamCatalogSourceCoordinate | null { + return source + ? { ownerPubkey: source.owner_pubkey, teamDTag: source.team_d_tag } + : null; +} + function fromRawTeam(team: RawTeam): AgentTeam { return { id: team.id, @@ -28,6 +45,8 @@ function fromRawTeam(team: RawTeam): AgentTeam { instructions: team.instructions ?? null, personaIds: team.persona_ids, isBuiltin: team.is_builtin ?? false, + shared: team.shared ?? false, + catalogSource: fromRawCatalogSource(team.catalog_source), sourceDir: team.source_dir ?? null, isSymlink: team.is_symlink ?? false, symlinkTarget: team.symlink_target ?? null, @@ -72,6 +91,71 @@ export async function deleteTeam(id: string): Promise { await invokeTauri("delete_team", { id }); } +// ── Team catalog commands ──────────────────────────────────────────────────── + +export type TeamSharePublicationResult = { + team: AgentTeam; + /** `queued` means the head is durably enqueued but the relay has not yet + * accepted it, so catalog visibility lags the toggle. */ + publicationStatus: "published" | "queued"; +}; + +type RawTeamSharePublicationResult = { + team: RawTeam; + publicationStatus: "published" | "queued"; +}; + +/** Publish this team's catalog head, or replace it with an untagged one. */ +export async function setTeamShared( + id: string, + shared: boolean, +): Promise { + const raw = await invokeTauri( + "set_team_shared", + { id, shared }, + ); + return { + team: fromRawTeam(raw.team), + publicationStatus: raw.publicationStatus, + }; +} + +export type AddTeamFromCatalogResult = { + team: AgentTeam; + /** True when the team was already added and nothing was written. */ + alreadyPresent: boolean; +}; + +type RawAddTeamFromCatalogResult = { + team: RawTeam; + alreadyPresent: boolean; +}; + +/** + * Copy a published team into the local stores. + * + * Only the coordinate crosses the boundary — never the projection the UI is + * displaying. The backend re-fetches the current head at + * `30178::` and rejects the add if it is not `eventId` or has + * stopped being shared, so a catalog entry that moved or was retracted while + * the dialog sat open cannot be copied. + */ +export async function addTeamFromCatalog( + source: TeamCatalogSourceCoordinate & { eventId: string }, +): Promise { + const raw = await invokeTauri( + "add_team_from_catalog", + { + input: { + ownerPubkey: source.ownerPubkey, + teamDTag: source.teamDTag, + eventId: source.eventId, + }, + }, + ); + return { team: fromRawTeam(raw.team), alreadyPresent: raw.alreadyPresent }; +} + // ── Team snapshot types ───────────────────────────────────────────────────── export type SnapshotFormat = "json" | "png"; @@ -113,27 +197,10 @@ export type TeamSnapshotImportMemberResult = { profileSyncError: string | null; }; -/** Wire shape of the nested `TeamRecord` — Rust has no `rename_all` so fields - * arrive in snake_case, matching the existing `RawTeam` convention. */ -type RawTeamRecord = { - id: string; - name: string; - description: string | null; - persona_ids: string[]; - instructions: string | null; - is_builtin: boolean; - source_dir: string | null; - is_symlink: boolean; - symlink_target: string | null; - version: string | null; - created_at: string; - updated_at: string; -}; - /** Raw wire shape of the import result — outer struct is camelCase, * but the nested `team` field is snake_case (no `rename_all` on TeamRecord). */ type RawTeamSnapshotImportResult = { - team: RawTeamRecord; + team: RawTeam; personaIds: string[]; members: TeamSnapshotImportMemberResult[]; }; diff --git a/desktop/src/shared/api/teamTypes.ts b/desktop/src/shared/api/teamTypes.ts new file mode 100644 index 00000000000..bd0e19617ae --- /dev/null +++ b/desktop/src/shared/api/teamTypes.ts @@ -0,0 +1,61 @@ +/** + * Team library wire types. + * + * Split out of `types.ts` the same way `searchTypes` and `socialTypes` are: + * they are one cohesive group, and `types.ts` is at its size ceiling. + */ + +/** + * A publication's coordinate in the kind:30178 team catalog. Mirrors the + * backend `TeamCatalogSource`. + * + * Deliberately not `CatalogSourceCoordinate`: that one addresses a kind:30175 + * persona, and a team `d`-tag resolved in the persona namespace names a + * different — possibly unrelated — event. + */ +export type TeamCatalogSourceCoordinate = { + ownerPubkey: string; + teamDTag: string; +}; + +export type AgentTeam = { + id: string; + name: string; + description: string | null; + instructions: string | null; + personaIds: string[]; + isBuiltin: boolean; + /** Whether this team is discoverable in the active community catalog. */ + shared: boolean; + /** + * Set only on a local copy of another owner's shared team. A copy carries a + * fresh local `id`, so this coordinate is the only thing that can answer "is + * this catalog entry already added" without minting a duplicate. + */ + catalogSource: TeamCatalogSourceCoordinate | null; + /** Absolute path to the team's backing directory (if directory-backed). */ + sourceDir: string | null; + /** Whether sourceDir is a symlink to an external directory. */ + isSymlink: boolean; + /** Resolved symlink target path (for display). Only set when isSymlink is true. */ + symlinkTarget: string | null; + /** Version from the team's plugin.json manifest. */ + version: string | null; + createdAt: string; + updatedAt: string; +}; + +export type CreateTeamInput = { + name: string; + description?: string; + instructions?: string; + personaIds: string[]; +}; + +export type UpdateTeamInput = { + id: string; + name: string; + description?: string; + instructions?: string; + personaIds: string[]; +}; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index d2251c25a56..7528998592d 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -788,39 +788,13 @@ export type UpdatePersonaInput = { }; // ── Team types ──────────────────────────────────────────────────────────────── -export type AgentTeam = { - id: string; - name: string; - description: string | null; - instructions: string | null; - personaIds: string[]; - isBuiltin: boolean; - /** Absolute path to the team's backing directory (if directory-backed). */ - sourceDir: string | null; - /** Whether sourceDir is a symlink to an external directory. */ - isSymlink: boolean; - /** Resolved symlink target path (for display). Only set when isSymlink is true. */ - symlinkTarget: string | null; - /** Version from the team's plugin.json manifest. */ - version: string | null; - createdAt: string; - updatedAt: string; -}; - -export type CreateTeamInput = { - name: string; - description?: string; - instructions?: string; - personaIds: string[]; -}; +export type { + AgentTeam, + CreateTeamInput, + TeamCatalogSourceCoordinate, + UpdateTeamInput, +} from "./teamTypes"; -export type UpdateTeamInput = { - id: string; - name: string; - description?: string; - instructions?: string; - personaIds: string[]; -}; // ── Channel Template types ───────────────────────────────────────────────────── export type TemplateBackend = diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 4f8b7afe2bd..5d4308c5c27 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -57,6 +57,11 @@ export const KIND_COMMUNITY_THEME = 30078; export const KIND_PERSONA = 30175; export const KIND_TEAM = 30176; export const KIND_MANAGED_AGENT = 30177; +// Team catalog projection: a self-contained snapshot of a team plus every +// member's safe definition, so a recipient can rebuild it without reading the +// publisher's personas. Separate from KIND_TEAM (30176, the team's own wire +// body) so an ordinary team edit cannot disturb catalog share state. +export const KIND_TEAM_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/shared/styles/globals/theme.css b/desktop/src/shared/styles/globals/theme.css index 5fd2593c79c..644181a3881 100644 --- a/desktop/src/shared/styles/globals/theme.css +++ b/desktop/src/shared/styles/globals/theme.css @@ -419,7 +419,7 @@ * SCOPED to the app sidebar container, NOT :root - the `bg-sidebar-active` / * `text-sidebar-active-foreground` tokens are also consumed by non-sidebar * controls (avatar edit buttons in ProfileSettingsCard / AgentCreationPreview, - * the selected persona row in PersonaCatalogDialog). A root-level override + * the selected row in CommunityCatalogDialog). A root-level override * turned those white (white-on-white under Buzz Dark); scoping keeps them on * the normal accent-driven active colors. */ diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index bf16dd3b00a..eb3b1485959 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -59,6 +59,7 @@ import { KIND_STREAM_MESSAGE_EDIT, KIND_SYSTEM_MESSAGE, KIND_TEXT_NOTE, + KIND_TEAM_CATALOG, KIND_USER_STATUS, } from "@/shared/constants/kinds"; import type { @@ -314,6 +315,10 @@ type E2eConfig = { /** Outcomes for successive explicit persona share publications. */ personaSharePublicationStatuses?: Array<"published" | "queued">; teams?: MockTeamSeed[]; + /** Community team-catalog (kind:30178) heads returned by relay queries. */ + teamCatalogEvents?: RelayEvent[]; + /** Outcomes for successive explicit team share publications. */ + teamSharePublicationStatuses?: Array<"published" | "queued">; relayAgents?: MockRelayAgentSeed[]; /** Reject successive relay-agent directory reads, then resume. */ relayAgentListErrors?: (string | null)[]; @@ -1008,6 +1013,8 @@ type RawTeam = { description: string | null; persona_ids: string[]; is_builtin: boolean; + shared?: boolean; + catalog_source?: { owner_pubkey: string; team_d_tag: string } | null; source_dir: string | null; is_symlink: boolean; symlink_target: string | null; @@ -1242,6 +1249,12 @@ declare global { members: MockHuddleMemberSeed[]; transcriptionEnabled: boolean; }) => Promise; + /** + * Replace the stored kind:30178 head for a coordinate WITHOUT notifying + * live subscribers. Reproduces a head that moved on the relay while a + * catalog dialog sat open holding the superseded event id. + */ + __BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__?: (event: RelayEvent) => void; __BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: RawFeedItem) => RawFeedItem; /** Replace an existing feed item by id (or push if not found) and fire the updated event. */ __BUZZ_E2E_REPLACE_MOCK_FEED_ITEM__?: ( @@ -3135,6 +3148,7 @@ const deferredSendMessageLiveEchoes: Array<{ const mockUserStatuses: RelayEvent[] = []; const mockReminderEvents: RelayEvent[] = []; const mockPersonaEvents: RelayEvent[] = []; +const mockTeamCatalogEvents: RelayEvent[] = []; let mockRelayMembers: RawRelayMember[] = []; const mockSockets = new Map(); const mockAuthResponses: Array<{ success: boolean; message: string }> = []; @@ -3298,7 +3312,7 @@ function mockPersonaCatalogPublications() { const coordinate = `${ownerPubkey}:${sourcePersonaId}`; if (claimed.has(coordinate)) continue; claimed.add(coordinate); - if (!personaHasExactSharedTag(event)) continue; + if (!hasExactSharedTag(event)) continue; let content: Record; try { content = JSON.parse(event.content) as Record; @@ -3402,6 +3416,73 @@ function mockPersonaCatalogPublications() { return publications; } +function resetMockTeamCatalogEvents(config: E2eConfig | undefined) { + mockTeamCatalogEvents.length = 0; + for (const event of config?.mock?.teamCatalogEvents ?? []) { + mockTeamCatalogEvents.push({ + ...event, + tags: event.tags.map((tag) => [...tag]), + }); + } +} + +// Mirrors the head-selection half of `fetch_team_catalog` (team_catalog.rs): +// NIP-33 head selection per coordinate and the exact `shared` gate. A +// coordinate is claimed before the shared/parse checks so an unshared or +// malformed newest head cannot resurrect an older shared one. Content parsing +// here is a shallow shape check (`v`, `name`, `members` is an array), not the +// native per-member validation — production trust rests on the Rust command. +function mockTeamCatalogPublications() { + const publications = []; + const claimed = new Set(); + for (const event of [...mockTeamCatalogEvents].sort( + (a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id), + )) { + const dTags = event.tags.filter((tag) => tag[0] === "d"); + if (dTags.length !== 1 || !dTags[0]?.[1]) continue; + const teamDTag = dTags[0][1]; + const ownerPubkey = event.pubkey.toLowerCase(); + const coordinate = `${ownerPubkey}:${teamDTag}`; + if (claimed.has(coordinate)) continue; + claimed.add(coordinate); + if (!hasExactSharedTag(event)) continue; + let content: Record; + try { + content = JSON.parse(event.content) as Record; + } catch { + continue; + } + if (content.v !== 1 || typeof content.name !== "string") continue; + if (!Array.isArray(content.members)) continue; + const optionalString = (value: unknown) => + typeof value === "string" && value.trim() ? value : null; + publications.push({ + eventId: event.id, + ownerPubkey, + teamDTag, + name: content.name, + description: optionalString(content.description), + instructions: optionalString(content.instructions), + members: content.members.map((member) => { + const record = member as Record; + return { + memberKey: record.member_key, + displayName: record.display_name, + systemPrompt: + typeof record.system_prompt === "string" + ? record.system_prompt + : "", + avatarUrl: optionalString(record.avatar_url), + runtime: optionalString(record.runtime), + model: optionalString(record.model), + provider: optionalString(record.provider), + }; + }), + }); + } + return publications; +} + // Mesh-compute mock state — TEST-ONLY. // // This entire module (e2eBridge.ts) is loaded only when `window.__BUZZ_E2E__` @@ -4705,9 +4786,9 @@ function emitOrDeferMockSendMessageLiveEcho( function emitMockGlobalEvent(event: RelayEvent) { if ( - event.kind === KIND_PERSONA && + (event.kind === KIND_PERSONA || event.kind === KIND_TEAM_CATALOG) && event.pubkey.toLowerCase() !== MOCK_IDENTITY_PUBKEY.toLowerCase() && - !personaHasExactSharedTag(event) + !hasExactSharedTag(event) ) { return; } @@ -8302,6 +8383,7 @@ const MOCK_PASSPHRASE_WORDS = [ // Per-page explicit catalog publication outcomes. let personaSharePublicationCallCount = 0; +let teamSharePublicationCallCount = 0; // Per-page confirm_team_snapshot_import call counter for sequenced error testing. let teamSnapshotConfirmCallCount = 0; @@ -8695,7 +8777,7 @@ async function handleSetPersonaActive(args: { return { ...persona }; } -function personaHasExactSharedTag(event: RelayEvent): boolean { +function hasExactSharedTag(event: RelayEvent): boolean { const tags = event.tags.filter((tag) => tag[0] === "shared"); return tags.length === 1 && tags[0]?.length === 2 && tags[0]?.[1] === "true"; } @@ -8824,11 +8906,12 @@ function ensureMockPersonaIdsAreActive(personaIds: string[]) { } } +function cloneMockTeam(team: RawTeam): RawTeam { + return { ...team, persona_ids: [...team.persona_ids] }; +} + async function handleListTeams(): Promise { - return mockTeams.map((team) => ({ - ...team, - persona_ids: [...team.persona_ids], - })); + return mockTeams.map(cloneMockTeam); } async function handleCreateTeam(args: { @@ -8887,6 +8970,184 @@ async function handleDeleteTeam(args: { id: string }): Promise { mockTeams = mockTeams.filter((candidate) => candidate.id !== args.id); } +// ── Team catalog (kind:30178) ─────────────────────────────────────────────── + +/** The team's catalog projection, as `team_catalog_content` builds it. */ +function mockTeamCatalogContent(team: RawTeam): string { + return JSON.stringify({ + v: 1, + name: team.name, + description: team.description, + instructions: null, + members: team.persona_ids.map((personaId) => { + const persona = mockPersonas.find( + (candidate) => candidate.id === personaId, + ); + return { + member_key: personaId, + display_name: persona?.display_name ?? personaId, + system_prompt: persona?.system_prompt ?? "", + avatar_url: persona?.avatar_url ?? null, + runtime: persona?.runtime ?? null, + model: persona?.model ?? null, + }; + }), + }); +} + +function upsertMockTeamCatalogEvent( + team: RawTeam, + identity?: TestIdentity, +): void { + const template = { + created_at: Math.floor(Date.now() / 1_000), + kind: KIND_TEAM_CATALOG, + tags: [["d", team.id], ...(team.shared ? [["shared", "true"]] : [])], + content: mockTeamCatalogContent(team), + }; + const event: RelayEvent = identity + ? finalizeEvent(template, hexToBytes(identity.privateKey)) + : { + ...template, + id: mockEventId(), + pubkey: MOCK_IDENTITY_PUBKEY, + sig: "0".repeat(128), + }; + const existingIndex = mockTeamCatalogEvents.findIndex( + (candidate) => + candidate.pubkey.toLowerCase() === event.pubkey.toLowerCase() && + candidate.tags.some((tag) => tag[0] === "d" && tag[1] === team.id), + ); + if (existingIndex >= 0) { + mockTeamCatalogEvents.splice(existingIndex, 1); + } + mockTeamCatalogEvents.push(event); + emitMockGlobalEvent(event); +} + +type MockTeamPublicationResult = { + team: RawTeam; + publicationStatus: "published" | "queued"; +}; + +/** + * Mirrors `set_team_shared`. A `queued` outcome must NOT make the head visible + * to catalog readers — that lag is exactly what the UI copy reports. + */ +async function handleSetTeamShared( + args: { id: string; shared: boolean }, + config?: E2eConfig, +): Promise { + const team = mockTeams.find((candidate) => candidate.id === args.id); + if (!team) { + throw new Error(`Team ${args.id} not found.`); + } + if (team.is_builtin) { + throw new Error("Built-in teams cannot be shared to the catalog."); + } + team.shared = args.shared; + team.updated_at = new Date().toISOString(); + + const publicationStatus = + config?.mock?.teamSharePublicationStatuses?.[ + teamSharePublicationCallCount++ + ] ?? "published"; + if (publicationStatus === "published") { + upsertMockTeamCatalogEvent(team, getActiveIdentity(config)); + } + return { + team: cloneMockTeam(team), + publicationStatus, + }; +} + +/** + * Mirrors `add_team_from_catalog`, including the canonical-head check: the + * coordinate is re-resolved against the current heads and the add is rejected + * unless that head is still `eventId` and still shared. A test that stales the + * head must see the same failure the real command produces. + */ +async function handleAddTeamFromCatalog(args: { + input: { ownerPubkey: string; teamDTag: string; eventId: string }; +}): Promise<{ team: RawTeam; alreadyPresent: boolean }> { + const { ownerPubkey, teamDTag, eventId } = args.input; + const owner = ownerPubkey.toLowerCase(); + const head = mockTeamCatalogEvents + .filter( + (event) => + event.pubkey.toLowerCase() === owner && + event.tags.filter((tag) => tag[0] === "d").length === 1 && + event.tags.some((tag) => tag[0] === "d" && tag[1] === teamDTag), + ) + .sort( + (left, right) => + right.created_at - left.created_at || left.id.localeCompare(right.id), + )[0]; + + if (!head || !hasExactSharedTag(head)) { + throw new Error("This team is no longer shared to the catalog."); + } + if (head.id !== eventId) { + throw new Error( + "This team was updated since you opened the catalog. Reopen it and try again.", + ); + } + + const existing = mockTeams.find( + (candidate) => + candidate.catalog_source?.owner_pubkey === owner && + candidate.catalog_source?.team_d_tag === teamDTag, + ); + if (existing) { + return { team: cloneMockTeam(existing), alreadyPresent: true }; + } + + const content = JSON.parse(head.content) as { + name: string; + description: string | null; + members: Array<{ + member_key: string; + display_name: string; + system_prompt: string; + avatar_url: string | null; + }>; + }; + const now = new Date().toISOString(); + const personaIds = content.members.map((member) => { + const id = crypto.randomUUID(); + mockPersonas.push({ + id, + display_name: member.display_name, + avatar_url: member.avatar_url, + system_prompt: member.system_prompt, + is_builtin: false, + is_active: true, + shared: false, + env_vars: {}, + created_at: now, + updated_at: now, + }); + return id; + }); + const team: RawTeam = { + id: crypto.randomUUID(), + name: content.name, + description: content.description, + persona_ids: personaIds, + is_builtin: false, + shared: false, + catalog_source: { owner_pubkey: owner, team_d_tag: teamDTag }, + source_dir: null, + is_symlink: false, + symlink_target: null, + version: null, + created_at: now, + updated_at: now, + }; + mockTeams.push(team); + return { team: cloneMockTeam(team), alreadyPresent: false }; +} + async function handleExportTeamToJson(args: { id: string }): Promise { const team = mockTeams.find((candidate) => candidate.id === args.id); if (!team) { @@ -10458,7 +10719,7 @@ function sendToMockSocket(args: { if (authors && !authors.includes(event.pubkey.toLowerCase())) continue; if ( event.pubkey.toLowerCase() !== MOCK_IDENTITY_PUBKEY.toLowerCase() && - !personaHasExactSharedTag(event) + !hasExactSharedTag(event) ) { continue; } @@ -10470,6 +10731,27 @@ function sendToMockSocket(args: { return; } + if (filter.kinds?.includes(KIND_TEAM_CATALOG)) { + const authors = filter.authors?.map((author) => author.toLowerCase()); + const teamDTags = filter["#d"]; + for (const event of mockTeamCatalogEvents) { + if (authors && !authors.includes(event.pubkey.toLowerCase())) continue; + // Own heads are readable unshared (the owner sees their own state); + // anyone else's must carry the exact shared tag, like the relay gate. + if ( + event.pubkey.toLowerCase() !== MOCK_IDENTITY_PUBKEY.toLowerCase() && + !hasExactSharedTag(event) + ) { + continue; + } + const teamDTag = event.tags.find((tag) => tag[0] === "d")?.[1]; + if (teamDTags && (!teamDTag || !teamDTags.includes(teamDTag))) 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 or by issue/PR root `e` tag (discussions, approvals, review // requests, assignment operations). Channel messages are kind 9, so a @@ -10599,7 +10881,7 @@ function sendToMockSocket(args: { const sharedTags = event.tags.filter((tag) => tag[0] === "shared"); if ( sharedTags.length > 1 || - (sharedTags.length === 1 && !personaHasExactSharedTag(event)) + (sharedTags.length === 1 && !hasExactSharedTag(event)) ) { sendWsText(socket.handler, [ "OK", @@ -10799,6 +11081,7 @@ export function maybeInstallE2eTauriMocks() { resetMockUserStatuses(); resetMockPersonaCatalogEvents(config); resetMockObservedUnread(); + resetMockTeamCatalogEvents(config); resetMockSaveSubscriptions(config); resetMockPendingCommunityDeepLinks(config); resetMockPendingNavigationDeepLinks(config); @@ -10911,6 +11194,18 @@ export function maybeInstallE2eTauriMocks() { ownerPubkey, kind, }) => hasMockOwnerKindSubscription(ownerPubkey, kind); + window.__BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__ = (event) => { + const dTag = event.tags.find((tag) => tag[0] === "d")?.[1]; + const existingIndex = mockTeamCatalogEvents.findIndex( + (candidate) => + candidate.pubkey.toLowerCase() === event.pubkey.toLowerCase() && + candidate.tags.some((tag) => tag[0] === "d" && tag[1] === dTag), + ); + if (existingIndex >= 0) { + mockTeamCatalogEvents.splice(existingIndex, 1); + } + mockTeamCatalogEvents.push(event); + }; window.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ = (item) => { const category = item.category === "mention" ? "mentions" : item.category; mockFeedOverrides[category].unshift(item); @@ -12879,6 +13174,15 @@ export function maybeInstallE2eTauriMocks() { ); case "list_teams": return handleListTeams(); + case "set_team_shared": + return handleSetTeamShared( + payload as Parameters[0], + activeConfig, + ); + case "add_team_from_catalog": + return handleAddTeamFromCatalog( + payload as Parameters[0], + ); case "list_channel_templates": return (activeConfig?.mock?.channelTemplates ?? []).map((template) => ({ id: template.id, @@ -13984,6 +14288,8 @@ export function maybeInstallE2eTauriMocks() { return null; case "fetch_persona_catalog": return mockPersonaCatalogPublications(); + case "fetch_team_catalog": + return mockTeamCatalogPublications(); case "channel_head_cache_load": { const args = payload as { scope: { pubkey: string; relayUrl: string }; diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index b81c5889f3a..ac57b30aee7 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -86,7 +86,7 @@ async function openPersonaCatalog(page: import("@playwright/test").Page) { async function getCatalogOrder(page: import("@playwright/test").Page) { return page - .locator('[data-testid^="persona-catalog-list-item-"]') + .locator('[data-testid^="community-catalog-agent-"]') .evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid") ?? ""), ); @@ -96,7 +96,7 @@ async function selectCatalogPersona( page: import("@playwright/test").Page, personaId: string, ) { - await page.getByTestId(`persona-catalog-list-item-${personaId}`).click(); + await page.getByTestId(`community-catalog-agent-${personaId}`).click(); } async function sharePersonaToCatalog( @@ -245,24 +245,26 @@ test("catalog hides built-ins and shows the shared-agent empty state", async ({ await openPersonaCatalog(page); for (const personaName of ["Fizz", "Honey", "Pollen"]) { - await expect(page.getByTestId("persona-catalog-dialog")).not.toContainText( - personaName, - ); + await expect( + page.getByTestId("community-catalog-dialog"), + ).not.toContainText(personaName); } - await expect(page.getByTestId("persona-catalog-dialog-header")).toBeVisible(); - await expect(page.getByTestId("persona-catalog-dialog-body")).toBeVisible(); await expect( - page.getByText("No shared agents", { exact: true }), + page.getByTestId("community-catalog-dialog-header"), ).toBeVisible(); + await expect(page.getByTestId("community-catalog-dialog-body")).toBeVisible(); + const emptyState = page.getByTestId("community-catalog-empty-state"); + await expect(emptyState).toContainText("Nothing shared yet"); await expect( - page.locator('[data-testid^="persona-catalog-list-item-"]'), - ).toHaveCount(0); + emptyState.getByTestId("community-catalog-empty-artwork"), + ).toBeVisible(); await expect( - page.getByTestId("persona-catalog-use-agent-target"), + page.locator('[data-testid^="community-catalog-agent-"]'), ).toHaveCount(0); + await expect(page.getByTestId("community-catalog-use-agent")).toHaveCount(0); await page - .getByTestId("persona-catalog-dialog") + .getByTestId("community-catalog-dialog") .getByRole("button", { name: "Close" }) .click(); await page.getByLabel("Open actions for Fizz").click(); @@ -277,19 +279,17 @@ test("catalog empty state remains available after reopening", async ({ await gotoApp(page); await page.getByTestId("open-agents-view").click(); await openPersonaCatalog(page); - await expect( - page.getByText("No shared agents", { exact: true }), - ).toBeVisible(); + await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible(); await page - .getByTestId("persona-catalog-dialog") + .getByTestId("community-catalog-dialog") .getByRole("button", { name: "Close" }) .click(); - await expect(page.getByTestId("persona-catalog-dialog")).not.toBeVisible(); + await expect(page.getByTestId("community-catalog-dialog")).not.toBeVisible(); await openPersonaCatalog(page); - await expect( - page.getByText("No shared agents", { exact: true }), - ).toBeVisible(); + await expect(page.getByTestId("community-catalog-empty-state")).toContainText( + "Nothing shared yet", + ); }); test("built-in persona edits persist", async ({ page }) => { @@ -451,7 +451,7 @@ test("the new agent card opens unified create, catalog, and import flows", async ); await newAgentCard.click(); - const catalogDialog = page.getByTestId("persona-catalog-dialog"); + const catalogDialog = page.getByTestId("community-catalog-dialog"); await expect(catalogDialog).toBeVisible(); await expect(page.getByTestId("agent-catalog-create")).toHaveAttribute( "aria-current", @@ -825,28 +825,28 @@ test("catalog detail pane shows the full persona details", async ({ page }) => { await selectCatalogPersona(page, personaId); const useAgentTarget = page.getByTestId( - `persona-catalog-use-agent-target-${personaId}`, + `community-catalog-use-agent-${personaId}`, ); - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Researcher", ); - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Added by You", ); - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Research the question and cite the evidence.", ); - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Custom agent", ); - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Preferred model", ); - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Preferred runtime", ); - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Agent instruction", ); await expect(useAgentTarget).toHaveAttribute( @@ -1507,7 +1507,7 @@ This deliberately long fenced-code example must not establish the minimum width await page.getByTestId("open-agents-view").click(); await openPersonaCatalog(page); await expect( - page.getByTestId(`persona-catalog-list-item-${personaId}`), + page.getByTestId(`community-catalog-agent-${personaId}`), ).toHaveCount(0); await page.keyboard.press("Escape"); @@ -1559,11 +1559,11 @@ This deliberately long fenced-code example must not establish the minimum width await openPersonaCatalog(page); await expect( - page.getByTestId(`persona-catalog-list-item-${personaId}`), + page.getByTestId(`community-catalog-agent-${personaId}`), ).toContainText("Catalog Analyst"); await selectCatalogPersona(page, personaId); - const catalogDialog = page.getByTestId("persona-catalog-dialog"); - const catalogDetailPane = page.getByTestId("persona-catalog-detail-pane"); + const catalogDialog = page.getByTestId("community-catalog-dialog"); + const catalogDetailPane = page.getByTestId("community-catalog-detail-pane"); await expect(catalogDetailPane).toContainText("Design System And Styling"); await expect(catalogDialog).toBeVisible(); await expect(catalogDetailPane).toBeVisible(); @@ -1628,7 +1628,7 @@ This deliberately long fenced-code example must not establish the minimum width await openPersonaCatalog(page); await selectCatalogPersona(page, personaId); - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Review the latest catalog changes.", ); await page.keyboard.press("Escape"); @@ -1645,7 +1645,7 @@ This deliberately long fenced-code example must not establish the minimum width await openPersonaCatalog(page); await expect( - page.getByTestId(`persona-catalog-list-item-${personaId}`), + page.getByTestId(`community-catalog-agent-${personaId}`), ).toHaveCount(0); }); @@ -1682,7 +1682,7 @@ test("a queued catalog share is not presented as relay-published", async ({ await openPersonaCatalog(page); await expect( - page.getByTestId(`persona-catalog-list-item-${personaId}`), + page.getByTestId(`community-catalog-agent-${personaId}`), ).toHaveCount(0); }); @@ -1708,11 +1708,9 @@ test("a foreign reader does not receive an unshared kind 30175 persona", async ( await openPersonaCatalog(page); await expect( - page.getByTestId(`persona-catalog-list-item-${remoteCatalogId}`), + page.getByTestId(`community-catalog-agent-${remoteCatalogId}`), ).toHaveCount(0); - await expect( - page.getByText("No shared agents", { exact: true }), - ).toBeVisible(); + await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible(); }); test("catalog exposes exact instructions and rejects hidden Unicode controls", async ({ @@ -1768,16 +1766,16 @@ test("catalog exposes exact instructions and rejects hidden Unicode controls", a const bidiCatalogId = `catalog:${TEST_IDENTITIES.alice.pubkey}:${bidiPersonaId}`; await expect( - page.getByTestId(`persona-catalog-list-item-${visibleCatalogId}`), + page.getByTestId(`community-catalog-agent-${visibleCatalogId}`), ).toBeVisible(); await expect( - page.getByTestId(`persona-catalog-list-item-${emojiCatalogId}`), + page.getByTestId(`community-catalog-agent-${emojiCatalogId}`), ).toContainText("Emoji Reviewer 👩‍💻"); await expect( - page.getByTestId(`persona-catalog-list-item-${zeroWidthCatalogId}`), + page.getByTestId(`community-catalog-agent-${zeroWidthCatalogId}`), ).toHaveCount(0); await expect( - page.getByTestId(`persona-catalog-list-item-${bidiCatalogId}`), + page.getByTestId(`community-catalog-agent-${bidiCatalogId}`), ).toHaveCount(0); await selectCatalogPersona(page, visibleCatalogId); @@ -1820,12 +1818,12 @@ test("a catalog entry keeps the owner's emoji avatar", async ({ page }) => { // An `` carrying the avatar — not the initials fallback — in both the // list row and the detail header is what proves the projection kept it. const remoteEntry = page.getByTestId( - `persona-catalog-list-item-${remoteCatalogId}`, + `community-catalog-agent-${remoteCatalogId}`, ); await expect(remoteEntry.locator("img")).toHaveAttribute("src", avatarUrl); await remoteEntry.click(); await expect( - page.getByTestId("persona-catalog-detail-pane").locator("img").first(), + page.getByTestId("community-catalog-detail-pane").locator("img").first(), ).toHaveAttribute("src", avatarUrl); }); @@ -1849,19 +1847,19 @@ test("a community member can discover and add another member's catalog agent", a await openPersonaCatalog(page); const remoteEntry = page.getByTestId( - `persona-catalog-list-item-${remoteCatalogId}`, + `community-catalog-agent-${remoteCatalogId}`, ); await expect(remoteEntry).toContainText("Alice’s Reviewer"); await remoteEntry.click(); // The detail pane resolves the publisher's display name; 'Community member' // is only the fallback for an unresolvable pubkey. - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Added by alice", ); await page .getByRole("button", { - name: "Add Alice’s Reviewer from Agent Catalog", + name: "Add Alice’s Reviewer from Community Catalog", }) .click(); await expect @@ -1895,10 +1893,10 @@ test("a community member can discover and add another member's catalog agent", a // The entry now projects onto the local copy, so its list-item testid is the // local persona id rather than the catalog coordinate. await expect( - page.getByTestId(`persona-catalog-list-item-${remoteCatalogId}`), + page.getByTestId(`community-catalog-agent-${remoteCatalogId}`), ).toHaveCount(0); await page - .locator('[data-testid^="persona-catalog-list-item-"]') + .locator('[data-testid^="community-catalog-agent-"]') .filter({ hasText: "Alice’s Reviewer" }) .click(); const addedTarget = page.getByRole("button", { @@ -1934,10 +1932,10 @@ test("catalog detail shows Community member when the publisher profile cannot be await page .getByTestId( - `persona-catalog-list-item-catalog:${unknownPubkey}:${personaId}`, + `community-catalog-agent-catalog:${unknownPubkey}:${personaId}`, ) .click(); - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Added by Community member", ); }); diff --git a/desktop/tests/e2e/team-catalog-screenshots.spec.ts b/desktop/tests/e2e/team-catalog-screenshots.spec.ts new file mode 100644 index 00000000000..caaac40ed68 --- /dev/null +++ b/desktop/tests/e2e/team-catalog-screenshots.spec.ts @@ -0,0 +1,302 @@ +import { hexToBytes } from "@noble/hashes/utils.js"; +import { expect, test } from "@playwright/test"; +import { finalizeEvent } from "nostr-tools/pure"; + +import type { RelayEvent } from "@/shared/api/types"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +function ownerPrivateKeyFor(pubkey: string): Uint8Array { + const privateKey = Object.values(TEST_IDENTITIES).find( + (identity) => identity.pubkey === pubkey, + )?.privateKey; + if (!privateKey) { + throw new Error(`No test private key for ${pubkey}`); + } + return hexToBytes(privateKey); +} + +const SHOTS = "test-results/team-catalog"; + +type CatalogMember = { + memberKey: string; + displayName: string; + systemPrompt: string; + model?: string | null; + runtime?: string | null; + provider?: string | null; +}; + +/** A kind:30178 head, shaped exactly as `team_catalog_content` projects it. */ +function createTeamCatalogEvent(input: { + ownerPubkey: string; + teamDTag: string; + name: string; + description?: string | null; + instructions?: string | null; + members: CatalogMember[]; +}): RelayEvent { + return finalizeEvent( + { + created_at: 1_721_750_400, + kind: 30178, + tags: [ + ["d", input.teamDTag], + ["shared", "true"], + ], + content: JSON.stringify({ + v: 1, + name: input.name, + description: input.description ?? null, + instructions: input.instructions ?? null, + members: input.members.map((member) => ({ + member_key: member.memberKey, + display_name: member.displayName, + system_prompt: member.systemPrompt, + avatar_url: null, + runtime: member.runtime ?? null, + model: member.model ?? null, + provider: member.provider ?? null, + })), + }), + }, + ownerPrivateKeyFor(input.ownerPubkey), + ); +} + +/** A kind:30175 head, shaped exactly as `persona_catalog_content` projects it. */ +function createPersonaCatalogEvent(input: { + ownerPubkey: string; + sourcePersonaId: string; + displayName: string; + systemPrompt: string; +}): RelayEvent { + return finalizeEvent( + { + created_at: 1_721_750_400, + kind: 30175, + tags: [ + ["d", input.sourcePersonaId], + ["shared", "true"], + ], + content: JSON.stringify({ + display_name: input.displayName, + system_prompt: input.systemPrompt, + avatar_url: null, + runtime: null, + model: null, + provider: null, + name_pool: [], + }), + }, + ownerPrivateKeyFor(input.ownerPubkey), + ); +} + +const PERSONA_CATALOG_EVENTS: RelayEvent[] = [ + createPersonaCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.bob.pubkey, + sourcePersonaId: "code-reviewer", + displayName: "Code Reviewer", + systemPrompt: "Review pull requests for correctness and edge cases.", + }), +]; + +const CATALOG_EVENTS: RelayEvent[] = [ + createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + teamDTag: "release-review", + name: "Release Review", + description: + "Reads the diff, drafts the release note, and files the follow-ups.", + instructions: + "Coordinate as a unit. The reviewer and scribe share findings before the triager acts.", + members: [ + { + memberKey: "reviewer", + displayName: "Reviewer", + systemPrompt: "Review the diff for correctness and edge cases.", + model: "claude-sonnet-4-5", + runtime: "claude-code", + provider: "anthropic", + }, + { + memberKey: "scribe", + displayName: "Scribe", + systemPrompt: "Write the release note from the merged changes.", + }, + { + memberKey: "triager", + displayName: "Triager", + systemPrompt: "File follow-ups for anything the review deferred.", + model: "gpt-5-codex", + }, + ], + }), + createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.bob.pubkey, + teamDTag: "incident-desk", + name: "Incident Desk", + description: "Two agents that hold the timeline during an incident.", + members: [ + { + memberKey: "commander", + displayName: "Commander", + systemPrompt: "Own the incident timeline and the comms cadence.", + }, + { + memberKey: "investigator", + displayName: "Investigator", + systemPrompt: "Chase the root cause and report findings.", + }, + ], + }), +]; + +async function gotoAgentsView(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("open-agents-view")).toBeVisible({ + timeout: 10_000, + }); + await page.getByTestId("open-agents-view").click(); +} + +async function openTeamCatalog(page: import("@playwright/test").Page) { + await page.getByTestId("new-team-card").click(); + await page.getByTestId("team-catalog-open").click(); + await expect(page.getByTestId("community-catalog-dialog")).toBeVisible(); + await waitForAnimations(page); +} + +test.describe("team catalog screenshots", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test("01 — catalog browse, add, and added states", async ({ page }) => { + test.setTimeout(60_000); + await installMockBridge(page, { teamCatalogEvents: CATALOG_EVENTS }); + await gotoAgentsView(page); + await openTeamCatalog(page); + + // 1. Browsing another member's publication: list, provenance, per-member + // model. Release Review is not the default selection, so click it. + const releaseReview = `community-catalog-team-${TEST_IDENTITIES.alice.pubkey}:release-review`; + await page.getByTestId(releaseReview).click(); + await waitForAnimations(page); + await page.getByTestId("community-catalog-dialog").screenshot({ + path: `${SHOTS}/catalog-browse.png`, + }); + + // 2. Expand the Reviewer member row to show metadata + instruction. + await page.getByTestId("community-catalog-member-expand-reviewer").click(); + await waitForAnimations(page); + await page.getByTestId("community-catalog-dialog").screenshot({ + path: `${SHOTS}/catalog-member-expanded.png`, + }); + + // 3. Team instructions section is visible (Release Review has instructions). + // Collapse the expanded member row first so the team-instructions state + // is visually distinct from the expanded-member screenshot above. + await page.getByTestId("community-catalog-member-expand-reviewer").click(); + await waitForAnimations(page); + await page.getByTestId("community-catalog-dialog").screenshot({ + path: `${SHOTS}/catalog-team-instructions.png`, + }); + + // 4. Adding closes the dialog and names the team in the notice. + await page.getByTestId("community-catalog-add-team").click(); + await expect( + page.getByText("Added Release Review to your teams."), + ).toBeVisible(); + + // 5. Reopened: the action reads "Added to my teams" and is inert, so a + // second copy of the same publication is not offered. + await openTeamCatalog(page); + await page.getByTestId(releaseReview).click(); + await expect(page.getByTestId("community-catalog-add-team")).toHaveText( + "Added to my teams", + ); + await waitForAnimations(page); + await page.getByTestId("community-catalog-dialog").screenshot({ + path: `${SHOTS}/catalog-added.png`, + }); + }); + + test("02 — empty catalog", async ({ page }) => { + await installMockBridge(page, { teamCatalogEvents: [] }); + await gotoAgentsView(page); + await openTeamCatalog(page); + + await page.getByTestId("community-catalog-dialog").screenshot({ + path: `${SHOTS}/catalog-empty.png`, + }); + }); + + test("03 — share dialog before and after publishing", async ({ page }) => { + test.setTimeout(60_000); + await installMockBridge(page, { + personas: [ + { + id: "custom:release-analyst", + displayName: "Release Analyst", + systemPrompt: "Summarise the release.", + }, + { + id: "custom:release-scribe", + displayName: "Release Scribe", + systemPrompt: "Write the release note.", + }, + ], + teams: [ + { + id: "team-release-010", + name: "Release Crew", + description: "Ships the release notes.", + personaIds: ["custom:release-analyst", "custom:release-scribe"], + }, + ], + }); + await gotoAgentsView(page); + + await page.getByLabel("Release Crew team actions").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + await expect(page.getByTestId("team-share-dialog")).toBeVisible(); + await waitForAnimations(page); + + // 4. Catalog access sits alongside the existing snapshot-share controls, + // defaulting to unchecked (not shared). + await page.getByTestId("team-share-dialog").screenshot({ + path: `${SHOTS}/share-not-shared.png`, + }); + + // 5. Published: toggle the Switch to share, the toast names the effect. + await page.getByTestId("team-share-catalog-access").click(); + await expect(page.getByTestId("team-share-catalog-access")).toBeChecked(); + await expect( + page.getByText("Published Release Crew to the community catalog."), + ).toBeVisible(); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOTS}/share-published.png` }); + }); + + test("04 — both sections populated (agents + teams)", async ({ page }) => { + await installMockBridge(page, { + personaCatalogEvents: PERSONA_CATALOG_EVENTS, + teamCatalogEvents: CATALOG_EVENTS, + }); + await gotoAgentsView(page); + await openTeamCatalog(page); + + // The Agents section header is visible alongside Teams in the sidebar. + await expect( + page.locator('[data-testid^="community-catalog-agent-"]'), + ).toHaveCount(1); + await expect( + page.locator('[data-testid^="community-catalog-team-"]'), + ).toHaveCount(2); + await waitForAnimations(page); + await page.getByTestId("community-catalog-dialog").screenshot({ + path: `${SHOTS}/catalog-both-sections.png`, + }); + }); +}); diff --git a/desktop/tests/e2e/team-catalog.spec.ts b/desktop/tests/e2e/team-catalog.spec.ts new file mode 100644 index 00000000000..f9ac7a94b81 --- /dev/null +++ b/desktop/tests/e2e/team-catalog.spec.ts @@ -0,0 +1,606 @@ +import { expect, test } from "@playwright/test"; +import { hexToBytes } from "@noble/hashes/utils.js"; +import { finalizeEvent } from "nostr-tools/pure"; + +import type { RelayEvent } from "@/shared/api/types"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { seedActiveIdentity } from "../helpers/onboarding"; + +type CatalogMember = { + memberKey: string; + displayName: string; + systemPrompt: string; + model?: string | null; + runtime?: string | null; + provider?: string | null; +}; + +/** A kind:30178 head, shaped exactly as `team_catalog_content` projects it. + * + * The catalog read path runs through the shared signature gate (the #4220 + * hardening ported into `catalogRelay.ts`), so a discoverable head must carry + * a valid signature over its own contents. Sign with the owner's test key + * rather than stamping a placeholder `sig`, mirroring the persona fixtures in + * `agents.spec.ts`. The signed `id` is content-derived, so callers read it off + * the returned event instead of supplying one. */ +function createTeamCatalogEvent(input: { + ownerPubkey: string; + teamDTag: string; + name: string; + description?: string | null; + instructions?: string | null; + members: CatalogMember[]; + createdAt?: number; + shared?: boolean; +}): RelayEvent { + const ownerPrivateKey = Object.values(TEST_IDENTITIES).find( + (identity) => identity.pubkey === input.ownerPubkey, + )?.privateKey; + if (!ownerPrivateKey) { + throw new Error(`No test private key for ${input.ownerPubkey}`); + } + return finalizeEvent( + { + created_at: input.createdAt ?? 1_721_750_400, + kind: 30178, + tags: [ + ["d", input.teamDTag], + ...(input.shared === false ? [] : [["shared", "true"]]), + ], + content: JSON.stringify({ + v: 1, + name: input.name, + description: input.description ?? null, + instructions: input.instructions ?? null, + members: input.members.map((member) => ({ + member_key: member.memberKey, + display_name: member.displayName, + system_prompt: member.systemPrompt, + avatar_url: null, + runtime: member.runtime ?? null, + model: member.model ?? null, + provider: member.provider ?? null, + })), + }), + }, + hexToBytes(ownerPrivateKey), + ); +} + +async function gotoAgentsView(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("open-agents-view")).toBeVisible({ + timeout: 10_000, + }); + await page.getByTestId("open-agents-view").click(); +} + +async function openTeamCatalog(page: import("@playwright/test").Page) { + await page.getByTestId("new-team-card").click(); + await page.getByTestId("team-catalog-open").click(); + await expect(page.getByTestId("community-catalog-dialog")).toBeVisible(); +} + +async function openTeamShareDialog( + page: import("@playwright/test").Page, + teamName: string, +) { + await page.getByLabel(`${teamName} team actions`).click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + await expect(page.getByTestId("team-share-dialog")).toBeVisible(); +} + +async function setTeamCatalogAccess( + page: import("@playwright/test").Page, + shared: boolean, +) { + const toggle = page.getByTestId("team-share-catalog-access"); + const isChecked = await toggle.isChecked(); + if (isChecked !== shared) { + await toggle.click(); + } +} + +async function listMockTeams(page: import("@playwright/test").Page) { + return page.evaluate(async () => { + const invoke = ( + window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + ) => Promise; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) throw new Error("Mock invoke bridge is not installed."); + return (await invoke("list_teams")) as Array<{ + name: string; + persona_ids: string[]; + catalog_source: { owner_pubkey: string; team_d_tag: string } | null; + }>; + }); +} + +const ALICE_TEAM_D_TAG = "alice-review-crew"; +const ALICE_TEAM_MEMBERS: CatalogMember[] = [ + { + memberKey: "reviewer", + displayName: "Alice’s Reviewer", + systemPrompt: "Review changes for the whole community.", + }, + { + memberKey: "scribe", + displayName: "Alice’s Scribe", + systemPrompt: "Write the summary.", + }, +]; + +test("an unshared kind 30178 head from another member is not offered", async ({ + page, +}) => { + await installMockBridge(page, { + teamCatalogEvents: [ + createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + teamDTag: ALICE_TEAM_D_TAG, + name: "Alice’s Private Crew", + members: ALICE_TEAM_MEMBERS, + shared: false, + }), + ], + }); + await gotoAgentsView(page); + await openTeamCatalog(page); + + await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible(); + await expect( + page.locator('[data-testid^="community-catalog-team-"]'), + ).toHaveCount(0); +}); + +test("adding another member's team records its catalog provenance", async ({ + page, +}) => { + const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`; + await installMockBridge(page, { + teamCatalogEvents: [ + createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + teamDTag: ALICE_TEAM_D_TAG, + name: "Alice’s Review Crew", + description: "Two agents that review and summarise.", + members: ALICE_TEAM_MEMBERS, + }), + ], + }); + await gotoAgentsView(page); + await openTeamCatalog(page); + + const entry = page.getByTestId(`community-catalog-team-${entryKey}`); + await expect(entry).toContainText("Alice’s Review Crew"); + await entry.click(); + + const detail = page.getByTestId("community-catalog-detail-pane"); + await expect(detail).toContainText("Added by alice"); + await expect(detail).toContainText("2 members"); + await expect( + page.getByTestId("community-catalog-member-reviewer"), + ).toContainText("Alice’s Reviewer"); + await expect( + page.getByTestId("community-catalog-member-scribe"), + ).toContainText("Alice’s Scribe"); + + await page.getByTestId("community-catalog-add-team").click(); + await expect( + page.getByText("Added Alice’s Review Crew to your teams."), + ).toBeVisible(); + + // The copy carries a fresh local id, so only the stored coordinate links it + // back to Alice's publication — that link is what stops a second copy. + const teams = await listMockTeams(page); + const added = teams.find((team) => team.name === "Alice’s Review Crew"); + expect(added).toMatchObject({ + catalog_source: { + owner_pubkey: TEST_IDENTITIES.alice.pubkey, + team_d_tag: ALICE_TEAM_D_TAG, + }, + }); + expect(added?.persona_ids).toHaveLength(2); + + await openTeamCatalog(page); + await page.getByTestId(`community-catalog-team-${entryKey}`).click(); + const addButton = page.getByTestId("community-catalog-add-team"); + await expect(addButton).toHaveText("Added to my teams"); + await expect(addButton).toBeDisabled(); + expect( + (await listMockTeams(page)).filter( + (team) => team.name === "Alice’s Review Crew", + ), + ).toHaveLength(1); +}); + +test("a head that moved while the dialog was open is rejected", async ({ + page, +}) => { + const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`; + await installMockBridge(page, { + teamCatalogEvents: [ + createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + teamDTag: ALICE_TEAM_D_TAG, + name: "Alice’s Review Crew", + members: ALICE_TEAM_MEMBERS, + }), + ], + }); + await gotoAgentsView(page); + await openTeamCatalog(page); + await page.getByTestId(`community-catalog-team-${entryKey}`).click(); + + // Republish the coordinate without notifying subscribers: the dialog keeps + // rendering — and keeps holding — the superseded event id. + await page.evaluate( + ({ ownerPubkey, teamDTag }) => { + const replace = ( + window as Window & { + __BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__?: ( + event: unknown, + ) => void; + } + ).__BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__; + if (!replace) throw new Error("Team catalog head seam is not installed."); + replace({ + id: "3".repeat(64), + pubkey: ownerPubkey, + created_at: 1_721_760_400, + kind: 30178, + tags: [ + ["d", teamDTag], + ["shared", "true"], + ], + content: JSON.stringify({ + v: 1, + name: "Alice’s Review Crew", + description: null, + instructions: null, + members: [], + }), + sig: "2".repeat(128), + }); + }, + { ownerPubkey: TEST_IDENTITIES.alice.pubkey, teamDTag: ALICE_TEAM_D_TAG }, + ); + + await page.getByTestId("community-catalog-add-team").click(); + await expect( + page.getByText( + "This team was updated since you opened the catalog. Reopen it and try again.", + ), + ).toBeVisible(); + expect( + (await listMockTeams(page)).filter( + (team) => + team.catalog_source !== null && team.catalog_source !== undefined, + ), + ).toHaveLength(0); +}); + +test("at an equal timestamp the lower-id head is canonical and a superseding head is rejected as stale", async ({ + page, +}) => { + // Two signed events for the same coordinate at identical created_at. Both + // carry valid signatures (the read path verifies them), so the relay's + // tie-break decides: `created_at DESC, id ASC` makes the lower-id event + // canonical. Because the signed `id` is content-derived, we cannot pin it to + // a literal — we sign both, sort by id, and derive the expected canonical + // name from whichever id sorts first. + const SAME_TIMESTAMP = 1_721_760_000; + const crew = createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + teamDTag: ALICE_TEAM_D_TAG, + name: "Alice's Review Crew", + members: ALICE_TEAM_MEMBERS, + createdAt: SAME_TIMESTAMP, + }); + const crewV2 = createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + teamDTag: ALICE_TEAM_D_TAG, + name: "Alice's Review Crew v2", + members: [], + createdAt: SAME_TIMESTAMP, + }); + const [lower] = [crew, crewV2].sort((left, right) => + left.id.localeCompare(right.id), + ); + const canonicalName = JSON.parse(lower.content).name as string; + + const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`; + await installMockBridge(page, { teamCatalogEvents: [crew, crewV2] }); + await gotoAgentsView(page); + await openTeamCatalog(page); + await page.getByTestId(`community-catalog-team-${entryKey}`).click(); + + // The lower-id event was selected as canonical, so its name is what renders. + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( + canonicalName, + ); + + // Republish the coordinate with a strictly newer head, without notifying + // subscribers: the dialog keeps holding the superseded id. The add must be + // rejected as stale. + await page.evaluate( + ({ ownerPubkey, teamDTag }) => { + const replace = ( + window as Window & { + __BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__?: ( + event: unknown, + ) => void; + } + ).__BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__; + if (!replace) throw new Error("Team catalog head seam is not installed."); + replace({ + id: "9".repeat(64), + pubkey: ownerPubkey, + created_at: 1_721_760_400, + kind: 30178, + tags: [ + ["d", teamDTag], + ["shared", "true"], + ], + content: JSON.stringify({ + v: 1, + name: "Alice's Review Crew v2", + description: null, + instructions: null, + members: [], + }), + sig: "2".repeat(128), + }); + }, + { ownerPubkey: TEST_IDENTITIES.alice.pubkey, teamDTag: ALICE_TEAM_D_TAG }, + ); + + await page.getByTestId("community-catalog-add-team").click(); + await expect( + page.getByText( + "This team was updated since you opened the catalog. Reopen it and try again.", + ), + ).toBeVisible(); +}); + +test("sharing a team publishes it to the catalog and unsharing retracts it", async ({ + page, +}) => { + // The own head is published through the same signature-verified read path as + // foreign heads, so the viewer must hold a real key to sign it — seed one. + await seedActiveIdentity(page, TEST_IDENTITIES.tyler); + await installMockBridge(page, { + personas: [ + { + id: "custom:release-analyst", + displayName: "Release Analyst", + systemPrompt: "Summarise the release.", + }, + ], + teams: [ + { + id: "team-release-010", + name: "Release Crew", + description: "Ships the release notes.", + personaIds: ["custom:release-analyst"], + }, + ], + }); + await gotoAgentsView(page); + + await openTeamCatalog(page); + await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible(); + await page.keyboard.press("Escape"); + + await openTeamShareDialog(page, "Release Crew"); + const catalogAccess = page.getByTestId("team-share-catalog-access"); + await expect(catalogAccess).not.toBeChecked(); + await setTeamCatalogAccess(page, true); + await expect(catalogAccess).toBeChecked(); + await expect( + page.getByText("Published Release Crew to the community catalog."), + ).toBeVisible(); + await page + .getByTestId("team-share-dialog") + .getByRole("button", { name: "Close" }) + .click(); + + await openTeamCatalog(page); + const ownEntry = page.locator('[data-testid^="community-catalog-team-"]'); + await expect(ownEntry).toHaveCount(1); + await ownEntry.click(); + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( + "Added by You", + ); + // The publisher already has the team, so the catalog must not offer a copy. + await expect(page.getByTestId("community-catalog-add-team")).toBeDisabled(); + await page.keyboard.press("Escape"); + + await openTeamShareDialog(page, "Release Crew"); + await setTeamCatalogAccess(page, false); + await expect( + page.getByText( + "Release Crew is no longer discoverable in the community catalog.", + ), + ).toBeVisible(); + await page + .getByTestId("team-share-dialog") + .getByRole("button", { name: "Close" }) + .click(); + + await openTeamCatalog(page); + await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible(); +}); + +test("a queued team share is not presented as relay-published", async ({ + page, +}) => { + await installMockBridge(page, { + personas: [ + { + id: "custom:queued-analyst", + displayName: "Queued Analyst", + systemPrompt: "Wait for relay acceptance.", + }, + ], + teams: [ + { + id: "team-queued-011", + name: "Queued Crew", + description: null, + personaIds: ["custom:queued-analyst"], + }, + ], + teamSharePublicationStatuses: ["queued"], + }); + await gotoAgentsView(page); + + await openTeamShareDialog(page, "Queued Crew"); + await setTeamCatalogAccess(page, true); + await expect( + page.getByText( + "Sharing Queued Crew is queued. It will appear after the relay accepts the update.", + ), + ).toBeVisible(); + await page + .getByTestId("team-share-dialog") + .getByRole("button", { name: "Close" }) + .click(); + + await openTeamCatalog(page); + await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible(); +}); + +test("expanding a member row reveals its metadata and instruction", async ({ + page, +}) => { + const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`; + await installMockBridge(page, { + teamCatalogEvents: [ + createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + teamDTag: ALICE_TEAM_D_TAG, + name: "Alice's Review Crew", + members: [ + { + memberKey: "reviewer", + displayName: "Alice's Reviewer", + systemPrompt: "Review changes for the whole community.", + model: "claude-sonnet-4-5", + runtime: "claude-code", + provider: "anthropic", + }, + ], + }), + ], + }); + await gotoAgentsView(page); + await openTeamCatalog(page); + await page.getByTestId(`community-catalog-team-${entryKey}`).click(); + + const memberRow = page.getByTestId("community-catalog-member-reviewer"); + await expect(memberRow).toBeVisible(); + + // Metadata card and instruction are hidden before expansion. + await expect(memberRow.getByTestId("agent-definition-metadata")).toBeHidden(); + + // Expand the row. + await page.getByTestId("community-catalog-member-expand-reviewer").click(); + + // aria-expanded transitions to true. + await expect( + page.getByTestId("community-catalog-member-expand-reviewer"), + ).toHaveAttribute("aria-expanded", "true"); + + // Metadata card is now visible and contains model/runtime/provider. + const metadata = memberRow.getByTestId("agent-definition-metadata"); + await expect(metadata).toBeVisible(); + await expect(metadata).toContainText("claude-sonnet-4-5"); + await expect(metadata).toContainText("claude-code"); + await expect(metadata).toContainText("anthropic"); + + // Instruction text is visible. + await expect(memberRow).toContainText( + "Review changes for the whole community.", + ); +}); + +test("expanding a member with no system prompt shows the no-instructions placeholder", async ({ + page, +}) => { + const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`; + await installMockBridge(page, { + teamCatalogEvents: [ + createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + teamDTag: ALICE_TEAM_D_TAG, + name: "Alice's Review Crew", + members: [ + { + memberKey: "reviewer", + displayName: "Alice's Reviewer", + systemPrompt: "", + }, + ], + }), + ], + }); + await gotoAgentsView(page); + await openTeamCatalog(page); + await page.getByTestId(`community-catalog-team-${entryKey}`).click(); + + await page.getByTestId("community-catalog-member-expand-reviewer").click(); + await expect( + page.getByTestId("community-catalog-member-reviewer"), + ).toContainText("No instructions"); +}); + +test("team instructions section is visible when the team has instructions set", async ({ + page, +}) => { + const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`; + await installMockBridge(page, { + teamCatalogEvents: [ + createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + teamDTag: ALICE_TEAM_D_TAG, + name: "Alice's Review Crew", + instructions: "Always check for security issues first.", + members: ALICE_TEAM_MEMBERS, + }), + ], + }); + await gotoAgentsView(page); + await openTeamCatalog(page); + await page.getByTestId(`community-catalog-team-${entryKey}`).click(); + + const detail = page.getByTestId("community-catalog-detail-pane"); + await expect(detail).toContainText("Team instructions"); + await expect(detail).toContainText("Always check for security issues first."); +}); + +test("team instructions section is absent when instructions are not set", async ({ + page, +}) => { + const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`; + await installMockBridge(page, { + teamCatalogEvents: [ + createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + teamDTag: ALICE_TEAM_D_TAG, + name: "Alice's Review Crew", + members: ALICE_TEAM_MEMBERS, + }), + ], + }); + await gotoAgentsView(page); + await openTeamCatalog(page); + await page.getByTestId(`community-catalog-team-${entryKey}`).click(); + + const detail = page.getByTestId("community-catalog-detail-pane"); + await expect(detail).not.toContainText("Team instructions"); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 284214a5493..6a6680fdba3 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -250,6 +250,10 @@ type MockBridgeOptions = { /** Outcomes for successive explicit persona share publications. */ personaSharePublicationStatuses?: Array<"published" | "queued">; teams?: MockTeamSeed[]; + /** Community team-catalog (kind:30178) heads returned by relay queries. */ + teamCatalogEvents?: RelayEvent[]; + /** Outcomes for successive explicit team share publications. */ + teamSharePublicationStatuses?: Array<"published" | "queued">; relayAgents?: MockRelayAgentSeed[]; /** Reject successive relay-agent directory reads, then resume. */ relayAgentListErrors?: (string | null)[];