From 1d325fb52063c581f5386222ca8c3347878b5114 Mon Sep 17 00:00:00 2001 From: Cameron Aragon <69489633+camaragon@users.noreply.github.com> Date: Wed, 19 Aug 2026 04:25:59 +0000 Subject: [PATCH 01/12] feat(agents): register existing agent references Signed-off-by: Cameron Aragon <69489633+camaragon@users.noreply.github.com> --- .../check-registered-agent-boundary.mjs | 43 +++ .../src-tauri/src/commands/agent_models.rs | 9 +- .../src/commands/agent_registered_targets.rs | 51 +++ .../src-tauri/src/commands/agent_settings.rs | 2 + desktop/src-tauri/src/commands/agents.rs | 9 +- desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 8 +- desktop/src-tauri/src/managed_agents/mod.rs | 5 + .../managed_agents/registered_references.rs | 356 ++++++++++++++++++ .../src/managed_agents/runtime_commands.rs | 3 + .../src-tauri/src/managed_agents/storage.rs | 8 + desktop/src/features/agents/hooks.ts | 48 +++ .../features/agents/hooksRegistered.test.mjs | 37 ++ .../agents/lib/registeredAgentCards.test.mjs | 61 +++ .../agents/lib/registeredAgentCards.ts | 46 +++ .../agents/lib/useAgentsDataRefresh.ts | 8 +- .../agents/registeredAgentBoundary.test.mjs | 41 ++ desktop/src/features/agents/ui/AgentsView.tsx | 74 +++- .../agents/ui/RegisterExistingAgentDialog.tsx | 128 +++++++ .../agents/ui/RegisteredAgentIdentityCard.tsx | 90 +++++ .../agents/ui/RemoveRegisteredAgentDialog.tsx | 51 +++ .../agents/ui/UnifiedAgentsSection.tsx | 36 +- .../features/agents/useKnownAgentPubkeys.tsx | 12 +- .../src/shared/api/registeredAgents.test.mjs | 63 ++++ .../src/shared/api/tauriRegisteredAgents.ts | 105 ++++++ desktop/src/testing/e2eBridge.ts | 73 ++++ desktop/tests/e2e/agents.spec.ts | 39 ++ desktop/tests/helpers/bridge.ts | 8 + 28 files changed, 1399 insertions(+), 17 deletions(-) create mode 100755 desktop/scripts/check-registered-agent-boundary.mjs create mode 100644 desktop/src-tauri/src/commands/agent_registered_targets.rs create mode 100644 desktop/src-tauri/src/managed_agents/registered_references.rs create mode 100644 desktop/src/features/agents/hooksRegistered.test.mjs create mode 100644 desktop/src/features/agents/lib/registeredAgentCards.test.mjs create mode 100644 desktop/src/features/agents/lib/registeredAgentCards.ts create mode 100644 desktop/src/features/agents/registeredAgentBoundary.test.mjs create mode 100644 desktop/src/features/agents/ui/RegisterExistingAgentDialog.tsx create mode 100644 desktop/src/features/agents/ui/RegisteredAgentIdentityCard.tsx create mode 100644 desktop/src/features/agents/ui/RemoveRegisteredAgentDialog.tsx create mode 100644 desktop/src/shared/api/registeredAgents.test.mjs create mode 100644 desktop/src/shared/api/tauriRegisteredAgents.ts diff --git a/desktop/scripts/check-registered-agent-boundary.mjs b/desktop/scripts/check-registered-agent-boundary.mjs new file mode 100755 index 00000000000..a4e70695c53 --- /dev/null +++ b/desktop/scripts/check-registered-agent-boundary.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +const root = process.cwd(); +const src = join(root, "src"); +const forbidden = [ + "createManagedAgent", + "startManagedAgent", + "stopManagedAgent", + "deleteManagedAgent", + "managedAgentRuntime", + "privateKeyNsec", + "private_key_nsec", + "envVars", + "agentCommand", + "agent_command", + "pid", +]; + +function walk(dir) { + return readdirSync(dir).flatMap((entry) => { + const path = join(dir, entry); + if (statSync(path).isDirectory()) return walk(path); + return /\.(ts|tsx)$/.test(path) ? [path] : []; + }); +} + +const offenders = []; +for (const path of walk(src)) { + const rel = relative(root, path); + if (!/registeredAgent|RegisteredAgent/.test(rel)) continue; + const text = readFileSync(path, "utf8"); + const hits = forbidden.filter((needle) => text.includes(needle)); + if (hits.length > 0) offenders.push(`${rel}: ${hits.join(", ")}`); +} + +if (offenders.length > 0) { + console.error("Registered agent boundary violations:"); + for (const offender of offenders) console.error(`- ${offender}`); + process.exit(1); +} +console.log("Registered agent boundary OK"); diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index cb809b6c04a..770aa064fba 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -699,9 +699,16 @@ use databricks::{discover_databricks_models, DatabricksAuthIntent}; #[path = "agent_models_update.rs"] mod update; -pub use update::update_managed_agent; pub(super) use update::{flush_managed_agent_policy, managed_agent_access_policy_changed}; +pub(super) async fn update_managed_agent_unchecked( + input: UpdateManagedAgentRequest, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + update::update_managed_agent(input, app, state).await +} + // ── Model normalization ─────────────────────────────────────────────────────── /// Normalize raw `buzz-acp models --json` output into a typed DTO for the frontend. diff --git a/desktop/src-tauri/src/commands/agent_registered_targets.rs b/desktop/src-tauri/src/commands/agent_registered_targets.rs new file mode 100644 index 00000000000..899043114d6 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_registered_targets.rs @@ -0,0 +1,51 @@ +use tauri::{AppHandle, State}; + +use crate::{ + app_state::AppState, + managed_agents::{ + reject_registered_reference_target, ManagedAgentSummary, UpdateManagedAgentRequest, + UpdateManagedAgentResponse, + }, +}; + +/// Validate ownership before dispatching commands whose implementation lives in +/// oversized legacy modules. Registered references and unknown pubkeys fail +/// before any lifecycle, config, or delete side effect. +#[tauri::command] +pub async fn update_managed_agent( + input: UpdateManagedAgentRequest, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + reject_registered_reference_target(&app, &input.pubkey)?; + super::agent_models::update_managed_agent_unchecked(input, app, state).await +} + +#[tauri::command] +pub async fn start_managed_agent( + pubkey: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + reject_registered_reference_target(&app, &pubkey)?; + super::agents::start_managed_agent_unchecked(pubkey, app, state).await +} + +#[tauri::command] +pub async fn stop_managed_agent( + pubkey: String, + app: AppHandle, +) -> Result { + reject_registered_reference_target(&app, &pubkey)?; + super::agents::stop_managed_agent_unchecked(pubkey, app).await +} + +#[tauri::command] +pub async fn delete_managed_agent( + pubkey: String, + force_remote_delete: Option, + app: AppHandle, +) -> Result<(), String> { + reject_registered_reference_target(&app, &pubkey)?; + super::agents::delete_managed_agent_unchecked(pubkey, force_remote_delete, app).await +} diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs index 6135c671606..6fee3e58132 100644 --- a/desktop/src-tauri/src/commands/agent_settings.rs +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -23,6 +23,7 @@ pub async fn set_managed_agent_start_on_app_launch( start_on_app_launch: bool, app: AppHandle, ) -> Result { + crate::managed_agents::reject_registered_reference_target(&app, &pubkey)?; tokio::task::spawn_blocking(move || { let state = app.state::(); let _store_guard = state @@ -67,6 +68,7 @@ pub async fn set_managed_agent_auto_restart( auto_restart_on_config_change: bool, app: AppHandle, ) -> Result { + crate::managed_agents::reject_registered_reference_target(&app, &pubkey)?; tokio::task::spawn_blocking(move || { let state = app.state::(); let _store_guard = state diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 33b6ae44620..599e7b0babe 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -857,8 +857,7 @@ pub async fn create_managed_agent( } /// Data needed for background profile reconciliation after agent start. -#[tauri::command] -pub async fn start_managed_agent( +pub(super) async fn start_managed_agent_unchecked( pubkey: String, expected_relay_url: Option, expected_signer_pubkey: Option, @@ -1036,8 +1035,7 @@ pub async fn start_managed_agent( result } -#[tauri::command] -pub async fn stop_managed_agent( +pub(super) async fn stop_managed_agent_unchecked( pubkey: String, app: AppHandle, ) -> Result { @@ -1089,8 +1087,7 @@ pub async fn stop_managed_agent( // Async so the blocking body (disk reads/writes, process termination, keyring // delete, nest regeneration) runs off the main UI thread via spawn_blocking. -#[tauri::command] -pub async fn delete_managed_agent( +pub(super) async fn delete_managed_agent_unchecked( pubkey: String, force_remote_delete: Option, app: AppHandle, diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 761bee9cd32..70a9d9b9a70 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -8,6 +8,7 @@ mod agent_model_process; mod agent_models; mod agent_models_env; mod agent_providers; +mod agent_registered_targets; mod agent_settings; mod agent_update_rollback; mod agents; @@ -76,6 +77,7 @@ pub use agent_logs::*; pub use agent_metric_archive::*; pub use agent_models::*; pub use agent_providers::*; +pub use agent_registered_targets::*; pub use agent_settings::*; pub use agents::*; pub use canvas::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 31d4ff37133..f39df5a0e5c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -71,9 +71,10 @@ use huddle::{ use initial_window::*; use managed_agents::{ backfill_persona_snapshots, ensure_nest, list_managed_agent_runtimes, - put_managed_agent_runtime_lifecycle, reconcile_managed_agent_runtimes, + list_registered_agent_references, put_managed_agent_runtime_lifecycle, + reconcile_managed_agent_runtimes, register_existing_agent_reference, restart_managed_agent_runtime, start_managed_agent_runtime, stop_managed_agent_runtime, - try_regenerate_nest, + try_regenerate_nest, unregister_existing_agent_reference, }; #[cfg(not(feature = "mesh-llm"))] use mesh_llm_stubs::*; @@ -795,6 +796,9 @@ pub fn run() { mesh_installed_models, mesh_model_catalog, update_managed_agent, + list_registered_agent_references, + register_existing_agent_reference, + unregister_existing_agent_reference, discover_backend_providers, probe_backend_provider, list_personas, diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 272c03348b9..10fa500aa27 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -28,6 +28,7 @@ mod personas; mod process_lifecycle; pub(crate) mod readiness; pub(crate) mod reconcile; +mod registered_references; mod relay_mesh; mod repos; mod restore; @@ -76,6 +77,10 @@ pub(crate) use readiness::{ agent_readiness, resolve_effective_agent_env, resolve_effective_harness_descriptor, AgentReadiness, Requirement, }; +pub(crate) use registered_references::{ + list_registered_agent_references, register_existing_agent_reference, + reject_registered_reference_target, unregister_existing_agent_reference, +}; pub use relay_mesh::*; pub use repos::{ effective_repos_dir, ensure_repos_symlink, resolve_repos_at_boot, validate_repos_dir, diff --git a/desktop/src-tauri/src/managed_agents/registered_references.rs b/desktop/src-tauri/src/managed_agents/registered_references.rs new file mode 100644 index 00000000000..9f92b2ccff0 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/registered_references.rs @@ -0,0 +1,356 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, Manager}; + +use super::storage::{atomic_write_json_restricted, backup_invalid_store, managed_agents_base_dir}; +use crate::app_state::AppState; + +const STORE_FILENAME: &str = "registered-agent-references.json"; +const AGENTS_DATA_CHANGED_EVENT: &str = "agents-data-changed"; +const LABEL_LIMIT_BYTES: usize = 80; +const ROLE_SUMMARY_LIMIT_BYTES: usize = 240; + +/// A keyless reference to an already-existing agent identity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RegisteredAgentReference { + /// The referenced agent public key as normalized 64-byte lowercase hex. + pub pubkey: String, + /// Optional user-facing label for the reference. + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + /// Optional short description of the agent's role. + #[serde(skip_serializing_if = "Option::is_none")] + pub role_summary: Option, + /// Creation timestamp in ISO-8601 UTC form. + pub created_at: String, + /// Last update timestamp in ISO-8601 UTC form. + pub updated_at: String, +} + +/// Request payload for registering or updating an existing agent reference. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RegisterAgentReferenceRequest { + /// Agent public key, accepted with leading/trailing whitespace and mixed case. + pub pubkey: String, + /// Optional label; blank strings are stored as `None`. + #[serde(default)] + pub label: Option, + /// Optional role summary; blank strings are stored as `None`. + #[serde(default)] + pub role_summary: Option, +} + +fn store_path(app: &AppHandle) -> Result { + Ok(managed_agents_base_dir(app)?.join(STORE_FILENAME)) +} + +fn normalize_pubkey(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.len() != 64 || !trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("agent pubkey must be exactly 64 ASCII hex characters".to_string()); + } + Ok(trimmed.to_ascii_lowercase()) +} + +fn normalize_optional( + value: Option, + limit: usize, + field: &str, +) -> Result, String> { + let Some(value) = value else { + return Ok(None); + }; + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(None); + } + if trimmed.len() > limit { + return Err(format!("{field} must be at most {limit} UTF-8 bytes")); + } + Ok(Some(trimmed.to_string())) +} + +fn load_from_path(path: &Path) -> Result, String> { + if !path.exists() { + return Ok(Vec::new()); + } + let content = std::fs::read_to_string(path) + .map_err(|error| format!("failed to read registered agent references: {error}"))?; + let mut refs: Vec = + serde_json::from_str(&content).map_err(|error| { + backup_invalid_store(path); + format!("failed to parse registered agent references (preserved as .invalid): {error}") + })?; + refs.sort_by(|left, right| left.pubkey.cmp(&right.pubkey)); + Ok(refs) +} + +fn save_to_path(path: &Path, refs: &[RegisteredAgentReference]) -> Result<(), String> { + let mut refs = refs.to_vec(); + refs.sort_by(|left, right| left.pubkey.cmp(&right.pubkey)); + let payload = serde_json::to_vec_pretty(&refs) + .map_err(|error| format!("failed to serialize registered agent references: {error}"))?; + atomic_write_json_restricted(path, &payload) +} + +/// Load all registered existing-agent references. +#[tauri::command] +pub fn list_registered_agent_references( + app: AppHandle, +) -> Result, String> { + load_from_path(&store_path(&app)?) +} + +/// Register or update a keyless reference to an existing agent identity. +#[tauri::command] +pub fn register_existing_agent_reference( + input: RegisterAgentReferenceRequest, + app: AppHandle, +) -> Result { + let pubkey = normalize_pubkey(&input.pubkey)?; + let label = normalize_optional(input.label, LABEL_LIMIT_BYTES, "label")?; + let role_summary = + normalize_optional(input.role_summary, ROLE_SUMMARY_LIMIT_BYTES, "roleSummary")?; + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + if super::storage::managed_agent_record_exists(&app, &pubkey)? { + return Err(format!("agent {pubkey} is already a managed agent")); + } + let path = store_path(&app)?; + let mut refs = load_from_path(&path)?; + let now = crate::util::now_iso(); + let reference = match refs.iter_mut().find(|reference| reference.pubkey == pubkey) { + Some(existing) => { + existing.label = label; + existing.role_summary = role_summary; + existing.updated_at = now; + existing.clone() + } + None => { + let reference = RegisteredAgentReference { + pubkey, + label, + role_summary, + created_at: now.clone(), + updated_at: now, + }; + refs.push(reference.clone()); + reference + } + }; + save_to_path(&path, &refs)?; + let _ = app.emit(AGENTS_DATA_CHANGED_EVENT, ()); + Ok(reference) +} + +/// Remove a registered existing-agent reference by public key. +#[tauri::command] +pub fn unregister_existing_agent_reference(pubkey: String, app: AppHandle) -> Result<(), String> { + let pubkey = normalize_pubkey(&pubkey)?; + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let path = store_path(&app)?; + let mut refs = load_from_path(&path)?; + let initial_len = refs.len(); + refs.retain(|reference| reference.pubkey != pubkey); + if refs.len() == initial_len { + return Err(format!("agent {pubkey} not found")); + } + save_to_path(&path, &refs)?; + let _ = app.emit(AGENTS_DATA_CHANGED_EVENT, ()); + Ok(()) +} + +/// Require a managed-agent record before any lifecycle/config/delete side +/// effect. Registered references and unknown pubkeys both fail closed. +pub(crate) fn reject_registered_reference_target( + app: &AppHandle, + pubkey: &str, +) -> Result<(), String> { + let normalized = normalize_pubkey(pubkey).unwrap_or_else(|_| pubkey.to_string()); + if !super::storage::managed_agent_record_exists(app, &normalized)? { + return Err(format!("agent {normalized} not found")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt as _; + + const PUBKEY_A_UPPER: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + const PUBKEY_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const PUBKEY_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + fn request( + pubkey: &str, + label: Option<&str>, + role_summary: Option<&str>, + ) -> RegisterAgentReferenceRequest { + RegisterAgentReferenceRequest { + pubkey: pubkey.to_string(), + label: label.map(str::to_string), + role_summary: role_summary.map(str::to_string), + } + } + + #[test] + fn normalizes_pubkey_and_blank_optionals() { + let normalized = normalize_pubkey(&format!(" {PUBKEY_A_UPPER} ")).unwrap(); + assert_eq!(normalized, PUBKEY_A); + assert_eq!( + normalize_optional(Some(" ".to_string()), LABEL_LIMIT_BYTES, "label").unwrap(), + None + ); + assert_eq!( + normalize_optional(Some(" hello ".to_string()), LABEL_LIMIT_BYTES, "label").unwrap(), + Some("hello".to_string()) + ); + } + + #[test] + fn rejects_non_exact_ascii_hex_pubkeys_and_overlong_fields() { + assert!(normalize_pubkey("abc").is_err()); + assert!(normalize_pubkey( + "g000000000000000000000000000000000000000000000000000000000000000" + ) + .is_err()); + assert!(normalize_optional(Some("é".repeat(41)), LABEL_LIMIT_BYTES, "label").is_err()); + assert!(normalize_optional( + Some("x".repeat(241)), + ROLE_SUMMARY_LIMIT_BYTES, + "roleSummary" + ) + .is_err()); + } + + #[test] + fn save_load_sorts_by_pubkey_and_uses_restricted_mode() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(STORE_FILENAME); + let refs = vec![ + RegisteredAgentReference { + pubkey: PUBKEY_B.to_string(), + label: None, + role_summary: None, + created_at: "2026-01-02T00:00:00Z".to_string(), + updated_at: "2026-01-02T00:00:00Z".to_string(), + }, + RegisteredAgentReference { + pubkey: PUBKEY_A.to_string(), + label: Some("A".to_string()), + role_summary: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + }, + ]; + + save_to_path(&path, &refs).unwrap(); + let loaded = load_from_path(&path).unwrap(); + + assert_eq!( + loaded.iter().map(|r| r.pubkey.as_str()).collect::>(), + vec![PUBKEY_A, PUBKEY_B] + ); + #[cfg(unix)] + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + #[test] + fn malformed_json_fails_closed_and_preserves_invalid_bytes() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(STORE_FILENAME); + let bytes = b"{ definitely not json"; + fs::write(&path, bytes).unwrap(); + + let error = load_from_path(&path).unwrap_err(); + + assert!(error.contains("failed to parse registered agent references")); + assert_eq!(fs::read(&path).unwrap(), bytes); + assert_eq!( + fs::read(path.with_extension("json.invalid")).unwrap(), + bytes + ); + } + + #[test] + fn update_preserves_created_at_and_replaces_optionals() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(STORE_FILENAME); + let mut refs = Vec::new(); + let first = upsert_for_test( + &path, + request(PUBKEY_A, Some("one"), Some("old")), + &mut refs, + "2026-01-01T00:00:00Z", + ) + .unwrap(); + let second = upsert_for_test( + &path, + request(PUBKEY_A_UPPER, Some("two"), None), + &mut refs, + "2026-01-02T00:00:00Z", + ) + .unwrap(); + + assert_eq!(first.created_at, "2026-01-01T00:00:00Z"); + assert_eq!(second.created_at, first.created_at); + assert_eq!(second.updated_at, "2026-01-02T00:00:00Z"); + assert_eq!(second.label.as_deref(), Some("two")); + assert_eq!(second.role_summary, None); + } + + #[test] + fn request_denies_unknown_fields() { + let json = format!(r#"{{"pubkey":"{PUBKEY_A}","label":"hi","privateKeyNsec":"nope"}}"#); + let error = serde_json::from_str::(&json).unwrap_err(); + assert!(error.to_string().contains("unknown field")); + } + + fn upsert_for_test( + path: &Path, + input: RegisterAgentReferenceRequest, + refs: &mut Vec, + now: &str, + ) -> Result { + let pubkey = normalize_pubkey(&input.pubkey)?; + let label = normalize_optional(input.label, LABEL_LIMIT_BYTES, "label")?; + let role_summary = + normalize_optional(input.role_summary, ROLE_SUMMARY_LIMIT_BYTES, "roleSummary")?; + let reference = match refs.iter_mut().find(|reference| reference.pubkey == pubkey) { + Some(existing) => { + existing.label = label; + existing.role_summary = role_summary; + existing.updated_at = now.to_string(); + existing.clone() + } + None => { + let reference = RegisteredAgentReference { + pubkey, + label, + role_summary, + created_at: now.to_string(), + updated_at: now.to_string(), + }; + refs.push(reference.clone()); + reference + } + }; + save_to_path(path, refs)?; + Ok(reference) + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index c0e55184b19..f7726b9f83d 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -106,6 +106,7 @@ pub fn put_managed_agent_runtime_lifecycle( app: AppHandle, ) -> Result { let key = observer_lifecycle_key(&outer_pubkey, &payload)?; + super::reject_registered_reference_target(&app, &key.pubkey)?; let state = app.state::(); let records = load_managed_agents(&app)?; let record = records @@ -243,6 +244,7 @@ fn start_pair( expected_updated_at: Option<&str>, app: AppHandle, ) -> Result { + super::reject_registered_reference_target(&app, &pubkey)?; let state = app.state::(); let _transition = state .managed_agent_runtime_transition @@ -315,6 +317,7 @@ pub fn stop_managed_agent_runtime( relay_url: String, app: AppHandle, ) -> Result { + super::reject_registered_reference_target(&app, &pubkey)?; let state = app.state::(); let _transition = state .managed_agent_runtime_transition diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 652bb9b9ea8..4665c8fad42 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -256,6 +256,14 @@ fn load_agent_store(app: &AppHandle) -> Result, String> }) } +/// Check managed-agent ownership without hydrating private keys or touching +/// runtime state. Command boundaries use this before any lifecycle side effect. +pub(crate) fn managed_agent_record_exists(app: &AppHandle, pubkey: &str) -> Result { + Ok(load_agent_store(app)? + .iter() + .any(|record| !record.pubkey.is_empty() && record.pubkey == pubkey)) +} + /// Load the keyed agent *instances*. Key-less definitions (former personas, /// folded into the same store) are filtered out so every pre-fold call site /// keeps seeing exactly the records it always did. diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 6d8ab4f6ea8..1834417e055 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -57,6 +57,12 @@ import { setPersonaActive, updatePersona, } from "@/shared/api/tauriPersonas"; +import { + listRegisteredAgentReferences, + registerExistingAgentReference, + unregisterExistingAgentReference, +} from "@/shared/api/tauriRegisteredAgents"; +import type { RegisteredAgentReference } from "@/shared/api/tauriRegisteredAgents"; import { teamsQueryKey } from "@/features/agents/teamHooks"; import type { AcpRuntime, @@ -122,6 +128,9 @@ export const managedAgentLogFocusRefetchPolicy = { export const relayAgentsQueryKey = ["relay-agents"] as const; export const managedAgentsQueryKey = ["managed-agents"] as const; +export const registeredAgentsQueryKey = [ + "registered-agent-references", +] as const; export const personasQueryKey = ["personas"] as const; export const acpRuntimesQueryKey = ["acp-runtimes"] as const; export const acpAuthMethodsQueryKey = ["acp-auth-methods"] as const; @@ -373,6 +382,45 @@ export function useManagedAgentsQuery(options?: { enabled?: boolean }) { }); } +export function useRegisteredAgentsQuery(options?: { enabled?: boolean }) { + return useQuery({ + enabled: options?.enabled ?? true, + queryKey: registeredAgentsQueryKey, + queryFn: listRegisteredAgentReferences, + staleTime: 30_000, + retry: false, + }); +} + +export function useRegisterExistingAgentMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: { + pubkey: string; + label?: string | null; + roleSummary?: string | null; + }) => registerExistingAgentReference(input), + onSettled: async () => { + await queryClient.invalidateQueries({ + queryKey: registeredAgentsQueryKey, + }); + }, + }); +} + +export function useUnregisterExistingAgentMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (reference: RegisteredAgentReference) => + unregisterExistingAgentReference(reference.pubkey), + onSettled: async () => { + await queryClient.invalidateQueries({ + queryKey: registeredAgentsQueryKey, + }); + }, + }); +} + export function useCreateManagedAgentMutation() { const queryClient = useQueryClient(); diff --git a/desktop/src/features/agents/hooksRegistered.test.mjs b/desktop/src/features/agents/hooksRegistered.test.mjs new file mode 100644 index 00000000000..ece48838fff --- /dev/null +++ b/desktop/src/features/agents/hooksRegistered.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const hooks = readFileSync(new URL("./hooks.ts", import.meta.url), "utf8"); +const refresh = readFileSync( + new URL("./lib/useAgentsDataRefresh.ts", import.meta.url), + "utf8", +); + +test("registered mutations invalidate only the registered-agent query key", () => { + assert.match(hooks, /registeredAgentsQueryKey/); + assert.match(hooks, /useRegisterExistingAgentMutation/); + assert.match(hooks, /useUnregisterExistingAgentMutation/); + const registeredBlock = hooks.slice( + hooks.indexOf("useRegisterExistingAgentMutation"), + hooks.indexOf("useUnregisterExistingAgentMutation"), + ); + assert.match(registeredBlock, /queryKey: registeredAgentsQueryKey/); + assert.doesNotMatch( + registeredBlock, + /managedAgentsQueryKey|relayAgentsQueryKey|managedAgentRuntimesQueryKey/, + ); +}); + +test("agents-data-changed invalidates registered key alongside existing library keys", () => { + const block = refresh.slice( + refresh.indexOf('listen("agents-data-changed"'), + refresh.indexOf("return () =>"), + ); + assert.match(block, /registeredAgentsQueryKey/); + assert.match(block, /personasQueryKey/); + assert.match(block, /teamsQueryKey/); + assert.match(block, /managedAgentsQueryKey/); + assert.match(block, /relayAgentsQueryKey/); + assert.doesNotMatch(block, /managedAgentRuntimesQueryKey/); +}); diff --git a/desktop/src/features/agents/lib/registeredAgentCards.test.mjs b/desktop/src/features/agents/lib/registeredAgentCards.test.mjs new file mode 100644 index 00000000000..5ca078c0b94 --- /dev/null +++ b/desktop/src/features/agents/lib/registeredAgentCards.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + dedupeRegisteredAgentsAgainstManaged, + resolveRegisteredAgentDisplay, + registeredAgentRoleSummary, +} from "./registeredAgentCards.ts"; + +const PUBKEY = "a1".repeat(32); +const OTHER = "b2".repeat(32); +const reference = (pubkey, label, roleSummary = null) => ({ + pubkey, + label, + roleSummary, + createdAt: "", + updatedAt: "", +}); + +test("display prefers profile display name, then label, then short key name", () => { + assert.equal( + resolveRegisteredAgentDisplay({ + reference: reference(PUBKEY, "Stored Label", "reviewer"), + profile: { displayName: "Relay Profile", avatarUrl: null }, + }).label, + "Relay Profile", + ); + assert.equal( + resolveRegisteredAgentDisplay({ + reference: reference(PUBKEY, "Stored Label", "reviewer"), + profile: { displayName: " ", avatarUrl: null }, + }).label, + "Stored Label", + ); + assert.equal( + resolveRegisteredAgentDisplay({ + reference: reference(PUBKEY, null), + profile: null, + }).label, + `${PUBKEY.slice(0, 8)}…${PUBKEY.slice(-4)}`, + ); +}); + +test("role summary is always an externally managed suffix", () => { + assert.equal( + registeredAgentRoleSummary("reviewer"), + "reviewer · Externally managed", + ); + assert.equal(registeredAgentRoleSummary(null), "Externally managed"); +}); + +test("registered references are deduped against managed pubkeys", () => { + const references = [ + reference(PUBKEY.toUpperCase(), "external"), + reference(OTHER, "visible"), + ]; + assert.deepEqual( + dedupeRegisteredAgentsAgainstManaged(references, [{ pubkey: PUBKEY }]), + [reference(OTHER, "visible")], + ); +}); diff --git a/desktop/src/features/agents/lib/registeredAgentCards.ts b/desktop/src/features/agents/lib/registeredAgentCards.ts new file mode 100644 index 00000000000..d91592262ba --- /dev/null +++ b/desktop/src/features/agents/lib/registeredAgentCards.ts @@ -0,0 +1,46 @@ +import type { RegisteredAgentReference } from "@/shared/api/tauriRegisteredAgents"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; + +type MinimalProfile = { + displayName?: string | null; + avatarUrl?: string | null; +}; + +type PubkeyRecord = { pubkey: string }; + +export function registeredAgentRoleSummary(roleSummary: string | null): string { + const normalizedRole = roleSummary?.trim(); + return normalizedRole + ? `${normalizedRole} · Externally managed` + : "Externally managed"; +} + +export function resolveRegisteredAgentDisplay({ + reference, + profile, +}: { + reference: RegisteredAgentReference; + profile: MinimalProfile | null | undefined; +}) { + const profileLabel = profile?.displayName?.trim(); + const storedLabel = reference.label?.trim(); + return { + label: + profileLabel || + storedLabel || + truncatePubkey(normalizePubkey(reference.pubkey)), + avatarUrl: profile?.avatarUrl?.trim() || null, + }; +} + +export function dedupeRegisteredAgentsAgainstManaged( + references: readonly RegisteredAgentReference[], + managed: readonly T[], +): RegisteredAgentReference[] { + const managedPubkeys = new Set( + managed.map((agent) => normalizePubkey(agent.pubkey)), + ); + return references.filter( + (reference) => !managedPubkeys.has(normalizePubkey(reference.pubkey)), + ); +} diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts index b086f12a9c4..61fd14ad593 100644 --- a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts +++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts @@ -5,6 +5,7 @@ import { useEffect } from "react"; import { managedAgentsQueryKey, personasQueryKey, + registeredAgentsQueryKey, teamsQueryKey, } from "@/features/agents/hooks"; import { managedAgentRuntimesQueryKey } from "@/features/agents/managedAgentRuntimeHooks"; @@ -13,13 +14,14 @@ export const LOCAL_AGENT_DATA_QUERY_KEYS = [ personasQueryKey, teamsQueryKey, managedAgentsQueryKey, + registeredAgentsQueryKey, ] as const; // Trailing-coalesce local agent-store bursts into one cache refresh. The relay // directory is deliberately excluded: local persona/team/agent reconciliation -// cannot change remote directory records, and rebuilding that directory is a -// relay-wide operation. Remote data keeps its focused poll and is revalidated -// directly before an agent mention is sent. +// and registered-reference edits cannot change remote directory records, and +// rebuilding that directory is a relay-wide operation. Remote data keeps its +// focused poll and is revalidated directly before an agent mention is sent. const COALESCE_MS = 200; export function useAgentsDataRefresh(): void { diff --git a/desktop/src/features/agents/registeredAgentBoundary.test.mjs b/desktop/src/features/agents/registeredAgentBoundary.test.mjs new file mode 100644 index 00000000000..0b10073297f --- /dev/null +++ b/desktop/src/features/agents/registeredAgentBoundary.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import test from "node:test"; + +const ROOT = new URL("../../..", import.meta.url).pathname; +const SRC = join(ROOT, "src"); + +function walk(dir) { + return readdirSync(dir).flatMap((entry) => { + const path = join(dir, entry); + if (entry === "node_modules" || entry === "dist") return []; + if (statSync(path).isDirectory()) return walk(path); + return /\.(ts|tsx|mjs)$/.test(path) ? [path] : []; + }); +} + +test("registered-agent frontend does not import or call managed lifecycle/create APIs", () => { + const offenders = []; + for (const path of walk(SRC)) { + const rel = relative(ROOT, path); + if (!/registeredAgent|RegisteredAgent/.test(rel)) continue; + if (rel.endsWith("registeredAgentBoundary.test.mjs")) continue; + if (rel.endsWith("registeredAgents.test.mjs")) continue; + const text = readFileSync(path, "utf8"); + const forbidden = [ + "createManagedAgent", + "startManagedAgent", + "stopManagedAgent", + "deleteManagedAgent", + "managedAgentRuntime", + "privateKeyNsec", + "private_key_nsec", + "envVars", + "agentCommand", + "agent_command", + ].filter((needle) => text.includes(needle)); + if (forbidden.length > 0) offenders.push(`${rel}: ${forbidden.join(", ")}`); + } + assert.deepEqual(offenders, []); +}); diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index e1e1f37f35f..a1b7c2d8876 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { EllipsisVertical, OctagonX, Settings2 } from "lucide-react"; +import { EllipsisVertical, Link2, OctagonX, Settings2 } from "lucide-react"; import { consumePendingSnapshotImport, subscribeSnapshotImport, @@ -20,11 +20,19 @@ import { TeamDeleteDialog } from "./TeamDeleteDialog"; import { TeamDialog } from "./TeamDialog"; import { TeamsSection } from "./TeamsSection"; import { UnifiedAgentsSection } from "./UnifiedAgentsSection"; +import { RegisterExistingAgentDialog } from "./RegisterExistingAgentDialog"; +import { RemoveRegisteredAgentDialog } from "./RemoveRegisteredAgentDialog"; import { useManagedAgentActions } from "./useManagedAgentActions"; import { usePersonaActions } from "./usePersonaActions"; import { useTeamActions } from "./useTeamActions"; import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; -import { useBakedBuildEnvQuery } from "@/features/agents/hooks"; +import { + useBakedBuildEnvQuery, + useRegisteredAgentsQuery, + useRegisterExistingAgentMutation, + useUnregisterExistingAgentMutation, +} from "@/features/agents/hooks"; +import type { RegisteredAgentReference } from "@/shared/api/tauriRegisteredAgents"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; import { Button } from "@/shared/ui/button"; @@ -49,6 +57,13 @@ export function AgentsView() { const fullAiDefaultsTriggerRef = React.useRef(null); const compactActionsTriggerRef = React.useRef(null); const [isAiDefaultsOpen, setIsAiDefaultsOpen] = React.useState(false); + const [isRegisterExistingOpen, setIsRegisterExistingOpen] = + React.useState(false); + const [referenceToRemove, setReferenceToRemove] = + React.useState(null); + const registeredReferencesQuery = useRegisteredAgentsQuery(); + const registerReferenceMutation = useRegisterExistingAgentMutation(); + const unregisterReferenceMutation = useUnregisterExistingAgentMutation(); function openUnifiedCatalog() { personas.prepareCreate(); @@ -137,6 +152,18 @@ export function AgentsView() { action={ <>
+ + + + + + + ); +} diff --git a/desktop/src/features/agents/ui/RegisteredAgentIdentityCard.tsx b/desktop/src/features/agents/ui/RegisteredAgentIdentityCard.tsx new file mode 100644 index 00000000000..d806c58c6bd --- /dev/null +++ b/desktop/src/features/agents/ui/RegisteredAgentIdentityCard.tsx @@ -0,0 +1,90 @@ +import { Copy, EllipsisVertical, Trash2 } from "lucide-react"; + +import { + registeredAgentRoleSummary, + resolveRegisteredAgentDisplay, +} from "@/features/agents/lib/registeredAgentCards"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { useUserProfileQuery } from "@/features/profile/hooks"; +import type { RegisteredAgentReference } from "@/shared/api/tauriRegisteredAgents"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { AgentIdentityCard } from "./AgentIdentityCard"; + +export function RegisteredAgentIdentityCard({ + isPending, + onOpenProfile, + onRemove, + reference, +}: { + isPending: boolean; + onOpenProfile: (pubkey: string) => void; + onRemove: (reference: RegisteredAgentReference) => void; + reference: RegisteredAgentReference; +}) { + const profileQuery = useUserProfileQuery(reference.pubkey); + const display = resolveRegisteredAgentDisplay({ + reference, + profile: profileQuery.data, + }); + const summary = registeredAgentRoleSummary(reference.roleSummary); + + async function copyPubkey() { + await navigator.clipboard?.writeText(reference.pubkey); + } + + return ( + + + + + + { + event.preventDefault(); + void copyPubkey(); + }} + > + + Copy full pubkey + + onRemove(reference)} + > + + Remove reference + + + + } + ariaLabel={`${display.label} externally managed agent profile, public key ${reference.pubkey}`} + avatarUrl={display.avatarUrl} + dataTestId={`registered-agent-${reference.pubkey}`} + label={display.label} + modelLabel={summary} + onClick={() => onOpenProfile(reference.pubkey)} + statusBadge={ + + {truncatePubkey(reference.pubkey)} + + } + /> + ); +} diff --git a/desktop/src/features/agents/ui/RemoveRegisteredAgentDialog.tsx b/desktop/src/features/agents/ui/RemoveRegisteredAgentDialog.tsx new file mode 100644 index 00000000000..5e36eae2d34 --- /dev/null +++ b/desktop/src/features/agents/ui/RemoveRegisteredAgentDialog.tsx @@ -0,0 +1,51 @@ +import type { RegisteredAgentReference } from "@/shared/api/tauriRegisteredAgents"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; + +export function RemoveRegisteredAgentDialog({ + isPending, + onConfirm, + onOpenChange, + reference, +}: { + isPending: boolean; + onConfirm: (reference: RegisteredAgentReference) => void; + onOpenChange: (open: boolean) => void; + reference: RegisteredAgentReference | null; +}) { + const label = reference?.label?.trim() || reference?.pubkey || "this agent"; + return ( + + + + Remove existing agent reference? + + Remove the reference to {label}? This removes only the local card + and reference. It does not delete the identity, stop a responder, + remove channel membership, or erase messages. + + + + Cancel + { + if (reference) onConfirm(reference); + }} + > + Remove reference + + + + + ); +} diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index d0ff2e2738a..c9b3efa5a23 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -12,6 +12,7 @@ import { pickProfileAgent } from "@/features/agents/lib/pickProfileAgent"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useUserProfileQuery } from "@/features/profile/hooks"; import type { AgentPersona, ManagedAgent } from "@/shared/api/types"; +import type { RegisteredAgentReference } from "@/shared/api/tauriRegisteredAgents"; import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; import { Badge } from "@/shared/ui/badge"; @@ -20,6 +21,8 @@ import { AgentIdentityCard } from "./AgentIdentityCard"; import { AgentRuntimeAvatarControl } from "./AgentRuntimeAvatarControl"; import { CreateIdentityCard } from "./CreateIdentityCard"; import { PersonaActionsMenu } from "./PersonaActionsMenu"; +import { RegisteredAgentIdentityCard } from "./RegisteredAgentIdentityCard"; +import { dedupeRegisteredAgentsAgainstManaged } from "../lib/registeredAgentCards"; import { buildUnifiedGroups } from "./unifiedAgentGroups"; type UnifiedAgentsSectionProps = { @@ -42,6 +45,11 @@ type UnifiedAgentsSectionProps = { onStartAgent: (pubkey: string) => void; onStartPersona: (persona: AgentPersona) => void; personas: AgentPersona[]; + registeredReferences: RegisteredAgentReference[]; + registeredReferencesError: Error | null; + isRegisteredReferencesLoading: boolean; + isRegisteredReferencePending: boolean; + onRemoveRegisteredReference: (reference: RegisteredAgentReference) => void; personasError: Error | null; personaFeedbackErrorMessage: string | null; personaFeedbackNoticeMessage: string | null; @@ -82,6 +90,11 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { onStartAgent, onStartPersona, personas, + registeredReferences, + registeredReferencesError, + isRegisteredReferencesLoading, + isRegisteredReferencePending, + onRemoveRegisteredReference, personasError, personaFeedbackErrorMessage, personaFeedbackNoticeMessage, @@ -112,7 +125,12 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { useFeedbackToasts(actionNoticeMessage, actionErrorMessage); useFeedbackToasts(personaFeedbackNoticeMessage, personaFeedbackErrorMessage); - const isLoading = isAgentsLoading || isPersonasLoading; + const isLoading = + isAgentsLoading || isPersonasLoading || isRegisteredReferencesLoading; + const visibleRegisteredReferences = React.useMemo( + () => dedupeRegisteredAgentsAgainstManaged(registeredReferences, agents), + [registeredReferences, agents], + ); return (
); })} + {visibleRegisteredReferences.map((reference) => ( + + ))}
{unknown.length > 0 ? ( @@ -215,6 +242,13 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { {personasError.message}

) : null} + {registeredReferencesError ? ( +

+ {registeredReferencesError.message} +

+ ) : null} ); } diff --git a/desktop/src/features/agents/useKnownAgentPubkeys.tsx b/desktop/src/features/agents/useKnownAgentPubkeys.tsx index e9fe7b9a9b0..5d2f281ded4 100644 --- a/desktop/src/features/agents/useKnownAgentPubkeys.tsx +++ b/desktop/src/features/agents/useKnownAgentPubkeys.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { useManagedAgentsQuery, + useRegisteredAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; import { mergeKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys"; @@ -41,10 +42,17 @@ export function KnownAgentPubkeysProvider({ }) { const managedAgents = useManagedAgentsQuery().data; const relayAgents = useRelayAgentsQuery().data; + const registeredAgents = useRegisteredAgentsQuery().data; const merged = React.useMemo( - () => mergeKnownAgentPubkeys(managedAgents, relayAgents), - [managedAgents, relayAgents], + () => + mergeKnownAgentPubkeys(managedAgents, [ + ...(relayAgents ?? []), + ...(registeredAgents ?? []).map((reference) => ({ + pubkey: reference.pubkey, + })), + ]), + [managedAgents, relayAgents, registeredAgents], ); const stable = useStableSet(merged); diff --git a/desktop/src/shared/api/registeredAgents.test.mjs b/desktop/src/shared/api/registeredAgents.test.mjs new file mode 100644 index 00000000000..e8e7666d830 --- /dev/null +++ b/desktop/src/shared/api/registeredAgents.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + fromRawRegisteredAgentReference, + toRawRegisterExistingAgentInput, +} from "./tauriRegisteredAgents.ts"; + +const PUBKEY = `${"ABCDEF".repeat(10)}ABCD`; + +test("maps registered agent references from Tauri snake_case to TS camelCase without runtime fields", () => { + const mapped = fromRawRegisteredAgentReference({ + pubkey: PUBKEY, + label: "Existing Goose", + role_summary: "reviewer", + created_at: "2026-08-18T12:00:00Z", + updated_at: "2026-08-18T12:30:00Z", + private_key_nsec: "redacted-test-value", + agent_command: "goose", + status: "running", + pid: 123, + }); + + assert.deepEqual(mapped, { + pubkey: PUBKEY.toLowerCase(), + label: "Existing Goose", + roleSummary: "reviewer", + createdAt: "2026-08-18T12:00:00Z", + updatedAt: "2026-08-18T12:30:00Z", + }); + assert.equal("privateKeyNsec" in mapped, false); + assert.equal("agentCommand" in mapped, false); + assert.equal("status" in mapped, false); + assert.equal("pid" in mapped, false); +}); + +test("register input sends only pubkey, label, and role summary with omitted blanks as null", () => { + const raw = toRawRegisterExistingAgentInput({ + pubkey: ` ${PUBKEY} `, + label: " Existing Goose ", + roleSummary: " ", + }); + + assert.deepEqual(raw, { + pubkey: PUBKEY.toLowerCase(), + label: "Existing Goose", + roleSummary: null, + }); +}); + +test("malformed registered agent store entries fail visibly instead of being filtered", () => { + assert.throws( + () => + fromRawRegisteredAgentReference({ + pubkey: "not-a-key", + label: "Bad", + role_summary: null, + created_at: "2026-08-18T12:00:00Z", + updated_at: "2026-08-18T12:30:00Z", + }), + /registered agent pubkey/i, + ); +}); diff --git a/desktop/src/shared/api/tauriRegisteredAgents.ts b/desktop/src/shared/api/tauriRegisteredAgents.ts new file mode 100644 index 00000000000..00d7a42f7a4 --- /dev/null +++ b/desktop/src/shared/api/tauriRegisteredAgents.ts @@ -0,0 +1,105 @@ +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { invokeTauri } from "./tauri"; + +export type RegisteredAgentReference = { + pubkey: string; + label: string | null; + roleSummary: string | null; + createdAt: string; + updatedAt: string; +}; + +export type RawRegisteredAgentReference = { + pubkey: string; + label?: string | null; + role_summary?: string | null; + created_at: string; + updated_at: string; +}; + +type RegisterExistingAgentInput = { + pubkey: string; + label?: string | null; + roleSummary?: string | null; +}; + +export type RawRegisterExistingAgentInput = { + pubkey: string; + label: string | null; + roleSummary: string | null; +}; + +const HEX_PUBKEY_RE = /^[0-9a-f]{64}$/; + +function requiredString(value: unknown, field: string): string { + if (typeof value !== "string") { + throw new Error(`Malformed registered agent ${field}.`); + } + return value; +} + +function nullableString(value: unknown, field: string): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== "string") { + throw new Error(`Malformed registered agent ${field}.`); + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeRegisteredPubkey(value: unknown): string { + const pubkey = normalizePubkey(requiredString(value, "pubkey")); + if (!HEX_PUBKEY_RE.test(pubkey)) { + throw new Error("Malformed registered agent pubkey."); + } + return pubkey; +} + +export function fromRawRegisteredAgentReference( + raw: RawRegisteredAgentReference, +): RegisteredAgentReference { + return { + pubkey: normalizeRegisteredPubkey(raw.pubkey), + label: nullableString(raw.label, "label"), + roleSummary: nullableString(raw.role_summary, "role_summary"), + createdAt: requiredString(raw.created_at, "created_at"), + updatedAt: requiredString(raw.updated_at, "updated_at"), + }; +} + +export function toRawRegisterExistingAgentInput( + input: RegisterExistingAgentInput, +): RawRegisterExistingAgentInput { + return { + pubkey: normalizeRegisteredPubkey(input.pubkey), + label: nullableString(input.label, "label"), + roleSummary: nullableString(input.roleSummary, "roleSummary"), + }; +} + +export async function listRegisteredAgentReferences(): Promise< + RegisteredAgentReference[] +> { + const raw = await invokeTauri( + "list_registered_agent_references", + ); + return raw.map(fromRawRegisteredAgentReference); +} + +export async function registerExistingAgentReference( + input: RegisterExistingAgentInput, +): Promise { + const raw = await invokeTauri( + "register_existing_agent_reference", + { input: toRawRegisterExistingAgentInput(input) }, + ); + return fromRawRegisteredAgentReference(raw); +} + +export async function unregisterExistingAgentReference( + pubkey: string, +): Promise { + await invokeTauri("unregister_existing_agent_reference", { + pubkey: normalizeRegisteredPubkey(pubkey), + }); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 53b17fc7ef0..0ea53cc2e59 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -111,6 +111,14 @@ type MockManagedAgentRuntimeSeed = { lifecycle?: MockManagedAgentRuntimeRow["lifecycle"]; }; +type MockRegisteredAgentReference = { + pubkey: string; + label?: string | null; + role_summary?: string | null; + created_at: string; + updated_at: string; +}; + type MockRelayAgentSeed = { pubkey: string; ownerPubkey?: string | null; @@ -278,6 +286,8 @@ type E2eConfig = { mcp?: MockCommandAvailability; }; managedAgents?: MockManagedAgentSeed[]; + registeredAgents?: MockRegisteredAgentReference[]; + registeredAgentsError?: string; /** Result returned by the mocked `add_agent_to_huddle` command. */ addAgentToHuddleResult?: { ephemeral_added: boolean; @@ -3089,6 +3099,7 @@ let mockClosedChannelLiveSubscription = false; const realSockets = new Map(); let mockManagedAgents: MockManagedAgent[] = []; let mockManagedAgentRuntimes: MockManagedAgentRuntimeRow[] = []; +let mockRegisteredAgents: MockRegisteredAgentReference[] = []; // Mutable `save_subscriptions` table mirror — TEST-ONLY. // @@ -10396,6 +10407,7 @@ export function maybeInstallE2eTauriMocks() { resetMockRelayMembers(config); resetMockRelayAgents(config); resetMockManagedAgents(config); + mockRegisteredAgents = structuredClone(config.mock?.registeredAgents ?? []); resetMockPersonas(config); resetMockTeams(config); seedMockSearchProfiles(config); @@ -12725,6 +12737,67 @@ export function maybeInstallE2eTauriMocks() { } case "list_managed_agents": return handleListManagedAgents(activeConfig); + case "list_registered_agent_references": + if (activeConfig?.mock?.registeredAgentsError) { + throw new Error(activeConfig.mock.registeredAgentsError); + } + return structuredClone(mockRegisteredAgents); + case "register_existing_agent_reference": { + const input = (payload as { input?: Record } | null) + ?.input; + if ( + !input || + Object.keys(input).some( + (key) => !["pubkey", "label", "roleSummary"].includes(key), + ) + ) { + throw new Error("invalid registered agent input"); + } + const pubkey = String(input.pubkey ?? "") + .trim() + .toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(pubkey)) { + throw new Error("invalid public key"); + } + if ( + mockManagedAgents.some( + (agent) => agent.pubkey.toLowerCase() === pubkey, + ) + ) { + throw new Error(`agent ${pubkey} is already a managed agent`); + } + const now = new Date().toISOString(); + const existing = mockRegisteredAgents.find( + (reference) => reference.pubkey === pubkey, + ); + const reference: MockRegisteredAgentReference = { + pubkey, + label: + typeof input.label === "string" && input.label.trim() + ? input.label.trim() + : null, + role_summary: + typeof input.roleSummary === "string" && input.roleSummary.trim() + ? input.roleSummary.trim() + : null, + created_at: existing?.created_at ?? now, + updated_at: now, + }; + mockRegisteredAgents = [ + ...mockRegisteredAgents.filter((item) => item.pubkey !== pubkey), + reference, + ]; + return structuredClone(reference); + } + case "unregister_existing_agent_reference": { + const pubkey = String( + (payload as { pubkey?: unknown } | null)?.pubkey ?? "", + ).toLowerCase(); + mockRegisteredAgents = mockRegisteredAgents.filter( + (item) => item.pubkey !== pubkey, + ); + return null; + } case "get_agent_memory": return handleGetAgentMemory( (payload as Parameters[0]) ?? {}, diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index b81c5889f3a..d6b4bc04e39 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -2708,3 +2708,42 @@ test("duplicate instances move from the agents gallery into the agent profile", page.getByTestId(`user-profile-agent-delete-${additionalPubkey}`), ).toHaveCount(0); }); + +test("register existing agent stays keyless and has no lifecycle controls", async ({ + page, +}) => { + const pubkey = "a1".repeat(32); + await installMockBridge(page, { registeredAgents: [] }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + await page.getByRole("button", { name: "Register existing agent" }).click(); + const dialog = page.getByRole("dialog", { name: "Register existing agent" }); + await expect(dialog).toContainText( + "Registers an existing identity for this device. Buzz will not import its key or run it.", + ); + await dialog.getByTestId("register-existing-agent-pubkey").fill(pubkey); + await dialog + .getByTestId("register-existing-agent-label") + .fill("Outside Goose"); + await dialog + .getByTestId("register-existing-agent-role-summary") + .fill("Reviewer"); + await dialog.getByRole("button", { name: "Register reference" }).click(); + + const card = page.getByTestId(`registered-agent-${pubkey}`); + await expect(card).toContainText("Outside Goose"); + await expect(card).toContainText("Reviewer · Externally managed"); + await expect(card).toContainText(pubkey.slice(-4)); + await expect(page.getByTestId(`agent-runtime-start-${pubkey}`)).toHaveCount( + 0, + ); + await expect(card.getByText(pubkey, { exact: true })).toHaveCount(0); + + const commands = await page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).map((call) => call.command), + ); + expect(commands).toContain("register_existing_agent_reference"); + expect(commands).not.toContain("create_managed_agent"); + expect(commands).not.toContain("start_managed_agent"); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index ed94e6b1767..27ee8804609 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -220,6 +220,14 @@ type MockBridgeOptions = { mcp?: MockCommandAvailability; }; managedAgents?: MockManagedAgentSeed[]; + registeredAgents?: Array<{ + pubkey: string; + label?: string | null; + role_summary?: string | null; + created_at: string; + updated_at: string; + }>; + registeredAgentsError?: string; /** Result returned by the mocked `add_agent_to_huddle` command. */ addAgentToHuddleResult?: { ephemeral_added: boolean; From ab5774687b5ab850ca371b3bbb2fda03e7cbb282 Mon Sep 17 00:00:00 2001 From: Cameron Aragon <69489633+camaragon@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:24:49 +0000 Subject: [PATCH 02/12] test(agents): harden registered reference boundaries Signed-off-by: Cameron Aragon <69489633+camaragon@users.noreply.github.com> --- .../check-registered-agent-boundary.mjs | 74 +++++- .../src-tauri/src/commands/agent_models.rs | 2 +- .../src/commands/agent_models_update.rs | 3 +- .../src/commands/agent_registered_targets.rs | 11 +- .../managed_agents/registered_references.rs | 251 +++++++++++++++++- .../src-tauri/src/managed_agents/storage.rs | 21 +- .../features/agents/hooksRegistered.test.mjs | 22 +- .../agents/registeredAgentBoundary.test.mjs | 70 ++++- .../UnifiedAgentsSectionCardTarget.test.mjs | 5 + .../features/agents/useKnownAgentPubkeys.tsx | 12 +- desktop/tests/e2e/agents.spec.ts | 174 ++++++++++++ 11 files changed, 611 insertions(+), 34 deletions(-) diff --git a/desktop/scripts/check-registered-agent-boundary.mjs b/desktop/scripts/check-registered-agent-boundary.mjs index a4e70695c53..db331b667ee 100755 --- a/desktop/scripts/check-registered-agent-boundary.mjs +++ b/desktop/scripts/check-registered-agent-boundary.mjs @@ -4,7 +4,51 @@ import { join, relative } from "node:path"; const root = process.cwd(); const src = join(root, "src"); -const forbidden = [ +// Registered references are a display/navigation-only data source. Keep the +// complete consumer list explicit: a new integration file must be reviewed +// and added here instead of evading the check because its filename does not +// happen to contain "registeredAgent". +const registeredAgentDataFiles = new Set([ + "src/features/agents/hooks.ts", + "src/features/agents/hooksRegistered.test.mjs", + "src/features/agents/lib/registeredAgentCards.test.mjs", + "src/features/agents/lib/registeredAgentCards.ts", + "src/features/agents/lib/useAgentsDataRefresh.ts", + "src/features/agents/registeredAgentBoundary.test.mjs", + "src/features/agents/ui/AgentsView.tsx", + "src/features/agents/ui/RegisterExistingAgentDialog.tsx", + "src/features/agents/ui/RegisteredAgentIdentityCard.tsx", + "src/features/agents/ui/RemoveRegisteredAgentDialog.tsx", + "src/features/agents/ui/UnifiedAgentsSection.tsx", + "src/features/agents/ui/UnifiedAgentsSectionCardTarget.test.mjs", + "src/shared/api/registeredAgents.test.mjs", + "src/shared/api/tauriRegisteredAgents.ts", + "src/testing/e2eBridge.ts", +]); +const registeredAgentDisplayFiles = new Set([ + "src/features/agents/lib/registeredAgentCards.ts", + "src/features/agents/ui/RegisterExistingAgentDialog.tsx", + "src/features/agents/ui/RegisteredAgentIdentityCard.tsx", + "src/features/agents/ui/RemoveRegisteredAgentDialog.tsx", + "src/shared/api/tauriRegisteredAgents.ts", +]); +const registeredAgentDataMarkers = [ + "RegisteredAgentReference", + "listRegisteredAgentReferences", + "registerExistingAgentReference", + "registeredAgentsQueryKey", + "registeredReferences", + "unregisterExistingAgentReference", + "useRegisteredAgentsQuery", +]; +const forbiddenTrustMarkers = [ + "KnownAgentPubkeys", + "configNudgeAuthPubkey", + "mergeKnownAgentPubkeys", + "mentionableAgentPubkeys", + "useKnownAgentPubkeys", +]; +const forbiddenInRegisteredAgentFiles = [ "createManagedAgent", "startManagedAgent", "stopManagedAgent", @@ -29,10 +73,32 @@ function walk(dir) { const offenders = []; for (const path of walk(src)) { const rel = relative(root, path); - if (!/registeredAgent|RegisteredAgent/.test(rel)) continue; const text = readFileSync(path, "utf8"); - const hits = forbidden.filter((needle) => text.includes(needle)); - if (hits.length > 0) offenders.push(`${rel}: ${hits.join(", ")}`); + const dataHits = registeredAgentDataMarkers.filter((needle) => + text.includes(needle), + ); + if (dataHits.length > 0 && !registeredAgentDataFiles.has(rel)) { + offenders.push( + `${rel}: registered-reference data (${dataHits.join(", ")})`, + ); + } + if (dataHits.length > 0 && registeredAgentDataFiles.has(rel)) { + const trustHits = forbiddenTrustMarkers.filter((needle) => + text.includes(needle), + ); + if (trustHits.length > 0) { + offenders.push( + `${rel}: registered-reference trust leak (${trustHits.join(", ")})`, + ); + } + } + if (!registeredAgentDisplayFiles.has(rel)) continue; + const forbiddenHits = forbiddenInRegisteredAgentFiles.filter((needle) => + text.includes(needle), + ); + if (forbiddenHits.length > 0) { + offenders.push(`${rel}: ${forbiddenHits.join(", ")}`); + } } if (offenders.length > 0) { diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 770aa064fba..15ad006e128 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -706,7 +706,7 @@ pub(super) async fn update_managed_agent_unchecked( app: AppHandle, state: State<'_, AppState>, ) -> Result { - update::update_managed_agent(input, app, state).await + update::update_managed_agent_impl(input, app, state).await } // ── Model normalization ─────────────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/commands/agent_models_update.rs b/desktop/src-tauri/src/commands/agent_models_update.rs index bb045b81a24..b81def4a1e9 100644 --- a/desktop/src-tauri/src/commands/agent_models_update.rs +++ b/desktop/src-tauri/src/commands/agent_models_update.rs @@ -57,8 +57,7 @@ pub(crate) async fn flush_managed_agent_policy( /// Most runtime config changes take effect on the next agent spawn. Access /// policy changes stop active local pairs before saving and restart those exact /// pairs after the relay policy is flushed. -#[tauri::command] -pub async fn update_managed_agent( +pub(super) async fn update_managed_agent_impl( input: UpdateManagedAgentRequest, app: AppHandle, state: State<'_, AppState>, diff --git a/desktop/src-tauri/src/commands/agent_registered_targets.rs b/desktop/src-tauri/src/commands/agent_registered_targets.rs index 899043114d6..2fb99092d3e 100644 --- a/desktop/src-tauri/src/commands/agent_registered_targets.rs +++ b/desktop/src-tauri/src/commands/agent_registered_targets.rs @@ -24,11 +24,20 @@ pub async fn update_managed_agent( #[tauri::command] pub async fn start_managed_agent( pubkey: String, + expected_relay_url: Option, + expected_signer_pubkey: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result { reject_registered_reference_target(&app, &pubkey)?; - super::agents::start_managed_agent_unchecked(pubkey, app, state).await + super::agents::start_managed_agent_unchecked( + pubkey, + expected_relay_url, + expected_signer_pubkey, + app, + state, + ) + .await } #[tauri::command] diff --git a/desktop/src-tauri/src/managed_agents/registered_references.rs b/desktop/src-tauri/src/managed_agents/registered_references.rs index 9f92b2ccff0..c8e94df30ce 100644 --- a/desktop/src-tauri/src/managed_agents/registered_references.rs +++ b/desktop/src-tauri/src/managed_agents/registered_references.rs @@ -176,10 +176,18 @@ pub(crate) fn reject_registered_reference_target( pubkey: &str, ) -> Result<(), String> { let normalized = normalize_pubkey(pubkey).unwrap_or_else(|_| pubkey.to_string()); - if !super::storage::managed_agent_record_exists(app, &normalized)? { - return Err(format!("agent {normalized} not found")); + reject_registered_reference_target_at_path( + &super::storage::managed_agents_store_path(app)?, + &normalized, + ) +} + +fn reject_registered_reference_target_at_path(path: &Path, pubkey: &str) -> Result<(), String> { + if super::storage::managed_agent_record_exists_at_path(path, pubkey)? { + Ok(()) + } else { + Err(format!("agent {pubkey} not found")) } - Ok(()) } #[cfg(test)] @@ -192,6 +200,9 @@ mod tests { const PUBKEY_A_UPPER: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; const PUBKEY_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const PUBKEY_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const COMMAND_TARGETS_SOURCE: &str = include_str!("../commands/agent_registered_targets.rs"); + const SETTINGS_SOURCE: &str = include_str!("../commands/agent_settings.rs"); + const RUNTIME_SOURCE: &str = include_str!("runtime_commands.rs"); fn request( pubkey: &str, @@ -353,4 +364,238 @@ mod tests { save_to_path(path, refs)?; Ok(reference) } + + /// Extract one small command body so the regression tests verify the real + /// production routing as well as the path-based ownership decision. These + /// commands deliberately contain no nested brace-bearing literals before + /// their ownership check. + fn command_body<'a>(source: &'a str, name: &str) -> &'a str { + let signature = format!("fn {name}("); + let start = source + .find(&signature) + .unwrap_or_else(|| panic!("missing command {name}")); + let body_start = source[start..] + .find('{') + .map(|offset| start + offset) + .unwrap_or_else(|| panic!("missing body for command {name}")); + let mut depth = 0usize; + for (offset, byte) in source[body_start..].bytes().enumerate() { + match byte { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + return &source[body_start..=body_start + offset]; + } + } + _ => {} + } + } + panic!("unterminated body for command {name}") + } + + fn assert_before(body: &str, first: &str, second: &str) { + let first_index = body + .find(first) + .unwrap_or_else(|| panic!("missing preflight `{first}` in {body}")); + let second_index = body + .find(second) + .unwrap_or_else(|| panic!("missing mutation/delegation `{second}` in {body}")); + assert!( + first_index < second_index, + "`{first}` must precede `{second}`" + ); + } + + fn assert_production_command_is_guarded(target: &str) { + match target { + "start_managed_agent" + | "stop_managed_agent" + | "update_managed_agent" + | "delete_managed_agent" => assert_before( + command_body(COMMAND_TARGETS_SOURCE, target), + "reject_registered_reference_target", + &format!("{target}_unchecked"), + ), + "set_managed_agent_start_on_app_launch" | "set_managed_agent_auto_restart" => { + assert_before( + command_body(SETTINGS_SOURCE, target), + "reject_registered_reference_target", + "spawn_blocking", + ); + } + "put_managed_agent_runtime_lifecycle" => assert_before( + command_body(RUNTIME_SOURCE, target), + "reject_registered_reference_target", + "app.state::()", + ), + "start_managed_agent_runtime" => { + assert!(command_body(RUNTIME_SOURCE, target) + .contains("start_managed_agent_runtime_pair_lazy")); + assert_before( + command_body(RUNTIME_SOURCE, "start_pair"), + "reject_registered_reference_target", + "app.state::()", + ); + } + "stop_managed_agent_runtime" => assert_before( + command_body(RUNTIME_SOURCE, target), + "reject_registered_reference_target", + "app.state::()", + ), + "restart_managed_agent_runtime" => assert_before( + command_body(RUNTIME_SOURCE, target), + "stop_managed_agent_runtime", + "start_pair", + ), + _ => panic!("unmapped registered-reference command target {target}"), + } + } + + fn snapshot_tree(root: &Path) -> Vec<(PathBuf, Vec)> { + fn visit(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, Vec)>) { + let mut entries = fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap()) + .collect::>(); + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let path = entry.path(); + if path.is_dir() { + visit(root, &path, out); + } else { + out.push(( + path.strip_prefix(root).unwrap().to_path_buf(), + fs::read(path).unwrap(), + )); + } + } + } + let mut snapshot = Vec::new(); + visit(root, root, &mut snapshot); + snapshot + } + + fn assert_registered_reference_command_fails_closed(target: &str) { + let temp = tempfile::tempdir().unwrap(); + let agents_dir = temp.path().join("agents"); + fs::create_dir_all(agents_dir.join("agent-pids")).unwrap(); + let managed_store = agents_dir.join("managed-agents.json"); + let registered_store = agents_dir.join(STORE_FILENAME); + + // The target exists only in the keyless reference store. The managed + // store contains an unrelated record with deliberately stale runtime + // metadata, matching the state that used to be synchronized before a + // missing-target error was returned. + let unrelated = serde_json::json!({ + "pubkey": PUBKEY_B, + "name": "unrelated-exited-runtime", + "relay_url": "wss://relay.example", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": "", + "runtime_pid": 424242, + "last_error": "stale exited child", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }); + fs::write( + &managed_store, + serde_json::to_vec_pretty(&vec![unrelated]).unwrap(), + ) + .unwrap(); + fs::write( + ®istered_store, + serde_json::to_vec_pretty(&vec![RegisteredAgentReference { + pubkey: PUBKEY_A.to_string(), + label: Some("external".to_string()), + role_summary: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + }]) + .unwrap(), + ) + .unwrap(); + + // Sentinels cover every side-effect class at issue: event emission, + // keyring writes/deletes, in-memory lifecycle state, runtime receipts, + // and process identity. A command reaching its mutation body would + // necessarily alter at least one of these or the managed store. + fs::write(agents_dir.join("event-sentinel"), b"0 events").unwrap(); + fs::write( + agents_dir.join("keyring-sentinel"), + b"agent:b = nsec-sentinel", + ) + .unwrap(); + fs::write( + agents_dir.join("runtime-state-sentinel"), + br#"{"pubkey":"bbbb","lifecycle":"exited","pid":424242}"#, + ) + .unwrap(); + fs::write( + agents_dir.join("agent-pids").join("stale.json"), + br#"{"pid":424242,"status":"exited"}"#, + ) + .unwrap(); + + let before = snapshot_tree(temp.path()); + let error = reject_registered_reference_target_at_path(&managed_store, PUBKEY_A) + .expect_err("registered reference must not authorize a managed command"); + assert_eq!(error, format!("agent {PUBKEY_A} not found")); + assert_eq!(snapshot_tree(temp.path()), before, "{target} mutated state"); + assert_production_command_is_guarded(target); + } + + macro_rules! fail_closed_command_test { + ($name:ident, $target:literal) => { + #[test] + fn $name() { + assert_registered_reference_command_fails_closed($target); + } + }; + } + + fail_closed_command_test!( + registered_agent_references_start_managed_agent_fails_closed, + "start_managed_agent" + ); + fail_closed_command_test!( + registered_agent_references_stop_managed_agent_fails_closed, + "stop_managed_agent" + ); + fail_closed_command_test!( + registered_agent_references_start_runtime_fails_closed, + "start_managed_agent_runtime" + ); + fail_closed_command_test!( + registered_agent_references_stop_runtime_fails_closed, + "stop_managed_agent_runtime" + ); + fail_closed_command_test!( + registered_agent_references_restart_runtime_fails_closed, + "restart_managed_agent_runtime" + ); + fail_closed_command_test!( + registered_agent_references_lifecycle_observer_write_fails_closed, + "put_managed_agent_runtime_lifecycle" + ); + fail_closed_command_test!( + registered_agent_references_start_on_launch_fails_closed, + "set_managed_agent_start_on_app_launch" + ); + fail_closed_command_test!( + registered_agent_references_auto_restart_fails_closed, + "set_managed_agent_auto_restart" + ); + fail_closed_command_test!( + registered_agent_references_update_fails_closed, + "update_managed_agent" + ); + fail_closed_command_test!( + registered_agent_references_delete_fails_closed, + "delete_managed_agent" + ); } diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 4665c8fad42..daa8252c6ff 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -259,7 +259,26 @@ fn load_agent_store(app: &AppHandle) -> Result, String> /// Check managed-agent ownership without hydrating private keys or touching /// runtime state. Command boundaries use this before any lifecycle side effect. pub(crate) fn managed_agent_record_exists(app: &AppHandle, pubkey: &str) -> Result { - Ok(load_agent_store(app)? + managed_agent_record_exists_at_path(&managed_agents_store_path(app)?, pubkey) +} + +/// Path-based ownership lookup used by the command-boundary regression tests. +/// It intentionally shares the raw, non-hydrating read used in production so +/// proving a rejected target cannot touch the keyring does not itself touch it. +pub(crate) fn managed_agent_record_exists_at_path( + path: &Path, + pubkey: &str, +) -> Result { + if !path.exists() { + return Ok(false); + } + let content = + fs::read_to_string(path).map_err(|error| format!("failed to read agent store: {error}"))?; + let records: Vec = serde_json::from_str(&content).map_err(|error| { + backup_invalid_store(path); + format!("failed to parse agent store (preserved as .invalid): {error}") + })?; + Ok(records .iter() .any(|record| !record.pubkey.is_empty() && record.pubkey == pubkey)) } diff --git a/desktop/src/features/agents/hooksRegistered.test.mjs b/desktop/src/features/agents/hooksRegistered.test.mjs index ece48838fff..9d3f6afe830 100644 --- a/desktop/src/features/agents/hooksRegistered.test.mjs +++ b/desktop/src/features/agents/hooksRegistered.test.mjs @@ -23,15 +23,21 @@ test("registered mutations invalidate only the registered-agent query key", () = ); }); -test("agents-data-changed invalidates registered key alongside existing library keys", () => { - const block = refresh.slice( +test("agents-data-changed invalidates registered key alongside existing local library keys", () => { + const localKeysBlock = refresh.slice( + refresh.indexOf("LOCAL_AGENT_DATA_QUERY_KEYS"), + refresh.indexOf("] as const"), + ); + assert.match(localKeysBlock, /registeredAgentsQueryKey/); + assert.match(localKeysBlock, /personasQueryKey/); + assert.match(localKeysBlock, /teamsQueryKey/); + assert.match(localKeysBlock, /managedAgentsQueryKey/); + assert.doesNotMatch(localKeysBlock, /relayAgentsQueryKey/); + + const listenerBlock = refresh.slice( refresh.indexOf('listen("agents-data-changed"'), refresh.indexOf("return () =>"), ); - assert.match(block, /registeredAgentsQueryKey/); - assert.match(block, /personasQueryKey/); - assert.match(block, /teamsQueryKey/); - assert.match(block, /managedAgentsQueryKey/); - assert.match(block, /relayAgentsQueryKey/); - assert.doesNotMatch(block, /managedAgentRuntimesQueryKey/); + assert.match(listenerBlock, /LOCAL_AGENT_DATA_QUERY_KEYS/); + assert.doesNotMatch(listenerBlock, /managedAgentRuntimesQueryKey/); }); diff --git a/desktop/src/features/agents/registeredAgentBoundary.test.mjs b/desktop/src/features/agents/registeredAgentBoundary.test.mjs index 0b10073297f..ca52d89ad22 100644 --- a/desktop/src/features/agents/registeredAgentBoundary.test.mjs +++ b/desktop/src/features/agents/registeredAgentBoundary.test.mjs @@ -5,6 +5,47 @@ import test from "node:test"; const ROOT = new URL("../../..", import.meta.url).pathname; const SRC = join(ROOT, "src"); +const REGISTERED_AGENT_DATA_FILES = new Set([ + "src/features/agents/hooks.ts", + "src/features/agents/hooksRegistered.test.mjs", + "src/features/agents/lib/registeredAgentCards.test.mjs", + "src/features/agents/lib/registeredAgentCards.ts", + "src/features/agents/lib/useAgentsDataRefresh.ts", + "src/features/agents/registeredAgentBoundary.test.mjs", + "src/features/agents/ui/AgentsView.tsx", + "src/features/agents/ui/RegisterExistingAgentDialog.tsx", + "src/features/agents/ui/RegisteredAgentIdentityCard.tsx", + "src/features/agents/ui/RemoveRegisteredAgentDialog.tsx", + "src/features/agents/ui/UnifiedAgentsSection.tsx", + "src/features/agents/ui/UnifiedAgentsSectionCardTarget.test.mjs", + "src/shared/api/registeredAgents.test.mjs", + "src/shared/api/tauriRegisteredAgents.ts", + "src/testing/e2eBridge.ts", +]); +const REGISTERED_AGENT_DISPLAY_FILES = new Set([ + "src/features/agents/lib/registeredAgentCards.ts", + "src/features/agents/ui/RegisterExistingAgentDialog.tsx", + "src/features/agents/ui/RegisteredAgentIdentityCard.tsx", + "src/features/agents/ui/RemoveRegisteredAgentDialog.tsx", + "src/shared/api/tauriRegisteredAgents.ts", +]); + +const REGISTERED_AGENT_DATA_MARKERS = [ + "RegisteredAgentReference", + "listRegisteredAgentReferences", + "registerExistingAgentReference", + "registeredAgentsQueryKey", + "registeredReferences", + "unregisterExistingAgentReference", + "useRegisteredAgentsQuery", +]; +const FORBIDDEN_TRUST_MARKERS = [ + "KnownAgentPubkeys", + "configNudgeAuthPubkey", + "mergeKnownAgentPubkeys", + "mentionableAgentPubkeys", + "useKnownAgentPubkeys", +]; function walk(dir) { return readdirSync(dir).flatMap((entry) => { @@ -15,14 +56,34 @@ function walk(dir) { }); } -test("registered-agent frontend does not import or call managed lifecycle/create APIs", () => { +test("registered-agent data stays inside the reviewed display/navigation integration files", () => { const offenders = []; for (const path of walk(SRC)) { const rel = relative(ROOT, path); - if (!/registeredAgent|RegisteredAgent/.test(rel)) continue; - if (rel.endsWith("registeredAgentBoundary.test.mjs")) continue; - if (rel.endsWith("registeredAgents.test.mjs")) continue; const text = readFileSync(path, "utf8"); + const dataHits = REGISTERED_AGENT_DATA_MARKERS.filter((needle) => + text.includes(needle), + ); + if (dataHits.length > 0 && !REGISTERED_AGENT_DATA_FILES.has(rel)) { + offenders.push( + `${rel}: registered-reference data (${dataHits.join(", ")})`, + ); + continue; + } + if ( + dataHits.length > 0 && + rel !== "src/features/agents/registeredAgentBoundary.test.mjs" + ) { + const trustHits = FORBIDDEN_TRUST_MARKERS.filter((needle) => + text.includes(needle), + ); + if (trustHits.length > 0) { + offenders.push( + `${rel}: registered-reference trust leak (${trustHits.join(", ")})`, + ); + } + } + if (!REGISTERED_AGENT_DISPLAY_FILES.has(rel)) continue; const forbidden = [ "createManagedAgent", "startManagedAgent", @@ -34,6 +95,7 @@ test("registered-agent frontend does not import or call managed lifecycle/create "envVars", "agentCommand", "agent_command", + "pid", ].filter((needle) => text.includes(needle)); if (forbidden.length > 0) offenders.push(`${rel}: ${forbidden.join(", ")}`); } diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSectionCardTarget.test.mjs b/desktop/src/features/agents/ui/UnifiedAgentsSectionCardTarget.test.mjs index 690a921040e..0eff46fc604 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSectionCardTarget.test.mjs +++ b/desktop/src/features/agents/ui/UnifiedAgentsSectionCardTarget.test.mjs @@ -94,6 +94,11 @@ function baseProps(overrides = {}) { onStartAgent: () => {}, onStartPersona: () => {}, personas: [], + registeredReferences: [], + registeredReferencesError: null, + isRegisteredReferencesLoading: false, + isRegisteredReferencePending: false, + onRemoveRegisteredReference: () => {}, personasError: null, personaFeedbackErrorMessage: null, personaFeedbackNoticeMessage: null, diff --git a/desktop/src/features/agents/useKnownAgentPubkeys.tsx b/desktop/src/features/agents/useKnownAgentPubkeys.tsx index 5d2f281ded4..e9fe7b9a9b0 100644 --- a/desktop/src/features/agents/useKnownAgentPubkeys.tsx +++ b/desktop/src/features/agents/useKnownAgentPubkeys.tsx @@ -2,7 +2,6 @@ import * as React from "react"; import { useManagedAgentsQuery, - useRegisteredAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; import { mergeKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys"; @@ -42,17 +41,10 @@ export function KnownAgentPubkeysProvider({ }) { const managedAgents = useManagedAgentsQuery().data; const relayAgents = useRelayAgentsQuery().data; - const registeredAgents = useRegisteredAgentsQuery().data; const merged = React.useMemo( - () => - mergeKnownAgentPubkeys(managedAgents, [ - ...(relayAgents ?? []), - ...(registeredAgents ?? []).map((reference) => ({ - pubkey: reference.pubkey, - })), - ]), - [managedAgents, relayAgents, registeredAgents], + () => mergeKnownAgentPubkeys(managedAgents, relayAgents), + [managedAgents, relayAgents], ); const stable = useStableSet(merged); diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index d6b4bc04e39..fab74bd089e 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -2747,3 +2747,177 @@ test("register existing agent stays keyless and has no lifecycle controls", asyn expect(commands).not.toContain("create_managed_agent"); expect(commands).not.toContain("start_managed_agent"); }); + +test("registered reference opens exact profile and remains display-only", async ({ + page, +}) => { + const pubkey = "b2".repeat(32); + const timestamp = "2026-08-18T00:00:00Z"; + await installMockBridge(page, { + registeredAgents: [ + { + pubkey, + label: "External Finch", + role_summary: "Research", + created_at: timestamp, + updated_at: timestamp, + }, + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + const card = page.getByTestId(`registered-agent-${pubkey}`); + await expect( + card.getByRole("button", { name: new RegExp(pubkey) }), + ).toBeVisible(); + for (const control of [ + "Start", + "Restart", + "Deploy", + "Auto-start", + "Model", + "Runtime error", + "Reveal secret", + ]) { + await expect( + card.getByRole("button", { name: new RegExp(control, "i") }), + ).toHaveCount(0); + } + + await card.click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + const profileCalls = await page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + (call) => call.command === "get_user_profile", + ), + ); + expect( + profileCalls.some( + (call) => + (call.payload as { pubkey?: unknown } | null)?.pubkey === pubkey, + ), + ).toBe(true); +}); + +test("registration rejects invalid and managed collisions without cards and normalizes blanks", async ({ + page, +}) => { + const managedPubkey = "c3".repeat(32); + const validPubkey = "d4".repeat(32); + await installMockBridge(page, { + managedAgents: [ + { + pubkey: managedPubkey, + name: "Managed Collision", + }, + ], + registeredAgents: [], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + for (const [input, error] of [ + ["not-a-pubkey", "invalid public key"], + [managedPubkey, `agent ${managedPubkey} is already a managed agent`], + ] as const) { + await page.getByRole("button", { name: "Register existing agent" }).click(); + const dialog = page.getByRole("dialog", { + name: "Register existing agent", + }); + await dialog.getByTestId("register-existing-agent-pubkey").fill(input); + await dialog.getByRole("button", { name: "Register reference" }).click(); + await expect(dialog).toContainText(error); + await expect(page.getByTestId(`registered-agent-${input}`)).toHaveCount(0); + await dialog.getByRole("button", { name: "Cancel" }).click(); + } + + await page.getByRole("button", { name: "Register existing agent" }).click(); + const dialog = page.getByRole("dialog", { name: "Register existing agent" }); + await dialog.getByTestId("register-existing-agent-pubkey").fill(validPubkey); + await dialog.getByTestId("register-existing-agent-label").fill(" "); + await dialog.getByTestId("register-existing-agent-role-summary").fill(" \t "); + await dialog.getByRole("button", { name: "Register reference" }).click(); + await expect( + page.getByTestId(`registered-agent-${validPubkey}`), + ).toBeVisible(); + const registerInput = await page.evaluate(() => { + const call = (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).findLast( + (entry) => entry.command === "register_existing_agent_reference", + ); + return (call?.payload as { input?: unknown } | null)?.input; + }); + expect(registerInput).toMatchObject({ + pubkey: validPubkey, + label: null, + roleSummary: null, + }); +}); + +test("same-name references stay distinct and removing one only unregisters that reference", async ({ + page, +}) => { + const first = "e5".repeat(32); + const second = "f6".repeat(32); + const timestamp = "2026-08-18T00:00:00Z"; + await installMockBridge(page, { + registeredAgents: [first, second].map((pubkey) => ({ + pubkey, + label: "Same Name", + role_summary: null, + created_at: timestamp, + updated_at: timestamp, + })), + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + for (const pubkey of [first, second]) { + const card = page.getByTestId(`registered-agent-${pubkey}`); + await expect(card).toContainText(pubkey.slice(-4)); + await expect( + card.getByRole("button", { name: new RegExp(pubkey) }), + ).toBeVisible(); + } + + await page.getByTestId(`registered-agent-actions-${first}`).click(); + await page.getByRole("menuitem", { name: "Remove reference" }).click(); + const confirm = page.getByRole("alertdialog"); + await expect(confirm).toContainText( + "removes only the local card and reference", + ); + await confirm.getByRole("button", { name: "Remove reference" }).click(); + await expect(page.getByTestId(`registered-agent-${first}`)).toHaveCount(0); + await expect(page.getByTestId(`registered-agent-${second}`)).toBeVisible(); + + const mutations = await page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter((call) => + ["unregister_existing_agent_reference", "delete_managed_agent"].includes( + call.command, + ), + ), + ); + expect(mutations).toEqual([ + expect.objectContaining({ + command: "unregister_existing_agent_reference", + payload: { pubkey: first }, + }), + ]); +}); + +test("malformed registered-reference store renders error and zero reference cards", async ({ + page, +}) => { + await installMockBridge(page, { + registeredAgentsError: "failed to parse registered agent references", + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + await expect( + page.getByText("failed to parse registered agent references"), + ).toBeVisible(); + await expect(page.locator('[data-testid^="registered-agent-"]')).toHaveCount( + 0, + ); +}); From c76f5768053f5592676935780b510bb25dda4a7d Mon Sep 17 00:00:00 2001 From: Cameron Aragon <69489633+camaragon@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:46:50 +0000 Subject: [PATCH 03/12] fix(agents): handle reference action failures Signed-off-by: Cameron Aragon <69489633+camaragon@users.noreply.github.com> --- desktop/src/features/agents/ui/AgentsView.tsx | 8 +++- .../agents/ui/RegisterExistingAgentDialog.tsx | 9 +++- .../agents/ui/RegisteredAgentIdentityCard.tsx | 7 ++- .../ui/registeredAgentAsyncErrors.test.mjs | 44 +++++++++++++++++++ 4 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 desktop/src/features/agents/ui/registeredAgentAsyncErrors.test.mjs diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index a1b7c2d8876..f02740646dd 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -354,7 +354,10 @@ export function AgentsView() { : null } isPending={registerReferenceMutation.isPending} - onOpenChange={setIsRegisterExistingOpen} + onOpenChange={(open) => { + setIsRegisterExistingOpen(open); + if (!open) registerReferenceMutation.reset(); + }} onSubmit={(input) => registerReferenceMutation.mutateAsync(input)} open={isRegisterExistingOpen} /> @@ -363,7 +366,8 @@ export function AgentsView() { onConfirm={(reference) => { void unregisterReferenceMutation .mutateAsync(reference) - .then(() => setReferenceToRemove(null)); + .then(() => setReferenceToRemove(null)) + .catch(() => undefined); }} onOpenChange={(open) => { if (!open) setReferenceToRemove(null); diff --git a/desktop/src/features/agents/ui/RegisterExistingAgentDialog.tsx b/desktop/src/features/agents/ui/RegisterExistingAgentDialog.tsx index bc040fddf32..589bcd78664 100644 --- a/desktop/src/features/agents/ui/RegisterExistingAgentDialog.tsx +++ b/desktop/src/features/agents/ui/RegisterExistingAgentDialog.tsx @@ -46,8 +46,13 @@ export function RegisterExistingAgentDialog({ async function submit(event: React.FormEvent) { event.preventDefault(); - await onSubmit({ pubkey, label, roleSummary }); - onOpenChange(false); + try { + await onSubmit({ pubkey, label, roleSummary }); + onOpenChange(false); + } catch { + // The mutation owns the error state rendered above. Keep the dialog open + // and consume the rejection at this UI event boundary. + } } return ( diff --git a/desktop/src/features/agents/ui/RegisteredAgentIdentityCard.tsx b/desktop/src/features/agents/ui/RegisteredAgentIdentityCard.tsx index d806c58c6bd..3d55251c2ee 100644 --- a/desktop/src/features/agents/ui/RegisteredAgentIdentityCard.tsx +++ b/desktop/src/features/agents/ui/RegisteredAgentIdentityCard.tsx @@ -35,7 +35,12 @@ export function RegisteredAgentIdentityCard({ const summary = registeredAgentRoleSummary(reference.roleSummary); async function copyPubkey() { - await navigator.clipboard?.writeText(reference.pubkey); + try { + await navigator.clipboard?.writeText(reference.pubkey); + } catch { + // Clipboard availability is best-effort; never leak a rejected browser + // permission promise from a menu event. + } } return ( diff --git a/desktop/src/features/agents/ui/registeredAgentAsyncErrors.test.mjs b/desktop/src/features/agents/ui/registeredAgentAsyncErrors.test.mjs new file mode 100644 index 00000000000..7bf72eb2a73 --- /dev/null +++ b/desktop/src/features/agents/ui/registeredAgentAsyncErrors.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const registerDialog = readFileSync( + new URL("./RegisterExistingAgentDialog.tsx", import.meta.url), + "utf8", +); +const registeredCard = readFileSync( + new URL("./RegisteredAgentIdentityCard.tsx", import.meta.url), + "utf8", +); +const agentsView = readFileSync( + new URL("./AgentsView.tsx", import.meta.url), + "utf8", +); + +test("registration rejection stays handled and leaves the dialog open", () => { + assert.match( + registerDialog, + /try\s*\{[\s\S]*await onSubmit\([\s\S]*onOpenChange\(false\)[\s\S]*\}\s*catch\s*\{/, + ); +}); + +test("clipboard rejection is handled inside the registered-reference card", () => { + assert.match( + registeredCard, + /try\s*\{[\s\S]*await navigator\.clipboard\?\.writeText\([\s\S]*\}\s*catch\s*\{/, + ); +}); + +test("closing registration resets stale mutation errors", () => { + assert.match( + agentsView, + /onOpenChange=\{\(open\) => \{[\s\S]*setIsRegisterExistingOpen\(open\)[\s\S]*if \(!open\) registerReferenceMutation\.reset\(\)/, + ); +}); + +test("unregister rejection is consumed instead of escaping the UI event", () => { + assert.match( + agentsView, + /unregisterReferenceMutation[\s\S]*\.mutateAsync\(reference\)[\s\S]*\.then\([\s\S]*\.catch\(/, + ); +}); From 36b2acc8d069a57f27112229064230353f34da8c Mon Sep 17 00:00:00 2001 From: Cameron Aragon <69489633+camaragon@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:51:58 +0000 Subject: [PATCH 04/12] test(agents): scan registered reference mjs consumers Signed-off-by: Cameron Aragon <69489633+camaragon@users.noreply.github.com> --- desktop/scripts/check-registered-agent-boundary.mjs | 9 +++++++-- .../features/agents/registeredAgentBoundary.test.mjs | 12 ++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/desktop/scripts/check-registered-agent-boundary.mjs b/desktop/scripts/check-registered-agent-boundary.mjs index db331b667ee..cdc57e3132d 100755 --- a/desktop/scripts/check-registered-agent-boundary.mjs +++ b/desktop/scripts/check-registered-agent-boundary.mjs @@ -65,8 +65,9 @@ const forbiddenInRegisteredAgentFiles = [ function walk(dir) { return readdirSync(dir).flatMap((entry) => { const path = join(dir, entry); + if (entry === "node_modules" || entry === "dist") return []; if (statSync(path).isDirectory()) return walk(path); - return /\.(ts|tsx)$/.test(path) ? [path] : []; + return /\.(ts|tsx|mjs)$/.test(path) ? [path] : []; }); } @@ -82,7 +83,11 @@ for (const path of walk(src)) { `${rel}: registered-reference data (${dataHits.join(", ")})`, ); } - if (dataHits.length > 0 && registeredAgentDataFiles.has(rel)) { + if ( + dataHits.length > 0 && + registeredAgentDataFiles.has(rel) && + rel !== "src/features/agents/registeredAgentBoundary.test.mjs" + ) { const trustHits = forbiddenTrustMarkers.filter((needle) => text.includes(needle), ); diff --git a/desktop/src/features/agents/registeredAgentBoundary.test.mjs b/desktop/src/features/agents/registeredAgentBoundary.test.mjs index ca52d89ad22..c9cdfdfb98d 100644 --- a/desktop/src/features/agents/registeredAgentBoundary.test.mjs +++ b/desktop/src/features/agents/registeredAgentBoundary.test.mjs @@ -5,6 +5,10 @@ import test from "node:test"; const ROOT = new URL("../../..", import.meta.url).pathname; const SRC = join(ROOT, "src"); +const boundaryScript = readFileSync( + join(ROOT, "scripts/check-registered-agent-boundary.mjs"), + "utf8", +); const REGISTERED_AGENT_DATA_FILES = new Set([ "src/features/agents/hooks.ts", "src/features/agents/hooksRegistered.test.mjs", @@ -101,3 +105,11 @@ test("registered-agent data stays inside the reviewed display/navigation integra } assert.deepEqual(offenders, []); }); + +test("standalone boundary scan includes mjs consumers and skips generated trees", () => { + assert.match( + boundaryScript, + /entry === "node_modules" \|\| entry === "dist"/, + ); + assert.match(boundaryScript, /\\\.\(ts\|tsx\|mjs\)\$/); +}); From 696827708fe5e362ff62626a182ec649bca5a9f3 Mon Sep 17 00:00:00 2001 From: Cameron Aragon <69489633+camaragon@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:59:51 +0000 Subject: [PATCH 05/12] fix(agents): reject duplicate registered references Signed-off-by: Cameron Aragon <69489633+camaragon@users.noreply.github.com> --- .../managed_agents/registered_references.rs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/desktop/src-tauri/src/managed_agents/registered_references.rs b/desktop/src-tauri/src/managed_agents/registered_references.rs index c8e94df30ce..e08a9a9bd82 100644 --- a/desktop/src-tauri/src/managed_agents/registered_references.rs +++ b/desktop/src-tauri/src/managed_agents/registered_references.rs @@ -84,6 +84,16 @@ fn load_from_path(path: &Path) -> Result, String> format!("failed to parse registered agent references (preserved as .invalid): {error}") })?; refs.sort_by(|left, right| left.pubkey.cmp(&right.pubkey)); + if let Some(duplicate) = refs + .windows(2) + .find(|pair| pair[0].pubkey == pair[1].pubkey) + { + backup_invalid_store(path); + return Err(format!( + "duplicate registered agent pubkey {} (preserved as .invalid)", + duplicate[0].pubkey + )); + } Ok(refs) } @@ -298,6 +308,39 @@ mod tests { ); } + #[test] + fn duplicate_pubkeys_fail_closed_and_preserve_invalid_bytes() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(STORE_FILENAME); + let refs = vec![ + RegisteredAgentReference { + pubkey: PUBKEY_A.to_string(), + label: Some("first".to_string()), + role_summary: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + }, + RegisteredAgentReference { + pubkey: PUBKEY_A.to_string(), + label: Some("duplicate".to_string()), + role_summary: None, + created_at: "2026-01-02T00:00:00Z".to_string(), + updated_at: "2026-01-02T00:00:00Z".to_string(), + }, + ]; + let bytes = serde_json::to_vec_pretty(&refs).unwrap(); + fs::write(&path, &bytes).unwrap(); + + let error = load_from_path(&path).unwrap_err(); + + assert!(error.contains("duplicate registered agent pubkey")); + assert_eq!(fs::read(&path).unwrap(), bytes); + assert_eq!( + fs::read(path.with_extension("json.invalid")).unwrap(), + bytes + ); + } + #[test] fn update_preserves_created_at_and_replaces_optionals() { let temp = tempfile::tempdir().unwrap(); From 351ae004639188476a88dc454500ea1271059b2c Mon Sep 17 00:00:00 2001 From: Cameron Aragon <69489633+camaragon@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:10:42 +0000 Subject: [PATCH 06/12] fix(agents): surface registered reference errors Signed-off-by: Cameron Aragon <69489633+camaragon@users.noreply.github.com> --- .../src/managed_agents/registered_references.rs | 4 ++-- desktop/src/features/agents/ui/AgentsView.tsx | 9 ++++++++- .../agents/ui/registeredAgentAsyncErrors.test.mjs | 4 ++++ desktop/src/shared/api/registeredAgents.test.mjs | 2 +- desktop/src/shared/api/tauriRegisteredAgents.ts | 2 +- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/registered_references.rs b/desktop/src-tauri/src/managed_agents/registered_references.rs index e08a9a9bd82..3ea930d9ccc 100644 --- a/desktop/src-tauri/src/managed_agents/registered_references.rs +++ b/desktop/src-tauri/src/managed_agents/registered_references.rs @@ -49,7 +49,7 @@ fn store_path(app: &AppHandle) -> Result { fn normalize_pubkey(input: &str) -> Result { let trimmed = input.trim(); if trimmed.len() != 64 || !trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Err("agent pubkey must be exactly 64 ASCII hex characters".to_string()); + return Err("invalid public key".to_string()); } Ok(trimmed.to_ascii_lowercase()) } @@ -242,7 +242,7 @@ mod tests { #[test] fn rejects_non_exact_ascii_hex_pubkeys_and_overlong_fields() { - assert!(normalize_pubkey("abc").is_err()); + assert_eq!(normalize_pubkey("abc").unwrap_err(), "invalid public key"); assert!(normalize_pubkey( "g000000000000000000000000000000000000000000000000000000000000000" ) diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index f02740646dd..9a0e0eb3100 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -364,10 +364,17 @@ export function AgentsView() { { + agents.setActionErrorMessage(null); void unregisterReferenceMutation .mutateAsync(reference) .then(() => setReferenceToRemove(null)) - .catch(() => undefined); + .catch((error) => { + agents.setActionErrorMessage( + error instanceof Error + ? error.message + : "Couldn't remove registered agent reference.", + ); + }); }} onOpenChange={(open) => { if (!open) setReferenceToRemove(null); diff --git a/desktop/src/features/agents/ui/registeredAgentAsyncErrors.test.mjs b/desktop/src/features/agents/ui/registeredAgentAsyncErrors.test.mjs index 7bf72eb2a73..7032ed1b72a 100644 --- a/desktop/src/features/agents/ui/registeredAgentAsyncErrors.test.mjs +++ b/desktop/src/features/agents/ui/registeredAgentAsyncErrors.test.mjs @@ -41,4 +41,8 @@ test("unregister rejection is consumed instead of escaping the UI event", () => agentsView, /unregisterReferenceMutation[\s\S]*\.mutateAsync\(reference\)[\s\S]*\.then\([\s\S]*\.catch\(/, ); + assert.match( + agentsView, + /\.catch\(\(error\) =>[\s\S]*agents\.setActionErrorMessage\(/, + ); }); diff --git a/desktop/src/shared/api/registeredAgents.test.mjs b/desktop/src/shared/api/registeredAgents.test.mjs index e8e7666d830..b68b7e5ed90 100644 --- a/desktop/src/shared/api/registeredAgents.test.mjs +++ b/desktop/src/shared/api/registeredAgents.test.mjs @@ -58,6 +58,6 @@ test("malformed registered agent store entries fail visibly instead of being fil created_at: "2026-08-18T12:00:00Z", updated_at: "2026-08-18T12:30:00Z", }), - /registered agent pubkey/i, + /invalid public key/i, ); }); diff --git a/desktop/src/shared/api/tauriRegisteredAgents.ts b/desktop/src/shared/api/tauriRegisteredAgents.ts index 00d7a42f7a4..499a02364ea 100644 --- a/desktop/src/shared/api/tauriRegisteredAgents.ts +++ b/desktop/src/shared/api/tauriRegisteredAgents.ts @@ -50,7 +50,7 @@ function nullableString(value: unknown, field: string): string | null { function normalizeRegisteredPubkey(value: unknown): string { const pubkey = normalizePubkey(requiredString(value, "pubkey")); if (!HEX_PUBKEY_RE.test(pubkey)) { - throw new Error("Malformed registered agent pubkey."); + throw new Error("invalid public key"); } return pubkey; } From 3e1aea0337c6c9d5832fca112c3c241f7cf15620 Mon Sep 17 00:00:00 2001 From: Cameron Aragon <69489633+camaragon@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:47:14 +0000 Subject: [PATCH 07/12] fix(agents): hide stale registered references on error Signed-off-by: Cameron Aragon <69489633+camaragon@users.noreply.github.com> --- .../agents/lib/registeredAgentCards.test.mjs | 17 +++++++++++++++++ .../features/agents/lib/registeredAgentCards.ts | 9 +++++++++ .../features/agents/ui/UnifiedAgentsSection.tsx | 11 ++++++++--- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/agents/lib/registeredAgentCards.test.mjs b/desktop/src/features/agents/lib/registeredAgentCards.test.mjs index 5ca078c0b94..a91c7c29870 100644 --- a/desktop/src/features/agents/lib/registeredAgentCards.test.mjs +++ b/desktop/src/features/agents/lib/registeredAgentCards.test.mjs @@ -5,6 +5,7 @@ import { dedupeRegisteredAgentsAgainstManaged, resolveRegisteredAgentDisplay, registeredAgentRoleSummary, + visibleRegisteredAgentReferences, } from "./registeredAgentCards.ts"; const PUBKEY = "a1".repeat(32); @@ -59,3 +60,19 @@ test("registered references are deduped against managed pubkeys", () => { [reference(OTHER, "visible")], ); }); + +test("registered-reference errors fail closed over stale cached data", () => { + const staleReferences = [reference(OTHER, "stale")]; + assert.deepEqual( + visibleRegisteredAgentReferences( + staleReferences, + [], + new Error("reference store invalid"), + ), + [], + ); + assert.deepEqual( + visibleRegisteredAgentReferences(staleReferences, [], null), + staleReferences, + ); +}); diff --git a/desktop/src/features/agents/lib/registeredAgentCards.ts b/desktop/src/features/agents/lib/registeredAgentCards.ts index d91592262ba..44459a2bfd9 100644 --- a/desktop/src/features/agents/lib/registeredAgentCards.ts +++ b/desktop/src/features/agents/lib/registeredAgentCards.ts @@ -44,3 +44,12 @@ export function dedupeRegisteredAgentsAgainstManaged( (reference) => !managedPubkeys.has(normalizePubkey(reference.pubkey)), ); } + +export function visibleRegisteredAgentReferences( + references: readonly RegisteredAgentReference[], + managed: readonly T[], + error: Error | null, +): RegisteredAgentReference[] { + if (error) return []; + return dedupeRegisteredAgentsAgainstManaged(references, managed); +} diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index c9b3efa5a23..2ddab30f3ea 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -22,7 +22,7 @@ import { AgentRuntimeAvatarControl } from "./AgentRuntimeAvatarControl"; import { CreateIdentityCard } from "./CreateIdentityCard"; import { PersonaActionsMenu } from "./PersonaActionsMenu"; import { RegisteredAgentIdentityCard } from "./RegisteredAgentIdentityCard"; -import { dedupeRegisteredAgentsAgainstManaged } from "../lib/registeredAgentCards"; +import { visibleRegisteredAgentReferences } from "../lib/registeredAgentCards"; import { buildUnifiedGroups } from "./unifiedAgentGroups"; type UnifiedAgentsSectionProps = { @@ -128,8 +128,13 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { const isLoading = isAgentsLoading || isPersonasLoading || isRegisteredReferencesLoading; const visibleRegisteredReferences = React.useMemo( - () => dedupeRegisteredAgentsAgainstManaged(registeredReferences, agents), - [registeredReferences, agents], + () => + visibleRegisteredAgentReferences( + registeredReferences, + agents, + registeredReferencesError, + ), + [registeredReferences, agents, registeredReferencesError], ); return ( From c2ff116da357a5481a0f7f0b088acc5c34e2c664 Mon Sep 17 00:00:00 2001 From: Cameron Aragon <69489633+camaragon@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:58:53 +0000 Subject: [PATCH 08/12] fix(agents): minimize ownership preflight data Signed-off-by: Cameron Aragon <69489633+camaragon@users.noreply.github.com> --- .../src-tauri/src/managed_agents/storage.rs | 22 +++++++++++----- .../src/managed_agents/storage_tests.rs | 26 +++++++++++++++++-- .../agents/lib/registeredAgentCards.test.mjs | 7 +++++ .../agents/lib/registeredAgentCards.ts | 9 +++++++ .../agents/ui/RegisteredAgentIdentityCard.tsx | 3 ++- 5 files changed, 58 insertions(+), 9 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index daa8252c6ff..f03084d3013 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -262,9 +262,18 @@ pub(crate) fn managed_agent_record_exists(app: &AppHandle, pubkey: &str) -> Resu managed_agent_record_exists_at_path(&managed_agents_store_path(app)?, pubkey) } +/// Minimal projection for ownership preflight. Unknown fields—including any +/// inline private key fallback—are skipped by Serde instead of materialized in +/// a `ManagedAgentRecord`. +#[derive(serde::Deserialize)] +struct ManagedAgentOwnershipRecord { + #[serde(default)] + pubkey: String, +} + /// Path-based ownership lookup used by the command-boundary regression tests. -/// It intentionally shares the raw, non-hydrating read used in production so -/// proving a rejected target cannot touch the keyring does not itself touch it. +/// It intentionally reads only the public-key projection used in production so +/// proving a rejected target cannot touch the keyring or materialize secrets. pub(crate) fn managed_agent_record_exists_at_path( path: &Path, pubkey: &str, @@ -274,10 +283,11 @@ pub(crate) fn managed_agent_record_exists_at_path( } let content = fs::read_to_string(path).map_err(|error| format!("failed to read agent store: {error}"))?; - let records: Vec = serde_json::from_str(&content).map_err(|error| { - backup_invalid_store(path); - format!("failed to parse agent store (preserved as .invalid): {error}") - })?; + let records: Vec = + serde_json::from_str(&content).map_err(|error| { + backup_invalid_store(path); + format!("failed to parse agent store (preserved as .invalid): {error}") + })?; Ok(records .iter() .any(|record| !record.pubkey.is_empty() && record.pubkey == pubkey)) diff --git a/desktop/src-tauri/src/managed_agents/storage_tests.rs b/desktop/src-tauri/src/managed_agents/storage_tests.rs index 9943c6b3ac3..bcce2eb2236 100644 --- a/desktop/src-tauri/src/managed_agents/storage_tests.rs +++ b/desktop/src-tauri/src/managed_agents/storage_tests.rs @@ -12,8 +12,8 @@ use std::path::Path; use tempfile::NamedTempFile; use super::{ - agent_keyring_name, hydrate_keys_with, migrate_inline_key, persist_agent_keys_with, - KeyMigration, KeyStore, KeyringProbe, ManagedAgentRecord, + agent_keyring_name, hydrate_keys_with, managed_agent_record_exists_at_path, migrate_inline_key, + persist_agent_keys_with, KeyMigration, KeyStore, KeyringProbe, ManagedAgentRecord, }; /// In-memory [`KeyStore`] for testing the migrate decision without the OS @@ -143,6 +143,28 @@ fn record_with_pubkey_and_key(pubkey: &str, nsec: &str) -> ManagedAgentRecord { .expect("sample record") } +#[test] +fn ownership_lookup_deserializes_only_pubkey() { + let mut file = NamedTempFile::new().expect("temp agent store"); + file.write_all( + br#"[{"pubkey":"target-pubkey","private_key_nsec":{"must":"stay ignored"},"future_schema":{"nested":[1,2,3]}}]"#, + ) + .expect("write agent store"); + + assert!( + serde_json::from_str::>( + &std::fs::read_to_string(file.path()).expect("read fixture") + ) + .is_err(), + "fixture must fail full-record deserialization" + ); + assert_eq!( + managed_agent_record_exists_at_path(file.path(), "target-pubkey"), + Ok(true), + "ownership preflight must ignore secret and unrelated record fields" + ); +} + #[test] fn migrate_persists_and_signals_stripping_when_keyring_reachable() { // Item 2: an inline key (residue from a prior keyring-unreachable save) diff --git a/desktop/src/features/agents/lib/registeredAgentCards.test.mjs b/desktop/src/features/agents/lib/registeredAgentCards.test.mjs index a91c7c29870..3144d7878a4 100644 --- a/desktop/src/features/agents/lib/registeredAgentCards.test.mjs +++ b/desktop/src/features/agents/lib/registeredAgentCards.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { dedupeRegisteredAgentsAgainstManaged, + registeredAgentAriaLabel, resolveRegisteredAgentDisplay, registeredAgentRoleSummary, visibleRegisteredAgentReferences, @@ -50,6 +51,12 @@ test("role summary is always an externally managed suffix", () => { assert.equal(registeredAgentRoleSummary(null), "Externally managed"); }); +test("accessible card label distinguishes the key without reading all 64 characters", () => { + const label = registeredAgentAriaLabel("External", PUBKEY); + assert.match(label, new RegExp(`${PUBKEY.slice(0, 8)}.*${PUBKEY.slice(-4)}`)); + assert.equal(label.includes(PUBKEY), false); +}); + test("registered references are deduped against managed pubkeys", () => { const references = [ reference(PUBKEY.toUpperCase(), "external"), diff --git a/desktop/src/features/agents/lib/registeredAgentCards.ts b/desktop/src/features/agents/lib/registeredAgentCards.ts index 44459a2bfd9..1c7b049c132 100644 --- a/desktop/src/features/agents/lib/registeredAgentCards.ts +++ b/desktop/src/features/agents/lib/registeredAgentCards.ts @@ -15,6 +15,15 @@ export function registeredAgentRoleSummary(roleSummary: string | null): string { : "Externally managed"; } +export function registeredAgentAriaLabel( + label: string, + pubkey: string, +): string { + return `${label} externally managed agent profile, public key ${truncatePubkey( + normalizePubkey(pubkey), + )}`; +} + export function resolveRegisteredAgentDisplay({ reference, profile, diff --git a/desktop/src/features/agents/ui/RegisteredAgentIdentityCard.tsx b/desktop/src/features/agents/ui/RegisteredAgentIdentityCard.tsx index 3d55251c2ee..6c7ee943c6e 100644 --- a/desktop/src/features/agents/ui/RegisteredAgentIdentityCard.tsx +++ b/desktop/src/features/agents/ui/RegisteredAgentIdentityCard.tsx @@ -1,6 +1,7 @@ import { Copy, EllipsisVertical, Trash2 } from "lucide-react"; import { + registeredAgentAriaLabel, registeredAgentRoleSummary, resolveRegisteredAgentDisplay, } from "@/features/agents/lib/registeredAgentCards"; @@ -79,7 +80,7 @@ export function RegisteredAgentIdentityCard({ } - ariaLabel={`${display.label} externally managed agent profile, public key ${reference.pubkey}`} + ariaLabel={registeredAgentAriaLabel(display.label, reference.pubkey)} avatarUrl={display.avatarUrl} dataTestId={`registered-agent-${reference.pubkey}`} label={display.label} From 7c791f73e8c1a12e97775fa117ae677fc2c22975 Mon Sep 17 00:00:00 2001 From: Cameron Aragon <69489633+camaragon@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:12:30 +0000 Subject: [PATCH 09/12] test(agents): match registered reference ordering Signed-off-by: Cameron Aragon <69489633+camaragon@users.noreply.github.com> --- .../src/features/agents/registeredAgentBoundary.test.mjs | 8 ++++++++ desktop/src/testing/e2eBridge.ts | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/agents/registeredAgentBoundary.test.mjs b/desktop/src/features/agents/registeredAgentBoundary.test.mjs index c9cdfdfb98d..9d66337e2ca 100644 --- a/desktop/src/features/agents/registeredAgentBoundary.test.mjs +++ b/desktop/src/features/agents/registeredAgentBoundary.test.mjs @@ -9,6 +9,7 @@ const boundaryScript = readFileSync( join(ROOT, "scripts/check-registered-agent-boundary.mjs"), "utf8", ); +const e2eBridgeSource = readFileSync(join(SRC, "testing/e2eBridge.ts"), "utf8"); const REGISTERED_AGENT_DATA_FILES = new Set([ "src/features/agents/hooks.ts", "src/features/agents/hooksRegistered.test.mjs", @@ -113,3 +114,10 @@ test("standalone boundary scan includes mjs consumers and skips generated trees" ); assert.match(boundaryScript, /\\\.\(ts\|tsx\|mjs\)\$/); }); + +test("E2E registered-reference listing preserves production pubkey ordering", () => { + assert.match( + e2eBridgeSource, + /case "list_registered_agent_references":[\s\S]*structuredClone\(mockRegisteredAgents\)\.sort\(\(left, right\) =>[\s\S]*left\.pubkey\.localeCompare\(right\.pubkey\)/, + ); +}); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 0ea53cc2e59..632f3b6d192 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -12741,7 +12741,9 @@ export function maybeInstallE2eTauriMocks() { if (activeConfig?.mock?.registeredAgentsError) { throw new Error(activeConfig.mock.registeredAgentsError); } - return structuredClone(mockRegisteredAgents); + return structuredClone(mockRegisteredAgents).sort((left, right) => + left.pubkey.localeCompare(right.pubkey), + ); case "register_existing_agent_reference": { const input = (payload as { input?: Record } | null) ?.input; From 93f0c6029beac3b1601b16065001e7ee2bca5d81 Mon Sep 17 00:00:00 2001 From: Cameron Aragon <69489633+camaragon@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:22:35 +0000 Subject: [PATCH 10/12] fix(agents): validate registered reference store Signed-off-by: Cameron Aragon <69489633+camaragon@users.noreply.github.com> --- .../managed_agents/registered_references.rs | 75 +++++++++++++++++++ desktop/tests/e2e/agents.spec.ts | 6 +- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/registered_references.rs b/desktop/src-tauri/src/managed_agents/registered_references.rs index 3ea930d9ccc..4a99a2eaa66 100644 --- a/desktop/src-tauri/src/managed_agents/registered_references.rs +++ b/desktop/src-tauri/src/managed_agents/registered_references.rs @@ -72,6 +72,26 @@ fn normalize_optional( Ok(Some(trimmed.to_string())) } +fn validate_loaded_reference(reference: &RegisteredAgentReference) -> Result<(), String> { + let pubkey = normalize_pubkey(&reference.pubkey)?; + if pubkey != reference.pubkey { + return Err("stored pubkey is not canonical lowercase hex".to_string()); + } + let label = normalize_optional(reference.label.clone(), LABEL_LIMIT_BYTES, "label")?; + if label != reference.label { + return Err("stored label is not canonical".to_string()); + } + let role_summary = normalize_optional( + reference.role_summary.clone(), + ROLE_SUMMARY_LIMIT_BYTES, + "roleSummary", + )?; + if role_summary != reference.role_summary { + return Err("stored role summary is not canonical".to_string()); + } + Ok(()) +} + fn load_from_path(path: &Path) -> Result, String> { if !path.exists() { return Ok(Vec::new()); @@ -83,6 +103,14 @@ fn load_from_path(path: &Path) -> Result, String> backup_invalid_store(path); format!("failed to parse registered agent references (preserved as .invalid): {error}") })?; + for reference in &refs { + if let Err(error) = validate_loaded_reference(reference) { + backup_invalid_store(path); + return Err(format!( + "invalid registered agent reference (preserved as .invalid): {error}" + )); + } + } refs.sort_by(|left, right| left.pubkey.cmp(&right.pubkey)); if let Some(duplicate) = refs .windows(2) @@ -341,6 +369,53 @@ mod tests { ); } + #[test] + fn noncanonical_store_records_fail_closed_and_preserve_invalid_bytes() { + let cases = [ + ("invalid pubkey", "not-a-key".to_string(), None, None), + ("mixed-case pubkey", PUBKEY_A_UPPER.to_string(), None, None), + ( + "whitespace label", + PUBKEY_A.to_string(), + Some(" padded ".to_string()), + None, + ), + ( + "overlong role summary", + PUBKEY_A.to_string(), + None, + Some("x".repeat(ROLE_SUMMARY_LIMIT_BYTES + 1)), + ), + ]; + + for (name, pubkey, label, role_summary) in cases { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join(STORE_FILENAME); + let refs = [RegisteredAgentReference { + pubkey, + label, + role_summary, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + }]; + let bytes = serde_json::to_vec_pretty(&refs).unwrap(); + fs::write(&path, &bytes).unwrap(); + + let error = load_from_path(&path).unwrap_err(); + + assert!( + error.contains("invalid registered agent reference"), + "{name}" + ); + assert_eq!(fs::read(&path).unwrap(), bytes, "{name}"); + assert_eq!( + fs::read(path.with_extension("json.invalid")).unwrap(), + bytes, + "{name}" + ); + } + } + #[test] fn update_preserves_created_at_and_replaces_optionals() { let temp = tempfile::tempdir().unwrap(); diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index fab74bd089e..21c17dfa723 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -2768,8 +2768,9 @@ test("registered reference opens exact profile and remains display-only", async await page.getByTestId("open-agents-view").click(); const card = page.getByTestId(`registered-agent-${pubkey}`); + const truncatedPubkey = `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`; await expect( - card.getByRole("button", { name: new RegExp(pubkey) }), + card.getByRole("button", { name: new RegExp(truncatedPubkey) }), ).toBeVisible(); for (const control of [ "Start", @@ -2875,8 +2876,9 @@ test("same-name references stay distinct and removing one only unregisters that for (const pubkey of [first, second]) { const card = page.getByTestId(`registered-agent-${pubkey}`); await expect(card).toContainText(pubkey.slice(-4)); + const truncatedPubkey = `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`; await expect( - card.getByRole("button", { name: new RegExp(pubkey) }), + card.getByRole("button", { name: new RegExp(truncatedPubkey) }), ).toBeVisible(); } From 6b723b3cade5ec485ad1fb589c72591755a1f0e2 Mon Sep 17 00:00:00 2001 From: Cameron Aragon <69489633+camaragon@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:26:41 +0000 Subject: [PATCH 11/12] test(agents): resolve boundary paths portably Signed-off-by: Cameron Aragon <69489633+camaragon@users.noreply.github.com> --- desktop/src/features/agents/registeredAgentBoundary.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/agents/registeredAgentBoundary.test.mjs b/desktop/src/features/agents/registeredAgentBoundary.test.mjs index 9d66337e2ca..8113fc9afee 100644 --- a/desktop/src/features/agents/registeredAgentBoundary.test.mjs +++ b/desktop/src/features/agents/registeredAgentBoundary.test.mjs @@ -1,9 +1,10 @@ import assert from "node:assert/strict"; import { readFileSync, readdirSync, statSync } from "node:fs"; import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; import test from "node:test"; -const ROOT = new URL("../../..", import.meta.url).pathname; +const ROOT = fileURLToPath(new URL("../../..", import.meta.url)); const SRC = join(ROOT, "src"); const boundaryScript = readFileSync( join(ROOT, "scripts/check-registered-agent-boundary.mjs"), From c19facaafbaa0b3d307c3152641e6b1068ba75cc Mon Sep 17 00:00:00 2001 From: Cameron Aragon <69489633+camaragon@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:30:41 +0000 Subject: [PATCH 12/12] docs(agents): correct registered key length Signed-off-by: Cameron Aragon <69489633+camaragon@users.noreply.github.com> --- desktop/src-tauri/src/managed_agents/registered_references.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/managed_agents/registered_references.rs b/desktop/src-tauri/src/managed_agents/registered_references.rs index 4a99a2eaa66..5511cea9ef8 100644 --- a/desktop/src-tauri/src/managed_agents/registered_references.rs +++ b/desktop/src-tauri/src/managed_agents/registered_references.rs @@ -14,7 +14,7 @@ const ROLE_SUMMARY_LIMIT_BYTES: usize = 240; /// A keyless reference to an already-existing agent identity. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RegisteredAgentReference { - /// The referenced agent public key as normalized 64-byte lowercase hex. + /// The referenced agent public key as normalized 64-character lowercase hex. pub pubkey: String, /// Optional user-facing label for the reference. #[serde(skip_serializing_if = "Option::is_none")]