fix(desktop): instance-level agents nav — fix round 2 corrections

Addresses all 6 blocking findings from Thufir pass 2:

1. [CRITICAL] Persona-grouped view model: get_owned_agent_inventory now
   parses kind:30177 content for persona_id via managed_agent_content_from_event,
   returns {byPersonaId, unknown} grouped view model. OwnedAgentInstance gains
   personaId: Option<String> field. TypeScript OwnedAgentInventorySnapshot updated
   to {byPersonaId: Record<string, ...>, unknown: [...]}.

2. [IMPORTANT] Pure reducer with raw-tail cursor: extracted reduce_page() pure
   function. Cursor advances from raw page tail only (last event before dedup).
   Transport errors propagate — partial inventory never silently returned.
   Three new unit tests for cursor behavior, tiebreak, and full-page detection.

3. [IMPORTANT] Archive snapshot uses scoped keys: load_archive_snapshot now
   calls query_relay_at_with_keys(&scope.keys) for NIP-98 auth. Returns
   (false, empty) for ALL failure conditions including query transport error.

4. [IMPORTANT] Co-generational recovery flags: flag stores moved inside epoch
   guard in both resolve_persisted_identity (app_state.rs) and
   commit_imported_identity (commands/identity.rs). SeqCst ordering throughout.
   New test capture_archive_scope_rejects_after_recovery_transition verifies
   ephemeral key + flag set co-generationally is rejected by capture.

5. [IMPORTANT] Expired-bound test hardened: result.expect(...) now asserts relay
   accepted the archive. Added kind:8002 delta query and consent == 'owner'
   assertion proving the owner path succeeded end-to-end.

6. [IMPORTANT] Playwright spec mounted + UI simplified: spec added to smoke
   project in playwright.config.ts. nip01_verification_rejects_tampered_event
   now actually tampers the event JSON. UnifiedAgentsSection simplified:
   openInstancesSheet takes only persona. InstancesSheet takes inventory
   snapshot directly and uses byPersonaId[persona.id] for filtering.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-05 19:32:28 -04:00
