diff --git a/desktop/scripts/check-registered-agent-boundary.mjs b/desktop/scripts/check-registered-agent-boundary.mjs new file mode 100755 index 00000000000..cdc57e3132d --- /dev/null +++ b/desktop/scripts/check-registered-agent-boundary.mjs @@ -0,0 +1,114 @@ +#!/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"); +// 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", + "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 (entry === "node_modules" || entry === "dist") return []; + if (statSync(path).isDirectory()) return walk(path); + return /\.(ts|tsx|mjs)$/.test(path) ? [path] : []; + }); +} + +const offenders = []; +for (const path of walk(src)) { + const rel = relative(root, path); + const text = readFileSync(path, "utf8"); + 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) && + rel !== "src/features/agents/registeredAgentBoundary.test.mjs" + ) { + 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) { + 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..15ad006e128 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_impl(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_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 new file mode 100644 index 00000000000..2fb99092d3e --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_registered_targets.rs @@ -0,0 +1,60 @@ +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, + 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, + expected_relay_url, + expected_signer_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..5511cea9ef8 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/registered_references.rs @@ -0,0 +1,719 @@ +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-character 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("invalid public key".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 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()); + } + 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}") + })?; + 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) + .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) +} + +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()); + 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")) + } +} + +#[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"; + 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, + 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_eq!(normalize_pubkey("abc").unwrap_err(), "invalid public key"); + 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 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 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(); + 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) + } + + /// 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/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..f03084d3013 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -256,6 +256,43 @@ 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 { + 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 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, +) -> 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)) +} + /// 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-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/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..9d3f6afe830 --- /dev/null +++ b/desktop/src/features/agents/hooksRegistered.test.mjs @@ -0,0 +1,43 @@ +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 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(listenerBlock, /LOCAL_AGENT_DATA_QUERY_KEYS/); + assert.doesNotMatch(listenerBlock, /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..3144d7878a4 --- /dev/null +++ b/desktop/src/features/agents/lib/registeredAgentCards.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + dedupeRegisteredAgentsAgainstManaged, + registeredAgentAriaLabel, + resolveRegisteredAgentDisplay, + registeredAgentRoleSummary, + visibleRegisteredAgentReferences, +} 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("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"), + reference(OTHER, "visible"), + ]; + assert.deepEqual( + dedupeRegisteredAgentsAgainstManaged(references, [{ pubkey: PUBKEY }]), + [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 new file mode 100644 index 00000000000..1c7b049c132 --- /dev/null +++ b/desktop/src/features/agents/lib/registeredAgentCards.ts @@ -0,0 +1,64 @@ +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 registeredAgentAriaLabel( + label: string, + pubkey: string, +): string { + return `${label} externally managed agent profile, public key ${truncatePubkey( + normalizePubkey(pubkey), + )}`; +} + +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)), + ); +} + +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/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..8113fc9afee --- /dev/null +++ b/desktop/src/features/agents/registeredAgentBoundary.test.mjs @@ -0,0 +1,124 @@ +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 = fileURLToPath(new URL("../../..", import.meta.url)); +const SRC = join(ROOT, "src"); +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", + "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) => { + 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 data stays inside the reviewed display/navigation integration files", () => { + const offenders = []; + for (const path of walk(SRC)) { + const rel = relative(ROOT, path); + 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", + "stopManagedAgent", + "deleteManagedAgent", + "managedAgentRuntime", + "privateKeyNsec", + "private_key_nsec", + "envVars", + "agentCommand", + "agent_command", + "pid", + ].filter((needle) => text.includes(needle)); + if (forbidden.length > 0) offenders.push(`${rel}: ${forbidden.join(", ")}`); + } + 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\)\$/); +}); + +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/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index e1e1f37f35f..9a0e0eb3100 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..6c7ee943c6e --- /dev/null +++ b/desktop/src/features/agents/ui/RegisteredAgentIdentityCard.tsx @@ -0,0 +1,96 @@ +import { Copy, EllipsisVertical, Trash2 } from "lucide-react"; + +import { + registeredAgentAriaLabel, + 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() { + 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 ( + + + + + + { + event.preventDefault(); + void copyPubkey(); + }} + > + + Copy full pubkey + + onRemove(reference)} + > + + Remove reference + + + + } + ariaLabel={registeredAgentAriaLabel(display.label, 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..2ddab30f3ea 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 { visibleRegisteredAgentReferences } 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,17 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { useFeedbackToasts(actionNoticeMessage, actionErrorMessage); useFeedbackToasts(personaFeedbackNoticeMessage, personaFeedbackErrorMessage); - const isLoading = isAgentsLoading || isPersonasLoading; + const isLoading = + isAgentsLoading || isPersonasLoading || isRegisteredReferencesLoading; + const visibleRegisteredReferences = React.useMemo( + () => + visibleRegisteredAgentReferences( + registeredReferences, + agents, + registeredReferencesError, + ), + [registeredReferences, agents, registeredReferencesError], + ); return (
); })} + {visibleRegisteredReferences.map((reference) => ( + + ))}
{unknown.length > 0 ? ( @@ -215,6 +247,13 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { {personasError.message}

) : null} + {registeredReferencesError ? ( +

+ {registeredReferencesError.message} +

+ ) : null} ); } 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/ui/registeredAgentAsyncErrors.test.mjs b/desktop/src/features/agents/ui/registeredAgentAsyncErrors.test.mjs new file mode 100644 index 00000000000..7032ed1b72a --- /dev/null +++ b/desktop/src/features/agents/ui/registeredAgentAsyncErrors.test.mjs @@ -0,0 +1,48 @@ +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\(/, + ); + 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 new file mode 100644 index 00000000000..b68b7e5ed90 --- /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", + }), + /invalid public key/i, + ); +}); diff --git a/desktop/src/shared/api/tauriRegisteredAgents.ts b/desktop/src/shared/api/tauriRegisteredAgents.ts new file mode 100644 index 00000000000..499a02364ea --- /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("invalid public key"); + } + 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..632f3b6d192 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,69 @@ 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).sort((left, right) => + left.pubkey.localeCompare(right.pubkey), + ); + 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..21c17dfa723 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -2708,3 +2708,218 @@ 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"); +}); + +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}`); + const truncatedPubkey = `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`; + await expect( + card.getByRole("button", { name: new RegExp(truncatedPubkey) }), + ).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)); + const truncatedPubkey = `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`; + await expect( + card.getByRole("button", { name: new RegExp(truncatedPubkey) }), + ).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, + ); +}); 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;