diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 5c5bdbc5666..738a8d58689 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -72,6 +72,7 @@ export default defineConfig({ "**/composer-selection-formatting.spec.ts", "**/composer-tooltip-dismiss.spec.ts", "**/mentions.spec.ts", + "**/remote-owned-mentions.spec.ts", "**/team-mentions.spec.ts", "**/persistent-agent-audience.spec.ts", "**/relay-reconnect.spec.ts", @@ -81,6 +82,7 @@ export default defineConfig({ "**/workflow-local-controls.spec.ts", "**/workflow-title-stability.spec.ts", "**/identity-archive.spec.ts", + "**/remote-agent-identity-ux.spec.ts", "**/identity-archive-hide.spec.ts", "**/relay-connectivity.spec.ts", "**/unread-pill.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 db0573acd7c..891e94de9f2 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) @@ -568,3 +596,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..fba103b58e6 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,6 +41,9 @@ 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()?; @@ -96,6 +100,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(), @@ -157,28 +169,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/tests.rs b/desktop/src-tauri/src/nostr_convert/tests.rs index 9401d19add4..7111dd6caaf 100644 --- a/desktop/src-tauri/src/nostr_convert/tests.rs +++ b/desktop/src-tauri/src/nostr_convert/tests.rs @@ -510,8 +510,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 +763,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/AGENTS.md b/desktop/src/features/agents/AGENTS.md index d9df8c164db..c13fe2d3c24 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -200,7 +200,13 @@ with a TypeScript lookup table or an id comparison in a component. agent from Agents, a DM, or a channel must expose the same actions, tabs, fields, and profile-wide activity selection. Caller context may control the panel shell or return navigation, but must not filter or replace profile - content. + content. Explicit public-key targets are always exact, including stopped, + archived, and relay-only identities. Only explicit persona navigation may + select a representative or offer persona Start; a relay persona link cannot + borrow a local sibling's management controls. Availability dots read relay + presence, never a saved deployment receipt. Missing local management proves + only “Not managed on this device,” not hosting location. See + [the identity contract](../../../../docs/agent-profile-identity.md). 14. **Thinking effort has two surfaces: a local-only WRITE control and a read-only two-facts DISPLAY.** The write control is `EffortPickerField` (`ui/EffortPickerField.tsx`), a self-contained section component mounted in @@ -236,12 +242,16 @@ with a TypeScript lookup table or an id comparison in a component. mid-conversation effort control without a plan ruling. The archived live-effort machinery lives on `archive/claude-config-gaps-live-effort` for reference only. -12. **Owner-only builds constrain managed runtimes, not relay-agent mentions.** +16. **Owner-only builds constrain managed runtimes, not relay-agent mentions.** The compiled owner-only capability applies when Desktop starts or deploys a managed agent. Independently operated relay agents with NIP-OA ownership remain eligible in every build when their verified owner's signed `respond_to` policy admits the viewer and relay membership includes the - target channel. Marked builds require that verified owner coordinate but do + target channel at publication. Owned nonmember identities may be offered + during preparation; the existing authorized add flow must succeed before a + fresh target-membership/policy check permits publication. Selected recipients + that fail that check block sending and preserve the draft, rather than + silently becoming plain text. Marked builds require that verified owner coordinate but do not require it to equal the viewer; OSS builds retain compatibility with self-authored legacy directory records. Keep native discovery and send-time revalidation fail closed on invalid ownership or managed policy evidence, diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 5398b28f055..7ffd01097c8 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -548,3 +548,92 @@ test("coalesceAgentAutocompleteCandidates: leaves non-agents alone", () => { assert.deepEqual(coalesce([first, second]), [first, second]); }); + +test("owners remain admitted by allowlist policy without listing themselves", () => { + assert.equal( + relayAgentCanRespondInChannel( + { + ownerPubkey: CURRENT_PUBKEY, + respondTo: "allowlist", + respondToAllowlist: [], + channelIds: ["general"], + }, + "general", + CURRENT_PUBKEY, + ), + true, + ); +}); + +test("owned discovery does not require a shared channel, but sending does", () => { + for (const respondTo of ["owner-only", "allowlist", "anyone"]) { + const agent = { + pubkey: PUB_B, + ownerPubkey: CURRENT_PUBKEY, + respondTo, + respondToAllowlist: [], + channelIds: [], + }; + assert.equal( + relayAgentIsSharedWithUser(agent, new Set(), CURRENT_PUBKEY), + true, + ); + assert.equal( + relayAgentCanRespondInChannel(agent, "general", CURRENT_PUBKEY), + false, + ); + } +}); + +test("DM ownership is independent of local configuration and still requires membership", () => { + const base = { + currentPubkey: CURRENT_PUBKEY, + managedAgentPubkeys: [PUB_A], + sharedChannelIds: new Set(), + relayAgents: [ + { + pubkey: PUB_B, + ownerPubkey: CURRENT_PUBKEY, + respondTo: "allowlist", + respondToAllowlist: [], + channelIds: ["dm"], + }, + { + pubkey: PUB_C, + ownerPubkey: OTHER_OWNER_PUBKEY, + respondTo: "anyone", + respondToAllowlist: [], + channelIds: ["dm"], + }, + { + pubkey: PUB_D, + ownerPubkey: CURRENT_PUBKEY, + respondTo: "nobody", + respondToAllowlist: [], + channelIds: ["dm"], + }, + ], + }; + assert.deepEqual( + getMentionableAgentPubkeys({ + ...base, + eligibilityScope: { type: "owned", channelId: "dm" }, + }), + new Set([PUB_A, PUB_B]), + ); + assert.deepEqual( + getMentionableAgentPubkeys({ + ...base, + eligibilityScope: { type: "owned", channelId: "other" }, + }), + new Set([PUB_A]), + ); + assert.deepEqual( + getMentionableAgentPubkeys({ + ...base, + eligibilityScope: { type: "owned", channelId: null }, + phase: "prepare", + }), + new Set([PUB_A, PUB_B]), + ); +}); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index e3c82cfff4f..65655f8163f 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -34,12 +34,17 @@ export function relayAgentIsSharedWithUser( ? normalizePubkey(currentPubkey) : null; + // Ownership is relay identity, not local key custody. Like the harness's + // author gate, every supported policy except nobody admits the owner. if ( - agent.respondTo === "owner-only" && + (agent.respondTo === "owner-only" || + agent.respondTo === "allowlist" || + agent.respondTo === "anyone") && normalizedCurrentPubkey && - agent.ownerPubkey + agent.ownerPubkey && + normalizePubkey(agent.ownerPubkey) === normalizedCurrentPubkey ) { - return normalizePubkey(agent.ownerPubkey) === normalizedCurrentPubkey; + return true; } if (agent.respondTo === "allowlist" && normalizedCurrentPubkey) { @@ -71,6 +76,7 @@ export function relayAgentCanRespondInChannel( export type AgentEligibilityScope = | { type: "community" } | { type: "channel"; channelId: string } + | { type: "owned"; channelId: string | null } | { type: "managed-only" }; export function getMentionableAgentPubkeys({ @@ -79,9 +85,11 @@ export function getMentionableAgentPubkeys({ managedAgentPubkeys, relayAgents, sharedChannelIds, + phase = "publish", }: { currentPubkey?: string | null; eligibilityScope: AgentEligibilityScope; + phase?: "prepare" | "publish"; managedAgentPubkeys: Iterable; relayAgents: readonly RelayAgent[] | undefined; sharedChannelIds: ReadonlySet; @@ -94,13 +102,38 @@ export function getMentionableAgentPubkeys({ const isAllowed = eligibilityScope.type === "managed-only" ? false - : eligibilityScope.type === "community" - ? relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey) - : relayAgentCanRespondInChannel( - agent, - eligibilityScope.channelId, - currentPubkey, - ); + : eligibilityScope.type === "owned" + ? Boolean( + currentPubkey && + agent.ownerPubkey && + normalizePubkey(agent.ownerPubkey) === + normalizePubkey(currentPubkey) && + relayAgentIsSharedWithUser( + agent, + sharedChannelIds, + currentPubkey, + ) && + (phase === "prepare" || + (eligibilityScope.channelId !== null && + agent.channelIds.includes(eligibilityScope.channelId))), + ) + : eligibilityScope.type === "community" + ? relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey) + : phase === "prepare" && + currentPubkey && + agent.ownerPubkey && + normalizePubkey(agent.ownerPubkey) === + normalizePubkey(currentPubkey) + ? relayAgentIsSharedWithUser( + agent, + sharedChannelIds, + currentPubkey, + ) + : relayAgentCanRespondInChannel( + agent, + eligibilityScope.channelId, + currentPubkey, + ); if (isAllowed) { pubkeys.add(normalizePubkey(agent.pubkey)); } diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index 8a4a6898cce..8223d500401 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -31,6 +31,7 @@ export type ManagedAgentActionResult = { noticeMessage?: string; }; +/** Lifecycle action routing only; deployed is a retained receipt, not presence. */ export function isManagedAgentActive(agent: Pick) { return agent.status === "running" || agent.status === "deployed"; } @@ -133,7 +134,8 @@ export async function stopManagedAgentWithRules({ agent.pubkey, ]); return { - noticeMessage: "Shutdown command sent. Agent will stop shortly.", + noticeMessage: + "Shutdown requested. This does not confirm the agent has stopped.", }; } diff --git a/desktop/src/features/agents/lib/otherSetupAgent.test.mjs b/desktop/src/features/agents/lib/otherSetupAgent.test.mjs index f57c7f8154f..a45ddb5e64c 100644 --- a/desktop/src/features/agents/lib/otherSetupAgent.test.mjs +++ b/desktop/src/features/agents/lib/otherSetupAgent.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { isOtherSetupAgent } from "./otherSetupAgent.ts"; +import { + isOtherSetupAgent, + isOwnedAgentNotManagedOnDevice, +} from "./otherSetupAgent.ts"; const OWNER = "a".repeat(64); const AGENT = "b".repeat(64); @@ -20,7 +23,7 @@ test("fails closed while the local managed directory is unresolved", () => { ); }); -test("labels a viewer-owned non-local identity as another setup", () => { +test("labels a viewer-owned identity as not managed on this device", () => { assert.equal( isOtherSetupAgent({ agentDirectoriesReady: true, @@ -33,3 +36,38 @@ test("labels a viewer-owned non-local identity as another setup", () => { true, ); }); + +test("a locally managed provider is not labeled as another device", () => { + assert.equal( + isOtherSetupAgent({ + agentDirectoriesReady: true, + currentPubkey: OWNER, + managedAgents: [{ pubkey: AGENT, backend: { type: "provider" } }], + profileOwnerPubkey: OWNER, + pubkey: AGENT, + relayAgents: [], + }), + false, + ); +}); + +for (const [name, overrides, expected] of [ + ["owned absent key", {}, true], + ["loading local inventory", { localInventoryReady: false }, false], + ["exact local provider record", { isLocallyManaged: true }, false], + ["different owner", { ownerPubkey: "b".repeat(64) }, false], + ["unknown ownership", { ownerPubkey: null }, false], +]) { + test(`shared provenance: ${name}`, () => { + assert.equal( + isOwnedAgentNotManagedOnDevice({ + currentPubkey: "a".repeat(64), + ownerPubkey: "A".repeat(64), + localInventoryReady: true, + isLocallyManaged: false, + ...overrides, + }), + expected, + ); + }); +} diff --git a/desktop/src/features/agents/lib/otherSetupAgent.ts b/desktop/src/features/agents/lib/otherSetupAgent.ts index 63438a983fd..f94215e1f3a 100644 --- a/desktop/src/features/agents/lib/otherSetupAgent.ts +++ b/desktop/src/features/agents/lib/otherSetupAgent.ts @@ -1,6 +1,7 @@ import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; +/** Owned identity absent from the loaded local inventory; not evidence of hosting location. */ export function isOtherSetupAgent({ agentDirectoriesReady, currentPubkey, @@ -32,8 +33,31 @@ export function isOtherSetupAgent({ )?.ownerPubkey; const ownerPubkey = profileOwnerPubkey ?? relayOwnerPubkey; + return isOwnedAgentNotManagedOnDevice({ + currentPubkey, + ownerPubkey, + localInventoryReady: agentDirectoriesReady, + isLocallyManaged: false, + }); +} + +/** Presentation provenance only; neither hosting location nor availability. */ +export function isOwnedAgentNotManagedOnDevice({ + currentPubkey, + ownerPubkey, + localInventoryReady, + isLocallyManaged, +}: { + currentPubkey?: string; + ownerPubkey?: string | null; + localInventoryReady: boolean; + isLocallyManaged: boolean; +}): boolean { return Boolean( - ownerPubkey && + localInventoryReady && + !isLocallyManaged && + currentPubkey && + ownerPubkey && normalizePubkey(ownerPubkey) === normalizePubkey(currentPubkey), ); } diff --git a/desktop/src/features/agents/lib/pickProfileAgent.test.mjs b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs index 710b5fc4be8..9e542be5a90 100644 --- a/desktop/src/features/agents/lib/pickProfileAgent.test.mjs +++ b/desktop/src/features/agents/lib/pickProfileAgent.test.mjs @@ -1,10 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { - pickDirectProfileAgent, - pickProfileAgent, -} from "./pickProfileAgent.ts"; +import { pickProfileAgent } from "./pickProfileAgent.ts"; const NONE_ARCHIVED = () => false; @@ -68,60 +65,3 @@ test("a fail-open predicate keeps every instance eligible while loading", () => // Fail-open (all false) during the archive-snapshot window: normal ranking. assert.equal(pickProfileAgent([stopped, running], NONE_ARCHIVED), running); }); - -test("a direct-opened active instance is never redirected to a sibling", () => { - // "Alpha Sibling" sorts before "Tyler Agent"; without the direct guard an - // access edit on Tyler would target the sibling. - const sibling = { - name: "Alpha Sibling", - pubkey: "a".repeat(64), - status: "running", - }; - const clicked = { - name: "Tyler Agent", - pubkey: "b".repeat(64), - status: "running", - }; - - assert.equal( - pickDirectProfileAgent(clicked, [sibling, clicked], NONE_ARCHIVED), - clicked, - ); -}); - -test("a direct-opened inactive instance redirects to the active sibling", () => { - const historical = { - name: "Earlier Parity Agent", - pubkey: "a".repeat(64), - status: "stopped", - }; - const current = { - name: "Current Parity Agent", - pubkey: "b".repeat(64), - status: "running", - }; - - assert.equal( - pickDirectProfileAgent(historical, [historical, current], NONE_ARCHIVED), - current, - ); -}); - -test("a direct-opened inactive instance with no active sibling stays put", () => { - const clicked = { - name: "Only Instance", - pubkey: "a".repeat(64), - status: "stopped", - }; - const otherStopped = { - name: "Another Stopped", - pubkey: "b".repeat(64), - status: "stopped", - }; - - assert.equal( - pickDirectProfileAgent(clicked, [clicked, otherStopped], NONE_ARCHIVED), - clicked, - ); - assert.equal(pickDirectProfileAgent(clicked, [], NONE_ARCHIVED), clicked); -}); diff --git a/desktop/src/features/agents/lib/pickProfileAgent.ts b/desktop/src/features/agents/lib/pickProfileAgent.ts index dc2437c86ea..19de21f7903 100644 --- a/desktop/src/features/agents/lib/pickProfileAgent.ts +++ b/desktop/src/features/agents/lib/pickProfileAgent.ts @@ -5,8 +5,8 @@ import type { ManagedAgent } from "@/shared/api/types"; * Pick the instance that represents a persona throughout the UI. * * A persona can have several historical agent instances. Keeping this rule in - * one place prevents an avatar click on an older message from opening a - * different detail surface than the card in the Agents library. + * one place keeps persona navigation consistent. Explicit pubkey navigation + * never uses this selector: older messages still name their exact author. * * Relay-archived instances are never eligible, so an archived record early in * file order can't hijack the persona target. Returns `undefined` when every @@ -28,25 +28,3 @@ export function pickProfileAgent( return left.name.localeCompare(right.name); })[0]; } - -/** - * Resolve which instance a profile panel opened for `directAgent` should - * show, given every instance of the same persona. - * - * Access edits must target the exact instance the user clicked — resolving a - * running sidebar member to an alphabetically-earlier sibling would let a - * "tighten access" save widen the wrong agent. But when the clicked instance - * is inactive and the persona has an active instance elsewhere (an avatar on - * an old message from a retired instance), redirect to the active one so the - * panel matches the Agents library. The `isArchived` predicate keeps that - * redirect from ever landing on an archived sibling. - */ -export function pickDirectProfileAgent( - directAgent: ManagedAgent, - personaInstances: readonly ManagedAgent[], - isArchived: (pubkey: string) => boolean, -) { - if (isManagedAgentActive(directAgent)) return directAgent; - const canonical = pickProfileAgent(personaInstances, isArchived); - return canonical && isManagedAgentActive(canonical) ? canonical : directAgent; -} diff --git a/desktop/src/features/agents/lib/useAgentAvailability.test.mjs b/desktop/src/features/agents/lib/useAgentAvailability.test.mjs new file mode 100644 index 00000000000..5197d9308bd --- /dev/null +++ b/desktop/src/features/agents/lib/useAgentAvailability.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { resolveAgentAvailability } from "./useAgentAvailability.ts"; +import { + getManagedAgentPrimaryActionLabel, + isManagedAgentActive, +} from "./managedAgentControlActions.ts"; +import { AgentRuntimeAvatarControl } from "../ui/AgentRuntimeAvatarControl.tsx"; + +const deployed = { + status: "deployed", + backend: { type: "provider", id: "fixture" }, + backendAgentId: "retained-receipt", +}; + +for (const presence of ["online", "away", "offline", undefined]) { + test(`retained deployment receipt does not supply availability (${presence})`, () => { + const availability = resolveAgentAvailability(presence, true, true); + assert.equal(availability, presence ?? "offline"); + // Controls retain their existing routing. Offline is not permission to + // spawn a second body, nor proof that a shutdown message succeeded. + assert.equal(isManagedAgentActive(deployed), true); + assert.equal(getManagedAgentPrimaryActionLabel(deployed), "Shutdown"); + const html = renderToStaticMarkup( + createElement(AgentRuntimeAvatarControl, { + activeTestId: "active", + isActive: true, + availability, + isStarting: false, + label: "Agent", + startTestId: "start", + onStart() {}, + }), + ); + assert.doesNotMatch(html, /is running/); + assert.match( + html, + new RegExp( + `Agent: ${availability[0].toUpperCase()}${availability.slice(1)}`, + ), + ); + assert.equal(html.includes("bg-emerald-500"), availability === "online"); + assert.doesNotMatch(html, /data-testid="start"/); + }); +} + +for (const [loaded, connected] of [ + [false, true], + [true, false], + [false, false], +]) { + test(`unavailable presence is unknown, not cached online (${loaded}, ${connected})`, () => { + const availability = resolveAgentAvailability("online", loaded, connected); + assert.equal(availability, undefined); + const html = renderToStaticMarkup( + createElement(AgentRuntimeAvatarControl, { + activeTestId: "active", + isActive: true, + availability, + isStarting: false, + label: "Agent", + startTestId: "start", + onStart() {}, + }), + ); + assert.match(html, /Availability unknown/); + assert.doesNotMatch(html, /bg-emerald-500|is running/); + }); +} diff --git a/desktop/src/features/agents/lib/useAgentAvailability.ts b/desktop/src/features/agents/lib/useAgentAvailability.ts new file mode 100644 index 00000000000..07da53c9b0b --- /dev/null +++ b/desktop/src/features/agents/lib/useAgentAvailability.ts @@ -0,0 +1,27 @@ +import { usePresenceQuery } from "@/features/presence/hooks"; +import type { PresenceStatus } from "@/shared/api/types"; +import { useRelayConnection } from "@/shared/api/useRelayConnection"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** Availability is relay presence, never a retained deployment receipt or PID. */ +export function resolveAgentAvailability( + status: PresenceStatus | undefined, + presenceLoaded: boolean, + connected: boolean, +): PresenceStatus | undefined { + // Missing entries in a successful presence snapshot mean offline. Failed or + // disconnected reads cannot establish availability (including cached online). + return presenceLoaded && connected ? (status ?? "offline") : undefined; +} + +/** Share the existing presence query/subscription; no separate status cache. */ +export function useAgentAvailability(pubkey: string | null | undefined) { + const query = usePresenceQuery(pubkey ? [pubkey] : []); + const connection = useRelayConnection(); + const status = resolveAgentAvailability( + pubkey ? query.data?.[normalizePubkey(pubkey)] : undefined, + query.isSuccess, + connection === "connected", + ); + return { query, status }; +} diff --git a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx index 1b3c7a05748..2d756a2ec2d 100644 --- a/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx +++ b/desktop/src/features/agents/ui/AgentRuntimeAvatarControl.tsx @@ -7,6 +7,11 @@ import { STATUS_DOT_MASK_CURVE, } from "@/features/profile/ui/MaskedAvatarBadgeFrame"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { + getPresenceDotClassName, + getPresenceLabel, +} from "@/features/presence/lib/presence"; +import type { PresenceStatus } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Spinner } from "@/shared/ui/spinner"; import { IdentityInitialsAvatar } from "./IdentityInitialsAvatar"; @@ -16,7 +21,9 @@ type AgentRuntimeAvatarControlProps = { avatarUrl?: string | null; errorLabel?: string | null; errorTestId?: string; + /** Lifecycle bookkeeping controls actions, not the availability dot. */ isActive: boolean; + availability?: PresenceStatus; isRestarting?: boolean; isStarting: boolean; label: string; @@ -128,6 +135,7 @@ const MASK_TRANSITION = { export function AgentRuntimeAvatarControl({ activeTestId, avatarUrl, + availability, errorLabel, errorTestId, isActive, @@ -151,32 +159,35 @@ export function AgentRuntimeAvatarControl({ : "Start Agent"; const actionText = isRestartAction ? "Restart" : "Start"; const isPending = isStarting || isRestarting; - const showRunningDot = isActive && !isRestartAction; + const availabilityLabel = availability + ? getPresenceLabel(availability) + : "Availability unknown"; + const showStatusDot = isActive && !isRestartAction; const hasError = !isActive && !isPending && Boolean(errorLabel); const errorActionLabel = `${label} has a runtime error. Open runtime details.`; const transition = shouldReduceMotion ? { duration: 0 } : MASK_TRANSITION; const actionBadge = isRestartAction ? RESTART_ACTION_BADGE : START_ACTION_BADGE; - const badge = showRunningDot + const badge = showStatusDot ? ACTIVE_BADGE : hasError ? ERROR_BADGE : actionBadge; const actionCutoutWidth = - showRunningDot || hasError ? undefined : actionBadge.cutoutWidth; + showStatusDot || hasError ? undefined : actionBadge.cutoutWidth; return ( - {showRunningDot ? ( + {showStatusDot ? ( ) : ( + + ) : ( + children + )} + + + ); +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 0f1ab3c981d..915327b108f 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -50,6 +50,7 @@ import { toast } from "sonner"; import { MessageAgentOwner } from "./MessageAgentOwner"; import { MessageAuthorText, + MessageAuthorIdentity, MessageHeaderRow, MessageMetaSegments, } from "./MessageHeader"; @@ -647,22 +648,14 @@ export const MessageRow = React.memo( const headerNode = isDisplayedAsContinuation ? null : ( - {message.pubkey ? ( - - - - ) : ( - authorNode - )} + + {authorNode} + {/* Author is not a segment: "Alice 9:53 AM" needs no divider. */} agent + {ownerLabel ? ( diff --git a/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx b/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx index c72686a6422..38f3825df44 100644 --- a/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx +++ b/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx @@ -16,7 +16,8 @@ type NonMemberMentionDialogProps = { isInvitePending: boolean; names: string[]; onDismiss: () => void; - onDoNothing: () => void; + /** Omit when publication requires the intended recipients to be invited. */ + onDoNothing?: () => void; onInvite: () => void; open: boolean; }; @@ -48,9 +49,13 @@ export function NonMemberMentionDialog({ {names.join(", ")} {names.length === 1 ? "is" : "are"} not in this channel.{" "} - {canInvite - ? "Invite them to the channel, or send without inviting them." - : `${PRIVATE_CHANNEL_ADD_DENIED_MESSAGE} You can still send without inviting them.`} + {onDoNothing + ? canInvite + ? "Invite them to the channel, or send without inviting them." + : `${PRIVATE_CHANNEL_ADD_DENIED_MESSAGE} You can still send without inviting them.` + : canInvite + ? "Invite them to the channel, or cancel to keep your draft." + : PRIVATE_CHANNEL_ADD_DENIED_MESSAGE} {error ? ( @@ -61,12 +66,16 @@ export function NonMemberMentionDialog({ {canInvite ? (