co-authored by Will Pfleger
parent 12e0ad7e25
commit 86fb624d06
8 changed files with 398 additions and 211 deletions
+1
View File
@@ -140,6 +140,7 @@ export default defineConfig({
"**/huddle-transcription.spec.ts",
"**/agent-numeric-tuning.spec.ts",
"**/needs-restart-screenshots.spec.ts",
"**/agent-instances-sheet.spec.ts",
],
use: {
...devices["Desktop Chrome"],
+11 -10
View File
@@ -458,22 +458,23 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<(
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
let resolved = load_or_create_identity(&data_dir)?;
// Write keys and storage before setting the recovery flags (Release) so
// any thread that reads a flag as false with Acquire sees consistent data.
// Write keys, storage, AND recovery flags inside the same epoch guard so
// they are co-generational: a `capture_archive_scope` that reads an even
// epoch after the guard exits sees a consistent (keys, flags) tuple.
{
let _epoch_guard = state.begin_workspace_write()?;
let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?;
*active_keys = resolved.keys;
state.set_identity_storage(resolved.storage);
state.identity_lost.store(
resolved.recovery == RecoveryState::Lost,
std::sync::atomic::Ordering::SeqCst,
);
state.keyring_locked.store(
resolved.recovery == RecoveryState::KeyringLocked,
std::sync::atomic::Ordering::SeqCst,
);
}
state.identity_lost.store(
resolved.recovery == RecoveryState::Lost,
std::sync::atomic::Ordering::Release,
);
state.keyring_locked.store(
resolved.recovery == RecoveryState::KeyringLocked,
std::sync::atomic::Ordering::Release,
);
Ok(())
}
@@ -316,3 +316,47 @@ fn capture_archive_scope_rejects_when_keyring_locked() {
"error must mention recovery mode"
);
}
/// Finding 4 (fix round 2): recovery key + true flag must never yield a
/// signable scope. This is the inverse-transition test:
/// 1. Start with a normal (non-recovery) key — capture succeeds.
/// 2. Simulate a recovery transition: write a new ephemeral key inside the
/// epoch guard AND set identity_lost = true (co-generational).
/// 3. A subsequent capture_archive_scope call must reject — the flag and key
/// are from the same generation, so there is no window where the
/// ephemeral key is visible with flags=false.
#[test]
fn capture_archive_scope_rejects_after_recovery_transition() {
use std::sync::atomic::Ordering;
let normal_keys = Keys::generate();
let state = make_epoch_test_state(normal_keys);
// Pre-condition: capture succeeds with normal keys.
assert!(
state.capture_archive_scope(8).is_ok(),
"pre-condition: capture must succeed with normal keys"
);
// Simulate a recovery transition — write ephemeral recovery key AND set
// identity_lost = true, both inside the epoch guard (co-generational).
{
let _guard = state
.begin_workspace_write()
.expect("begin_workspace_write");
let ephemeral = Keys::generate();
*state.keys.lock().unwrap() = ephemeral;
state.identity_lost.store(true, Ordering::SeqCst);
}
// Post-condition: capture must reject the ephemeral recovery key.
let result = state.capture_archive_scope(8);
assert!(
result.is_err(),
"capture must reject ephemeral recovery key after co-generational transition"
);
assert!(
result.unwrap_err().contains("recovery mode"),
"error must mention recovery mode"
);
}
+10 -15
View File
@@ -415,29 +415,24 @@ fn commit_imported_identity(
let storage = persist(&keys)?;
// Update in-memory keys BEFORE clearing recovery flags. The Release
// stores below pair with Acquire loads in get_identity: a reader
// observing false is guaranteed to see the updated keys.
// Update in-memory keys AND clear recovery flags inside the same epoch guard
// so they are co-generational: a concurrent `capture_archive_scope` that
// reads an even epoch after the guard exits sees the new key with flags=false.
let pubkey = keys.public_key();
{
let _epoch_guard = state.begin_workspace_write()?;
let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?;
*active_keys = keys;
state.set_identity_storage(storage);
// Clear both recovery flags — an import resolves lost and locked.
state
.identity_lost
.store(false, std::sync::atomic::Ordering::SeqCst);
state
.keyring_locked
.store(false, std::sync::atomic::Ordering::SeqCst);
}
// Clear both recovery flags — an import is valid in either lost or
// keyring-locked state and resolves both. In the locked case the
// keyring is unreachable, so the persist step already fell back to
// identity.key; on the next Unreachable boot the file is loaded
// directly and when the keyring returns the adoption path picks it up.
state
.identity_lost
.store(false, std::sync::atomic::Ordering::Release);
state
.keyring_locked
.store(false, std::sync::atomic::Ordering::Release);
// Importing a different identity invalidates the app-managed backup: it
// encrypts the previous key and must not linger mislabeled. Best-effort
// per the ordering contract above.
@@ -1,6 +1,7 @@
//! Owned-agent relay inventory: exhaustive keyset-paged `kind:30177` query,
//! `d`-tag agent extraction, `kind:0` fetch + NIP-01 verification, and
//! `NipIaOwnerProof` classification joined with the archive snapshot.
//! `d`-tag agent extraction + content parsing, `kind:0` fetch + NIP-01
//! verification, `NipIaOwnerProof` classification joined with the archive
//! snapshot, and persona-grouped view model.
//!
//! All state is captured atomically via `capture_archive_scope` before any I/O.
@@ -10,9 +11,9 @@ use serde::Serialize;
use crate::{
app_state::{AppState, ArchiveScope},
managed_agents::agent_events::managed_agent_content_from_event,
relay::{
classify_request_error, query_relay_at, query_relay_at_with_keys, relay_api_base_url,
relay_http_base_url,
classify_request_error, query_relay_at_with_keys, relay_api_base_url, relay_http_base_url,
},
};
@@ -47,16 +48,24 @@ pub struct OwnedAgentInstance {
pub nip_ia_owner_proof: NipIaOwnerProof,
/// Archive tri-state joined from the `kind:13535` snapshot.
pub archive_state: OwnedAgentArchiveState,
/// Persona ID parsed from `kind:30177` content, if present.
/// `None` for standalone (definition-less) agents or malformed content.
pub persona_id: Option<String>,
}
/// Snapshot returned by `get_owned_agent_inventory`.
/// Complete merged view model returned by `get_owned_agent_inventory`.
///
/// Instances are grouped by persona ID. The `unknown` bucket holds instances
/// whose `kind:30177` content is missing or has no `persona_id`.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OwnedAgentInventorySnapshot {
/// Whether the archive snapshot was loaded and trusted.
pub archive_state_trusted: bool,
/// All owned agent instances, sorted `(created_at DESC, id ASC)`.
pub instances: Vec<OwnedAgentInstance>,
/// Instances keyed by their persona ID — drives per-card counts and Sheet.
pub by_persona_id: HashMap<String, Vec<OwnedAgentInstance>>,
/// Instances with no parseable persona ID (standalone agents).
pub unknown: Vec<OwnedAgentInstance>,
}
// ── Page-to-exhaustion fetch ──────────────────────────────────────────────
@@ -70,22 +79,83 @@ fn is_valid_agent_pubkey(s: &str) -> bool {
lower.len() == 64 && lower.chars().all(|c| c.is_ascii_hexdigit())
}
/// State returned by `reduce_page`.
struct PageResult {
/// Canonical (latest) event per agent pubkey accumulated across all pages.
canonical: HashMap<String, (u64, String, nostr::Event)>,
/// Cursor for the next request: `(created_at, id)` of the LAST raw event
/// on this page. Advances from the raw page tail ONLY — dedup never
/// influences cursor progression.
next_until: u64,
next_before_id: String,
/// Whether the relay returned a full page (more data may follow).
full_page: bool,
}
/// Pure reducer: merge one raw relay page into `canonical` and compute the
/// next cursor from the raw page tail.
///
/// Malformed `d` tags are skipped and DO NOT affect the cursor.
fn reduce_page(
mut canonical: HashMap<String, (u64, String, nostr::Event)>,
page: Vec<nostr::Event>,
) -> PageResult {
let full_page = page.len() as u64 == PAGE_SIZE;
// Cursor from raw page tail — the relay sorts (created_at DESC, id ASC),
// so the last event is the oldest on this page.
let (next_until, next_before_id) = page
.last()
.map(|ev| (ev.created_at.as_secs(), ev.id.to_hex()))
.unwrap_or((0, String::new()));
for ev in page {
let d_raw = ev
.tags
.iter()
.find(|t| t.as_slice().first().map(String::as_str) == Some("d"))
.and_then(|t| t.as_slice().get(1).cloned())
.unwrap_or_default();
let agent_pubkey = d_raw.to_ascii_lowercase();
if !is_valid_agent_pubkey(&agent_pubkey) {
continue; // malformed d tag — skip, cursor unaffected
}
let ts = ev.created_at.as_secs();
let id = ev.id.to_hex();
let supersedes = canonical
.get(&agent_pubkey)
.map(|(ets, eid, _)| ts > *ets || (ts == *ets && id < *eid))
.unwrap_or(true);
if supersedes {
canonical.insert(agent_pubkey, (ts, id, ev));
}
}
PageResult {
canonical,
next_until,
next_before_id,
full_page,
}
}
/// Fetch all `kind:30177` events authored by `scope.actor`, paging to
/// exhaustion via composite `(until, before_id)` cursor.
///
/// Returns the canonical latest event per NIP-33 `d` tag (agent pubkey),
/// sorted `(created_at DESC, id ASC)`. Events with missing or non-hex-64 `d`
/// tags are silently skipped (malformed).
/// tags are silently skipped (malformed). Transport errors propagate — a
/// partial inventory is never silently returned as complete.
async fn fetch_all_owned_30177(
state: &AppState,
scope: &ArchiveScope,
api_base_url: &str,
) -> Result<Vec<nostr::Event>, String> {
// Cursor state: start from "now" and page backwards by timestamp.
let mut until: Option<u64> = None;
let mut before_id: Option<String> = None;
// NIP-33 canonical map: agent_pubkey → (created_at, event_id, event).
let mut canonical: HashMap<String, (u64, String, nostr::Event)> = HashMap::new();
loop {
@@ -101,71 +171,33 @@ async fn fetch_all_owned_30177(
filter["before_id"] = serde_json::json!(bid);
}
// Transport failure propagates — partial inventory is not returned.
let page =
query_relay_at_with_keys(state, api_base_url, &[filter], &scope.keys, None).await?;
let page_len = page.len() as u64;
let PageResult {
canonical: new_canonical,
next_until,
next_before_id,
full_page,
} = reduce_page(canonical, page);
canonical = new_canonical;
for ev in page {
// Extract and validate agent pubkey from `d` tag.
let d_raw = ev
.tags
.iter()
.find(|t| t.as_slice().first().map(String::as_str) == Some("d"))
.and_then(|t| t.as_slice().get(1).cloned())
.unwrap_or_default();
let agent_pubkey = d_raw.to_ascii_lowercase();
if !is_valid_agent_pubkey(&agent_pubkey) {
continue; // malformed d tag — skip
}
let ts = ev.created_at.as_secs();
let id = ev.id.to_hex();
// Canonical ordering: higher created_at wins;
// on tie, lexicographically LOWER event ID wins (ascending).
let supersedes = canonical
.get(&agent_pubkey)
.map(|(existing_ts, existing_id, _)| {
ts > *existing_ts || (ts == *existing_ts && id < *existing_id)
})
.unwrap_or(true);
if supersedes {
canonical.insert(agent_pubkey, (ts, id, ev));
}
}
// Stop when the relay returned a partial page — no more data.
if page_len < PAGE_SIZE {
if !full_page {
break;
}
// Compute the minimum (oldest) event across all seen events to use
// as the `until` boundary for the next page.
let cursor = canonical.values().fold(
(u64::MAX, String::new()),
|(acc_ts, acc_id), (ts, id, _)| {
// Oldest = smallest created_at; on tie, LARGEST id (descending)
// so we can use before_id to skip it on the next page.
if *ts < acc_ts || (*ts == acc_ts && *id > acc_id) {
(*ts, id.clone())
} else {
(acc_ts, acc_id)
}
},
);
// Detect no-progress (cursor didn't advance) — stop to avoid loops.
if until == Some(cursor.0) && before_id.as_deref() == Some(&cursor.1) {
// Guard against degenerate relay behaviour.
let no_progress =
until == Some(next_until) && before_id.as_deref() == Some(next_before_id.as_str());
if no_progress {
break;
}
until = Some(cursor.0);
before_id = Some(cursor.1);
until = Some(next_until);
before_id = Some(next_before_id);
}
// Sort by (created_at DESC, id ASC) for stable presentation.
let mut events: Vec<nostr::Event> = canonical.into_values().map(|(_, _, ev)| ev).collect();
events.sort_by(|a, b| {
let ts = b.created_at.as_secs().cmp(&a.created_at.as_secs());
@@ -224,10 +256,17 @@ async fn fetch_and_verify_kind0(
// ── Archive snapshot loader ───────────────────────────────────────────────
/// Load the relay's `kind:13535` archive snapshot for the tri-state join.
/// Uses the pre-scoped `api_base_url` so it queries the same relay instance
/// captured by `capture_archive_scope` — no separate state read.
async fn load_archive_snapshot(state: &AppState, api_base_url: &str) -> (bool, HashSet<String>) {
// Fetch the NIP-11 relay-information document at the scoped URL.
///
/// Uses `scope.keys` for NIP-98 authentication (same generation as the
/// inventory query). Returns `(false, empty)` for ALL failure conditions —
/// query failure, absent relay self, absent snapshot, and invalid signature.
/// Only a successfully fetched, verified, relay-signed snapshot returns `true`.
async fn load_archive_snapshot(
state: &AppState,
scope: &ArchiveScope,
api_base_url: &str,
) -> (bool, HashSet<String>) {
// Fetch NIP-11 relay-information document at the scoped URL.
let relay_self: Option<String> = async {
let response = state
.http_client
@@ -256,10 +295,12 @@ async fn load_archive_snapshot(state: &AppState, api_base_url: &str) -> (bool, H
.unwrap_or(None);
let Some(relay_self) = relay_self else {
// relay_self absent or fetch failed → unknown, not trusted-empty
return (false, HashSet::new());
};
let snaps = query_relay_at(
// Use scope.keys for NIP-98 auth — same generation as the inventory query.
let snaps = query_relay_at_with_keys(
state,
api_base_url,
&[serde_json::json!({
@@ -267,23 +308,29 @@ async fn load_archive_snapshot(state: &AppState, api_base_url: &str) -> (bool, H
"kinds": [13535u32],
"limit": 1,
})],
&scope.keys,
None,
)
.await
.unwrap_or_default();
.await;
// Query failure → unknown (not trusted-empty).
let Ok(snaps) = snaps else {
return (false, HashSet::new());
};
match snaps.into_iter().next() {
// No snapshot present yet → trusted-empty (relay self confirmed).
None => (true, HashSet::new()),
Some(snap) => {
if !snap.verify_id()
|| !snap.verify_signature()
|| !snap.pubkey.to_hex().eq_ignore_ascii_case(&relay_self)
{
(false, HashSet::new())
} else {
let set: HashSet<String> =
archived_pubkeys_from_snapshot(&snap).into_iter().collect();
(true, set)
// Invalid snapshot → unknown.
return (false, HashSet::new());
}
let set: HashSet<String> = archived_pubkeys_from_snapshot(&snap).into_iter().collect();
(true, set)
}
}
}
@@ -310,25 +357,26 @@ fn parse_display_fields(content: &str) -> (Option<String>, Option<String>) {
/// Query the relay's `kind:30177` inventory for agents owned by the current
/// user. Pages to exhaustion; applies NIP-33 dedup; fetches each agent's
/// `kind:0` for NIP-OA classification; joins the archive tri-state.
/// Parses `kind:30177` content for `persona_id` and groups results.
///
/// All state is captured atomically via the seqlock before any I/O. The
/// previous `cursor`/`page_size` parameters are removed — this command always
/// returns a complete snapshot.
/// All state is captured atomically via the seqlock before any I/O.
#[tauri::command]
pub async fn get_owned_agent_inventory(
state: tauri::State<'_, AppState>,
) -> Result<OwnedAgentInventorySnapshot, String> {
let scope = state.capture_archive_scope(8)?;
// Derive the API base URL from the epoch-captured scope — same pattern as
// `scoped_archive_operation`. Never re-reads state.relay_url_override.
let api_base_url = match &scope.relay_url_override {
Some(url) => relay_http_base_url(url),
None => relay_api_base_url(),
};
let owned_events = fetch_all_owned_30177(&state, &scope, &api_base_url).await?;
let (archive_state_trusted, archived_set) = load_archive_snapshot(&state, &api_base_url).await;
let (archive_state_trusted, archived_set) =
load_archive_snapshot(&state, &scope, &api_base_url).await;
// Bounded batch: fetch all kind:0 profiles concurrently but fail the
// entire snapshot on transport error (a partial inventory is dangerous
// for the no-third-mint gate).
let mut instances = Vec::with_capacity(owned_events.len());
for ev in owned_events {
// Re-extract agent pubkey (already validated by fetch_all_owned_30177).
@@ -340,10 +388,16 @@ pub async fn get_owned_agent_inventory(
.unwrap_or_default()
.to_ascii_lowercase();
// Parse persona_id from kind:30177 content (authoritative per wire contract).
let persona_id = managed_agent_content_from_event(&ev)
.ok()
.and_then(|c| c.persona_id);
// Fetch + NIP-01-verify the agent's kind:0.
// Transport failure propagates — do NOT silently omit this instance.
let (proof, display_name, picture) =
match fetch_and_verify_kind0(&state, &scope, &api_base_url, &agent_pubkey).await {
Err(_) => continue, // I/O failure — skip, will refresh
Err(e) => return Err(format!("kind:0 fetch failed for {agent_pubkey}: {e}")),
Ok(None) => (NipIaOwnerProof::MissingProfile, None, None),
Ok(Some(k0)) => {
let proof = classify_nip_ia_owner_proof(&k0, &scope.actor);
@@ -365,12 +419,26 @@ pub async fn get_owned_agent_inventory(
relay_url: api_base_url.clone(),
nip_ia_owner_proof: proof,
archive_state: OwnedAgentArchiveState { is_archived },
persona_id,
});
}
// Group instances: byPersonaId for those with a known persona, unknown for
// those without. This grouping is the authoritative source for per-card
// counts, Sheet filtering, and the start-control safeguard.
let mut by_persona_id: HashMap<String, Vec<OwnedAgentInstance>> = HashMap::new();
let mut unknown: Vec<OwnedAgentInstance> = Vec::new();
for instance in instances {
match &instance.persona_id {
Some(pid) => by_persona_id.entry(pid.clone()).or_default().push(instance),
None => unknown.push(instance),
}
}
Ok(OwnedAgentInventorySnapshot {
archive_state_trusted,
instances,
by_persona_id,
unknown,
})
}
@@ -395,16 +463,12 @@ mod tests {
assert!(!is_valid_agent_pubkey(&"g".repeat(64))); // non-hex
}
/// Finding 6: `fetch_and_verify_kind0` rejects events with invalid NIP-01
/// ID or signature. Verify the reject-if-tampered path by constructing a
/// well-formed event and then checking that a tampered copy is rejected.
///
/// We can't call the async fn in a sync unit test, but we can directly
/// exercise the verification predicates it delegates to, confirming the
/// branches it would take.
/// Finding 6: `fetch_and_verify_kind0` rejects events with tampered NIP-01
/// ID or signature. Construct a genuine event, tamper it, and confirm the
/// verification predicates it delegates to both reject the tampered copy.
#[test]
fn nip01_verification_rejects_tampered_event() {
use nostr::{EventBuilder, Keys, Kind};
use nostr::{EventBuilder, JsonUtil, Keys, Kind};
let agent = Keys::generate();
let ev = EventBuilder::new(Kind::Metadata, "{}")
.sign_with_keys(&agent)
@@ -417,22 +481,26 @@ mod tests {
"genuine event must pass verify_signature"
);
// Simulate what fetch_and_verify_kind0 would do with a genuinely signed
// event: both checks pass and the kind and pubkey match.
assert_eq!(ev.kind, nostr::Kind::Metadata, "kind:0 check");
assert_eq!(
ev.pubkey.to_hex(),
agent.public_key().to_hex(),
"authorship check"
// Tamper: mutate the content so the event ID no longer matches.
// Deserialize to raw JSON, swap the content field, reserialise.
let mut raw: serde_json::Value =
serde_json::from_str(&ev.as_json()).expect("event must be valid JSON");
raw["content"] = serde_json::json!("tampered content");
let tampered_json = serde_json::to_string(&raw).unwrap();
let tampered = nostr::Event::from_json(&tampered_json)
.expect("tampered JSON must still parse as an Event struct");
// The tampered copy must FAIL at least verify_id (content was changed).
// fetch_and_verify_kind0 checks both and rejects if either fails.
assert!(
!tampered.verify_id() || !tampered.verify_signature(),
"tampered event must fail at least one NIP-01 check"
);
}
/// Finding 6: when fetch_and_verify_kind0 returns None, the inventory
/// code correctly maps to NipIaOwnerProof::MissingProfile. Verify the
/// mapping is present in the `get_owned_agent_inventory` path.
///
/// We test this via the NipIaOwnerProof enum itself — MissingProfile must
/// exist and be serializable (it was previously "dead" per Thufir's review).
#[test]
fn missing_profile_variant_is_reachable_and_serializable() {
use super::super::NipIaOwnerProof;
@@ -445,63 +513,128 @@ mod tests {
);
}
// ── reduce_page tests ────────────────────────────────────────────────────
/// reduce_page uses the raw page tail for the cursor, not the dedup map
/// tail. When a page contains only malformed d-tags, the canonical map is
/// empty but the cursor still advances from the raw events.
#[test]
fn canonical_ordering_later_created_at_wins() {
fn reduce_page_cursor_from_raw_tail_not_dedup_map() {
use nostr::{EventBuilder, Keys, Kind, Tag};
let owner = Keys::generate();
// Two events with MALFORMED d-tags — they won't enter canonical,
// but they ARE on the raw page and the cursor must advance from them.
let ev_old = EventBuilder::new(Kind::Custom(30177), "")
.tags([Tag::parse(["d", "not-hex"]).unwrap()])
.sign_with_keys(&owner)
.unwrap();
let ev_new = EventBuilder::new(Kind::Custom(30177), "")
.tags([Tag::parse(["d", "also-bad"]).unwrap()])
.sign_with_keys(&owner)
.unwrap();
// Simulate relay order: newest first. ev_new is first, ev_old is last.
// The cursor must come from ev_old (the raw page tail = oldest event).
let page = vec![ev_new.clone(), ev_old.clone()];
let result = reduce_page(HashMap::new(), page);
// No events passed the pubkey validation — canonical stays empty.
assert!(
result.canonical.is_empty(),
"malformed d tags must not enter canonical"
);
// Cursor must come from the raw tail (ev_old), not u64::MAX or empty.
assert_eq!(result.next_until, ev_old.created_at.as_secs());
assert_eq!(result.next_before_id, ev_old.id.to_hex());
}
/// Two events with equal created_at: the one with the lexicographically
/// smaller ID wins in the canonical map (NIP-33 tiebreak rule).
#[test]
fn reduce_page_equal_timestamp_lower_id_wins() {
use nostr::{EventBuilder, Keys, Kind, Tag};
let owner = Keys::generate();
let agent_pk = "a".repeat(64);
let mut map: HashMap<String, (u64, String, nostr::Event)> = HashMap::new();
// Insert ev1 first.
let ev1 = EventBuilder::new(Kind::Custom(30177), "")
// Generate two events and keep trying until we have same created_at.
// In practice, two Events built back-to-back in the same second will
// share the timestamp — but we can't guarantee that in a unit test.
// Instead, use two events and pick the winner based on the rule.
let ev1 = EventBuilder::new(Kind::Custom(30177), "v1")
.tags([Tag::parse(["d", &agent_pk]).unwrap()])
.sign_with_keys(&owner)
.unwrap();
let ts1 = ev1.created_at.as_secs();
let id1 = ev1.id.to_hex();
map.insert(agent_pk.clone(), (ts1, id1.clone(), ev1.clone()));
// ev2 has the same created_at but a potentially different id.
let ev2 = EventBuilder::new(Kind::Custom(30177), "v2")
.tags([Tag::parse(["d", &agent_pk]).unwrap()])
.sign_with_keys(&owner)
.unwrap();
let ts2 = ev2.created_at.as_secs();
let id1 = ev1.id.to_hex();
let id2 = ev2.id.to_hex();
let ts1 = ev1.created_at.as_secs();
let ts2 = ev2.created_at.as_secs();
// Apply the canonical supersedes logic.
let supersedes = map
.get(&agent_pk)
.map(|(ets, eid, _)| ts2 > *ets || (ts2 == *ets && id2 < *eid))
.unwrap_or(true);
let page = vec![ev1.clone(), ev2.clone()];
let result = reduce_page(HashMap::new(), page);
assert_eq!(result.canonical.len(), 1);
if supersedes {
map.insert(agent_pk.clone(), (ts2, id2.clone(), ev2.clone()));
}
// Exactly one canonical event per agent_pk.
assert_eq!(map.len(), 1);
let (_ts, _id, canonical) = map.get(&agent_pk).unwrap();
// If timestamps differ, the later one wins.
let (_, winning_id, _) = result.canonical.get(&agent_pk).unwrap();
if ts1 != ts2 {
if ts2 > ts1 {
assert_eq!(canonical.id, ev2.id);
// Whichever has higher created_at wins.
if ts1 > ts2 {
assert_eq!(winning_id, &id1);
} else {
assert_eq!(canonical.id, ev1.id);
assert_eq!(winning_id, &id2);
}
} else {
// Equal timestamps: lower event ID wins.
if id2 < id1 {
assert_eq!(canonical.id, ev2.id);
// Equal timestamps: lower id wins.
if id1 < id2 {
assert_eq!(winning_id, &id1);
} else {
assert_eq!(canonical.id, ev1.id);
assert_eq!(winning_id, &id2);
}
}
}
/// A full page (PAGE_SIZE events) sets full_page = true; a partial page
/// does not.
#[test]
fn reduce_page_full_page_detection() {
use nostr::{EventBuilder, Keys, Kind, Tag};
let owner = Keys::generate();
// Build PAGE_SIZE events with distinct d-tags.
let full_page_events: Vec<nostr::Event> = (0..PAGE_SIZE)
.map(|i| {
let pk = format!("{:0>64}", format!("{i:x}"));
EventBuilder::new(Kind::Custom(30177), "")
.tags([Tag::parse(["d", &pk]).unwrap()])
.sign_with_keys(&owner)
.unwrap()
})
.collect();
let result = reduce_page(HashMap::new(), full_page_events);
assert!(result.full_page, "PAGE_SIZE events must set full_page");
// One event less → partial.
let partial: Vec<nostr::Event> = (0..(PAGE_SIZE - 1))
.map(|i| {
let pk = format!("{:0>64}", format!("{i:x}"));
EventBuilder::new(Kind::Custom(30177), "")
.tags([Tag::parse(["d", &pk]).unwrap()])
.sign_with_keys(&owner)
.unwrap()
})
.collect();
let result = reduce_page(HashMap::new(), partial);
assert!(!result.full_page, "partial page must NOT set full_page");
}
#[test]
fn distinct_agent_pubkeys_yield_separate_canonical_entries() {
use nostr::{EventBuilder, Keys, Kind, Tag};
@@ -510,16 +643,17 @@ mod tests {
let agent1 = "a".repeat(64);
let agent2 = "b".repeat(64);
let mut map: HashMap<String, (u64, String, nostr::Event)> = HashMap::new();
for pk in [&agent1, &agent2] {
let ev = EventBuilder::new(Kind::Custom(30177), "")
.tags([Tag::parse(["d", pk]).unwrap()])
let page = vec![
EventBuilder::new(Kind::Custom(30177), "")
.tags([Tag::parse(["d", &agent1]).unwrap()])
.sign_with_keys(&owner)
.unwrap();
let ts = ev.created_at.as_secs();
let id = ev.id.to_hex();
map.insert(pk.to_string(), (ts, id, ev));
}
assert_eq!(map.len(), 2);
.unwrap(),
EventBuilder::new(Kind::Custom(30177), "")
.tags([Tag::parse(["d", &agent2]).unwrap()])
.sign_with_keys(&owner)
.unwrap(),
];
let result = reduce_page(HashMap::new(), page);
assert_eq!(result.canonical.len(), 2);
}
}
@@ -472,8 +472,14 @@ async fn expired_bound_profile_mints_fresh_empty_condition_tag() {
let (result, observed_event, _) =
run_with_observation(&state, &scope, ArchiveKind::Archive, &agent_pubkey).await;
// The relay may accept or reject based on condition eval, but the
// submitted request MUST carry exactly one fresh empty-condition auth tag.
// NIP-IA §Published-profile rule: time clauses MUST NOT be evaluated by
// the relay — an expired profile MUST still be accepted for the owner path.
// `result.expect()` proves the zombie-agent path is not broken.
let relay_result = result.expect(
"expired-profile owner-path archive must succeed: NIP-IA time clauses must not be evaluated"
);
// The submitted request MUST carry exactly one fresh empty-condition auth tag.
let observed_event = observed_event.expect("must have observed a production-signed event");
let auth_tags: Vec<&[String]> = observed_event
.tags
@@ -490,12 +496,21 @@ async fn expired_bound_profile_mints_fresh_empty_condition_tag() {
auth_tags[0][2], "",
"fresh-minted tag must have EMPTY condition (not the expired profile condition)"
);
// Distinct from the profile tag: the profile tag has condition=past, the
// fresh tag has condition="". The assertion above confirms this.
// The result depends on whether the relay accepts an expired profile tag
// or not. Either way, the WIRE form was correct.
let _ = result;
// Assert owner-path archive state via kind:8002 delta.
let request_event_id = &relay_result.event_id;
let delta_8002 = query_nipia_delta(&state, &relay_url, 8002, request_event_id)
.await
.expect("query kind:8002 delta for expired-profile test");
let delta = delta_8002.as_ref().expect(
"kind:8002 delta must be emitted after owner-path archive of expired-profile agent",
);
let consent = extract_consent_tag(delta)
.expect("kind:8002 must have consent tag for expired-profile owner path");
assert_eq!(
consent, "owner",
"expired-profile owner-path kind:8002 consent must be 'owner'"
);
}
#[tokio::test]
@@ -19,6 +19,7 @@ import { truncatePubkey } from "@/shared/lib/pubkey";
import type {
NipIaOwnerProof,
OwnedAgentInstance,
OwnedAgentInventorySnapshot,
} from "@/shared/api/tauriIdentityArchive";
import type { AgentPersona } from "@/shared/api/types";
import { Badge } from "@/shared/ui/badge";
@@ -42,8 +43,6 @@ function canMutate(proof: NipIaOwnerProof): boolean {
type InstanceRowProps = {
instance: OwnedAgentInstance;
archiveStateTrusted: boolean;
/** Whether this instance's pubkey is managed locally (i.e. in the local agents list). */
isManagedLocally: boolean;
onOpenProfile: (pubkey: string) => void;
onArchive: (pubkey: string) => void;
onUnarchive: (pubkey: string) => void;
@@ -54,7 +53,6 @@ type InstanceRowProps = {
function InstanceRow({
instance,
archiveStateTrusted,
isManagedLocally,
onOpenProfile,
onArchive,
onUnarchive,
@@ -115,8 +113,8 @@ function InstanceRow({
</Badge>
) : null}
{/* "Not managed on this device" badge when no local agent exists */}
{!isManagedLocally && !isArchived ? (
{/* "Not managed on this device" badge for relay-only instances */}
{instance.personaId === null && !isArchived ? (
<Badge className="shrink-0 gap-1" variant="outline">
<MonitorOff className="h-3 w-3" />
Relay only
@@ -163,30 +161,30 @@ type InstancesSheetProps = {
/** The persona whose instances to display. Filters by persona coordinate. */
persona: AgentPersona | null;
/**
* Lowercase-hex pubkeys of local agents associated with the persona.
* Instances whose pubkey is in this set are marked as locally managed.
* Instances NOT in this set are marked "Relay only" (not on this device).
* The complete grouped inventory snapshot from the parent. The Sheet uses
* `inventory.byPersonaId[persona.id]` as the authoritative instance list for
* this persona — no secondary pubkey-set reconstruction needed.
* `null` when the inventory is not yet loaded.
*/
personaAgentPubkeys: ReadonlySet<string>;
inventory: OwnedAgentInventorySnapshot | undefined;
/** Open the exact-pubkey profile panel. */
onOpenProfile: (pubkey: string) => void;
};
/**
* Sheet showing the owner's relay inventory of agent instances (`kind:30177`)
* scoped to the opener's persona.
* scoped to the opener's persona via `inventory.byPersonaId[persona.id]`.
*
* - Rows link to the exact-pubkey profile panel.
* - Archive/Unarchive are offered only for `Verified` instances.
* - Unknown archive trust shows a retry affordance; mutations are suppressed.
* - Tri-state badge is scoped to this surface — `useIsIdentityArchived` elsewhere is unchanged.
* - "Relay only" marker for instances without a matching local agent.
*/
export function InstancesSheet({
open,
onOpenChange,
persona,
personaAgentPubkeys,
inventory,
onOpenProfile,
}: InstancesSheetProps) {
const inventoryQuery = useOwnedAgentInventoryQuery(open);
@@ -198,21 +196,20 @@ export function InstancesSheet({
string | null
>(null);
const allInstances = inventoryQuery.data?.instances ?? [];
const archiveStateTrusted = inventoryQuery.data?.archiveStateTrusted ?? false;
// Use the parent-supplied snapshot when available; fall back to the Sheet's
// own query result. This avoids a redundant fetch while keeping the Sheet
// self-contained when opened standalone (e.g. from a future entry point).
const effectiveData = inventory ?? inventoryQuery.data;
const archiveStateTrusted = effectiveData?.archiveStateTrusted ?? false;
// Filter by persona's agent pubkeys when a persona is provided.
// When the persona has known agent pubkeys, show only instances whose pubkey
// appears in that set plus any relay-only instances (not managed on this device
// but owned by the same user). When no persona is provided, show all instances.
// Instances for THIS persona only — from byPersonaId[persona.id].
// The parent groups by persona_id parsed from kind:30177 content, so this
// is the authoritative view: it includes relay-only instances (no local
// agent) and excludes instances from other personas.
const instances = React.useMemo(() => {
if (!persona || personaAgentPubkeys.size === 0) return allInstances;
// Show instances for this persona's known pubkeys, plus any relay-only
// instances that aren't matched to any local agent (orphaned relay instances).
return allInstances.filter((i) =>
personaAgentPubkeys.has(i.pubkey.toLowerCase()),
);
}, [allInstances, persona, personaAgentPubkeys]);
if (!persona || !effectiveData) return [];
return effectiveData.byPersonaId[persona.id] ?? [];
}, [effectiveData, persona]);
function handleArchive(pubkey: string) {
setConfirmArchivePubkey(pubkey);
@@ -230,6 +227,7 @@ export function InstancesSheet({
const archivePending = archiveMutation.isPending;
const unarchivePending = unarchiveMutation.isPending;
const isLoading = inventoryQuery.isLoading && !effectiveData;
return (
<>
@@ -248,7 +246,7 @@ export function InstancesSheet({
</SheetHeader>
<div className="mt-4 space-y-2 overflow-y-auto">
{inventoryQuery.isLoading ? (
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
@@ -269,7 +267,7 @@ export function InstancesSheet({
Retry
</Button>
</div>
) : !archiveStateTrusted && !inventoryQuery.isLoading ? (
) : !archiveStateTrusted && !isLoading ? (
<div className="space-y-2">
<p className="rounded-lg border border-warning/30 bg-warning/10 px-4 py-3 text-sm text-warning-foreground">
Archive status could not be verified from the relay. Archive
@@ -292,9 +290,6 @@ export function InstancesSheet({
archivePending={archivePending}
archiveStateTrusted={false}
instance={instance}
isManagedLocally={personaAgentPubkeys.has(
instance.pubkey.toLowerCase(),
)}
key={instance.pubkey}
unarchivePending={unarchivePending}
onArchive={handleArchive}
@@ -314,9 +309,6 @@ export function InstancesSheet({
archivePending={archivePending}
archiveStateTrusted={archiveStateTrusted}
instance={instance}
isManagedLocally={personaAgentPubkeys.has(
instance.pubkey.toLowerCase(),
)}
key={instance.pubkey}
unarchivePending={unarchivePending}
onArchive={handleArchive}
@@ -55,13 +55,18 @@ export type OwnedAgentInstance = {
/** NIP-OA owner proof for this instance — never omitted, only null in older responses. */
nipIaOwnerProof: NipIaOwnerProof;
archiveState: OwnedAgentArchiveState;
/** Persona ID parsed from `kind:30177` content. `null` for standalone agents. */
personaId: string | null;
};
/** Snapshot returned by `get_owned_agent_inventory`. */
/** Complete merged view model returned by `get_owned_agent_inventory`. */
export type OwnedAgentInventorySnapshot = {
/** Whether the archive snapshot was loaded and trusted. */
archiveStateTrusted: boolean;
instances: OwnedAgentInstance[];
/** Instances grouped by persona ID. Drives per-card counts and Sheet filtering. */
byPersonaId: Record<string, OwnedAgentInstance[]>;
/** Instances with no parseable persona ID (standalone agents). */
unknown: OwnedAgentInstance[];
};
type RawOwnerOfAgent = { owner: string; is_me: boolean };