From 23c95ff640d82f09b7b6e82470a55b08d3b13736 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Tue, 18 Aug 2026 15:09:11 -0400 Subject: [PATCH] feat(desktop): name the computer an agent lives on An owner signs into Buzz on several computers. Persona definitions (kind:30175) sync and insert on arrival, but managed-agent records (kind:30177) are a deliberate no-op on inbound no-match, because they carry device-local secrets that must never ride the relay. So every computer that receives a persona mints its own keypair: one name, N pubkeys, N computers. Mentions then route by pubkey. The `p`-tag match in buzz-acp reaches exactly one of the N -- whichever pubkey the sending client happened to resolve -- so a mention aimed at a sleeping computer dies in silence, and nothing in the UI ever said which computer an agent lived on. This adds the missing noun: - `device_identity`: a stable per-install id and a human label, minted once, persisted 0600 to `/agents/device.json`. The id is an opaque uuid v4, never derived from hardware. The label is seeded from the OS host name -- which routinely contains a real person's name -- so it is user-editable and sanitized before it is published. - The label rides the agent's own kind:30177 projection, and is accepted on the way back in only when the event author matches the owner the agent's NIP-OA profile cryptographically declares. A peer cannot stamp a label onto someone else's agent. - The mention dropdown names the computer, and a mention that resolves to another computer's keypair says so instead of dead-ending. - A settings card to rename this computer. Deliberately out of scope: moving agent secrets between computers, and making the relay-side single-connection exclusion real. Both are Stage 1 and Stage 2 of docs/agent-identity-sync.md on branch design/tailnet-agent-mesh; this is that document's Stage 0. Signed-off-by: Michael Feth --- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 3 + .../personas/inbound/inbound_tests.rs | 2 + desktop/src-tauri/src/device_identity.rs | 359 ++++++++++++++++++ desktop/src-tauri/src/lib.rs | 24 +- .../src/managed_agents/agent_events.rs | 88 +++++ .../managed_agents/harness_catalog_types.rs | 155 ++++++++ desktop/src-tauri/src/managed_agents/mod.rs | 2 + .../src/managed_agents/reconcile/tests.rs | 35 ++ desktop/src-tauri/src/managed_agents/types.rs | 159 +------- .../src/nostr_convert/agent_directory.rs | 2 + desktop/src-tauri/src/nostr_convert/tests.rs | 52 +++ desktop/src-tauri/src/reset.rs | 23 ++ desktop/src/features/agents/hooks.ts | 21 +- .../agents/lib/agentDeviceLabel.test.mjs | 82 ++++ .../features/agents/lib/agentDeviceLabel.ts | 38 ++ .../messages/lib/mentionCandidates.ts | 44 +++ .../messages/lib/mentionSuggestionMapping.ts | 4 + .../src/features/messages/lib/useMentions.ts | 35 +- .../messages/ui/MentionAutocomplete.tsx | 23 ++ .../ui/useEnsureManagedAgentMentionsReady.ts | 151 ++++++++ .../messages/ui/useMentionSendFlow.ts | 87 +---- desktop/src/features/pulse/ui/PulseView.tsx | 4 + .../settings/ui/AgentsSettingsPanel.tsx | 2 + .../settings/ui/DeviceNameSettingsCard.tsx | 108 ++++++ desktop/src/shared/api/relayDirectoryTypes.ts | 34 ++ desktop/src/shared/api/tauri.ts | 34 -- desktop/src/shared/api/tauriDeviceIdentity.ts | 18 + desktop/src/shared/api/tauriRelayAgents.ts | 39 +- desktop/src/shared/api/types.ts | 26 +- desktop/src/testing/e2eBridge.ts | 56 +++ desktop/tests/e2e/mentions.spec.ts | 139 +++++++ 32 files changed, 1533 insertions(+), 317 deletions(-) create mode 100644 desktop/src-tauri/src/device_identity.rs create mode 100644 desktop/src-tauri/src/managed_agents/harness_catalog_types.rs create mode 100644 desktop/src/features/agents/lib/agentDeviceLabel.test.mjs create mode 100644 desktop/src/features/agents/lib/agentDeviceLabel.ts create mode 100644 desktop/src/features/messages/ui/useEnsureManagedAgentMentionsReady.ts create mode 100644 desktop/src/features/settings/ui/DeviceNameSettingsCard.tsx create mode 100644 desktop/src/shared/api/relayDirectoryTypes.ts create mode 100644 desktop/src/shared/api/tauriDeviceIdentity.ts diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 081e345edb..4d2a833f4c 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1106,6 +1106,7 @@ dependencies = [ "ed25519-dalek", "flate2", "futures-util", + "gethostname", "getrandom 0.2.17", "hex", "image", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 01504852b6..1108a86c59 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -126,6 +126,9 @@ chrono = { version = "0.4", features = ["serde"] } tauri-plugin-global-shortcut = "2" tauri-plugin-notification = "2.3.3" uuid = { version = "1", features = ["v4", "v5"] } +# Seeds the first-run device label from the OS host name. Already in the +# lock file as a transitive dep, so this edge adds no new crate. +gethostname = "1" png = "0.18" # wayland-data-control: without it arboard is X11-only on Linux, so copies made # in a Wayland session land in XWayland's clipboard where Wayland-native apps diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index c052622215..9c85b65b36 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -876,6 +876,8 @@ fn inbound_managed_agent_content( parallelism: 1, respond_to: crate::managed_agents::RespondTo::OwnerOnly, respond_to_allowlist: vec![], + device_id: None, + device_label: None, } } diff --git a/desktop/src-tauri/src/device_identity.rs b/desktop/src-tauri/src/device_identity.rs new file mode 100644 index 0000000000..9161d1cbbe --- /dev/null +++ b/desktop/src-tauri/src/device_identity.rs @@ -0,0 +1,359 @@ +//! Stable per-install device identity. +//! +//! Buzz agents carry device-local secrets: `apply_inbound_managed_agent` is a +//! deliberate no-op on no-match, so a persona synced to a second computer mints +//! a *fresh* keypair there. One name, N pubkeys, N computers — and nothing in +//! the UI says which computer an agent actually lives on. This module supplies +//! that missing noun. +//! +//! # What this is NOT +//! +//! It is not [`crate::managed_agents::runtime::current_instance_id`]. That +//! returns the Tauri *bundle identifier* — a build constant, identical on every +//! machine — and exists to keep a dev build from reaping a packaged build's +//! processes on the SAME computer. The two answer different questions and must +//! stay separate. +//! +//! # Storage +//! +//! `/agents/device.json`, written `0o600` via +//! `atomic_write_json_restricted` — the same pattern the agent store and +//! `global-agent-config.json` use. +//! +//! # Privacy +//! +//! `device_label` is seeded from the OS host name and is published in a +//! world-readable kind:30177 event (see +//! [`crate::managed_agents::agent_events`]). Host names routinely contain a +//! real person's name, so the label is user-editable via [`set_device_label`] +//! and capped/sanitized by [`sanitize_label`]. The opaque `device_id` alone is +//! enough to tell N devices apart. + +use std::path::{Path, PathBuf}; +use std::sync::{PoisonError, RwLock}; + +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; + +use crate::managed_agents::storage::{atomic_write_json_restricted, managed_agents_base_dir}; + +/// Maximum length of a device label, in `char`s. +const MAX_DEVICE_LABEL_CHARS: usize = 32; + +/// Stable identity of the computer this Buzz install runs on. +/// +/// Distinguishes two devices signed into the same Buzz account. Minted +/// once at first run and never rotated; the label is user-editable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceIdentity { + /// Opaque uuid v4 (simple hex, 32 chars). Never derived from hardware. + pub device_id: String, + /// Human label shown beside this device's agents on other devices. + /// Seeded from the OS host name at first run. + pub device_label: String, + /// RFC 3339 first-run timestamp. Diagnostics only. + pub created_at: String, +} + +/// Process-wide cache of the resolved identity. +/// +/// `None` until [`ensure`] runs, which only happens inside the Tauri `setup` +/// hook. Unit tests never boot the app, so every existing test observes `None` +/// and its published projections are byte-identical to before this module +/// existed. +static CURRENT: RwLock> = RwLock::new(None); + +/// Normalize a user-supplied or host-derived device label. +/// +/// Trims, rejects an empty or control-character-bearing value, and caps the +/// result at [`MAX_DEVICE_LABEL_CHARS`] `char`s. Control characters are refused +/// rather than stripped because the label is published to a relay and rendered +/// in other clients' UI. +fn sanitize_label(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("device label must not be empty".to_string()); + } + if trimmed.chars().any(char::is_control) { + return Err("device label must not contain control characters".to_string()); + } + let capped: String = trimmed.chars().take(MAX_DEVICE_LABEL_CHARS).collect(); + let capped = capped.trim_end(); + if capped.is_empty() { + return Err("device label must not be empty".to_string()); + } + Ok(capped.to_string()) +} + +/// Derive the first-run label from `seed` (the OS host name), falling back to +/// an id-derived placeholder when the seed sanitizes to nothing. +/// +/// The fallback is deliberately opaque: a device with an unusable host name +/// still gets a stable, distinguishable label without inventing a plausible +/// but wrong name. +fn seed_label(seed: &str, device_id: &str) -> String { + sanitize_label(seed) + .unwrap_or_else(|_| format!("device-{}", device_id.chars().take(8).collect::())) +} + +/// Mint a brand-new identity, seeding the label from the OS host name. +fn mint_identity() -> DeviceIdentity { + let device_id = uuid::Uuid::new_v4().simple().to_string(); + let host = gethostname::gethostname(); + let device_label = seed_label(&host.to_string_lossy(), &device_id); + DeviceIdentity { + device_id, + device_label, + created_at: chrono::Utc::now().to_rfc3339(), + } +} + +/// Persist `identity` to `path`, `0o600`, atomically. +fn write_identity_at(path: &Path, identity: &DeviceIdentity) -> Result<(), String> { + let payload = serde_json::to_vec_pretty(identity) + .map_err(|e| format!("failed to serialize device identity: {e}"))?; + atomic_write_json_restricted(path, &payload) +} + +/// Load the identity at `path`, minting and persisting a fresh one when the +/// file is absent, unreadable, or malformed. +/// +/// A corrupt file is preserved as `device.json.corrupt` (best effort) and +/// replaced. Losing the identity only *relabels* a device — it never touches +/// agent data — so this path must never fail the caller. +fn load_or_create_at(path: &Path) -> Result { + if path.exists() { + match std::fs::read_to_string(path) + .map_err(|e| format!("failed to read device identity: {e}")) + .and_then(|content| { + serde_json::from_str::(&content) + .map_err(|e| format!("failed to parse device identity: {e}")) + }) { + Ok(identity) => return Ok(identity), + Err(error) => { + let corrupt = path.with_extension("json.corrupt"); + if let Err(rename_error) = std::fs::rename(path, &corrupt) { + tracing::warn!( + "device identity: could not preserve corrupt file: {rename_error}" + ); + } + tracing::warn!("device identity: minting a fresh identity ({error})"); + } + } + } + + let identity = mint_identity(); + write_identity_at(path, &identity)?; + Ok(identity) +} + +/// Replace the label on the identity at `path`, minting one first if needed. +fn set_label_at(path: &Path, label: &str) -> Result { + let device_label = sanitize_label(label)?; + let mut identity = load_or_create_at(path)?; + identity.device_label = device_label; + write_identity_at(path, &identity)?; + Ok(identity) +} + +fn device_identity_path(app: &AppHandle) -> Result { + Ok(managed_agents_base_dir(app)?.join("device.json")) +} + +fn cache(identity: &DeviceIdentity) { + let mut guard = CURRENT.write().unwrap_or_else(PoisonError::into_inner); + *guard = Some(identity.clone()); +} + +/// Load or create this install's device identity and populate the process +/// cache read by [`current`]. +/// +/// Idempotent: safe to call more than once. Called once from the Tauri `setup` +/// hook, after boot migrations and before identity resolution. +pub fn ensure(app: &AppHandle) -> Result { + let path = device_identity_path(app)?; + let identity = load_or_create_at(&path)?; + cache(&identity); + Ok(identity) +} + +/// The cached device identity, or `None` when [`ensure`] has not run. +/// +/// `None` is a supported answer, not an error: unit tests and any code path +/// that runs before the Tauri `setup` hook simply publish no device stamp. +pub fn current() -> Option { + CURRENT + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone() +} + +/// Rename this device, persisting and caching the result. +/// +/// The new label reaches other devices on the next kind:30177 republish. The +/// [`set_device_label`] command triggers that republish immediately via the +/// managed-agent reconcile; calling this function directly leaves propagation +/// to the next agent mutation or app restart. +pub fn set_label(app: &AppHandle, label: &str) -> Result { + let path = device_identity_path(app)?; + let identity = set_label_at(&path, label)?; + cache(&identity); + Ok(identity) +} + +/// Return this install's device identity, minting it on first call. +#[tauri::command] +pub fn get_device_identity(app: AppHandle) -> Result { + ensure(&app) +} + +/// Rename this device and republish every local agent's kind:30177 record so +/// other devices see the new label without waiting for the next app restart. +/// +/// The republish is best-effort: a rename that persists locally but cannot +/// reach the retention store still succeeds, and propagates on the next agent +/// mutation or restart. +#[tauri::command] +pub fn set_device_label(app: AppHandle, label: String) -> Result { + let identity = set_label(&app, &label)?; + republish_agent_records(&app); + Ok(identity) +} + +/// Best-effort re-reconcile of every local managed-agent record so a changed +/// device label reaches the relay now. `retain_agent_record`'s content-equality +/// guard means records whose projection did not change stay untouched. +fn republish_agent_records(app: &AppHandle) { + use tauri::Manager; + + let state = app.state::(); + match crate::managed_agents::retention::active_retention_scope(app, &state) { + Ok(scope) => crate::managed_agents::reconcile::reconcile_agents_to_events( + app, + &scope.owner_keys, + &scope.db_path, + ), + Err(error) => { + tracing::warn!("device identity: label republish skipped: {error}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_label_trims() { + assert_eq!(sanitize_label(" mfeth-win \t").unwrap(), "mfeth-win"); + } + + #[test] + fn sanitize_label_rejects_empty() { + assert!(sanitize_label("").is_err()); + assert!(sanitize_label(" ").is_err()); + } + + #[test] + fn sanitize_label_rejects_control_characters() { + assert!(sanitize_label("mfeth\u{0}win").is_err()); + assert!(sanitize_label("mfeth\nwin").is_err()); + } + + #[test] + fn sanitize_label_truncates_to_thirty_two_chars() { + let long = "a".repeat(100); + let sanitized = sanitize_label(&long).unwrap(); + assert_eq!(sanitized.chars().count(), 32); + assert_eq!(sanitized, "a".repeat(32)); + } + + #[test] + fn seed_label_falls_back_to_id_derived_label() { + let device_id = "0123456789abcdef0123456789abcdef"; + assert_eq!(seed_label(" ", device_id), "device-01234567"); + assert_eq!(seed_label("\u{0}", device_id), "device-01234567"); + } + + #[test] + fn seed_label_prefers_the_sanitized_seed() { + let device_id = "0123456789abcdef0123456789abcdef"; + assert_eq!(seed_label(" mfeth-win ", device_id), "mfeth-win"); + } + + #[test] + fn device_identity_round_trips_as_camel_case() { + let identity = DeviceIdentity { + device_id: "0123456789abcdef0123456789abcdef".to_string(), + device_label: "mfeth-win".to_string(), + created_at: "2026-08-18T00:00:00+00:00".to_string(), + }; + let json = serde_json::to_string(&identity).unwrap(); + assert!(json.contains("\"deviceId\""), "{json}"); + assert!(json.contains("\"deviceLabel\""), "{json}"); + assert!(json.contains("\"createdAt\""), "{json}"); + assert!(!json.contains("device_id"), "{json}"); + + let parsed: DeviceIdentity = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, identity); + } + + #[test] + fn load_or_create_mints_then_reuses() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("device.json"); + + let first = load_or_create_at(&path).unwrap(); + assert_eq!(first.device_id.chars().count(), 32); + assert!(!first.device_label.is_empty()); + assert!(path.exists()); + + let second = load_or_create_at(&path).unwrap(); + assert_eq!(first, second, "identity must be stable across loads"); + } + + #[test] + fn corrupt_file_is_preserved_and_replaced() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("device.json"); + std::fs::write(&path, "{ not json at all").unwrap(); + + let identity = load_or_create_at(&path).expect("corrupt file must never fail the caller"); + assert_eq!(identity.device_id.chars().count(), 32); + assert!( + dir.path().join("device.json.corrupt").exists(), + "the corrupt file must be preserved" + ); + // The replacement is durable. + assert_eq!(load_or_create_at(&path).unwrap(), identity); + } + + #[test] + fn set_label_persists_and_keeps_the_id() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("device.json"); + + let minted = load_or_create_at(&path).unwrap(); + let renamed = set_label_at(&path, " Studio Mac ").unwrap(); + assert_eq!(renamed.device_label, "Studio Mac"); + assert_eq!(renamed.device_id, minted.device_id); + assert_eq!(load_or_create_at(&path).unwrap(), renamed); + } + + #[test] + fn set_label_rejects_an_unusable_label() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("device.json"); + assert!(set_label_at(&path, " ").is_err()); + assert!(set_label_at(&path, "bad\nlabel").is_err()); + } + + #[test] + fn current_is_none_before_ensure_runs() { + // Guards the zero-churn contract: every pre-existing unit test sees no + // device stamp because the Tauri setup hook never ran, so no existing + // published-projection assertion has to change. + assert!(current().is_none()); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index cefdccfd69..886baf19d6 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod archive; mod builderlab; mod commands; mod deep_link; +mod device_identity; mod egress_guard; mod event_sync; mod events; @@ -323,20 +324,7 @@ pub fn run() { // ── Phase 2: boot-time sentinel wipe ────────────────────────────── // Must run before migrations and identity resolution so the wipe // completes atomically on crash recovery. - // - // init_nest_dir is called early here (normally it runs inside - // run_boot_migrations) so reset::run_boot_reset can call nest_dir(). - let reset_outcome = if let Ok(data_dir) = app_handle.path().app_data_dir() { - let is_dev_for_reset = data_dir - .file_name() - .and_then(|n| n.to_str()) - .map(crate::migration::is_dev_data_dir_name) - .unwrap_or(false); - crate::managed_agents::init_nest_dir(is_dev_for_reset); - crate::reset::run_boot_reset(&data_dir) - } else { - crate::reset::ResetOutcome::default() - }; + let reset_outcome = crate::reset::run_boot_reset_for_app(&app_handle); if reset_outcome.failed { // Surface reset-failed state — skip identity resolution and @@ -356,6 +344,12 @@ pub fn run() { migration::run_boot_migrations(&app_handle); } + // Stable per-install device identity. Non-fatal: without it the + // app simply publishes no device label on its agents. + if let Err(e) = device_identity::ensure(&app_handle) { + eprintln!("buzz-desktop: device identity unavailable: {e}"); + } + // Resolve persisted identity key (env var → file → generate+save). // This is fatal — the app should not start with an ephemeral identity // that will be lost on restart, as that silently breaks channel @@ -788,6 +782,8 @@ pub fn run() { put_agent_session_config, get_global_agent_config, set_global_agent_config, + device_identity::get_device_identity, + device_identity::set_device_label, mesh_start_node, mesh_stop_node, mesh_node_status, diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index f70c714323..e40c2604ef 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -21,6 +21,11 @@ //! - `backend` — `Provider { config }` is an opaque blob that may hold secrets. //! - any runtime field (`runtime_pid`, `last_*`, `backend_agent_id`, …) — these //! mutate on every start/stop and describe transient process state. +//! +//! The device fields (`device_id` / `device_label`) ARE publishable: they name +//! the install that holds this instance's secret — public, non-secret, and +//! user-editable — and they do not mutate on start/stop, so they are identity, +//! not runtime state. use buzz_core_pkg::kind::KIND_MANAGED_AGENT; use nostr::{EventBuilder, Kind, Tag}; @@ -57,6 +62,13 @@ pub struct ManagedAgentEventContent { /// public keys, not secrets. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub respond_to_allowlist: Vec, + /// Opaque id of the device that holds this instance's secret and runs + /// it. Absent on events published before device identity shipped. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_id: Option, + /// Human label for that device. Public, non-secret, user-editable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub device_label: Option, } /// Project a `ManagedAgentRecord` onto the content fields published in @@ -77,6 +89,10 @@ pub fn agent_event_content(record: &ManagedAgentRecord) -> ManagedAgentEventCont // restore path. This branch retires once every record is // definition-backed (B5 backfill). let definition_linked = record.persona_id.is_some(); + // Device fields describe the INSTANCE (which install holds its secret), + // never the definition, so they are emitted regardless of slimming. + // `None` before the Tauri setup hook runs — unit tests publish no stamp. + let device = crate::device_identity::current(); ManagedAgentEventContent { name: record.name.clone(), persona_id: record.persona_id.clone(), @@ -103,6 +119,8 @@ pub fn agent_event_content(record: &ManagedAgentRecord) -> ManagedAgentEventCont parallelism: record.parallelism, respond_to: record.respond_to, respond_to_allowlist: record.respond_to_allowlist.clone(), + device_id: device.as_ref().map(|d| d.device_id.clone()), + device_label: device.as_ref().map(|d| d.device_label.clone()), } } @@ -457,6 +475,76 @@ mod tests { assert!(!json.contains("backend")); } + /// Zero-churn contract: `device_identity::current()` is `None` outside a + /// booted app, so the projection serializes exactly as it did before the + /// device fields existed. This is why no other test in the crate changed. + #[test] + fn projection_omits_device_fields_without_a_device_identity() { + assert!( + crate::device_identity::current().is_none(), + "unit tests must never boot the device identity" + ); + let content = agent_event_content(&sample_agent()); + assert_eq!(content.device_id, None); + assert_eq!(content.device_label, None); + + let json = serde_json::to_string(&content).unwrap(); + assert!(!json.contains("deviceId"), "{json}"); + assert!(!json.contains("device_id"), "{json}"); + assert!(!json.contains("deviceLabel"), "{json}"); + assert!(!json.contains("device_label"), "{json}"); + } + + /// Mixed-fleet back-compat: a 30177 event published by a build that predates + /// device identity parses cleanly, with both fields absent rather than an + /// invented value. + #[test] + fn from_event_without_device_keys_yields_none() { + use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; + let content = serde_json::json!({ + "name": "Bumble", + "parallelism": 1, + "respond_to": "owner-only", + }); + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), content.to_string()) + .tags(vec![Tag::parse(["d", "agentpubkeyhex"]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let event = nostr::Event::from_json(event.as_json()).unwrap(); + + let parsed = managed_agent_content_from_event(&event).unwrap(); + assert_eq!(parsed.device_id, None); + assert_eq!(parsed.device_label, None); + } + + /// The forward direction of the same contract: an event that DOES carry the + /// device fields round-trips them. + #[test] + fn from_event_reads_device_fields_when_present() { + use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; + let content = serde_json::json!({ + "name": "Bumble", + "parallelism": 1, + "respond_to": "owner-only", + "device_id": "0123456789abcdef0123456789abcdef", + "device_label": "mfeth-win", + }); + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), content.to_string()) + .tags(vec![Tag::parse(["d", "agentpubkeyhex"]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let event = nostr::Event::from_json(event.as_json()).unwrap(); + + let parsed = managed_agent_content_from_event(&event).unwrap(); + assert_eq!( + parsed.device_id.as_deref(), + Some("0123456789abcdef0123456789abcdef") + ); + assert_eq!(parsed.device_label.as_deref(), Some("mfeth-win")); + } + #[test] fn build_agent_delete_has_single_a_tag_no_e_tag() { const OWNER: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; diff --git a/desktop/src-tauri/src/managed_agents/harness_catalog_types.rs b/desktop/src-tauri/src/managed_agents/harness_catalog_types.rs new file mode 100644 index 0000000000..fdcbc187ee --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/harness_catalog_types.rs @@ -0,0 +1,155 @@ +//! Wire types for the ACP runtime catalog: which harnesses this install can +//! run, whether each is installed and logged in, and the results of trying to +//! install one. +//! +//! Split out of `types.rs`, which had reached the desktop file-size ceiling; +//! these are the harness/prerequisite DTOs and share no state with the +//! managed-agent record types left behind. Re-exported through +//! `managed_agents::*`, so every existing import path still resolves. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AcpAvailabilityStatus { + Available, + AdapterMissing, + /// Adapter binary is present but unsupported — either the deprecated + /// package or a version below the supported floor. Reinstall required. + AdapterOutdated, + CliMissing, + NotInstalled, +} + +/// Authentication/login status for a CLI-based ACP runtime. Serializes as a tagged union +/// `{ status: "...", diagnostic?: "..." }` so the TypeScript side can exhaustively switch on `status`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case", tag = "status")] +pub enum AuthStatus { + /// The CLI reported a successful login. + LoggedIn, + /// The CLI exited non-zero without a config-parse signal. + LoggedOut, + /// The CLI exited non-zero and its stderr contains a config-parse error. + ConfigInvalid { + /// Trimmed excerpt of the stderr message. + diagnostic: String, + }, + /// This runtime does not have a login step (e.g. goose, buzz-agent). + NotApplicable, + /// Probe was not attempted (runtime unavailable or probe timed out). + Unknown, +} + +/// Origin of an ACP runtime catalog entry. Serializes as a lowercase string so the TypeScript consumer can switch on it without numeric comparisons. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HarnessSource { + /// Compiled into the app — one of the four first-class runtimes. + Builtin, + /// Static preset entry with bundled logo, PATH-probed, not editable/deletable. + Preset, + /// Loaded at runtime from the user's `custom_harnesses/` directory. + Custom, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AcpRuntimeCatalogEntry { + pub id: String, + pub label: String, + pub avatar_url: String, + pub availability: AcpAvailabilityStatus, + pub command: Option, + pub binary_path: Option, + pub default_args: Vec, + pub mcp_command: Option, + /// Environment variable used to apply the initial model, when supported. + pub model_env_var: Option, + /// Environment variable used to apply the selected LLM provider, when supported. + pub provider_env_var: Option, + /// Environment variable used to apply thinking effort, when supported. + pub thinking_env_var: Option, + pub max_tokens_env_var: Option, + pub context_limit_env_var: Option, + pub max_rounds_env_var: Option, + pub install_hint: String, + pub install_instructions_url: String, + /// true when at least one automated install step is available + pub can_auto_install: bool, + /// true when this runtime depends on a separately installed vendor CLI. + pub requires_external_cli: bool, + pub underlying_cli_path: Option, + /// true when an npm adapter step is pending but Node.js / npm is absent. + /// The UI hides the Install button and shows a Node.js install callout. + pub node_required: bool, + /// Login/authentication status for CLI-based runtimes. + pub auth_status: AuthStatus, + /// Hint for completing authentication, shown when `auth_status` is not `logged_in`. + #[serde(skip_serializing_if = "Option::is_none")] + pub login_hint: Option, + /// Whether this entry came from the compiled-in catalog or a user-supplied + /// JSON file in `custom_harnesses/`. The UI uses this to decide editability. + pub source: HarnessSource, + /// Definition-level env vars for `source: custom` entries; populated from + /// `HarnessDefinition.env` so saves don't silently erase existing vars. + /// Absent for builtin/preset entries. Skipped when empty in serialization. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub definition_env: BTreeMap, + /// Spawn-time parallelism cap; absent for uncapped harnesses. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_parallelism: Option, +} + +/// Result of a single install step (CLI or adapter). +#[derive(Debug, Clone, Serialize)] +pub struct InstallStepResult { + pub step: String, + pub command: String, + pub success: bool, + pub stdout: String, + pub stderr: String, + pub exit_code: Option, + /// Actionable guidance shown in the UI when this step failed due to a + /// recognized condition (e.g. EACCES writing Buzz's private npm prefix). + /// `None` when the step succeeded or no pattern matched. + #[serde(skip_serializing_if = "Option::is_none")] + pub hint: Option, +} + +/// Aggregate result of installing a runtime (may include CLI + adapter steps). +#[derive(Debug, Clone, Serialize)] +pub struct InstallRuntimeResult { + pub success: bool, + pub steps: Vec, + /// Number of local agents successfully stopped and restarted after a + /// successful install. Mirrors `GlobalAgentConfigSaveResult.restarted_count`. + pub restarted_count: u32, + /// Number of agents whose stop succeeded but respawn failed. + /// Mirrors `GlobalAgentConfigSaveResult.failed_restart_count`. + pub failed_restart_count: u32, + /// Install log file for this run, when one was written. The UI surfaces it + /// on failure so a user can read the full retry history instead of only the + /// last step's truncated output. `None` when no log could be opened. + pub log_path: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CommandAvailabilityInfo { + pub command: String, + pub resolved_path: Option, + pub available: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscoverManagedAgentPrereqsRequest { + pub acp_command: Option, + pub mcp_command: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ManagedAgentPrereqsInfo { + pub acp: CommandAvailabilityInfo, + pub mcp: CommandAvailabilityInfo, +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 16234aa3d6..e15caa607a 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -17,6 +17,7 @@ pub(crate) mod effective_config; mod env_vars; pub(crate) mod git_bash; pub(crate) mod global_config; +mod harness_catalog_types; mod managed_node_paths; mod nest; pub(crate) mod parallelism; @@ -65,6 +66,7 @@ pub(crate) use global_config::{ load_global_agent_config, resolve_effective_model_provider, save_global_agent_config, validate_global_config, GlobalAgentConfig, }; +pub use harness_catalog_types::*; pub(crate) use managed_node_paths::*; pub use nest::*; pub use parallelism::{acp_agents_value, effective_parallelism, harness_max_parallelism}; diff --git a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs index c9269dbf00..da0010d778 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs @@ -400,3 +400,38 @@ fn retain_agent_record_is_noop_when_unchanged() { "no pending_sync churn for an unchanged record" ); } + +/// The 9 key-less DEFINITION rows in a real store (empty `pubkey`, no secret) +/// are skipped by the reconcile loop, so they never mint a kind:30177 +/// coordinate and therefore never carry a device stamp. Pins the boundary that +/// makes "every keyed record in this store is THIS device's" true. +#[test] +fn keyless_definition_row_publishes_no_device() { + let dir = TempDir::new().unwrap(); + let keys = nostr::Keys::generate(); + write_store( + &dir, + &[ + sample_record("", "keyless-definition"), + sample_record("d".repeat(64).as_str(), "keyed-instance"), + ], + ); + + // Only the keyed instance reconciles. + assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 1); + + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + let pending = get_pending_sync(&conn).unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].d_tag, "d".repeat(64)); + assert!( + get_retained_event(&conn, KIND_MANAGED_AGENT, &keys.public_key().to_hex(), "") + .unwrap() + .is_none(), + "a key-less definition row must never get an event coordinate" + ); + // No device stamp on the wire either — `device_identity::current()` is + // `None` in unit tests, so the projection stays byte-identical to before. + assert!(!pending[0].raw_event.contains("device_id")); + assert!(!pending[0].raw_event.contains("device_label")); +} diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 3b0641cb67..1f8543d7f0 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -1,6 +1,11 @@ use serde::{Deserialize, Serialize}; use std::{collections::BTreeMap, path::PathBuf, process::Child}; +// Re-exported, not merely imported: `ManagedAgentRecord` embeds it, and +// callers that already say `managed_agents::types::AcpAvailabilityStatus` +// keep resolving after the harness DTOs moved to their own module. +pub use super::harness_catalog_types::AcpAvailabilityStatus; + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum BackendKind { @@ -210,6 +215,16 @@ pub struct RelayAgentInfo { pub respond_to: Option, #[serde(default)] pub respond_to_allowlist: Vec, + /// Opaque id of the device that runs this agent, as published on its + /// kind:30177 record. `None` for legacy kind:10100 directory entries and + /// for records published before device identity shipped. + #[serde(default)] + pub device_id: Option, + /// Human label for that device — what the UI shows to say "on mfeth-win". + /// Owner-authenticated: it only reaches here through a 30177 coordinate + /// whose author matches the agent's signed NIP-OA owner. + #[serde(default)] + pub device_label: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ManagedAgentRecord { @@ -583,150 +598,6 @@ pub struct ManagedAgentLogResponse { pub log_path: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum AcpAvailabilityStatus { - Available, - AdapterMissing, - /// Adapter binary is present but unsupported — either the deprecated - /// package or a version below the supported floor. Reinstall required. - AdapterOutdated, - CliMissing, - NotInstalled, -} - -/// Authentication/login status for a CLI-based ACP runtime. Serializes as a tagged union -/// `{ status: "...", diagnostic?: "..." }` so the TypeScript side can exhaustively switch on `status`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case", tag = "status")] -pub enum AuthStatus { - /// The CLI reported a successful login. - LoggedIn, - /// The CLI exited non-zero without a config-parse signal. - LoggedOut, - /// The CLI exited non-zero and its stderr contains a config-parse error. - ConfigInvalid { - /// Trimmed excerpt of the stderr message. - diagnostic: String, - }, - /// This runtime does not have a login step (e.g. goose, buzz-agent). - NotApplicable, - /// Probe was not attempted (runtime unavailable or probe timed out). - Unknown, -} - -/// Origin of an ACP runtime catalog entry. Serializes as a lowercase string so the TypeScript consumer can switch on it without numeric comparisons. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum HarnessSource { - /// Compiled into the app — one of the four first-class runtimes. - Builtin, - /// Static preset entry with bundled logo, PATH-probed, not editable/deletable. - Preset, - /// Loaded at runtime from the user's `custom_harnesses/` directory. - Custom, -} - -#[derive(Debug, Clone, Serialize)] -pub struct AcpRuntimeCatalogEntry { - pub id: String, - pub label: String, - pub avatar_url: String, - pub availability: AcpAvailabilityStatus, - pub command: Option, - pub binary_path: Option, - pub default_args: Vec, - pub mcp_command: Option, - /// Environment variable used to apply the initial model, when supported. - pub model_env_var: Option, - /// Environment variable used to apply the selected LLM provider, when supported. - pub provider_env_var: Option, - /// Environment variable used to apply thinking effort, when supported. - pub thinking_env_var: Option, - pub max_tokens_env_var: Option, - pub context_limit_env_var: Option, - pub max_rounds_env_var: Option, - pub install_hint: String, - pub install_instructions_url: String, - /// true when at least one automated install step is available - pub can_auto_install: bool, - /// true when this runtime depends on a separately installed vendor CLI. - pub requires_external_cli: bool, - pub underlying_cli_path: Option, - /// true when an npm adapter step is pending but Node.js / npm is absent. - /// The UI hides the Install button and shows a Node.js install callout. - pub node_required: bool, - /// Login/authentication status for CLI-based runtimes. - pub auth_status: AuthStatus, - /// Hint for completing authentication, shown when `auth_status` is not `logged_in`. - #[serde(skip_serializing_if = "Option::is_none")] - pub login_hint: Option, - /// Whether this entry came from the compiled-in catalog or a user-supplied - /// JSON file in `custom_harnesses/`. The UI uses this to decide editability. - pub source: HarnessSource, - /// Definition-level env vars for `source: custom` entries; populated from - /// `HarnessDefinition.env` so saves don't silently erase existing vars. - /// Absent for builtin/preset entries. Skipped when empty in serialization. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub definition_env: BTreeMap, - /// Spawn-time parallelism cap; absent for uncapped harnesses. - #[serde(skip_serializing_if = "Option::is_none")] - pub max_parallelism: Option, -} - -/// Result of a single install step (CLI or adapter). -#[derive(Debug, Clone, Serialize)] -pub struct InstallStepResult { - pub step: String, - pub command: String, - pub success: bool, - pub stdout: String, - pub stderr: String, - pub exit_code: Option, - /// Actionable guidance shown in the UI when this step failed due to a - /// recognized condition (e.g. EACCES writing Buzz's private npm prefix). - /// `None` when the step succeeded or no pattern matched. - #[serde(skip_serializing_if = "Option::is_none")] - pub hint: Option, -} - -/// Aggregate result of installing a runtime (may include CLI + adapter steps). -#[derive(Debug, Clone, Serialize)] -pub struct InstallRuntimeResult { - pub success: bool, - pub steps: Vec, - /// Number of local agents successfully stopped and restarted after a - /// successful install. Mirrors `GlobalAgentConfigSaveResult.restarted_count`. - pub restarted_count: u32, - /// Number of agents whose stop succeeded but respawn failed. - /// Mirrors `GlobalAgentConfigSaveResult.failed_restart_count`. - pub failed_restart_count: u32, - /// Install log file for this run, when one was written. The UI surfaces it - /// on failure so a user can read the full retry history instead of only the - /// last step's truncated output. `None` when no log could be opened. - pub log_path: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct CommandAvailabilityInfo { - pub command: String, - pub resolved_path: Option, - pub available: bool, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DiscoverManagedAgentPrereqsRequest { - pub acp_command: Option, - pub mcp_command: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ManagedAgentPrereqsInfo { - pub acp: CommandAvailabilityInfo, - pub mcp: CommandAvailabilityInfo, -} - #[derive(Debug, Serialize)] pub struct UpdateManagedAgentResponse { pub agent: ManagedAgentSummary, diff --git a/desktop/src-tauri/src/nostr_convert/agent_directory.rs b/desktop/src-tauri/src/nostr_convert/agent_directory.rs index 28604de5e5..9978b31208 100644 --- a/desktop/src-tauri/src/nostr_convert/agent_directory.rs +++ b/desktop/src-tauri/src/nostr_convert/agent_directory.rs @@ -138,6 +138,8 @@ fn relay_agent_from_managed_policy(agent_pubkey: &str, event: &Event) -> Option< status: "offline".to_string(), respond_to: Some(content.respond_to), respond_to_allowlist: content.respond_to_allowlist, + device_id: content.device_id, + device_label: content.device_label, }) } diff --git a/desktop/src-tauri/src/nostr_convert/tests.rs b/desktop/src-tauri/src/nostr_convert/tests.rs index 9401d19add..77286caf59 100644 --- a/desktop/src-tauri/src/nostr_convert/tests.rs +++ b/desktop/src-tauri/src/nostr_convert/tests.rs @@ -444,6 +444,58 @@ fn managed_agent_directory_accepts_only_the_verified_owner_policy() { assert_eq!(agents[0].respond_to_allowlist, vec![viewer_pubkey]); } +/// The device label reaches the directory only through an owner-verified +/// coordinate, and stays `None` for a record published by a build that predates +/// device identity. +#[test] +fn managed_agent_directory_surfaces_the_owner_verified_device_label() { + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key().to_hex(); + + let auth_tag_json = + buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "") + .expect("compute auth tag"); + let auth_tag_values: Vec = + serde_json::from_str(&auth_tag_json).expect("parse auth tag json"); + let profile = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Bumble"}"#) + .tags([Tag::parse(auth_tag_values).expect("parse auth tag")]) + .sign_with_keys(&agent_keys) + .expect("sign profile"); + + let stamped = EventBuilder::new( + Kind::Custom(30177), + serde_json::json!({ + "name": "Bumble", + "parallelism": 1, + "respond_to": "anyone", + "device_id": "0123456789abcdef0123456789abcdef", + "device_label": "mfeth-win", + }) + .to_string(), + ) + .tags([Tag::parse(["d", agent_pubkey.as_str()]).expect("parse d tag")]) + .sign_with_keys(&owner_keys) + .expect("sign managed-agent event"); + + let agents = relay_agents_from_managed_agent_events(&[stamped], std::slice::from_ref(&profile)); + assert_eq!(agents.len(), 1); + assert_eq!( + agents[0].device_id.as_deref(), + Some("0123456789abcdef0123456789abcdef") + ); + assert_eq!(agents[0].device_label.as_deref(), Some("mfeth-win")); + + // An unstamped record from an older build yields no label — never a + // fabricated one. + let unstamped = managed_agent_event(&owner_keys, &agent_pubkey, "Bumble", "anyone", &[]); + let agents = + relay_agents_from_managed_agent_events(&[unstamped], std::slice::from_ref(&profile)); + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].device_id, None); + assert_eq!(agents[0].device_label, None); +} + #[test] fn managed_agent_directory_rejects_agents_without_verified_owner_profiles() { let owner_keys = Keys::generate(); diff --git a/desktop/src-tauri/src/reset.rs b/desktop/src-tauri/src/reset.rs index 18ddd80eb8..ef16641044 100644 --- a/desktop/src-tauri/src/reset.rs +++ b/desktop/src-tauri/src/reset.rs @@ -138,6 +138,29 @@ pub(crate) fn run_boot_reset(app_data_dir: &Path) -> ResetOutcome { run_boot_reset_with_keychain(ctx) } +/// Phase 2 as `setup()` needs it: resolve the app-data dir, prime the nest +/// path, and run the wipe. +/// +/// `init_nest_dir` has to happen here rather than inside +/// `run_boot_migrations`, because `run_boot_reset` calls `nest_dir()` and the +/// wipe runs *before* migrations. Returns a default (no-op) outcome when the +/// platform cannot give us an app-data dir, which is the same thing an absent +/// sentinel produces. +pub(crate) fn run_boot_reset_for_app(app_handle: &tauri::AppHandle) -> ResetOutcome { + use tauri::Manager as _; + + let Ok(data_dir) = app_handle.path().app_data_dir() else { + return ResetOutcome::default(); + }; + let is_dev = data_dir + .file_name() + .and_then(|name| name.to_str()) + .map(crate::migration::is_dev_data_dir_name) + .unwrap_or(false); + crate::managed_agents::init_nest_dir(is_dev); + run_boot_reset(&data_dir) +} + /// Deterministic trash path: `.reset-trash`. Unlike PID-based names, /// any boot can discover and clean trash from a prior crashed attempt. fn trash_path(original: &Path) -> PathBuf { diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 0913582cab..2a9b93a2a9 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -38,11 +38,12 @@ import { installAcpRuntime, invokeTauri, listManagedAgents, - listRelayAgents, saveCustomHarness, updateManagedAgent, } from "@/shared/api/tauri"; import type { HarnessDefinitionInput } from "@/shared/api/tauri"; +import { getDeviceIdentity } from "@/shared/api/tauriDeviceIdentity"; +import { listRelayAgents } from "@/shared/api/tauriRelayAgents"; import { setManagedAgentAutoRestart, setManagedAgentStartOnAppLaunch, @@ -336,6 +337,24 @@ export function useManagedAgentPrereqsQuery( }); } +export const deviceIdentityQueryKey = ["device-identity"] as const; + +/** + * The device identity of this install. + * + * Machine-scoped, NOT community-scoped: it must survive a community + * switch, so it is deliberately absent from `resetCommunityState()` in + * `desktop/src/features/communities/useCommunityInit.ts`. Do not add it + * there. + */ +export function useDeviceIdentityQuery() { + return useQuery({ + queryKey: deviceIdentityQueryKey, + queryFn: getDeviceIdentity, + staleTime: Number.POSITIVE_INFINITY, + }); +} + export function useRelayAgentsQuery(options?: { enabled?: boolean }) { const refetchInterval = useFocusedRefetchInterval(AGENTS_FOCUS_STALE_TIME_MS); return useQuery({ diff --git a/desktop/src/features/agents/lib/agentDeviceLabel.test.mjs b/desktop/src/features/agents/lib/agentDeviceLabel.test.mjs new file mode 100644 index 0000000000..e170a42ff6 --- /dev/null +++ b/desktop/src/features/agents/lib/agentDeviceLabel.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { describeAgentDevice } from "./agentDeviceLabel.ts"; + +test("device label stays silent for a local agent with no name collision", () => { + assert.equal( + describeAgentDevice({ + isLocal: true, + deviceLabel: "this-mac", + hasNameCollision: false, + }), + null, + ); + assert.equal( + describeAgentDevice({ + isLocal: true, + deviceLabel: null, + hasNameCollision: false, + }), + null, + ); +}); + +test("device label names this device when a local agent collides on name", () => { + assert.equal( + describeAgentDevice({ + isLocal: true, + deviceLabel: "mfeth-win", + hasNameCollision: true, + }), + "on this device", + ); + assert.equal( + describeAgentDevice({ + isLocal: true, + deviceLabel: null, + hasNameCollision: true, + }), + "on this device", + ); +}); + +test("device label names the remote device when one is published", () => { + assert.equal( + describeAgentDevice({ + isLocal: false, + deviceLabel: "mfeth-win", + hasNameCollision: true, + }), + "on mfeth-win", + ); + assert.equal( + describeAgentDevice({ + isLocal: false, + deviceLabel: "mfeth-win", + hasNameCollision: false, + }), + "on mfeth-win", + ); + assert.equal( + describeAgentDevice({ + isLocal: false, + deviceLabel: " mfeth-win ", + hasNameCollision: false, + }), + "on mfeth-win", + ); +}); + +test("device label never fabricates a name for a remote agent without one", () => { + for (const deviceLabel of [null, undefined, "", " ", "\t\n"]) { + assert.equal( + describeAgentDevice({ + isLocal: false, + deviceLabel, + hasNameCollision: false, + }), + "on another device", + ); + } +}); diff --git a/desktop/src/features/agents/lib/agentDeviceLabel.ts b/desktop/src/features/agents/lib/agentDeviceLabel.ts new file mode 100644 index 0000000000..fe6f3dda7d --- /dev/null +++ b/desktop/src/features/agents/lib/agentDeviceLabel.ts @@ -0,0 +1,38 @@ +/** + * Describes which computer an agent lives on, for a UI that must + * distinguish same-named agents minted on different devices. + * + * The same account signed in on several computers mints a *separate* + * keypair per computer for the same agent, so a channel can show four + * identical "Winnie" entries of which only one is runnable here. This is + * the copy that tells them apart. + * + * Returns `null` when there is nothing informative to say — a local agent + * with no name collision is on this device by definition, and saying so + * would be noise for the single-device majority. + * + * | isLocal | deviceLabel | hasNameCollision | result | + * | ------- | ----------- | ---------------- | -------------------- | + * | true | any | false | `null` (no noise) | + * | true | any | true | `"on this device"` | + * | false | `"mfeth-win"` | any | `"on mfeth-win"` | + * | false | null/empty | any | `"on another device"`| + * + * A whitespace-only label counts as absent. A wrong device name is worse + * than no device name, so a missing label is never filled in with a guess. + */ +export function describeAgentDevice(input: { + /** True when this pubkey has a record in the local managed-agent store. */ + isLocal: boolean; + /** Device label read off the agent's kind:30177 event, if any. */ + deviceLabel?: string | null; + /** True when another visible suggestion shares this display name. */ + hasNameCollision: boolean; +}): string | null { + if (input.isLocal) { + return input.hasNameCollision ? "on this device" : null; + } + + const label = input.deviceLabel?.trim(); + return label ? `on ${label}` : "on another device"; +} diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index 3ad358a0d6..ef7f0d577e 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -48,8 +48,52 @@ export type MentionCandidate = { isAgent: boolean; isManagedAgent?: boolean; isGlobalSearchResult?: boolean; + /** Device label from the agent's kind:30177 event; local agents leave it unset. */ + deviceLabel?: string | null; }; +/** + * Fold a newly discovered candidate into the one already held for a pubkey. + * + * The same pubkey can surface from several sources (channel member, relay + * agent directory, local managed-agent store), each carrying a different + * slice of the truth, so every field takes the first non-nullish value — + * except the display name, where an agent-sourced name beats a person-sourced + * one because only the agent sources know an agent's configured name. + * + * `fallbackOwnerPubkey` is the profile-derived owner used when neither side + * declares one. + */ +export function mergeMentionCandidates( + current: MentionCandidate, + candidate: MentionCandidate, + fallbackOwnerPubkey: string | null | undefined, +): MentionCandidate { + return { + ...current, + avatarUrl: current.avatarUrl ?? candidate.avatarUrl ?? null, + displayName: + current.isAgent && !candidate.isAgent + ? current.displayName + : candidate.isAgent && !current.isAgent + ? (candidate.displayName ?? current.displayName) + : (current.displayName ?? candidate.displayName), + isAgent: current.isAgent || candidate.isAgent, + isMember: current.isMember || candidate.isMember, + personaId: current.personaId ?? candidate.personaId, + personaName: current.personaName ?? candidate.personaName ?? null, + role: current.role ?? candidate.role ?? null, + secondaryLabel: current.secondaryLabel ?? candidate.secondaryLabel ?? null, + ownerPubkey: + current.ownerPubkey ?? + candidate.ownerPubkey ?? + fallbackOwnerPubkey ?? + null, + isManagedAgent: current.isManagedAgent || candidate.isManagedAgent, + deviceLabel: current.deviceLabel ?? candidate.deviceLabel ?? null, + }; +} + export function mentionCandidateLabel(candidate: MentionCandidate) { return ( candidate.displayName ?? diff --git a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts index c710cf613b..7ae3593481 100644 --- a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts +++ b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts @@ -16,6 +16,8 @@ export type MentionSuggestionCandidate = { isMember: boolean; role?: ChannelRole | null; ownerPubkey?: string | null; + deviceLabel?: string | null; + isManagedAgent?: boolean; }; export function mapMentionCandidateToSuggestion(opts: { @@ -58,5 +60,7 @@ export function mapMentionCandidateToSuggestion(opts: { candidate.isMember === false, ownerLabel, role: !candidate.isAgent && candidate.role === "admin" ? "admin" : null, + deviceLabel: candidate.deviceLabel ?? null, + isLocalAgent: candidate.isManagedAgent === true, }; } diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 160d999a4d..4b815642c9 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -57,6 +57,7 @@ import { globalSearchIdentityKey, type MentionCandidate, mentionCandidateLabel, + mergeMentionCandidates, } from "./mentionCandidates"; const MENTION_DEBOUNCE_MS = 120; const MENTION_SUGGESTION_LIMIT = 50; @@ -275,31 +276,16 @@ export function useMentions( candidatesByPubkey.set(pubkey, { ...candidate, pubkey }); return; } - candidatesByPubkey.set(pubkey, { - ...current, - avatarUrl: current.avatarUrl ?? candidate.avatarUrl ?? null, - displayName: - current.isAgent && !candidate.isAgent - ? current.displayName - : candidate.isAgent && !current.isAgent - ? (candidate.displayName ?? current.displayName) - : (current.displayName ?? candidate.displayName), - isAgent: current.isAgent || candidate.isAgent, - isMember: current.isMember || candidate.isMember, - personaId: current.personaId ?? candidate.personaId, - personaName: current.personaName ?? candidate.personaName ?? null, - role: current.role ?? candidate.role ?? null, - secondaryLabel: - current.secondaryLabel ?? candidate.secondaryLabel ?? null, - ownerPubkey: - current.ownerPubkey ?? - candidate.ownerPubkey ?? - (candidate.isAgent && candidate.pubkey + candidatesByPubkey.set( + pubkey, + mergeMentionCandidates( + current, + candidate, + candidate.isAgent && candidate.pubkey ? profiles?.[pubkey]?.ownerPubkey - : null) ?? - null, - isManagedAgent: current.isManagedAgent || candidate.isManagedAgent, - }); + : null, + ), + ); }; for (const member of members ?? []) { const pubkey = normalizePubkey(member.pubkey); @@ -351,6 +337,7 @@ export function useMentions( (activePersonaById.has(pubkey) ? pubkey : undefined), ownerPubkey: null, isAgent: true, + deviceLabel: agent.deviceLabel, }); } for (const agent of managedAgentsQuery.data ?? []) { diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index 508e35f402..7f793a6bad 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { Bot, Users } from "lucide-react"; +import { describeAgentDevice } from "@/features/agents/lib/agentDeviceLabel"; import type { TeamMentionMember } from "@/features/messages/lib/mentionCandidates"; import { Badge } from "@/shared/ui/badge"; @@ -25,6 +26,10 @@ export type MentionSuggestion = { notInChannel?: boolean; ownerLabel?: string | null; role?: string | null; + /** Device label from the agent's kind:30177 event, when it is not this device's. */ + deviceLabel?: string | null; + /** True when this agent has a record in the local managed-agent store. */ + isLocalAgent?: boolean; }; type MentionAutocompleteProps = { @@ -107,6 +112,15 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ hasNameCollision && suggestion.pubkey ? safeNpub(suggestion.pubkey) : null; + // Same account on several computers mints one keypair per computer, + // so identically named agents are usually different devices. + const deviceLine = suggestion.isAgent + ? describeAgentDevice({ + isLocal: suggestion.isLocalAgent === true, + deviceLabel: suggestion.deviceLabel, + hasNameCollision, + }) + : null; return ( + + + + + ); +} diff --git a/desktop/src/shared/api/relayDirectoryTypes.ts b/desktop/src/shared/api/relayDirectoryTypes.ts new file mode 100644 index 0000000000..e04ab6dac3 --- /dev/null +++ b/desktop/src/shared/api/relayDirectoryTypes.ts @@ -0,0 +1,34 @@ +import type { RespondToMode } from "./types"; + +export type RelayMemberRole = "owner" | "admin" | "member"; + +export type RelayMember = { + pubkey: string; + role: RelayMemberRole; + addedBy: string | null; + createdAt: string; +}; + +export type RelayAgent = { + pubkey: string; + ownerPubkey: string | null; + name: string; + agentType: string; + channels: string[]; + channelIds: string[]; + capabilities: string[]; + status: "online" | "away" | "offline"; + respondTo: RespondToMode | null; + respondToAllowlist: string[]; + /** Opaque id of the device that holds this agent's secret. */ + deviceId: string | null; + /** Human label for that device, or null on pre-feature events. */ + deviceLabel: string | null; +}; + +/** Identity of the computer this Buzz install runs on. */ +export type DeviceIdentity = { + deviceId: string; + deviceLabel: string; + createdAt: string; +}; diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 038ae52714..ff98e74861 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -18,7 +18,6 @@ import type { HomeFeedResponse, ManagedAgent, ManagedAgentBackend, - RelayAgent, RelayMember, RelayMemberRole, PresenceLookup, @@ -98,18 +97,6 @@ type RawSearchResponse = { found: number; }; -type RawRelayAgent = { - pubkey: string; - owner_pubkey?: string | null; - name: string; - agent_type: string; - channels: string[]; - channel_ids: string[]; - capabilities: string[]; - status: RelayAgent["status"]; - respond_to?: RelayAgent["respondTo"]; - respond_to_allowlist?: string[]; -}; import type { RestartDiffEntry as RawRestartDiffEntry } from "./restartDiff"; export type RawManagedAgent = { pubkey: string; @@ -652,21 +639,6 @@ export async function createAuthEvent(input: { const eventJson = await invokeTauri("create_auth_event", input); return JSON.parse(eventJson) as RelayEvent; } -function fromRawRelayAgent(agent: RawRelayAgent): RelayAgent { - return { - pubkey: agent.pubkey, - ownerPubkey: agent.owner_pubkey ?? null, - name: agent.name, - agentType: agent.agent_type, - channels: agent.channels, - channelIds: agent.channel_ids ?? [], - capabilities: agent.capabilities, - status: agent.status, - respondTo: agent.respond_to ?? null, - respondToAllowlist: agent.respond_to_allowlist ?? [], - }; -} - export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { return { pubkey: agent.pubkey, @@ -810,12 +782,6 @@ export async function changeRelayMemberRole( await invokeTauri("change_relay_member_role", { targetPubkey, newRole }); } -export async function listRelayAgents(): Promise { - return (await invokeTauri("list_relay_agents")).map( - fromRawRelayAgent, - ); -} - export async function listManagedAgents(): Promise { return (await invokeTauri("list_managed_agents")).map( fromRawManagedAgent, diff --git a/desktop/src/shared/api/tauriDeviceIdentity.ts b/desktop/src/shared/api/tauriDeviceIdentity.ts new file mode 100644 index 0000000000..cc4271491c --- /dev/null +++ b/desktop/src/shared/api/tauriDeviceIdentity.ts @@ -0,0 +1,18 @@ +import { invokeTauri } from "@/shared/api/tauri"; +import type { DeviceIdentity } from "@/shared/api/types"; + +/** Read this install's device identity, minting one on first call. */ +export async function getDeviceIdentity(): Promise { + return invokeTauri("get_device_identity"); +} + +/** + * Rename this device. + * + * The backend trims, rejects control characters, caps the label at 32 + * characters, and republishes the label on every local agent's kind:30177 + * event, so callers need no follow-up write. + */ +export async function setDeviceLabel(label: string): Promise { + return invokeTauri("set_device_label", { label }); +} diff --git a/desktop/src/shared/api/tauriRelayAgents.ts b/desktop/src/shared/api/tauriRelayAgents.ts index 8ae6766f79..6b7c7e0c94 100644 --- a/desktop/src/shared/api/tauriRelayAgents.ts +++ b/desktop/src/shared/api/tauriRelayAgents.ts @@ -1,7 +1,8 @@ import { invokeTauri } from "@/shared/api/tauri"; import type { RelayAgent } from "@/shared/api/types"; -type RawRelayAgent = { +/** Wire shape of a relay agent directory entry. */ +export type RawRelayAgent = { pubkey: string; owner_pubkey?: string | null; name: string; @@ -12,17 +13,13 @@ type RawRelayAgent = { status: RelayAgent["status"]; respond_to?: RelayAgent["respondTo"]; respond_to_allowlist?: string[]; + device_id?: string | null; + device_label?: string | null; }; -export async function revalidateRelayAgents( - pubkeys: string[], - channelId?: string, -): Promise { - const agents = await invokeTauri("revalidate_relay_agents", { - pubkeys, - channelId, - }); - return agents.map((agent) => ({ +/** Normalize a wire relay agent, defaulting fields absent on older payloads. */ +export function fromRawRelayAgent(agent: RawRelayAgent): RelayAgent { + return { pubkey: agent.pubkey, ownerPubkey: agent.owner_pubkey ?? null, name: agent.name, @@ -33,5 +30,25 @@ export async function revalidateRelayAgents( status: agent.status, respondTo: agent.respond_to ?? null, respondToAllowlist: agent.respond_to_allowlist ?? [], - })); + deviceId: agent.device_id ?? null, + deviceLabel: agent.device_label ?? null, + }; +} + +/** List the agents visible in the viewer's relay agent directory. */ +export async function listRelayAgents(): Promise { + return (await invokeTauri("list_relay_agents")).map( + fromRawRelayAgent, + ); +} + +export async function revalidateRelayAgents( + pubkeys: string[], + channelId?: string, +): Promise { + const agents = await invokeTauri("revalidate_relay_agents", { + pubkeys, + channelId, + }); + return agents.map(fromRawRelayAgent); } diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index dcf6d2e8bc..397a7808a3 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -258,26 +258,12 @@ export type { // ── Relay Members ──────────────────────────────────────────────────────────── -export type RelayMemberRole = "owner" | "admin" | "member"; - -export type RelayMember = { - pubkey: string; - role: RelayMemberRole; - addedBy: string | null; - createdAt: string; -}; -export type RelayAgent = { - pubkey: string; - ownerPubkey: string | null; - name: string; - agentType: string; - channels: string[]; - channelIds: string[]; - capabilities: string[]; - status: "online" | "away" | "offline"; - respondTo: RespondToMode | null; - respondToAllowlist: string[]; -}; +export type { + DeviceIdentity, + RelayAgent, + RelayMember, + RelayMemberRole, +} from "./relayDirectoryTypes"; export type ManagedAgentRuntimeLifecycle = | "starting" diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 1aa98ca4a7..598fd85fdd 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -122,6 +122,8 @@ type MockRelayAgentSeed = { channelNames?: string[]; channelIds?: string[]; status?: PresenceStatus; + deviceId?: string | null; + deviceLabel?: string | null; }; type MockPersonaSeed = { @@ -863,6 +865,8 @@ type RawRelayAgent = { status: PresenceStatus; respond_to?: "owner-only" | "allowlist" | "anyone"; respond_to_allowlist?: string[]; + device_id?: string | null; + device_label?: string | null; }; type RawManagedAgent = { @@ -1477,6 +1481,11 @@ const BOB_PUBKEY = "bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260"; const CHARLIE_PUBKEY = "554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea"; +// A second agent that shares the display name "alice" but lives on another +// device of the same account. Fixture for the duplicate-name / which-device +// mention flow. +const ALICE_OTHER_DEVICE_PUBKEY = + "f6a1501f0a4e4d2c8b7a3e19d5c60b4471e8f2a3c9d0b6e5f4a3928170615243"; const OUTSIDER_PUBKEY = "df8e91b86fda13a9a67896df77232f7bdab2ba9c3e165378e1ba3d24c13a328e"; const PROFILE_ONLY_AGENT_PUBKEY = @@ -2337,6 +2346,8 @@ function resetMockRelayAgents(config?: E2eConfig) { status: seed.status ?? "online", respond_to: seed.respondTo ?? "owner-only", respond_to_allowlist: seed.respondToAllowlist ?? [], + device_id: seed.deviceId ?? null, + device_label: seed.deviceLabel ?? null, }); } } @@ -3324,6 +3335,17 @@ function initializeMockHuddle( persistMockHuddle(); } const openedExternalUrls: string[] = []; +const MOCK_DEVICE_ID = "e2edevice00000000000000000000aaaa"; +const MOCK_DEVICE_LABEL = "this-mac"; +const MOCK_OTHER_DEVICE_ID = "e2edevice00000000000000000000bbbb"; +const MOCK_OTHER_DEVICE_LABEL = "mfeth-win"; +const DEFAULT_MOCK_DEVICE_IDENTITY = { + deviceId: MOCK_DEVICE_ID, + deviceLabel: MOCK_DEVICE_LABEL, + createdAt: "2026-01-01T00:00:00Z", +}; +let mockDeviceIdentity = { ...DEFAULT_MOCK_DEVICE_IDENTITY }; + const defaultMockRelayAgents: RawRelayAgent[] = [ { pubkey: ALICE_PUBKEY, @@ -3338,6 +3360,25 @@ const defaultMockRelayAgents: RawRelayAgent[] = [ status: "online", respond_to: "anyone", respond_to_allowlist: [], + device_id: MOCK_DEVICE_ID, + device_label: MOCK_DEVICE_LABEL, + }, + { + // Same display name as the agent above, different secret-holding device. + pubkey: ALICE_OTHER_DEVICE_PUBKEY, + name: "alice", + agent_type: "goose", + channels: ["general", "agents"], + channel_ids: [ + "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50", + "94a444a4-c0a3-5966-ab05-530c6ddc2301", + ], + capabilities: ["search", "summaries", "workflows"], + status: "online", + respond_to: "anyone", + respond_to_allowlist: [], + device_id: MOCK_OTHER_DEVICE_ID, + device_label: MOCK_OTHER_DEVICE_LABEL, }, { pubkey: CHARLIE_PUBKEY, @@ -3349,6 +3390,8 @@ const defaultMockRelayAgents: RawRelayAgent[] = [ status: "away", respond_to: "anyone", respond_to_allowlist: [], + device_id: null, + device_label: null, }, ]; let mockRelayAgents: RawRelayAgent[] = defaultMockRelayAgents.map((agent) => ({ @@ -3689,6 +3732,9 @@ function syncMockRelayAgentsFromManagedAgents() { : "offline", respond_to: agent.respond_to, respond_to_allowlist: [...agent.respond_to_allowlist], + // A local managed agent's secret is, by construction, on this device. + device_id: mockDeviceIdentity.deviceId, + device_label: mockDeviceIdentity.deviceLabel, }; }, ); @@ -10267,6 +10313,7 @@ export function maybeInstallE2eTauriMocks() { ? { ...config.mock.globalAgentConfig } : null; resetMockRelayMembers(config); + mockDeviceIdentity = { ...DEFAULT_MOCK_DEVICE_IDENTITY }; resetMockRelayAgents(config); resetMockManagedAgents(config); resetMockPersonas(config); @@ -12236,6 +12283,15 @@ export function maybeInstallE2eTauriMocks() { (!channelId || agent.channel_ids.includes(channelId)), ); } + case "get_device_identity": + return { ...mockDeviceIdentity }; + case "set_device_label": { + const label = (payload as { label?: unknown } | undefined)?.label; + if (typeof label === "string" && label.trim().length > 0) { + mockDeviceIdentity.deviceLabel = label.trim().slice(0, 32); + } + return { ...mockDeviceIdentity }; + } case "list_personas": return handleListPersonas(); case "create_persona": diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index e6e0e9806e..913b6db885 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from "@playwright/test"; +import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, openChannelBrowser, @@ -31,6 +32,14 @@ const PROFILE_ONLY_AGENT_PUBKEY = "8f83d6b7f3d74f7d933ae3a54dd8c6cc85c7f98e531c16e5a827b953441a8d67"; const OWNED_AGENT_PROFILE_PUBKEY = "1212121212121212121212121212121212121212121212121212121212121212"; +/** + * Second built-in mock agent named "alice", holding a different keypair on a + * different computer of the same account. Mirrors + * `ALICE_OTHER_DEVICE_PUBKEY` / `MOCK_OTHER_DEVICE_LABEL` in `e2eBridge.ts`. + */ +const ALICE_OTHER_DEVICE_PUBKEY = + "f6a1501f0a4e4d2c8b7a3e19d5c60b4471e8f2a3c9d0b6e5f4a3928170615243"; +const OTHER_DEVICE_LABEL = "mfeth-win"; const SYSTEM_MESSAGE_KIND = 40099; const DM_THREAD_AGENT_MENTION_ERROR_TEXT = "Agents must already be in a DM to be mentioned in its threads. Start a new conversation that includes the agent."; @@ -2900,3 +2909,133 @@ test("delayed inaccessible agent profile keeps all actions hidden", async ({ ), ).toHaveCount(0); }); + +// --------------------------------------------------------------------------- +// Stage 0 device identity: the same account signed in on several computers +// mints a separate keypair per computer for the same agent, so a channel can +// show two identically named agents of which only one is runnable here. +// --------------------------------------------------------------------------- + +test("mention dropdown names the device behind same-named agents", async ({ + page, +}) => { + // Seeding `alice` locally makes exactly one of the two relay `alice` + // identities this device's; the other stays a remote twin. + await installMockBridge(page, { + managedAgents: [ + { + pubkey: TEST_IDENTITIES.alice.pubkey, + name: "alice", + status: "stopped", + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.getByTestId("message-input").fill("@alice"); + + const dropdown = autocomplete(page); + const localRow = dropdown.getByTestId( + `mention-suggestion-${TEST_IDENTITIES.alice.pubkey}`, + ); + const remoteRow = dropdown.getByTestId( + `mention-suggestion-${ALICE_OTHER_DEVICE_PUBKEY}`, + ); + await expect(localRow).toBeVisible(); + await expect(remoteRow).toBeVisible(); + await waitForAnimations(page); + + await expect(remoteRow.getByTestId("mention-device-label")).toHaveText( + `on ${OTHER_DEVICE_LABEL}`, + ); + await expect(localRow.getByTestId("mention-device-label")).toHaveText( + "on this device", + ); + + // The collision npub is the impersonation guard and is deliberately left + // unchanged by the device line. + await expect(localRow.getByTestId("mention-collision-npub")).toBeVisible(); + await expect(remoteRow.getByTestId("mention-collision-npub")).toBeVisible(); +}); + +test("mention dropdown stays silent about the device when a name is unique", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + status: "stopped", + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.getByTestId("message-input").fill("@quinn"); + + const quinnRow = autocomplete(page).getByTestId( + `mention-suggestion-${ALLOWLIST_RELAY_AGENT_PUBKEY}`, + ); + await expect(quinnRow).toBeVisible(); + await waitForAnimations(page); + // No collision, and it is this device's agent: saying so would be noise for + // the single-device majority, so the element must not exist at all. + await expect(quinnRow.getByTestId("mention-device-label")).toHaveCount(0); + await expect(quinnRow.getByTestId("mention-collision-npub")).toHaveCount(0); +}); + +test("mentioning another device's agent says so and still sends", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: TEST_IDENTITIES.alice.pubkey, + name: "alice", + status: "stopped", + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("@alice"); + const remoteRow = autocomplete(page).getByTestId( + `mention-suggestion-${ALICE_OTHER_DEVICE_PUBKEY}`, + ); + await expect(remoteRow).toBeVisible(); + await remoteRow.click(); + await page.keyboard.type("are you there"); + + const content = "@alice are you there"; + await expect(input).toHaveText(content); + await page.getByTestId("send-message").click(); + + const inviteButton = page.getByRole("button", { + name: "Invite", + exact: true, + }); + if (await inviteButton.isVisible().catch(() => false)) { + await inviteButton.click(); + } + + // The notice names the computer that would have to answer, instead of the + // silence that produced "I @-mentioned four agents and none replied". + await expect( + page.getByText( + `alice is set up on ${OTHER_DEVICE_LABEL}, not on this device. Only that device can reply.`, + ), + ).toBeVisible(); + + // A notice, not an error: the message still goes out carrying its p tag. + await expect + .poll(() => readOutgoingMentionPubkeys(page, content)) + .toContain(ALICE_OTHER_DEVICE_PUBKEY); +});