diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 701c767b329..4b9b161def2 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/owned-agent-discovery.spec.ts", "**/thread-head-stale-edit.spec.ts", "**/sidebar-offcanvas-rail.spec.ts", "**/tooltip-semantics.spec.ts", diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs index 0b6bf22a6a9..eac91f5b71d 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -134,9 +134,25 @@ async fn list_relay_agents_for_selection( .await? .ok_or_else(|| "relay agent membership authority is unavailable".to_string())?; - // Membership is the authoritative and bounded candidate source. Only - // channels visible to this identity are read, and only bot-role p-tags can - // drive the downstream managed-policy and owner-profile lookups. + // Owned identities are relay state, even when this Desktop has never run + // them or they have not joined a channel yet. Owner-authored coordinates + // seed discovery only; the agent's signed NIP-OA profile still has to + // authenticate ownership below. Scope selection queries to the exact keys. + let mut owned_filter = serde_json::json!({ + "kinds": [30177], + "authors": [&viewer_pubkey], + }); + if let Some(requested_pubkeys) = requested_pubkeys { + owned_filter["#d"] = serde_json::json!(requested_pubkeys); + } + let owned_events = query_all_relay_pages(state, owned_filter) + .await + .map_err(|error| format!("relay owned-agent query failed: {error}"))?; + let owned_candidates = nostr_convert::managed_agent_pubkeys_from_events(&owned_events); + + // Membership remains authoritative and visible only to this viewer. + // Known owned identities can have any membership role; other candidates + // must still have explicit bot-role evidence. let mut membership_filter = serde_json::json!({ "kinds": [39002], "authors": [&relay_pubkey], @@ -148,12 +164,21 @@ async fn list_relay_agents_for_selection( let membership_events = query_all_relay_pages(state, membership_filter) .await .map_err(|error| format!("relay agent channel-membership query failed: {error}"))?; - let mut member_agent_channel_ids = - nostr_convert::member_agent_channel_ids_from_events(&membership_events, &relay_pubkey); + let mut member_agent_channel_ids = nostr_convert::member_agent_channel_ids_from_events( + &membership_events, + &relay_pubkey, + &owned_candidates, + ); if let Some(requested_pubkeys) = requested_pubkeys { member_agent_channel_ids.retain(|pubkey, _| requested_pubkeys.contains(pubkey)); } - let candidate_pubkeys: Vec = member_agent_channel_ids.keys().cloned().collect(); + let candidate_pubkeys: Vec = member_agent_channel_ids + .keys() + .cloned() + .chain(owned_candidates) + .collect::>() + .into_iter() + .collect(); if candidate_pubkeys.is_empty() { return Ok(Vec::new()); } @@ -206,7 +231,10 @@ async fn list_relay_agents_for_selection( &mut agents, crate::managed_agents::owner_only_access_build(), ); - agents.retain(|agent| member_agent_channel_ids.contains_key(&agent.pubkey)); + agents.retain(|agent| { + member_agent_channel_ids.contains_key(&agent.pubkey) + || agent.owner_pubkey.as_deref() == Some(viewer_pubkey.as_str()) + }); for agent in &mut agents { agent.channel_ids = member_agent_channel_ids .get(&agent.pubkey) @@ -569,3 +597,6 @@ mod real_relay_tests { assert_eq!(emitted_mentions, vec![agent.public_key().to_hex()]); } } + +#[cfg(test)] +mod owned_tests; diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory/owned_tests.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory/owned_tests.rs new file mode 100644 index 00000000000..bb42b3e6d24 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory/owned_tests.rs @@ -0,0 +1,192 @@ +//! Exercise the production query plan against a loopback relay with signed fixtures. +use super::*; +use axum::{ + routing::{get, post}, + Json, Router, +}; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use std::sync::{Arc, Mutex}; + +#[tokio::test] +async fn remote_owned_discovery_and_membership_do_not_require_local_records() { + let _serial = crate::relay_admission::TEST_SERIAL.lock().await; + crate::relay_admission::reset_rate_limit_gate(); + let relay = Keys::generate(); + let owner = Keys::generate(); + let agent = Keys::generate(); + let stranger = Keys::generate(); + let agent_key = agent.public_key().to_hex(); + let owner_key = owner.public_key().to_hex(); + let relay_key = relay.public_key().to_hex(); + let auth = buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + let auth: Vec = serde_json::from_str(&auth).unwrap(); + let profile = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Remote Scout"}"#) + .tags([Tag::parse(auth).unwrap()]) + .sign_with_keys(&agent) + .unwrap(); + let policy = |key: &str| { + EventBuilder::new( + Kind::Custom(30177), + r#"{"name":"Remote Scout","parallelism":1,"respond_to":"owner-only"}"#, + ) + .tags([Tag::parse(["d", key]).unwrap()]) + .sign_with_keys(&owner) + .unwrap() + }; + // An owner-authored coordinate is a discovery hint, not ownership proof. + let forged = policy(&stranger.public_key().to_hex()); + let stranger_profile = EventBuilder::new(Kind::Metadata, "{}") + .sign_with_keys(&stranger) + .unwrap(); + let events = Arc::new(Mutex::new(vec![ + profile, + policy(&agent_key), + forged, + stranger_profile, + ])); + let queries = Arc::new(Mutex::new(Vec::::new())); + let query_events = events.clone(); + let query_log = queries.clone(); + let router = Router::new() + .route( + "/", + get(move || { + let key = relay_key.clone(); + async move { Json(serde_json::json!({"self": key})) } + }), + ) + .route( + "/query", + post(move |Json(filters): Json>| { + let events = query_events.clone(); + let queries = query_log.clone(); + async move { + queries.lock().unwrap().extend(filters.clone()); + let events = events.lock().unwrap(); + let result: Vec<_> = events + .iter() + .filter(|event| { + filters.iter().any(|filter| { + filter["kinds"] + .as_array() + .unwrap() + .contains(&serde_json::json!(event.kind.as_u16())) + && filter.get("authors").is_none_or(|authors| { + authors + .as_array() + .unwrap() + .contains(&serde_json::json!(event.pubkey.to_hex())) + }) + && ["d", "p"].iter().all(|tag| { + filter.get(format!("#{tag}")).is_none_or(|values| { + event.tags.iter().any(|t| { + t.as_slice().first().map(String::as_str) + == Some(*tag) + && t.as_slice().get(1).is_some_and(|value| { + values + .as_array() + .unwrap() + .contains(&serde_json::json!(value)) + }) + }) + }) + }) + }) + }) + .cloned() + .collect(); + Json(result) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + let state = crate::app_state::build_app_state(); + *state.keys.lock().unwrap() = owner.clone(); + *state.relay_url_override.lock().unwrap() = Some(format!("ws://{address}")); + + let discovered = list_relay_agents_for_state(&state).await.unwrap(); + assert_eq!(discovered.len(), 1, "forged ownership must not be admitted"); + assert_eq!(discovered[0].pubkey, agent_key); + assert_eq!( + discovered[0].owner_pubkey.as_deref(), + Some(owner_key.as_str()) + ); + assert!( + discovered[0].channel_ids.is_empty(), + "discovery is not membership" + ); + + let membership = EventBuilder::new(Kind::Custom(39002), "") + .tags([ + Tag::parse(["d", "general"]).unwrap(), + Tag::parse(["p", &owner_key, "", "member"]).unwrap(), + Tag::parse(["p", &agent_key, "", "member"]).unwrap(), + ]) + .sign_with_keys(&relay) + .unwrap(); + events.lock().unwrap().push(membership); + let requested = std::collections::HashSet::from([agent_key.clone()]); + let admitted = list_relay_agents_for_selection(&state, Some(&requested), Some("general")) + .await + .unwrap(); + assert_eq!(admitted.len(), 1); + assert_eq!(admitted[0].channel_ids, vec!["general".to_string()]); + let outside = list_relay_agents_for_selection(&state, Some(&requested), Some("private-other")) + .await + .unwrap(); + assert_eq!(outside.len(), 1); + assert!( + outside[0].channel_ids.is_empty(), + "ownership cannot fabricate destination membership" + ); + // A newer signed snapshot revokes membership, even if an old snapshot + // is also returned. The owned identity remains discoverable, not admitted. + let removed = EventBuilder::new(Kind::Custom(39002), "") + .tags([ + Tag::parse(["d", "general"]).unwrap(), + Tag::parse(["p", &owner_key, "", "member"]).unwrap(), + ]) + .custom_created_at(nostr::Timestamp::from( + nostr::Timestamp::now().as_secs() + 1, + )) + .sign_with_keys(&relay) + .unwrap(); + events.lock().unwrap().push(removed); + let revoked = list_relay_agents_for_selection(&state, Some(&requested), Some("general")) + .await + .unwrap(); + assert!(revoked[0].channel_ids.is_empty()); + + let deny = EventBuilder::new( + Kind::Custom(30177), + r#"{"name":"Remote Scout","parallelism":1,"respond_to":"nobody"}"#, + ) + .tags([Tag::parse(["d", &agent_key]).unwrap()]) + .custom_created_at(nostr::Timestamp::from( + nostr::Timestamp::now().as_secs() + 2, + )) + .sign_with_keys(&owner) + .unwrap(); + events.lock().unwrap().push(deny); + let denied = list_relay_agents_for_selection(&state, Some(&requested), Some("general")) + .await + .unwrap(); + assert!( + denied.is_empty(), + "latest unsupported policy cannot fall back to an older allow" + ); + + assert!(queries + .lock() + .unwrap() + .iter() + .any(|filter| filter["kinds"] == serde_json::json!([30177]) + && filter["authors"] == serde_json::json!([owner_key]) + && filter.get("#d").is_none())); + server.abort(); + crate::relay_admission::reset_rate_limit_gate(); +} diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index 64c8df05a79..51f648769d3 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -57,30 +57,47 @@ fn tags_named<'a>(event: &'a Event, name: &'a str) -> impl Iterator Option { - let target_hex = event.pubkey.to_hex(); - let Ok(target_pubkey) = nostr::PublicKey::from_hex(&target_hex) else { + if event.kind != nostr::Kind::Metadata { return None; - }; + } - for tag in event.tags.iter() { - let slice = tag.as_slice(); - if slice.first().map(String::as_str) != Some("auth") || slice.len() != 4 { - continue; - } - let Ok(json) = serde_json::to_string(slice) else { - continue; - }; - if let Ok(owner_pubkey) = buzz_sdk_pkg::nip_oa::verify_auth_tag(&json, &target_pubkey) { - return Some(owner_pubkey.to_hex()); - } + let mut auth_tags = tags_named(event, "auth"); + let auth_tag = auth_tags.next()?; + // Count malformed auth tags too: no first-valid-tag fallback is permitted. + if auth_tags.next().is_some() { + return None; } - None + let json = serde_json::to_string(auth_tag).ok()?; + // The structural parser also enforces canonical lowercase key/signature hex. + buzz_sdk_pkg::nip_oa::parse_auth_tag(&json).ok()?; + event.verify().ok()?; + let owner = buzz_sdk_pkg::nip_oa::verify_auth_tag(&json, &event.pubkey).ok()?; + let conditions = auth_tag.get(2)?; + // Syntax/ranges were checked by the SDK; evaluate every signed clause as-is. + let applies = conditions.is_empty() + || conditions.split('&').all(|clause| { + if let Some(value) = clause.strip_prefix("kind=") { + value.parse::() == Ok(event.kind.as_u16()) + } else if let Some(value) = clause.strip_prefix("created_at<") { + value + .parse::() + .is_ok_and(|bound| event.created_at.as_secs() < bound) + } else if let Some(value) = clause.strip_prefix("created_at>") { + value + .parse::() + .is_ok_and(|bound| event.created_at.as_secs() > bound) + } else { + false + } + }); + + applies.then(|| owner.to_hex()) } pub(crate) fn profile_has_valid_oa_owner(event: &Event) -> bool { @@ -588,3 +605,6 @@ fn days_to_ymd(days: i64) -> (i64, u32, u32) { #[cfg(test)] mod tests; + +#[cfg(test)] +mod oa_profile_tests; diff --git a/desktop/src-tauri/src/nostr_convert/agent_directory.rs b/desktop/src-tauri/src/nostr_convert/agent_directory.rs index 28604de5e5f..1429efa4fa6 100644 --- a/desktop/src-tauri/src/nostr_convert/agent_directory.rs +++ b/desktop/src-tauri/src/nostr_convert/agent_directory.rs @@ -14,6 +14,7 @@ use super::{agents_from_events, first_tag_value, profile_valid_oa_owner_pubkey, pub fn managed_agent_pubkeys_from_events(events: &[Event]) -> std::collections::HashSet { events .iter() + .filter(|event| event.kind == nostr::Kind::Custom(30177) && event.verify().is_ok()) .filter_map(|event| first_tag_value(event, "d")) .filter_map(|pubkey| nostr::PublicKey::from_hex(pubkey).ok()) .map(|pubkey| pubkey.to_hex()) @@ -40,9 +41,25 @@ fn relay_agents_from_legacy_events(events: &[Event]) -> Vec { latest .into_values() .filter_map(|event| { + if event.kind != nostr::Kind::Custom(10100) || event.verify().is_err() { + return None; + } let value = agents_from_events(std::slice::from_ref(event)); let mut agent: RelayAgentInfo = serde_json::from_value(value.get("agents")?.as_array()?.first()?.clone()).ok()?; + // The generic converter defaults missing status to offline for + // compatibility. Discovery must retain only explicit, known runtime + // evidence from this verified latest event, never that fallback. + agent.status = serde_json::from_str::(&event.content) + .ok() + .and_then(|content| { + content + .get("status")? + .as_str() + .filter(|status| matches!(*status, "online" | "away" | "offline")) + .map(str::to_owned) + }) + .unwrap_or_else(|| "unknown".to_string()); // Legacy directory entries are not authenticated managed-policy // coordinates, so they must not drive the live 30177 watcher. agent.owner_pubkey = None; @@ -67,11 +84,15 @@ pub fn relay_agents_from_directory_events( .into_iter() .map(|agent| (agent.pubkey.clone(), agent)) .collect(); - for agent_pubkey in verified_policies.keys() { - agents.remove(agent_pubkey); - } for (agent_pubkey, event) in verified_policies { - if let Some(agent) = relay_agent_from_managed_policy(&agent_pubkey, event) { + // Remove even when policy parsing fails: invalid latest policy must not + // revive runtime permissions. Only verified runtime liveness survives + // a valid policy overlay; ownership, permissions and membership do not. + let runtime = agents.remove(&agent_pubkey); + if let Some(mut agent) = relay_agent_from_managed_policy(&agent_pubkey, event) { + if let Some(runtime) = runtime { + agent.status = runtime.status; + } agents.insert(agent_pubkey, agent); } } @@ -96,6 +117,9 @@ pub fn verified_agent_owners_from_profiles(events: &[Event]) -> HashMap( } fn relay_agent_from_managed_policy(agent_pubkey: &str, event: &Event) -> Option { + // Check the envelope as well as the declared author. Keep invalid latest + // coordinates reserved above so they cannot revive older legacy permissions. + if event.kind != nostr::Kind::Custom(30177) || event.verify().is_err() { + return None; + } let content = managed_agent_content_from_event(event).ok()?; Some(RelayAgentInfo { pubkey: agent_pubkey.to_string(), @@ -135,7 +164,8 @@ fn relay_agent_from_managed_policy(agent_pubkey: &str, event: &Event) -> Option< channels: Vec::new(), channel_ids: Vec::new(), capabilities: Vec::new(), - status: "offline".to_string(), + // Ownership/policy proves discovery, not conversational liveness. + status: "unknown".to_string(), respond_to: Some(content.respond_to), respond_to_allowlist: content.respond_to_allowlist, }) @@ -157,28 +187,48 @@ pub fn relay_agents_from_managed_agent_events( } /// Build a pubkey-to-channel-id candidate map from relay-signed membership -/// events. Only p-tags explicitly marked with the `bot` role are agents. +/// events. Known agent identities need not have the cosmetic `bot` role; +/// otherwise only explicit bot tags seed discovery. pub fn member_agent_channel_ids_from_events( events: &[Event], relay_pubkey: &str, + known_agent_pubkeys: &std::collections::HashSet, ) -> HashMap> { - let mut channel_ids: HashMap> = HashMap::new(); + let mut latest: HashMap = HashMap::new(); for event in events { - if !event.pubkey.to_hex().eq_ignore_ascii_case(relay_pubkey) { + if event.kind != nostr::Kind::Custom(39002) + || !event.pubkey.to_hex().eq_ignore_ascii_case(relay_pubkey) + || event.verify().is_err() + { continue; } let Some(channel_id) = first_tag_value(event, "d") else { continue; }; + if latest + .get(channel_id) + .is_none_or(|previous| event_is_newer(event, previous)) + { + latest.insert(channel_id.to_string(), event); + } + } + let mut channel_ids: HashMap> = HashMap::new(); + for (channel_id, event) in latest { for tag in tags_named(event, "p") { - let (Some(pubkey), Some(role)) = (tag.get(1), tag.get(3)) else { + let Some(pubkey) = tag + .get(1) + .and_then(|key| nostr::PublicKey::from_hex(key).ok()) + else { continue; }; - if role != "bot" || nostr::PublicKey::from_hex(pubkey).is_err() { + let pubkey = pubkey.to_hex(); + if tag.get(3).map(String::as_str) != Some("bot") + && !known_agent_pubkeys.contains(&pubkey) + { continue; } channel_ids - .entry(pubkey.clone()) + .entry(pubkey) .or_default() .insert(channel_id.to_string()); } diff --git a/desktop/src-tauri/src/nostr_convert/oa_profile_tests.rs b/desktop/src-tauri/src/nostr_convert/oa_profile_tests.rs new file mode 100644 index 00000000000..0e031b70b52 --- /dev/null +++ b/desktop/src-tauri/src/nostr_convert/oa_profile_tests.rs @@ -0,0 +1,216 @@ +//! NIP-OA profile regressions. All keys, timestamps and Schnorr nonces are +//! synthetic and fixed; these fixtures require no clock, RNG, relay or config. + +use nostr::hashes::{sha256, Hash}; +use nostr::secp256k1::{schnorr::Signature, Keypair, Message}; +use nostr::{Event, EventBuilder, Keys, Kind, SecretKey, Tag, Timestamp, SECP256K1}; + +use super::{ + profile_has_valid_oa_owner, profile_info_from_event, profile_valid_oa_owner_pubkey, tags_named, + user_search_result_from_event, users_batch_from_events, verified_agent_owners_from_profiles, +}; + +const CREATED_AT: u64 = 1_700_000_000; + +// Public test scalars, matching the owner/agent identities in NIP-OA's vectors. +fn keys(scalar: u8) -> Keys { + let mut bytes = [0; 32]; + bytes[31] = scalar; + Keys::new(SecretKey::from_slice(&bytes).unwrap()) +} + +fn sign(keys: &Keys, message: Message) -> Signature { + let keypair = Keypair::from_secret_key(SECP256K1, keys.secret_key()); + SECP256K1.sign_schnorr_no_aux_rand(&message, &keypair) +} + +// Intentionally bypass the SDK's *creation* validation so malformed conditions +// and self-attestation can have genuine signatures and exercise verification. +fn auth_tag_for(owner: &Keys, agent: &Keys, conditions: &str) -> Tag { + let preimage = format!( + "nostr:agent-auth:{}:{conditions}", + agent.public_key().to_hex() + ); + let digest = sha256::Hash::hash(preimage.as_bytes()).to_byte_array(); + let signature = sign(owner, Message::from_digest(digest)); + Tag::parse(vec![ + "auth".to_string(), + owner.public_key().to_hex(), + conditions.to_string(), + signature.to_string(), + ]) + .unwrap() +} + +fn auth_tag(conditions: &str) -> Tag { + auth_tag_for(&keys(1), &keys(2), conditions) +} + +fn event(kind: Kind, created_at: u64, tags: Vec) -> Event { + let agent = keys(2); + let mut unsigned = EventBuilder::new(kind, r#"{"display_name":"Synthetic agent"}"#) + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .build(agent.public_key()); + let signature = sign(&agent, Message::from_digest(unsigned.id().to_bytes())); + unsigned.add_signature(signature).unwrap() +} + +fn profile(tags: Vec) -> Event { + event(Kind::Metadata, CREATED_AT, tags) +} + +fn assert_ownership(event: &Event, expected: Option) { + assert_eq!(profile_valid_oa_owner_pubkey(event), expected); + assert_eq!(profile_has_valid_oa_owner(event), expected.is_some()); + + let info = profile_info_from_event(event).unwrap(); + assert_eq!(info.owner_pubkey, expected); + assert_eq!(info.pubkey, event.pubkey.to_hex()); + let search = user_search_result_from_event(event); + assert_eq!(search.owner_pubkey, expected); + assert_eq!(search.is_agent, expected.is_some()); + assert_eq!(search.pubkey, event.pubkey.to_hex()); + let pubkey = event.pubkey.to_hex(); + let batch = users_batch_from_events(std::slice::from_ref(event), std::slice::from_ref(&pubkey)); + assert_eq!(batch.profiles[&pubkey].owner_pubkey, expected); + assert_eq!(batch.profiles[&pubkey].is_agent, expected.is_some()); + let owners = verified_agent_owners_from_profiles(std::slice::from_ref(event)); + assert_eq!(owners.get(&pubkey), expected.as_ref()); +} + +#[test] +fn accepts_unconditional_and_applicable_conditional_ownership() { + for conditions in [ + "", + "kind=0", + "created_at>1699999999&kind=0&created_at<1700000001", + "created_at<1700000001&created_at>1699999999&kind=0&kind=0", + ] { + let tag = auth_tag(conditions); + // Check that deterministic fixture signing agrees with the SDK verifier. + let json = serde_json::to_string(tag.as_slice()).unwrap(); + assert_eq!( + buzz_sdk_pkg::nip_oa::verify_auth_tag(&json, &keys(2).public_key()).unwrap(), + keys(1).public_key() + ); + assert_ownership(&profile(vec![tag]), Some(keys(1).public_key().to_hex())); + } +} + +#[test] +fn rejects_duplicate_auth_tags_including_malformed_tags_in_either_order() { + let valid = auth_tag(""); + let malformed = Tag::parse(["auth"]).unwrap(); + for tags in [ + vec![valid.clone(), valid.clone()], + vec![valid.clone(), auth_tag("kind=0")], + vec![valid.clone(), malformed.clone()], + vec![malformed, valid], + ] { + let event = profile(tags); + assert_eq!(tags_named(&event, "auth").count(), 2); + assert_ownership(&event, None); + } +} + +#[test] +fn rejects_wrong_kind_condition_and_conflicting_clauses() { + for conditions in ["kind=1", "kind=0&kind=1", "kind=1&kind=0"] { + assert_ownership(&profile(vec![auth_tag(conditions)]), None); + } +} + +#[test] +fn time_bounds_are_strict_and_use_event_time_not_wall_clock() { + let tag = auth_tag("created_at>1699999999&created_at<1700000001"); + for (timestamp, accepted) in [ + (1_699_999_998, false), + (1_699_999_999, false), + (CREATED_AT, true), + (1_700_000_001, false), + (1_700_000_002, false), + (u64::from(u32::MAX) + 1, false), + ] { + let event = event(Kind::Metadata, timestamp, vec![tag.clone()]); + let expected = accepted.then(|| keys(1).public_key().to_hex()); + assert_ownership(&event, expected); + } +} + +#[test] +fn rejects_malformed_tag_shapes_and_hex() { + let valid = auth_tag("").as_slice().to_vec(); + let mut extra = valid.clone(); + extra.push("extra".to_string()); + let mut bad_owner = valid.clone(); + bad_owner[1] = "not-a-pubkey".to_string(); + let mut uppercase_owner = valid.clone(); + uppercase_owner[1] = uppercase_owner[1].to_uppercase(); + let mut uppercase_signature = valid.clone(); + uppercase_signature[3] = uppercase_signature[3].to_uppercase(); + let mut bad_signature = valid.clone(); + bad_signature[3] = "00".repeat(64); + for values in [ + vec!["auth".to_string()], + valid[..3].to_vec(), + extra, + bad_owner, + uppercase_owner, + uppercase_signature, + bad_signature, + ] { + assert_ownership(&profile(vec![Tag::parse(values).unwrap()]), None); + } +} + +#[test] +fn rejects_signed_but_malformed_conditions() { + for conditions in [ + "kind=0&", + "&kind=0", + "kind=0&&kind=0", + "kind=00", + "kind=65536", + "kind=0 ", + "kind=٠", + "Kind=0", + "created_at=1700000000", + "created_at<4294967296", + "created_at>-1", + "unsupported=0", + ] { + assert_ownership(&profile(vec![auth_tag(conditions)]), None); + } +} + +#[test] +fn rejects_absent_authority_self_attestation_and_wrong_agent_binding() { + assert_ownership(&profile(vec![]), None); + assert_ownership( + &profile(vec![ + Tag::parse(["owner", &keys(1).public_key().to_hex()]).unwrap() + ]), + None, + ); + assert_ownership(&profile(vec![auth_tag_for(&keys(2), &keys(2), "")]), None); + assert_ownership(&profile(vec![auth_tag_for(&keys(1), &keys(3), "")]), None); +} + +#[test] +fn rejects_non_profile_and_invalid_event_even_with_valid_auth_tag() { + assert_ownership(&event(Kind::TextNote, CREATED_AT, vec![auth_tag("")]), None); + + let mut wrong_id = profile(vec![auth_tag("")]); + wrong_id.content = r#"{"display_name":"Tampered"}"#.to_string(); + assert!(wrong_id.verify().is_err()); + assert_ownership(&wrong_id, None); + + let mut wrong_signature = profile(vec![auth_tag("")]); + wrong_signature.sig = sign( + &keys(3), + Message::from_digest(wrong_signature.id.to_bytes()), + ); + assert!(wrong_signature.verify().is_err()); + assert_ownership(&wrong_signature, None); +} diff --git a/desktop/src-tauri/src/nostr_convert/runtime_policy_tests.rs b/desktop/src-tauri/src/nostr_convert/runtime_policy_tests.rs new file mode 100644 index 00000000000..dd0dda7f11e --- /dev/null +++ b/desktop/src-tauri/src/nostr_convert/runtime_policy_tests.rs @@ -0,0 +1,143 @@ +//! Bind availability provenance to the production runtime/policy merge. + +use super::*; + +fn fixture() -> (Keys, Event, Event) { + let owner = Keys::generate(); + let agent = Keys::generate(); + let auth = buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "") + .expect("compute ownership"); + let values: Vec = serde_json::from_str(&auth).expect("parse ownership"); + let profile = EventBuilder::new(Kind::Metadata, "{}") + .tags([Tag::parse(values).expect("ownership tag")]) + .sign_with_keys(&agent) + .expect("sign identity"); + let policy = managed_agent_event( + &owner, + &agent.public_key().to_hex(), + "Policy name", + "allowlist", + &["a".repeat(64)], + ); + (agent, profile, policy) +} + +fn runtime(keys: &Keys, status: Option, timestamp: u64) -> Event { + let mut content = serde_json::json!({ + "name": "Runtime name", + "owner_pubkey": "b".repeat(64), + "respond_to": "anyone", + "respond_to_allowlist": ["b".repeat(64)], + "channels": ["Untrusted"], + "channel_ids": ["untrusted-channel"], + "capabilities": ["untrusted-capability"] + }); + if let Some(status) = status { + content["status"] = status; + } + EventBuilder::new(Kind::Custom(10100), content.to_string()) + .custom_created_at(nostr::Timestamp::from(timestamp)) + .sign_with_keys(keys) + .expect("sign runtime") +} + +fn assert_merge(directory: &[Event], profile: &Event, policy: &Event, status: &str) { + let merged = relay_agents_from_directory_events( + directory, + std::slice::from_ref(policy), + std::slice::from_ref(profile), + ); + assert_eq!(merged.len(), 1); + let agent = &merged[0]; + assert_eq!(agent.status, status); + assert_eq!(serde_json::to_value(agent).unwrap()["status"], status); + assert_eq!(agent.pubkey, profile.pubkey.to_hex()); + assert_eq!(agent.owner_pubkey, Some(policy.pubkey.to_hex())); + assert_eq!(agent.name, "Policy name"); + assert_eq!( + agent.respond_to, + Some(crate::managed_agents::RespondTo::Allowlist) + ); + assert_eq!(agent.respond_to_allowlist, vec!["a".repeat(64)]); + assert!( + agent.channel_ids.is_empty(), + "runtime cannot grant membership" + ); + assert!(agent.channels.is_empty()); + assert!(agent.capabilities.is_empty()); +} + +fn assert_known_status(status: &str) { + let (keys, profile, policy) = fixture(); + assert_merge( + &[runtime(&keys, Some(json!(status)), 10)], + &profile, + &policy, + status, + ); +} + +#[test] +fn policy_preserves_signed_online_runtime() { + assert_known_status("online"); +} + +#[test] +fn policy_preserves_signed_away_runtime() { + assert_known_status("away"); +} + +#[test] +fn policy_preserves_signed_offline_runtime() { + assert_known_status("offline"); +} + +#[test] +fn missing_or_unrecognized_runtime_status_is_unknown() { + let (keys, profile, policy) = fixture(); + for status in [ + None, + Some(Value::Null), + Some(json!(42)), + Some(json!("busy")), + Some(json!("unknown")), + ] { + let directory = runtime(&keys, status, 10); + assert_merge( + std::slice::from_ref(&directory), + &profile, + &policy, + "unknown", + ); + let legacy = relay_agents_from_directory_events(&[directory], &[], &[]); + assert_eq!(legacy[0].status, "unknown", "no default offline evidence"); + } +} + +#[test] +fn policy_only_has_unknown_availability() { + let (_, profile, policy) = fixture(); + assert_merge(&[], &profile, &policy, "unknown"); +} + +#[test] +fn latest_runtime_without_status_does_not_revive_older_online_status() { + let (keys, profile, policy) = fixture(); + let online = runtime(&keys, Some(json!("online")), 10); + let missing = runtime(&keys, None, 20); + for directory in [[online.clone(), missing.clone()], [missing, online]] { + assert_merge(&directory, &profile, &policy, "unknown"); + } +} + +#[test] +fn forged_latest_runtime_cannot_supply_or_revive_availability() { + let (keys, profile, policy) = fixture(); + let old = runtime(&keys, Some(json!("online")), 10); + let new = runtime(&keys, Some(json!("away")), 20); + let mut value = serde_json::to_value(new).unwrap(); + value["content"] = json!(r#"{"status":"online"}"#); + let forged: Event = serde_json::from_value(value).unwrap(); + assert!(forged.verify().is_err()); + assert_merge(&[old, forged], &profile, &policy, "unknown"); +} diff --git a/desktop/src-tauri/src/nostr_convert/tests.rs b/desktop/src-tauri/src/nostr_convert/tests.rs index 9401d19add4..68d8cb7dcbb 100644 --- a/desktop/src-tauri/src/nostr_convert/tests.rs +++ b/desktop/src-tauri/src/nostr_convert/tests.rs @@ -3,6 +3,9 @@ use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; +#[path = "runtime_policy_tests.rs"] +mod runtime_policy_tests; + /// Build a signed event for testing with the given kind, content, and tags. fn ev(kind: u16, content: &str, tags: Vec>) -> Event { let keys = Keys::generate(); @@ -437,6 +440,11 @@ fn managed_agent_directory_accepts_only_the_verified_owner_policy() { assert_eq!(agents.len(), 1); assert_eq!(agents[0].pubkey, agent_pubkey); assert_eq!(agents[0].name, "Codex"); + assert_eq!(agents[0].status, "unknown"); + assert_eq!( + serde_json::to_value(&agents[0]).unwrap()["status"], + "unknown" + ); assert_eq!( agents[0].respond_to, Some(crate::managed_agents::RespondTo::Allowlist) @@ -510,8 +518,11 @@ fn managed_agent_candidates_use_only_relay_signed_bot_membership() { vec![vec!["d", "forged"], vec!["p", &agent_pubkey, "", "bot"]], ); - let channel_ids = - member_agent_channel_ids_from_events(&[forged, general], &relay_keys.public_key().to_hex()); + let channel_ids = member_agent_channel_ids_from_events( + &[forged, general], + &relay_keys.public_key().to_hex(), + &Default::default(), + ); assert_eq!( channel_ids.get(&agent_pubkey), @@ -760,3 +771,62 @@ fn timestamp_to_iso_known_value() { // Epoch assert_eq!(timestamp_to_iso(0), "1970-01-01T00:00:00Z"); } + +#[test] +fn known_owned_agents_have_membership_independent_of_role() { + let relay = Keys::generate(); + let agent = Keys::generate().public_key().to_hex(); + let event = EventBuilder::new(Kind::Custom(39002), "") + .tags([ + Tag::parse(["d", "general"]).unwrap(), + Tag::parse(["p", &agent, "", "member"]).unwrap(), + ]) + .sign_with_keys(&relay) + .unwrap(); + let memberships = member_agent_channel_ids_from_events( + &[event], + &relay.public_key().to_hex(), + &std::collections::HashSet::from([agent.clone()]), + ); + assert_eq!(memberships.get(&agent), Some(&vec!["general".to_string()])); +} + +#[test] +fn managed_directory_rejects_tampered_event_envelopes() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let auth = buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + let auth: Vec = serde_json::from_str(&auth).unwrap(); + let profile = EventBuilder::new(Kind::Metadata, "{}") + .tags([Tag::parse(auth).unwrap()]) + .sign_with_keys(&agent) + .unwrap(); + let policy = managed_agent_event( + &owner, + &agent.public_key().to_hex(), + "Scout", + "owner-only", + &[], + ); + let tamper = |event: &Event, content: &str| -> Event { + let mut value = serde_json::to_value(event).unwrap(); + value["content"] = serde_json::json!(content); + serde_json::from_value(value).unwrap() + }; + let forged_policy = tamper( + &policy, + r#"{"name":"Scout","parallelism":1,"respond_to":"anyone"}"#, + ); + assert!(forged_policy.verify().is_err()); + assert!( + relay_agents_from_managed_agent_events(&[forged_policy], std::slice::from_ref(&profile),) + .is_empty(), + "an owner pubkey string is not an owner signature" + ); + let forged_profile = tamper(&profile, r#"{"name":"forged"}"#); + assert!(forged_profile.verify().is_err()); + assert!( + relay_agents_from_managed_agent_events(&[policy], &[forged_profile],).is_empty(), + "a valid OA tag does not authenticate the profile envelope" + ); +} diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index 594fa1cbaa5..4e526692246 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -7,7 +7,6 @@ import { XCircle, } from "lucide-react"; -import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -40,7 +39,8 @@ import { import { buildTranscriptState } from "./agentSessionTranscript"; type ManagedAgentSessionPanelProps = { - agent: Pick & { + agent: Pick & { + status: ManagedAgent["status"] | "unknown"; avatarUrl?: string | null; }; autoTail?: boolean; @@ -76,7 +76,7 @@ export function ManagedAgentSessionPanel({ rawEventsOverride, transcriptOverride, }: ManagedAgentSessionPanelProps) { - const hasObserver = isManagedAgentActive(agent); + const hasObserver = agent.status === "running" || agent.status === "deployed"; // Always read from the store — archived frames are ingested regardless of // live status and must be renderable for idle agents with channel history. // The `hasObserver` flag still gates the relay subscription (via the diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index def497ac4a1..db6155c3c47 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -9,7 +9,6 @@ import { import { toast } from "sonner"; import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; -import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { mergeObserverEventWindows, observerEventScrollId, @@ -98,7 +97,7 @@ export function AgentSessionThreadPanel({ widthPx, transparentChrome = false, }: AgentSessionThreadPanelProps) { - const isLive = isManagedAgentActive(agent); + const isLive = agent.status === "running" || agent.status === "deployed"; const isOverlay = useIsThreadPanelOverlay(); const sessionChannelId = channelId ?? channel?.id ?? null; // Unified working signal, scoped to this panel's channel (or all channels diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 9e15cdc6359..e6121c25dd1 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -332,7 +332,7 @@ export function MembersSidebar({ displayName: agent.name, avatarUrl: null, nip05Handle: null, - ownerPubkey: null, + ownerPubkey: agent.ownerPubkey, isAgent: true, }); } diff --git a/desktop/src/features/channels/ui/unknownRelayAgentStatus.test.mjs b/desktop/src/features/channels/ui/unknownRelayAgentStatus.test.mjs new file mode 100644 index 00000000000..7adba9ece19 --- /dev/null +++ b/desktop/src/features/channels/ui/unknownRelayAgentStatus.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { buildChannelAgentSessionCandidates } from "./useChannelAgentSessions.ts"; +import { useActiveAgentPubkeys } from "../../messages/lib/useActiveAgentPubkeys.ts"; + +const relayAgents = ["unknown", "online", "away", "offline"].map((status) => ({ + pubkey: status, + name: status, + status, + channelIds: [], + channels: [], +})); + +test("session projection retains unknown rather than manufacturing deployed status", () => { + const candidates = buildChannelAgentSessionCandidates({ + managedAgents: [], + relayAgents, + }); + assert.deepEqual( + candidates.map(({ status }) => status), + ["unknown", "deployed", "deployed", "stopped"], + ); +}); + +test("active-agent lookup requires positive relay liveness evidence", () => { + let active; + function Probe() { + active = useActiveAgentPubkeys([], relayAgents); + return null; + } + renderToStaticMarkup(React.createElement(Probe)); + assert.deepEqual([...active], ["online", "away"]); +}); diff --git a/desktop/src/features/channels/ui/useChannelAgentSessions.ts b/desktop/src/features/channels/ui/useChannelAgentSessions.ts index 8dd22bb94a9..e12c1bc7768 100644 --- a/desktop/src/features/channels/ui/useChannelAgentSessions.ts +++ b/desktop/src/features/channels/ui/useChannelAgentSessions.ts @@ -19,10 +19,8 @@ import { } from "./agentSessionSelection"; import type { PanelValueSetter } from "./useChannelPanelHistoryState"; -export type ChannelAgentSessionAgent = Pick< - ManagedAgent, - "pubkey" | "name" | "status" -> & { +export type ChannelAgentSessionAgent = Pick & { + status: ManagedAgent["status"] | "unknown"; agentSource: "managed" | "member-bot" | "relay"; canInterruptTurn: boolean; channelIds?: string[]; @@ -52,7 +50,8 @@ type UseChannelAgentSessionsOptions = { function relayStatusToManagedStatus( status: RelayAgent["status"], -): ManagedAgent["status"] { +): ChannelAgentSessionAgent["status"] { + if (status === "unknown") return "unknown"; return status === "offline" ? "stopped" : "deployed"; } diff --git a/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs b/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs index 250e40e0a38..abf14a6bd47 100644 --- a/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs +++ b/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs @@ -130,3 +130,22 @@ test("global search results join only while global search is enabled", () => { assert.equal(searched[0].displayName, "Dana"); assert.equal(searched[0].isGlobalSearchResult, true); }); + +test("policy-only discovery stays selectable without claiming active presence", () => { + const [candidate] = buildMentionCandidates( + input({ + mentionableAgentPubkeys: new Set([AGENT_PUBKEY]), + relayAgents: [ + { + pubkey: AGENT_PUBKEY, + name: "Scout", + ownerPubkey: MEMBER_PUBKEY, + status: "unknown", + }, + ], + }), + ); + assert.equal(candidate.pubkey, AGENT_PUBKEY); + assert.equal(candidate.isActiveAgent, false); + assert.equal(candidate.ownerPubkey, MEMBER_PUBKEY); +}); diff --git a/desktop/src/features/messages/lib/buildMentionCandidates.ts b/desktop/src/features/messages/lib/buildMentionCandidates.ts index 45bdf591e0c..78b998a2619 100644 --- a/desktop/src/features/messages/lib/buildMentionCandidates.ts +++ b/desktop/src/features/messages/lib/buildMentionCandidates.ts @@ -181,7 +181,7 @@ export function buildMentionCandidates({ (activePersonaById.has(pubkey) ? pubkey : undefined), ownerPubkey: agent.ownerPubkey, isAgent: true, - isActiveAgent: agent.status !== "offline", + isActiveAgent: agent.status === "online" || agent.status === "away", }); } for (const agent of managedAgents ?? []) { diff --git a/desktop/src/features/messages/lib/useActiveAgentPubkeys.ts b/desktop/src/features/messages/lib/useActiveAgentPubkeys.ts index 0ac56e72eb2..e23e47767f3 100644 --- a/desktop/src/features/messages/lib/useActiveAgentPubkeys.ts +++ b/desktop/src/features/messages/lib/useActiveAgentPubkeys.ts @@ -16,7 +16,9 @@ export function useActiveAgentPubkeys( ) .map((agent) => normalizePubkey(agent.pubkey)), ...(relayAgents ?? []) - .filter((agent) => agent.status !== "offline") + .filter( + (agent) => agent.status === "online" || agent.status === "away", + ) .map((agent) => normalizePubkey(agent.pubkey)), ]), [managedAgents, relayAgents], diff --git a/desktop/src/features/messages/ui/useNewMessageRecipients.ts b/desktop/src/features/messages/ui/useNewMessageRecipients.ts index 494ef124d26..ffc45c6c9e2 100644 --- a/desktop/src/features/messages/ui/useNewMessageRecipients.ts +++ b/desktop/src/features/messages/ui/useNewMessageRecipients.ts @@ -178,7 +178,7 @@ export function useNewMessageRecipients({ displayName: agent.name, avatarUrl: null, nip05Handle: null, - ownerPubkey: null, + ownerPubkey: agent.ownerPubkey, isAgent: true, }, { includeSelected: deferredSearchQuery.length > 0 }, diff --git a/desktop/src/features/profile/lib/profileActivityAgent.test.mjs b/desktop/src/features/profile/lib/profileActivityAgent.test.mjs new file mode 100644 index 00000000000..094ac13dfe9 --- /dev/null +++ b/desktop/src/features/profile/lib/profileActivityAgent.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { resolveProfileActivityAgent } from "./profileActivityAgent.ts"; + +const input = { + effectivePubkey: "a".repeat(64), + isBot: true, + managedAgent: undefined, + profile: { displayName: "Scout" }, + relayAgent: undefined, + viewerIsOwner: true, +}; + +for (const [status, expected] of [ + ["unknown", "unknown"], + ["online", "deployed"], + ["away", "deployed"], + ["offline", "stopped"], +]) { + test(`profile activity preserves relay ${status} evidence`, () => { + const agent = resolveProfileActivityAgent({ + ...input, + relayAgent: { status, name: "Relay Scout" }, + }); + assert.equal(agent.status, expected); + assert.equal(agent.pubkey, input.effectivePubkey); + }); +} + +test("profile activity cannot infer liveness from ownership alone", () => { + assert.equal(resolveProfileActivityAgent(input).status, "unknown"); + assert.equal( + resolveProfileActivityAgent({ ...input, viewerIsOwner: false }), + null, + ); +}); + +test("local managed runtime status still takes precedence", () => { + assert.equal( + resolveProfileActivityAgent({ + ...input, + managedAgent: { + pubkey: input.effectivePubkey, + name: "Local Scout", + status: "running", + }, + relayAgent: { status: "unknown" }, + }).status, + "running", + ); +}); diff --git a/desktop/src/features/profile/lib/profileActivityAgent.ts b/desktop/src/features/profile/lib/profileActivityAgent.ts index 2bc4fe44655..93a1caf4ab0 100644 --- a/desktop/src/features/profile/lib/profileActivityAgent.ts +++ b/desktop/src/features/profile/lib/profileActivityAgent.ts @@ -1,9 +1,7 @@ import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; -export type ProfileActivityAgent = Pick< - ManagedAgent, - "pubkey" | "name" | "status" -> & { +export type ProfileActivityAgent = Pick & { + status: ManagedAgent["status"] | "unknown"; avatarUrl?: string | null; }; @@ -39,6 +37,11 @@ export function resolveProfileActivityAgent({ avatarUrl: profile?.avatarUrl ?? null, name: relayAgent?.name ?? profile?.displayName?.trim() ?? "Agent", pubkey: effectivePubkey, - status: relayAgent?.status === "offline" ? "stopped" : "deployed", + status: + !relayAgent || relayAgent.status === "unknown" + ? "unknown" + : relayAgent.status === "offline" + ? "stopped" + : "deployed", }; } diff --git a/desktop/src/features/profile/lib/profileActivityFeedScope.test.mjs b/desktop/src/features/profile/lib/profileActivityFeedScope.test.mjs new file mode 100644 index 00000000000..c56d2b41ef9 --- /dev/null +++ b/desktop/src/features/profile/lib/profileActivityFeedScope.test.mjs @@ -0,0 +1,250 @@ +import assert from "node:assert/strict"; +import { after, afterEach, beforeEach, test } from "node:test"; +import { JSDOM } from "jsdom"; + +import { + clearActiveTurnsForAgent, + getActiveTurnsForAgent, + resetActiveAgentTurnsStore, + syncAgentTurnsFromEvents, + useActiveAgentTurns, +} from "@/features/agents/activeAgentTurnsStore.ts"; +import { + getAgentObserverSnapshot, + getAgentTranscript, + resetAgentObserverStore, + syncAgentObserverEvents, +} from "@/features/agents/observerRelayStore.ts"; +import { + useAgentTranscript, + useObserverEvents, +} from "@/features/agents/ui/useObserverEvents.ts"; +import { resolveProfileActivityAgent } from "./profileActivityAgent.ts"; +import { useProfileActivityFeedScope } from "./profileActivityFeedScope.ts"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); +Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, +}); +const { act, cleanup, renderHook } = await import("@testing-library/react"); +const AGENT = "a".repeat(64); +const OTHER_AGENT = "b".repeat(64); +const nativeCalls = []; +window.__TAURI_INTERNALS__ = { + invoke: async (...args) => { + nativeCalls.push(args); + throw new Error( + "Reading stored profile history must not invoke native APIs", + ); + }, +}; + +beforeEach(() => { + resetAgentObserverStore(); + resetActiveAgentTurnsStore(); + nativeCalls.length = 0; +}); +afterEach(() => { + cleanup(); + resetAgentObserverStore(); + resetActiveAgentTurnsStore(); + assert.deepEqual( + nativeCalls, + [], + "history reads must not start subscriptions", + ); +}); +after(() => dom.window.close()); + +function resolveAgent(relayStatus, overrides = {}) { + return resolveProfileActivityAgent({ + effectivePubkey: AGENT, + isBot: true, + managedAgent: undefined, + profile: { displayName: "Scout" }, + relayAgent: relayStatus + ? { name: "Scout", status: relayStatus } + : undefined, + viewerIsOwner: true, + ...overrides, + }); +} + +function event(seq, kind, channelId = "general", payload = {}) { + return { + seq, + timestamp: new Date(Date.UTC(2026, 8, 2) + seq * 1000).toISOString(), + kind, + agentIndex: 0, + channelId, + sessionId: "session", + turnId: "turn", + payload, + }; +} + +function message(seq, channelId) { + return event(seq, "acp_read", channelId, { + method: "session/update", + params: { + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Completed the review." }, + }, + }, + }); +} + +function useScope(agent) { + const turns = useActiveAgentTurns(agent?.pubkey); + return useProfileActivityFeedScope(agent, turns); +} + +const idleScope = { + channelIds: [], + hasFeedContent: false, + isLive: false, + latestActivityAtByChannel: {}, + preferredChannelId: null, +}; + +for (const [evidence, agent] of [ + ["unknown", resolveAgent("unknown")], + ["absent", resolveAgent(undefined)], + ["offline", resolveAgent("offline")], + ["online", resolveAgent("online")], + [ + "local stopped", + resolveAgent("unknown", { + managedAgent: { pubkey: AGENT, name: "Local Scout", status: "stopped" }, + }), + ], +]) { + test(`${evidence} evidence retains completed raw history with no active turns`, () => { + const completed = event(1, "turn_completed"); + syncAgentObserverEvents(AGENT, [completed]); + syncAgentTurnsFromEvents(AGENT, [completed]); + assert.deepEqual(getActiveTurnsForAgent(AGENT), []); + assert.deepEqual(getAgentTranscript(AGENT), []); + + const { result } = renderHook(() => useScope(agent)); + assert.deepEqual(result.current, { + channelIds: ["general"], + hasFeedContent: true, + isLive: false, + latestActivityAtByChannel: { general: Date.parse(completed.timestamp) }, + preferredChannelId: "general", + }); + if (evidence === "unknown" || evidence === "absent") { + assert.equal(agent.status, "unknown", "history is not liveness evidence"); + } + }); + + test(`${evidence} evidence reads stored transcript as well as raw events`, () => { + // The last raw event has no transcript row. The existing scope contract + // prefers the last transcript channel; dropping either read is detectable. + syncAgentObserverEvents(AGENT, [ + message(1, "general"), + event(2, "turn_completed", "general"), + event(3, "turn_completed", "random"), + ]); + const transcript = getAgentTranscript(AGENT); + assert.equal(transcript.length, 1); + assert.equal(transcript[0].channelId, "general"); + const { result } = renderHook(() => useScope(agent)); + assert.deepEqual(result.current.channelIds, ["general", "random"]); + assert.equal(result.current.preferredChannelId, "general"); + assert.equal(result.current.hasFeedContent, true); + assert.equal(result.current.isLive, false); + }); +} + +for (const evidence of ["unknown", undefined]) { + for (const finish of ["completion", "clear"]) { + test(`${evidence ?? "absent"} evidence keeps history after active turn ${finish}`, () => { + const agent = resolveAgent(evidence); + const started = event(1, "turn_started"); + syncAgentObserverEvents(AGENT, [started, message(2, "general")]); + syncAgentTurnsFromEvents(AGENT, [started]); + const { result } = renderHook(() => useScope(agent)); + assert.equal(result.current.isLive, true); + assert.deepEqual(result.current.channelIds, ["general"]); + + act(() => { + const completed = event(3, "turn_completed"); + syncAgentObserverEvents(AGENT, [completed]); + if (finish === "clear") clearActiveTurnsForAgent(AGENT); + else syncAgentTurnsFromEvents(AGENT, [completed]); + }); + assert.deepEqual(getActiveTurnsForAgent(AGENT), []); + assert.equal(result.current.isLive, false); + assert.equal(result.current.hasFeedContent, true); + assert.deepEqual(result.current.channelIds, ["general"]); + assert.equal(result.current.preferredChannelId, "general"); + assert.equal(getAgentObserverSnapshot(AGENT).events.length, 3); + assert.equal(getAgentTranscript(AGENT).length, 2); + assert.equal(agent.status, "unknown"); + }); + } +} + +test("store updates, evidence loss, agent switches and reset preserve history boundaries", () => { + const { result, rerender } = renderHook(({ agent }) => useScope(agent), { + initialProps: { agent: resolveAgent("online") }, + }); + assert.deepEqual(result.current, idleScope); + act(() => syncAgentObserverEvents(AGENT, [message(1, "general")])); + assert.equal(result.current.hasFeedContent, true); + + for (const evidence of ["unknown", undefined, "offline"]) { + rerender({ agent: resolveAgent(evidence) }); + assert.equal(result.current.hasFeedContent, true); + assert.equal(result.current.isLive, false); + assert.deepEqual(result.current.channelIds, ["general"]); + } + rerender({ + agent: resolveAgent(undefined, { effectivePubkey: OTHER_AGENT }), + }); + assert.deepEqual(result.current, idleScope); + rerender({ agent: resolveAgent(undefined) }); + assert.equal(result.current.hasFeedContent, true); + act(() => resetAgentObserverStore()); + assert.deepEqual(result.current, idleScope, "community reset hides old data"); +}); + +test("unowned, non-bot and missing identities cannot acquire stored history", () => { + syncAgentObserverEvents(AGENT, [message(1, "general")]); + for (const overrides of [ + { viewerIsOwner: false }, + { isBot: false }, + { effectivePubkey: null }, + ]) { + const agent = resolveAgent("unknown", overrides); + assert.equal(agent, null); + const { result, unmount } = renderHook(() => useScope(agent)); + assert.deepEqual(result.current, idleScope); + unmount(); + } +}); + +test("idle session readers and profile scope share history without a live subscription", () => { + syncAgentObserverEvents(AGENT, [ + message(1, "general"), + event(2, "turn_completed"), + ]); + const { result } = renderHook(() => ({ + scope: useScope(resolveAgent("unknown")), + observer: useObserverEvents(false, AGENT), + transcript: useAgentTranscript(false, AGENT), + })); + assert.equal(result.current.scope.hasFeedContent, true); + assert.equal(result.current.scope.isLive, false); + assert.equal(result.current.observer.events.length, 2); + assert.equal(result.current.transcript.length, 1); + assert.equal(result.current.observer.connectionState, "idle"); +}); diff --git a/desktop/src/features/profile/lib/profileActivityFeedScope.ts b/desktop/src/features/profile/lib/profileActivityFeedScope.ts index 596c04ec923..c57c32f335f 100644 --- a/desktop/src/features/profile/lib/profileActivityFeedScope.ts +++ b/desktop/src/features/profile/lib/profileActivityFeedScope.ts @@ -2,7 +2,6 @@ import * as React from "react"; import type { ActiveTurnSummary } from "@/features/agents/activeAgentTurnsStore"; import { subscribeActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; -import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { getAgentObserverSnapshot, getAgentTranscript, @@ -231,11 +230,8 @@ export function useProfileActivityFeedScope( const agentCacheKey = activityAgent ? normalizePubkey(activityAgent.pubkey) : "none"; - const hasObserver = - activityAgent !== null && isManagedAgentActive(activityAgent); - const getSnapshot = React.useCallback(() => { - if (!activityAgent || !hasObserver) { + if (!activityAgent) { return stableFeedScope( agentCacheKey, deriveProfileActivityFeedScope({ @@ -246,13 +242,15 @@ export function useProfileActivityFeedScope( ); } - const { events } = getAgentObserverSnapshot(activityAgent.pubkey, true); - const transcript = getAgentTranscript(activityAgent.pubkey, true); + // Availability gates live subscriptions, not already-observed history. + // Unknown or stopped agents can still have completed activity to show. + const { events } = getAgentObserverSnapshot(activityAgent.pubkey); + const transcript = getAgentTranscript(activityAgent.pubkey); return stableFeedScope( agentCacheKey, deriveProfileActivityFeedScope({ activeTurns, events, transcript }), ); - }, [activeTurns, activityAgent, agentCacheKey, hasObserver]); + }, [activeTurns, activityAgent, agentCacheKey]); const snapshot = React.useSyncExternalStore((onStoreChange) => { const unsubscribeObserver = subscribeAgentObserverStore(onStoreChange); diff --git a/desktop/src/features/projects/lib/projectAgentConversation.ts b/desktop/src/features/projects/lib/projectAgentConversation.ts index cd6180fc971..80d1c721777 100644 --- a/desktop/src/features/projects/lib/projectAgentConversation.ts +++ b/desktop/src/features/projects/lib/projectAgentConversation.ts @@ -194,7 +194,7 @@ export async function submitProjectAgentMessage({ openDm, send, }: { - agent: { pubkey: string; isManaged: boolean; isActive: boolean }; + agent: { pubkey: string; isManaged: boolean; isActive: boolean | null }; conversation: { channel: Ch; opener: ProjectsConversationOpener } | null; content: string; mentionPubkeys: string[]; diff --git a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx index 4e1f7c24a68..6d151666d6e 100644 --- a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx +++ b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx @@ -90,7 +90,7 @@ export type AgentCandidate = { personaId?: string | null; /** Managed agents can be auto-started before the prompt is sent. */ isManaged: boolean; - isActive: boolean; + isActive: boolean | null; }; type ProjectAgentConversation = { @@ -191,12 +191,18 @@ export function useAgentCandidates() { pubkey, name: agent.name, isManaged: false, - isActive: agent.status !== "offline", + isActive: + agent.status === "unknown" ? null : agent.status !== "offline", }); } return candidates.sort((left, right) => { - if (left.isActive !== right.isActive) return left.isActive ? -1 : 1; + // Unknown is neither offline nor proof that the agent can answer now. + const activityRank = (active: boolean | null) => + active === true ? 0 : active === null ? 1 : 2; + const activityOrder = + activityRank(left.isActive) - activityRank(right.isActive); + if (activityOrder) return activityOrder; if (left.isManaged !== right.isManaged) return left.isManaged ? -1 : 1; return left.name.localeCompare(right.name); }); @@ -704,14 +710,16 @@ export function ProjectsAgentPromptPage({ {candidate.name} - + {candidate.isActive !== null ? ( + + ) : null} ))} diff --git a/desktop/src/features/pulse/ui/AgentActivityCard.test.mjs b/desktop/src/features/pulse/ui/AgentActivityCard.test.mjs new file mode 100644 index 00000000000..78fb1306d11 --- /dev/null +++ b/desktop/src/features/pulse/ui/AgentActivityCard.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React from "react"; +import { + RouterContextProvider, + createRouter, + createRootRoute, + createMemoryHistory, +} from "@tanstack/react-router"; +import { renderToStaticMarkup } from "react-dom/server"; +import { AgentActivityCard } from "./AgentActivityCard.tsx"; + +function render(status) { + const pubkey = "a".repeat(64); + const router = createRouter({ + routeTree: createRootRoute(), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + return renderToStaticMarkup( + React.createElement( + RouterContextProvider, + { router }, + React.createElement( + QueryClientProvider, + { + client: new QueryClient({ + defaultOptions: { queries: { enabled: false } }, + }), + }, + React.createElement(AgentActivityCard, { + agentStatus: status, + profile: { displayName: "Policy-only Scout", avatarUrl: null }, + group: { + pubkey, + latestAt: 1700000000, + earliestAt: 1700000000, + notes: [ + { + id: "note", + pubkey, + content: "Still discoverable", + createdAt: 1700000000, + }, + ], + }, + }), + ), + ), + ); +} + +test("Pulse does not render unknown policy-only discovery as an offline dot", () => { + const html = render("unknown"); + assert.match(html, /Policy-only Scout/); + assert.match(html, /Still discoverable/); + assert.doesNotMatch(html, /aria-label="Agent (offline|online|away)"/); + assert.doesNotMatch(html, /bg-zinc-400/); +}); + +for (const status of ["online", "away", "offline"]) { + test(`Pulse retains explicit ${status} liveness evidence`, () => { + assert.match(render(status), new RegExp(`aria-label="Agent ${status}"`)); + }); +} diff --git a/desktop/src/features/pulse/ui/AgentActivityCard.tsx b/desktop/src/features/pulse/ui/AgentActivityCard.tsx index dd84b5c31fb..30e8601912a 100644 --- a/desktop/src/features/pulse/ui/AgentActivityCard.tsx +++ b/desktop/src/features/pulse/ui/AgentActivityCard.tsx @@ -11,7 +11,7 @@ import { truncatePubkey } from "@/shared/lib/pubkey"; type AgentActivityCardProps = { group: AgentNoteGroup; profile?: UserProfileSummary | null; - agentStatus?: "online" | "away" | "offline"; + agentStatus?: "online" | "away" | "offline" | "unknown"; }; function formatRelativeTime(unixSeconds: number): string { @@ -36,7 +36,13 @@ function StatusDot({ status }: { status: "online" | "away" | "offline" }) { : status === "away" ? "bg-amber-500" : "bg-zinc-400"; - return ; + return ( + + ); } export function AgentActivityCard({ @@ -79,7 +85,9 @@ export function AgentActivityCard({ {displayName} - {agentStatus ? : null} + {agentStatus && agentStatus !== "unknown" ? ( + + ) : null} {formatRelativeTime(group.latestAt)} diff --git a/desktop/src/features/pulse/ui/PulseView.tsx b/desktop/src/features/pulse/ui/PulseView.tsx index 1b5bcc7ac0e..2b595ed2ccb 100644 --- a/desktop/src/features/pulse/ui/PulseView.tsx +++ b/desktop/src/features/pulse/ui/PulseView.tsx @@ -128,7 +128,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) { [agentPubkeys], ); const agentStatusMap = React.useMemo(() => { - const map: Record = {}; + const map: Record = {}; for (const a of relayAgents) { map[a.pubkey] = a.status; } diff --git a/desktop/src/features/search/useSearchResults.ts b/desktop/src/features/search/useSearchResults.ts index a8da5a57f99..d2ed85527c1 100644 --- a/desktop/src/features/search/useSearchResults.ts +++ b/desktop/src/features/search/useSearchResults.ts @@ -392,7 +392,7 @@ export function useSearchResults({ displayName: agent.name, avatarUrl: null, nip05Handle: null, - ownerPubkey: null, + ownerPubkey: agent.ownerPubkey, isAgent: true, }; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 308ea46ace2..b95cdebb634 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -270,7 +270,8 @@ export type RelayAgent = { channels: string[]; channelIds: string[]; capabilities: string[]; - status: "online" | "away" | "offline"; + /** Policy-only discovery has no liveness evidence. */ + status: "online" | "away" | "offline" | "unknown"; respondTo: RespondToMode | null; respondToAllowlist: string[]; }; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index b31504fe0a0..a0a34ae5c5b 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -922,7 +922,7 @@ type RawRelayAgent = { channels: string[]; channel_ids: string[]; capabilities: string[]; - status: PresenceStatus; + status: PresenceStatus | "unknown"; respond_to?: "owner-only" | "allowlist" | "anyone"; respond_to_allowlist?: string[]; }; diff --git a/desktop/tests/e2e/owned-agent-discovery.spec.ts b/desktop/tests/e2e/owned-agent-discovery.spec.ts new file mode 100644 index 00000000000..46af9c495c1 --- /dev/null +++ b/desktop/tests/e2e/owned-agent-discovery.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from "@playwright/test"; +import { installMockBridge, openNewMessagePage } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; + +const AGENT = "a7".repeat(32); +const OWNER = "deadbeef".repeat(8); + +test.beforeEach(async ({ page }) => { + await installMockBridge(page, { + managedAgents: [], + searchProfiles: [], + relayAgents: [ + { + pubkey: AGENT, + name: "Policy-only Scout", + ownerPubkey: OWNER, + status: "unknown", + respondTo: "owner-only", + channelNames: [], + channelIds: [], + }, + ], + }); + await page.goto("/"); +}); + +test("New Message keeps authenticated owner without a user-search duplicate", async ({ + page, +}) => { + await openNewMessagePage(page); + await page.getByTestId("new-dm-search").fill("Policy-only Scout"); + const row = page.getByTestId(`new-dm-result-${AGENT}`); + await expect(row).toBeVisible(); + await expect(row).toContainText("managed by you"); + await waitForAnimations(page); + await row.screenshot({ + path: "test-results/owned-agent-discovery/new-message-owner.png", + }); +}); + +test("member-add keeps authenticated owner without a user-search duplicate", async ({ + page, +}) => { + await page.getByTestId("channel-general").click(); + await page.getByTestId("channel-members-trigger").click(); + await page + .getByTestId("channel-management-search-users") + .fill("Policy-only Scout"); + const row = page.getByTestId(`channel-user-search-result-${AGENT}`); + await expect(row).toBeVisible(); + await expect(row).toContainText("managed by you"); + await waitForAnimations(page); + await row.screenshot({ + path: "test-results/owned-agent-discovery/member-add-owner.png", + }); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 6694be68f5f..97c7096d5a5 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -56,6 +56,7 @@ type MockSearchProfileSeed = { type MockRelayAgentSeed = { pubkey: string; + ownerPubkey?: string | null; name: string; agentType?: string; capabilities?: string[]; @@ -63,7 +64,7 @@ type MockRelayAgentSeed = { respondToAllowlist?: string[]; channelNames?: string[]; channelIds?: string[]; - status?: "online" | "away" | "offline"; + status?: "online" | "away" | "offline" | "unknown"; }; type MockHuddleSeed = { diff --git a/docs/owned-agent-discovery.md b/docs/owned-agent-discovery.md new file mode 100644 index 00000000000..d4d96a41189 --- /dev/null +++ b/docs/owned-agent-discovery.md @@ -0,0 +1,58 @@ +# Authenticated owned-agent discovery + +An owner-authored kind 30177 coordinate seeds discovery independently of local +runtime inventory and shared-channel membership. It is not ownership proof. The +latest agent kind 0 profile must have a valid envelope and exactly one valid +NIP-OA auth tag. Every signed condition is evaluated against event time, never +wall-clock time. The owner remains provenance, not the agent's author identity. + +Owner policy coordinates must match that verified owner and have valid signed +envelopes. Invalid latest policy reserves the coordinate and fails closed: it +cannot revive a legacy permission. Missing policy retains existing OSS legacy +compatibility; marked builds require verified owner policy. This change does not +invent a missing-policy default or broaden policy audiences. + +Membership comes from the latest relay-signed kind 39002 snapshot for each +channel. Known owned agents need not carry the cosmetic bot role. Ownership does +not fabricate membership: a nonmember may be discovered with empty channel_ids. +Selection queries constrain exact requested keys and destination. Relay authority +is obtained through the existing community-bound relay admission boundary. + +This native change expands discovery only. Preparing an invitation and freshly +authorizing publication to its exact destination belong to the subsequent +mention-routing change; discovery alone does not authorize a message. + +## Regression gates + +- `commands/agent_discovery/relay_directory/owned_tests.rs`: signed loopback + discovery with no local record, forged ownership exclusion, ordinary-role + membership, wrong destination, revocation and unsupported latest policy. +- `nostr_convert/oa_profile_tests.rs`: signed envelope, duplicate/malformed auth, + canonical conditions and signed-event time, wrong-owner policy, invalid latest + policy, forged and stale membership. +- `nostr_convert/tests.rs`: signed conversion fixtures and OSS compatibility. +- `nostr_convert/runtime_policy_tests.rs`: known signed runtime status survives + policy overlay; absent/unrecognized status, policy-only discovery, and an + invalid or status-less latest runtime cannot fabricate or revive availability. + +NIP-OA time conditions are not wall-clock expiry (see `docs/nips/NIP-OA.md`). Relay +membership and policy reads remain separate snapshots, not an atomic publication +transaction. Independent security review is required before landing. + +## Presentation preserves evidence + +Policy-only agents carry `status: "unknown"` through IPC. When the same agent has +an independently verified latest kind 10100 runtime record, its explicit +`online`, `away`, or `offline` status survives the owner-policy overlay. Missing +or unrecognized runtime status remains unknown, not the generic converter's +legacy offline default. Policy still controls ownership and permissions; claimed +runtime membership is not restored. An invalid latest policy still excludes the +agent rather than falling back to runtime permissions. + +Discovery alone supplies no liveness evidence: Pulse and Projects omit the status +dot rather than inventing offline or active presence, and active-agent lookups +require positive evidence. +Channel and profile activity projections preserve unknown rather than claiming a +deployed observer; local managed runtime status still takes precedence. +Authenticated `ownerPubkey` is retained by New Message, member-add, and search +candidates even when no user-search profile duplicates the relay result.