diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 5519153578..3113a9b42f 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -112,6 +112,7 @@ fn agent_record() -> ManagedAgentRecord { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, @@ -138,6 +139,7 @@ fn persona_with_model(model: &str) -> AgentDefinition { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: Default::default(), respond_to: None, respond_to_allowlist: Vec::new(), diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index e7d0e70fd0..fcf2e1129b 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -398,6 +398,7 @@ fn model_discovery_ignores_stale_record_for_linked_agent() { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index dd61fc9398..ddce8c65c7 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -901,6 +901,7 @@ pub async fn create_managed_agent( source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, @@ -1303,7 +1304,6 @@ pub async fn delete_managed_agent( for pubkey in &exited_pubkeys { state.clear_agent_session_caches(pubkey); } - // Guard: reject deletion of deployed remote agents unless explicitly forced. // This turns "don't orphan remote infra" from a UI convention into a backend // invariant — a buggy or compromised IPC caller cannot silently orphan a live diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 54a03e2bab..71e69d0a9d 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -57,6 +57,7 @@ fn bare_agent_record( source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, relay_mesh: None, auto_restart_on_config_change: false, definition_respond_to: None, @@ -81,6 +82,7 @@ fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> Agen source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/commands/personas/create.rs b/desktop/src-tauri/src/commands/personas/create.rs index c00de1c6da..526a77c618 100644 --- a/desktop/src-tauri/src/commands/personas/create.rs +++ b/desktop/src-tauri/src/commands/personas/create.rs @@ -66,6 +66,9 @@ pub async fn create_persona( source_team: None, source_team_persona_slug: None, catalog_source, + // Team-publication provenance is set only by + // `add_team_from_catalog`, never by an ordinary create. + team_catalog_source: None, env_vars: input.env_vars, respond_to: None, respond_to_allowlist: Vec::new(), diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 8ff7cfbd9b..752715c49f 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -65,6 +65,7 @@ fn make_agent( source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, relay_mesh: None, auto_restart_on_config_change: false, definition_respond_to: None, diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index d7ffecef2d..c107e9577d 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -439,6 +439,11 @@ fn apply_inbound_team(teams: &mut Vec, d_tag: String, inbound: TeamE instructions: inbound.instructions.unwrap_or_default(), persona_ids: inbound.persona_ids.unwrap_or_default(), is_builtin: false, + // Catalog share state is scoped and never inbound-authoritative. + shared: false, + // Owner-device sync, not a catalog add: the team is this owner's + // own, so it has no foreign publication to attribute. + catalog_source: None, source_dir: None, is_symlink: false, symlink_target: None, diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 1005a83432..5dbb7591f9 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -24,6 +24,7 @@ fn local_in_app() -> AgentDefinition { source_team: Some("team-1".to_string()), source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: BTreeMap::from([("API_KEY".to_string(), "secret".to_string())]), respond_to: None, respond_to_allowlist: Vec::new(), @@ -51,6 +52,7 @@ fn inbound_for(d_tag: &str, display_name: &str) -> AgentDefinition { source_team: None, source_team_persona_slug: Some(d_tag.to_string()), catalog_source: None, + team_catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -211,6 +213,7 @@ fn local_agent() -> ManagedAgentRecord { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, @@ -395,6 +398,8 @@ fn local_team() -> TeamRecord { instructions: None, persona_ids: vec!["p-local".to_string()], is_builtin: false, + shared: false, + catalog_source: None, source_dir: Some(std::path::PathBuf::from("/local/team/dir")), is_symlink: true, symlink_target: Some("/external".to_string()), diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index cab5fababc..2404a0e896 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -268,6 +268,7 @@ mod tests { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), diff --git a/desktop/src-tauri/src/commands/personas/sharing.rs b/desktop/src-tauri/src/commands/personas/sharing.rs index 914c56252d..fa492b338b 100644 --- a/desktop/src-tauri/src/commands/personas/sharing.rs +++ b/desktop/src-tauri/src/commands/personas/sharing.rs @@ -82,7 +82,10 @@ pub async fn update_persona_and_publish( // Strict path: this command's contract is to report the publication // outcome, so an enqueue failure must reach the UI rather than being // logged and swallowed. - prepare_persona_publication(app, state, persona, None) + let result = prepare_persona_publication(app, state, persona, None)?; + // F2: refresh any shared 30178 heads that include this persona. + crate::commands::refresh_team_catalog_heads_for_persona(app, state, &persona.id); + Ok(result) }) .await?; @@ -157,6 +160,7 @@ mod tests { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index b769d74d7b..ed8104ff63 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -60,6 +60,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index d7f0323304..23a936ecf8 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -578,6 +578,7 @@ pub async fn confirm_agent_snapshot_import( source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: std::collections::BTreeMap::new(), respond_to: respond_to_wire.clone(), respond_to_allowlist: minted.respond_to_allowlist.clone(), @@ -648,6 +649,7 @@ pub async fn confirm_agent_snapshot_import( source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, definition_respond_to: respond_to_wire.clone(), definition_respond_to_allowlist: minted.respond_to_allowlist.clone(), definition_parallelism: minted_parallelism, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index c453b09a9d..5b456afa73 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -69,6 +69,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/personas/update.rs b/desktop/src-tauri/src/commands/personas/update.rs index ed2472d54e..775db95549 100644 --- a/desktop/src-tauri/src/commands/personas/update.rs +++ b/desktop/src-tauri/src/commands/personas/update.rs @@ -64,6 +64,10 @@ pub async fn update_persona( ) -> Result { let (persona, ()) = update_persona_with(input, app, |app, state, persona| { retain_persona_pending(app, state, persona); + // F2: immediately refresh any shared 30178 heads that include this + // persona as a member. Best-effort inside retain so a hiccup cannot + // fail the persona edit itself. + crate::commands::refresh_team_catalog_heads_for_persona(app, state, &persona.id); Ok(()) }) .await?; diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index c60215ae4d..a5953064aa 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -54,6 +54,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 97cd11933d..1279fd88be 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -133,6 +133,7 @@ fn definition_from_snapshot( source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: Default::default(), respond_to, respond_to_allowlist: behavior.respond_to_allowlist, @@ -172,6 +173,11 @@ pub(crate) fn build_import_team( persona_ids, instructions: snapshot.team.instructions.clone(), is_builtin: false, + // An imported team starts unshared; sharing is an explicit choice. + shared: false, + // A snapshot import is not a catalog add — there is no publication + // coordinate to point back to. + catalog_source: None, source_dir: None, is_symlink: false, symlink_target: None, @@ -605,6 +611,7 @@ pub async fn confirm_team_snapshot_import( source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, definition_respond_to: respond_to_wire.clone(), definition_respond_to_allowlist: definition.respond_to_allowlist.clone(), definition_parallelism: minted_parallelism, diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index c9a6d8812a..3eb8ea6e4c 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -69,6 +69,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: Default::default(), respond_to: None, respond_to_allowlist: vec![], @@ -91,6 +92,7 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: Default::default(), respond_to: None, respond_to_allowlist: vec![], @@ -106,6 +108,8 @@ fn team_export_round_trip_preserves_team_and_excludes_member_memory() { instructions: Some("Be thorough.".to_string()), persona_ids: vec!["alice".to_string(), "bob".to_string()], is_builtin: false, + shared: false, + catalog_source: None, source_dir: None, is_symlink: false, symlink_target: None, @@ -154,6 +158,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: Default::default(), respond_to: None, respond_to_allowlist: vec![], @@ -168,6 +173,8 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { instructions: None, persona_ids: vec!["alice".to_string()], is_builtin: false, + shared: false, + catalog_source: None, source_dir: None, is_symlink: false, symlink_target: None, @@ -225,6 +232,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, @@ -682,6 +690,8 @@ fn full_rollback_at_teams_boundary_absent_agents_store() { instructions: None, persona_ids: vec![], is_builtin: false, + shared: false, + catalog_source: None, source_dir: None, is_symlink: false, symlink_target: None, diff --git a/desktop/src-tauri/src/commands/teams/adopt.rs b/desktop/src-tauri/src/commands/teams/adopt.rs new file mode 100644 index 0000000000..72fb151a63 --- /dev/null +++ b/desktop/src-tauri/src/commands/teams/adopt.rs @@ -0,0 +1,188 @@ +//! `add_team_from_catalog`: copy another owner's published team into the local +//! stores with byte-level rollback on error. +//! +//! Two properties define this command, and both are amendment requirements: +//! +//! **The frontend is not trusted (A2).** The caller supplies only a +//! coordinate — owner pubkey, team d-tag, and the event id it is looking at. +//! The backend re-fetches the CURRENT head at `30178::` from the +//! active relay and requires it to be the same event, still carrying the +//! `shared` tag. A head that cannot be established at all is a failure, not a +//! fallback: adding from a coordinate we cannot read is exactly the case where +//! a retracted or superseded team would be copied. +//! +//! **The write uses byte-level rollback.** Both stores are snapshotted (raw +//! bytes) under the store lock before any write. If either save fails, both +//! files are restored from their snapshots. A crash between the two writes +//! leaves the stores inconsistent; retry is the recovery path (the add is +//! idempotent: an orphaned team is found by the replay check, and orphaned +//! member copies are reused by provenance matching). +//! +//! Everything about the projection itself — the schema, the size contract, +//! the member shape — is `managed_agents::team_catalog`'s; this module only +//! verifies provenance and writes records. + +use tauri::{AppHandle, Manager}; + +use crate::{ + app_state::AppState, + managed_agents::{ + team_catalog::{team_catalog_content_from_event, TeamCatalogContent}, + TeamCatalogSource, TeamRecord, + }, +}; + +mod apply; +#[cfg(test)] +mod tests; + +/// The coordinate the frontend asks to add, before any verification. +/// +/// `event_id` is what the user is looking at. It is never the source of the +/// content — it is compared against the freshly fetched head, so an add is +/// rejected when the catalog moved underneath the open dialog. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddTeamFromCatalogRequest { + pub owner_pubkey: String, + pub team_d_tag: String, + pub event_id: String, +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AddTeamFromCatalogResult { + pub team: TeamRecord, + /// True when the team was already present and nothing was written. The + /// caller distinguishes "added" from "you already have this". + pub already_present: bool, +} + +/// Add a published team from the community catalog. +#[tauri::command] +pub async fn add_team_from_catalog( + input: AddTeamFromCatalogRequest, + app: AppHandle, +) -> Result { + let source = TeamCatalogSource { + owner_pubkey: input.owner_pubkey, + team_d_tag: input.team_d_tag, + } + .normalized()?; + let event_id = normalized_event_id(&input.event_id)?; + + // Fetch and verify BEFORE taking the store lock: the network call is the + // slow part, and holding the lock across it would stall every unrelated + // agent read for the duration of a relay round-trip. + let content = { + let state = app.state::(); + verified_catalog_head(&state, &source, &event_id).await? + }; + + let app_for_write = app.clone(); + tokio::task::spawn_blocking(move || apply::add_verified_team(&app_for_write, &source, &content)) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +fn normalized_event_id(value: &str) -> Result { + let event_id = value.trim().to_ascii_lowercase(); + if event_id.len() != 64 || !event_id.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!( + "invalid catalog event id: '{event_id}' (must be 64 hex chars)" + )); + } + Ok(event_id) +} + +/// Fetch the current head at the team's catalog coordinate and accept it only +/// if it is the exact event the caller asked for, still shared. +/// +/// Each rejection below is a distinct real scenario, not defensive padding: +/// an empty result is a deleted or never-readable coordinate; a differing id +/// is a head the owner republished since the dialog opened; and an id match +/// with the `shared` tag gone is an unshare the reader has not seen yet. All +/// three must fail closed — the alternative is copying a team its owner has +/// already withdrawn from the community. +async fn verified_catalog_head( + state: &AppState, + source: &TeamCatalogSource, + event_id: &str, +) -> Result { + use buzz_core_pkg::kind::KIND_TEAM_CATALOG; + + let filter = serde_json::json!({ + "kinds": [KIND_TEAM_CATALOG], + "authors": [source.owner_pubkey], + "#d": [source.team_d_tag], + "limit": 1, + }); + let events = crate::relay::query_relay(state, &[filter]) + .await + .map_err(|e| format!("could not verify the team with the relay: {e}"))?; + + let head = events + .first() + .ok_or("This team is no longer available in the catalog.")?; + + verified_head_content(head, source, event_id) +} + +/// The verification itself, separated from the fetch so every rejection is +/// testable without a relay. +fn verified_head_content( + head: &nostr::Event, + source: &TeamCatalogSource, + event_id: &str, +) -> Result { + use buzz_core_pkg::kind::{event_is_shared, KIND_TEAM_CATALOG}; + + // Verify the signature before trusting ANY field on the event. The relay + // is not a trusted source of authorship: `pubkey` and `content` are both + // attacker-controlled if the signature is not checked here. + head.verify() + .map_err(|e| format!("the catalog event failed signature verification: {e}"))?; + + if head.kind.as_u16() as u32 != KIND_TEAM_CATALOG { + return Err("The catalog event is not a team publication.".to_string()); + } + if head.id.to_hex() != event_id { + return Err( + "This team has changed since it was listed. Refresh and try again.".to_string(), + ); + } + if !event_is_shared(head) { + return Err("This team is no longer shared to the community.".to_string()); + } + // Author and d-tag are re-derived from the verified event rather than + // taken from the request, so a relay that answers a filter with an + // unrelated event cannot set provenance. + if head.pubkey.to_hex() != source.owner_pubkey { + return Err("The catalog event was published by a different owner.".to_string()); + } + if head_d_tag(head).as_deref() != Some(source.team_d_tag.as_str()) { + return Err("The catalog event is for a different team.".to_string()); + } + + team_catalog_content_from_event(head) +} + +/// The event's single `d` tag, or `None` when it is absent or not unique. +/// +/// Uniqueness matters: the relay's ingest gate (amendment A4) already rejects +/// a multi-`d` 30178, but a reader that took the first of several would +/// resolve a different coordinate than the one it verified against. +fn head_d_tag(event: &nostr::Event) -> Option { + let mut found: Option = None; + for tag in event.tags.iter() { + let values: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect(); + if values.first() != Some(&"d") { + continue; + } + if found.is_some() { + return None; + } + found = Some(values.get(1)?.to_string()); + } + found +} diff --git a/desktop/src-tauri/src/commands/teams/adopt/apply.rs b/desktop/src-tauri/src/commands/teams/adopt/apply.rs new file mode 100644 index 0000000000..59d561e010 --- /dev/null +++ b/desktop/src-tauri/src/commands/teams/adopt/apply.rs @@ -0,0 +1,342 @@ +//! The store-mutation half of `add_team_from_catalog`: turn a verified +//! projection into local records with byte-level rollback on error. +//! +//! [`plan_add`] computes both stores in memory before anything is written, so +//! no failure in resolving members can leave a half-added team on disk. Only +//! the two saves themselves remain. Before either write we snapshot the raw +//! bytes of both files (or record their absence). When either save fails we +//! restore both snapshots, replacing the on-disk content exactly as it was — +//! including for a reactivated member copy where logical undo would require a +//! field revert with no row to delete. +//! +//! **Crash window.** A process kill between the first commit and the second +//! (or between the second and a successful restore) leaves the stores +//! inconsistent. On restart the team either exists without some member copies, +//! or the member copies exist without the team. The next add of the same +//! publication is idempotent: the replay check in `plan_add` finds the team +//! (if present) and returns it; any orphaned copies are reused by provenance +//! matching. Users who encounter a crash mid-add should retry the add. + +use std::path::Path; + +use tauri::{AppHandle, Manager}; +use uuid::Uuid; + +use crate::{ + app_state::AppState, + managed_agents::{ + load_personas, load_teams, managed_agents_store_path, save_personas, save_teams, + team_catalog::{ + builtin_catalog_slug, local_member_projection_hash, TeamCatalogContent, + TeamCatalogMember, + }, + teams_store_path, try_regenerate_nest, AgentDefinition, RespondTo, TeamCatalogSource, + TeamMemberCatalogSource, TeamRecord, + }, + util::now_iso, +}; + +use super::AddTeamFromCatalogResult; + +/// The complete post-add state of both stores, plus the team to report. +/// +/// `stores` is `None` when nothing needs writing — the replay case. +#[derive(Debug)] +pub(super) struct AddPlan { + pub stores: Option<(Vec, Vec)>, + pub team: TeamRecord, +} + +/// Read the raw bytes of `path`, or `None` if the file does not yet exist. +/// +/// Delegates to `managed_agents::storage::snapshot_store`. +pub(super) use crate::managed_agents::storage::snapshot_store as snapshot; + +/// Write both stores with byte-level rollback on failure, using +/// caller-supplied pre-computed snapshots. +/// +/// Both restores are attempted independently on failure, so a persona-restore +/// failure does not prevent the team restore from running. Errors from both +/// restores are aggregated into the returned message (I5). +/// +/// Delegates to `managed_agents::storage::commit_stores_with_snapshots`. +pub(super) use crate::managed_agents::storage::commit_stores_with_snapshots as commit_stores_with_snaps; + +/// Write both stores with byte-level rollback on failure. +/// +/// Snapshots the files just before the writes. Prefer +/// [`commit_stores_with_snaps`] when you need to snapshot before a write-on-load +/// call that precedes the actual writes. +#[cfg_attr(not(test), allow(dead_code))] +pub(super) fn commit_stores( + personas_path: &Path, + teams_path: &Path, + write_personas: impl FnOnce() -> Result<(), String>, + write_teams: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + let personas_snap = snapshot(personas_path)?; + let teams_snap = snapshot(teams_path)?; + commit_stores_with_snaps( + personas_path, + teams_path, + personas_snap, + teams_snap, + write_personas, + write_teams, + ) +} + +pub(super) fn add_verified_team( + app: &AppHandle, + source: &TeamCatalogSource, + content: &TeamCatalogContent, +) -> Result { + let state = app.state::(); + // Held across load, plan, and save: the replay check is only meaningful if + // no concurrent add of the same coordinate can interleave between reading + // the teams and writing them back. + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + + let personas_path = managed_agents_store_path(app)?; + let teams_path = teams_store_path(app)?; + + // Snapshot raw bytes BEFORE any load: load_personas() can write merged + // built-ins on first call (write-on-load effect). Snapshotting after that + // write would capture post-merge bytes as "before", so a rollback would + // restore the wrong content (I5). + let personas_snap = snapshot(&personas_path)?; + let teams_snap = snapshot(&teams_path)?; + + let personas_before = load_personas(app)?; + let teams_before = load_teams(app)?; + let plan = plan_add(&personas_before, &teams_before, source, content, &now_iso())?; + + let Some((personas, teams)) = plan.stores else { + return Ok(AddTeamFromCatalogResult { + team: plan.team, + already_present: true, + }); + }; + + // Write both stores with byte-level rollback on failure. Snapshots were + // taken before any load effect, so the restore is byte-exact even for a + // reactivated member copy whose logical undo is a field revert. + commit_stores_with_snaps( + &personas_path, + &teams_path, + personas_snap, + teams_snap, + || save_personas(app, &personas), + || save_teams(app, &teams), + )?; + + try_regenerate_nest(app); + Ok(AddTeamFromCatalogResult { + team: plan.team, + already_present: false, + }) +} + +/// Compute both stores as they will be after the add. Pure — no I/O, so every +/// resolution rule below is testable without a Tauri app or a relay. +pub(super) fn plan_add( + personas_before: &[AgentDefinition], + teams_before: &[TeamRecord], + source: &TeamCatalogSource, + content: &TeamCatalogContent, + now: &str, +) -> Result { + // Replay: the same publication added twice returns the team already held + // instead of minting a second copy. + if let Some(existing) = teams_before + .iter() + .find(|team| team.catalog_source.as_ref() == Some(source)) + { + return Ok(AddPlan { + stores: None, + team: existing.clone(), + }); + } + + let mut personas = personas_before.to_vec(); + let persona_ids = content + .members + .iter() + .map(|member| resolve_member(&mut personas, source, member, now)) + .collect::, _>>()?; + let team = TeamRecord { + id: Uuid::new_v4().to_string(), + name: content.name.clone(), + description: content.description.clone(), + instructions: content.instructions.clone(), + persona_ids, + is_builtin: false, + // A copy is not published. Sharing it is a separate, explicit act by + // its new owner, at their own coordinate. + shared: false, + catalog_source: Some(source.clone()), + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: now.to_string(), + updated_at: now.to_string(), + }; + + let mut teams = teams_before.to_vec(); + teams.push(team.clone()); + Ok(AddPlan { + stores: Some((personas, teams)), + team, + }) +} + +/// Resolve one published member to a local persona id, adding or reactivating +/// a record as needed. Returns the local id to put in the team's membership. +fn resolve_member( + personas: &mut Vec, + source: &TeamCatalogSource, + member: &TeamCatalogMember, + now: &str, +) -> Result { + if let Some(local_id) = reusable_builtin(personas, member) { + return Ok(local_id); + } + if let Some(existing) = personas + .iter_mut() + .find(|persona| member_provenance_matches(persona, source, member)) + { + // A copy of this exact member version already exists, from an earlier + // add of this same publication. Reuse it, reactivating if a prior team + // delete left it inactive. Reuse is deliberately NOT extended across + // publications: two teams by one publisher that embed an identical + // member get one copy each, so deleting either team cannot orphan a + // record the other still points at. + if !existing.is_active { + existing.is_active = true; + existing.updated_at = now.to_string(); + } + return Ok(existing.id.clone()); + } + let copy = member_copy(source, member, now)?; + let id = copy.id.clone(); + personas.push(copy); + Ok(id) +} + +/// A local built-in that is byte-identical to the published member. +/// +/// Substitution requires BOTH the canonical `builtin:` to exist locally +/// AND the local built-in's current projection hash to equal the published +/// `projection_hash`. The hash is what makes the hint exact-match gated: a +/// hostile `builtin_slug` paired with unrelated embedded fields, a retired +/// slug, and a slug whose local definition has since changed all fail the +/// comparison and fall through to an ordinary copy built from the embedded +/// fields — which are authoritative. +fn reusable_builtin(personas: &[AgentDefinition], member: &TeamCatalogMember) -> Option { + let slug = member.builtin_slug.as_deref()?; + let published_hash = member.projection_hash.as_deref()?; + personas + .iter() + .find(|persona| { + builtin_catalog_slug(persona) == Some(slug) + && local_member_projection_hash(persona) == published_hash + }) + .map(|persona| persona.id.clone()) +} + +/// Whether a local persona is a copy of exactly this published member. +/// +/// All four components must match. Dropping `projection_hash` would make two +/// versions of one published member collapse onto a single mutable local +/// record, so adding the newer team would silently rewrite the copy the older +/// team is still using. +fn member_provenance_matches( + persona: &AgentDefinition, + source: &TeamCatalogSource, + member: &TeamCatalogMember, +) -> bool { + persona.team_catalog_source.as_ref().is_some_and(|held| { + held.owner_pubkey == source.owner_pubkey + && held.team_d_tag == source.team_d_tag + && held.member_key == member.member_key + && held.projection_hash == member_version_hash(member) + }) +} + +/// The version stamp stored on a copy. +/// +/// A publisher-supplied `projection_hash` is only present on built-in reuse +/// hints, so it cannot serve as the version for ordinary members — and it is +/// publisher-controlled either way. Recomputing it locally over the member as +/// published makes the stamp mean "this exact projection", uniformly, for +/// every member. +fn member_version_hash(member: &TeamCatalogMember) -> String { + use sha2::{Digest, Sha256}; + let json = serde_json::to_vec(member).unwrap_or_default(); + hex::encode(Sha256::digest(&json)) +} + +/// Build a local persona from a published member's embedded fields. +/// +/// Embedding is authoritative: every field comes from the projection, never +/// from a local record that happens to share a name. Fields absent from the +/// projection by design — env vars, allowlist pubkeys — are absent here too, +/// so a copy starts with no inherited secrets and no inherited audience. +fn member_copy( + source: &TeamCatalogSource, + member: &TeamCatalogMember, + now: &str, +) -> Result { + Ok(AgentDefinition { + id: Uuid::new_v4().to_string(), + display_name: member.display_name.clone(), + avatar_url: member.avatar_url.clone(), + system_prompt: member.system_prompt.clone().unwrap_or_default(), + runtime: member.runtime.clone(), + model: member.model.clone(), + provider: member.provider.clone(), + name_pool: member.name_pool.clone(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + team_catalog_source: Some(TeamMemberCatalogSource { + owner_pubkey: source.owner_pubkey.clone(), + team_d_tag: source.team_d_tag.clone(), + member_key: member.member_key.clone(), + projection_hash: member_version_hash(member), + }), + env_vars: Default::default(), + // Validated at the boundary rather than copied opaquely: an + // unrecognized mode from a foreign publisher must not become a local + // definition whose audience differs from what the recipient sees. + // `allowlist` is additionally normalized to `owner-only`: the allowlist + // pubkeys are never published (privacy), so adopting `allowlist` with an + // empty allowlist would create a persona that fails at mint time. The + // recipient can widen from `owner-only` in the edit dialog if desired. + respond_to: member + .respond_to + .as_deref() + .map(|mode| -> Result, String> { + let parsed = + RespondTo::parse_wire(mode).map_err(|e| format!("invalid respond_to: {e}"))?; + if parsed == RespondTo::Allowlist { + Ok(Some(RespondTo::OwnerOnly.as_str().to_string())) + } else { + Ok(Some(mode.to_string())) + } + }) + .transpose()? + .flatten(), + respond_to_allowlist: Vec::new(), + parallelism: member.parallelism, + created_at: now.to_string(), + updated_at: now.to_string(), + }) +} diff --git a/desktop/src-tauri/src/commands/teams/adopt/tests.rs b/desktop/src-tauri/src/commands/teams/adopt/tests.rs new file mode 100644 index 0000000000..bbc1151c54 --- /dev/null +++ b/desktop/src-tauri/src/commands/teams/adopt/tests.rs @@ -0,0 +1,999 @@ +//! Behavior tests for `add_team_from_catalog`: A2 (backend head acceptance) and +//! A1 (local store planning). No Tauri app or relay needed. + +use super::apply::plan_add; +use super::{normalized_event_id, verified_head_content}; +use crate::managed_agents::{ + team_catalog::{ + build_team_catalog_event, local_member_projection_hash, TeamCatalogContent, + TeamCatalogMember, MAX_MEMBERS, TEAM_CATALOG_SCHEMA_VERSION, + }, + AgentDefinition, TeamCatalogSource, TeamRecord, +}; +use nostr::{EventBuilder, JsonUtil, Kind, Tag}; +use std::collections::BTreeMap; + +const NOW: &str = "2026-07-30T00:00:00Z"; +const TEAM_D_TAG: &str = "team-alpha"; + +fn persona(id: &str, prompt: &str) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: id.to_string(), + avatar_url: None, + system_prompt: prompt.to_string(), + runtime: None, + model: None, + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + team_catalog_source: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: NOW.to_string(), + updated_at: NOW.to_string(), + } +} + +fn member(member_key: &str, prompt: &str) -> TeamCatalogMember { + TeamCatalogMember { + member_key: member_key.to_string(), + display_name: member_key.to_string(), + system_prompt: Some(prompt.to_string()), + avatar_url: None, + runtime: None, + model: None, + provider: None, + name_pool: Vec::new(), + respond_to: None, + parallelism: None, + builtin_slug: None, + projection_hash: None, + } +} + +fn content(members: Vec) -> TeamCatalogContent { + TeamCatalogContent { + v: TEAM_CATALOG_SCHEMA_VERSION, + name: "Alpha".to_string(), + description: Some("The alpha team.".to_string()), + instructions: None, + members, + } +} + +fn source(owner_pubkey: &str) -> TeamCatalogSource { + TeamCatalogSource { + owner_pubkey: owner_pubkey.to_string(), + team_d_tag: TEAM_D_TAG.to_string(), + } +} + +/// A signed 30178 head for `team` + `members`, plus its owner and source. +fn published( + team: &TeamRecord, + members: &[AgentDefinition], + shared: bool, +) -> (nostr::Event, TeamCatalogSource) { + let keys = nostr::Keys::generate(); + let event = build_team_catalog_event(team, members, shared) + .expect("the fixture team is within the size contract") + .sign_with_keys(&keys) + .expect("signing a locally built event cannot fail"); + let source = source(&keys.public_key().to_hex()); + (event, source) +} + +fn team_fixture(persona_ids: Vec) -> TeamRecord { + TeamRecord { + id: TEAM_D_TAG.to_string(), + name: "Alpha".to_string(), + description: Some("The alpha team.".to_string()), + instructions: None, + persona_ids, + is_builtin: false, + shared: false, + catalog_source: None, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: NOW.to_string(), + updated_at: NOW.to_string(), + } +} + +// ── Event-id normalization ─────────────────────────────────────────────────── + +#[test] +fn test_uppercase_event_id_normalizes_to_lowercase() { + // Head ids compared as strings against `Event::id().to_hex()` (always lowercase). + let normalized = normalized_event_id(&format!(" {} ", "A".repeat(64))) + .expect("64 hex chars with surrounding space is valid"); + assert_eq!(normalized, "a".repeat(64)); +} + +#[test] +fn test_short_event_id_is_rejected() { + let error = normalized_event_id("abc123").unwrap_err(); + assert!( + error.contains("64 hex"), + "error must name the rule: {error}" + ); +} + +#[test] +fn test_non_hex_event_id_is_rejected() { + let error = normalized_event_id(&"z".repeat(64)).unwrap_err(); + assert!( + error.contains("64 hex"), + "error must name the rule: {error}" + ); +} + +// ── Head verification (A2) ─────────────────────────────────────────────────── + +#[test] +fn test_matching_shared_head_yields_its_projection() { + let (event, source) = published( + &team_fixture(vec!["m1".to_string()]), + &[persona("m1", "Do the work.")], + true, + ); + + let parsed = verified_head_content(&event, &source, &event.id.to_hex()) + .expect("a signed, shared head at the requested coordinate is acceptable"); + + assert_eq!(parsed.name, "Alpha"); + assert_eq!(parsed.members.len(), 1); +} + +#[test] +fn test_head_that_moved_since_the_dialog_opened_is_rejected() { + // Owner republished between catalog render and click — stale head must fail. + let (event, source) = published( + &team_fixture(vec!["m1".to_string()]), + &[persona("m1", "Do the work.")], + true, + ); + + let error = verified_head_content(&event, &source, &"a".repeat(64)).unwrap_err(); + + assert!( + error.contains("changed"), + "the rejection must tell the user to refresh: {error}" + ); +} + +#[test] +fn test_unshared_head_is_rejected() { + // Unshare replaces the head with an untagged event; stale readers must not be able to add it. + let (event, source) = published( + &team_fixture(vec!["m1".to_string()]), + &[persona("m1", "Do the work.")], + false, + ); + + let error = verified_head_content(&event, &source, &event.id.to_hex()).unwrap_err(); + + assert!( + error.contains("no longer shared"), + "the rejection must name the withdrawal: {error}" + ); +} + +#[test] +fn test_head_from_a_different_owner_is_rejected() { + // Hostile relay answering an `authors` filter with another publisher's event must fail. + let (event, _) = published( + &team_fixture(vec!["m1".to_string()]), + &[persona("m1", "Do the work.")], + true, + ); + + let error = + verified_head_content(&event, &source(&"a".repeat(64)), &event.id.to_hex()).unwrap_err(); + + assert!( + error.contains("different owner"), + "the rejection must name the mismatch: {error}" + ); +} + +#[test] +fn test_head_for_a_different_team_is_rejected() { + let (event, source) = published( + &team_fixture(vec!["m1".to_string()]), + &[persona("m1", "Do the work.")], + true, + ); + let other_team = TeamCatalogSource { + team_d_tag: "team-beta".to_string(), + ..source + }; + + let error = verified_head_content(&event, &other_team, &event.id.to_hex()).unwrap_err(); + + assert!( + error.contains("different team"), + "the rejection must name the mismatch: {error}" + ); +} + +#[test] +fn test_head_of_the_wrong_kind_is_rejected() { + // 30176 is the owner's private wire shape, not a catalog projection. + let keys = nostr::Keys::generate(); + let event = EventBuilder::new(Kind::Custom(30176), "{}") + .tags(vec![Tag::parse(["d", TEAM_D_TAG]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + + let error = verified_head_content( + &event, + &source(&keys.public_key().to_hex()), + &event.id.to_hex(), + ) + .unwrap_err(); + + assert!( + error.contains("not a team publication"), + "the rejection must name the kind mismatch: {error}" + ); +} + +#[test] +fn test_head_with_a_forged_signature_is_rejected() { + // Without this check, a hostile relay could set both `pubkey` and `content`. + let (event, source) = published( + &team_fixture(vec!["m1".to_string()]), + &[persona("m1", "Do the work.")], + true, + ); + let mut json: serde_json::Value = serde_json::from_str(&event.as_json()).unwrap(); + json["content"] = serde_json::json!(r#"{"v":1,"name":"Trojan","members":[]}"#); + let tampered = ::from_json(json.to_string()).unwrap(); + + let error = verified_head_content(&tampered, &source, &tampered.id.to_hex()).unwrap_err(); + + assert!( + error.contains("signature"), + "content edits must fail signature verification: {error}" + ); +} + +#[test] +fn test_head_with_two_d_tags_is_rejected() { + // Relay's A4 gate rejects these; a reader taking the first d-tag would resolve an unverified coordinate. + let keys = nostr::Keys::generate(); + let body = serde_json::to_string(&content(vec![member("m1", "Do the work.")])).unwrap(); + let event = EventBuilder::new(Kind::Custom(30178), body) + .tags(vec![ + Tag::parse(["d", TEAM_D_TAG]).unwrap(), + Tag::parse(["d", "team-beta"]).unwrap(), + Tag::parse(["shared", "true"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + + let error = verified_head_content( + &event, + &source(&keys.public_key().to_hex()), + &event.id.to_hex(), + ) + .unwrap_err(); + + assert!( + error.contains("different team"), + "an ambiguous d-tag resolves to no coordinate: {error}" + ); +} + +#[test] +fn test_head_with_an_unknown_schema_version_is_rejected() { + let keys = nostr::Keys::generate(); + let event = EventBuilder::new( + Kind::Custom(30178), + r#"{"v":2,"name":"Alpha","members":[]}"#, + ) + .tags(vec![ + Tag::parse(["d", TEAM_D_TAG]).unwrap(), + Tag::parse(["shared", "true"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + + let error = verified_head_content( + &event, + &source(&keys.public_key().to_hex()), + &event.id.to_hex(), + ) + .unwrap_err(); + + assert!( + error.contains("schema version"), + "a v2 body may reshape any field: {error}" + ); +} + +#[test] +fn test_head_that_violates_the_size_contract_is_rejected() { + // Publisher bypassing the local builder must not force an unbounded projection. + let keys = nostr::Keys::generate(); + let members = (0..=MAX_MEMBERS) + .map(|i| member(&format!("m{i}"), "Do the work.")) + .collect(); + let body = serde_json::to_string(&content(members)).unwrap(); + let event = EventBuilder::new(Kind::Custom(30178), body) + .tags(vec![ + Tag::parse(["d", TEAM_D_TAG]).unwrap(), + Tag::parse(["shared", "true"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + + let error = verified_head_content( + &event, + &source(&keys.public_key().to_hex()), + &event.id.to_hex(), + ) + .unwrap_err(); + + assert!( + error.contains("too large"), + "the size contract applies on read as well as write: {error}" + ); +} + +// ── Store planning (A1 provenance) ─────────────────────────────────────────── + +fn plan( + personas: &[AgentDefinition], + teams: &[TeamRecord], + source: &TeamCatalogSource, + content: &TeamCatalogContent, +) -> super::apply::AddPlan { + plan_add(personas, teams, source, content, NOW).expect("the fixture projection is resolvable") +} + +#[test] +fn test_first_add_copies_every_member_and_records_provenance() { + let source = source(&"a".repeat(64)); + let body = content(vec![member("m1", "Do the work."), member("m2", "Review.")]); + + let plan = plan(&[], &[], &source, &body); + + let (personas, teams) = plan.stores.expect("a first add must write"); + assert_eq!(personas.len(), 2); + assert_eq!(teams.len(), 1); + assert_eq!( + plan.team.catalog_source.as_ref(), + Some(&source), + "the copy's only link back to the publication" + ); + assert!( + !plan.team.shared, + "a copy is not published; sharing it is a separate act by its new owner" + ); + assert_eq!( + plan.team.persona_ids, + personas.iter().map(|p| p.id.clone()).collect::>(), + "membership must preserve the published order" + ); + for copy in &personas { + let held = copy + .team_catalog_source + .as_ref() + .expect("every copy carries team provenance"); + assert_eq!(held.owner_pubkey, source.owner_pubkey); + assert_eq!(held.team_d_tag, source.team_d_tag); + assert!( + copy.catalog_source.is_none(), + "a team member is not addressable as a 30175 persona coordinate" + ); + } +} + +#[test] +fn test_adding_the_same_publication_twice_writes_nothing() { + let source = source(&"a".repeat(64)); + let body = content(vec![member("m1", "Do the work.")]); + let first = plan(&[], &[], &source, &body); + let (personas, teams) = first.stores.unwrap(); + + let second = plan(&personas, &teams, &source, &body); + + assert!( + second.stores.is_none(), + "a replay must not mint a second copy" + ); + assert_eq!(second.team.id, first.team.id); +} + +#[test] +fn test_a_second_team_by_the_same_publisher_gets_its_own_member_copies() { + // Reuse scoped to one publication: sharing a copy across teams would let deleting either orphan it. + let source = source(&"a".repeat(64)); + let body = content(vec![member("m1", "Do the work.")]); + let (personas, teams) = plan(&[], &[], &source, &body).stores.unwrap(); + let other_publication = TeamCatalogSource { + team_d_tag: "team-beta".to_string(), + ..source + }; + + let (after, _) = plan(&personas, &teams, &other_publication, &body) + .stores + .expect("a different team d-tag is a new add"); + + assert_eq!( + after.len(), + 2, + "an identical member from a different publication is its own copy" + ); +} + +#[test] +fn test_a_deactivated_copy_is_reactivated_rather_than_duplicated() { + // `delete_team_with_cascade` deactivates copies; re-adding must revive them, not stack a second set. + // (verifies `plan_add`'s reactivation branch; production deactivation path in `teams_tests`). + let source = source(&"a".repeat(64)); + let body = content(vec![member("m1", "Do the work.")]); + let (mut personas, _) = plan(&[], &[], &source, &body).stores.unwrap(); + personas[0].is_active = false; // mirrors what delete_team_with_cascade does + + let (after, _) = plan(&personas, &[], &source, &body) + .stores + .expect("with the team gone, this is a fresh add"); + + assert_eq!(after.len(), 1, "the existing copy is reused"); + assert!(after[0].is_active, "and reactivated"); +} + +#[test] +fn test_a_newer_version_of_a_member_becomes_a_separate_copy() { + // Provenance match is on triple (owner, d_tag, member_key, prompt): adding newer version is a distinct copy. + let source = source(&"a".repeat(64)); + let (personas, _) = plan(&[], &[], &source, &content(vec![member("m1", "Old.")])) + .stores + .unwrap(); + let (after, _) = plan( + &personas, + &[], + &source, + &content(vec![member("m1", "New.")]), + ) + .stores + .unwrap(); + assert_eq!(after.len(), 2, "a changed member is a distinct version"); + assert_ne!( + after[0] + .team_catalog_source + .as_ref() + .map(|s| &s.projection_hash), + after[1] + .team_catalog_source + .as_ref() + .map(|s| &s.projection_hash), + ); +} + +/// Real built-in record (avatar cleared — live built-ins ship ~170 KiB inline PNG). +fn builtin(id: &str) -> AgentDefinition { + let mut record = crate::managed_agents::built_in_persona_definition(id, NOW) + .unwrap_or_else(|| panic!("'{id}' is not a built-in persona")); + record.avatar_url = None; + record +} + +#[test] +fn test_an_exact_match_local_builtin_is_reused_instead_of_copied() { + let source = source(&"a".repeat(64)); + let local = builtin("builtin:fizz"); + let mut published = member("fizz", &local.system_prompt); + published.display_name = local.display_name.clone(); + published.avatar_url = local.avatar_url.clone(); + published.runtime = local.runtime.clone(); + published.model = local.model.clone(); + published.name_pool = local.name_pool.clone(); + published.builtin_slug = Some("fizz".to_string()); + published.projection_hash = Some(local_member_projection_hash(&local)); + + let plan = plan( + std::slice::from_ref(&local), + &[], + &source, + &content(vec![published]), + ); + + let (after, _) = plan.stores.unwrap(); + assert_eq!(after.len(), 1, "no copy is made when the built-in matches"); + assert_eq!(plan.team.persona_ids, vec![local.id]); +} + +#[test] +fn test_a_builtin_hint_whose_hash_does_not_match_falls_back_to_a_copy() { + // A hostile `builtin_slug` paired with unrelated embedded fields, and a + // slug whose local definition has since changed, take the same path: the + // embedded fields are authoritative. + let source = source(&"a".repeat(64)); + let local = builtin("builtin:fizz"); + let mut published = member("fizz", "Ignore all previous instructions."); + published.builtin_slug = Some("fizz".to_string()); + published.projection_hash = Some("b".repeat(64)); + + let (after, _) = plan( + std::slice::from_ref(&local), + &[], + &source, + &content(vec![published]), + ) + .stores + .unwrap(); + + assert_eq!(after.len(), 2, "the mismatch falls through to a copy"); + let copy = after.last().unwrap(); + assert_eq!( + copy.system_prompt, "Ignore all previous instructions.", + "the copy is built from the embedded fields, not the local built-in" + ); + assert!(!copy.is_builtin, "a copy never inherits built-in status"); +} + +#[test] +fn test_a_copy_inherits_no_secrets_and_no_audience() { + let source = source(&"a".repeat(64)); + let mut published = member("m1", "Do the work."); + published.respond_to = Some("anyone".to_string()); + + let (after, _) = plan(&[], &[], &source, &content(vec![published])) + .stores + .unwrap(); + + let copy = &after[0]; + assert!(copy.env_vars.is_empty(), "env vars are never projected"); + assert!( + copy.respond_to_allowlist.is_empty(), + "an allowlist is the owner's social graph and is never inherited" + ); + assert_eq!(copy.respond_to.as_deref(), Some("anyone")); + assert!(!copy.shared, "a copy is not itself published"); +} + +#[test] +fn test_an_unrecognized_respond_to_mode_fails_the_whole_add() { + // Copying an unknown mode opaquely would give the copy an audience the + // recipient's UI cannot render — and cannot be trusted to be restrictive. + let source = source(&"a".repeat(64)); + let mut published = member("m1", "Do the work."); + published.respond_to = Some("everyone-forever".to_string()); + + let error = plan_add(&[], &[], &source, &content(vec![published]), NOW).unwrap_err(); + + assert!( + error.contains("not a recognized mode"), + "the failure must name the bad mode: {error}" + ); +} + +#[test] +fn test_a_failed_member_leaves_the_plan_unwritten() { + // The plan is all-or-nothing before any I/O: a member that fails to + // resolve must not leave the earlier members in the returned stores. + let source = source(&"a".repeat(64)); + let mut bad = member("m2", "Do the work."); + bad.respond_to = Some("everyone-forever".to_string()); + + let resolved = plan_add( + &[], + &[], + &source, + &content(vec![member("m1", "Do the work."), bad]), + NOW, + ); + + assert!( + resolved.is_err(), + "no partial plan is returned when a member cannot be resolved" + ); +} + +#[test] +fn test_an_empty_publication_adds_a_team_with_no_members() { + // A team whose every member was deleted still projects; adding it must + // produce an empty team rather than failing or inventing a member. + let source = source(&"a".repeat(64)); + + let plan = plan(&[], &[], &source, &content(Vec::new())); + + let (personas, teams) = plan.stores.expect("an empty team is still an add"); + assert!(personas.is_empty()); + assert_eq!(teams.len(), 1); + assert!(plan.team.persona_ids.is_empty()); +} + +#[test] +fn test_provenance_from_a_different_owner_does_not_match() { + // Two publishers can legitimately use the same team d-tag and member key. + let mine = source(&"a".repeat(64)); + let theirs = source(&"b".repeat(64)); + let body = content(vec![member("m1", "Do the work.")]); + let (personas, _) = plan(&[], &[], &mine, &body).stores.unwrap(); + + let (after, _) = plan(&personas, &[], &theirs, &body).stores.unwrap(); + + assert_eq!( + after.len(), + 2, + "provenance is scoped to the publishing owner" + ); +} + +#[test] +fn test_a_persona_catalog_copy_is_not_mistaken_for_a_team_member() { + // 30175 and 30178 are different namespaces; a persona-catalog copy must not satisfy team provenance. + let source = source(&"a".repeat(64)); + let mut persona_copy = persona("p1", "Do the work."); + persona_copy.catalog_source = Some(crate::managed_agents::CatalogSource { + owner_pubkey: source.owner_pubkey.clone(), + persona_id: "m1".to_string(), + }); + + let (after, _) = plan( + &[persona_copy], + &[], + &source, + &content(vec![member("m1", "Do the work.")]), + ) + .stores + .unwrap(); + + assert_eq!(after.len(), 2, "the 30175 copy is not a 30178 member"); +} + +#[test] +fn test_provenance_survives_a_store_round_trip() { + // Reuse reads from disk; a provenance field that does not persist would silently duplicate copies. + let src = source(&"a".repeat(64)); + let (personas, _) = plan(&[], &[], &src, &content(vec![member("m1", "Do it.")])) + .stores + .unwrap(); + let json = serde_json::to_string(&personas).unwrap(); + let reloaded: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!( + reloaded[0].team_catalog_source.clone(), + personas[0].team_catalog_source.clone(), + ); +} + +// ── Lifecycle: delete seam + re-add, allowlist normalization, built-in round-trip + +#[test] +fn test_delete_catalog_team_seam_then_re_add_reactivates_copies() { + // Exercises delete_catalog_team_at (the production file-based seam) + re-add. + let dir = tempfile::tempdir().unwrap(); + let src = TeamCatalogSource { + owner_pubkey: "f".repeat(64), + team_d_tag: "team-delta".to_string(), + }; + let body = content(vec![member("mk1", "Do it.")]); + let (personas, teams) = plan_add(&[], &[], &src, &body, NOW) + .unwrap() + .stores + .unwrap(); + let copy_id = personas[0].id.clone(); + let (pp, tp) = (dir.path().join("p.json"), dir.path().join("t.json")); + std::fs::write(&pp, serde_json::to_string(&personas).unwrap()).unwrap(); + std::fs::write(&tp, serde_json::to_string(&teams).unwrap()).unwrap(); + crate::managed_agents::delete_catalog_team_at(&pp, &tp, &teams[0].id).unwrap(); + let del_p: Vec = + serde_json::from_str(&std::fs::read_to_string(&pp).unwrap()).unwrap(); + let del_t: Vec = + serde_json::from_str(&std::fs::read_to_string(&tp).unwrap()).unwrap(); + assert!( + del_t.is_empty() && !del_p[0].is_active, + "delete must remove team and deactivate copy" + ); + let (after, _) = plan_add(&del_p, &del_t, &src, &body, NOW) + .unwrap() + .stores + .unwrap(); + assert_eq!(after[0].id, copy_id, "re-add reuses same copy id"); + assert!(after[0].is_active, "copy is reactivated"); +} + +#[test] +fn test_allowlist_respond_to_is_normalized_to_owner_only_on_adoption() { + // The publisher's allowlist is their social graph and must not be copied. + // The mode itself downgrades to owner-only so the copy is launch-valid. + let src = source(&"e".repeat(64)); + let mut m = member("m1", "Review the work."); + m.respond_to = Some("allowlist".to_string()); + let (personas, _) = plan(&[], &[], &src, &content(vec![m])).stores.unwrap(); + assert_eq!( + personas[0].respond_to.as_deref(), + Some("owner-only"), + "allowlist mode must be normalized to owner-only at adoption" + ); + assert!(personas[0].respond_to_allowlist.is_empty()); + let mint = crate::managed_agents::resolve_mint_behavioral_defaults( + personas[0] + .respond_to + .as_deref() + .and_then(|w| crate::managed_agents::RespondTo::parse_wire(w).ok()), + personas[0].respond_to_allowlist.clone(), + None, + None, + ); + assert!( + mint.is_ok(), + "normalized respond_to must be launch-valid: {mint:?}" + ); +} + +#[test] +fn test_real_builtin_round_trips_through_publish_and_plan_add() { + // End-to-end reuse fix: fizz (with its ~170 KiB avatar) is published via + // build_team_catalog_event, parsed on the recipient side, and plan_add + // reuses the local built-in rather than minting a copy. + use crate::managed_agents::team_catalog::{ + build_team_catalog_event, team_catalog_content_from_event, MAX_AVATAR_URL_BYTES, + }; + let local = crate::managed_agents::built_in_persona_definition("builtin:fizz", NOW) + .expect("builtin:fizz must exist"); + let t = team_fixture(vec![local.id.clone()]); + let keys = nostr::Keys::generate(); + let event = build_team_catalog_event(&t, std::slice::from_ref(&local), true) + .expect("real built-in projects without avatar mutation") + .sign_with_keys(&keys) + .unwrap(); + let src = source(&keys.public_key().to_hex()); + let body = team_catalog_content_from_event(&event).expect("projected event must parse"); + if local + .avatar_url + .as_deref() + .is_some_and(|u| u.len() > MAX_AVATAR_URL_BYTES) + { + assert!( + body.members[0].avatar_url.is_none(), + "oversized avatar stripped" + ); + } + let (after, _) = plan_add(std::slice::from_ref(&local), &[], &src, &body, NOW) + .expect("add with matching built-in must succeed") + .stores + .expect("add must produce stores"); + assert_eq!( + after[0].id, local.id, + "local built-in is reused, no copy minted" + ); +} + +// ── commit_stores: byte-level rollback coverage ─────────────────────────── + +mod commit_stores_tests { + use super::super::apply::commit_stores; + use std::fs; + + fn write_file(path: &std::path::Path, contents: &[u8]) { + fs::write(path, contents).unwrap(); + } + + #[test] + fn test_both_writes_succeed_leaves_new_content() { + let dir = tempfile::tempdir().unwrap(); + let personas = dir.path().join("personas.json"); + let teams = dir.path().join("teams.json"); + write_file(&personas, b"old-personas"); + write_file(&teams, b"old-teams"); + + let result = commit_stores( + &personas, + &teams, + || { + fs::write(&personas, b"new-personas").map_err(|e| e.to_string())?; + Ok(()) + }, + || { + fs::write(&teams, b"new-teams").map_err(|e| e.to_string())?; + Ok(()) + }, + ); + + assert!(result.is_ok()); + assert_eq!(fs::read(&personas).unwrap(), b"new-personas"); + assert_eq!(fs::read(&teams).unwrap(), b"new-teams"); + } + + #[test] + fn test_first_write_fails_both_files_restored() { + let dir = tempfile::tempdir().unwrap(); + let personas = dir.path().join("personas.json"); + let teams = dir.path().join("teams.json"); + write_file(&personas, b"original-personas"); + write_file(&teams, b"original-teams"); + + let result = commit_stores( + &personas, + &teams, + || Err("personas save failed".to_string()), + || unreachable!("teams write should not run if personas failed"), + ); + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("personas save failed")); + assert_eq!(fs::read(&personas).unwrap(), b"original-personas"); + assert_eq!(fs::read(&teams).unwrap(), b"original-teams"); + } + + #[test] + fn test_second_write_fails_after_first_committed_both_restored() { + let dir = tempfile::tempdir().unwrap(); + let personas = dir.path().join("personas.json"); + let teams = dir.path().join("teams.json"); + write_file(&personas, b"original-personas"); + write_file(&teams, b"original-teams"); + + let result = commit_stores( + &personas, + &teams, + || { + fs::write(&personas, b"new-personas").map_err(|e| e.to_string())?; + Ok(()) + }, + || Err("teams save failed".to_string()), + ); + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("teams save failed")); + assert_eq!(fs::read(&personas).unwrap(), b"original-personas"); + assert_eq!(fs::read(&teams).unwrap(), b"original-teams"); + } + + #[test] + fn test_absent_file_is_removed_on_rollback() { + let dir = tempfile::tempdir().unwrap(); + let personas = dir.path().join("personas.json"); + let teams = dir.path().join("teams.json"); + + let result = commit_stores( + &personas, + &teams, + || { + fs::write(&personas, b"new-personas").map_err(|e| e.to_string())?; + Ok(()) + }, + || Err("teams save failed".to_string()), + ); + + assert!(result.is_err()); + assert!( + !personas.exists(), + "newly created file should be removed on rollback" + ); + assert!(!teams.exists()); + } + + #[test] + fn test_restore_failure_message_includes_both_errors() { + // Restore failure aggregates both original error and restore error. + // Trigger restore failure by removing the parent dir after snapshotting. + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("sub"); + std::fs::create_dir(&sub).unwrap(); + let personas = sub.join("personas.json"); + let teams = sub.join("teams.json"); + write_file(&personas, b"snap-p"); + write_file(&teams, b"snap-t"); + + let sub_clone = sub.clone(); + let result = commit_stores( + &personas, + &teams, + || { + let _ = std::fs::remove_dir_all(&sub_clone); + Err("original error".to_string()) + }, + || unreachable!(), + ); + + assert!(result.is_err()); + let msg = result.unwrap_err(); + assert!( + msg.contains("original error"), + "missing original error in: {msg}" + ); + assert!( + msg.contains("could not be restored"), + "missing restore-failure note in: {msg}" + ); + } + + #[test] + fn test_second_position_restore_failure_reported() { + // Second restore (teams) failure must be reported alongside original error. + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("sub"); + std::fs::create_dir(&sub).unwrap(); + let personas = sub.join("personas.json"); + let teams = sub.join("teams.json"); + write_file(&personas, b"snap-p"); + write_file(&teams, b"snap-t"); + + let sub_clone = sub.clone(); + let result = commit_stores( + &personas, + &teams, + || { + fs::write(&personas, b"new-personas").map_err(|e| e.to_string())?; + Ok(()) + }, + || { + let _ = std::fs::remove_dir_all(&sub_clone); + Err("teams save failed".to_string()) + }, + ); + + assert!(result.is_err()); + let msg = result.unwrap_err(); + assert!( + msg.contains("teams save failed"), + "original teams error missing in: {msg}" + ); + assert!( + msg.contains("could not be restored"), + "restore-failure note missing in: {msg}" + ); + } + + #[test] + fn test_absent_snap_restore_is_noop_and_both_restores_are_independent() { + // Part A — absent snap: when no file existed before the add and the + // write fails, removing a non-existent path is treated as success + // (desired state already reached, I5). No "could not be restored" noise. + let dir = tempfile::tempdir().unwrap(); + let personas = dir.path().join("personas.json"); + let teams = dir.path().join("teams.json"); + let r = commit_stores( + &personas, + &teams, + || Err("write failed".to_string()), + || unreachable!(), + ); + assert!(r.is_err()); + let msg = r.unwrap_err(); + assert!(msg.contains("write failed")); + assert!(!msg.contains("could not be restored"), "{msg}"); + assert!(!personas.exists() && !teams.exists()); + + // Part B — independent restores: personas restore fails (dir gone after + // the first write), teams restore is a no-op (absent snap → NotFound). + // Both failures aggregated in the returned error (I5). + let sub = dir.path().join("sub"); + std::fs::create_dir(&sub).unwrap(); + let personas2 = sub.join("personas.json"); + let teams2 = sub.join("teams.json"); + write_file(&personas2, b"snap-p"); + let sub_clone = sub.clone(); + let r2 = commit_stores( + &personas2, + &teams2, + || { + fs::write(&personas2, b"new-p").map_err(|e| e.to_string())?; + let _ = std::fs::remove_dir_all(&sub_clone); + Ok(()) + }, + || Err("teams write failed".to_string()), + ); + assert!(r2.is_err()); + let msg2 = r2.unwrap_err(); + assert!(msg2.contains("teams write failed"), "{msg2}"); + assert!(msg2.contains("could not be restored"), "{msg2}"); + } +} diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams/mod.rs similarity index 83% rename from desktop/src-tauri/src/commands/teams.rs rename to desktop/src-tauri/src/commands/teams/mod.rs index 4377ddaa43..f3a1a731bb 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams/mod.rs @@ -25,6 +25,26 @@ fn trim_optional(value: Option) -> Option { }) } +mod adopt; +mod pending; +mod sharing; +pub use adopt::add_team_from_catalog; +pub use sharing::set_team_shared; + +/// Refresh the shared 30178 catalog heads of every team that includes +/// `persona_id` as a member, after a successful persona edit. +/// +/// Exposed as `pub(crate)` so persona-edit commands can trigger a catalog +/// refresh without crossing into the `commands::teams` private module. +/// Best-effort: failures are logged, not returned. +pub(crate) fn refresh_team_catalog_heads_for_persona( + app: &AppHandle, + state: &AppState, + persona_id: &str, +) { + pending::refresh_shared_team_catalog_heads_for_persona(app, state, persona_id); +} + /// Retain a freshly authored team event in the local store, flagged for relay /// sync. Called inside a command's `managed_agents_store_lock`-held body after /// `save_teams`; the background flush loop publishes it out-of-band. @@ -134,7 +154,9 @@ pub async fn list_teams(app: AppHandle) -> Result, String> { .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - load_teams(&app) + let mut teams = load_teams(&app)?; + pending::project_active_team_sharing(&app, &state, &mut teams); + Ok(teams) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? @@ -164,6 +186,10 @@ pub async fn create_team(input: CreateTeamRequest, app: AppHandle) -> Result Result Result Result<(), String> { // so reaching here means this team was owner-published — tombstone it. The // d_tag is the team id, captured before the record left the store. tombstone_team_pending(&app, &state, &id); + // The catalog projection is a separate coordinate with its own + // retained head, so the 30176 tombstone above does not retract it. + // Without this, deleting a shared team would leave a live catalog + // entry the owner can no longer see or unshare. + pending::tombstone_team_catalog_pending(&app, &state, &id); // Tombstone the cascaded personas too, so their orphaned kind:30175 heads // don't linger on the relay (F4). Each d-tag was captured pre-removal. for persona_d_tag in &cascaded_persona_d_tags { diff --git a/desktop/src-tauri/src/commands/teams/pending.rs b/desktop/src-tauri/src/commands/teams/pending.rs new file mode 100644 index 0000000000..22f56992c2 --- /dev/null +++ b/desktop/src-tauri/src/commands/teams/pending.rs @@ -0,0 +1,485 @@ +//! Retention-store enqueue helpers for the owner's kind:30178 team catalog +//! heads: build and retain a pending projection on share, retain a newer +//! untagged head on unshare, purge + tombstone on delete. +//! +//! Mirrors `commands::personas::pending` one-for-one — same retention store, +//! same monotonic `created_at` rule, same tombstone-first ordering, same +//! flush loop (`flush_pending_events`) as the sole background publisher. The +//! only structural difference is the projection itself: a persona head is +//! built from one record, while a catalog head is built from a team plus its +//! ordered member definitions (`managed_agents::team_catalog`). + +use tauri::AppHandle; + +use crate::app_state::AppState; +use crate::managed_agents::{ + retention::{RetainedEvent, RetentionScope}, + AgentDefinition, TeamRecord, +}; + +use buzz_core_pkg::kind::KIND_TEAM_CATALOG; + +/// A signed catalog head, retained and awaiting relay acceptance. +pub(super) struct PreparedTeamPublication { + pub scope: RetentionScope, + pub event: nostr::Event, + pub retained: RetainedEvent, + pub team: TeamRecord, +} + +/// Outcome of a single refresh-or-retract operation. +/// +/// Returned by `refresh_or_retract_shared_head_at` and carried through every +/// wrapper so each site can emit the right queue-accurate notice. "Removal" +/// means a tombstone has been *enqueued* for the flush loop — the relay head +/// may still be live until the flush succeeds. +#[derive(Debug, PartialEq)] +pub(super) enum RefreshOrRetractOutcome { + /// No retained shared head — the operation is a no-op. + Noop, + /// The shared head was rebuilt and the newer version is now retained. + Refreshed, + /// The shared head could not be rebuilt; a tombstone was enqueued. + RemovalQueued { reason: String }, +} + +/// Whether a retained catalog head carries the exact `shared` tag. +/// +/// Reuses `event_is_shared`, the same fail-closed exact-shape check the relay +/// applies at its read gate, so the client's notion of "shared" cannot drift +/// from the relay's. +fn retained_team_is_shared(row: Option<&RetainedEvent>) -> bool { + use buzz_core_pkg::kind::event_is_shared; + use nostr::JsonUtil; + + row.and_then(|retained| nostr::Event::from_json(&retained.raw_event).ok()) + .is_some_and(|event| event_is_shared(&event)) +} + +/// Project each team's catalog visibility from the active relay+owner scope's +/// retained 30178 head. +/// +/// Infallible by design, for the same reason as +/// `personas::pending::project_active_persona_sharing`: the scope needs +/// `signing_keys()`, which fails process-wide whenever the identity is lost or +/// the keyring is locked, and propagating that error would break listing, +/// creating, and editing EVERY team. Share state is a view projection, so an +/// unresolvable scope degrades to "not shared" — it can under-report +/// visibility but can never present an unshared team as published. +pub(super) fn project_active_team_sharing( + app: &AppHandle, + state: &AppState, + teams: &mut [TeamRecord], +) { + let scope = crate::managed_agents::retention::active_retention_scope(app, state); + project_scoped_team_sharing(scope, teams); +} + +fn project_scoped_team_sharing(scope: Result, teams: &mut [TeamRecord]) { + let projected = scope.and_then(|scope| { + project_team_sharing_at( + &scope.db_path, + &scope.owner_keys.public_key().to_hex(), + teams, + ) + }); + if let Err(error) = projected { + eprintln!( + "buzz-desktop: team-share-projection unavailable, reporting every team as unshared: {error}" + ); + for team in teams { + team.shared = false; + } + } +} + +fn project_team_sharing_at( + db_path: &std::path::Path, + owner_pubkey: &str, + teams: &mut [TeamRecord], +) -> Result<(), String> { + use crate::managed_agents::retention::{get_retained_event, open_retention_db}; + + let conn = open_retention_db(db_path)?; + for team in teams { + if team.is_builtin { + team.shared = false; + continue; + } + let retained = get_retained_event(&conn, KIND_TEAM_CATALOG, owner_pubkey, &team.id)?; + team.shared = retained_team_is_shared(retained.as_ref()); + } + Ok(()) +} + +/// Build, sign, and durably retain a team's catalog head in the active +/// relay+owner scope. +/// +/// `shared_override` follows the persona rule: the explicit share toggle +/// passes `Some(shared)`, while a rebuild triggered by an edit passes `None` +/// and preserves whatever the scoped head already says. That is what makes an +/// ordinary team edit unable to silently unshare — and it is belt-and-braces +/// here, since share state lives on 30178 and an edit republishes 30176. +pub(super) fn prepare_team_publication( + app: &AppHandle, + state: &AppState, + team: &TeamRecord, + members: &[AgentDefinition], + shared_override: Option, +) -> Result { + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let (event, retained, team) = prepare_team_publication_at( + &scope.db_path, + &scope.owner_keys, + team, + members, + shared_override, + )?; + Ok(PreparedTeamPublication { + scope, + event, + retained, + team, + }) +} + +pub(super) fn prepare_team_publication_at( + db_path: &std::path::Path, + keys: &nostr::Keys, + team: &TeamRecord, + members: &[AgentDefinition], + shared_override: Option, +) -> Result<(nostr::Event, RetainedEvent, TeamRecord), String> { + use crate::managed_agents::{ + persona_events::monotonic_created_at, + retention::{get_retained_event, open_retention_db, retain_event}, + team_catalog::build_team_catalog_event, + }; + use nostr::JsonUtil; + + let pubkey = keys.public_key().to_hex(); + let conn = open_retention_db(db_path)?; + let existing = get_retained_event(&conn, KIND_TEAM_CATALOG, &pubkey, &team.id)?; + let mut scoped_team = team.clone(); + scoped_team.shared = + shared_override.unwrap_or_else(|| retained_team_is_shared(existing.as_ref())); + // The size contract runs inside the builder, BEFORE signing, so an + // oversized team fails here with a named field instead of enqueuing an + // event the relay would permanently refuse. + let event = build_team_catalog_event(&scoped_team, members, scoped_team.shared)? + .custom_created_at(monotonic_created_at( + existing.as_ref().map(|row| row.created_at), + )) + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign team catalog event: {e}"))?; + let retained = RetainedEvent { + kind: KIND_TEAM_CATALOG, + pubkey, + d_tag: team.id.clone(), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }; + retain_event(&conn, &retained)?; + Ok((event, retained, scoped_team)) +} + +/// Purge a deleted team's retained catalog head and enqueue a NIP-09 +/// tombstone for its 30178 coordinate. +/// +/// The 30176 team head has its own tombstone (`tombstone_team_pending`); this +/// is the catalog counterpart and both run on delete, because the two kinds +/// are separate coordinates and deleting one does not retract the other. Same +/// purge-then-tombstone ordering as personas: removing the 30178 row first +/// under the store lock stops an unpublished re-share from resurrecting the +/// entry after the tombstone lands. Best-effort — a failure is logged and +/// swallowed so a retention hiccup never blocks the disk-authoritative delete. +pub(super) fn tombstone_team_catalog_pending(app: &AppHandle, state: &AppState, d_tag: &str) { + let result = (|| -> Result<(), String> { + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + tombstone_team_catalog_at(&scope.db_path, &scope.owner_keys, d_tag) + })(); + if let Err(e) = result { + eprintln!("buzz-desktop: team-catalog-tombstone: {e}"); + } +} + +/// Scope-free core of [`tombstone_team_catalog_pending`], so the purge and +/// enqueue can be asserted directly against a retention database. +pub(super) fn tombstone_team_catalog_at( + db_path: &std::path::Path, + keys: &nostr::Keys, + d_tag: &str, +) -> Result<(), String> { + crate::managed_agents::team_catalog::tombstone_team_catalog_coordinate(db_path, keys, d_tag) +} + +/// Refresh or retract the shared 30178 head for `team` after a team edit, +/// resolving members from `personas` first. +/// +/// Resolution failure (a member was deleted) is treated as a projection +/// failure: the shared head is tombstoned and the owner is notified via the +/// typed `team-catalog-auto-retracted` Tauri event. Best-effort: a retention +/// hiccup never blocks the team edit from returning. +pub(super) fn refresh_shared_team_catalog_head_resolving( + app: &AppHandle, + state: &AppState, + team: &TeamRecord, + personas: &[AgentDefinition], +) { + let result = (|| -> Result { + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + resolve_and_refresh_or_retract_at(&scope.db_path, &scope.owner_keys, team, personas) + })(); + match result { + Ok(RefreshOrRetractOutcome::RemovalQueued { ref reason }) => { + eprintln!( + "buzz-desktop: team-catalog-refresh: retracting '{}' — {reason}", + team.name + ); + emit_team_catalog_auto_retracted(app, &team.name, reason); + } + Err(ref e) => { + eprintln!("buzz-desktop: team-catalog-refresh: '{}' — {e}", team.name); + } + _ => {} + } +} + +/// Scope-free single-team core: resolve `team`'s members from `personas`, +/// then run the refresh-or-retract state machine. +/// +/// On resolution failure the head may already be shared; the function checks +/// and tombstones if so, returning `RemovalQueued`. This is the ONLY place +/// the "resolution failure → tombstone-if-shared" logic lives — both the +/// production persona-edit path and the `#[cfg(test)]` file-based seam call +/// this function so there is no divergence between tested and production code. +pub(super) fn resolve_and_refresh_or_retract_at( + db_path: &std::path::Path, + keys: &nostr::Keys, + team: &TeamRecord, + personas: &[AgentDefinition], +) -> Result { + use crate::managed_agents::team_catalog::resolve_team_members; + + match resolve_team_members(team, personas) { + Ok(members) => refresh_or_retract_shared_head_at(db_path, keys, team, &members), + Err(reason) => { + // Resolution failed (a required member is missing). Treat this + // identically to a projection build failure: tombstone the shared + // head if one exists, so the stale projection is not left live. + // `refresh_or_retract_shared_head_at` implements this exact policy + // when the builder returns Err — we reproduce the guard + tombstone + // inline so the resolution-error reason is preserved in the payload. + use crate::managed_agents::retention::{get_retained_event, open_retention_db}; + use buzz_core_pkg::kind::{event_is_shared, KIND_TEAM_CATALOG}; + use nostr::JsonUtil; + + let pubkey = keys.public_key().to_hex(); + let conn = open_retention_db(db_path)?; + let Some(existing) = get_retained_event(&conn, KIND_TEAM_CATALOG, &pubkey, &team.id)? + else { + return Ok(RefreshOrRetractOutcome::Noop); + }; + let head_event = nostr::Event::from_json(&existing.raw_event) + .map_err(|e| format!("failed to parse retained head: {e}"))?; + if !event_is_shared(&head_event) { + return Ok(RefreshOrRetractOutcome::Noop); + } + // Shared head exists but team is now unresolvable — tombstone it. + drop(conn); + crate::managed_agents::team_catalog::tombstone_team_catalog_coordinate( + db_path, keys, &team.id, + )?; + Ok(RefreshOrRetractOutcome::RemovalQueued { reason }) + } + } +} + +/// Core of [`refresh_shared_team_catalog_head_resolving`], scope-free so it is +/// testable without a Tauri `AppHandle`. +pub(super) fn refresh_or_retract_shared_head_at( + db_path: &std::path::Path, + keys: &nostr::Keys, + team: &TeamRecord, + members: &[AgentDefinition], +) -> Result { + use crate::managed_agents::{ + persona_events::monotonic_created_at, + retention::{get_retained_event, open_retention_db, retain_event}, + team_catalog::build_team_catalog_event, + }; + use buzz_core_pkg::kind::{event_is_shared, KIND_TEAM_CATALOG}; + use nostr::JsonUtil; + + let pubkey = keys.public_key().to_hex(); + let conn = open_retention_db(db_path)?; + + // Guard: only act when a retained shared head exists. A never-shared team + // must never produce a 30178 row — failing to check this was the CRITICAL + // security issue (the whole persona store was being published). + let Some(existing) = get_retained_event(&conn, KIND_TEAM_CATALOG, &pubkey, &team.id)? else { + return Ok(RefreshOrRetractOutcome::Noop); + }; + let head_event = nostr::Event::from_json(&existing.raw_event) + .map_err(|e| format!("failed to parse retained head: {e}"))?; + if !event_is_shared(&head_event) { + return Ok(RefreshOrRetractOutcome::Noop); + } + + // Attempt to rebuild. On failure, purge + tombstone immediately so the + // stale shared head is not left public. + let rebuilt = build_team_catalog_event(team, members, true); + let builder = match rebuilt { + Ok(b) => b, + Err(reason) => { + // Close the read connection before the tombstone opens another + // write connection (WAL allows concurrent connections but + // explicit drop is cleaner for test isolation). + drop(conn); + crate::managed_agents::team_catalog::tombstone_team_catalog_coordinate( + db_path, keys, &team.id, + )?; + return Ok(RefreshOrRetractOutcome::RemovalQueued { reason }); + } + }; + + let event = builder + .custom_created_at(monotonic_created_at(Some(existing.created_at))) + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign team catalog head: {e}"))?; + + retain_event( + &conn, + &crate::managed_agents::retention::RetainedEvent { + kind: KIND_TEAM_CATALOG, + pubkey, + d_tag: team.id.clone(), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + )?; + Ok(RefreshOrRetractOutcome::Refreshed) +} + +/// Refresh or retract the shared 30178 heads of every team that includes +/// `persona_id` as a member, after a successful persona edit. +/// +/// A persona edit changes every catalog projection it is part of. Walking all +/// teams is the only way to find them without an inverse index. +/// +/// **Privacy invariant**: for each affected team, `resolve_team_members` is +/// called so that only that team's own ordered members are projected — never +/// the entire persona store. Passing the whole store to the projection core +/// was the CRITICAL defect: it would embed every local persona's instructions +/// in the published 30178, regardless of whether they were members. +/// +/// Best-effort: per-team failures are logged and do not block each other. +pub(super) fn refresh_shared_team_catalog_heads_for_persona( + app: &AppHandle, + state: &AppState, + persona_id: &str, +) { + let result = (|| -> Result<(), String> { + use crate::managed_agents::{load_personas, load_teams}; + + let teams = load_teams(app)?; + let personas = load_personas(app)?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + + for team in &teams { + if team.is_builtin || !team.persona_ids.iter().any(|id| id == persona_id) { + continue; + } + // Use the unified core so resolution failure → tombstone semantics + // are identical in production and in tests (no divergence). + let outcome = resolve_and_refresh_or_retract_at( + &scope.db_path, + &scope.owner_keys, + team, + &personas, + ); + match outcome { + Ok(RefreshOrRetractOutcome::RemovalQueued { ref reason }) => { + eprintln!( + "buzz-desktop: team-catalog-refresh: retracting '{}' after persona edit — {reason}", + team.name + ); + emit_team_catalog_auto_retracted(app, &team.name, reason); + } + Err(ref e) => { + eprintln!( + "buzz-desktop: team-catalog-refresh: '{}' after persona edit — {e}", + team.name + ); + } + _ => {} + } + } + Ok(()) + })(); + if let Err(e) = result { + eprintln!("buzz-desktop: team-catalog-refresh-for-persona: {e}"); + } +} + +/// Testable seam for [`refresh_shared_team_catalog_heads_for_persona`]. +/// +/// Reads teams and personas from flat JSON files in `base_dir` rather than +/// through the Tauri store. Calls the SAME `resolve_and_refresh_or_retract_at` +/// that production uses — the seam is a thin file-loading shim with no +/// independent logic. Tests therefore exercise the exact production code path. +#[cfg(test)] +pub(super) fn refresh_for_persona_at( + base_dir: &std::path::Path, + keys: &nostr::Keys, + db_path: &std::path::Path, + persona_id: &str, +) -> Result<(), String> { + use crate::event_sync::read_json_store_pub as read_json_store; + + let teams: Vec = + read_json_store(&base_dir.join("teams.json"))?; + let personas: Vec = + read_json_store(&base_dir.join("personas.json"))?; + + for team in &teams { + if team.is_builtin || !team.persona_ids.iter().any(|id| id == persona_id) { + continue; + } + // Identical call to production — no parallel implementation. + let _ = resolve_and_refresh_or_retract_at(db_path, keys, team, &personas); + } + Ok(()) +} + +/// Emit a typed Tauri event so the frontend can show the owner a notice when +/// a shared team is automatically retracted due to a projection failure. +/// +/// "Removal queued" is accurate: the tombstone has been enqueued for the flush +/// loop, but the relay head may still be live until the flush succeeds. +/// Best-effort: a failed emit is logged but does not block the operation. +fn emit_team_catalog_auto_retracted(app: &AppHandle, team_name: &str, reason: &str) { + use serde::Serialize; + use tauri::Emitter; + + #[derive(Clone, Serialize)] + #[serde(rename_all = "camelCase")] + struct TeamCatalogAutoRetractedPayload<'a> { + team_name: &'a str, + reason: &'a str, + } + + if let Err(e) = app.emit( + "team-catalog-auto-retracted", + TeamCatalogAutoRetractedPayload { team_name, reason }, + ) { + eprintln!("buzz-desktop: team-catalog-auto-retracted: failed to emit notice: {e}"); + } +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/commands/teams/pending/tests.rs b/desktop/src-tauri/src/commands/teams/pending/tests.rs new file mode 100644 index 0000000000..a0fc0a4938 --- /dev/null +++ b/desktop/src-tauri/src/commands/teams/pending/tests.rs @@ -0,0 +1,679 @@ +use super::*; +use crate::managed_agents::retention::{ + get_pending_sync, get_retained_event, open_retention_db, retain_event, + scoped_retention_db_path, tombstone_retention_d_tag, +}; +use buzz_core_pkg::kind::{event_is_shared, KIND_TEAM}; +use nostr::JsonUtil; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +const KIND_DELETE: u32 = 5; + +fn member(id: &str, display_name: &str) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: display_name.to_string(), + avatar_url: None, + system_prompt: "Do the work.".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + team_catalog_source: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2026-07-30T00:00:00Z".to_string(), + updated_at: "2026-07-30T00:00:00Z".to_string(), + } +} + +fn team() -> TeamRecord { + TeamRecord { + id: "team-abc".to_string(), + name: "Catalog Team".to_string(), + description: Some("A shared team".to_string()), + instructions: None, + persona_ids: vec!["m1".to_string(), "m2".to_string()], + is_builtin: false, + shared: false, + catalog_source: None, + source_dir: Some(PathBuf::from("/local/only/path")), + is_symlink: false, + symlink_target: None, + version: None, + created_at: "2026-07-30T00:00:00Z".to_string(), + updated_at: "2026-07-30T00:00:00Z".to_string(), + } +} + +fn members() -> Vec { + vec![member("m1", "One"), member("m2", "Two")] +} + +/// A retention database in its own scope directory, ready to write. +fn scoped_db(dir: &Path, relay_url: &str, owner: &str) -> PathBuf { + let db_path = scoped_retention_db_path(dir, relay_url, owner); + std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + db_path +} + +fn retained_head(db_path: &Path, owner: &str) -> Option { + let conn = open_retention_db(db_path).unwrap(); + get_retained_event(&conn, KIND_TEAM_CATALOG, owner, "team-abc").unwrap() +} + +// ── Publish / unshare ──────────────────────────────────────────────────────── + +#[test] +fn test_share_retains_a_pending_head_carrying_the_shared_tag() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let db_path = scoped_db(dir.path(), "wss://a.example", &owner); + + let (event, _, scoped_team) = + prepare_team_publication_at(&db_path, &keys, &team(), &members(), Some(true)).unwrap(); + + assert!(event_is_shared(&event)); + assert!(scoped_team.shared); + let row = retained_head(&db_path, &owner).expect("the head is retained on share"); + assert!(row.pending_sync, "the flush loop must still owe a publish"); +} + +#[test] +fn test_unshare_publishes_a_newer_untagged_head_instead_of_deleting() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let db_path = scoped_db(dir.path(), "wss://a.example", &owner); + + let (shared_event, _, _) = + prepare_team_publication_at(&db_path, &keys, &team(), &members(), Some(true)).unwrap(); + let (untagged_event, _, scoped_team) = + prepare_team_publication_at(&db_path, &keys, &team(), &members(), Some(false)).unwrap(); + + assert!(!event_is_shared(&untagged_event)); + assert!(!scoped_team.shared); + assert!( + untagged_event.created_at > shared_event.created_at, + "the retraction must supersede the shared head monotonically" + ); + let row = retained_head(&db_path, &owner).expect("unshare replaces the head, never deletes it"); + assert!(!retained_team_is_shared(Some(&row))); + assert!(row.pending_sync); +} + +#[test] +fn test_edit_without_an_override_preserves_the_scoped_share_state() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let db_path = scoped_db(dir.path(), "wss://a.example", &owner); + prepare_team_publication_at(&db_path, &keys, &team(), &members(), Some(true)).unwrap(); + + let mut edited = team(); + edited.name = "Renamed Team".to_string(); + let (event, _, scoped_team) = + prepare_team_publication_at(&db_path, &keys, &edited, &members(), None).unwrap(); + + assert!( + scoped_team.shared && event_is_shared(&event), + "an ordinary edit must not silently unshare the team" + ); +} + +#[test] +fn test_share_state_is_scoped_by_relay_and_owner() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let community_a = scoped_db(dir.path(), "wss://a.example", &owner); + let community_b = scoped_db(dir.path(), "wss://b.example", &owner); + + prepare_team_publication_at(&community_a, &keys, &team(), &members(), Some(true)).unwrap(); + let (_, _, in_b) = + prepare_team_publication_at(&community_b, &keys, &team(), &members(), None).unwrap(); + + assert!(!in_b.shared, "one community's share choice must not leak"); + assert!(retained_team_is_shared( + retained_head(&community_a, &owner).as_ref() + )); + assert!(!retained_team_is_shared( + retained_head(&community_b, &owner).as_ref() + )); +} + +#[test] +fn test_oversized_team_fails_before_anything_is_enqueued() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let db_path = scoped_db(dir.path(), "wss://a.example", &owner); + let mut huge = member("m1", "One"); + huge.system_prompt = + "x".repeat(crate::managed_agents::team_catalog::MAX_SYSTEM_PROMPT_BYTES + 1); + + let error = + prepare_team_publication_at(&db_path, &keys, &team(), &[huge], Some(true)).unwrap_err(); + + assert!( + error.contains("the system prompt for 'One'"), + "the error must name the oversized field, got: {error}" + ); + assert!( + retained_head(&db_path, &owner).is_none(), + "a projection the relay would refuse must never reach the pending queue" + ); +} + +// ── Projection ─────────────────────────────────────────────────────────────── + +#[test] +fn test_resolvable_scope_projects_the_retained_share_state() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let db_path = scoped_db(dir.path(), "wss://a.example", &owner); + prepare_team_publication_at(&db_path, &keys, &team(), &members(), Some(true)).unwrap(); + let mut teams = vec![team()]; + + project_scoped_team_sharing( + Ok(RetentionScope { + db_path, + relay_url: "wss://a.example".to_string(), + owner_keys: keys, + }), + &mut teams, + ); + + assert!(teams[0].shared); +} + +#[test] +fn test_builtin_teams_project_as_unshared() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let db_path = scoped_db(dir.path(), "wss://a.example", &owner); + // A head exists at the coordinate, so only the built-in guard can keep the + // projection false. + prepare_team_publication_at(&db_path, &keys, &team(), &members(), Some(true)).unwrap(); + let mut teams = vec![team()]; + teams[0].is_builtin = true; + + project_scoped_team_sharing( + Ok(RetentionScope { + db_path, + relay_url: "wss://a.example".to_string(), + owner_keys: keys, + }), + &mut teams, + ); + + assert!(!teams[0].shared, "built-in teams are never shareable"); +} + +#[test] +fn test_unresolvable_scope_projects_unshared_instead_of_failing() { + let mut teams = vec![team()]; + teams[0].shared = true; + // The real recovery-mode failure: `active_retention_scope` cannot resolve a + // scope without signing keys, which is exactly what `identity_lost` + // withholds. + let state = crate::app_state::build_app_state(); + state + .identity_lost + .store(true, std::sync::atomic::Ordering::Release); + let error = state + .signing_keys() + .expect_err("recovery mode must withhold signing keys"); + + project_scoped_team_sharing(Err(error), &mut teams); + + assert!( + !teams[0].shared, + "an unresolvable scope degrades to unshared so list/create/update keep working" + ); +} + +#[test] +fn test_unopenable_retention_db_projects_unshared_instead_of_failing() { + let dir = tempfile::tempdir().unwrap(); + let mut teams = vec![team()]; + teams[0].shared = true; + + project_scoped_team_sharing( + Ok(RetentionScope { + // A directory cannot be opened as the retention database. + db_path: dir.path().to_path_buf(), + relay_url: "wss://a.example".to_string(), + owner_keys: nostr::Keys::generate(), + }), + &mut teams, + ); + + assert!(!teams[0].shared); +} + +// ── Tombstone ──────────────────────────────────────────────────────────────── + +#[test] +fn test_delete_purges_the_catalog_head_and_enqueues_a_tombstone() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let db_path = scoped_db(dir.path(), "wss://a.example", &owner); + prepare_team_publication_at(&db_path, &keys, &team(), &members(), Some(true)).unwrap(); + + tombstone_team_catalog_at(&db_path, &keys, "team-abc").unwrap(); + + assert!( + retained_head(&db_path, &owner).is_none(), + "the purge must run first so an unpublished re-share cannot resurrect the entry" + ); + let conn = open_retention_db(&db_path).unwrap(); + let pending = get_pending_sync(&conn).unwrap(); + let tombstone = pending + .iter() + .find(|row| row.kind == KIND_DELETE) + .expect("the deletion is enqueued for the flush loop"); + assert_eq!( + tombstone.d_tag, + tombstone_retention_d_tag(KIND_TEAM_CATALOG, "team-abc") + ); + assert!(tombstone.pending_sync, "an offline delete stays durable"); + let event = nostr::Event::from_json(&tombstone.raw_event).unwrap(); + assert!( + event.tags.iter().any(|tag| tag.as_slice() + == [ + "a".to_string(), + format!("{KIND_TEAM_CATALOG}:{owner}:team-abc") + ]), + "the published deletion targets the 30178 coordinate" + ); +} + +#[test] +fn test_catalog_tombstone_does_not_clobber_the_team_tombstone() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let db_path = scoped_db(dir.path(), "wss://a.example", &owner); + let conn = open_retention_db(&db_path).unwrap(); + // The kind:30176 tombstone `delete_team` enqueues alongside this one. Both + // carry kind 5 and the same team id, so only the folded-in target kind + // keeps them on separate primary-key rows. + retain_event( + &conn, + &RetainedEvent { + kind: KIND_DELETE, + pubkey: owner.clone(), + d_tag: tombstone_retention_d_tag(KIND_TEAM, "team-abc"), + content: String::new(), + created_at: 1, + raw_event: "{}".to_string(), + pending_sync: true, + }, + ) + .unwrap(); + + tombstone_team_catalog_at(&db_path, &keys, "team-abc").unwrap(); + + let mut keys_seen: Vec = get_pending_sync(&conn) + .unwrap() + .into_iter() + .filter(|row| row.kind == KIND_DELETE) + .map(|row| row.d_tag) + .collect(); + keys_seen.sort(); + assert_eq!(keys_seen, ["30176:team-abc", "30178:team-abc"]); +} + +// ── F2 / I1 / I2: refresh_or_retract_shared_head_at ────────────────────── + +#[test] +fn test_team_edit_refreshes_a_shared_head() { + // After a team rename / member reorder, the 30178 content must reflect the + // new state without waiting for the next workspace apply or restart. + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let db_path = scoped_db(dir.path(), "wss://a.example", &owner); + + // Initial share. + prepare_team_publication_at(&db_path, &keys, &team(), &members(), Some(true)).unwrap(); + let before = retained_head(&db_path, &owner).unwrap(); + assert!(before.content.contains("One")); + + // Rename the member; refresh_or_retract_shared_head_at with shared_override:None + // is what refresh_shared_team_catalog_head_resolving calls. + let new_members = vec![member("m1", "Renamed"), member("m2", "Two")]; + refresh_or_retract_shared_head_at(&db_path, &keys, &team(), &new_members).unwrap(); + + let after = retained_head(&db_path, &owner).unwrap(); + assert!( + after.content.contains("Renamed"), + "head must reflect the member rename immediately" + ); + assert!( + after.pending_sync, + "the refreshed head must be queued for the flush loop" + ); + // Shared tag must be preserved. + let event = nostr::Event::from_json(&after.raw_event).unwrap(); + assert!(event_is_shared(&event), "refresh must not unshare the team"); +} + +#[test] +fn test_team_edit_retracts_immediately_when_projection_fails() { + // A member edit that pushes past MAX_TOTAL_BYTES or MAX_SYSTEM_PROMPT_BYTES + // must immediately purge+tombstone the shared head — not leave it public + // until the next boot (I2). + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let db_path = scoped_db(dir.path(), "wss://a.example", &owner); + + // Initial share. + prepare_team_publication_at(&db_path, &keys, &team(), &members(), Some(true)).unwrap(); + assert!( + retained_head(&db_path, &owner).is_some(), + "shared head exists" + ); + + // A member with a system_prompt that exceeds MAX_SYSTEM_PROMPT_BYTES (16 KiB) + // causes build_team_catalog_event to fail. + let mut oversized = member("m1", "One"); + oversized.system_prompt = "x".repeat(17 * 1024); + let bad_members = vec![oversized, member("m2", "Two")]; + + // refresh_or_retract_shared_head_at must succeed (Ok) even on projection + // failure — the failure triggers a tombstone, not an error return. + refresh_or_retract_shared_head_at(&db_path, &keys, &team(), &bad_members).unwrap(); + + // The 30178 head must have been purged. + let head_after = retained_head(&db_path, &owner); + assert!( + head_after.is_none(), + "oversized projection must immediately purge the shared 30178 head" + ); + + // A kind:5 tombstone must be queued. + let conn = open_retention_db(&db_path).unwrap(); + let pending = get_pending_sync(&conn).unwrap(); + assert!( + pending.iter().any(|row| row.kind == 5), + "a kind:5 tombstone must be queued after immediate retraction" + ); +} + +#[test] +fn test_refresh_skips_never_shared_team() { + // A never-shared team must produce no 30178 row even after refresh is + // called — this is the I1 security guard. + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let db_path = scoped_db(dir.path(), "wss://a.example", &owner); + + // No retained head at all — simulate what an edit of a never-shared team sees. + let result = refresh_or_retract_shared_head_at(&db_path, &keys, &team(), &members()); + assert!(result.is_ok(), "no-op must return Ok"); + + // No head must have been written. + assert!( + retained_head(&db_path, &owner).is_none(), + "never-shared team must produce no 30178 row after refresh" + ); +} + +#[test] +fn test_refresh_skips_unshared_retained_head() { + // A team with a retained unshared (retracted) head must also be a no-op — + // only a live shared head triggers a refresh. + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let db_path = scoped_db(dir.path(), "wss://a.example", &owner); + + // Retain an unshared head (what unshare produces). + prepare_team_publication_at(&db_path, &keys, &team(), &members(), Some(false)).unwrap(); + let before = retained_head(&db_path, &owner).unwrap(); + let before_content = before.content.clone(); + + // Rename a member and call refresh — the unshared head must not be touched. + let new_members = vec![member("m1", "Renamed"), member("m2", "Two")]; + refresh_or_retract_shared_head_at(&db_path, &keys, &team(), &new_members).unwrap(); + + let after = retained_head(&db_path, &owner).unwrap(); + assert_eq!( + after.content, before_content, + "unshared head must not be refreshed" + ); +} + +// ── CRITICAL: persona edit must only project team members ────────────────── +// +// These tests use `refresh_for_persona_at`, the file-based testable seam for +// `refresh_shared_team_catalog_heads_for_persona`, to verify that a persona +// edit never embeds unrelated local personas in the published 30178. + +fn write_stores(base_dir: &std::path::Path, teams: &[TeamRecord], personas: &[AgentDefinition]) { + std::fs::write( + base_dir.join("teams.json"), + serde_json::to_string(teams).unwrap(), + ) + .unwrap(); + std::fs::write( + base_dir.join("personas.json"), + serde_json::to_string(personas).unwrap(), + ) + .unwrap(); +} + +fn team_with_members(id: &str, name: &str, persona_ids: Vec) -> TeamRecord { + TeamRecord { + id: id.to_string(), + name: name.to_string(), + description: None, + instructions: None, + persona_ids, + is_builtin: false, + shared: false, + catalog_source: None, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: "2026-07-30T00:00:00Z".to_string(), + updated_at: "2026-07-30T00:00:00Z".to_string(), + } +} + +#[test] +fn test_persona_edit_only_projects_team_members_not_the_whole_store() { + // CRITICAL: editing persona "m1" must only project m1 and m2 into the + // shared 30178 — not "unrelated" (which happens to be in the persona store + // but is not a member of the team). + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let db_path = scoped_db(dir.path(), "wss://a.example", &owner); + + let m1 = member("m1", "Member One."); + let m2 = member("m2", "Member Two."); + let unrelated = member("unrelated", "SECRET INSTRUCTIONS."); + + let t = team_with_members( + "team-abc", + "Catalog Team", + vec!["m1".to_string(), "m2".to_string()], + ); + + // Pre-share the team head. + prepare_team_publication_at(&db_path, &keys, &t, &[m1.clone(), m2.clone()], Some(true)) + .unwrap(); + + // Write stores: 3 personas (2 team members + 1 unrelated). + write_stores( + dir.path(), + &[t], + &[m1.clone(), m2.clone(), unrelated.clone()], + ); + + // Simulate a persona edit on "m1". + super::refresh_for_persona_at(dir.path(), &keys, &db_path, "m1").unwrap(); + + // The resulting 30178 must contain m1 and m2 — never "unrelated". + let head = retained_head(&db_path, &owner).expect("shared head must still exist"); + let event = nostr::Event::from_json(&head.raw_event).unwrap(); + assert!( + event_is_shared(&event), + "the team must remain discoverable after a member edit" + ); + assert!( + head.content.contains("Member One."), + "the edited persona's content must be in the 30178" + ); + assert!( + head.content.contains("Member Two."), + "the other team member must be in the 30178" + ); + assert!( + !head.content.contains("SECRET INSTRUCTIONS."), + "unrelated personas must NEVER appear in the 30178 projection" + ); +} + +#[test] +fn test_persona_edit_does_not_publish_for_never_shared_team() { + // A persona that belongs to a never-shared team must produce no 30178 + // even when the persona is edited and the store has many other personas. + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let db_path = scoped_db(dir.path(), "wss://a.example", &owner); + + let m1 = member("m1", "Member One."); + let t = team_with_members("team-abc", "Catalog Team", vec!["m1".to_string()]); + + // No shared head — the team was never shared. + write_stores(dir.path(), &[t], &[m1]); + + super::refresh_for_persona_at(dir.path(), &keys, &db_path, "m1").unwrap(); + + assert!( + retained_head(&db_path, &owner).is_none(), + "persona edit on a never-shared team must not produce a 30178 row" + ); +} + +#[test] +fn test_persona_edit_tombstones_when_another_member_is_missing() { + // If m2 is deleted from the persona store while the team is still shared, + // an edit of m1 must tombstone the shared head rather than publishing a + // projection that is missing a team member. + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let db_path = scoped_db(dir.path(), "wss://a.example", &owner); + + let m1 = member("m1", "Member One."); + let m2 = member("m2", "Member Two."); + let t = team_with_members( + "team-abc", + "Catalog Team", + vec!["m1".to_string(), "m2".to_string()], + ); + + // Pre-share with both members. + prepare_team_publication_at(&db_path, &keys, &t, &[m1.clone(), m2.clone()], Some(true)) + .unwrap(); + + // m2 is gone from the store — team is now unresolvable. + write_stores(dir.path(), &[t], &[m1]); + + super::refresh_for_persona_at(dir.path(), &keys, &db_path, "m1").unwrap(); + + // The shared head must be purged (tombstoned). + assert!( + retained_head(&db_path, &owner).is_none(), + "unresolvable team must be tombstoned, not left with stale members" + ); + let conn = open_retention_db(&db_path).unwrap(); + let pending = get_pending_sync(&conn).unwrap(); + assert!( + pending.iter().any(|r| r.kind == 5), + "a kind:5 tombstone must be queued" + ); +} + +// ── Typed outcome ───────────────────────────────────────────────────────── + +#[test] +fn test_refresh_returns_refreshed_outcome() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let db_path = scoped_db(dir.path(), "wss://a.example", &owner); + + prepare_team_publication_at(&db_path, &keys, &team(), &members(), Some(true)).unwrap(); + + let outcome = refresh_or_retract_shared_head_at(&db_path, &keys, &team(), &members()).unwrap(); + + assert_eq!( + outcome, + RefreshOrRetractOutcome::Refreshed, + "a successful rebuild must return Refreshed" + ); +} + +#[test] +fn test_refresh_returns_noop_for_never_shared_team() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let db_path = scoped_db(dir.path(), "wss://a.example", &owner); + + // No retained head at all. + let outcome = refresh_or_retract_shared_head_at(&db_path, &keys, &team(), &members()).unwrap(); + + assert_eq!( + outcome, + RefreshOrRetractOutcome::Noop, + "no retained head must return Noop" + ); + let _ = owner; // suppress unused warning +} + +#[test] +fn test_refresh_returns_removal_queued_on_failure() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let db_path = scoped_db(dir.path(), "wss://a.example", &owner); + + prepare_team_publication_at(&db_path, &keys, &team(), &members(), Some(true)).unwrap(); + + let mut oversized = member("m1", "One"); + oversized.system_prompt = + "x".repeat(crate::managed_agents::team_catalog::MAX_SYSTEM_PROMPT_BYTES + 1); + let bad = vec![oversized, member("m2", "Two")]; + + let outcome = refresh_or_retract_shared_head_at(&db_path, &keys, &team(), &bad).unwrap(); + + assert!( + matches!(outcome, RefreshOrRetractOutcome::RemovalQueued { .. }), + "projection failure must return RemovalQueued, got {outcome:?}" + ); + let _ = owner; +} diff --git a/desktop/src-tauri/src/commands/teams/sharing.rs b/desktop/src-tauri/src/commands/teams/sharing.rs new file mode 100644 index 0000000000..97a644d196 --- /dev/null +++ b/desktop/src-tauri/src/commands/teams/sharing.rs @@ -0,0 +1,125 @@ +//! The `set_team_shared` command: publish a team's kind:30178 catalog head, +//! or replace it with an untagged head to unshare. +//! +//! Delegates wholesale to the persona sharing machinery's shape +//! (`commands::personas::sharing`): the same strict `prepare → submit → +//! mark_synced` path, the same `published | queued` contract, the same rule +//! that a relay rejection or an unreachable relay leaves the head durably +//! queued for the flush loop rather than failing the command. What is new +//! here is only the projection input — a team plus its ordered members. + +use tauri::{AppHandle, Manager}; + +use crate::{ + app_state::AppState, + managed_agents::{ + load_personas, load_teams, + retention::{mark_synced, open_retention_db}, + TeamRecord, + }, +}; + +use super::pending::{prepare_team_publication, PreparedTeamPublication}; +use crate::managed_agents::team_catalog::resolve_team_members; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum TeamSharePublicationStatus { + Published, + Queued, +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetTeamSharedResult { + pub team: TeamRecord, + pub publication_status: TeamSharePublicationStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub relay_message: Option, +} + +/// Share a team to the community catalog, or retract it from discovery. +/// +/// Unsharing publishes a NEWER, still-valid 30178 head WITHOUT the `shared` +/// tag rather than deleting the coordinate. The relay's read gate keys off the +/// tag, so the untagged head is invisible to the community while remaining +/// readable by its author — which is what lets a later re-share replace it +/// monotonically instead of racing a tombstone. Deletion is reserved for +/// deleting the team itself (`delete_team`). +#[tauri::command] +pub async fn set_team_shared( + id: String, + shared: bool, + app: AppHandle, +) -> Result { + let prepared = tokio::task::spawn_blocking({ + let app = app.clone(); + move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let teams = load_teams(&app)?; + let team = teams + .iter() + .find(|record| record.id == id) + .ok_or_else(|| format!("team {id} not found"))?; + + if team.is_builtin { + return Err("Built-in teams cannot be shared to the catalog.".to_string()); + } + + let members = resolve_team_members(team, &load_personas(&app)?)?; + // Strict path: unlike ordinary team saves, an enqueue failure for + // this privacy-sensitive toggle must reach the command/UI. + prepare_team_publication(&app, &state, team, &members, Some(shared)) + } + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))??; + + let state = app.state::(); + publish_prepared_team(&state, prepared).await +} + +async fn publish_prepared_team( + state: &AppState, + prepared: PreparedTeamPublication, +) -> Result { + let api_base_url = crate::relay::relay_http_base_url(&prepared.scope.relay_url); + let publish_result = crate::relay::submit_signed_event_at_with_keys( + &prepared.event, + state, + &api_base_url, + &prepared.scope.owner_keys, + ) + .await; + + match publish_result { + Ok(_) => { + let conn = open_retention_db(&prepared.scope.db_path)?; + mark_synced( + &conn, + prepared.retained.kind, + &prepared.retained.pubkey, + &prepared.retained.d_tag, + prepared.retained.created_at, + &prepared.retained.content, + )?; + Ok(SetTeamSharedResult { + team: prepared.team, + publication_status: TeamSharePublicationStatus::Published, + relay_message: None, + }) + } + Err(error) => Ok(SetTeamSharedResult { + team: prepared.team, + publication_status: TeamSharePublicationStatus::Queued, + relay_message: Some(error), + }), + } +} + +#[cfg(all(test, not(target_os = "windows")))] +mod tests; diff --git a/desktop/src-tauri/src/commands/teams/sharing/tests.rs b/desktop/src-tauri/src/commands/teams/sharing/tests.rs new file mode 100644 index 0000000000..388876f79e --- /dev/null +++ b/desktop/src-tauri/src/commands/teams/sharing/tests.rs @@ -0,0 +1,217 @@ +use super::*; +use crate::{ + app_state::build_app_state, + commands::teams::pending::prepare_team_publication_at, + managed_agents::{ + retention::{get_retained_event, open_retention_db, RetentionScope}, + AgentDefinition, + }, +}; +use buzz_core_pkg::kind::KIND_TEAM_CATALOG; +use std::collections::BTreeMap; +use std::path::PathBuf; + +fn member(id: &str) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: "One".to_string(), + avatar_url: None, + system_prompt: "Do the work.".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + team_catalog_source: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2026-07-30T00:00:00Z".to_string(), + updated_at: "2026-07-30T00:00:00Z".to_string(), + } +} + +fn team() -> TeamRecord { + TeamRecord { + id: "team-abc".to_string(), + name: "Catalog Team".to_string(), + description: None, + instructions: None, + persona_ids: vec!["m1".to_string()], + is_builtin: false, + shared: false, + catalog_source: None, + source_dir: Some(PathBuf::from("/local/only/path")), + is_symlink: false, + symlink_target: None, + version: None, + created_at: "2026-07-30T00:00:00Z".to_string(), + updated_at: "2026-07-30T00:00:00Z".to_string(), + } +} + +async fn spawn_relay(accepted: bool) -> String { + use axum::{routing::post, Router}; + + let app = Router::new().route( + "/events", + post(move |body: String| async move { + let event: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); + serde_json::json!({ + "event_id": event.get("id").and_then(serde_json::Value::as_str).unwrap_or(""), + "accepted": accepted, + "message": if accepted { "" } else { "policy rejection" } + }) + .to_string() + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.ok(); + }); + format!("http://{addr}") +} + +fn prepared( + db_path: &std::path::Path, + relay_url: String, + keys: nostr::Keys, + shared: bool, +) -> PreparedTeamPublication { + let (event, retained, team) = + prepare_team_publication_at(db_path, &keys, &team(), &[member("m1")], Some(shared)) + .unwrap(); + PreparedTeamPublication { + scope: RetentionScope { + db_path: db_path.to_path_buf(), + relay_url, + owner_keys: keys, + }, + event, + retained, + team, + } +} + +fn retained_head( + db_path: &std::path::Path, + owner: &str, +) -> crate::managed_agents::retention::RetainedEvent { + get_retained_event( + &open_retention_db(db_path).unwrap(), + KIND_TEAM_CATALOG, + owner, + "team-abc", + ) + .unwrap() + .expect("the head is retained before the relay is ever contacted") +} + +#[tokio::test] +async fn test_accepted_share_reports_published_and_clears_the_pending_flag() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("retention.db"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let prepared = prepared(&db_path, spawn_relay(true).await, keys, true); + let state = build_app_state(); + + let result = publish_prepared_team(&state, prepared).await.unwrap(); + + assert_eq!( + result.publication_status, + TeamSharePublicationStatus::Published + ); + assert!(result.relay_message.is_none()); + assert!(result.team.shared); + assert!( + !retained_head(&db_path, &owner).pending_sync, + "a confirmed publish must not be republished by the flush loop" + ); +} + +#[tokio::test] +async fn test_relay_rejection_stays_durably_queued() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("retention.db"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let prepared = prepared(&db_path, spawn_relay(false).await, keys, true); + let state = build_app_state(); + + let result = publish_prepared_team(&state, prepared).await.unwrap(); + + assert_eq!( + result.publication_status, + TeamSharePublicationStatus::Queued + ); + assert!(result + .relay_message + .as_deref() + .is_some_and(|message| message.contains("relay rejected event"))); + assert!(retained_head(&db_path, &owner).pending_sync); +} + +#[tokio::test] +async fn test_unavailable_relay_stays_durably_queued() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay_url = format!("http://{}", listener.local_addr().unwrap()); + drop(listener); + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("retention.db"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let prepared = prepared(&db_path, relay_url, keys, true); + let state = build_app_state(); + + let result = publish_prepared_team(&state, prepared).await.unwrap(); + + assert_eq!( + result.publication_status, + TeamSharePublicationStatus::Queued + ); + assert!( + retained_head(&db_path, &owner).pending_sync, + "an offline share must survive for the flush loop rather than failing the command" + ); +} + +#[tokio::test] +async fn test_unshare_leaves_an_untagged_head_retained_after_publication() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("retention.db"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let relay_url = spawn_relay(true).await; + let state = build_app_state(); + publish_prepared_team( + &state, + prepared(&db_path, relay_url.clone(), keys.clone(), true), + ) + .await + .unwrap(); + + let result = publish_prepared_team(&state, prepared(&db_path, relay_url, keys, false)) + .await + .unwrap(); + + assert_eq!( + result.publication_status, + TeamSharePublicationStatus::Published + ); + assert!(!result.team.shared); + let row = retained_head(&db_path, &owner); + assert!( + !buzz_core_pkg::kind::event_is_shared( + &::from_json(&row.raw_event).unwrap() + ), + "unshare retracts by replacement, so the coordinate stays readable by its author" + ); +} diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index f487c8ce16..22e646348c 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -254,6 +254,8 @@ const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[ // Mock-relay route in its in-file tests; production publish goes through // the guarded boundary-1 funnel (`submit_signed_event_at_with_keys`). ("src/commands/personas/sharing.rs", 1, 0), + // Mock-relay route in team-sharing tests; same pattern as persona sharing above. + ("src/commands/teams/sharing/tests.rs", 1, 0), ]; // Needles are assembled at runtime so this scan file itself contains no diff --git a/desktop/src-tauri/src/event_sync.rs b/desktop/src-tauri/src/event_sync.rs index ee8e0d8b10..6a9308f3b6 100644 --- a/desktop/src-tauri/src/event_sync.rs +++ b/desktop/src-tauri/src/event_sync.rs @@ -16,6 +16,7 @@ use std::path::Path; pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys, db_path: &Path) { migrate_personas_to_events(app, owner_keys, db_path); migrate_teams_to_events(app, owner_keys, db_path); + reconcile_team_catalog_heads(app, owner_keys, db_path); crate::managed_agents::reconcile::reconcile_agents_to_events(app, owner_keys, db_path); } @@ -99,7 +100,6 @@ fn migrate_personas_in_dir_at( use crate::managed_agents::{ persona_events::{build_persona_event, monotonic_created_at, persona_d_tag}, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, - AgentDefinition, }; use buzz_core_pkg::kind::KIND_PERSONA; use nostr::JsonUtil; @@ -111,29 +111,7 @@ fn migrate_personas_in_dir_at( // (run_event_sync runs after run_boot_migrations, so the fold has // already happened) never reach this path with personas.json present — // but read it as a fallback for one release in case the fold errored. - let records: Vec = { - let personas_path = base_dir.join("personas.json"); - if personas_path.exists() { - let content = std::fs::read_to_string(&personas_path) - .map_err(|e| format!("failed to read personas.json: {e}"))?; - serde_json::from_str(&content) - .map_err(|e| format!("failed to parse personas.json: {e}"))? - } else { - let agents_path = base_dir.join("managed-agents.json"); - if !agents_path.exists() { - return Ok(0); - } - let content = std::fs::read_to_string(&agents_path) - .map_err(|e| format!("failed to read managed-agents.json: {e}"))?; - let all: Vec = - serde_json::from_str(&content) - .map_err(|e| format!("failed to parse managed-agents.json: {e}"))?; - all.iter() - .filter(|record| record.pubkey.is_empty()) - .filter_map(|record| record.to_definition_view()) - .collect() - } - }; + let records = read_persona_definitions(base_dir)?; if records.is_empty() { return Ok(0); @@ -332,6 +310,296 @@ fn migrate_teams_in_dir_at( Ok(migrated) } +/// Reconcile every shared team's kind:30178 catalog head against the team as +/// it exists on disk now. +/// +/// The publish path only rebuilds a catalog head when the owner touches the +/// team itself. A team's *members* are separate records, so editing a member's +/// prompt — or deleting one — changes what the team actually is while leaving +/// a stale projection published to the community. This is the seam that +/// catches that drift, and it runs only over heads that are currently shared: +/// an unshared head is not discoverable, so there is nothing stale to correct. +/// +/// Two outcomes, both keeping the published catalog truthful: +/// +/// - The team still projects and the bytes changed → republish a newer shared +/// head. +/// - The team can no longer be projected at all (a member was deleted, or it +/// outgrew the size contract) → **purge + tombstone** (I4). Keeping the stale +/// body as an unshared "retraction" left the coordinate live with no opt-in +/// tag, which is not a true retraction — the team must fully disappear. +/// A typed `team-catalog-auto-retracted` frontend notice names the team and +/// reason so the owner is not left wondering why their share toggle changed. +/// +/// Deliberately not wired into `save_teams()`: that is a disk-store primitive +/// with many callers (import, repair, cascade delete), and signing a relay +/// event from inside it would publish on paths that never intended to. +fn reconcile_team_catalog_heads(app: &tauri::AppHandle, keys: &nostr::Keys, db_path: &Path) { + use crate::managed_agents::managed_agents_base_dir; + + let Ok(base_dir) = managed_agents_base_dir(app) else { + return; + }; + + match reconcile_team_catalog_heads_at(app, &base_dir, keys, db_path) { + Ok(0) => {} + Ok(reconciled) => { + eprintln!( + "buzz-desktop: team-catalog-reconcile: {reconciled} shared team heads refreshed" + ); + } + Err(e) => { + eprintln!("buzz-desktop: team-catalog-reconcile: {e}"); + } + } +} + +/// Core catalog reconcile, decoupled from the Tauri `AppHandle` for testing. +/// +/// Returns the number of heads (re)written — republished or tombstoned. +fn reconcile_team_catalog_heads_at( + app: &tauri::AppHandle, + base_dir: &Path, + keys: &nostr::Keys, + db_path: &Path, +) -> Result { + reconcile_team_catalog_heads_core(Some(app), base_dir, keys, db_path) +} + +#[cfg(test)] +pub(crate) fn reconcile_team_catalog_heads_at_for_test( + base_dir: &Path, + keys: &nostr::Keys, + db_path: &Path, +) -> Result { + reconcile_team_catalog_heads_core(None, base_dir, keys, db_path) +} + +/// Inner reconcile, `app` is `None` only in unit tests (no Tauri runtime). +fn reconcile_team_catalog_heads_core( + app: Option<&tauri::AppHandle>, + base_dir: &Path, + keys: &nostr::Keys, + db_path: &Path, +) -> Result { + use crate::managed_agents::{ + persona_events::monotonic_created_at, + retention::{get_retained_events_by_kind, open_retention_db, retain_event, RetainedEvent}, + team_catalog::{ + build_team_catalog_event, resolve_team_members, tombstone_team_catalog_coordinate, + }, + TeamRecord, + }; + use buzz_core_pkg::kind::{event_is_shared, KIND_TEAM_CATALOG}; + use nostr::JsonUtil; + + let pubkey = keys.public_key().to_hex(); + let conn = + open_retention_db(db_path).map_err(|e| format!("failed to open retention db: {e}"))?; + + // Enumerate retained 30178 heads as the authoritative worklist. A team + // that was deleted after a shared head was written is still visible here; + // iterating only the current team store would miss the orphan entirely. + let all_heads = get_retained_events_by_kind(&conn, KIND_TEAM_CATALOG, &pubkey)?; + if all_heads.is_empty() { + return Ok(0); + } + + // Load teams once; missing is equivalent to empty (owner cleared the + // store). Load personas only when at least one shared head is found. + let teams: Vec = read_json_store(&base_dir.join("teams.json"))?; + let personas = read_persona_definitions(base_dir)?; + + let mut reconciled = 0u32; + + for head in &all_heads { + let head_event = nostr::Event::from_json(&head.raw_event).map_err(|e| { + format!( + "failed to parse retained head for d-tag '{}': {e}", + head.d_tag + ) + })?; + + // Only shared heads represent live community-visible state. An + // already-unshared head cannot be made worse by leaving it; a + // tombstone covers deletion of the whole coordinate (delete_team). + if !event_is_shared(&head_event) { + continue; + } + + // F1: the corresponding team no longer exists → the owner deleted it + // after it was shared. Tombstone the coordinate now so the community + // catalog stops showing it. This is the case the team-first loop + // could never see. + let Some(team) = teams.iter().find(|t| t.id == head.d_tag) else { + // Retrieve the team name from the head's content for the notice, + // falling back to the d-tag when content is unparseable. + let team_name = (|| -> Option { + let content: serde_json::Value = + serde_json::from_str(head_event.content.as_ref()).ok()?; + content.get("name")?.as_str().map(str::to_string) + })() + .unwrap_or_else(|| head.d_tag.clone()); + let reason = "team no longer exists".to_string(); + eprintln!("buzz-desktop: team-catalog-reconcile: tombstoning '{team_name}' — {reason}"); + // `tombstone_team_catalog_coordinate` opens its own connection + // (WAL mode allows concurrent connections); `conn` is kept alive + // for the success-path retain_event calls in subsequent iterations. + if let Err(e) = tombstone_team_catalog_coordinate(db_path, keys, &head.d_tag) { + eprintln!( + "buzz-desktop: team-catalog-reconcile: tombstone failed for '{}': {e}", + head.d_tag + ); + } else { + reconciled += 1; + if let Some(app) = app { + emit_team_catalog_auto_retracted(app, &team_name, &reason); + } + } + continue; + }; + + // Built-in teams can never have been shared, but be defensive. + if team.is_builtin { + continue; + } + + // Reproject from the current on-disk team and members. A failure here + // is the retraction trigger: purge + tombstone the coordinate and + // notify the owner via a typed frontend event. The stale-body + // "retraction" pattern was replaced because an unshared-but-retained + // coordinate leaves the event live on the relay with no opt-in tag. + let rebuilt = resolve_team_members(team, &personas) + .and_then(|members| build_team_catalog_event(team, &members, true)); + let builder = match rebuilt { + Ok(builder) => builder, + Err(reason) => { + eprintln!( + "buzz-desktop: team-catalog-reconcile: tombstoning '{}' — {reason}", + team.name + ); + // `tombstone_team_catalog_coordinate` opens its own WAL + // connection; there is no need to drop `conn`, and NOT + // dropping it is what allows the loop to continue processing + // remaining heads (I2 — multi-head continuation). + if let Err(e) = tombstone_team_catalog_coordinate(db_path, keys, &team.id) { + eprintln!( + "buzz-desktop: team-catalog-reconcile: tombstone failed for '{}': {e}", + team.name + ); + } else { + reconciled += 1; + if let Some(app) = app { + emit_team_catalog_auto_retracted(app, &team.name, &reason); + } + } + // Continue to the next retained head — do not stop after the + // first tombstone (the original `drop(conn); return` pattern + // was the I2 bug). + continue; + } + }; + + let event = builder + // Supersede the retained head even when it is future-dated, for + // the same reason the persona and team reconciles do. + .custom_created_at(monotonic_created_at(Some(head.created_at))) + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign catalog head for '{}': {e}", team.name))?; + + // Compare the tag too, not just the body: an unshare replays the + // retained content verbatim, so bytes alone would report "unchanged" + // and leave the stale head shared. + if head.content == event.content && event_is_shared(&event) { + continue; + } + + retain_event( + &conn, + &RetainedEvent { + kind: KIND_TEAM_CATALOG, + pubkey: pubkey.clone(), + d_tag: team.id.clone(), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + .map_err(|e| format!("failed to retain catalog head for '{}': {e}", team.name))?; + reconciled += 1; + } + + Ok(reconciled) +} + +/// Emit a typed Tauri event so the frontend can show the owner a notice when +/// the boot reconcile automatically retracts a shared team. +/// +/// Best-effort: a failed emit is logged but does not block reconcile. +fn emit_team_catalog_auto_retracted(app: &tauri::AppHandle, team_name: &str, reason: &str) { + use serde::Serialize; + use tauri::Emitter; + + #[derive(Clone, Serialize)] + #[serde(rename_all = "camelCase")] + struct TeamCatalogAutoRetractedPayload<'a> { + team_name: &'a str, + reason: &'a str, + } + + if let Err(e) = app.emit( + "team-catalog-auto-retracted", + TeamCatalogAutoRetractedPayload { team_name, reason }, + ) { + eprintln!("buzz-desktop: team-catalog-reconcile: failed to emit retraction notice: {e}"); + } +} + +/// Read a JSON array store, treating an absent file as empty. +fn read_json_store(path: &Path) -> Result, String> { + if !path.exists() { + return Ok(Vec::new()); + } + let name = path.file_name().unwrap_or_default().to_string_lossy(); + let content = + std::fs::read_to_string(path).map_err(|e| format!("failed to read {name}: {e}"))?; + serde_json::from_str(&content).map_err(|e| format!("failed to parse {name}: {e}")) +} + +/// Test-accessible alias for `read_json_store`, used by the `pending` module's +/// `refresh_for_persona_at` testable seam without re-exporting the private fn. +#[cfg(test)] +pub(crate) fn read_json_store_pub( + path: &Path, +) -> Result, String> { + read_json_store(path) +} + +/// Read every persona definition in the legacy shape, from whichever store +/// holds them. +/// +/// Post-fold (Phase 1A.2) definitions are key-less records in the unified +/// agent store; `personas.json` only survives on a boot where the fold +/// errored. Both callers must read the same set — a reconcile that saw an +/// empty persona list would conclude every team's members had been deleted. +fn read_persona_definitions( + base_dir: &Path, +) -> Result, String> { + let personas: Vec = + read_json_store(&base_dir.join("personas.json"))?; + if !personas.is_empty() { + return Ok(personas); + } + let all: Vec = + read_json_store(&base_dir.join("managed-agents.json"))?; + Ok(all + .iter() + .filter(|record| record.pubkey.is_empty()) + .filter_map(|record| record.to_definition_view()) + .collect()) +} + #[cfg(test)] #[path = "event_sync_tests.rs"] mod tests; @@ -339,3 +607,7 @@ mod tests; #[cfg(test)] #[path = "event_sync_team_events_tests.rs"] mod team_events_tests; + +#[cfg(test)] +#[path = "event_sync_team_catalog_tests.rs"] +mod team_catalog_tests; diff --git a/desktop/src-tauri/src/event_sync_team_catalog_tests.rs b/desktop/src-tauri/src/event_sync_team_catalog_tests.rs new file mode 100644 index 0000000000..8d37028573 --- /dev/null +++ b/desktop/src-tauri/src/event_sync_team_catalog_tests.rs @@ -0,0 +1,436 @@ +use super::*; +use crate::managed_agents::{ + retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, + team_catalog::build_team_catalog_event, + AgentDefinition, TeamRecord, +}; +use buzz_core_pkg::kind::{event_is_shared, KIND_TEAM_CATALOG}; +use nostr::JsonUtil; +use std::collections::BTreeMap; + +const TEAM_ID: &str = "team-alpha"; + +fn member(id: &str, prompt: &str) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: id.to_string(), + avatar_url: None, + system_prompt: prompt.to_string(), + runtime: None, + model: None, + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + team_catalog_source: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2026-07-30T00:00:00Z".to_string(), + updated_at: "2026-07-30T00:00:00Z".to_string(), + } +} + +fn team() -> TeamRecord { + TeamRecord { + id: TEAM_ID.to_string(), + name: "Alpha".to_string(), + description: None, + instructions: None, + persona_ids: vec!["m1".to_string()], + is_builtin: false, + shared: false, + catalog_source: None, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: "2026-07-30T00:00:00Z".to_string(), + updated_at: "2026-07-30T00:00:00Z".to_string(), + } +} + +fn write_stores(base_dir: &Path, teams: &[TeamRecord], personas: &[AgentDefinition]) { + std::fs::write( + base_dir.join("teams.json"), + serde_json::to_string(teams).unwrap(), + ) + .unwrap(); + std::fs::write( + base_dir.join("personas.json"), + serde_json::to_string(personas).unwrap(), + ) + .unwrap(); +} + +/// Retain a catalog head for `team`/`members`, as the share toggle would. +fn retain_head( + base_dir: &Path, + keys: &nostr::Keys, + team: &TeamRecord, + members: &[AgentDefinition], +) { + let event = build_team_catalog_event(team, members, true) + .unwrap() + .sign_with_keys(keys) + .unwrap(); + let conn = open_retention_db(&base_dir.join("retention.db")).unwrap(); + retain_event( + &conn, + &RetainedEvent { + kind: KIND_TEAM_CATALOG, + pubkey: keys.public_key().to_hex(), + d_tag: team.id.clone(), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: false, + }, + ) + .unwrap(); +} + +fn head(base_dir: &Path, keys: &nostr::Keys) -> Option { + let conn = open_retention_db(&base_dir.join("retention.db")).unwrap(); + get_retained_event( + &conn, + KIND_TEAM_CATALOG, + &keys.public_key().to_hex(), + TEAM_ID, + ) + .unwrap() +} + +fn reconcile(base_dir: &Path, keys: &nostr::Keys) -> Result { + crate::event_sync::reconcile_team_catalog_heads_at_for_test( + base_dir, + keys, + &base_dir.join("retention.db"), + ) +} + +fn head_is_shared(row: &RetainedEvent) -> bool { + event_is_shared(&nostr::Event::from_json(&row.raw_event).unwrap()) +} + +#[test] +fn test_member_edit_republishes_a_newer_shared_head() { + let base = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + retain_head(base.path(), &keys, &team(), &[member("m1", "Original.")]); + let before = head(base.path(), &keys).unwrap(); + // The team is untouched; only the member's prompt changed, which the + // publish path never observes. + write_stores(base.path(), &[team()], &[member("m1", "Rewritten.")]); + + assert_eq!(reconcile(base.path(), &keys).unwrap(), 1); + + let after = head(base.path(), &keys).unwrap(); + assert!(after.content.contains("Rewritten.")); + assert!(head_is_shared(&after), "a refresh stays discoverable"); + assert!( + after.pending_sync, + "the refreshed head is queued to publish" + ); + assert!(after.created_at > before.created_at); +} + +#[test] +fn test_unchanged_team_is_left_alone() { + let base = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + retain_head(base.path(), &keys, &team(), &[member("m1", "Original.")]); + write_stores(base.path(), &[team()], &[member("m1", "Original.")]); + + assert_eq!(reconcile(base.path(), &keys).unwrap(), 0); + + assert!( + !head(base.path(), &keys).unwrap().pending_sync, + "an unchanged team must not churn pending_sync on every boot" + ); +} + +#[test] +fn test_deleted_member_tombstones_the_coordinate() { + // I4: a member disappears making the team unrebuildable. The reconcile + // must purge+tombstone the coordinate (not retain a stale-body unshared + // head), and the tombstone must be queued for the flush loop. + let base = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + retain_head(base.path(), &keys, &team(), &[member("m1", "Original.")]); + // The member is gone, so the team can no longer be projected at all. + write_stores(base.path(), &[team()], &[]); + + assert_eq!(reconcile(base.path(), &keys).unwrap(), 1); + + // The 30178 row must be purged (not merely unshared). + assert!( + head(base.path(), &keys).is_none(), + "unrebuildable team must purge the 30178 row, not retain a stale-body unshared head" + ); + + // A kind:5 tombstone must be queued. + let conn = open_retention_db(&base.path().join("retention.db")).unwrap(); + let pending = crate::managed_agents::retention::get_pending_sync(&conn).unwrap(); + assert!( + pending.iter().any(|row| row.kind == 5), + "a kind:5 tombstone must be queued after purge" + ); +} + +#[test] +fn test_tombstone_is_not_repeated_on_next_boot() { + // After the first boot tombstones the unrebuildable head (purging the 30178 + // row), the next boot must see no 30178 head and do nothing. + let base = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + retain_head(base.path(), &keys, &team(), &[member("m1", "Original.")]); + write_stores(base.path(), &[team()], &[]); + reconcile(base.path(), &keys).unwrap(); + + assert_eq!( + reconcile(base.path(), &keys).unwrap(), + 0, + "no 30178 head remains after tombstone, so nothing to do" + ); +} + +#[test] +fn test_unshared_head_is_never_touched() { + let base = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + // An unshared head with a member that no longer exists — the retraction + // trigger — must still be left alone: it is not discoverable. + let event = build_team_catalog_event(&team(), &[member("m1", "Original.")], false) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + let conn = open_retention_db(&base.path().join("retention.db")).unwrap(); + retain_event( + &conn, + &RetainedEvent { + kind: KIND_TEAM_CATALOG, + pubkey: keys.public_key().to_hex(), + d_tag: TEAM_ID.to_string(), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: false, + }, + ) + .unwrap(); + write_stores(base.path(), &[team()], &[]); + + assert_eq!(reconcile(base.path(), &keys).unwrap(), 0); + + assert!(!head(base.path(), &keys).unwrap().pending_sync); +} + +#[test] +fn test_team_with_no_head_is_skipped() { + let base = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + write_stores(base.path(), &[team()], &[member("m1", "Original.")]); + + assert_eq!( + reconcile(base.path(), &keys).unwrap(), + 0, + "a team the owner never shared must not be published by a boot reconcile" + ); + assert!(head(base.path(), &keys).is_none()); +} + +#[test] +fn test_members_are_read_from_the_unified_agent_store_after_the_fold() { + let base = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + retain_head(base.path(), &keys, &team(), &[member("m1", "Original.")]); + // Post-fold there is no personas.json; definitions are key-less records in + // managed-agents.json. Reading only personas.json would see zero members + // and retract every shared team on the next boot. + std::fs::write( + base.path().join("teams.json"), + serde_json::to_string(&[team()]).unwrap(), + ) + .unwrap(); + let folded: Vec = + vec![member("m1", "Original.").into_agent_record()]; + std::fs::write( + base.path().join("managed-agents.json"), + serde_json::to_string(&folded).unwrap(), + ) + .unwrap(); + + assert_eq!(reconcile(base.path(), &keys).unwrap(), 0); + + let after = head(base.path(), &keys).unwrap(); + assert!(head_is_shared(&after), "the team must not be retracted"); + assert!(!after.pending_sync); +} + +#[test] +fn test_builtin_teams_are_skipped() { + let base = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + retain_head(base.path(), &keys, &team(), &[member("m1", "Original.")]); + let mut builtin = team(); + builtin.is_builtin = true; + write_stores(base.path(), &[builtin], &[]); + + assert_eq!(reconcile(base.path(), &keys).unwrap(), 0); +} + +#[test] +fn test_deleted_team_with_shared_head_is_tombstoned_at_reconcile() { + // F1: a team is deleted after it was shared. `delete_team` is best-effort + // for the tombstone; a crash there (or any failure) leaves the shared head + // visible indefinitely until the next boot reconcile. The reconcile must + // see the orphaned head via the retained-coordinate worklist and tombstone + // it — it cannot rely on the team still existing in the store. + let base = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + retain_head(base.path(), &keys, &team(), &[member("m1", "Original.")]); + assert!(!head(base.path(), &keys).unwrap().pending_sync); + + // Simulate the team having been deleted: write empty stores, as if the + // team record was removed before the tombstone helper ran. + write_stores(base.path(), &[], &[]); + + assert_eq!(reconcile(base.path(), &keys).unwrap(), 1); + + // The 30178 coordinate is gone from the retention store (tombstone_team_catalog_at + // purges it and enqueues a kind:5 in its place). Verify the head is absent. + assert!( + head(base.path(), &keys).is_none(), + "the orphaned shared head must be purged from the retention store" + ); +} + +#[test] +fn test_deleted_team_tombstone_is_not_repeated_on_next_boot() { + // After the first boot tombstones the orphaned head (purging the 30178 + // row), the next boot must see no 30178 heads and do nothing. + let base = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + retain_head(base.path(), &keys, &team(), &[member("m1", "Original.")]); + write_stores(base.path(), &[], &[]); + reconcile(base.path(), &keys).unwrap(); + + assert_eq!( + reconcile(base.path(), &keys).unwrap(), + 0, + "no 30178 head remains, so nothing to tombstone" + ); +} + +// ── I2: Multi-head continuation ───────────────────────────────────────────── + +fn team_b() -> TeamRecord { + TeamRecord { + id: "team-beta".to_string(), + name: "Beta".to_string(), + description: None, + instructions: None, + persona_ids: vec!["m2".to_string()], + is_builtin: false, + shared: false, + catalog_source: None, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: "2026-07-30T00:00:00Z".to_string(), + updated_at: "2026-07-30T00:00:00Z".to_string(), + } +} + +fn head_for(base_dir: &Path, keys: &nostr::Keys, team_id: &str) -> Option { + let conn = open_retention_db(&base_dir.join("retention.db")).unwrap(); + get_retained_event( + &conn, + KIND_TEAM_CATALOG, + &keys.public_key().to_hex(), + team_id, + ) + .unwrap() +} + +#[test] +fn test_two_unrebuildable_teams_are_both_tombstoned_in_one_reconcile() { + // I2: when two shared teams cannot be reprojected, BOTH must be tombstoned + // in a single boot reconcile — not just the first one, with the second + // waiting for the next boot (the original `drop(conn); return` bug). + let base = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + + // Share two teams. + retain_head(base.path(), &keys, &team(), &[member("m1", "Alpha.")]); + retain_head(base.path(), &keys, &team_b(), &[member("m2", "Beta.")]); + + // Both members vanish — both teams are unrebuildable. + write_stores(base.path(), &[team(), team_b()], &[]); + + // One reconcile must tombstone both. + let count = reconcile(base.path(), &keys).unwrap(); + assert_eq!(count, 2, "both tombstones must be applied in one pass"); + + // Both 30178 heads must be gone. + assert!( + head_for(base.path(), &keys, TEAM_ID).is_none(), + "team-alpha 30178 head must be purged" + ); + assert!( + head_for(base.path(), &keys, "team-beta").is_none(), + "team-beta 30178 head must be purged" + ); + + // Both kind:5 tombstones must be queued. + let conn = open_retention_db(&base.path().join("retention.db")).unwrap(); + let pending = crate::managed_agents::retention::get_pending_sync(&conn).unwrap(); + let tombstones: Vec<_> = pending.iter().filter(|r| r.kind == 5).collect(); + assert_eq!( + tombstones.len(), + 2, + "two kind:5 tombstones must be queued (one per team)" + ); +} + +#[test] +fn test_one_valid_one_unrebuildable_team_both_processed() { + // Continuation must also work when only one of two teams fails rebuild: + // the failed team gets tombstoned, the valid team gets refreshed. + let base = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + + retain_head(base.path(), &keys, &team(), &[member("m1", "Alpha.")]); + retain_head(base.path(), &keys, &team_b(), &[member("m2", "Beta.")]); + + // team-alpha's m1 disappears; team-beta's m2 stays but with a new prompt. + write_stores( + base.path(), + &[team(), team_b()], + &[member("m2", "Beta revised.")], + ); + + let count = reconcile(base.path(), &keys).unwrap(); + assert_eq!(count, 2, "one tombstone + one refresh = 2 reconciled"); + + // team-alpha must be tombstoned. + assert!(head_for(base.path(), &keys, TEAM_ID).is_none()); + + // team-beta must still have a shared head with the new content. + let beta_head = head_for(base.path(), &keys, "team-beta").unwrap(); + assert!( + beta_head.content.contains("Beta revised."), + "team-beta must reflect the updated member prompt" + ); + assert!( + head_is_shared(&beta_head), + "the refreshed team-beta must remain discoverable" + ); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 2847b87877..b630a7ca2e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -85,11 +85,7 @@ use tray_menu::show_main_window; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - // mesh-llm's async chains (model download, node start/join) overflow - // tokio's default 2 MiB worker stacks — a stack-guard SIGABRT, not a - // panic. Upstream mesh-llm and mesh-console both run on 8 MiB worker - // stacks for this reason; give Tauri's command runtime the same headroom - // before anything else touches tauri::async_runtime. + // mesh-llm async chains overflow tokio's default 2 MiB stacks; run on 8 MiB like upstream. #[cfg(feature = "mesh-llm")] match tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -807,6 +803,8 @@ pub fn run() { list_teams, create_team, update_team, + set_team_shared, + add_team_from_catalog, delete_team, export_agent_snapshot, card_mint_key_status, diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 4a7b80079d..32822e5290 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -212,6 +212,7 @@ mod tests { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index 8508c27073..5aa003ae44 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -419,6 +419,7 @@ mod tests { agent_command_override: None, persona_source_version: None, provider: None, + team_catalog_source: None, } } diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index b4492418e5..92baac0100 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -69,6 +69,7 @@ fn minimal_record() -> ManagedAgentRecord { source_team_persona_slug: Some("lep".to_string()), // MUST NOT appear definition_respond_to: Some("allowlist".to_string()), catalog_source: None, + team_catalog_source: None, definition_respond_to_allowlist: vec!["abc123def".to_string()], definition_parallelism: Some(4), relay_mesh: None, diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 62caffeb2e..396fff7471 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -111,6 +111,7 @@ fn test_record() -> ManagedAgentRecord { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 6fe6a77521..a6a38b265c 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -203,6 +203,7 @@ fn persona_with_runtime(id: &str, runtime: Option<&str>) -> crate::managed_agent source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: std::collections::BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -279,6 +280,7 @@ fn record_with( source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, @@ -1771,7 +1773,6 @@ fn harness_def( install_hint: String::new(), } } - /// A `save_and_warm` landing mid-discovery (after the scan, before the /// publish) must survive discovery's registry publish — through the real /// `discover_acp_runtimes_from` path. @@ -1805,7 +1806,6 @@ fn discovery_publish_path_survives_mid_flight_save() { publish clobbers a save that landed mid-discovery" ); } - /// A `delete_and_warm` landing mid-discovery must stay gone after discovery's /// publish — a stale snapshot (taken while the file existed) would resurrect it. #[test] diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index c8e437809c..769efebf14 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -22,6 +22,7 @@ fn definition( source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: vec![], @@ -87,6 +88,7 @@ fn record( source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, relay_mesh: None, auto_restart_on_config_change: false, definition_respond_to: None, diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 553596e226..b377f6efb3 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -347,6 +347,7 @@ fn bare_record() -> ManagedAgentRecord { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, relay_mesh: None, auto_restart_on_config_change: false, definition_respond_to: None, @@ -371,6 +372,7 @@ fn persona(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefini source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: vec![], @@ -632,6 +634,7 @@ fn record_runtime_wins_over_persona_runtime_for_command_resolution() { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index fe90ce430f..584683cad4 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -36,6 +36,7 @@ mod runtime_types; pub(crate) mod snapshot_avatar; pub(crate) mod spawn_snapshot; pub(crate) mod storage; +pub(crate) mod team_catalog; pub(crate) mod team_events; mod team_repair; mod teams; @@ -83,6 +84,9 @@ pub use storage::*; pub use teams::*; pub use types::*; +#[cfg(test)] +pub(crate) use teams::delete_catalog_team_at; + /// Returns the Buzz nest directory (`~/.buzz`) if it exists as a real /// directory (not a symlink), falling back to the user's home directory. /// diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index cbef171f6f..e3453abbdf 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -438,6 +438,7 @@ fn make_persona(id: &str, display_name: &str) -> AgentDefinition { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: std::collections::BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -498,6 +499,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index e1691575b1..13f34705be 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -113,6 +113,7 @@ mod tests { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, @@ -140,6 +141,7 @@ mod tests { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: std::collections::BTreeMap::new(), respond_to: None, respond_to_allowlist: vec![], diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index de396f45c0..cb03f7d171 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -196,6 +196,7 @@ pub fn persona_from_event(event: &nostr::Event) -> Result ManagedAgentRecord { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, @@ -155,6 +156,7 @@ pub(super) fn sample_persona() -> AgentDefinition { source_team: None, source_team_persona_slug: Some("test-slug".to_string()), catalog_source: None, + team_catalog_source: None, env_vars: BTreeMap::from([("KEY".to_string(), "value".to_string())]), respond_to: None, respond_to_allowlist: Vec::new(), @@ -382,6 +384,7 @@ fn content_matches_nip_ap_vector() { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -413,6 +416,7 @@ fn round_trip_minimal_persona() { source_team: Some("team-1".to_string()), source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -510,6 +514,7 @@ fn quad_absent_definition_hash_stable_across_activation() { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -554,6 +559,7 @@ fn persona_from_event_content_for_test(content: PersonaEventContent) -> AgentDef source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: BTreeMap::new(), respond_to: content.respond_to, respond_to_allowlist: content.respond_to_allowlist, diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 9bf7ab74b0..fb7d123789 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -125,6 +125,7 @@ fn built_in_persona_records(now: &str) -> Vec { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: std::collections::BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 387b4d72c6..bf419b2df4 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -22,6 +22,7 @@ fn custom_persona(id: &str, display_name: &str) -> AgentDefinition { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: std::collections::BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c072448ff1..2cb562618f 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1526,6 +1526,7 @@ mod tests { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, @@ -1715,7 +1716,6 @@ mod tests { key: "OPENROUTER_API_KEY".to_string() })); } - #[test] fn buzz_agent_openrouter_with_provider_model_fallback_is_ready() { let env = make_env( diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 7e97fa1f56..ade54e8eb9 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -460,6 +460,43 @@ pub fn get_retained_event( .map_err(|e| format!("failed to get retained event: {e}")) } +/// Return every retained event for `pubkey` at the given kind. +/// +/// Used by the team-catalog reconcile, which enumerates retained 30178 heads +/// as the authoritative worklist — not the current team store — so that a +/// shared head whose team was later deleted is visible and can be tombstoned. +pub fn get_retained_events_by_kind( + conn: &Connection, + kind: u32, + pubkey: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync + FROM persona_events + WHERE kind = ?1 AND pubkey = ?2 + ORDER BY d_tag", + ) + .map_err(|e| format!("failed to prepare query: {e}"))?; + + let rows = stmt + .query_map(params![kind, pubkey], |row| { + Ok(RetainedEvent { + kind: row.get(0)?, + pubkey: row.get(1)?, + d_tag: row.get(2)?, + content: row.get(3)?, + created_at: row.get(4)?, + raw_event: row.get(5)?, + pending_sync: row.get::<_, i32>(6)? != 0, + }) + }) + .map_err(|e| format!("failed to query retained events: {e}"))?; + + rows.collect::, _>>() + .map_err(|e| format!("failed to read retained event row: {e}")) +} + #[cfg(test)] mod tests { use super::*; diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs index 9836d983ed..206b720bd5 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -85,6 +85,7 @@ pub(super) fn fixture( source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 762b0fe2a6..8af021aeff 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -284,6 +284,7 @@ fn persona_with_provider( source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: std::collections::BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), @@ -414,10 +415,8 @@ fn agent_env_overrides_win_over_persona_env_at_spawn() { #[test] fn orphaned_agent_refused_at_spawn_boundary() { // Persona deleted: `spawn_agent_child` must refuse before any process - // side effect, not silently degrade to the record's stale overrides. - // `require_resolved` on the shared resolver is the pure predicate - // `spawn_agent_child` checks first — this pins the contract without - // needing a real `AppHandle`. + // side effect. `require_resolved` on the shared resolver is the pure + // predicate checked first — pins the contract without a real `AppHandle`. let persona = persona_v("p", "prompt", &[("ANTHROPIC_API_KEY", "persona-key")]); let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into())); record.env_vars = BTreeMap::from([("EXTRA".to_string(), "agent-value".to_string())]); diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index 1ceeee372f..f93ef6e5c7 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -66,6 +66,7 @@ fn record() -> ManagedAgentRecord { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, definition_respond_to: None, definition_respond_to_allowlist: Vec::new(), definition_parallelism: None, @@ -89,6 +90,7 @@ fn persona(id: &str, runtime: Option<&str>, prompt: &str) -> AgentDefinition { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 652bb9b9ea..35a3146b61 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -634,6 +634,78 @@ pub(crate) fn atomic_write_json_restricted(path: &Path, payload: &[u8]) -> Resul .map_err(|e| format!("commit {}: {e}", resolved.display())) } +// ── Two-store byte-level rollback ───────────────────────────────────────── +// +// Shared by `commands::teams::adopt::apply` (catalog adoption) and +// `managed_agents::teams` (adopted-team deletion). Identical rollback policy +// in both paths (I5 / I6). + +/// Raw pre-write snapshot of a JSON store file. +/// +/// `None` means the file did not exist at snapshot time; restoring `None` +/// removes the file (with `NotFound` treated as success — desired state +/// already reached). +pub(crate) type StoreSnapshot = Option>; + +/// Snapshot the raw bytes of `path`, or `None` if the file is absent. +pub(crate) fn snapshot_store(path: &Path) -> Result { + match std::fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("failed to snapshot {}: {e}", path.display())), + } +} + +/// Restore `path` from a [`StoreSnapshot`]. +/// +/// `NotFound` when restoring an absent snap is treated as success — the +/// desired state is already reached (I5). +pub(crate) fn restore_store(path: &Path, snap: StoreSnapshot) -> Result<(), String> { + match snap { + Some(bytes) => atomic_write_json_restricted(path, &bytes), + None => match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!( + "failed to remove {} during restore: {e}", + path.display() + )), + }, + } +} + +/// Write both stores via the supplied callbacks, rolling back both from +/// caller-supplied snapshots on any failure. +/// +/// Both restores are attempted independently on failure so a restore failure +/// in one store does not prevent the other from being restored. Errors from +/// both restores are aggregated into the returned error message (I5). +pub(crate) fn commit_stores_with_snapshots( + personas_path: &Path, + teams_path: &Path, + personas_snap: StoreSnapshot, + teams_snap: StoreSnapshot, + write_personas: impl FnOnce() -> Result<(), String>, + write_teams: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + if let Err(error) = write_personas().and_then(|()| write_teams()) { + let personas_err = restore_store(personas_path, personas_snap).err(); + let teams_err = restore_store(teams_path, teams_snap).err(); + let restore_errors: Vec<&str> = [personas_err.as_deref(), teams_err.as_deref()] + .into_iter() + .flatten() + .collect(); + if !restore_errors.is_empty() { + return Err(format!( + "{error} (and the local stores could not be restored: {})", + restore_errors.join("; ") + )); + } + return Err(error); + } + Ok(()) +} + /// Maximum log file size before rotation (10 MB). const MAX_LOG_FILE_SIZE: u64 = 10 * 1024 * 1024; diff --git a/desktop/src-tauri/src/managed_agents/team_catalog.rs b/desktop/src-tauri/src/managed_agents/team_catalog.rs new file mode 100644 index 0000000000..4620f1c431 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/team_catalog.rs @@ -0,0 +1,825 @@ +//! Project a `TeamRecord` plus its member definitions onto a kind:30178 team +//! catalog event. +//! +//! Kind 30176 (`team_events`) is the team's own wire body: membership by local +//! persona id, meaningless to anyone but the owner. Kind 30178 is the +//! *catalog projection* — a self-contained, shareable snapshot that embeds +//! every member's safe definition so a recipient can rebuild the team without +//! reading the owner's personas. The two are deliberately separate kinds: an +//! ordinary team edit republishes 30176 and cannot disturb catalog share +//! state, which lives only on the 30178 head's `shared` tag. +//! +//! This module is a pure builder plus validator — it performs no I/O and owns +//! no wiring. Publication, unshare, and tombstone live in +//! `commands::teams::sharing`. +//! +//! Field discipline is inherited from `persona_events::PersonaEventContent`: +//! an explicit opt-IN projection over the persona-catalog safe set. Env vars, +//! respond-to allowlist pubkeys, local ids, filesystem paths, and every other +//! install-specific or secret-bearing field are structurally absent from the +//! projection types below, so no future `AgentDefinition` field can leak by +//! being forgotten. + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use buzz_core_pkg::kind::KIND_TEAM_CATALOG; +use image::ImageDecoder; +use nostr::{EventBuilder, Kind, Tag}; +use serde::{Deserialize, Serialize}; +use std::io::Cursor; + +use super::{AgentDefinition, RespondTo, TeamRecord}; + +/// Schema version of the 30178 content body. A reader that does not recognize +/// the value must refuse the event rather than guess at its shape. +pub const TEAM_CATALOG_SCHEMA_VERSION: u32 = 1; + +// ── Size contract ──────────────────────────────────────────────────────────── +// +// A 30178 event amplifies N member definitions into ONE event, so bounds that +// are immaterial for a single kind:30175 persona become load-bearing here: 64 +// members each carrying a 64 KiB avatar would be a 4 MiB event. The relay's +// ingest ceiling is 256 KiB (`MAX_EVENT_CONTENT_BYTES`, +// `crates/buzz-relay/src/handlers/ingest.rs`), and an event that exceeds it is +// rejected AFTER being signed and durably enqueued — a permanently stuck +// pending row with no user-visible cause. Every bound below is therefore +// enforced BEFORE the event is built, so the failure surfaces synchronously at +// share time as a deterministic error instead of a silent queue. +// +// `MAX_TOTAL_BYTES` is the only bound that matters for relay acceptance; the +// per-field bounds exist so an oversized team names the specific field that +// pushed it over instead of reporting an opaque total. + +/// Maximum members in one catalog projection. +pub const MAX_MEMBERS: usize = 64; +/// Maximum bytes for a team or member display name. +pub const MAX_NAME_BYTES: usize = 256; +/// Maximum bytes for the team description (display text). +pub const MAX_TEXT_BYTES: usize = 4 * 1024; +/// Maximum bytes for the team instructions — prompt content, parity with +/// `MAX_SYSTEM_PROMPT_BYTES`. +pub const MAX_INSTRUCTIONS_BYTES: usize = 16 * 1024; +/// Maximum bytes for a member's system prompt. +pub const MAX_SYSTEM_PROMPT_BYTES: usize = 16 * 1024; +/// Maximum bytes for a member's avatar URL. Generous because the persona +/// catalog permits inline emoji data URLs, not just `https://` links. +pub const MAX_AVATAR_URL_BYTES: usize = 32 * 1024; +/// Maximum entries in a member's name pool. +pub const MAX_NAME_POOL_ENTRIES: usize = 64; +/// Maximum bytes for the whole serialized content body. Held well under the +/// relay's 256 KiB ingest ceiling so base64/transport overhead upstream of the +/// check cannot turn an accepted projection into a rejected event. +pub const MAX_TOTAL_BYTES: usize = 192 * 1024; + +/// Maximum pixel dimension (width or height) accepted when decoding an inline +/// avatar for downscaling. Prevents decompression-bomb attacks before any +/// pixel allocation occurs. Mirrors `snapshot_avatar.rs`. +const MAX_DOWNSCALE_DECODE_DIMENSION: u32 = 2048; +/// Maximum heap allocation the image decoder may perform when materializing +/// a raster for downscaling. Mirrors `snapshot_avatar.rs`. +const MAX_DOWNSCALE_DECODE_ALLOC: u64 = 32 * 1024 * 1024; + +/// Maximum bytes for a member's opaque `member_key`. A conforming key is a +/// 64-char SHA-256 hex digest; the bound is the parse-side ceiling for a +/// foreign publisher's value, which need only be opaque and unique. +pub const MAX_MEMBER_KEY_BYTES: usize = 128; +/// Maximum bytes for a member's runtime, model, or provider identifier. +pub const MAX_IDENTIFIER_BYTES: usize = 256; +/// Maximum bytes for a built-in reuse slug. +pub const MAX_BUILTIN_SLUG_BYTES: usize = 128; +/// Length of a hex-encoded SHA-256 projection hash. +pub const PROJECTION_HASH_HEX_LEN: usize = 64; + +/// The JSON body stored in a kind:30178 event's content field. +/// +/// Field order is pinned by the struct declaration: serde emits in declaration +/// order, so a reorder changes the content bytes and the NIP-01 event id — and +/// the freshness reconcile compares exactly those bytes to decide whether a +/// shared projection is stale. Reordering would make every shared team look +/// stale exactly once, republishing the entire catalog. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TeamCatalogContent { + /// Schema version. First field so a reader can dispatch on it before + /// committing to the rest of the shape. + pub v: u32, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instructions: Option, + /// Member projections in the team's own membership order. Order is part of + /// the canonical bytes, so a membership reorder is a genuine change and + /// correctly republishes. + pub members: Vec, +} + +/// One member's safe definition, embedded in full. +/// +/// Embedding is authoritative: a recipient can always rebuild this member from +/// these fields alone. `builtin_slug` / `projection_hash` are a reuse *hint* +/// and never an identity authority — see their doc comments. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TeamCatalogMember { + /// Stable, opaque identity of this member WITHIN this team publication. + /// + /// Provenance for an added member is the triple `(owner_pubkey, + /// team_d_tag, member_key)`, so the key must distinguish every member the + /// publisher can hold. It is a domain-separated SHA-256 over the source + /// record's `id` — see [`member_key_for`]. Hashing an already-unique id is + /// deterministic, so an unchanged team rebuilds to identical bytes, while + /// the published value discloses no local id. + /// + /// A recipient MUST treat it as opaque and MUST NOT resolve it as a + /// kind:30175 coordinate in the publisher's namespace: the publisher may + /// never have shared that persona individually, and a member that is + /// present here is not thereby readable there. Hashing makes that misuse + /// structurally impossible rather than merely forbidden. + pub member_key: String, + pub display_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub name_pool: Vec, + /// Sanitized audience mode. `allowlist` is never projected — see + /// [`sanitized_respond_to`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub respond_to: Option, + /// Clamped to 1..=32 at projection time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parallelism: Option, + /// Reuse hint: the built-in slug this member was installed from. + /// + /// Present only for built-in members. A recipient may substitute its own + /// local built-in ONLY when the slug exists locally AND that built-in's + /// current projection hash equals `projection_hash`. Any mismatch — a + /// retired slug, a changed prompt, or a hostile slug paired with unrelated + /// embedded fields — falls back to an ordinary copy built from the + /// embedded fields above. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub builtin_slug: Option, + /// Hash of this member's own embedded projection. Meaningful only + /// alongside `builtin_slug`; it is what makes the reuse hint exact-match + /// gated rather than name-trusting. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub projection_hash: Option, +} + +/// Resolve the members of `team` from `personas`, in the team's own +/// membership order. +/// +/// Order is load-bearing: it is part of the canonical projection bytes, so +/// resolving through a map would make an unchanged team rebuild to different +/// bytes each time. An unresolvable id is an error rather than a skip — +/// silently publishing a team with a member missing would present a different +/// team to the community than the one the owner is looking at, and the +/// freshness reconcile treats exactly this failure as grounds for retraction. +pub fn resolve_team_members( + team: &TeamRecord, + personas: &[AgentDefinition], +) -> Result, String> { + team.persona_ids + .iter() + .map(|persona_id| { + personas + .iter() + .find(|record| &record.id == persona_id) + .cloned() + .ok_or_else(|| format!("team member {persona_id} not found")) + }) + .collect() +} + +/// There is no `respond_to_allowlist` field on [`TeamCatalogMember`], and that +/// absence is the anti-leak guarantee: an allowlist is a list of real pubkeys +/// the owner chose to trust, and publishing it to a community catalog would +/// disclose the owner's social graph. Rather than projecting an emptied list — +/// which would silently widen the member's audience for a recipient who reads +/// `allowlist` mode with no entries — the mode itself is downgraded to +/// `owner-only`, the most restrictive setting. A recipient that wants an +/// allowlist must author one. +fn sanitized_respond_to(record: &AgentDefinition) -> Option { + match record.respond_to.as_deref() { + Some(mode) if mode == RespondTo::Allowlist.as_str() => { + Some(RespondTo::OwnerOnly.as_str().to_string()) + } + other => other.map(str::to_string), + } +} + +/// The opaque published identity of one member. +/// +/// Derived from the source record's `id`, which is unique within the +/// publisher's persona store — a UUID for in-app personas, `builtin:` +/// for built-ins, the pack slug for pack-installed records. The id is hashed +/// with a domain-separation prefix rather than published raw, so the key +/// leaks no local identifier and cannot be mistaken for a resolvable +/// kind:30175 d-tag. +/// +/// Deliberately NOT `persona_events::persona_d_tag`: that normalizer is +/// documented non-injective — it case-folds, maps every char outside `[a-z0-9_-]` to +/// `-`, and truncates to 64 bytes — so two distinct members could publish one +/// key. Provenance is keyed on `(owner_pubkey, team_d_tag, member_key)`, so a +/// collision there is not cosmetic: on adoption the second colliding member +/// matches the first copy's provenance and both published members collapse +/// onto a single local persona. SHA-256 over the exact id keeps distinct +/// sources distinct. +pub fn member_key_for(record: &AgentDefinition) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(b"buzz:team-catalog:member-key:v1\0"); + hasher.update(record.id.as_bytes()); + hex::encode(hasher.finalize()) +} + +/// Downscale an oversized inline raster data URL to fit within `MAX_AVATAR_URL_BYTES`. +/// +/// Tries successively smaller maximum dimensions (256 → 192 → 128 → 96 → 64) +/// and returns the first PNG data URL that fits. Returns `None` if the input is +/// not a decodable raster data URL or no dimension produces a small enough result. +fn downscale_raster_avatar(url: &str) -> Option { + if !url.starts_with("data:image/") { + return None; + } + let bytes = crate::managed_agents::agent_snapshot::decode_avatar_data_url(url)?; + // Use a bounded decoder to reject decompression bombs before pixel + // allocation. `image::load_from_memory` imposes no dimension ceiling and + // allows the decoder's default 512 MiB allocation budget. + let reader = image::ImageReader::new(Cursor::new(&bytes)) + .with_guessed_format() + .ok()?; + let mut decoder = reader.into_decoder().ok()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_DOWNSCALE_DECODE_DIMENSION); + limits.max_image_height = Some(MAX_DOWNSCALE_DECODE_DIMENSION); + limits.max_alloc = Some(MAX_DOWNSCALE_DECODE_ALLOC); + decoder.set_limits(limits).ok()?; + let img = image::DynamicImage::from_decoder(decoder).ok()?; + for &max_dim in &[256u32, 192, 128, 96, 64] { + let resized = if img.width().max(img.height()) > max_dim { + img.resize(max_dim, max_dim, image::imageops::FilterType::Lanczos3) + } else { + img.clone() + }; + let mut png = Vec::new(); + if resized + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .is_ok() + { + let data_url = format!("data:image/png;base64,{}", STANDARD.encode(&png)); + if data_url.len() <= MAX_AVATAR_URL_BYTES { + return Some(data_url); + } + } + } + None +} + +/// Project one member definition, without the built-in reuse hint. +fn member_projection(record: &AgentDefinition) -> TeamCatalogMember { + // Built-in members: oversized avatars are silently stripped. Downscaling + // a built-in would change its projection bytes and break the reuse-hint + // hash, which must stay recomputable from the recipient's pristine local + // copy. + // + // Non-built-in members: oversized inline raster data URLs are downscaled + // so the share succeeds. An owner who set a large avatar should be able to + // share — the catalog renders at small sizes anyway. If decoding fails or + // no dimension fits, the avatar falls through unchanged and `validate_member` + // surfaces the deterministic "avatar too large" error. + let is_builtin = builtin_catalog_slug(record).is_some(); + let avatar_url = record + .avatar_url + .as_deref() + .filter(|url| !is_builtin || url.len() <= MAX_AVATAR_URL_BYTES) + .map(|url| { + if !is_builtin && url.len() > MAX_AVATAR_URL_BYTES { + downscale_raster_avatar(url).unwrap_or_else(|| url.to_string()) + } else { + url.to_string() + } + }); + + TeamCatalogMember { + member_key: member_key_for(record), + display_name: record.display_name.clone(), + // Mirrors `persona_event_content`: always `Some`, including for an + // empty prompt, so the encoding does not depend on emptiness. + system_prompt: Some(record.system_prompt.clone()), + avatar_url, + runtime: record.runtime.clone(), + model: record.model.clone(), + provider: record.provider.clone(), + name_pool: record.name_pool.clone(), + respond_to: sanitized_respond_to(record), + parallelism: record.parallelism.map(|value| value.clamp(1, 32)), + builtin_slug: None, + projection_hash: None, + } +} + +/// The canonical catalog slug of a local built-in, or `None` for any record +/// that is not one. +/// +/// Real built-ins are constructed with ids like `builtin:fizz` and +/// `source_team_persona_slug: None` (`managed_agents::personas`), so keying +/// the reuse hint on `source_team_persona_slug` matched no real built-in on +/// either side — the publisher emitted no hint and the recipient could find +/// no local candidate. The `builtin:` id prefix is the actual canonical +/// identity, and it is identical across installs, which is exactly what a +/// cross-install reuse hint needs. +pub fn builtin_catalog_slug(record: &AgentDefinition) -> Option<&str> { + if !record.is_builtin { + return None; + } + record + .id + .strip_prefix("builtin:") + .filter(|slug| !slug.is_empty()) +} + +/// Project a member and attach the built-in reuse hint when applicable. +/// +/// The hash is computed over the member projection with both hint fields +/// still absent, so the recipient — which recomputes it from its own local +/// built-in — derives the same value without needing to know the publisher's +/// slug. A hash that covered the slug would be self-referential and could +/// never match across installs. +fn member_projection_with_reuse_hint(record: &AgentDefinition) -> TeamCatalogMember { + let mut member = member_projection(record); + if let Some(slug) = builtin_catalog_slug(record) { + member.projection_hash = Some(member_projection_hash(&member)); + member.builtin_slug = Some(slug.to_string()); + } + member +} + +/// Canonical JSON encoding of a content body — the single serializer. +/// +/// Every byte-sensitive consumer (the size contract, the content hash, and the +/// event body) routes through this function so they can never disagree about +/// what the canonical encoding is. +pub fn team_catalog_content_json(content: &TeamCatalogContent) -> Result { + serde_json::to_string(content).map_err(|e| format!("failed to serialize team catalog: {e}")) +} + +fn member_projection_hash(member: &TeamCatalogMember) -> String { + use sha2::{Digest, Sha256}; + let json = serde_json::to_vec(member).unwrap_or_default(); + hex::encode(Sha256::digest(&json)) +} + +/// The projection hash a recipient computes for one of its OWN local records, +/// to compare against a published member's `projection_hash`. +/// +/// This is the reader half of the built-in reuse hint: the publisher stamps +/// `projection_hash` over the hint-free projection, and the recipient +/// recomputes it here from its own local built-in. Equality means the two +/// installs hold a byte-identical definition, which is the only condition +/// under which substituting the local record for the published one is safe. +pub fn local_member_projection_hash(record: &AgentDefinition) -> String { + member_projection_hash(&member_projection(record)) +} + +/// Validate an avatar URL against the catalog-safe allowlist. +/// +/// Shared contract with `safeCatalogAvatarUrl` / `isSafeHttpUrl` in +/// `catalogRelay.ts`. The two sides must accept and reject the same inputs so +/// a publisher and a TS-side display reader always agree. +/// +/// **Length metric: UTF-8 bytes** — the relay's native encoding and the same +/// unit used by every other field bound in this module. TypeScript uses the +/// existing `byteLength` helper to match (JS `value.length` counts UTF-16 +/// code units, which diverges for non-ASCII input). +/// +/// Permitted forms: +/// - `http://` or `https://` URLs that parse cleanly via `url::Url::parse`, +/// scheme checked on the parsed (normalized) value, and whose UTF-8 byte +/// length is ≤ 2 048. Both Rust's `url` crate and the browser's `new URL()` +/// implement the WHATWG URL Standard, so parse-first makes both sides run the +/// same algorithm — including shorthand forms like `http:example.com` which +/// both normalize to `http://example.com/`. +/// - Inline SVG: `data:image/svg+xml,…` up to 8 192 bytes +/// - Inline raster (png/jpeg/gif/webp): `data:image/;base64,` up +/// to 256 KiB with strict base64 shape +/// +/// A `javascript:` URL, an arbitrary `data:` scheme, unparseable strings like +/// `https://^`, or anything else returns false. +pub fn is_safe_catalog_avatar_url(url: &str) -> bool { + const INLINE_SVG_PREFIX: &str = "data:image/svg+xml,"; + const MAX_INLINE_SVG_LEN: usize = 8_192; + const MAX_INLINE_RASTER_LEN: usize = 256 * 1_024; + /// HTTP/HTTPS URL cap in UTF-8 bytes — same unit as TypeScript's `byteLength`. + const MAX_HTTP_URL_BYTES: usize = 2_048; + + // Candidate HTTP/HTTPS URLs: byte cap → whitespace/paren guard → WHATWG parse → scheme check. + // + // We parse all candidates rather than requiring a literal `http://`/`https://` + // prefix, because the WHATWG URL Standard normalizes shorthand forms like + // `http:example.com` to `http://example.com/` — both Rust and TypeScript + // accept them — and a literal prefix gate would reject them before parsing. + // The 2 048-byte cap acts as the performance gate instead. + // + // Only check this path for strings that could plausibly be HTTP/HTTPS URLs + // (i.e. not data: URIs which are handled below). + if !url.starts_with("data:") { + // UTF-8 byte length cap — same unit as TS `byteLength`. + if url.len() > MAX_HTTP_URL_BYTES { + return false; + } + // Reject URLs containing ECMAScript-`\s` whitespace or parentheses, + // matching TypeScript's pre-check `/[\s()]/u.test(value)`. + // + // Exact ECMAScript `\s` equivalence in Rust: + // ECMAScript `\s` = char::is_whitespace() − U+0085 (NEL) + U+FEFF (BOM) + // Proof: `/\s/u.test('\u0085')` → false (JS excludes NEL); + // `'\u{FEFF}'.is_whitespace()` → false (Rust excludes BOM). + // url::Url::parse percent-encodes these characters rather than rejecting + // them, so without the guard the two validators would diverge. + if url.chars().any(|c| { + ((c.is_whitespace() && c != '\u{0085}') || c == '\u{FEFF}') || c == '(' || c == ')' + }) { + return false; + } + // Parse with the same WHATWG algorithm as TypeScript's `new URL()`. + // url::Url::parse rejects malformed authority components (https://^, + // https://a:b) and normalizes the scheme so HTTPS://example.com and + // http:example.com are handled identically on both sides. + if let Ok(u) = ::url::Url::parse(url) { + if matches!(u.scheme(), "http" | "https") { + return true; + } + } + return false; + } + if url.starts_with(INLINE_SVG_PREFIX) { + return url.len() <= MAX_INLINE_SVG_LEN; + } + // Inline raster: data:image/(png|jpeg|gif|webp);base64, + if url.len() <= MAX_INLINE_RASTER_LEN { + if let Some(rest) = url.strip_prefix("data:image/") { + for mime in &["png", "jpeg", "gif", "webp"] { + if let Some(b64_part) = rest + .strip_prefix(mime) + .and_then(|r| r.strip_prefix(";base64,")) + { + // Strict base64: only [A-Za-z0-9+/] with up to 2 trailing '=' + let trimmed = b64_part.trim_end_matches('='); + let padding = b64_part.len() - trimmed.len(); + if padding <= 2 + && trimmed + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/') + && b64_part.len() % 4 == 0 + { + return true; + } + } + } + } + } + false +} +fn bounded(value: &str, max: usize, label: &str) -> Result<(), String> { + if value.len() > max { + return Err(format!( + "team too large to share: {label} is {} bytes (limit {max})", + value.len() + )); + } + Ok(()) +} + +fn non_empty(value: &str, label: &str) -> Result<(), String> { + if value.trim().is_empty() { + return Err(format!("invalid team projection: {label} is empty")); + } + Ok(()) +} + +/// Validate one member against the v1 contract. +/// +/// Every field a recipient will persist is checked here, because adoption +/// copies the projection into a local `AgentDefinition` verbatim. A field that +/// is bounded on the way in but unvalidated on the way out produces a record +/// that is accepted at add time and only fails later when the agent is minted +/// — `parallelism` was exactly that: a publisher could send `999`, adoption +/// stored it, and minting rejected it out of 1..=32 at launch. Validating at +/// the parse boundary makes an unusable team un-addable instead of +/// add-then-broken. +fn validate_member(member: &TeamCatalogMember) -> Result<(), String> { + let who = &member.display_name; + non_empty(&member.member_key, "a member key")?; + bounded(&member.member_key, MAX_MEMBER_KEY_BYTES, "a member key")?; + non_empty(&member.display_name, "a member display name")?; + bounded( + &member.display_name, + MAX_NAME_BYTES, + "a member display name", + )?; + if let Some(prompt) = &member.system_prompt { + bounded( + prompt, + MAX_SYSTEM_PROMPT_BYTES, + &format!("the system prompt for '{who}'"), + )?; + } + if let Some(avatar) = &member.avatar_url { + bounded( + avatar, + MAX_AVATAR_URL_BYTES, + &format!("the avatar for '{who}'"), + )?; + if !is_safe_catalog_avatar_url(avatar) { + return Err(format!( + "invalid team projection: the avatar for '{who}' uses an unsafe URL scheme (must be https, http, or an approved inline data URL)" + )); + } + } + for (value, label) in [ + (&member.runtime, "runtime"), + (&member.model, "model"), + (&member.provider, "provider"), + ] { + if let Some(value) = value { + non_empty(value, &format!("the {label} for '{who}'"))?; + bounded( + value, + MAX_IDENTIFIER_BYTES, + &format!("the {label} for '{who}'"), + )?; + } + } + if member.name_pool.len() > MAX_NAME_POOL_ENTRIES { + return Err(format!( + "team too large to share: '{who}' has {} name-pool entries (limit {MAX_NAME_POOL_ENTRIES})", + member.name_pool.len() + )); + } + for name in &member.name_pool { + non_empty(name, &format!("a name-pool entry for '{who}'"))?; + bounded( + name, + MAX_NAME_BYTES, + &format!("a name-pool entry for '{who}'"), + )?; + } + // Rejected at the boundary rather than on use: an unrecognized mode must + // not become a local definition whose audience differs from what the + // recipient was shown. + if let Some(mode) = &member.respond_to { + RespondTo::parse_wire(mode)?; + } + // Mirrors the 1..=32 range `mint_behavioral_defaults` enforces, so a team + // whose members could never launch is refused at add time. + if let Some(parallelism) = member.parallelism { + if !(1..=32).contains(¶llelism) { + return Err(format!( + "invalid team projection: parallelism {parallelism} for '{who}' is out of range (must be between 1 and 32)" + )); + } + } + // The reuse hint is only meaningful as a complete, well-formed pair. A + // half-pair or a malformed hash is a broken publisher, not a hostile one + // the hash comparison would absorb — refuse it rather than silently + // ignoring the hint. + match (&member.builtin_slug, &member.projection_hash) { + (Some(slug), Some(hash)) => { + non_empty(slug, &format!("the built-in slug for '{who}'"))?; + bounded( + slug, + MAX_BUILTIN_SLUG_BYTES, + &format!("the built-in slug for '{who}'"), + )?; + if hash.len() != PROJECTION_HASH_HEX_LEN || !hash.bytes().all(|b| b.is_ascii_hexdigit()) + { + return Err(format!( + "invalid team projection: the reuse hash for '{who}' is not a SHA-256 hex digest" + )); + } + } + (None, None) => {} + _ => { + return Err(format!( + "invalid team projection: '{who}' has an incomplete built-in reuse hint" + )) + } + } + Ok(()) +} + +/// Enforce the size contract on a projected body. +/// +/// Field bounds are checked before the total so the error names the specific +/// oversized field; the total is the backstop that actually guarantees relay +/// acceptance, because many individually-legal members still sum past the +/// ceiling. +pub fn validate_team_catalog_content(content: &TeamCatalogContent) -> Result<(), String> { + // Non-empty trimmed name — exact parity with the TS reader which checks + // `parsed.name.trim().length > 0`. A blank name persisted via a direct + // backend add would be invisible in the catalog UI. + non_empty(content.name.trim(), "the team name")?; + bounded(&content.name, MAX_NAME_BYTES, "the team name")?; + if let Some(description) = &content.description { + bounded(description, MAX_TEXT_BYTES, "the team description")?; + } + if let Some(instructions) = &content.instructions { + bounded( + instructions, + MAX_INSTRUCTIONS_BYTES, + "the team instructions", + )?; + } + if content.members.len() > MAX_MEMBERS { + return Err(format!( + "team too large to share: {} members (limit {MAX_MEMBERS})", + content.members.len() + )); + } + // Provenance for every adopted member is `(owner_pubkey, team_d_tag, + // member_key)`. Two members sharing a key would resolve to one provenance + // and collapse onto a single local persona at adoption, silently dropping + // a member the recipient was shown. Rejecting the publication is the only + // safe answer: there is no way to tell which of the two the recipient + // meant to keep. + let mut seen = std::collections::HashSet::with_capacity(content.members.len()); + for member in &content.members { + validate_member(member)?; + if !seen.insert(member.member_key.as_str()) { + return Err(format!( + "invalid team projection: '{}' repeats the member key '{}' of an earlier member", + member.display_name, member.member_key + )); + } + } + let encoded = team_catalog_content_json(content)?; + if encoded.len() > MAX_TOTAL_BYTES { + return Err(format!( + "team too large to share: the projection is {} bytes (limit {MAX_TOTAL_BYTES})", + encoded.len() + )); + } + Ok(()) +} + +/// Project a team and its resolved members onto a validated 30178 body. +/// +/// `members` are supplied already resolved and ordered by the caller — the +/// team's own `persona_ids` order — because resolution needs the persona store +/// and this module stays pure. A member the caller could not resolve is simply +/// absent from the slice; the projection describes what is actually there. +/// +/// Returns `Err` when the size contract is violated, so a share attempt fails +/// synchronously with a deterministic reason instead of enqueuing an event the +/// relay will refuse. +pub fn build_team_catalog_content( + team: &TeamRecord, + members: &[AgentDefinition], +) -> Result { + let content = TeamCatalogContent { + v: TEAM_CATALOG_SCHEMA_VERSION, + name: team.name.clone(), + description: team.description.clone(), + instructions: team.instructions.clone(), + members: members + .iter() + .map(member_projection_with_reuse_hint) + .collect(), + }; + validate_team_catalog_content(&content)?; + Ok(content) +} + +/// Build an unsigned kind:30178 event for a team catalog projection. +/// +/// The `d` tag is the team's id, matching its kind:30176 coordinate, so the +/// two heads for one team address consistently. `shared` is tagged only when +/// true: the relay's read gate keys off the tag's presence +/// (`SHARED_GATED_KINDS`), and an untagged head is the durable "published but +/// not discoverable" state that unshare produces. +/// +/// Returns an `EventBuilder`; the caller sets `created_at`, signs, and submits. +pub fn build_team_catalog_event( + team: &TeamRecord, + members: &[AgentDefinition], + shared: bool, +) -> Result { + let content = build_team_catalog_content(team, members)?; + let content_json = team_catalog_content_json(&content)?; + let mut tags = + vec![Tag::parse(["d", team.id.as_str()]).map_err(|e| format!("invalid d-tag: {e}"))?]; + if shared { + tags.push(Tag::parse(["shared", "true"]).map_err(|e| format!("invalid shared tag: {e}"))?); + } + Ok(EventBuilder::new(Kind::Custom(KIND_TEAM_CATALOG as u16), content_json).tags(tags)) +} + +/// Parse a kind:30178 event body, rejecting an unrecognized schema version. +/// +/// Version dispatch happens before field access: a future `v: 2` body may +/// legally reshape any field, so parsing it as `v: 1` and rendering whatever +/// deserializes would present a corrupted team as a valid one. +pub fn team_catalog_content_from_event(event: &nostr::Event) -> Result { + let content: TeamCatalogContent = serde_json::from_str(event.content.as_ref()) + .map_err(|e| format!("failed to parse team catalog content: {e}"))?; + if content.v != TEAM_CATALOG_SCHEMA_VERSION { + return Err(format!( + "unsupported team catalog schema version {} (expected {TEAM_CATALOG_SCHEMA_VERSION})", + content.v + )); + } + validate_team_catalog_content(&content)?; + Ok(content) +} + +/// Build a NIP-09 deletion (kind:5) targeting a team's kind:30178 projection. +/// +/// Mirrors `team_events::build_team_delete` but at the 30178 coordinate: a +/// single `a`-tag and no `e`-tag, because an `e`-tag routes the relay to the +/// event-id deletion path and leaves the replaceable coordinate live. Deleting +/// a shared team must retract the catalog entry for every reader, not just +/// this client. +pub fn build_team_catalog_delete( + d_tag: &str, + owner_pubkey_hex: &str, +) -> Result { + let coord = format!("{KIND_TEAM_CATALOG}:{owner_pubkey_hex}:{d_tag}"); + let tag = Tag::parse(["a", coord.as_str()]).map_err(|e| format!("invalid a-tag: {e}"))?; + Ok(EventBuilder::new(Kind::Custom(5), "").tags(vec![tag])) +} + +/// Purge the retained 30178 head at `d_tag` and enqueue a kind:5 tombstone. +/// +/// Called from two contexts that both hold the db path and keys but cannot +/// both reach `commands::teams::pending::tombstone_team_catalog_at`: +/// - `commands::teams::pending` (direct delete_team path) +/// - `event_sync` (boot reconcile for orphaned shared heads, F1) +/// - `commands::teams::pending` (immediate retraction on I2 failure) +/// +/// The two SQLite operations (DELETE retained row + INSERT tombstone) run in a +/// single transaction. A kill between them would otherwise leave the relay +/// head shared indefinitely — the exact A3 failure mode (I3). +/// +/// Splitting the shared logic here avoids a cross-module layering violation +/// while keeping the two callers in agreement about what a tombstone is. +pub fn tombstone_team_catalog_coordinate( + db_path: &std::path::Path, + keys: &nostr::Keys, + d_tag: &str, +) -> Result<(), String> { + use crate::managed_agents::retention::{ + open_retention_db, retain_event, tombstone_retention_d_tag, RetainedEvent, + }; + use nostr::JsonUtil; + + const KIND_DELETE: u32 = 5; + + let pubkey = keys.public_key().to_hex(); + let event = build_team_catalog_delete(d_tag, &pubkey)? + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign team catalog tombstone: {e}"))?; + let tombstone = RetainedEvent { + kind: KIND_DELETE, + pubkey: pubkey.clone(), + // Key by the target coordinate so the 30176 and 30178 + // tombstones for one team occupy distinct rows. + d_tag: tombstone_retention_d_tag(KIND_TEAM_CATALOG, d_tag), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }; + + let conn = open_retention_db(db_path)?; + // Wrap the DELETE + INSERT in a single transaction so a process kill + // between them cannot leave the relay head shared indefinitely (I3). + conn.execute_batch("BEGIN IMMEDIATE") + .map_err(|e| format!("failed to begin tombstone transaction: {e}"))?; + let result = (|| -> Result<(), String> { + conn.execute( + "DELETE FROM persona_events + WHERE kind = ?1 AND pubkey = ?2 AND d_tag = ?3", + rusqlite::params![KIND_TEAM_CATALOG, &pubkey, d_tag], + ) + .map_err(|e| format!("failed to purge retained 30178 head: {e}"))?; + retain_event(&conn, &tombstone) + })(); + match result { + Ok(()) => conn + .execute_batch("COMMIT") + .map_err(|e| format!("failed to commit tombstone transaction: {e}")), + Err(e) => { + let _ = conn.execute_batch("ROLLBACK"); + Err(e) + } + } +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs b/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs new file mode 100644 index 0000000000..a566463d55 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/team_catalog/tests.rs @@ -0,0 +1,999 @@ +use super::*; +use std::collections::BTreeMap; +use std::path::PathBuf; + +fn member(id: &str, display_name: &str) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: display_name.to_string(), + avatar_url: None, + system_prompt: "Do the work.".to_string(), + runtime: Some("goose".to_string()), + model: Some("claude-opus-4".to_string()), + provider: Some("anthropic".to_string()), + name_pool: vec!["Alpha".to_string()], + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + team_catalog_source: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2026-07-30T00:00:00Z".to_string(), + updated_at: "2026-07-30T00:00:00Z".to_string(), + } +} + +fn team() -> TeamRecord { + TeamRecord { + id: "team-abc".to_string(), + name: "Catalog Team".to_string(), + description: Some("A shared team".to_string()), + instructions: Some("Coordinate carefully.".to_string()), + persona_ids: vec!["m1".to_string(), "m2".to_string()], + is_builtin: false, + shared: false, + catalog_source: None, + source_dir: Some(PathBuf::from("/local/only/path")), + is_symlink: true, + symlink_target: Some("/somewhere/private".to_string()), + version: Some("1.0".to_string()), + created_at: "2026-07-30T00:00:00Z".to_string(), + updated_at: "2026-07-30T00:00:00Z".to_string(), + } +} + +#[test] +fn test_projection_omits_local_only_team_fields() { + let content = build_team_catalog_content(&team(), &[member("m1", "One")]).unwrap(); + let json = team_catalog_content_json(&content).unwrap(); + + assert!(json.contains("\"name\":\"Catalog Team\"")); + for local_only in [ + "source_dir", + "is_symlink", + "symlink_target", + "is_builtin", + "version", + "created_at", + "updated_at", + "persona_ids", + ] { + assert!( + !json.contains(local_only), + "local-only field '{local_only}' must never be projected" + ); + } +} + +#[test] +fn test_projection_never_contains_a_source_allowlist_pubkey() { + // Allowlist entries are real pubkeys the owner trusts — must not appear in the projection. + const SECRET_PEER: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + let mut one = member("m1", "One"); + one.respond_to = Some(RespondTo::Allowlist.as_str().to_string()); + one.respond_to_allowlist = vec![SECRET_PEER.to_string()]; + one.env_vars + .insert("API_TOKEN".to_string(), "super-secret".to_string()); + + let content = build_team_catalog_content(&team(), &[one]).unwrap(); + let json = team_catalog_content_json(&content).unwrap(); + + assert!(!json.contains(SECRET_PEER), "allowlist pubkey leaked"); + assert!(!json.contains("super-secret"), "env var value leaked"); + assert!(!json.contains("API_TOKEN"), "env var key leaked"); + assert!(!json.contains("respond_to_allowlist")); +} + +#[test] +fn test_allowlist_mode_downgrades_to_owner_only_not_an_empty_allowlist() { + // Must downgrade the mode itself, not empty the list — empty list reads as mode with no trust. + let mut one = member("m1", "One"); + one.respond_to = Some(RespondTo::Allowlist.as_str().to_string()); + one.respond_to_allowlist = vec!["a".repeat(64)]; + + let content = build_team_catalog_content(&team(), &[one]).unwrap(); + + assert_eq!( + content.members[0].respond_to.as_deref(), + Some(RespondTo::OwnerOnly.as_str()) + ); +} + +#[test] +fn test_non_allowlist_respond_to_modes_are_projected_verbatim() { + for mode in [RespondTo::OwnerOnly, RespondTo::Anyone] { + let mut one = member("m1", "One"); + one.respond_to = Some(mode.as_str().to_string()); + let content = build_team_catalog_content(&team(), &[one]).unwrap(); + assert_eq!( + content.members[0].respond_to.as_deref(), + Some(mode.as_str()) + ); + } +} + +#[test] +fn test_parallelism_is_clamped_into_the_supported_range() { + for (input, expected) in [(0u32, 1u32), (1, 1), (32, 32), (9_999, 32)] { + let mut one = member("m1", "One"); + one.parallelism = Some(input); + let content = build_team_catalog_content(&team(), &[one]).unwrap(); + assert_eq!(content.members[0].parallelism, Some(expected)); + } +} + +#[test] +fn test_members_resolve_in_team_membership_order() { + let personas = vec![member("m2", "Two"), member("m1", "One")]; + + let resolved = resolve_team_members(&team(), &personas).unwrap(); + + let ids: Vec<&str> = resolved.iter().map(|m| m.id.as_str()).collect(); + assert_eq!( + ids, + ["m1", "m2"], + "order is part of the canonical bytes, so it follows the team, not the store" + ); +} + +#[test] +fn test_unresolvable_member_fails_resolution_rather_than_being_skipped() { + let error = resolve_team_members(&team(), &[member("m1", "One")]).unwrap_err(); + + assert!(error.contains("team member m2 not found")); +} + +#[test] +fn test_rebuilding_an_unchanged_team_reproduces_identical_bytes() { + // The freshness reconcile republishes on a byte mismatch. + let members = [member("m1", "One"), member("m2", "Two")]; + let first = build_team_catalog_content(&team(), &members).unwrap(); + let second = build_team_catalog_content(&team(), &members).unwrap(); + + assert_eq!( + team_catalog_content_json(&first), + team_catalog_content_json(&second) + ); +} + +#[test] +fn test_member_order_is_part_of_the_canonical_bytes() { + let forward = [member("m1", "One"), member("m2", "Two")]; + let reversed = [member("m2", "Two"), member("m1", "One")]; + + let a = build_team_catalog_content(&team(), &forward).unwrap(); + let b = build_team_catalog_content(&team(), &reversed).unwrap(); + + assert_ne!(team_catalog_content_json(&a), team_catalog_content_json(&b)); +} + +#[test] +fn test_editing_a_member_definition_changes_the_team_bytes() { + let before = build_team_catalog_content(&team(), &[member("m1", "One")]).unwrap(); + let mut edited = member("m1", "One"); + edited.system_prompt = "Do the work differently.".to_string(); + let after = build_team_catalog_content(&team(), &[edited]).unwrap(); + + assert_ne!( + team_catalog_content_json(&before), + team_catalog_content_json(&after) + ); +} + +/// Real built-in record (avatar cleared — live built-ins ship ~170 KiB inline PNG). +fn builtin_record(id: &str) -> AgentDefinition { + let mut record = crate::managed_agents::built_in_persona_definition(id, "2026-07-30T00:00:00Z") + .unwrap_or_else(|| panic!("'{id}' is not a built-in persona")); + record.avatar_url = None; + record +} + +#[test] +fn test_builtin_member_carries_slug_and_projection_hash() { + let content = build_team_catalog_content(&team(), &[builtin_record("builtin:fizz")]).unwrap(); + let projected = &content.members[0]; + + assert_eq!(projected.builtin_slug.as_deref(), Some("fizz")); + assert!(projected.projection_hash.is_some()); +} + +#[test] +fn test_non_builtin_member_carries_no_reuse_hint() { + let content = build_team_catalog_content(&team(), &[member("m1", "One")]).unwrap(); + + assert_eq!(content.members[0].builtin_slug, None); + assert_eq!(content.members[0].projection_hash, None); +} + +#[test] +fn test_a_record_flagged_builtin_without_the_canonical_id_carries_no_hint() { + // `is_builtin` alone is not the identity: a pack-installed or adopted copy has no cross-install slug. + let mut impostor = member("m1", "One"); + impostor.is_builtin = true; + + let content = build_team_catalog_content(&team(), &[impostor]).unwrap(); + + assert_eq!(content.members[0].builtin_slug, None); + assert_eq!(content.members[0].projection_hash, None); +} + +#[test] +fn test_reuse_hash_changes_when_the_builtin_definition_changes() { + // Same slug, different definition — the recipient must detect it and fall back. + let original = builtin_record("builtin:fizz"); + let mut changed = original.clone(); + changed.system_prompt = "Review differently.".to_string(); + + let a = build_team_catalog_content(&team(), &[original]).unwrap(); + let b = build_team_catalog_content(&team(), &[changed]).unwrap(); + + assert_eq!( + a.members[0].builtin_slug, b.members[0].builtin_slug, + "the slug is unchanged, which is exactly why the hash must differ" + ); + assert_ne!(a.members[0].projection_hash, b.members[0].projection_hash); +} + +#[test] +fn test_reuse_hash_excludes_the_hint_fields_so_a_recipient_can_recompute_it() { + // The recipient hashes its own local copy — no cross-install slug is involved. + let builtin = builtin_record("builtin:fizz"); + let recomputed = local_member_projection_hash(&builtin); + let content = build_team_catalog_content(&team(), &[builtin]).unwrap(); + let projected = &content.members[0]; + assert_eq!( + projected.projection_hash.as_deref(), + Some(recomputed.as_str()) + ); + let mut hint_free = projected.clone(); + hint_free.builtin_slug = None; + hint_free.projection_hash = None; + assert_eq!( + projected.projection_hash.as_deref(), + Some(member_projection_hash(&hint_free).as_str()) + ); +} + +#[test] +fn test_member_count_at_the_limit_is_accepted_and_one_over_is_rejected() { + let at_limit: Vec = (0..MAX_MEMBERS) + .map(|i| member(&format!("m{i}"), &format!("Member {i}"))) + .collect(); + assert!(build_team_catalog_content(&team(), &at_limit).is_ok()); + + let mut over = at_limit; + over.push(member("extra", "Extra")); + let error = build_team_catalog_content(&team(), &over).unwrap_err(); + assert!(error.contains("team too large to share"), "{error}"); + assert!(error.contains("65 members"), "{error}"); +} + +#[test] +fn test_oversized_avatar_on_a_builtin_is_omitted_from_the_projection() { + // Built-in avatars over the cap are silently omitted; recipient gets default. + let mut one = member("m1", "Builtin Avatar Hog"); + one.is_builtin = true; + one.id = "builtin:fizz".to_string(); // gives builtin_catalog_slug() a non-empty slug + one.avatar_url = Some("d".repeat(MAX_AVATAR_URL_BYTES + 1)); + + let content = build_team_catalog_content(&team(), &[one]).unwrap(); + + assert_eq!(content.members.len(), 1); + assert!( + content.members[0].avatar_url.is_none(), + "oversized built-in avatar must be omitted — not rejected — from the projection" + ); +} + +#[test] +fn test_oversized_avatar_on_a_non_builtin_fails_the_size_contract() { + // Non-raster oversized avatar (https URL) produces an error; owner can act on it. + let mut one = member("m1", "Avatar Hog"); + one.avatar_url = Some(format!( + "https://example.com/{}", + "a".repeat(MAX_AVATAR_URL_BYTES) + )); + let error = build_team_catalog_content(&team(), &[one]).unwrap_err(); + assert!( + error.contains("avatar") || error.contains("too large"), + "non-builtin oversized avatar must name the field in the error: {error}" + ); +} + +#[test] +fn test_avatar_exactly_at_the_limit_is_accepted() { + // Safe https:// URL at exactly the 2 048-char cap must be accepted. + let url = format!( + "https://example.com/{}", + "a".repeat(2_048 - "https://example.com/".len()) + ); + let mut one = member("m1", "One"); + one.avatar_url = Some(url); + assert!(build_team_catalog_content(&team(), &[one]).is_ok()); +} + +#[test] +fn test_many_legal_members_still_reject_on_the_total_ceiling() { + // All members individually within bounds, but together exceed the relay ingest ceiling. + let members: Vec = (0..MAX_MEMBERS) + .map(|i| { + let mut one = member(&format!("m{i}"), &format!("Member {i}")); + one.system_prompt = "p".repeat(MAX_SYSTEM_PROMPT_BYTES); + one + }) + .collect(); + + let error = build_team_catalog_content(&team(), &members).unwrap_err(); + + assert!(error.contains("the projection is"), "{error}"); + assert!( + !error.contains("members (limit"), + "the per-field bounds all pass; the total is what rejects: {error}" + ); +} + +#[test] +fn test_the_total_ceiling_stays_under_the_relay_ingest_limit() { + // MAX_EVENT_CONTENT_BYTES = 256 KiB; an accepted projection must fit. + const { assert!(MAX_TOTAL_BYTES < 256 * 1024) }; +} + +#[test] +fn test_oversized_team_text_fields_are_rejected() { + for (label, subject) in [ + ("the team name", { + let mut t = team(); + t.name = "n".repeat(MAX_NAME_BYTES + 1); + t + }), + ("the team description", { + let mut t = team(); + t.description = Some("d".repeat(MAX_TEXT_BYTES + 1)); + t + }), + ("the team instructions", { + let mut t = team(); + t.instructions = Some("i".repeat(MAX_INSTRUCTIONS_BYTES + 1)); + t + }), + ] { + let error = build_team_catalog_content(&subject, &[member("m1", "One")]).unwrap_err(); + assert!(error.contains(label), "expected '{label}' in: {error}"); + } +} + +#[test] +fn test_oversized_name_pool_is_rejected() { + let mut one = member("m1", "Pool Hog"); + one.name_pool = (0..=MAX_NAME_POOL_ENTRIES).map(|i| i.to_string()).collect(); + + let error = build_team_catalog_content(&team(), &[one]).unwrap_err(); + + assert!(error.contains("name-pool entries"), "{error}"); +} + +#[test] +fn test_an_empty_team_projects_successfully() { + let content = build_team_catalog_content(&team(), &[]).unwrap(); + + assert!(content.members.is_empty()); + // `members` is not `skip_serializing_if`, so an empty team is explicit + // rather than indistinguishable from an omitted field. + assert!(team_catalog_content_json(&content) + .unwrap() + .contains("\"members\":[]")); +} + +#[test] +fn test_event_uses_kind_30178_and_the_team_id_as_its_d_tag() { + let event = build_team_catalog_event(&team(), &[member("m1", "One")], false) + .unwrap() + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + + assert_eq!(event.kind.as_u16() as u32, KIND_TEAM_CATALOG); + let d_tags: Vec<&str> = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("d")).then(|| parts[1].as_str()) + }) + .collect(); + // The relay rejects anything but exactly one bounded `d` tag. + assert_eq!(d_tags, vec!["team-abc"]); +} + +#[test] +fn test_shared_tag_is_present_only_when_sharing() { + for shared in [true, false] { + let event = build_team_catalog_event(&team(), &[member("m1", "One")], shared) + .unwrap() + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + + assert_eq!( + buzz_core_pkg::kind::event_is_shared(&event), + shared, + "the relay read gate keys off this tag" + ); + } +} + +#[test] +fn test_oversized_team_fails_before_an_event_is_ever_built() { + // Pre-enqueue: no signed event exists to be durably queued. Uses total-size violation. + let members: Vec = (0..MAX_MEMBERS) + .map(|i| { + let mut one = member(&format!("m{i}"), &format!("Member {i}")); + one.system_prompt = "p".repeat(MAX_SYSTEM_PROMPT_BYTES); + one + }) + .collect(); + + assert!(build_team_catalog_event(&team(), &members, true).is_err()); +} + +fn signed_event_with_content(content: &str) -> nostr::Event { + EventBuilder::new(Kind::Custom(KIND_TEAM_CATALOG as u16), content) + .tags(vec![Tag::parse(["d", "team-abc"]).unwrap()]) + .sign_with_keys(&nostr::Keys::generate()) + .unwrap() +} + +#[test] +fn test_content_round_trips_through_an_event() { + let members = [member("m1", "One"), member("m2", "Two")]; + let built = build_team_catalog_content(&team(), &members).unwrap(); + let event = build_team_catalog_event(&team(), &members, true) + .unwrap() + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + + assert_eq!(team_catalog_content_from_event(&event).unwrap(), built); +} + +#[test] +fn test_unknown_schema_version_is_rejected() { + let event = signed_event_with_content(r#"{"v":2,"name":"Future Team","members":[]}"#); + + let error = team_catalog_content_from_event(&event).unwrap_err(); + + assert!( + error.contains("unsupported team catalog schema version 2"), + "{error}" + ); +} + +#[test] +fn test_body_missing_the_version_is_rejected() { + // `v` has no serde default — body without it cannot masquerade as v1. + let event = signed_event_with_content(r#"{"name":"No Version","members":[]}"#); + + assert!(team_catalog_content_from_event(&event).is_err()); +} + +#[test] +fn test_malformed_member_fields_are_rejected() { + // Wrong-typed field must fail parsing, not silently coerce. + let event = signed_event_with_content( + r#"{"v":1,"name":"Bad","members":[{"member_key":"m1","display_name":"One","parallelism":"lots"}]}"#, + ); + + assert!(team_catalog_content_from_event(&event).is_err()); +} + +#[test] +fn test_inbound_body_over_the_size_contract_is_rejected_on_read() { + // Readers enforce the same bounds as writers. + let members: String = (0..=MAX_MEMBERS) + .map(|i| format!(r#"{{"member_key":"m{i}","display_name":"M{i}"}}"#)) + .collect::>() + .join(","); + let event = signed_event_with_content(&format!( + r#"{{"v":1,"name":"Too Many","members":[{members}]}}"# + )); + + let error = team_catalog_content_from_event(&event).unwrap_err(); + + assert!(error.contains("team too large to share"), "{error}"); +} + +#[test] +fn test_member_key_is_stable_for_an_unchanged_member() { + let one = member("m1", "One"); + let a = build_team_catalog_content(&team(), std::slice::from_ref(&one)).unwrap(); + let b = build_team_catalog_content(&team(), &[one]).unwrap(); + + assert_eq!(a.members[0].member_key, b.members[0].member_key); + assert!(!a.members[0].member_key.is_empty()); +} + +#[test] +fn test_member_key_follows_the_member_across_a_reorder() { + // A position-derived key would re-point every copy after any membership reorder. + let forward = + build_team_catalog_content(&team(), &[member("m1", "One"), member("m2", "Two")]).unwrap(); + let reversed = + build_team_catalog_content(&team(), &[member("m2", "Two"), member("m1", "One")]).unwrap(); + + assert_eq!( + forward.members[0].member_key, + reversed.members[1].member_key + ); + assert_eq!( + forward.members[1].member_key, + reversed.members[0].member_key + ); +} + +#[test] +fn test_two_members_with_identical_content_still_get_distinct_keys() { + let mut twin = member("m2", "One"); + twin.system_prompt = member("m1", "One").system_prompt.clone(); + + let content = build_team_catalog_content(&team(), &[member("m1", "One"), twin]).unwrap(); + + assert_ne!(content.members[0].member_key, content.members[1].member_key); +} + +#[test] +fn test_ids_that_persona_d_tag_would_collapse_get_distinct_keys() { + use crate::managed_agents::persona_events::persona_d_tag; + + // Each pair has the same d-tag but must get distinct member keys. + let long = "x".repeat(64); + for (left, right) in [ + ("Reviewer".to_string(), "reviewer".to_string()), + ("a b".to_string(), "a.b".to_string()), + (format!("{long}1"), format!("{long}2")), + ] { + let (one, two) = (member(&left, "One"), member(&right, "Two")); + assert_eq!( + persona_d_tag(&one), + persona_d_tag(&two), + "fixture must actually collide under the d-tag normalizer" + ); + + let content = build_team_catalog_content(&team(), &[one, two]).unwrap(); + + assert_ne!( + content.members[0].member_key, content.members[1].member_key, + "'{left}' and '{right}' must not share a published identity" + ); + } +} + +#[test] +fn test_member_key_does_not_disclose_the_local_id() { + let content = build_team_catalog_content(&team(), &[member("secret-local-id", "One")]).unwrap(); + + assert!(!team_catalog_content_json(&content) + .unwrap() + .contains("secret-local-id")); + assert_eq!( + content.members[0].member_key.len(), + PROJECTION_HASH_HEX_LEN, + "a SHA-256 hex digest" + ); +} + +#[test] +fn test_a_body_repeating_a_member_key_is_rejected_on_read() { + // Two members on one key collapse onto a single local persona, silently dropping one. + let event = signed_event_with_content( + r#"{"v":1,"name":"Twins","members":[ + {"member_key":"k","display_name":"One"}, + {"member_key":"k","display_name":"Two"} + ]}"#, + ); + + let error = team_catalog_content_from_event(&event).unwrap_err(); + + assert!(error.contains("repeats the member key"), "{error}"); + assert!( + error.contains("Two"), + "the error names the offender: {error}" + ); +} + +/// A body carrying one member built from `fields`, as JSON. +fn body_with_member(fields: &str) -> nostr::Event { + signed_event_with_content(&format!( + r#"{{"v":1,"name":"T","members":[{{"member_key":"k","display_name":"One",{fields}}}]}}"# + )) +} + +#[test] +fn test_members_violating_the_v1_contract_are_rejected_on_read() { + for (label, fields) in [ + ( + "out-of-range parallelism", + r#""parallelism":999"#.to_string(), + ), + ("zero parallelism", r#""parallelism":0"#.to_string()), + ( + "unknown respond_to mode", + r#""respond_to":"everyone""#.to_string(), + ), + ("empty runtime", r#""runtime":"""#.to_string()), + ( + "oversize model", + format!(r#""model":"{}""#, "m".repeat(MAX_IDENTIFIER_BYTES + 1)), + ), + ("empty name-pool entry", r#""name_pool":[""]"#.to_string()), + ( + "reuse slug with no hash", + r#""builtin_slug":"reviewer""#.to_string(), + ), + ( + "reuse hash with no slug", + format!(r#""projection_hash":"{}""#, "a".repeat(64)), + ), + ( + "malformed reuse hash", + r#""builtin_slug":"reviewer","projection_hash":"nope""#.to_string(), + ), + ( + "non-hex reuse hash", + format!( + r#""builtin_slug":"reviewer","projection_hash":"{}""#, + "z".repeat(64) + ), + ), + ] { + assert!( + team_catalog_content_from_event(&body_with_member(&fields)).is_err(), + "{label} must be refused at the parse boundary" + ); + } +} + +#[test] +fn test_members_at_the_edges_of_the_v1_contract_are_accepted() { + for (label, fields) in [ + ("minimum parallelism", r#""parallelism":1"#.to_string()), + ("maximum parallelism", r#""parallelism":32"#.to_string()), + ( + "well-formed reuse hint", + format!( + r#""builtin_slug":"reviewer","projection_hash":"{}""#, + "A".repeat(64) + ), + ), + ( + "identifier at the limit", + format!(r#""model":"{}""#, "m".repeat(MAX_IDENTIFIER_BYTES)), + ), + ] { + assert!( + team_catalog_content_from_event(&body_with_member(&fields)).is_ok(), + "{label} is within the contract and must be accepted" + ); + } +} + +#[test] +fn test_a_member_with_an_empty_key_or_name_is_rejected_on_read() { + for members in [ + r#"{"member_key":"","display_name":"One"}"#, + r#"{"member_key":"k","display_name":" "}"#, + ] { + let event = + signed_event_with_content(&format!(r#"{{"v":1,"name":"T","members":[{members}]}}"#)); + assert!( + team_catalog_content_from_event(&event).is_err(), + "{members}" + ); + } +} + +#[test] +fn test_an_oversize_member_key_is_rejected_on_read() { + let event = signed_event_with_content(&format!( + r#"{{"v":1,"name":"T","members":[{{"member_key":"{}","display_name":"One"}}]}}"#, + "k".repeat(MAX_MEMBER_KEY_BYTES + 1) + )); + + assert!(team_catalog_content_from_event(&event).is_err()); +} + +#[test] +fn test_catalog_delete_targets_the_30178_coordinate_with_no_e_tag() { + const OWNER: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + let event = build_team_catalog_delete("team-abc", OWNER) + .unwrap() + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + + assert_eq!(event.kind, Kind::Custom(5)); + let a_tags: Vec<&[String]> = event + .tags + .iter() + .map(|tag| tag.as_slice()) + .filter(|parts| parts.first().map(String::as_str) == Some("a")) + .collect(); + assert_eq!(a_tags.len(), 1); + assert_eq!( + a_tags[0][1], + format!("{KIND_TEAM_CATALOG}:{OWNER}:team-abc") + ); + // An e-tag would leave the replaceable coordinate live. + assert!(event + .tags + .iter() + .all(|tag| tag.as_slice().first().map(String::as_str) != Some("e"))); +} + +macro_rules! fixture { + ($name:literal) => { + include_str!(concat!( + "../../../tests/fixtures/team_catalog_content/", + $name + )) + }; +} + +/// Run the parser on each named fixture; `$expect_ok` determines pass/fail. +macro_rules! run_fixture_table { + ($fn_name:ident, $expect_ok:expr, $( ($name:literal, $file:literal $(, $note:literal)?) ),+ $(,)?) => { + #[test] + fn $fn_name() { + for (name, body) in [$( ($name, fixture!($file)) ),+] { + let event = signed_event_with_content(body.trim()); + if $expect_ok { + assert!( + team_catalog_content_from_event(&event).is_ok(), + "{name}.json must be accepted" + ); + } else { + assert!( + team_catalog_content_from_event(&event).is_err(), + "{name}.json must be rejected" + ); + } + } + } + }; +} + +run_fixture_table!( + test_fixtures_that_must_be_accepted_are_accepted, + true, + ("valid_minimal", "valid_minimal.json"), + ( + "valid_respond_to_owner_only", + "valid_respond_to_owner_only.json" + ), + ( + "valid_respond_to_allowlist", + "valid_respond_to_allowlist.json" + ), + ("valid_respond_to_anyone", "valid_respond_to_anyone.json"), + ("valid_uppercase_hash", "valid_uppercase_hash.json"), + ("valid_avatar_url_https", "valid_avatar_url_https.json"), + ( + "valid_avatar_url_uppercase_scheme", + "valid_avatar_url_uppercase_scheme.json" + ), + ( + "valid_avatar_url_non_ascii_at_utf8_limit", + "valid_avatar_url_non_ascii_at_utf8_limit.json" + ), + ( + "valid_avatar_url_shorthand_scheme", + "valid_avatar_url_shorthand_scheme.json" + ), + ( + "valid_avatar_url_unicode_nel", + "valid_avatar_url_unicode_nel.json" + ), +); + +run_fixture_table!( + test_fixtures_that_must_be_rejected_are_rejected, + false, + ( + "invalid_respond_to_pascal_case", + "invalid_respond_to_pascal_case.json" + ), + ( + "invalid_description_wrong_type", + "invalid_description_wrong_type.json" + ), + ( + "invalid_instructions_wrong_type", + "invalid_instructions_wrong_type.json" + ), + ( + "invalid_duplicate_member_key", + "invalid_duplicate_member_key.json" + ), + ( + "invalid_name_pool_not_array", + "invalid_name_pool_not_array.json" + ), + ("invalid_name_pool_null", "invalid_name_pool_null.json"), + ( + "invalid_builtin_slug_wrong_type", + "invalid_builtin_slug_wrong_type.json" + ), + ( + "invalid_avatar_url_javascript", + "invalid_avatar_url_javascript.json" + ), + ("invalid_team_name_blank", "invalid_team_name_blank.json"), + ( + "invalid_avatar_url_bare_https", + "invalid_avatar_url_bare_https.json" + ), + ( + "invalid_avatar_url_whitespace_in_url", + "invalid_avatar_url_whitespace_in_url.json" + ), + ( + "invalid_avatar_url_https_over_2048", + "invalid_avatar_url_https_over_2048.json" + ), + ( + "invalid_avatar_url_malformed_port", + "invalid_avatar_url_malformed_port.json" + ), + ( + "invalid_avatar_url_non_ascii_over_utf8_limit", + "invalid_avatar_url_non_ascii_over_utf8_limit.json" + ), + ( + "invalid_avatar_url_unicode_nbsp", + "invalid_avatar_url_unicode_nbsp.json" + ), + ( + "invalid_avatar_url_unicode_em_space", + "invalid_avatar_url_unicode_em_space.json" + ), + ( + "invalid_avatar_url_unicode_bom", + "invalid_avatar_url_unicode_bom.json" + ), +); + +#[test] +fn test_real_builtin_without_avatar_mutation_projects_successfully() { + // A real built-in (fizz) has a ~170 KiB oversized avatar that is stripped in member_projection. + let builtin = + crate::managed_agents::built_in_persona_definition("builtin:fizz", "2026-07-30T00:00:00Z") + .expect("builtin:fizz must exist"); + let has_large_avatar = builtin + .avatar_url + .as_deref() + .is_some_and(|url| url.len() > MAX_AVATAR_URL_BYTES); + let mut t = team(); + t.instructions = None; + let content = build_team_catalog_content(&t, &[builtin]).expect( + "a team containing a real built-in must project successfully without avatar mutation", + ); + assert_eq!(content.members.len(), 1); + if has_large_avatar { + assert!( + content.members[0].avatar_url.is_none(), + "oversized built-in avatar must be omitted, not rejected" + ); + } + assert!( + validate_team_catalog_content(&content).is_ok(), + "projected content must pass full validation" + ); +} + +#[test] +fn test_tombstone_transaction_rolls_back_delete_when_insert_fails() { + // Use a BEFORE INSERT trigger to force the INSERT step to fail; verify DELETE is rolled back. + use crate::managed_agents::retention::{ + get_retained_event, open_retention_db, retain_event, scoped_retention_db_path, + RetainedEvent, + }; + use buzz_core_pkg::kind::KIND_TEAM_CATALOG; + use nostr::JsonUtil; + + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let db_path = scoped_retention_db_path(dir.path(), "wss://a.example", &owner); + std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + + let t = team(); + let m = member("m1", "Sentinel."); + let head_event = build_team_catalog_event(&t, &[m], true) + .unwrap() + .sign_with_keys(&keys) + .unwrap(); + let conn = open_retention_db(&db_path).unwrap(); + retain_event( + &conn, + &RetainedEvent { + kind: KIND_TEAM_CATALOG, + pubkey: owner.clone(), + d_tag: "team-abc".to_string(), + content: head_event.content.to_string(), + created_at: head_event.created_at.as_secs() as i64, + raw_event: head_event.as_json(), + pending_sync: false, + }, + ) + .unwrap(); + + conn.execute_batch( + "CREATE TRIGGER block_all_inserts BEFORE INSERT ON persona_events + BEGIN + SELECT RAISE(ABORT, 'insert blocked by test trigger'); + END;", + ) + .unwrap(); + drop(conn); + + let result = tombstone_team_catalog_coordinate(&db_path, &keys, "team-abc"); + assert!(result.is_err(), "tombstone with INSERT trigger must fail"); + let err = result.unwrap_err(); + let blocked = err.contains("insert blocked by test trigger") || err.contains("blocked"); + assert!(blocked, "error must name the trigger cause; got: {err}"); + + let conn = open_retention_db(&db_path).unwrap(); + let head = get_retained_event(&conn, KIND_TEAM_CATALOG, &owner, "team-abc").unwrap(); + assert!(head.is_some()); +} + +#[test] +fn test_oversized_inline_raster_avatar_on_non_builtin_is_downscaled() { + // 300×300 gradient PNG data URL exceeds MAX_AVATAR_URL_BYTES. + let img = image::RgbaImage::from_fn(300, 300, |x, y| { + image::Rgba([(x % 256) as u8, (y % 256) as u8, ((x + y) % 256) as u8, 255]) + }); + let mut raw = Vec::new(); + let mut cursor = std::io::Cursor::new(&mut raw); + img.write_to(&mut cursor, image::ImageFormat::Png).unwrap(); + let url = format!("data:image/png;base64,{}", STANDARD.encode(&raw)); + assert!(url.len() > MAX_AVATAR_URL_BYTES); + let mut one = member("m1", "Avatar Hog"); + one.avatar_url = Some(url); + let content = build_team_catalog_content(&team(), &[one]).unwrap(); + let pav = content.members[0].avatar_url.as_deref().unwrap(); + assert!(pav.len() <= MAX_AVATAR_URL_BYTES && is_safe_catalog_avatar_url(pav)); +} + +#[test] +fn test_undecodable_oversized_data_url_falls_through_to_validation_error() { + let cap = MAX_AVATAR_URL_BYTES; + let url = format!("data:image/png;base64,{}", "!!!".repeat(cap / 3 + 1)); + let mut one = member("m1", "Bad Avatar"); + one.avatar_url = Some(url); + let error = build_team_catalog_content(&team(), &[one]).unwrap_err(); + assert!(error.contains("avatar") || error.contains("too large")); +} + +#[test] +fn test_extreme_dimension_avatar_falls_through_to_validation_error() { + // 2100×2100 PNG exceeds the 2048px decode ceiling; bounded decoder rejects it before pixel allocation. + let img = image::RgbaImage::from_fn(2100, 2100, |x, y| { + image::Rgba([(x % 256) as u8, (y % 256) as u8, 128, 255]) + }); + let mut raw = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut raw), image::ImageFormat::Png) + .unwrap(); + let url = format!("data:image/png;base64,{}", STANDARD.encode(&raw)); + assert!( + url.len() > MAX_AVATAR_URL_BYTES, + "fixture must be oversized" + ); + let mut one = member("m1", "Bomb"); + one.avatar_url = Some(url); + let error = build_team_catalog_content(&team(), &[one]).unwrap_err(); + assert!( + error.contains("avatar") || error.contains("too large"), + "{error}" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/team_events.rs b/desktop/src-tauri/src/managed_agents/team_events.rs index 64861c0dec..faf1e8fdea 100644 --- a/desktop/src-tauri/src/managed_agents/team_events.rs +++ b/desktop/src-tauri/src/managed_agents/team_events.rs @@ -112,6 +112,8 @@ mod tests { instructions: Some("Coordinate carefully.".to_string()), persona_ids: vec!["p1".to_string(), "p2".to_string()], is_builtin: false, + shared: false, + catalog_source: None, source_dir: Some(PathBuf::from("/local/only/path")), is_symlink: true, symlink_target: Some("/somewhere".to_string()), diff --git a/desktop/src-tauri/src/managed_agents/team_repair.rs b/desktop/src-tauri/src/managed_agents/team_repair.rs index 6420792109..fe8e0d111a 100644 --- a/desktop/src-tauri/src/managed_agents/team_repair.rs +++ b/desktop/src-tauri/src/managed_agents/team_repair.rs @@ -30,6 +30,8 @@ mod tests { instructions: None, persona_ids: Vec::new(), is_builtin: false, + shared: false, + catalog_source: None, source_dir: None, is_symlink: false, symlink_target: None, diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index 96082acc76..329842dc77 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -240,6 +240,8 @@ mod tests { instructions: None, persona_ids: vec![], is_builtin: false, + shared: false, + catalog_source: None, source_dir: None, is_symlink: false, symlink_target: None, @@ -306,6 +308,7 @@ mod tests { source_team_persona_slug: Some("SENTINEL_SLUG".to_string()), // MUST NOT appear definition_respond_to: None, catalog_source: None, + team_catalog_source: None, definition_respond_to_allowlist: vec![], definition_parallelism: None, relay_mesh: None, diff --git a/desktop/src-tauri/src/managed_agents/teams.rs b/desktop/src-tauri/src/managed_agents/teams.rs index 937893d531..6ca2d813ef 100644 --- a/desktop/src-tauri/src/managed_agents/teams.rs +++ b/desktop/src-tauri/src/managed_agents/teams.rs @@ -59,6 +59,9 @@ fn built_in_team_records(built_ins: &[BuiltInTeam], now: &str) -> Vec( /// enqueue NIP-09 tombstones for them — without this, the team coordinate is /// tombstoned but the orphaned kind:30175 persona heads stay live on the relay. /// For JSON-only teams (no `source_dir`), nothing cascades and the returned -/// vec is empty. +/// vec is empty. For catalog-adopted teams (`catalog_source` present), member +/// copies whose provenance matches this publication are deactivated rather than +/// deleted — they are re-activatable if the same team is re-added from the +/// catalog. pub fn delete_team_with_cascade(app: &AppHandle, team_id: &str) -> Result, String> { let mut teams = load_teams(app)?; let team = teams @@ -291,14 +297,194 @@ pub fn delete_team_with_cascade(app: &AppHandle, team_id: &str) -> Result = teams.iter().filter(|t| t.id != team_id).collect(); + + let changed = deactivate_catalog_member_copies_with_ref_check( + &mut personas, + &catalog_source.owner_pubkey, + &catalog_source.team_d_tag, + &remaining_teams, + &managed_agents, + ); + + // Remove the team record from the working slice; save both atomically. + teams.retain(|record| record.id != team_id); + + let personas_path = super::managed_agents_store_path(app)?; + let teams_path = teams_store_path(app)?; + let personas_to_write = personas.clone(); + let teams_to_write = teams.clone(); + + // Byte-snapshot both stores before writing, so a save failure rolls + // back both (same policy as catalog adoption — I6). Reuse the same + // commit primitive so rollback behaviour is identical in both paths. + let personas_snap = crate::managed_agents::storage::snapshot_store(&personas_path)?; + let teams_snap = crate::managed_agents::storage::snapshot_store(&teams_path)?; + + crate::managed_agents::storage::commit_stores_with_snapshots( + &personas_path, + &teams_path, + personas_snap, + teams_snap, + || { + if changed { + super::save_personas(app, &personas_to_write)?; + } + Ok(()) + }, + || save_teams(app, &teams_to_write), + )?; + + return Ok(cascaded_persona_d_tags); } - // 4. Remove TeamRecord + // Remove TeamRecord teams.retain(|record| record.id != team_id); save_teams(app, &teams)?; Ok(cascaded_persona_d_tags) } +/// Deactivate non-built-in personas whose provenance matches +/// `(owner_pubkey, team_d_tag)` AND that are not referenced by any remaining +/// team's `persona_ids` or any managed agent's `persona_id`. +/// +/// A persona copy is "referenced" when it appears in a remaining team's +/// `persona_ids`, or when a managed agent was created from it +/// (`ManagedAgentRecord.persona_id == Some(copy.id)`). The agent case is +/// critical: deleting a catalog team must not archive a copy that a standalone +/// managed agent depends on — doing so leaves the agent pointing at a hidden +/// inactive definition. Returns `true` when any record was changed. +pub(crate) fn deactivate_catalog_member_copies_with_ref_check( + personas: &mut [super::AgentDefinition], + owner_pubkey: &str, + team_d_tag: &str, + remaining_teams: &[&super::TeamRecord], + managed_agents: &[super::ManagedAgentRecord], +) -> bool { + let mut changed = false; + for persona in personas.iter_mut() { + if persona.is_builtin { + continue; + } + let is_copy = persona + .team_catalog_source + .as_ref() + .is_some_and(|s| s.owner_pubkey == owner_pubkey && s.team_d_tag == team_d_tag); + if !is_copy || !persona.is_active { + continue; + } + // Skip copies still referenced by another remaining team. + let still_in_team = remaining_teams + .iter() + .any(|t| t.persona_ids.iter().any(|id| id == &persona.id)); + // Skip copies that a standalone managed agent was created from. + let still_in_agent = managed_agents + .iter() + .any(|a| a.persona_id.as_deref() == Some(persona.id.as_str())); + if still_in_team || still_in_agent { + continue; + } + persona.is_active = false; + changed = true; + } + changed +} + #[cfg(test)] #[path = "teams_tests.rs"] mod tests; + +/// Test-only seam for [`delete_team_with_cascade`] that takes explicit file +/// paths instead of an `AppHandle`. Mirrors the catalog-adopted deletion path +/// (the only path that uses the byte-rollback boundary) without requiring a +/// full Tauri runtime. +/// +/// Only the catalog-adopted path is covered by this seam because that is the +/// path with the byte-rollback boundary. Directory-backed team deletion +/// requires filesystem operations that are best left to integration tests. +#[cfg(test)] +pub(crate) fn delete_catalog_team_at( + personas_path: &std::path::Path, + teams_path: &std::path::Path, + team_id: &str, +) -> Result<(), String> { + // Read raw JSON without the merge-in-built-ins side effect so the test + // stores reflect exactly what delete_team_with_cascade writes (which also + // reads via load_teams, not load_teams_readonly, and never writes back + // built-ins in the middle of a delete). + let personas: Vec = if personas_path.exists() { + let json = std::fs::read_to_string(personas_path) + .map_err(|e| format!("failed to read personas: {e}"))?; + serde_json::from_str(&json).map_err(|e| format!("failed to parse personas: {e}"))? + } else { + Vec::new() + }; + let teams: Vec = if teams_path.exists() { + let json = std::fs::read_to_string(teams_path) + .map_err(|e| format!("failed to read teams: {e}"))?; + serde_json::from_str(&json).map_err(|e| format!("failed to parse teams: {e}"))? + } else { + Vec::new() + }; + + let team = teams + .iter() + .find(|t| t.id == team_id) + .ok_or_else(|| format!("team {team_id} not found"))?; + + let catalog_source = team + .catalog_source + .as_ref() + .ok_or_else(|| "delete_catalog_team_at only handles catalog-adopted teams".to_string())? + .clone(); + + let mut personas_mut = personas; + let remaining_teams: Vec<&TeamRecord> = teams.iter().filter(|t| t.id != team_id).collect(); + + // No managed agents in the test seam — pass an empty slice. Test coverage + // for the agent-reference preservation path lives in teams_tests.rs. + deactivate_catalog_member_copies_with_ref_check( + &mut personas_mut, + &catalog_source.owner_pubkey, + &catalog_source.team_d_tag, + &remaining_teams, + &[], + ); + + let new_teams: Vec = teams.into_iter().filter(|t| t.id != team_id).collect(); + + let personas_snap = super::storage::snapshot_store(personas_path)?; + let teams_snap = super::storage::snapshot_store(teams_path)?; + + super::storage::commit_stores_with_snapshots( + personas_path, + teams_path, + personas_snap, + teams_snap, + || { + let json = serde_json::to_vec_pretty(&personas_mut) + .map_err(|e| format!("failed to serialize personas: {e}"))?; + super::storage::atomic_write_json(personas_path, &json) + }, + || { + let mut sorted = new_teams.clone(); + sort_teams(&mut sorted); + let json = serde_json::to_vec_pretty(&sorted) + .map_err(|e| format!("failed to serialize teams: {e}"))?; + super::storage::atomic_write_json(teams_path, &json) + }, + )?; + + Ok(()) +} diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 1ffa60eda9..7e4f78a18c 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -4,10 +4,12 @@ //! `#[path]`-included from there. use super::{ - agents_referencing_team, load_teams_readonly, merge_teams, merge_teams_impl, sort_teams, - validate_team_deletion, BuiltInTeam, + agents_referencing_team, deactivate_catalog_member_copies_with_ref_check, load_teams_readonly, + merge_teams, merge_teams_impl, sort_teams, validate_team_deletion, BuiltInTeam, +}; +use crate::managed_agents::{ + AgentDefinition, ManagedAgentRecord, TeamMemberCatalogSource, TeamRecord, }; -use crate::managed_agents::{ManagedAgentRecord, TeamRecord}; fn team(id: &str, name: &str) -> TeamRecord { TeamRecord { @@ -17,6 +19,8 @@ fn team(id: &str, name: &str) -> TeamRecord { instructions: None, persona_ids: Vec::new(), is_builtin: false, + shared: false, + catalog_source: None, source_dir: None, is_symlink: false, symlink_target: None, @@ -212,6 +216,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, relay_mesh: None, definition_respond_to: None, definition_respond_to_allowlist: vec![], @@ -274,6 +279,8 @@ fn migration_pristine_fizz_is_purged() { instructions: None, persona_ids: vec!["builtin:fizz".to_string()], is_builtin: true, + shared: false, + catalog_source: None, source_dir: None, is_symlink: false, symlink_target: None, @@ -299,6 +306,8 @@ fn migration_customized_fizz_is_demoted_to_user_team() { instructions: None, persona_ids: vec!["builtin:fizz".to_string(), "extra:persona".to_string()], is_builtin: true, + shared: false, + catalog_source: None, source_dir: None, is_symlink: false, symlink_target: None, @@ -434,3 +443,419 @@ fn load_teams_readonly_surfaces_read_error() { "read error must be surfaced" ); } + +// ── deactivate_catalog_member_copies_with_ref_check ────────────────────────── + +const OWNER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const D_TAG: &str = "my-team"; + +fn catalog_copy(id: &str, owner: &str, d_tag: &str) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: id.to_string(), + avatar_url: None, + system_prompt: String::new(), + runtime: None, + model: None, + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + team_catalog_source: Some(TeamMemberCatalogSource { + owner_pubkey: owner.to_string(), + team_d_tag: d_tag.to_string(), + member_key: id.to_string(), + projection_hash: "hash".to_string(), + }), + env_vars: Default::default(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + } +} + +fn builtin_copy(id: &str) -> AgentDefinition { + let mut p = catalog_copy(id, OWNER, D_TAG); + p.is_builtin = true; + p +} + +#[test] +fn test_deactivate_catalog_member_copies_deactivates_matching_copies() { + let mut personas = vec![ + catalog_copy("m1", OWNER, D_TAG), + catalog_copy("m2", OWNER, D_TAG), + ]; + let changed = + deactivate_catalog_member_copies_with_ref_check(&mut personas, OWNER, D_TAG, &[], &[]); + assert!(changed); + assert!(!personas[0].is_active, "m1 should be deactivated"); + assert!(!personas[1].is_active, "m2 should be deactivated"); +} + +#[test] +fn test_deactivate_catalog_member_copies_skips_different_owner() { + let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let mut personas = vec![catalog_copy("m1", other, D_TAG)]; + let changed = + deactivate_catalog_member_copies_with_ref_check(&mut personas, OWNER, D_TAG, &[], &[]); + assert!(!changed, "different owner must not be deactivated"); + assert!(personas[0].is_active); +} + +#[test] +fn test_deactivate_catalog_member_copies_skips_different_d_tag() { + let mut personas = vec![catalog_copy("m1", OWNER, "other-team")]; + let changed = + deactivate_catalog_member_copies_with_ref_check(&mut personas, OWNER, D_TAG, &[], &[]); + assert!(!changed, "different d-tag must not be deactivated"); + assert!(personas[0].is_active); +} + +#[test] +fn test_deactivate_catalog_member_copies_skips_builtins() { + // Built-in substitutions are local records, not copies — deleting the team + // must never deactivate them. + let mut personas = vec![builtin_copy("builtin:fizz")]; + let changed = + deactivate_catalog_member_copies_with_ref_check(&mut personas, OWNER, D_TAG, &[], &[]); + assert!(!changed, "built-in should not be deactivated"); + assert!(personas[0].is_active); +} + +#[test] +fn test_deactivate_catalog_member_copies_skips_already_inactive() { + let mut personas = vec![{ + let mut p = catalog_copy("m1", OWNER, D_TAG); + p.is_active = false; + p + }]; + let changed = + deactivate_catalog_member_copies_with_ref_check(&mut personas, OWNER, D_TAG, &[], &[]); + assert!( + !changed, + "already-inactive record should not count as a change" + ); +} + +#[test] +fn test_deactivate_catalog_member_copies_is_scoped_per_publication() { + // A copy belonging to a DIFFERENT team by the same publisher must not be + // deactivated — it belongs to a separate adoption. + let mut personas = vec![ + catalog_copy("m1", OWNER, D_TAG), + catalog_copy("m2", OWNER, "other-team"), + ]; + deactivate_catalog_member_copies_with_ref_check(&mut personas, OWNER, D_TAG, &[], &[]); + assert!( + !personas[0].is_active, + "m1 (matching) should be deactivated" + ); + assert!( + personas[1].is_active, + "m2 (different d-tag) should remain active" + ); +} + +// ── ref-check-specific behaviour ───────────────────────────────────────────── + +#[test] +fn test_ref_check_preserves_copy_still_referenced_by_another_team() { + // m1 is in both D_TAG (being deleted) and "team-two" (remaining). + // Only D_TAG is being deleted, so m1 must stay active because team-two + // still needs it. + let mut personas = vec![catalog_copy("m1", OWNER, D_TAG)]; + let remaining = team("team-two", "Team Two"); + let remaining_with_m1: TeamRecord = TeamRecord { + persona_ids: vec!["m1".to_string()], + ..remaining + }; + let remaining_teams: Vec<&TeamRecord> = vec![&remaining_with_m1]; + + let changed = deactivate_catalog_member_copies_with_ref_check( + &mut personas, + OWNER, + D_TAG, + &remaining_teams, + &[], // no managed agents in this test + ); + + assert!(!changed, "a referenced copy must not be deactivated"); + assert!( + personas[0].is_active, + "m1 is still referenced by team-two and must stay active" + ); +} + +#[test] +fn test_ref_check_deactivates_copy_not_referenced_by_any_remaining_team() { + // m1 is in D_TAG (being deleted) but not in any remaining team. + let mut personas = vec![catalog_copy("m1", OWNER, D_TAG)]; + let unrelated_remaining = team("team-two", "Team Two"); + // team-two's persona_ids is empty, so m1 is not referenced. + let remaining_teams: Vec<&TeamRecord> = vec![&unrelated_remaining]; + + let changed = deactivate_catalog_member_copies_with_ref_check( + &mut personas, + OWNER, + D_TAG, + &remaining_teams, + &[], // no managed agents in this test + ); + + assert!(changed, "unreferenced copy must be deactivated"); + assert!(!personas[0].is_active); +} + +#[test] +fn test_ref_check_deactivates_one_but_preserves_another_in_same_call() { + // m1 is referenced by a remaining team; m2 is not. The function must + // deactivate m2 but leave m1 active in a single call. + let mut personas = vec![ + catalog_copy("m1", OWNER, D_TAG), + catalog_copy("m2", OWNER, D_TAG), + ]; + let remaining_with_m1: TeamRecord = TeamRecord { + persona_ids: vec!["m1".to_string()], + ..team("team-two", "Team Two") + }; + let remaining_teams: Vec<&TeamRecord> = vec![&remaining_with_m1]; + + let changed = deactivate_catalog_member_copies_with_ref_check( + &mut personas, + OWNER, + D_TAG, + &remaining_teams, + &[], // no managed agents in this test + ); + + assert!(changed, "at least one copy was deactivated"); + assert!(personas[0].is_active, "m1 is referenced — must stay active"); + assert!( + !personas[1].is_active, + "m2 is unreferenced — must be deactivated" + ); +} + +#[test] +fn test_ref_check_preserves_copy_used_by_a_standalone_managed_agent() { + // Thufir finding 1: adopt a catalog team, build a standalone managed agent + // from one of its personas (persona_id = copy.id, no team_id), then delete + // the catalog team. The persona copy must NOT be archived because the agent + // still depends on it. + // + // Policy: preserve-not-block — deletion of the team succeeds, but copies + // linked to a live agent stay active so the agent keeps working. + let m1_id = "m1"; + let m2_id = "m2"; + let mut personas = vec![ + catalog_copy(m1_id, OWNER, D_TAG), + catalog_copy(m2_id, OWNER, D_TAG), + ]; + + // A standalone managed agent whose persona_id points at the m1 copy. + let mut agent = managed_agent("my-agent"); + agent.persona_id = Some(m1_id.to_string()); + + let changed = deactivate_catalog_member_copies_with_ref_check( + &mut personas, + OWNER, + D_TAG, + &[], // no remaining teams reference either copy + std::slice::from_ref(&agent), + ); + + assert!(changed, "m2 (unreferenced) must be deactivated"); + assert!( + personas[0].is_active, + "m1 is used by a managed agent and must stay active" + ); + assert!( + !personas[1].is_active, + "m2 is not used by any agent and must be deactivated" + ); +} + +// ── delete_catalog_team_at: production-path delete/persist/reload/re-add ── +// +// Tests that exercise the catalog-adopted team deletion path through the +// `delete_catalog_team_at` seam (which mirrors `delete_team_with_cascade`'s +// catalog branch without needing a Tauri AppHandle). + +fn catalog_persona(id: &str, owner: &str, d_tag: &str) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: id.to_string(), + avatar_url: None, + system_prompt: "Do the work.".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + team_catalog_source: Some(crate::managed_agents::TeamMemberCatalogSource { + owner_pubkey: owner.to_string(), + team_d_tag: d_tag.to_string(), + member_key: id.to_string(), + projection_hash: "a".repeat(64), + }), + env_vars: std::collections::BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2026-07-30T00:00:00Z".to_string(), + updated_at: "2026-07-30T00:00:00Z".to_string(), + } +} + +fn catalog_team(id: &str, owner: &str, d_tag: &str, persona_ids: Vec) -> TeamRecord { + TeamRecord { + id: id.to_string(), + name: id.to_string(), + description: None, + instructions: None, + persona_ids, + is_builtin: false, + shared: false, + catalog_source: Some(crate::managed_agents::TeamCatalogSource { + owner_pubkey: owner.to_string(), + team_d_tag: d_tag.to_string(), + }), + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, + created_at: "2026-07-30T00:00:00Z".to_string(), + updated_at: "2026-07-30T00:00:00Z".to_string(), + } +} + +fn write_stores(base: &std::path::Path, personas: &[AgentDefinition], teams: &[TeamRecord]) { + std::fs::write( + base.join("personas.json"), + serde_json::to_string(personas).unwrap(), + ) + .unwrap(); + std::fs::write( + base.join("teams.json"), + serde_json::to_string(teams).unwrap(), + ) + .unwrap(); +} + +fn read_personas(base: &std::path::Path) -> Vec { + let json = std::fs::read_to_string(base.join("personas.json")).unwrap(); + serde_json::from_str(&json).unwrap() +} + +fn read_teams(base: &std::path::Path) -> Vec { + let json = std::fs::read_to_string(base.join("teams.json")).unwrap_or_default(); + serde_json::from_str(&json).unwrap_or_default() +} + +#[test] +fn test_delete_catalog_team_deactivates_members_and_removes_team() { + // Full lifecycle: add a catalog-adopted team with two members, delete it + // via delete_catalog_team_at, then reload and verify the team is gone and + // the member copies are deactivated. + let dir = tempfile::tempdir().unwrap(); + let owner = "a".repeat(64); + let d_tag = "team-alpha"; + + let m1 = catalog_persona("m1", &owner, d_tag); + let m2 = catalog_persona("m2", &owner, d_tag); + let t = catalog_team( + "team-abc", + &owner, + d_tag, + vec!["m1".to_string(), "m2".to_string()], + ); + write_stores(dir.path(), &[m1, m2], &[t]); + + let personas_path = dir.path().join("personas.json"); + let teams_path = dir.path().join("teams.json"); + + super::delete_catalog_team_at(&personas_path, &teams_path, "team-abc").unwrap(); + + let after_personas = read_personas(dir.path()); + let after_teams = read_teams(dir.path()); + + assert_eq!(after_teams.len(), 0, "team must be removed"); + assert_eq!( + after_personas.len(), + 2, + "copies stay in store but deactivated" + ); + assert!( + !after_personas[0].is_active && !after_personas[1].is_active, + "all copies must be deactivated" + ); +} + +#[test] +fn test_delete_catalog_team_team_save_failure_rolls_back_both_stores() { + // When the teams save fails, the byte-rollback must restore both personas + // and teams to their pre-delete state. We simulate teams-save failure by + // using commit_stores_with_snapshots with an injected failure on the + // teams-write callback. + use crate::managed_agents::storage; + + let dir = tempfile::tempdir().unwrap(); + let owner = "c".repeat(64); + let d_tag = "team-gamma"; + + let m1 = catalog_persona("m1", &owner, d_tag); + let t = catalog_team("team-gamma-copy", &owner, d_tag, vec!["m1".to_string()]); + let personas_path = dir.path().join("personas.json"); + let teams_path = dir.path().join("teams.json"); + write_stores( + dir.path(), + std::slice::from_ref(&m1), + std::slice::from_ref(&t), + ); + + // Snapshot the original bytes for comparison. + let orig_personas_bytes = std::fs::read(&personas_path).unwrap(); + let orig_teams_bytes = std::fs::read(&teams_path).unwrap(); + + // Simulate the delete: personas-write succeeds, teams-write fails. + let personas_snap = storage::snapshot_store(&personas_path).unwrap(); + let teams_snap = storage::snapshot_store(&teams_path).unwrap(); + + let mut personas_mut = vec![m1.clone()]; + personas_mut[0].is_active = false; + let personas_bytes = serde_json::to_vec_pretty(&personas_mut).unwrap(); + + let result = storage::commit_stores_with_snapshots( + &personas_path, + &teams_path, + personas_snap, + teams_snap, + || storage::atomic_write_json(&personas_path, &personas_bytes), + || Err("simulated teams-write failure".to_string()), + ); + + assert!(result.is_err(), "write failure must propagate"); + // Both files must be restored to their original bytes. + assert_eq!( + std::fs::read(&personas_path).unwrap(), + orig_personas_bytes, + "personas must be restored to original bytes" + ); + assert_eq!( + std::fs::read(&teams_path).unwrap(), + orig_teams_bytes, + "teams must be restored to original bytes" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e5be105fed..27767a6a06 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -71,6 +71,12 @@ pub struct AgentDefinition { /// a new local id, so the only link back to the publication is this pair. #[serde(default, skip_serializing_if = "Option::is_none")] pub catalog_source: Option, + /// Provenance of a persona copied out of another owner's shared TEAM + /// publication, as opposed to their persona catalog. Distinct from + /// `catalog_source` because a 30178 member is not addressable as a 30175 + /// coordinate — see [`TeamMemberCatalogSource`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub team_catalog_source: Option, /// Harness-level configuration passed to the agent subprocess as environment variables. /// Opaque to Buzz — keys and values are runtime-specific. /// @@ -149,6 +155,7 @@ impl AgentDefinition { source_team: self.source_team, source_team_persona_slug: self.source_team_persona_slug, catalog_source: self.catalog_source, + team_catalog_source: self.team_catalog_source, definition_respond_to: self.respond_to, definition_respond_to_allowlist: self.respond_to_allowlist, definition_parallelism: self.parallelism, @@ -183,6 +190,7 @@ impl ManagedAgentRecord { source_team: self.source_team.clone(), source_team_persona_slug: self.source_team_persona_slug.clone(), catalog_source: self.catalog_source.clone(), + team_catalog_source: self.team_catalog_source.clone(), env_vars: self.env_vars.clone(), respond_to: self.definition_respond_to.clone(), respond_to_allowlist: self.definition_respond_to_allowlist.clone(), @@ -409,6 +417,10 @@ pub struct ManagedAgentRecord { /// definition was copied from, when it came from another owner's catalog. #[serde(default, skip_serializing_if = "Option::is_none")] pub catalog_source: Option, + /// Absorbed from `AgentDefinition.team_catalog_source` — the team + /// publication and member this definition was copied out of. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub team_catalog_source: Option, /// NIP-AP definition-level behavioral defaults, absorbed from /// `AgentDefinition` in WIRE shape (kebab-case string / optional u32), /// distinct from the instance-side `respond_to`/`respond_to_allowlist`/ @@ -758,54 +770,6 @@ pub struct AgentModelInfo { pub description: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TeamRecord { - pub id: String, - pub name: String, - pub description: Option, - /// Runtime-layered instructions shared by every member deployment. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub instructions: Option, - pub persona_ids: Vec, - #[serde(default)] - pub is_builtin: bool, - /// Absolute path to the team's backing directory (if directory-backed). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_dir: Option, - /// Whether `source_dir` is a symlink to an external directory. - #[serde(default)] - pub is_symlink: bool, - /// Resolved symlink target path (for display). Only set when `is_symlink` is true. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub symlink_target: Option, - /// Version from the team's `plugin.json` manifest. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub version: Option, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateTeamRequest { - pub name: String, - pub description: Option, - pub instructions: Option, - #[serde(default)] - pub persona_ids: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateTeamRequest { - pub id: String, - pub name: String, - pub description: Option, - pub instructions: Option, - #[serde(default)] - pub persona_ids: Vec, -} - pub const DEFAULT_ACP_COMMAND: &str = "buzz-acp"; /// ~5 min (320s) — matches the CLI harness default (BUZZ_ACP_IDLE_TIMEOUT). pub const DEFAULT_AGENT_TURN_TIMEOUT_SECONDS: u64 = 320; @@ -992,6 +956,10 @@ mod catalog_source; pub use catalog_source::CatalogSource; mod requests; pub use requests::*; +mod team_catalog_source; +pub use team_catalog_source::{TeamCatalogSource, TeamMemberCatalogSource}; +mod teams; +pub use teams::{CreateTeamRequest, TeamRecord, UpdateTeamRequest}; #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index e28b0bd461..3e1afff256 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -283,6 +283,7 @@ mod tests { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: BTreeMap::new(), respond_to: None, respond_to_allowlist: Vec::new(), diff --git a/desktop/src-tauri/src/managed_agents/types/team_catalog_source.rs b/desktop/src-tauri/src/managed_agents/types/team_catalog_source.rs new file mode 100644 index 0000000000..03f225246a --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/types/team_catalog_source.rs @@ -0,0 +1,81 @@ +//! Catalog provenance for a team copied from another owner's catalog, and for +//! each member within it. Split from `types.rs` (file-size cap), alongside +//! [`super::CatalogSource`]. + +use serde::{Deserialize, Serialize}; + +/// Normalize an owner pubkey arriving from outside the backend. +/// +/// Shares [`super::CatalogSource::normalized`]'s contract: 64 hex, any case +/// in, lowercase out. An un-normalized value silently fails to match a +/// publication, which re-enables the duplicate add that provenance exists to +/// prevent. +fn normalized_owner_pubkey(value: &str) -> Result { + let owner_pubkey = value.trim().to_ascii_lowercase(); + if owner_pubkey.len() != 64 || !owner_pubkey.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!( + "invalid catalog source owner pubkey: '{owner_pubkey}' (must be 64 hex chars)" + )); + } + Ok(owner_pubkey) +} + +/// Where a team copy came from in another owner's shared catalog. +/// +/// Deliberately NOT [`super::CatalogSource`]: that type is the kind:30175 +/// persona coordinate `(owner_pubkey, persona_id)`, and a 30178 team d-tag +/// resolved in the 30175 namespace addresses a different — possibly +/// nonexistent, possibly unrelated — event. Reusing one type for two kinds +/// would let a team's provenance match a persona's and vice versa. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TeamCatalogSource { + #[serde(alias = "ownerPubkey")] + pub owner_pubkey: String, + /// The publication's `d`-tag — the team's id in the publisher's namespace. + #[serde(alias = "teamDTag")] + pub team_d_tag: String, +} + +impl TeamCatalogSource { + pub fn normalized(self) -> Result { + let owner_pubkey = normalized_owner_pubkey(&self.owner_pubkey)?; + let team_d_tag = self.team_d_tag.trim().to_string(); + if team_d_tag.is_empty() { + return Err("catalog source team d-tag is required".to_string()); + } + Ok(Self { + owner_pubkey, + team_d_tag, + }) + } +} + +/// Where a persona copy came from within a published team. +/// +/// The full amendment-A1 provenance triple plus a version stamp: +/// `(owner_pubkey, team_d_tag)` says which publication, `member_key` says +/// which member inside it, and `projection_hash` says which version of that +/// member. All four are required for reuse to be safe — matching on the +/// triple alone would let two different versions of one published member +/// share a single mutable local definition, so an add of the newer version +/// would silently rewrite the copy made from the older one. +/// +/// `member_key` is opaque. It is NOT a kind:30175 coordinate in the +/// publisher's namespace: the publisher may never have shared that member +/// individually, and its presence in a team publication grants no read access +/// to a persona coordinate. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TeamMemberCatalogSource { + #[serde(alias = "ownerPubkey")] + pub owner_pubkey: String, + #[serde(alias = "teamDTag")] + pub team_d_tag: String, + #[serde(alias = "memberKey")] + pub member_key: String, + /// Hash of the member projection this copy was built from. + #[serde(alias = "projectionHash")] + pub projection_hash: String, +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/types/team_catalog_source/tests.rs b/desktop/src-tauri/src/managed_agents/types/team_catalog_source/tests.rs new file mode 100644 index 0000000000..97b3ed8e4a --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/types/team_catalog_source/tests.rs @@ -0,0 +1,94 @@ +use super::{TeamCatalogSource, TeamMemberCatalogSource}; + +fn source(owner_pubkey: &str, team_d_tag: &str) -> TeamCatalogSource { + TeamCatalogSource { + owner_pubkey: owner_pubkey.to_string(), + team_d_tag: team_d_tag.to_string(), + } +} + +#[test] +fn normalized_lowercases_and_trims_the_owner_pubkey() { + // "Already added" compares this against a publication's author hex, which + // is always lowercase — a mixed-case value from the UI must not miss. + let normalized = source(&format!(" {} ", "A".repeat(64)), " team-abc ") + .normalized() + .expect("64 hex chars with surrounding space is valid"); + assert_eq!(normalized.owner_pubkey, "a".repeat(64)); + assert_eq!(normalized.team_d_tag, "team-abc"); +} + +#[test] +fn normalized_rejects_a_short_owner_pubkey() { + let err = source("abc123", "team-abc").normalized().unwrap_err(); + assert!(err.contains("64 hex"), "error must name the rule: {err}"); +} + +#[test] +fn normalized_rejects_a_non_hex_owner_pubkey() { + let err = source(&"z".repeat(64), "team-abc") + .normalized() + .unwrap_err(); + assert!(err.contains("64 hex"), "error must name the rule: {err}"); +} + +#[test] +fn normalized_rejects_a_blank_team_d_tag() { + let err = source(&"a".repeat(64), " ").normalized().unwrap_err(); + assert!(err.contains("d-tag"), "error must name the field: {err}"); +} + +#[test] +fn deserializes_the_camel_case_payload_the_frontend_sends() { + let parsed: TeamCatalogSource = + serde_json::from_str(r#"{"ownerPubkey":"abc","teamDTag":"team-abc"}"#) + .expect("camelCase payload from TS should deserialize"); + assert_eq!(parsed, source("abc", "team-abc")); +} + +#[test] +fn round_trips_persisted_snake_case() { + let value = source(&"a".repeat(64), "team-abc"); + let json = serde_json::to_string(&value).unwrap(); + assert!(json.contains("owner_pubkey"), "persisted shape: {json}"); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + value, + "the camelCase alias must not break the stored-record round trip" + ); +} + +#[test] +fn member_provenance_round_trips_all_four_components() { + // Reuse safety depends on every component surviving a store round trip: + // a dropped `projection_hash` would silently widen a version-pinned match + // into a version-agnostic one. + let value = TeamMemberCatalogSource { + owner_pubkey: "a".repeat(64), + team_d_tag: "team-abc".to_string(), + member_key: "member-1".to_string(), + projection_hash: "b".repeat(64), + }; + let json = serde_json::to_string(&value).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + value + ); +} + +#[test] +fn member_provenance_differs_when_only_the_projection_hash_differs() { + // The equality that gates copy reuse must treat two versions of one + // published member as distinct records. + let base = TeamMemberCatalogSource { + owner_pubkey: "a".repeat(64), + team_d_tag: "team-abc".to_string(), + member_key: "member-1".to_string(), + projection_hash: "b".repeat(64), + }; + let newer = TeamMemberCatalogSource { + projection_hash: "c".repeat(64), + ..base.clone() + }; + assert_ne!(base, newer); +} diff --git a/desktop/src-tauri/src/managed_agents/types/teams.rs b/desktop/src-tauri/src/managed_agents/types/teams.rs new file mode 100644 index 0000000000..5bce6bec7b --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/types/teams.rs @@ -0,0 +1,68 @@ +//! Team record and team command request types, split from `types.rs` +//! (file-size cap) as the sibling of [`super::requests`]. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use super::TeamCatalogSource; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TeamRecord { + pub id: String, + pub name: String, + pub description: Option, + /// Runtime-layered instructions shared by every member deployment. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instructions: Option, + pub persona_ids: Vec, + #[serde(default)] + pub is_builtin: bool, + /// Whether this team is discoverable in the currently active community. + /// View projection recomputed from the relay+owner-scoped kind:30178 head + /// on every read — see [`super::AgentDefinition::shared`]. + #[serde(default)] + pub shared: bool, + /// Provenance of a team copied from another owner's shared catalog. + /// + /// Set only on the copy, never on the original. It is the sole link back + /// to the publication — the copy carries a fresh local id — so it is what + /// makes a repeated add idempotent instead of minting a second team. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub catalog_source: Option, + /// Absolute path to the team's backing directory (if directory-backed). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_dir: Option, + /// Whether `source_dir` is a symlink to an external directory. + #[serde(default)] + pub is_symlink: bool, + /// Resolved symlink target path (for display). Only set when `is_symlink` is true. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub symlink_target: Option, + /// Version from the team's `plugin.json` manifest. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateTeamRequest { + pub name: String, + pub description: Option, + pub instructions: Option, + #[serde(default)] + pub persona_ids: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateTeamRequest { + pub id: String, + pub name: String, + pub description: Option, + pub instructions: Option, + #[serde(default)] + pub persona_ids: Vec, +} diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 1db7b9b524..232ac6a66b 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -486,6 +486,7 @@ fn sample_persona() -> AgentDefinition { source_team: Some("team-1".to_string()), source_team_persona_slug: Some("helper".to_string()), catalog_source: None, + team_catalog_source: None, env_vars: [("K".to_string(), "v".to_string())].into_iter().collect(), respond_to: None, respond_to_allowlist: Vec::new(), diff --git a/desktop/src-tauri/src/mesh_llm/recovery.rs b/desktop/src-tauri/src/mesh_llm/recovery.rs index 7933fd291e..6398f47250 100644 --- a/desktop/src-tauri/src/mesh_llm/recovery.rs +++ b/desktop/src-tauri/src/mesh_llm/recovery.rs @@ -454,6 +454,7 @@ mod tests { source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: std::collections::BTreeMap::from([ ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), ( diff --git a/desktop/src-tauri/src/migration_avatar_tests.rs b/desktop/src-tauri/src/migration_avatar_tests.rs index 39dfc988dd..5bc8a6e432 100644 --- a/desktop/src-tauri/src/migration_avatar_tests.rs +++ b/desktop/src-tauri/src/migration_avatar_tests.rs @@ -39,6 +39,7 @@ fn refresh_builtin_agent_avatars_updates_seeded_values_and_preserves_customizati source_team: None, source_team_persona_slug: None, catalog_source: None, + team_catalog_source: None, env_vars: Default::default(), respond_to: None, respond_to_allowlist: Vec::new(), diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_bare_https.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_bare_https.json new file mode 100644 index 0000000000..522acaeb10 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_bare_https.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "avatar_url": "https://" + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_https_over_2048.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_https_over_2048.json new file mode 100644 index 0000000000..8174862550 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_https_over_2048.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "avatar_url": "https://example.com/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_javascript.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_javascript.json new file mode 100644 index 0000000000..31a5079f78 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_javascript.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "avatar_url": "javascript:alert(1)" + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_malformed_port.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_malformed_port.json new file mode 100644 index 0000000000..4a6f482e8a --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_malformed_port.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "avatar_url": "https://a:b" + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_non_ascii_over_utf8_limit.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_non_ascii_over_utf8_limit.json new file mode 100644 index 0000000000..7f61a66525 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_non_ascii_over_utf8_limit.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "avatar_url": "https://a/éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé" + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_unicode_bom.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_unicode_bom.json new file mode 100644 index 0000000000..98ccb79c4a --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_unicode_bom.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "avatar_url": "https://example.com/path" + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_unicode_em_space.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_unicode_em_space.json new file mode 100644 index 0000000000..366d891078 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_unicode_em_space.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "avatar_url": "https://example.com/ path" + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_unicode_nbsp.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_unicode_nbsp.json new file mode 100644 index 0000000000..57f8d0a993 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_unicode_nbsp.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "avatar_url": "https://example.com/ path" + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_whitespace_in_url.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_whitespace_in_url.json new file mode 100644 index 0000000000..aa35ac1b31 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_avatar_url_whitespace_in_url.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "avatar_url": "https://example.com/a b.png" + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_builtin_slug_wrong_type.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_builtin_slug_wrong_type.json new file mode 100644 index 0000000000..4e1a9a3232 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_builtin_slug_wrong_type.json @@ -0,0 +1,13 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "builtin_slug": 42, + "projection_hash": {} + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_description_wrong_type.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_description_wrong_type.json new file mode 100644 index 0000000000..d961668201 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_description_wrong_type.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "description": 42, + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review." + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_duplicate_member_key.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_duplicate_member_key.json new file mode 100644 index 0000000000..83ad8f94dc --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_duplicate_member_key.json @@ -0,0 +1,16 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "First Reviewer", + "system_prompt": "Review first." + }, + { + "member_key": "reviewer", + "display_name": "Second Reviewer", + "system_prompt": "Review second." + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_instructions_wrong_type.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_instructions_wrong_type.json new file mode 100644 index 0000000000..e65d123feb --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_instructions_wrong_type.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "instructions": false, + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review." + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_name_pool_not_array.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_name_pool_not_array.json new file mode 100644 index 0000000000..773365d0e0 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_name_pool_not_array.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "name_pool": "not-an-array" + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_name_pool_null.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_name_pool_null.json new file mode 100644 index 0000000000..f6bfadd6dc --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_name_pool_null.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "name_pool": null + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_respond_to_pascal_case.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_respond_to_pascal_case.json new file mode 100644 index 0000000000..e564e1cc66 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_respond_to_pascal_case.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "respond_to": "OwnerOnly" + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_team_name_blank.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_team_name_blank.json new file mode 100644 index 0000000000..61642a6fc5 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/invalid_team_name_blank.json @@ -0,0 +1,11 @@ +{ + "v": 1, + "name": " ", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review." + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_avatar_url_https.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_avatar_url_https.json new file mode 100644 index 0000000000..b401464953 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_avatar_url_https.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "avatar_url": "https://example.com/avatar.png" + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_avatar_url_non_ascii_at_utf8_limit.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_avatar_url_non_ascii_at_utf8_limit.json new file mode 100644 index 0000000000..a9d0acca8e --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_avatar_url_non_ascii_at_utf8_limit.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "avatar_url": "https://a/ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé" + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_avatar_url_shorthand_scheme.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_avatar_url_shorthand_scheme.json new file mode 100644 index 0000000000..87e882b1c2 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_avatar_url_shorthand_scheme.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "avatar_url": "http:example.com" + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_avatar_url_unicode_nel.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_avatar_url_unicode_nel.json new file mode 100644 index 0000000000..8127556a85 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_avatar_url_unicode_nel.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "avatar_url": "https://example.com/…path" + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_avatar_url_uppercase_scheme.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_avatar_url_uppercase_scheme.json new file mode 100644 index 0000000000..292b81b1eb --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_avatar_url_uppercase_scheme.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "avatar_url": "HTTPS://example.com/avatar.png" + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_minimal.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_minimal.json new file mode 100644 index 0000000000..e09c61614c --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_minimal.json @@ -0,0 +1,11 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review changes." + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_respond_to_allowlist.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_respond_to_allowlist.json new file mode 100644 index 0000000000..99e809ca43 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_respond_to_allowlist.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "respond_to": "allowlist" + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_respond_to_anyone.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_respond_to_anyone.json new file mode 100644 index 0000000000..47f651db4d --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_respond_to_anyone.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "respond_to": "anyone" + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_respond_to_owner_only.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_respond_to_owner_only.json new file mode 100644 index 0000000000..fa32cc37a6 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_respond_to_owner_only.json @@ -0,0 +1,12 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "respond_to": "owner-only" + } + ] +} diff --git a/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_uppercase_hash.json b/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_uppercase_hash.json new file mode 100644 index 0000000000..b6c76098f2 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/team_catalog_content/valid_uppercase_hash.json @@ -0,0 +1,13 @@ +{ + "v": 1, + "name": "Review Squad", + "members": [ + { + "member_key": "reviewer", + "display_name": "Relay Reviewer", + "system_prompt": "Review.", + "builtin_slug": "fizz", + "projection_hash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + ] +}