From 23c95ff640d82f09b7b6e82470a55b08d3b13736 Mon Sep 17 00:00:00 2001
From: Michael Feth
Date: Tue, 18 Aug 2026 15:09:11 -0400
Subject: [PATCH 1/2] 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 081e345edb7..4d2a833f4c2 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 01504852b6f..1108a86c59d 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 c0526222151..9c85b65b36c 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 00000000000..9161d1cbbe5
--- /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 cefdccfd69f..886baf19d64 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 f70c714323e..e40c2604efa 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 00000000000..fdcbc187ee6
--- /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 16234aa3d69..e15caa607a6 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 c9269dbf002..da0010d7786 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 3b0641cb677..1f8543d7f03 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 28604de5e5f..9978b31208b 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 9401d19add4..77286caf596 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 18ddd80eb8d..ef166410440 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 0913582cabd..2a9b93a2a9c 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 00000000000..e170a42ff63
--- /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 00000000000..fe6f3dda7dd
--- /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 3ad358a0d66..ef7f0d577ec 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 c710cf613b5..7ae35934815 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 160d999a4d9..4b815642c99 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 508e35f4026..7f793a6badd 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 (
) : null}
+ {deviceLine ? (
+
+ {deviceLine}
+
+ ) : null}
) : null}
{collisionNpub ? (
diff --git a/desktop/src/features/messages/ui/useEnsureManagedAgentMentionsReady.ts b/desktop/src/features/messages/ui/useEnsureManagedAgentMentionsReady.ts
new file mode 100644
index 00000000000..df591d8f968
--- /dev/null
+++ b/desktop/src/features/messages/ui/useEnsureManagedAgentMentionsReady.ts
@@ -0,0 +1,151 @@
+import * as React from "react";
+import {
+ type useAttachManagedAgentToChannelMutation,
+ useRelayAgentsQuery,
+ type useStartManagedAgentMutation,
+} from "@/features/agents/hooks";
+import type { ManagedAgent, RelayAgent } from "@/shared/api/types";
+import { normalizePubkey } from "@/shared/lib/pubkey";
+import {
+ getErrorMessage,
+ isManagedAgentRunning,
+ isProviderBackedAgent,
+ uniqueNormalizedPubkeys,
+} from "./useMentionSendFlow.helpers";
+
+/** Outcome of preparing the managed agents named by a message's mentions. */
+export type ManagedAgentMentionReadiness = {
+ /** Agents that exist here but could not be started or attached. */
+ errors: string[];
+ /** Agents that live on a different computer and so cannot answer from here. */
+ notices: string[];
+ /** Agents that are now running and attached to the channel. */
+ pubkeys: string[];
+};
+
+/**
+ * Start or attach every locally owned agent named by a mention, and describe
+ * the ones that are not locally owned.
+ *
+ * The same account signed in on several computers mints a *separate* keypair
+ * per computer for the same agent, so a mention can resolve to an identity
+ * only another machine can run. Those used to be dropped in silence, which is
+ * the reported "I @-mentioned four agents and none replied" failure; they now
+ * come back as `notices` naming the device that would have to answer.
+ */
+export function useEnsureManagedAgentMentionsReady(options: {
+ attachAgentMutation: ReturnType<
+ typeof useAttachManagedAgentToChannelMutation
+ >;
+ getManagedAgentsByPubkey: () => Promise>;
+ memberPubkeys: ReadonlySet;
+ startAgentMutation: ReturnType;
+}) {
+ const {
+ attachAgentMutation,
+ getManagedAgentsByPubkey,
+ memberPubkeys,
+ startAgentMutation,
+ } = options;
+ // Deduped by React Query against the identical `relayAgentsQueryKey` already
+ // in flight from `useMentions`, so this costs no extra fetch. It is the only
+ // place a mention resolved to another computer's keypair can be named.
+ const relayAgentsQuery = useRelayAgentsQuery();
+ const relayAgentsByPubkey = React.useMemo(
+ () =>
+ new Map(
+ (relayAgentsQuery.data ?? []).map((agent) => [
+ normalizePubkey(agent.pubkey),
+ agent,
+ ]),
+ ),
+ [relayAgentsQuery.data],
+ );
+ return React.useCallback(
+ async (
+ mentionPubkeys: string[],
+ capturedChannelId: string,
+ preparedParticipantPubkeys: string[] = [],
+ preparedManagedAgents: ManagedAgent[] = [],
+ ): Promise => {
+ if (!capturedChannelId || mentionPubkeys.length === 0) {
+ return { errors: [], notices: [], pubkeys: [] };
+ }
+ const managedAgentsByPubkey = await getManagedAgentsByPubkey();
+ for (const agent of preparedManagedAgents) {
+ managedAgentsByPubkey.set(normalizePubkey(agent.pubkey), agent);
+ }
+ const participantPubkeys = new Set([
+ ...memberPubkeys,
+ ...preparedParticipantPubkeys.map(normalizePubkey),
+ ]);
+ const errors: string[] = [];
+ const notices: string[] = [];
+ const pubkeys: string[] = [];
+ for (const pubkey of uniqueNormalizedPubkeys(mentionPubkeys)) {
+ const agent = managedAgentsByPubkey.get(pubkey);
+ if (!agent) {
+ const notice = describeUnrunnableMention(
+ relayAgentsByPubkey.get(pubkey),
+ );
+ if (notice) notices.push(notice);
+ continue;
+ }
+ try {
+ if (participantPubkeys.has(pubkey)) {
+ if (isProviderBackedAgent(agent)) {
+ if (agent.status !== "deployed") {
+ await startAgentMutation.mutateAsync(agent.pubkey);
+ }
+ } else if (!isManagedAgentRunning(agent)) {
+ await startAgentMutation.mutateAsync(agent.pubkey);
+ }
+ } else {
+ await attachAgentMutation.mutateAsync({
+ channelId: capturedChannelId,
+ agent,
+ role: "bot",
+ });
+ }
+ pubkeys.push(pubkey);
+ } catch (error) {
+ errors.push(
+ `${agent.name}: ${getErrorMessage(
+ error,
+ "Could not prepare agent.",
+ )}`,
+ );
+ }
+ }
+ return { errors, notices, pubkeys: uniqueNormalizedPubkeys(pubkeys) };
+ },
+ [
+ attachAgentMutation,
+ getManagedAgentsByPubkey,
+ memberPubkeys,
+ relayAgentsByPubkey,
+ startAgentMutation,
+ ],
+ );
+}
+
+/**
+ * Copy for a mention that resolved to an agent identity this install does not
+ * hold the secret for. Names the owning device when the agent published one,
+ * and never guesses a device name when it did not.
+ *
+ * Returns `null` — meaning stay silent, exactly as before this feature — for
+ * an agent that declares no device at all. Relay-hosted and pre-feature
+ * agents are legitimately not "set up on" any computer, so pinning them to
+ * one would be both wrong and noisy on every shared-agent mention.
+ */
+function describeUnrunnableMention(
+ remote: RelayAgent | undefined,
+): string | null {
+ if (!remote?.deviceId) return null;
+ const name = remote.name.trim() || "That agent";
+ const device = remote.deviceLabel?.trim();
+ return device
+ ? `${name} is set up on ${device}, not on this device. Only that device can reply.`
+ : `${name} is set up on another device, not on this device. Only that device can reply.`;
+}
diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts
index e322f91987d..4d6d0eb58fd 100644
--- a/desktop/src/features/messages/ui/useMentionSendFlow.ts
+++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts
@@ -32,6 +32,7 @@ import type { UseMentionsResult } from "@/features/messages/lib/useMentions";
import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor";
import type { UseDraftsResult } from "@/features/messages/lib/useDrafts";
import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews";
+import { useEnsureManagedAgentMentionsReady } from "./useEnsureManagedAgentMentionsReady";
import { invokeTauri } from "@/shared/api/tauri";
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types";
@@ -39,8 +40,6 @@ import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags";
import {
getErrorMessage,
- isManagedAgentRunning,
- isProviderBackedAgent,
MENTION_REFERENCE_TAG,
mergeOutgoingTagsWithReferenceMentions,
type PendingNonMemberMentionSend,
@@ -173,72 +172,12 @@ export function useMentionSendFlow({
availableRuntimesQuery.isLoading,
availableRuntimesQuery.refetch,
]);
- const ensureManagedAgentMentionsReady = React.useCallback(
- async (
- mentionPubkeys: string[],
- capturedChannelId: string,
- preparedParticipantPubkeys: string[] = [],
- preparedManagedAgents: ManagedAgent[] = [],
- ) => {
- if (!capturedChannelId || mentionPubkeys.length === 0) {
- return {
- errors: [] as string[],
- pubkeys: [] as string[],
- };
- }
- const managedAgentsByPubkey = await getManagedAgentsByPubkey();
- for (const agent of preparedManagedAgents) {
- managedAgentsByPubkey.set(normalizePubkey(agent.pubkey), agent);
- }
- const participantPubkeys = new Set([
- ...mentions.memberPubkeys,
- ...preparedParticipantPubkeys.map(normalizePubkey),
- ]);
- const errors: string[] = [];
- const pubkeys: string[] = [];
- for (const pubkey of uniqueNormalizedPubkeys(mentionPubkeys)) {
- const agent = managedAgentsByPubkey.get(pubkey);
- if (!agent) {
- continue;
- }
- try {
- if (participantPubkeys.has(pubkey)) {
- if (isProviderBackedAgent(agent)) {
- if (agent.status !== "deployed") {
- await startAgentMutation.mutateAsync(agent.pubkey);
- }
- } else if (!isManagedAgentRunning(agent)) {
- await startAgentMutation.mutateAsync(agent.pubkey);
- }
- } else {
- await attachAgentMutation.mutateAsync({
- channelId: capturedChannelId,
- agent,
- role: "bot",
- });
- }
- pubkeys.push(pubkey);
- } catch (error) {
- errors.push(
- `${agent.name}: ${getErrorMessage(
- error,
- "Could not prepare agent.",
- )}`,
- );
- }
- }
- return {
- errors,
- pubkeys: uniqueNormalizedPubkeys(pubkeys),
- };
- },
- [
- attachAgentMutation,
- getManagedAgentsByPubkey,
- mentions.memberPubkeys,
- startAgentMutation,
- ],
- );
+ const ensureManagedAgentMentionsReady = useEnsureManagedAgentMentionsReady({
+ attachAgentMutation,
+ getManagedAgentsByPubkey,
+ memberPubkeys: mentions.memberPubkeys,
+ startAgentMutation,
+ });
const createMentionedPersonaAgents = React.useCallback(
async (trimmed: string, capturedChannelId: string) => {
const personaMentions = mentions.extractMentionPersonas(trimmed);
@@ -446,8 +385,13 @@ export function useMentionSendFlow({
}
}
+ // Every mentioned *agent*, not just the ones this device holds a
+ // secret for. The ones it does not are exactly the twins minted on
+ // another computer for the same persona, and naming them is the
+ // whole point of the readiness notices — pre-filtering to the
+ // locally managed set is what made them fail in silence.
const agentReadiness = await ensureManagedAgentMentionsReady(
- managedMentionPubkeys.filter(
+ agentMentionPubkeys.filter(
(pubkey) => !readyAgentPubkeys.has(normalizePubkey(pubkey)),
),
sendChannelId ?? "",
@@ -459,6 +403,11 @@ export function useMentionSendFlow({
persistPreflightDraft();
return;
}
+ if (agentReadiness.notices.length > 0) {
+ // A notice, not an error: the message still sends, it just cannot be
+ // answered from here.
+ toast.info(agentReadiness.notices.join(" "));
+ }
if (agentReadiness.errors.length > 0) {
const message =
agentReadiness.errors.length === 1
diff --git a/desktop/src/features/pulse/ui/PulseView.tsx b/desktop/src/features/pulse/ui/PulseView.tsx
index 4cde503fde5..025c762fbe5 100644
--- a/desktop/src/features/pulse/ui/PulseView.tsx
+++ b/desktop/src/features/pulse/ui/PulseView.tsx
@@ -114,6 +114,10 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
: "offline",
respondTo: agent.respondTo,
respondToAllowlist: agent.respondToAllowlist,
+ // Local managed agents are always on this device, so there is no
+ // sibling device to disambiguate against here.
+ deviceId: null,
+ deviceLabel: null,
});
}
}
diff --git a/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx b/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx
index 7307ff5df05..ed2accc3cab 100644
--- a/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx
+++ b/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx
@@ -1,4 +1,5 @@
import { AgentDefaultsSettingsCard } from "./AgentDefaultsSettingsCard";
+import { DeviceNameSettingsCard } from "./DeviceNameSettingsCard";
import { HarnessesSettingsPanel } from "./HarnessesSettingsPanel";
import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard";
import { SettingsOptionGroupList } from "./SettingsOptionGroup";
@@ -13,6 +14,7 @@ export function AgentsSettingsPanel() {
/>
+
diff --git a/desktop/src/features/settings/ui/DeviceNameSettingsCard.tsx b/desktop/src/features/settings/ui/DeviceNameSettingsCard.tsx
new file mode 100644
index 00000000000..22c7659004e
--- /dev/null
+++ b/desktop/src/features/settings/ui/DeviceNameSettingsCard.tsx
@@ -0,0 +1,108 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import * as React from "react";
+import { toast } from "sonner";
+
+import {
+ deviceIdentityQueryKey,
+ useDeviceIdentityQuery,
+} from "@/features/agents/hooks";
+import { setDeviceLabel } from "@/shared/api/tauriDeviceIdentity";
+import { Button } from "@/shared/ui/button";
+import { Input } from "@/shared/ui/input";
+import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
+
+/** Backend cap on the device label; mirrored here so the field stops typing at it. */
+const DEVICE_LABEL_MAX_LENGTH = 32;
+
+/**
+ * Shows and renames this install's device label.
+ *
+ * The label is published on each local agent's kind:30177 event so the same
+ * account's other devices can tell duplicate agent names apart, which is why
+ * the subcopy spells out that it leaves the machine.
+ */
+export function DeviceNameSettingsCard() {
+ const queryClient = useQueryClient();
+ const deviceIdentity = useDeviceIdentityQuery();
+ const savedLabel = deviceIdentity.data?.deviceLabel ?? "";
+ const [draftLabel, setDraftLabel] = React.useState(savedLabel);
+ const [editedLabel, setEditedLabel] = React.useState(false);
+
+ // Seed the field from the query the first time it resolves, but never stomp
+ // on what the user is currently typing.
+ React.useEffect(() => {
+ if (!editedLabel) {
+ setDraftLabel(savedLabel);
+ }
+ }, [editedLabel, savedLabel]);
+
+ const renameDevice = useMutation({
+ mutationFn: (label: string) => setDeviceLabel(label),
+ onSuccess: (identity) => {
+ setEditedLabel(false);
+ setDraftLabel(identity.deviceLabel);
+ void queryClient.invalidateQueries({ queryKey: deviceIdentityQueryKey });
+ toast.success("Device name updated");
+ },
+ onError: (error: unknown) => {
+ toast.error(
+ error instanceof Error ? error.message : "Could not rename this device",
+ );
+ },
+ });
+
+ const trimmedLabel = draftLabel.trim();
+ const saveDisabled =
+ trimmedLabel.length === 0 ||
+ trimmedLabel === savedLabel ||
+ renameDevice.isPending ||
+ !deviceIdentity.data;
+
+ return (
+
+
+
+
+
+ Device name
+
+
+ Shown next to this device's agents when you view them from your
+ other devices. Published with your agents, so avoid personal
+ details.
+
+
+
+ {
+ setEditedLabel(true);
+ setDraftLabel(event.target.value);
+ }}
+ value={draftLabel}
+ />
+ {
+ renameDevice.mutate(trimmedLabel);
+ }}
+ size="sm"
+ type="button"
+ variant="outline"
+ >
+ Save
+
+
+
+
+
+ );
+}
diff --git a/desktop/src/shared/api/relayDirectoryTypes.ts b/desktop/src/shared/api/relayDirectoryTypes.ts
new file mode 100644
index 00000000000..e04ab6dac32
--- /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 038ae52714b..ff98e748618 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 00000000000..cc4271491cc
--- /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 8ae6766f79a..6b7c7e0c94f 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 dcf6d2e8bc7..397a7808a38 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 1aa98ca4a7f..598fd85fdd0 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 e6e0e9806e4..913b6db8853 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);
+});
From 8f4ce10f6141de91dba89313b3664f2caf01e080 Mon Sep 17 00:00:00 2001
From: Michael Feth
Date: Wed, 19 Aug 2026 14:26:14 -0400
Subject: [PATCH 2/2] fix(desktop): bound the device stamp to local agents and
validate every label
Review follow-up. Four issues, all confirmed against the code first.
**A provider-backed agent was given the wrong location.**
`agent_event_content` stamped the current device onto every record without
consulting `record.backend`. A `Provider` body runs elsewhere -- deployed to a
cluster from a laptop -- and stays online after that laptop sleeps, so the
mention UI told the user "only that device can reply" about a machine that is
not where the agent runs. The stamp is now gated on `BackendKind::Local`.
That alone closes the false guidance: `describeUnrunnableMention` already
returns `null` when no `device_id` is published, so such an agent now produces
no notice at all rather than a corrected one.
Distinguishing key *custody* from *execution* is a protocol change, not a second
stamp, and is left as a named future-work note.
**The label contract was bypassed on stored and inbound data.**
Validation ran only when a label was typed. Three holes:
- `load_or_create_at` returned whatever deserialized. A hand-edited
`device.json` was published unchecked. It now validates the whole identity --
id shape and label -- and an invalid file takes the path a corrupt one
already did: preserved as `device.json.corrupt`, replaced, never failing the
caller.
- The inbound kind:30177 projection forwarded `device_id`/`device_label`
verbatim. Owner authentication proves authorship, not well-formedness; a
sibling device on an older or tampered build can sign anything. Each field is
now validated independently and degrades to `None`, so a bad label never
hides an otherwise reachable agent.
- `char::is_control` is category `Cc` only, so zero-width (U+200B) and bidi
overrides (U+202E) passed through. Rather than a second rule, the label now
goes through `validate_device_label`, reusing the visible-text policy that
already guards agent definition text -- whose own doc comment asks for
validation "at every local, inbound, and publication boundary".
An over-long label is now refused rather than truncated: publishing something
other than what the owner typed is the worse failure.
**Rename claimed more than it did.** It reconciles only the active retention
scope. Republishing every scope needs owner keys for communities that are not
applied -- identity handling, not this command -- so the contract is now stated
truthfully instead: immediate for the active community, eventual elsewhere via
`run_event_sync` on activation.
**The OS host name could be published before the owner saw the warning.**
First-run labels are now opaque (`device-<8 hex>`) and `mint_identity` no longer
reads the host name. It is offered as an explicit opt-in in the settings card
via `get_device_name_suggestion`, so nothing derived from it reaches a relay
until the owner applies it.
The new tests share a process-global cache, so the seam is an RAII guard holding
a mutex and restoring on drop -- deliberately not repeating the pattern that
makes `claude_spawn_uses_the_probed_cli_executable` flaky.
Signed-off-by: Michael Feth
---
desktop/src-tauri/src/device_identity.rs | 310 ++++++++++++++----
desktop/src-tauri/src/lib.rs | 1 +
.../src/managed_agents/agent_events.rs | 69 +++-
.../managed_agents/definition_validation.rs | 26 ++
desktop/src-tauri/src/managed_agents/mod.rs | 4 +-
.../src/nostr_convert/agent_directory.rs | 20 +-
desktop/src-tauri/src/nostr_convert/tests.rs | 67 ++++
.../settings/ui/DeviceNameSettingsCard.tsx | 44 ++-
desktop/src/shared/api/tauriDeviceIdentity.ts | 20 +-
desktop/src/testing/e2eBridge.ts | 5 +
desktop/tests/e2e/mentions.spec.ts | 63 ++++
11 files changed, 562 insertions(+), 67 deletions(-)
diff --git a/desktop/src-tauri/src/device_identity.rs b/desktop/src-tauri/src/device_identity.rs
index 9161d1cbbe5..7ca11c0285f 100644
--- a/desktop/src-tauri/src/device_identity.rs
+++ b/desktop/src-tauri/src/device_identity.rs
@@ -22,12 +22,16 @@
//!
//! # 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.
+//! `device_label` is published in a world-readable kind:30177 event (see
+//! [`crate::managed_agents::agent_events`]), so it starts **opaque** —
+//! `device-<8 hex>`, derived from the id and saying nothing about the machine.
+//! Host names routinely contain a real person's name, so the OS host name is
+//! only ever *offered* as a suggestion ([`hostname_suggestion`]) and reaches the
+//! relay solely when the owner applies it via [`set_device_label`]. Every label,
+//! whichever boundary it arrives from — typed, loaded from disk, or received
+//! from a peer — passes [`validate_device_label`], the same visible-text policy
+//! that guards agent definition text. The opaque `device_id` alone is enough to
+//! tell N devices apart.
use std::path::{Path, PathBuf};
use std::sync::{PoisonError, RwLock};
@@ -35,11 +39,9 @@ use std::sync::{PoisonError, RwLock};
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
+use crate::managed_agents::definition_validation::validate_device_label;
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
@@ -49,8 +51,9 @@ const MAX_DEVICE_LABEL_CHARS: usize = 32;
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.
+ /// Human label shown beside this device's agents on other devices. Starts
+ /// opaque (`device-<8 hex>`); the owner may rename it, including to the OS
+ /// host name, which is never applied without that explicit choice.
pub device_label: String,
/// RFC 3339 first-run timestamp. Diagnostics only.
pub created_at: String,
@@ -64,44 +67,70 @@ pub struct DeviceIdentity {
/// existed.
static CURRENT: RwLock> = RwLock::new(None);
-/// Normalize a user-supplied or host-derived device label.
+/// Normalize a user-supplied 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.
+/// Trims, then enforces the shared visible-text policy
+/// ([`validate_device_label`]) — which rejects not only `Cc` control
+/// characters but the `Cf` format characters `char::is_control` misses, such as
+/// zero-width spaces and bidi overrides. An over-long label is **refused, not
+/// truncated**: silently publishing something other than what the owner typed
+/// is worse than telling them it is too long.
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());
+ validate_device_label(trimmed)?;
+ Ok(trimmed.to_string())
+}
+
+/// The opaque, id-derived label every device starts with.
+///
+/// Deliberately says nothing about the machine. See [`mint_identity`].
+fn opaque_label(device_id: &str) -> String {
+ format!("device-{}", device_id.chars().take(8).collect::())
+}
+
+/// Validate a `device_id` against the shape [`mint_identity`] produces:
+/// 32 lowercase hex digits (a uuid v4 in simple form).
+///
+/// Shared with the inbound relay path, which must not trust a peer's value.
+pub(crate) fn validate_device_id(device_id: &str) -> Result<(), String> {
+ if device_id.len() == 32
+ && device_id
+ .chars()
+ .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())
+ {
+ return Ok(());
}
- Ok(capped.to_string())
+ Err("device id must be 32 lowercase hex characters".to_string())
+}
+
+/// Validate a whole identity, whichever boundary it arrived from.
+fn validate_identity(identity: &DeviceIdentity) -> Result<(), String> {
+ validate_device_id(&identity.device_id)?;
+ validate_device_label(&identity.device_label)
}
-/// 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 OS host name, offered to the owner as a suggested device name.
///
-/// 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::()))
+/// Returns `None` when the host name is unusable — empty, over-long, or
+/// carrying characters the label policy refuses. This is only ever a
+/// *suggestion*: nothing here reaches the relay until the owner applies it.
+pub fn hostname_suggestion() -> Option {
+ let host = gethostname::gethostname();
+ let host = host.to_string_lossy();
+ sanitize_label(&host).ok()
}
-/// Mint a brand-new identity, seeding the label from the OS host name.
+/// Mint a brand-new identity with an **opaque** label.
+///
+/// The label is *not* seeded from the OS host name. Host names routinely carry
+/// a real person's name ("marys-macbook"), and this label is published in a
+/// world-readable kind:30177 event — so seeding from it would publish that name
+/// before the owner had seen any warning or had a chance to edit it. The owner
+/// opts into the host name explicitly, via [`hostname_suggestion`] surfaced in
+/// the device-name settings card.
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);
+ let device_label = opaque_label(&device_id);
DeviceIdentity {
device_id,
device_label,
@@ -117,11 +146,17 @@ fn write_identity_at(path: &Path, identity: &DeviceIdentity) -> Result<(), Strin
}
/// Load the identity at `path`, minting and persisting a fresh one when the
-/// file is absent, unreadable, or malformed.
+/// file is absent, unreadable, malformed, **or invalid**.
+///
+/// Deserializing proves only that the JSON has the right shape. The stored file
+/// is on disk, editable by hand, and survives downgrades — so the contents are
+/// revalidated here against the same policy [`set_label`] enforces. Without
+/// that, a hand-edited `device.json` carrying a 5000-character label or a bidi
+/// override would be published to the relay unchecked.
///
-/// 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.
+/// A file that fails either step 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)
@@ -129,6 +164,11 @@ fn load_or_create_at(path: &Path) -> Result {
.and_then(|content| {
serde_json::from_str::(&content)
.map_err(|e| format!("failed to parse device identity: {e}"))
+ })
+ .and_then(|identity| {
+ validate_identity(&identity)
+ .map(|()| identity)
+ .map_err(|e| format!("stored device identity is invalid: {e}"))
}) {
Ok(identity) => return Ok(identity),
Err(error) => {
@@ -189,6 +229,60 @@ pub fn current() -> Option {
.clone()
}
+/// Serializes every test that reads or writes [`CURRENT`].
+///
+/// `CURRENT` is process-global and Rust runs a binary's tests on parallel
+/// threads, so without this a test that seeds a device would race one asserting
+/// there is none. That is exactly the failure mode of the crate's known-flaky
+/// `claude_spawn_uses_the_probed_cli_executable`, which mutates the global
+/// `PATH`; do not reproduce it here.
+#[cfg(test)]
+pub(crate) static DEVICE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
+/// RAII seam letting a test exercise a code path that branches on a device
+/// being cached. Holds [`DEVICE_TEST_LOCK`] and restores the previous value on
+/// drop, so tests stay order-independent.
+///
+/// Test-only: `#[cfg(test)]` keeps it out of every release artifact, so the
+/// cache stays writable only by [`ensure`] and [`set_label`] in production.
+#[cfg(test)]
+pub(crate) struct DeviceGuard {
+ previous: Option,
+ _lock: std::sync::MutexGuard<'static, ()>,
+}
+
+#[cfg(test)]
+impl DeviceGuard {
+ /// Install `identity` as the cached device for the guard's lifetime.
+ pub(crate) fn set(identity: Option) -> Self {
+ let _lock = DEVICE_TEST_LOCK
+ .lock()
+ .unwrap_or_else(PoisonError::into_inner);
+ let previous = CURRENT
+ .read()
+ .unwrap_or_else(PoisonError::into_inner)
+ .clone();
+ *CURRENT.write().unwrap_or_else(PoisonError::into_inner) = identity;
+ Self { previous, _lock }
+ }
+
+ /// A deterministic device for assertions.
+ pub(crate) fn sample() -> DeviceIdentity {
+ DeviceIdentity {
+ device_id: "0123456789abcdef0123456789abcdef".to_string(),
+ device_label: "studio-mac".to_string(),
+ created_at: "2026-01-01T00:00:00+00:00".to_string(),
+ }
+ }
+}
+
+#[cfg(test)]
+impl Drop for DeviceGuard {
+ fn drop(&mut self) {
+ *CURRENT.write().unwrap_or_else(PoisonError::into_inner) = self.previous.take();
+ }
+}
+
/// Rename this device, persisting and caching the result.
///
/// The new label reaches other devices on the next kind:30177 republish. The
@@ -208,12 +302,35 @@ 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.
+/// Return the OS host name as a *suggested* device name, or `None` when it is
+/// unusable under the label policy.
///
-/// 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.
+/// Purely advisory: the settings card offers it, and nothing is published until
+/// the owner applies it via [`set_device_label`]. See [`mint_identity`] for why
+/// the host name is not the default.
+#[tauri::command]
+pub fn get_device_name_suggestion() -> Option {
+ hostname_suggestion()
+}
+
+/// Rename this device and republish the **active community's** local agents so
+/// its members see the new label without waiting for the next app restart.
+///
+/// Scope, stated precisely because it is narrower than it looks: republishing
+/// needs a retention scope, and a scope carries the owner keys for one
+/// `(owner, relay)` pair — which are only resolved for the community currently
+/// applied. Agents in the owner's *other* configured communities keep
+/// publishing the old label until that community is next activated, at which
+/// point `run_event_sync` reconciles them with the current label. So
+/// propagation is eventual everywhere, immediate only here.
+///
+/// Republishing every scope up front would mean resolving owner keys for
+/// communities that are not applied — a change to identity handling, not to
+/// this command, and out of scope for Stage 0.
+///
+/// The republish is best-effort in the other direction too: 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)?;
@@ -261,25 +378,100 @@ mod tests {
assert!(sanitize_label("mfeth\nwin").is_err());
}
+ /// Over-long labels are refused, never silently shortened: publishing
+ /// something other than what the owner typed is the worse failure.
#[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));
+ fn sanitize_label_rejects_over_thirty_two_chars() {
+ assert!(sanitize_label(&"a".repeat(33)).is_err());
+ assert_eq!(sanitize_label(&"a".repeat(32)).unwrap(), "a".repeat(32));
}
+ /// `char::is_control` covers only category `Cc`. These are `Cf`, and a bidi
+ /// override can visually reorder the text rendered around the label.
#[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");
+ fn sanitize_label_rejects_format_characters_is_control_would_miss() {
+ assert!(!'\u{202E}'.is_control(), "precondition: RLO is not Cc");
+ assert!(!'\u{200B}'.is_control(), "precondition: ZWSP is not Cc");
+ assert!(sanitize_label("mfeth\u{202E}win").is_err(), "bidi override");
+ assert!(
+ sanitize_label("mfeth\u{200B}win").is_err(),
+ "zero width space"
+ );
+ assert!(sanitize_label("mfeth\u{2066}win").is_err(), "bidi isolate");
}
#[test]
- fn seed_label_prefers_the_sanitized_seed() {
- let device_id = "0123456789abcdef0123456789abcdef";
- assert_eq!(seed_label(" mfeth-win ", device_id), "mfeth-win");
+ fn opaque_label_is_derived_from_the_id() {
+ assert_eq!(
+ opaque_label("0123456789abcdef0123456789abcdef"),
+ "device-01234567"
+ );
+ }
+
+ /// A minted identity must not leak the OS host name — it is published
+ /// world-readable before the owner has seen any warning.
+ #[test]
+ fn mint_identity_uses_an_opaque_label_not_the_hostname() {
+ let identity = mint_identity();
+ assert_eq!(identity.device_label, opaque_label(&identity.device_id));
+ assert!(identity.device_label.starts_with("device-"));
+ let host = gethostname::gethostname().to_string_lossy().to_string();
+ if !host.trim().is_empty() {
+ assert_ne!(identity.device_label, host.trim());
+ }
+ validate_identity(&identity).expect("a minted identity must be valid");
+ }
+
+ #[test]
+ fn validate_device_id_accepts_only_lowercase_hex_of_length_32() {
+ assert!(validate_device_id("0123456789abcdef0123456789abcdef").is_ok());
+ assert!(validate_device_id("0123456789ABCDEF0123456789ABCDEF").is_err());
+ assert!(validate_device_id("tooshort").is_err());
+ assert!(validate_device_id(&"a".repeat(33)).is_err());
+ assert!(validate_device_id("g123456789abcdef0123456789abcdef").is_err());
+ }
+
+ /// Deserializing proves shape, not validity. A hand-edited file must be
+ /// quarantined and replaced rather than published.
+ #[test]
+ fn load_or_create_replaces_a_syntactically_valid_but_invalid_identity() {
+ // Built through serde rather than written as a literal: an unescaped
+ // U+202E in source trips rustc's own
+ // `text_direction_codepoint_in_literal` lint — the same hazard this
+ // validation exists to keep out of the relay.
+ let bad_id = serde_json::json!({
+ "deviceId": "nothex",
+ "deviceLabel": "ok",
+ "createdAt": "2026-01-01T00:00:00Z",
+ })
+ .to_string();
+ let bad_label = serde_json::json!({
+ "deviceId": "0123456789abcdef0123456789abcdef",
+ "deviceLabel": "mfeth\u{202E}win",
+ "createdAt": "2026-01-01T00:00:00Z",
+ })
+ .to_string();
+
+ for bad in [bad_id.as_str(), bad_label.as_str()] {
+ let dir = tempfile::tempdir().unwrap();
+ let path = dir.path().join("device.json");
+ std::fs::write(&path, bad).unwrap();
+
+ let identity = load_or_create_at(&path)
+ .expect("an invalid stored file must never fail the caller");
+ validate_identity(&identity).expect("the replacement must be valid");
+ assert!(path.with_extension("json.corrupt").exists(), "quarantined");
+ // And the replacement sticks.
+ assert_eq!(load_or_create_at(&path).unwrap(), identity);
+ }
+ }
+
+ #[test]
+ fn hostname_suggestion_is_valid_when_present() {
+ if let Some(suggestion) = hostname_suggestion() {
+ validate_device_label(&suggestion)
+ .expect("a suggestion offered to the owner must already be valid");
+ }
}
#[test]
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 886baf19d64..0980c9aa4dc 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -784,6 +784,7 @@ pub fn run() {
set_global_agent_config,
device_identity::get_device_identity,
device_identity::set_device_label,
+ device_identity::get_device_name_suggestion,
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 e40c2604efa..efcf47b2f2e 100644
--- a/desktop/src-tauri/src/managed_agents/agent_events.rs
+++ b/desktop/src-tauri/src/managed_agents/agent_events.rs
@@ -92,7 +92,23 @@ pub fn agent_event_content(record: &ManagedAgentRecord) -> ManagedAgentEventCont
// 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();
+ //
+ // Only a LOCAL backend is device-bound. A `Provider` backend's body runs
+ // elsewhere — deployed to Kubernetes from a laptop, say — and stays online
+ // after this install sleeps, so stamping it with this Desktop would make
+ // the mention UI claim "only that device can reply" about a machine that is
+ // not where the agent runs. Such a record publishes no device at all and
+ // degrades to the same "no device information" rendering as a pre-Stage-0
+ // peer.
+ //
+ // Future work (out of scope for Stage 0): a provider-backed agent still has
+ // a *custody* device — the install holding its secret — which is a
+ // different coordinate from its *execution* location. Distinguishing the
+ // two needs a protocol change, not a second stamp here.
+ let device = match record.backend {
+ super::BackendKind::Local => crate::device_identity::current(),
+ super::BackendKind::Provider { .. } => None,
+ };
ManagedAgentEventContent {
name: record.name.clone(),
persona_id: record.persona_id.clone(),
@@ -480,6 +496,9 @@ mod tests {
/// device fields existed. This is why no other test in the crate changed.
#[test]
fn projection_omits_device_fields_without_a_device_identity() {
+ // Takes the guard (with `None`) purely to serialize against the tests
+ // below that seed a device — `CURRENT` is process-global.
+ let _guard = crate::device_identity::DeviceGuard::set(None);
assert!(
crate::device_identity::current().is_none(),
"unit tests must never boot the device identity"
@@ -495,6 +514,54 @@ mod tests {
assert!(!json.contains("device_label"), "{json}");
}
+ /// A local-backend agent's secret lives on this install, so it is the one
+ /// case where naming this computer is true.
+ #[test]
+ fn projection_stamps_the_device_for_a_local_backend() {
+ use crate::device_identity::DeviceGuard;
+ let device = DeviceGuard::sample();
+ let _guard = DeviceGuard::set(Some(device.clone()));
+
+ let mut record = sample_agent();
+ record.backend = super::super::BackendKind::Local;
+
+ let content = agent_event_content(&record);
+ assert_eq!(
+ content.device_id.as_deref(),
+ Some(device.device_id.as_str())
+ );
+ assert_eq!(
+ content.device_label.as_deref(),
+ Some(device.device_label.as_str())
+ );
+ }
+
+ /// A provider-backed agent's body runs elsewhere and outlives this install,
+ /// so stamping it here would make the mention UI claim "only that device can
+ /// reply" about a machine that is not where the agent runs.
+ #[test]
+ fn projection_omits_the_device_for_a_provider_backend() {
+ use crate::device_identity::DeviceGuard;
+ let _guard = DeviceGuard::set(Some(DeviceGuard::sample()));
+
+ let mut record = sample_agent();
+ record.backend = super::super::BackendKind::Provider {
+ id: "buzz-backend-x".to_string(),
+ config: serde_json::json!({ "cluster": "staging" }),
+ };
+
+ let content = agent_event_content(&record);
+ assert_eq!(
+ content.device_id, None,
+ "a remote body must not be given this computer's id"
+ );
+ assert_eq!(content.device_label, None);
+
+ let json = serde_json::to_string(&content).unwrap();
+ assert!(!json.contains("deviceId"), "{json}");
+ assert!(!json.contains("deviceLabel"), "{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.
diff --git a/desktop/src-tauri/src/managed_agents/definition_validation.rs b/desktop/src-tauri/src/managed_agents/definition_validation.rs
index 92445604d2e..1e99f5ca16c 100644
--- a/desktop/src-tauri/src/managed_agents/definition_validation.rs
+++ b/desktop/src-tauri/src/managed_agents/definition_validation.rs
@@ -60,6 +60,32 @@ pub(crate) fn validate_managed_agent_definition_text(
validate_agent_definition_text(name, executable_prompt)
}
+/// Maximum length of a device label, in `char`s.
+pub(crate) const MAX_DEVICE_LABEL_CHARS: usize = 32;
+
+/// Validate a device label against the same visible-text policy as agent
+/// definition text.
+///
+/// A device label names the computer an agent lives on and is published in a
+/// world-readable kind:30177 event, then rendered beside an agent's name in
+/// other people's clients. That makes it the same class of input as a display
+/// name: `char::is_control` alone would pass zero-width characters (U+200B) and
+/// bidi overrides (U+202E), which are Unicode category `Cf` and can visually
+/// reorder the text around them.
+pub(crate) fn validate_device_label(label: &str) -> Result<(), String> {
+ let trimmed = label.trim();
+ if trimmed.is_empty() {
+ return Err("Device name must not be empty".to_string());
+ }
+ let count = trimmed.chars().count();
+ if count > MAX_DEVICE_LABEL_CHARS {
+ return Err(format!(
+ "Device name is too long ({count} characters, max {MAX_DEVICE_LABEL_CHARS})"
+ ));
+ }
+ validate_visible_text(trimmed, "Device name", false)
+}
+
fn validate_visible_text(
value: &str,
label: &str,
diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs
index e15caa607a6..048244a1f84 100644
--- a/desktop/src-tauri/src/managed_agents/mod.rs
+++ b/desktop/src-tauri/src/managed_agents/mod.rs
@@ -11,7 +11,9 @@ pub(crate) use agent_env::{
mod backend;
pub(crate) mod config_bridge;
pub(crate) mod custom_harnesses;
-mod definition_validation;
+// `pub(crate)` so the device-identity and inbound-directory paths can reuse the
+// one visible-text policy instead of growing a second, drifting copy.
+pub(crate) mod definition_validation;
mod discovery;
pub(crate) mod effective_config;
mod env_vars;
diff --git a/desktop/src-tauri/src/nostr_convert/agent_directory.rs b/desktop/src-tauri/src/nostr_convert/agent_directory.rs
index 9978b31208b..8ff7a8f476d 100644
--- a/desktop/src-tauri/src/nostr_convert/agent_directory.rs
+++ b/desktop/src-tauri/src/nostr_convert/agent_directory.rs
@@ -4,6 +4,8 @@ use std::collections::{BTreeSet, HashMap};
use nostr::Event;
+use crate::device_identity::validate_device_id;
+use crate::managed_agents::definition_validation::validate_device_label;
use crate::managed_agents::{agent_events::managed_agent_content_from_event, RelayAgentInfo};
use super::{agents_from_events, first_tag_value, profile_valid_oa_owner_pubkey, tags_named};
@@ -138,8 +140,22 @@ 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,
+ // Owner authentication proves *who wrote this*, not that what they
+ // wrote is well-formed. A sibling device running an older, buggy, or
+ // tampered-with build can publish any string here, and these two values
+ // are rendered verbatim beside an agent's name — so they are validated
+ // like any other untrusted input before they reach the UI.
+ //
+ // A value that fails degrades to `None` on its own. Dropping the whole
+ // directory entry over a bad label would hide a real, reachable agent;
+ // dropping just the label falls back to the same "no device
+ // information" rendering as a peer that predates Stage 0.
+ device_id: content
+ .device_id
+ .filter(|id| validate_device_id(id).is_ok()),
+ device_label: content
+ .device_label
+ .filter(|label| validate_device_label(label).is_ok()),
})
}
diff --git a/desktop/src-tauri/src/nostr_convert/tests.rs b/desktop/src-tauri/src/nostr_convert/tests.rs
index 77286caf596..4bd18a12ef0 100644
--- a/desktop/src-tauri/src/nostr_convert/tests.rs
+++ b/desktop/src-tauri/src/nostr_convert/tests.rs
@@ -496,6 +496,73 @@ fn managed_agent_directory_surfaces_the_owner_verified_device_label() {
assert_eq!(agents[0].device_label, None);
}
+/// Owner authentication proves authorship, not well-formedness: a sibling
+/// device on an older, buggy, or tampered build can sign anything. A bad value
+/// must degrade to `None` on its own without hiding a real, reachable agent.
+#[test]
+fn managed_agent_directory_drops_invalid_device_metadata_but_keeps_the_agent() {
+ 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");
+
+ // A bidi override in the label and a malformed id, both correctly signed.
+ let hostile = EventBuilder::new(
+ Kind::Custom(30177),
+ serde_json::json!({
+ "name": "Bumble",
+ "parallelism": 1,
+ "respond_to": "anyone",
+ "device_id": "not-a-uuid",
+ "device_label": "mfeth\u{202E}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(&[hostile], std::slice::from_ref(&profile));
+ assert_eq!(agents.len(), 1, "the agent itself must still be reachable");
+ assert_eq!(agents[0].name, "Bumble");
+ assert_eq!(agents[0].device_id, None, "malformed id dropped");
+ assert_eq!(agents[0].device_label, None, "bidi-bearing label dropped");
+
+ // An over-long label is refused by the same policy.
+ let long = EventBuilder::new(
+ Kind::Custom(30177),
+ serde_json::json!({
+ "name": "Bumble",
+ "parallelism": 1,
+ "respond_to": "anyone",
+ "device_id": "0123456789abcdef0123456789abcdef",
+ "device_label": "a".repeat(33),
+ })
+ .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(&[long], std::slice::from_ref(&profile));
+ assert_eq!(agents.len(), 1);
+ assert_eq!(
+ agents[0].device_id.as_deref(),
+ Some("0123456789abcdef0123456789abcdef"),
+ "a valid id survives its label being dropped"
+ );
+ 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/features/settings/ui/DeviceNameSettingsCard.tsx b/desktop/src/features/settings/ui/DeviceNameSettingsCard.tsx
index 22c7659004e..1d17142747b 100644
--- a/desktop/src/features/settings/ui/DeviceNameSettingsCard.tsx
+++ b/desktop/src/features/settings/ui/DeviceNameSettingsCard.tsx
@@ -6,7 +6,10 @@ import {
deviceIdentityQueryKey,
useDeviceIdentityQuery,
} from "@/features/agents/hooks";
-import { setDeviceLabel } from "@/shared/api/tauriDeviceIdentity";
+import {
+ getDeviceNameSuggestion,
+ setDeviceLabel,
+} from "@/shared/api/tauriDeviceIdentity";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
@@ -51,7 +54,33 @@ export function DeviceNameSettingsCard() {
},
});
+ // The OS host name is only ever *offered*. A device's name starts opaque
+ // (`device-xxxxxxxx`) precisely because host names routinely carry a real
+ // person's name and this label is published world-readable — so the owner
+ // opts in here, seeing the warning above before anything leaves the machine.
+ const [suggestion, setSuggestion] = React.useState(null);
+ React.useEffect(() => {
+ let cancelled = false;
+ void getDeviceNameSuggestion()
+ .then((value) => {
+ if (!cancelled) {
+ setSuggestion(value);
+ }
+ })
+ .catch(() => {
+ // Advisory only — a device with no usable host name simply gets no
+ // suggestion, which is not worth surfacing as an error.
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
const trimmedLabel = draftLabel.trim();
+ const showSuggestion =
+ suggestion !== null &&
+ suggestion !== savedLabel &&
+ suggestion !== trimmedLabel;
const saveDisabled =
trimmedLabel.length === 0 ||
trimmedLabel === savedLabel ||
@@ -74,6 +103,19 @@ export function DeviceNameSettingsCard() {
other devices. Published with your agents, so avoid personal
details.
+ {showSuggestion ? (
+ {
+ setEditedLabel(true);
+ setDraftLabel(suggestion);
+ }}
+ type="button"
+ >
+ Use this computer's name ({suggestion})
+
+ ) : null}
{
/**
* 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.
+ * The backend trims and rejects an empty, over-long (>32 char), or
+ * invisible-character-bearing label — including the zero-width and bidi
+ * codepoints `char::is_control` misses — then republishes the label on the
+ * active community's local agents, so callers need no follow-up write. The
+ * owner's other communities pick it up when next activated.
*/
export async function setDeviceLabel(label: string): Promise {
return invokeTauri("set_device_label", { label });
}
+
+/**
+ * The OS host name, offered as a suggested device name.
+ *
+ * `null` when the host name is unusable under the label policy. This is only a
+ * suggestion: a device's name starts opaque (`device-xxxxxxxx`) and the host
+ * name — which routinely contains a real person's name — is never published
+ * until the owner applies it.
+ */
+export async function getDeviceNameSuggestion(): Promise {
+ return invokeTauri("get_device_name_suggestion");
+}
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 598fd85fdd0..b6bf99fce6e 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -3345,6 +3345,9 @@ const DEFAULT_MOCK_DEVICE_IDENTITY = {
createdAt: "2026-01-01T00:00:00Z",
};
let mockDeviceIdentity = { ...DEFAULT_MOCK_DEVICE_IDENTITY };
+// Stands in for the OS host name the settings card offers as an opt-in. Kept
+// distinct from MOCK_DEVICE_LABEL so a test can tell "suggested" from "applied".
+const MOCK_HOSTNAME_SUGGESTION = "marys-macbook";
const defaultMockRelayAgents: RawRelayAgent[] = [
{
@@ -12285,6 +12288,8 @@ export function maybeInstallE2eTauriMocks() {
}
case "get_device_identity":
return { ...mockDeviceIdentity };
+ case "get_device_name_suggestion":
+ return MOCK_HOSTNAME_SUGGESTION;
case "set_device_label": {
const label = (payload as { label?: unknown } | undefined)?.label;
if (typeof label === "string" && label.trim().length > 0) {
diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts
index 913b6db8853..ee58fdbcc7e 100644
--- a/desktop/tests/e2e/mentions.spec.ts
+++ b/desktop/tests/e2e/mentions.spec.ts
@@ -3039,3 +3039,66 @@ test("mentioning another device's agent says so and still sends", async ({
.poll(() => readOutgoingMentionPubkeys(page, content))
.toContain(ALICE_OTHER_DEVICE_PUBKEY);
});
+
+// A provider-backed agent's body runs elsewhere (deployed to a cluster) and
+// outlives the install that deployed it, so its kind:30177 record carries no
+// device at all. The UI must not invent one, and must never tell the user that
+// some particular computer is the only thing that can answer.
+test("a device-less remote agent claims no device and sends without a notice", async ({
+ page,
+}) => {
+ await installMockBridge(page, {
+ managedAgents: [
+ {
+ pubkey: TEST_IDENTITIES.alice.pubkey,
+ name: "charlie",
+ 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("@charlie");
+
+ const dropdown = autocomplete(page);
+ const localRow = dropdown.getByTestId(
+ `mention-suggestion-${TEST_IDENTITIES.alice.pubkey}`,
+ );
+ const remoteRow = dropdown.getByTestId(
+ `mention-suggestion-${TEST_IDENTITIES.charlie.pubkey}`,
+ );
+ await expect(localRow).toBeVisible();
+ await expect(remoteRow).toBeVisible();
+ await waitForAnimations(page);
+
+ // The name collides, so the local row earns its device line. The remote row
+ // declares no device, so it may say only the honest, unpinned thing — never
+ // this computer's name, which is what a provider-backed agent used to borrow.
+ await expect(localRow.getByTestId("mention-device-label")).toHaveText(
+ "on this device",
+ );
+ await expect(remoteRow.getByTestId("mention-device-label")).toHaveText(
+ "on another device",
+ );
+ // "this-mac" is the mock bridge's label for THIS install. A provider-backed
+ // agent borrowing it is exactly the bug this test guards.
+ await expect(
+ remoteRow.getByTestId("mention-device-label"),
+ ).not.toContainText("this-mac");
+
+ await remoteRow.click();
+ await page.keyboard.type("are you there");
+ await page.getByTestId("send-message").click();
+
+ const inviteButton = page.getByRole("button", { name: "Invite", exact: true });
+ if (await inviteButton.isVisible().catch(() => false)) {
+ await inviteButton.click();
+ }
+
+ // No device is known, so no "only that device can reply" guidance can be
+ // true. Saying it anyway is what this assertion exists to prevent.
+ await expect(page.getByText(/Only that device can reply\./)).toHaveCount(0);
+});