From 98fe33ecaf43b47bd3530de6517c15722d2be895 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 31 Aug 2026 12:32:56 -0400 Subject: [PATCH] fix(desktop): preserve remote agent identity and mention routing Discover authenticated owned relay identities independently of local runtimes, keep preparation distinct from fresh destination authorization, and preserve exact selected recipients through invitations and edits. Keep explicit profiles bound to their public key, use relay presence for availability, and show consistent cloud provenance without claiming hosting location. Include native signed-fixture and browser regression coverage. Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/playwright.config.ts | 2 + .../agent_discovery/relay_directory.rs | 45 +- .../relay_directory/owned_tests.rs | 192 +++++++ desktop/src-tauri/src/nostr_convert.rs | 58 ++- .../src/nostr_convert/agent_directory.rs | 44 +- .../src/nostr_convert/oa_profile_tests.rs | 216 ++++++++ desktop/src-tauri/src/nostr_convert/tests.rs | 66 ++- desktop/src/features/agents/AGENTS.md | 16 +- .../lib/agentAutocompleteEligibility.test.mjs | 89 ++++ .../lib/agentAutocompleteEligibility.ts | 53 +- .../agents/lib/managedAgentControlActions.ts | 4 +- .../agents/lib/otherSetupAgent.test.mjs | 42 +- .../features/agents/lib/otherSetupAgent.ts | 26 +- .../agents/lib/pickProfileAgent.test.mjs | 62 +-- .../features/agents/lib/pickProfileAgent.ts | 26 +- .../agents/lib/useAgentAvailability.test.mjs | 71 +++ .../agents/lib/useAgentAvailability.ts | 27 + .../agents/ui/AgentRuntimeAvatarControl.tsx | 31 +- .../agents/ui/OtherSetupAgentMarker.tsx | 22 +- .../agents/ui/UnifiedAgentsSection.tsx | 5 + .../agents/useKnownAgentPubkeys.test.mjs | 73 +++ .../features/agents/useKnownAgentPubkeys.tsx | 71 ++- .../channels/ui/AddMemberSearchResultRow.tsx | 5 + .../channels/ui/ChannelScreenHeader.tsx | 15 +- .../features/channels/ui/MembersSidebar.tsx | 19 +- .../channels/ui/MembersSidebarMemberCard.tsx | 16 +- .../src/features/forum/ui/ForumComposer.tsx | 18 +- .../forum/ui/useForumMentionPreparation.ts | 173 +++++++ .../lib/agentMentionRevalidation.test.mjs | 184 ++++++- .../messages/lib/agentMentionRevalidation.ts | 69 ++- .../lib/buildMentionCandidates.test.mjs | 45 ++ .../messages/lib/buildMentionCandidates.ts | 1 + .../messages/lib/extractMentionPubkeys.ts | 64 ++- .../lib/mentionHighlightExtension.test.mjs | 58 +++ .../messages/lib/mentionHighlightExtension.ts | 92 +++- .../messages/lib/mentionSuggestionMapping.ts | 11 +- .../messages/lib/useDraftMentionRouting.ts | 6 + .../messages/lib/useMentions.test.mjs | 82 ++- .../src/features/messages/lib/useMentions.ts | 61 ++- .../messages/ui/ComposerAddressControls.tsx | 4 +- .../messages/ui/MentionAutocomplete.test.mjs | 47 +- .../messages/ui/MentionAutocomplete.tsx | 9 +- .../messages/ui/MessageAgentAddressPrefix.tsx | 5 + .../features/messages/ui/MessageHeader.tsx | 40 ++ .../src/features/messages/ui/MessageRow.tsx | 25 +- .../messages/ui/NewMessageResultRow.tsx | 5 + .../messages/ui/NonMemberMentionDialog.tsx | 21 +- .../features/messages/ui/SystemMessageRow.tsx | 7 + .../messages/ui/submitMessageEdit.test.mjs | 15 +- .../features/messages/ui/submitMessageEdit.ts | 45 +- .../ui/useAgentAddressLockPicker.test.mjs | 110 +++- .../messages/ui/useAgentAddressLockPicker.ts | 61 ++- .../ui/useMentionSendFlow.helpers.test.mjs | 26 +- .../messages/ui/useMentionSendFlow.helpers.ts | 34 ++ .../messages/ui/useMentionSendFlow.ts | 70 ++- .../messages/ui/useNewMessageRecipients.ts | 2 +- .../lib/resolveCanonicalManagedAgent.test.mjs | 45 +- .../useCanonicalManagedAgentProfile.test.mjs | 94 ++++ .../lib/useCanonicalManagedAgentProfile.ts | 95 +--- .../useOwnedManagedAgentPersonaId.test.mjs | 218 -------- .../lib/useOwnedManagedAgentPersonaId.ts | 164 ------ .../features/profile/ui/UserProfilePanel.tsx | 27 +- .../ui/UserProfilePanelHeaderContent.tsx | 10 + .../profile/ui/UserProfilePanelSections.tsx | 22 +- .../profile/ui/UserProfilePopover.tsx | 7 + .../features/sidebar/ui/SidebarSection.tsx | 7 + desktop/src/shared/ui/markdown.tsx | 48 +- .../shared/ui/markdown/MarkdownMention.tsx | 52 ++ desktop/src/testing/e2eBridge.ts | 22 +- desktop/tests/e2e/mentions.spec.ts | 89 ++-- desktop/tests/e2e/profile.spec.ts | 70 ++- .../e2e/remote-agent-identity-ux.spec.ts | 130 +++++ .../tests/e2e/remote-owned-mentions.spec.ts | 474 ++++++++++++++++++ desktop/tests/helpers/bridge.ts | 1 + docs/agent-profile-identity.md | 87 ++++ 75 files changed, 3363 insertions(+), 985 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_discovery/relay_directory/owned_tests.rs create mode 100644 desktop/src-tauri/src/nostr_convert/oa_profile_tests.rs create mode 100644 desktop/src/features/agents/lib/useAgentAvailability.test.mjs create mode 100644 desktop/src/features/agents/lib/useAgentAvailability.ts create mode 100644 desktop/src/features/agents/useKnownAgentPubkeys.test.mjs create mode 100644 desktop/src/features/forum/ui/useForumMentionPreparation.ts create mode 100644 desktop/src/features/profile/lib/useCanonicalManagedAgentProfile.test.mjs delete mode 100644 desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.test.mjs delete mode 100644 desktop/src/features/profile/lib/useOwnedManagedAgentPersonaId.ts create mode 100644 desktop/src/shared/ui/markdown/MarkdownMention.tsx create mode 100644 desktop/tests/e2e/remote-agent-identity-ux.spec.ts create mode 100644 desktop/tests/e2e/remote-owned-mentions.spec.ts create mode 100644 docs/agent-profile-identity.md 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 ? (