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..7ca11c0285f --- /dev/null +++ b/desktop/src-tauri/src/device_identity.rs @@ -0,0 +1,551 @@ +//! 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 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}; + +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}; + +/// 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. 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, +} + +/// 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 device label. +/// +/// 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(); + 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(()); + } + 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) +} + +/// The OS host name, offered to the owner as a suggested device name. +/// +/// 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 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 device_label = opaque_label(&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, 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 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) + .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}")) + }) + .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) => { + 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() +} + +/// 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 +/// [`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) +} + +/// Return the OS host name as a *suggested* device name, or `None` when it is +/// unusable under the label policy. +/// +/// 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)?; + 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()); + } + + /// Over-long labels are refused, never silently shortened: publishing + /// something other than what the owner typed is the worse failure. + #[test] + 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 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 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] + 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..0980c9aa4dc 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,9 @@ 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, + 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 f70c714323e..efcf47b2f2e 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,26 @@ 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. + // + // 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(), @@ -103,6 +135,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 +491,127 @@ 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() { + // 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" + ); + 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}"); + } + + /// 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. + #[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/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/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..048244a1f84 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -11,12 +11,15 @@ 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; 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 +68,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..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,6 +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, + // 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 9401d19add4..4bd18a12ef0 100644 --- a/desktop/src-tauri/src/nostr_convert/tests.rs +++ b/desktop/src-tauri/src/nostr_convert/tests.rs @@ -444,6 +444,125 @@ 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); +} + +/// 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-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} + +
+ { + setEditedLabel(true); + setDraftLabel(event.target.value); + }} + value={draftLabel} + /> + +
+ + + + ); +} 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..500973b8289 --- /dev/null +++ b/desktop/src/shared/api/tauriDeviceIdentity.ts @@ -0,0 +1,32 @@ +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 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/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..b6bf99fce6e 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,20 @@ 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 }; +// 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[] = [ { pubkey: ALICE_PUBKEY, @@ -3338,6 +3363,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 +3393,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 +3735,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 +10316,7 @@ export function maybeInstallE2eTauriMocks() { ? { ...config.mock.globalAgentConfig } : null; resetMockRelayMembers(config); + mockDeviceIdentity = { ...DEFAULT_MOCK_DEVICE_IDENTITY }; resetMockRelayAgents(config); resetMockManagedAgents(config); resetMockPersonas(config); @@ -12236,6 +12286,17 @@ export function maybeInstallE2eTauriMocks() { (!channelId || agent.channel_ids.includes(channelId)), ); } + 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) { + 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..ee58fdbcc7e 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,196 @@ 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); +}); + +// 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); +});